made tiny updates

This commit is contained in:
SinachPat
2026-05-02 17:33:03 +01:00
parent ed485b1a15
commit 4dca0f1bac
+263 -109
View File
@@ -192,7 +192,7 @@ Three entry points, all result in a `createArtboard()` dispatch:
3. **Duplicate** — right-click any artboard → Duplicate. Creates a copy at a different device size (opens device preset picker). 3. **Duplicate** — right-click any artboard → Duplicate. Creates a copy at a different device size (opens device preset picker).
**Auto-arrange (default layout):** **Auto-arrange (default layout):**
New artboards snap to a horizontal row with `gap: 200px`. When a row exceeds 3 artboards or total width > 6000px, a new row begins below. The vertical gap between rows is 240px. The Y position of a new row is `maxHeightInPreviousRow + 240px` (using the tallest artboard in the completed row as the row height). The layout algorithm runs on `createArtboard()` only — it computes a suggested `(x, y)` position and assigns it. The user can drag the artboard away from that position at any time; subsequent auto-arrange calls do not move manually-positioned artboards (a `manuallyPositioned: boolean` flag on each artboard record prevents re-calculation). The flag is set to `true` on `pointerup` at the end of a successful artboard drag (when the user has moved the artboard at least 4px from its pre-drag position). It is never set by the auto-arrange algorithm's own writes to `canvas_x`/`canvas_y`. New artboards snap to a horizontal row with `gap: 200px`. When a row exceeds 3 artboards or total width > 6000px, a new row begins below. The vertical gap between rows is 240px. The Y position of a new row is `previousRowStartY + maxHeightInPreviousRow + 240px` (using the tallest artboard in the completed row as the row height — accumulated across all previous rows, not just the last one). The layout algorithm runs on `createArtboard()` only — it computes a suggested `(x, y)` position and assigns it. The user can drag the artboard away from that position at any time; subsequent auto-arrange calls do not move manually-positioned artboards (a `manuallyPositioned: boolean` flag on each artboard record prevents re-calculation). The flag is set to `true` on `pointerup` at the end of a successful artboard drag (when the user has moved the artboard at least **10 world-space pixels** from its pre-drag position — convert screen delta to world delta by dividing by `canvasTransform.scale` before comparing). It is never set by the auto-arrange algorithm's own writes to `canvas_x`/`canvas_y`.
**"Re-arrange all"** button in the Artboard Navigator context menu resets all positions to the auto-grid (after a confirmation prompt, since it discards freeform layout). **"Re-arrange all"** button in the Artboard Navigator context menu resets all positions to the auto-grid (after a confirmation prompt, since it discards freeform layout).
@@ -304,14 +304,15 @@ The viewport cull is triggered by pan and zoom events and runs on a 100ms deboun
1. When an artboard transitions from **Active****Near/Far**, the canvas sends `{ type: 'CAPTURE_THUMBNAIL' }` to that artboard's iframe 1. When an artboard transitions from **Active****Near/Far**, the canvas sends `{ type: 'CAPTURE_THUMBNAIL' }` to that artboard's iframe
2. The fiber hook / DOM inspector inside the iframe receives this message, runs `html2canvas(document.body)` (the `html2canvas` library is injected by the CLI proxy alongside the fiber hook), and posts the result back: 2. The fiber hook / DOM inspector inside the iframe receives this message, runs `html2canvas(document.body)` (the `html2canvas` library is injected by the CLI proxy alongside the fiber hook), and posts the result back:
`post({ type: 'THUMBNAIL_READY', dataUrl: canvas.toDataURL('image/jpeg', 0.6) })` `post({ type: 'THUMBNAIL_READY', dataUrl: canvas.toDataURL('image/jpeg', 0.6) })`
3. The canvas receives `THUMBNAIL_READY`, stores the data URL in `artboardFrames[id].thumbnailDataUrl` in Zustand, and uses it to display the placeholder 3. The canvas receives `THUMBNAIL_READY`, stores the data URL in `artboardFrames[id].thumbnailDataUrl` in Zustand for immediate display as a placeholder
4. `thumbnail_url` in Supabase stores this data URL (or a Supabase Storage URL if the image is uploaded — see §3.8) 4. The canvas uploads the data URL as a JPEG to Supabase Storage (`artboard-thumbnails/{workspaceId}/{artboardId}.jpg`) and stores the resulting public Storage URL in `thumbnail_url`. **Data URIs are never written to the database** — only the Supabase Storage URL is persisted (see §3.8). If the upload fails, `thumbnail_url` remains null and the Zustand in-memory data URL continues to serve as the placeholder for the current session.
**Required additions:** **Required additions:**
- `CAPTURE_THUMBNAIL` added as a valid host message type in `protocol.ts` - `CAPTURE_THUMBNAIL` added as a valid host message type in `protocol.ts`
- `THUMBNAIL_READY` added as a valid renderer message type in `protocol.ts` - `THUMBNAIL_READY` added as a valid renderer message type in `protocol.ts`
- `html2canvas` is bundled inside the CLI package and injected as an inline script (not a CDN URL — CDN dependency would break offline use and introduce a supply-chain risk). The CLI bundles `html2canvas` during its own build step. If `html2canvas` fails (returns a blank canvas, throws, or times out after 3s), the iframe sends `{ type: 'THUMBNAIL_READY', dataUrl: null }` and the canvas displays a "thumbnail unavailable" placeholder with the artboard label instead. Performance note: html2canvas adds ~250KB to the injected script per artboard tab — it is only injected once per page load and only when the CLI is running in dev mode. - `html2canvas` is bundled inside the CLI package and injected as an inline script (not a CDN URL — CDN dependency would break offline use and introduce a supply-chain risk). The CLI bundles `html2canvas` during its own build step. If `html2canvas` fails (returns a blank canvas, throws, or times out after 3s), the iframe sends `{ type: 'THUMBNAIL_READY', dataUrl: null }` and the canvas displays a "thumbnail unavailable" placeholder with the artboard label instead. Performance note: html2canvas adds ~250KB to the injected script per artboard tab — it is only injected once per page load and only when the CLI is running in dev mode.
- Handler added to `dom-inspector.ts` for `CAPTURE_THUMBNAIL` - Handler added to `dom-inspector.ts` for `CAPTURE_THUMBNAIL`
- **html2canvas injection note:** The script is injected once per page load by the CLI proxy (alongside the fiber hook) — not repeatedly per artboard. Since each artboard is a separate iframe/page load, each page gets one injection. The ~250KB cost is per page load, not per artboard-on-screen. The CLI proxy injects `html2canvas` as a separate `<script>` tag (not inlined into the fiber hook bundle) so it can be served with a cache-control header and reused by the browser's disk cache across artboard navigations.
### 3.7 Artboard Navigator Panel (Left Sidebar) ### 3.7 Artboard Navigator Panel (Left Sidebar)
@@ -360,7 +361,7 @@ Canvas transform (pan/zoom) is **session-only** — stored in Zustand + localSto
| `packages/app/src/lib/artboard-iframe-map.ts` | NEW — module-level `Map<string, HTMLIFrameElement>` singleton; imported by `ArtboardFrame` and canvas dispatch logic | | `packages/app/src/lib/artboard-iframe-map.ts` | NEW — module-level `Map<string, HTMLIFrameElement>` singleton; imported by `ArtboardFrame` and canvas dispatch logic |
| `packages/app/src/components/canvas/CanvasViewport.tsx` | NEW — pan/zoom world container, wheel+drag handlers, viewport culling | | `packages/app/src/components/canvas/CanvasViewport.tsx` | NEW — pan/zoom world container, wheel+drag handlers, viewport culling |
| `packages/app/src/components/canvas/ArtboardFrame.tsx` | NEW — single artboard wrapper: label, handles, device outline, iframe | | `packages/app/src/components/canvas/ArtboardFrame.tsx` | NEW — single artboard wrapper: label, handles, device outline, iframe |
| `packages/app/src/components/canvas/IsolationFrame.tsx` | NEW — isolation artboard type; props override panel integration | | `packages/app/src/components/canvas/IsolationFrame.tsx` | NEW (stub in Phase 0) — renders a placeholder frame with label "Isolation artboard — requires CLI indexer (Phase 3)". Becomes functional in Phase 3 when the CLI serves `/__om_isolation__` pages and the indexer is running. The `ArtboardFrame` renders `<IsolationFrame>` only when `artboard.type === 'isolation'`; the creation UI for isolation artboards is disabled in the Artboard Navigator until `indexerStatus === 'ready'`. |
| `packages/app/src/components/canvas/Canvas.tsx` | Replace single-iframe layout with `<CanvasViewport>` containing `<ArtboardFrame>` list | | `packages/app/src/components/canvas/Canvas.tsx` | Replace single-iframe layout with `<CanvasViewport>` containing `<ArtboardFrame>` list |
| `packages/app/src/components/navigator/ArtboardNavigator.tsx` | Add "Routes" tab, thumbnails, create-from-route action | | `packages/app/src/components/navigator/ArtboardNavigator.tsx` | Add "Routes" tab, thumbnails, create-from-route action |
| `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 |
@@ -443,8 +444,8 @@ The panel is driven by `selectedComponentStyles` (computed CSS) and `selectedCom
| Input | CSS property | Notes | | Input | CSS property | Notes |
|---|---|---| |---|---|---|
| X | `left` (absolute) or layout offset | Read-only display value when `position: static` — computed as `offsetLeft` / `offsetTop` relative to the nearest positioned ancestor (the artboard root). Does not shift on scroll. Shows a tooltip: "Set position: absolute to edit." Editable when `position: absolute` or `fixed`, writing to the `left` / `top` CSS property. | | X | `left` (absolute) or layout offset | Read-only display value when `position: static` — computed by calling `el.getBoundingClientRect()` and subtracting the artboard iframe's root element `getBoundingClientRect()` (this gives a stable position relative to the artboard frame, not affected by scroll). Note: do NOT use `offsetLeft`/`offsetTop` — these are relative to `offsetParent` which may skip positioned ancestors and does not account for CSS transforms. Shows a tooltip: "Set position: absolute to edit." Editable when `position: absolute` or `fixed`, writing to the `left` / `top` CSS property. |
| Y | `top` (absolute) or layout offset | Read-only display value when `position: static`computed as `offsetLeft` / `offsetTop` relative to the nearest positioned ancestor (the artboard root). Does not shift on scroll. Shows a tooltip: "Set position: absolute to edit." Editable when `position: absolute` or `fixed`, writing to the `left` / `top` CSS property. | | Y | `top` (absolute) or layout offset | Read-only display value when `position: static`same `getBoundingClientRect()` approach as X above. Shows a tooltip: "Set position: absolute to edit." Editable when `position: absolute` or `fixed`, writing to the `left` / `top` CSS property. |
| W | `width` | Accepts px, %, rem | | W | `width` | Accepts px, %, rem |
| H | `height` | Accepts px, %, rem | | H | `height` | Accepts px, %, rem |
| Lock aspect ratio | — | Derived: W/H ratio locked on resize | | Lock aspect ratio | — | Derived: W/H ratio locked on resize |
@@ -655,6 +656,8 @@ interface ComponentEntry {
isDefaultExport: boolean; isDefaultExport: boolean;
props: PropEntry[]; props: PropEntry[];
tokensUsed: string[]; // CSS custom properties found in file: ["--color-primary"] tokensUsed: string[]; // CSS custom properties found in file: ["--color-primary"]
cssImports: string[]; // relative paths of CSS/SCSS/module files imported by this component (e.g. ["./Card.module.css"])
// Required by §6.4: when a CSS file changes, the indexer re-scans any component whose cssImports includes it
lastIndexed: number; // Date.now() lastIndexed: number; // Date.now()
} }
@@ -662,7 +665,7 @@ interface PropEntry {
name: string; name: string;
type: string; // "string | undefined" type: string; // "string | undefined"
optional: boolean; optional: boolean;
// Note: default values out of scope for Phase 3Phase 5+ // Note: default prop values require expression evaluation — out of scope for Phase 3. Deferred to post-Phase 7.
} }
``` ```
@@ -671,7 +674,7 @@ interface PropEntry {
Use TypeScript's compiler API (`ts.createSourceFile()`) — parse only, no type-checking. Use TypeScript's compiler API (`ts.createSourceFile()`) — parse only, no type-checking.
**What to extract per file:** **What to extract per file:**
1. All named and default exported functions/arrow functions starting with a capital letter. Note: functions exported with capital letters that are not React components (e.g., `GetUserById`, `FormatCurrency`) will appear in the index. The indexer uses a secondary heuristic to filter them: a function is treated as a component only if its return type or return statement contains JSX (detected by scanning the function body for `<` followed by a capital letter or known HTML tag). Functions with no JSX in the body are excluded from the component index. 1. All named and default exported functions/arrow functions starting with a capital letter. Note: functions exported with capital letters that are not React components (e.g., `GetUserById`, `FormatCurrency`) will appear in the index. The indexer uses the TypeScript AST to filter them: walk the function body using `ts.forEachChild` and check for `ts.SyntaxKind.JsxElement`, `ts.SyntaxKind.JsxSelfClosingElement`, or `ts.SyntaxKind.JsxFragment` nodes. A function is treated as a component only if its body contains at least one JSX node. Do NOT use raw string scanning (e.g., looking for `<` followed by a capital letter) — this produces false positives in JSDoc comments, string literals, and generics like `Array<Card>`.
2. Their first parameter's type annotation (the `Props` type) 2. Their first parameter's type annotation (the `Props` type)
3. All string literals matching `/var\(--[-\w]+\)/g` in JSX attributes and template literals. The character class `[-\w]` covers lowercase, uppercase, digits, underscores, and hyphens — the full valid set for CSS custom property names after `--`. 3. All string literals matching `/var\(--[-\w]+\)/g` in JSX attributes and template literals. The character class `[-\w]` covers lowercase, uppercase, digits, underscores, and hyphens — the full valid set for CSS custom property names after `--`.
@@ -681,7 +684,7 @@ Use TypeScript's compiler API (`ts.createSourceFile()`) — parse only, no type-
- `node_modules` (skip entirely) - `node_modules` (skip entirely)
- Files > 500KB (skip with a warning log) - Files > 500KB (skip with a warning log)
**Parse-only limitation for imported types:** `ts.createSourceFile()` parses the AST but does not resolve type aliases or imports. A component typed as `(props: CardProps) => ...` where `CardProps` is imported will yield a single `PropEntry { name: "props", type: "CardProps", optional: false }` rather than expanded individual prop names. The Props tab shows this as an opaque type badge with a note: "Type `CardProps`expand by running the indexer with `--full-types` (Phase 5+)." Full type expansion via TypeScript's program API is deferred to Phase 5. **Parse-only limitation for imported types:** `ts.createSourceFile()` parses the AST but does not resolve type aliases or imports. A component typed as `(props: CardProps) => ...` where `CardProps` is imported will yield a single `PropEntry { name: "props", type: "CardProps", optional: false }` rather than expanded individual prop names. The Props tab shows this as an opaque type badge with a note: "Type `CardProps`full type expansion available post-Phase 7." Full type expansion via TypeScript's program API is deferred to post-Phase 7 (requires a full `ts.createProgram()` with type checker — significantly more expensive than parse-only).
### 6.3 Local HTTP API ### 6.3 Local HTTP API
@@ -692,10 +695,28 @@ GET /components → ComponentEntry[] all indexed components
GET /components?name=Card → ComponentEntry[] fuzzy-matched by name GET /components?name=Card → ComponentEntry[] fuzzy-matched by name
GET /components?file=src/… → ComponentEntry[] all exports from a file GET /components?file=src/… → ComponentEntry[] all exports from a file
GET /file?path=src/… → { content: string, lines: number } ← NEW GET /file?path=src/… → { content: string, lines: number } ← NEW
GET /health → { status, indexed, lastScan, projectRoot } GET /health → { status, indexed, lastScan, projectRoot, projectMeta }
POST /reindex → triggers full rescan POST /reindex → triggers full rescan
``` ```
**`GET /health` response shape:**
```ts
{
status: 'ready' | 'indexing' | 'error';
indexed: number; // number of components in the index
lastScan: number; // Date.now() of last full scan
projectRoot: string; // absolute path
projectMeta: { // detected at startup — used by client-side diff generator
framework: 'next' | 'vite' | 'remix' | 'generic';
tailwind: boolean; // true if tailwind.config.* exists in projectRoot
cssModules: boolean; // true if any *.module.css found in project
styledComponents: boolean; // true if 'styled-components' or '@emotion' in package.json
};
}
```
**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 === 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`).
- 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.
@@ -734,7 +755,7 @@ originmain dev --target http://localhost:3000 [--port 4170] [--index-port 4171]
| File | Change | | File | Change |
|---|---| |---|---|
| `packages/cli/src/indexer.ts` | NEW — AST walker, component map, file watcher | | `packages/cli/src/indexer.ts` | NEW — AST walker, component map, file watcher |
| `packages/cli/src/index-server.ts` | NEW — HTTP API on port 4171, `GET /file` endpoint | | `packages/cli/src/index-server.ts` | NEW — HTTP API on port 4171, `GET /file` endpoint, `GET /health` with `projectMeta` (Tailwind, cssModules, styledComponents detection — run once at startup in `detect-framework.ts`) |
| `packages/cli/src/isolation-server.ts` | NEW — serves `/__om_isolation__` wrapper pages (Vite: inline HTML; Next.js: writes temporary `src/app/__om_isolation__/page.tsx` to the user's project) | | `packages/cli/src/isolation-server.ts` | NEW — serves `/__om_isolation__` wrapper pages (Vite: inline HTML; Next.js: writes temporary `src/app/__om_isolation__/page.tsx` to the user's project) |
| `packages/cli/src/detect-framework.ts` | NEW — canonical framework detection logic; imported by all CLI modules | | `packages/cli/src/detect-framework.ts` | NEW — canonical framework detection logic; imported by all CLI modules |
| `packages/cli/src/cli.ts` | Add `--index-port` / `--no-index` / isolation handler | | `packages/cli/src/cli.ts` | Add `--index-port` / `--no-index` / isolation handler |
@@ -832,20 +853,26 @@ interface IntentChange {
} }
interface DiffHunk { interface DiffHunk {
startLine: number; startLine: number; // 1-indexed "before" pane start line
afterLine: number; // 1-indexed "after" pane start line (renamed from afterStartLine for consistency with annotations usage below)
lines: DiffLine[]; lines: DiffLine[];
confidence: 'exact' | 'approximate'; // copied from the matching IntentChange
tokenKey?: string; // copied from the matching IntentChange (Phase 6)
} }
interface DiffLine { interface DiffLine {
type: 'context' | 'added' | 'removed'; type: 'context' | 'added' | 'removed';
content: string; content: string;
lineNumber: number; lineNumber: number;
// Character/word-level inline change ranges — passed to FileDiff options.inlineChanges: 'character' // Character/word-level inline change ranges — used internally by diff-generator.ts for building
// the patchedContent string; NOT passed to @pierre/diffs (which computes its own char diffs via lineDiffType: 'char')
inlineChanges?: Array<{ start: number; end: number; type: 'added' | 'removed' }>; inlineChanges?: Array<{ start: number; end: number; type: 'added' | 'removed' }>;
} }
``` ```
**Line number convention:** `DiffHunk.startLine` and `DiffLine.lineNumber` are **1-indexed** (line 1 is the first line of the file), matching standard editor conventions and the `DiffLineAnnotation.line` field accepted by `@pierre/diffs`. **Line number convention:** `DiffHunk.startLine`, `DiffHunk.afterLine`, and `DiffLine.lineNumber` are **1-indexed** (line 1 is the first line of the file), matching standard editor conventions and the `DiffLineAnnotation.line` field accepted by `@pierre/diffs`.
> **✓ Package verified:** `@pierre/diffs@1.1.20` confirmed on npm. All APIs in §7.4 verified against the distributed TypeScript types. Note: several prop names in the document's initial draft were wrong (`diff`→`fileDiff`, `layout`→`diffStyle`, `inlineChanges`→`lineDiffType`, `'background'`→`'bars'`, `'stacked'`→`'unified'`, `TokenPayload`→`DiffTokenEventBaseProps`, `annotations` inside options→`lineAnnotations` top-level prop, `side: 'before'/'after'`→`'deletions'/'additions'`) — all corrected in §7.4. The revised code in §7.4 is accurate against the package.
### 7.3 Client-side Diff Generation ### 7.3 Client-side Diff Generation
@@ -891,30 +918,50 @@ Peer dependencies: React ≥ 18.3.1 or 19.x, `shiki ^3.0.0`.
**Imports:** **Imports:**
```ts ```ts
// React components // React components (verified against @pierre/diffs@1.1.20 dist/react/index.d.ts)
import { FileDiff, MultiFileDiff, Virtualizer } from '@pierre/diffs/react'; import { FileDiff, MultiFileDiff, Virtualizer } from '@pierre/diffs/react';
import type { DiffLineAnnotation, DiffTokenEventBaseProps, FileDiffMetadata } from '@pierre/diffs/react';
// Core utilities (accept/reject API, theming) // Core utilities — all verified to exist in the package
import { import {
diffAcceptRejectHunk, diffAcceptRejectHunk, // (diff: FileDiffMetadata, hunkIndex: number, options: 'accept' | 'reject' | 'both') => FileDiffMetadata
resolveTheme, resolveTheme,
registerCustomCSSVariableTheme, registerCustomCSSVariableTheme,
processFile, // (unifiedDiffString: string) => FileDiffMetadata | undefined
} from '@pierre/diffs'; } from '@pierre/diffs';
``` ```
`@pierre/diffs` manages a Shadow DOM internally for CSS isolation — this is completely transparent to React consumers. `FileDiff` is a standard React component with no extra setup. `@pierre/diffs` manages a Shadow DOM internally for CSS isolation — this is completely transparent to React consumers. `FileDiff` is a standard React component with no extra setup.
**Building a `FileDiffMetadata` from content strings:**
The `CodeTab` does not directly pass `originalContent`/`patchedContent` to `<FileDiff>`. Instead, `diff-generator.ts` generates a standard unified diff string (using the `diff` npm package's `createTwoFilesPatch(filename, filename, original, patched)`) and calls `processFile(unifiedDiffString)` to produce a `FileDiffMetadata` object. That object is then passed to `<FileDiff fileDiff={...}>`. This is the correct data flow:
```ts
// In diff-generator.ts
import { createTwoFilesPatch } from 'diff';
import { processFile } from '@pierre/diffs';
export function buildFileDiffMetadata(
filename: string,
original: string,
patched: string
): FileDiffMetadata | undefined {
const unifiedDiff = createTwoFilesPatch(filename, filename, original, patched, '', '');
return processFile(unifiedDiff);
}
```
#### Layout #### Layout
The Code tab renders a **split view** (side-by-side) by default — left pane shows the original, right pane shows the patched version. A toggle in the tab header switches to **stacked (unified) view** for narrower panels. The choice is persisted to localStorage per-user. The Code tab renders a **split view** (side-by-side) by default — left pane shows the original, right pane shows the patched version. A toggle in the tab header switches to **stacked (unified) view** for narrower panels. The choice is persisted to localStorage per-user.
The `layout` prop on `FileDiff` controls this directly: The `diffStyle` option on `FileDiff` controls this (verified option name and values):
```tsx ```tsx
<FileDiff <FileDiff
diff={diff} fileDiff={fileDiff}
options={{ options={{
layout: splitMode ? 'split' : 'stacked', // 'split' | 'stacked' diffStyle: splitMode ? 'split' : 'unified', // 'split' | 'unified' (NOT 'stacked')
}} }}
/> />
``` ```
@@ -938,18 +985,18 @@ The `layout` prop on `FileDiff` controls this directly:
#### Character/word-level highlighting #### Character/word-level highlighting
`@pierre/diffs` renders inline change ranges within each modified line via `options.inlineChanges`. Set this to `'character'` for the finest granularity — for a change from `'8px'` to `'12px'`, only those characters are highlighted rather than the whole line. This is the most important improvement over a naive line-diff: users instantly see exactly what value changed without scanning an entire line. `@pierre/diffs` renders inline change ranges via `options.lineDiffType` (verified option name and values). Set to `'char'` for character-level granularity — for a change from `'8px'` to `'12px'`, only those characters are highlighted rather than the whole line.
```tsx ```tsx
<FileDiff <FileDiff
diff={diff} fileDiff={fileDiff}
options={{ options={{
inlineChanges: 'character', // 'character' | 'word' | 'disabled' lineDiffType: 'char', // 'char' | 'word' | 'word-alt' | 'none' ← verified from LineDiffTypes
}} }}
/> />
``` ```
The `inlineChanges` field on each `DiffLine` (see §7.2) carries the pre-computed character ranges passed through to `FileDiff`. The library uses these to render sub-line highlights in its Shadow DOM. The library computes inline char/word diffs internally — you do not pre-compute character ranges. The `inlineChanges` field in `DiffLine` (§7.2) is an internal tracking field for the `DiffHunk`/`DiffLine` types used in `IntentMessage`; it is NOT passed to `@pierre/diffs`. The library handles sub-line highlighting itself when `lineDiffType: 'char'` is set.
#### Per-change Accept / Reject #### Per-change Accept / Reject
@@ -957,24 +1004,34 @@ The `inlineChanges` field on each `DiffLine` (see §7.2) carries the pre-compute
```ts ```ts
import { diffAcceptRejectHunk } from '@pierre/diffs'; import { diffAcceptRejectHunk } from '@pierre/diffs';
import type { FileDiffMetadata } from '@pierre/diffs';
// In CodeTab.tsx state: // In CodeTab.tsx state:
const [diff, setDiff] = useState<Diff>(initialDiff); const [fileDiff, setFileDiff] = useState<FileDiffMetadata>(initialFileDiff);
// Track rejected hunk indices separately (HunkData has no .rejected property)
const [rejectedHunks, setRejectedHunks] = useState<Set<number>>(new Set());
// Accept hunk at index i: // Accept hunk at index i (updates the rendered diff):
setDiff(prev => diffAcceptRejectHunk(prev, i, 'accept')); setFileDiff(prev => diffAcceptRejectHunk(prev, i, 'accept'));
setRejectedHunks(prev => { const next = new Set(prev); next.delete(i); return next; });
// Reject hunk at index i: // Reject hunk at index i:
setDiff(prev => diffAcceptRejectHunk(prev, i, 'reject')); setFileDiff(prev => diffAcceptRejectHunk(prev, i, 'reject'));
setRejectedHunks(prev => new Set([...prev, i]));
``` ```
**Signatures:** **Verified signature:**
```ts ```ts
function diffAcceptRejectHunk( function diffAcceptRejectHunk(
diff: Diff, diff: FileDiffMetadata,
hunkIndex: number, hunkIndex: number,
action: 'accept' | 'reject' options: 'accept' | 'reject' | 'both' // 'both' accepts both sides of a merge conflict
): Diff; ): FileDiffMetadata;
```
**`allRejected` check:** Since `HunkData` has no `.rejected` property, track rejected indices in a separate `Set<number>`:
```ts
const allRejected = fileDiff.hunks.length > 0 && rejectedHunks.size === fileDiff.hunks.length;
``` ```
Semantic behaviour wired in `CodeTab.tsx`: Semantic behaviour wired in `CodeTab.tsx`:
@@ -985,41 +1042,64 @@ Semantic behaviour wired in `CodeTab.tsx`:
#### Inline annotations #### Inline annotations
`@pierre/diffs` injects annotations adjacent to diff lines via `options.annotations`. Each annotation is a `DiffLineAnnotation` object: `@pierre/diffs` injects annotations via the **`lineAnnotations` prop** (top-level on `<FileDiff>`, not inside `options`). The type is generic — you define your own metadata type and provide a `renderAnnotation` callback to render it.
**Verified `DiffLineAnnotation` type:**
```ts ```ts
interface DiffLineAnnotation { // From @pierre/diffs types.d.ts — the type is generic
line: number; // 1-based line number type DiffLineAnnotation<T = undefined> = {
side: 'before' | 'after'; // which pane the annotation appears in side: 'deletions' | 'additions'; // NOT 'before'/'after' — these are the real values
message: string; // text to display lineNumber: number; // 1-based
type?: 'info' | 'warning' | 'error'; } & (T extends undefined ? { metadata?: undefined } : { metadata: T });
```
**Define your annotation metadata shape:**
```ts
// In CodeTab.tsx — your own metadata type passed as the generic
interface HunkAnnotation {
message: string;
kind: 'warning' | 'info';
} }
``` ```
**Usage in `CodeTab.tsx`:** **Build and render annotations:**
```tsx ```tsx
const annotations: DiffLineAnnotation[] = codeDiff.hunks.flatMap(hunk => { const annotations: DiffLineAnnotation<HunkAnnotation>[] = codeDiff.hunks.flatMap(hunk => {
const results: DiffLineAnnotation[] = []; const results: DiffLineAnnotation<HunkAnnotation>[] = [];
if (hunk.confidence === 'approximate') { if (hunk.confidence === 'approximate') {
results.push({ results.push({
line: hunk.afterLine, lineNumber: hunk.afterLine,
side: 'after', side: 'additions', // ← 'additions', not 'after'
metadata: {
message: '⚠ Approximate — agent will verify this location before applying', message: '⚠ Approximate — agent will verify this location before applying',
type: 'warning', kind: 'warning',
},
}); });
} }
if (hunk.tokenKey) { if (hunk.tokenKey) {
results.push({ results.push({
line: hunk.afterLine, lineNumber: hunk.afterLine,
side: 'after', side: 'additions',
metadata: {
message: `✦ Token: ${hunk.tokenKey} — exact match from design language`, message: `✦ Token: ${hunk.tokenKey} — exact match from design language`,
type: 'info', kind: 'info',
},
}); });
} }
return results; return results;
}); });
<FileDiff diff={diff} options={{ annotations }} /> // lineAnnotations is a top-level prop, NOT inside options:
<FileDiff
fileDiff={fileDiff}
lineAnnotations={annotations}
renderAnnotation={(annotation) => (
<div className={`hunk-annotation hunk-annotation--${annotation.metadata.kind}`}>
{annotation.metadata.message}
</div>
)}
options={{ diffStyle: layout, lineDiffType: 'char' }}
/>
``` ```
Rendered output for an approximate hunk: Rendered output for an approximate hunk:
@@ -1038,34 +1118,49 @@ For a token-mapped hunk (Phase 6):
#### Token hover callbacks #### Token hover callbacks
`@pierre/diffs` fires `onTokenEnter`, `onTokenLeave`, and `onTokenClick` on *syntax* tokens as identified by the Shiki highlighter (keywords, string literals, numbers, etc.). Each callback receives a `TokenPayload`: `@pierre/diffs` fires `onTokenEnter`, `onTokenLeave`, and `onTokenClick` on *syntax* tokens as identified by the Shiki highlighter. These are options (in `FileDiffOptions` via `InteractionManagerBaseOptions`) — they go inside the `options` prop. Each callback receives the token props PLUS the DOM event as a second argument.
**Verified `DiffTokenEventBaseProps` type** (from `@pierre/diffs` types — used for diff mode):
```ts ```ts
interface TokenPayload { // From types.d.ts
text: string; // the raw token text interface TokenEventBase {
line: number; // 1-based line number type: 'token';
column: number; // 0-based character offset lineNumber: number; // 1-based
side: 'before' | 'after'; lineCharStart: number; // 0-based char start offset (NOT 'column')
element: Element; // the DOM element inside the Shadow DOM (for positioning) lineCharEnd: number; // 0-based char end offset
tokenText: string; // the raw token text (NOT 'text')
tokenElement: HTMLElement;// the DOM element for positioning (NOT 'element')
}
interface DiffTokenEventBaseProps extends TokenEventBase {
side: 'deletions' | 'additions'; // NOT 'before'/'after'
} }
``` ```
```tsx ```tsx
<FileDiff <FileDiff
diff={diff} fileDiff={fileDiff}
options={{ options={{
onTokenEnter: (payload) => showTokenPopover(payload), onTokenEnter: (props: DiffTokenEventBaseProps, event: PointerEvent) => showTokenPopover(props),
onTokenLeave: () => hideTokenPopover(), onTokenLeave: (props: DiffTokenEventBaseProps, event: PointerEvent) => hideTokenPopover(),
onTokenClick: (payload) => pinTokenPopover(payload), onTokenClick: (props: DiffTokenEventBaseProps, event: MouseEvent) => pinTokenPopover(props),
}} }}
/> />
``` ```
**Field name mapping** (document → real API):
| Document used | Actual field | Note |
|---|---|---|
| `payload.text` | `props.tokenText` | renamed |
| `payload.column` | `props.lineCharStart` / `props.lineCharEnd` | two fields |
| `payload.element` | `props.tokenElement` | renamed |
| `payload.side === 'before'` | `props.side === 'deletions'` | renamed |
| `payload.side === 'after'` | `props.side === 'additions'` | renamed |
**Custom range detection required for CSS value semantics:** `onTokenEnter` fires on every Shiki syntax token, not specifically CSS property values. To attach design token metadata, `CodeTab.tsx` must intercept `onTokenEnter` and apply additional logic: **Custom range detection required for CSS value semantics:** `onTokenEnter` fires on every Shiki syntax token, not specifically CSS property values. To attach design token metadata, `CodeTab.tsx` must intercept `onTokenEnter` and apply additional logic:
1. Run a regex over the `payload.text` to detect CSS value patterns: color functions (`rgb(…)`, `hsl(…)`, hex), pixel/rem values, `var(--token-name)` references. 1. Run a regex over the `props.tokenText` to detect CSS value patterns: color functions (`rgb(…)`, `hsl(…)`, hex), pixel/rem values, `var(--token-name)` references.
2. Call `resolveValueToToken(payload.text, designLanguage)` (from `@originmain/design-language`) to check for matches. 2. Call `resolveValueToToken(props.tokenText, undefined, designLanguage)` (from `@originmain/design-language`) to check for matches.
3. If a match is found, show a popover positioned relative to `payload.element` (use `getBoundingClientRect()` on it — it is a real DOM node). 3. If a match is found, show a popover positioned relative to `props.tokenElement` (use `getBoundingClientRect()` on it — it is a real DOM node inside the Shadow DOM, but `getBoundingClientRect()` works cross-boundary).
4. Popover content when `designLanguage` is loaded (Phase 6): 4. Popover content when `designLanguage` is loaded (Phase 6):
- Token match → token name, group, raw value, color swatch for color tokens - Token match → token name, group, raw value, color swatch for color tokens
- Near-miss → "Not in design language. Nearest: `--radius-md` (8px)" - Near-miss → "Not in design language. Nearest: `--radius-md` (8px)"
@@ -1075,13 +1170,13 @@ This is additional logic in `CodeTab.tsx` — `@pierre/diffs` fires the event, t
#### Visual style options #### Visual style options
Use **full-width background colors** (not `+`/`` symbols) via `options.diffIndicators: 'background'`. This fills the entire line with a red/green tint, giving a clean aesthetic consistent with the rest of the canvas UI and avoiding terminal-style gutter symbols: Use **bar-style indicators** (colored background bars, not `+`/`` symbols) via `options.diffIndicators: 'bars'`. The real values are `'classic' | 'bars' | 'none'` (verified — `'background'` does not exist):
```tsx ```tsx
<FileDiff <FileDiff
diff={diff} fileDiff={fileDiff}
options={{ options={{
diffIndicators: 'background', // 'background' | 'gutter' | 'both' | 'none' diffIndicators: 'bars', // 'classic' (shows +/- symbols) | 'bars' (background bars) | 'none'
}} }}
/> />
``` ```
@@ -1109,7 +1204,7 @@ Then pass the theme name to `FileDiff`:
```tsx ```tsx
<FileDiff <FileDiff
diff={diff} fileDiff={fileDiff}
options={{ options={{
theme: theme === 'dark' ? 'originmain-dark' : 'github-light', theme: theme === 'dark' ? 'originmain-dark' : 'github-light',
}} }}
@@ -1126,7 +1221,7 @@ For files exceeding ~500 lines, wrap `FileDiff` in `Virtualizer` to avoid render
import { FileDiff, Virtualizer } from '@pierre/diffs/react'; import { FileDiff, Virtualizer } from '@pierre/diffs/react';
<Virtualizer> <Virtualizer>
<FileDiff diff={diff} options={options} /> <FileDiff fileDiff={fileDiff} options={options} /> {/* 'fileDiff' prop, not 'diff' */}
</Virtualizer> </Virtualizer>
``` ```
@@ -1134,47 +1229,61 @@ import { FileDiff, Virtualizer } from '@pierre/diffs/react';
#### Full `CodeTab.tsx` component sketch #### Full `CodeTab.tsx` component sketch
All prop names and types below are verified against `@pierre/diffs@1.1.20`:
```tsx ```tsx
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { FileDiff, Virtualizer } from '@pierre/diffs/react'; import { FileDiff, Virtualizer } from '@pierre/diffs/react';
import { diffAcceptRejectHunk } from '@pierre/diffs'; import { diffAcceptRejectHunk, processFile } from '@pierre/diffs';
import type { DiffLineAnnotation, TokenPayload } from '@pierre/diffs'; import type { DiffLineAnnotation, DiffTokenEventBaseProps, FileDiffMetadata } from '@pierre/diffs';
export function CodeTab({ codeDiff, artboardId }: CodeTabProps) { // CodeTabProps receives the resolved codeDiff from IntentMessage.codeDiff,
const [diff, setDiff] = useState(codeDiff.fileDiff); // plus a pre-built FileDiffMetadata built by diff-generator.ts via processFile().
const [layout, setLayout] = useState<'split' | 'stacked'>(() => // IntentMessage.codeDiff does NOT have a `fileDiff` field — diff-generator.ts builds
(localStorage.getItem('diffLayout') as 'split' | 'stacked') ?? 'split' // the FileDiffMetadata separately from originalContent/patchedContent and
// passes it into CodeTab alongside the raw codeDiff.
interface HunkAnnotation { message: string; kind: 'warning' | 'info'; }
export function CodeTab({ codeDiff, fileDiff: initialFileDiff, artboardId }: CodeTabProps) {
const [fileDiff, setFileDiff] = useState<FileDiffMetadata>(initialFileDiff); // FileDiffMetadata, not 'Diff'
const [rejectedHunks, setRejectedHunks] = useState<Set<number>>(new Set()); // track rejections separately
const [layout, setLayout] = useState<'split' | 'unified'>(() => // 'unified' not 'stacked'
(localStorage.getItem('diffLayout') as 'split' | 'unified') ?? 'split'
); );
const [popover, setPopover] = useState<TokenPopoverState | null>(null); const [popover, setPopover] = useState<TokenPopoverState | null>(null);
const { designLanguage } = useCanvas(); const { designLanguage } = useCanvas();
const annotations = useMemo<DiffLineAnnotation[]>(() => const annotations = useMemo<DiffLineAnnotation<HunkAnnotation>[]>(() =>
codeDiff.hunks.flatMap(hunk => buildAnnotations(hunk)), codeDiff.hunks.flatMap(hunk => buildAnnotations(hunk)), // hunk has .confidence and .tokenKey
[codeDiff.hunks] [codeDiff.hunks]
); );
const allRejected = diff.hunks.every(h => h.rejected); // HunkData has no .rejected property — use the separate rejectedHunks Set
const allRejected = fileDiff.hunks.length > 0 && rejectedHunks.size === fileDiff.hunks.length;
function handleAccept(i: number) { function handleAccept(i: number) {
setDiff(prev => diffAcceptRejectHunk(prev, i, 'accept')); setFileDiff(prev => diffAcceptRejectHunk(prev, i, 'accept'));
setRejectedHunks(prev => { const next = new Set(prev); next.delete(i); return next; });
} }
function handleReject(i: number) { function handleReject(i: number) {
setDiff(prev => diffAcceptRejectHunk(prev, i, 'reject')); setFileDiff(prev => diffAcceptRejectHunk(prev, i, 'reject'));
setRejectedHunks(prev => new Set([...prev, i]));
} }
function handleTokenEnter(payload: TokenPayload) { function handleTokenEnter(props: DiffTokenEventBaseProps) {
if (!designLanguage) return; if (!designLanguage) return;
const match = resolveValueToToken(payload.text, designLanguage); // resolveValueToToken's `type` is optional — auto-detects from value when omitted
if (match) setPopover({ payload, match }); const match = resolveValueToToken(props.tokenText, undefined, designLanguage); // .tokenText not .text
if (match) setPopover({ props, match });
} }
return ( return (
<div className="code-tab"> <div className="code-tab">
<CodeTabHeader <CodeTabHeader
filePath={codeDiff.filePath} filePath={codeDiff.file}
layout={layout} layout={layout}
onLayoutToggle={() => { onLayoutToggle={() => {
const next = layout === 'split' ? 'stacked' : 'split'; const next = layout === 'split' ? 'unified' : 'split'; // 'unified' not 'stacked'
setLayout(next); setLayout(next);
localStorage.setItem('diffLayout', next); localStorage.setItem('diffLayout', next);
}} }}
@@ -1182,15 +1291,20 @@ export function CodeTab({ codeDiff, artboardId }: CodeTabProps) {
<Virtualizer> <Virtualizer>
<FileDiff <FileDiff
diff={diff} fileDiff={fileDiff} {/* 'fileDiff' not 'diff' */}
lineAnnotations={annotations} {/* top-level prop, NOT inside options */}
renderAnnotation={(a) => ( {/* custom renderer — library has no built-in message display */}
<div className={`hunk-anno hunk-anno--${a.metadata.kind}`}>{a.metadata.message}</div>
)}
options={{ options={{
layout, diffStyle: layout, {/* 'diffStyle' not 'layout' */}
inlineChanges: 'character', lineDiffType: 'char', {/* 'lineDiffType'+'char' not 'inlineChanges'+'character' */}
diffIndicators: 'background', diffIndicators: 'bars', {/* 'bars' not 'background' */}
annotations, onTokenEnter: handleTokenEnter, {/* inside options (InteractionManagerBaseOptions) */}
onTokenEnter: handleTokenEnter,
onTokenLeave: () => setPopover(null), onTokenLeave: () => setPopover(null),
onTokenClick: (p) => setPopover(prev => prev?.payload === p ? null : { payload: p, match: resolveValueToToken(p.text, designLanguage) }), onTokenClick: (p: DiffTokenEventBaseProps) =>
setPopover(prev => prev?.props === p ? null
: { props: p, match: resolveValueToToken(p.tokenText, undefined, designLanguage) }),
}} }}
/> />
</Virtualizer> </Virtualizer>
@@ -1198,7 +1312,7 @@ export function CodeTab({ codeDiff, artboardId }: CodeTabProps) {
{/* Per-hunk accept/reject controls rendered outside FileDiff, positioned by line */} {/* Per-hunk accept/reject controls rendered outside FileDiff, positioned by line */}
<HunkControls <HunkControls
hunks={codeDiff.hunks} hunks={codeDiff.hunks}
diff={diff} rejectedHunks={rejectedHunks}
onAccept={handleAccept} onAccept={handleAccept}
onReject={handleReject} onReject={handleReject}
/> />
@@ -1207,8 +1321,8 @@ export function CodeTab({ codeDiff, artboardId }: CodeTabProps) {
<CodeTabFooter <CodeTabFooter
allRejected={allRejected} allRejected={allRejected}
onSend={() => sendToAgent(diff, codeDiff, artboardId)} onSend={() => sendToAgent(fileDiff, codeDiff, rejectedHunks, artboardId)}
onDiscardAll={() => setDiff(codeDiff.fileDiff)} onDiscardAll={() => { setFileDiff(initialFileDiff); setRejectedHunks(new Set()); }}
/> />
</div> </div>
); );
@@ -1230,15 +1344,43 @@ Builds the full `IntentMessage`:
**Undo queue:** `styleEditQueue` in Zustand maintains a stack. `Cmd+Z` pops the last item, sends `PATCH_ELEMENT_STYLE` with the `from` value to restore the DOM preview, and clears the Code tab. **Undo queue:** `styleEditQueue` in Zustand maintains a stack. `Cmd+Z` pops the last item, sends `PATCH_ELEMENT_STYLE` with the `from` value to restore the DOM preview, and clears the Code tab.
**Intent status subscription:** After `push_intent` succeeds, the canvas must subscribe to real-time status updates for that intent so it can transition "Sent ✓ — waiting for agent" → "✓ Applied". The canvas opens a Supabase Realtime subscription scoped to the new `intentId`:
```ts
// Called in CodeTab.tsx after a successful POST to /api/intent
function subscribeToIntentStatus(intentId: string, onUpdate: (status: string) => void) {
const channel = supabase
.channel(`intent_status_${intentId}`)
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'intent_diffs',
filter: `id=eq.${intentId}`,
}, (payload) => {
onUpdate(payload.new.status); // 'IMPLEMENTED' | 'BLOCKED'
})
.subscribe();
return () => channel.unsubscribe(); // return cleanup fn
}
```
The `CodeTabFooter` shows:
- `status === 'EXPORTED'`: spinner + "Waiting for agent…"
- `status === 'IMPLEMENTED'`: green "✓ Applied" badge
- `status === 'BLOCKED'`: red "⚠ Agent could not apply" + reason from `payload.blocked_reason` (add `blocked_reason text` column to `intent_diffs` — the agent calls `update_diff_status` with `status: 'BLOCKED', reason: '...'`)
Add `blocked_reason text` to the `intent_diffs` migration. Add `subscribeToIntentStatus` and a `blocked_reason` column to `§8.4a`.
### 7.5 Files to Create / Change ### 7.5 Files to Create / Change
| File | Change | | File | Change |
|---|---| |---|---|
| `packages/app/src/lib/diff-generator.ts` | NEW — client-side diff logic (CSS, prop, Tailwind strategies) | | `packages/app/src/lib/diff-generator.ts` | NEW — client-side diff logic (CSS, prop, Tailwind strategies) |
| `packages/app/src/components/inspector/CodeTab.tsx` | NEW — diff viewer, edit mode, Send to Agent button | | `packages/app/src/components/inspector/CodeTab.tsx` | NEW — diff viewer, edit mode, Send to Agent button |
| `packages/app/src/store/canvas.ts` | Add `undoStyleEdit()` action; add `intentStatus` map | | `packages/app/src/store/canvas.ts` | Add `undoStyleEdit()` action; add `intentStatus` map; add `projectMeta: ProjectMeta \| null` (populated by `GET /health` on CLI connection); add `indexerStatus: 'offline' \| 'indexing' \| 'ready'` |
| `packages/app/src/app/api/intent/route.ts` | NEW — POST endpoint for `push_intent` (proxies to Agent Bridge) | | `packages/app/src/app/api/intent/route.ts` | NEW — POST endpoint for `push_intent` (proxies to Agent Bridge) |
| `packages/app/package.json` | Add `"@pierre/diffs": "^1.1.20"` and `"shiki": "^3.0.0"` | | `packages/app/src/lib/diff-generator.ts` | Reads `canvasStore.projectMeta.tailwind` to set confidence and annotation on Tailwind projects; reads `projectMeta.cssModules` to prioritize `.module.css` search strategy |
| `packages/app/package.json` | Add `"@pierre/diffs": "^1.1.20"`, `"shiki": "^3.0.0"`, and `"diff": "^5.2.0"` (for `createTwoFilesPatch()` in diff-generator.ts — package verified on npm ✓) |
--- ---
@@ -1256,9 +1398,16 @@ Builds the full `IntentMessage`:
intentId: z.string().uuid(), intentId: z.string().uuid(),
component: z.object({ component: z.object({
name: z.string(), name: z.string(),
definitionFile: z.string().optional(), nodeId: z.string(), // fiber path ID — used for diff correlation
callSite: z.string().optional(), // "src/app/dashboard/page.tsx:34"
definitionFile: z.string().optional(), // "src/components/DashboardCard.tsx"
definitionLine: z.number().optional(), definitionLine: z.number().optional(),
callSite: z.string().optional(), props: z.record(z.unknown()), // current runtime props — helps agent understand context
propsSchema: z.array(z.object({ // from AST indexer — optional (indexer may not be running)
name: z.string(),
type: z.string(),
optional: z.boolean(),
})).optional(),
}), }),
changes: z.array(z.object({ changes: z.array(z.object({
type: z.enum(['style', 'prop', 'layout', 'remove']), type: z.enum(['style', 'prop', 'layout', 'remove']),
@@ -1322,11 +1471,13 @@ originmain dev starts
→ proxy on :4170 → proxy on :4170
→ indexer on :4171 → indexer on :4171
→ POST {AGENT_BRIDGE_URL}/register-indexer → POST {AGENT_BRIDGE_URL}/register-indexer
{ workspaceToken, indexerUrl: "http://localhost:4171", ttl: 60 } { workspaceToken, indexerUrl: "http://localhost:4171", ttl: 300 }
→ Agent Bridge stores { indexerUrl, expiresAt: now + ttl } per workspace session → Agent Bridge stores { indexerUrl, expiresAt: now + ttl } per workspace session
→ resolve_component and file-fetch proxy to indexerUrl → resolve_component and file-fetch proxy to indexerUrl
→ CLI sends a heartbeat POST every 30s to refresh the TTL → CLI sends a heartbeat POST every 120s to refresh the TTL
→ Agent Bridge removes the registration if no heartbeat for > 90s → Agent Bridge removes the registration if no heartbeat for > 360s (3× heartbeat interval)
// TTL is 300s (5min) with 120s heartbeat — gives 3× margin for brief network hiccups
// and survives a 23 minute machine sleep without deregistering
``` ```
**Agent Bridge URL:** The CLI reads `ORIGINMAIN_BRIDGE_URL` from environment (set in the workspace's `.env.local` or `~/.originmain/config.json` written by the CLI's `originmain login` command). Default for self-hosted: `http://localhost:4172`. **Agent Bridge URL:** The CLI reads `ORIGINMAIN_BRIDGE_URL` from environment (set in the workspace's `.env.local` or `~/.originmain/config.json` written by the CLI's `originmain login` command). Default for self-hosted: `http://localhost:4172`.
@@ -1356,10 +1507,11 @@ Claude Code sessions receive this immediately after `push_intent` is called.
CREATE TABLE intent_diffs ( CREATE TABLE intent_diffs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid REFERENCES workspaces(id) NOT NULL, workspace_id uuid REFERENCES workspaces(id) NOT NULL,
artboard_id uuid REFERENCES artboards(id), artboard_id uuid REFERENCES artboards(id) ON DELETE SET NULL, -- SET NULL (not CASCADE): preserve diff history after artboard deletion (see §3.3)
payload jsonb NOT NULL, -- the full IntentMessage JSON payload jsonb NOT NULL, -- the full IntentMessage JSON
status text NOT NULL DEFAULT 'DRAFT', status text NOT NULL DEFAULT 'DRAFT',
-- DiffStatus enum: 'DRAFT' | 'EXPORTED' | 'IMPLEMENTED' | 'BLOCKED' -- DiffStatus enum: 'DRAFT' | 'EXPORTED' | 'IMPLEMENTED' | 'BLOCKED'
blocked_reason text, -- populated by agent via update_diff_status when status = 'BLOCKED'
created_at timestamptz DEFAULT now(), created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now() updated_at timestamptz DEFAULT now()
); );
@@ -1376,7 +1528,8 @@ CREATE TABLE intent_diffs (
| `packages/agent-bridge/src/tools.ts` | Add `push_intent`, `resolve_component` | | `packages/agent-bridge/src/tools.ts` | Add `push_intent`, `resolve_component` |
| `packages/agent-bridge/src/protocol.ts` | Add `IntentMessage`, `IntentChange`, `INTENT_RECEIVED` | | `packages/agent-bridge/src/protocol.ts` | Add `IntentMessage`, `IntentChange`, `INTENT_RECEIVED` |
| `packages/agent-bridge/src/index.ts` | Handle `/register-indexer`, store per-workspace indexer URL | | `packages/agent-bridge/src/index.ts` | Handle `/register-indexer`, store per-workspace indexer URL |
| `packages/agent-bridge/src/adapters/claude-code.ts` | Document new tools in `CLAUDE.md` section — **must explicitly instruct**: "After successfully applying an intent diff, call `update_diff_status` with the `intentId` from the `INTENT_RECEIVED` message and status `IMPLEMENTED`. If the diff could not be applied, call with status `BLOCKED` and include a reason." | | `packages/agent-bridge/src/adapters/claude-code.ts` | Document new tools in `CLAUDE.md` section — **must explicitly instruct**: "After successfully applying an intent diff, call `update_diff_status` with the `intentId` from the `INTENT_RECEIVED` message and status `IMPLEMENTED`. If the diff could not be applied, call with status `BLOCKED` and include a `reason` string describing why (e.g. 'Component not found in file', 'File is read-only', 'Diff conflicts with current file state')." |
| `packages/agent-bridge/src/tools.ts` | Update `update_diff_status` input schema to accept optional `reason: z.string()` (written to `intent_diffs.blocked_reason` when status is `'BLOCKED'`). |
| `packages/app/src/app/api/agent-bridge/route.ts` | Wire `push_intent` and `resolve_component` | | `packages/app/src/app/api/agent-bridge/route.ts` | Wire `push_intent` and `resolve_component` |
| `packages/cli/src/commands/login.ts` | NEW — OAuth flow; on success writes `{ workspaceToken, workspaceId, bridgeUrl }` to `~/.originmain/config.json` | | `packages/cli/src/commands/login.ts` | NEW — OAuth flow; on success writes `{ workspaceToken, workspaceId, bridgeUrl }` to `~/.originmain/config.json` |
| `packages/cli/src/cli.ts` | Register `originmain login` subcommand | | `packages/cli/src/cli.ts` | Register `originmain login` subcommand |
@@ -1503,7 +1656,7 @@ interface TokenMatch {
function resolveValueToToken( function resolveValueToToken(
value: string, value: string,
type: TokenType, type: TokenType | undefined, // optional — auto-detects from value when omitted (used in hover callbacks where type is unknown)
tokens: DesignToken[] tokens: DesignToken[]
): TokenMatch | null ): TokenMatch | null
``` ```
@@ -1517,7 +1670,7 @@ function resolveValueToToken(
4. If `value` is already `var(--color-primary)`, extract the key and do a direct lookup 4. If `value` is already `var(--color-primary)`, extract the key and do a direct lookup
**Spacing / sizing / borderRadius / borderWidth / lineHeight:** **Spacing / sizing / borderRadius / borderWidth / lineHeight:**
1. Parse the numeric value and unit from both the input and the token. Normalize rem to px using the actual root font size, not a hardcoded 16px. The root font size is sent from the iframe to the canvas in the `READY` message as `rootFontSizePx: number` (add this field to the `READY` message in `dom-inspector.ts` — read it as `parseFloat(getComputedStyle(document.documentElement).fontSize)`). The canvas stores it per-artboard in Zustand and passes it to the token resolver. 1. Parse the numeric value and unit from both the input and the token. Normalize rem to px using the actual root font size, not a hardcoded 16px. The root font size is sent from the iframe to the canvas in the `READY` message as `rootFontSizePx: number` (add this field to the `READY` message in **`fiber-hook.ts`** — read it as `parseFloat(getComputedStyle(document.documentElement).fontSize)`). The Phase 6 files table in §9.14 correctly lists this change under `fiber-hook.ts`. The canvas stores it per-artboard in Zustand and passes it to the token resolver.
2. Exact match: same number, same normalized unit 2. Exact match: same number, same normalized unit
3. Near match: within 1px (rounding from rem) 3. Near match: within 1px (rounding from rem)
4. No match: any other value 4. No match: any other value
@@ -1773,15 +1926,13 @@ This tells the agent: "instead of writing `border-radius: 12px`, write `border-r
| `packages/design-language/src/resolver.ts` | NEW — `resolveValueToToken()`, color distance, unit normalization | | `packages/design-language/src/resolver.ts` | NEW — `resolveValueToToken()`, color distance, unit normalization |
| `packages/design-language/src/validator.ts` | NEW — alias resolution, circular reference detection | | `packages/design-language/src/validator.ts` | NEW — alias resolution, circular reference detection |
| `packages/design-language/src/index.ts` | Export all above | | `packages/design-language/src/index.ts` | Export all above |
| Note: `DesignInput.tsx` (raw CSS input, no token awareness) is built in Phase 2. It is upgraded to `TokenAwareInput.tsx` in Phase 6 when the token resolver is available. Phase 2 does not build TokenAwareInput. | | Note: `DesignInput.tsx` (raw CSS input, no token awareness) is built in Phase 2 (§5.12). Phase 6 builds `TokenAwareInput.tsx` as a wrapper around `DesignInput` that adds the token chip, picker, and deviation indicator. Phase 2 does not build `TokenAwareInput`. |
| `packages/app/src/components/inspector/TokenPicker.tsx` | NEW — token picker popover | | `packages/app/src/components/inspector/TokenPicker.tsx` | NEW — token picker popover |
| `packages/app/src/app/settings/design-language/page.tsx` | NEW — upload UI, validation flow, version history | | `packages/app/src/app/settings/design-language/page.tsx` | NEW — upload UI, validation flow, version history |
| `packages/app/src/app/api/design-language/route.ts` | NEW — GET (load) / POST (save) | | `packages/app/src/app/api/design-language/route.ts` | NEW — GET (load) / POST (save) |
| `packages/app/src/app/api/design-language/fetch/route.ts` | NEW — server-side proxy for URL fetch; validates HTTPS + non-private IP | | `packages/app/src/app/api/design-language/fetch/route.ts` | NEW — server-side proxy for URL fetch; validates HTTPS + non-private IP |
| `packages/app/src/store/canvas.ts` | Add `designLanguage: DesignToken[] \| null`, `loadDesignLanguage()` | | `packages/app/src/store/canvas.ts` | Add `designLanguage: DesignToken[] \| null`, `loadDesignLanguage()`, `artboardRootFontSize: Record<string, number>`; subscribe to Supabase Realtime for design language INSERT and UPDATE events |
| `packages/app/src/store/canvas.ts` | Subscribe to Supabase realtime for design language changes |
| Database | Migration: `create-design-languages.sql` | | Database | Migration: `create-design-languages.sql` |
| Note: `DesignInput.tsx` (no token awareness) is built in Phase 2 (§5.12). Phase 6 builds `TokenAwareInput.tsx` as a wrapper around `DesignInput` that adds the token chip, picker, and deviation indicator. |
--- ---
@@ -1845,6 +1996,9 @@ User's dev server CLI Proxy CLI Indexer Originmain Canvas (browser)
│ │ │ │ reads file │ │ │ │ reads file
│ │ │ │ applies diff │ │ │ │ applies diff
│ │ │ │ saves file │ │ │ │ saves file
// NOTE: Claude Code → indexer path above goes THROUGH the Agent Bridge
// (Agent Bridge proxies GET /components → registered indexerUrl per §8.3).
// Claude Code never has direct network access to localhost:4171 on the user's machine.
│ hot reload │ │ │ │ hot reload │ │ │
│◄──────────────────│ │ │ │◄──────────────────│ │ │
│ │ FIBER_TREE_UPDATE (post-edit) │ │ │ FIBER_TREE_UPDATE (post-edit) │
@@ -1904,7 +2058,7 @@ Once the agent edits a file and hot-reload fires, the DOM preview change is "rea
Figma's Variables API can export the design language directly. A "Sync from Figma" button would call the Figma API and import variables in DTCG format. This is Phase 8+. Figma's Variables API can export the design language directly. A "Sync from Figma" button would call the Figma API and import variables in DTCG format. This is Phase 8+.
### Q5: Component prop defaults in AST indexer ### Q5: Component prop defaults in AST indexer
Extracting default prop values requires evaluating expressions. Deferred to Phase 5+; Phase 3 extracts names and types only. Extracting default prop values requires evaluating expressions (e.g., `const { color = 'blue' } = props`). Deferred to post-Phase 7; Phase 3 extracts names and types only. Note: "Phase 5" in this document refers to the Agent Bridge enhancements — not a type-expansion phase.
--- ---