kicad-validity B3+C+S: revert toast, corpus lint, .kicad_mod tier

B3 (editor): kicad-binding observes kdoc_meta.revertNonce (deduped, like
seedNonce) → DOC_REVERTED_EVENT → WasmTool doc-reverted-toast (15 s,
data-testid for the deferred e2e check).
C: tests/tools/corpus-lint.ts (npm run corpus:lint) — kicad_tools --lint
over the fixture corpora raw + shared-codec round-trips
(docToFile(fileToDoc(x))); the E3/wrapInBoardEnvelope-class gate. Skips
when output/kicad_tools.js isn't built; CI step lands when kicad_tools
joins the CI app set. 7 fixtures / 7 round-trips green.
S: --lint + --resave gain the .kicad_mod tier (pcbToolsLintFootprint /
pcbToolsResaveFootprint; writer needs CTL_FOR_LIBRARY or the board flags
strip the (version) header). Verified: qa footprint round-trip lints
clean, resave stamps 20260206, garbage exits 1. Also bumps pcbjam-shared
(mergeYUpdates for the bisect pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLua64PCVwkQ1hpWdaf1Gm
This commit is contained in:
Gergő Törcsvári 2026-07-14 10:57:46 +02:00
commit fb2aa2759d
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 323 additions and 7 deletions

View file

@ -67,6 +67,7 @@ import {
type FollowTarget,
} from "@/wasm/collab/follow-user";
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
import { DOC_REVERTED_EVENT } from "@/wasm/collab/kicad-binding";
import {
createComments,
hasCommentsBridge,
@ -790,6 +791,9 @@ export function WasmTool({
// A collaborator updated library items that are PLACED in the open document
// (LIB_ITEM_UPDATED_EVENT) — placed copies keep the previous version, so warn.
const [libUpdate, setLibUpdate] = React.useState<string | null>(null);
// The backend rolled this document back to its last valid state
// (kicad-validity 0001 — DOC_REVERTED_EVENT from the collab binding).
const [docReverted, setDocReverted] = React.useState<string | null>(null);
// Eager whole-library idb→wasm load in flight (the ~tens-of-seconds fat-load on
// first chooser/editor open). Drives a full-cover overlay so the freeze reads as
// "loading, just slow" rather than a hang. Null when idle; `done/total` count the
@ -856,14 +860,23 @@ export function WasmTool({
`placed copies keep the previous version until updated from the library.`,
);
};
const onDocReverted = (e: Event) => {
const d = (e as CustomEvent<{ reason?: string; at?: string }>).detail;
setDocReverted(
`This document was rolled back to its last valid state — invalid content ` +
`was detected${d?.reason ? ` (${d.reason})` : ""}. Recent edits may have been undone.`,
);
};
window.addEventListener(LIB_BUSY_EVENT, onBusy);
window.addEventListener(LIB_ERROR_EVENT, onError);
window.addEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
window.addEventListener(DOC_REVERTED_EVENT, onDocReverted);
return () => {
clearTimeout(busyTimer);
window.removeEventListener(LIB_BUSY_EVENT, onBusy);
window.removeEventListener(LIB_ERROR_EVENT, onError);
window.removeEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
window.removeEventListener(DOC_REVERTED_EVENT, onDocReverted);
};
}, []);
@ -881,6 +894,13 @@ export function WasmTool({
return () => clearTimeout(t);
}, [libUpdate]);
// Auto-dismiss the doc-reverted toast (longest — the user should see it).
React.useEffect(() => {
if (!docReverted) return;
const t = setTimeout(() => setDocReverted(null), 15_000);
return () => clearTimeout(t);
}, [docReverted]);
// Full-library eager load overlay. The fat-load fires one loading:true/false
// pair PER library (222 on the full set), and between them the C++ side parses
// with the main thread blocked. Show immediately on `true`, and only hide after
@ -1647,6 +1667,18 @@ export function WasmTool({
</button>
)}
{/* Backend rolled this doc back to the last valid state (kicad-validity). */}
{docReverted && (
<button
data-testid="doc-reverted-toast"
className="absolute left-1/2 top-3 z-40 max-w-md -translate-x-1/2 rounded bg-orange-950/95 px-3 py-2 text-center text-xs text-orange-100 shadow-lg ring-1 ring-orange-500/40"
onClick={() => setDocReverted(null)}
title="Dismiss"
>
{docReverted}
</button>
)}
{!effectiveChromeHidden && (
<div className="absolute bottom-0 left-0 right-0 z-20">
<button

View file

@ -15,6 +15,9 @@ import {
wireItemUuids,
wireLibSymbols,
Y_KDOC_META,
Y_KDOC_REVERT_AT,
Y_KDOC_REVERT_NONCE,
Y_KDOC_REVERT_REASON,
Y_KDOC_SEED_NONCE,
ydocHasState,
ydocSexprVersion,
@ -44,6 +47,12 @@ import { clog, cwarn } from "./debug";
* fake editor bridge (kicad-binding.test.ts).
*/
/**
* Window event fired when the backend rolled this doc back to its last valid
* state (kicad-validity 0001 B3). detail: { reason?: string; at?: string }.
*/
export const DOC_REVERTED_EVENT = "pcbjam:doc-reverted";
/** The v2 per-item s-expr bridge (Stage C C++ contract), runtime-adapted. */
export interface KicadItemsBridge {
/** Full current model as an all-`added` ItemsWireDelta JSON. */
@ -192,6 +201,31 @@ export function bindKicadCollab(
};
items.observeDeep(observer);
// Validity-revert notice (kicad-validity 0001 B3): the backend stamps
// kdoc_meta.revertNonce when it rolls the doc back to the last valid state
// (the content itself arrives through the normal item sync above). Watched
// like seedNonce; surfaced as a window event for the shell's toast. The
// nonce is deduped so a reconnect replaying the same marker stays silent.
const revMeta = doc.getMap(Y_KDOC_META);
let lastRevertNonce = revMeta.get(Y_KDOC_REVERT_NONCE);
const onRevertMeta = () => {
const nonce = revMeta.get(Y_KDOC_REVERT_NONCE);
if (nonce === undefined || nonce === lastRevertNonce) return;
lastRevertNonce = nonce;
clog("doc reverted by backend:", revMeta.get(Y_KDOC_REVERT_REASON));
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(DOC_REVERTED_EVENT, {
detail: {
reason: revMeta.get(Y_KDOC_REVERT_REASON),
at: revMeta.get(Y_KDOC_REVERT_AT),
},
}),
);
}
};
revMeta.observe(onRevertMeta);
function seed(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void {
seeded = true; // open the UP gate; everything below runs synchronously
// `ydocHasState` (meta + layout + items), NOT `items.size`: a populated
@ -344,6 +378,7 @@ export function bindKicadCollab(
detachSeedArbitration?.();
detachSeedArbitration = undefined;
items.unobserveDeep(observer);
revMeta.unobserve(onRevertMeta);
},
items,
};