feat: standalone home rework — tool launcher + backend libraries listing

Rework HomePage into KiCad-launcher-style zones: local file-load, a Tools grid,
backend projects, and a backend libraries listing (useLibs over the shared
listLibs). The Tools grid shows all seven tools in KiCad's standalone order
(Schematic/Symbol/PCB/Footprint editors, Gerber Viewer, Calculator, Drawing
Sheet); clicking a tool or a library launches the editor with no project
(launchedTool → WasmTool slug=local) — file-less editors browse backend libs,
document editors open blank. Harden the web e2e global-setup to assert the
self-provisioned origin libs (Device, Resistor_SMD) are present, failing fast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-15 12:28:22 +02:00
commit b0b7f69846
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
4 changed files with 233 additions and 7 deletions

View file

@ -13,6 +13,12 @@ import type { FullConfig } from '@playwright/test';
const API_BASE = process.env.BACKEND_URL ?? 'http://localhost:3060';
const DEMO_SLUG = 'demo';
const DEMO_FILES = ['demo.kicad_sch', 'demo.kicad_pcb', 'demo.kicad_wks'];
// Origin libs the remote-lib specs browse/place (symbol-write/footprint-browse).
// The backend self-provisions these on dev/start (src/extract/ensure-example-libs).
const REQUIRED_LIBS: Record<'symbol' | 'footprint', string[]> = {
symbol: ['Device'],
footprint: ['Resistor_SMD'],
};
async function waitForApi(timeoutMs = 60000): Promise<void> {
const deadline = Date.now() + timeoutMs;
@ -50,8 +56,30 @@ async function verifyDemoProject(): Promise<void> {
}
}
async function verifyLibs(): Promise<void> {
for (const kind of ['symbol', 'footprint'] as const) {
const r = await fetch(`${API_BASE}/api/libs?kind=${kind}`);
if (!r.ok) {
throw new Error(`GET /api/libs?kind=${kind} -> HTTP ${r.status}`);
}
const libs = (await r.json()) as { id: string }[];
const have = new Set(libs.map((l) => l.id));
const missing = REQUIRED_LIBS[kind].filter((id) => !have.has(id));
if (missing.length) {
throw new Error(
`backend missing ${kind} origin lib(s): ${missing.join(', ')}. The example ` +
`backend self-provisions libs on dev/start (clones + extracts a KiCad slice ` +
`into web/backend/.libs) — check that step ran (network on first run).`
);
}
}
}
export default async function globalSetup(_config: FullConfig): Promise<void> {
await waitForApi();
await verifyDemoProject();
console.log(`web e2e setup: backend at ${API_BASE} serving "${DEMO_SLUG}" — OK`);
await verifyLibs();
console.log(
`web e2e setup: backend at ${API_BASE} serving "${DEMO_SLUG}" + origin libs — OK`
);
}

View file

@ -0,0 +1,70 @@
import { TOOL_LABELS, TOOLS, type Tool } from "@pcbjam/shared";
import {
Calculator,
CircuitBoard,
Component,
Cpu,
Layers,
LayoutTemplate,
type LucideIcon,
Workflow,
} from "lucide-react";
/** Icon per tool — KiCad-launcher style. */
const TOOL_ICONS: Record<Tool, LucideIcon> = {
eeschema: Workflow,
symbol_editor: Component,
pcbnew: CircuitBoard,
footprint_editor: Cpu,
gerbview: Layers,
calculator: Calculator,
pl_editor: LayoutTemplate,
};
/**
* Display order for the launcher, matching KiCad's standalone project-manager
* list (Image Converter + Plugin Manager are KiCad tools we don't ship). Any
* tool in TOOLS but missing here is appended, so adding a tool can't drop it.
*/
const TOOL_ORDER: Tool[] = [
"eeschema", // Schematic Editor
"symbol_editor", // Symbol Editor
"pcbnew", // PCB Editor
"footprint_editor", // Footprint Editor
"gerbview", // Gerber Viewer
"calculator", // PCB Calculator
"pl_editor", // Drawing Sheet Editor
];
function orderedTools(): Tool[] {
const known = TOOL_ORDER.filter((t) => TOOLS.includes(t));
const rest = TOOLS.filter((t) => !known.includes(t));
return [...known, ...rest];
}
/**
* KiCad-style tool launcher: a grid of icon+text cards, one per tool. Clicking a
* card launches that tool in-place with no project the file-less editors browse
* backend libraries; the document editors (schematic/PCB/drawing sheet) boot to a
* blank document, the same as opening them from KiCad's project manager.
*/
export function ToolGrid({ onLaunch }: { onLaunch: (tool: Tool) => void }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{orderedTools().map((tool) => {
const Icon = TOOL_ICONS[tool] ?? Component;
return (
<button
key={tool}
type="button"
onClick={() => onLaunch(tool)}
className="flex flex-col items-center gap-2 rounded-lg border bg-card p-4 text-center transition-colors hover:bg-accent"
>
<Icon size={28} className="text-muted-foreground" />
<span className="text-sm font-medium">{TOOL_LABELS[tool]}</span>
</button>
);
})}
</div>
);
}

View file

@ -1,5 +1,6 @@
import {
contract,
type Lib,
type Project,
type ProjectWithFiles,
} from "@pcbjam/shared";
@ -30,6 +31,23 @@ export function useProjects() {
});
}
/**
* Libraries the backend serves, optionally filtered to a kind ("symbol" |
* "footprint"). Origins are kind-filtered server-side; user libs are
* kind-agnostic and always returned. Mirrors `useProjects` read-only listing
* for the home page; the editor consumes libs over its own WASM bridge.
*/
export function useLibs(kind?: "symbol" | "footprint") {
return useQuery({
queryKey: ["libs", kind ?? "all"],
queryFn: async (): Promise<Lib[]> => {
const res = await client.listLibs({ query: { kind } });
if (res.status !== 200) throw new Error("failed to list libraries");
return res.body;
},
});
}
export function useProject(slug: string) {
return useQuery({
queryKey: ["project", slug],

View file

@ -1,10 +1,11 @@
import * as React from "react";
import { Link } from "react-router-dom";
import type { Tool } from "@pcbjam/shared";
import { FolderOpen, Loader2 } from "lucide-react";
import { useProjects } from "@/lib/api";
import type { Lib, Tool } from "@pcbjam/shared";
import { FolderOpen, Library, Loader2, Package } from "lucide-react";
import { useLibs, useProjects } from "@/lib/api";
import { downloadBytes } from "@/lib/download";
import { Button } from "@/components/ui/button";
import { ToolGrid } from "@/components/ToolGrid";
import type { SaveBytes } from "@/wasm/save-flow";
import { LocalProjectView, type LocalFile } from "@/components/LocalProjectView";
import { WasmTool } from "@/components/WasmTool";
@ -99,19 +100,43 @@ function buildLocalProject(fileList: FileList): LocalProject {
export function HomePage() {
const { data: projects, isLoading, error } = useProjects();
const symbolLibs = useLibs("symbol");
const footprintLibs = useLibs("footprint");
const inputRef = React.useRef<HTMLInputElement>(null);
const [local, setLocal] = React.useState<LocalProject | null>(null);
const [launched, setLaunched] = React.useState<{ tool: Tool; target?: string } | null>(
null,
);
// A tool launched straight from the home page — no local folder, no backend
// project. File-less editors browse backend libraries; document editors
// (schematic/PCB/drawing sheet) boot to a blank document (KiCad-launcher style).
const [launchedTool, setLaunchedTool] = React.useState<{ tool: Tool } | null>(null);
// <input webkitdirectory> is non-standard; set it imperatively.
React.useEffect(() => {
if (inputRef.current) inputRef.current.setAttribute("webkitdirectory", "");
}, []);
// Once launched, the WASM runtime is process-global and one-shot for this page
// load — render the editor full-screen and nothing else (no going back).
// Tool launched from the home page: no project, no files. File-less editors
// read libraries from the backend (libsSourceConfig) and persist through the
// lib write bridge; document editors open a blank document. Either way there's
// no project fetch/save plumbing to wire.
if (launchedTool) {
return (
<WasmTool
tool={launchedTool.tool}
slug="local"
projectId="local"
files={[]}
fetchBytes={async (p) => {
throw new Error(`no project file to fetch: ${p}`);
}}
/>
);
}
// Once launched over a local folder, the WASM runtime is process-global and
// one-shot for this page load — render the editor full-screen and nothing else.
if (local && launched) {
return (
<WasmTool
@ -189,8 +214,14 @@ export function HomePage() {
)}
</section>
{/* --- Tools (KiCad-style launcher for the standalone tools) --- */}
<section className="mb-10">
<h2 className="mb-3 text-lg font-medium">Tools</h2>
<ToolGrid onLaunch={(tool) => setLaunchedTool({ tool })} />
</section>
{/* --- Backend projects --- */}
<section>
<section className="mb-10">
<h2 className="mb-3 text-lg font-medium">Projects from the backend</h2>
{isLoading && (
<p className="flex items-center gap-2 text-muted-foreground">
@ -225,6 +256,85 @@ export function HomePage() {
)}
</div>
</section>
{/* --- Backend libraries --- */}
<section>
<h2 className="mb-3 text-lg font-medium">Libraries from the backend</h2>
<div className="space-y-3">
<LibGroup
icon={<Library size={16} />}
label="Symbols"
query={symbolLibs}
onOpen={() => setLaunchedTool({ tool: "symbol_editor" })}
/>
<LibGroup
icon={<Package size={16} />}
label="Footprints"
query={footprintLibs}
onOpen={() => setLaunchedTool({ tool: "footprint_editor" })}
/>
</div>
</section>
</div>
);
}
/**
* One backend-library group (Symbols / Footprints): clickable chips that launch
* the matching editor to browse the library. The editor lists ALL libs of that
* kind in its own tree (lib-scoped open is a later iteration); clicking here
* just opens the right tool.
*/
function LibGroup({
icon,
label,
query,
onOpen,
}: {
icon: React.ReactNode;
label: string;
query: ReturnType<typeof useLibs>;
onOpen: (lib: Lib) => void;
}) {
const { data: libs, isLoading, error } = query;
return (
<div className="rounded-lg border p-4">
<h3 className="mb-2 flex items-center gap-2 text-sm font-medium">
{icon} {label}
</h3>
{isLoading && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="animate-spin" size={14} /> loading
</p>
)}
{error && (
<p className="text-sm text-muted-foreground">No backend reachable.</p>
)}
{libs && libs.length === 0 && (
<p className="text-sm text-muted-foreground">
No {label.toLowerCase()} libraries.
</p>
)}
{libs && libs.length > 0 && (
<div className="flex flex-wrap gap-2">
{libs.map((lib) => (
<button
key={lib.id}
type="button"
onClick={() => onOpen(lib)}
title={lib.description ?? undefined}
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-sm hover:bg-accent"
>
{lib.name}
{lib.itemCount !== undefined && (
<span className="text-xs text-muted-foreground">
{lib.itemCount}
</span>
)}
</button>
))}
</div>
)}
</div>
);
}