openpencil/tests/engine/render/canvas/text.test.ts

673 lines
23 KiB
TypeScript
Raw Normal View History

2026-09-01 22:49:36 +00:00
/* eslint-disable max-lines -- text rendering scenarios share CanvasKit setup and fixtures */
import { describe, test, expect, mock, spyOn } from 'bun:test'
2026-05-03 18:03:40 +00:00
import {
detectTextDirection,
resolveTextDirection,
SceneGraph,
SkiaRenderer as SkiaRendererClass
} from '@open-pencil/core'
import type { SceneNode } from '@open-pencil/scene-graph'
2026-08-17 09:00:01 +00:00
import { createDefaultSourceMetadata } from '@open-pencil/scene-graph/node-defaults'
import { initCanvasKit } from '#cli/headless'
2026-05-03 18:03:40 +00:00
import type { SkiaRenderer } from '#core/canvas/renderer'
fix(fig): write text glyphs from the layout the renderer draws (#928) * fix(fig): write text glyphs from the layout the renderer draws Text without saved glyphs was written to .fig with outlines from a character-by-character fallback: one unwrapped line at y = lineHeight, no alignment, advances in pixels. Figma lays saved text out from that data, so every wrapped OpenPencil label opened in Figma on one line, and since saved glyphs now draw before the paragraph, OpenPencil did the same after a reopen (#914). The Figma clipboard had a second writer with its own shaping that matched outlines to characters by index. One builder in @open-pencil/fig now writes derivedTextData for both. It keeps glyphs a layer already has and otherwise asks the export runtime to shape the text. Core shapes with the paragraph the renderer draws, so lines, alignment, and baselines match, and takes each outline from the glyph ID CanvasKit chose, so ligatures and contextual forms keep their shapes. CanvasKit does not say which font drew a run, so outlines are written only when the run's own font covers it; otherwise the layout is written without outlines and readers lay the text out themselves. Advances are in em units and the character offset map has one entry per character, as in Figma's files. The reader drops glyphs the earlier fallback wrote, recognised by its offset map one entry longer than the text with every glyph on the one written baseline, and any glyph set with a missing outline. * fix(scene-graph): reflow resized text instead of stretching its glyphs Resizing scaled a text layer's saved glyphs into the new box. That suits path text, whose glyphs follow its path, but flat text reflows, and with saved glyphs drawn before the paragraph a resized Figma text layer was drawn stretched. Scale glyphs only for path text, including baked path text that kept only rotated glyphs, and let the width change drop the rest. * docs(fig): describe how derived text is written Figma draws saved text from derivedTextData even when it has the font, so the export docs record which glyphs the shared writer keeps, shapes, or leaves without outlines, and the units it writes. The README names the runtime service by what it now does. * fix(fig): write text without glyphs when shaping fails Glyphs are derived data, so a shaper error must not fail the .fig save or Figma clipboard copy that writes them. The builder now writes the layer without glyphs and logs a warning; the clipboard used to swallow the error silently, which hid a font-provider mismatch.
2026-10-06 11:37:02 +00:00
import { renderText } from '#core/canvas/scene'
import {
buildParagraph,
isNodeFontLoaded,
nodeFontReadiness,
textVerticalOffset
} from '#core/canvas/text'
import { transformTextCase } from '#core/text/case'
import { fontManager } from '#core/text/fonts'
import { fontFaceDemand, fontResolver, missingGlyphCharacters } from '#core/text/resolver'
import { expectDefined } from '#tests/helpers/assert'
import { repoPath } from '#tests/helpers/paths'
2026-05-03 18:03:40 +00:00
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
import { asDouble, expectRgbaPixels } from './helpers'
function createMockCanvas() {
return {
drawParagraph: mock(() => undefined),
drawPicture: mock(() => undefined),
drawText: mock(() => undefined),
drawPath: mock(() => undefined),
drawRect: mock(() => undefined),
save: mock(() => undefined),
saveLayer: mock(() => undefined),
restore: mock(() => undefined),
clipRect: mock(() => undefined),
fix(text): finalize font readiness and label shaping (#593) * fix(text): prepare browser fonts atomically - Fetch approved Fontsource resources directly in browsers with bounded responses and retryable provider failures - Limit page font resolution concurrency and retain live Inter substitution paragraphs after baked glyph invalidation - Shape frame, section, and component labels through a bounded native Paragraph cache - Cover real Geist, Geist Mono, and Roboto Mono browser loading plus substitution and cache lifecycle behavior * test(canvas): cover substitution and label shaping - Capture baked missing-font text before editing, live Inter substitution on first input, and visible undo output - Assert text-picture and derived-glyph invalidation with finalized substituted readiness - Cover shaped frame, rotated frame, section, component, component-set, ellipsis, kerning, and zoom label presentation * fix(text): preserve same-origin fetches during font resolution - Route same-origin application and fixture requests through native fetch while Unifont temporarily proxies global HTTP requests - Keep the external provider HTTPS allowlist enforced for cross-origin font resources - Cover the production race discovered while loading gold-preview.fig during provider initialization * fix(text): preserve substituted path glyphs - Keep imported derived curved glyphs for text-on-path layers when exact fonts finalize as substituted - Cover substituted path rendering through the runtime renderer and refresh the corrected visual oracle - Update shaped Inter measurement badges and merged typography panel snapshots after visual inspection - Search virtualized font catalogs explicitly and restore the canvas screenshot helper used by broad E2E coverage * fix(text): use browser font transport without desktop warning - Keep the browser provider implementation aligned with current settings behavior after splitting from preparation * test(text): inject same-origin browser font context - Keep the transport test deterministic outside a Window global * fix(text): preserve final browser font transport hardening - Carry the same-origin large-document bypass and bounded cross-origin provider checks from the preserved integration snapshot * fix(text): initialize font transport outside Window contexts - Keep unit and headless module imports safe while browsers use their current origin * test(text): satisfy async visual fixture contract - Return from the resolver setup continuation after requesting render * test(canvas): include paragraph cache lifecycle * fix(text): cancel queued browser font loads - Race serialized provider work against preparation cancellation\n- Release queue slots after cancelled waiters without disturbing active requests\n- Reuse one section-title paragraph cache entry for measurement and drawing * fix(text): close font queue cancellation races - Release reserved proxy queue slots when cancellation lands after queue acquisition\n- Skip paragraph work for zero-width section labels * fix(text): abort active provider resolution - Race active provider resolution against its preparation signal and restore the temporary fetch proxy promptly\n- Forward cancellation through proxied provider requests\n- Align the renderer font-readiness facade with substituted text
2026-08-30 11:33:32 +00:00
translate: mock(() => undefined),
scale: mock(() => undefined),
rotate: mock(() => undefined)
}
}
function createMockParagraph() {
return { delete: mock(() => undefined), getHeight: mock(() => 20) }
}
function createMockPicture() {
return { delete: mock(() => undefined) }
}
function createMockRenderer(overrides: Partial<Record<string, unknown>> = {}) {
const paragraph = createMockParagraph()
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
return asDouble<SkiaRenderer & { _paragraph: ReturnType<typeof createMockParagraph> }>({
fontsLoaded: true,
fontProvider: {},
textFont: {},
fillPaint: { getColor: () => new Float32Array([0, 0, 0, 1]) },
effectLayerPaint: {
setBlendMode: mock(() => undefined),
setColorFilter: mock(() => undefined),
setImageFilter: mock(() => undefined)
},
AI chat polishing: skeleton workflow, batched tools, visual feedback, stock photos (#111) * Fix text rendering: always use paragraph wrapping, font fallback, batch tools, severity levels Rendering: - renderText: always use paragraph when fontProvider available, not just when node-specific font loaded — fixes single-line overflow for Georgia/ non-Inter fonts - buildParagraph: add Inter as fallback fontFamily so paragraph wraps text even when requested font not yet loaded - measureTextNode/buildTextPicture: remove isNodeFontLoaded gate — measure with fallback font for consistent layout↔rendering - TEXT_PICTURE_KEYS: add width/height so textPicture invalidates on resize - renderText drawText fallback: clip to node bounds - onAfterExecute: invalidate textPictures after font load Layout: - estimateTextSize: accept maxWidth, compute multi-line height - toJSON: depth parameter with childCount fallback AI tools: - batch_update tool: multiple set_layout/set_layout_child in one call - describe: accept ids array for multi-node inspection - Issue severity (error/warning/info) with pattern classification - checkNestedFlexWithoutFill: cross-axis fill tolerance - CORE_TOOLS trimmed 34→27, rest moved to EXTENDED - System prompt: batch_update, describe ids, severity rules, font/text wrapping guidance, card grid patterns * Add Pexels stock photo integration for AI chat - stock_photo tool: search Pexels by query, download image, apply as IMAGE fill on any node. Picks resolution based on node dimensions. - Pexels API key stored in localStorage, synced to core via setPexelsApiKey on change - ProviderSettings UI: Pexels API key input with link to get free key - System prompt: stock photo usage guidance - Added to CORE_TOOLS so AI can use it in every session * Batch stock_photo + calc, 4-phase workflow, fix wrap false positives - stock_photo: accepts JSON array of requests, all fetched in parallel. Rejects nodes with children (leaf shapes only). One call replaces 14. - calc: accepts JSON array of expressions or single string. One call replaces 3 separate calc invocations. - System prompt: 4-phase workflow (plan → skeleton → content → polish). Batch stock_photo and calc examples. Updated step budget. - describe-layout-issues: skip 'nested flex may collapse' for wrap containers (pill/tag frames are intentionally HUG). Extract childNeedsFill helper to reduce complexity. * Fix nested flex false positive for pills, single-root render rule - describe-layout-issues: skip 'nested flex may collapse' when a sibling already has grow or fill — pill/badge frames next to flex-grow content are intentionally small (e.g. BreakingLabel next to ticker text) - System prompt: render must have exactly one root element. Stock_photo 401 fallback: tell user, don't eval gradients. * Rework workflow: render complete sections, not empty frames Phase 2 now prescribes 5-7 renders, each producing a complete section (nav, hero, stories, opinions, sidebar, footer) with placeholder images, real text, proper auto-layout. No more empty frames to fill later. Removes the fill content phase — skeleton IS the content, just without real photos. Phase 3 is now just polish (batch stock_photo + fixes). Step budget: 12-15 steps instead of 15-30. * Pexels key input: match AI key behavior Same pattern as the AI provider key — intermediate ref, 'Key saved — enter new to replace' placeholder, Clear button, save on change. No longer exposes the raw key in the input field. * No empty frames in skeleton, select nodes after each tool - System prompt: explicit rules against empty frames, no pre-creating wrapper frames for future content. Each render = complete section with visible placeholders. Render 1 = only nav+ticker, not the whole page with empty containers. - tools.ts: set selection to flash node IDs after each mutating tool, so user sees which section AI is working on (selection border persists vs flash which is transient). * AI overlay: pulsing blue while working, green flash on done; skeleton workflow Rendering: - ai-overlays.ts: new module with pulsing blue border (sinusoidal opacity 0.3-0.8 at 1.5s period) for nodes AI is working on, green fade-out flash (800ms) when done - renderer.ts: aiMarkActive/aiMarkDone/aiClearAll methods, drawn after regular flashes - constants.ts: AI_ACTIVE_COLOR, AI_DONE_COLOR, timing constants Integration: - ai-adapter.ts: onBeforeExecute now receives args (for target ID) - tools.ts: marks target node (id or parent_id) active before tool executes, marks done via onFlashNodes after completion - editor.ts: aiMarkActive/aiMarkDone/aiClearAll forwarded to renderer Workflow: - System prompt: 3-phase skeleton workflow. Phase 2 renders gray placeholder blocks (Rectangle bg=#E2E8F0) for every section — page looks like wireframe before any real content. Phase 3 replaces skeletons with real content. Phase 4 batch stock_photo + polish. * render replace_id: atomic skeleton-to-content swap, fix AI overlay lifecycle render tool: - New replace_id parameter — renders new content, places it at the skeleton's position in parent, then deletes the skeleton. No visual gap between skeleton removal and content appearance. AI overlay fixes: - Track lastActiveIds across before/after execute — aiMarkDone clears the exact nodes that were marked active, not just the result IDs - aiFlashDone: direct green flash on result nodes (not gated by _aiActiveNodes membership) - replace_id included in target ID detection for active marking System prompt: - Phase 3 uses render({ replace_id }) instead of delete_node + render * Fix gradient fills, nested flex false positives, render insert_index, h=hug prompt - set_fill: gradient support with color_end + direction (top-bottom, left-right, etc). Uses proper Matrix type. No more eval for gradients. - describe-layout-issues: skip nested flex warning for all non-MIN alignments (SPACE_BETWEEN, CENTER, MAX) — not just SPACE_BETWEEN - render: insert_index parameter for controlling child order - system prompt: page frame h="hug" not fixed height - AI overlay: mark active on any tool with target ID (not just mutating), clear previous active in onBeforeExecute * Add desktop business news site example to system prompt Full workflow example: skeleton with gray placeholders → replace_id content fills → batch stock_photo. Shows 12-col grid pattern, card components, section headers, sidebar widgets, h=hug page frame. Complements the existing mobile app UI example. * Drop blue active pulse, keep only green done flash Blue active marking was invisible — tool execution is 20-80ms (one frame), AI thinking happens between tool calls where no hooks fire. Green done flash on result nodes works and is visible. * Blue pulse on skeleton during AI tool streaming Watch streaming message parts in ChatPanel — when a tool part appears with state 'call'/'partial-call' and has replace_id or parent_id in args, mark that node active (blue pulse). When tool transitions to 'output-available', mark done (green flash). This shows the pulsing blue border on the skeleton wireframe frame while AI generates JSX, and switches to green when the content replaces it. * AI overlay debug log section, watch tool parts by toolCallId - chat-debug.ts: aiOverlayLog array + AI OVERLAY LOG section in debug output showing every part/mark-active/mark-done event with timestamps - ChatPanel.vue: check parts by toolCallId instead of isToolUIPart, log all overlay events to aiOverlayLog for debugging * Fix AI overlay: use 'input' not 'args' for tool part params Vercel AI SDK UIToolInvocation uses 'input' field (not 'args') for tool call parameters. The watch was checking p.args which was always undefined, so no overlay events ever fired. Also use p.type instead of p.toolName for the log. * Move AI overlay to onBeforeExecute/onFlashNodes, reorder render params - onBeforeExecute: mark replace_id/parent_id active (blue pulse) - onFlashNodes: clear active, flash done on new nodes (green) - Reorder render tool params: replace_id first so streaming partial input reveals target ID before JSX body - Remove ChatPanel watch — all overlay logic in tools.ts now - Add aiClearActive() to renderer (clears active without touching done flashes) * Wait 2 rAF frames after marking active before tool execute onBeforeExecute marks the target node active (blue pulse), but execute ran immediately after without yielding to the render loop. The blue frame was never painted because markActive → execute → clearActive all happened in the same JS task. Now await two requestAnimationFrame callbacks so the canvas renders at least one blue-pulse frame before execution starts. * Add comprehensive AI overlay debug logging to chat debug log Both streaming watch (ChatPanel) and tool execution hooks (tools.ts) now log every event to aiOverlayLog with full state: renderer exists, activeNodes count, hasFlashes, node existence. This will show exactly what happens and why the blue pulse is or isn't visible. * Filter partial node IDs in streaming overlay watch During input-streaming, replace_id may be partially received (e.g. '0:' without the number part). Guard with isValidNodeId check. Also refactor watch callback into collectPendingTargets + syncOverlayState to stay under complexity limit. Analysis of last debug log: - Hero: only 235ms active (streaming didn't deliver replace_id early) - Stories: 14.8s active — should be clearly visible - Opinions: 12.5s, LatestNews: 11.5s, Stocks: 9.8s — all long enough - Hero was the only one too fast to notice * Fix undo race, JSX pragma conflict, partial ID overlay, add deletion log - Fix undo snapshot race condition: replace shared `let beforeSnapshot` with a per-tool stack. Vercel AI SDK runs tool calls from one step via Promise.all — concurrent mutating tools overwrote the shared snapshot, causing wrong undo entries and potential content loss. - Fix 'h is not a function' JSX error: rename sucrase pragma from `h` to `__h`. When AI writes `.map((h, i) => ...)`, the loop variable shadowed the pragma, crashing render. - Fix partial node ID `0:1` in overlay: strengthen `isValidNodeId` to require both parts ≥2 chars and verify node exists in graph. During streaming, `replace_id: "0:1811"` arrives as `0:1` → `0:18` → etc. - Add deletion log to SceneGraph: `_deletionLog` records id, name, type, parentId, timestamp, and truncated stack for last 200 deletions. Included in debug log as NODE DELETION LOG section to diagnose the intermittent frame disappearance bug. * Reduce find_nodes spam and describe bloat in system prompt - Instruct AI to reuse IDs from render results and describe output instead of calling find_nodes to rediscover them (saves 8+ calls) - Recommend depth=1 for full-page describe, depth=2 only for sections * Auto-depth describe + validate-after-render workflow Describe tool now auto-picks depth based on subtree size when depth is omitted: ≤15 nodes → depth 4, ≤40 → 3, ≤100 → 2, larger → 1. Max raised to 5 for explicit overrides. Prompt updated: after each render replace_id, call describe on that block (auto depth gives full detail on small sections). Every 2-3 content renders, describe root at depth=1 to catch cross-section layout issues early. * Enforce describe-after-every-render in system prompt Show explicit render → describe → fix pattern with code example. AI was skipping per-block validation and batching all describes at the end, causing compounding layout errors. * Add describe rules: nested text, missing padding, button padding, row centering Four new layout issue detectors in describe-issues.ts: - Nested Text (error): Text node inside Text causes overflow. Suggestion: split into separate Text elements in flex row. - Zero padding container (warning): Frame with fill + text children but no padding — content touches edges. - Button without padding (warning): Button-like frame with children but zero horizontal padding. - Row without vertical centering (warning): Horizontal flex with items=start, tall container, and short children — suggests items=center. All verified with bun -e tests including false positive checks. * Fix gradient alpha loss, add undersize child detection Gradient fix: set_fill with color_end was overwriting alpha to 1 on both gradient stops (`{ ...c, a: 1 }`). Now preserves original alpha from parsed hex — #00000000 → a:0, #000000AA → a:0.667. This caused transparent-to-dark overlays to render as solid black. New describe rule: detect child significantly narrower than parent in a container without auto-layout (e.g. 100px Rectangle inside 896px Frame). Suggests adding flex + w=fill. Caught the HeroImg bug that describe previously missed. * Auto-include issues in render result, no separate describe needed Render tool now scans the created subtree for errors/warnings and includes them in the response. AI gets issues automatically without needing to call describe — eliminates the pattern where AI skips describe on 'simple' sidebar blocks. Example response: { id: '0:42', name: 'Card', issues: [ { node: 'Text', id: '0:45', message: 'no color', severity: 'error' } ] } * Run layout before collecting render issues Issues were checked on raw node sizes (default 100px) before computeAllLayouts ran in onAfterExecute. Now render calls computeAllLayouts inline before collectSubtreeIssues, so issues reflect actual computed dimensions. This fixes hundreds of false 'X 100px overflows parent' warnings that made AI chase nonexistent problems. * Revert auto-issues in render and layout-before-issues Reverts 09a9b89 and 70c116a. Auto-including issues in render result caused false positives (100px default sizes before layout) and made AI chase nonexistent problems, degrading output quality. * Clean up AI chat PR: remove debug scaffolding, add stock photo provider adapter - Delete _deletionLog (stack traces on every node deletion) - Delete aiOverlayLog and all debug push() calls - Delete succesful_prompt_diffucult_ux.md (raw debug dump) and tokens.md (empty) - Remove double-rAF hack from ai-adapter (added 32ms to every tool call) - Restore simple beforeSnapshot for undo (was matching by tool name) - Remove 70-line overlay state tracker from ChatPanel.vue - Remove Math.* regex hack from calc (expr-eval has builtins) - Refactor stock-photo into provider adapter: Pexels + Unsplash - Add Unsplash key management to settings UI and use-chat composable --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-15 12:42:41 +00:00
ck: {
MakePicture: mock(() => createMockPicture()),
PathBuilder: class {
moveTo = mock(() => this)
lineTo = mock(() => this)
cubicTo = mock(() => this)
quadTo = mock(() => this)
close = mock(() => this)
fix(text): finalize font readiness and label shaping (#593) * fix(text): prepare browser fonts atomically - Fetch approved Fontsource resources directly in browsers with bounded responses and retryable provider failures - Limit page font resolution concurrency and retain live Inter substitution paragraphs after baked glyph invalidation - Shape frame, section, and component labels through a bounded native Paragraph cache - Cover real Geist, Geist Mono, and Roboto Mono browser loading plus substitution and cache lifecycle behavior * test(canvas): cover substitution and label shaping - Capture baked missing-font text before editing, live Inter substitution on first input, and visible undo output - Assert text-picture and derived-glyph invalidation with finalized substituted readiness - Cover shaped frame, rotated frame, section, component, component-set, ellipsis, kerning, and zoom label presentation * fix(text): preserve same-origin fetches during font resolution - Route same-origin application and fixture requests through native fetch while Unifont temporarily proxies global HTTP requests - Keep the external provider HTTPS allowlist enforced for cross-origin font resources - Cover the production race discovered while loading gold-preview.fig during provider initialization * fix(text): preserve substituted path glyphs - Keep imported derived curved glyphs for text-on-path layers when exact fonts finalize as substituted - Cover substituted path rendering through the runtime renderer and refresh the corrected visual oracle - Update shaped Inter measurement badges and merged typography panel snapshots after visual inspection - Search virtualized font catalogs explicitly and restore the canvas screenshot helper used by broad E2E coverage * fix(text): use browser font transport without desktop warning - Keep the browser provider implementation aligned with current settings behavior after splitting from preparation * test(text): inject same-origin browser font context - Keep the transport test deterministic outside a Window global * fix(text): preserve final browser font transport hardening - Carry the same-origin large-document bypass and bounded cross-origin provider checks from the preserved integration snapshot * fix(text): initialize font transport outside Window contexts - Keep unit and headless module imports safe while browsers use their current origin * test(text): satisfy async visual fixture contract - Return from the resolver setup continuation after requesting render * test(canvas): include paragraph cache lifecycle * fix(text): cancel queued browser font loads - Race serialized provider work against preparation cancellation\n- Release queue slots after cancelled waiters without disturbing active requests\n- Reuse one section-title paragraph cache entry for measurement and drawing * fix(text): close font queue cancellation races - Release reserved proxy queue slots when cancellation lands after queue acquisition\n- Skip paragraph work for zero-width section labels * fix(text): abort active provider resolution - Race active provider resolution against its preparation signal and restore the temporary fetch proxy promptly\n- Forward cancellation through proxied provider requests\n- Align the renderer font-readiness facade with substituted text
2026-08-30 11:33:32 +00:00
setFillType = mock(() => this)
delete = mock(() => undefined)
fix(text): finalize font readiness and label shaping (#593) * fix(text): prepare browser fonts atomically - Fetch approved Fontsource resources directly in browsers with bounded responses and retryable provider failures - Limit page font resolution concurrency and retain live Inter substitution paragraphs after baked glyph invalidation - Shape frame, section, and component labels through a bounded native Paragraph cache - Cover real Geist, Geist Mono, and Roboto Mono browser loading plus substitution and cache lifecycle behavior * test(canvas): cover substitution and label shaping - Capture baked missing-font text before editing, live Inter substitution on first input, and visible undo output - Assert text-picture and derived-glyph invalidation with finalized substituted readiness - Cover shaped frame, rotated frame, section, component, component-set, ellipsis, kerning, and zoom label presentation * fix(text): preserve same-origin fetches during font resolution - Route same-origin application and fixture requests through native fetch while Unifont temporarily proxies global HTTP requests - Keep the external provider HTTPS allowlist enforced for cross-origin font resources - Cover the production race discovered while loading gold-preview.fig during provider initialization * fix(text): preserve substituted path glyphs - Keep imported derived curved glyphs for text-on-path layers when exact fonts finalize as substituted - Cover substituted path rendering through the runtime renderer and refresh the corrected visual oracle - Update shaped Inter measurement badges and merged typography panel snapshots after visual inspection - Search virtualized font catalogs explicitly and restore the canvas screenshot helper used by broad E2E coverage * fix(text): use browser font transport without desktop warning - Keep the browser provider implementation aligned with current settings behavior after splitting from preparation * test(text): inject same-origin browser font context - Keep the transport test deterministic outside a Window global * fix(text): preserve final browser font transport hardening - Carry the same-origin large-document bypass and bounded cross-origin provider checks from the preserved integration snapshot * fix(text): initialize font transport outside Window contexts - Keep unit and headless module imports safe while browsers use their current origin * test(text): satisfy async visual fixture contract - Return from the resolver setup continuation after requesting render * test(canvas): include paragraph cache lifecycle * fix(text): cancel queued browser font loads - Race serialized provider work against preparation cancellation\n- Release queue slots after cancelled waiters without disturbing active requests\n- Reuse one section-title paragraph cache entry for measurement and drawing * fix(text): close font queue cancellation races - Release reserved proxy queue slots when cancellation lands after queue acquisition\n- Skip paragraph work for zero-width section labels * fix(text): abort active provider resolution - Race active provider resolution against its preparation signal and restore the temporary fetch proxy promptly\n- Forward cancellation through proxied provider requests\n- Align the renderer font-readiness facade with substituted text
2026-08-30 11:33:32 +00:00
detachAndDelete = mock(() => ({
delete: mock(() => undefined),
setFillType: mock(() => undefined)
}))
},
AI chat polishing: skeleton workflow, batched tools, visual feedback, stock photos (#111) * Fix text rendering: always use paragraph wrapping, font fallback, batch tools, severity levels Rendering: - renderText: always use paragraph when fontProvider available, not just when node-specific font loaded — fixes single-line overflow for Georgia/ non-Inter fonts - buildParagraph: add Inter as fallback fontFamily so paragraph wraps text even when requested font not yet loaded - measureTextNode/buildTextPicture: remove isNodeFontLoaded gate — measure with fallback font for consistent layout↔rendering - TEXT_PICTURE_KEYS: add width/height so textPicture invalidates on resize - renderText drawText fallback: clip to node bounds - onAfterExecute: invalidate textPictures after font load Layout: - estimateTextSize: accept maxWidth, compute multi-line height - toJSON: depth parameter with childCount fallback AI tools: - batch_update tool: multiple set_layout/set_layout_child in one call - describe: accept ids array for multi-node inspection - Issue severity (error/warning/info) with pattern classification - checkNestedFlexWithoutFill: cross-axis fill tolerance - CORE_TOOLS trimmed 34→27, rest moved to EXTENDED - System prompt: batch_update, describe ids, severity rules, font/text wrapping guidance, card grid patterns * Add Pexels stock photo integration for AI chat - stock_photo tool: search Pexels by query, download image, apply as IMAGE fill on any node. Picks resolution based on node dimensions. - Pexels API key stored in localStorage, synced to core via setPexelsApiKey on change - ProviderSettings UI: Pexels API key input with link to get free key - System prompt: stock photo usage guidance - Added to CORE_TOOLS so AI can use it in every session * Batch stock_photo + calc, 4-phase workflow, fix wrap false positives - stock_photo: accepts JSON array of requests, all fetched in parallel. Rejects nodes with children (leaf shapes only). One call replaces 14. - calc: accepts JSON array of expressions or single string. One call replaces 3 separate calc invocations. - System prompt: 4-phase workflow (plan → skeleton → content → polish). Batch stock_photo and calc examples. Updated step budget. - describe-layout-issues: skip 'nested flex may collapse' for wrap containers (pill/tag frames are intentionally HUG). Extract childNeedsFill helper to reduce complexity. * Fix nested flex false positive for pills, single-root render rule - describe-layout-issues: skip 'nested flex may collapse' when a sibling already has grow or fill — pill/badge frames next to flex-grow content are intentionally small (e.g. BreakingLabel next to ticker text) - System prompt: render must have exactly one root element. Stock_photo 401 fallback: tell user, don't eval gradients. * Rework workflow: render complete sections, not empty frames Phase 2 now prescribes 5-7 renders, each producing a complete section (nav, hero, stories, opinions, sidebar, footer) with placeholder images, real text, proper auto-layout. No more empty frames to fill later. Removes the fill content phase — skeleton IS the content, just without real photos. Phase 3 is now just polish (batch stock_photo + fixes). Step budget: 12-15 steps instead of 15-30. * Pexels key input: match AI key behavior Same pattern as the AI provider key — intermediate ref, 'Key saved — enter new to replace' placeholder, Clear button, save on change. No longer exposes the raw key in the input field. * No empty frames in skeleton, select nodes after each tool - System prompt: explicit rules against empty frames, no pre-creating wrapper frames for future content. Each render = complete section with visible placeholders. Render 1 = only nav+ticker, not the whole page with empty containers. - tools.ts: set selection to flash node IDs after each mutating tool, so user sees which section AI is working on (selection border persists vs flash which is transient). * AI overlay: pulsing blue while working, green flash on done; skeleton workflow Rendering: - ai-overlays.ts: new module with pulsing blue border (sinusoidal opacity 0.3-0.8 at 1.5s period) for nodes AI is working on, green fade-out flash (800ms) when done - renderer.ts: aiMarkActive/aiMarkDone/aiClearAll methods, drawn after regular flashes - constants.ts: AI_ACTIVE_COLOR, AI_DONE_COLOR, timing constants Integration: - ai-adapter.ts: onBeforeExecute now receives args (for target ID) - tools.ts: marks target node (id or parent_id) active before tool executes, marks done via onFlashNodes after completion - editor.ts: aiMarkActive/aiMarkDone/aiClearAll forwarded to renderer Workflow: - System prompt: 3-phase skeleton workflow. Phase 2 renders gray placeholder blocks (Rectangle bg=#E2E8F0) for every section — page looks like wireframe before any real content. Phase 3 replaces skeletons with real content. Phase 4 batch stock_photo + polish. * render replace_id: atomic skeleton-to-content swap, fix AI overlay lifecycle render tool: - New replace_id parameter — renders new content, places it at the skeleton's position in parent, then deletes the skeleton. No visual gap between skeleton removal and content appearance. AI overlay fixes: - Track lastActiveIds across before/after execute — aiMarkDone clears the exact nodes that were marked active, not just the result IDs - aiFlashDone: direct green flash on result nodes (not gated by _aiActiveNodes membership) - replace_id included in target ID detection for active marking System prompt: - Phase 3 uses render({ replace_id }) instead of delete_node + render * Fix gradient fills, nested flex false positives, render insert_index, h=hug prompt - set_fill: gradient support with color_end + direction (top-bottom, left-right, etc). Uses proper Matrix type. No more eval for gradients. - describe-layout-issues: skip nested flex warning for all non-MIN alignments (SPACE_BETWEEN, CENTER, MAX) — not just SPACE_BETWEEN - render: insert_index parameter for controlling child order - system prompt: page frame h="hug" not fixed height - AI overlay: mark active on any tool with target ID (not just mutating), clear previous active in onBeforeExecute * Add desktop business news site example to system prompt Full workflow example: skeleton with gray placeholders → replace_id content fills → batch stock_photo. Shows 12-col grid pattern, card components, section headers, sidebar widgets, h=hug page frame. Complements the existing mobile app UI example. * Drop blue active pulse, keep only green done flash Blue active marking was invisible — tool execution is 20-80ms (one frame), AI thinking happens between tool calls where no hooks fire. Green done flash on result nodes works and is visible. * Blue pulse on skeleton during AI tool streaming Watch streaming message parts in ChatPanel — when a tool part appears with state 'call'/'partial-call' and has replace_id or parent_id in args, mark that node active (blue pulse). When tool transitions to 'output-available', mark done (green flash). This shows the pulsing blue border on the skeleton wireframe frame while AI generates JSX, and switches to green when the content replaces it. * AI overlay debug log section, watch tool parts by toolCallId - chat-debug.ts: aiOverlayLog array + AI OVERLAY LOG section in debug output showing every part/mark-active/mark-done event with timestamps - ChatPanel.vue: check parts by toolCallId instead of isToolUIPart, log all overlay events to aiOverlayLog for debugging * Fix AI overlay: use 'input' not 'args' for tool part params Vercel AI SDK UIToolInvocation uses 'input' field (not 'args') for tool call parameters. The watch was checking p.args which was always undefined, so no overlay events ever fired. Also use p.type instead of p.toolName for the log. * Move AI overlay to onBeforeExecute/onFlashNodes, reorder render params - onBeforeExecute: mark replace_id/parent_id active (blue pulse) - onFlashNodes: clear active, flash done on new nodes (green) - Reorder render tool params: replace_id first so streaming partial input reveals target ID before JSX body - Remove ChatPanel watch — all overlay logic in tools.ts now - Add aiClearActive() to renderer (clears active without touching done flashes) * Wait 2 rAF frames after marking active before tool execute onBeforeExecute marks the target node active (blue pulse), but execute ran immediately after without yielding to the render loop. The blue frame was never painted because markActive → execute → clearActive all happened in the same JS task. Now await two requestAnimationFrame callbacks so the canvas renders at least one blue-pulse frame before execution starts. * Add comprehensive AI overlay debug logging to chat debug log Both streaming watch (ChatPanel) and tool execution hooks (tools.ts) now log every event to aiOverlayLog with full state: renderer exists, activeNodes count, hasFlashes, node existence. This will show exactly what happens and why the blue pulse is or isn't visible. * Filter partial node IDs in streaming overlay watch During input-streaming, replace_id may be partially received (e.g. '0:' without the number part). Guard with isValidNodeId check. Also refactor watch callback into collectPendingTargets + syncOverlayState to stay under complexity limit. Analysis of last debug log: - Hero: only 235ms active (streaming didn't deliver replace_id early) - Stories: 14.8s active — should be clearly visible - Opinions: 12.5s, LatestNews: 11.5s, Stocks: 9.8s — all long enough - Hero was the only one too fast to notice * Fix undo race, JSX pragma conflict, partial ID overlay, add deletion log - Fix undo snapshot race condition: replace shared `let beforeSnapshot` with a per-tool stack. Vercel AI SDK runs tool calls from one step via Promise.all — concurrent mutating tools overwrote the shared snapshot, causing wrong undo entries and potential content loss. - Fix 'h is not a function' JSX error: rename sucrase pragma from `h` to `__h`. When AI writes `.map((h, i) => ...)`, the loop variable shadowed the pragma, crashing render. - Fix partial node ID `0:1` in overlay: strengthen `isValidNodeId` to require both parts ≥2 chars and verify node exists in graph. During streaming, `replace_id: "0:1811"` arrives as `0:1` → `0:18` → etc. - Add deletion log to SceneGraph: `_deletionLog` records id, name, type, parentId, timestamp, and truncated stack for last 200 deletions. Included in debug log as NODE DELETION LOG section to diagnose the intermittent frame disappearance bug. * Reduce find_nodes spam and describe bloat in system prompt - Instruct AI to reuse IDs from render results and describe output instead of calling find_nodes to rediscover them (saves 8+ calls) - Recommend depth=1 for full-page describe, depth=2 only for sections * Auto-depth describe + validate-after-render workflow Describe tool now auto-picks depth based on subtree size when depth is omitted: ≤15 nodes → depth 4, ≤40 → 3, ≤100 → 2, larger → 1. Max raised to 5 for explicit overrides. Prompt updated: after each render replace_id, call describe on that block (auto depth gives full detail on small sections). Every 2-3 content renders, describe root at depth=1 to catch cross-section layout issues early. * Enforce describe-after-every-render in system prompt Show explicit render → describe → fix pattern with code example. AI was skipping per-block validation and batching all describes at the end, causing compounding layout errors. * Add describe rules: nested text, missing padding, button padding, row centering Four new layout issue detectors in describe-issues.ts: - Nested Text (error): Text node inside Text causes overflow. Suggestion: split into separate Text elements in flex row. - Zero padding container (warning): Frame with fill + text children but no padding — content touches edges. - Button without padding (warning): Button-like frame with children but zero horizontal padding. - Row without vertical centering (warning): Horizontal flex with items=start, tall container, and short children — suggests items=center. All verified with bun -e tests including false positive checks. * Fix gradient alpha loss, add undersize child detection Gradient fix: set_fill with color_end was overwriting alpha to 1 on both gradient stops (`{ ...c, a: 1 }`). Now preserves original alpha from parsed hex — #00000000 → a:0, #000000AA → a:0.667. This caused transparent-to-dark overlays to render as solid black. New describe rule: detect child significantly narrower than parent in a container without auto-layout (e.g. 100px Rectangle inside 896px Frame). Suggests adding flex + w=fill. Caught the HeroImg bug that describe previously missed. * Auto-include issues in render result, no separate describe needed Render tool now scans the created subtree for errors/warnings and includes them in the response. AI gets issues automatically without needing to call describe — eliminates the pattern where AI skips describe on 'simple' sidebar blocks. Example response: { id: '0:42', name: 'Card', issues: [ { node: 'Text', id: '0:45', message: 'no color', severity: 'error' } ] } * Run layout before collecting render issues Issues were checked on raw node sizes (default 100px) before computeAllLayouts ran in onAfterExecute. Now render calls computeAllLayouts inline before collectSubtreeIssues, so issues reflect actual computed dimensions. This fixes hundreds of false 'X 100px overflows parent' warnings that made AI chase nonexistent problems. * Revert auto-issues in render and layout-before-issues Reverts 09a9b89 and 70c116a. Auto-including issues in render result caused false positives (100px default sizes before layout) and made AI chase nonexistent problems, degrading output quality. * Clean up AI chat PR: remove debug scaffolding, add stock photo provider adapter - Delete _deletionLog (stack traces on every node deletion) - Delete aiOverlayLog and all debug push() calls - Delete succesful_prompt_diffucult_ux.md (raw debug dump) and tokens.md (empty) - Remove double-rAF hack from ai-adapter (added 32ms to every tool call) - Restore simple beforeSnapshot for undo (was matching by tool name) - Remove 70-line overlay state tracker from ChatPanel.vue - Remove Math.* regex hack from calc (expr-eval has builtins) - Refactor stock-photo into provider adapter: Pexels + Unsplash - Add Unsplash key management to settings UI and use-chat composable --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-15 12:42:41 +00:00
LTRBRect: mock((...args: number[]) => args),
Color4f: mock((...args: number[]) => new Float32Array(args)),
BlendMode: { SrcOver: 0, SrcIn: 1 },
AI chat polishing: skeleton workflow, batched tools, visual feedback, stock photos (#111) * Fix text rendering: always use paragraph wrapping, font fallback, batch tools, severity levels Rendering: - renderText: always use paragraph when fontProvider available, not just when node-specific font loaded — fixes single-line overflow for Georgia/ non-Inter fonts - buildParagraph: add Inter as fallback fontFamily so paragraph wraps text even when requested font not yet loaded - measureTextNode/buildTextPicture: remove isNodeFontLoaded gate — measure with fallback font for consistent layout↔rendering - TEXT_PICTURE_KEYS: add width/height so textPicture invalidates on resize - renderText drawText fallback: clip to node bounds - onAfterExecute: invalidate textPictures after font load Layout: - estimateTextSize: accept maxWidth, compute multi-line height - toJSON: depth parameter with childCount fallback AI tools: - batch_update tool: multiple set_layout/set_layout_child in one call - describe: accept ids array for multi-node inspection - Issue severity (error/warning/info) with pattern classification - checkNestedFlexWithoutFill: cross-axis fill tolerance - CORE_TOOLS trimmed 34→27, rest moved to EXTENDED - System prompt: batch_update, describe ids, severity rules, font/text wrapping guidance, card grid patterns * Add Pexels stock photo integration for AI chat - stock_photo tool: search Pexels by query, download image, apply as IMAGE fill on any node. Picks resolution based on node dimensions. - Pexels API key stored in localStorage, synced to core via setPexelsApiKey on change - ProviderSettings UI: Pexels API key input with link to get free key - System prompt: stock photo usage guidance - Added to CORE_TOOLS so AI can use it in every session * Batch stock_photo + calc, 4-phase workflow, fix wrap false positives - stock_photo: accepts JSON array of requests, all fetched in parallel. Rejects nodes with children (leaf shapes only). One call replaces 14. - calc: accepts JSON array of expressions or single string. One call replaces 3 separate calc invocations. - System prompt: 4-phase workflow (plan → skeleton → content → polish). Batch stock_photo and calc examples. Updated step budget. - describe-layout-issues: skip 'nested flex may collapse' for wrap containers (pill/tag frames are intentionally HUG). Extract childNeedsFill helper to reduce complexity. * Fix nested flex false positive for pills, single-root render rule - describe-layout-issues: skip 'nested flex may collapse' when a sibling already has grow or fill — pill/badge frames next to flex-grow content are intentionally small (e.g. BreakingLabel next to ticker text) - System prompt: render must have exactly one root element. Stock_photo 401 fallback: tell user, don't eval gradients. * Rework workflow: render complete sections, not empty frames Phase 2 now prescribes 5-7 renders, each producing a complete section (nav, hero, stories, opinions, sidebar, footer) with placeholder images, real text, proper auto-layout. No more empty frames to fill later. Removes the fill content phase — skeleton IS the content, just without real photos. Phase 3 is now just polish (batch stock_photo + fixes). Step budget: 12-15 steps instead of 15-30. * Pexels key input: match AI key behavior Same pattern as the AI provider key — intermediate ref, 'Key saved — enter new to replace' placeholder, Clear button, save on change. No longer exposes the raw key in the input field. * No empty frames in skeleton, select nodes after each tool - System prompt: explicit rules against empty frames, no pre-creating wrapper frames for future content. Each render = complete section with visible placeholders. Render 1 = only nav+ticker, not the whole page with empty containers. - tools.ts: set selection to flash node IDs after each mutating tool, so user sees which section AI is working on (selection border persists vs flash which is transient). * AI overlay: pulsing blue while working, green flash on done; skeleton workflow Rendering: - ai-overlays.ts: new module with pulsing blue border (sinusoidal opacity 0.3-0.8 at 1.5s period) for nodes AI is working on, green fade-out flash (800ms) when done - renderer.ts: aiMarkActive/aiMarkDone/aiClearAll methods, drawn after regular flashes - constants.ts: AI_ACTIVE_COLOR, AI_DONE_COLOR, timing constants Integration: - ai-adapter.ts: onBeforeExecute now receives args (for target ID) - tools.ts: marks target node (id or parent_id) active before tool executes, marks done via onFlashNodes after completion - editor.ts: aiMarkActive/aiMarkDone/aiClearAll forwarded to renderer Workflow: - System prompt: 3-phase skeleton workflow. Phase 2 renders gray placeholder blocks (Rectangle bg=#E2E8F0) for every section — page looks like wireframe before any real content. Phase 3 replaces skeletons with real content. Phase 4 batch stock_photo + polish. * render replace_id: atomic skeleton-to-content swap, fix AI overlay lifecycle render tool: - New replace_id parameter — renders new content, places it at the skeleton's position in parent, then deletes the skeleton. No visual gap between skeleton removal and content appearance. AI overlay fixes: - Track lastActiveIds across before/after execute — aiMarkDone clears the exact nodes that were marked active, not just the result IDs - aiFlashDone: direct green flash on result nodes (not gated by _aiActiveNodes membership) - replace_id included in target ID detection for active marking System prompt: - Phase 3 uses render({ replace_id }) instead of delete_node + render * Fix gradient fills, nested flex false positives, render insert_index, h=hug prompt - set_fill: gradient support with color_end + direction (top-bottom, left-right, etc). Uses proper Matrix type. No more eval for gradients. - describe-layout-issues: skip nested flex warning for all non-MIN alignments (SPACE_BETWEEN, CENTER, MAX) — not just SPACE_BETWEEN - render: insert_index parameter for controlling child order - system prompt: page frame h="hug" not fixed height - AI overlay: mark active on any tool with target ID (not just mutating), clear previous active in onBeforeExecute * Add desktop business news site example to system prompt Full workflow example: skeleton with gray placeholders → replace_id content fills → batch stock_photo. Shows 12-col grid pattern, card components, section headers, sidebar widgets, h=hug page frame. Complements the existing mobile app UI example. * Drop blue active pulse, keep only green done flash Blue active marking was invisible — tool execution is 20-80ms (one frame), AI thinking happens between tool calls where no hooks fire. Green done flash on result nodes works and is visible. * Blue pulse on skeleton during AI tool streaming Watch streaming message parts in ChatPanel — when a tool part appears with state 'call'/'partial-call' and has replace_id or parent_id in args, mark that node active (blue pulse). When tool transitions to 'output-available', mark done (green flash). This shows the pulsing blue border on the skeleton wireframe frame while AI generates JSX, and switches to green when the content replaces it. * AI overlay debug log section, watch tool parts by toolCallId - chat-debug.ts: aiOverlayLog array + AI OVERLAY LOG section in debug output showing every part/mark-active/mark-done event with timestamps - ChatPanel.vue: check parts by toolCallId instead of isToolUIPart, log all overlay events to aiOverlayLog for debugging * Fix AI overlay: use 'input' not 'args' for tool part params Vercel AI SDK UIToolInvocation uses 'input' field (not 'args') for tool call parameters. The watch was checking p.args which was always undefined, so no overlay events ever fired. Also use p.type instead of p.toolName for the log. * Move AI overlay to onBeforeExecute/onFlashNodes, reorder render params - onBeforeExecute: mark replace_id/parent_id active (blue pulse) - onFlashNodes: clear active, flash done on new nodes (green) - Reorder render tool params: replace_id first so streaming partial input reveals target ID before JSX body - Remove ChatPanel watch — all overlay logic in tools.ts now - Add aiClearActive() to renderer (clears active without touching done flashes) * Wait 2 rAF frames after marking active before tool execute onBeforeExecute marks the target node active (blue pulse), but execute ran immediately after without yielding to the render loop. The blue frame was never painted because markActive → execute → clearActive all happened in the same JS task. Now await two requestAnimationFrame callbacks so the canvas renders at least one blue-pulse frame before execution starts. * Add comprehensive AI overlay debug logging to chat debug log Both streaming watch (ChatPanel) and tool execution hooks (tools.ts) now log every event to aiOverlayLog with full state: renderer exists, activeNodes count, hasFlashes, node existence. This will show exactly what happens and why the blue pulse is or isn't visible. * Filter partial node IDs in streaming overlay watch During input-streaming, replace_id may be partially received (e.g. '0:' without the number part). Guard with isValidNodeId check. Also refactor watch callback into collectPendingTargets + syncOverlayState to stay under complexity limit. Analysis of last debug log: - Hero: only 235ms active (streaming didn't deliver replace_id early) - Stories: 14.8s active — should be clearly visible - Opinions: 12.5s, LatestNews: 11.5s, Stocks: 9.8s — all long enough - Hero was the only one too fast to notice * Fix undo race, JSX pragma conflict, partial ID overlay, add deletion log - Fix undo snapshot race condition: replace shared `let beforeSnapshot` with a per-tool stack. Vercel AI SDK runs tool calls from one step via Promise.all — concurrent mutating tools overwrote the shared snapshot, causing wrong undo entries and potential content loss. - Fix 'h is not a function' JSX error: rename sucrase pragma from `h` to `__h`. When AI writes `.map((h, i) => ...)`, the loop variable shadowed the pragma, crashing render. - Fix partial node ID `0:1` in overlay: strengthen `isValidNodeId` to require both parts ≥2 chars and verify node exists in graph. During streaming, `replace_id: "0:1811"` arrives as `0:1` → `0:18` → etc. - Add deletion log to SceneGraph: `_deletionLog` records id, name, type, parentId, timestamp, and truncated stack for last 200 deletions. Included in debug log as NODE DELETION LOG section to diagnose the intermittent frame disappearance bug. * Reduce find_nodes spam and describe bloat in system prompt - Instruct AI to reuse IDs from render results and describe output instead of calling find_nodes to rediscover them (saves 8+ calls) - Recommend depth=1 for full-page describe, depth=2 only for sections * Auto-depth describe + validate-after-render workflow Describe tool now auto-picks depth based on subtree size when depth is omitted: ≤15 nodes → depth 4, ≤40 → 3, ≤100 → 2, larger → 1. Max raised to 5 for explicit overrides. Prompt updated: after each render replace_id, call describe on that block (auto depth gives full detail on small sections). Every 2-3 content renders, describe root at depth=1 to catch cross-section layout issues early. * Enforce describe-after-every-render in system prompt Show explicit render → describe → fix pattern with code example. AI was skipping per-block validation and batching all describes at the end, causing compounding layout errors. * Add describe rules: nested text, missing padding, button padding, row centering Four new layout issue detectors in describe-issues.ts: - Nested Text (error): Text node inside Text causes overflow. Suggestion: split into separate Text elements in flex row. - Zero padding container (warning): Frame with fill + text children but no padding — content touches edges. - Button without padding (warning): Button-like frame with children but zero horizontal padding. - Row without vertical centering (warning): Horizontal flex with items=start, tall container, and short children — suggests items=center. All verified with bun -e tests including false positive checks. * Fix gradient alpha loss, add undersize child detection Gradient fix: set_fill with color_end was overwriting alpha to 1 on both gradient stops (`{ ...c, a: 1 }`). Now preserves original alpha from parsed hex — #00000000 → a:0, #000000AA → a:0.667. This caused transparent-to-dark overlays to render as solid black. New describe rule: detect child significantly narrower than parent in a container without auto-layout (e.g. 100px Rectangle inside 896px Frame). Suggests adding flex + w=fill. Caught the HeroImg bug that describe previously missed. * Auto-include issues in render result, no separate describe needed Render tool now scans the created subtree for errors/warnings and includes them in the response. AI gets issues automatically without needing to call describe — eliminates the pattern where AI skips describe on 'simple' sidebar blocks. Example response: { id: '0:42', name: 'Card', issues: [ { node: 'Text', id: '0:45', message: 'no color', severity: 'error' } ] } * Run layout before collecting render issues Issues were checked on raw node sizes (default 100px) before computeAllLayouts ran in onAfterExecute. Now render calls computeAllLayouts inline before collectSubtreeIssues, so issues reflect actual computed dimensions. This fixes hundreds of false 'X 100px overflows parent' warnings that made AI chase nonexistent problems. * Revert auto-issues in render and layout-before-issues Reverts 09a9b89 and 70c116a. Auto-including issues in render result caused false positives (100px default sizes before layout) and made AI chase nonexistent problems, degrading output quality. * Clean up AI chat PR: remove debug scaffolding, add stock photo provider adapter - Delete _deletionLog (stack traces on every node deletion) - Delete aiOverlayLog and all debug push() calls - Delete succesful_prompt_diffucult_ux.md (raw debug dump) and tokens.md (empty) - Remove double-rAF hack from ai-adapter (added 32ms to every tool call) - Restore simple beforeSnapshot for undo (was matching by tool name) - Remove 70-line overlay state tracker from ChatPanel.vue - Remove Math.* regex hack from calc (expr-eval has builtins) - Refactor stock-photo into provider adapter: Pexels + Unsplash - Add Unsplash key management to settings UI and use-chat composable --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-15 12:42:41 +00:00
ClipOp: { Intersect: 0 }
},
DEFAULT_FONT_SIZE: 14,
isNodeFontLoaded: mock(() => true),
nodeFontReadiness: mock(() => 'ready'),
isTextPictureCurrent: mock(() => true),
buildParagraph: mock(() => paragraph),
_paragraph: paragraph,
...overrides
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
})
}
function textNode(overrides: Partial<SceneNode> = {}): SceneNode {
return {
type: 'TEXT',
text: 'Hello 你好',
fontSize: 16,
fontFamily: 'Arial',
fontWeight: 400,
italic: false,
letterSpacing: 0,
lineHeight: null,
textAlignHorizontal: 'LEFT',
textAlignVertical: 'TOP',
textAutoResize: 'NONE',
textDecoration: 'NONE',
textDirection: 'AUTO',
styleRuns: [],
2026-08-17 09:00:01 +00:00
source: createDefaultSourceMetadata(),
...overrides
} as SceneNode
}
async function createTextRenderer() {
const ck = await initCanvasKit()
const surface = expectDefined(ck.MakeSurface(400, 120), 'surface')
const renderer = new SkiaRendererClass(ck, surface)
return { renderer, surface }
}
describe('text case and vertical alignment', () => {
test('transforms display text without changing the source', () => {
expect(transformTextCase('hello WORLD', 'ORIGINAL')).toBe('hello WORLD')
expect(transformTextCase('hello World', 'UPPER')).toBe('HELLO WORLD')
expect(transformTextCase('Hello WORLD', 'LOWER')).toBe('hello world')
expect(transformTextCase('hello WORLD 42nd', 'TITLE')).toBe('Hello World 42nd')
})
test('computes top, center, and bottom paragraph offsets', () => {
expect(textVerticalOffset(textNode({ height: 100, textAlignVertical: 'TOP' }), 20)).toBe(0)
expect(textVerticalOffset(textNode({ height: 100, textAlignVertical: 'CENTER' }), 20)).toBe(40)
expect(textVerticalOffset(textNode({ height: 100, textAlignVertical: 'BOTTOM' }), 20)).toBe(80)
expect(textVerticalOffset(textNode({ height: 10, textAlignVertical: 'BOTTOM' }), 20)).toBe(0)
})
})
describe('renderText', () => {
test('uses buildParagraph when fonts are loaded and node font is available', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
renderText(r, canvas as never, textNode())
expect(r.buildParagraph).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
halfLeading: true
})
expect(canvas.drawParagraph).toHaveBeenCalledTimes(1)
expect(canvas.drawText).not.toHaveBeenCalled()
expect(r._paragraph.delete).toHaveBeenCalledTimes(1)
})
test('skips text while the node font is not available', () => {
const r = createMockRenderer({ nodeFontReadiness: mock(() => 'pending') })
const canvas = createMockCanvas()
renderText(r, canvas as never, textNode())
expect(r.buildParagraph).not.toHaveBeenCalled()
expect(canvas.drawParagraph).not.toHaveBeenCalled()
expect(canvas.drawText).not.toHaveBeenCalled()
})
test('paints gradients through native paragraphs without outline font data', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
renderText(r, canvas as never, textNode(), {
type: 'GRADIENT_LINEAR',
visible: true,
opacity: 1,
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
color: { r: 0, g: 0, b: 0, a: 1 },
gradientStops: [],
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
})
expect(r.buildParagraph).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
halfLeading: true,
foregroundPaint: r.fillPaint
})
expect(canvas.saveLayer).not.toHaveBeenCalled()
expect(canvas.drawParagraph).toHaveBeenCalledTimes(1)
expect(canvas.drawRect).not.toHaveBeenCalled()
expect(r._paragraph.delete).toHaveBeenCalledTimes(1)
})
test('keeps native paragraph layout even when outline font data is available', async () => {
const interData = await Bun.file(repoPath('public/Inter-Regular.ttf')).arrayBuffer()
fontManager.markLoaded('Inter', 'Regular', interData)
const r = createMockRenderer()
const canvas = createMockCanvas()
renderText(
r,
canvas as never,
textNode({ text: 'OPEN', fontFamily: 'Inter', width: 120, height: 40 }),
{
type: 'GRADIENT_LINEAR',
visible: true,
opacity: 1,
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
color: { r: 0, g: 0, b: 0, a: 1 },
gradientStops: [],
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
}
)
expect(canvas.drawPath).not.toHaveBeenCalled()
expect(r.buildParagraph).toHaveBeenCalledTimes(1)
expect(canvas.saveLayer).not.toHaveBeenCalled()
})
test('prefers resolved fonts over baked text pictures', () => {
AI chat polishing: skeleton workflow, batched tools, visual feedback, stock photos (#111) * Fix text rendering: always use paragraph wrapping, font fallback, batch tools, severity levels Rendering: - renderText: always use paragraph when fontProvider available, not just when node-specific font loaded — fixes single-line overflow for Georgia/ non-Inter fonts - buildParagraph: add Inter as fallback fontFamily so paragraph wraps text even when requested font not yet loaded - measureTextNode/buildTextPicture: remove isNodeFontLoaded gate — measure with fallback font for consistent layout↔rendering - TEXT_PICTURE_KEYS: add width/height so textPicture invalidates on resize - renderText drawText fallback: clip to node bounds - onAfterExecute: invalidate textPictures after font load Layout: - estimateTextSize: accept maxWidth, compute multi-line height - toJSON: depth parameter with childCount fallback AI tools: - batch_update tool: multiple set_layout/set_layout_child in one call - describe: accept ids array for multi-node inspection - Issue severity (error/warning/info) with pattern classification - checkNestedFlexWithoutFill: cross-axis fill tolerance - CORE_TOOLS trimmed 34→27, rest moved to EXTENDED - System prompt: batch_update, describe ids, severity rules, font/text wrapping guidance, card grid patterns * Add Pexels stock photo integration for AI chat - stock_photo tool: search Pexels by query, download image, apply as IMAGE fill on any node. Picks resolution based on node dimensions. - Pexels API key stored in localStorage, synced to core via setPexelsApiKey on change - ProviderSettings UI: Pexels API key input with link to get free key - System prompt: stock photo usage guidance - Added to CORE_TOOLS so AI can use it in every session * Batch stock_photo + calc, 4-phase workflow, fix wrap false positives - stock_photo: accepts JSON array of requests, all fetched in parallel. Rejects nodes with children (leaf shapes only). One call replaces 14. - calc: accepts JSON array of expressions or single string. One call replaces 3 separate calc invocations. - System prompt: 4-phase workflow (plan → skeleton → content → polish). Batch stock_photo and calc examples. Updated step budget. - describe-layout-issues: skip 'nested flex may collapse' for wrap containers (pill/tag frames are intentionally HUG). Extract childNeedsFill helper to reduce complexity. * Fix nested flex false positive for pills, single-root render rule - describe-layout-issues: skip 'nested flex may collapse' when a sibling already has grow or fill — pill/badge frames next to flex-grow content are intentionally small (e.g. BreakingLabel next to ticker text) - System prompt: render must have exactly one root element. Stock_photo 401 fallback: tell user, don't eval gradients. * Rework workflow: render complete sections, not empty frames Phase 2 now prescribes 5-7 renders, each producing a complete section (nav, hero, stories, opinions, sidebar, footer) with placeholder images, real text, proper auto-layout. No more empty frames to fill later. Removes the fill content phase — skeleton IS the content, just without real photos. Phase 3 is now just polish (batch stock_photo + fixes). Step budget: 12-15 steps instead of 15-30. * Pexels key input: match AI key behavior Same pattern as the AI provider key — intermediate ref, 'Key saved — enter new to replace' placeholder, Clear button, save on change. No longer exposes the raw key in the input field. * No empty frames in skeleton, select nodes after each tool - System prompt: explicit rules against empty frames, no pre-creating wrapper frames for future content. Each render = complete section with visible placeholders. Render 1 = only nav+ticker, not the whole page with empty containers. - tools.ts: set selection to flash node IDs after each mutating tool, so user sees which section AI is working on (selection border persists vs flash which is transient). * AI overlay: pulsing blue while working, green flash on done; skeleton workflow Rendering: - ai-overlays.ts: new module with pulsing blue border (sinusoidal opacity 0.3-0.8 at 1.5s period) for nodes AI is working on, green fade-out flash (800ms) when done - renderer.ts: aiMarkActive/aiMarkDone/aiClearAll methods, drawn after regular flashes - constants.ts: AI_ACTIVE_COLOR, AI_DONE_COLOR, timing constants Integration: - ai-adapter.ts: onBeforeExecute now receives args (for target ID) - tools.ts: marks target node (id or parent_id) active before tool executes, marks done via onFlashNodes after completion - editor.ts: aiMarkActive/aiMarkDone/aiClearAll forwarded to renderer Workflow: - System prompt: 3-phase skeleton workflow. Phase 2 renders gray placeholder blocks (Rectangle bg=#E2E8F0) for every section — page looks like wireframe before any real content. Phase 3 replaces skeletons with real content. Phase 4 batch stock_photo + polish. * render replace_id: atomic skeleton-to-content swap, fix AI overlay lifecycle render tool: - New replace_id parameter — renders new content, places it at the skeleton's position in parent, then deletes the skeleton. No visual gap between skeleton removal and content appearance. AI overlay fixes: - Track lastActiveIds across before/after execute — aiMarkDone clears the exact nodes that were marked active, not just the result IDs - aiFlashDone: direct green flash on result nodes (not gated by _aiActiveNodes membership) - replace_id included in target ID detection for active marking System prompt: - Phase 3 uses render({ replace_id }) instead of delete_node + render * Fix gradient fills, nested flex false positives, render insert_index, h=hug prompt - set_fill: gradient support with color_end + direction (top-bottom, left-right, etc). Uses proper Matrix type. No more eval for gradients. - describe-layout-issues: skip nested flex warning for all non-MIN alignments (SPACE_BETWEEN, CENTER, MAX) — not just SPACE_BETWEEN - render: insert_index parameter for controlling child order - system prompt: page frame h="hug" not fixed height - AI overlay: mark active on any tool with target ID (not just mutating), clear previous active in onBeforeExecute * Add desktop business news site example to system prompt Full workflow example: skeleton with gray placeholders → replace_id content fills → batch stock_photo. Shows 12-col grid pattern, card components, section headers, sidebar widgets, h=hug page frame. Complements the existing mobile app UI example. * Drop blue active pulse, keep only green done flash Blue active marking was invisible — tool execution is 20-80ms (one frame), AI thinking happens between tool calls where no hooks fire. Green done flash on result nodes works and is visible. * Blue pulse on skeleton during AI tool streaming Watch streaming message parts in ChatPanel — when a tool part appears with state 'call'/'partial-call' and has replace_id or parent_id in args, mark that node active (blue pulse). When tool transitions to 'output-available', mark done (green flash). This shows the pulsing blue border on the skeleton wireframe frame while AI generates JSX, and switches to green when the content replaces it. * AI overlay debug log section, watch tool parts by toolCallId - chat-debug.ts: aiOverlayLog array + AI OVERLAY LOG section in debug output showing every part/mark-active/mark-done event with timestamps - ChatPanel.vue: check parts by toolCallId instead of isToolUIPart, log all overlay events to aiOverlayLog for debugging * Fix AI overlay: use 'input' not 'args' for tool part params Vercel AI SDK UIToolInvocation uses 'input' field (not 'args') for tool call parameters. The watch was checking p.args which was always undefined, so no overlay events ever fired. Also use p.type instead of p.toolName for the log. * Move AI overlay to onBeforeExecute/onFlashNodes, reorder render params - onBeforeExecute: mark replace_id/parent_id active (blue pulse) - onFlashNodes: clear active, flash done on new nodes (green) - Reorder render tool params: replace_id first so streaming partial input reveals target ID before JSX body - Remove ChatPanel watch — all overlay logic in tools.ts now - Add aiClearActive() to renderer (clears active without touching done flashes) * Wait 2 rAF frames after marking active before tool execute onBeforeExecute marks the target node active (blue pulse), but execute ran immediately after without yielding to the render loop. The blue frame was never painted because markActive → execute → clearActive all happened in the same JS task. Now await two requestAnimationFrame callbacks so the canvas renders at least one blue-pulse frame before execution starts. * Add comprehensive AI overlay debug logging to chat debug log Both streaming watch (ChatPanel) and tool execution hooks (tools.ts) now log every event to aiOverlayLog with full state: renderer exists, activeNodes count, hasFlashes, node existence. This will show exactly what happens and why the blue pulse is or isn't visible. * Filter partial node IDs in streaming overlay watch During input-streaming, replace_id may be partially received (e.g. '0:' without the number part). Guard with isValidNodeId check. Also refactor watch callback into collectPendingTargets + syncOverlayState to stay under complexity limit. Analysis of last debug log: - Hero: only 235ms active (streaming didn't deliver replace_id early) - Stories: 14.8s active — should be clearly visible - Opinions: 12.5s, LatestNews: 11.5s, Stocks: 9.8s — all long enough - Hero was the only one too fast to notice * Fix undo race, JSX pragma conflict, partial ID overlay, add deletion log - Fix undo snapshot race condition: replace shared `let beforeSnapshot` with a per-tool stack. Vercel AI SDK runs tool calls from one step via Promise.all — concurrent mutating tools overwrote the shared snapshot, causing wrong undo entries and potential content loss. - Fix 'h is not a function' JSX error: rename sucrase pragma from `h` to `__h`. When AI writes `.map((h, i) => ...)`, the loop variable shadowed the pragma, crashing render. - Fix partial node ID `0:1` in overlay: strengthen `isValidNodeId` to require both parts ≥2 chars and verify node exists in graph. During streaming, `replace_id: "0:1811"` arrives as `0:1` → `0:18` → etc. - Add deletion log to SceneGraph: `_deletionLog` records id, name, type, parentId, timestamp, and truncated stack for last 200 deletions. Included in debug log as NODE DELETION LOG section to diagnose the intermittent frame disappearance bug. * Reduce find_nodes spam and describe bloat in system prompt - Instruct AI to reuse IDs from render results and describe output instead of calling find_nodes to rediscover them (saves 8+ calls) - Recommend depth=1 for full-page describe, depth=2 only for sections * Auto-depth describe + validate-after-render workflow Describe tool now auto-picks depth based on subtree size when depth is omitted: ≤15 nodes → depth 4, ≤40 → 3, ≤100 → 2, larger → 1. Max raised to 5 for explicit overrides. Prompt updated: after each render replace_id, call describe on that block (auto depth gives full detail on small sections). Every 2-3 content renders, describe root at depth=1 to catch cross-section layout issues early. * Enforce describe-after-every-render in system prompt Show explicit render → describe → fix pattern with code example. AI was skipping per-block validation and batching all describes at the end, causing compounding layout errors. * Add describe rules: nested text, missing padding, button padding, row centering Four new layout issue detectors in describe-issues.ts: - Nested Text (error): Text node inside Text causes overflow. Suggestion: split into separate Text elements in flex row. - Zero padding container (warning): Frame with fill + text children but no padding — content touches edges. - Button without padding (warning): Button-like frame with children but zero horizontal padding. - Row without vertical centering (warning): Horizontal flex with items=start, tall container, and short children — suggests items=center. All verified with bun -e tests including false positive checks. * Fix gradient alpha loss, add undersize child detection Gradient fix: set_fill with color_end was overwriting alpha to 1 on both gradient stops (`{ ...c, a: 1 }`). Now preserves original alpha from parsed hex — #00000000 → a:0, #000000AA → a:0.667. This caused transparent-to-dark overlays to render as solid black. New describe rule: detect child significantly narrower than parent in a container without auto-layout (e.g. 100px Rectangle inside 896px Frame). Suggests adding flex + w=fill. Caught the HeroImg bug that describe previously missed. * Auto-include issues in render result, no separate describe needed Render tool now scans the created subtree for errors/warnings and includes them in the response. AI gets issues automatically without needing to call describe — eliminates the pattern where AI skips describe on 'simple' sidebar blocks. Example response: { id: '0:42', name: 'Card', issues: [ { node: 'Text', id: '0:45', message: 'no color', severity: 'error' } ] } * Run layout before collecting render issues Issues were checked on raw node sizes (default 100px) before computeAllLayouts ran in onAfterExecute. Now render calls computeAllLayouts inline before collectSubtreeIssues, so issues reflect actual computed dimensions. This fixes hundreds of false 'X 100px overflows parent' warnings that made AI chase nonexistent problems. * Revert auto-issues in render and layout-before-issues Reverts 09a9b89 and 70c116a. Auto-including issues in render result caused false positives (100px default sizes before layout) and made AI chase nonexistent problems, degrading output quality. * Clean up AI chat PR: remove debug scaffolding, add stock photo provider adapter - Delete _deletionLog (stack traces on every node deletion) - Delete aiOverlayLog and all debug push() calls - Delete succesful_prompt_diffucult_ux.md (raw debug dump) and tokens.md (empty) - Remove double-rAF hack from ai-adapter (added 32ms to every tool call) - Restore simple beforeSnapshot for undo (was matching by tool name) - Remove 70-line overlay state tracker from ChatPanel.vue - Remove Math.* regex hack from calc (expr-eval has builtins) - Refactor stock-photo into provider adapter: Pexels + Unsplash - Add Unsplash key management to settings UI and use-chat composable --------- Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-15 12:42:41 +00:00
const r = createMockRenderer()
const canvas = createMockCanvas()
const node = textNode({ textPicture: new Uint8Array([1, 2, 3]) })
renderText(r, canvas as never, node)
expect(canvas.drawPicture).not.toHaveBeenCalled()
expect(r.buildParagraph).toHaveBeenCalledTimes(1)
})
fix(text): finalize font readiness and label shaping (#593) * fix(text): prepare browser fonts atomically - Fetch approved Fontsource resources directly in browsers with bounded responses and retryable provider failures - Limit page font resolution concurrency and retain live Inter substitution paragraphs after baked glyph invalidation - Shape frame, section, and component labels through a bounded native Paragraph cache - Cover real Geist, Geist Mono, and Roboto Mono browser loading plus substitution and cache lifecycle behavior * test(canvas): cover substitution and label shaping - Capture baked missing-font text before editing, live Inter substitution on first input, and visible undo output - Assert text-picture and derived-glyph invalidation with finalized substituted readiness - Cover shaped frame, rotated frame, section, component, component-set, ellipsis, kerning, and zoom label presentation * fix(text): preserve same-origin fetches during font resolution - Route same-origin application and fixture requests through native fetch while Unifont temporarily proxies global HTTP requests - Keep the external provider HTTPS allowlist enforced for cross-origin font resources - Cover the production race discovered while loading gold-preview.fig during provider initialization * fix(text): preserve substituted path glyphs - Keep imported derived curved glyphs for text-on-path layers when exact fonts finalize as substituted - Cover substituted path rendering through the runtime renderer and refresh the corrected visual oracle - Update shaped Inter measurement badges and merged typography panel snapshots after visual inspection - Search virtualized font catalogs explicitly and restore the canvas screenshot helper used by broad E2E coverage * fix(text): use browser font transport without desktop warning - Keep the browser provider implementation aligned with current settings behavior after splitting from preparation * test(text): inject same-origin browser font context - Keep the transport test deterministic outside a Window global * fix(text): preserve final browser font transport hardening - Carry the same-origin large-document bypass and bounded cross-origin provider checks from the preserved integration snapshot * fix(text): initialize font transport outside Window contexts - Keep unit and headless module imports safe while browsers use their current origin * test(text): satisfy async visual fixture contract - Return from the resolver setup continuation after requesting render * test(canvas): include paragraph cache lifecycle * fix(text): cancel queued browser font loads - Race serialized provider work against preparation cancellation\n- Release queue slots after cancelled waiters without disturbing active requests\n- Reuse one section-title paragraph cache entry for measurement and drawing * fix(text): close font queue cancellation races - Release reserved proxy queue slots when cancellation lands after queue acquisition\n- Skip paragraph work for zero-width section labels * fix(text): abort active provider resolution - Race active provider resolution against its preparation signal and restore the temporary fetch proxy promptly\n- Forward cancellation through proxied provider requests\n- Align the renderer font-readiness facade with substituted text
2026-08-30 11:33:32 +00:00
test('renders a live paragraph for finalized default-family substitution', () => {
const r = createMockRenderer({ nodeFontReadiness: mock(() => 'substituted') })
const canvas = createMockCanvas()
const node = textNode({
fontFamily: 'Geist',
text: 'Edited text',
textPicture: null,
derivedTextGlyphs: null
})
renderText(r, canvas as never, node)
expect(r.buildParagraph).toHaveBeenCalledTimes(1)
expect(canvas.drawParagraph).toHaveBeenCalledTimes(1)
})
test.each(['ready', 'substituted'] as const)(
'keeps derived path-text glyphs when its face is %s',
(readiness) => {
const base = createMockRenderer()
const r = createMockRenderer({
nodeFontReadiness: mock(() => readiness),
ck: { ...base.ck, FillType: { EvenOdd: 0, Winding: 1 } }
})
const canvas = createMockCanvas()
const node = textNode({
fontFamily: 'Missing Path Font',
textPathData: {
network: { vertices: [], segments: [], regions: [] },
normalizedSize: { x: 100, y: 20 },
tValue: 0,
forward: true
},
derivedTextGlyphs: [
{
commandsBlob: new Uint8Array(),
x: 0,
y: 0,
rotation: 0,
fontSize: 12
}
]
})
renderText(r, canvas as never, node)
expect(r.buildParagraph).not.toHaveBeenCalled()
}
)
test('uses baked text pictures after font resolution is exhausted', () => {
const r = createMockRenderer({ nodeFontReadiness: mock(() => 'exhausted') })
const canvas = createMockCanvas()
const node = textNode({ textPicture: new Uint8Array([1, 2, 3]) })
renderText(r, canvas as never, node)
expect(canvas.drawPicture).toHaveBeenCalledTimes(1)
expect(r.buildParagraph).not.toHaveBeenCalled()
})
test('falls back to drawText only when fonts are NOT loaded', () => {
const r = createMockRenderer({ fontsLoaded: false, fontProvider: null })
const canvas = createMockCanvas()
renderText(r, canvas as never, textNode())
expect(canvas.drawText).toHaveBeenCalledTimes(1)
expect(r.buildParagraph).not.toHaveBeenCalled()
})
test('does nothing for empty text', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
renderText(r, canvas as never, textNode({ text: '' }))
expect(r.buildParagraph).not.toHaveBeenCalled()
expect(canvas.drawText).not.toHaveBeenCalled()
expect(canvas.drawPicture).not.toHaveBeenCalled()
})
})
describe('paragraph font weights', () => {
test('bold Inter paragraph is wider than regular Inter', async () => {
const { renderer, surface } = await createTextRenderer()
await renderer.loadFonts()
const regular = await Bun.file(repoPath('public/Inter-Regular.ttf')).arrayBuffer()
const bold = await Bun.file(repoPath('public/Inter-Bold.ttf')).arrayBuffer()
fontManager.markLoaded('Inter', 'Regular', regular)
fontManager.markLoaded('Inter', 'Bold', bold)
const base = textNode({
text: 'World largest design',
fontFamily: 'Inter',
fontSize: 64,
width: 1000,
height: 100,
fontWeight: 400,
italic: false
})
const regularParagraph = buildParagraph(renderer, base)
const boldParagraph = buildParagraph(renderer, { ...base, fontWeight: 700 })
expect(boldParagraph.getLongestLine()).toBeGreaterThan(regularParagraph.getLongestLine())
regularParagraph.delete()
boldParagraph.delete()
surface.delete()
})
test('shapes italic text when only the regular family face is available', async () => {
const { renderer, surface } = await createTextRenderer()
await renderer.loadFonts()
const regular = await Bun.file(repoPath('public/Inter-Regular.ttf')).arrayBuffer()
fontManager.markLoaded('Inter', 'Regular', regular)
const demand = fontFaceDemand('Inter', 'Regular Italic', 'Synthetic italic')
fontResolver.reset(demand)
fontResolver.exhaust(demand)
const node = textNode({
text: 'Synthetic italic',
fontFamily: 'Inter',
italic: true,
width: 300,
height: 40
})
expect(isNodeFontLoaded(renderer, node)).toBe(true)
const paragraph = buildParagraph(renderer, node)
paragraph.layout(300)
expect(paragraph.getLongestLine()).toBeGreaterThan(0)
paragraph.delete()
fontResolver.reset(demand)
surface.delete()
})
})
describe('renderText headless visual', () => {
2026-03-29 09:42:30 +00:00
test('detects base direction for Arabic and mixed text', () => {
expect(detectTextDirection('مرحبا')).toBe('RTL')
expect(resolveTextDirection('AUTO', 'مرحبا world')).toBe('RTL')
expect(resolveTextDirection('AUTO', 'Hello مرحبا')).toBe('LTR')
expect(resolveTextDirection('RTL', 'Hello')).toBe('RTL')
})
test('observes CanvasKit notdef glyphs for unsupported CJK text', async () => {
const ck = await initCanvasKit()
const fontProvider = ck.TypefaceFontProvider.Make()
fontManager.attachProvider(ck, fontProvider)
const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
fontProvider.registerFont(interData, 'Inter')
fontManager.markLoaded('Inter', 'Regular', interData)
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
const fallbackFamilies = fontManager.getCJKFallbackFamilies()
const originalFallbacks = [...fallbackFamilies]
fallbackFamilies.length = 0
const surface = expectDefined(ck.MakeSurface(200, 50), 'CanvasKit surface')
try {
const renderer = new SkiaRendererClass(ck, surface)
renderer.fontsLoaded = true
renderer.fontProvider = fontProvider
const paragraph = buildParagraph(
renderer,
textNode({ text: 'A𠀀B', fontFamily: 'Inter', fontWeight: 400 })
)
paragraph.layout(200)
expect(missingGlyphCharacters('A𠀀B', paragraph.getShapedLines())).toEqual(['𠀀'])
paragraph.delete()
} finally {
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
fallbackFamilies.splice(0, fallbackFamilies.length, ...originalFallbacks)
surface.delete()
}
})
test('requests a CJK fallback for text whose face is substituted', async () => {
const ck = await initCanvasKit()
const fontProvider = ck.TypefaceFontProvider.Make()
fontManager.attachProvider(ck, fontProvider)
const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
fontManager.markLoaded('Inter', 'Regular', interData)
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
const fallbackFamilies = fontManager.getCJKFallbackFamilies()
const originalFallbacks = [...fallbackFamilies]
fallbackFamilies.length = 0
const fallbackPack = spyOn(fontManager, 'ensureFallbackPack').mockResolvedValue({})
const face = fontFaceDemand('Undrawable Sans', 'Regular')
fontResolver.exhaust(face)
const surface = expectDefined(ck.MakeSurface(200, 50), 'CanvasKit surface')
try {
const renderer = new SkiaRendererClass(ck, surface)
renderer.fontsLoaded = true
renderer.fontProvider = fontProvider
const node = textNode({ text: '按钮', fontFamily: 'Undrawable Sans', fontWeight: 400 })
expect(nodeFontReadiness(renderer, node)).toBe('pending')
await Promise.resolve()
expect(fallbackPack).toHaveBeenCalled()
expect(fallbackPack.mock.calls[0]?.[0]?.some((script) => script.startsWith('cjk'))).toBe(true)
} finally {
fallbackPack.mockRestore()
fontResolver.reset(face)
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
fallbackFamilies.splice(0, fallbackFamilies.length, ...originalFallbacks)
surface.delete()
}
})
test('does not require fallback families when the primary font covers CJK glyphs', async () => {
const notoPath = repoPath('tests/fixtures/fonts/NotoSansSC-Regular.ttf')
const notoData = await Bun.file(notoPath).arrayBuffer()
fontManager.markLoaded('Noto Sans SC', 'Regular', notoData)
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
const fallbackFamilies = fontManager.getCJKFallbackFamilies()
const originalFallbacks = [...fallbackFamilies]
fallbackFamilies.length = 0
try {
const loaded = isNodeFontLoaded(
{ fontProvider: {}, fontsLoaded: true } as never,
textNode({ text: '你好世界', fontFamily: 'Noto Sans SC', fontWeight: 400 })
)
expect(loaded).toBe(true)
} finally {
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
fallbackFamilies.splice(0, fallbackFamilies.length, ...originalFallbacks)
}
})
test('renders CJK text via fallback font through paragraph shaper', async () => {
const ck = await initCanvasKit()
const fontProvider = ck.TypefaceFontProvider.Make()
fontManager.attachProvider(ck, fontProvider)
const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
fontManager.markLoaded('Inter', 'Regular', interData)
const notoPath = repoPath('tests/fixtures/fonts/NotoSansSC-Regular.ttf')
const notoData = await Bun.file(notoPath).arrayBuffer()
fontManager.markLoaded('Noto Sans SC', 'Regular', notoData)
fontManager.setCJKFallbackFamily('Noto Sans SC')
for (let attempt = 0; attempt < 5; attempt++) {
fontManager.markLoaded('Noto Sans SC', 'Regular', notoData)
}
const graph = new SceneGraph()
const page = graph.getPages()[0]
const node = graph.createNode('TEXT', page.id, {
text: '你好世界',
fontFamily: 'Inter',
fontSize: 32,
fontWeight: 400,
width: 200,
height: 50,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const surface = expectDefined(ck.MakeSurface(200, 50), 'CanvasKit surface')
const renderer = new SkiaRendererClass(ck, surface)
renderer.viewportWidth = 200
renderer.viewportHeight = 50
renderer.dpr = 1
renderer.fontsLoaded = true
renderer.fontProvider = fontProvider
const canvas = surface.getCanvas()
canvas.clear(ck.WHITE)
renderText(renderer, canvas, expectDefined(graph.getNode(node.id), 'text node'))
surface.flush()
const image = surface.makeImageSnapshot()
const encoded = expectDefined(image.encodeToBytes(ck.ImageFormat.PNG, 100), 'encoded PNG')
image.delete()
surface.delete()
expect(encoded.length).toBeGreaterThan(200)
const decodedImage = expectDefined(ck.MakeImageFromEncoded(encoded), 'decoded PNG image')
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
const pixels = expectRgbaPixels(
decodedImage.readPixels(0, 0, {
width: 200,
height: 50,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
}),
'decoded CJK pixels'
)
decodedImage.delete()
let darkPixels = 0
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i] < 128 && pixels[i + 1] < 128 && pixels[i + 2] < 128) {
darkPixels++
}
}
// CJK characters are dense — should have many dark pixels if rendering correctly
// Tofu boxes would have far fewer (just outlines)
expect(darkPixels).toBeGreaterThan(500)
})
2026-03-29 09:42:30 +00:00
test('renders linear gradient text through the canvas scene fill path', async () => {
const ck = await initCanvasKit()
const fontProvider = ck.TypefaceFontProvider.Make()
fontManager.attachProvider(ck, fontProvider)
const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
fontProvider.registerFont(interData, 'Inter')
fontManager.markLoaded('Inter', 'Regular', interData)
const graph = new SceneGraph()
const page = graph.getPages()[0]
const node = graph.createNode('TEXT', page.id, {
text: 'OPEN',
fontFamily: 'Inter',
fontSize: 64,
fontWeight: 400,
width: 220,
height: 80,
fills: [
{
type: 'GRADIENT_LINEAR',
opacity: 1,
visible: true,
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
color: { r: 1, g: 0, b: 0, a: 1 },
gradientStops: [
{ position: 0, color: { r: 1, g: 0, b: 0, a: 1 } },
{ position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }
],
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
}
]
})
const surface = expectDefined(ck.MakeSurface(220, 80), 'CanvasKit surface')
const renderer = new SkiaRendererClass(ck, surface)
renderer.viewportWidth = 220
renderer.viewportHeight = 80
renderer.dpr = 1
renderer.fontsLoaded = true
renderer.fontProvider = fontProvider
const canvas = surface.getCanvas()
canvas.clear(ck.WHITE)
renderer.renderShape(canvas, expectDefined(graph.getNode(node.id), 'text node'), graph)
surface.flush()
const image = surface.makeImageSnapshot()
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
const pixels = expectRgbaPixels(
image.readPixels(0, 0, {
width: 220,
height: 80,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
}),
'gradient text pixels'
)
image.delete()
surface.delete()
let redTextPixels = 0
let blueTextPixels = 0
for (let y = 0; y < 80; y++) {
for (let x = 0; x < 220; x++) {
const i = (y * 220 + x) * 4
const r = pixels[i]
const g = pixels[i + 1]
const b = pixels[i + 2]
if (g > 220) continue
2026-05-21 00:54:10 +00:00
if (x < 110 && b > r + 40) blueTextPixels++
if (x >= 110 && r > b + 40) redTextPixels++
}
}
expect(redTextPixels).toBeGreaterThan(40)
expect(blueTextPixels).toBeGreaterThan(40)
})
2026-03-29 09:42:30 +00:00
test('renders Arabic text via fallback font through paragraph shaper', async () => {
const ck = await initCanvasKit()
const fontProvider = ck.TypefaceFontProvider.Make()
fontManager.attachProvider(ck, fontProvider)
2026-03-29 09:42:30 +00:00
const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
fontProvider.registerFont(interData, 'Inter')
fontManager.markLoaded('Inter', 'Regular', interData)
2026-03-29 09:42:30 +00:00
const arabicPath = repoPath('tests/fixtures/fonts/NotoNaskhArabic-Regular.ttf')
2026-03-29 09:42:30 +00:00
const arabicData = await Bun.file(arabicPath).arrayBuffer()
fontProvider.registerFont(arabicData, 'Noto Naskh Arabic')
fontManager.setArabicFallbackFamily('Noto Naskh Arabic')
2026-03-29 09:42:30 +00:00
const graph = new SceneGraph()
const page = graph.getPages()[0]
const node = graph.createNode('TEXT', page.id, {
text: 'مرحبا بالعالم',
textDirection: 'AUTO',
fontFamily: 'Inter',
fontSize: 32,
fontWeight: 400,
width: 220,
height: 60,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const surface = expectDefined(ck.MakeSurface(220, 60), 'CanvasKit surface')
2026-03-29 09:42:30 +00:00
const renderer = new SkiaRendererClass(ck, surface)
renderer.viewportWidth = 220
renderer.viewportHeight = 60
renderer.dpr = 1
renderer.fontsLoaded = true
renderer.fontProvider = fontProvider
2026-03-29 09:42:30 +00:00
const canvas = surface.getCanvas()
canvas.clear(ck.WHITE)
renderText(renderer, canvas, expectDefined(graph.getNode(node.id), 'text node'))
2026-03-29 09:42:30 +00:00
surface.flush()
const image = surface.makeImageSnapshot()
const encoded = expectDefined(image.encodeToBytes(ck.ImageFormat.PNG, 100), 'encoded PNG')
2026-03-29 09:42:30 +00:00
image.delete()
surface.delete()
expect(encoded.length).toBeGreaterThan(200)
const decodedImage = expectDefined(ck.MakeImageFromEncoded(encoded), 'decoded PNG image')
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
const pixels = expectRgbaPixels(
decodedImage.readPixels(0, 0, {
width: 220,
height: 60,
colorType: ck.ColorType.RGBA_8888,
alphaType: ck.AlphaType.Unpremul,
colorSpace: ck.ColorSpace.SRGB
}),
'decoded text pixels'
)
2026-03-29 09:42:30 +00:00
decodedImage.delete()
let darkPixels = 0
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i] < 128 && pixels[i + 1] < 128 && pixels[i + 2] < 128) {
darkPixels++
}
}
expect(darkPixels).toBeGreaterThan(450)
})
})