* 🐛 Fix nested board drop target ignoring ancestor clip bounds
Frame hit-testing (get-frame-by-position, get-frames-by-position and
top-nested-frame) only checked a candidate board's own rectangle,
without accounting for an ancestor board with clip content enabled.
A nested board wider/taller than its clipping ancestor could still be
picked as the drop target in its invisible, clipped-away area, so a
dragged shape would get reparented there and disappear from view.
Add clipped-by-ancestor? to reject a point when it falls outside the
bounds of any ancestor board that has clip content enabled, so the
lookup now stops at the correct visible ancestor instead of
descending into the hidden region.
* 🐛 Fix Ctrl+click deep-select reaching into clipped board area
The clip-aware quadtree query (query-index) filters candidate shapes
by whether they overlap every clip-parent ancestor, but the whole
filter was skipped whenever clip-children? was false. That flag is
turned off while a modifier key (Ctrl/Cmd) is held for deep/penetrate
selection, which was meant to let it reach past boolean/mask clip
boundaries, but it also disabled enforcement for board "Clip content"
ancestors, letting a modifier-held click select a shape sitting in a
board's invisible, clipped-away region.
overlaps-parent? now only relaxes the check for non-frame clip-parents
(bool shapes / mask children) when clip-children? is false; board clip
ancestors are always enforced regardless of the modifier key.
The project title's max-width was capped via an inline style computed
from the number of thumbnail columns fitting in the grid below it (an
unrelated value, reused only because it happened to be in scope). This
produced an oversized gap between a short/medium title and the file
count, timestamp, and action buttons, and gave long titles an
arbitrary, columns-based truncation point unrelated to the row's
actual available width.
Replace it with a standard flexbox truncate-to-fit: the title sizes to
its own content and sits right next to the info/actions, only
shrinking (and ellipsizing) once the row runs out of room, while the
info/actions never shrink.
* ♻️ Consolidate HIGHLIGHTS.md into CHANGES.md 🚀 section
Eliminate the redundant HIGHLIGHTS.md file and make CHANGES.md
the single source of truth for version highlights.
- Add 🚀 section for 2.15.0 (MCP server integration)
- Add 4 missing highlight entries to 2.17.0 🚀 section
- Rewrite frontend parser to extract from CHANGES.md 🚀
subsections instead of flat HIGHLIGHTS.md format
- Decouple parse-latest-released-version from highlights
extraction so it works independently of 🚀 content
- Conditionally render highlights section in modal when non-empty
- Rewrite tests for new parser behavior (11 tests, 21 assertions)
- Delete HIGHLIGHTS.md and remove .gitignore exception
- Add step 8b to update-changelog skill for proactively
proposing highlights during release workflows
- Add missing-highlights and missing-highlight-reference
anomaly types to the changelog anomaly report script
Closes#11530
AI-assisted-by: qwen3.7-plus
* ♻️ Use consistent string library and add multi-version test
Address code review findings:
- Use str/split (cuerdas) consistently in extract-rocket-items
instead of mixing cstr/split (clojure.string)
- Add parse-highlights-extracts-multiple-versions test to verify
the parser correctly extracts 🚀 items from multiple
versions in a single CHANGES.md body
AI-assisted-by: qwen3.7-plus
* ♻️ Scope 🚀 checks to X.Y.0 and split gaps from anomalies
Type C now only checks released X.Y.0 versions, since patches never carry 🚀 subsections by design. Type D requires both issue AND PR references with exact format, accepting multi-PR entries. C/D are reported as highlight gaps in their own section and no longer count toward the anomaly total. Key Principles and anomaly definitions updated to match. Addresses review comments on PR #11531.
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Render markdown links and bold in check-updates highlights
The highlights modal showed raw markdown from CHANGES.md 🚀 lines (brackets and URLs). Add a pure parse-highlight-item parser for inline links and bold, render fragments with literal hiccup in the modal (links open in a new tab), and style links and strong elements. Non-http URLs and malformed markup degrade to plain text. Adds 12 unit tests.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Point full changelog link to main instead of staging
The view-changelog button in the check-updates modal linked to the staging branch. Point it to main, which holds the published changelog. Version detection still fetches from staging.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix fontFamilies token property mapping in Plugin API
The Plugin API exposes the font-family token property as `fontFamilies`,
while Penpot stores the canonical applied-token attribute as
`:font-family`. The bidirectional plugin/internal attribute map did not
contain that alias, so explicit `applyToken(..., ["fontFamilies"])`
validation rejected the property and applied-token readback exposed the
undocumented singular `fontFamily`.
Add `:font-family -> :font-families` to the existing canonical alias
map. The reverse mapping is derived automatically, keeping application
and readback symmetric without introducing a font-specific code path.
Closes#11405
AI-assisted-by: Omen Alpha
Signed-off-by: 최준수 <junsoo1172@gmail.com>
* 🐛 Fix fontFamilies e2e test to target a text shape
The fontFamilies end-to-end regression created a flex layout frame,
whose attribute set (frame-with-layout-attributes) excludes
:font-family. The workspace token application filters such shapes,
so the internal binding and readback assertions would pass vacuously
without exercising the alias.
Target an actual `:text` shape (ctho/add-text) instead, so the test
verifies the full JS "fontFamilies" -> schema -> alias -> canonical
:font-family -> camelCase readback path.
AI-assisted-by: Omen Alpha
Signed-off-by: 최준수 <junsoo1172@gmail.com>
* 🐛 Fix fontFamilies test WASM error and add changelog entry
The text-shape fontFamilies e2e applies a layout-affecting token via
wasm renderer path, hitting missing WASM exports under Node. Merge
thw/setup-wasm-mocks! into the :each fixture and add plugins
CHANGELOG entry for the fontFamilies alias fix.
AI-assisted-by: muse-spark-1.3-contributor
Related to #11566
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
---------
Signed-off-by: 최준수 <junsoo1172@gmail.com>
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
* ✨ Add new slides content for 2.18 release
* ♻️ Use buttons from DS
* ♻️ Use new SCSS guidelines
* ♻️ Use a base stylesheet for all version files
* ♻️ Use new SCSS guidelines
* ✨ Add new images and wording
---------
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
Store the hide-resolved filter in user storage and restore it when
entering the workspace or viewer, consistent with canvas comment
visibility from #10239. Match the comments filter separator styling to
the main menu and add the missing mentions option in the viewer
dropdown.
Closes#10686
Signed-off-by: Andres Gonzalez <andres.gonzalez79@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
During reload-renderer!, reloading? keeps initialized?/ready? false
while set-objects runs (especially the sync path for small files).
Text content used that guard and was skipped; geometry already used
live?. Gate use-shape, has-shape, and set-shape-text-content on
wasm/live? so text is restored with the rest of the shapes.
* 🎉 Implement independent image bounds resizing
Add canvas resize interaction mode that allows users to resize an
image object's bounding box independently from the underlying bitmap
content without scaling or distortion while holding the Mod key.
AI-assisted-by: gemini-2.5-pro
* ♻️ Address reviewer feedback from elenatorro
- Remove legacy cfh/image-shape? check in shape-has-image-fill?
- Guard bounds-resize with positive dimensions instead of clamping scalev to preserve flipping
- Remove :metadata from transform-attrs in modifiers.cljs
- Restore preserveAspectRatio logic based on keep-ar? in fills.cljs
- Compute source rect against destination rect for raster and SVG fills in WASM renderer
* 🔧 Fix clippy needless borrow warnings in wasm image fills
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Fix missing text in legacy SVG board thumbnails
Board thumbnails rendered by frame-imposter used React's
renderToStaticMarkup, a synchronous pass with no live DOM to measure
text against. Text shapes without a persisted position-data value
therefore rendered as nothing, so a cached board thumbnail silently
lost its text until the board was hovered, selected, or the canvas
was zoomed past 130%, all of which bypass the cached thumbnail in
favor of live content.
frame-imposter now provides the same is-render? context the
standalone exporter already sets, so text without position-data
falls back to the synchronous foreignObject renderer instead of
rendering nothing.
Thumbnails cached before this fix stay broken until something
regenerates them, so on each page load, board thumbnails containing
text are opportunistically regenerated once per browser (tracked via
local-storage) so existing files self-heal without requiring an
edit.
AI-assisted-by: claude-sonnet-5
* ♻️ Use a transducer in heal-stale-text-thumbnails
Fixes a PR review comment: the frame filtering/mapping was spread
across four separate rx operators. Collapse it into a single
transducer pass over frame-ids, leaving only one rx/map to perform
the mark-healed side effect and build the update-thumbnail action.
Set storeDir in every pnpm-workspace.yaml: `.pnpm-store` at the repo
root and `../.pnpm-store` in the ten module workspaces, so all of them
resolve to <repo>/.pnpm-store. pnpm resolves the value against the
workspace root, and nested workspaces do not inherit settings, which
had left the root workspace and the modules on two different stores.
Add scripts/clean-node-modules: removes every workspace node_modules
in one pass (ignores external/ and .opencode/), keeps the shared store
unless --store removes it too.
Verified: every workspace resolves the same store path; reinstalls
after a full clean reuse the cache with zero downloads;
frozen-lockfile installs pass in all 11 workspaces with no lockfile
changes; the frontend storybook suite stays green.
AI-assisted-by: omen-alpha
Replace __dirname with import.meta.dirname in the plugins workspace
vite configs (root paths and the plugins-runtime tsconfigPath) and in
frontend/vite.config.js, dropping the fileURLToPath fallback.
Import vite.config.iife with the explicit .ts extension in the
plugin-api-test-suite headless/tests configs and allow it in
tsconfig.node.json (moduleResolution Bundler + noEmit), matching the
mcp/packages/plugin precedent.
Remove .storybook/vitest.setup.ts and its setupFiles entry;
@storybook/addon-vitest 10.3+ provisions preview annotations
automatically.
These clear the Vite `configLoader: 'native'` warnings ahead of that
loader becoming the default.
AI-assisted-by: omen-alpha
Run `corepack use pnpm@next-12` (resolved to 12.3.4) on every directory
with a package.json: the repo root, the 11 module workspaces, and all
submodules. Every packageManager field now carries the same pinned
version and hash; the root and backend move off 11.20.0.
Fix the composable-test-suite workspace config (esbuild allowBuilds
placeholder left by pnpm 12) so its install passes, and add the missing
packageManager fields to frontend/packages/ui and mcp/packages/plugin,
since corepack only updates existing fields.
Document the canonical update procedure in
.serena/memories/workflow/updating-pnpm.md.
AI-assisted-by: omen-alpha
Add a "PENPOT" section to the board size presets list with sizes for
File thumbnail, Template cover, Plugin icon and Plugin cover, so
designers no longer need to look up these dimensions manually when
creating thumbnails for files, template submissions or plugin
listings.
Closes#11561
AI-assisted-by: claude-sonnet-5
Signed-off-by: Andres Gonzalez <andres.gonzalez79@gmail.com>
Emit clipped `<image href>` for fills with a registered media URL instead
of Skia base64 embeds. Register URLs from the frontend and exporter via
store_image_url. Cover rect, path, frame, and text (glyph clip) cases in
SVG export tests.
Closes#11383
* 🐛 Add content:write permission checks to Design Tokens plugin API
The Design Tokens API (tokens.cljs) had zero permission checks, allowing
any plugin to create, modify, and delete tokens, sets, and themes
regardless of granted permissions.
Add r/check-permission checks to all 22 write operations across:
- token-proxy: name, value, description, duplicate, remove, applyToken
- token-set-proxy: name, active, toggleActive, addToken, duplicate, remove
- token-theme-proxy: group, name, active, toggleActive, addSet, removeSet,
duplicate, remove
- tokens-catalog: addTheme, addSet
Follows the established pattern from comments.cljs, file.cljs, page.cljs.
Closes#11137
AI-assisted-by: qwen3.7-plus
* 🐛 Add permission checks to shape proxy interactions, detach, export, and variants
The shape proxy (shape.cljs) had multiple operations missing permission
checks, plus a cond ordering bug that bypassed the existing content:write
check for text shapes in commit-fills!.
Fix commit-fills! cond ordering: move permission check before the
text-shape branch so text shapes are also protected.
Add content:write permission checks to:
- interaction-proxy: :trigger, :delay, :action setters, :remove method
- shape-proxy: :addInteraction, :removeInteraction, :detach
- shape-proxy: :applyToken, :switchVariant, :combineAsVariants
Add content:read permission check to:
- shape-proxy: :export (read/extraction operation)
Follows the established pattern from :resize, :rotate, :blocked setters.
Relates to #11137
AI-assisted-by: qwen3.7-plus
* 🐛 Add library:write permission checks to variant plugin API
The library.cljs variant operations (variant-proxy and
lib-component-proxy) had seven mutating operations that
did not check the library:write permission, allowing
any plugin to create, modify, and delete component
variants regardless of granted permissions.
Add r/check-permission checks to all 7 operations:
- variant-proxy: addVariant, addProperty,
removeProperty, renameProperty
- lib-component-proxy: transformInVariant, addVariant,
setVariantProperty
Follows the established pattern from the :name and
:path setters in the same file.
Relates to #11137
AI-assisted-by: qwen3.7-plus
* 🐛 Add content:write permission checks to flow and flex layout plugin API
Add permission checks to prototype flow and flex layout operations
that were missing them, allowing plugins to modify flows and layout
structure without explicit user permission.
Changes:
- page.cljs: Add content:write checks to flow-proxy (name,
startingBoard setters, remove) and page-proxy (createFlow,
removeFlow)
- flex.cljs: Add content:write checks to flex-layout-proxy
(remove, appendChild)
Follows the established pattern from tokens.cljs, shape.cljs,
and library.cljs.
Relates to #11137
AI-assisted-by: qwen3.7-plus
* 🐛 Add user:read permission checks to plugin API
Add permission checks to user identity accessors that were bypassing
the consent model, allowing plugins to access user data regardless
of whether the user granted user:read permission.
Changes:
- api.cljs: Add user:read checks to getCurrentUser and getActiveUsers
- comments.cljs: Add user:read checks to comment-proxy and
comment-thread-proxy owner/user getters
- file.cljs: Add user:read check to file-version-proxy createdBy getter
When user:read permission is not granted:
- getCurrentUser() returns null
- getActiveUsers() returns empty array
- owner/user/createdBy getters return null
Follows the established pattern from other permission checks in the
plugin API.
Relates to #11137
AI-assisted-by: qwen3.7-plus
* 🐛 Fix problem with token API
---------
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
* ♻️ Migrate editable-select to DS icon* and design tokens
The dropdown toggle and selected-item tick used the deprecated
app.main.ui.icons component and the SCSS relied on refactor/
placeholders and aliased design tokens.
Replace both icon usages with the DS icon* component (arrow-down
instead of a rotated arrow, tick unchanged, both at DS size "s"),
and rework editable_select.scss to depend only on DS modules:
placeholders inlined, aliases resolved to their terminal --color-*
tokens, sizes/spacing/borders mapped to DS tokens, logical
properties used where they preserve behavior, and per-state colors
driven by component-local CSS custom properties (fed through
currentColor for the icons, since DS icon* strokes default to
currentColor).
AI-assisted-by: claude-sonnet-5
* ♻️ Migrate frame_grid to the DS and unify its options dropdown
Refactors frame_grid.scss off the deprecated refactor/ styling system
onto DS tokens (borders, sizes, spacing, typography), and replaces the
remaining deprecated-icon usages (gap-horizontal, grid-margin) with
the DS icon* component.
Unifies the square/column/row "reset to default" toggle+panel, which
had drifted into two inconsistent, duplicated implementations, into
two shared components (default-options-toggle*, default-options-
dropdown*) built on the existing dropdown component, with proper
menu/menuitem roles and Enter/Space keyboard activation.
Along the way, fixes several regressions the migration surfaced:
- .show-more-options lost its border width when converted to a
CSS-variable-driven color.
- the reset-to-default toggle was enabled while its own options were
disabled instead of the other way around.
- numeric inputs in .height/.gutter/.margin lost their explicit width,
letting them fall back to the browser's intrinsic input width and
overflow the sidebar.
- a leftover CSS override was forcing the docked dropdown-select's
icon to render fully transparent.
Also fixes editable-select's is-selected check, which compared a
stringified option value against a non-stringified current-value and
so could never match, silently hiding the selected-item checkmark.
AI-assisted-by: claude-sonnet-5
* ♻️ Migrate frame_grid's numeric inputs to the DS component
Replaces the deprecated app.main.ui.components.numeric-input with the
DS numeric-input* across .grid-size, .height, .gutter and .margin,
using its built-in :icon/:text-icon slots (via input-field*) instead
of rendering a separate icon element beside the input.
The gutter/margin icons previously rotated 90deg via CSS to indicate
row vs column orientation; DS ships dedicated icon-ids for both axes
(gap-horizontal/gap-vertical, margin-top-bottom/margin-left-right),
so the icon itself now swaps per grid type instead of being rotated.
The height row's "H"/"W" text label is now the matching DS
character-h/character-w icon rather than literal text.
AI-assisted-by: claude-sonnet-5
* ♻️ Flatten unnecessary nesting in frame_grid.scss
CSS Modules already scope every class to this file, so nesting
purely to dodge cross-file name collisions is dead weight now.
Rewrite the nested rules as flat top-level selectors, dropping
ancestor-wrapper prefixes wherever the target class is already
unique in the file and keeping them only where they still do real
work (winning a same-node specificity fight against another
component's own class).
Also drop --show-options-icon-color: it was assigned across every
state but never actually read anywhere, so it did nothing.
AI-assisted-by: claude-sonnet-5
* 🐛 Fix font-family sample not showing for numeric font names
Setting style.fontFamily to a raw, unquoted family name (e.g. "Micro
5") parses it against CSS's <family-name> grammar: a whitespace-
separated sequence of <custom-ident>s. "Micro" tokenizes fine, but a
bare "5" isn't a valid CSS identifier (idents can't start with a
digit) — it tokenizes as a number instead, so the whole property is
invalid CSS and the browser silently drops it. Every other font in
the list happened to avoid this because none of their names have a
token that's purely numeric.
Quote the family name, matching what font-item-preview* (the font
selector's own preview, a few lines down in the same file) already
does, so it's parsed as a CSS string instead of unquoted identifiers.
Also falls back to the live fontsdb entry's family when the
typography record's own :font-family is blank — a font that was
unloaded when a typography's font/variant was last changed can leave
that field nil (the same failure mode remove-nil-style-attrs already
repairs for shape text spans) — and loads the font unconditionally in
the collapsed asset row, matching the expanded editor, since the
optical-offset cache can otherwise skip loading it entirely.
AI-assisted-by: claude-sonnet-5
* 🐛 Fix flaky typography sample position in automated tests
The optical-centering offset for the "Ag" sample (and the font
selector's fallback name label) resolves asynchronously: first paint
is unshifted, then an idle-scheduled Canvas measurement lands and the
sample jumps to its final position. Any test that checks position or
takes a screenshot shortly after paint races that jump — whether it
runs before or after is a timing accident, not a deterministic
outcome, which is exactly the "sometimes a few pixels up, sometimes
down" flakiness QA hit.
use-optical-offset now returns [offset ready?], with both lazily
initialized from the cache so a cache hit needs no async round-trip
at all. sample-text-style hides the glyphs until ready?, so the
sample only ever appears already in its final, correct position
instead of visibly moving there after the fact.
The font selector's own name label uses the same hook but always
shows real text content rather than a decorative sample, so it keeps
the old behavior instead: hiding it would blank out font names while
scrolling, worse than the minor positional nicety it's fixing.
AI-assisted-by: claude-sonnet-5
When the fetched manifest differs from the stored manifest
(code, name, etc.), show the permissions dialog instead of
silently updating — preventing execution of tampered/injected
plugins.
On fetch error, show a warning notification instead of loading
with the old manifest.
Bundled plugins (no URL) skip validation as they are trusted
with no remote source.
Completes the 3-layer defense for T3-N1-02: (1) closed
permission schema, (2) dedicated RPC methods, (3) integrity
validation on open.
AI-assisted-by: qwen3.7-plus
Add `add-profile-plugin` and `remove-profile-plugin` RPC methods for
atomic plugin registry operations, preventing manipulation via the
broader `update-profile-props` endpoint.
- Close the `:plugins` field in `update-profile-props` schema to
eliminate the mass assignment attack vector for plugin data.
- Define `valid-permissions` and a closed `schema:permissions` enum to
restrict plugin permissions to known values.
- Migrate the frontend to use the new granular RPC methods with
optimistic updates and rollback on failure.
- Add comprehensive backend tests covering valid/invalid permissions,
updates, removal, and rejection via old endpoint.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix crash when a nil shape id reaches WASM modifiers
A modif-tree containing a nil shape id crashed the WASM heap write
with "Cannot read properties of null (reading '__u32_buffer')" in
propagate-modifiers. Production builds elide the upstream asserts,
so a nil id (e.g. update-dimensions called with a missing shape's
parent id in the variants flow) reached the uuid heap write.
Drop nil-keyed modif-tree entries in apply-wasm-modifiers and
set-wasm-modifiers before they reach propagate-modifiers and
set-structure-modifiers, filter nil ids at the parse level, and
guard the variant reposition/creation events against missing shapes.
AI-assisted-by: hy3
* 🐛 Fix let scoping bugs in modifiers.cljs
Two let forms in set-wasm-modifiers and apply-wasm-modifiers
had their bindings vectors closed prematurely, causing undeclared
var warnings for snap-pixel?, translation?, ids, update-shape,
options, bool-ids, undo-id, and other bindings.
In set-wasm-modifiers: removed premature ) after ] on the let
bindings so the let body wraps snap-pixel? and translation?.
In apply-wasm-modifiers: the cond form was not explicitly closed,
so all subsequent bindings (ignore-tree, options, modif-tree, ids,
update-shape, bool-ids, undo-id) were parsed as cond clauses
instead of let bindings. Added ) to close cond after :else, and
moved ] to close the bindings vector after undo-id.
AI-assisted-by: mimo-v2.5-pro
* 🐛 Fix stroke width token dropdown closing mid-interaction
The stroke-row* key included a hash of applied-tokens, forcing a full
remount whenever the async token-resolution pipeline updated that
state (e.g. after a detach action settles). If the remount landed
while the width dropdown was open, it destroyed the dropdown and
reset its open state, permanently closing it before the user could
pick a token.
Drop the hash from the key so the row only remounts on actual
structural changes (add/remove/reorder), not on every token
resolution tick.
AI-assisted-by: claude-sonnet-5
* 🐛 Fix crash when a typography token has a nil value
A composite typography token saved with no fields filled in stores a
nil :value. That value reached StyleDictionary's tokens-studio
font-styles preprocessor, which assumes a typography value is never
null and threw, crashing token resolution for every other token in
the file.
Reject the nil value at the source in the typography token form
validation, and as defense in depth, filter nil-valued tokens out of
every resolve-tokens* entry point before they reach StyleDictionary,
tagging them with the existing empty-input error instead.
Also remove a stray debug prn left in find-refs.
AI-assisted-by: claude-sonnet-5
* 🔧 Gitignore local CLAUDE.md
Keep the commit-conventions instructions file local to each
contributor's checkout instead of tracking it in the repo.
AI-assisted-by: claude-sonnet-5
* ⚡ Use a single transducer to tag invalid-value tokens
merge-invalid-value-tokens ran three separate passes over the token
map (remove, map, into) and then merged the result back in. Combine
the remove/map steps into one ns-level transducer, defined once
instead of rebuilt on every call, and pass resolved as the seed to
into so the trailing merge isn't needed either.
AI-assisted-by: claude-sonnet-5
* ♻️ Drop redundant t/testing wrapper in nil-value token test
The outer t/testing just repeated the deftest's own name and added
nothing the two inner t/testing blocks (each covering one concrete
assertion group) don't already say.
AI-assisted-by: claude-sonnet-5
* 🐛 Fail the nil-value token test on a resolution error
rx/sub! only handles the success case, so if token resolution ever
errors instead, done is never called and the async test hangs
instead of failing. Switch to rx/subs! with an error handler that
reports the failure and calls done, matching the pattern already
used elsewhere in the tokens test suite.
AI-assisted-by: claude-sonnet-5
* 🐛 Align WASM auto-width text size with HTML measurement
Include the HTML paragraph-set 1px right margin in Skia auto-width
so WASM and DOM measurements match.
* ⚡ Scope and defer post-font WASM text relayout
Track pending font faces per shape, force-relayout only affected
texts, defer stale-selrect sync until the first tiles paint, and
batch request-render after attr updates. Tolerate missing font-face
keys so callers that omit them do not blow up.