diff --git a/AGENTS.md b/AGENTS.md index c558bfc72..ef6b9eebb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ Production Cloudflare Pages deploys are intentionally release/manual only: `app. - `AGENTS.md` (this file) — contributor/agent reference: architecture, conventions, how to release. - `packages/docs/` — VitePress site deployed at `openpencil.dev`. Keep its public information architecture explicit: `/getting-started` for installation, `/overview/**` for product overview and comparisons, `/user-guide/**` for editor workflows, `/programmable/**` for automation and SDK docs, `/reference/**` for compatibility and technical reference, and `/development/**` for contributor internals and the roadmap. Do not reintroduce a generic `/guide/**` section. Preserve moved public routes in `packages/docs/public/_redirects`. Do not create English placeholder copies under locale directories; until a real translation exists, localized navigation should link to the canonical English page. -When adding features, update `CHANGELOG.md` (Unreleased section) and `README.md` (if user-facing). Changelog entries use the public categories `Breaking changes`, `Added`, `Changed`, `Fixed`, `Performance`, and `Security`; omit empty categories. Use `## x.y.z — YYYY-MM-DD` for release headings, describe one user-visible outcome per bullet in present tense, end complete sentences with periods, and append related issue or PR references such as `(#395)`. Avoid implementation details, test counts, and internal refactors unless they affect users or package consumers. Update `AGENTS.md` when architecture or conventions change. Do not put speculative/internal implementation plans in `packages/docs/**`; VitePress docs are published. Keep temporary plans in ignored `scratch/` or distill durable public direction into the canonical roadmap. +When adding user-facing changes, update `CHANGELOG.md` under the single appropriate `Unreleased` category. Treat it as curated release notes, not a commit log: omit tests, benchmarks, CI, internal refactors, development tooling, and bugs introduced and fixed since the last release. After merges, review the complete `Unreleased` section against changes since the latest release, consolidate related work into end-user outcomes, preserve important shipped-facing changes, and remove duplicate bullets or category headings. Use the public categories `Breaking changes`, `Added`, `Changed`, `Fixed`, `Performance`, and `Security`; omit empty categories. Use `## x.y.z — YYYY-MM-DD` for release headings, write one present-tense user-visible outcome per bullet, end complete sentences with periods, and append relevant issue or PR references such as `(#395)`. Update `README.md` when appropriate for user-facing features and `AGENTS.md` when architecture or conventions change. Do not put speculative/internal implementation plans in `packages/docs/**`; VitePress docs are published. Keep temporary plans in ignored `scratch/` or distill durable public direction into the canonical roadmap. ## Commit messages diff --git a/CHANGELOG.md b/CHANGELOG.md index 20cbef094..3e28686f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,53 +6,30 @@ - Add a searchable command palette for editor and application actions. - Render triangle and line arrow stroke caps on lines and open vector paths, and choose them from the stroke cap picker. -- Add a timestamp-faithful pan and zoom benchmark workflow with physical macOS trackpad recording, CDP and DOM replay, Chromium traces, frame-pacing and latency distributions, zoom-anchor drift and viewport-jump detection, retained-backing settlement metrics, and a documented native Instruments acceptance procedure. +- Expose component properties and instance-swap targets through the Figma API and automation. +- Normalize imported stroke dash patterns for more reliable `.fig` compatibility. -- Choose and persist the app interface language from General settings. -- Duplicate guides with Option/Alt-drag, show active guide coordinates in rulers, measure ruler-created guides against selected frames and their contents, and remove guides from the context menu. -- Create, select, move, transfer, and delete canvas and frame guides directly from rulers, with undoable edits and `.fig` round-trip fidelity. -- Open to a unified home with recent and configured storage documents, including grid or list layouts. -- Snap vector points, moved layers, and resized edges to nearby geometry, sibling layer bounds, canvas and frame layout guides, and whole-pixel coordinates with visible alignment guides, fractional-coordinate preservation when pixel snapping is off, and persistent geometry, object, and pixel-grid controls in General settings and the Preferences menu. -- Run Pi through AI SDK HarnessAgent as a configurable desktop provider with multiple saved model profiles, secure credentials, existing MCP design tools, and per-profile thinking and permission settings. -- Open multiple selected design files in separate tabs. -- Let Figma API scripts and automation combine components into variant sets. +- Create, select, move, duplicate, transfer, and delete canvas and frame guides directly from rulers, with undoable edits, measurements, context-menu actions, and `.fig` round-trip fidelity. +- Open to a unified home with recent and configured storage documents in grid or list layouts, and open multiple selected design files in separate tabs. +- Snap vector points, moved layers, and resized edges to nearby geometry, guides, frame and canvas bounds, and whole-pixel coordinates, with visible alignment guides and persistent snapping preferences. +- Run Pi through AI SDK HarnessAgent as a configurable desktop provider with saved model profiles, secure credentials, existing MCP design tools, and per-profile thinking and permission settings. +- Combine components into variant sets through Figma API scripts and automation. - Add local AI usage and technical diagnostics, including token telemetry, provider/model summaries, recent failures, configurable retention, export, and clear controls. (#588) -- Add deterministic two-browser collaboration coverage for bidirectional edits, awareness, departure cleanup, partitioned-peer convergence, and reconnect synchronization without public network dependencies. (#530) - Import, render, edit, resize, select, and export Figma text-on-path layers while preserving their curved glyph layout. -- Show Figma-style temporary distance measurements between selected and Option/Alt-hovered layers. (#491) -- Add a single CodeMirror editor for live Design JSX and HTML/CSS canvas previews, with Tailwind JSX viewing, completion, diagnostics, line numbers, bounded execution, and session-level undo. (#130) -- Allow supported AI model profiles to set a provider-specific reasoning effort. (#454) +- Show temporary Figma-style distance measurements between selected and Option/Alt-hovered layers. (#491) +- Edit Design JSX and HTML/CSS previews in CodeMirror, with Tailwind viewing, completion, diagnostics, bounded execution, and session-level undo. (#130) +- Set provider-specific reasoning effort on supported AI model profiles. (#454) - Show unavailable or substituted document fonts with affected-layer selection and retry actions, and expose font fidelity through the Figma API and MCP tooling. (#503) -- Add reusable remote MCP connections for ACP agents, with Streamable HTTP endpoints and bearer tokens stored in the configured credential backend. -- Author multidimensional component variants in the Design panel, including property/value renaming, sparse-combination diagnostics, variant duplication, and exact instance transitions. (#239) -- Create deterministic, dependency-complete component-library revisions through the provider-neutral Core library catalog, with browser IndexedDB persistence for local catalogs. (#239) -- Enable published component libraries per document, browse their assets alongside local components, and lazily materialize read-only definitions for offline linked instances. (#239) -- Detect newer component-library revisions and explicitly apply stable-key updates to linked instances while preserving assignments and retaining old definitions. (#239) -- Let AI, MCP, and headless tools discover enabled library components, rank preferred libraries before local assets, and insert components by stable library identity. (#239) -- Publish the current document as an immutable local component library from the Assets panel, assigning durable collision-safe asset keys on first publication. (#239) -- Preview component-library asset changes before acceptance and undo or redo accepted instance updates as one editor history action. (#239) -- Preserve enabled-library bindings and materialized definition identities through `.fig` save/reopen, and reject Figma API or AI edits to read-only library definitions. (#239) -- Enforce read-only library capabilities across visual/layout Figma API setters, structural edits, editor history actions, variant authoring, and clipboard replacement while keeping instances editable. (#239) -- Publish component-library revisions to storage-provider object namespaces with immutable revision objects, validated manifests, and conflict-checked latest pointers. (#239) -- Switch the library manager between local and configured storage catalogs, caching remote revisions in IndexedDB for offline browsing and insertion. (#239) -- Bind the active library catalog into live automation requests so MCP component discovery and stable-identity insertion use the targeted document’s enabled libraries. (#239) -- Show affected-instance and variant-fallback counts before accepting a component-library update. (#239) -- Persist the selected local/storage catalog and preferred-library priorities, restoring them for Assets browsing and AI component ranking. (#239) -- Validate downloaded library revisions against size, node, image, content-hash, and revision-hash limits before caching or materialization. (#239) -- Manage bounded filesystem component-library catalogs from the CLI with JSON-capable `libraries list` and immutable `libraries publish` commands. (#239) -- Track library update state per linked instance so individual instances or every instance of one asset can accept a revision while older definitions remain usable. (#239) -- Show outdated linked library instances by asset and update the current page or all pages in one undoable action. (#239) -- Add a reproducible Dev Container for web, package, CLI, and non-browser test development. -- Add local crash recovery for unsaved and pathless documents, including MCP-created documents. (#487) -- Make local unsaved-work recovery configurable and remove retained recovery snapshots when recovery is disabled. (#574) -- Add isolated visual inspection that sends bounded selection renders to the configured Vision model and returns text findings without retaining image data in Design chat history. (#232, #471) -- Add image attachments to AI chat with bounded analysis, immediate transcript thumbnails, hover previews, and click-to-view images. (#232) +- Add reusable remote MCP connections for ACP agents, with Streamable HTTP endpoints and credential-backed bearer tokens. +- Author and manage multidimensional component variants and published component libraries, including revision previews, linked-instance updates, stable library identities, offline catalogs, storage-backed catalogs, and read-only library definitions. (#239) +- Recover unsaved and pathless documents locally, with settings to disable recovery and remove retained snapshots. (#487, #574) +- Inspect selected designs with a configured Vision model and attach images to AI chat with bounded analysis and previews. (#232, #471) ### Changed - Vertically center shaped section titles and allow renaming a section by double-clicking its canvas label. -- Load supported online fonts in the browser before revealing imported pages, preserve substituted text during editing, and shape canvas labels with bundled Inter typography. -- Upgrade CanvasKit to 0.41 and migrate renderer geometry to immutable paths built through `PathBuilder`. +- Load supported online fonts before revealing imported pages, preserve substituted text during editing, and shape canvas labels with bundled Inter typography. +- Upgrade CanvasKit to 0.41 and use immutable renderer paths through `PathBuilder`. - Upgrade direct model chat providers and transports to AI SDK 7 while retaining the local ACP execution path. - Localize file, clipboard, collaboration, chat, vectorization, storage, recovery, and component-library notifications in every supported language. - Move MCP connections into their own Settings destination instead of presenting them as part of model configuration. @@ -61,10 +38,8 @@ ### Performance - Scope automation and Figma API layout reconciliation to graph nodes and parent containers actually changed by each mutation. - -- Keep rapid trackpad zoom reversals responsive on complex documents by cancelling obsolete high-resolution scene reconstruction and settling only after navigation becomes idle. -- Keep zooming responsive on effect-heavy documents by reusing bounded, invalidation-aware raster snapshots for supported shadow effects while retaining vector-picture fallbacks for backdrop-dependent and oversized effects. -- Show the FIG page list from a lightweight Kiwi scan before materializing the full document, making large files feel responsive sooner. +- Keep rapid trackpad zoom reversals and effect-heavy document navigation responsive by cancelling obsolete reconstruction and reusing safe raster snapshots. +- Show the FIG page list from a lightweight Kiwi scan before materializing the full document. - Avoid redundant collaboration writes when synchronized node fields have not changed. - Release obsolete streamed Markdown parser history after each AI response completes, preventing chat memory from multiplying with every streamed chunk. (#544) - Open large documents faster by using cached world positions while finding layers under the pointer. (#527) @@ -75,30 +50,33 @@ ### Fixed +- Preserve source text offsets when resolving fallback languages after text-case transformations. +- Track character coverage restored from downloaded font cache entries. + - Preserve imported Figma divider-line geometry during auto-layout recomputation, preventing half-pixel shifts on save and reload. - Make published package export conditions resolve to files included in npm tarballs. - Use the user's home directory as the default MCP file root on Windows, avoiding the caller's unreliable working directory. - Open legacy raw `.fig` files that store the Kiwi document and thumbnail without a ZIP wrapper. (#582) -- Preserve a frame's auto-layout HUG sizing mode when converting it into a component with `create_component`, so later padding changes still resize the component as expected. -- Run `openpencil import` on Node so the npm-installed CLI no longer fails with `Bun is not defined`. (#575) +- Preserve a frame's auto-layout HUG sizing mode when converting it into a component with `create_component`. +- Run `openpencil import` on Node so npm-installed CLI users no longer encounter `Bun is not defined`. (#575) +- Generate recent-file previews from the conventional `Cover` page without modifying the source file. - Isolate browser-development MCP servers behind worktree-aware Portless WebSocket routes and per-runtime socket/discovery paths, preventing concurrent worktrees from competing for port 7600 or the global MCP socket. - -- Generate and cache recent-file previews from the conventional `Cover` page after opening a `.fig`, without modifying the source file. +- Resolve Vue SDK semantic test selectors correctly in non-browser runtimes. (#397) +- Commit vector vertex and Bézier-handle edits when the pointer is released and keep transformed vector-edit overlays aligned. (#586) - Preserve app-created component properties and instance-swap targets across `.fig` save and reload cycles. (#548) -- Reconnect desktop automation to an already-running MCP server by allowing access to its discovery file. (#546) + +- Reconnect desktop automation to an already-running MCP server through its discovery file. (#546) - Keep text-editing carets, hit testing, and selection highlights aligned with vertically centered or bottom-aligned text. (#539) -- Match AI chat code-block syntax colors and backgrounds to the active light or dark theme. (#537) -- Let desktop users select and copy AI chat text without replacing it with the selected canvas layers. (#538) +- Match AI chat code-block colors and backgrounds to the active theme, and let desktop users select and copy chat text without replacing it with canvas layers. (#537, #538) - Restore visible above, below, and child drop feedback while dragging layers in the Layers panel. -- Place editor-created instances beside nested source components in world space, including transformed source and destination parents. +- Place editor-created instances beside nested source components in world space, including transformed parents. - Prevent malformed collaboration updates from corrupting synchronized nodes or derived text rendering. -- Transfer native `.fig` exports over binary Tauri IPC instead of JSON byte arrays, preventing large desktop saves from being truncated or exhausting WebView memory. (#484) -- Keep unsaved source-less documents recoverable after their editor tab is closed, matching Figma's retained offline-change behavior. +- Transfer native `.fig` exports over binary Tauri IPC, preventing large desktop saves from being truncated or exhausting WebView memory. (#484) +- Keep unsaved source-less documents recoverable after their editor tab is closed. - Decode zstd-compressed FIG containers, reject invalid compressed payloads, and preserve exact fixture byte ranges. (#397) - Compose caller CSS with Tailwind defaults when importing DOM/CSS documents. (#397) - Preserve desktop HTTP timeout, abort, and empty-response semantics. (#397) -- Resolve Vue SDK semantic test selectors correctly in non-browser runtimes. (#397) - Report whether missing Figma clipboard images were actually fetched. (#397) - Report exhausted provider credit, request failures, and output-token limits through localized chat toasts and copied diagnostics. (#451, #454) - Prevent Windows desktop crashes when loading large system fonts for non-Latin text. @@ -108,15 +86,15 @@ - Restore native copy, cut, and paste shortcuts in desktop text inputs while preserving design clipboard handling on the canvas. - Preserve selected layers when browser clipboard serialization fails during cut operations, and fall back to the session clipboard when system clipboard access is unavailable. (#568) - Treat MCP tool results with an omitted `isError` field as successful while preserving explicit MCP errors. (#583) -- Commit vector vertex and Bézier-handle edits when the pointer is released and keep transformed vector-edit overlays aligned. (#586) - Remove the permanent CORS configuration action from cloud-storage settings and report connection results through standard toasts with clear browser-specific guidance. -- Complete translated app, accessibility, font, color, collaboration, import, connection-test, and browser fallback text across all supported locales, and keep the document language synchronized with the selected locale. +- Complete translated app, accessibility, font, color, collaboration, import, connection-test, and browser fallback text across all supported locales, and synchronize document language with the selected locale. - Preserve effective nested instance text overrides when importing complex Figma component hierarchies. (#102) - Preserve SVG clip paths, including clip shapes referenced through ``, when importing editable vectors. - Preserve circles, ellipses, rectangles, lines, polylines, and polygons supplied as JSX children of inline SVG elements. (#452) - Preserve component links when pasting Figma instances so later component edits continue to update them. - Stop local MCP servers after the app disconnects instead of leaving orphaned background processes. (#494) + ## 0.14.0 — 2026-08-10 ### Breaking changes diff --git a/packages/core/src/canvas/text/index.ts b/packages/core/src/canvas/text/index.ts index 554622b20..4b4b77b82 100644 --- a/packages/core/src/canvas/text/index.ts +++ b/packages/core/src/canvas/text/index.ts @@ -22,7 +22,7 @@ import { fontFaceDemand, fontRemoteCoverageDemand, fontResolver, - missingGlyphCharacters, + missingGlyphOccurrences, type FontResolutionSettled } from '#core/text/resolver' @@ -70,12 +70,25 @@ function requiredNodeFaces(node: SceneNode): Array<{ family: string; style: stri return Array.from(faces.values()) } -function languageForCharacter(node: SceneNode, character: string): string | null { - const index = node.text.indexOf(character) - const run = node.styleRuns.find((item) => index >= item.start && index < item.start + item.length) +function languageForCharacter(node: SceneNode, sourceIndex: number): string | null { + const run = node.styleRuns.find( + (item) => sourceIndex >= item.start && sourceIndex < item.start + item.length + ) return run?.style.textLanguage ?? node.textLanguage } +function transformedSourceOffsets(node: SceneNode): number[] { + const offsets: number[] = [] + let sourceIndex = 0 + for (const sourceCharacter of node.text) { + for (const _character of transformTextCase(sourceCharacter, node.textCase)) { + offsets.push(sourceIndex) + } + sourceIndex += sourceCharacter.length + } + return offsets +} + export type NodeFontReadiness = 'ready' | 'substituted' | 'pending' | 'exhausted' function requiredFacesReadiness(r: FontReadinessRenderer, node: SceneNode): NodeFontReadiness { @@ -119,13 +132,17 @@ function demandRemoteCoverage(r: TextRenderer, node: SceneNode, characters: stri function observedGlyphReadiness(r: TextRenderer, node: SceneNode): NodeFontReadiness { const paragraph = buildParagraph(r, node) paragraph.layout(resolveParagraphLayoutWidth(node)) - const missingCharacters = missingGlyphCharacters(node.text, paragraph.getShapedLines()) + const missingOccurrences = missingGlyphOccurrences( + transformTextCase(node.text, node.textCase), + paragraph.getShapedLines(), + transformedSourceOffsets(node) + ) paragraph.delete() - if (missingCharacters.length === 0) return 'ready' + if (missingOccurrences.length === 0) return 'ready' const charactersByScript = new Map() - for (const character of missingCharacters) { - const script = fontFallbackScriptForCharacter(character, languageForCharacter(node, character)) + for (const { character, utf16Start } of missingOccurrences) { + const script = fontFallbackScriptForCharacter(character, languageForCharacter(node, utf16Start)) if (!script) continue const characters = charactersByScript.get(script) ?? [] characters.push(character) diff --git a/packages/core/src/text/fonts.ts b/packages/core/src/text/fonts.ts index 298f2cbe6..91c30518b 100644 --- a/packages/core/src/text/fonts.ts +++ b/packages/core/src/text/fonts.ts @@ -149,6 +149,10 @@ export class FontManager { ): Promise { const cached = await this.readDownloadedFont(family, style, characters) if (!cached) return null + const key = `${family}|${style}` + const loadedCoverage = this.remoteCoverage.get(key) ?? new Set() + for (const character of normalizedCoverageText(characters)) loadedCoverage.add(character) + this.remoteCoverage.set(key, loadedCoverage) return this.registerAndCache(family, style, cached, 'cache') } diff --git a/packages/core/src/text/requirements.ts b/packages/core/src/text/requirements.ts index 8665e69ca..6e62494aa 100644 --- a/packages/core/src/text/requirements.ts +++ b/packages/core/src/text/requirements.ts @@ -57,6 +57,19 @@ function textLanguageAt(node: SceneNode, index: number): string | null { return run?.style.textLanguage ?? node.textLanguage } +function transformedCharactersWithSourceOffsets( + node: SceneNode +): Array<{ character: string; sourceIndex: number }> { + const result: Array<{ character: string; sourceIndex: number }> = [] + let sourceIndex = 0 + for (const sourceCharacter of node.text) { + for (const character of transformTextCase(sourceCharacter, node.textCase)) { + result.push({ character, sourceIndex }) + } + sourceIndex += sourceCharacter.length + } + return result +} export interface GraphFontRequirements { characters: string nodes: SceneNode[] @@ -75,12 +88,10 @@ export function collectGraphFontRequirements( if (!node) return nodes.push(node) if (node.type === 'TEXT') { - let index = 0 - for (const character of transformTextCase(node.text, node.textCase)) { + for (const { character, sourceIndex } of transformedCharactersWithSourceOffsets(node)) { characters.add(character) - const script = fallbackScriptForCharacter(character, textLanguageAt(node, index)) + const script = fallbackScriptForCharacter(character, textLanguageAt(node, sourceIndex)) if (script) scripts.add(script) - index += character.length } } for (const childId of node.childIds) collect(childId) diff --git a/packages/core/src/text/resolver/coverage.ts b/packages/core/src/text/resolver/coverage.ts index 2465ba263..5a7ed0a73 100644 --- a/packages/core/src/text/resolver/coverage.ts +++ b/packages/core/src/text/resolver/coverage.ts @@ -27,31 +27,51 @@ function codePointSpans(text: string): { spans: CodePointSpan[]; utf8Length: num return { spans, utf8Length: utf8Start } } -export function missingGlyphCharacters( +export interface MissingGlyphOccurrence { + character: string + utf16Start: number +} + +export function missingGlyphOccurrences( text: string, - lines: readonly ObservedShapedLine[] -): string[] { + lines: readonly ObservedShapedLine[], + sourceOffsets?: readonly number[] +): MissingGlyphOccurrence[] { if (!text || lines.length === 0) return [] const { spans, utf8Length } = codePointSpans(text) - const finalOffset = lines.at(-1)?.textRange.last + const sourceText = text + const sourceSpans = codePointSpans(sourceText).spans + const finalLine = lines.at(-1) + const finalOffset = finalLine ? finalLine.textRange.last : undefined const offsetsAreUtf16 = finalOffset === text.length && utf8Length !== text.length - const spansByOffset = new Map() - for (const span of spans) { - spansByOffset.set(offsetsAreUtf16 ? span.utf16Start : span.utf8Start, span.character) + const spansByOffset = new Map() + for (const [index, span] of spans.entries()) { + const sourceIndex = + sourceOffsets?.[index] ?? sourceSpans[Math.min(index, sourceSpans.length - 1)].utf16Start + spansByOffset.set(offsetsAreUtf16 ? span.utf16Start : span.utf8Start, { + character: span.character, + utf16Start: sourceIndex + }) } - const missing = new Set() + const missing = new Map() for (const line of lines) { for (const run of line.runs) { for (let index = 0; index < run.glyphs.length; index++) { if (run.glyphs[index] !== 0) continue - const offset = run.offsets[index] - const character = spansByOffset.get(offset) - if (character) missing.add(character) + const occurrence = spansByOffset.get(run.offsets[index]) + if (occurrence) missing.set(`${occurrence.utf16Start}\0${occurrence.character}`, occurrence) } } } - return [...missing] + return [...missing.values()] +} + +export function missingGlyphCharacters( + text: string, + lines: readonly ObservedShapedLine[] +): string[] { + return missingGlyphOccurrences(text, lines).map(({ character }) => character) } export function missingGlyphScripts( diff --git a/tests/engine/text/fonts/loading.test.ts b/tests/engine/text/fonts/loading.test.ts index b68f34681..79f0bafa8 100644 --- a/tests/engine/text/fonts/loading.test.ts +++ b/tests/engine/text/fonts/loading.test.ts @@ -214,6 +214,33 @@ describe('FontManager loaded font cache', () => { expect(writes).toBe(0) }) + test('tracks character coverage restored from downloaded cache', async () => { + const manager = new FontManager() + manager.attachProvider({} as CanvasKit, createRecordingProvider().provider) + const cached = new ArrayBuffer(16) + manager.setDownloadedFontCache({ + async read(family, style, characters) { + return family === 'SubsetCache' && style === 'Regular' && characters === 'A' ? cached : null + }, + async write() { + return undefined + } + }) + + await expect(manager.loadFont('SubsetCache', 'Regular', 'A')).resolves.toBe(cached) + const originalLoadRemoteFont = manager.loadRemoteFont.bind(manager) + let requested = '' + manager.loadRemoteFont = async (_family, _style, characters) => { + requested = characters ?? '' + return cached + } + try { + await manager.loadFont('SubsetCache', 'Regular', 'B') + } finally { + manager.loadRemoteFont = originalLoadRemoteFont + } + expect(requested).toContain('B') + }) test('forwards cancellation to fallback web-font requests', async () => { const manager = new FontManager() let remoteRequestStarted: (() => void) | null = null