CI's lint:determinism rejects blind waitForTimeout. Convert the chromium
clipboard-content read to expect.poll (the write lands after a JSPI
suspension) and annotate the remaining waits as documented interaction
dwells — select-all/paste/commit resolve inside the wx tool framework with
no page-observable, and the test's real gate is the FS-save poll.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cmd+C/Cmd+V in the wasm eeschema pasted a stray "(" SCH_TEXT — the stroke
font renders it as a small blue arc — instead of the copied symbol, and any
copy reached navigator.clipboard as a single character. Root cause and fix
live in wxwidgets (90bbad29822: UTF-8 wxDF_UNICODETEXT + the port's missing
GetAllFormats key function; c1f14775ba3: empty-read cache fallback + no
browser pre-clear on copy). This bump carries them plus the guards:
- tests/kicad/eeschema-copy-paste.spec.ts: chromium asserts the full
multi-form (lib_symbols …)(symbol …) blob reaches navigator.clipboard;
both engines assert copy → paste → save yields a second symbol and no
stray (text …) item (firefox exercises the m_textCache fallback path).
The kicad-chromium project now grants clipboard-read/write. NB the kicad
projects' device UAs claim Windows, so specs must send plain Control+…
— ControlOrMeta resolves to Meta on a mac host, which the "Windows" app
ignores, and the bare key fires eeschema hotkeys instead.
- tests/e2e/textdataobj.spec.ts + Makefile.wasm target `textdataobj`:
drives the wx-repo harness app (wxwidgets/tests/wasm/textdataobj_test.cpp)
and asserts the SUITE-DONE failure counters plus the full-string browser
round-trip, sentinel-seeded so a failed write cannot read as stale success.
- tests/e2e/clipboard.spec.ts: the Copy test now asserts the full input
text reaches the clipboard (previously truncated to "S").
Verified locally: all specs red on the old wx, green after; wx-chromium
320/320, kicad-chromium 121/121 (collab specs env-gated).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same FOOTPRINT_EDIT_FRAME and the same wx-layer focus rule; opening a second
editor from a board session never became ready on CI (Chromium 180 s, Firefox
footprint load >60 s).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Wd1r3ewftpV1DBSEArpRa
In ydoc mode the collab room owns a document's state — items reach it at
commit time, layout heads at save time (syncLayoutFromSave). The raw CAS
PUT on Ctrl+S was only ever the file-list registration + fallback copy,
but for a file the listing already marks hasYdoc/isLive it bumps the
revision for nothing, can 409 the target into the durable save-blocked
banner, and leaves a shadow row the ydoc supersedes on every read.
registerSaveHook gains `uploadPolicy(relPath) → "upload" | "room"`;
WasmTool marks the boot listing's ydoc/live files "room" (ydoc mode only).
Files with no row yet (created sheets, the synthesized .kicad_pro) still
upload; onSavedText still runs for room-backed saves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Wd1r3ewftpV1DBSEArpRa
The staging editor was built with neither --models-tag (no models on the
staging CDN) nor VITE_MODELS_SOURCE, so modelsSourceConfig() returned null,
no model3d handler was installed and every 3D ensure answered "(unserved)"
despite the chunked packages3D ingest being complete on the staging registry.
- build-editor.mjs: --models-source <cdn|registry> → VITE_MODELS_SOURCE
- deploy-staging.yml: editor builds with --models-source registry
- release.yml: prod editor builds with --models-source registry (libs/0016 §6
step 4); MODELS_TAG now only feeds the demo build, which has no closed API.
Prod rollout order: split-lib-kinds + packages3D ingest on prod BEFORE the
next release tag — prod's registry currently has 0 model3d libs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1iwtBFvQ8CFg966bqSZh2
The blit's one-time setup leaked its quad-VBO GL_ARRAY_BUFFER binding
(not VAO state); the raytracing->OpenGL switch-back reload then captured
client-memory gl*Pointer addresses as offsets into that 48-byte buffer and
baked them into the recorded display lists — every frame INVALID_OPERATION,
viewer blank until canvas recreation. Found via a bounded routed-draw
diagnostic on the live repro; verified by driving two full real-Chrome
round-trips (back-to-OpenGL frame hashes byte-identical to pre-toggle).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Switching OpenGL → raytracing → OpenGL could leave the viewer showing only the
background gradient, with mid-session "[gl1] WebGL context changed" thrash and
INVALID_OPERATION storms on BOTH WebGL contexts. Traced mechanism: the shim's
FFP draw routing keyed on one process-global client-array flag; an interrupted
fixed-function window (MODEL_3D::BeginDrawMulti loops, or the switch-back
reload re-recording display lists with the GL context lock released across
JSPI suspensions) left it set, after which the raytracer blit's and the 2D
GAL's glDrawArrays were routed through the FFP pipeline — and one misrouted
draw permanently repointed the VICTIM's own VAO attributes at shim buffers
(the blit's attribute 0 collides with ATTR_POSITION), so both stayed
broken/blank even after the flag cleared.
Shim fixes (wasm/gl1):
- Owner-context routing gate: __wrap_glDrawArrays/Elements route into the FFP
pipeline only under the shim's owner context (adopted at the first FFP
client-state mutation or programSync in a context); foreign-context draws
always pass through — the 2D GAL can never be misrouted and the context
guard can never thrash.
- VAO isolation (ScopedDefaultVAO): draw executors do their attribute setup on
VAO 0 and restore the caller's binding — a misrouted draw can no longer
corrupt the caller.
- contextSync() resets the whole client-array mirror on a context change
(enables/pointers/VBO names all described the dead context).
kicad pointer bump (d6e3dc1a87a): blit preamble disables the four client
arrays (same-context firewall) + DoRePaint hidden-parent early return now
clears m_is_currently_painting like its six siblings (a standalone
sufficient cause of a permanently blank viewer).
TDD (each observed red before its fix, green after; harness = authoritative):
- T1 VAO corruption, T2 foreign-context routing + guard thrash, T3 stale
client-state surviving context recreation — tests/e2e/3d-webgl.spec.ts over
new harness choreography (appQuad/ffpMakeStale/createSecondContext/
quadDrawFresh). Parity stays 47/47 with zero drift.
- tests/kicad/3d-viewer-engine-toggle.spec.ts (new, CI-skipped like the
deadlock spec): real round-trip happy-path gate — board re-renders, zero
[gl1] lines, zero INVALID_OPERATION. (The raytraced image itself never
displays on the wasm build — the pre-existing inert-toggle KNOWN ISSUE in
3d-viewer-deadlock.spec.ts, out of scope here; the engine switch and the
poisoning reload path run regardless.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Files staged from the project sync namespace bundle (stageViaProjectSync)
never passed through fetchFileBytes, the only place baseRevisions was set,
so their first save PUT carried expected revision 0 and 409'd against any
row ever re-saved — e.g. assigning a footprint (CvPcb → eeschema root save
also writes .kicad_pro) failed with "Save conflict … (local base 0,
server 1)".
Add ProjectSource.rememberBaseRevision + api.rememberFileBaseRevision,
DriveOptions.onStagedRevision reported by stageViaProjectSync with the
listing revision, and wire it through WasmTool from ToolPage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Wd1r3ewftpV1DBSEArpRa
A bare expect(false).toBe(true) after the 90s poll hid WHAT diverged in the
drift-trio S4 same-item races (CI 2026-08-24 ×2, local under 15-worker
contention 2/21). The failure now reports each tab that differs from A and
the first differing model line, so the next divergence self-documents
(memory: s4-value-race-divergence — never re-quarantine without the diff).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AH2iPekUGsEAYnMUD5BmAi
The display-index fix (wxDisplay::GetFromWindow returning 0) disabled
DIALOG_SHIM::Show's accidental always-Centre() fallback, so every
unpositioned dialog opened at the port's literal (0,0) wxDefaultPosition.
CI caught it: the eeschema text-properties dialog opened under the
just-clicked canvas-centre point, its OK/Cancel corner swallowed the
trailing mouse event and the dialog insta-closed (run 32739091966 — the
dialog exists in exactly one trace snapshot; passes locally where the
dialog finishes opening after the click sequence).
wxwidgets bump: wasm wxDialog::Show now centres any dialog still at (0,0)
on first show — the native default-placement parity native ports get from
the window manager. Restores the exact pre-regression on-screen placement
(and thereby every dialog screenshot baseline).
New red->green test: eeschema-ui.spec.ts "text properties dialog opens
centred, not at the top-left origin" — clicks away from the dialog's
centred footprint (so no engine can reproduce the swallow), asserts the
dialog window div is viewport-centred; was at (0,0) pre-fix, (332,171)
post-fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closing the viewer destroys its wxGLCanvas's WebGL context; reopening mints a
new one. The gl1 shim cached GL names (FFP program, stream/scratch VBOs) in
never-reset statics behind `if (!handle)` guards — in the new context every
draw died with INVALID_OPERATION and the viewer showed only the clear color
("Reload time 0.031 s" is benign: warm model caches make the rebuild fast).
contextSync() (gl1_state.cpp) now detects the context change in programSync()
— the one choke point every shim draw crosses, and a path the 2D GAL never
reaches (a first attempt checking in the glBindTexture wrap saw the GAL's
context and thrash-rebuilt the program 23x per run) — and drops the cached
names for lazy rebuild in the new context. Context identity is a monotonic id
stamped on Emscripten's per-context record: the numeric
EMSCRIPTEN_WEBGL_CONTEXT_HANDLE is recycled, so a destroy-then-create can
return the same number and a handle comparison detects nothing.
The lost-position half is a wxwidgets wasm fix (pointer bump: GetFromWindow
reports display 0; saved geometry used to carry display=(unsigned)-1, which
LoadWindowState treats as "display not found" and re-centres the frame).
TDD (red observed before each fix, green after):
- tests/kicad/3d-viewer-reopen.spec.ts (new, own worker like the deadlock
spec): load board, open viewer, render-gate, drag by the titlebar, close
via the x, reopen; asserts the board re-renders (was: 1 distinct colour for
90 s) and the window position is restored (was: re-centred to 0,0 after
closing at 40,90). Green run logs exactly one [gl1] context-change line.
- 3d-regression harness: recreateContext() destroys the context AND swaps in
a fresh canvas element (a browser canvas keeps its context for life, so
same-element recreation hands back the live old context and hides the bug);
the new 3d-webgl spec test renders redraw-mini-board-navigator before and
after recreation and requires pixel-identical output. Parity: 47/47, zero
drift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kicad_tools --resave-batch <outdir> <file>...: N resaves in one process
(one WASM init — marginal resave is ~50ms vs ~0.3s/process). Outputs in
<outdir>/<index>/, per-file "RESAVE-BATCH <index> <exit-code>" stderr
verdicts with the single-file 0/4/5 contract; exit 0 = loop completed, so
an invalid file mid-batch can't mask its neighbors.
loadSchematicHeadless now binds each schematic to ITS project via
SETTINGS_MANAGER::GetProject, never Prj(): with aSetActive=false projects
accumulate across batch entries and Prj() keeps returning the first one —
the writer then stamped a wrong/empty project name into saved
symbol-instance data. (UnloadProject is no fix: unloading the active
project immediately reloads a null "" project.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AH2iPekUGsEAYnMUD5BmAi
emcc only runs Binaryen at link -O2+ (link.py: should_run_binaryen_optimizer
returns OPT_LEVEL >= 2) and we link at -O1, so the shipped module had never seen
wasm-opt at all — it kept its entire 19.56 MB name section, ~20% of the editor
(-sJSPI sets ASYNCIFY=2, which suppresses wasm-ld's --strip-debug, leaving
wasm-opt as the only thing that would drop it). Step 8.2 runs it post-link and
in-container, so CI's cached compile phase covers it and the host post-process
stays pure-host.
Default -O2, picked by measuring every level on the same module: -O0 already
captures 27% of the raw win (it is mostly the name section), -O2 costs 23 s and
gives the best frame rate, and -O3/-O4/-Os/-Oz cost 48-132 s for at most 1.5%
more brotli — -O4 is not even smaller than -O3. Targets that already link -O2/-Oz
(occ_service, kicad_tools) are skipped by testing for the target_features
section, which emcc strips whenever it ran the optimizer itself, so there is no
hard-coded target list to drift. Feature flags come from the module's own
target_features section and so cannot diverge from the link.
The perf specs reported requestAnimationFrame ticks as "FPS". That is not a frame
rate: rAF fires on the compositor's schedule whether or not the GAL redrew, and
it read 120/s on a board where the renderer completed zero frames in six seconds.
measureInteractionFps now counts completed GAL frames — runs of draws to the
default framebuffer, exactly one per frame in every AA mode — and drives a pure
middle-drag pan after a zoom-to-fit. Mixing wheel zoom into the drive made the
result depend on where the wheel left the view: +-20% across identical repeats,
against +-2% for pan alone. The report gains a GAL fps column with a regression
flag on the 1x number; rAF is kept so historical runs stay comparable.
CI has no GPU, so its number is a software-rasteriser redraw rate — a regression
signal, not a user-facing frame rate. Method and measurements in the bench report.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
Staging tags are "staging-<full 40-char sha>" — shorten any embedded
full hash for display only; the corresponding-source link keeps the
full sha, release tags pass through untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gJ3M1RpeZLeNUUj8jKC4h
- Read-only sessions boot BOTH panels open as collapsed headers (a stored
per-browser choice wins); inspector's default anchor moves to the
top-left, layers keep the top-right stack under the FAB.
- Layer rows follow KiCad's Appearance-pane order: color swatch, eye
toggle, layer name (name click = set active).
- read-only spec: boot-default assertions (open + collapsed + top-left
anchor) in the lock test, chevron expansion in the panels test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gJ3M1RpeZLeNUUj8jKC4h
Canvas-only sessions (read-only viewers, hide-UI editors) get two
floating, draggable, closable panels standing in for the chrome-hidden
wx panes, opened from the overlay menu's View section:
- wasm/bindings: layer bridge — kicadLayersGetState/SetVisible/SetActive
(bodies mirror the compiled-in-but-unreachable IPC handlers, applies
on the coroutine lane, fresh state pushed to
window.kicadCollab.onLayersState); setters join the jspi-scheduler
mutator lane.
- standalone: LayerPanel + SelectionInspector on the comments-panel
shell conventions (useDraggablePanel, collapse, persisted state);
local-selection store fed from presence's onSelection in edit
sessions and bindLocalSelectionFeed for read-only viewers (with a
bounded post-gesture pull burst — clarify-menu selections produce no
canvas event); pure item-summary extraction + unit tests.
- kicad submodule: read-only selection unlock (selection live for
inspection; point editors + RMB context menus stay locked).
- tests/web: read-only spec updated — viewer click selects (or pops the
clarify list), RMB context menu suppressed with writer positive
control, Delete still swallowed; new viewer-panels test (eye toggle
round-trip, active layer, panel drag, inspector rows from a real
canvas click).
Record: docs/features/read-only-viewer/0002-viewer-panels.md (root).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gJ3M1RpeZLeNUUj8jKC4h
Standalone side of the collision fixes + registry 3D serving:
- remote list + boot preload map the backend's collision-safe mount
nickname into LibInfo.name — one string everywhere KiCad-facing, so
lib tables never mount duplicate names (first-match-wins shadowing)
- registryModelsSource beside cdnModelsSource: model3d origin libs as
sparse sync layers (boot-preloaded index/stacks, IDB per version);
VITE_MODELS_SOURCE=registry opts in, CDN stays default
- bumps web/pcbjam-shared (libSchema.nickname)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3C6837qtdFU84xWhFekxd
tests/wizard-04-finish-headless.png is a spec-owned reference image the
dark-mode spec diffs against in-process (toolbar must ignore the
browser's dark theme) — it was never a promotable baseline, but the
baselines-to-R2 migration (6dfa6eb) swept it out of git with the rest of
the PNGs, and every e2e run since fails with ENOENT at
screenshot-compare.ts:56. Restored verbatim from 6dfa6eb^ and documented
as deliberately committed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Staging deploy builds now upload their renders to runs/pcbjam/<run-id>/
like ci-ubicloud does (secrets: inherit there), so render changes on the
staging branch can be reviewed and promoted in morelli without a manual
ci-ubicloud dispatch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The committed tests/screenshot-manifest.json is retired. CI now downloads
baselines/pcbjam/manifest.json (manifest v3, written only by the morelli
review app + its seed script) to the gitignored .baseline-manifest.json,
and everything downstream (pull, verify, compare) reads that copy:
- config.ts: MANIFEST_VERSION 3, MANIFEST_PATH .baseline-manifest.json,
R2_BASELINES_MANIFEST_KEY; ManifestEntry grows opaque provenance
- r2-sync.ts: new --manifest mode (atomic fetch; no-creds skip DELETES a
stale copy so the gate skips rather than using old baselines); --push
gone (bytes enter the CAS only via morelli's promote)
- compare.ts: hard-skips when no manifest was fetched — a stale warm
cache can never gate
- wasm-build.yml: fetch-manifest step before the baselines cache; cache
key now hashes the fetched manifest; the gen-manifest --check lint gate
goes with the committed manifest
- deleted: screenshot-manifest.json, promote.ts, changelog.ts,
gen-manifest.ts, screenshot-changelog.yml, promote-screenshots skill
- docs (CLAUDE/README/TESTING/WHATWORKS/tools README): promote flow is
now https://pcbjam-morelli-staging.pcbjam-staging.workers.dev
Validated locally against the real bucket: fetch-manifest (492), cold
pull 492 / warm pull cached=492, no-creds skip chain, compare gate skip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Durable per-run screenshot store for the morelli review app
(github.com/PCBJam/morelli): after the report step, CI uploads the renders
and a meta.json index (identity, branch/commit, per-shot sha256+dims,
embedded compare summary) to runs/pcbjam/<GITHUB_RUN_ID>/ — 30-day R2
lifecycle; GH artifacts remain the debugging archive. meta.json is written
last as the upload-complete marker. Uses a new optional WRITE keypair
(CI_SCREENSHOTS_S3_WRITE_*); without it the step no-ops, so secretless
callers stay green. R2Store gains putKey() for the non-CAS run keys.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The standalone's save path was a bare multipart POST: no per-path lane, no
revision tracking, 'Saved ✓' on any 2xx (findings D-1/D-2/D-3 client half —
the 8e3a886 server CAS + smoke oracle landed without it). Re-created fresh:
- save-flow.ts: full persistence lane — per-path active+pending snapshots,
capacity admission before the byte copy, committed-ONLY promotion,
conflict/unknown outcomes install a durable path block, status generations,
SaveHookHandle.stop() aborts transports (D-9 teardown superseding the
interim unregister). +17 unit tests (codex suite, green unmodified).
- project-source.ts: D-1 two-map revision tracking (baseRevisions = model
ancestry and the only legal write precondition; observedRevisions =
metadata), seeded from listing rows, download headers, and cache hits;
uploadFileBytes is now the CAS PUT with x-pcbjam-file-revision and
409 / pre-publish / ambiguous-outcome classification; refreshFileRevision.
- api.ts / idb-project-store / HomePage local sources / ToolPage /
NewFileDialog / persistCreatedSheet: SaveOutcome contract threaded through.
Validated end-to-end by apps/tests editor-save-lane.spec.ts against the live
stack (revision 1 learned on load, PUT base 1 → 200 → server revision 2).
Standalone units 365/365, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZJ1pUePb4W47hGoLMYTw4
- compare gate: refuse an INCOMPLETE cache, not just an empty one — a partial
R2 fetch (continue-on-error step) previously misreported un-fetched
baselines as ADDED and silently disabled their removed-detection.
- post-discord: a missing report.json now posts "gate SKIPPED", never the
false "✅ no screenshot drift" — a disabled pipeline must look disabled.
- promote: hard-fail on a pre-migration (non-v2) manifest instead of warning;
the warn path could commit manifest hashes never uploaded to R2.
- changelog: tolerate per-image R2 failures (skip + loud note, like the old
git-blob null-skip) instead of aborting the whole post; missing creds warn
+ exit 0 (notification-only workflow); git-show failures are loud errors,
no longer mistaken for "predates the migration".
- version guard: a manifest NEWER than the tooling throws everywhere instead
of reading as a silent no-op.
- manifest ordering: locale-independent code-unit comparator in writer +
checker (localeCompare depends on host locale; writer=dev Mac, checker=CI).
- r2-sync pull: byte-size pre-filter before hashing cached files.
- wasm-build.yml: restore-keys on the baseline cache — manifest changes now
restore the previous tree and download only the delta.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three docs still described the committed-PNG model (following them would
trip the new resurrection guard); they now document the fetch-from-R2 cache,
the manifest pin, and the credential requirements. The new /promote-screenshots
skill is the runbook for blessing a CI run's render (dry-run first, RW keypair
from tests/.env, commit only the manifest diff).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Baseline PNGs (492, 35 MB) leave git: they now live in the private R2 bucket
pcbjam-ci-screenshots as immutable sha256/<hex>.png objects, pinned by the
committed screenshot-manifest.json (v2: name, engine, sha256, bytes, dims).
tests/baseline-screenshots/ becomes a gitignored cache materialized by the new
`npm run screenshots:fetch` (r2-sync.ts + aws4fetch r2-store.ts).
- promote.ts: requires the RW keypair (shell env or gitignored tests/.env),
syncs the cache, uploads new hashes BEFORE rewriting the manifest; the
manifest diff is the only git-visible output. --prune only edits the
manifest — R2 objects are never deleted, old commits still resolve.
- compare.ts: skips the gate (exit 0, no report.json) when the manifest
expects baselines but the cache is empty (secretless callers).
- changelog.ts: diffs the manifest between revs and fetches bytes from R2;
guards against the migration commit (base manifest not v2 → skip).
- gen-manifest --check: v2 schema + resurrection guard (fails if baseline
PNGs are ever re-committed); credential-free so every caller can gate.
- wasm-build.yml: declares optional read-only S3 secrets, caches + fetches
baselines before the lint gate; release.yml/deploy-staging.yml pass them.
- screenshot-changelog.yml: triggers on the manifest path instead of PNGs.
All 492 objects are seeded and hash-verified in the bucket; fetch/compare
degrade to a warn-and-skip without credentials.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pre-boot state was a white page with bare 'loading…' text; now it's the
same dark full-viewport centered spinner + mono status as WasmTool's boot
overlay, so the boot wait, the download screen and the editor read as one
continuous load with no white flash.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
Closes the doc-23 §7 KNOWN GAP that killed the editor wherever the wasm is
CDN-served (staging/prod platform): emscripten 6 spawns pthread workers from
_scriptName — the glue's absolute CDN URL — and new Worker(<cross-origin>) is
a SecurityError, observed on staging as 'Failed to construct Worker' right
after instantiation and an editor that never renders. No runtime hook exists
post-mainScriptUrlOrBlob, so wrap window.Worker and redirect EXACTLY the
glue-URL construction to the (formerly dormant) pthreadWorkerScript blob that
importScripts() the glue — blob workers inherit the page origin, and the CDN
already sends the CORP/ACAO the page's COEP requires. ?trace= now reaches
pthread realms through the same blob, same-origin included.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
fileCacheValidator: hasYdoc && !isLive rows now validate as
y<YDOC_CONVERT_EPOCH>:<ydocTag> (revision-0 collab-only rows included); live
rows and untagged older backends stay uncacheable. The remote source caches
the CONVERTED KiCad text — a warm load skips the download and the measured
~2s-class ydoc→s-expr conversion — and the unconvertible-ydoc plain fallback
is cached under the same tag, ending the stale-ydoc double-fetch. A ydoc
response under a revision-form validator (room appeared mid-listing) stays
uncached, preserving the old race guard exactly.
Measured (Arduino Leonardo, dev stack): cold 52 file GETs → warm reload 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
One websocket per project (GatewayConnection, refcounted per endpoint+project,
single reconnect ladder) with per-doc facades: a mini y-websocket client over
varint-channel-tagged frames (y-protocols sync + awareness, own Awareness per
facade so skeleton presence stays per-room). connectProvider's partykit kind
routes every parseable board/presence room through the gateway; the per-room
dial remains only for unparseable operator rooms.
Laziness contract (0001 §5 amendment): sheet-manager warms the hierarchy with
PASSIVE subscriptions — no SyncStep1, no BoardRoom wake; touched hints drive
the parked dirty flag; doSwitch awaits the new provider.activate() sync
barrier before bind/seed; syncLayoutFromSave activates before writing; a stray
local write into a passive doc auto-activates. suberr (invalid-file 409,
presence 403) surfaces as CollabSubRejectedError — terminal in the switch
retry ladder like SexprVersionError, ending the old blind re-dial.
Verified: standalone 346 unit tests green (10 new facade tests incl. the
no-doc-frame passive pin); browser on the dev stack: pcbnew demo = 1 gateway
socket / 0 board-rooms with live collab, eeschema Arduino Leonardo = 2 sockets
total, sub table presence+root active / 3 children passive. tests/web: no
regressions (4 failures reproduced identically without this diff —
pre-existing on the JSPI line; 1 parallel-load flake passes alone).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
useProjectBoot: the tool page tries the ONE composed boot round-trip first —
its project half is the getProject shape — and falls back to the active
source's getProject on any miss (older backend, local-store slug, static
gallery), so downstream behavior without a payload is exactly pre-boot.
With a payload: seedSessionIdentity makes the /api/me flight a resolved
no-op; the synced libs source is constructed with the preloaded listing
(kind-filtered client-side by kindCounts — the same org/mirror-always +
libHasKind rule the server applies) and the batch-resolved stacks, and every
INTERNAL listing (syncState, presync, enableRealtime name-mapping) rides the
preload too; the fresh project-sync digest threads into staging so a warm
restage affirms with zero HTTP.
Measured warm load (Arduino repo-as-project): total API traffic = 1 boot
request + the ydoc-backed per-file set — /api/me, /libs, sync-stacks and
/sync/manifest all at zero.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
stageViaProjectSync: plain uploaded files (revision>0, not ydoc-backed, not
the target) stage from a one-layer static SyncStack over the backend's
project sync surface — ONE bundle GET cold, a manifest diff warm, bodies
IDB-mirrored like a library. The target keeps its room-materialized wrapper,
ydoc-backed files keep the negotiated per-file fetch, a namespace miss (a
write raced the listing) falls back per-file, and ANY namespace failure
(older backend, decode error) falls back wholesale — exactly the previous
behavior. Demo/local sources pass no config and are untouched.
Measured (Arduino repo-as-project, 108 files = 76 plain + 32 ydoc-backed):
cold 76 requests → 1 bundle; warm 1 manifest GET, 0 bodies.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
connectAll warmed a room for every .kicad_sch in the project — a
repo-as-project upload (N boards × sheets) opened dozens of sockets for
schematics the wasm never loads. Only the opened root plus its transitive
(property "Sheetfile" …) closure is in memory, so only those need rooms:
no in-memory copy, no divergence risk, no clobber, and C++ sheet navigation
can only reach hierarchy members anyway (same reasoning as pcbnew's
directory-scoped sibling restage).
resolveSheetHierarchy: regex closure over staged MEMFS text, refs resolved
against the referencing sheet's directory (../ and ${KIPRJMOD}/ handled),
non-project refs ignored, unreadable sheets kept warmed but unexpanded, and
an unscopable root (fileless boot) falls back to all project sheets —
over-warming costs sockets, under-warming would cost collab. In-editor
"Add Sheet" children keep their created-hook warm-up.
Verified on the Arduino repo-as-project (27 schematics): 26 board-room
sockets → 5 (root + 3 hierarchy children + presence).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
saveItemBody retries ONCE after a SyncRoomMovedError: invalidates the cached
batch-resolved descriptor (onStackMoved clears batchedStacks), closes the stale
stack, re-resolves, and retries the write against the room the fresh descriptor
names. A persistent refusal fails after the single retry. Bumps pcbjam-shared
for the registry wire types + typed error + mux replay.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
Step 1: warm platform loads stop re-downloading every project file. New
project-file-cache.ts (raw IDB, best-effort) keyed by the listing's
revision:updatedAt validator — the pair, not revision alone, because the resave
equivalent-body swap keeps revision but touches updatedAt. Listing row threaded
through fetchFileBytes(slug, path, meta?); ydoc-backed and revision-0 files are
never cached; prune runs on every fresh listing.
Step 2: cdnLibsSource marks its tag-pinned static layers immutable — warm demo
loads skip all ~155 per-lib manifest GETs (bumps pcbjam-shared for the
LayerDescriptor.immutable flag).
docs/features/load-path-rework/0001 steps 1-2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLSht9cadprtT2mhynawWu
C-1: pending-session ownership in WasmTool (registered before adoption,
destroyed on every non-adoption exit incl. unmount/late-handoff guards);
sheet-manager destroyed flag refuses post-destroy connects.
C-2: attachKicadCollab destroys a partially-attached binding on seed throw.
C-3: connectKicadDoc gains a 30s whole-path deadline + abort signal covering
provider import/construction/initial sync, with partial cleanup on every
loss path (incl. late-resolving construction).
C-4: sheet switch clears host presence/comments/follow/drift callbacks
BEFORE the new room connects (onActiveChange(null) pre-connect).
C-5: switchTo rethrows SexprVersionError terminally (no retry timer, queue
unpoisoned); boot surfaces it, nav hook degrades per-sheet.
C-6: sibling-restage failed dial retries on 1s→30s backoff instead of
latching forever; roster churn still never re-dials.
C-7: terminal-error promote() now tears down every ws-driven collab ingress
(shared teardownCollab) — no ticket storm under the fatal overlay; UP apply
observer gains the symmetric try/catch + clean-stack re-surface.
Tests: index.test.ts (new, 8), sheet-manager +3, sibling-restage +2;
standalone units 135/135, tsc clean; ysync-two-tab + eeschema-subschema
7/7 kicad-chromium on the rebuilt bundle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZJ1pUePb4W47hGoLMYTw4
Unit-tier rebuild of drift-trio S4/S4b (findings A-4): two bound editors over
a held Y relay, conflicts released in fixed order, asserting Y.Doc convergence
FIRST then native projections against the doc. Pinned clientIDs make the LWW
winner deterministic; reverse-order release must converge byte-identically.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZJ1pUePb4W47hGoLMYTw4
Second half of the staging Firefox boot failure: the bucket stores
artifacts brotli-compressed (publish-wasm --compress br) with
Content-Encoding in the object metadata, but the worker returned them
with the default encodeBody:'automatic' — the runtime drops a user-set
Content-Encoding and re-negotiates, and on workers.dev that served the
raw brotli bytes with NO encoding header. The editor <script> tags
loaded binary garbage: Firefox fires onload and silently executes
nothing, so wxElementRegistry/Module never appeared even after the 206
fix. encodeBody:'manual' passes the header+body through untouched
(verified against local workerd with a br-encoded seeded object).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmR6goSk7JC17h7fkgGvHG
The previous fix only stopped passing range options for range-less
GETs, but current workerd reports a DEFINED full-span object.range even
for a plain get() with empty options — so every response still went out
206 + Content-Range and Firefox still refused to execute the editor
glue. Verified against local workerd (wrangler dev + seeded R2): the
206 branch must key off the REQUEST having asked for a range.
Plain GET now 200 with full body; Range requests still get proper 206s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmR6goSk7JC17h7fkgGvHG
- itemToJson: pass PADSTACK::ALL_LAYERS for vias — the layerless virtual
PCB_VIA::GetWidth() is an assert trap since the padstack refactor, and the
collab baseline/snapshot serializer hit it once per via per snapshot
(big-board load = assert storm). Values were already correct; wire format
unchanged (applyChanged's layerless SetWidth writes the same slot).
- console: closed state is a content-width bottom-left tab again (version
badge + app bottom edge visible); opened footer panel unchanged.
- console: partial-selection copy works — wx's window-level keydown handler
preventDefaults Ctrl/Cmd+C, so a capture-phase guard stops propagation to wx
when the selection lives in the console; canvas mousedown collapses stale
log selections so they can't steal the editor's own Ctrl+C.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPtPBVLKQzaXTkirYgcVay
env.BUCKET.get(key, { range: request.headers }) makes R2 report a
DEFINED object.range (full span) even when the request carried no Range
header, so every plain GET went out as 206 + Content-Range. Chrome
executes a 206 <script>; Firefox fires onload but refuses to EXECUTE
the script — on hosts with no masking edge cache (staging workers.dev)
the injected editor glue never ran: no [KICAD_STATUS] logs, heap 0MB,
'runtime did not initialize (no FS) in 90s' on every Firefox boot.
Prod's cdn.pcbjam.com zone cache normalizes cached hits to 200, which
is why only staging surfaced it; cold prod objects share the bug.
Pass the range option only when the request actually sent a Range.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmR6goSk7JC17h7fkgGvHG
1) pcbnew/eeschema two-tab 'a local move propagates A->B' read the
pre-move baseline AFTER kicadCollabTestMoveFirst. The move is queued
through CallAfter + the apply coroutine, and the drain can land
between two consecutive page.evaluate round-trips — when it does
(~50% under CI load, reproduced locally with --repeat-each) orig
captures the ALREADY-MOVED position and the not-toBe poll waits on
itself. Wire tracing showed the bridge working: the moved delta
emits, nothing reverts. Fix: baseline from kicadCollabSnapshot
BEFORE the move. 12/12 green at --repeat-each=4 (was ~50% red).
2) jspi-firefox suites 404'd their harness modules: jspi-stack and
jspi-coroutine build via ad-hoc build.sh (Phase 3 Makefile wiring
TODO) which CI never ran. Wire both into build-wasm-test.sh (its
hash is already in the testapps cache key), and add the _pt pthread
variant that index.html?pt=1 loads but nothing built. jspi-firefox
8/8 green locally.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmR6goSk7JC17h7fkgGvHG