V0.7.3 (#111)
* fix(ai): stop white section bands on dark-themed pages - role-resolver: skip fixSectionAlternation when parent fill luminance < 0.5, so we no longer paint #FFFFFF/#F8FAFC over a dark root - strip-redundant-section-fills: add SAFE_LIGHT_HEXES so stale whites from earlier runs (or weak-model hedges) are cleaned up on the sink side - regression tests for both layers * feat(ai): design.md-driven background + sidebar color pipeline - orchestrator-sidebar-color: extract sidebar surface picker; prefer design.md palette role (sidebar/panel/surface) over catalog style-guide legacy cell - orchestrator-planning: force rootFrame fill from design.md background when a user spec is provided, so sections don't inherit a bright catalog default - orchestrator-prompt-optimizer: infer design.md background + neutral theme fallback for sub-agent prompts - orchestrator-sub-agent / ai-prompts: tell sub-agents to leave section root fills unset when design.md drives the palette - design-md-style-policy: surface-colors policy block keeps MCP and web pipeline aligned - add planning + prompt-optimizer regression tests * chore: ignore .omx/ directory * Enable local OS fonts with vector rendering and proper permission handling (#110) * docs(readme): update cover screenshot * fix(renderer): enable local OS fonts with vector rendering and proper permission handling * test(renderer): refactoring names and creating vi.stubGlobal for the navigator as it's not available in the test environment. --------- Co-authored-by: Fini <fini.yang@gmail.com> Co-authored-by: Daniel Chettiar <danielc@snapwork.com> * feat(types): add AppendContext and SubTask.existingSectionLabels * feat(ai): add detectAppendIntent for continue/append prompts * feat(ai): detect append intent before generate_design dispatch * feat(ai): add applyAppendContextToPlan helper * feat(ai): reuse existing content-root in append mode * feat(ai): sub-agent APPEND MODE preamble for existing siblings * docs(ai): teach horizontal scroll card-row pattern * chore(ai): enable incremental-add skill in generation phase * fix(canvas): render synchronously on resize to prevent white flash Setting canvas.width/height clears the pixel buffer to transparent. resize() previously only marked dirty, leaving the canvas transparent until the next RAF and showing the container bg-muted through for one frame whenever the flex layout shifted (e.g. RightPanel mount on first selection after idle). Rendering inline after recreateSurface fills the new surface before the browser paints, closing that window. * style: apply oxfmt formatting drift across web and renderer files Non-semantic line-break and wrapping adjustments picked up by oxfmt. No behavior changes. * fix(mcp): run codex via shell on Windows to handle .cmd shims Since Node 18.20/20.12 (CVE-2024-27980) execFileSync refuses to spawn .cmd/.bat files directly and throws EINVAL. On Windows route through execSync with shell resolution so PATHEXT picks whichever shim exists (codex.exe / codex.cmd / codex.ps1). * feat(editor): anchor paste to selected container or sibling Pressing Cmd/Ctrl+V now inserts pasted nodes into the selected container (if it can hold children) or immediately after the selected node as a sibling, falling back to the root when nothing is selected. Previously every paste landed at document root, which broke expected behavior when working inside nested frames. * docs(ai): expand horizontal scroll card-row example in overflow skill Flesh out the inline JSON example so the generation-phase skill shows the full clipContent + nested fit_content row pattern, instead of a truncated snippet that left model output inconsistent. * style(lint): clear 7 oxlint warnings from recent commits - orchestrator-planning.test.ts: narrow fill-array type to Array<{...}> | undefined and use ?.[0] instead of unchecked [0] so optional chain does not throw on short-circuit - mcp-install.ts: drop `?? {}` fallbacks when spreading config.mcpServers; spread of undefined in an object literal is a no-op (ES2018+) * style(lint): clear remaining 15 oxlint warnings across repo Removes pre-existing warnings not related to any single feature: - no-useless-fallback-in-spread (6): drop `?? {}` when spreading possibly-undefined records (document-store-variable-actions, pen-mcp/tools/{variables,theme-presets}, variable-theme-manager) - no-useless-spread (2): replace `[...iterable]` with `Array.from` in for-of snapshots (document-events, agent-indicator), keeping the re-entry-safe copy intent explicit - no-control-regex (2): use `\P{ASCII}` unicode property escape instead of `[^\x00-\x7F]` to express "non-ASCII" without referencing U+0000 (opencode clients) - no-new-array (1): `Array.from({ length }, () => '..')` in document-assets - no-unused-vars (3): drop unused catch params (agent.ts, code-generation-pipeline) and unused globSync import (patch-srvx-bun) - no-useless-escape (1): `[[{]` instead of `[\[{]` in chat-message-content regex --------- Co-authored-by: Fini <fini.yang@gmail.com> Co-authored-by: Daniel Chettiar <74943095+1MochaChan1@users.noreply.github.com> Co-authored-by: Daniel Chettiar <danielc@snapwork.com>
This commit is contained in:
parent
e9b0d0d822
commit
e0b606833b
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -24,3 +24,4 @@ dist-electron/
|
|||
.claude
|
||||
.omx
|
||||
.worktrees/
|
||||
.omx/
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@zseven-w/desktop",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@zseven-w/web",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, any>): Record<string, any> {
|
||||
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<string, any>): 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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ function ThemeTabsHeaderInner({
|
|||
themes: Record<string, string[]>;
|
||||
variables: Record<string, VariableDefinition>;
|
||||
}) => {
|
||||
const mergedThemes = { ...(themes ?? {}), ...preset.themes };
|
||||
const mergedThemes = { ...themes, ...preset.themes };
|
||||
setThemes(mergedThemes);
|
||||
const currentVars = variables ?? {};
|
||||
for (const [name, def] of Object.entries(preset.variables)) {
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={handleToggle}
|
||||
className={cn(
|
||||
'flex items-center justify-between w-full h-6 px-2 text-[11px] rounded-md',
|
||||
'border border-border bg-card text-foreground',
|
||||
|
|
@ -137,7 +145,11 @@ export default function FontPicker({ value, onChange, className }: FontPickerPro
|
|||
style={{ fontFamily: value }}
|
||||
>
|
||||
<span className="truncate">{currentDisplay}</span>
|
||||
<ChevronDown className="w-3 h-3 shrink-0 text-muted-foreground ml-1" />
|
||||
{loading ? (
|
||||
<Loader2 className="w-3 h-3 shrink-0 text-muted-foreground ml-1 animate-spin" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 shrink-0 text-muted-foreground ml-1" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
|
|
@ -175,6 +187,15 @@ export default function FontPicker({ value, onChange, className }: FontPickerPro
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Permission denied info — user must change browser settings */}
|
||||
{permissionState === 'denied' && (
|
||||
<div className="px-2 py-1.5 border-b border-border">
|
||||
<p className="text-[9px] text-muted-foreground leading-tight">
|
||||
System fonts unavailable. Allow local fonts in browser settings to enable them.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bundled fonts group */}
|
||||
{bundled.length > 0 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string[]> | null = null;
|
||||
let cachedPermissionState: FontPermissionState | null = null;
|
||||
let fetchPromise: Promise<{ fonts: string[]; permission: FontPermissionState }> | null = null;
|
||||
|
||||
async function querySystemFonts(): Promise<string[]> {
|
||||
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<string[]> {
|
|||
.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<string[]>(cachedSystemFonts ?? []);
|
||||
const [loading, setLoading] = useState(!cachedSystemFonts);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [permissionState, setPermissionState] = useState<FontPermissionState>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<AgentEvent, { type: 'tool_call' }>;
|
||||
|
||||
|
|
@ -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 } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, import('@/types/variables').VariableDefinition>;
|
||||
themes?: Record<string, string[]>;
|
||||
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 */
|
||||
|
|
|
|||
109
apps/web/src/services/ai/append-intent-detector.ts
Normal file
109
apps/web/src/services/ai/append-intent-detector.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string, string> {
|
||||
return generationRemappedIds;
|
||||
|
|
|
|||
32
apps/web/src/services/ai/orchestrator-append.ts
Normal file
32
apps/web/src/services/ai/orchestrator-append.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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<T extends { meta: { name: string } }>(
|
||||
skills: T[],
|
||||
|
|
@ -14,22 +20,23 @@ export function filterPlanningSkillsForPrompt<T extends { meta: { name: string }
|
|||
export function parseOrchestratorResponse(
|
||||
raw: string,
|
||||
prompt: string,
|
||||
designMd?: DesignMdSpec,
|
||||
): { plan: OrchestratorPlan; repaired: boolean } | null {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
const direct = tryParsePlan(trimmed);
|
||||
const direct = tryParsePlan(trimmed, designMd);
|
||||
if (direct) return { plan: direct, repaired: false };
|
||||
|
||||
const repairedDirect = tryRepairPlan(trimmed, prompt);
|
||||
const repairedDirect = tryRepairPlan(trimmed, prompt, designMd);
|
||||
if (repairedDirect) return { plan: repairedDirect, repaired: true };
|
||||
|
||||
const fenceMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
||||
if (fenceMatch) {
|
||||
const fencedText = fenceMatch[1].trim();
|
||||
const fenced = tryParsePlan(fencedText);
|
||||
const fenced = tryParsePlan(fencedText, designMd);
|
||||
if (fenced) return { plan: fenced, repaired: false };
|
||||
|
||||
const repairedFenced = tryRepairPlan(fencedText, prompt);
|
||||
const repairedFenced = tryRepairPlan(fencedText, prompt, designMd);
|
||||
if (repairedFenced) return { plan: repairedFenced, repaired: true };
|
||||
}
|
||||
|
||||
|
|
@ -37,17 +44,17 @@ export function parseOrchestratorResponse(
|
|||
const lastBrace = trimmed.lastIndexOf('}');
|
||||
if (firstBrace >= 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
return repairPlanObject(obj, prompt);
|
||||
return repairPlanObject(obj, prompt, designMd);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function repairPlanObject(obj: Record<string, unknown>, prompt: string): OrchestratorPlan | null {
|
||||
const fallback = buildFallbackPlanFromPrompt(prompt);
|
||||
function repairPlanObject(
|
||||
obj: Record<string, unknown>,
|
||||
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<string, unknown>, 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<string, unknown>): OrchestratorPlan {
|
||||
function finalizePlan(
|
||||
plan: OrchestratorPlan,
|
||||
rawObj?: Record<string, unknown>,
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
39
apps/web/src/services/ai/orchestrator-sidebar-color.ts
Normal file
39
apps/web/src/services/ai/orchestrator-sidebar-color.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 <step> 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<string, VariableDefinition>;
|
||||
themes?: Record<string, string[]>;
|
||||
designMd?: DesignMdSpec;
|
||||
}): string {
|
||||
return buildSubAgentUserPrompt(
|
||||
args.subtask,
|
||||
args.plan,
|
||||
args.compactPrompt,
|
||||
args.fullPrompt,
|
||||
args.modelId,
|
||||
args.variables,
|
||||
args.themes,
|
||||
args.designMd,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<number, { color: string; name: string }>();
|
||||
|
|
@ -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<OrchestratorPlan> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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 || '.';
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class DocumentEventEmitter {
|
|||
const set = this.handlers[event] as Set<Handler<E>> | 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) {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 2c48403f81f43b19c9f2b7a60aca83646ab5c5f4
|
||||
Subproject commit 7a309968515df9bafb5d12eb4716299b402b556f
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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<string, VariableDefinition>;
|
||||
|
||||
await saveDocument(filePath, doc);
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>).queryLocalFonts;
|
||||
(globalThis as unknown as Record<string, unknown>).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<string, () => Promise<Array<{ family: string }>>>
|
||||
).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<string, unknown>).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<string, unknown[]> }).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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Blob>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>();
|
||||
/** In-flight font fetch promises to avoid duplicate requests */
|
||||
private pendingFetches = new Map<string, Promise<boolean>>();
|
||||
/** Cached set of native (OS-installed) font families from Local Font Access API (lowercase) */
|
||||
private nativeFontSet: Set<string> | null = null;
|
||||
/** Full native font entries with blob accessors, keyed by lowercase family name */
|
||||
private nativeFontMap = new Map<string, NativeFontEntry[]>();
|
||||
/** 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<boolean> {
|
||||
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<boolean> {
|
||||
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<Blob>;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
).queryLocalFonts();
|
||||
|
||||
const families = new Set<string>();
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
// 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<boolean> {
|
||||
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<Set<string>> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Reference in a new issue