本文へ移動
cccskills
無料GitHub で公開

engine

Query, recommend, and switch the Pulp JS engine backend (QuickJS, JavaScriptCore, V8). Handles "which JS engine", "switch to V8", "engine for Three.js".

インストール方法を見る

含まれるファイル(1)

  • SKILL.md85.8 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

JS Engine Skill

Manage the JavaScript engine backend used by Pulp's scripting layer. Three engines are available:

EnginePlatformStrengthsLicense
QuickJSAllPortable, small, zero dependencies. Default.MIT
JavaScriptCoreApple onlySystem framework, good JIT, zero-dep on macOS/iOSLGPL-2.1 (system use OK)
V8DesktopBest JIT, ideal for heavy JS (Three.js), largest footprintBSD-3-Clause

Web-compat preludes shipped with every engine

Every engine boots with the same set of web-compat-*.js preludes embedded into web_compat_preludes_gen.hpp and evaluated in order by WidgetBridge. The current set covers everything React 18 dev (and most similar frameworks) feature-detect on construction:

SurfaceWhereWhy
Element.nodeType (=1) / nodeName (=tagName)web-compat-element.jsReact reconciler walks every node and bails before first commit without DOM-compatible node identity.
Element.ELEMENT_NODE / TEXT_NODE / COMMENT_NODE constantsweb-compat-element.jsnode.ELEMENT_NODE === 1 fast-paths in React
Widget tag factories (virtual-list, virtuallist, segmented, stepper, etc.)web-compat-element.jsDOM-lite custom tags construct the same native widgets as @pulp/react; keep this table in sync when a new widget tag is routed through _ensureNative. A tag needs FOUR tables in step, not one: this map, the createX factory (factory_api.cpp), make_widget_for_tag() (widget_bridge.cpp, the __domAppend path), and wire_callbacks() (widget_callbacks.cpp). The factory wires its callbacks inline while the tag path takes them from wire_callbacks(), so missing that one builds a real, clickable widget whose changes reach nothing — on the tag path only, from a script that is identical either way.
createTextNode → nodeType=3 + nodeName='#text' + data/nodeValue mirrorsweb-compat-document.jsDOM Level 1 text-node spec; React's text-update path
createComment → nodeType=8, createDocumentFragment → nodeType=11web-compat-document.jsReact portal sentinels + batched commits
MutationObserver / IntersectionObserver / ResizeObserver / PerformanceObserver no-opsweb-compat-observers.jstypeof X === 'function' feature-detects pass; React skips because no events ever fire
XMLHttpRequest no-op + spec readyState constantsweb-compat-observers.jsReact dev-mode error-stack lookup probes XHR
Element.scrollTop/scrollLeft/scrollWidth/scrollHeight (returns 0)web-compat-observers.jsReact dev focus warnings
queueMicrotask (Promise-based shim)web-compat-scheduler.jsReact 18 concurrent scheduler
MessageChannel + MessagePort (microtask-deferred postMessage)web-compat-scheduler.jsReact 18 scheduler prefers MC; falls back to setTimeout if missing (perf cliff, not a blocker)
URLSearchParams polyfillweb-compat-scheduler.jsReact error-source URL parsing
requestAnimationFrame / cancelAnimationFrame (driven by native __requestFrame__)web-compat-scheduler.jsBundled-React frameworks reference the standard names; without this each consumer has to carry its own scheduler shim.
setTimeout / clearTimeout / setInterval / clearInterval (driven by native __scheduleTimer__ deadline tracker)web-compat-scheduler.jsReact's scheduler yield path + plugin code; setTimeout(fn, 0) drains via microtask, positive delays drain in service_frame_callbacks().
performance.now() (driven by native __performanceNow__)web-compat-scheduler.jsBundled-React modules read performance.now at module-eval time before the legacy window.performance shim is reachable.
Mirror block onto window (rAF/cAF/sT/cT/sI/cI/MC/qM/perf)web-compat-scheduler.jsReact 18's scheduler reads window.setTimeout / window.requestAnimationFrame specifically; the global must be reachable through both names.

Popup keyboard navigation is owned by the prelude, not the app

web-compat-document.js runs a popup owner that gives any open popup a roving keyboard cursor: ArrowUp/ArrowDown step, Home/End jump, Enter activates, Escape dismisses, and the stepped row is marked data-pulp-popup-active="true". Apps should NOT re-implement this in script; authoring role="menu" with role="menuitem" rows is enough to inherit it.

Two things about that marker and that owner are easy to get wrong:

  • PRESENCE vs VALUE of data-pulp-popup-active. The attribute is set on EVERY row of a popup Pulp owns; the value "true" marks the one row the cursor is on. A check written as getAttribute(...) truthiness reports every row active and always finds row 0 — it must compare === "true". The same distinction bites selectors: [data-pulp-popup-active] asks "does Pulp own a popup", [data-pulp-popup-active="true"] asks "is a cursor painted", and a pointer-opened popup paints no cursor until the user asks for one.

  • A popup may have NO trigger. The owner was originally entered only from triggerFrom(document.activeElement), so a context menu — summoned at coordinates, owned by no aria-haspopup element — never got state and ignored arrow keys. Such a menu is now adopted on the first arrow. When touching this code, remember state.trigger can be null: document.body.contains(null) is false, so an unguarded staleness test reads "my trigger vanished" and dismisses the state on every event, which presents as a cursor that will not move (it is re-adopted from scratch each key). Adoption is gated on exactly one unowned, non-empty, non-opted-out role="menu" being open, because an arrow key that no popup consumes must stay available to the app and to the DAW.

When adding new framework support, check the engine capability comparison before assuming a polyfill is missing — the entry above is exhaustive for React 18 dev. Add new files to core/view/CMakeLists.txt's PULP_JS_PRELUDES list AND to the eval_or_throw block in core/view/src/widget_bridge.cpp (embed_js.cmake only embeds the constants; the bridge constructor evaluates them).

Canvas2D surface coverage

web-compat-canvas.js exposes CanvasRenderingContext2D.prototype with the standard methods plus the gap-list closures and FilterBank-parity additions that keep common Canvas2D calls from silently no-oping:

MethodNotes
measureText(text)Returns a full HTML5 TextMetrics object — width + actualBoundingBox{Left,Right,Ascent,Descent} + fontBoundingBox{Ascent,Descent}. Routed through canvasMeasureText which calls SkiaCanvas::measure_text_with_font for surface-less metrics.
drawImage(img, …)3 / 5 / 9-arg signatures supported; the 9-arg (sx,sy,sw,sh,dx,dy,dw,dh) form currently ignores the source rect — file a follow-up if a plugin needs sprite-sheet slicing.
setLineDash([…]) / getLineDash()Even-length patterns are taken verbatim; odd-length patterns are duplicated per the HTML5 spec. Phase comes from lineDashOffset.
getImageData(x,y,w,h)Returns {data: Uint8ClampedArray, width, height}. The bridge currently returns zero-filled pixels (no live surface handle from JS-call context); consumers that need real pixels should round-trip through a render-host integration.
putImageData(img, dx, dy)Decodes the typed array to base64 across the bridge and applies via Canvas::write_pixels on backends that implement it (Skia today).
save() / restore()Forward to canvasSave / canvasRestore. JS-side caches of last-pushed text/line/global state are invalidated on save so the next draw re-pushes — the bridge captures the matching state on the C++ side via SkCanvas::save.
translate / scale / rotate / setTransform / resetTransform / transformForward to canvasTranslate / canvasScale / canvasRotate / canvasSetTransform. transform is best-effort: pure translation forwards to canvasTranslate; other matrices are silently dropped (the bridge has no concat primitive). setTransform accepts the (a,b,c,d,e,f) form and the single-DOMMatrix form.
arc(cx,cy,r,a0,a1,ccw) / ellipseApproximated as cubic-Bezier segments (4-segment unit-circle scaling) so the path participates in fill() / stroke() / clip(). arcTo is a conservative two-segment lineTo approximation — sufficient for rounded marquee corners, not fidelity-critical.
bezierCurveTo / quadraticCurveTo / rect / roundRectForward to canvasCubicTo / canvasQuadTo / repeated canvasLineTo. rect emits an explicit closing lineTo back to the start so the resulting subpath is closed. roundRect honours the uniform-radius case; non-uniform radii[] falls back to radii[0].
clip(fillRule)Calls canvasClip. The fill rule is dropped — Pulp's bridge currently ignores even-odd vs nonzero, matching SkCanvas defaults.
fillText(text,x,y) / strokeTextfillText syncs global / text state and forwards to canvasFillText with the active fillStyle's colour (or first gradient stop). strokeText falls back to fillText with the strokeStyle colour — Pulp's bridge has no stroke-text command.
createLinearGradient / createRadialGradient / createConicGradientReturn a CanvasGradient object with _kind, _params, _stops, and an addColorStop(offset, color) method. Gradients are NOT pushed to the bridge until they're assigned to fillStyle / strokeStyle AND a draw fires — _applyFillStyle() / _applyStrokeStyle() flush via the matching linear, radial, two-circle radial, or conic bridge setter.
fillStyle / strokeStylePlain fields. _applyFillStyle() runs before every fill draw and flushes either a string colour (via canvasSetFillColor), a gradient (via canvasSetLinearGradient / canvasSetRadialGradient / canvasSetConicGradient), or a pattern (via canvasSetFillPattern), tracking _activeFillKind so a subsequent string assignment first calls canvasClearGradient. _applyStrokeStyle() routes string colours, linear/radial/conic gradients, and patterns through the stroke bridge setters where the backend supports them; CoreGraphics conic stroke and stroke pattern paths still fall back to a solid colour.
globalAlpha / globalCompositeOperation / font / textAlign / textBaseline / lineCap / lineJoinPlain fields. Pushed to the bridge via _syncGlobalState / _syncTextState / _syncLineState lazy helpers — they only emit a canvas* call when the value differs from the last-sent cache, and the cache is invalidated on save() / restore().
createPatternReturns a CanvasPattern handle for non-empty file / data-URL image sources. Fill patterns flush through canvasSetFillPattern; Skia renders tiled image shaders and CoreGraphics uses a CGPattern tile callback for fills. Missing or undecodable images fail gracefully in the backend paint path.

Why the shim must export every Canvas2D method

If any common method (save, setTransform, createLinearGradient, globalAlpha setter, …) is missing from CanvasRenderingContext2D.prototype, the very first call to it throws TypeError: ... is not a function. The exception unwinds the calling function — and in a React render boundary, the boundary swallows the throw and silently retries on the next commit. Net effect: only the prefix of canvas calls before the throw makes it to the bridge, and the rendered output is missing whatever the rest of the frame would have drawn. The Spectr FilterBank standalone displayed this exact symptom (a clean clearRect + nothing else, leaving the parent's dark navy bg showing through where canvas content should have been). The fix is shim coverage at the JS layer, not at the CanvasWidget/SkiaCanvas pipeline below.

When adding a new Canvas2D method, audit:

  1. Does the bridge expose a matching canvas* function in core/view/src/widget_bridge.cpp? If not, add it.
  2. Is the path expressed as path-construction (records into the current Skia path) vs immediate-mode (draws now)? Match the spec — arc() is path-construction, not a stroke.
  3. Does the new method need any of the cached state to flush (font / textAlign / lineCap / globalAlpha)? Call the relevant _sync* helper before forwarding to the bridge.
  4. Add a regression test in test/test_canvas2d_shim.cpp covering the new method's existence + a representative end-to-end Skia render (the FilterBank-style raster test pattern).

Whole scripts reuse compiled bytecode (QuickJS)

JsEngine::evaluate_script() / ScriptEngine::evaluate_script() evaluate a whole script (a bundle, a prelude, a runtime-import payload) with evaluate()'s exact result and error semantics. On QuickJS a script of 2 KB or more is compiled once per process (JS_EVAL_FLAG_COMPILE_ONLY + JS_WriteObject), and every later realm evaluating byte-identical source deserializes the bytecode (JS_ReadObject + JS_EvalFunction) instead of parsing. WidgetBridge routes preludes, load_script(), runtime-import payloads and a document's inline <script> blocks through it, so a reopened plug-in editor or a second instance skips parsing its 1–2 MB UI bundle and every inline script of a few KB.

  • Keyed by the full source text (no hash collisions), bounded (64 scripts / 96 MB), in memory only. Never persist QuickJS bytecode to disk or load it from anywhere else: QuickJS does not validate bytecode, so only bytes this process produced are read back.
  • Any failure to reuse falls back to compiling; PULP_JS_BYTECODE_CACHE=0 disables it.
  • script_bytecode_cache_stats() (compiles / hits / bypassed) lets a test or an editor-open budget assert reuse by count.
  • Use evaluate() for expressions whose value you read and for per-frame traffic. Only whole, repeatable scripts belong in evaluate_script(): the 2 KB floor assumes it never sees per-frame or generated-per-call source, and a caller that builds a unique string each call (a JSON payload spliced into code) would churn the 64-entry cache and evict the UI bundle.
  • A warm open that still shows script_compile under runtime_import_inline_eval means an inline script differs per open (a timestamp or nonce baked into the document), not that the cache is off.
  • JSC and V8 inherit the default (evaluate_script → evaluate).

Bridge dispatch measurement

WidgetBridge::bridge_call_count() is a per-bridge diagnostic counter for JS-to-native dispatches. It covers direct registered functions, host-object methods, and promise-backed functions; pure JavaScript evaluation and native callbacks that invoke JavaScript do not increment it. Reset the counter before an import or interaction when collecting deterministic work counts. The counter is instrumentation only and does not change engine selection or runtime behavior.

Registered functions retain shared counter state until their engine releases them. Do not replace that capture with a pointer into WidgetBridge: a retained stateless function or queued Promise can run after the bridge is destroyed. Counter ownership does not extend the lifetime of the bridge or its widgets.

Commands

status — Show current engine configuration

  1. Read CMakeCache.txt in the build directory to find PULP_JS_ENGINE:
    grep PULP_JS_ENGINE build/CMakeCache.txt 2>/dev/null || echo "Not configured (default: QuickJS)"
    
  2. Report which engines are available on this platform:
    • QuickJS: always
    • JSC: only on macOS/iOS
    • V8: only if V8_INCLUDE_DIR and V8_LIB_DIR are set
  3. Show the current default engine for this build.

recommend <workload> — Suggest the best engine

Based on the workload description:

  • Three.js / heavy 3D scenes: Recommend V8 (JIT compilation makes ~1MB library parse viable, complex scene graphs need fast execution). If V8 not available, warn that QuickJS will work but parse time may exceed 1 second.
  • Standard plugin UIs: Recommend QuickJS (portable, proven, all existing code tested against it).
  • Apple-only shipping: Recommend JSC (zero dependency, good performance, system framework).
  • Cross-platform shipping: Recommend QuickJS (works identically everywhere).

switch <engine> — Change the JS engine

IMPORTANT: Always confirm with the user before switching.

  1. Determine the requested engine (quickjs, jsc, v8).
  2. Check availability:
    • If jsc on non-Apple: explain it's not available, suggest alternatives.
    • If v8 without V8 libs: explain V8 must be built/installed separately, link to docs.
  3. Use AskUserQuestion to confirm the change:
    • Show the current engine
    • Show what will change
    • Warn about any implications (e.g., reconfigure + full rebuild required)
    • Ask "Switch JS engine to X?" with Yes/No options
  4. If confirmed, run:
    cd <project_root>
    cmake -S . -B build -DPULP_JS_ENGINE=<engine>
    tools/ci/governed-build.sh cmake --build build
    
    Or via the CLI:
    pulp build --js-engine=<engine>
    
  5. After build succeeds, run tests to verify nothing broke:
    ctest --test-dir build --output-on-failure -E "AudioWorkgroup|GpuSurface"
    

Auto-detection hint

When reviewing or loading JS code, if you see any of these patterns, proactively suggest an engine:

  • THREE.Scene, THREE.WebGLRenderer, import * as THREE → suggest V8
  • Large JS files (>500KB) → suggest V8 for parse performance
  • Apple-only target (iOS app, AU-only plugin) → mention JSC as an option

Use recommend logic above, but never auto-switch — always confirm first.

Engine Selection Semantics

  • auto (default): QuickJS everywhere unless the build explicitly sets a PULP_DEFAULT_ENGINE_* compile option. Backward compatible. Safe. Do not describe this as "Apple defaults to JSC"; that was an old header comment, not the implementation.
  • quickjs: Explicit QuickJS. Same as auto today.
  • jsc: JavaScriptCore on Apple. Build fails on non-Apple.
  • v8: V8 on desktop and Android API 29+ via the pinned sealed libv8 (fetched into external/v8-build/). Build fails if not fetched; Android API 26-28 and iOS fail closed for this provider. See "V8 provider library" below.

The engine choice is a build-time CMake option. Changing it requires reconfigure + rebuild. The abstraction ensures all JS bridge code works identically across engines — the switch is invisible to UI scripts.

Cooperative interrupt & the runtime inspector (no step debugger)

JsEngine exposes supports_interrupt() / request_interrupt() — the one sanctioned cross-thread entry point on this otherwise single-threaded interface. It aborts a runaway evaluation (surfacing as a thrown "interrupted" exception on the engine thread). Gotchas:

  • QuickJS reuses CHOC's handler — do NOT install your own. CHOC's QuickJSContext already installs a JS_SetInterruptHandler backed by an atomic shouldCancel, exposed as the thread-safe Context::cancel(). QuickJsEngine::request_interrupt() just calls context_.cancel(). Installing a second JS_SetInterruptHandler would clobber CHOC's and break cancel().
  • Arming while idle aborts the next eval. QuickJS only clears the cancel flag on the next interrupt check, which fires only during JS execution. So callers must arm the interrupt only while an evaluation is actually in flight (ScriptInspectorBridge enforces this).
  • Inspector evaluation is bounded before values become generic CHOC data. QuickJS implements evaluate_bounded_json() by walking the live JSValue with byte, depth, and cycle limits. Keep that serializer in the backend; materializing an unbounded object graph first defeats the result-size limit. The bridge gives evaluation two seconds, then a fixed 500 ms grace for the mandatory realm rebuild, all inside the standalone inspector's three-second main-thread RPC fence. The server's owned asynchronous worker leaves the same authenticated controller connection free to deliver Runtime.interrupt and is included in the module-unload shutdown fence.
  • Mainline QuickJS has NO source-line debugger protocol. No breakpoints, stepping, suspended frames, or local-scope inspection — those live only in QuickJS forks (quickjs-ng / koush) Pulp does not vendor. The scripted-UI runtime inspector (ScriptInspectorBridge + the inspector Runtime.* domain) is therefore an honest evaluate / capabilities / interrupt / device logs console, not a step debugger; Runtime.getCapabilities reports canBreak/canStep/canInspectLocals = false. A real step debugger is a future engine-capability milestone (a debugger-enabled backend, or the Chrome DevTools inspector JSC/V8 expose). See docs/reference/scripted-ui-inspector.md.

V8 provider library (how V8 is obtained)

Pulp does not build V8 from source, and (since 2026-06) does not use a developer's Homebrew libnode. The provider is a pinned, sealed prebuilt libv8 from the danielraffel/v8-builder fork — the same pin/fetch/Find pattern as Skia:

  1. Pin: the V8 entry in tools/deps/manifest.json (determinism.release_assets, per-platform URL + sha256, tag v8-m153-15.3.76.5-26cef0256b0e).
  2. Fetch: python3 tools/scripts/fetch_v8_for_release.py <platform> downloads + sha256-verifies + unpacks to external/v8-build/<platform>/ (include/ + lib/). Platforms: darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64, windows-arm64, android-arm64, ios-simulator-arm64. The iOS asset is an actual jitless simulator V8.framework; Pulp validates its provenance and headers but does not select or package it as an iOS/AUv3 runtime.
  3. Resolve: tools/cmake/FindV8.cmake finds external/v8-build/<key> (or a baked $V8_DIR, or legacy overrides) and exposes the v8::v8 imported target. The configure log prints -- Pulp V8 provider: <path> (platform key: ...).

Selecting V8 — that's all you need:

python3 tools/scripts/fetch_v8_for_release.py darwin-arm64   # once
cmake -S . -B build -DPULP_JS_ENGINE=v8 \
  -DPULP_ENABLE_GPU=ON -DPULP_BUILD_TESTS=ON                 # no V8_* paths

FindV8 resolves the fetched artifact automatically — no V8_INCLUDE_DIR/ V8_LIB_DIR/V8_LIBRARY_PATH needed. Those still exist as advanced local-experiment overrides (point at a hand-built V8). V8_DIR points at a baked V8 (golden VMs: V8_DIR=~/pulp-v8-build).

Three behavior rules:

  • PULP_JS_ENGINE=auto never pulls in V8 — V8 is strictly opt-in via =v8. (Previously auto + V8_INCLUDE_DIR silently enabled it.)
  • PULP_JS_ENGINE=v8 on iOS is a configure-time FATAL_ERROR. The pinned m153 asset is a jitless simulator framework, but Pulp has no device/AUv3 V8 runtime acceptance or packaging contract. Use QuickJS (the default) or JSC.
  • PULP_JS_ENGINE=v8 on Android requires API 29+ for the pinned m153 provider. Pulp's general Android floor remains API 26; use QuickJS below 29.

Why the sealed build (the ICU caveat): the v8-builder libv8 exports only the v8::/cppgc:: API and keeps its bundled ICU/zlib/Abseil internal, so they don't collide with Skia's bundled-but-flat-named ICU/HarfBuzz at the final link. Confirm any provider is seal-safe with nm -gU <lib> | c++filt | grep -cE 'icu_[0-9]+::|absl::' — only v8::internal:: functions whose signatures mention those types should appear, never re-exported icu_NN::/absl:: library symbols. V8 is still confined to js_v8_engine.cpp and never shares a TU with a Skia/Dawn header.

Windows sealed v8-builder V8 (clang-cl + Chromium libc++ __Cr)

The v8-builder sealed Windows v8.dll (e.g. v8-m153-15.3.76.5-26cef0256b0e) is a special ABI lane, NOT a drop-in for MSVC cl. It is built (v8-builder build-v8.py:win_gn_args()) with pointer compression ON, Chromium's bundled libc++ (__Cr ABI namespace), sandbox OFF, rtti OFF. So v8.dll.lib exports its std::-bearing API mangled into __Cr@std (verify: hundreds of ...@__Cr@std@@ symbols). A consumer built with MSVC cl + MSVC STL will not link — its plain @std@@ symbols never resolve against the __Cr@std imports. The m153 archive's ABI shape and required files are inspected and pinned. The following consumer contract was last link/run proven with m152 (15.2.124.7) on the Win11-24H2 arm64 QEMU golden (x64 V8 under ARM x64 emulation); the m153 consumer link/run remains a required platform acceptance step:

  1. Compile the V8 TU (js_v8_engine.cpp) with clang-cl, not cl. tools/cmake/PulpV8Windows.cmake hard-fails configure if the compiler is MSVC cl.
  2. -DV8_COMPRESS_POINTERS — mandatory; without it the inline v8-internal.h tagged-field offsets mismatch the DLL and silently corrupt the heap.
  3. Chromium-style libc++ headers with _LIBCPP_ABI_NAMESPACE=__Cr (routed via clang-cl's /clang:-nostdinc++ /clang:-isystem — bare -nostdinc++ is silently dropped by clang-cl, which is the trap that makes a build fall back to MSVC STL). The official Windows LLVM package ships clang-cl but no libc++, so headers come from an llvm-project libcxx/include checkout. The __config_site must set _LIBCPP_HAS_LOCALIZATION 1 or <ostream>/<sstream>/std::endl silently vanish.
  4. A __Cr-ABI libc++.lib — v8.dll exports NONE of the out-of-line libc++ runtime (no std::cout, basic_string/ basic_ostream ctors/dtors, operator new). Build it from llvm-project runtimes with LLVM_ENABLE_RUNTIMES=libcxx, LIBCXX_ABI_NAMESPACE=__Cr, LIBCXX_ABI_VERSION=2, LIBCXX_CXX_ABI=vcruntime, RTTI ON + exceptions ON (libc++ refuses exceptions-on/rtti-off). Link it + msvcprt.lib (the latter supplies the __ExceptionPtr* vcruntime glue), and define _CRT_STDIO_ISO_WIDE_SPECIFIERS=1 to match the lib (else lld-link /FAILIFMISMATCH).

Wiring: select V8 the normal way (PULP_JS_ENGINE=v8 after fetch_v8_for_release.py windows-x64, which unpacks the sealed v8.dll.lib under external/v8-build/, so FindV8.cmake resolves it). On Windows, additionally supply PULP_V8_WIN_LIBCXX_INCLUDE and PULP_V8_WIN_LIBCXX_LIB (the Chromium-style libc++ headers + the __Cr-ABI libc++.lib). core/view/CMakeLists.txt links v8::v8 and then calls pulp_v8_windows_apply_abi(pulp-view-script) (no-op off Windows). Last runtime proof (m152): choc V8 consumer init + evaluateExpression("40 + 2") == 42, V8::GetVersion() == 15.2.124.7. The m153 Windows archive has passed structural ABI inspection but not this consumer link/run gate. The mac/linux sealed artifacts use the system libc++ (no __Cr) and need none of this — the __Cr contract is Windows-only.

Cross-platform rollout + ship/sign/package (Windows MSVC /MT ABI, macOS nested-dylib re-signing, Android ABI gating, golden-VM bake) are tracked in planning/2026-06-06-v8-sealed-libv8-provider-migration-plan.md. macOS arm64 is the proven m153 runtime lane; other platforms are pinned but pending per-platform verification.

Sealed v8-builder provider (FindV8.cmake → external/v8-build/)

The pinned provider is a sealed libv8.dylib from the v8-builder project: a single dylib with @rpath/libv8.dylib install_name, no external ICU/zlib/Abseil links, and a pinned runtime version recorded in tools/deps/manifest.json. Fetch it once, then select v8 — FindV8.cmake resolves the unpacked artifact under external/v8-build/<platform>/ and exposes the v8::v8 imported target; no V8_* paths are needed:

python3 tools/scripts/fetch_v8_for_release.py darwin-arm64   # once
cmake -S . -B build -DPULP_JS_ENGINE=v8 \
  -DPULP_VALIDATE_V8_PROVIDER_STRICT=ON \
  -DPULP_ENABLE_GPU=ON -DPULP_BUILD_TESTS=ON

When PULP_JS_ENGINE=v8, core/view/CMakeLists.txt includes FindV8.cmake (fatal if no sealed V8 is resolved — an explicit =v8 request never silently falls back), links v8::v8, and then fills in the provider-identity compile defs from the resolved artifact: PULP_V8_PROVIDER_KIND (v8builder), PULP_V8_PROVIDER_PATH (${V8_RUNTIME_LIBRARY}), and PULP_V8_EXPECTED_RUNTIME_VERSION (parsed from the V8 dependency's pinned tag in manifest.json). The build-tree rpath for @rpath/libv8.dylib is handled by FindV8's imported target. This is the explicit V8 lane only — it does NOT change the engine default (auto stays QuickJS; iOS is a configure-time FATAL_ERROR because Pulp has no accepted device/AUv3 V8 runtime or packaging contract for the simulator-only jitless provider).

Identity surface: JsEngine::runtime_version() (V8's v8::V8::GetVersion()) / provider_kind() / provider_path() / expected_runtime_version() (defaults empty for QuickJS/JSC), forwarded through ScriptEngine. They prove which V8 is actually linked, not just which headers compiled. examples/threejs-native-demo --print-engine-identity emits a parseable PULP_ENGINE_IDENTITY_BEGIN…END block; otool -L the demo to confirm @rpath/libv8.dylib and no libnode.

Why the seal matters (observed A/B, 2026-06-05): Homebrew libnode.147.dylib links external Homebrew ICU and exports ~567 Abseil symbols; under the full three.webgpu.js workload its bundled Abseil aborts with PerTableSeed/raw_hash_set.h:456 (SIGABRT) — the cube never renders, though basic evaluate() and all pulp-test-js-engine cases still pass. The sealed v8-builder libv8.dylib (no external ICU, 4 incidental absl symbols) renders the cube cleanly next to Dawn/Skia. The libnode-147 three.js abort is also documented in examples/threejs-native-demo/README.md.

Strict CTest: PULP_VALIDATE_V8_PROVIDER_STRICT=ON adds v8_provider_identity_strict — a no-skip-pass gate (contrast with capture_test.cmake, which tolerates no-V8/no-Dawn) that asserts the identity block and renders --demo cube --capture to a non-empty PNG.

Gotchas

Selector parses are memoized. _parseSelector (web-compat-document- selectors.js) caches its record by selector text (bounded, cleared when full), so Element.matches()/closest() in a loop over many elements parse once. The record is shared: never mutate what _parseSelector returns -- copy it first, as StyleSheet does before stripping a pseudo-class. _parseSelector.__pulpMemoized tells a vendored runtime carrying its own memo to step aside.

The split preludes and the legacy web-compat.js must agree on window

The runtime embeds the SPLIT preludes (web-compat-document.js and friends, listed in core/view/cmake/PulpViewJsPreludes.cmake), not the monolithic web-compat.js. When the two define the same global differently, only the split one is live. window.innerWidth/innerHeight were once the constants 800/600 in the split module while the legacy bundle read the root size, so a popover positioned against the window believed the surface was 800x600: a menu in a 1320x860 editor flipped its submenu left and clamped itself upward. Both now read getRootSize(). When changing a window/document member, change it in the split module and keep the legacy bundle in step; a test through a real WidgetBridge (see test_web_compat_overlay.cpp) is what exercises the live one.

To confirm-failure a prelude edit, name the generated unit: --object web_compat_preludes_gen.cpp -- the .js file has no object of its own, so without it confirm_failure.sh reports INCONCLUSIVE by construction.

Three.js V8 builds use the pinned sealed libv8 (no Homebrew node)

The native Three.js bridge needs V8. Use the pinned sealed libv8 — fetch once, then just select v8:

python3 tools/scripts/fetch_v8_for_release.py darwin-arm64
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
  -DPULP_JS_ENGINE=v8 -DPULP_ENABLE_GPU=ON -DPULP_BUILD_TESTS=ON

Do not trust an old build directory just because CMake says V8 is enabled. Check the linked dylib points at the pinned artifact:

otool -L build/examples/threejs-native-demo/pulp-threejs-native-demo | grep libv8
# → @rpath/libv8.dylib ; rpath includes external/v8-build/<key>/lib

If it reports a Homebrew libnode.*.dylib, you're on a stale build dir from before the sealed-libv8 cutover — reconfigure clean. (The former -DV8_INCLUDE_DIR=/opt/homebrew/opt/node@24/... libnode recipe is gone; see the V8 provider section above.)

Headless-render gotcha (non-V8): threejs-native-demo loads demo.js.template via fs::path(__FILE__). ccache CCACHE_BASEDIR+ NOHASHDIR (CI/VM builds) relativizes __FILE__ so it resolves into the build tree (missing) → SIGABRT. Stage the template into the build dir or build without ccache. Unrelated to V8.

A new bridge global needs a manifest row, not just a register_bridge_function

register_bridge_function(api, "foo", ...) makes the global callable from a prelude, and nothing else. The @pulp/react type declarations, the mock registry the package's tests run against, and docs/reference/js-bridge.md are all GENERATED from core/view/src/widget_bridge_api_manifest.tsv — which is hand-maintained and is not derived from the C++. A function registered without its manifest row is invisible to every one of them, and a prelude that calls it type-checks as an unknown global downstream.

The sequence is: register the function, add its name<TAB>category<TAB>kind<TAB>source row to the manifest, add its TypeScript signature to the signature map in tools/scripts/generate_widget_bridge_api.py, then

python3 tools/scripts/generate_widget_bridge_api.py --write   # regenerate
python3 tools/scripts/generate_widget_bridge_api.py --check   # what CI runs

--check passes on a missing row rather than failing, because a row that is not there describes nothing to drift from — so a clean check is not evidence that a newly registered function reached the generated surfaces. Grep the generated .d.ts for the name instead.

A web-compat prelude reads its author hints through _dataset, and only re-evaluates when told

data-* attributes land in Element._dataset with the name camel-cased (data-overlay-trigger → _dataset.overlayTrigger), but nothing re-runs a prelude's heuristic just because an attribute changed. setAttribute / removeAttribute in web-compat-element.js carry an explicit per-attribute hook that calls the re-evaluation, so a new author hint that is not named there appears to work in a test that sets it before the element mounts, and silently does nothing when it is set or cleared later.

The mirror-image trap costs more, because it hits every real consumer rather than a test. _reevaluateOverlay returns immediately while _nativeCreated is false, and React commits setAttribute BEFORE appendChild, so the hook above fires against an element with no native widget and does nothing. An author hint therefore needs BOTH the per-attribute hook (for a later write) and a flush at mount, which for ARIA attributes is __replayAriaAttributes__ — the same replay aria-label and role already go through. A hint wired only to the hook works in a hand-written appendChild-then-setAttribute test and never fires for React. aria-haspopup, which marks an overlay TRIGGER, is wired to both.

An author hint may also have a standards spelling already in the document, and reading only the Pulp-specific one is a silent half-implementation rather than a missing feature. The overlay pair is the worked example: Pulp read role/aria-modal to CLAIM an overlay but only data-overlay-trigger to mark the control that OPENS one, so a document with correct ARIA got the half that consumes presses and not the half that gives one back — and switching menus cost two presses in an app whose markup already said everything the policy needed. When adding a hint, check whether ARIA (or another web standard) already expresses it.

Script focus has a native half, and mount-time behaviour has two entry points

Element.prototype.focus() / blur() (web-compat-element-events.js) call the bridge's setFocus(id) / clearFocus(id), which run transfer_input_focus() - the same blur/gain/publish protocol a pointer press uses. Updating only document.activeElement and firing a synthetic focus event looks right to script and to every JS-side test, while the TextEditor never gets a caret and the host keeps delivering keys elsewhere: the window hosts read the root's native focus slot, not the DOM. A focus test must therefore assert TextEditor::has_focus() / focused_input_under_root() and deliver text to whatever that slot names, never to a widget the test picked (test/web-compat/test_events_focus.cpp).

Anything that must happen "when an element mounts" has TWO entry points, and a change to one silently misses the other. Plain DOM script mounts through Element.appendChild / insertBefore in web-compat-dom-ops.js; @pulp/react never calls those - its host config materializes widgets through the bridge directly and only mirrors the DOM shim's parent links. autofocus and the dialog first-field default are therefore implemented in both places: __pulpApplyMountFocus__ on the DOM path, and finalizeInitialChildren / commitMount in packages/pulp-react/src/host-config.ts on the React path (which is also the only path that sees React's autoFocus prop; it is not a DOM attribute). React's shim Element does not carry type, so React decides "text field" from instance props, not from the shim.

Web-API global registration is hybrid native+JS by design

CHOC's NativeFunction signature can only carry choc::value::Value arguments — JS function values don't round-trip through it. So even though requestAnimationFrame / setTimeout / setInterval look like they "should" be C++-only bindings, the callbacks themselves have to live in a JS-side registry (__frameCallbacks__, __timerCallbacks__).

What is C++-side: id allocation in WidgetBridge::__scheduleTimer__, deadline tracking in pending_timers_, and the flush driver invoked from service_frame_callbacks(). What is JS-side: the registry table and the setTimeout / requestAnimationFrame global wrappers (in web-compat-scheduler.js) that allocate ids, stash callbacks, and call into the natives.

Don't refactor this into a "pure native" shape — there's no way to do it without copying the entire JS engine's value type into Pulp's bridge layer.

The mock GPUBuffer must COMMIT getMappedRange() writes on unmap()

web-compat-document-gpu-mock.js's __createMockGPUBuffer backs each buffer with a JS _bytes Uint8Array that the buffered-draw serializer (web-compat-canvas-gpu.js) ships to the native bridge. WebGPU's mapped-write contract is: getMappedRange() returns an ArrayBuffer the caller writes into, and unmap() commits those writes to the buffer. An ArrayBuffer cannot alias a sliced view of _bytes, so getMappedRange() MUST hand back a standalone range (seeded from _bytes), record it, and unmap() MUST copy each recorded range back into _bytes. The original code returned _bytes.buffer.slice(...) (an independent copy) with a no-op unmap(), so every mapped write was silently dropped. Three.js's WebGPUBackend uploads all geometry via createBuffer({mappedAtCreation:true}) → new T(getMappedRange()).set(...) → unmap(), so vertex/index buffers arrived all-zero and meshes collapsed to a point — only queue.writeBuffer-backed uniforms survived. See the threejs-bridge skill for the full runtime chronology.

setTimeout(fn, 0) takes the microtask path, not the timer queue

setTimeout(fn, 0) deliberately bypasses __scheduleTimer__ and routes through Promise.resolve().then(...) so it drains on the next pump_message_loop() call. This matches React's scheduler expectations and makes tests deterministic (no host frame loop needed). Positive-delay timeouts go through the native deadline tracker and only fire when service_frame_callbacks() runs.

If a consumer reports "my setTimeout(fn, 1) never fires", check that the host is actually calling service_frame_callbacks() from its frame loop — that's the drain hook for non-zero delays.

display: flex defaults to flex-direction: row

Pulp's underlying widgets default to FlexDirection::column (RN convention). The CSS web platform default for display: flex is flex-direction: row — children lay out horizontally. Imported / extracted designs assume the web default, so web-compat-style-decl.js explicitly emits setFlex(id, 'direction', 'row') whenever a CSSStyleDeclaration resolves display: flex and the consumer has NOT also declared flexDirection, flex-direction, or a flexFlow shorthand that includes a direction token.

Order independence is intentional. Both of these end up column:

el.style.flexDirection = 'column'; el.style.display = 'flex';
el.style.display = 'flex'; el.style.flexDirection = 'column';

The setter trap stores into _props BEFORE _applyProperty runs, so the display handler can see a previously-declared direction and skip the row default. A later explicit flexDirection overrides the row default through the normal handler.

flexFlow is content-aware: flexFlow: 'wrap' does NOT block the row default (CSS shorthand semantics — omitted flex-direction defaults to row), but flexFlow: 'column wrap' does. The check uses a \b(row|column)\b regex against _props.flexFlow.

Not changed by this fix: createCol / createRow / createPanel C++ paths preserve their explicit direction; typed React props in pulp-react/prop-applier.ts route directly through bridge setters and don't touch style.

A restored style value is often the empty string, so honor CSS initial values

web-compat-document.js's :hover translator snapshots el.style[prop] on mouseenter and assigns it back on mouseleave. For the ordinary case — an element that never carried an inline value for the hovered property — the snapshot is the empty string, so the restore assigns "", not a number.

That makes parseFloat(resolved) || 0 a trap in _applyPaintProp: "" parses to NaN, NaN || 0 is 0, and the widget is left fully transparent while its rect, visibility and clip box are untouched — the element looks deleted rather than un-styled, which sends you hunting in layout instead of paint. When a paint property cannot parse its resolved value, fall back to that property's CSS initial value (opacity → 1), never to zero.

The same shape applies to any future numeric paint property routed through this path. A test for it must NOT pre-assign the inline value, or it exercises the parseable branch and passes regardless; assert isNaN(parseFloat(el.style.<prop>)) after the leave as a control.

An empty CSS colour means REMOVE the declaration, not "leave it alone"

The empty string is CSSOM's spelling of removal: el.style.background = "" deletes the declaration. Both halves of Pulp's colour lane used to read it as "nothing to do" instead — web-compat-style-decl-paint.js skipped the bridge call because parseCSSColor("") is falsy, and style_visual_api.cpp skipped the paint because hex.empty(). Two independent no-ops for the same value, so fixing either alone changes nothing and reads as "the fix did not work".

The symptom is a colour that will not go away. A dropdown deactivates a row by assigning its captured base background, which is "" for a row that was never styled — so every row the highlight visited stayed lit, and the highlight read as an accumulating frontier rather than a moving one. The element's own bookkeeping stayed correct throughout, which is why an attribute assertion (data-pulp-popup-active) passed while the pixels were wrong.

View already had clear_background_color() / clear_background_gradient() and has_background_color(); the bridge simply never called them. Any new colour-valued bridge entry point needs the same three-way split — empty clears, parseable sets, unparseable is the only no-op — and the test for it must assert has_background_color() on the view, with the still-lit element as a positive control so a stuck colour cannot be confused with one that never painted. "transparent" is NOT a substitute: css_color.cpp maps it to rgba(0,0,0,0), which paints nothing but still counts as a declaration.

setTextColor (typography_api.cpp) still drops empty and has no clear_text_color() primitive, so @pulp/react's documented removal contract (textColor removed → setTextColor(id,"")) is currently inert on the native side. Fix that half the same way when it next bites.

A popup has TWO states, and only one of them belongs on screen at open

web-compat-document.js owns any popup it can reach by ARIA shape -- a trigger carrying aria-haspopup over a role="listbox"/role="menu" -- and paints a keyboard cursor on one row. That cursor is NOT the app's selection. The app paints its own selected row however it likes; the owner's cursor is navigation position. Both on screen at once reads as two selections, which is exactly what a user reports as "why is it showing me two".

So the cursor is created at open (an arrow needs somewhere to start) but not painted until the user asks for one: pointerenter on a row, an arrow key, or an arrow that opened the popup in the first place. state.activeVisible is that flag and paint() is gated on it.

The seed comes from selectedIndexIn(), which reads aria-activedescendant / aria-selected / aria-checked / .checked / aria-current in that order. An app whose rows advertise none of those gets index 0, so a listbox whose current value is any row but the first shows the app's selection on one row and the owner's cursor on another. The fix is on the app side and is required for assistive technology anyway: a role="listbox" whose children are bare <button>s has no selection to report. Mark the rows.

Where the first arrow lands then depends on whether the cursor had a home. Seeded from a real selection it steps off it, the way a platform combo box does. Seeded from an edge because nothing was marked, it lands ON that edge -- otherwise the first ArrowDown skips the row the user was aiming at.

confirm_failure.sh needs --object to verdict a .js prelude edit

Preludes are embedded into a generated build/core/view/web_compat_preludes_gen.cpp, so a .js file produces no compile line of its own. The script verifies a recompile by watching for the edited file's object in the build log, so without help it sees no evidence and returns INCONCLUSIVE. Point it at the generated translation unit instead and it verdicts normally:

tools/scripts/confirm_failure.sh \
  --file core/view/js/web-compat-style-decl.js \
  --break "perl -0pi -e 's/var consume = hinted;/var consume = false;/'" \
  --object web_compat_preludes_gen.cpp \
  --build-dir build --target pulp-test-web-compat-overlay --jobs 6 \
  --test ./build/test/pulp-test-web-compat-overlay

The --object flag is documented in the script's own header for exactly this case. Counting the changed text inside the generated file still works as a manual positive control, but it is no longer the only route to a verdict.

Note also that core/view/js/web-compat.js is a monolith that is not embedded. The embedded lane is the split web-compat-*.js set listed above, so an edit to the monolith is inert — verify against PULP_JS_PRELUDES before concluding a JS change had no effect.

Canvas2D textBaseline initializes to alphabetic, not top

The Canvas2D initial value for textBaseline is "alphabetic": the y handed to fillText IS the baseline. Browser-authored canvas code that never assigns ctx.textBaseline — which is most of it — relies on that, so defaulting to top treats the same y as the top of the em box and pushes every such caption down by one ascent. The symptom is subtle: text still draws, in roughly the right place, just consistently low.

The default lives in three places that must agree, and changing one alone produces a shim/native split that only shows up in a render: core/view/js/web-compat-canvas.js (the shim's own this.textBaseline), core/view/src/widget_bridge/canvas2d_api.cpp (the canvasSetTextBaseline default argument and its string→int mapping), and core/view/src/canvas_widget.cpp (the replay's initial TextBaseline).

canvas::TextBaseline enumerators are append-only: the bridge records the enum's integer value into the command stream, so reordering it would silently reinterpret every previously recorded top / middle / bottom.

A canvas path run is buffered — any new shim emitter must flush it first

moveTo / lineTo in core/view/js/web-compat-canvas.js do not cross the bridge per point. They accumulate into this._pendPts, and _fp() ships the whole run as one canvasPathPolyline call. This exists because the crossing, not the engine, is the cost on a canvas-heavy UI: a band drag measured 959,968 canvasLineTo calls in 115,880 runs — 81% of them inside runs of 64 points or more, and every run immediately preceded by a canvasMoveTo. Collapsing a run into one call removes roughly half of all bridge crossings without changing a single recorded command. Reach for this shape before reaching for a different JS engine; swapping QuickJS for JSC or V8 does not make a crossing cheaper.

The buffering has one invariant, and it is easy to break by accident: any shim method that calls a canvas* bridge global must call this._fp() as its first statement. Without it the new command is recorded ahead of the buffered points, so a stroke() paints an empty path, or a fillStyle applies to the wrong subpath. Nothing about the recorded stream looks wrong in isolation — the commands are all present, just out of order.

tools/scripts/check_canvas_path_flush.py (ctest canvas-path-flush-lint) enforces it. It parses the prototype methods out of the shim by brace depth, so it fails closed if it stops recognizing the file: finding zero bridge-emitting methods is treated as an error, not a clean result. moveTo, lineTo, rect and _fp are the only exemptions, because they own the buffer.

The run survives a moveTo: a later subpath appends its points and records its start index in this._pendStarts, and _fp() sends canvasPathPolyline(id, coords, starts), so a row of 63 tick marks is one crossing instead of 63. _openPendingSubpath flushes first when a run would pass the bridge's 65536-coordinate cap — over the cap the bridge rejects the whole batch and the path silently vanishes.

The same "the crossing is the cost" logic is why save()/restore() keep the shim's _sent* record of native state instead of clearing it: see the view-bridge skill's scripted-editor call-budget checklist for the replay-side contract that makes that safe.

ctx.pulpCachedGroup(key, drawFn) records drawFn into a native group between canvasBeginGroup and canvasEndGroup and later replays it with one canvasReplayGroup call. While _groupRecording is set, two shim behaviours change and must stay changed: a full-frame clearRect must not take the retained-frame canvasClear path (that would replace the frame the group is being recorded into), and restore() must not pop below _stateFloor, the save depth at which the group began. The JS state snapshot is taken with _captureState()/_applyState() rather than save()/restore(), because the native group brackets itself and a real save/restore would land inside the group.

CSS-shim gap fills — translator vs. bridge contract

Four classes of "silent drop" recur in web-compat-style-decl.js. When adding a style property, walk all four before declaring done:

  1. Missing case "X": — the property is nowhere in the switch, so el.style.X = ... writes to _props[X] and never reaches the bridge. Harness verdict: NOT-IMPL. Example: backdropFilter already had a bridge setBackdropFilter, but still needed the JS route.

  2. Coalesced shorthand only — the shorthand routes (e.g. textDecoration) but the longhands (textDecorationLine / -Color / -Style) silently no-op. Per-attribute longhands MUST route to per-attribute bridge setters so a previously-set sibling isn't clobbered — the same pattern as the per-side border setters. Don't try to coalesce three independent property assignments into a single setX(id, line, color, style) call; the JS shim iterates assignments in source order, so the first call would always overwrite the next two with defaults.

  3. Keyword-vs-numeric coercion — CSS / RN accept both keyword forms (fontWeight: 'bold') and numeric (fontWeight: 700). parseInt('bold') returns NaN, the || 400 fallback then silently maps bold → normal. Translate before reaching the bridge. The same translation lives in packages/pulp-react/src/prop-applier.ts (_normalizeFontWeight) for parity with React-Native style objects — both paths must emit the same numeric weight.

  4. Not a CSS property at all — React Native contributes style keys CSS never had (hitSlop). The shim still has to carry them, because the RN-style object and el.style.X are the same surface to a consumer, and the three-class walk above applies unchanged. The extra cost is that there is no CSS shorthand to inherit from, so packages/pulp-react/src/prop-applier-paint.ts has to accept BOTH the RN forms (a number, or {top,right,bottom,left}) and the CSS-shorthand form a designer will reach for anyway (hitSlop: '12px 2px'), and normalize to the four-value bridge call. Emitting the number form only is the silent half-fix: the object form then writes [object Object] into one edge.

__cssProperties__ array gotcha: properties also need an entry in the __cssProperties__ array near the bottom of web-compat-style-decl.js for el.style.X = ... to set the trap. Properties only reachable via setProperty('x-y', ...) work without this because setProperty converts kebab to camel and goes through the same _applyProperty switch — but JSX consumers writing style.backdropFilter = "blur(10px)" need both the array entry AND the case block.

Verifier harness ground truth: run python3 tools/harness/verifier.py --surface=css --json before and after a CSS-shim change. The JSON delta tells you which entries reclassified between NOT-IMPL → DIVERGE → PASS, and a drift_count that drops without manual compat.json edits is the sign that the catalog status was already correct and only the JS route was missing.

Sticky-state setters (Canvas2D shadow / direction / filter / …): when wiring a new ctx.X setter that the bridge captures as sticky state, follow this checklist or you'll land a silent no-op:

  1. Local field + spec default on CanvasRenderingContext2D — numeric 0 for shadowBlur, string "none" for filter, etc.
  2. _sentX cache field initialised to null; the _syncXState helper only flushes when the value differs.
  3. _syncXState helper — coerce defensively (unknown strings → spec default), gate isFinite() for numeric fields per HTML5 ("non-finite assignments are silently ignored"), then call the bridge fn and update the cache.
  4. Wire _syncXState() into every consuming draw — fillRect, stroke, fillText, drawImage, etc. The fillRect set is the minimum; text + image draws need it too if the state visually affects them.
  5. Invalidate the cache in save() and restore() — the C++ GState pops the value, so the post-restore draw must re-flush. Forgetting this is a one-frame lag invisible in single-frame tests.
  6. Bridge fn registration in core/view/src/widget_bridge.cpp — record a CanvasDrawCmd with the right int_val / extra / text field per the enum docstring.
  7. Dispatch + cmd_type_to_string in core/view/src/canvas_widget.cpp — add to the paint switch AND the trace-logger name table at the top of the file.
  8. Canvas base virtual + RecordingCanvas capture — even when Skia / CG have nothing to do, RecordingCanvas is what the canvas2d-shim tests assert against.

This pattern is now used for shadow*, miter / image-smoothing, direction, and filter setters. Copy the same shape for the next canvas2d catalog setter.

String-valued custom CSS properties (var(--mono) etc.)

setProperty('--name', value) in web-compat-style-decl.js (and the mirror in web-compat.js) has THREE tiers, not the original two:

  1. Length (parseCSSLength) → setMotionToken writes theme.dimensions[name].
  2. Color (parseCSSColor) → applyTokenDiff writes theme.colors[color.name].
  3. String fallback → setStringToken(name, value) writes theme.strings[name]. This is what catches font families (--mono: "JetBrains Mono") and any other arbitrary string.

Without tier 3, font-family-shaped custom properties were silently dropped at set time and var(--mono) resolved to 0 (the getMotionToken empty-token return). getPropertyValue mirrors the same tier order — string token first, then numeric — so the round-trip works.

getStringToken and setStringToken are bridge fns registered in core/view/src/widget_bridge.cpp next to getMotionToken / setMotionToken.

The React-side mirror lives in packages/pulp-react/src/prop-applier.ts as _resolveVar(value), with the same lookup tiers (developer-set __pulpCssVars registry → getStringToken → getMotionToken → fallback). Call it from every string-valued style prop case that might receive var(--name) from JSX — see the fontFamily / color / borderColor* / outlineColor / textDecorationColor / textShadowColor / shadowColor / background cases for the canonical wiring.

Native global name ownership lives in the JsEngine base, not per-backend

JsEngine::register_function, register_host_object, and register_promise_function are now non-virtual on the base class. They each call claim_native_symbol(name) (which throws on duplicates) and then delegate to the backend's register_*_impl hook. Backends only implement the _impl half.

Why this matters: a duplicate-name registration is a silent bug that either shadows the previous binding (QuickJS-shaped behavior) or crashes at first call (JSC/V8-shaped behavior), and the symptom path depends on which engine the user picked at build time. Catching it at registration time, in the base class, makes the failure backend-independent and immediate.

When adding a new registration surface (Promise variants, typed-array bridges, host-object subforms, etc.), follow the same pattern: public non-virtual entry point on JsEngine → claim the name → call a new _impl hook each backend implements. Do not add a duplicate-name table inside an individual backend; that path drifts.

pulp-test-widget-bridge-api-contracts scans for accidental literal re-registrations across WidgetBridge (function, host-object, and promise-function names) and is the cheapest tripwire if a split-up registration module forgets the contract.

Native events must have one DOM fan-out owner

When an element event is delivered through the shared __dispatch__ path, its per-event registration callback should only keep the native channel alive. Do not dispatch the same DOM event there too: one wheel tick will otherwise reach every listener twice even though engine-specific tests may still look healthy.

This includes append-time auto-registration for React root delegation: __pulpRegisterAutoDomEvents__ installs no-op click/pointer callbacks solely to arm native delivery. __dispatch__ owns the only Element dispatch, while the native pointer registrar emits the matching mouse event as a separate channel.

Direct @pulp/react handlers return the internal __pulpEventPropagation marker from their synthetic-event wrapper. Pointer payloads carry a per-native-dispatch token so __dispatchCallbackOnly__ can suppress later native ancestor callbacks. Keep the token scoped and reentrant, and key cancellation by both token and event name: cancelling pointerdown must not silently cancel its independent compatibility mousedown. Level 1 (stopPropagation) still allows remaining same-target listeners; level 2 (stopImmediatePropagation) does not.

A wheel that resolved inside an open overlay is contained there: the bridge adds __pulpWheelBoundary: "<overlay element id>" to the wheel payload, _makeEvent copies it to ev._pulpBoundaryId, and _dispatchEvent drops every path element above that element from BOTH phases, except __root__ (the React-DOM delegate, which dispatches by fiber tree and would otherwise lose the tick inside the overlay too). The native half stops at the overlay root in deliver_mouse_wheel. Testing the JS half needs its own fail-before: a native on(id,'wheel') ancestor stops at the native boundary, so only an addEventListener('wheel') ancestor proves the DOM bubble is cut.

ARIA is a PAIR, and the overlay half is easy to leave unread

_reevaluateOverlay (in web-compat-style-decl.js) decides whether an element claims the native overlay slot. It reads three kinds of signal, and they are not interchangeable:

SignalKindClaims with
data-overlay="true"statementoutside-click consumed
role=menu|listbox|tree|grid|dialog|alertdialog, aria-modal="true"statementoutside-click consumed
position:absolute + z-index >= 10inferenceclick-through

A fourth signal does not claim at all — it qualifies a claim:

SignalKindEffect
data-overlay-parent="<id>" on the overlaystatementthe claim NESTS on that overlay instead of dismissing it
aria-owns="<overlay id>" on its parentstatementsame, stated from the other end

The inference stays click-through on purpose — a false positive that consumes swallows a real click, whereas one that clicks through merely closes something that should not have claimed. The statements consume, because an author who wrote role="menu" meant a menu, and a menu that lets the press through keeps operating whatever sits under it.

Two traps:

  • The trigger half is not the overlay half. aria-haspopup says "I OPEN an overlay" and marks set_overlay_trigger; role/aria-modal say "I AM one". For a while only the trigger half was read, so a correctly-authored role="menu" panel fell through to the CSS-shape inference and claimed click-through — visible to the user as a menu that closes but also draws on whatever was behind it.
  • A claim needs a re-evaluation trigger. _reevaluateOverlay runs on position/zIndex writes and on specific setAttribute/removeAttribute names in web-compat-element.js. An attribute that is not in that list marks nothing on a static panel, because no unrelated style write ever arrives to drive the heuristic. Adding a signal means adding its attribute name to BOTH branches there, not only to the heuristic.
  • A lifted submenu that claims without naming its menu DISMISSES that menu. role="menu" is equally true of a menu and of its submenu, and a submenu positioned to escape its menu's box — position: fixed, a portal, a returned fragment — is emitted as a SIBLING of that menu. Native stacking recognises a submenu by descent, so a sibling reads as a rival: the menu underneath is dismissed the moment the submenu opens and every row on both of them goes with it. Nothing in the markup that already claims can supply the missing fact, so _resolveOverlayParent reads it from data-overlay-parent on the submenu or aria-owns on the menu and passes it as claimOverlay's third argument. Deliberately never inferred — an inferred parent would let any panel nest on whatever happened to be open, which is what the descent rule exists to prevent — and a name that resolves to no live, currently-open overlay under the same root is ignored natively, leaving the ordinary claim.
  • aria-owns changes a claim that is not on the element it was written on. It lands on the menu and names the submenu, so re-evaluating only the element that received the attribute leaves the submenu still claiming as a rival. __pulpReevaluateOwnedOverlays__ in web-compat-element.js walks the token list and re-evaluates each named element, from setAttribute, from removeAttribute (reading the OLD value, since the attribute is already gone), and from the pre-mount replay for a menu that mounts after the panel it owns.

A consumer that marks its overlays in its own private vocabulary (data-<product>-overlay) is invisible to all three rows above. The attribute name is the contract; nothing infers intent from a product-specific prefix.

The popup owner claims from the click, not from focus — and must ignore its own clicks

__pulpPopupDefaultHandle__ (in web-compat-document.js) is the default keyboard/dismiss state machine for any aria-haspopup trigger. It can only answer ArrowUp/ArrowDown/Enter/Escape for a menu it has taken ownership of, and it paints the row highlight from exactly one place — paint(), the sole writer of data-pulp-popup-active and of the highlight background. If ownership never happens, both the navigation and the highlight are silently absent, and they fail together: one cause, two symptoms.

Two traps around that ownership:

  • A keydown path alone is not ownership. The owner's keydown branch needs triggerFrom(document.activeElement), and document.activeElement is written only by Element.prototype.focus. A native mouse click never runs one, so on the real user path that branch is dead. Ownership on that path has to come from the pointer/click the user actually made. A test that calls trigger.focus() in setup hand-satisfies the one condition the mouse path cannot, so it passes over a defect a user still sees — if a popup test focuses the trigger, it is not covering the mouse-opened case.

  • The menu does not exist yet when the click arrives. The app's own click handler creates the popup, and a host that defers dispatch plus a reconciler that commits a tick later mean a single synchronous activate() inspects a DOM with no popup and gives up. Re-offer across a few frames instead of deciding once.

  • The owner clicks things itself — a trigger to commit or close, an option to mirror a keyboard open onto the app's handler. Once element clicks are offered to the owner, those come back in as if a user had pressed the control, and a close reads as an open. Wrap owner-issued clicks in a counter and return at the very top of the handler while it is non-zero: above the stale-state sweep, not merely inside each branch. The sweep runs first, sees a popup the app has already removed, and dismisses the state the in-flight branch still needs to hand focus back to its trigger.

  • A press on a menu's OWN trigger is a toggle, not a switch. The native policy (route_press_to_active_overlay) lets a press on an overlay trigger through so switching to a DIFFERENT dropdown costs one press. Applied to the trigger that opened the menu, that pass-through was a reopen: the dismissal made this owner clickSelf the trigger shut, then the same press's click reached the app's toggle and opened it again, so a dropdown button could never close its own menu. The overlay now knows its anchor (View::set_overlay_anchor): a claim that follows a press on a trigger adopts it natively, and adopt() names it as claimOverlay's fourth argument so a KEYBOARD-opened menu, which had no press to learn from, knows it too. A press on the anchor is dismissed and consumed (trigger_closed). Enter/Space on a focused <button> trigger open a closed menu and, while no row is revealed, close an open one — scoped to real buttons because a browser gives role="button" no key activation and its author handles the key in script, so activating it here as well toggles twice.

Restoring a row's background is its own trap: parseCSSColor("") returns null and _applyPaintProp silently drops it, so assigning the empty string leaves the highlight on every visited row, while "transparent" parses to #00000000 and paints a transparent fill over a background that came from a non-script source. Remove the property and clear the native background instead (getBackground / clearBackground on the style bridge).

Note that __pulpActivateMaterializedElement__ invokes the React callback directly and never enters Element.prototype._dispatchEvent, so a fixture that opens a menu through it gets a menu no popup owner has claimed. Drive the production line (__dispatch__(el._id, "click", …)) when the fixture is meant to stand in for a user's mouse.

A popup's keyboard cursor seeds from the marked selection, not an edge

web-compat-document.js's semantic popup default opens a role="listbox" / role="menu" with its cursor on the option the author marked as selected, and only falls back to the edge the caller asked for ("first" for ArrowDown, "last" for ArrowUp) when nothing is marked. selectedIndexIn() reads, in descending order of authority: aria-activedescendant on the trigger or the popup, aria-selected="true", aria-checked="true", the option's checked property, then aria-current. Assigning the checked property writes no attribute in this shim, so it is a genuinely separate signal from aria-checked.

Two consequences for authors of scripted UI:

  • A listbox that marks nothing gets edge behaviour. There is no inference from the trigger's own text: matching trigger text against option text would mis-seed on duplicate labels and on triggers that decorate the value ("Bands: 64"), so the shim does not do it. An app whose menu highlights the wrong row on open is missing aria-selected on its options — which is also the ARIA requirement for role="option" inside a listbox.
  • This matches the native widget. ui_components.cpp's ComboBox::open seeds hover_index_ = selected_. The scripted path and the native path now agree, so a design that moves between them keeps the same first highlight.

An omitted bridge argument is a silent false, not a default

claimOverlay(id, consume) reaches native as args.size() > 1 && args.get<bool>(1, false) (event_api.cpp). A caller that passes only the id therefore opts OUT of consuming the dismissing press, with no diagnostic anywhere — the call looks complete and the behaviour silently differs from a caller that passed true. web-compat-style-decl.js shipped that way for months while web-compat-document.js and all three prop-applier-events.ts sites passed true, so the same author intent behaved differently depending on which authoring surface expressed it, and a comment claiming the two paths matched made the fork read as intentional.

Two rules follow. When you add a bridge argument, grep every call site rather than trusting a default to be safe — args.size() > N style readers cannot distinguish "omitted" from "explicitly false". And when the shim claims to mirror another authoring surface, verify that against the surface, not the comment: the comment is the thing that goes stale.

The style-decl overlay claim now splits deliberately. data-overlay="true" is an explicit author statement, identical in intent to <View overlay>, so it consumes. The CSS-shape heuristic (position: absolute + z-index >= 10) is an inference, so it stays click-through: a false positive that consumed would swallow a real click outright, while a false positive that clicks through only closes something that should not have claimed. The claimed mode is tracked in _autoOverlayConsume so adding the hint to an element that already claimed by shape re-claims instead of stranding the old mode — claim_overlay() is idempotent, so the re-claim is safe.

ESM support per engine

EnginePublic ESM APIStatus
V8 (pinned sealed libv8 on desktop/Android)import.meta, dynamic import(), setModuleLoaderDelegate-equivalentFull ESM. Used by examples/threejs-native-demo/main.cpp for macOS Three.js.
JSC (system framework on iOS)NO public ESM module loader on iOS❌ JSScript.h + JSModuleLoaderDelegate are private. Shipping them in an .appex risks App Store rejection.
JSC (system framework on macOS)JSScript exposed via framework headers🟡 Available, but typically unused since macOS uses V8.
QuickJS (vendored under external/quickjs/)Full ESM via JS_NewModule*✅ Used as a fallback when V8 is unavailable + jitless V8 isn't an option.
HermesLimited (no full ES module support)❌ Not viable for Three.js.

iOS Three.js workaround: Bundle the ESM source into a self-contained IIFE that registers exports on globalThis.THREE, then JSC evaluate() it. The bundler is at tools/scripts/bundle_threejs_for_jsc.mjs; the iOS NSBundle loader at core/view/src/threejs_resources_apple.mm; the .appex build-time wiring at tools/cmake/PulpAuv3.cmake. Pattern is general — any ESM library can be bundled this way for the JSC iOS lane.

Bundler implementation note: The bundler delegates to esbuild (pinned in tools/scripts/package.json at 0.25.10). The earlier regex-only pass could not resolve sibling import { ... } from "./three.core.js" statements that Three.js's webgpu entry depends on, which caused JSC parse errors at runtime ("expecting ("). esbuild handles ESM resolution + http: import stripping out of the box. The bundler auto-installs esbuild on first run via npm install --prefix tools/scripts/ when node_modules/esbuild is missing — no manual setup needed.

See .agents/skills/{ios,threejs-bridge}/SKILL.md for the runtime contract.

Diagnostics for silent JS failures

Several silent-failure traps were caught during the iOS-D.3c Three.js bring-up. Each now has an explicit diagnostic surface in the engine layer.

QuickJS unhandled-promise-rejection tracker (core/view/src/js_quickjs_engine.cpp)

QuickJS does not call JS-side addEventListener("unhandledrejection", ...) handlers. Without a host hook, a rejected promise with no .catch is silently dropped. The worst case is the new Promise(async (resolve, reject) => { ... }) anti-pattern: a sync throw inside the async executor rejects the inner async function's promise (not the outer), and the outer Promise sits in pending forever.

install_quickjs_rejection_tracker() calls JS_SetHostPromiseRejectionTracker in QuickJsEngine()'s constructor and logs unhandled rejections + stack via runtime::log_error("PULP_QJS_UNHANDLED_REJECTION: …") / PULP_QJS_UNHANDLED_REJECTION_STACK: …. This single hook surfaced the real Three.js init error (TypeError on self not defined) in one shot after hours of fruitless other debugging — turn it on, grep for PULP_QJS_UNHANDLED_REJECTION in the log.

JSC ObjC-exception bridge (core/view/src/js_jsc_engine.mm)

[JSContext evaluateScript:] can throw NSException for malformed scripts (bare import statements, syntax errors that the parser can't recover from). Without @catch(NSException*), the exception unwinds through the Objective-C runtime and surfaces in C++ as the useless string "unknown exception". Now the engine catches NSException + falls back to id, formats the reason + name, and rethrows as std::runtime_error with the actual JSC error message. Grep for PULP_JSC_EVAL_NSEXCEPTION / PULP_JSC_EVAL_OBJC in logs to find scripts that JSC's parser rejected.

self as a window alias in the web-compat document shim (core/view/js/web-compat-document.js)

Per browser spec, self, window, and globalThis all reference the same object in a page context. Three.js's Animation class (and many other libraries) keys its requestAnimationFrame lookup on self:

this._context = typeof self !== "undefined" ? self : null;

If self is undefined, libraries set their context to null and crash with TypeError on the next rAF call. globalThis.self = window; is the spec-conforming fix and is now installed alongside the other window-global aliases (localStorage, sessionStorage). Holds for any standards-compliant library that follows the browser globals convention.

eval_or_throw logs the JS error before wrapping (core/view/src/widget_bridge.cpp)

When WidgetBridge evaluates user JS via eval_or_throw, the catch chain re-throws as std::runtime_error("failed to evaluate <name>: <err>"). A cross-translation-unit std::exception typeinfo mismatch can cause the caller's catch(const std::exception&) to fall through to catch(...), which then loses the original message. The fix: runtime::log_error("PULP_EVAL_THROW: name={} js_len={} ..._error={}", ...) is called before re-throwing in all three catch branches, so the JS error reaches the log regardless of whether the downstream catch matches. Grep PULP_EVAL_THROW to find the actual error text when "unknown exception" surfaces upstream.

The web-compat preludes are engine-agnostic — a green V8/macOS test does NOT prove the JSC/iOS path

The web-compat-*.js mocks (web-compat-document-gpu-mock.js etc.) are loaded identically under QuickJS, JSC, and V8 — so a logic bug in a mock behaves the same on every engine, but the surrounding native path may not. A WebGPU-mock writeBuffer bug (treating a TypedArray dataOffset/size as bytes instead of element counts) can pass the macOS V8 headless smoke because that lane does a one-shot Skia pixel readback, while the live iOS AUv3 path depends on the presentable swapchain. Two lessons: (1) when you change a web-compat-*.js mock, add a focused assertion of the exact code path you touched (the smoke's whole-array writeBuffer masked the explicit five-arg element-count form); (2) "green on the V8 headless lane" is necessary but not sufficient for the JSC/iOS live present path — that path must be verified on a device/simulator screenshot, never inferred from the readback test.

A colour crosses the JS/native boundary as a STRING — resolve CSS Color 4 on the JS side

web-compat-style-decl-paint.js hands a gradient to the native side as ONE string and the stops are parsed there, by a colour parser that does not know oklab() / oklch() / lab() / lch() / color(). An unrecognised function name falls back to white, so a translucent bloom paints as an opaque white blob rather than failing visibly.

This is not an exotic case. Chrome serializes every color-mix(in oklab, …) to oklab(…), which is the idiom generated panels use for glows, blooms and shadows — so the modern-colour path is the common path, not the edge. Measured on one stop: it rendered (254,254,254) where the design asked for #58f0ff at 48%, with G - R = 0 at every blur radius (a flat grey/white tell, since the requested colour is strongly cyan).

parseCSSColor on the JS side already resolves all of these correctly, so resolve to hex before the string crosses over. Do NOT grow a second colour parser on the native side: two parsers for one syntax must then be kept in step forever, and the failure mode when they drift is a silently wrong colour rather than an error.

Two traps worth keeping when writing the rewrite pattern:

  • Exclude nested parens. These colour forms take plain numeric components, so matching non-greedily without recursion keeps a calc() inside a stop position from being swallowed by the colour match.
  • Leave anything unparseable exactly as-is. The native side may still understand a form the JS parser does not, and a silent rewrite to a wrong colour is worse than passing the original through untouched.

The same boundary explains why box-shadow needed a colour-first branch: CSS allows the colour on either side of the offsets and Chrome serializes it first (oklab(0.97 0.008 -0.014 / 0.18) 0px 1px 0px 0px inset). Resolve function colours to hex before any offset split — every offset match divides on whitespace, and oklab(…) carries both spaces and a slash, which tears the colour apart in either ordering.

Anchor the offsets regex. Unanchored, it matches inside a colour-first shadow: given #f7f3ff2e 0px 1px 3px 0px it starts at the offsets and takes the trailing 0px as the colour, yielding a shadow that parses "successfully" with garbage in it — strictly worse than not matching, because the colour-first branch then never runs.

Do not test this with a round-trip assertion. Reading el.style.boxShadow back returns whatever string was assigned whether or not it ever parsed, so a round-trip passes against a shadow that never reached the renderer. Export the parse helper and assert its decomposition directly.

A style write that changes nothing must not cross the bridge

CSSStyleDeclaration._applyProperty skips a write whose raw string equals the one already applied for that property. This is not a micro-optimisation: a stylesheet re-application pass rewrites every matched declaration on every pass, so an interaction that changes no fonts still re-sent every font property every frame, each one paying var() resolution, per-property parsing and a bridge call to set a value the widget already held.

Four things about the cache are load-bearing, and all four are easy to break:

  • It is keyed on the RAW string, before var() resolution — and var() values are never deduped. They resolve against live theme tokens, so the same raw string can legitimately produce a different applied value with no write to observe.
  • A property only trusts its cache while nothing sharing its widget state has been applied since. Several CSS properties reach one piece of widget state (visibility and opacity both drive setOpacity; shorthands expand over their longhands), so caching properties independently would let a stale entry suppress a write the widget needs. _DEDUP_GROUP / _DEDUP_MEMBERS in web-compat-style-dedup-table.js partition the properties into groups that share state; applying one member drops the cached entries of the others. That table is generated, not hand-written — tools/scripts/style_dedup_table.py reads the _apply*Prop handlers, records which bridge function and which literal sub-key each case reaches (setFlex(id, "margin_top", v) — the sub-key, not the function, is the real granularity), and unions the properties that collide. A computed sub-key conflicts with every sub-key of its function. Only writes count: a get* bridge call reads state, and treating one as a slot would merge every property that resolves a var() into a single group and erase the optimisation while still rendering correctly. A hand-maintained alias table would have to stay exhaustively correct forever and fails visually and silently when it does not; a ctest re-derives this one and fails if it has drifted from the handlers. A property the extractor cannot classify is absent from the table, and an absent property is never cached and drops the whole element's cache when it applies — so being missing costs speed, never correctness.
  • A blanket wipe on every apply looks safer and is inert. That was the first implementation: within a single stylesheet pass each property's apply wiped the previous property's entry, so the cache retained only the last-written property and every write missed on the next pass, self-perpetuating. It measured a 1x speedup over three runs — correct, and worth exactly nothing. If you change this cache, re-run the [style][dedup][benchmark] case; it A/Bs the guard inside one binary and prints the apply ratio.
  • Anything that changes widget state outside el.style must call __invalidateStyleCache__(el). The cache describes a widget, not an Element, so it is wrong the moment the widget is recreated under a surviving Element (_ensureNative, _reparentNative, the appendChild / removeChild / replaceChild mount paths, __forgetWidgetCallbacks__) or another path writes a slot it claims (media-attribute replay, the hidden setter, dialog show / close). If you add a bridge setter call outside the web-compat-style-decl-* handlers, you are adding one of these. The failure mode is a later identical style write being skipped as redundant against a widget that no longer holds that value — which renders wrong with every test still green, because el.style.foo reads back the assigned string whether or not it ever reached the widget. Assert against real View state.

Precompiling whole scripts off the UI thread (QuickJS)

precompile_scripts(sources, cancel) compiles whole scripts into the process-wide bytecode cache in a private QuickJS runtime on the calling thread, without running them; evaluate_script() in any later realm then reads the bytecode (script_bytecode_read) instead of compiling (script_compile). Bytecode is runtime-independent (atoms serialize as strings), which is what makes a background compile reusable. Gotchas: the calling thread needs a stack of a few MB — QuickJS's parser recurses on the native stack and the engine allows it 1 MB of JS stack, so a default 512 KB macOS secondary thread can crash before QuickJS notices (view::prewarm_scripted_ui uses an 8 MB thread); the cache tracks in-flight sources, so an evaluation that asks for a source another thread is compiling waits (bounded, 5 s) instead of compiling it twice — script_bytecode_cache_stats().waits counts those; a precompile that finds the source already cached is not a hit (hits count evaluations only). Only the QuickJS backend has a bytecode cache; with a JSC or V8 default the prewarm is a no-op. Bytecode stays in memory, never on disk (QuickJS does not validate untrusted bytecode).

Testing a wait on an in-flight compile without timing

To test that evaluate_script() waits for a precompile that is still running, do not give precompile_scripts() a head start and hope it is still compiling: on a loaded runner a small script finishes first, the evaluation reads a cached entry, and waits stays 0. Install detail::set_precompile_claim_hook_for_tests() instead. It runs on the precompile's thread after the source is claimed and before it is compiled, so the hook can hold the compile in flight until script_bytecode_cache_stats().waits shows the evaluation waiting. Bound every wait in such a test (the hook's and the main thread's) so a regression fails instead of hanging, and clear the hook afterwards.

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

aax

無料

Optional AAX support for Pulp, including developer-supplied Avid SDK setup, CMake enablement, DigiShell/AAX Validator workflows, and local AAX builds on macOS or Windows.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Configure, implement, and test Pulp's optional desktop Ableton Link tempo-sync adapter while preserving the developer-supplied SDK, licensing, realtime, latency-compensation, and no-install boundaries.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Maintain Pulp's installed design-time agent capability manifest and public-surface ledger. Use when adding, removing, renaming, or materially changing public audio, MIDI, signal, timebase, or sequence APIs; registering a new algorithm for generators; changing capability support or deprecation state; or repairing agent-capabilities freshness, schema, fingerprint, tombstone, or installed-SDK tests.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

android

無料

Android platform development for Pulp — NDK cross-compilation, Oboe audio, Dawn/Skia GPU rendering, JNI bridge, touch interaction, emulator workflows, and end-to-end smoke validation. Covers build, deploy, debug, and the gotchas discovered during bringup.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

ara

無料

Optional ARA support for Pulp, including developer-supplied ARA SDK setup, CMake enablement, adapter companion APIs, validation, and ARA-aware plugin implementation guidance.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

The measurement surface for ALL Pulp DSP and audio-pipeline work — read it BEFORE writing or gating DSP, not only when something already sounds wrong. Covers the C++ harness (signal generators, metrics, assertions, RenderScenario, contracts), the offline Audio Doctor (magnitude/frequency response, THD/THD+N, phase/group delay), and their Python sibling the Audio Quality Lab (tools/audio/quality-lab — null residual + alignment, LTAS log-spectral distance, spectral flux/centroid, HNR, Theil-Sen drift slope, Kaiser-sinc resampling, license-guarded corpus, regression-net ratchet). TRIGGER on AUTHORING work — "build/design an oscillator/filter/synth/effect", "add a DSP module", "what should the acceptance gate be", "how do I measure aliasing / anti-aliasing / alias floor", "null against a reference", "is this DSP correct", "choose a tolerance", "golden/regression corpus for audio", "measure drift or jitter", "A/B two renders" — AND on DEBUGGING work — "is there sound / no audio / I hear nothing", "does this filter/compressor/synth/delay produce the right signal", "prove the DSP / prove the contract", "measure the frequency response", "what's the THD / is it distorting", "what's the group delay / phase response / measured latency", "magnitude response curve", "render a test tone and assert", "audio regression", "64-frame works but 128 is silent", "sample-rate change pitch-shifted it", "describe what's in this buffer", "audio doctor", "compare before/after a DSP refactor". Reach for this BEFORE hand-rolling any FFT, null test, alias measurement, pitch tracker, or golden-render script — most of it already exists in one of the two lanes. Test/tool layer over HeadlessHost — deterministic, no audio device, no speakers. Off the realtime thread entirely.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Generous-Corp のスキルをすべて見る

このスキルの問題を報告する