feat(collab): cross-app selection — eeschema symbol ⇄ pcbnew footprint ghost highlight (collab-presence 0006)

Selecting a symbol in eeschema ghost-highlights the linked footprint(s) in
every pcbnew tab of the project, and vice versa — across users AND one
user's own two tabs. Native KIWAY cross-probe is inert in WASM (one frame
per page); this rides the presence layer instead.

- cross-app.ts: project-wide awareness-only room (presenceRoomId), publishes
  full PresenceState at selection rate (cursor always null); peers() = other-
  TOOL clients incl. own user's other tabs; window.__pcbjamCrossApp test handle
- presence-kicad.ts: parseSelectionEmit (bare array | {uuids,fpPaths}),
  xselFromPeerState (pcbnew paths → symbol uuids; eeschema uuids verbatim),
  cross peers appended to the kicadCollabSetRemote snapshot as
  {id "<user>#x<client>", name "<user> · sch|pcb", xsel}
- C++ (zero fork changes): pcbnew emits {uuids, fpPaths} (FOOTPRINT::GetPath)
  and ghost-renders xsel via path-tail suffix scan; eeschema resolves xsel via
  ResolveItem gated to the CURRENT sheet (xsel arrives project-wide, unlike
  room-scoped selections); ghostStyle = alphas × xselAlphaScale (0.55, tuner-
  patchable); new exports kicadCollabGetSelectionFull / TestGetCrossMapped /
  TestSelectComponent (skips power symbols — PWR_FLAG has no footprint) +
  merged-image dispatch
- tests: presence suites extended (payload shape, ghost render pixel tests,
  13/13) + new two-tab tests/web/cross-probe.spec.ts (passing vs real
  partykit); eeschema pixel compares now target the #glcanvas-* GAL panel
  (the whole-window #canvas compare flaked on the auto-dismissing version
  infobar — also fixes the long-known presence-eeschema restore flake);
  cross-app + presence-kicad vitest suites

Spec: docs/features/collab-presence/0006 (closed repo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lg5jwWuhFH5dL8hEcBDuP2
This commit is contained in:
Gergő Törcsvári 2026-07-07 19:30:18 +02:00
commit 3186986a0a
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
14 changed files with 1276 additions and 48 deletions

@ -1 +1 @@
Subproject commit 5f5effa4e0d9cab0261a5e0f771c7c4cb31b427d
Subproject commit b68ee96a85b4793ebd15afab5a68f296406b53b5

View file

@ -58,6 +58,7 @@ import {
hasPresenceBridge,
type PresenceKicadWindow,
} from "@/wasm/collab/presence-kicad";
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
import {
createComments,
hasCommentsBridge,
@ -598,6 +599,9 @@ export function WasmTool({
const driftRef = React.useRef<{ stop(): void } | null>(null);
const presenceRef = React.useRef<PresenceHandle | null>(null);
const presenceBridgeRef = React.useRef<{ destroy(): void } | null>(null);
// Project-wide presence room (0006): joined once per session, survives
// eeschema sheet rebinds — the bridge re-reads it on every startPresence.
const crossAppRef = React.useRef<CrossAppHandle | null>(null);
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
// (miss 08B); eeschema routes per sheet through the manager instead.
@ -811,6 +815,8 @@ export function WasmTool({
mod: win.Module,
win: win as unknown as PresenceKicadWindow,
presence,
// Cross-app selection (0006): the project presence room, if joined.
crossApp: crossAppRef.current ?? undefined,
// Live world↔screen transform for the DOM comment layer (0005).
onViewport: setViewportState,
});
@ -964,6 +970,26 @@ export function WasmTool({
// divergence. Gated on a real collab session; re-targeted per active sheet below.
const { startDriftDetection } = await import("@/wasm/collab/drift-detect");
// Cross-app selection (0006): join the project-wide presence room BEFORE
// the per-file collab starts, so the first startPresence bind already
// routes xsel. Honors the same ?collab=0 opt-out as the room collab.
const collabOptOut =
new URLSearchParams(win.location.search).get("collab") === "0" ||
new URLSearchParams(win.location.search).get("collab") === "false";
if ((tool === "pcbnew" || tool === "eeschema") && !collabOptOut) {
crossAppRef.current =
(await startCrossAppPresence({
projectId,
provider: yjsProviderConfig(),
user: presenceUser(),
tool,
})) ?? null;
// Test/debug handle (mirrors __pcbjamComments): lets the e2e assert
// the project-room peer view without driving pixels.
(win as { __pcbjamCrossApp?: CrossAppHandle | null }).__pcbjamCrossApp =
crossAppRef.current;
}
if (tool === "eeschema") {
// Multi-room (subschema) collab: every .kicad_sch is its own warm room; the
// active sheet is bound, navigation re-routes it (C++ onSheetChanged hook).
@ -1042,6 +1068,8 @@ export function WasmTool({
presenceBridgeRef.current = null;
presenceRef.current?.destroy();
presenceRef.current = null;
crossAppRef.current?.destroy();
crossAppRef.current = null;
driftRef.current?.stop();
driftRef.current = null;
// Tears down every warm room's provider/doc (the only place providers are

View file

@ -0,0 +1,108 @@
import { afterEach, describe, expect, it } from "vitest";
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
import { resetPresenceColorClaims } from "./presence";
import { startCrossAppPresence, type CrossAppHandle } from "./cross-app";
/**
* Cross-app presence room unit tests (collab-presence 0006): real handles on
* the BroadcastChannel provider (node 18 has BroadcastChannel globally) stand
* in for an eeschema tab and a pcbnew tab of the same project.
*/
const settle = () => new Promise((r) => setTimeout(r, 60));
function user(id: string): PresenceUser {
return { id, name: id, color: colorForUser(id) };
}
let handles: CrossAppHandle[] = [];
let projectSeq = 0;
async function join(projectId: string, userId: string, tool: string): Promise<CrossAppHandle> {
const h = await startCrossAppPresence({
projectId,
provider: { kind: "broadcastchannel", settleMs: 10 },
user: user(userId),
tool,
});
if (!h) throw new Error("cross-app handle expected on the BC provider");
handles.push(h);
return h;
}
afterEach(() => {
for (const h of handles) h.destroy();
handles = [];
resetPresenceColorClaims();
});
describe("startCrossAppPresence", () => {
it("returns undefined for the none provider", async () => {
const h = await startCrossAppPresence({
projectId: "P",
provider: { kind: "none" },
user: user("alice"),
tool: "pcbnew",
});
expect(h).toBeUndefined();
});
it("peers see only OTHER-tool clients, including the own user's other tab", async () => {
const project = `xapp-${projectSeq++}`;
const sch = await join(project, "alice", "eeschema");
const pcb = await join(project, "alice", "pcbnew"); // same user, other tool
const pcb2 = await join(project, "bob", "pcbnew");
await settle();
// The eeschema tab sees both pcbnew tabs (own user's included).
const schPeers = sch.peers();
expect(schPeers.map((p) => p.state.user.id).sort()).toEqual(["alice", "bob"]);
expect(schPeers.every((p) => p.state.tool === "pcbnew")).toBe(true);
// A pcbnew tab sees only the eeschema tab — the other pcbnew tab is
// same-tool (per-file rooms own that relationship).
expect(pcb.peers().map((p) => p.state.user.id)).toEqual(["alice"]);
expect(pcb2.peers().map((p) => p.state.user.id)).toEqual(["alice"]);
});
it("propagates selection + selectionPaths and clears on destroy", async () => {
const project = `xapp-${projectSeq++}`;
const sch = await join(project, "alice", "eeschema");
const pcb = await join(project, "bob", "pcbnew");
await settle();
pcb.setSelection(["fp-uuid"], ["/sheet/sym-1"]);
await settle();
const seen = sch.peers().find((p) => p.state.user.id === "bob");
expect(seen?.state.selection).toEqual(["fp-uuid"]);
expect(seen?.state.selectionPaths).toEqual(["/sheet/sym-1"]);
// Clearing the paths drops the optional field from the published state.
pcb.setSelection([], undefined);
await settle();
const cleared = sch.peers().find((p) => p.state.user.id === "bob");
expect(cleared?.state.selection).toEqual([]);
expect(cleared?.state.selectionPaths).toBeUndefined();
pcb.destroy();
await settle();
expect(sch.peers()).toEqual([]);
});
it("different projects do not share the presence room", async () => {
const a = await join(`xapp-${projectSeq++}`, "alice", "eeschema");
await join(`xapp-${projectSeq++}`, "bob", "pcbnew");
await settle();
expect(a.peers()).toEqual([]);
});
it("notifies subscribers on peer changes", async () => {
const project = `xapp-${projectSeq++}`;
const sch = await join(project, "alice", "eeschema");
let fired = 0;
sch.subscribe(() => fired++);
await join(project, "bob", "pcbnew");
await settle();
expect(fired).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,141 @@
import * as Y from "yjs";
import {
presenceRoomId,
presenceStateSchema,
type PresenceState,
type PresenceUser,
} from "@pcbjam/shared";
import { connectProvider, type ProviderConfig, type YjsProvider } from "./provider";
import { claimedPresenceColor } from "./presence";
import { clog } from "./debug";
/**
* Project-wide presence room (collab-presence 0006): one awareness-only room
* per PROJECT, joined by every collab-capable editor alongside its per-file
* room(s). Per-file rooms keep owning same-document presence (cursors, roster,
* selection outlines); this room exists ONLY so cross-app features can see
* peers in the project's OTHER documents an eeschema tab learning what a
* pcbnew tab has selected, and vice versa.
*
* The local client publishes a full `PresenceState` (cursor always null
* world coordinates are meaningless across documents) and updates only its
* `selection`/`selectionPaths`, so traffic is selection-rate, not cursor-rate.
* The Y.Doc is a required transport sidecar that stays empty; backends skip
* persisting `~presence` rooms.
*/
export interface CrossAppPeer {
clientId: number;
state: PresenceState;
}
export interface CrossAppHandle {
/** Publish this tab's selection (uuids + pcbnew footprint paths). */
setSelection(uuids: string[], paths?: string[]): void;
/**
* Peers in a DIFFERENT tool, one entry per awareness client. Unlike the
* room roster this INCLUDES the own user's other tabs one person with
* the schematic and the board open gets classic cross-probing.
*/
peers(): CrossAppPeer[];
/** Fires on every awareness change in the project room. Returns unsubscribe. */
subscribe(cb: () => void): () => void;
destroy(): void;
}
export async function startCrossAppPresence(opts: {
projectId: string;
provider: ProviderConfig;
user: PresenceUser;
tool: string;
}): Promise<CrossAppHandle | undefined> {
if (opts.provider.kind === "none") return undefined;
const room = presenceRoomId(opts.projectId);
const doc = new Y.Doc();
let provider: YjsProvider;
try {
provider = await connectProvider(doc, opts.provider, { room });
} catch (err) {
clog("cross-app: provider connect failed —", String(err));
doc.destroy();
return undefined;
}
const awareness = provider.awareness;
if (!awareness) {
provider.destroy();
doc.destroy();
return undefined;
}
let selection: string[] = [];
let selectionPaths: string[] | undefined;
const publish = () => {
const state: PresenceState = {
// Reuse the bound room's claimed color so one user is one color
// everywhere (same rule as the eeschema skeleton states).
user: { ...opts.user, color: claimedPresenceColor(opts.user.id) ?? opts.user.color },
tool: opts.tool,
cursor: null,
selection,
...(selectionPaths?.length ? { selectionPaths } : {}),
updatedAt: Date.now(),
};
awareness.setLocalState(state);
};
publish();
clog("cross-app: joined project presence room", room, "as", opts.tool);
const subscribers = new Set<() => void>();
const onChange = () => {
for (const cb of subscribers) cb();
};
awareness.on("change", onChange);
// Fast removal on tab close (same rationale as presence.ts).
const onPageHide = () => awareness.setLocalState(null);
if (typeof window !== "undefined") {
window.addEventListener("pagehide", onPageHide);
}
let destroyed = false;
return {
setSelection(uuids, paths) {
selection = uuids;
selectionPaths = paths;
publish();
},
peers() {
const out: CrossAppPeer[] = [];
for (const [clientId, raw] of awareness.getStates()) {
if (clientId === awareness.clientID) continue;
const parsed = presenceStateSchema.safeParse(raw);
if (!parsed.success) continue;
// Same-tool peers are the per-file rooms' business (and may not even
// share a document with us) — cross-app only maps across editors.
if (parsed.data.tool === opts.tool) continue;
out.push({ clientId, state: parsed.data });
}
return out.sort((a, b) => a.clientId - b.clientId);
},
subscribe(cb) {
subscribers.add(cb);
return () => subscribers.delete(cb);
},
destroy() {
if (destroyed) return;
destroyed = true;
if (typeof window !== "undefined") {
window.removeEventListener("pagehide", onPageHide);
}
awareness.off("change", onChange);
subscribers.clear();
awareness.setLocalState(null);
provider.destroy();
doc.destroy();
},
};
}

View file

@ -1,7 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import { colorForUser } from "@pcbjam/shared";
import { bindKicadPresence, hasPresenceBridge, type PresenceKicadWindow } from "./presence-kicad";
import { colorForUser, type PresenceState } from "@pcbjam/shared";
import {
bindKicadPresence,
hasPresenceBridge,
parseSelectionEmit,
xselFromPeerState,
type PresenceKicadWindow,
} from "./presence-kicad";
import type { PresenceHandle, PresencePeer } from "./presence";
import type { CrossAppHandle, CrossAppPeer } from "./cross-app";
/**
* presence-kicad bridge unit tests (collab-presence 0002): a fake Module +
@ -123,3 +130,120 @@ describe("bindKicadPresence", () => {
expect(hasPresenceBridge(undefined)).toBe(false);
});
});
// ── cross-app selection (0006) ───────────────────────────────────────────────
function crossState(tool: string, over: Partial<PresenceState> = {}): PresenceState {
return {
user: { id: "bob", name: "bob", color: colorForUser("bob") },
tool,
cursor: null,
selection: [],
updatedAt: 1,
...over,
};
}
function stubCrossApp(initial: CrossAppPeer[] = []) {
let peers = initial;
const subscribers = new Set<() => void>();
const handle: CrossAppHandle & { firePeers(p: CrossAppPeer[]): void } = {
setSelection: vi.fn(),
peers: () => peers,
subscribe(cb) {
subscribers.add(cb);
return () => subscribers.delete(cb);
},
destroy: vi.fn(),
firePeers(p) {
peers = p;
for (const cb of subscribers) cb();
},
};
return handle;
}
describe("parseSelectionEmit", () => {
it("handles the bare array (eeschema / pre-0006) and object shapes", () => {
expect(parseSelectionEmit('["u1","u2"]')).toEqual({ uuids: ["u1", "u2"] });
expect(parseSelectionEmit('{"uuids":["u1"],"fpPaths":["/p1"]}')).toEqual({
uuids: ["u1"],
fpPaths: ["/p1"],
});
expect(parseSelectionEmit('{"uuids":["u1"]}')).toEqual({ uuids: ["u1"] });
});
it("drops non-string entries and returns null on malformed payloads", () => {
expect(parseSelectionEmit('["u1",42]')).toEqual({ uuids: ["u1"] });
expect(parseSelectionEmit("not json")).toBeNull();
expect(parseSelectionEmit("42")).toBeNull();
});
});
describe("xselFromPeerState", () => {
it("maps a pcbnew peer's footprint paths to symbol uuids", () => {
expect(
xselFromPeerState(crossState("pcbnew", { selectionPaths: ["/sheet-1/sym-1", "/sym-2"] })),
).toEqual(["sym-1", "sym-2"]);
// Board selection uuids alone (no paths) map to nothing in eeschema.
expect(xselFromPeerState(crossState("pcbnew", { selection: ["board-uuid"] }))).toEqual([]);
});
it("passes an eeschema peer's symbol uuids through, and skips other tools", () => {
expect(xselFromPeerState(crossState("eeschema", { selection: ["sym-1"] }))).toEqual(["sym-1"]);
expect(xselFromPeerState(crossState("pl_editor", { selection: ["u1"] }))).toEqual([]);
});
});
describe("bindKicadPresence × crossApp", () => {
it("forwards selection emits (uuids + fpPaths) into the cross-app room", () => {
const mod = fakeModule();
const win: PresenceKicadWindow = {};
const presence = stubPresence();
const crossApp = stubCrossApp();
bindKicadPresence({ mod, win, presence, crossApp });
win.kicadCollab!.onSelection!('{"uuids":["u1"],"fpPaths":["/p1"]}');
expect(presence.setSelection).toHaveBeenCalledWith(["u1"]);
expect(crossApp.setSelection).toHaveBeenCalledWith(["u1"], ["/p1"]);
win.kicadCollab!.onSelection!('["u2"]');
expect(crossApp.setSelection).toHaveBeenCalledWith(["u2"], undefined);
});
it("seeds cross-app from kicadCollabGetSelectionFull when present", () => {
const mod = {
...fakeModule(),
kicadCollabGetSelectionFull: vi.fn(() => '{"uuids":["pre"],"fpPaths":["/pp"]}'),
};
const crossApp = stubCrossApp();
bindKicadPresence({ mod, win: {}, presence: stubPresence(), crossApp });
expect(crossApp.setSelection).toHaveBeenCalledWith(["pre"], ["/pp"]);
});
it("appends cross-app peers to the remote snapshot as xsel ghost entries", async () => {
const mod = fakeModule();
const presence = stubPresence();
const crossApp = stubCrossApp();
bindKicadPresence({ mod, win: {}, presence, crossApp });
crossApp.firePeers([
{ clientId: 7, state: crossState("eeschema", { selection: ["sym-1"] }) },
// Empty mapped selection → no snapshot entry.
{ clientId: 8, state: crossState("pcbnew", { selection: ["board-uuid"] }) },
]);
await new Promise((r) => setTimeout(r, 60));
const snapshot = JSON.parse(mod.kicadCollabSetRemote.mock.calls.at(-1)![0]);
expect(snapshot.peers).toEqual([
{
id: "bob#x7",
name: "bob · sch",
color: colorForUser("bob"),
cursor: null,
selection: [],
xsel: ["sym-1"],
},
]);
});
});

View file

@ -1,5 +1,7 @@
import { symbolUuidFromFootprintPath } from "@pcbjam/shared";
import { clog } from "./debug";
import type { PresenceHandle, PresencePeer } from "./presence";
import type { CrossAppHandle } from "./cross-app";
/**
* Wire the C++ presence bridge (collab-presence 0002) to the awareness layer:
@ -23,6 +25,9 @@ export interface PresenceKicadModule {
kicadCollabSetRemote(json: string): void;
kicadCollabGetViewport(): string;
kicadCollabGetSelection(): string;
/** 0006 (pcbnew builds): `{uuids, fpPaths}` uuids plus the selected
* footprints' schematic paths. Absent on older wasm. */
kicadCollabGetSelectionFull?(): string;
}
export interface PresenceKicadWindow {
@ -53,24 +58,78 @@ export function hasPresenceBridge(mod: unknown): mod is PresenceKicadModule {
const PUSH_THROTTLE_MS = 30;
/**
* Parse a C++ selection emit (0006): pcbnew emits `{uuids, fpPaths}` (paths =
* the selected footprints' `GetPath()` strings), eeschema and older builds a
* bare uuid array. Returns null on a malformed payload (callers then keep the
* last published selection, the pre-0006 behavior).
*/
export function parseSelectionEmit(
json: string,
): { uuids: string[]; fpPaths?: string[] } | null {
try {
const v: unknown = JSON.parse(json);
if (Array.isArray(v)) {
return { uuids: v.filter((u): u is string => typeof u === "string") };
}
if (v && typeof v === "object") {
const o = v as { uuids?: unknown; fpPaths?: unknown };
return {
uuids: Array.isArray(o.uuids)
? o.uuids.filter((u): u is string => typeof u === "string")
: [],
...(Array.isArray(o.fpPaths)
? { fpPaths: o.fpPaths.filter((p): p is string => typeof p === "string") }
: {}),
};
}
} catch {
/* malformed */
}
return null;
}
/**
* The cross-app highlight targets a peer's state maps to in THIS editor
* (0006): a pcbnew peer's footprint paths become symbol uuids (eeschema
* resolves them directly); an eeschema peer's selection uuids ARE the symbol
* uuids (pcbnew suffix-matches them against footprint paths). Tools without
* a counterpart map to nothing.
*/
export function xselFromPeerState(state: {
tool: string;
selection: string[];
selectionPaths?: string[];
}): string[] {
if (state.tool === "pcbnew") {
return (state.selectionPaths ?? [])
.map(symbolUuidFromFootprintPath)
.filter((u): u is string => u !== null);
}
if (state.tool === "eeschema") return state.selection;
return [];
}
const TOOL_TAG: Record<string, string> = { pcbnew: "pcb", eeschema: "sch" };
export function bindKicadPresence(opts: {
mod: PresenceKicadModule;
win: PresenceKicadWindow;
presence: PresenceHandle;
/** 0006: the project presence room — cross-app selection in/out. */
crossApp?: CrossAppHandle;
onViewport?: (vp: ViewportState) => void;
}): { destroy(): void } {
const { mod, win, presence } = opts;
const { mod, win, presence, crossApp } = opts;
// C++ → awareness ------------------------------------------------------------
win.kicadCollab = {
...win.kicadCollab,
onSelection: (uuidsJson) => {
try {
const uuids = JSON.parse(uuidsJson) as string[];
presence.setSelection(Array.isArray(uuids) ? uuids : []);
} catch {
/* malformed emit — keep the last published selection */
}
const parsed = parseSelectionEmit(uuidsJson);
if (!parsed) return; // malformed emit — keep the last published selection
presence.setSelection(parsed.uuids);
crossApp?.setSelection(parsed.uuids, parsed.fpPaths);
},
onCursor: (x, y, active) => {
presence.setCursor(active ? { x, y } : null);
@ -80,10 +139,16 @@ export function bindKicadPresence(opts: {
},
};
// Seed: the tab may attach with a selection already made (e.g. rebind).
// Seed: the tab may attach with a selection already made (e.g. rebind). The
// Full variant (pcbnew 0006) also carries footprint paths for cross-app.
try {
const seed = JSON.parse(mod.kicadCollabGetSelection() || "[]") as string[];
if (Array.isArray(seed) && seed.length) presence.setSelection(seed);
const seed = parseSelectionEmit(
(mod.kicadCollabGetSelectionFull?.() ?? mod.kicadCollabGetSelection()) || "[]",
);
if (seed?.uuids.length) {
presence.setSelection(seed.uuids);
crossApp?.setSelection(seed.uuids, seed.fpPaths);
}
} catch {
/* bridge present but frame not up yet — the first emit will seed */
}
@ -91,7 +156,16 @@ export function bindKicadPresence(opts: {
// awareness → C++ ------------------------------------------------------------
const pushRemote = () => {
const peers = presence.peers();
const snapshot = {
const snapshot: {
peers: Array<{
id: string;
name: string;
color: string;
cursor: { x: number; y: number } | null;
selection: string[];
xsel?: string[];
}>;
} = {
peers: peers.map((p: PresencePeer) => ({
id: p.user.id,
name: p.user.name,
@ -100,6 +174,21 @@ export function bindKicadPresence(opts: {
selection: p.selection,
})),
};
// Cross-app peers (0006): rendered as ghost outlines on the mapped items.
// One entry per awareness CLIENT (own other tabs included — that's the
// single-user cross-probe), tagged with the source editor.
for (const p of crossApp?.peers() ?? []) {
const xsel = xselFromPeerState(p.state);
if (!xsel.length) continue;
snapshot.peers.push({
id: `${p.state.user.id}#x${p.clientId}`,
name: `${p.state.user.name} · ${TOOL_TAG[p.state.tool] ?? p.state.tool}`,
color: p.state.user.color,
cursor: null,
selection: [],
xsel,
});
}
mod.kicadCollabSetRemote(JSON.stringify(snapshot));
};
@ -113,6 +202,7 @@ export function bindKicadPresence(opts: {
};
const unsubscribe = presence.subscribe(schedulePush);
const unsubscribeCross = crossApp?.subscribe(schedulePush);
mod.kicadCollabPresenceStart();
pushRemote();
@ -121,6 +211,7 @@ export function bindKicadPresence(opts: {
return {
destroy() {
unsubscribe();
unsubscribeCross?.();
if (pushTimer) clearTimeout(pushTimer);
pushTimer = undefined;
if (win.kicadCollab) {

View file

@ -67,6 +67,12 @@ export function resetPresenceColorClaims(): void {
g_claims.clear();
}
/** The session's claimed color for a user, if any sibling rooms (skeletons,
* the 0006 project presence room) reuse it so one user is one color everywhere. */
export function claimedPresenceColor(userId: string): string | undefined {
return g_claims.get(userId);
}
/** Least-used palette color among the OTHER users in `states` the lowest
* free slot while the room is smaller than the palette, fair reuse after. */
function lowestFreeColor(