diff --git a/.gitignore b/.gitignore index c85a14965..434d0f732 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ dist-electron/ .claude .omx .worktrees/ +.omx/ diff --git a/apps/cli/package.json b/apps/cli/package.json index 67b291bce..f0312fe68 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/openpencil", - "version": "0.7.2", + "version": "0.7.3", "description": "CLI for OpenPencil — control the design tool from your terminal", "homepage": "https://github.com/ZSeven-W/openpencil/tree/main/apps/cli", "bugs": { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index abb894bc7..af55f757c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/desktop", - "version": "0.7.2", + "version": "0.7.3", "private": true, "type": "module" } diff --git a/apps/web/package.json b/apps/web/package.json index ee46f33db..84e74874c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/web", - "version": "0.7.2", + "version": "0.7.3", "private": true, "type": "module", "dependencies": { diff --git a/apps/web/server/api/ai/agent.ts b/apps/web/server/api/ai/agent.ts index ebe457866..bdc44880f 100644 --- a/apps/web/server/api/ai/agent.ts +++ b/apps/web/server/api/ai/agent.ts @@ -608,7 +608,7 @@ export default defineEventHandler(async (event) => { if (streamClosed) return; try { controller.enqueue(chunk); - } catch (err) { + } catch { // Controller may have closed mid-notification (e.g. client disconnect, // idle timeout). Mark closed and stop enqueuing to avoid noise. streamClosed = true; diff --git a/apps/web/server/api/ai/mcp-install.ts b/apps/web/server/api/ai/mcp-install.ts index c004bfbba..bf9ecf31d 100644 --- a/apps/web/server/api/ai/mcp-install.ts +++ b/apps/web/server/api/ai/mcp-install.ts @@ -190,7 +190,7 @@ function installMcpServer( return { ...config, mcpServers: { - ...(config.mcpServers ?? {}), + ...config.mcpServers, [MCP_SERVER_NAME]: buildMcpServerEntry(serverPath, transportMode, httpPort), }, }; @@ -204,14 +204,14 @@ function installMcpServerHttpUrl( return { ...config, mcpServers: { - ...(config.mcpServers ?? {}), + ...config.mcpServers, [MCP_SERVER_NAME]: buildMcpHttpUrlEntry(httpPort), }, }; } function uninstallMcpServer(config: Record): Record { - const servers = { ...(config.mcpServers ?? {}) }; + const servers = { ...config.mcpServers }; delete servers[MCP_SERVER_NAME]; return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }; } @@ -261,8 +261,32 @@ async function writeJsonConfig(filePath: string, config: Record): P await writeFile(filePath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); } -function codexBinary(): string { - return process.platform === 'win32' ? 'codex.cmd' : 'codex'; +/** + * Run the codex CLI with args, handling Windows shim spawning. + * + * Since Node 18.20/20.12 (CVE-2024-27980), execFileSync refuses to spawn + * .cmd/.bat files directly (throws EINVAL). On Windows we route through the + * shell so PATHEXT resolves whichever shim exists (codex.exe / codex.cmd / + * codex.ps1). Args here are internal (no user input) — we just quote paths + * that contain spaces. + */ +function runCodex(args: string[]): void { + if (process.platform === 'win32') { + const cmdLine = ['codex', ...args].map(quoteWinArg).join(' '); + execSync(cmdLine, { encoding: 'utf-8', timeout: 15_000, stdio: 'pipe' }); + return; + } + execFileSync('codex', args, { + encoding: 'utf-8', + timeout: 15_000, + stdio: 'pipe', + }); +} + +function quoteWinArg(arg: string): string { + if (arg.length === 0) return '""'; + if (!/[\s"&|<>^%]/.test(arg)) return arg; + return `"${arg.replace(/"/g, '""')}"`; } async function installCodexMcp( @@ -287,28 +311,16 @@ async function installCodexMcp( // Non-fatal: server may already be running or will be started manually } - execFileSync( - codexBinary(), - ['mcp', 'add', MCP_SERVER_NAME, '--url', `http://127.0.0.1:${port}/mcp`], - { encoding: 'utf-8', timeout: 15_000, stdio: 'pipe' }, - ); + runCodex(['mcp', 'add', MCP_SERVER_NAME, '--url', `http://127.0.0.1:${port}/mcp`]); return { configPath: CODEX_CONFIG_PATH, fallbackHttp: true }; } - execFileSync( - codexBinary(), - ['mcp', 'add', MCP_SERVER_NAME, '--', resolveNodeCommand(), serverPath], - { encoding: 'utf-8', timeout: 15_000, stdio: 'pipe' }, - ); + runCodex(['mcp', 'add', MCP_SERVER_NAME, '--', resolveNodeCommand(), serverPath]); return { configPath: CODEX_CONFIG_PATH, fallbackHttp: false }; } function uninstallCodexMcp(): { configPath: string } { - execFileSync(codexBinary(), ['mcp', 'remove', MCP_SERVER_NAME], { - encoding: 'utf-8', - timeout: 15_000, - stdio: 'pipe', - }); + runCodex(['mcp', 'remove', MCP_SERVER_NAME]); return { configPath: CODEX_CONFIG_PATH }; } diff --git a/apps/web/server/opencode/client.ts b/apps/web/server/opencode/client.ts index 6c2dc5129..e66ceac46 100644 --- a/apps/web/server/opencode/client.ts +++ b/apps/web/server/opencode/client.ts @@ -21,7 +21,7 @@ export function createOpencodeClient( } if (config?.directory) { - const isNonASCII = /[^\x00-\x7F]/.test(config.directory); + const isNonASCII = /\P{ASCII}/u.test(config.directory); const encodedDirectory = isNonASCII ? encodeURIComponent(config.directory) : config.directory; config.headers = { ...config.headers, diff --git a/apps/web/server/opencode/v2/client.ts b/apps/web/server/opencode/v2/client.ts index f76b88670..5a67e36e0 100644 --- a/apps/web/server/opencode/v2/client.ts +++ b/apps/web/server/opencode/v2/client.ts @@ -21,7 +21,7 @@ export function createOpencodeClient( } if (config?.directory) { - const isNonASCII = /[^\x00-\x7F]/.test(config.directory); + const isNonASCII = /\P{ASCII}/u.test(config.directory); const encodedDirectory = isNonASCII ? encodeURIComponent(config.directory) : config.directory; config.headers = { ...config.headers, diff --git a/apps/web/src/canvas/agent-indicator.ts b/apps/web/src/canvas/agent-indicator.ts index 2ad767737..dd010792a 100644 --- a/apps/web/src/canvas/agent-indicator.ts +++ b/apps/web/src/canvas/agent-indicator.ts @@ -86,7 +86,7 @@ export function removeAgentIndicatorsByPrefix(prefix: string): void { const map = getIndicatorMap(); const set = getPreviewSet(); const prefixDash = `${prefix}-`; - for (const key of [...map.keys()]) { + for (const key of Array.from(map.keys())) { if (key.startsWith(prefixDash)) { map.delete(key); set.delete(key); diff --git a/apps/web/src/canvas/skia/__tests__/skia-engine.test.ts b/apps/web/src/canvas/skia/__tests__/skia-engine.test.ts index a4b6b0d22..fbfaddb57 100644 --- a/apps/web/src/canvas/skia/__tests__/skia-engine.test.ts +++ b/apps/web/src/canvas/skia/__tests__/skia-engine.test.ts @@ -73,3 +73,60 @@ describe('SkiaEngine surface recovery', () => { expect(engine.surface).toBe(healthySurface as any); }); }); + +describe('SkiaEngine resize flash prevention', () => { + it('renders synchronously after recreating the surface on resize', () => { + const canvasOps = createCanvasOps(); + let flushCount = 0; + const surface = { + getCanvas() { + return canvasOps; + }, + flush() { + flushCount += 1; + }, + delete() {}, + }; + + const ck = { + Color4f(r: number, g: number, b: number, a: number) { + return Float32Array.of(r, g, b, a); + }, + TypefaceFontProvider: { + Make() { + return { + registerFont() {}, + delete() {}, + }; + }, + }, + MakeWebGLCanvasSurface() { + return surface; + }, + MakeSWCanvasSurface() { + return null; + }, + }; + + const engine = new SkiaEngine(ck as any); + const canvasEl = createCanvasStub(); + (engine as any).canvasEl = canvasEl; + (engine as any).renderNodes = []; + engine.surface = surface as any; + (engine as any).dirty = true; + + const originalWindow = (globalThis as any).window; + (globalThis as any).window = { devicePixelRatio: 1 }; + try { + engine.resize(800, 600); + } finally { + (globalThis as any).window = originalWindow; + } + + // Synchronous render must have run: dirty cleared, surface flushed once. + // Without this, canvas.width = ... leaves the element transparent until + // the next RAF, causing a one-frame white flash on layout changes. + expect((engine as any).dirty).toBe(false); + expect(flushCount).toBe(1); + }); +}); diff --git a/apps/web/src/canvas/skia/skia-engine.ts b/apps/web/src/canvas/skia/skia-engine.ts index c455520d2..3f9ab5645 100644 --- a/apps/web/src/canvas/skia/skia-engine.ts +++ b/apps/web/src/canvas/skia/skia-engine.ts @@ -148,7 +148,14 @@ export class SkiaEngine { this.canvasEl.height = height * dpr; if (this.recreateSurface()) { - this.markDirty(); + // Render synchronously so the new surface is filled before the browser + // paints. Setting canvas.width/height clears the pixel buffer to + // transparent; ResizeObserver fires between layout and paint, so writing + // pixels here lands in the same frame's composite. Without this, the + // canvas flashes through to its container background (e.g. bg-muted) + // for one frame whenever a sibling panel mounts and the flex layout shifts. + this.dirty = false; + this.render(); } } diff --git a/apps/web/src/components/panels/chat-message-content.tsx b/apps/web/src/components/panels/chat-message-content.tsx index c369f773d..b975988b3 100644 --- a/apps/web/src/components/panels/chat-message-content.tsx +++ b/apps/web/src/components/panels/chat-message-content.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; /** Check if a JSON string looks like PenNode data */ export function isDesignJson(code: string): boolean { - return /^\s*[\[{]/.test(code) && /"type"\s*:/.test(code) && /"id"\s*:/.test(code); + return /^\s*[[{]/.test(code) && /"type"\s*:/.test(code) && /"id"\s*:/.test(code); } export function parseMarkdown( diff --git a/apps/web/src/components/panels/variable-theme-manager.tsx b/apps/web/src/components/panels/variable-theme-manager.tsx index 9388593ff..6a68877e5 100644 --- a/apps/web/src/components/panels/variable-theme-manager.tsx +++ b/apps/web/src/components/panels/variable-theme-manager.tsx @@ -139,7 +139,7 @@ function ThemeTabsHeaderInner({ themes: Record; variables: Record; }) => { - const mergedThemes = { ...(themes ?? {}), ...preset.themes }; + const mergedThemes = { ...themes, ...preset.themes }; setThemes(mergedThemes); const currentVars = variables ?? {}; for (const [name, def] of Object.entries(preset.variables)) { diff --git a/apps/web/src/components/shared/font-picker.tsx b/apps/web/src/components/shared/font-picker.tsx index 31257aa73..8c39ed9b7 100644 --- a/apps/web/src/components/shared/font-picker.tsx +++ b/apps/web/src/components/shared/font-picker.tsx @@ -17,7 +17,7 @@ function displayName(value: string): string { export default function FontPicker({ value, onChange, className }: FontPickerProps) { const { t } = useTranslation(); - const { allFonts, loading } = useSystemFonts(); + const { allFonts, loading, permissionState, requestAccess } = useSystemFonts(); const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const [highlightIndex, setHighlightIndex] = useState(-1); @@ -25,6 +25,14 @@ export default function FontPicker({ value, onChange, className }: FontPickerPro const listRef = useRef(null); const inputRef = useRef(null); + const handleToggle = useCallback(() => { + const opening = !open; + setOpen(opening); + if (opening && permissionState === 'prompt') { + requestAccess(); // user gesture context — browser shows permission prompt + } + }, [open, permissionState, requestAccess]); + // Filter fonts by search const filtered = useMemo(() => { if (!search) return allFonts; @@ -86,7 +94,7 @@ export default function FontPicker({ value, onChange, className }: FontPickerPro if (!open) { if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { e.preventDefault(); - setOpen(true); + handleToggle(); } return; } @@ -127,7 +135,7 @@ export default function FontPicker({ value, onChange, className }: FontPickerPro {/* Trigger button */} {/* Dropdown */} @@ -175,6 +187,15 @@ export default function FontPicker({ value, onChange, className }: FontPickerPro )} + {/* Permission denied info — user must change browser settings */} + {permissionState === 'denied' && ( +
+

+ System fonts unavailable. Allow local fonts in browser settings to enable them. +

+
+ )} + {/* Bundled fonts group */} {bundled.length > 0 && ( <> diff --git a/apps/web/src/hooks/use-clipboard-shortcuts.ts b/apps/web/src/hooks/use-clipboard-shortcuts.ts index d5a3b58c1..eb75b4144 100644 --- a/apps/web/src/hooks/use-clipboard-shortcuts.ts +++ b/apps/web/src/hooks/use-clipboard-shortcuts.ts @@ -3,6 +3,17 @@ import { useCanvasStore } from '@/stores/canvas-store'; import { useDocumentStore } from '@/stores/document-store'; import { cloneNodesWithNewIds } from '@/utils/node-clone'; import { tryPasteFigmaFromClipboard } from '@/hooks/use-figma-paste'; +import { + findNodeInTree, + findParentInTree, + getActivePageChildren, +} from '@/stores/document-tree-utils'; +import type { PenNode } from '@zseven-w/pen-types'; + +// Container types (extend ContainerProps in pen-types) — only these can hold children. +function canHoldChildren(node: PenNode): boolean { + return node.type === 'frame' || node.type === 'group' || node.type === 'rectangle'; +} export function useClipboardShortcuts() { useEffect(() => { @@ -46,9 +57,37 @@ export function useClipboardShortcuts() { // Paste: Cmd/Ctrl+V if (isMod && e.key === 'v' && !e.shiftKey) { - const { clipboard } = useCanvasStore.getState(); + const canvasState = useCanvasStore.getState(); + const { clipboard } = canvasState; if (clipboard.length > 0) { e.preventDefault(); + + // Anchor paste to the active selection: + // - If the selected node is a container, paste inside it (as last child). + // - Otherwise paste as a sibling, right after the selected node. + // - Falls back to root when nothing is selected. + const anchorId = canvasState.selection.selectedIds[0]; + const docState = useDocumentStore.getState(); + const children = getActivePageChildren(docState.document, canvasState.activePageId); + + let parentId: string | null = null; + let insertIndex: number | undefined; + if (anchorId) { + const anchor = findNodeInTree(children, anchorId); + if (anchor && canHoldChildren(anchor)) { + // Paste inside the selected container + parentId = anchor.id; + insertIndex = undefined; // append to end + } else { + // Paste as sibling of the selected node + const parent = findParentInTree(children, anchorId); + parentId = parent ? parent.id : null; + const siblings = parent && 'children' in parent ? (parent.children ?? []) : children; + const idx = siblings.findIndex((n) => n.id === anchorId); + if (idx >= 0) insertIndex = idx + 1; + } + } + const newIds: string[] = []; for (const original of clipboard) { // Pasting a reusable component creates an instance (RefNode) @@ -64,8 +103,9 @@ export function useClipboardShortcuts() { } // Regular paste for non-reusable nodes const [cloned] = cloneNodesWithNewIds([original], { offset: 10 }); - useDocumentStore.getState().addNode(null, cloned); + useDocumentStore.getState().addNode(parentId, cloned, insertIndex); newIds.push(cloned.id); + if (insertIndex !== undefined) insertIndex += 1; } useCanvasStore.getState().setSelection(newIds, newIds[0] ?? null); } else { diff --git a/apps/web/src/hooks/use-system-fonts.ts b/apps/web/src/hooks/use-system-fonts.ts index fa5237df2..edc1cfe06 100644 --- a/apps/web/src/hooks/use-system-fonts.ts +++ b/apps/web/src/hooks/use-system-fonts.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; export interface FontInfo { family: string; @@ -35,12 +35,18 @@ const FALLBACK_SYSTEM_FONTS = [ 'Comic Sans MS', ]; +/** Permission state for the Local Font Access API */ +export type FontPermissionState = 'prompt' | 'granted' | 'denied' | 'unavailable'; + /** Cached system font families to avoid re-querying */ let cachedSystemFonts: string[] | null = null; -let fetchPromise: Promise | null = null; +let cachedPermissionState: FontPermissionState | null = null; +let fetchPromise: Promise<{ fonts: string[]; permission: FontPermissionState }> | null = null; -async function querySystemFonts(): Promise { - if (cachedSystemFonts) return cachedSystemFonts; +async function querySystemFonts(): Promise<{ fonts: string[]; permission: FontPermissionState }> { + if (cachedSystemFonts && cachedPermissionState) { + return { fonts: cachedSystemFonts, permission: cachedPermissionState }; + } if (fetchPromise) return fetchPromise; @@ -61,42 +67,75 @@ async function querySystemFonts(): Promise { .filter((f) => !bundledSet.has(f.toLowerCase())) .sort((a, b) => a.localeCompare(b)); cachedSystemFonts = systemFonts; - return systemFonts; + cachedPermissionState = 'granted'; + return { fonts: systemFonts, permission: 'granted' as FontPermissionState }; } - } catch { - // Permission denied or API not available + } catch (e: unknown) { + // Check if it was a permission denial + if (e instanceof DOMException && e.name === 'NotAllowedError') { + cachedPermissionState = 'denied'; + cachedSystemFonts = FALLBACK_SYSTEM_FONTS; + return { fonts: FALLBACK_SYSTEM_FONTS, permission: 'denied' }; + } + // Other error — API may be unavailable + console.warn( + '[useSystemFonts] queryLocalFonts failed:', + e instanceof Error ? e.message : String(e), + ); + } + + // API not available or failed + if (!cachedPermissionState) { + cachedPermissionState = + typeof window !== 'undefined' && 'queryLocalFonts' in window ? 'denied' : 'unavailable'; } cachedSystemFonts = FALLBACK_SYSTEM_FONTS; - return FALLBACK_SYSTEM_FONTS; + return { fonts: FALLBACK_SYSTEM_FONTS, permission: cachedPermissionState }; })(); return fetchPromise; } +/** + * Request local font access permission from the user. + * Must be called from a user gesture context (click handler). + * Resets cached state and re-queries fonts. + */ +async function requestFontAccess(): Promise<{ fonts: string[]; permission: FontPermissionState }> { + // Reset cache to force re-query + cachedSystemFonts = null; + cachedPermissionState = null; + fetchPromise = null; + return querySystemFonts(); +} + /** * Hook to enumerate system fonts via the Local Font Access API. * Falls back to a common font list if the API is unavailable. */ export function useSystemFonts() { const [systemFonts, setSystemFonts] = useState(cachedSystemFonts ?? []); - const [loading, setLoading] = useState(!cachedSystemFonts); + const [loading, setLoading] = useState(false); + const [permissionState, setPermissionState] = useState( + cachedPermissionState ?? 'prompt', + ); + // Only read from module-level cache on mount — do NOT call queryLocalFonts() + // here because it requires a user gesture context. Fonts are populated via + // requestAccess() when the user opens the font picker dropdown. useEffect(() => { - if (cachedSystemFonts) { + if (cachedSystemFonts && cachedPermissionState) { setSystemFonts(cachedSystemFonts); - setLoading(false); - return; + setPermissionState(cachedPermissionState); } - let cancelled = false; - querySystemFonts().then((fonts) => { - if (!cancelled) { - setSystemFonts(fonts); - setLoading(false); - } - }); - return () => { - cancelled = true; - }; + }, []); + + const requestAccess = useCallback(async () => { + setLoading(true); + const { fonts, permission } = await requestFontAccess(); + setSystemFonts(fonts); + setPermissionState(permission); + setLoading(false); }, []); const allFonts: FontInfo[] = [ @@ -104,5 +143,12 @@ export function useSystemFonts() { ...systemFonts.map((f) => ({ family: f, source: 'system' as const })), ]; - return { allFonts, systemFonts, bundledFonts: BUNDLED_FAMILIES, loading }; + return { + allFonts, + systemFonts, + bundledFonts: BUNDLED_FAMILIES, + loading, + permissionState, + requestAccess, + }; } diff --git a/apps/web/src/services/ai/__tests__/append-intent-detector.test.ts b/apps/web/src/services/ai/__tests__/append-intent-detector.test.ts new file mode 100644 index 000000000..c0ddd3eff --- /dev/null +++ b/apps/web/src/services/ai/__tests__/append-intent-detector.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { detectAppendIntent } from '../append-intent-detector'; +import type { PenDocument, FrameNode } from '@/types/pen'; + +function frame(id: string, name: string, width = 375, children: any[] = []): FrameNode { + return { + id, + type: 'frame', + name, + x: 0, + y: 0, + width, + height: 812, + children, + } as FrameNode; +} + +function doc(children: any[], pageId?: string): PenDocument { + if (pageId) { + return { + children: [], + pages: [{ id: pageId, name: 'Page', children }], + } as unknown as PenDocument; + } + return { children } as unknown as PenDocument; +} + +describe('detectAppendIntent', () => { + it('returns null when canvas is empty', () => { + expect(detectAppendIntent('continue the workouts interface', doc([]), null)).toBeNull(); + }); + + it('returns null when prompt has no append keyword', () => { + const d = doc([frame('page-1', 'Page', 375, [frame('section-1', 'Hero')])]); + expect(detectAppendIntent('generate a new landing page', d, null)).toBeNull(); + }); + + it('returns null when prompt explicitly asks for a new page/screen', () => { + const d = doc([frame('page-1', 'Page', 375, [frame('section-1', 'Hero')])]); + expect(detectAppendIntent('add another screen for workouts', d, null)).toBeNull(); + expect(detectAppendIntent('也加一个新页面', d, null)).toBeNull(); + }); + + it('detects "continue" with a non-empty page', () => { + const content = frame('content-root', 'Page Content Root', 375, [frame('s-1', 'Hero')]); + const page = frame('page-1', 'Page', 375, [frame('status-bar', 'Status bar'), content]); + const d = doc([page]); + const result = detectAppendIntent('Continue to generate the workouts interface', d, null); + expect(result).not.toBeNull(); + expect(result!.targetParentId).toBe('content-root'); + expect(result!.isMobile).toBe(true); + expect(result!.existingSectionLabels).toContain('Hero'); + }); + + it('detects Chinese append keywords', () => { + const page = frame('page-1', 'Page', 1200, [frame('hero', 'Hero')]); + const d = doc([page]); + const result = detectAppendIntent('再加一个功能 section', d, null); + expect(result).not.toBeNull(); + expect(result!.isMobile).toBe(false); + }); + + it('falls back to the page frame when no content-root child exists', () => { + const page = frame('page-1', 'Page', 375, [frame('hero', 'Hero')]); + const d = doc([page]); + const result = detectAppendIntent('continue', d, null); + expect(result!.targetParentId).toBe('page-1'); + }); + + it('honors the active page when pages[] is used', () => { + const pageA = frame('page-a', 'Page', 375, [frame('hero', 'Hero')]); + const pageB = frame('page-b', 'Page 2', 375, [frame('intro', 'Intro')]); + const d = doc([pageA, pageB], 'page-b'); + const result = detectAppendIntent('continue', d, 'page-b'); + expect(result!.targetParentId).toBe('page-b'); + expect(result!.existingSectionLabels).toContain('Intro'); + expect(result!.existingSectionLabels).not.toContain('Hero'); + }); + + it('returns null when the page has only a status bar child', () => { + const page = frame('page-1', 'Page', 375, [frame('status-bar', 'Status Bar')]); + const d = doc([page]); + expect(detectAppendIntent('continue', d, null)).toBeNull(); + }); + + it('excludes status bar from existingSectionLabels when real content also exists', () => { + const page = frame('page-1', 'Page', 375, [ + frame('status-bar', 'Status Bar'), + frame('hero', 'Hero'), + ]); + const d = doc([page]); + const result = detectAppendIntent('continue', d, null); + expect(result).not.toBeNull(); + expect(result!.existingSectionLabels).toContain('Hero'); + expect(result!.existingSectionLabels).not.toContain('Status Bar'); + }); +}); diff --git a/apps/web/src/services/ai/__tests__/orchestrator-append.test.ts b/apps/web/src/services/ai/__tests__/orchestrator-append.test.ts new file mode 100644 index 000000000..ec29c038e --- /dev/null +++ b/apps/web/src/services/ai/__tests__/orchestrator-append.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { applyAppendContextToPlan } from '../orchestrator-append'; +import type { OrchestratorPlan } from '../ai-types'; + +function basePlan(): OrchestratorPlan { + return { + rootFrame: { id: 'new-root', name: 'Page', width: 375, height: 812 }, + subtasks: [ + { + id: 'status-bar', + label: 'Status Bar', + region: { width: 375, height: 44 }, + idPrefix: '', + parentFrameId: null, + }, + { + id: 'header', + label: 'Screen Header', + region: { width: 375, height: 120 }, + idPrefix: '', + parentFrameId: null, + }, + { + id: 'cards', + label: 'Workout Type Cards', + region: { width: 375, height: 200 }, + idPrefix: '', + parentFrameId: null, + }, + ], + }; +} + +describe('applyAppendContextToPlan', () => { + it('returns the plan unchanged when no appendContext is given', () => { + const plan = basePlan(); + const out = applyAppendContextToPlan(plan, undefined); + expect(out.skipRootInsertion).toBe(false); + expect(out.skipStatusBar).toBe(false); + expect(out.plan.rootFrame.id).toBe('new-root'); + expect(out.plan.subtasks).toHaveLength(3); + }); + + it('repoints rootFrame.id to targetParentId and drops status-bar subtasks', () => { + const out = applyAppendContextToPlan(basePlan(), { + targetParentId: 'content-root', + targetWidth: 375, + existingSectionLabels: ['Hero'], + isMobile: true, + }); + expect(out.skipRootInsertion).toBe(true); + expect(out.skipStatusBar).toBe(true); + expect(out.plan.rootFrame.id).toBe('content-root'); + expect(out.plan.subtasks.map((s) => s.id)).toEqual(['header', 'cards']); + }); + + it('propagates existingSectionLabels onto every remaining subtask', () => { + const out = applyAppendContextToPlan(basePlan(), { + targetParentId: 'content-root', + targetWidth: 1200, + existingSectionLabels: ['Navbar', 'Hero', 'Features'], + isMobile: false, + }); + for (const st of out.plan.subtasks) { + expect(st.existingSectionLabels).toEqual(['Navbar', 'Hero', 'Features']); + } + }); +}); diff --git a/apps/web/src/services/ai/__tests__/orchestrator-planning.test.ts b/apps/web/src/services/ai/__tests__/orchestrator-planning.test.ts index d1a6daff5..077b41d16 100644 --- a/apps/web/src/services/ai/__tests__/orchestrator-planning.test.ts +++ b/apps/web/src/services/ai/__tests__/orchestrator-planning.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; import { filterPlanningSkillsForPrompt, parseOrchestratorResponse } from '../orchestrator-planning'; +import { DESIGN_MD_STYLE_GUIDE_NAME } from '../orchestrator-prompt-optimizer'; +import { extractSidebarSurfaceColor } from '../orchestrator-sidebar-color'; +import type { OrchestratorPlan } from '../ai-types'; +import type { DesignMdSpec } from '@/types/design-md'; describe('filterPlanningSkillsForPrompt', () => { const skills = [ @@ -95,4 +99,186 @@ describe('parseOrchestratorResponse', () => { expect(parsed?.repaired).toBe(false); expect(parsed?.plan.subtasks[0]?.id).toBe('header'); }); + + it('strips stale catalog styling from the repair path when design.md is present', () => { + const designMd: DesignMdSpec = { + raw: '# Test', + colorPalette: [{ name: 'Midnight Canvas', hex: '#111111', role: 'Primary app background' }], + }; + + // Invalid JSON (missing rootFrame) with stale catalog styleGuideName + fill + const raw = JSON.stringify({ + styleGuideName: 'health-minimal-mobile-dark', + rootFrame: { fill: [{ type: 'solid', color: '#0A0F1C' }] }, + sections: [ + { title: 'Header', elements: 'greeting, avatar', height: 120 }, + { title: 'Main', elements: 'cards' }, + ], + }); + + const parsed = parseOrchestratorResponse(raw, 'design a mobile wellness home screen', designMd); + + expect(parsed?.repaired).toBe(true); + expect(parsed?.plan.styleGuideName).toBe(DESIGN_MD_STYLE_GUIDE_NAME); + expect(parsed?.plan.styleGuide).toBeUndefined(); + expect((parsed?.plan.rootFrame.fill as Array<{ color?: string }> | undefined)?.[0]?.color).toBe( + '#111111', + ); + }); + + it('strips stale catalog styling from a fully-parsed plan when design.md is present', () => { + const designMd: DesignMdSpec = { + raw: '# Test', + colorPalette: [{ name: 'Midnight Canvas', hex: '#111111', role: 'Primary app background' }], + }; + + const raw = JSON.stringify({ + rootFrame: { + id: 'page', + name: 'Page', + width: 375, + height: 812, + layout: 'vertical', + fill: [{ type: 'solid', color: '#0A0F1C' }], + }, + styleGuideName: 'health-minimal-mobile-dark', + styleGuide: { + palette: { background: '#0A0F1C', accent: '#ff0000' }, + fonts: { heading: 'Space Grotesk', body: 'Inter' }, + }, + subtasks: [ + { + id: 'header', + label: 'Header', + elements: 'greeting, avatar', + region: { width: 375, height: 140 }, + }, + ], + }); + + const parsed = parseOrchestratorResponse(raw, 'design a mobile wellness home screen', designMd); + + expect(parsed?.repaired).toBe(false); + expect(parsed?.plan.styleGuideName).toBe(DESIGN_MD_STYLE_GUIDE_NAME); + expect(parsed?.plan.styleGuide).toBeUndefined(); + expect((parsed?.plan.rootFrame.fill as Array<{ color?: string }> | undefined)?.[0]?.color).toBe( + '#111111', + ); + }); + + it('does not use a surface/card color as the page background', () => { + // design.md has a "Card surface" role but no explicit page background. + // Painting the whole page with #1A1F2E would flatten the card distinction. + const designMd: DesignMdSpec = { + raw: '# Test', + visualTheme: 'moody dark fitness dashboard', + colorPalette: [ + { name: 'Brand Green', hex: '#22C55E', role: 'CTA accent' }, + { name: 'Card Surface', hex: '#1A1F2E', role: 'Card surface / panel surface' }, + ], + }; + + const raw = JSON.stringify({ + rootFrame: { + id: 'page', + name: 'Page', + width: 375, + height: 812, + layout: 'vertical', + fill: [{ type: 'solid', color: '#1A1F2E' }], + }, + styleGuideName: 'catalog-dark', + subtasks: [ + { id: 'header', label: 'Header', elements: 'title', region: { width: 375, height: 120 } }, + ], + }); + + const parsed = parseOrchestratorResponse(raw, 'design a mobile screen', designMd); + + // Should fall back to neutral dark (#111111), NOT the card surface color. + expect((parsed?.plan.rootFrame.fill as Array<{ color?: string }> | undefined)?.[0]?.color).toBe( + '#111111', + ); + }); + + it('enforces the neutral fallback on parsed planner output when design.md lacks a background role', () => { + const designMd: DesignMdSpec = { + raw: '# Test', + visualTheme: 'sleek dark cyberpunk dashboard', + colorPalette: [ + { name: 'Brand Coral', hex: '#FF5733', role: 'Primary CTA color' }, + { name: 'Ink', hex: '#1A1A1A', role: 'Body text' }, + ], + }; + + // Model returned a brand color as page background — this is exactly the + // regression we guard against. Finalize must overwrite it with the neutral + // default derived from visualTheme ("dark" → #111111). + const raw = JSON.stringify({ + rootFrame: { + id: 'page', + name: 'Page', + width: 375, + height: 812, + layout: 'vertical', + fill: [{ type: 'solid', color: '#FF5733' }], + }, + styleGuideName: 'light-minimal', + subtasks: [ + { id: 'header', label: 'Header', elements: 'title', region: { width: 375, height: 120 } }, + ], + }); + + const parsed = parseOrchestratorResponse(raw, 'design a mobile screen', designMd); + + expect((parsed?.plan.rootFrame.fill as Array<{ color?: string }> | undefined)?.[0]?.color).toBe( + '#111111', + ); + }); +}); + +describe('extractSidebarSurfaceColor', () => { + const basePlan: OrchestratorPlan = { + rootFrame: { id: 'page', name: 'Page', width: 1440, height: 900 }, + subtasks: [], + }; + + it('reads Sidebar Surface from catalog style guide content when present', () => { + const plan: OrchestratorPlan = { + ...basePlan, + selectedStyleGuideContent: '| Sidebar Surface | #1E293B |\n', + }; + expect(extractSidebarSurfaceColor(plan)).toBe('#1E293B'); + }); + + it('falls back to design.md sidebar role when no style guide content is set', () => { + const designMd: DesignMdSpec = { + raw: '# Spec', + colorPalette: [ + { name: 'Canvas', hex: '#0B1120', role: 'Page background' }, + { name: 'Rail', hex: '#1E293B', role: 'Sidebar navigation surface' }, + { name: 'Card', hex: '#111827', role: 'Card surface' }, + ], + }; + expect(extractSidebarSurfaceColor(basePlan, designMd)).toBe('#1E293B'); + }); + + it('falls back to a generic surface/card color when design.md has no sidebar role', () => { + const designMd: DesignMdSpec = { + raw: '# Spec', + colorPalette: [ + { name: 'Canvas', hex: '#0B1120', role: 'Page background' }, + { name: 'Card', hex: '#111827', role: 'Card surface' }, + ], + }; + expect(extractSidebarSurfaceColor(basePlan, designMd)).toBe('#111827'); + }); + + it('returns undefined when design.md provides no usable palette and no style guide is present', () => { + const designMd: DesignMdSpec = { + raw: '# Spec', + colorPalette: [{ name: 'Accent', hex: '#22C55E', role: 'CTA accent' }], + }; + expect(extractSidebarSurfaceColor(basePlan, designMd)).toBeUndefined(); + }); }); diff --git a/apps/web/src/services/ai/__tests__/orchestrator-prompt-optimizer.test.ts b/apps/web/src/services/ai/__tests__/orchestrator-prompt-optimizer.test.ts index af473e6b0..f93bbbe22 100644 --- a/apps/web/src/services/ai/__tests__/orchestrator-prompt-optimizer.test.ts +++ b/apps/web/src/services/ai/__tests__/orchestrator-prompt-optimizer.test.ts @@ -3,8 +3,10 @@ import { buildFallbackPlanFromPrompt, buildCompactPlanningPrompt, buildPlanningStyleGuideContext, + DESIGN_MD_STYLE_GUIDE_NAME, getBuiltinPlanningTimeouts, } from '../orchestrator-prompt-optimizer'; +import type { DesignMdSpec } from '@/types/design-md'; describe('buildPlanningStyleGuideContext', () => { it('lists the full guide catalog while limiting detailed snippets for basic models', () => { @@ -40,6 +42,91 @@ describe('buildPlanningStyleGuideContext', () => { expect(minimal.snippetGuideNames).toEqual([]); expect(minimal.availableStyleGuides).not.toContain('Detailed references'); }); + + it('steers the planning prompt away from brand colors when design.md has no explicit background role', () => { + const designMd: DesignMdSpec = { + raw: '# Test', + visualTheme: 'sleek dark cyberpunk dashboard', + colorPalette: [ + { name: 'Brand Coral', hex: '#FF5733', role: 'Primary CTA color' }, + { name: 'Ink', hex: '#1A1A1A', role: 'Body text' }, + ], + }; + + const ctx = buildPlanningStyleGuideContext( + 'design a dashboard', + 'claude-sonnet-4', + 'rich', + designMd, + ); + + // No explicit background role → don't hint a palette color; provide a + // neutral dark default and warn the model. + const bgLine = ctx.availableStyleGuides + .split('\n') + .find((line) => line.startsWith('- Set rootFrame.fill')); + expect(bgLine).toBeDefined(); + expect(bgLine).not.toContain('#FF5733'); + expect(bgLine).toContain('#111111'); + expect(ctx.availableStyleGuides).toContain('DO NOT pick a brand/CTA/accent/text color'); + }); + + it('calls out surface/sidebar colors so dashboard layouts keep their layered styling', () => { + const designMd: DesignMdSpec = { + raw: '# Test', + visualTheme: 'moody fintech dashboard', + colorPalette: [ + { name: 'Main Canvas', hex: '#0A0F1C', role: 'Primary app background' }, + { name: 'Sidebar Surface', hex: '#12182A', role: 'Sidebar surface' }, + { name: 'Card Surface', hex: '#1A1F2E', role: 'Card surface' }, + { name: 'Brand', hex: '#22C55E', role: 'CTA accent' }, + ], + }; + + const ctx = buildPlanningStyleGuideContext( + 'design a finance dashboard', + 'claude-sonnet-4', + 'rich', + designMd, + ); + + expect(ctx.availableStyleGuides).toContain('SURFACE COLORS'); + expect(ctx.availableStyleGuides).toContain('#12182A'); + expect(ctx.availableStyleGuides).toContain('#1A1F2E'); + // Page bg still resolves to the explicit Primary app background. + const bgLine = ctx.availableStyleGuides + .split('\n') + .find((line) => line.startsWith('- Set rootFrame.fill')); + expect(bgLine).toContain('#0A0F1C'); + }); + + it('skips the pre-built catalog when the user has a design.md and routes design.md content instead', () => { + const designMd: DesignMdSpec = { + raw: '# Test', + projectName: 'Test', + visualTheme: 'moody athletic night-mode dashboard', + colorPalette: [ + { name: 'Midnight Canvas', hex: '#111111', role: 'Primary app background' }, + { name: 'Vital Green', hex: '#22C55E', role: 'Active tab highlight' }, + ], + typography: { fontFamily: 'Inter' }, + }; + + const ctx = buildPlanningStyleGuideContext( + 'add a workouts screen', + 'claude-sonnet-4', + 'rich', + designMd, + ); + + expect(ctx.metadataCount).toBe(0); + expect(ctx.snippetCount).toBe(0); + expect(ctx.topGuideNames).toEqual([DESIGN_MD_STYLE_GUIDE_NAME]); + expect(ctx.availableStyleGuides).toContain('custom design system (design.md)'); + expect(ctx.availableStyleGuides).toContain(DESIGN_MD_STYLE_GUIDE_NAME); + expect(ctx.availableStyleGuides).toContain('#111111'); + expect(ctx.availableStyleGuides).not.toContain('Available style guides (compact catalog'); + }); }); describe('buildFallbackPlanFromPrompt', () => { @@ -50,6 +137,23 @@ describe('buildFallbackPlanFromPrompt', () => { expect(plan.subtasks[0]?.elements).toContain('Top-of-screen summary'); expect(plan.subtasks[1]?.elements).toContain('All remaining main UI content'); }); + + it('uses design.md background and style-guide name when designMd is present', () => { + const designMd: DesignMdSpec = { + raw: '# Test', + visualTheme: 'dark athletic', + colorPalette: [ + { name: 'Midnight Canvas', hex: '#111111', role: 'Primary app background' }, + { name: 'Accent', hex: '#22C55E', role: 'CTA accent' }, + ], + }; + + const plan = buildFallbackPlanFromPrompt('design a mobile wellness app home screen', designMd); + + expect(plan.styleGuideName).toBe(DESIGN_MD_STYLE_GUIDE_NAME); + expect(plan.selectedStyleGuideContent).toBeUndefined(); + expect(plan.rootFrame.fill?.[0]).toMatchObject({ type: 'solid', color: '#111111' }); + }); }); describe('getBuiltinPlanningTimeouts', () => { @@ -75,4 +179,28 @@ describe('buildCompactPlanningPrompt', () => { expect(compact.selectedStyleGuideName).toBeTruthy(); expect(compact.systemPrompt).not.toContain('Available style guides'); }); + + it('injects design.md policy and background into the compact prompt', () => { + const designMd: DesignMdSpec = { + raw: '# Test', + visualTheme: 'moody athletic night-mode dashboard', + colorPalette: [ + { name: 'Midnight Canvas', hex: '#111111', role: 'Primary app background' }, + { name: 'Vital Green', hex: '#22C55E', role: 'Active tab highlight' }, + ], + layoutPrinciples: 'Use 24px horizontal padding and 16-20px vertical gaps between cards.', + }; + + const compact = buildCompactPlanningPrompt( + 'design a mobile wellness home screen', + 'minimax-m2.7', + designMd, + ); + + expect(compact.selectedStyleGuideName).toBe(DESIGN_MD_STYLE_GUIDE_NAME); + expect(compact.systemPrompt).toContain(DESIGN_MD_STYLE_GUIDE_NAME); + expect(compact.systemPrompt).toContain('#111111'); + expect(compact.systemPrompt).toContain('USER DESIGN SYSTEM'); + expect(compact.systemPrompt).toContain('LAYOUT PRINCIPLES'); + }); }); diff --git a/apps/web/src/services/ai/__tests__/orchestrator-sub-agent.test.ts b/apps/web/src/services/ai/__tests__/orchestrator-sub-agent.test.ts index da5bb5d39..dfb9bfb4a 100644 --- a/apps/web/src/services/ai/__tests__/orchestrator-sub-agent.test.ts +++ b/apps/web/src/services/ai/__tests__/orchestrator-sub-agent.test.ts @@ -83,3 +83,45 @@ platform: mobile expect(summary).not.toContain('## Color System'); }); }); + +import { buildSubAgentUserPromptForTest } from '../orchestrator-sub-agent'; + +describe('buildSubAgentUserPrompt in append mode', () => { + const plan = { + rootFrame: { id: 'content-root', name: 'Page Content Root', width: 375, height: 0 }, + subtasks: [ + { + id: 'workout-cards', + label: 'Workout Type Cards', + region: { width: 375, height: 200 }, + idPrefix: 'workoutCards', + parentFrameId: 'content-root', + existingSectionLabels: ['Greeting Section', 'Activity Rings Section'], + }, + ], + }; + + it('injects APPEND MODE instructions and existing-section list', () => { + const prompt = buildSubAgentUserPromptForTest({ + subtask: plan.subtasks[0] as any, + plan: plan as any, + compactPrompt: 'Continue the fitness app', + fullPrompt: 'Continue the fitness app', + }); + expect(prompt).toMatch(/APPEND MODE/); + expect(prompt).toMatch(/Greeting Section/); + expect(prompt).toMatch(/Activity Rings Section/); + expect(prompt).toMatch(/do NOT.*status bar|no.{0,8}status bar/i); + expect(prompt).toMatch(/do NOT re-emit|off-limits/i); + }); + + it('does not inject APPEND MODE when existingSectionLabels is absent', () => { + const prompt = buildSubAgentUserPromptForTest({ + subtask: { ...(plan.subtasks[0] as any), existingSectionLabels: undefined }, + plan: plan as any, + compactPrompt: 'new landing page', + fullPrompt: 'new landing page', + }); + expect(prompt).not.toMatch(/APPEND MODE/); + }); +}); diff --git a/apps/web/src/services/ai/__tests__/role-resolver.test.ts b/apps/web/src/services/ai/__tests__/role-resolver.test.ts index d3255fd35..ed4db779f 100644 --- a/apps/web/src/services/ai/__tests__/role-resolver.test.ts +++ b/apps/web/src/services/ai/__tests__/role-resolver.test.ts @@ -1028,6 +1028,67 @@ describe('resolveTreePostPass — section background alternation', () => { expect((children[0] as any).fill).toBeUndefined(); expect((children[1] as any).fill).toBeUndefined(); }); + + // Regression guard for 2026-04-15: when design.md forces a dark rootFrame, + // the hardcoded #FFFFFF/#F8FAFC alternation painted visible white strips + // over the dark page background. On dark pages sections must stay + // transparent — internal card contrast already groups them visually. + it('does NOT alternate on a dark-themed parent (luminance < 0.5)', () => { + const children = [ + { + id: 's0', + type: 'frame' as const, + name: 'Hero', + x: 0, + y: 0, + width: 375, + height: 400, + role: 'hero', + layout: 'vertical' as const, + children: [], + }, + { + id: 's1', + type: 'frame' as const, + name: 'Stats', + x: 0, + y: 0, + width: 375, + height: 400, + role: 'stats-section', + layout: 'vertical' as const, + children: [], + }, + { + id: 's2', + type: 'frame' as const, + name: 'CTA', + x: 0, + y: 0, + width: 375, + height: 400, + role: 'cta-section', + layout: 'vertical' as const, + children: [], + }, + ] as PenNode[]; + const root: PenNode = { + id: 'root', + type: 'frame', + name: 'Root', + x: 0, + y: 0, + width: 375, + height: 1200, + layout: 'vertical', + fill: [{ type: 'solid', color: '#111111' }], + children, + } as PenNode; + resolveTreePostPass(root, 375); + expect((children[0] as any).fill).toBeUndefined(); + expect((children[1] as any).fill).toBeUndefined(); + expect((children[2] as any).fill).toBeUndefined(); + }); }); describe('resolveTreePostPass — orphan container contrast', () => { diff --git a/apps/web/src/services/ai/agent-tool-executor.ts b/apps/web/src/services/ai/agent-tool-executor.ts index b359b0186..987ec7c3e 100644 --- a/apps/web/src/services/ai/agent-tool-executor.ts +++ b/apps/web/src/services/ai/agent-tool-executor.ts @@ -1,6 +1,7 @@ import type { AgentEvent, ToolResult, AuthLevel } from '@/types/agent'; import type { PenNode } from '@/types/pen'; import { createEmptyDocument, DEFAULT_FRAME_ID, useDocumentStore } from '@/stores/document-store'; +import { detectAppendIntent } from './append-intent-detector'; type ToolCallEvent = Extract; @@ -228,6 +229,10 @@ export class AgentToolExecutor { progressStore.getState().setAgentOrchestrationSteps(text); }; + const activePageId = (await import('@/stores/canvas-store')).useCanvasStore.getState() + .activePageId; + const appendContext = detectAppendIntent(prompt, doc, activePageId); + let result: { nodes: unknown[] }; try { result = await generateDesign( @@ -242,6 +247,7 @@ export class AgentToolExecutor { variables: doc.variables, themes: doc.themes, designMd, + ...(appendContext ? { appendContext } : {}), }, }, { diff --git a/apps/web/src/services/ai/ai-prompts.ts b/apps/web/src/services/ai/ai-prompts.ts index 1e726f35f..b9dc9eef4 100644 --- a/apps/web/src/services/ai/ai-prompts.ts +++ b/apps/web/src/services/ai/ai-prompts.ts @@ -24,6 +24,23 @@ export function buildDesignMdStylePolicy(spec: DesignMdSpec): string { .map((c) => `${c.name} (${c.hex}) — ${c.role}`) .join('\n- '); parts.push(`COLOR PALETTE:\n- ${colors}`); + + // Call out surface/card/panel/sidebar colors explicitly. The page + // background uses the primary-background color, but cards, panels, and + // sidebars MUST use these surface colors — otherwise dashboards flatten + // into a single-color wash and lose their layered look. + const surfaces = spec.colorPalette.filter((c) => + /surface|card|panel|sidebar|tile|chip/i.test(c.role || ''), + ); + if (surfaces.length > 0) { + const surfaceLines = surfaces + .slice(0, 6) + .map((c) => `${c.name} (${c.hex}) — ${c.role}`) + .join('\n- '); + parts.push( + `SURFACE COLORS (use ONLY as \`fill\` on visually distinct components placed on top of the page background — cards, sidebars, floating panels, chips, badges. DO NOT fill section root frames or generic wrapper frames with these; section containers must stay transparent and inherit the page background. NEVER use these as the page/rootFrame fill):\n- ${surfaceLines}`, + ); + } } if (spec.typography?.fontFamily) { @@ -44,6 +61,22 @@ export function buildDesignMdStylePolicy(spec: DesignMdSpec): string { parts.push(`COMPONENT STYLES:\n${styles}`); } + if (spec.layoutPrinciples) { + const layout = + spec.layoutPrinciples.length > 400 + ? spec.layoutPrinciples.substring(0, 400) + '...' + : spec.layoutPrinciples; + parts.push(`LAYOUT PRINCIPLES:\n${layout}`); + } + + if (spec.generationNotes) { + const notes = + spec.generationNotes.length > 400 + ? spec.generationNotes.substring(0, 400) + '...' + : spec.generationNotes; + parts.push(`GENERATION NOTES:\n${notes}`); + } + return parts.join('\n\n'); } diff --git a/apps/web/src/services/ai/ai-types.ts b/apps/web/src/services/ai/ai-types.ts index b16108b14..c7251c46f 100644 --- a/apps/web/src/services/ai/ai-types.ts +++ b/apps/web/src/services/ai/ai-types.ts @@ -19,6 +19,23 @@ export interface ChatMessage { source?: string; } +/** + * Emitted by `detectAppendIntent()` when the user asks to *extend* an existing + * non-empty page rather than generate a fresh screen. When present, the + * orchestrator skips creating a new root frame and inserts generated sections + * into the existing content-root frame. + */ +export interface AppendContext { + /** ID of the frame that new sub-agent sections should be inserted into. */ + targetParentId: string; + /** Width of the target parent (used to size sub-agent regions). */ + targetWidth: number; + /** Labels of existing top-level sections — sub-agents are told not to duplicate these. */ + existingSectionLabels: string[]; + /** True when `targetParentId` belongs to a mobile page (width <= 480). */ + isMobile: boolean; +} + export interface AIDesignRequest { prompt: string; model?: string; @@ -31,6 +48,7 @@ export interface AIDesignRequest { variables?: Record; themes?: Record; designMd?: DesignMdSpec; + appendContext?: AppendContext; }; } @@ -112,6 +130,8 @@ export interface SubTask { htmlReference?: string; /** Actual generated root node ID for this subtask (captured at runtime) */ generatedRootId?: string; + /** Propagated from AppendContext so the sub-agent prompt can avoid duplicates. */ + existingSectionLabels?: string[]; } /** Style guide produced by the orchestrator for visual consistency */ diff --git a/apps/web/src/services/ai/append-intent-detector.ts b/apps/web/src/services/ai/append-intent-detector.ts new file mode 100644 index 000000000..6dda5fca3 --- /dev/null +++ b/apps/web/src/services/ai/append-intent-detector.ts @@ -0,0 +1,109 @@ +import type { PenDocument, PenNode, FrameNode } from '@/types/pen'; +import type { AppendContext } from './ai-types'; + +const APPEND_EN = + /\b(continue|continuing|append|also add|add (?:another|more|a new)\s+section|one more|next section|add to the)\b/i; +const APPEND_CJK = + /(继续|接着|再加|再加一个|再添加|再生成|再来一个|补充|追加|加一个.{0,6}(区块|栏|模块|section|段))/; + +const NEW_SCREEN_EN = + /\b(new (?:page|screen|design|mockup)|another (?:page|screen|design|mockup)|from scratch|brand new)\b/i; +const NEW_SCREEN_CJK = /(新页面|新屏|新设计|从零|全新|另起|另外一页)/; + +const STATUS_BAR_RE = /(status[\s_-]*bar|system[\s_-]*chrome|状态栏|系统栏)/i; + +function isFrame(node: PenNode): node is FrameNode { + return node.type === 'frame'; +} + +function isStatusBarLike(frame: FrameNode): boolean { + return STATUS_BAR_RE.test(`${frame.name ?? ''} ${frame.id ?? ''}`); +} + +function pickContentRoot(page: FrameNode): { target: FrameNode; sectionLabels: string[] } { + const children = ( + 'children' in page && Array.isArray(page.children) ? page.children : [] + ) as PenNode[]; + const frames = children.filter(isFrame); + const contentFrames = frames.filter((f) => !isStatusBarLike(f)); + + const CONTENT_NAME = /\b(content|main|body|root)\b/i; + const contentCandidate = contentFrames.find((f) => CONTENT_NAME.test(f.name ?? '')); + if (contentCandidate) { + const grand = ( + 'children' in contentCandidate && Array.isArray(contentCandidate.children) + ? contentCandidate.children + : [] + ) as PenNode[]; + return { + target: contentCandidate, + sectionLabels: grand + .filter(isFrame) + .filter((f) => !isStatusBarLike(f)) + .map((c) => c.name ?? c.id), + }; + } + + return { + target: page, + sectionLabels: contentFrames.map((c) => c.name ?? c.id), + }; +} + +function pickActivePageFrame(doc: PenDocument, activePageId: string | null): FrameNode | null { + const pages = (doc as unknown as { pages?: Array<{ id: string; children: PenNode[] }> }).pages; + if (pages && pages.length > 0) { + // Each page entry has an id and children (the top-level frames on that page). + // If activePageId matches a frame inside any page's children, use that frame directly. + if (activePageId) { + for (const page of pages) { + const matchingFrame = page.children.find((n) => isFrame(n) && n.id === activePageId); + if (matchingFrame && isFrame(matchingFrame)) return matchingFrame; + } + } + // Fall back: use active page entry's first frame + const active = (activePageId && pages.find((p) => p.id === activePageId)) || pages[0]; + const firstFrame = active.children.find(isFrame); + if (firstFrame) return firstFrame; + return null; + } + // No pages array: look among top-level children frames + const topFrames = (doc.children ?? []).filter(isFrame); + if (activePageId) { + const match = topFrames.find((f) => f.id === activePageId); + if (match) return match; + } + return topFrames[0] ?? null; +} + +export function detectAppendIntent( + prompt: string, + doc: PenDocument, + activePageId: string | null, +): AppendContext | null { + if (!prompt || prompt.trim().length === 0) return null; + + const hasAppendKeyword = APPEND_EN.test(prompt) || APPEND_CJK.test(prompt); + if (!hasAppendKeyword) return null; + + if (NEW_SCREEN_EN.test(prompt) || NEW_SCREEN_CJK.test(prompt)) return null; + + const pageFrame = pickActivePageFrame(doc, activePageId); + if (!pageFrame) return null; + + const pageHasContent = + 'children' in pageFrame && + Array.isArray(pageFrame.children) && + pageFrame.children.some((c) => isFrame(c) && !isStatusBarLike(c)); + if (!pageHasContent) return null; + + const { target, sectionLabels } = pickContentRoot(pageFrame); + const width = typeof pageFrame.width === 'number' ? pageFrame.width : 375; + + return { + targetParentId: target.id, + targetWidth: typeof target.width === 'number' ? target.width : width, + existingSectionLabels: sectionLabels, + isMobile: width <= 480, + }; +} diff --git a/apps/web/src/services/ai/code-generation-pipeline.ts b/apps/web/src/services/ai/code-generation-pipeline.ts index 92a548fdb..cf7779c02 100644 --- a/apps/web/src/services/ai/code-generation-pipeline.ts +++ b/apps/web/src/services/ai/code-generation-pipeline.ts @@ -339,7 +339,7 @@ export async function generateCode( error: validation.issues.join('; ') || 'Chunk contract validation failed', }); } - } catch (err) { + } catch { // Retry once try { const assetHints = collectChunkAssetHints(chunk.nodes, assets); diff --git a/apps/web/src/services/ai/design-canvas-ops.ts b/apps/web/src/services/ai/design-canvas-ops.ts index a54b8ea26..79185b445 100644 --- a/apps/web/src/services/ai/design-canvas-ops.ts +++ b/apps/web/src/services/ai/design-canvas-ops.ts @@ -123,6 +123,11 @@ export function getGenerationRootFrameId(): string { return generationRootFrameId; } +/** Override the root frame ID — used by append-mode to reuse an existing page frame. */ +export function setGenerationRootFrameId(id: string): void { + generationRootFrameId = id; +} + /** Expose the current remapped IDs map for use by other modules (read-only). */ export function getGenerationRemappedIds(): Map { return generationRemappedIds; diff --git a/apps/web/src/services/ai/orchestrator-append.ts b/apps/web/src/services/ai/orchestrator-append.ts new file mode 100644 index 000000000..cff3cca70 --- /dev/null +++ b/apps/web/src/services/ai/orchestrator-append.ts @@ -0,0 +1,32 @@ +import type { AppendContext, OrchestratorPlan } from './ai-types'; + +const STATUS_BAR_SUBTASK_RE = /(status\s*bar|status_bar|status-bar|system chrome|系统栏|状态栏)/i; + +export interface AppendPlanResult { + plan: OrchestratorPlan; + skipRootInsertion: boolean; + skipStatusBar: boolean; +} + +/** + * Mutates/returns the plan according to an AppendContext: + * - Repoints `rootFrame.id` to `targetParentId` so sub-agent sections are + * inserted as children of the existing page content root. + * - Drops any planner-emitted status-bar subtasks (existing page already has one). + * - Carries `existingSectionLabels` through to each remaining subtask so the + * sub-agent prompt can instruct the model not to regenerate them. + */ +export function applyAppendContextToPlan( + plan: OrchestratorPlan, + append: AppendContext | undefined, +): AppendPlanResult { + if (!append) { + return { plan, skipRootInsertion: false, skipStatusBar: false }; + } + plan.rootFrame.id = append.targetParentId; + plan.rootFrame.width = append.targetWidth; + plan.subtasks = plan.subtasks + .filter((st) => !STATUS_BAR_SUBTASK_RE.test(`${st.id} ${st.label}`)) + .map((st) => ({ ...st, existingSectionLabels: append.existingSectionLabels })); + return { plan, skipRootInsertion: true, skipStatusBar: true }; +} diff --git a/apps/web/src/services/ai/orchestrator-planning.ts b/apps/web/src/services/ai/orchestrator-planning.ts index 6993e4a4c..c7674c0cc 100644 --- a/apps/web/src/services/ai/orchestrator-planning.ts +++ b/apps/web/src/services/ai/orchestrator-planning.ts @@ -1,6 +1,12 @@ import type { OrchestratorPlan, StyleGuide, SubTask } from './ai-types'; +import type { DesignMdSpec } from '@/types/design-md'; import { detectDesignType } from './design-type-presets'; -import { buildFallbackPlanFromPrompt } from './orchestrator-prompt-optimizer'; +import { + buildFallbackPlanFromPrompt, + DESIGN_MD_STYLE_GUIDE_NAME, + guessNeutralBackgroundFromTheme, + inferDesignMdBackground, +} from './orchestrator-prompt-optimizer'; export function filterPlanningSkillsForPrompt( skills: T[], @@ -14,22 +20,23 @@ export function filterPlanningSkillsForPrompt= 0 && lastBrace > firstBrace) { const bracedText = trimmed.slice(firstBrace, lastBrace + 1); - const braced = tryParsePlan(bracedText); + const braced = tryParsePlan(bracedText, designMd); if (braced) return { plan: braced, repaired: false }; - const repairedBraced = tryRepairPlan(bracedText, prompt); + const repairedBraced = tryRepairPlan(bracedText, prompt, designMd); if (repairedBraced) return { plan: repairedBraced, repaired: true }; } return null; } -function tryParsePlan(text: string): OrchestratorPlan | null { +function tryParsePlan(text: string, designMd?: DesignMdSpec): OrchestratorPlan | null { try { const obj = JSON.parse(text) as Record; if (!obj.rootFrame || typeof obj.rootFrame !== 'object') return null; @@ -60,29 +67,40 @@ function tryParsePlan(text: string): OrchestratorPlan | null { if (!st.id || !st.region) return null; } - return finalizePlan(obj as unknown as OrchestratorPlan, obj); + return finalizePlan(obj as unknown as OrchestratorPlan, obj, designMd); } catch { return null; } } -function tryRepairPlan(text: string, prompt: string): OrchestratorPlan | null { +function tryRepairPlan( + text: string, + prompt: string, + designMd?: DesignMdSpec, +): OrchestratorPlan | null { try { const obj = JSON.parse(text) as Record; - return repairPlanObject(obj, prompt); + return repairPlanObject(obj, prompt, designMd); } catch { return null; } } -function repairPlanObject(obj: Record, prompt: string): OrchestratorPlan | null { - const fallback = buildFallbackPlanFromPrompt(prompt); +function repairPlanObject( + obj: Record, + prompt: string, + designMd?: DesignMdSpec, +): OrchestratorPlan | null { + const fallback = buildFallbackPlanFromPrompt(prompt, designMd); const rawSubtasks = extractSubtaskCandidates(obj); if (rawSubtasks.length === 0) return null; const rootSource = isRecord(obj.rootFrame) ? obj.rootFrame : obj; const fallbackHeights = buildFallbackHeights(fallback, rawSubtasks.length); + // Shape the rootFrame first — finalizePlan will re-stamp rootFrame.fill + // below when design.md is present, so we don't need to special-case the fill + // here. For non-design.md flows, honor the model's fill when coercible. const rootFrame: OrchestratorPlan['rootFrame'] = { id: asString(rootSource.id) ?? fallback.rootFrame.id, name: asString(rootSource.name) ?? fallback.rootFrame.name, @@ -103,19 +121,43 @@ function repairPlanObject(obj: Record, prompt: string): Orchest const repaired: OrchestratorPlan = { rootFrame, - styleGuideName: - asString(obj.styleGuideName) ?? asString(obj.style_guide) ?? fallback.styleGuideName, + // When design.md is present, force the design-md style guide name so a + // stale catalog name left over from invalid planner JSON can't leak into + // downstream sub-agent prompts. + styleGuideName: designMd + ? DESIGN_MD_STYLE_GUIDE_NAME + : (asString(obj.styleGuideName) ?? asString(obj.style_guide) ?? fallback.styleGuideName), subtasks, }; - if (isRecord(obj.styleGuide)) { + if (!designMd && isRecord(obj.styleGuide)) { repaired.styleGuide = obj.styleGuide as unknown as StyleGuide; } - return finalizePlan(repaired, obj); + return finalizePlan(repaired, obj, designMd); } -function finalizePlan(plan: OrchestratorPlan, rawObj?: Record): OrchestratorPlan { +function finalizePlan( + plan: OrchestratorPlan, + rawObj?: Record, + designMd?: DesignMdSpec, +): OrchestratorPlan { + // design.md always wins: strip any catalog styleGuideName/styleGuide the + // model might have emitted, force the root background to the design.md + // primary palette color (or a neutral default biased by visualTheme when no + // palette entry is tagged as background — DO NOT trust the model's own + // rootFrame.fill here because it often lifts a brand/CTA color from the + // palette), and leave plan.styleGuide unset so downstream consumers fall + // back to the buildDesignMdStylePolicy path. + if (designMd) { + plan.styleGuideName = DESIGN_MD_STYLE_GUIDE_NAME; + plan.styleGuide = undefined; + const bg = + inferDesignMdBackground(designMd) ?? guessNeutralBackgroundFromTheme(designMd.visualTheme); + plan.rootFrame.fill = [{ type: 'solid', color: bg }]; + return plan; + } + if (!plan.styleGuide && rawObj && isRecord(rawObj.styleGuide)) { plan.styleGuide = rawObj.styleGuide as unknown as StyleGuide; } diff --git a/apps/web/src/services/ai/orchestrator-prompt-optimizer.ts b/apps/web/src/services/ai/orchestrator-prompt-optimizer.ts index aef30c8ac..980f06d9b 100644 --- a/apps/web/src/services/ai/orchestrator-prompt-optimizer.ts +++ b/apps/web/src/services/ai/orchestrator-prompt-optimizer.ts @@ -1,4 +1,5 @@ import type { OrchestratorPlan } from './ai-types'; +import type { DesignMdSpec } from '@/types/design-md'; import { ORCHESTRATOR_TIMEOUT_PROFILES, PROMPT_TIMEOUT_BUCKETS, @@ -10,6 +11,9 @@ import { getSkillByName } from '@zseven-w/pen-ai-skills'; import { extractStyleGuideValues, selectStyleGuide } from '@zseven-w/pen-ai-skills/style-guide'; import { styleGuideRegistry } from '@zseven-w/pen-ai-skills/_generated/style-guide-registry'; import { resolveModelProfile, applyProfileToTimeouts, type ModelTier } from './model-profiles'; +import { buildDesignMdStylePolicy } from './ai-prompts'; + +export const DESIGN_MD_STYLE_GUIDE_NAME = 'design-md-custom'; export interface PreparedDesignPrompt { original: string; @@ -122,17 +126,30 @@ export function prepareDesignPrompt(prompt: string): PreparedDesignPrompt { }; } -export function buildFallbackPlanFromPrompt(prompt: string): OrchestratorPlan { +export function buildFallbackPlanFromPrompt( + prompt: string, + designMd?: DesignMdSpec, +): OrchestratorPlan { const preset = detectDesignType(prompt); - // Try to select a style guide based on prompt keywords + // If the user has a design.md, seed the fallback plan from it so basic-tier + // models that fall through to this path still honor the user's custom theme. + const designMdBg = designMd ? inferDesignMdBackground(designMd) : null; + + // Try to select a style guide based on prompt keywords (only when no design.md) const platform = preset.width <= 500 ? 'mobile' : 'webapp'; const tags = inferTagsFromPrompt(prompt); - const guide = selectStyleGuide(styleGuideRegistry, { tags, platform }); + const guide = designMd ? null : selectStyleGuide(styleGuideRegistry, { tags, platform }); - // Extract background color from selected guide, or use default - let bgColor = '#FFFFFF'; - if (guide) { + // Extract background color from selected guide, or use default. + // When design.md is present but has no palette entry tagged as background, + // fall back to a neutral color derived from visualTheme rather than the + // catalog — picking any palette color here risks painting the page with a + // brand/CTA/accent color. + let bgColor = designMd ? guessNeutralBackgroundFromTheme(designMd.visualTheme) : '#FFFFFF'; + if (designMdBg) { + bgColor = designMdBg; + } else if (guide) { const bgMatch = guide.content.match(/(#[0-9A-Fa-f]{6})\s*[—–-]\s*(?:Page )?Background/i) ?? guide.content.match(/Background[^#]*(#[0-9A-Fa-f]{6})/i); @@ -175,8 +192,12 @@ export function buildFallbackPlanFromPrompt(prompt: string): OrchestratorPlan { })), }; - // Attach selected style guide for downstream injection - if (guide) { + // Attach selected style guide for downstream injection. + // When design.md is present, we intentionally skip catalog content so it + // doesn't override the user's custom design system downstream. + if (designMd) { + plan.styleGuideName = DESIGN_MD_STYLE_GUIDE_NAME; + } else if (guide) { plan.styleGuideName = guide.name; plan.selectedStyleGuideContent = guide.content; } @@ -218,7 +239,37 @@ export function buildPlanningStyleGuideContext( prompt: string, model?: string, mode: PlanningContextMode = 'rich', + designMd?: DesignMdSpec, ): PlanningStyleGuideContext { + if (designMd) { + const policy = buildDesignMdStylePolicy(designMd); + const bgHint = inferDesignMdBackground(designMd); + // When design.md has no palette entry explicitly marked as background/ + // surface/canvas, do NOT ask the model to "pick" from the palette — it will + // happily pick a brand/CTA color and paint the whole page that color. Give + // it a neutral default instead, biased by visualTheme keywords. + const neutralDefault = guessNeutralBackgroundFromTheme(designMd.visualTheme); + const lines = [ + `The user has a custom design system (design.md). DO NOT pick a style guide from a catalog.`, + `Use the rules below for all style decisions:`, + '', + policy || '(design.md is present but has no extractable policy; use project defaults)', + '', + `Output directives:`, + `- Set "styleGuideName": "${DESIGN_MD_STYLE_GUIDE_NAME}" (exact string).`, + bgHint + ? `- Set rootFrame.fill color to "${bgHint}" (the primary background color from the design.md palette).` + : `- Set rootFrame.fill color to "${neutralDefault}" (neutral page background — design.md has no palette entry tagged as background, so DO NOT pick a brand/CTA/accent/text color from the palette for the page background).`, + ]; + return { + availableStyleGuides: lines.join('\n'), + metadataCount: 0, + snippetCount: 0, + topGuideNames: [DESIGN_MD_STYLE_GUIDE_NAME], + snippetGuideNames: [], + }; + } + const preset = detectDesignType(prompt); const platform = preset.width <= 500 ? 'mobile' : 'webapp'; const tags = inferTagsFromPrompt(prompt); @@ -251,14 +302,69 @@ export function buildPlanningStyleGuideContext( }; } -export function buildCompactPlanningPrompt(prompt: string, _model?: string): CompactPlanningPrompt { +/** + * Pick the color in design.md most likely to be the root/app background. + * Returns null when no palette entry has a role that clearly marks it as a + * background — guessing at the first palette color is dangerous because it is + * often a brand/accent color, which would turn the entire page that color. + */ +/** + * Pick a safe neutral page background when design.md lacks an explicit + * background role. Biases toward dark when the visual theme hints at dark mode, + * otherwise defaults to a light page color. + */ +export function guessNeutralBackgroundFromTheme(theme?: string): string { + if (!theme) return '#FFFFFF'; + if (/\b(dark|night|noir|cyber|neon|terminal|midnight|obsidian|onyx)\b/i.test(theme)) { + return '#111111'; + } + return '#FFFFFF'; +} + +export function inferDesignMdBackground(spec: DesignMdSpec): string | null { + const palette = spec.colorPalette; + if (!palette?.length) return null; + const scoreRole = (role: string): number => { + const r = role.toLowerCase(); + // A surface/card role is NOT a page background — e.g. dark-mode palettes + // often use #0A0F1C for the page and #1A1F2E for cards, and painting the + // whole page with the card color produces a flat result that hides the + // card boundaries. Only accept roles that explicitly describe a page/app + // background or the root canvas. + if (/primary.*background|app background|main background|page background|canvas/.test(r)) { + return 3; + } + if (/\bbackground\b/.test(r) && !/surface|card|tile|chip|panel/.test(r)) return 2; + return 0; + }; + let best: (typeof palette)[number] | null = null; + let bestScore = 0; + for (const c of palette) { + const s = scoreRole(c.role || ''); + if (s > bestScore) { + best = c; + bestScore = s; + } + } + return best ? best.hex : null; +} + +export function buildCompactPlanningPrompt( + prompt: string, + _model?: string, + designMd?: DesignMdSpec, +): CompactPlanningPrompt { const preset = detectDesignType(prompt); const platform = preset.width <= 500 ? 'mobile' : 'webapp'; const tags = inferTagsFromPrompt(prompt); - const selectedGuide = selectStyleGuide(styleGuideRegistry, { tags, platform }); + const designMdBg = designMd ? inferDesignMdBackground(designMd) : null; + const selectedGuide = designMd ? null : selectStyleGuide(styleGuideRegistry, { tags, platform }); const guideValues = selectedGuide ? extractStyleGuideValues(selectedGuide.content) : null; const backgroundColor = - guideValues?.colors.background ?? (preset.type === 'mobile-screen' ? '#111827' : '#F8FAFC'); + designMdBg ?? + (designMd ? guessNeutralBackgroundFromTheme(designMd.visualTheme) : null) ?? + guideValues?.colors.background ?? + (preset.type === 'mobile-screen' ? '#111827' : '#F8FAFC'); const defaultGap = preset.type === 'mobile-screen' || preset.type === 'desktop-screen' ? 20 : 0; const subtaskHint = preset.type === 'mobile-screen' @@ -274,9 +380,20 @@ export function buildCompactPlanningPrompt(prompt: string, _model?: string): Com 'Use width=375 and height=812 on the root frame.', ] : ['Use width=1200 and height=0 on the root frame.']; - const styleRule = selectedGuide - ? `Use styleGuideName="${selectedGuide.name}" and rootFrame background ${backgroundColor}.` - : `Pick a suitable styleGuideName for platform=${platform} and set rootFrame background to ${backgroundColor}.`; + const styleRule = designMd + ? `Use styleGuideName="${DESIGN_MD_STYLE_GUIDE_NAME}" and rootFrame background ${backgroundColor} (from the user's design.md — overrides any catalog default).` + : selectedGuide + ? `Use styleGuideName="${selectedGuide.name}" and rootFrame background ${backgroundColor}.` + : `Pick a suitable styleGuideName for platform=${platform} and set rootFrame background to ${backgroundColor}.`; + + const designMdPolicy = designMd ? buildDesignMdStylePolicy(designMd) : ''; + const designMdBlock = designMdPolicy + ? [ + '', + 'USER DESIGN SYSTEM (design.md — follow these EXACTLY; they OVERRIDE any default):', + designMdPolicy, + ] + : []; return { systemPrompt: [ @@ -290,9 +407,10 @@ export function buildCompactPlanningPrompt(prompt: string, _model?: string): Com styleRule, ...mobileRules, `Always set rootFrame layout="vertical" and gap=${defaultGap}.`, + ...designMdBlock, ].join('\n'), userPrompt: prompt, - selectedStyleGuideName: selectedGuide?.name, + selectedStyleGuideName: designMd ? DESIGN_MD_STYLE_GUIDE_NAME : selectedGuide?.name, }; } diff --git a/apps/web/src/services/ai/orchestrator-sidebar-color.ts b/apps/web/src/services/ai/orchestrator-sidebar-color.ts new file mode 100644 index 000000000..d2c2fe3e6 --- /dev/null +++ b/apps/web/src/services/ai/orchestrator-sidebar-color.ts @@ -0,0 +1,39 @@ +import type { OrchestratorPlan } from './ai-types'; +import type { DesignMdSpec } from '@/types/design-md'; + +/** + * Picks a color for the pre-built dashboard sidebar frame. + * + * Precedence: + * 1. "Sidebar Surface" cell from a catalog style guide (legacy path). + * 2. design.md palette role matching sidebar → panel → surface/card. + * 3. undefined — caller falls back to rootFrame fill or a neutral default. + * + * The design.md path is what keeps dashboards layered when the user + * supplies their own spec (which deliberately skips the catalog lookup). + */ +export function extractSidebarSurfaceColor( + plan: OrchestratorPlan, + designMd?: DesignMdSpec, +): string | undefined { + const content = plan.selectedStyleGuideContent; + if (content) { + const tableMatch = content.match(/Sidebar Surface\s*\|\s*(#[0-9A-Fa-f]{6})/i); + if (tableMatch) return tableMatch[1].toUpperCase(); + + const inlineMatch = content.match(/Sidebar Surface[^#]*(#[0-9A-Fa-f]{6})/i); + if (inlineMatch) return inlineMatch[1].toUpperCase(); + } + + const palette = designMd?.colorPalette; + if (palette?.length) { + const bySidebar = palette.find((c) => /sidebar/i.test(c.role || '')); + if (bySidebar?.hex) return bySidebar.hex.toUpperCase(); + const byPanel = palette.find((c) => /panel/i.test(c.role || '')); + if (byPanel?.hex) return byPanel.hex.toUpperCase(); + const bySurface = palette.find((c) => /surface|card/i.test(c.role || '')); + if (bySurface?.hex) return bySurface.hex.toUpperCase(); + } + + return undefined; +} diff --git a/apps/web/src/services/ai/orchestrator-sub-agent.ts b/apps/web/src/services/ai/orchestrator-sub-agent.ts index 567d15b3c..4fd6aaf15 100644 --- a/apps/web/src/services/ai/orchestrator-sub-agent.ts +++ b/apps/web/src/services/ai/orchestrator-sub-agent.ts @@ -33,6 +33,7 @@ import { } from './design-generator'; import { emitProgress } from './orchestrator-progress'; import { StreamingDesignRenderer } from './streaming-design-renderer'; +import { buildDesignMdStylePolicy } from './ai-prompts'; export { ensureIdPrefix, ensurePrefixStr } from './streaming-design-renderer'; @@ -318,17 +319,28 @@ async function executeSubAgent( const variables = request.context?.variables; const modelProfile = resolveModelProfile(request.model); const isMobileScreen = plan.rootFrame.width <= 480; + + // Build design.md payload for the skill template. If the structured summary + // is empty (a bare-minimum design.md with only free-form text), fall back to + // the raw markdown source so the sub-agent still sees the user's spec. + let designMdContent = ''; + if (designMd) { + const structured = buildDesignMdStylePolicy(designMd).trim(); + designMdContent = structured || designMd.raw.trim(); + } + const hasDesignMdContent = designMdContent.length > 0; + const genCtx = resolveSkills('generation', request.prompt, { flags: { hasVariables: !!variables && Object.keys(variables).length > 0, - hasDesignMd: !!designMd, + hasDesignMd: hasDesignMdContent, isBasicTier: modelProfile.tier === 'basic', // style-defaults.md only loads when no style direction exists at all: // - no pre-built style guide selected - // - no design.md present (even without colorPalette, design.md provides visual direction) - noStyleGuideMatch: !plan.selectedStyleGuideContent && !designMd, + // - no usable design.md content (empty raw + empty structured summary) + noStyleGuideMatch: !plan.selectedStyleGuideContent && !hasDesignMdContent, }, - dynamicContent: designMd ? { designMdContent: JSON.stringify(designMd) } : undefined, + dynamicContent: hasDesignMdContent ? { designMdContent } : undefined, budgetOverride: modelProfile.tier === 'basic' ? 5200 : modelProfile.tier === 'standard' ? 6500 : undefined, }); @@ -527,6 +539,12 @@ function buildSubAgentUserPrompt( ? `The page root frame already has background color ${rootBgColor} — your section inherits it.` : `The page root frame already carries the background color — your section inherits it.`; + // When the user has a design.md, any numeric padding/spacing hint below must + // defer to design.md. Otherwise use the legacy desktop default. + const paddingHint = designMd + ? `Use padding/spacing that matches the design.md "LAYOUT PRINCIPLES" and "COMPONENT STYLES" blocks below — those numbers OVERRIDE any generic defaults in these layout constraints.` + : `Use padding=[0,80] for horizontal page margins.`; + let prompt = `Page sections:\n${sectionList}\n\nGenerate ONLY "${subtask.label}" (~${region.height}px of content).${myElements}\n${compactPrompt} CRITICAL LAYOUT CONSTRAINTS: @@ -535,7 +553,7 @@ CRITICAL LAYOUT CONSTRAINTS: - ALL nodes must be descendants of the root frame. No floating/orphan nodes. - NEVER set x or y on children inside layout frames. - Use "fill_container" for children that stretch, "fit_content" for shrink-wrap sizing. -- Use justifyContent="space_between" to distribute items (e.g. navbar: logo | links | CTA). Use padding=[0,80] for horizontal page margins. +- Use justifyContent="space_between" to distribute items (e.g. navbar: logo | links | CTA). ${paddingHint} - For side-by-side layouts, nest a horizontal frame with child frames using "fill_container" width. - SECTION BACKGROUND: do NOT set \`fill\` on your section root frame. ${rootBgHint} Hardcoding a "safe dark" fill (e.g. #000 / #0A0A0A / #111) will cover the intended background and break theme switching. Only set \`fill\` on cards, buttons, chips, badges, and other visually distinct components — never on the section container itself. - IDs prefix="${subtask.idPrefix}-". No tags. Output \`\`\`json immediately.`; @@ -558,6 +576,17 @@ CRITICAL LAYOUT CONSTRAINTS: prompt += `\n\nNO PHONE MOCKUP WRAPPER: The whole design IS a mobile screen. Do NOT wrap your section in a phone-shaped frame (cornerRadius 32 dark bezel, fixed 260-300px width, name "Phone Mockup"). Your section's root frame must use width="fill_container" and contain only the content that belongs to this section — never the entire app's children.`; } + if (subtask.existingSectionLabels && subtask.existingSectionLabels.length > 0) { + const existing = subtask.existingSectionLabels.map((n) => `"${n}"`).join(', '); + prompt += `\n\nAPPEND MODE: The page already contains these sibling sections (read-only, already on canvas): ${existing}. +- Your root frame will be inserted as a NEW sibling at the end of that list. +- Do NOT re-emit any of the sections listed above. Do NOT emit any status bar or system chrome — that is also already on the page. +- Do NOT wrap your output in a phone mockup or a full-page container. +- Internal headings/titles within YOUR new section are fine — only the top-level sibling sections above are off-limits. +- Match the visual style (colors, cornerRadius, padding, gap) already established by those existing siblings. +- Output ONLY this one new section — a single root frame with its content.`; + } + if (needsNativeDenseCardInstruction(subtask.label, compactPrompt, fullPrompt)) { prompt += `\n\nNATIVE DENSE-CARD MODE (must be solved during generation): - If you create a horizontal row with 5+ cards (or cards become narrow), compact each card natively BEFORE output. @@ -582,17 +611,13 @@ CRITICAL LAYOUT CONSTRAINTS: } // Style guide injection precedence: - // 1. designMd color palette (user's own design system) — highest + // 1. designMd (user's own design system) — highest, OVERRIDES everything else // 2. Selected pre-built style guide content — middle // 3. AI-generated styleGuide from planning (existing fallback) — lowest - if (designMd?.colorPalette?.length) { - const colors = designMd.colorPalette - .slice(0, 8) - .map((c) => `${c.name} (${c.hex}) — ${c.role}`) - .join('\n- '); - prompt += `\n\nDESIGN SYSTEM (from design.md — use these consistently):\n- ${colors}`; - if (designMd.typography?.fontFamily) { - prompt += `\nFont: ${designMd.typography.fontFamily}`; + if (designMd) { + const policy = buildDesignMdStylePolicy(designMd); + if (policy) { + prompt += `\n\nDESIGN SYSTEM (from design.md — follow these EXACTLY; they OVERRIDE any other style guide, default padding, or component convention):\n${policy}`; } } else if (plan.selectedStyleGuideContent) { prompt += `\n\n${buildSubAgentStyleGuideInstruction( @@ -716,3 +741,26 @@ function needsPhoneMockupInstruction( text, ); } + +/** Test-only entry point so unit tests don't have to stand up real model calls. */ +export function buildSubAgentUserPromptForTest(args: { + subtask: SubTask; + plan: OrchestratorPlan; + compactPrompt: string; + fullPrompt: string; + modelId?: string; + variables?: Record; + themes?: Record; + designMd?: DesignMdSpec; +}): string { + return buildSubAgentUserPrompt( + args.subtask, + args.plan, + args.compactPrompt, + args.fullPrompt, + args.modelId, + args.variables, + args.themes, + args.designMd, + ); +} diff --git a/apps/web/src/services/ai/orchestrator.ts b/apps/web/src/services/ai/orchestrator.ts index aec187f19..29ab5a615 100644 --- a/apps/web/src/services/ai/orchestrator.ts +++ b/apps/web/src/services/ai/orchestrator.ts @@ -18,6 +18,7 @@ import type { OrchestrationProgress, SubAgentResult, } from './ai-types'; +import type { DesignMdSpec } from '@/types/design-md'; import { streamChat } from './ai-service'; import { resolveSkills } from '@zseven-w/pen-ai-skills'; import { styleGuideRegistry } from '@zseven-w/pen-ai-skills/_generated/style-guide-registry'; @@ -29,6 +30,7 @@ import { buildPlanningStyleGuideContext, buildCompactPlanningPrompt, getBuiltinPlanningTimeouts, + DESIGN_MD_STYLE_GUIDE_NAME, } from './orchestrator-prompt-optimizer'; import { adjustRootFrameHeightToContent, @@ -39,6 +41,7 @@ import { getGenerationRemappedIds, getGenerationRootFrameId, } from './design-generator'; +import { setGenerationRootFrameId } from './design-canvas-ops'; import { useDocumentStore, DEFAULT_FRAME_ID, createEmptyDocument } from '@/stores/document-store'; import { useHistoryStore } from '@/stores/history-store'; import { zoomToFitContent } from '@/canvas/skia-engine-ref'; @@ -53,6 +56,7 @@ import { addAgentFrame, clearAgentIndicators } from '@/canvas/agent-indicator'; import { createMobileStatusBar, inferStatusBarVariant } from './mobile-status-bar'; import { resolveModelProfile } from './model-profiles'; import { filterPlanningSkillsForPrompt, parseOrchestratorResponse } from './orchestrator-planning'; +import { extractSidebarSurfaceColor } from './orchestrator-sidebar-color'; // --------------------------------------------------------------------------- // Public API @@ -212,28 +216,16 @@ function normalizeOrchestratorPlan(plan: OrchestratorPlan, prompt: string): void } } -function extractSidebarSurfaceColor(plan: OrchestratorPlan): string | undefined { - const content = plan.selectedStyleGuideContent; - if (!content) return undefined; - - const tableMatch = content.match(/Sidebar Surface\s*\|\s*(#[0-9A-Fa-f]{6})/i); - if (tableMatch) return tableMatch[1].toUpperCase(); - - const inlineMatch = content.match(/Sidebar Surface[^#]*(#[0-9A-Fa-f]{6})/i); - if (inlineMatch) return inlineMatch[1].toUpperCase(); - - return undefined; -} - function createDashboardColumnFrames( plan: OrchestratorPlan, rootId: string, + designMd?: DesignMdSpec, ): { sidebar: FrameNode; main: FrameNode; } { const sidebarFillColor = - extractSidebarSurfaceColor(plan) ?? + extractSidebarSurfaceColor(plan, designMd) ?? (plan.rootFrame.fill as Array<{ color?: string }> | undefined)?.[0]?.color ?? '#0F172A'; const contentGap = @@ -490,6 +482,10 @@ function reorderDashboardMainChildren(plan: OrchestratorPlan, mainParentId: stri }); } +import { applyAppendContextToPlan } from './orchestrator-append'; +export { applyAppendContextToPlan } from './orchestrator-append'; +export type { AppendPlanResult } from './orchestrator-append'; + export async function executeOrchestration( request: AIDesignRequest, callbacks?: { @@ -528,21 +524,29 @@ export async function executeOrchestration( request.provider, abortSignal, isBuiltin, + request.context?.designMd, ); } catch (err) { // User abort — propagate so the outer catch cleans up without mutating canvas if (abortSignal?.aborted) throw err; // Network error, timeout, or provider failure — use heuristic plan - plan = buildFallbackPlanFromPrompt(preparedPrompt.orchestratorPrompt); + plan = buildFallbackPlanFromPrompt( + preparedPrompt.orchestratorPrompt, + request.context?.designMd, + ); } if (shouldUseDashboardColumns(request.prompt, plan)) { normalizeDashboardMainSubtasks(plan); } - // Remove status-bar subtasks on mobile — the bar is pre-injected + const appendResult = applyAppendContextToPlan(plan, request.context?.appendContext); + + // Remove status-bar subtasks on mobile — the bar is pre-injected. + // In append mode the existing page already carries the status bar, so the + // planner-emitted one is stripped by applyAppendContextToPlan above. const isMobileScreen = plan.rootFrame.width <= 480; - if (isMobileScreen) { + if (isMobileScreen && !appendResult.skipStatusBar) { plan.subtasks = plan.subtasks.filter( (st) => !STATUS_BAR_NAME_RE.test(`${st.id} ${st.label}`), ); @@ -602,8 +606,14 @@ export async function executeOrchestration( } } - // Effective concurrency: only parallel when there are multiple screen groups - const effectiveConcurrency = screenGroups.length > 1 ? concurrency : 1; + // Effective concurrency: only parallel when there are multiple screen groups. + // Append mode is forced sequential — the concurrent branch creates multiple + // root frames which conflicts with reusing an existing content-root. + const effectiveConcurrency = appendResult.skipRootInsertion + ? 1 + : screenGroups.length > 1 + ? concurrency + : 1; // Assign agent identities — one per screen group (concurrent) or per subtask (sequential) const subtaskIdentity = new Map(); @@ -724,77 +734,93 @@ export async function executeOrchestration( nextX += plan.rootFrame.width + gap; } } else { - // Sequential mode: single root frame containing all sections - const totalPlannedHeight = plan.subtasks.reduce((sum, st) => sum + st.region.height, 0); - const initialHeight = isMobile - ? plan.rootFrame.height || 812 - : useDashboardColumns - ? getDashboardPlaceholderHeight(plan) - : Math.max(320, totalPlannedHeight); - const rootNode: FrameNode = { - id: plan.rootFrame.id, - type: 'frame', - name: plan.rootFrame.name, - x: 0, - y: 0, - width: plan.rootFrame.width, - height: useDashboardColumns ? `fit_content(${initialHeight})` : initialHeight, - layout: useDashboardColumns ? 'horizontal' : (plan.rootFrame.layout ?? 'vertical'), - gap: useDashboardColumns - ? 0 - : isMobile - ? plan.rootFrame.gap || 16 - : (plan.rootFrame.gap ?? 16), - ...(plan.rootFrame.padding != null ? { padding: plan.rootFrame.padding } : {}), - fill: defaultFill, - children: [], - }; - insertStreamingNode(rootNode, null); - // insertStreamingNode may remap ID (e.g. replacing empty frame) - const actualRootId = getGenerationRootFrameId(); - rootNode.id = actualRootId; - rootNodes.push(rootNode); - - // Inject fixed iPhone status bar for iOS mobile screens - if (isMobile) { - const bgColor = (defaultFill as Array<{ color?: string }>)?.[0]?.color; - const statusBar = createMobileStatusBar(inferStatusBarVariant(bgColor)); - insertStreamingNode(statusBar, actualRootId); - } - - // Register agent badge on the actual root frame - const firstIdentity = subtaskIdentity.get(0); - if (firstIdentity) { - addAgentFrame(actualRootId, firstIdentity.color, firstIdentity.name); - } - - if (useDashboardColumns) { - const dashboardColumns = createDashboardColumnFrames(plan, actualRootId); - insertStreamingNode(dashboardColumns.sidebar, actualRootId); - insertStreamingNode(dashboardColumns.main, actualRootId); - dashboardColumnIds = { - sidebarId: dashboardColumns.sidebar.id, - mainId: dashboardColumns.main.id, - }; - for (const st of plan.subtasks) { - st.parentFrameId = isSidebarSubtask(st) ? dashboardColumns.sidebar.id : null; - } - const mainRowFrames = assignDashboardMainParents(plan, dashboardColumns.main.id); - for (const frame of mainRowFrames) { - insertStreamingNode(frame.node, frame.parentId); - } - for (const st of plan.subtasks) { - if (isSidebarSubtask(st) && st.parentFrameId == null) { - st.parentFrameId = dashboardColumns.sidebar.id; - } - if (!isSidebarSubtask(st) && st.parentFrameId == null) { - st.parentFrameId = dashboardColumns.main.id; - } - } - } else { + // Sequential mode: single root frame containing all sections. + // In append mode we reuse the caller-provided content-root instead + // of creating a new root + status bar + dashboard columns. + if (appendResult.skipRootInsertion) { + const actualRootId = plan.rootFrame.id; + setGenerationRootFrameId(actualRootId); for (const st of plan.subtasks) { st.parentFrameId = actualRootId; } + // No root frame insert, no status bar, no agent badge, no dashboard + // columns — the existing page already has all of that. + } else { + const totalPlannedHeight = plan.subtasks.reduce((sum, st) => sum + st.region.height, 0); + const initialHeight = isMobile + ? plan.rootFrame.height || 812 + : useDashboardColumns + ? getDashboardPlaceholderHeight(plan) + : Math.max(320, totalPlannedHeight); + const rootNode: FrameNode = { + id: plan.rootFrame.id, + type: 'frame', + name: plan.rootFrame.name, + x: 0, + y: 0, + width: plan.rootFrame.width, + height: useDashboardColumns ? `fit_content(${initialHeight})` : initialHeight, + layout: useDashboardColumns ? 'horizontal' : (plan.rootFrame.layout ?? 'vertical'), + gap: useDashboardColumns + ? 0 + : isMobile + ? plan.rootFrame.gap || 16 + : (plan.rootFrame.gap ?? 16), + ...(plan.rootFrame.padding != null ? { padding: plan.rootFrame.padding } : {}), + fill: defaultFill, + children: [], + }; + insertStreamingNode(rootNode, null); + // insertStreamingNode may remap ID (e.g. replacing empty frame) + const actualRootId = getGenerationRootFrameId(); + rootNode.id = actualRootId; + rootNodes.push(rootNode); + + // Inject fixed iPhone status bar for iOS mobile screens + if (isMobile) { + const bgColor = (defaultFill as Array<{ color?: string }>)?.[0]?.color; + const statusBar = createMobileStatusBar(inferStatusBarVariant(bgColor)); + insertStreamingNode(statusBar, actualRootId); + } + + // Register agent badge on the actual root frame + const firstIdentity = subtaskIdentity.get(0); + if (firstIdentity) { + addAgentFrame(actualRootId, firstIdentity.color, firstIdentity.name); + } + + if (useDashboardColumns) { + const dashboardColumns = createDashboardColumnFrames( + plan, + actualRootId, + request.context?.designMd, + ); + insertStreamingNode(dashboardColumns.sidebar, actualRootId); + insertStreamingNode(dashboardColumns.main, actualRootId); + dashboardColumnIds = { + sidebarId: dashboardColumns.sidebar.id, + mainId: dashboardColumns.main.id, + }; + for (const st of plan.subtasks) { + st.parentFrameId = isSidebarSubtask(st) ? dashboardColumns.sidebar.id : null; + } + const mainRowFrames = assignDashboardMainParents(plan, dashboardColumns.main.id); + for (const frame of mainRowFrames) { + insertStreamingNode(frame.node, frame.parentId); + } + for (const st of plan.subtasks) { + if (isSidebarSubtask(st) && st.parentFrameId == null) { + st.parentFrameId = dashboardColumns.sidebar.id; + } + if (!isSidebarSubtask(st) && st.parentFrameId == null) { + st.parentFrameId = dashboardColumns.main.id; + } + } + } else { + for (const st of plan.subtasks) { + st.parentFrameId = actualRootId; + } + } } } @@ -1049,6 +1075,7 @@ async function callOrchestrator( provider?: AIDesignRequest['provider'], abortSignal?: AbortSignal, fastTimeout?: boolean, + designMd?: DesignMdSpec, ): Promise { const modelProfile = resolveModelProfile(model); const attemptModes = @@ -1079,7 +1106,7 @@ async function callOrchestrator( let forcedStyleGuideName: string | undefined; if (mode === 'compact') { - const compact = buildCompactPlanningPrompt(prompt, model); + const compact = buildCompactPlanningPrompt(prompt, model, designMd); planningSystemPrompt = compact.systemPrompt; planningUserPrompt = compact.userPrompt; forcedStyleGuideName = compact.selectedStyleGuideName; @@ -1087,15 +1114,17 @@ async function callOrchestrator( model: model ?? 'default', tier: modelProfile.tier, mode, + hasDesignMd: !!designMd, selectedStyleGuideName: forcedStyleGuideName, systemChars: planningSystemPrompt.length, }); } else { - planningGuideContext = buildPlanningStyleGuideContext(prompt, model, mode); + planningGuideContext = buildPlanningStyleGuideContext(prompt, model, mode, designMd); console.info('[Orchestrator] planning shortlist', { model: model ?? 'default', tier: modelProfile.tier, mode, + hasDesignMd: !!designMd, metadataCount: planningGuideContext.metadataCount, snippetCount: planningGuideContext.snippetCount, topGuides: planningGuideContext.topGuideNames, @@ -1151,7 +1180,7 @@ async function callOrchestrator( throw new Error('Aborted'); } - const parsed = parseOrchestratorResponse(rawResponse, prompt); + const parsed = parseOrchestratorResponse(rawResponse, prompt, designMd); if (parsed) { if (parsed.repaired) { console.info('[Orchestrator] repaired near-miss planning JSON', { @@ -1166,7 +1195,12 @@ async function callOrchestrator( } normalizeOrchestratorPlan(plan, prompt); - if (plan.styleGuideName) { + // design.md takes precedence over the pre-built catalog. If the user + // has a design.md, we intentionally DO NOT resolve plan.styleGuideName + // through styleGuideRegistry — leaving selectedStyleGuideContent + // undefined prevents a catalog guide from bleeding into sub-agent + // prompts and overriding design.md. + if (plan.styleGuideName && !designMd && plan.styleGuideName !== DESIGN_MD_STYLE_GUIDE_NAME) { const rootWidth = typeof plan.rootFrame.width === 'number' ? plan.rootFrame.width : 1440; const platform = rootWidth <= 500 ? 'mobile' : 'webapp'; @@ -1220,7 +1254,7 @@ async function callOrchestrator( detail: lastPlanningFailure?.detail, preview: lastPlanningFailure?.preview, }); - const fallback = buildFallbackPlanFromPrompt(prompt); + const fallback = buildFallbackPlanFromPrompt(prompt, designMd); normalizeOrchestratorPlan(fallback, prompt); return fallback; } diff --git a/apps/web/src/services/ai/role-resolver.ts b/apps/web/src/services/ai/role-resolver.ts index 68f97ecf7..7fde31560 100644 --- a/apps/web/src/services/ai/role-resolver.ts +++ b/apps/web/src/services/ai/role-resolver.ts @@ -781,6 +781,15 @@ const ALTERNATING_BG = ['#FFFFFF', '#F8FAFC']; function fixSectionAlternation(parent: FrameNode, children: PenNode[]): void { if (parent.layout !== 'vertical') return; + // Only alternate on light-themed pages. ALTERNATING_BG is hardcoded to + // #FFFFFF/#F8FAFC, which paints visible white strips over a dark root + // background — the opposite of what the user wants. Dark themes rely on + // card/component internal contrast to group sections, not an outer + // section-background wash. When the parent has no solid fill we fall + // through to the existing (light-mode) behavior. + const parentBg = getFirstSolidColor(parent); + if (parentBg && hexLuminance(parentBg) < 0.5) return; + const runs: PenNode[][] = []; let current: PenNode[] = []; diff --git a/apps/web/src/stores/document-store-variable-actions.ts b/apps/web/src/stores/document-store-variable-actions.ts index 6e84f7dca..f35142058 100644 --- a/apps/web/src/stores/document-store-variable-actions.ts +++ b/apps/web/src/stores/document-store-variable-actions.ts @@ -29,7 +29,7 @@ export function createVariableActions( set((s) => ({ document: { ...s.document, - variables: { ...(s.document.variables ?? {}), [name]: definition }, + variables: { ...s.document.variables, [name]: definition }, }, isDirty: true, })); diff --git a/apps/web/src/utils/document-assets.ts b/apps/web/src/utils/document-assets.ts index c7e16e9dc..0b1c49bba 100644 --- a/apps/web/src/utils/document-assets.ts +++ b/apps/web/src/utils/document-assets.ts @@ -138,7 +138,7 @@ function toRelativePath(baseDir: string, targetPath: string): string | null { shared += 1; } - const up = new Array(baseParts.length - shared).fill('..'); + const up: string[] = Array.from({ length: baseParts.length - shared }, () => '..'); const down = targetParts.slice(shared); const relative = [...up, ...down].join('/'); return relative || '.'; diff --git a/apps/web/src/utils/document-events.ts b/apps/web/src/utils/document-events.ts index 2422aca9e..aaf2de97d 100644 --- a/apps/web/src/utils/document-events.ts +++ b/apps/web/src/utils/document-events.ts @@ -41,7 +41,7 @@ class DocumentEventEmitter { const set = this.handlers[event] as Set> | undefined; if (!set) return; // Snapshot to avoid re-entry mutation issues if a handler unsubscribes. - for (const handler of [...set]) { + for (const handler of Array.from(set)) { try { handler(payload); } catch (err) { diff --git a/package.json b/package.json index 7be8ed8b5..b945c0de8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openpencil", - "version": "0.7.2", + "version": "0.7.3", "private": true, "description": "The world's first open-source AI-native vector design tool and the first to feature concurrent Agent Teams. Design-as-Code. Turn prompts into UI directly on the live canvas. A modern alternative to Pencil.", "author": { diff --git a/packages/agent-native b/packages/agent-native index 2c48403f8..7a3099685 160000 --- a/packages/agent-native +++ b/packages/agent-native @@ -1 +1 @@ -Subproject commit 2c48403f81f43b19c9f2b7a60aca83646ab5c5f4 +Subproject commit 7a309968515df9bafb5d12eb4716299b402b556f diff --git a/packages/pen-acp/package.json b/packages/pen-acp/package.json index 0515455ab..45e5d1a01 100644 --- a/packages/pen-acp/package.json +++ b/packages/pen-acp/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/pen-acp", - "version": "0.7.2", + "version": "0.7.3", "description": "ACP (Agent Client Protocol) client for OpenPencil — connect to external ACP agents", "files": [ "src" diff --git a/packages/pen-ai-skills/package.json b/packages/pen-ai-skills/package.json index 426d083ec..30fbf9df4 100644 --- a/packages/pen-ai-skills/package.json +++ b/packages/pen-ai-skills/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/pen-ai-skills", - "version": "0.7.2", + "version": "0.7.3", "homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-ai-skills", "bugs": { "url": "https://github.com/ZSeven-W/openpencil/issues" diff --git a/packages/pen-ai-skills/skills/phases/generation/overflow.md b/packages/pen-ai-skills/skills/phases/generation/overflow.md index 3fd5d3eec..d7e960ac1 100644 --- a/packages/pen-ai-skills/skills/phases/generation/overflow.md +++ b/packages/pen-ai-skills/skills/phases/generation/overflow.md @@ -14,3 +14,77 @@ OVERFLOW PREVENTION (CRITICAL): - NEVER set fixed pixel width on text inside layout frames (e.g. width:378 in 195px card - overflows!). - Fixed-width children must be <= parent content area (parent width - padding). - Badges: short labels only (CJK <=8 chars / Latin <=16 chars). + +## HORIZONTAL SCROLL ROWS (cards / chips / categories) + +When the spec says "horizontal scrolling cards", "swipeable row", "chip row", or similar, generate EXACTLY this structure — do NOT just emit 6 cards inside a horizontal layout, the children will spill outside the page frame. + +Structure: + +- A wrapper frame with `width="fill_container"`, `height="fit_content"`, `layout="vertical"`, `clipContent=true`. +- Inside it, a row frame with `width="fit_content"`, `height="fit_content"`, `layout="horizontal"`, `gap=12`, `padding=[0,20]`. +- The row frame holds the actual cards. + +Every card in the row MUST: + +- Have a FIXED numeric `width` (typically 120-160 for mobile, 200-260 for desktop). Never `fill_container`, never `fit_content` - fixed pixels. +- Share identical width with its siblings for visual rhythm. + +Example - 6 workout cards inside a 375px-wide mobile page: + +```json +{ + "id": "cards-scroll", + "type": "frame", + "name": "Workouts Scroll", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "clipContent": true, + "children": [ + { + "id": "cards-row", + "type": "frame", + "name": "Workouts Row", + "width": "fit_content", + "height": "fit_content", + "layout": "horizontal", + "gap": 12, + "padding": [0, 20], + "children": [ + { + "id": "card-hiit", + "type": "frame", + "width": 140, + "height": 160, + "cornerRadius": 20, + "layout": "vertical", + "gap": 8, + "padding": 16, + "fill": [{ "type": "solid", "color": "#1a1a1a" }], + "children": [] + }, + { + "id": "card-strength", + "type": "frame", + "width": 140, + "height": 160, + "cornerRadius": 20, + "layout": "vertical", + "gap": 8, + "padding": 16, + "fill": [{ "type": "solid", "color": "#1a1a1a" }], + "children": [] + } + ] + } + ] +} +``` + +Anti-patterns (do NOT emit any of these): + +- Putting 5+ cards directly inside a `layout="horizontal"` page-root frame (they overflow the phone width). +- Using `fill_container` on cards in a horizontal row (they squish down to invisibility). +- Using `width="fit_content"` on cards - text-driven widths are unpredictable and break rhythm. +- Skipping the `clipContent=true` wrapper and relying on Skia to clip (it doesn't — only `clipContent:true` enables clipping). diff --git a/packages/pen-ai-skills/skills/phases/maintenance/incremental-add.md b/packages/pen-ai-skills/skills/phases/maintenance/incremental-add.md index 2a64c6d07..aa66ed611 100644 --- a/packages/pen-ai-skills/skills/phases/maintenance/incremental-add.md +++ b/packages/pen-ai-skills/skills/phases/maintenance/incremental-add.md @@ -1,9 +1,9 @@ --- name: incremental-add description: Rules for adding new elements to existing designs -phase: [maintenance] +phase: [maintenance, generation] trigger: - keywords: [add, insert, new section, append] + keywords: [add, insert, new section, append, continue, 继续, 再加, 追加] priority: 20 budget: 1500 category: domain diff --git a/packages/pen-core/package.json b/packages/pen-core/package.json index 6b2283537..3379d8c8e 100644 --- a/packages/pen-core/package.json +++ b/packages/pen-core/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/pen-core", - "version": "0.7.2", + "version": "0.7.3", "description": "Core document operations, tree utils, variables, layout engine for OpenPencil", "homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-core", "bugs": { diff --git a/packages/pen-core/src/__tests__/strip-redundant-section-fills.test.ts b/packages/pen-core/src/__tests__/strip-redundant-section-fills.test.ts index 8578d3792..a3c7c42b9 100644 --- a/packages/pen-core/src/__tests__/strip-redundant-section-fills.test.ts +++ b/packages/pen-core/src/__tests__/strip-redundant-section-fills.test.ts @@ -235,6 +235,58 @@ describe('stripRedundantSectionFills', () => { expect((deepInner as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A')); }); + it('strips stale #FFFFFF section fills on a dark root (legacy alternation residue)', () => { + // Regression guard for 2026-04-15: the legacy fixSectionAlternation + // painted #FFFFFF / #F8FAFC on unfilled section runs regardless of + // page theme. After the alternation skip for dark parents landed, + // stale docs (and weak-model hedges) still carry those whites. + // stripRedundantSectionFills must now clean them up. + const section1 = frame({ + id: 's1', + name: 'Hero', + role: 'hero', + fill: solidFill('#FFFFFF'), + }); + const section2 = frame({ + id: 's2', + name: 'Stats', + role: 'stats-section', + fill: solidFill('#F8FAFC'), + }); + const section3 = frame({ + id: 's3', + name: 'CTA', + role: 'cta-section', + fill: solidFill('#FFFFFF'), + }); + const root = frame({ + id: 'root', + fill: solidFill('#111111'), + children: [section1, section2, section3], + }); + const changed = stripRedundantSectionFills(root); + expect(changed).toBe(true); + expect((section1 as PenNode & { fill?: unknown }).fill).toBeUndefined(); + expect((section2 as PenNode & { fill?: unknown }).fill).toBeUndefined(); + expect((section3 as PenNode & { fill?: unknown }).fill).toBeUndefined(); + }); + + it('strips a safe-light hedge even when the root has no fill', () => { + // Mirror of the existing "root frame without a fill" dark case: a + // bare #FFFFFF on a section root is almost certainly the sub-agent + // hedging against a missing background spec, not a deliberate choice. + const section = frame({ + id: 'sec', + fill: solidFill('#FAFAFA'), + }); + const root = frame({ + id: 'root', + children: [section], + }); + stripRedundantSectionFills(root); + expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined(); + }); + it('reproduces the M2.7 health-tracker case', () => { // Direct repro of the actual failure: root #1a1a2e, six section roots // all hardcoded #0A0A0A, including one real card. The six section diff --git a/packages/pen-core/src/layout/strip-redundant-section-fills.ts b/packages/pen-core/src/layout/strip-redundant-section-fills.ts index 5bceac17a..e4119b8ee 100644 --- a/packages/pen-core/src/layout/strip-redundant-section-fills.ts +++ b/packages/pen-core/src/layout/strip-redundant-section-fills.ts @@ -126,13 +126,41 @@ const SAFE_DARK_HEXES = new Set([ '#202020', ]); +/** + * Light-mode counterparts of SAFE_DARK_HEXES. Two sources produce these + * on section roots: + * 1. Weaker sub-agents that hedge with pure white / near-white instead + * of the role-correct transparent fill. + * 2. The legacy `fixSectionAlternation` post-pass that painted an + * #FFFFFF / #F8FAFC ladder on every run of ≥3 unfilled sections. + * On dark pages that ladder leaves visible white strips; if a doc + * with those stale fills is re-opened or re-rendered after the + * alternation skip landed, the fills are still there and the skip + * alone won't remove them. + * Treat these the same as safe-dark hedges: strip on any section root. + */ +const SAFE_LIGHT_HEXES = new Set([ + '#ffffff', + '#fff', + '#fefefe', + '#fdfdfd', + '#fcfcfc', + '#fafafa', + '#f9fafb', + '#f8f8f8', + '#f8fafc', + '#f5f5f5', + '#f4f4f5', + '#f3f4f6', +]); + function shouldStripFill(childFill: string, rootFill: string | null): boolean { const childKey = normalizeHex(childFill); if (rootFill) { const rootKey = normalizeHex(rootFill); if (childKey === rootKey) return true; } - return SAFE_DARK_HEXES.has(childKey); + return SAFE_DARK_HEXES.has(childKey) || SAFE_LIGHT_HEXES.has(childKey); } function normalizeHex(color: string): string { diff --git a/packages/pen-engine/package.json b/packages/pen-engine/package.json index 61f18e6ff..85f2d6f3b 100644 --- a/packages/pen-engine/package.json +++ b/packages/pen-engine/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/pen-engine", - "version": "0.7.2", + "version": "0.7.3", "description": "Headless design engine for OpenPencil — zero framework dependencies", "homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-engine", "bugs": { diff --git a/packages/pen-figma/package.json b/packages/pen-figma/package.json index cf65a8f8d..5abc0cdea 100644 --- a/packages/pen-figma/package.json +++ b/packages/pen-figma/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/pen-figma", - "version": "0.7.2", + "version": "0.7.3", "description": "Figma .fig file parser and converter for OpenPencil", "homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-figma", "bugs": { diff --git a/packages/pen-mcp/package.json b/packages/pen-mcp/package.json index dbc4bf983..67951a982 100644 --- a/packages/pen-mcp/package.json +++ b/packages/pen-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/pen-mcp", - "version": "0.7.2", + "version": "0.7.3", "description": "MCP server, document manager, and tools for OpenPencil", "homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-mcp", "bugs": { diff --git a/packages/pen-mcp/src/tools/theme-presets.ts b/packages/pen-mcp/src/tools/theme-presets.ts index 9d1a1c641..9ab34ff5d 100644 --- a/packages/pen-mcp/src/tools/theme-presets.ts +++ b/packages/pen-mcp/src/tools/theme-presets.ts @@ -57,13 +57,10 @@ export async function handleLoadThemePreset( } // Merge themes - doc.themes = { ...(doc.themes ?? {}), ...data.themes }; + doc.themes = { ...doc.themes, ...data.themes }; // Merge variables - doc.variables = { ...(doc.variables ?? {}), ...data.variables } as Record< - string, - VariableDefinition - >; + doc.variables = { ...doc.variables, ...data.variables } as Record; await saveDocument(filePath, doc); return { diff --git a/packages/pen-mcp/src/tools/variables.ts b/packages/pen-mcp/src/tools/variables.ts index 251b6a367..ea7324a7b 100644 --- a/packages/pen-mcp/src/tools/variables.ts +++ b/packages/pen-mcp/src/tools/variables.ts @@ -31,7 +31,7 @@ export async function handleSetVariables( if (params.replace) { doc.variables = params.variables; } else { - doc.variables = { ...(doc.variables ?? {}), ...params.variables }; + doc.variables = { ...doc.variables, ...params.variables }; } await saveDocument(filePath, doc); @@ -68,7 +68,7 @@ export async function handleSetThemes( if (params.replace) { doc.themes = params.themes; } else { - doc.themes = { ...(doc.themes ?? {}), ...params.themes }; + doc.themes = { ...doc.themes, ...params.themes }; } await saveDocument(filePath, doc); diff --git a/packages/pen-mcp/src/utils/design-md-style-policy.ts b/packages/pen-mcp/src/utils/design-md-style-policy.ts index ef93b79ce..6bb03fa90 100644 --- a/packages/pen-mcp/src/utils/design-md-style-policy.ts +++ b/packages/pen-mcp/src/utils/design-md-style-policy.ts @@ -16,6 +16,19 @@ export function buildDesignMdStylePolicy(spec: DesignMdSpec): string { .map((c) => `${c.name} (${c.hex}) — ${c.role}`) .join('\n- '); parts.push(`COLOR PALETTE:\n- ${colors}`); + + const surfaces = spec.colorPalette.filter((c) => + /surface|card|panel|sidebar|tile|chip/i.test(c.role || ''), + ); + if (surfaces.length > 0) { + const surfaceLines = surfaces + .slice(0, 6) + .map((c) => `${c.name} (${c.hex}) — ${c.role}`) + .join('\n- '); + parts.push( + `SURFACE COLORS (use ONLY as \`fill\` on visually distinct components placed on top of the page background — cards, sidebars, floating panels, chips, badges. DO NOT fill section root frames or generic wrapper frames with these; section containers must stay transparent and inherit the page background. NEVER use these as the page/rootFrame fill):\n- ${surfaceLines}`, + ); + } } if (spec.typography?.fontFamily) { @@ -36,5 +49,21 @@ export function buildDesignMdStylePolicy(spec: DesignMdSpec): string { parts.push(`COMPONENT STYLES:\n${styles}`); } + if (spec.layoutPrinciples) { + const layout = + spec.layoutPrinciples.length > 400 + ? spec.layoutPrinciples.substring(0, 400) + '...' + : spec.layoutPrinciples; + parts.push(`LAYOUT PRINCIPLES:\n${layout}`); + } + + if (spec.generationNotes) { + const notes = + spec.generationNotes.length > 400 + ? spec.generationNotes.substring(0, 400) + '...' + : spec.generationNotes; + parts.push(`GENERATION NOTES:\n${notes}`); + } + return parts.join('\n\n'); } diff --git a/packages/pen-react/package.json b/packages/pen-react/package.json index 64a714920..1c7449991 100644 --- a/packages/pen-react/package.json +++ b/packages/pen-react/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/pen-react", - "version": "0.7.2", + "version": "0.7.3", "description": "React UI SDK for OpenPencil — hooks, components, and state bridges for pen-engine", "homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-react", "bugs": { diff --git a/packages/pen-renderer/package.json b/packages/pen-renderer/package.json index 77d69ff79..3a9d7eb07 100644 --- a/packages/pen-renderer/package.json +++ b/packages/pen-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/pen-renderer", - "version": "0.7.2", + "version": "0.7.3", "description": "Standalone CanvasKit/Skia renderer for OpenPencil (.op) design files", "homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-renderer", "bugs": { diff --git a/packages/pen-renderer/src/__tests__/font-manager.test.ts b/packages/pen-renderer/src/__tests__/font-manager.test.ts index 23f02ba32..08c35f74a 100644 --- a/packages/pen-renderer/src/__tests__/font-manager.test.ts +++ b/packages/pen-renderer/src/__tests__/font-manager.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SkiaFontManager } from '../font-manager'; // Minimal mock CanvasKit shim — only the bits SkiaFontManager constructor touches. @@ -63,3 +63,273 @@ describe('SkiaFontManager.pendingCount / flushPending', () => { expect(flushResolved).toBe(true); }); }); + +describe('system font detection via Local Font Access API', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('marks unknown local font as systemFont when Google Fonts fails', async () => { + // Mock fetch to simulate Google Fonts returning 400 + globalThis.fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 400, statusText: 'Bad Request' })); + + // Mock window.queryLocalFonts to include "HeyMeow Rnd" + const origQuery = (globalThis as unknown as Record).queryLocalFonts; + (globalThis as unknown as Record).queryLocalFonts = async () => [ + { + family: 'HeyMeow Rnd', + fullName: 'HeyMeow Rnd Regular', + postscriptName: 'HeyMeowRnd-Regular', + style: 'Regular', + blob: async () => new Blob([]), + }, + { + family: 'Arial', + fullName: 'Arial Regular', + postscriptName: 'ArialMT', + style: 'Regular', + blob: async () => new Blob([]), + }, + ]; + + // Mock window for the Local Font Access API check + const origWindow = globalThis.window; + vi.stubGlobal('window', { + queryLocalFonts: ( + globalThis as unknown as Record Promise>> + ).queryLocalFonts, + }); + + const fm = new SkiaFontManager(makeMockCk() as never); + + // "HeyMeow Rnd" is not bundled and not in NON_GOOGLE_FONT_PATTERNS + const result = await fm.ensureFont('HeyMeow Rnd'); + + // Should return false (not loaded into CanvasKit) but be classified as system font + expect(result).toBe(false); + expect(fm.isSystemFont('HeyMeow Rnd')).toBe(true); + + // Cleanup + vi.unstubAllGlobals(); + if (origWindow !== undefined) { + globalThis.window = origWindow as Window & typeof globalThis; + } + (globalThis as unknown as Record).queryLocalFonts = origQuery; + }); + + it('marks font as failed when not found locally either', async () => { + // Mock fetch to simulate Google Fonts returning 400 + globalThis.fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 400, statusText: 'Bad Request' })); + + // Mock window.queryLocalFonts WITHOUT "SomeRandomFont" + vi.stubGlobal('window', { + queryLocalFonts: async () => [ + { + family: 'Arial', + fullName: 'Arial Regular', + postscriptName: 'ArialMT', + style: 'Regular', + blob: async () => new Blob([]), + }, + { + family: 'Inter', + fullName: 'Inter Regular', + postscriptName: 'Inter-Regular', + style: 'Regular', + blob: async () => new Blob([]), + }, + ], + }); + + const fm = new SkiaFontManager(makeMockCk() as never); + const result = await fm.ensureFont('SomeRandomFont'); + + expect(result).toBe(false); + expect(fm.isSystemFont('SomeRandomFont')).toBe(false); + + // Cleanup + vi.unstubAllGlobals(); + }); +}); + +describe('requestLocalFontAccess', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns true and populates localFontMap when permission granted', async () => { + const mockFontData = new Blob([new ArrayBuffer(100)]); + vi.stubGlobal('window', { + queryLocalFonts: async () => [ + { + family: 'Segoe UI', + fullName: 'Segoe UI Regular', + postscriptName: 'SegoeUI-Regular', + style: 'Regular', + blob: async () => mockFontData, + }, + { + family: 'Segoe UI', + fullName: 'Segoe UI Bold', + postscriptName: 'SegoeUI-Bold', + style: 'Bold', + blob: async () => mockFontData, + }, + ], + }); + + const fm = new SkiaFontManager(makeMockCk() as never); + const result = await fm.requestNativeFontAccess(); + + expect(result).toBe(true); + expect(fm.nativeFontPermission).toBe('granted'); + + // Check nativeFontMap has entries + const map = (fm as unknown as { nativeFontMap: Map }).nativeFontMap; + expect(map.has('segoe ui')).toBe(true); + expect(map.get('segoe ui')?.length).toBe(2); + + vi.unstubAllGlobals(); + }); + + it('returns false and sets denied when permission denied', async () => { + vi.stubGlobal('window', { + queryLocalFonts: async () => { + throw new DOMException('Permission denied', 'NotAllowedError'); + }, + }); + + const fm = new SkiaFontManager(makeMockCk() as never); + const result = await fm.requestNativeFontAccess(); + + expect(result).toBe(false); + expect(fm.nativeFontPermission).toBe('denied'); + + vi.unstubAllGlobals(); + }); + + it('returns false and sets unavailable when API not present', async () => { + vi.stubGlobal('window', {}); + + const fm = new SkiaFontManager(makeMockCk() as never); + const result = await fm.requestNativeFontAccess(); + + expect(result).toBe(false); + expect(fm.nativeFontPermission).toBe('unavailable'); + + vi.unstubAllGlobals(); + }); +}); + +describe('local font blob loading into CanvasKit', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('loads local font data into CanvasKit when blob provides valid data', async () => { + // Create mock font data (enough bytes to look like a font file) + const fontBuffer = new ArrayBuffer(100); + const mockBlob = new Blob([fontBuffer]); + + // Mock fetch to fail (no bundled, no Google Fonts) + globalThis.fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 400, statusText: 'Bad Request' })); + + vi.stubGlobal('window', { + queryLocalFonts: async () => [ + { + family: 'TestFont', + fullName: 'TestFont Regular', + postscriptName: 'TestFont-Regular', + style: 'Regular', + blob: async () => mockBlob, + }, + ], + }); + + const fm = new SkiaFontManager(makeMockCk() as never); + + // Grant access first + await fm.requestNativeFontAccess(); + expect(fm.nativeFontPermission).toBe('granted'); + + // Now try to ensure the font — should attempt blob loading + await fm.ensureFont('TestFont'); + + // Even if registerFont fails with mock data, the font should be recognized + // The mock CK registerFont is a no-op, so it won't actually register + // But the flow should exercise the blob loading path + expect(fm.nativeFontPermission).toBe('granted'); + + vi.unstubAllGlobals(); + }); +}); + +describe('requestNativeFontAccess prompt-preservation', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('preserves prompt state when queryLocalFonts fails and permissions.query returns prompt', async () => { + vi.stubGlobal('window', { + queryLocalFonts: async () => { + throw new DOMException('Permission denied', 'NotAllowedError'); + }, + }); + // Stub navigator.permissions for Node.js test environment + vi.stubGlobal('navigator', { + permissions: { + query: async () => ({ state: 'prompt' }), + }, + }); + + const fm = new SkiaFontManager(makeMockCk() as never); + const result = await fm.requestNativeFontAccess(); + + expect(result).toBe(false); + expect(fm.nativeFontPermission).toBe('prompt'); + + vi.unstubAllGlobals(); + }); + + it('sets denied when queryLocalFonts fails and permissions.query returns denied', async () => { + vi.stubGlobal('window', { + queryLocalFonts: async () => { + throw new DOMException('Permission denied', 'NotAllowedError'); + }, + }); + // Stub navigator.permissions for Node.js test environment + vi.stubGlobal('navigator', { + permissions: { + query: async () => ({ state: 'denied' }), + }, + }); + + const fm = new SkiaFontManager(makeMockCk() as never); + const result = await fm.requestNativeFontAccess(); + + expect(result).toBe(false); + expect(fm.nativeFontPermission).toBe('denied'); + + vi.unstubAllGlobals(); + }); +}); diff --git a/packages/pen-renderer/src/font-manager.ts b/packages/pen-renderer/src/font-manager.ts index 2f1e5333f..1d4324f53 100644 --- a/packages/pen-renderer/src/font-manager.ts +++ b/packages/pen-renderer/src/font-manager.ts @@ -7,6 +7,18 @@ export interface FontManagerOptions { googleFontsCssUrl?: string; } +/** Permission state for the native font access (Local Font Access API) */ +export type NativeFontPermission = 'prompt' | 'granted' | 'denied' | 'unavailable'; + +/** Native font entry from the Local Font Access API with blob accessor */ +interface NativeFontEntry { + family: string; + fullName: string; + postscriptName: string; + style: string; + blob: () => Promise; +} + /** * Bundled font files (relative paths, prepended with fontBasePath at load time). * Key = lowercase family name, values = relative file names. @@ -88,6 +100,12 @@ export class SkiaFontManager { private systemFontFamilies = new Set(); /** In-flight font fetch promises to avoid duplicate requests */ private pendingFetches = new Map>(); + /** Cached set of native (OS-installed) font families from Local Font Access API (lowercase) */ + private nativeFontSet: Set | null = null; + /** Full native font entries with blob accessors, keyed by lowercase family name */ + private nativeFontMap = new Map(); + /** Current permission state for native font access (Local Font Access API) */ + nativeFontPermission: NativeFontPermission = 'prompt'; constructor(ck: CanvasKit, options?: FontManagerOptions) { this.provider = ck.TypefaceFontProvider.Make(); @@ -95,6 +113,9 @@ export class SkiaFontManager { // Ensure trailing slash if (!this.fontBasePath.endsWith('/')) this.fontBasePath += '/'; this.googleFontsCssUrl = options?.googleFontsCssUrl ?? 'https://fonts.googleapis.com/css2'; + + // Check initial permission state (non-blocking) + this._checkPermissionState(); } getProvider(): TypefaceFontProvider { @@ -176,7 +197,8 @@ export class SkiaFontManager { } /** - * Ensure a font family is loaded. Tries bundled fonts first, then Google Fonts. + * Ensure a font family is loaded. Tries bundled fonts first, then native + * fonts (Local Font Access API + canvas heuristic), then Google Fonts CDN. */ async ensureFont(family: string, weights: number[] = [400, 500, 600, 700]): Promise { const key = family.toLowerCase(); @@ -193,6 +215,7 @@ export class SkiaFontManager { this.pendingFetches.delete(key); if (!result) { if (isSystemFont(family)) { + console.warn(`[FontManager] "${family}" is now a system font fallback after failed load.`); this.systemFontFamilies.add(key); } else { this.failedFamilies.add(key); @@ -214,6 +237,117 @@ export class SkiaFontManager { return loaded; } + /** + * Request native font access from the user via the Local Font Access API. + * Must be called from a user gesture context (click handler) for the + * browser to show the permission prompt. + * + * Returns true if access was granted and fonts were enumerated. + */ + async requestNativeFontAccess(): Promise { + if (typeof window === 'undefined') { + this.nativeFontPermission = 'unavailable'; + return false; + } + + if (!('queryLocalFonts' in window)) { + console.warn('[FontManager] Local Font Access API not available in this browser.'); + this.nativeFontPermission = 'unavailable'; + return false; + } + + try { + const fonts = await ( + window as unknown as { + queryLocalFonts(): Promise< + Array<{ + family: string; + fullName: string; + postscriptName: string; + style: string; + blob(): Promise; + }> + >; + } + ).queryLocalFonts(); + + const families = new Set(); + this.nativeFontMap.clear(); + + for (const f of fonts) { + const key = f.family.toLowerCase(); + families.add(key); + + const entry: NativeFontEntry = { + family: f.family, + fullName: f.fullName, + postscriptName: f.postscriptName, + style: f.style, + blob: f.blob.bind(f), + }; + + const existing = this.nativeFontMap.get(key) ?? []; + existing.push(entry); + this.nativeFontMap.set(key, existing); + } + + this.nativeFontSet = families; + this.nativeFontPermission = 'granted'; + console.log(`[FontManager] Native font access granted — ${families.size} families found.`); + return true; + } catch (e: unknown) { + const errMsg = e instanceof Error ? e.message : String(e); + if (e instanceof DOMException && e.name === 'NotAllowedError') { + // Distinguish: never prompted (no user gesture) vs. user actually denied + try { + const status = await navigator.permissions.query({ + name: 'local-fonts' as PermissionName, + }); + if (status.state === 'prompt') { + // Not yet prompted — likely called without user gesture. Keep prompt + // state so getNativeFontSet() will retry on the next ensureFont() call. + console.warn( + '[FontManager] Native font access not yet prompted — will retry on next user gesture.', + ); + this.nativeFontPermission = 'prompt'; + this.nativeFontSet = null; + return false; + } + } catch { + // permissions.query() not supported — assume denied + } + console.warn('[FontManager] Native font access denied by user.'); + this.nativeFontPermission = 'denied'; + } else { + console.warn('[FontManager] Native font access failed:', errMsg); + this.nativeFontPermission = 'denied'; + } + this.nativeFontSet = new Set(); + return false; + } + } + + /** + * Check the current permission state without triggering a prompt. + */ + private async _checkPermissionState(): Promise { + if (typeof navigator === 'undefined' || !navigator.permissions) { + return; + } + try { + const result = await navigator.permissions.query({ + name: 'local-fonts' as PermissionName, + }); + this.nativeFontPermission = result.state as NativeFontPermission; + // If already granted, eagerly enumerate fonts + if (result.state === 'granted') { + this.requestNativeFontAccess().catch(() => {}); + } + } catch { + // Permission name not supported — will need explicit request + } + } + private async _loadFont(family: string, weights: number[]): Promise { // 1. Try bundled fonts first (no network dependency) const bundled = BUNDLED_FONTS[family.toLowerCase()]; @@ -223,13 +357,74 @@ export class SkiaFontManager { if (ok) return true; } - // 2. Skip Google Fonts for system/proprietary fonts - if (isSystemFont(family)) { + // 2. Skip further loading for known system/proprietary fonts + if (isKnownNonGoogleFont(family)) { return false; } - // 3. Fall back to Google Fonts CDN - return this._fetchGoogleFont(family, weights); + // 3. Try loading from native fonts via Local Font Access API (vector rendering) + // If we have the font data cached, load it into CanvasKit for vector rendering. + const nativeLoaded = await this._loadNativeFontData(family); + if (nativeLoaded) return true; + + // 4. Check if font is installed natively via Local Font Access API + const nativeFonts = await this.getNativeFontSet(); + if (nativeFonts.has(family.toLowerCase())) { + // Found natively — try blob loading for vector rendering + const blobLoaded = await this._loadNativeFontData(family); + if (blobLoaded) return true; + // Can't load blob data — mark as system font for bitmap fallback + this.systemFontFamilies.add(family.toLowerCase()); + return false; + } + + // 5. Canvas-based width comparison heuristic (fast, no permission needed) + if (isFontLocallyAvailable(family)) { + this.systemFontFamilies.add(family.toLowerCase()); + return false; + } + + // 6. Fall back to Google Fonts CDN (network request — last resort) + const isFontFromGoogle = await this._fetchGoogleFont(family, weights); + if (isFontFromGoogle) return true; + + return false; + } + + /** + * Attempt to load a native font from the Local Font Access API blob data + * into CanvasKit's TypefaceFontProvider for vector rendering. + */ + private async _loadNativeFontData(family: string): Promise { + const key = family.toLowerCase(); + const entries = this.nativeFontMap.get(key); + if (!entries || entries.length === 0) return false; + + // Try each variant (regular, bold, italic, etc.) + let registered = 0; + for (const entry of entries) { + try { + const blob = await entry.blob(); + const buffer = await blob.arrayBuffer(); + if (buffer.byteLength > 0 && this.registerFont(buffer, family)) { + registered++; + } + } catch (e) { + // Individual variant may fail — try the next one + console.warn( + `[FontManager] Failed to load native font blob for "${entry.fullName}":`, + e instanceof Error ? e.message : String(e), + ); + } + } + + if (registered > 0) { + console.log( + `[FontManager] Loaded "${family}" from native fonts (${registered}/${entries.length} variants).`, + ); + return true; + } + return false; } private async _fetchLocalFonts( @@ -294,8 +489,8 @@ export class SkiaFontManager { const fontBuffers = await Promise.all( urls.map(async (url) => { try { - const resp = await fetchWithTimeout(url, 8000); - return resp.ok ? resp.arrayBuffer() : null; + const resp = fetchWithTimeout(url, 8000); + return (await resp).ok ? (await resp).arrayBuffer() : null; } catch { return null; } @@ -314,12 +509,31 @@ export class SkiaFontManager { return false; } + /** + * Build a set of all native (OS-installed) font families using the + * Local Font Access API (Chrome 103+, Edge 103+). + * Falls back to empty set if API is unavailable or permission denied. + * Results are cached after the first successful call. + */ + private async getNativeFontSet(): Promise> { + if (this.nativeFontSet) return this.nativeFontSet; + + // Try to enumerate native fonts if we haven't already + if (this.nativeFontPermission !== 'denied' && this.nativeFontPermission !== 'unavailable') { + await this.requestNativeFontAccess(); + } + + return this.nativeFontSet ?? new Set(); + } + dispose() { this.provider.delete(); this.loadedFamilies.clear(); this.failedFamilies.clear(); this.systemFontFamilies.clear(); this.pendingFetches.clear(); + this.nativeFontSet = null; + this.nativeFontMap.clear(); } } diff --git a/packages/pen-renderer/src/index.ts b/packages/pen-renderer/src/index.ts index db6a1c322..9f2e46648 100644 --- a/packages/pen-renderer/src/index.ts +++ b/packages/pen-renderer/src/index.ts @@ -25,7 +25,10 @@ export type { RenderNode, ViewportState, PenRendererOptions, IconLookupFn } from export { SkiaNodeRenderer } from './node-renderer.js'; export { SkiaTextRenderer } from './text-renderer.js'; export { SkiaFontManager, BUNDLED_FONT_FAMILIES } from './font-manager.js'; -export type { FontManagerOptions } from './font-manager.js'; +export type { + FontManagerOptions, + NativeFontPermission as LocalFontPermission, +} from './font-manager.js'; export { SkiaImageLoader } from './image-loader.js'; export { SpatialIndex } from './spatial-index.js'; export { diff --git a/packages/pen-sdk/package.json b/packages/pen-sdk/package.json index 88aab501d..c60f646b6 100644 --- a/packages/pen-sdk/package.json +++ b/packages/pen-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/pen-sdk", - "version": "0.7.2", + "version": "0.7.3", "description": "OpenPencil SDK — parse, manipulate, and generate code from .op design files", "homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-sdk", "bugs": { diff --git a/packages/pen-types/package.json b/packages/pen-types/package.json index 4462938b7..4c6264c6f 100644 --- a/packages/pen-types/package.json +++ b/packages/pen-types/package.json @@ -1,6 +1,6 @@ { "name": "@zseven-w/pen-types", - "version": "0.7.2", + "version": "0.7.3", "description": "Type definitions for OpenPencil document model", "homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-types", "bugs": { diff --git a/scripts/patch-srvx-bun.ts b/scripts/patch-srvx-bun.ts index 54c755dca..4e5889513 100644 --- a/scripts/patch-srvx-bun.ts +++ b/scripts/patch-srvx-bun.ts @@ -12,7 +12,6 @@ */ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; -import { globSync } from 'node:fs'; // Find srvx node adapter in various possible locations const candidates = [