diff --git a/SOURCE.txt b/SOURCE.txt
deleted file mode 100644
index 9012317..0000000
--- a/SOURCE.txt
+++ /dev/null
@@ -1,81 +0,0 @@
-PCBJam — self-hosted fork (CMMS embed)
-======================================
-
-This repository is a self-hosted fork of PCBJam (browser-based KiCad compiled to
-WebAssembly), maintained for embedding the editor in the tas-tech.net CMMS
-(TT-DB) at /vendor/pcbjam/.
-
-Upstream
---------
- https://github.com/emergence-engineering/pcbjam
- tracked here as git remote `upstream`.
-
- Forked at upstream commit:
- 766e2f409fa8b098555226479742ec694709ea48 (branch main, 2026-09-01)
-
-Branches
---------
- main pristine mirror of upstream @ 766e2f4 (no local changes)
- build/host-networking docker/docker-compose.yml: host networking on the build
- host (its dockerd runs bridge:none + iptables:false for
- a VPN killswitch, so the image build + compile container
- use the host network namespace). 1 commit (28c545f).
- cmms-embed build/host-networking + sub-path mount support so the
- standalone editor serves under /vendor/pcbjam/ with no
- change to KiCad/wxWidgets or @pcbjam/shared. See the
- commit message for the file-by-file breakdown. A pure
- no-op when built without `--base` (root deploy).
-
-Submodules
-----------
-Pinned by the superproject gitlinks (nothing in scripts/ overrides them —
-`KICAD_COMMIT` in scripts/common/versions.sh is unused/informational):
-
- kicad 56678639eea1b3c0358f384fa47ef7fab78bd09b
- upstream https://github.com/PCBJam/kicad-source-mirror (branch wasm-port)
- wxwidgets 4e6cc5a441e46ac07b242caa3adacfd8e7c7ec50
- upstream https://github.com/PCBJam/wxWidgets (branch wasm-port)
- web/pcbjam-shared a4984c626d847bd07ce66148d6ec96fde8ee8e42
- upstream https://github.com/PCBJam/pcbjam-shared (branch main)
-
-.gitmodules still points at PCBJam's GitHub forks — these are NOT mirrored into
-Forgejo. A checkout needs `git submodule update --init --recursive` against GitHub.
-
-Toolchain / dependency pins (scripts/common/versions.sh)
--------------------------------------------------------
- Emscripten SDK 6.0.6 (installed from emsdk inside docker/Dockerfile;
- base image ubuntu:22.04)
- KiCad 8.99
- OpenCASCADE 7.8.0
- wxWidgets 3.3.1
- Boost 1.84.0
- Protobuf 3.21.12
- Python 3.11.5
- ngspice 46
- FreeType 2.13.2
- HarfBuzz 8.3.0
- Cairo 1.18.0
- Pixman 0.42.2
- Zstd 1.5.5
- GLM 0.9.9.8
- (every download in versions.sh also carries a SHA256 pin)
-
-Build
------
- KiCad WASM: ./docker/build.sh kicad_editor -j 4 --build-deps (debug)
- ./docker/build.sh kicad_editor -j 4 --full --release (ship)
- Editor UI: cd web && pnpm install --frozen-lockfile
- pnpm --filter @pcbjam/standalone exec vite build --base=/vendor/pcbjam/
- (env: web/standalone/.env.production on the cmms-embed branch)
-
-License
--------
-KiCad and wxWidgets are GPLv3. PCBJam's own code carries its upstream licenses
-(see LICENSE and the per-package headers; @pcbjam/shared is MIT). This fork is
-distributed under the same terms.
-
-The corresponding source for any deployed WASM build IS this repository at the
-commit the build was made from, together with the submodule revisions its
-gitlinks pin (listed above). Repository:
-
- https://repo.tas-tech.net/AI_Assistant/pcbjam
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 83a9f70..d17d33f 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -3,9 +3,6 @@ services:
build:
context: ..
dockerfile: docker/Dockerfile
- # Build host runs dockerd with bridge:none + iptables:false (PIA killswitch,
- # no Docker NAT). Image-build RUN steps (emsdk download) use the host netns.
- network: host
args:
# Single source of truth: scripts/common/versions.sh, exported into the env by the scripts
# that drive compose (docker/build.sh, docker/shell.sh). No default -> the Dockerfile fails
@@ -13,10 +10,6 @@ services:
EMSCRIPTEN_VERSION: ${EMSCRIPTEN_VERSION:?source scripts/common/versions.sh before docker compose}
# Container name is auto-generated with project prefix (set in build.sh)
- # The compile container shares the host network namespace so it inherits the
- # PIA killswitch directly — the build host's dockerd has no bridge/NAT.
- network_mode: host
-
# Resource limits. Defaults are sized for a dev Mac (Docker Desktop VM).
# CI overrides via env: the 32-core Hetzner runner sets KICAD_DOCKER_CPUS=$(nproc)
# and KICAD_DOCKER_MEM=110G — with the 10-CPU default, run 27226030304 compiled
diff --git a/web/standalone/.env.production b/web/standalone/.env.production
deleted file mode 100644
index 118ef4d..0000000
--- a/web/standalone/.env.production
+++ /dev/null
@@ -1,33 +0,0 @@
-# CMMS embed (cmms-embed branch). Consumed by `vite build` (mode=production).
-# Pair with `vite build --base=/vendor/pcbjam/`. All values are public (they ship
-# in the bundle) — no secrets here.
-
-# The PHP adapter. Relative so it resolves against the CMMS origin. The adapter
-# handles PATH_INFO, so /api/scopes/... and /api/me land as
-# /pcb-project-api.php/api/scopes/... and /pcb-project-api.php/api/me.
-VITE_API_BASE_URL=/pcb-project-api.php
-
-# Projects come from the REST adapter above; file bytes fetched + saves PUT back.
-VITE_PROJECT_SOURCE=remote
-VITE_DOC_SOURCE=api
-
-# No library backend for v1. `static` (not `off`): `off` leaves `libsSource` null so boot.ts
-# never calls installLibsProvider → `window.kicadLibs` is never installed → any C++ path that
-# resolves a symbol/footprint through the libs bridge on schematic open (park site K1,
-# sch_io_pcbjam_lib) calls a method on undefined → throws inside a suspending import →
-# SuspendError → abort. `static` installs the bridge backed by 2 built-in example symbols; it
-# answers "not found" gracefully for everything else. No backend, no manifest, self-contained.
-VITE_LIBS_SOURCE=static
-
-# Flat WASM layout, served static by Apache under the mount (no per-tool subdir,
-# VITE_WASM_MANIFEST intentionally unset). Files: kicad_editor.{js,wasm}, wx.js,
-# wx-dom.js, images.tar.gz.
-VITE_WASM_ROOT=/vendor/pcbjam/wasm
-
-# Cross-tab only; no collab server.
-VITE_YJS_PROVIDER=broadcastchannel
-
-# VITE_APP_URL deliberately UNSET — with it set, every non-editor route would
-# redirect away from the mount (lib/redirect.ts).
-# VITE_USER / VITE_SCOPE deliberately UNSET — identity comes from
-# /pcb-project-api.php/api/me, scope from the URL path segment after the mount.
diff --git a/web/standalone/src/App.tsx b/web/standalone/src/App.tsx
index 49b27a9..2d550ea 100644
--- a/web/standalone/src/App.tsx
+++ b/web/standalone/src/App.tsx
@@ -1,6 +1,5 @@
import { useEffect } from "react";
import { Route, Routes, useLocation } from "react-router-dom";
-import { CmmsAiWidget } from "@/components/CmmsAiWidget";
import { VersionBadge } from "@/components/VersionBadge";
import { useChromeHidden } from "@/lib/chrome-visibility";
import { APP_URL } from "@/lib/config";
@@ -35,9 +34,6 @@ export default function App() {
{/* Version + source link, bottom-right on every route (home + editor).
Keys off the Figma-like hide-UI toggle (hidden is the mobile default). */}
{!chromeHidden && }
- {/* No-op (fetches config, renders nothing) unless the CMMS embed's
- ai_assistant module is on for this tenant — see CmmsAiWidget. */}
-
>
);
}
diff --git a/web/standalone/src/components/CmmsAiWidget.tsx b/web/standalone/src/components/CmmsAiWidget.tsx
deleted file mode 100644
index 3aabf5f..0000000
--- a/web/standalone/src/components/CmmsAiWidget.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-// cmms-embed / PCBJam Part 2: floating AI-assistant launcher + panel, for the
-// CMMS embed only. Inert (renders nothing) unless `/api/cmms-config` answers
-// `aiEnabled: true` — a plain pcbjam.io deploy or a CMMS tenant with the
-// module off never fetches or renders anything here beyond the one config
-// call. Visual language (position, colors, toggle/panel shape) mirrors
-// `render_ai_assistant_editor_embed()` (TT-DB `src/ai_assistant_widget.php`)
-// so it reads as the same widget as every other embedded editor; the
-// draggable/resizable geometry system that PHP version has is CMMS-side
-// static JS this bundle can't import, so this is fixed-position (bottom
-// right) — a reasonable v1 given the panel is small relative to the board
-// canvas.
-import * as React from "react";
-import { fetchCmmsConfig, type CmmsConfig } from "@/lib/cmms-config";
-import { getActiveFilePath, installAiBridge } from "@/wasm/collab/ai-bridge";
-import { setTheme } from "@/lib/theme";
-
-export function CmmsAiWidget(): React.ReactElement | null {
- const [config, setConfig] = React.useState(null);
- const [open, setOpen] = React.useState(false);
-
- React.useEffect(() => {
- let cancelled = false;
- // fetchCmmsConfig() never rejects (its own internal .catch() falls back
- // to NO_WIDGET) — this .catch() guards the callback body itself instead
- // (setConfig/installAiBridge/setTheme), so a mistake here becomes a
- // logged no-op, not a silent unhandled rejection with nothing to say why
- // the widget didn't appear.
- fetchCmmsConfig()
- .then((c) => {
- if (cancelled) return;
- setConfig(c);
- if (c.aiEnabled) installAiBridge();
- // Belt-and-braces alongside the `?theme=` hand-off (lib/theme.ts) —
- // that param already wins whenever edit.php sets it, so this only
- // matters if it's ever absent. Skip if the URL param already ran;
- // setTheme is otherwise idempotent (persists + toggles `.dark`).
- if (c.dark && !new URLSearchParams(window.location.search).get("theme")) {
- setTheme("dark");
- }
- })
- .catch((err: unknown) => {
- console.error("[cmms-ai-widget] boot failed:", err);
- });
- return () => {
- cancelled = true;
- };
- }, []);
-
- if (!config?.aiEnabled) return null;
-
- // Computed at render time (not memoized) so re-opening the panel after
- // switching sheets sends the file that's actually open now — a context
- // hint only; get-buffer carries the real content regardless.
- const params = new URLSearchParams({
- editor: "kicad",
- editor_entity_type: config.entityType,
- editor_entity_id: config.entityId,
- editor_filename: getActiveFilePath() ?? "",
- page_context: "PCBJam (KiCad editor)",
- });
- const src = `/modules/ai_assistant/widget-frame.php?${params.toString()}`;
-
- return (
-
-
- {open && (
-
-
- AI Assistant
-
-
-
-
- )}
-
- );
-}
diff --git a/web/standalone/src/components/NewFileDialog.tsx b/web/standalone/src/components/NewFileDialog.tsx
index 230bf49..651b7ef 100644
--- a/web/standalone/src/components/NewFileDialog.tsx
+++ b/web/standalone/src/components/NewFileDialog.tsx
@@ -7,7 +7,6 @@ import {
} from "@pcbjam/shared";
import { Loader2 } from "lucide-react";
import { uploadFileBytes } from "@/lib/api";
-import { hardNav } from "@/lib/base-path";
import { currentScope } from "@/lib/config";
import { localProjectStore } from "@/lib/project-source";
import { useLocalProjects } from "@/lib/api";
@@ -125,7 +124,7 @@ export function NewFileDialog({
// A home-created project is browser-local (@local scope); an in-project new
// file keeps the current scope. The tool is inferred from the file's ext.
const scope = homeMode ? LOCAL_SCOPE : currentScope();
- hardNav(projectPath(scope, slug, finalName));
+ window.location.assign(projectPath(scope, slug, finalName));
} catch (e) {
setBusy(false);
setError(e instanceof Error ? e.message : String(e));
diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx
index d1e4af0..78b57b8 100644
--- a/web/standalone/src/components/WasmTool.tsx
+++ b/web/standalone/src/components/WasmTool.tsx
@@ -20,7 +20,6 @@ import {
yjsProviderConfig,
type DocSource,
} from "@/lib/config";
-import { stripBase } from "@/lib/base-path";
import { redirectTargetFor } from "@/lib/redirect";
import { loadSessionIdentity, seedSessionIdentity } from "@/lib/session-identity";
import { useThemeValue } from "@/lib/theme";
@@ -47,7 +46,6 @@ import { startFilesWatch, type FilesWatchHandle } from "@/wasm/collab/files-watc
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
import { errorMessage, isTerminalError } from "@/wasm/terminal-error";
import { registerSaveHook, type SaveBlock, type SaveBytes } from "@/wasm/save-flow";
-import { registerActiveCollabDoc } from "@/wasm/collab/ai-bridge";
import type {
KicadCollabHandle,
KicadDocSession,
@@ -421,7 +419,6 @@ export function WasmTool({
localSelectionRef.current?.destroy();
localSelectionRef.current = null;
setPanelDoc(null);
- registerActiveCollabDoc(null);
commentsRef.current?.destroy();
commentsRef.current = null;
followRef.current?.destroy();
@@ -632,10 +629,7 @@ export function WasmTool({
// non-editor surfaces: on a backed deploy (APP_URL set) they belong to the
// management app, so quit goes straight there (one hop instead of letting
// App.tsx's 0006 redirect bounce it).
- // stripBase: segments are relative to the mount, so segments[1] is
- // "projects"/"libs" even under /vendor/pcbjam/. exitPath stays app-absolute
- // (no mount prefix) — installQuitHook's hardNav() re-adds it.
- const segments = stripBase(win.location.pathname).split("/").filter(Boolean);
+ const segments = win.location.pathname.split("/").filter(Boolean);
const exitPath =
segments[1] === "libs" ? "/" : projectPath(currentScope(), slug);
const removeQuitHook = installQuitHook(win, {
@@ -1221,7 +1215,6 @@ export function WasmTool({
startPresence(activeRoom?.provider, activeRoom?.sheetPath, activeRoom?.doc);
startComments(activeRoom?.doc);
setPanelDoc(activeRoom?.doc ?? null);
- registerActiveCollabDoc(activeRoom?.doc ?? null, activeRoom?.sheetPath);
if (activeRoom && !readOnly) {
driftRef.current = startDriftDetection({
doc: activeRoom.doc,
@@ -1276,7 +1269,6 @@ export function WasmTool({
startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
startComments(collabHandle?.doc);
setPanelDoc(collabHandle?.doc ?? null);
- registerActiveCollabDoc(collabHandle?.doc ?? null, targetPath);
// Live sibling mirror (project-sync 0001 bug 3): keep the schematic
// files a PCB session syncs from fresh in MEMFS, instead of the
// one-shot boot snapshot. Same opt-out as the room collab; read-only
diff --git a/web/standalone/src/components/wasm-tool/quit-hook.ts b/web/standalone/src/components/wasm-tool/quit-hook.ts
index 8cf77bf..dfd3f78 100644
--- a/web/standalone/src/components/wasm-tool/quit-hook.ts
+++ b/web/standalone/src/components/wasm-tool/quit-hook.ts
@@ -6,8 +6,6 @@
// the page itself unloads (app.cpp UnloadCallback), so the dispatcher latches
// off as soon as any unload/navigation is under way.
-import { hardNav } from "@/lib/base-path";
-
let activeQuitHook: (() => void) | undefined;
let quitHandled = false;
@@ -95,9 +93,7 @@ export function installQuitHook(
// wasm stack has unwound.
setTimeout(() => {
opts.log(`[quit] editor closed — going to ${opts.exitUrl}`);
- // hardNav: re-add the CMMS mount prefix to the app-absolute exit path
- // (a full APP_URL redirect target passes through untouched).
- hardNav(opts.exitUrl, win);
+ win.location.assign(opts.exitUrl);
}, 0);
};
diff --git a/web/standalone/src/components/wasm-tool/tool-navigation.ts b/web/standalone/src/components/wasm-tool/tool-navigation.ts
index 2b9ea4e..77aeb39 100644
--- a/web/standalone/src/components/wasm-tool/tool-navigation.ts
+++ b/web/standalone/src/components/wasm-tool/tool-navigation.ts
@@ -7,7 +7,6 @@ import {
toolSchema,
type Tool,
} from "@pcbjam/shared";
-import { hardNav } from "@/lib/base-path";
import { currentScope } from "@/lib/config";
import { defaultFileName, newFileTemplate, withExtension } from "@/lib/new-file";
import { memfsProjectDir } from "@/wasm/constants";
@@ -176,7 +175,7 @@ export function installToolNavigationHook(
await createFile(relPath, bytes);
opts.log(`[nav] created missing ${nextTool} file ${relPath} -> ${url}`);
markDeliberateNavigation();
- hardNav(url, win);
+ win.location.assign(url);
} catch (e) {
pendingCreate = null;
opts.log(
@@ -197,7 +196,7 @@ export function installToolNavigationHook(
opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`);
markDeliberateNavigation();
- hardNav(url, win);
+ win.location.assign(url);
return true;
};
diff --git a/web/standalone/src/lib/base-path.ts b/web/standalone/src/lib/base-path.ts
deleted file mode 100644
index bf47041..0000000
--- a/web/standalone/src/lib/base-path.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-/**
- * Sub-path mount support (cmms-embed fork).
- *
- * The CMMS serves this SPA under `/vendor/pcbjam/`, built with
- * `vite build --base=/vendor/pcbjam/` — so `import.meta.env.BASE_URL` is that
- * prefix (always a leading + trailing slash; "/" for a root deploy).
- *
- * `` already makes
- * `` / `navigate()` / `useLocation()` mount-relative. This module covers
- * the two things the router can't:
- * 1. code that reads `window.location.pathname` raw (currentScope, WasmTool's
- * quit-path), and
- * 2. the hard cross-document `window.location.assign()` navigations — the
- * WASM runtime is process-global, so a tool switch / File→Quit / new file
- * is a full page load, not a router transition, and the app paths handed
- * to it (`@pcbjam/shared` `projectPath()` etc.) are origin-absolute.
- *
- * Nothing here touches `@pcbjam/shared` — it also compiles for the backend,
- * where `import.meta` does not exist.
- */
-
-/** `import.meta.env.BASE_URL` without its trailing slash ("" for a root deploy). */
-export const BASE_PREFIX = import.meta.env.BASE_URL.replace(/\/$/, "");
-
-/**
- * `pathname` with the mount prefix removed; the result always starts with "/".
- * A pathname outside the mount (or a root deploy) is returned unchanged.
- */
-export function stripBase(pathname: string): string {
- if (
- BASE_PREFIX &&
- (pathname === BASE_PREFIX || pathname.startsWith(`${BASE_PREFIX}/`))
- ) {
- const rest = pathname.slice(BASE_PREFIX.length);
- return rest === "" ? "/" : rest;
- }
- return pathname;
-}
-
-/**
- * `window.location.assign()` to an app-absolute path (e.g. from
- * `projectPath()`), re-adding the mount prefix. A full URL (has a scheme or is
- * protocol-relative) passes through untouched — so an `APP_URL` redirect target
- * is never corrupted.
- *
- * `win` is injectable for the same reason the tool-navigation / quit hooks take
- * a window: test seams and the wx-port's frame window.
- */
-export function hardNav(
- appPath: string,
- win: { location: { assign(url: string): void } } = window,
-): void {
- const url = /^([a-z][a-z0-9+.-]*:)?\/\//i.test(appPath)
- ? appPath
- : BASE_PREFIX + appPath;
- win.location.assign(url);
-}
diff --git a/web/standalone/src/lib/cmms-config.ts b/web/standalone/src/lib/cmms-config.ts
deleted file mode 100644
index 4ede25a..0000000
--- a/web/standalone/src/lib/cmms-config.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-// cmms-embed / PCBJam Part 2: fetch the AI-widget config the CMMS side
-// exposes at boot. Mirrors the existing `/api/me` identity call (same-origin,
-// `credentials: "include"` for the session cookie) — see lib/api.ts.
-//
-// Absent route, non-2xx, or malformed JSON all mean "no AI widget" (a plain
-// pcbjam.io deploy, or a CMMS build where the module is off) — never throws,
-// never blocks boot.
-import { API_BASE_URL } from "@/lib/config";
-export interface CmmsConfig {
- dark: boolean;
- aiEnabled: boolean;
- aiEditorKind: "kicad";
- csrfToken: string;
- entityType: string;
- entityId: string;
-}
-
-// Only `dark` / `aiEnabled` / `aiEditorKind` actually gate behavior — a
-// bug found 2026-09-05 in browser verification required all six fields as
-// strict strings, so the real server response (which omits `entityType` /
-// `entityId`) failed validation and silently fell back to NO_WIDGET despite
-// `aiEnabled: true` — the widget never mounted and nothing logged why.
-// `csrfToken` / `entityType` / `entityId` are only used to build the iframe's
-// query string (harmless as `""`), so a missing one shouldn't disable the
-// widget entirely.
-function isCmmsConfigCore(v: unknown): v is Pick {
- if (!v || typeof v !== "object") return false;
- const c = v as Record;
- return typeof c.dark === "boolean" && typeof c.aiEnabled === "boolean" && c.aiEditorKind === "kicad";
-}
-
-function optionalString(v: unknown): string {
- return typeof v === "string" ? v : "";
-}
-
-const NO_WIDGET: CmmsConfig = {
- dark: false,
- aiEnabled: false,
- aiEditorKind: "kicad",
- csrfToken: "",
- entityType: "",
- entityId: "",
-};
-
-let cached: Promise | null = null;
-
-/** Fetched once per page load; every caller shares the same in-flight/settled
- * promise. Same API base as the rest of the app (`VITE_API_BASE_URL`,
- * `/pcb-project-api.php` in the CMMS build) — the route lives alongside
- * `/api/me` on that same PHP endpoint. */
-export function fetchCmmsConfig(): Promise {
- if (!cached) {
- cached = fetch(`${API_BASE_URL}/api/cmms-config`, { credentials: "include" })
- .then((r) => (r.ok ? r.json() : null))
- .then((v): CmmsConfig => {
- if (!isCmmsConfigCore(v)) return NO_WIDGET;
- const c = v as Record;
- return {
- dark: v.dark,
- aiEnabled: v.aiEnabled,
- aiEditorKind: v.aiEditorKind,
- csrfToken: optionalString(c.csrfToken),
- entityType: optionalString(c.entityType),
- entityId: optionalString(c.entityId),
- };
- })
- .catch(() => NO_WIDGET);
- }
- return cached;
-}
diff --git a/web/standalone/src/lib/config.ts b/web/standalone/src/lib/config.ts
index 959f587..0685612 100644
--- a/web/standalone/src/lib/config.ts
+++ b/web/standalone/src/lib/config.ts
@@ -133,7 +133,6 @@ export const LOCAL_PROJECTS_ENABLED =
import.meta.env.VITE_LOCAL_PROJECTS === "idb";
import { colorForUser, type PresenceUser } from "@pcbjam/shared";
-import { stripBase } from "@/lib/base-path";
import { sessionIdentity } from "@/lib/session-identity";
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
import { cdnLibsSource } from "@/wasm/libs/cdn-source";
@@ -283,11 +282,7 @@ export const PRESENCE_TUNER_ENABLED = import.meta.env.VITE_PRESENCE_TUNER === "1
*/
export function currentScope(): string {
if (typeof window !== "undefined") {
- // stripBase: under the CMMS sub-path mount the scope is the first segment
- // AFTER /vendor/pcbjam/, not "vendor". No-op for a root deploy.
- const seg = stripBase(window.location.pathname)
- .split("/")
- .filter(Boolean)[0];
+ const seg = window.location.pathname.split("/").filter(Boolean)[0];
if (seg && seg !== "projects" && seg !== "libs") {
return decodeURIComponent(seg);
}
diff --git a/web/standalone/src/main.tsx b/web/standalone/src/main.tsx
index ba35ef6..bb194f1 100644
--- a/web/standalone/src/main.tsx
+++ b/web/standalone/src/main.tsx
@@ -38,10 +38,7 @@ const queryClient = new QueryClient({
// instantiate exactly once per navigation.
ReactDOM.createRoot(document.getElementById("root")!).render(
- {/* basename = the vite `base` (import.meta.env.BASE_URL) so the CMMS
- sub-path mount (/vendor/pcbjam/) is transparent to /navigate();
- the hard location.assign() navs use hardNav() (lib/base-path.ts). */}
-
+ ,
diff --git a/web/standalone/src/pages/ProjectView.tsx b/web/standalone/src/pages/ProjectView.tsx
index 00fbbb3..a79c2ab 100644
--- a/web/standalone/src/pages/ProjectView.tsx
+++ b/web/standalone/src/pages/ProjectView.tsx
@@ -17,7 +17,6 @@ import {
Trash2,
} from "lucide-react";
import { fetchFileBytes, useProject, useSourceDescriptor } from "@/lib/api";
-import { hardNav } from "@/lib/base-path";
import { downloadBytes } from "@/lib/download";
import { localProjectStore } from "@/lib/project-source";
import { zipFiles } from "@/lib/zip";
@@ -109,7 +108,7 @@ export function ProjectView() {
// scoped with a full reload; document tools create a templated file first.
const launchTool = (tool: Tool) => {
if (FILELESS_TOOLS.has(tool)) {
- hardNav(projectToolPath(scope, slug, tool));
+ window.location.assign(projectToolPath(scope, slug, tool));
} else {
setNewFileTool(tool);
}
diff --git a/web/standalone/src/wasm/collab/ai-bridge.test.ts b/web/standalone/src/wasm/collab/ai-bridge.test.ts
deleted file mode 100644
index 535a8da..0000000
--- a/web/standalone/src/wasm/collab/ai-bridge.test.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { describe, expect, it } from "vitest";
-import * as Y from "yjs";
-import { docToFile, docToY, kicadLibSymbolsMap, fileToDoc, yToDoc } from "@pcbjam/shared";
-import { applyToActiveBuffer, readActiveBuffer, registerActiveCollabDoc } from "./ai-bridge";
-
-// A symbol instance is only renderable with its `(lib_symbols …)` definition.
-// `docDelta` (the apply path's item differ) only diffs uuid-bearing items, and a
-// `(symbol "Device:R" …)` lib def has no uuid — so an AI proposal that places a
-// part the sheet has never carried used to land the instance with no definition,
-// and KiCad drew nothing. `applyToActiveBuffer` now upserts the proposal's lib
-// defs into `kdoc_libsymbols` alongside the item delta.
-
-const WIRE1 = "22222222-0000-0000-0000-000000000001";
-const SYM1 = "44444444-0000-0000-0000-000000000001";
-
-const SCH_WITHOUT_SYMBOL = `(kicad_sch
-\t(version 20250114)
-\t(generator "eeschema")
-\t(generator_version "9.0")
-\t(uuid "11111111-1111-1111-1111-111111111111")
-\t(paper "A4")
-\t(lib_symbols)
-\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}"))
-\t(sheet_instances (path "/" (page "1")))
-)
-`;
-
-const SCH_WITH_SYMBOL = `(kicad_sch
-\t(version 20250114)
-\t(generator "eeschema")
-\t(generator_version "9.0")
-\t(uuid "11111111-1111-1111-1111-111111111111")
-\t(paper "A4")
-\t(lib_symbols
-\t\t(symbol "Device:R" (pin_numbers (hide yes)) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)
-\t\t\t(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
-\t\t\t(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
-\t\t\t(symbol "R_0_1"
-\t\t\t\t(rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none)))
-\t\t\t)
-\t\t\t(symbol "R_1_1"
-\t\t\t\t(pin passive line (at 0 3.81 270) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
-\t\t\t\t(pin passive line (at 0 -3.81 90) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
-\t\t\t)
-\t\t)
-\t)
-\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}"))
-\t(symbol (lib_id "Device:R") (at 63.5 63.5 0) (unit 1) (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no)
-\t\t(uuid "${SYM1}")
-\t\t(property "Reference" "R1" (at 66.04 62.23 0) (effects (font (size 1.27 1.27)) (justify left)))
-\t\t(property "Value" "10k" (at 66.04 64.77 0) (effects (font (size 1.27 1.27)) (justify left)))
-\t\t(pin "1" (uuid "44444444-0000-0000-0000-0000000000a1"))
-\t\t(pin "2" (uuid "44444444-0000-0000-0000-0000000000a2"))
-\t\t(instances (project "rt" (path "/11111111-1111-1111-1111-111111111111" (reference "R1") (unit 1))))
-\t)
-\t(sheet_instances (path "/" (page "1")))
-)
-`;
-
-function seed(src: string): Y.Doc {
- const ydoc = new Y.Doc();
- docToY(fileToDoc(src), ydoc);
- return ydoc;
-}
-
-describe("ai-bridge applyToActiveBuffer", () => {
- it("carries a newly-placed symbol's (lib_symbols) definition, not just the instance", () => {
- const ydoc = seed(SCH_WITHOUT_SYMBOL);
- registerActiveCollabDoc(ydoc, "board.kicad_sch");
- try {
- expect(kicadLibSymbolsMap(ydoc).get("Device:R")).toBeUndefined();
-
- applyToActiveBuffer(SCH_WITH_SYMBOL);
-
- const def = kicadLibSymbolsMap(ydoc).get("Device:R");
- expect(def).toBeDefined();
- expect(def).toContain("R_0_1");
-
- // and the instance item itself landed via the normal item delta
- const out = docToFile(yToDoc(ydoc));
- expect(out).toContain(SYM1);
- expect(out).toContain('(lib_id "Device:R")');
- } finally {
- registerActiveCollabDoc(null);
- }
- });
-
- it("is a no-op on lib_symbols when the proposal adds no new definitions", () => {
- const ydoc = seed(SCH_WITH_SYMBOL);
- registerActiveCollabDoc(ydoc, "board.kicad_sch");
- try {
- const before = kicadLibSymbolsMap(ydoc).get("Device:R");
- expect(before).toBeDefined();
-
- // same doc, wire nudged — no new parts
- applyToActiveBuffer(SCH_WITH_SYMBOL.replace("101.6 50.8", "127 50.8"));
-
- expect(kicadLibSymbolsMap(ydoc).get("Device:R")).toBe(before);
- expect(readActiveBuffer()).toContain("127 50.8");
- } finally {
- registerActiveCollabDoc(null);
- }
- });
-});
diff --git a/web/standalone/src/wasm/collab/ai-bridge.ts b/web/standalone/src/wasm/collab/ai-bridge.ts
deleted file mode 100644
index 4040831..0000000
--- a/web/standalone/src/wasm/collab/ai-bridge.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-// cmms-embed / PCBJam Part 2: AI chat editor-buffer bridge.
-//
-// Same `cmms-ai-editor-*` postMessage protocol as the cad-editor and
-// pcb-retrace bridges (see TT-DB docs/vendored/{cad-editor,pcb-retrace}.md):
-// the chat is a nested iframe, this top-level page is the "editor" side.
-//
-// get-buffer: reads the ACTIVE sheet's live Y.Doc (not last-saved-to-MEMFS —
-// this reflects in-progress edits, including a peer's) via `yToDoc` +
-// `docToFile`, both pure/read-only.
-//
-// apply: deliberately does NOT reuse `KicadBinding.seed()`. `seed()` is a
-// boot-time/reconnect-only function (three branches: editor-matches-doc,
-// empty-room file-seed, adopt-existing-room) with its own empty/hollow-doc
-// edge cases (see kicad-binding.ts) — none of those branches is "a live,
-// already-populated doc gets replaced with unrelated new content", and this
-// file's git history (sibling-restage, paste-collision, ysync 0001-0008) is
-// almost entirely bugfixes in exactly that state-transition space. Instead,
-// this treats the AI's proposed file exactly like a REMOTE PEER'S EDIT
-// arriving: diff the two `KicadDoc`s (`docDelta`) and push the result through
-// `applyDeltaToY` with an origin that does not match the binding's internal
-// `ORIGIN` object, so the normal item-observer chain (the most exercised path
-// in this codebase — every multi-user co-edit goes through it) picks it up
-// and pushes it into the live WASM editor, the same as any other client's
-// change would. Known limitation: `docDelta` only diffs items, not
-// layout/preamble (page size, title block) — matches its own doc comment.
-import {
- applyDeltaToY,
- docDelta,
- docToFile,
- fileToDoc,
- libSymbolsFromLayout,
- upsertLibSymbolsToY,
- yToDoc,
- type KicadDoc,
-} from "@pcbjam/shared";
-import type * as Y from "yjs";
-import { clog, cwarn } from "@/wasm/collab/debug";
-
-const APPLY_ORIGIN = { external: "cmms-ai-assistant" };
-
-// The active sheet's Y.Doc — registered by WasmTool.tsx at each of its three
-// `setPanelDoc(...)` call sites (single-room collabHandle, multi-sheet
-// onActiveChange, and teardown-to-null), the same signal it already uses as
-// "which doc does the SheetPanel show right now". A module-level singleton
-// rather than prop-drilled: the widget mounts in App.tsx, several route
-// levels above WasmTool, matching the ambient `window.kicadCollab` /
-// `window.CMMS` cross-boundary pattern the other editor bridges already use.
-let activeDoc: Y.Doc | null = null;
-let activeFilePath: string | undefined;
-
-export function registerActiveCollabDoc(doc: Y.Doc | null, filePath?: string): void {
- activeDoc = doc;
- activeFilePath = doc ? filePath : undefined;
-}
-
-/** The active sheet's path, for the widget iframe's `editor_filename` param —
- * a context hint only, not load-bearing (get-buffer carries the real
- * content). `undefined` before any sheet is open. */
-export function getActiveFilePath(): string | undefined {
- return activeFilePath;
-}
-
-export class AiBridgeNotReadyError extends Error {
- constructor() {
- super("no active KiCad sheet to read/write");
- this.name = "AiBridgeNotReadyError";
- }
-}
-
-/** Live S-expr text of the currently active sheet — read-only, never touches
- * the Y.Doc. Safe to call any time a sheet is open. */
-export function readActiveBuffer(): string {
- if (!activeDoc) throw new AiBridgeNotReadyError();
- const doc: KicadDoc = yToDoc(activeDoc);
- return docToFile(doc);
-}
-
-/** Diff `newSource` against the active sheet's current doc and push the
- * result in as though a peer had made the edit. Throws on a source that
- * doesn't parse as a KiCad s-expr document (`fileToDoc`) — the caller
- * should surface that as a rejected proposal, not a crash. */
-export function applyToActiveBuffer(newSource: string): void {
- if (!activeDoc) throw new AiBridgeNotReadyError();
- const prev = yToDoc(activeDoc);
- const next = fileToDoc(newSource);
- const delta = docDelta(prev, next);
- // `docDelta` only diffs uuid-bearing items — a `(lib_symbols …)` def has no
- // uuid, so a proposal that places a symbol the sheet has never carried adds
- // the instance item but drops its library definition, leaving KiCad an
- // instance pointing at an undefined symbol (renders nothing). Push the
- // proposal's lib defs into the kdoc_libsymbols map FIRST (upsert by lib id,
- // never deletes — the server always splices into the existing block, so this
- // is existing ∪ additions), so the items observer's `deltaToItemsWire` can
- // prefix each new instance with its `(lib_symbols …)` on the way to the
- // editor — exactly what `kicad-binding.ts`'s own DOWN path does with
- // `upsertLibSymbolsToY(doc, defs, ORIGIN)`. Deliberately NOT `syncLayoutToY`:
- // that also reconciles non-item layout heads (root `uuid`, `sheet_instances`,
- // `paper`…) wholesale-LWW from the proposal, which would corrupt the sheet's
- // identity if the server's spliced preamble deviates at all.
- upsertLibSymbolsToY(activeDoc, libSymbolsFromLayout(next.layout, next.items), APPLY_ORIGIN);
- applyDeltaToY(activeDoc, delta, APPLY_ORIGIN);
-}
-
-interface GetBufferMessage {
- type: "cmms-ai-editor-get-buffer";
-}
-interface ApplyMessage {
- type: "cmms-ai-editor-apply";
- editor: "kicad";
- new_source: string;
-}
-
-function isGetBufferMessage(d: unknown): d is GetBufferMessage {
- return !!d && typeof d === "object" && (d as { type?: unknown }).type === "cmms-ai-editor-get-buffer";
-}
-function isApplyMessage(d: unknown): d is ApplyMessage {
- return (
- !!d &&
- typeof d === "object" &&
- (d as { type?: unknown }).type === "cmms-ai-editor-apply" &&
- (d as { editor?: unknown }).editor === "kicad" &&
- typeof (d as { new_source?: unknown }).new_source === "string"
- );
-}
-
-/** Reply to the chat iframe. Prefer `evt.source`; a DOM fallback covers the
- * cross-realm-Window case cad-editor's history flagged (see its bridge). */
-function replyToChat(evtSource: MessageEventSource | null, msg: Record): void {
- let posted = false;
- try {
- if (evtSource && "postMessage" in evtSource) {
- (evtSource as Window).postMessage(msg, window.location.origin);
- posted = true;
- }
- } catch {
- /* fall through to the DOM-query fallback */
- }
- if (!posted) {
- try {
- const f = document.querySelector("#cmms-ai-widget iframe");
- if (f?.contentWindow) {
- f.contentWindow.postMessage(msg, window.location.origin);
- posted = true;
- }
- } catch {
- /* nothing left to try */
- }
- }
- clog("ai-bridge: reply", msg.type, posted ? "sent" : "DROPPED");
-}
-
-let installed = false;
-
-/** Install the window `message` listener once per page load. Idempotent —
- * App.tsx mounts the widget once, but this guards against remounts. */
-export function installAiBridge(): void {
- if (installed) return;
- installed = true;
- window.addEventListener("message", (evt: MessageEvent) => {
- if (evt.origin !== window.location.origin) return;
- const d: unknown = evt.data;
- if (isGetBufferMessage(d)) {
- let buffer: string | null = null;
- try {
- buffer = readActiveBuffer();
- } catch (err) {
- cwarn("ai-bridge: get-buffer failed", err);
- }
- replyToChat(evt.source, { type: "cmms-ai-editor-buffer", buffer });
- } else if (isApplyMessage(d)) {
- try {
- applyToActiveBuffer(d.new_source);
- } catch (err) {
- cwarn("ai-bridge: apply failed", err);
- }
- }
- });
-}