diff --git a/site/src/content/blog/devblog-2026-w27.mdx b/site/src/content/blog/devblog-2026-w27.mdx
new file mode 100644
index 0000000..e69de29
diff --git a/web/standalone/src/App.tsx b/web/standalone/src/App.tsx
index 2708238..b22ae98 100644
--- a/web/standalone/src/App.tsx
+++ b/web/standalone/src/App.tsx
@@ -10,9 +10,12 @@ export default function App() {
<>
} />
- } />
- } />
- } />
+ {/* scope/kind/name grammar (see @pcbjam/shared routes.ts). The tool is
+ inferred from the file (or lib kind); `-/:tool` boots a fileless tool. */}
+ } />
+ } />
+ } />
+ } />
{/* Version + source link, bottom-right on every route (home + editor). */}
diff --git a/web/standalone/src/components/NewFileDialog.tsx b/web/standalone/src/components/NewFileDialog.tsx
index ba8a5a0..2f4a69e 100644
--- a/web/standalone/src/components/NewFileDialog.tsx
+++ b/web/standalone/src/components/NewFileDialog.tsx
@@ -1,7 +1,13 @@
import * as React from "react";
-import { TOOL_LABELS, type Tool } from "@pcbjam/shared";
+import {
+ LOCAL_SCOPE,
+ TOOL_LABELS,
+ type Tool,
+ projectPath,
+} from "@pcbjam/shared";
import { Loader2 } from "lucide-react";
import { uploadFileBytes } from "@/lib/api";
+import { currentScope } from "@/lib/config";
import { localProjectStore } from "@/lib/project-source";
import { useLocalProjects } from "@/lib/api";
import {
@@ -92,9 +98,10 @@ export function NewFileDialog({
await uploadFileBytes(slug, finalName, bytes);
}
// Full navigation so Emscripten boots into a clean page opening the file.
- window.location.assign(
- `/p/${encodeURIComponent(slug)}/${tool}/${finalName}`,
- );
+ // 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();
+ 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/ProjectsSection.tsx b/web/standalone/src/components/ProjectsSection.tsx
index 60d1d55..35c990d 100644
--- a/web/standalone/src/components/ProjectsSection.tsx
+++ b/web/standalone/src/components/ProjectsSection.tsx
@@ -1,7 +1,7 @@
import * as React from "react";
import { Link } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
-import type { Project } from "@pcbjam/shared";
+import { type Project, projectPath } from "@pcbjam/shared";
import { Download, Loader2, Pencil, Trash2 } from "lucide-react";
import { useLocalProjects, useProjects } from "@/lib/api";
import { PROJECT_SOURCE_KIND } from "@/lib/config";
@@ -149,7 +149,7 @@ function ProjectRow({
{children}
diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx
index 463d3f2..644f095 100644
--- a/web/standalone/src/components/WasmTool.tsx
+++ b/web/standalone/src/components/WasmTool.tsx
@@ -5,6 +5,8 @@ import {
EXTENSION_TOOL,
FILELESS_TOOLS,
fileToDoc,
+ projectPath,
+ projectToolPath,
toolSchema,
ydocHasState,
yToDoc,
@@ -13,6 +15,7 @@ import {
} from "@pcbjam/shared";
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
import {
+ currentScope,
libsSourceConfig,
yjsProviderConfig,
type DocSource,
@@ -160,10 +163,6 @@ function chooseToolFile(
return candidates[0]?.path;
}
-function encodeRelPath(path: string): string {
- return path.split("/").map(encodeURIComponent).join("/");
-}
-
function installToolNavigationHook(
win: ToolWindow,
opts: {
@@ -191,10 +190,13 @@ function installToolNavigationHook(
return false;
}
+ // Scope/kind/name grammar: a fileless tool boots at `…/-/:tool`; a file route
+ // carries the path (its tool is inferred). Scope = the current URL's scope.
+ const scope = currentScope();
const url =
- `/p/${encodeURIComponent(opts.slug)}/${nextTool}` +
- (nextPath ? `/${encodeRelPath(nextPath)}` : "") +
- win.location.search;
+ (FILELESS_TOOLS.has(nextTool)
+ ? projectToolPath(scope, opts.slug, nextTool)
+ : projectPath(scope, opts.slug, nextPath)) + win.location.search;
opts.log(`[nav] ${rawToolName} ${rawFileName || "(no file)"} -> ${url}`);
win.location.assign(url);
diff --git a/web/standalone/src/lib/api.ts b/web/standalone/src/lib/api.ts
index ca7c12d..ca98c09 100644
--- a/web/standalone/src/lib/api.ts
+++ b/web/standalone/src/lib/api.ts
@@ -1,6 +1,6 @@
import type { DriftReportBody, Project } from "@pcbjam/shared";
import { useQuery } from "@tanstack/react-query";
-import { API_BASE_URL, libsSourceConfig } from "./config";
+import { API_BASE_URL, currentScope, libsSourceConfig } from "./config";
import { client } from "./contract-client";
import type { LibInfo } from "@/wasm/libs/source";
import { downloadBytes } from "./download";
@@ -119,7 +119,10 @@ export async function reportDrift(
slug: string,
body: DriftReportBody,
): Promise {
- await client.reportDrift({ params: { project: slug }, body });
+ await client.reportDrift({
+ params: { scope: currentScope(), project: slug },
+ body,
+ });
}
/**
@@ -128,7 +131,7 @@ export async function reportDrift(
* a keepalive `fetch` is the fallback when the beacon is rejected (too large).
*/
export function reportDriftBeacon(slug: string, body: DriftReportBody): void {
- const url = `${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/drift`;
+ const url = `${API_BASE_URL}/api/scopes/${encodeURIComponent(currentScope())}/projects/${encodeURIComponent(slug)}/drift`;
const blob = new Blob([JSON.stringify(body)], { type: "application/json" });
try {
if (navigator.sendBeacon(url, blob)) return;
diff --git a/web/standalone/src/lib/config.ts b/web/standalone/src/lib/config.ts
index bc1f8f3..3ec5de9 100644
--- a/web/standalone/src/lib/config.ts
+++ b/web/standalone/src/lib/config.ts
@@ -151,16 +151,38 @@ export function docSourceConfig(): DocSource {
* "off" — disable libs (empty sym-lib-table).
*/
/**
- * The (thin, pre-auth) owner the editor writes libs as, sent on every lib
- * request via OWNER_HEADER. `?libowner=` (e2e isolation) wins over
- * `VITE_LIBS_OWNER`, else a stable local default.
+ * The (thin, pre-auth) current user — sent on every request via USER_HEADER and
+ * doubling as the personal scope slug. `?user=`/`?libowner=` (e2e isolation) win
+ * over `VITE_USER`/`VITE_LIBS_OWNER`, else a stable local default.
*/
-export function libsOwner(): string {
+export function userSlug(): string {
if (typeof window !== "undefined") {
- const p = new URLSearchParams(window.location.search).get("libowner");
+ const q = new URLSearchParams(window.location.search);
+ const p = q.get("user") ?? q.get("libowner");
if (p) return p;
}
- return import.meta.env.VITE_LIBS_OWNER ?? "local-user";
+ return (
+ import.meta.env.VITE_USER ?? import.meta.env.VITE_LIBS_OWNER ?? "local-user"
+ );
+}
+
+/**
+ * The active scope (first URL segment) for API calls. Mirrors how `userSlug()`
+ * reads the URL, so the source layer scopes requests without threading scope
+ * through every signature. Falls back to `?scope=` / `VITE_SCOPE` / the personal
+ * scope (the user slug). Client-only scopes (e.g. `@local`) are routed by the
+ * project source and never sent to a backend.
+ */
+export function currentScope(): string {
+ if (typeof window !== "undefined") {
+ const seg = window.location.pathname.split("/").filter(Boolean)[0];
+ if (seg && seg !== "projects" && seg !== "libs") {
+ return decodeURIComponent(seg);
+ }
+ const q = new URLSearchParams(window.location.search).get("scope");
+ if (q) return q;
+ }
+ return import.meta.env.VITE_SCOPE ?? userSlug();
}
/** Full URL of the CDN libs top manifest (required for VITE_LIBS_SOURCE=cdn),
@@ -187,7 +209,7 @@ export function libsSourceConfig(projectId?: string): LibsSource | null {
? CDN_LIBS_MANIFEST_URL
? cdnLibsSource(CDN_LIBS_MANIFEST_URL)
: staticLibsSource() // misconfigured cdn ⇒ offline fallback
- : remoteLibsSource(API_BASE_URL, libsOwner(), project);
+ : remoteLibsSource(API_BASE_URL, currentScope(), userSlug(), project);
// 0004-A spike: `?libwrite=1` adds one in-memory writable user SYMBOL lib so the
// editor save path works with no backend (a dev/test aid). The real remote
@@ -221,7 +243,8 @@ export function libsSourceForLib(
if (import.meta.env.VITE_LIBS_SOURCE === "synced") {
return syncedLibsSource(libId, {
apiBase: API_BASE_URL,
- owner: libsOwner(),
+ scope: currentScope(),
+ user: userSlug(),
project,
log: (m) => console.log(m),
});
diff --git a/web/standalone/src/lib/contract-client.ts b/web/standalone/src/lib/contract-client.ts
index c56f963..4f804ce 100644
--- a/web/standalone/src/lib/contract-client.ts
+++ b/web/standalone/src/lib/contract-client.ts
@@ -1,14 +1,15 @@
-import { contract } from "@pcbjam/shared";
+import { contract, USER_HEADER } from "@pcbjam/shared";
import { initClient } from "@ts-rest/core";
-import { API_BASE_URL } from "./config";
+import { API_BASE_URL, userSlug } from "./config";
/**
* ts-rest client over the shared contract (the REST backend). Shared by the
* remote project source (lib/project-source.ts) and the lib/drift endpoints
* (lib/api.ts). Kept in its own module so project-source and api don't import
- * each other (avoids a cycle).
+ * each other (avoids a cycle). The scope is a PATH param (per call); the user is
+ * a header (the thin pre-auth identity, used for per-user lib pins).
*/
export const client = initClient(contract, {
baseUrl: API_BASE_URL,
- baseHeaders: {},
+ baseHeaders: { [USER_HEADER]: userSlug() },
});
diff --git a/web/standalone/src/lib/idb-project-store.ts b/web/standalone/src/lib/idb-project-store.ts
index 450470e..3cda6bf 100644
--- a/web/standalone/src/lib/idb-project-store.ts
+++ b/web/standalone/src/lib/idb-project-store.ts
@@ -1,4 +1,9 @@
-import type { Project, ProjectFile, ProjectWithFiles } from "@pcbjam/shared";
+import {
+ LOCAL_SCOPE,
+ type Project,
+ type ProjectFile,
+ type ProjectWithFiles,
+} from "@pcbjam/shared";
import type { ProjectSource } from "./project-source";
import {
SOURCE_DESCRIPTORS,
@@ -114,6 +119,7 @@ function slugify(name: string): string {
function toProject(r: ProjectRecord): Project {
return {
id: deterministicUuid(`local:project:${r.slug}`),
+ scope: LOCAL_SCOPE,
slug: r.slug,
name: r.name,
createdAt: r.createdAt,
diff --git a/web/standalone/src/lib/project-source-shared.ts b/web/standalone/src/lib/project-source-shared.ts
index a35d43a..b15142f 100644
--- a/web/standalone/src/lib/project-source-shared.ts
+++ b/web/standalone/src/lib/project-source-shared.ts
@@ -4,11 +4,11 @@
* both can import the descriptor types + the deterministic-uuid helper without a
* runtime import cycle.
*
- * A source has one of three KINDS, surfaced verbatim in the UI so the user always
- * knows where their edits go:
- * - "remote-rw" — a backend the editor reads AND writes (saves upload).
- * - "remote-ro" — a read-only remote (the demo's CDN gallery); saves download.
- * - "local" — this browser's IndexedDB; saves persist locally, export by zip.
+ * A source has one of three KINDS, surfaced in the UI so the user always knows
+ * where their edits go:
+ * - "remote-rw" — a backend the editor reads AND writes (Cloud; saves upload).
+ * - "remote-ro" — the curated CDN gallery (Demo); editing auto-forks a local copy.
+ * - "local" — this browser's IndexedDB (Local); saves persist, export by zip.
*/
export type SourceKind = "remote-rw" | "remote-ro" | "local";
@@ -26,22 +26,24 @@ export const SOURCE_DESCRIPTORS: Record = {
"remote-rw": {
kind: "remote-rw",
writable: true,
- label: "Remote · editable",
+ label: "Cloud",
description: "Stored on the backend — your saves are uploaded there.",
},
"remote-ro": {
kind: "remote-ro",
+ // Not directly writable, but editing transparently forks a local copy, so
+ // the UI never shows a scary "read-only" — it's just a Demo you can edit.
writable: false,
- label: "Remote · read-only",
+ label: "Demo",
description:
- "A read-only example from the server — edits aren't saved back; Save downloads the file to your machine.",
+ "A demo project — open it and your edits become a local copy you can save and export.",
},
local: {
kind: "local",
writable: true,
- label: "Local (this browser)",
+ label: "Local",
description:
- "Stored in this browser only — saves persist here across visits. Export with Download .zip (nothing is uploaded).",
+ "Stored in this browser — saves persist here across visits. Export with Download .zip.",
},
};
diff --git a/web/standalone/src/lib/project-source.test.ts b/web/standalone/src/lib/project-source.test.ts
index 53b0f87..f61b656 100644
--- a/web/standalone/src/lib/project-source.test.ts
+++ b/web/standalone/src/lib/project-source.test.ts
@@ -28,6 +28,9 @@ async function loadStatic() {
// Local IDB store off ⇒ the active source is the plain static gallery
// (no composite), which is what these read-only assertions cover.
LOCAL_PROJECTS_ENABLED: false,
+ // The contract client (imported transitively) reads these at module load.
+ userSlug: () => "test-user",
+ currentScope: () => "demo",
}));
return (await import("./project-source")).projectSource;
}
diff --git a/web/standalone/src/lib/project-source.ts b/web/standalone/src/lib/project-source.ts
index 736357d..202ea29 100644
--- a/web/standalone/src/lib/project-source.ts
+++ b/web/standalone/src/lib/project-source.ts
@@ -1,9 +1,15 @@
-import type { Project, ProjectFile, ProjectWithFiles } from "@pcbjam/shared";
+import {
+ DEMO_SCOPE,
+ type Project,
+ type ProjectFile,
+ type ProjectWithFiles,
+} from "@pcbjam/shared";
import {
API_BASE_URL,
LOCAL_PROJECTS_ENABLED,
PROJECT_MANIFEST_URL,
PROJECT_SOURCE_KIND,
+ currentScope,
} from "./config";
import { client } from "./contract-client";
import { idbProjectStore, type LocalProjectStore } from "./idb-project-store";
@@ -51,18 +57,24 @@ function encodePath(relPath: string): string {
}
function remoteProjectSource(): ProjectSource {
+ // The active scope is the URL's first segment (config.currentScope), read at
+ // call time so a single source instance serves whatever scope is open.
+ const projectsBase = () =>
+ `${API_BASE_URL}/api/scopes/${encodeURIComponent(currentScope())}/projects`;
const fileUrl = (slug: string, relPath: string) =>
- `${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files/${encodePath(relPath)}`;
+ `${projectsBase()}/${encodeURIComponent(slug)}/files/${encodePath(relPath)}`;
return {
descriptor: SOURCE_DESCRIPTORS["remote-rw"],
readOnly: false,
async listProjects() {
- const res = await client.listProjects();
+ const res = await client.listProjects({ params: { scope: currentScope() } });
if (res.status !== 200) throw new Error("failed to list projects");
return res.body;
},
async getProject(slug) {
- const res = await client.getProject({ params: { project: slug } });
+ const res = await client.getProject({
+ params: { scope: currentScope(), project: slug },
+ });
if (res.status === 404) throw new Error("project not found");
if (res.status !== 200) throw new Error("failed to load project");
return res.body;
@@ -78,10 +90,10 @@ function remoteProjectSource(): ProjectSource {
// The form FIELD NAME carries the project-relative path (upsert by
// (project, path)) — same convention as the management app's folder upload.
form.append(relPath, new File([bytes as BlobPart], name));
- const res = await fetch(
- `${API_BASE_URL}/api/projects/${encodeURIComponent(slug)}/files`,
- { method: "POST", body: form },
- );
+ const res = await fetch(`${projectsBase()}/${encodeURIComponent(slug)}/files`, {
+ method: "POST",
+ body: form,
+ });
if (!res.ok) throw new Error(`upload failed (${res.status}): ${relPath}`);
},
};
@@ -125,6 +137,8 @@ function staticProjectSource(manifestUrl: string): ProjectSource {
const toProject = (p: StaticManifestProject, ts: string): Project => ({
id: deterministicUuid(`project:${p.slug}`),
+ // The curated gallery lives under the reserved `demo` scope.
+ scope: DEMO_SCOPE,
slug: p.slug,
name: p.name,
createdAt: ts,
diff --git a/web/standalone/src/pages/HomePage.tsx b/web/standalone/src/pages/HomePage.tsx
index 4b56165..a9af4f6 100644
--- a/web/standalone/src/pages/HomePage.tsx
+++ b/web/standalone/src/pages/HomePage.tsx
@@ -1,10 +1,14 @@
import * as React from "react";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
-import type { Tool } from "@pcbjam/shared";
+import { type Tool, libPath, projectPath } from "@pcbjam/shared";
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
import { useLibs } from "@/lib/api";
-import { LOCAL_PROJECTS_ENABLED, PROJECT_SOURCE_KIND } from "@/lib/config";
+import {
+ LOCAL_PROJECTS_ENABLED,
+ PROJECT_SOURCE_KIND,
+ currentScope,
+} from "@/lib/config";
import { localFileLibsSource } from "@/wasm/libs/local-file-source";
import type { LibsSource } from "@/wasm/libs/source";
import { downloadBytes } from "@/lib/download";
@@ -151,7 +155,7 @@ export function HomePage() {
if (!store) return;
const project = await store.createProject(imported.name, imported.files);
await queryClient.invalidateQueries({ queryKey: ["local-projects"] });
- navigate(`/p/${project.slug}`);
+ navigate(projectPath(project.scope, project.slug));
};
// Tool launched from the home page: no project, no files. File-less editors
@@ -388,7 +392,7 @@ function LibGroup({
{shown.map((lib) => (
diff --git a/web/standalone/src/pages/LibToolPage.tsx b/web/standalone/src/pages/LibToolPage.tsx
index 27133ff..5f5c04b 100644
--- a/web/standalone/src/pages/LibToolPage.tsx
+++ b/web/standalone/src/pages/LibToolPage.tsx
@@ -1,37 +1,31 @@
-import { useParams } from "react-router-dom";
-import { toolSchema } from "@pcbjam/shared";
+import { useParams, useSearchParams } from "react-router-dom";
+import { parseToolParam } from "@pcbjam/shared";
import { libsSourceForLib } from "@/lib/config";
import { WasmTool } from "@/components/WasmTool";
import { PreflightGate } from "@/preflight/PreflightGate";
/**
- * Open one BACKEND library scoped to itself in its editor, addressed by URL:
- * /l// e.g. /l/Diode/symbol_editor
+ * Open one library scoped to itself in its editor, addressed by URL:
+ * /:scope/libs/ (symbol_editor — the default)
+ * /:scope/libs/?tool=footprint_editor
*
- * Deep-linkable + reload-safe (unlike the home page's in-place lib launch). The
- * editor boots with no project/files and a `scopedLibsSource` so its lib tree
- * shows exactly this one library. (Local lib FILES can't be URL-addressed — they
- * stay an in-place launch on the home page.)
+ * The lib's editor is chosen by `?tool=` (the home page appends it from the lib's
+ * kind); absent ⇒ the symbol editor. `` is the lib's opaque token (its CDN
+ * name or backend id). Deep-linkable + reload-safe. The editor boots with no
+ * project/files and a `scopedLibsSource` showing exactly this one library.
*/
export function LibToolPage() {
const params = useParams();
- const libId = params.lib ?? "";
- const parsedTool = toolSchema.safeParse(params.tool);
-
- if (!parsedTool.success) {
- return (
-