feat(standalone): device-capability preflight check (0001)

Add a pre-boot "potato check" for the standalone editor: probe the device
against what the KiCad WASM tools actually require and warn (or block, with an
override) before the expensive WASM asset fetch.

- preflight/capabilities.ts: pure, unit-testable probe → CapabilityReport with
  stable codes. Fatal: no SharedArrayBuffer/cross-origin-isolation, no
  WebAssembly, no Workers+Atomics, no WebGL2. Warn: low deviceMemory, low JS
  heap limit (Chromium), few cores, mobile, small screen. Thresholds in one
  tunable const block.
- preflight/BlockingDialog.tsx: reusable modal blocking overlay (no dismiss
  except via explicit action) — also for feature 0002's terminal dialog.
- preflight/PreflightGate.tsx: runs the probe once on mount; fatal → blocking
  dialog with "Try anyway" (short-circuits the asset fetch); warnings →
  dismissible advisory banner with localStorage "don't show again" keyed to the
  exact warning codes.
- ToolPage: wrap WasmTool in PreflightGate so the gate runs before boot.

Optional micro-bench (phase 3) deferred per the spec.

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

View file

@ -2,6 +2,7 @@ import { useParams } from "react-router-dom";
import { toolSchema } from "@pcbjam/shared";
import { fetchFileBytes, useProject } from "@/lib/api";
import { WasmTool } from "@/components/WasmTool";
import { PreflightGate } from "@/preflight/PreflightGate";
export function ToolPage() {
const params = useParams();
@ -30,14 +31,18 @@ export function ToolPage() {
);
}
// PreflightGate runs the device-capability check; on a fatal mismatch it blocks
// here (before WasmTool mounts) so the expensive WASM asset fetch is skipped.
return (
<WasmTool
tool={parsedTool.data}
slug={slug}
projectId={data.project.id}
files={data.files}
targetPath={targetPath}
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
/>
<PreflightGate>
<WasmTool
tool={parsedTool.data}
slug={slug}
projectId={data.project.id}
files={data.files}
targetPath={targetPath}
fetchBytes={(relPath) => fetchFileBytes(slug, relPath)}
/>
</PreflightGate>
);
}

View file

@ -0,0 +1,77 @@
import * as React from "react";
import { Button, type ButtonProps } from "@/components/ui/button";
/**
* A self-contained, modal blocking overlay intentionally NOT the radix
* `Dialog` (which ships a close "X" and closes on outside-click). Preflight's
* fatal case and feature 0002's terminal "out of memory" dialog both need a
* modal the user cannot dismiss except via an explicit action button, so this
* presentational component owns the whole overlay.
*/
export interface BlockingReason {
title: string;
detail: string;
}
export interface BlockingAction {
label: string;
onClick: () => void;
variant?: ButtonProps["variant"];
}
export function BlockingDialog({
title,
description,
reasons = [],
primary,
secondary,
}: {
title: string;
description?: string;
reasons?: BlockingReason[];
/** Right-most button (e.g. the "Try anyway" escape hatch). */
primary?: BlockingAction;
/** Left-of-primary button. */
secondary?: BlockingAction;
}) {
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
>
<div className="w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg">
<h2 className="text-lg font-semibold leading-none tracking-tight">{title}</h2>
{description && (
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
)}
{reasons.length > 0 && (
<ul className="mt-4 space-y-2 text-sm">
{reasons.map((r) => (
<li key={r.title} className="rounded-md border border-border/60 p-3">
<div className="font-medium">{r.title}</div>
<div className="text-muted-foreground">{r.detail}</div>
</li>
))}
</ul>
)}
{(primary || secondary) && (
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
{secondary && (
<Button variant={secondary.variant ?? "outline"} onClick={secondary.onClick}>
{secondary.label}
</Button>
)}
{primary && (
<Button variant={primary.variant ?? "default"} onClick={primary.onClick}>
{primary.label}
</Button>
)}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
import * as React from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { BlockingDialog } from "./BlockingDialog";
import { probeCapabilities, type CapabilityReport } from "./capabilities";
/**
* Wraps the tool boot with a device-capability check (feature 0001). On mount it
* runs `probeCapabilities()` exactly once and decides:
*
* - no issues render `children` immediately (boot proceeds).
* - warnings render `children` + a dismissible advisory banner overlay.
* - any fatal render a blocking dialog INSTEAD of `children` (so the
* expensive WASM asset fetch is short-circuited), with a
* "Try anyway" override that then renders `children`.
*
* The override decision (user): warn but allow proceeding in every case.
*/
const DISMISS_PREFIX = "pcbjam:preflight:dismissed:";
/** Dismissal is keyed to the exact set of warning codes seen, so a new kind of
* warning still shows even after the user dismissed a previous one. */
function dismissKey(report: CapabilityReport): string {
const codes = report.warnings.map((w) => w.code).sort();
return DISMISS_PREFIX + codes.join(",");
}
function readDismissed(key: string): boolean {
try {
return localStorage.getItem(key) === "1";
} catch {
return false;
}
}
export function PreflightGate({ children }: { children: React.ReactNode }) {
// Probe once; capabilities don't change within a page load.
const [report] = React.useState<CapabilityReport>(() => probeCapabilities());
const [override, setOverride] = React.useState(false);
const key = dismissKey(report);
const [bannerHidden, setBannerHidden] = React.useState(() => readDismissed(key));
const hasFatal = report.fatal.length > 0;
const hasWarnings = report.warnings.length > 0;
// Fatal + not yet overridden: block before children mount (no asset fetch).
if (hasFatal && !override) {
return (
<BlockingDialog
title="This device can't run the editor"
description="We detected one or more requirements that aren't met. You can try anyway, but the editor will most likely fail to start."
reasons={report.fatal.map((f) => ({ title: f.title, detail: f.detail }))}
primary={{ label: "Try anyway", variant: "destructive", onClick: () => setOverride(true) }}
/>
);
}
const showBanner = hasWarnings && !bannerHidden;
return (
<>
{showBanner && (
<PreflightBanner
report={report}
onDismiss={() => setBannerHidden(true)}
onDontShowAgain={() => {
try {
localStorage.setItem(key, "1");
} catch {
/* private mode / storage disabled — dismiss for this session only */
}
setBannerHidden(true);
}}
/>
)}
{children}
</>
);
}
function PreflightBanner({
report,
onDismiss,
onDontShowAgain,
}: {
report: CapabilityReport;
onDismiss: () => void;
onDontShowAgain: () => void;
}) {
return (
<div className="pointer-events-auto fixed inset-x-0 top-0 z-30 border-b border-amber-500/40 bg-amber-950/90 px-4 py-3 text-amber-100 shadow-lg backdrop-blur">
<div className="mx-auto flex max-w-3xl items-start gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">
This device may struggle to run the editor
</div>
<ul className="mt-1 list-disc space-y-0.5 pl-5 text-xs text-amber-200/90">
{report.warnings.map((w) => (
<li key={w.code}>
<span className="font-medium">{w.title}:</span> {w.detail}
</li>
))}
</ul>
<div className="mt-2 flex gap-2">
<Button size="sm" variant="secondary" onClick={onDontShowAgain}>
Don't show again
</Button>
</div>
</div>
<button
type="button"
aria-label="Dismiss"
onClick={onDismiss}
className="rounded-sm opacity-70 transition-opacity hover:opacity-100"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,250 @@
/**
* Device-capability preflight ("potato check") feature 0001.
*
* A pure, unit-testable probe of the browser/device against what the KiCad WASM
* editor actually requires. It does NOT block anything by itself; it returns a
* report that `PreflightGate` turns into a blocking dialog (fatal) or an advisory
* banner (warnings). The four `fatal` checks are the genuinely-cannot-run ones
* (no SharedArrayBuffer / WASM / threads / WebGL2 all load-bearing facts in
* boot.ts and vite.config.ts); everything else is a soft `warn`.
*
* Every issue carries a stable `code` so warnings can be correlated with real OOM
* reports later (feature 0003) and so a "don't show again" choice can be keyed to
* the exact set of issues seen.
*/
export interface CapabilityIssue {
/** Stable identifier for analytics + dismissal keys (e.g. "no-sab"). */
code: string;
title: string;
detail: string;
}
export interface CapabilityReport {
/** Cannot run at all (blocking dialog, with a "Try anyway" override). */
fatal: CapabilityIssue[];
/** May run but risky (dismissible advisory banner). */
warnings: CapabilityIssue[];
/** Raw measured values, for warning copy + telemetry/debug. */
info: Record<string, unknown>;
}
/**
* Tunable thresholds in one place so they can be adjusted without touching the
* probe logic. `deviceMemory` is capped at 8 and coarse by spec treat as a hint.
*/
export const THRESHOLDS = {
/** GB; `navigator.deviceMemory` below this warns. */
minDeviceMemoryGb: 4,
/** Bytes; Chromium `performance.memory.jsHeapSizeLimit` below this warns. */
minHeapLimitBytes: 2 * 1024 ** 3,
/** `navigator.hardwareConcurrency` below this warns. */
minCores: 4,
/** Device pixels (`screen.width * devicePixelRatio`) below this warns. */
minScreenWidthPx: 1024,
} as const;
// `navigator`/`performance` carry non-standard, browser-specific fields we read
// only when present. Narrow casts keep the probe honest without `any`.
interface DeviceNavigator {
deviceMemory?: number;
hardwareConcurrency?: number;
userAgent?: string;
userAgentData?: { mobile?: boolean };
}
interface MemoryPerformance {
memory?: { jsHeapSizeLimit?: number };
}
function getNavigator(): DeviceNavigator | undefined {
return typeof navigator === "undefined"
? undefined
: (navigator as unknown as DeviceNavigator);
}
/**
* Probe WebGL2 once: returns whether a context can be created and, if so, the
* unmasked renderer string (handy for the warning copy + OOM correlation).
* Returns `supported: null` when no canvas API is available to test with (e.g.
* a non-DOM test env) the caller must NOT treat "couldn't test" as fatal.
*/
function probeWebgl2(): { supported: boolean | null; renderer?: string } {
let canvas: HTMLCanvasElement | OffscreenCanvas | undefined;
try {
if (typeof OffscreenCanvas !== "undefined") {
canvas = new OffscreenCanvas(1, 1);
} else if (typeof document !== "undefined") {
canvas = document.createElement("canvas");
}
} catch {
/* fall through to "untestable" */
}
if (!canvas) return { supported: null };
let gl: WebGL2RenderingContext | null = null;
try {
gl = canvas.getContext("webgl2") as WebGL2RenderingContext | null;
} catch {
return { supported: false };
}
if (!gl) return { supported: false };
let renderer: string | undefined;
try {
const ext = gl.getExtension("WEBGL_debug_renderer_info");
if (ext) {
renderer = gl.getParameter(
(ext as { UNMASKED_RENDERER_WEBGL: number }).UNMASKED_RENDERER_WEBGL,
) as string;
}
} catch {
/* renderer is best-effort */
}
return { supported: true, renderer };
}
/** Inspect the current environment; never throws. */
export function probeCapabilities(): CapabilityReport {
const fatal: CapabilityIssue[] = [];
const warnings: CapabilityIssue[] = [];
const info: Record<string, unknown> = {};
const nav = getNavigator();
info.userAgent = nav?.userAgent;
// --- fatal: SharedArrayBuffer + cross-origin isolation ---------------------
// No SAB ⇒ KiCad's pthreads cannot exist (vite sets COOP/COEP precisely to
// enable it). `crossOriginIsolated` is the reliable truth; prefer it over UA.
const hasSAB = typeof SharedArrayBuffer !== "undefined";
const isolated =
typeof crossOriginIsolated === "boolean" ? crossOriginIsolated : undefined;
info.sharedArrayBuffer = hasSAB;
info.crossOriginIsolated = isolated;
if (!hasSAB || isolated === false) {
fatal.push({
code: "no-sab",
title: "Shared memory is unavailable",
detail:
"The editor needs SharedArrayBuffer with cross-origin isolation. " +
"This usually means a desktop Chrome or Edge browser is required; " +
"Safari and iOS lack the threading support KiCad relies on.",
});
}
// --- fatal: WebAssembly ----------------------------------------------------
const hasWasm = typeof WebAssembly === "object";
info.webAssembly = hasWasm;
if (!hasWasm) {
fatal.push({
code: "no-wasm",
title: "WebAssembly is not supported",
detail: "This browser cannot run WebAssembly, which the editor is built on.",
});
}
// --- fatal: threads (Workers + Atomics) ------------------------------------
// Only meaningful when SAB exists; otherwise no-sab already explains it.
const hasThreads =
typeof Worker !== "undefined" && typeof Atomics !== "undefined";
info.threads = hasThreads;
if (hasSAB && !hasThreads) {
fatal.push({
code: "no-threads",
title: "Threads are unavailable",
detail:
"Web Workers and Atomics are required for KiCad's threaded runtime.",
});
}
// --- fatal: WebGL2 ---------------------------------------------------------
const gl = probeWebgl2();
info.webgl2 = gl.supported;
info.glRenderer = gl.renderer;
if (gl.supported === false) {
fatal.push({
code: "no-webgl2",
title: "WebGL2 is unavailable",
detail:
"The editor renders through WebGL2. Enable hardware acceleration, " +
"or try a different browser.",
});
}
// --- warn: low device memory ----------------------------------------------
const deviceMemory = nav?.deviceMemory;
info.deviceMemory = deviceMemory;
if (typeof deviceMemory === "number" && deviceMemory < THRESHOLDS.minDeviceMemoryGb) {
warnings.push({
code: "low-memory",
title: "Limited memory",
detail:
`This device reports about ${deviceMemory} GB of RAM. The editor is ` +
"memory-hungry and may run out of memory on large boards.",
});
}
// --- warn: low JS heap limit (Chromium only) -------------------------------
const perf =
typeof performance === "undefined"
? undefined
: (performance as unknown as MemoryPerformance);
const heapLimit = perf?.memory?.jsHeapSizeLimit;
info.jsHeapSizeLimit = heapLimit;
if (typeof heapLimit === "number" && heapLimit < THRESHOLDS.minHeapLimitBytes) {
warnings.push({
code: "low-heap",
title: "Small memory budget",
detail:
"The browser's JavaScript heap limit is low, so large designs may run " +
"out of memory.",
});
}
// --- warn: few cores -------------------------------------------------------
const cores = nav?.hardwareConcurrency;
info.hardwareConcurrency = cores;
if (typeof cores === "number" && cores < THRESHOLDS.minCores) {
warnings.push({
code: "few-cores",
title: "Few CPU cores",
detail:
`This device reports ${cores} logical core(s); the editor may feel slow.`,
});
}
// --- warn: mobile ----------------------------------------------------------
const uaMobile = nav?.userAgentData?.mobile;
const coarsePointer =
typeof matchMedia === "function" &&
matchMedia("(pointer: coarse)").matches &&
matchMedia("(max-width: 900px)").matches;
const isMobile = uaMobile === true || coarsePointer;
info.mobile = isMobile;
if (isMobile) {
warnings.push({
code: "mobile",
title: "Mobile device detected",
detail:
"The editor is designed for desktop. A mouse and a larger screen are " +
"strongly recommended.",
});
}
// --- warn: small screen ----------------------------------------------------
const dpr = typeof devicePixelRatio === "number" ? devicePixelRatio : 1;
const screenWidthPx =
typeof screen === "undefined" ? undefined : screen.width * dpr;
info.screenWidthPx = screenWidthPx;
if (
typeof screenWidthPx === "number" &&
screenWidthPx < THRESHOLDS.minScreenWidthPx
) {
warnings.push({
code: "small-screen",
title: "Small screen",
detail: "The editor's panels and toolbars need more screen space to be usable.",
});
}
return { fatal, warnings, info };
}