fix(annotation): keep preview marks on the artifact region the user selected at any UI zoom - #6476
fix(annotation): keep preview marks on the artifact region the user selected at any UI zoom#6476linghaoSu wants to merge 20 commits into
Conversation
Marking a region of an HTML preview could send the agent a different region than the user selected. Two independent causes, both measured on the reported artifact (10 bands, 40px tall, 50px pitch). 1. capturePage clipped in the wrong coordinate space. The renderer measures the preview frame with getBoundingClientRect() -- CSS pixels -- but Electron's capturePage() clips in DIP. Those spaces coincide only at zoom factor 1. The proof is the returned bitmap: at zoom 1.095 a 692x666 CSS frame came back as 1384x1332, exactly 2.0x the CSS rect, while devicePixelRatio was 2.1909. The capture therefore started 14 DIP above the frame and the mark landed a full band high -- red box at rows 683-765 while the marked band occupied 777-864. parseCaptureClip now converts CSS px to DIP with the live zoom factor. 2. Marks were pinned to a fraction of the frame, and the artifact moves. Changing UI zoom narrows the preview frame, which reflows the artifact: the header paragraph wraps to a second line and every band below shifts down 19.5px. A mark held at a fraction of the frame slides across the content, so "zoom in, mark, restore, send" sent the neighbouring band. Marks now bind to the element they were drawn on -- reusing the data-od-id identity the preview bridge already publishes for comments -- and are re-projected from that element's current box before the pixels and the structured bounds are read. A new od:mark-anchor-request / od:mark-anchor-targets pair serves element boxes without enabling comment mode, whose hover/click interception would fight the canvas. Boxes, pen strokes and text labels all follow. Anchoring is best-effort: a preview that reports no elements leaves marks on their frame-relative position rather than blocking the send, and a bridge that never answers is remembered so later sends do not wait out the timeout. A pure frame resize (no reflow) is still corrected geometrically, which also covers device-frame previews whose layout box never changes. Because anchors must come from the frame the user sees and that the compositor screenshots, draw mode now requires the URL-loaded preview to serve the anchor bridge as well as the snapshot bridge; the raw preview route injects an older selection bridge that predates this protocol, so draw mode falls back to srcDoc. Resolving anchors against the hidden srcDoc twin agreed with the visible frame only until it was scrolled. Scroll re-projects on the leading edge so a mark tracks its content during the gesture; resizes and fresh marks stay on a trailing debounce. Fixes nexu-io#6361
β¦ontract Draw mode now requires the URL-loaded preview to serve the anchor bridge as well as the snapshot bridge, so a frame carrying only `odPreviewBridge=snapshot` falls back to srcDoc. This test asserted the previous contract and was the one failure this branch added over main (584 -> 585); it now asserts the swap, with the reason and the condition that would make url-load eligible again.
β¦ed client rect Full-matrix runtime verification of nexu-io#6361 surfaced a third coordinate seam: in a fit-to-window scaled device frame (tablet/mobile preview in a narrow window) the structured annotation bounds were read from canvas.getBoundingClientRect(), which reports the on-screen size after the ancestor transform: scale(). The composited screenshot paints marks across the snapshot's artifact-local dimensions, so the PNG showed the right region while the structured position sent to the agent was shrunk by the fit scale β measured 0.42x: a mark on a 784px-wide band went out as a 330px rect at the wrong offset. Bounds now derive from the canvas layout box (offsetWidth/Height, the same source the canvas is sized from and the anchor pass measures against), with the client rect kept only as a fallback for environments without layout metrics. Text-label bounds divide their client-space offsets by the live scale for the same reason. Verified against a live tools-dev runtime driving the real UI: box marks, pen strokes at all four preview edges, sidebar collapsed/expanded, and a 0.42x-scaled tablet frame β the decoded screenshot's painted mark, the structured bounds, and the pointer-targeted band now agree within 2px in every case, at DPR 1 and 2. Refs nexu-io#6361
|
Thanks @linghaoSu. Opened as a draft, so I'll hold off on a full review until you mark it ready for review. I've started the triage/classification steps in the meantime. |
nettee
left a comment
There was a problem hiding this comment.
@linghaoSu I found three blocking correctness regressions in the changed annotation path; the focused geometry tests and app typechecks pass, but these cases can still break powered previews or send a mark to the wrong region. The inline threads below describe the evidence and concrete fixes.
π Powered by Looper Β· runner=reviewer Β· agent=codex Β· An autonomous AI dev team for your GitHub repos.|
Hey @linghaoSu, @nettee's current-head review is the right blocking thread to work through first β the three inline comments there are the actionable merge blockers on this revision. I've also marked this for manual QA because it changes user-visible annotation behavior. Once those fixes are in and the PR is merge-ready, we'll pull QA in for the validation pass before merge. π‘ To drive this PR to merge hands-free, paste this to your AI coding agent (Claude Code / Codex / opencode / Cursor β¦): |
β¦ undone-stroke sync Three review findings on nexu-io#6476, each with a red spec: 1. Draw mode forced every preview through srcDoc because nothing ever set urlLoadDecision.urlAnchorBridge, which broke powered previews (SharedArrayBuffer/WebGL artifacts that need the cross-origin-isolated URL frame). The daemon's URL selection bridge now answers od:mark-anchor-request with the same reply shape as the srcDoc bridge and advertises markAnchors:true in its ready message; FileViewer keys urlAnchorBridge off that flag and resets it wherever the selection bridge readiness resets. 2. One empty anchor-targets response permanently latched anchorBridgeRef=false, disabling anchoring for the session β including for dynamic apps that annotate elements after first paint and for later files shown through the same overlay. The probe now distinguishes an answered-empty reply (live bridge, keep asking) from an unanswered one, gives up only after two consecutive silent probes, and forgets the verdict when the probed iframe changes. 3. syncContentAnchors re-projected only live strokes, so undo β reflow β redo restored a stroke at its stale frame position on the neighbouring element. Undone strokes now ride the same syncStrokeAnchor pass as visible ones. Refs nexu-io#6361
|
@nettee All three blocking threads are addressed in fbf371b (each with a red spec that fails on the previous head), threads replied + resolved. Ready for re-review.
Validation: guard + repo typecheck green; web suite 6237 passed; daemon suite failures are identical with and without this diff (pre-existing on baseline, verified by stash); live-runtime annotation matrix (box/pen/4 edges/sidebar/fit-to-window/DPR 1&2) re-run green on the new head. |
nettee
left a comment
There was a problem hiding this comment.
@linghaoSu I found two remaining correctness blockers in the current annotation path. The focused and full suites, app typechecks, and guard all pass, but these cases can still bind a mark to the wrong artifact region or permanently fall back to frame-relative coordinates. Please address the inline findings before merge.
π Powered by Looper Β· runner=reviewer Β· agent=codex Β· An autonomous AI dev team for your GitHub repos.β¦p a cooldown Second review round on nexu-io#6476, two findings: 1. Anchor probes went through snapshotHostIframe(), which prefers the hidden srcDoc twin. With urlAnchorBridge keeping the URL/powered iframe active, the user draws on and the compositor captures one document while anchors were read from another β the twins scroll independently (URL scroll restoration targets the active frame only) and a powered artifact may not execute in the opaque sandbox at all. Anchor requests now go to the data-od-active frame via a dedicated anchorHostIframe(); snapshotHostIframe() remains for snapshot capture, whose bridge genuinely lives in the srcDoc transport. 2. The silent-probe counter was a permanent verdict: two 1.5s timeouts during a slow document load disabled anchoring for the rest of the overlay's mount, and the counter survived deactivation and file switches on a reused iframe element. The give-up is now a 5s cooldown with single-probe retries, and the budget resets on overlay deactivation and on filePath change. Tests (each red on the previous head): active-frame probe with a diverging srcDoc twin mounted, delayed bridge-ready recovery under fake timers, and file-switch budget reset on a reused iframe. Refs nexu-io#6361
|
@nettee Round-2 findings addressed in 8345bab, threads replied + resolved. Anchor probes now target the active ( |
nettee
left a comment
There was a problem hiding this comment.
@linghaoSu I rechecked all changed ranges, the prior resolved review threads, focused web/desktop/daemon tests, the three app typechecks, guard, diff validation, and the current checks. The coordinate-space fixes and bridge gating are otherwise well covered, but one stale active-frame probe can still send marks to the wrong artifact region during a normal iframe or zoom transition. I also found a smaller bridge-error path that suppresses retry semantics; both details are inline.
π Powered by Looper Β· runner=reviewer Β· agent=codex Β· An autonomous AI dev team for your GitHub repos.β¦swered Third review round on nexu-io#6476: 1. syncContentAnchors captured the iframe and frame size before an await that can span the full bridge timeout. If the active iframe swapped (srcDoc -> URL when bridge-ready advertises markAnchors) or the frame resized mid-flight, the reply β measured against the old document and geometry β was still committed to the shared mark refs, moving marks to the wrong region right before capture. The continuation now re-checks both identities after the await and discards a stale reply; the pass accompanying the change re-probes against the current frame. 2. Both bridges' od:mark-anchor-request handlers swallowed enumeration exceptions into a normal empty reply, which the host counts as an answered bridge β clearing the retry budget and pinning the mark frame-relative with no retry path. A failed enumeration now stays silent, so the host's timeout classifies it as unanswered and the cooldown/retry semantics engage. Applied to the daemon URL bridge and mirrored in the srcDoc bridge. Test (red on the previous head): 'discards an anchor reply that resolves after the active iframe swapped' β anchor acquired on frame A, a hanging probe resolves with A's scrolled boxes after the active frame swaps to B, and the send must keep the mark where the user drew it. Refs nexu-io#6361
|
@nettee Round-3 findings addressed in c5765ba, threads replied + resolved. Stale anchor replies (iframe swap or resize during the probe await) are now discarded via post-await identity re-checks, and bridge enumeration failures stay unanswered so the host's cooldown/retry engages instead of reading them as a healthy empty document. New regression test red on the previous head. Validation: guard + typecheck green, web component suite 3788 passed, daemon bridge tests 47 passed, live-runtime annotation matrix green on this head. Ready for re-review. |
nettee
left a comment
There was a problem hiding this comment.
@linghaoSu I reviewed every changed file and reran the focused web (347), desktop (7), and daemon (47) suites, all three app typechecks, the repository guard, and the root typecheck. The zoom, layout-space, and content-anchor coverage is strong, but two blocking correctness paths remain; the concrete cases and fixes are in the inline comments.
π Powered by Looper Β· runner=reviewer Β· agent=codex Β· An autonomous AI dev team for your GitHub repos.β¦y generation Fourth review round on nexu-io#6476: 1. HTML above HTML_PREVIEW_BRIDGE_MAX_BYTES streams from disk with no transform, so it carried no preview bridges β a >2MiB powered artifact never advertised markAnchors, and Draw's new urlAnchorBridge gate kicked it into the opaque srcDoc sandbox that cannot run Worker/SAB/WASM content. Large HTML responses now append the requested bridge scripts after the streamed bytes (each bridge is a guarded IIFE, so a trailing script is equivalent to the buffered no-</body> injection path); Range replies keep exact file bytes, and Content-Length covers file + suffix. Applied to both the raw and powered routes. 2. The post-await staleness guards compared only the iframe node and frame size, which cannot see a same-node document reload or a deactivate/reopen cycle, and did not order overlapping probes. A monotonic probe generation now supersedes: each probe takes a token, deactivation and filePath changes invalidate it, and only the latest probe may commit its reply. Tests (red on the previous head): streamed-large-HTML bridge suffix on both routes (daemon), and a reopen-with-pending-probe regression where the stale pre-reopen reply must not re-project the fresh mark (web). Refs nexu-io#6361
|
@nettee Round-4 findings addressed in f284004, threads replied + resolved. Large streamed HTML (>2MiB, both raw and powered routes) now carries the preview bridges as a streamed suffix β no buffering added, Range semantics preserved β so powered artifacts keep markAnchors and Draw stays on the URL frame. Probe staleness is now governed by a monotonic generation invalidated on deactivation/file-switch and superseded by newer probes. Both regression tests red on the previous head. Validation: guard + typecheck green, web component suite 3789 passed, daemon file-route suites 54 passed, live-runtime annotation matrix green on this head. Ready for re-review. |
|
Maintainer adjudication requested. Status summary for a human decision:
Question for maintainers: should the remaining deactivate-during-capture threads (and any further capture-lifecycle refinements) block this PR, or land as a follow-up issue so the zoom-drift fix (the user-facing bug) can ship? I'm happy to either fix the open threads in one more round or file the follow-up β your call. |
|
@linghaoSu Thanks for laying this out clearly. I re-read the current head, and I don't think the remaining deactivate-during-capture race should move to a follow-up: it still sits on the core Given that, we should keep this PR blocked on closing that lifecycle edge case rather than land a path that can still emit a missing mark or mismatched screenshot/bounds under a normal Escape or file-switch during send. I also re-ran the failed workflow jobs so you'll get a fresh CI signal on this head. |
Maintainer-adjudicated round on nexu-io#6476 (lefarcen: the deactivate-during-capture race stays merge-blocking): the active === false cleanup cleared strokesRef/selectionBoxesRef/ textMarksRef and released the frozen layout while send() could still be between requestSnapshot and its bounds read β an Escape or file switch during a slow capture emitted an annotation with an unmarked PNG and undefined bounds. The cleanup now defers while and runs from send()'s finally once the payload is fully read; every other deactivation path is unchanged. Test (red on the previous head): deactivating mid-capture must still emit the annotation with intact structured bounds and the mark painted into the PNG (asserted via recorded compositor calls). Refs nexu-io#6361
|
@lefarcen @nettee Per the adjudication, the deactivate-during-capture race is fixed in a6260db (both threads replied + resolved): the inactive cleanup defers while |
nettee
left a comment
There was a problem hiding this comment.
@linghaoSu I found one remaining blocking race in the active-frame anchor-probe lifecycle. A stale probe is correctly discarded when Draw switches from srcDoc to the URL frame, but the send path can then finish without probing the new frame, so the screenshot and structured bounds can describe different artifact documents. The inline comment has the repro and a concrete fix; the focused suites, package typechecks, and guard pass, so this is the remaining correctness gap before merge. The coordinate-space and lifecycle coverage is otherwise thoroughβthank you for the careful follow-up work.
π Powered by Looper Β· runner=reviewer Β· agent=codex Β· An autonomous AI dev team for your GitHub repos.|
Hey @linghaoSu, @nettee's latest current-head review is still the active blocker here β the remaining gap on \ is the stale-frame discard path in : after Draw switches from the srcDoc iframe to the URL iframe, the old reply is dropped but the pre-capture path can still finish without probing the new active frame. Once that re-probe is forced before capture, the existing \ path can continue. |
|
Hey @linghaoSu, @nettee's latest current-head review is still the active blocker here β the remaining gap on |
β¦scarded Eleventh review round on nexu-io#6476: discarding a stale reply (active iframe swapped or frame resized during the probe await) settled the shared sync chain without re-probing, so a pre-capture sync that joined that chain could proceed to capture with marks never resolved against the document being screenshotted β e.g. Draw opens on srcDoc, the URL anchor bridge becomes ready mid-send, data-od-active flips with unchanged wrapper size, and no resize/scroll event follows to trigger another pass. The iframe-identity and frame-size discards now set the trailing flag before returning; the trailing pass runs inside the same chain (syncContentAnchors), so awaiting callers observe the re-probe against the current frame before the freeze engages. Test (red on the previous head): Send's own probe held while data-od-active swaps srcDoc -> URL with the wrapper size unchanged; the awaited chain must probe the URL frame before capture and the sent bounds must resolve the mark's anchor against that frame's boxes. Refs nexu-io#6361
|
@nettee Round-11 finding addressed in 12b53fc, thread replied + resolved. Stale-reply discards now queue an in-chain trailing pass, so awaited pre-capture syncs always cover a probe of the frame actually being captured. Regression test reproduces the exact bridge-ready mid-send sequence, red on the previous head. Validation: guard + typecheck green, web component suite 3797 passed, live-runtime annotation matrix green on this head. Ready for re-review. |
nettee
left a comment
There was a problem hiding this comment.
@linghaoSu I completed a full changed-range review of the current head. The focused web anchor/geometry suites (97 tests), daemon route suite (51), desktop clip suite (7), all three app typechecks, and pnpm guard pass. I found two merge-safe follow-ups in the new bridge path below; the core coordinate and capture-lifecycle fixes are otherwise well covered. Thanks for the careful regression work and the thorough verification matrix.
β¦numerator Twelfth review round on nexu-io#6476 (both non-blocking, addressed to close out the review): 1. requestPreviewAnchorTargets cast any array straight to PreviewAnchorTarget[]. A malformed or forged entry (null, empty selector, NaN box) would throw inside the anchor chooser and abort the send β anchoring is best-effort and must never block the annotation. Replies are now sanitized field-by-field; invalid entries drop, a non-array reply counts as answered-empty. 2. Both bridges served od:mark-anchor-request via allTargets(), whose targetFrom() builds the full comment payload (text, htmlHint, computed style) per node β wasted work on the 32ms scroll pacing that made large artifacts jank. Each bridge now uses a dedicated enumerator that queries only annotated nodes, emits only the three anchor fields, caps the walk at 1500 nodes, and keeps the visibility filter. Tests: malformed-reply sanitization (red on the previous head) and answered-empty classification in tests/runtime/exports.test.ts; daemon bridge assertions updated to pin the lean walker (annotated-only query, cap, no allTargets) alongside the existing visibility checks. Refs nexu-io#6361
|
@nettee Both round-12 follow-ups addressed in eec0415, threads replied + resolved β bridge replies sanitized before casting, and both bridges now use a lean capped anchor enumerator instead of the full comment-payload walk. With no blocking findings on the changed ranges this round, the PR should be ready for approval. Validation: guard + typecheck green, web suites 4426 passed, daemon route suites 56 passed, live-runtime annotation matrix green on this head. cc @lefarcen |
|
@lefarcen @nettee Status: all 24 review findings across 12 rounds are fixed and resolved (0 unresolved threads), CI is fully green on head eec0415, and nettee's latest full changed-range review came back with no blocking findings β the two merge-safe follow-ups it listed are also now fixed on this head. The PR still shows Changes requested only because the stale Could @nettee submit an approving review of the current head (or @lefarcen dismiss the stale one)? After that this should be ready to merge β happy to leave the squash to a maintainer since my fork permissions don't cover merging either. |
nettee
left a comment
There was a problem hiding this comment.
@linghaoSu The zoom/DIP conversion, content anchoring, streamed bridge/cache handling, and layout-space bounds are covered by the focused regression suites, pnpm guard, and the workspace typecheck. I found one merge-safe hardening follow-up in the new cross-frame reply sanitizer; it is called out inline. The extensive race/reflow coverage and careful iteration here are excellentβthank you!
π Powered by Looper Β· runner=reviewer Β· agent=codex Β· An autonomous AI dev team for your GitHub repos.Thirteenth review round on nexu-io#6476 (non-blocking): sanitizeAnchorTargets validated fields but still iterated an arbitrarily large cross-frame array on the UI thread. Our bridges cap their own enumeration at 1500 targets, so anything larger cannot be a legitimate reply β it is now rejected wholesale as answered-empty before the loop, and per-entry elementId/selector strings above 512 chars are dropped. Frame-relative fallback still applies, so a forged reply degrades anchoring, never the send. Test: an oversized (5000-entry) forged reply sanitizes to empty and a 600-char elementId entry drops while a valid sibling survives. Refs nexu-io#6361
|
@nettee Round-13 follow-up addressed in c953192, thread resolved β forged anchor replies are now bounded before iteration (count + string-length caps, over-limit refused as answered-empty). Validation: guard + typecheck green, exports suite 93 passed. That clears every finding to date; @lefarcen the stale CHANGES_REQUESTED from a6260db is still the only thing holding reviewDecision β an approval of the current head or a dismissal would unblock the merge. |
|
@lefarcen Gentle nudge: everything on this PR is now clear β 0 unresolved threads (25 findings across 13 rounds all fixed with red specs), CI fully green on c953192, and nettee's last two passes found no blocking issues. The only remaining blocker is the stale CHANGES_REQUESTED review from a6260db, which as a fork contributor I can neither dismiss nor re-request past. Could you dismiss it or have @nettee approve the current head, and merge (squash) when convenient? Thanks! |
|
@linghaoSu β the re-review request has already been sent to @nettee for the current head ( |
# Conflicts: # apps/daemon/src/routes/project/index.ts # apps/web/src/components/FileViewer.tsx
|
@lefarcen @nettee Rebase/merge update:
Validation on the merge head: PR is |
The suite's afterAll awaited server.close(), which only stops new connections; undici keeps the sockets from this suite's fetches alive, so the close callback never fired and the hook hit vitest's 10s budget on CI. Drop the live connections explicitly, matching the other daemon server suites.
|
CI follow-up on the merge head: Root cause: the hook awaited Fixed in
|
|
Status on head
@nettee β could you take a look at this head? It carries two changes since your last pass: the upstream/main merge (union-resolving @lefarcen β alternatively, dismissing that stale review would open the merge path. Happy to leave the squash to a maintainer since fork permissions don't cover merging. |
|
Daily check-in: head @nettee a re-review of this head would clear the last blocker; @lefarcen dismissing the stale |
|
@lefarcen @nettee Check-in on progress β re-verified just now, nothing has changed since yesterday and nothing is pending from my side:
@nettee β could you take a pass on @lefarcen β alternatively, dismissing the stale review opens the merge path. Also, is the manual QA validation still queued for this one? Happy to provide a repro recipe for the zoom/annotation matrix if that helps whoever picks it up. Not trying to rush anyone β just flagging that this has been sitting merge-ready for a while and I want to make sure it is not silently stuck on my side. |
# Conflicts: # apps/web/src/components/FileViewer.tsx # apps/web/src/runtime/srcdoc.ts
|
Validation on the merge head: Back to |













































Fixes #6361
Why
Reported in #6361: with the desktop app at a non-100% UI zoom, a Mark/Draw annotation's screenshot, painted mark, and the structured
positionsent to the coding agent no longer identify the same preview region β the reporter's capture showed a1459px-wide box spanning the app sidebar, and the downstream agent edited the wrong element. I reproduced the report end-to-end and found three independent coordinate-space seams, all fixed here:capturePageclipped in the wrong space (desktop). The renderer measures the preview frame withgetBoundingClientRect()(CSS px) but Electron'scapturePage()clips in DIP; the two coincide only at zoom factor 1. At any other zoom the capture starts offset from the frame and includes app chrome.parseCaptureClipnow converts CSS px β DIP with the live zoom factor.data-od-ididentity the preview bridge already publishes) and are re-projected from that element's current box before pixels and structured bounds are read.getBoundingClientRect()β the transform-scaled on-screen size β while the composited PNG stayed artifact-local. Measured at 0.42Γ fit scale: a mark on a 784px-wide band went out as a 330px rect. Bounds now derive from the canvas layout box (offsetWidth/Height), the same source the canvas is sized from.What users will see
Drawing a mark on an HTML preview and sending it now targets the same artifact region the user selected β at any UI zoom, DPR, sidebar width, or preview viewport (desktop/tablet/mobile, including fit-to-window scaling). Marks visually track their content when the preview reflows, resizes, or scrolls while draw mode is open. No new UI.
Surface area
Screenshots
Before/after comparison from two live namespaced runtimes (
mainvs this branch), same scripted gesture β mark Band 5, narrow the window so the content reflows, send:Reflow scenario (web runtime,
mainvs this branch, same scripted gesture):Electron capturePage clip of Band 7 (magenta) at 125% UI zoom:
Same at 80% UI zoom:
Raw gesture screenshots (before-fix runtime): mark Band 5 wide, then reflow
Zoom before
zoom-before.mp4
Zoom after
zoom-after.mp4
mainthe structured bounds land 32px off β one full band β onto Band 4/5; on this branch they track Band 5 exactly (drift 0px).Bug fix verification
main/ green here:apps/desktop/tests/main/capture-clip-zoom.test.tsβ CSSβDIP clip conversion, pinned to the zoom/bitmap numbers measured in the reportapps/web/tests/components/PreviewDrawOverlay.content-anchor.test.tsx,PreviewDrawOverlay.frame-resize.test.tsx,preview-mark-anchor.test.ts,preview-mark-geometry.test.tsβ content anchoring and geometric re-projectionapps/web/tests/components/PreviewDrawOverlay.scaled-frame-bounds.test.tsxβ layout-space bounds in a 0.42Γ-scaled device frametools-devruntimes (production APIs only, real pointer gestures, annotation payload intercepted at theopendesign:annotationboundary), covering the issue's requested matrix:parseCaptureClip+ realcapturePage, pixel-checked bitmaps; pre-fix control path reproduces the bug at every non-100% zoom)deviceScaleFactor)Validation
pnpm guard,pnpm typecheckpnpm --filter @open-design/web testβ 6235 passedpnpm --filter @open-design/desktop testβ 319 passedpnpm --filter @open-design/web typecheck,pnpm --filter @open-design/desktop build