+
-
+
+
+
+
+
+ {/* What the execution mode does to capture, stated where the captured
+ frames are rather than in the global toolbar. */}
+
+ {mode === "educational" ? t("mode.captureOn") : t("mode.captureOff")}
+
);
diff --git a/client/src/components/chartColors.ts b/client/src/components/chartColors.ts
index 569e88e..0f02f2a 100644
--- a/client/src/components/chartColors.ts
+++ b/client/src/components/chartColors.ts
@@ -1,6 +1,10 @@
-/** Semantic line colours for the training charts (mirror the theme). */
+/**
+ * Semantic plot colours for the training charts, from the plot palette:
+ * coral for the loss (error), emerald for held-out accuracy (positive), and
+ * desaturated slate for the train-batch reference trace.
+ */
export const CHART_COLORS = {
loss: "#f85149",
heldOut: "#3fb950",
- trainBatch: "#8b949e",
+ trainBatch: "#64748b",
} as const;
diff --git a/client/src/components/datasetFields.ts b/client/src/components/datasetFields.ts
new file mode 100644
index 0000000..b9d47c7
--- /dev/null
+++ b/client/src/components/datasetFields.ts
@@ -0,0 +1,64 @@
+/**
+ * The dataset picker's options and the dataset facts, built from what the
+ * server listed.
+ *
+ * The names and counts are the server's own; nothing here is estimated or
+ * filled in when the list has not arrived.
+ */
+
+import type { TranslationKey } from "../i18n/translations";
+import type { DatasetInfo } from "../types";
+import type { Fact } from "./FactGrid";
+import type { Option } from "./SelectField";
+
+type Translate = (key: TranslationKey) => string;
+
+/** Human label for a dataset option, including modality and availability. */
+export function datasetLabel(d: DatasetInfo, t: Translate): string {
+ const base = `${d.name} (${d.classes} ${t("dataset.classes")})`;
+ if (d.modality !== "event") return base;
+ if (d.available === false) {
+ return `${base} — ${t("dataset.unavailable")}`;
+ }
+ return `${base} — ${t("dataset.events")}`;
+}
+
+/** Build the dataset dropdown, disabling event sets with no tonic loader. */
+export function datasetOptions(
+ datasets: DatasetInfo[],
+ current: string,
+ t: Translate,
+): Option[] {
+ if (datasets.length === 0) return [{ value: current, label: current }];
+ return datasets.map((d) => ({
+ value: d.name,
+ label: datasetLabel(d, t),
+ // Unavailable events would otherwise fail inside the download worker.
+ disabled: d.modality === "event" && d.available === false,
+ }));
+}
+
+/** The properties of the configured dataset, or none when it is unlisted. */
+export function datasetFacts(
+ dataset: DatasetInfo | undefined,
+ t: Translate,
+): Fact[] {
+ if (dataset === undefined) return [];
+ return [
+ {
+ label: t("dataset.modality"),
+ value:
+ dataset.modality === "event"
+ ? t("modality.event")
+ : t("modality.image"),
+ },
+ { label: t("dataset.classes"), value: String(dataset.classes) },
+ {
+ label: t("dataset.availability"),
+ value:
+ dataset.available === false
+ ? t("dataset.unavailable")
+ : t("dataset.available"),
+ },
+ ];
+}
diff --git a/client/src/components/pipelineInput.ts b/client/src/components/pipelineInput.ts
new file mode 100644
index 0000000..a5452c1
--- /dev/null
+++ b/client/src/components/pipelineInput.ts
@@ -0,0 +1,96 @@
+/**
+ * The run-input template a pipeline starts from.
+ *
+ * A pipeline feeds its source nodes with `{frames, encoded}`, the same body
+ * `spikeforge-serve` reads from stdin. The two modes are not interchangeable:
+ *
+ * - `encoded: true` steps each frame straight into the first layer. Nothing
+ * reshapes it, so the frame has to already match that layer's expected
+ * input. A flat vector works for a fully connected topology and fails for a
+ * convolutional one, which needs `[C, H, W]`.
+ * - `encoded: false` treats each frame as a **raw sample**. The server encodes
+ * it with the checkpoint's own frozen encode spec and lays the result out
+ * for the topology's input stage, so one body works for every topology.
+ *
+ * The template therefore uses the raw form. A raw sample must be at least 3-D
+ * (`[C, H, W]`) or the server reads a 2-D payload as a *batch of rows* rather
+ * than one image -- which is what made an earlier hand-written `[H][W]`
+ * template fail with a matrix-shape error.
+ *
+ * One raw frame is a complete run: the encoder expands it into the whole spike
+ * train and the session steps every timestep, so the template does not need to
+ * repeat itself once per step.
+ */
+
+import type { SavedModel } from "../types";
+
+/** Input geometry assumed when a checkpoint recorded none (MNIST-shaped). */
+const DEFAULT_GEOMETRY: readonly [number, number] = [28, 28];
+
+/** Metadata a checkpoint may record about the input it expects. */
+interface GeometrySource {
+ input_size?: unknown;
+}
+
+/** Return `value` as a positive integer, or `fallback` when it is not one. */
+function positiveInt(value: unknown, fallback: number): number {
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) {
+ return fallback;
+ }
+ return Math.floor(value);
+}
+
+/** Read `input_size` off a metadata block that may be absent or malformed. */
+function geometryFrom(block: unknown): readonly [number, number] | null {
+ if (typeof block !== "object" || block === null) return null;
+ const value = (block as GeometrySource).input_size;
+ if (!Array.isArray(value) || value.length !== 2) return null;
+ const [height, width] = value;
+ if (typeof height !== "number" || typeof width !== "number") return null;
+ return [
+ positiveInt(height, DEFAULT_GEOMETRY[0]),
+ positiveInt(width, DEFAULT_GEOMETRY[1]),
+ ];
+}
+
+/**
+ * The `[height, width]` a checkpoint was trained on.
+ *
+ * Checked in the order the engine itself resolves them: the frozen encode
+ * spec a bundle carries, then the topology's own parameters, then the
+ * top-level field, then the default. Reading only the last of those -- which
+ * is null for every dataset that uses the default geometry -- silently
+ * produced an MNIST-shaped template for checkpoints that were not.
+ */
+export function inputGeometry(
+ model: SavedModel | undefined,
+): readonly [number, number] {
+ const meta = model?.meta ?? {};
+ return (
+ geometryFrom(meta.encode_spec) ??
+ geometryFrom(meta.topology_params) ??
+ geometryFrom(meta) ??
+ DEFAULT_GEOMETRY
+ );
+}
+
+/**
+ * A comparable description of what a checkpoint accepts.
+ *
+ * Two source nodes fed by one run body must agree on this, or the body cannot
+ * satisfy both.
+ */
+export function inputContract(model: SavedModel | undefined): string {
+ const [height, width] = inputGeometry(model);
+ return `${height}x${width}`;
+}
+
+/** Build a runnable `{frames, encoded}` body for `model`. */
+export function inputTemplate(model?: SavedModel): string {
+ const [height, width] = inputGeometry(model);
+ // One channel, matching the grayscale samples every bundled dataset ships.
+ const sample = [
+ Array.from({ length: height }, () => new Array(width).fill(0)),
+ ];
+ return JSON.stringify({ frames: [sample], encoded: false });
+}
diff --git a/client/src/components/pipelineSources.ts b/client/src/components/pipelineSources.ts
new file mode 100644
index 0000000..9847b35
--- /dev/null
+++ b/client/src/components/pipelineSources.ts
@@ -0,0 +1,43 @@
+/**
+ * Which nodes a pipeline run actually feeds.
+ *
+ * `run_pipeline` hands the request body to every node with **no incoming
+ * edge**. Those are the nodes whose input contract the run body has to match.
+ * Position in `graph.nodes` says nothing about that: the interface lets a user
+ * add nodes in any order and connect them afterwards, so the first entry in
+ * the array is frequently a downstream node.
+ */
+
+import type { PipelineGraph } from "../pipelineTypes";
+import type { SavedModel } from "../types";
+
+type GraphNode = NonNullable
[number];
+
+/**
+ * Return the nodes with no incoming edge, in graph order.
+ *
+ * A graph with nodes but no edges has every node as a source, which is
+ * correct: the runner feeds all of them.
+ */
+export function sourceNodes(graph: PipelineGraph): GraphNode[] {
+ const nodes = graph.nodes ?? [];
+ const edges = graph.edges ?? [];
+ return nodes.filter(
+ (node) => !edges.some((edge) => edge.target === node.id),
+ );
+}
+
+/** The saved checkpoints the run body will be fed into. */
+export function sourceModels(
+ graph: PipelineGraph,
+ models: SavedModel[],
+): SavedModel[] {
+ const found: SavedModel[] = [];
+ for (const node of sourceNodes(graph)) {
+ const model = models.find(
+ (candidate) => candidate.name === node.checkpoint,
+ );
+ if (model !== undefined) found.push(model);
+ }
+ return found;
+}
diff --git a/client/src/helpText.ts b/client/src/helpText.ts
index 61df13e..dad2bf0 100644
--- a/client/src/helpText.ts
+++ b/client/src/helpText.ts
@@ -211,6 +211,10 @@ export const TRAIN_HELP: Record = {
"Surrogate gradient used to backprop through spikes. Default uses " +
"snnTorch's built-in Fast Sigmoid; the other names are snnTorch's " +
"surrogate factories and affect training only, never inference.",
+ stage_neurons:
+ "Override the neuron model for one named stage of the selected " +
+ "topology, for example lif1 or lif2. Stage names come from the " +
+ "topology; leave a stage out to use the Neuron setting above for it.",
resources:
"Live CPU RAM and GPU VRAM for the machine running the server. CPU RAM " +
"covers the whole host; VRAM is the GPU's memory. 'active' shows the " +
diff --git a/client/src/i18n/locales/de.ts b/client/src/i18n/locales/de.ts
index 1234eaa..801ca8a 100644
--- a/client/src/i18n/locales/de.ts
+++ b/client/src/i18n/locales/de.ts
@@ -15,6 +15,10 @@ export const DE: TranslationSet = {
"tab.pipeline.hint": "Gespeicherte Checkpoints als DAG ausführen",
"tab.sections": "Dashboard-Bereiche",
"tab.running": "läuft",
+ "nav.expand": "Navigation erweitern",
+ "nav.collapse": "Navigation einklappen",
+ "nav.donate": "Projekt unterstützen",
+ "viewer.inspector": "Inspektor",
"theme.light": "Zum hellen Design wechseln",
"theme.dark": "Zum dunklen Design wechseln",
"theme.toggle": "Farbschema wechseln",
@@ -115,4 +119,28 @@ export const DE: TranslationSet = {
"energy.target": "Energieziel",
"energy.empty":
"Noch keine Energieschätzung. Mit Schätzen das gewählte Ziel berechnen.",
+ "pane.data": "Daten & Codierung",
+ "pane.network": "Netzwerk",
+ "pane.collapse": "Bereich einklappen",
+ "pane.expand": "Bereich ausklappen",
+ "dataset.modality": "Modalität",
+ "dataset.availability": "Verfügbarkeit",
+ "dataset.available": "verfügbar",
+ "modality.image": "Bild",
+ "modality.event": "Ereignis",
+ "preview.sample": "Beispiel",
+ "arch.title": "Architektur",
+ "arch.input": "Eingabe",
+ "arch.encoder": "Encoder",
+ "arch.hidden": "Verdeckt",
+ "arch.output": "Ausgabe",
+ "arch.neurons": "Neuronen",
+ "arch.classes": "Klassen",
+ "arch.device": "Gerät",
+ "arch.gpuAvailable": "GPU verfügbar",
+ "arch.gpuUnavailable": "GPU nicht verfügbar",
+ "loaded.accuracy": "Genauigkeit",
+ "loaded.input": "Eingabe",
+ "loaded.hidden": "verdeckt",
+ "loaded.device": "Gerät",
};
diff --git a/client/src/i18n/locales/es.ts b/client/src/i18n/locales/es.ts
index f1a5c5f..a5b51f1 100644
--- a/client/src/i18n/locales/es.ts
+++ b/client/src/i18n/locales/es.ts
@@ -17,6 +17,10 @@ export const ES: TranslationSet = {
"tab.pipeline.hint": "Ejecuta puntos de control guardados como un DAG",
"tab.sections": "Secciones del panel",
"tab.running": "en ejecución",
+ "nav.expand": "Expandir la navegación",
+ "nav.collapse": "Contraer la navegación",
+ "nav.donate": "Apoyar el proyecto",
+ "viewer.inspector": "Inspector",
"theme.light": "Cambiar al tema claro",
"theme.dark": "Cambiar al tema oscuro",
"theme.toggle": "Cambiar tema de color",
@@ -117,4 +121,28 @@ export const ES: TranslationSet = {
"energy.target": "Destino energético",
"energy.empty":
"Aún no hay cálculo energético. Pulsa Estimar para analizar el destino seleccionado.",
+ "pane.data": "Datos y codificación",
+ "pane.network": "Red",
+ "pane.collapse": "Contraer panel",
+ "pane.expand": "Expandir panel",
+ "dataset.modality": "Modalidad",
+ "dataset.availability": "Disponibilidad",
+ "dataset.available": "disponible",
+ "modality.image": "imagen",
+ "modality.event": "evento",
+ "preview.sample": "Muestra",
+ "arch.title": "Arquitectura",
+ "arch.input": "Entrada",
+ "arch.encoder": "Codificador",
+ "arch.hidden": "Oculta",
+ "arch.output": "Salida",
+ "arch.neurons": "neuronas",
+ "arch.classes": "clases",
+ "arch.device": "Dispositivo",
+ "arch.gpuAvailable": "GPU disponible",
+ "arch.gpuUnavailable": "GPU no disponible",
+ "loaded.accuracy": "precisión",
+ "loaded.input": "entrada",
+ "loaded.hidden": "ocultas",
+ "loaded.device": "dispositivo",
};
diff --git a/client/src/i18n/locales/fr.ts b/client/src/i18n/locales/fr.ts
index adaec14..1e6f89a 100644
--- a/client/src/i18n/locales/fr.ts
+++ b/client/src/i18n/locales/fr.ts
@@ -9,6 +9,10 @@ export const FR: TranslationSet = {
"tab.pipeline": "Pipeline",
"tab.sections": "Sections du tableau de bord",
"tab.running": "en cours",
+ "nav.expand": "Développer la navigation",
+ "nav.collapse": "Réduire la navigation",
+ "nav.donate": "Soutenir le projet",
+ "viewer.inspector": "Inspecteur",
"theme.light": "Passer au thème clair",
"theme.dark": "Passer au thème sombre",
"theme.toggle": "Changer le thème",
@@ -59,4 +63,28 @@ export const FR: TranslationSet = {
"training.idle": "Pas d’entraînement",
"training.loss": "Perte (entraînement)",
"training.accuracy": "Précision (%)",
+ "pane.data": "Données et encodage",
+ "pane.network": "Réseau",
+ "pane.collapse": "Réduire le volet",
+ "pane.expand": "Développer le volet",
+ "dataset.modality": "Modalité",
+ "dataset.availability": "Disponibilité",
+ "dataset.available": "disponible",
+ "modality.image": "image",
+ "modality.event": "événement",
+ "preview.sample": "Échantillon",
+ "arch.title": "Architecture",
+ "arch.input": "Entrée",
+ "arch.encoder": "Encodeur",
+ "arch.hidden": "Cachée",
+ "arch.output": "Sortie",
+ "arch.neurons": "neurones",
+ "arch.classes": "classes",
+ "arch.device": "Appareil",
+ "arch.gpuAvailable": "GPU disponible",
+ "arch.gpuUnavailable": "GPU indisponible",
+ "loaded.accuracy": "précision",
+ "loaded.input": "entrée",
+ "loaded.hidden": "cachées",
+ "loaded.device": "appareil",
};
diff --git a/client/src/i18n/locales/it.ts b/client/src/i18n/locales/it.ts
index 5932422..32a680c 100644
--- a/client/src/i18n/locales/it.ts
+++ b/client/src/i18n/locales/it.ts
@@ -9,6 +9,10 @@ export const IT: TranslationSet = {
"tab.pipeline": "Pipeline",
"tab.sections": "Sezioni della dashboard",
"tab.running": "in esecuzione",
+ "nav.expand": "Espandi la navigazione",
+ "nav.collapse": "Comprimi la navigazione",
+ "nav.donate": "Sostieni il progetto",
+ "viewer.inspector": "Ispettore",
"theme.light": "Passa al tema chiaro",
"theme.dark": "Passa al tema scuro",
"theme.toggle": "Cambia tema",
@@ -58,4 +62,28 @@ export const IT: TranslationSet = {
"training.idle": "Non in addestramento",
"training.loss": "Perdita (addestramento)",
"training.accuracy": "Accuratezza (%)",
+ "pane.data": "Dati e codifica",
+ "pane.network": "Rete",
+ "pane.collapse": "Comprimi pannello",
+ "pane.expand": "Espandi pannello",
+ "dataset.modality": "Modalità",
+ "dataset.availability": "Disponibilità",
+ "dataset.available": "disponibile",
+ "modality.image": "immagine",
+ "modality.event": "evento",
+ "preview.sample": "Campione",
+ "arch.title": "Architettura",
+ "arch.input": "Ingresso",
+ "arch.encoder": "Codificatore",
+ "arch.hidden": "Nascosti",
+ "arch.output": "Uscita",
+ "arch.neurons": "neuroni",
+ "arch.classes": "classi",
+ "arch.device": "Dispositivo",
+ "arch.gpuAvailable": "GPU disponibile",
+ "arch.gpuUnavailable": "GPU non disponibile",
+ "loaded.accuracy": "precisione",
+ "loaded.input": "ingresso",
+ "loaded.hidden": "nascosti",
+ "loaded.device": "dispositivo",
};
diff --git a/client/src/i18n/locales/ja.ts b/client/src/i18n/locales/ja.ts
index c8e0ca5..da7895e 100644
--- a/client/src/i18n/locales/ja.ts
+++ b/client/src/i18n/locales/ja.ts
@@ -16,6 +16,10 @@ export const JA: TranslationSet = {
"tab.pipeline.hint": "保存済みチェックポイントを DAG として実行",
"tab.sections": "ダッシュボードのセクション",
"tab.running": "実行中",
+ "nav.expand": "ナビゲーションを展開",
+ "nav.collapse": "ナビゲーションを折りたたむ",
+ "nav.donate": "プロジェクトを支援",
+ "viewer.inspector": "インスペクター",
"theme.light": "ライトテーマに切り替える",
"theme.dark": "ダークテーマに切り替える",
"theme.toggle": "カラーテーマを切り替える",
@@ -114,4 +118,28 @@ export const JA: TranslationSet = {
"energy.target": "エネルギー対象",
"energy.empty":
"エネルギー推定はまだありません。推定を押して選択したターゲットを計算します。",
+ "pane.data": "データとエンコーディング",
+ "pane.network": "ネットワーク",
+ "pane.collapse": "ペインを折りたたむ",
+ "pane.expand": "ペインを展開する",
+ "dataset.modality": "モダリティ",
+ "dataset.availability": "利用可否",
+ "dataset.available": "利用可",
+ "modality.image": "画像",
+ "modality.event": "イベント",
+ "preview.sample": "サンプル",
+ "arch.title": "アーキテクチャ",
+ "arch.input": "入力",
+ "arch.encoder": "エンコーダー",
+ "arch.hidden": "隠れ層",
+ "arch.output": "出力",
+ "arch.neurons": "ニューロン",
+ "arch.classes": "クラス",
+ "arch.device": "デバイス",
+ "arch.gpuAvailable": "GPU 利用可",
+ "arch.gpuUnavailable": "GPU 利用不可",
+ "loaded.accuracy": "精度",
+ "loaded.input": "入力",
+ "loaded.hidden": "隠れ層",
+ "loaded.device": "デバイス",
};
diff --git a/client/src/i18n/locales/ko.ts b/client/src/i18n/locales/ko.ts
index 7ce04ad..4e67299 100644
--- a/client/src/i18n/locales/ko.ts
+++ b/client/src/i18n/locales/ko.ts
@@ -15,6 +15,10 @@ export const KO: TranslationSet = {
"tab.pipeline.hint": "저장된 체크포인트를 DAG로 실행",
"tab.sections": "대시보드 섹션",
"tab.running": "실행 중",
+ "nav.expand": "탐색 펼치기",
+ "nav.collapse": "탐색 접기",
+ "nav.donate": "프로젝트 후원",
+ "viewer.inspector": "검사기",
"theme.light": "라이트 테마로 전환",
"theme.dark": "다크 테마로 전환",
"theme.toggle": "색상 테마 전환",
@@ -113,4 +117,28 @@ export const KO: TranslationSet = {
"energy.target": "에너지 대상",
"energy.empty":
"에너지 추정치가 없습니다. 추정을 눌러 선택한 대상을 계산하세요.",
+ "pane.data": "데이터 및 인코딩",
+ "pane.network": "네트워크",
+ "pane.collapse": "창 접기",
+ "pane.expand": "창 펼치기",
+ "dataset.modality": "모달리티",
+ "dataset.availability": "사용 가능 여부",
+ "dataset.available": "사용 가능",
+ "modality.image": "이미지",
+ "modality.event": "이벤트",
+ "preview.sample": "샘플",
+ "arch.title": "아키텍처",
+ "arch.input": "입력",
+ "arch.encoder": "인코더",
+ "arch.hidden": "은닉층",
+ "arch.output": "출력",
+ "arch.neurons": "뉴런",
+ "arch.classes": "클래스",
+ "arch.device": "장치",
+ "arch.gpuAvailable": "GPU 사용 가능",
+ "arch.gpuUnavailable": "GPU 사용 불가",
+ "loaded.accuracy": "정확도",
+ "loaded.input": "입력",
+ "loaded.hidden": "은닉층",
+ "loaded.device": "장치",
};
diff --git a/client/src/i18n/locales/pt.ts b/client/src/i18n/locales/pt.ts
index 556446f..3587ecd 100644
--- a/client/src/i18n/locales/pt.ts
+++ b/client/src/i18n/locales/pt.ts
@@ -9,6 +9,10 @@ export const PT: TranslationSet = {
"tab.pipeline": "Pipeline",
"tab.sections": "Seções do painel",
"tab.running": "em execução",
+ "nav.expand": "Expandir a navegação",
+ "nav.collapse": "Recolher a navegação",
+ "nav.donate": "Apoiar o projeto",
+ "viewer.inspector": "Inspetor",
"theme.light": "Mudar para tema claro",
"theme.dark": "Mudar para tema escuro",
"theme.toggle": "Alternar tema",
@@ -58,4 +62,28 @@ export const PT: TranslationSet = {
"training.idle": "Sem treino",
"training.loss": "Perda (treino)",
"training.accuracy": "Precisão (%)",
+ "pane.data": "Dados e codificação",
+ "pane.network": "Rede",
+ "pane.collapse": "Recolher painel",
+ "pane.expand": "Expandir painel",
+ "dataset.modality": "Modalidade",
+ "dataset.availability": "Disponibilidade",
+ "dataset.available": "disponível",
+ "modality.image": "imagem",
+ "modality.event": "evento",
+ "preview.sample": "Amostra",
+ "arch.title": "Arquitetura",
+ "arch.input": "Entrada",
+ "arch.encoder": "Codificador",
+ "arch.hidden": "Ocultos",
+ "arch.output": "Saída",
+ "arch.neurons": "neurônios",
+ "arch.classes": "classes",
+ "arch.device": "Dispositivo",
+ "arch.gpuAvailable": "GPU disponível",
+ "arch.gpuUnavailable": "GPU indisponível",
+ "loaded.accuracy": "precisão",
+ "loaded.input": "entrada",
+ "loaded.hidden": "ocultos",
+ "loaded.device": "dispositivo",
};
diff --git a/client/src/i18n/translations.ts b/client/src/i18n/translations.ts
index a3a8e99..961c8b7 100644
--- a/client/src/i18n/translations.ts
+++ b/client/src/i18n/translations.ts
@@ -25,6 +25,10 @@ export const EN = {
"tab.pipeline.hint": "Chain saved checkpoints into a DAG and run it",
"tab.sections": "Dashboard sections",
"tab.running": "running",
+ "nav.expand": "Expand navigation",
+ "nav.collapse": "Collapse navigation",
+ "nav.donate": "Support the project",
+ "viewer.inspector": "Inspector",
"theme.light": "Switch to light theme",
"theme.dark": "Switch to dark theme",
"theme.toggle": "Toggle color theme",
@@ -127,6 +131,34 @@ export const EN = {
"energy.empty":
"No energy estimate yet. Press Estimate to account the configured " +
"topology's event-driven operations for the selected target.",
+ // --- docked panes ------------------------------------------------------
+ "pane.data": "Data & Encoding",
+ "pane.network": "Network",
+ "pane.collapse": "Collapse pane",
+ "pane.expand": "Expand pane",
+ // --- dataset metadata and the input preview ----------------------------
+ "dataset.modality": "Modality",
+ "dataset.availability": "Availability",
+ "dataset.available": "available",
+ "modality.image": "image",
+ "modality.event": "event",
+ "preview.sample": "Sample",
+ // --- architecture strip ------------------------------------------------
+ "arch.title": "Architecture",
+ "arch.input": "Input",
+ "arch.encoder": "Encoder",
+ "arch.hidden": "Hidden",
+ "arch.output": "Output",
+ "arch.neurons": "neurons",
+ "arch.classes": "classes",
+ "arch.device": "Device",
+ "arch.gpuAvailable": "GPU available",
+ "arch.gpuUnavailable": "GPU unavailable",
+ // --- loaded checkpoint summary -----------------------------------------
+ "loaded.accuracy": "accuracy",
+ "loaded.input": "input",
+ "loaded.hidden": "hidden",
+ "loaded.device": "device",
} as const;
export type TranslationKey = keyof typeof EN;
diff --git a/client/src/styles.css b/client/src/styles.css
index b118c9a..855cfa6 100644
--- a/client/src/styles.css
+++ b/client/src/styles.css
@@ -1,5 +1,9 @@
@import "./styles/base.css";
+@import "./styles/primitives.css";
+@import "./styles/readouts.css";
+@import "./styles/shell.css";
@import "./styles/tabs.css";
+@import "./styles/dock.css";
@import "./styles/sections.css";
@import "./styles/controls.css";
@import "./styles/feedback.css";
diff --git a/client/src/styles/analysis.css b/client/src/styles/analysis.css
index 891a1aa..c5e17f4 100644
--- a/client/src/styles/analysis.css
+++ b/client/src/styles/analysis.css
@@ -11,7 +11,7 @@
gap: 2px;
height: 64px;
padding: 0 1px;
- border-bottom: 1px solid var(--border);
+ border-bottom: 1px solid var(--line-soft);
}
.histogram-bar {
@@ -25,7 +25,7 @@
justify-content: space-between;
gap: 6px;
margin-top: 3px;
- font-size: 10px;
+ font-size: var(--fs-telemetry);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
@@ -45,22 +45,21 @@
.readout-table {
width: 100%;
border-collapse: collapse;
- font-size: 10px;
+ font-size: var(--fs-telemetry);
font-variant-numeric: tabular-nums;
}
.readout-table th {
text-align: left;
- font-weight: 600;
+ font-weight: var(--fw-semibold);
color: var(--muted);
- padding: 3px 4px;
- border-bottom: 1px solid var(--border);
+ padding: 4px;
+ border-bottom: 1px solid var(--line-soft);
white-space: nowrap;
}
.readout-table td {
- padding: 3px 4px;
- border-bottom: 1px solid var(--border);
+ padding: 4px;
color: var(--text);
white-space: nowrap;
}
@@ -75,7 +74,11 @@
text-overflow: ellipsis;
}
-/* --- benchmark header button sits inline with the help tip --- */
-.benchmark-panel .panel-title {
- margin-bottom: 6px;
+/* The analysis stack tiles into as many columns as fit, so the Training tab
+ uses its width instead of stacking four short panels down one column. */
+.analysis-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 14px;
+ align-content: start;
}
diff --git a/client/src/styles/base.css b/client/src/styles/base.css
index 4541450..79ddda5 100644
--- a/client/src/styles/base.css
+++ b/client/src/styles/base.css
@@ -1,43 +1,147 @@
+/*
+ * Design tokens — "SpikeForge Studio", a precision scientific workstation.
+ *
+ * Near-black surfaces with subtle depth, restrained cyan illumination, and
+ * precision typography. The accent is reserved for selected / navigation /
+ * live-data / action states, never general decoration.
+ *
+ * Every token has a value in both :root and [data-theme="light"]. Older
+ * variable names (--bg, --panel, --border, ...) are kept as aliases so the
+ * per-panel stylesheets keep working while they migrate to the tokens.
+ */
:root {
- --bg: #020610;
- --panel: #0a1221;
- --panel-alt: #070e1b;
- --panel-head: #050a15;
- --popup: #101b30;
- --border: #2c3c55;
+ /* --- surface levels (near-black base, three raised steps) --- */
+ --canvas: #070a0f;
+ --surface-1: #0b1017;
+ --surface-2: #0f151e;
+ --surface-3: #141c27;
+
+ /* --- separators ---
+ Translucent rather than opaque: a separator is a seam inside one surface
+ (a pane edge, a header underline), not a surface of its own. On the dark
+ hierarchy 6% white lands a little above --surface-2 and stays below
+ --surface-3, so a line never outranks the content next to it. */
+ --line-soft: rgba(255, 255, 255, 0.06);
+ --line: rgba(255, 255, 255, 0.12);
+
+ /* --- ambient background wash (subtle, never neon) --- */
+ --ambient:
+ radial-gradient(
+ 1200px 700px at 16% -12%,
+ rgba(112, 207, 255, 0.05),
+ transparent 62%
+ ),
+ radial-gradient(
+ 900px 560px at 100% 0%,
+ rgba(122, 132, 255, 0.035),
+ transparent 58%
+ );
+
+ /* --- semantic colours --- */
--text: #dfebff;
- --muted: #a1b2cc;
+ --muted: #93a3bd;
--accent: #70cfff;
--on-accent: #030a17;
--ok: #3fb950;
--bad: #f85149;
--warn: #e3b341;
- --canvas-bg: #020610;
- --overlay: rgba(13, 17, 23, 0.85);
- --shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
--on-danger: #ffffff;
+
+ /* --- typography scale ---
+ Five ranks, in the order they are read: the workspace title, a pane
+ heading, a section micro-heading, normal interface text, and the two
+ text ranks below it (field labels, helper text). Anything that reads
+ like a heading uses a rank from the top three; nothing needs a sixth. */
+ --fs-workspace: 17px; /* workspace title */
+ --fs-major: 15px; /* major section */
+ --fs-pane: 13px; /* pane heading */
+ --fs-section: 11px; /* section micro-heading */
+ --fs-ui: 13px; /* normal interface text */
+ --fs-label: 12px; /* field label */
+ --fs-helper: 11px; /* helper text */
+ --fs-panel: 13px; /* legacy alias of --fs-ui */
+ --fs-secondary: 12px; /* legacy alias of --fs-label */
+ --fs-micro: 11px; /* legacy alias of --fs-helper */
+ --fs-telemetry: 10px; /* uppercase telemetry readouts only */
+ --fs-metric: 26px; /* major metric */
+ --fw-semibold: 600;
+
+ /* --- control geometry --- */
+ --control-h: 30px; /* standard control */
+ --control-h-toolbar: 28px;
+ --icon: 18px; /* navigation and toolbar icon size */
+ --panel-header-h: 36px;
+ --nav-row-h: 42px;
+ --rail-collapsed: 54px;
+ --rail-expanded: 208px;
+ --radius: 4px;
+ /* Form label column: wide enough for the longest parameter name, narrow
+ enough to leave the control column most of a 380px inspector pane. */
+ --field-label-w: 120px;
+
+ /* --- depth --- */
+ --overlay: rgba(5, 7, 12, 0.82);
+ --shadow: 0 8px 24px rgba(0, 0, 0, 0.55);
+
+ /* --- motion ---
+ One duration for every hover and focus state: long enough to read as a
+ transition, short enough that a pointer crossing a rack of controls does
+ not leave a trail of them. */
+ --t-fast: 140ms ease;
color-scheme: dark;
+
+ /* --- legacy aliases (do not add new uses) --- */
+ --bg: var(--canvas);
+ --panel: var(--surface-1);
+ --panel-alt: var(--surface-1);
+ --panel-head: var(--surface-2);
+ --popup: var(--surface-3);
+ --border: var(--line);
+ --canvas-bg: var(--canvas);
+}
+
+/* A reduced-motion preference collapses every hover and focus transition at
+ once, because they all read the one duration token. */
+@media (prefers-reduced-motion: reduce) {
+ :root {
+ --t-fast: 0ms;
+ }
}
/* --- light theme (toggled via ) --- */
[data-theme="light"] {
- --bg: #f6f8fa;
- --panel: #ffffff;
- --panel-alt: #ffffff;
- --panel-head: #f6f8fa;
- --popup: #ffffff;
- --border: #d0d7de;
+ --canvas: #eef1f5;
+ --surface-1: #ffffff;
+ --surface-2: #f4f6f8;
+ --surface-3: #ffffff;
+ --line-soft: rgba(31, 35, 40, 0.08);
+ --line: rgba(31, 35, 40, 0.16);
+ /* Geometry token, not a colour: restated only because every token is. */
+ --field-label-w: 120px;
+
+ --ambient:
+ radial-gradient(
+ 1100px 640px at 14% -12%,
+ rgba(9, 105, 218, 0.05),
+ transparent 60%
+ ),
+ radial-gradient(
+ 820px 520px at 100% 0%,
+ rgba(9, 105, 218, 0.03),
+ transparent 58%
+ );
+
--text: #1f2328;
- --muted: #59636e;
+ --muted: #57606a;
--accent: #0969da;
--on-accent: #ffffff;
--ok: #1a7f37;
--bad: #cf222e;
--warn: #9a6700;
- --canvas-bg: #ffffff;
- --overlay: rgba(255, 255, 255, 0.85);
- --shadow: 0 6px 20px rgba(31, 35, 40, 0.15);
--on-danger: #ffffff;
+
+ --overlay: rgba(238, 241, 245, 0.85);
+ --shadow: 0 8px 24px rgba(31, 35, 40, 0.18);
color-scheme: light;
}
@@ -59,45 +163,77 @@ body {
system-ui,
-apple-system,
sans-serif;
- background: var(--bg);
+ font-size: var(--fs-ui);
+ background: var(--canvas);
color: var(--text);
}
-/* Fixed shell: header and footer stay put, only .app-main scrolls. */
+/* Numeric readouts and chart axis labels line up column-wise. */
+.tabular,
+.res-value,
+.cursor-readout,
+.metric-value,
+.readout-table,
+.drift-table,
+.energy-grid {
+ font-variant-numeric: tabular-nums;
+}
+
+/*
+ * Fixed shell: the navigation rail is a whole-height column, the toolbar and
+ * status bar stay put, and the workspace between them fills the rest. Named
+ * areas keep the rail out of the document flow order, so no extra wrapper
+ * element is needed. Dialogs and the tour card are position: fixed and take
+ * no grid cell.
+ */
.app {
height: 100%;
- display: flex;
- flex-direction: column;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ grid-template-areas:
+ "rail header"
+ "rail main"
+ "rail footer";
overflow: hidden;
+ background-color: var(--canvas);
+ background-image: var(--ambient);
}
-.app-header {
- flex: 0 0 auto;
- padding: 12px 12px 0;
+.nav-rail {
+ grid-area: rail;
}
-.app-header .topbar {
- margin-bottom: 0;
+.app-header {
+ grid-area: header;
}
+/*
+ * The workspace: a full-height region with no page padding and no page
+ * scroll, so a section that is one screenful of content uses one screenful
+ * of window instead of floating in the top half of it. Panes scroll
+ * internally (see dock.css); the workspace itself does not scroll
+ * horizontally and leaves no gap under short content.
+ */
.app-main {
- flex: 1 1 auto;
+ grid-area: main;
min-height: 0;
- overflow-y: auto;
- padding: 12px;
+ min-width: 0;
+ overflow: hidden;
display: flex;
flex-direction: column;
}
+/* The status bar is told apart from the workspace by its surface step rather
+ than by another rule across the window. */
.app-footer {
- flex: 0 0 auto;
+ grid-area: footer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
- padding-right: 12px;
- border-top: 1px solid var(--border);
- background: var(--panel);
+ padding-right: 18px;
+ background: var(--surface-2);
}
.conn {
@@ -105,7 +241,7 @@ body {
align-items: center;
gap: 6px;
padding: 7px 0;
- font-size: 12px;
+ font-size: var(--fs-secondary);
color: var(--muted);
white-space: nowrap;
}
@@ -118,38 +254,6 @@ body {
color: var(--bad);
}
-.topbar {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 12px;
-}
-
-.theme-toggle {
- width: 30px;
- height: 24px;
- font-size: 13px;
-}
-
-.language-select {
- margin-left: auto;
- display: inline-flex;
- align-items: center;
- gap: 5px;
- color: var(--muted);
-}
-
-.language-select select {
- width: 104px;
- min-height: 24px;
- border: 1px solid var(--border);
- border-radius: 4px;
- background: var(--panel);
- color: var(--text);
- font: inherit;
- font-size: 12px;
-}
-
.sr-only {
position: absolute;
width: 1px;
@@ -162,15 +266,9 @@ body {
border: 0;
}
-.logo {
- display: inline-flex;
- align-items: center;
- color: var(--accent);
-}
-
.dot {
- width: 10px;
- height: 10px;
+ width: 8px;
+ height: 8px;
border-radius: 50%;
}
@@ -184,5 +282,5 @@ body {
.status {
color: var(--muted);
- font-size: 13px;
+ font-size: var(--fs-ui);
}
diff --git a/client/src/styles/capsize.css b/client/src/styles/capsize.css
index f3c07ea..8b89f24 100644
--- a/client/src/styles/capsize.css
+++ b/client/src/styles/capsize.css
@@ -1,13 +1,10 @@
-/* A quiet instrument surface: brand light stays away from plots and data. */
-:root:not([data-theme="light"]) .app-header {
- background: linear-gradient(110deg,#030815,#11172e 65%,#061b2b);
- border-bottom: 1px solid #426593;
- box-shadow: 0 1px 16px #316bf426;
-}
-:root:not([data-theme="light"]) .app-header .brand {
- letter-spacing: .025em;
- color: #e2edff;
-}
+/*
+ * Dark-theme refinements to the studio surface: the focus ring.
+ *
+ * The illuminated header gradient and glow this file used to carry are gone.
+ * Accent light is reserved for selected / navigation / live-data / action
+ * states, never for chrome (see the tokens in base.css).
+ */
:root:not([data-theme="light"]) button:focus-visible,
:root:not([data-theme="light"]) select:focus-visible,
:root:not([data-theme="light"]) input:focus-visible {
diff --git a/client/src/styles/confirm.css b/client/src/styles/confirm.css
index b82e2f9..9b52295 100644
--- a/client/src/styles/confirm.css
+++ b/client/src/styles/confirm.css
@@ -12,21 +12,20 @@
.confirm-card {
width: 320px;
padding: 16px;
- border: 1px solid var(--border);
- border-radius: 8px;
- background: var(--panel);
+ border-radius: var(--radius);
+ background: var(--surface-3);
box-shadow: var(--shadow);
}
.confirm-title {
- font-size: 13px;
+ font-size: var(--fs-panel);
font-weight: 700;
color: var(--text);
}
.confirm-message {
margin: 8px 0 0;
- font-size: 12px;
+ font-size: var(--fs-secondary);
line-height: 1.5;
color: var(--muted);
}
diff --git a/client/src/styles/controls.css b/client/src/styles/controls.css
index 48ae63c..3211fd1 100644
--- a/client/src/styles/controls.css
+++ b/client/src/styles/controls.css
@@ -8,10 +8,9 @@
width: 15px;
height: 15px;
border-radius: 50%;
- border: 1px solid var(--border);
- background: var(--bg);
+ background: var(--surface-2);
color: var(--muted);
- font-size: 10px;
+ font-size: var(--fs-micro);
font-weight: 700;
line-height: 1;
cursor: help;
@@ -21,103 +20,74 @@
.help-tip:hover,
.help-tip:focus {
color: var(--accent);
- border-color: var(--accent);
+ background: var(--surface-3);
outline: none;
}
+/* The popup is portalled to the body and placed in viewport coordinates (see
+ HelpTip), so a pane's own scroll container can never clip it — the failure
+ mode on the wide form columns — and it flips above the trigger when there
+ is no room below. */
.help-popup {
- position: absolute;
- top: 130%;
- right: 0;
- z-index: 20;
- width: 220px;
+ position: fixed;
+ z-index: 1000;
padding: 8px 10px;
- border-radius: 6px;
- border: 1px solid var(--border);
- background: var(--popup);
+ border-radius: var(--radius);
+ background: var(--surface-3);
color: var(--text);
- font-size: 12px;
+ font-size: var(--fs-secondary);
font-weight: 400;
line-height: 1.4;
text-align: left;
box-shadow: var(--shadow);
- opacity: 0;
- visibility: hidden;
- transition: opacity 120ms ease;
pointer-events: none;
}
-.help-tip:hover .help-popup,
-.help-tip:focus .help-popup {
- opacity: 1;
- visibility: visible;
-}
-
+/* A picker fills the control cell of its joined field group (see .field in
+ sections.css): no border or corner of its own, the quieter surface. */
.field select {
- background: var(--bg);
+ width: 100%;
+ height: 100%;
+ min-height: var(--control-h);
+ padding: 0 10px;
+ background: var(--surface-1);
color: var(--text);
- border: 1px solid var(--border);
- border-radius: 4px;
- padding: 4px 6px;
-}
-
-.field.check {
- flex-direction: row;
- align-items: center;
- gap: 8px;
-}
-
-.field.check .field-label {
- flex: 1;
+ border: none;
+ border-radius: 0;
}
-.field-label > span:first-child {
- display: inline-flex;
- align-items: center;
- gap: 6px;
-}
+/* Legacy layout hooks for `.apply` (the tonal rules live in primitives.css,
+ beside the Button they mirror). */
.apply {
width: 100%;
margin-top: 6px;
- padding: 8px 10px;
- background: var(--accent);
- color: var(--on-accent);
- border: none;
- border-radius: 6px;
- font-weight: 600;
- cursor: pointer;
-}
-
-.apply:disabled {
- opacity: 0.5;
- cursor: not-allowed;
-}
-
-.apply.stop {
- background: var(--bad);
- color: var(--on-danger);
}
+/* Train and Stop sit side by side while both fit; in a narrow pane (the asset
+ browser) they wrap rather than overrun it. */
.actions {
display: flex;
+ flex-wrap: wrap;
gap: 8px;
}
/* Train / stop sit side by side, each taking half the row. */
+.actions .btn,
.actions .apply {
width: auto;
- flex: 1;
+ flex: 1 1 0;
margin-top: 0;
}
+/* A failure is stated by its tint and its colour; an extra edge down the
+ side is one more line the pane does not need. */
.error {
margin-top: 12px;
- padding: 10px;
- border: 1px solid var(--bad);
- border-radius: 6px;
+ padding: 10px 12px;
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--bad) 12%, transparent);
color: var(--bad);
- background: color-mix(in srgb, var(--bad) 10%, transparent);
}
/* --- dataset / sample-index controls --- */
@@ -134,13 +104,15 @@
.step-btn {
flex: 0 0 auto;
- padding: 5px 9px;
- background: var(--bg);
+ height: var(--control-h-toolbar);
+ padding: 0 10px;
+ background: var(--surface-2);
color: var(--text);
- border: 1px solid var(--border);
- border-radius: 4px;
+ border: none;
+ border-radius: var(--radius);
cursor: pointer;
- font-size: 12px;
+ font: inherit;
+ font-size: var(--fs-secondary);
}
.step-btn:disabled {
@@ -149,7 +121,7 @@
}
.step-btn:not(:disabled):hover {
- border-color: var(--accent);
+ background: var(--surface-3);
color: var(--accent);
}
@@ -158,27 +130,61 @@
}
.mirror-note {
- font-size: 11px;
+ font-size: var(--fs-micro);
color: var(--muted);
font-style: italic;
}
-.panel-title .help-tip {
- margin-left: 4px;
+/* The data and encoding editor stacks by default and tiles into two columns
+ once the pane is wide enough, so a wide window gets two readable columns
+ instead of one run of full-width controls. */
+.controls {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-width: 0;
}
-/* Data+Encoding beside Model, instead of one long column — each column
- takes only the height its own fields need (align-items: start), so the
- Model & Data tab reflects its real content height exactly rather than a
- browser column-balance estimate that can overshoot the available space. */
-.controls-cols {
+.controls-grid {
display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 28px;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 22px;
align-items: start;
}
.controls-col {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
min-width: 0;
}
+/* The sample is a fixed-size instrument reading, not a surface that stretches
+ to whatever width the pane happens to be. */
+.sample-preview {
+ max-width: 260px;
+}
+
+/* --- per-stage neuron override editor --- */
+/* Ordinary field rows, so the overrides share the form's label and control
+ columns rather than forming a second, differently-aligned grid. */
+.stage-neurons {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.stage-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* The trailing button takes its own width; `.apply` is full-width in a
+ column, and neither it nor the remove button should grow the row. */
+.stage-row .icon-btn,
+.stage-add .apply.small {
+ flex: 0 0 auto;
+ width: auto;
+ margin-top: 0;
+}
diff --git a/client/src/styles/dock.css b/client/src/styles/dock.css
new file mode 100644
index 0000000..1ee7e93
--- /dev/null
+++ b/client/src/styles/dock.css
@@ -0,0 +1,212 @@
+/*
+ * Docked panes: the workspace vocabulary.
+ *
+ * A pane is a full-height child of the workspace. It has no margin, no
+ * corner radius, no shadow, and no card on a background — the only thing
+ * that separates it from its neighbour is a single hairline, and the only
+ * thing that separates it from the shell is its surface step. Width is
+ * either fixed (an asset browser or an inspector, which read badly when
+ * stretched) or flexible (an editor, which wants every pixel left over).
+ *
+ * The head is a fixed row and the body scrolls beneath it, so a short form
+ * does not leave a gap below itself: the pane reaches the bottom of the
+ * window and keeps the empty space inside it.
+ */
+
+.dock {
+ display: flex;
+ align-items: stretch;
+ flex: 1 1 auto;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.dock-pane {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 0;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--surface-1);
+}
+
+/* One line between neighbours. Never a border around a pane. */
+.dock-pane + .dock-pane {
+ border-left: 1px solid var(--line-soft);
+}
+
+.dock-pane--assets {
+ flex: 0 0 260px;
+ width: 260px;
+}
+
+.dock-pane--inspector {
+ flex: 0 0 380px;
+ width: 380px;
+}
+
+/* Pane heading: one rank below the workspace title, and the only full-width
+ row inside a pane. It stays put while the body scrolls.
+ It keeps the pane's own surface, so the hairline beneath it is the pane's
+ one internal seam and the toolbar above is separated by its surface step
+ alone — no line between the two of them. */
+.pane-head {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 34px;
+ padding: 0 12px;
+ border-bottom: 1px solid var(--line-soft);
+ background: var(--surface-1);
+}
+
+.pane-title {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ font-size: var(--fs-pane);
+ font-weight: var(--fw-semibold);
+ color: var(--text);
+}
+
+.pane-head-meta {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--fs-helper);
+ color: var(--muted);
+ font-variant-numeric: tabular-nums;
+}
+
+/* The scrolling part. A pane's own padding lives here, so no child needs a
+ margin to separate itself from its neighbours. */
+.pane-body {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ gap: 14px;
+ min-height: 0;
+ overflow: auto;
+ padding: 12px;
+}
+
+/* --- section micro-heading ------------------------------------------- */
+
+/* A grouping label inside a pane. Sentence case, 11px, semibold: it is a
+ quiet wayfinding mark, not a bar of its own. */
+.section-micro {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 8px;
+ font-size: var(--fs-section);
+ font-weight: var(--fw-semibold);
+ color: var(--muted);
+}
+
+/* --- narrow desktops: narrower, then collapsed, then stacked ---------- */
+
+/* `.collapsed` is the pane's own toggle state (see DockPane.tsx), set from
+ the viewport at first paint and overridable by hand at any width. */
+.dock-pane.collapsed {
+ flex: 0 0 42px;
+ width: 42px;
+}
+
+/* A collapsed pane is a labelled strip: the heading runs down the pane so the
+ strip is still identifiable, and the toggle sits at the bottom of it. */
+.dock-pane.collapsed .pane-head {
+ flex: 1 1 auto;
+ flex-direction: column;
+ justify-content: flex-start;
+ gap: 10px;
+ min-height: 0;
+ padding: 10px 0;
+ border-bottom: none;
+}
+
+.dock-pane.collapsed .pane-title {
+ writing-mode: vertical-rl;
+}
+
+.dock-pane.collapsed .pane-toggle {
+ margin-top: auto;
+ margin-left: 0;
+}
+
+.dock-pane.collapsed .pane-head-meta {
+ display: none;
+}
+
+/* Hidden, not unmounted: what is inside a collapsed pane keeps its state and
+ stays mounted while the section is on screen. */
+.dock-pane.collapsed .pane-body {
+ display: none;
+}
+
+/* Before either pane collapses, they give up width in the same order they
+ matter — the asset browser first, then the inspector. */
+@media (max-width: 1360px) {
+ .dock-pane--assets:not(.collapsed) {
+ flex-basis: 236px;
+ width: 236px;
+ }
+
+ .dock-pane--inspector:not(.collapsed) {
+ flex-basis: 340px;
+ width: 340px;
+ }
+}
+
+/* Last: stack. The pane heading and the hairline survive, the dock turns into
+ a column and grows to the height of the three panes stacked, and the
+ workspace region scrolls (see .tab-panel in tabs.css) — a stacked pane has
+ no second axis left to scroll on. Collapse is meaningless once a pane is
+ full width, so every pane is open here. */
+@media (max-width: 900px) {
+ .dock {
+ flex-direction: column;
+ flex: 0 0 auto;
+ min-height: auto;
+ overflow: visible;
+ }
+
+ .dock-pane,
+ .dock-pane--assets,
+ .dock-pane--inspector,
+ .dock-pane.collapsed {
+ flex: 0 0 auto;
+ width: auto;
+ overflow: visible;
+ }
+
+ .dock-pane.collapsed .pane-head {
+ flex: 0 0 auto;
+ flex-direction: row;
+ gap: 8px;
+ padding: 0 12px;
+ border-bottom: 1px solid var(--line-soft);
+ }
+
+ .dock-pane.collapsed .pane-title {
+ writing-mode: horizontal-tb;
+ }
+
+ .dock-pane.collapsed .pane-body {
+ display: block;
+ }
+
+ .dock-pane + .dock-pane {
+ border-left: none;
+ border-top: 1px solid var(--line-soft);
+ }
+
+ .pane-body {
+ overflow: visible;
+ }
+}
diff --git a/client/src/styles/download.css b/client/src/styles/download.css
index fa9fae3..767e1d7 100644
--- a/client/src/styles/download.css
+++ b/client/src/styles/download.css
@@ -12,14 +12,15 @@
.download-card {
width: 320px;
padding: 16px;
- border: 1px solid var(--border);
- background: var(--panel);
+ border-radius: var(--radius);
+ background: var(--surface-1);
+ box-shadow: var(--shadow);
}
.download-title {
margin-bottom: 10px;
- font-size: 13px;
- font-weight: 600;
+ font-size: var(--fs-panel);
+ font-weight: var(--fw-semibold);
color: var(--text);
}
@@ -27,8 +28,8 @@
position: relative;
height: 6px;
overflow: hidden;
- background: var(--canvas-bg);
- border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--surface-3);
}
.download-bar-fill {
@@ -56,6 +57,6 @@
align-items: center;
justify-content: space-between;
margin-top: 8px;
- font-size: 12px;
+ font-size: var(--fs-secondary);
color: var(--muted);
}
diff --git a/client/src/styles/energy.css b/client/src/styles/energy.css
index 7ccc2ce..45972dc 100644
--- a/client/src/styles/energy.css
+++ b/client/src/styles/energy.css
@@ -3,33 +3,35 @@
.energy-panel .energy-target {
display: block;
width: 100%;
- margin-bottom: 6px;
- padding: 3px 6px;
- background: var(--bg);
+ height: var(--control-h);
+ margin-bottom: 8px;
+ padding: 0 8px;
+ background: var(--surface-3);
color: var(--text);
- border: 1px solid var(--border);
- border-radius: 4px;
+ border: none;
+ border-radius: var(--radius);
font: inherit;
- font-size: 12px;
+ font-size: var(--fs-secondary);
}
.energy-badge {
display: inline-block;
- padding: 0 6px;
- border: 1px solid var(--border);
+ padding: 0 7px;
border-radius: 999px;
- font-size: 10px;
+ background: var(--surface-3);
+ font-size: var(--fs-telemetry);
text-transform: uppercase;
+ letter-spacing: 0.04em;
}
.energy-badge.estimate {
color: var(--warn);
- border-color: var(--warn);
+ background: color-mix(in srgb, var(--warn) 14%, transparent);
}
.energy-badge.measured {
color: var(--ok);
- border-color: var(--ok);
+ background: color-mix(in srgb, var(--ok) 14%, transparent);
}
.energy-grid {
@@ -37,7 +39,7 @@
grid-template-columns: auto 1fr;
gap: 1px 8px;
margin: 4px 0;
- font-size: 12px;
+ font-size: var(--fs-secondary);
}
.energy-grid .energy-key {
diff --git a/client/src/styles/feedback.css b/client/src/styles/feedback.css
index e835a6b..92411f4 100644
--- a/client/src/styles/feedback.css
+++ b/client/src/styles/feedback.css
@@ -11,22 +11,21 @@
flex-direction: column;
align-items: flex-end;
gap: 1px;
- padding: 4px 7px;
- border-radius: 6px;
- border: 1px solid var(--border);
- background: var(--overlay);
- font-size: 11px;
+ padding: 5px 8px;
+ border-radius: var(--radius);
+ background: var(--surface-3);
+ font-size: var(--fs-micro);
line-height: 1.25;
+ font-variant-numeric: tabular-nums;
+ box-shadow: var(--shadow);
pointer-events: none;
}
.pred-badge.ok {
- border-color: color-mix(in srgb, var(--ok) 70%, transparent);
color: var(--ok);
}
.pred-badge.bad {
- border-color: color-mix(in srgb, var(--bad) 70%, transparent);
color: var(--bad);
}
@@ -43,11 +42,10 @@
.mismatch {
margin: 6px 0 10px;
padding: 8px 10px;
- border: 1px solid color-mix(in srgb, var(--warn) 60%, transparent);
- border-radius: 6px;
- background: color-mix(in srgb, var(--warn) 10%, transparent);
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--warn) 12%, transparent);
color: var(--warn);
- font-size: 12px;
+ font-size: var(--fs-label);
line-height: 1.4;
}
@@ -69,8 +67,7 @@
gap: 4px;
height: 96px;
padding: 6px;
- border: 1px solid var(--border);
- border-radius: 4px;
+ border-radius: var(--radius);
background: var(--canvas-bg);
}
@@ -88,7 +85,7 @@
width: 100%;
min-height: 1px;
background: var(--accent);
- border-radius: 3px 3px 0 0;
+ border-radius: 2px 2px 0 0;
}
.class-bar.pred .class-bar-fill {
@@ -98,13 +95,14 @@
.class-bar.true {
outline: 1px solid var(--warn);
outline-offset: 1px;
- border-radius: 3px;
+ border-radius: 2px;
}
.class-bar-label {
- font-size: 9px;
+ font-size: var(--fs-telemetry);
line-height: 1;
color: var(--muted);
+ font-variant-numeric: tabular-nums;
}
/* --- footer resource bar (single row) --- */
@@ -114,7 +112,7 @@
align-items: center;
gap: 6px 18px;
padding: 7px 12px;
- font-size: 12px;
+ font-size: var(--fs-secondary);
color: var(--muted);
}
@@ -124,11 +122,6 @@
gap: 6px;
}
-/* The footer sits on the viewport edge, so its help opens upward. */
-.res-bar .help-popup {
- top: auto;
- bottom: 130%;
-}
.res-label {
color: var(--muted);
@@ -144,14 +137,14 @@
width: 90px;
height: 6px;
overflow: hidden;
- background: var(--canvas-bg);
- border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--surface-3);
}
.res-fill {
display: block;
height: 100%;
- background: linear-gradient(90deg, var(--ok), var(--accent));
+ background: var(--accent);
transition: width 400ms ease;
}
@@ -162,4 +155,3 @@
.res-warn {
color: var(--warn);
}
-
diff --git a/client/src/styles/hub.css b/client/src/styles/hub.css
index fa160f2..29dc229 100644
--- a/client/src/styles/hub.css
+++ b/client/src/styles/hub.css
@@ -10,44 +10,51 @@
.hub-search-input {
flex: 1 1 120px;
min-width: 0;
- padding: 3px 6px;
- background: var(--bg);
+ height: var(--control-h-toolbar);
+ padding: 0 8px;
+ background: var(--surface-3);
color: var(--text);
- border: 1px solid var(--border);
- border-radius: 4px;
+ border: none;
+ border-radius: var(--radius);
font: inherit;
- font-size: 12px;
+ font-size: var(--fs-secondary);
}
.hub-search-caption {
margin-bottom: 4px;
color: var(--muted);
- font-size: 10px;
+ font-size: var(--fs-telemetry);
}
.hub-issues {
margin: 0 0 6px;
padding-left: 16px;
color: var(--warn);
- font-size: 10px;
+ font-size: var(--fs-telemetry);
}
+/* Entries tile into as many columns as fit so the catalog fills a wide tab
+ instead of running down one narrow strip. */
.hub-list {
- display: flex;
- flex-direction: column;
- gap: 4px;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 6px;
margin: 0;
padding: 0;
list-style: none;
+ align-content: start;
}
+/* The selected entry is a tinted surface rather than a bar down one side:
+ the state is legible without spending a line on it. */
.hub-card {
- border: 1px solid var(--border);
- border-radius: 4px;
+ border-radius: var(--radius);
+ background: var(--surface-2);
+ transition: background var(--t-fast);
}
.hub-card.selected {
- border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 14%, var(--surface-2));
}
.hub-card-main {
@@ -55,87 +62,84 @@
grid-template-columns: 1fr auto;
gap: 2px 8px;
width: 100%;
- padding: 5px 7px;
+ padding: 6px 8px;
text-align: left;
- background: var(--bg);
+ background: none;
color: var(--text);
border: none;
+ border-radius: var(--radius);
cursor: pointer;
font: inherit;
- font-size: 12px;
+ font-size: var(--fs-secondary);
}
.hub-name {
display: inline-flex;
align-items: center;
gap: 6px;
- font-weight: 600;
+ font-weight: var(--fw-semibold);
}
.hub-cached {
color: var(--muted);
- font-size: 10px;
+ font-size: var(--fs-telemetry);
font-weight: 400;
text-transform: uppercase;
}
.hub-badge {
justify-self: end;
- padding: 1px 6px;
- border: 1px solid var(--border);
+ padding: 1px 7px;
border-radius: 999px;
- font-size: 10px;
+ background: var(--surface-3);
+ font-size: var(--fs-telemetry);
white-space: nowrap;
}
.hub-badge.ok {
color: var(--ok);
- border-color: color-mix(in srgb, var(--ok) 50%, transparent);
background: color-mix(in srgb, var(--ok) 12%, transparent);
}
.hub-badge.off {
color: var(--muted);
- border-color: color-mix(in srgb, var(--muted) 50%, transparent);
- background: color-mix(in srgb, var(--muted) 12%, transparent);
}
.hub-meta {
grid-column: 1 / -1;
color: var(--muted);
- font-size: 10px;
+ font-size: var(--fs-telemetry);
font-variant-numeric: tabular-nums;
}
.hub-actions {
display: flex;
gap: 6px;
- padding: 4px 7px 6px;
- border-top: 1px solid var(--border);
+ padding: 0 8px 8px;
}
.hub-note {
color: var(--muted);
- font-size: 10px;
+ font-size: var(--fs-telemetry);
}
/* --- hub download progress --- */
.hub-download {
margin-top: 6px;
- padding: 6px 7px;
- border: 1px solid var(--border);
- border-radius: 4px;
+ padding: 8px;
+ border-radius: var(--radius);
+ background: var(--surface-2);
}
.hub-download-title {
- font-size: 12px;
- font-weight: 600;
+ font-size: var(--fs-secondary);
+ font-weight: var(--fw-semibold);
}
.hub-download-bar {
height: 4px;
- margin: 4px 0;
- background: var(--border);
+ margin: 5px 0;
+ background: var(--surface-3);
border-radius: 999px;
overflow: hidden;
}
@@ -153,7 +157,7 @@
align-items: center;
gap: 8px;
color: var(--muted);
- font-size: 10px;
+ font-size: var(--fs-telemetry);
font-variant-numeric: tabular-nums;
}
@@ -169,14 +173,22 @@
0% {
opacity: 0.35;
}
+
50% {
opacity: 1;
}
+
100% {
opacity: 0.35;
}
}
+@media (prefers-reduced-motion: reduce) {
+ .hub-download-fill {
+ animation: none;
+ }
+}
+
/* --- hub compatibility verdict --- */
.hub-verdict-view {
margin-top: 6px;
@@ -190,38 +202,12 @@
.hub-verdict-topo {
color: var(--muted);
- font-size: 11px;
+ font-size: var(--fs-micro);
}
.hub-verdict-note {
margin: 4px 0;
color: var(--muted);
- font-size: 10px;
+ font-size: var(--fs-telemetry);
word-break: break-word;
}
-
-.hub-verdict {
- padding: 1px 6px;
- border: 1px solid var(--border);
- border-radius: 999px;
- font-size: 10px;
- text-transform: uppercase;
-}
-
-.hub-verdict.exact {
- color: var(--ok);
- border-color: color-mix(in srgb, var(--ok) 50%, transparent);
- background: color-mix(in srgb, var(--ok) 12%, transparent);
-}
-
-.hub-verdict.mappable {
- color: var(--warn);
- border-color: color-mix(in srgb, var(--warn) 50%, transparent);
- background: color-mix(in srgb, var(--warn) 12%, transparent);
-}
-
-.hub-verdict.incompatible {
- color: var(--bad);
- border-color: color-mix(in srgb, var(--bad) 55%, transparent);
- background: color-mix(in srgb, var(--bad) 10%, transparent);
-}
diff --git a/client/src/styles/mode.css b/client/src/styles/mode.css
index c16d38e..37ee387 100644
--- a/client/src/styles/mode.css
+++ b/client/src/styles/mode.css
@@ -3,74 +3,72 @@
display: inline-flex;
align-items: center;
gap: 8px;
- margin-left: auto;
-}
-
-/* The mode group takes the free space, so the theme button no longer does. */
-.topbar .theme-toggle {
- margin-left: 0;
}
.mode-label {
display: inline-flex;
align-items: center;
gap: 4px;
- font-size: 12px;
+ font-size: var(--fs-secondary);
color: var(--muted);
}
+/* A recessed track with a raised selected option. The selected mode is a
+ quiet state — surface and label, never a saturated fill — because which
+ mode is on is a fact about the session, not an action being taken. */
.mode-options {
display: inline-flex;
- border: 1px solid var(--border);
- border-radius: 6px;
- overflow: hidden;
+ gap: 2px;
+ padding: 2px;
+ border-radius: var(--radius);
+ background: var(--surface-1);
}
.mode-option {
- padding: 4px 10px;
+ height: calc(var(--control-h-toolbar) - 4px);
+ padding: 0 11px;
border: none;
- background: var(--bg);
+ border-radius: 2px;
+ background: none;
color: var(--muted);
- font-size: 12px;
+ font: inherit;
+ font-size: var(--fs-label);
cursor: pointer;
+ transition: background var(--t-fast), color var(--t-fast);
}
-.mode-option:hover {
+.mode-option:hover:not(.active) {
color: var(--text);
}
.mode-option.active {
- background: var(--accent);
- color: var(--on-accent);
-}
-
-.mode-note {
- font-size: 11px;
- font-style: italic;
- color: var(--muted);
+ background: var(--surface-3);
+ color: var(--text);
+ font-weight: var(--fw-semibold);
}
/* --- architecture note in the Model section --- */
.arch-note {
margin: 0;
- font-size: 11px;
+ font-size: var(--fs-micro);
font-style: italic;
line-height: 1.4;
color: var(--muted);
}
/* --- dataset modality note (encoder-neutral for event datasets) --- */
+/* The tint carries the note; a second edge down its side would be one more
+ line in a pane that is trying to stay quiet. */
.modality-note {
margin: 0 0 8px;
padding: 6px 8px;
- border-left: 2px solid var(--accent);
- background: color-mix(in srgb, var(--accent) 8%, transparent);
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--text);
- font-size: 11px;
+ font-size: var(--fs-helper);
line-height: 1.4;
}
.modality-note.warn {
- border-left-color: var(--warn);
- background: color-mix(in srgb, var(--warn) 12%, transparent);
+ background: color-mix(in srgb, var(--warn) 14%, transparent);
}
diff --git a/client/src/styles/model-actions.css b/client/src/styles/model-actions.css
index 2a1a075..1b6477c 100644
--- a/client/src/styles/model-actions.css
+++ b/client/src/styles/model-actions.css
@@ -12,24 +12,35 @@
gap: 6px;
}
+/* Icon-only control. Legacy markup carries this class directly; new code
+ uses the IconButton component, which renders the same class. */
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
- width: 26px;
- height: 22px;
+ width: var(--control-h-toolbar);
+ height: var(--control-h-toolbar);
padding: 0;
- background: var(--bg);
- color: var(--text);
- border: 1px solid var(--border);
+ border: none;
+ border-radius: var(--radius);
+ background: none;
+ color: var(--muted);
cursor: pointer;
- font-size: 11px;
+ font-size: var(--fs-secondary);
line-height: 1;
+ text-decoration: none;
+ transition: background var(--t-fast), color var(--t-fast);
+}
+
+/* The donation link is an icon button that leaves the app; a heart is the
+ one place in the chrome a warm colour is allowed. */
+.icon-btn.donate:hover {
+ color: var(--bad);
}
.icon-btn:not(:disabled):hover {
- color: var(--accent);
- border-color: var(--accent);
+ background: var(--surface-3);
+ color: var(--text);
}
.icon-btn:disabled {
@@ -41,14 +52,11 @@
display: inline-flex;
align-items: center;
gap: 4px;
- margin-left: 6px;
}
.index-input {
flex: 0 0 auto;
width: 66px;
- height: 22px;
- padding: 0 6px;
text-align: center;
}
@@ -72,20 +80,24 @@
position: relative;
width: 30px;
height: 16px;
- border: 1px solid var(--border);
- background: var(--bg);
+ border: 1px solid var(--line-soft);
+ border-radius: 999px;
+ background: var(--surface-2);
transition: border-color 120ms ease;
}
.toggle-track::after {
content: "";
position: absolute;
- top: 1px;
- left: 1px;
- width: 12px;
- height: 12px;
+ top: 2px;
+ left: 2px;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
background: var(--muted);
- transition: transform 120ms ease, background 120ms ease;
+ transition:
+ transform 120ms ease,
+ background 120ms ease;
}
.toggle input:checked + .toggle-track {
@@ -98,18 +110,17 @@
}
.toggle input:focus-visible + .toggle-track {
- outline: 1px solid var(--accent);
- outline-offset: 1px;
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
}
/* --- locked controls while a checkpoint is loaded --- */
.lock-note {
- margin-bottom: 10px;
- padding: 7px 9px;
- border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
- background: color-mix(in srgb, var(--accent) 8%, transparent);
+ padding: 8px 10px;
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--muted);
- font-size: 11px;
+ font-size: var(--fs-helper);
line-height: 1.4;
}
diff --git a/client/src/styles/model.css b/client/src/styles/model.css
index 1ba0121..f1082c1 100644
--- a/client/src/styles/model.css
+++ b/client/src/styles/model.css
@@ -1,36 +1,33 @@
/* --- model manager --- */
+/* Inside the asset-browser pane the pane body already owns the padding, the
+ spacing and the surface, so the panel drops the card chrome it needs when
+ it is dropped into a tab on its own. */
+.pane-body .model-panel {
+ flex: 0 0 auto;
+ gap: 12px;
+}
+
.model-panel {
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
- border: 1px solid var(--border);
- background: var(--panel-alt);
+ background: none;
+ overflow: visible;
}
.model-head {
display: flex;
align-items: center;
gap: 8px;
- padding: 8px 12px;
- background: var(--panel-head);
- border-bottom: 1px solid var(--border);
-}
-
-.model-title {
- font-size: 12px;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--muted);
}
.model-current {
- margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
- font-size: 12px;
+ font-size: var(--fs-secondary);
color: var(--muted);
}
@@ -40,8 +37,9 @@
gap: 4px;
max-width: 170px;
padding: 2px 4px 2px 8px;
- border: 1px solid var(--border);
- font-size: 11px;
+ border-radius: var(--radius);
+ background: var(--surface-3);
+ font-size: var(--fs-micro);
color: var(--muted);
}
@@ -62,7 +60,7 @@
background: none;
border: none;
color: var(--muted);
- font-size: 10px;
+ font-size: var(--fs-telemetry);
line-height: 1;
cursor: pointer;
}
@@ -78,7 +76,7 @@
.model-chip.on {
color: var(--ok);
- border-color: color-mix(in srgb, var(--ok) 50%, transparent);
+ background: color-mix(in srgb, var(--ok) 14%, var(--surface-3));
}
.model-loading {
@@ -92,7 +90,7 @@
display: inline-block;
width: 10px;
height: 10px;
- border: 1.5px solid var(--border);
+ border: 1.5px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
@@ -104,37 +102,39 @@
}
}
+/* The action tabs are a quiet segmented row: the selected one is a raised
+ surface, not an underline, so they add no line to the pane. */
.tabs {
display: flex;
- padding: 0 12px;
- border-bottom: 1px solid var(--border);
+ gap: 4px;
}
.tab {
flex: 1;
- padding: 7px 8px;
+ padding: 6px 8px;
+ border-radius: var(--radius);
background: none;
- border: none;
- border-bottom: 2px solid transparent;
color: var(--muted);
- font-size: 12px;
+ font: inherit;
+ font-size: var(--fs-label);
cursor: pointer;
+ transition: background var(--t-fast), color var(--t-fast);
}
-.tab:hover {
+.tab:hover:not(.active) {
color: var(--text);
}
.tab.active {
+ background: var(--surface-3);
color: var(--text);
- border-bottom-color: var(--accent);
+ font-weight: var(--fw-semibold);
}
.model-block {
display: flex;
flex-direction: column;
gap: 16px;
- padding: 14px 12px;
}
.control-row {
@@ -146,15 +146,14 @@
.control-row .text-input {
flex: 1;
min-width: 0;
- height: 30px;
- padding: 0 8px;
}
+/* `.apply` is a full-width button in a column; in a row beside a picker it
+ takes its own width, or the two of them together overrun a narrow pane. */
.control-row .apply.small {
flex: 0 0 auto;
+ width: auto;
margin-top: 0;
- height: 30px;
- padding: 0 14px;
}
/* An styled as .apply needs what a gets for free. */
@@ -171,32 +170,23 @@ a.apply.disabled {
pointer-events: none;
}
-/* --- compact loaded-model summary row --- */
-/* Sits under the tab strip, in the fixed header, so it stays visible on
- every tab instead of only the one that happened to render it. */
-.loaded-model-bar {
- margin-top: 10px;
-}
-
+/* --- loaded-checkpoint summary (network inspector) --- */
+/* The checkpoint's own record, as a fact grid: the pane body owns the
+ padding, so this only has to hold its name and its numbers apart. */
.loaded-model {
- padding: 8px 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
}
-.loaded-model-row {
+.loaded-model-head {
display: flex;
- flex-wrap: wrap;
align-items: center;
gap: 6px;
- font-size: 12px;
- color: var(--muted);
}
.loaded-model-name {
- font-weight: 600;
+ font-size: var(--fs-pane);
+ font-weight: var(--fw-semibold);
color: var(--text);
}
-
-.loaded-model-sep {
- color: var(--border);
-}
-
diff --git a/client/src/styles/panels.css b/client/src/styles/panels.css
index ce15f14..255fa3f 100644
--- a/client/src/styles/panels.css
+++ b/client/src/styles/panels.css
@@ -1,6 +1,6 @@
/* --- shared captions and empty-state notes for the CENTER panels --- */
.panel-note {
- font-size: 12px;
+ font-size: var(--fs-secondary);
font-style: italic;
line-height: 1.4;
color: var(--muted);
@@ -12,7 +12,7 @@
.panel-caption {
margin-bottom: 6px;
- font-size: 11px;
+ font-size: var(--fs-micro);
line-height: 1.4;
color: var(--muted);
font-variant-numeric: tabular-nums;
@@ -24,19 +24,21 @@
align-items: center;
gap: 6px;
margin-bottom: 6px;
- font-size: 12px;
+ font-size: var(--fs-secondary);
color: var(--muted);
}
.trajectory-stage select {
flex: 1;
min-width: 0;
- background: var(--bg);
+ height: var(--control-h-toolbar);
+ padding: 0 6px;
+ background: var(--surface-3);
color: var(--text);
- border: 1px solid var(--border);
- border-radius: 4px;
- padding: 4px 6px;
- font-size: 12px;
+ border: none;
+ border-radius: var(--radius);
+ font: inherit;
+ font-size: var(--fs-secondary);
}
.trajectory-chart canvas {
@@ -58,20 +60,20 @@
}
.nir-node {
- fill: var(--panel-alt);
- stroke: var(--border);
+ fill: var(--surface-2);
+ stroke: var(--line-soft);
stroke-width: 1;
}
.nir-node-name {
fill: var(--text);
- font-size: 10px;
- font-weight: 600;
+ font-size: var(--fs-telemetry);
+ font-weight: var(--fw-semibold);
}
.nir-node-kind {
fill: var(--muted);
- font-size: 9px;
+ font-size: var(--fs-telemetry);
}
.nir-edge {
@@ -98,30 +100,28 @@
display: inline-block;
margin-bottom: 6px;
padding: 3px 8px;
- border: 1px solid var(--border);
- border-radius: 4px;
- font-size: 12px;
- font-weight: 600;
+ border-radius: var(--radius);
+ background: var(--surface-2);
+ font-size: var(--fs-secondary);
+ font-weight: var(--fw-semibold);
}
.verdict.ok {
color: var(--ok);
- border-color: color-mix(in srgb, var(--ok) 50%, transparent);
background: color-mix(in srgb, var(--ok) 12%, transparent);
}
.verdict.bad {
color: var(--bad);
- border-color: color-mix(in srgb, var(--bad) 50%, transparent);
background: color-mix(in srgb, var(--bad) 12%, transparent);
}
.worst {
margin-bottom: 6px;
- padding: 5px 7px;
- border: 1px solid color-mix(in srgb, var(--warn) 45%, transparent);
- border-radius: 4px;
- font-size: 11px;
+ padding: 5px 8px;
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--warn) 12%, transparent);
+ font-size: var(--fs-micro);
color: var(--warn);
font-variant-numeric: tabular-nums;
}
@@ -129,21 +129,20 @@
.drift-table {
width: 100%;
border-collapse: collapse;
- font-size: 10px;
+ font-size: var(--fs-telemetry);
font-variant-numeric: tabular-nums;
}
.drift-table th {
text-align: left;
- font-weight: 600;
+ font-weight: var(--fw-semibold);
color: var(--muted);
- padding: 3px 4px;
- border-bottom: 1px solid var(--border);
+ padding: 4px;
+ border-bottom: 1px solid var(--line-soft);
}
.drift-table td {
- padding: 3px 4px;
- border-bottom: 1px solid var(--border);
+ padding: 4px;
color: var(--text);
}
@@ -162,7 +161,7 @@
.notes {
margin: 6px 0 0;
padding-left: 16px;
- font-size: 11px;
+ font-size: var(--fs-micro);
line-height: 1.4;
color: var(--muted);
}
diff --git a/client/src/styles/pipeline.css b/client/src/styles/pipeline.css
index a23666b..d0d49b2 100644
--- a/client/src/styles/pipeline.css
+++ b/client/src/styles/pipeline.css
@@ -12,6 +12,7 @@
.pipeline-panel {
grid-template-columns: minmax(0, 1fr);
}
+
.pipeline-canvas {
min-height: 320px;
}
@@ -27,23 +28,23 @@
.pipeline-canvas {
min-width: 0;
min-height: 400px;
- border: 1px solid var(--border);
- background: var(--panel-alt);
+ border-radius: var(--radius);
+ background: var(--surface-2);
}
/* React Flow's own theme vars, mapped onto the app's tokens so the canvas
doesn't look like a foreign widget dropped into the dashboard. */
.pipeline-canvas .react-flow {
- --xy-background-color: var(--panel-alt);
- --xy-node-background-color: var(--panel);
- --xy-node-border-default: 1px solid var(--border);
+ --xy-background-color: var(--surface-2);
+ --xy-node-background-color: var(--surface-1);
+ --xy-node-border-default: 1px solid var(--line-soft);
--xy-node-color-default: var(--text);
--xy-edge-stroke-default: var(--muted);
--xy-edge-stroke-selected-default: var(--accent);
- --xy-minimap-background-color: var(--panel);
- --xy-controls-button-background-color: var(--panel);
+ --xy-minimap-background-color: var(--surface-1);
+ --xy-controls-button-background-color: var(--surface-1);
--xy-controls-button-color: var(--text);
- --xy-controls-button-border-color: var(--border);
+ --xy-controls-button-border-color: var(--line-soft);
}
.pipeline-side {
@@ -57,13 +58,12 @@
.pipeline-input {
width: 100%;
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
- font-size: 12px;
+ font-size: var(--fs-secondary);
resize: vertical;
}
.pipeline-edge-inspector {
padding-bottom: 8px;
- border-bottom: 1px solid var(--border);
}
/* --- one node in the canvas --- */
@@ -71,10 +71,11 @@
.pipeline-node {
min-width: 150px;
padding: 8px 12px;
- background: var(--panel);
- border: 1px solid var(--border);
+ background: var(--surface-1);
+ border: 1px solid var(--line-soft);
+ border-radius: var(--radius);
color: var(--text);
- font-size: 13px;
+ font-size: var(--fs-panel);
}
.pipeline-node.selected {
@@ -82,7 +83,7 @@
}
.pipeline-node-name {
- font-weight: 600;
+ font-weight: var(--fw-semibold);
margin-bottom: 4px;
word-break: break-word;
}
@@ -91,7 +92,7 @@
display: flex;
align-items: center;
gap: 6px;
- font-size: 11px;
+ font-size: var(--fs-micro);
color: var(--muted);
}
@@ -121,8 +122,14 @@
}
@keyframes pipeline-node-pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.3; }
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.3;
+ }
}
@media (prefers-reduced-motion: reduce) {
diff --git a/client/src/styles/primitives.css b/client/src/styles/primitives.css
new file mode 100644
index 0000000..3c1cb77
--- /dev/null
+++ b/client/src/styles/primitives.css
@@ -0,0 +1,214 @@
+/*
+ * Control primitives. Geometry comes from the tokens in base.css so every
+ * control is the same height and every icon the same size.
+ *
+ * The read-out primitives — Metric, Badge, StatusIndicator and the fact
+ * grids — live in readouts.css beside the panels that state their numbers.
+ */
+
+/* --- Button: base, then variants ------------------------------------- */
+/* Filled accent means *primary action* only; routine actions are neutral
+ or ghost.
+
+ `.apply` / `.apply.stop` / `.apply.ghost` / `.apply.small` are the legacy
+ names for the same four tones. They are kept so the panels that have not
+ been migrated yet render identically to the ones that have. */
+.btn,
+.apply {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ height: var(--control-h);
+ padding: 0 14px;
+ border: none;
+ border-radius: var(--radius);
+ background: var(--surface-2);
+ color: var(--text);
+ font: inherit;
+ font-size: var(--fs-ui);
+ font-weight: 500;
+ white-space: nowrap;
+ cursor: pointer;
+ transition: background var(--t-fast), color var(--t-fast);
+}
+
+.btn:hover:not(:disabled),
+.apply:hover:not(:disabled) {
+ background: var(--surface-3);
+}
+
+.btn:focus-visible,
+.apply:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.btn:disabled,
+.apply:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.btn.primary,
+.apply {
+ background: var(--accent);
+ color: var(--on-accent);
+ font-weight: var(--fw-semibold);
+}
+
+.btn.danger,
+.apply.stop {
+ background: var(--bad);
+ color: var(--on-danger);
+ font-weight: var(--fw-semibold);
+}
+
+.btn.ghost,
+.apply.ghost {
+ background: none;
+ color: var(--muted);
+}
+
+.btn.ghost:hover:not(:disabled),
+.apply.ghost:hover:not(:disabled) {
+ background: var(--surface-2);
+ color: var(--text);
+}
+
+.btn.sm,
+.apply.small {
+ height: var(--control-h-toolbar);
+ padding: 0 10px;
+ font-size: var(--fs-secondary);
+}
+
+.btn.block {
+ width: 100%;
+}
+
+/* --- PanelHeader ------------------------------------------------------ */
+.panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ min-height: var(--panel-header-h);
+ margin-bottom: 10px;
+}
+
+.panel-header-title {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ font-size: var(--fs-panel);
+ font-weight: var(--fw-semibold);
+ color: var(--text);
+}
+
+.panel-header-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.panel-header.subsection {
+ min-height: 0;
+ margin: 16px 0 8px;
+}
+
+/* --- Field controls --------------------------------------------------- */
+/* A field is a label row plus one control. The label is 12px and the control
+ 30px, so "this is a setting" and "this is its value" are two different
+ ranks rather than one run of 13px text. */
+.field-label {
+ font-size: var(--fs-label);
+}
+
+/* A slider paired with the numeric box for the same value: the track feels
+ the parameter, the box states it. Both fill the field's control cell. */
+.field-scale {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ min-width: 0;
+}
+
+.field-scale input[type="range"] {
+ flex: 1 1 auto;
+ align-self: center;
+ min-width: 0;
+ margin: 0 10px;
+}
+
+/* A numeric box is a value, not a form: it fills its control cell, keeps no
+ border of its own, and right-aligns so magnitudes line up. Beside a slider
+ it is narrower, and a seam — the same one the label cell uses — separates
+ it from the track. */
+.num-input {
+ width: 100%;
+ min-width: 0;
+ height: 100%;
+ min-height: var(--control-h);
+ padding: 0 10px;
+ border: none;
+ border-radius: 0;
+ background: var(--surface-1);
+ color: var(--text);
+ font: inherit;
+ font-size: var(--fs-ui);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ transition: background var(--t-fast);
+}
+
+.field-scale .num-input {
+ flex: 0 0 auto;
+ width: 72px;
+ border-left: 1px solid var(--line-soft);
+}
+
+.num-input.invalid {
+ color: var(--bad);
+ background: color-mix(in srgb, var(--bad) 12%, var(--surface-3));
+}
+
+.num-input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* No spin buttons anywhere: a stepper in this application is the arrow keys
+ on the field itself, and the native widgets are too wide for a box that has
+ to sit beside a slider or inside a 260px pane. */
+input[type="number"] {
+ appearance: textfield;
+ -moz-appearance: textfield;
+}
+
+/* --- Toolbar ---------------------------------------------------------- */
+/* A strip of controls inside a pane: one surface step up from it, no border
+ — the surface difference is the whole edge. */
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 10px;
+ border-radius: var(--radius);
+ background: var(--surface-2);
+}
+
+/* --- InspectorSection ------------------------------------------------- */
+.inspector-section {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-width: 0;
+}
+
+.inspector-section-title {
+ font-size: var(--fs-label);
+ font-weight: var(--fw-semibold);
+ color: var(--muted);
+}
diff --git a/client/src/styles/readouts.css b/client/src/styles/readouts.css
new file mode 100644
index 0000000..cd4307c
--- /dev/null
+++ b/client/src/styles/readouts.css
@@ -0,0 +1,202 @@
+/*
+ * Read-out primitives: the small labelled facts a panel states about the
+ * session — a metric, a badge, a status line, a fact grid, the architecture
+ * strip, a telemetry note.
+ *
+ * They share one rule: a number is stated, aligned and quiet. None of them
+ * is a surface of its own, and none is decorated.
+ */
+
+/* --- Metric ----------------------------------------------------------- */
+.metric {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.metric-label {
+ font-size: var(--fs-helper);
+ color: var(--muted);
+}
+
+.metric-value {
+ font-size: var(--fs-metric);
+ font-weight: var(--fw-semibold);
+ line-height: 1.1;
+ color: var(--text);
+}
+
+.metric.sm .metric-value {
+ font-size: var(--fs-major);
+}
+
+/* --- Badge ------------------------------------------------------------ */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--surface-2);
+ color: var(--muted);
+ font-size: var(--fs-telemetry);
+ white-space: nowrap;
+}
+
+.badge.ok {
+ background: color-mix(in srgb, var(--ok) 14%, transparent);
+ color: var(--ok);
+}
+
+.badge.warn {
+ background: color-mix(in srgb, var(--warn) 14%, transparent);
+ color: var(--warn);
+}
+
+.badge.bad {
+ background: color-mix(in srgb, var(--bad) 14%, transparent);
+ color: var(--bad);
+}
+
+/* --- StatusIndicator -------------------------------------------------- */
+.status-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: var(--fs-label);
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.status-indicator.ok {
+ color: var(--ok);
+}
+
+.status-indicator.bad {
+ color: var(--bad);
+}
+
+/* --- Fact grid -------------------------------------------------------- */
+/* The properties of one thing, as a label above its value. A fact the
+ application does not hold is simply absent from the grid. */
+.fact-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
+ gap: 8px 12px;
+ margin: 0;
+}
+
+.fact {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+.fact dt {
+ font-size: var(--fs-helper);
+ color: var(--muted);
+}
+
+.fact dd {
+ margin: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--fs-label);
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
+}
+
+/* --- architecture strip ----------------------------------------------- */
+/* Input → Encoder → Hidden → Output, as the product actually configures it.
+ The arrow is a pseudo-element, so it is decoration rather than content. */
+.arch-summary {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.arch-strip {
+ display: flex;
+ align-items: stretch;
+ gap: 14px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.arch-node {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ flex: 1 1 0;
+ min-width: 0;
+ padding: 6px 8px;
+ border-radius: var(--radius);
+ background: var(--surface-2);
+}
+
+.arch-node + .arch-node::before {
+ content: "→";
+ position: absolute;
+ top: 50%;
+ left: -11px;
+ transform: translateY(-50%);
+ color: var(--line);
+ font-size: var(--fs-helper);
+ line-height: 1;
+}
+
+.arch-node-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--fs-helper);
+ color: var(--muted);
+}
+
+.arch-node-value {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--fs-ui);
+ font-weight: var(--fw-semibold);
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
+}
+
+.arch-node-unit {
+ font-size: var(--fs-helper);
+ color: var(--muted);
+}
+
+/* --- sample preview --------------------------------------------------- */
+/* The heatmap brings its own panel chrome for the viewer's stage; inside a
+ pane the canvas and its label are all that is wanted. */
+.sample-preview .panel {
+ flex: 0 0 auto;
+ padding: 0;
+ border-radius: 0;
+ background: none;
+}
+
+.sample-preview .panel-title {
+ min-height: 0;
+ margin-bottom: 6px;
+ font-size: var(--fs-helper);
+ font-weight: 400;
+ color: var(--muted);
+}
+
+/* --- telemetry note --------------------------------------------------- */
+/* Uppercase microtype is for real telemetry; this one is a sentence, so it
+ stays sentence case and only its figures are tabular. */
+.capture-note {
+ font-size: var(--fs-helper);
+ color: var(--muted);
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
diff --git a/client/src/styles/sections.css b/client/src/styles/sections.css
index 04a80fb..c290bcd 100644
--- a/client/src/styles/sections.css
+++ b/client/src/styles/sections.css
@@ -1,105 +1,52 @@
/* --- static section headings --- */
-.section {
- border-bottom: 1px solid var(--border);
-}
-
-.section:last-child {
- border-bottom: none;
+/* Sections are separated by a hairline between them, not by a rule under
+ each one (the last child used to need a special case for that). */
+.section:not(:first-child) {
+ border-top: 1px solid var(--line-soft);
}
.section-head {
display: flex;
align-items: center;
gap: 8px;
- padding: 11px 0;
+ padding: 12px 0 6px;
}
.section-text {
flex: 1;
display: flex;
flex-direction: column;
- gap: 1px;
+ gap: 2px;
min-width: 0;
}
.section-title {
- font-size: 13px;
- font-weight: 600;
+ font-size: var(--fs-panel);
+ font-weight: var(--fw-semibold);
color: var(--text);
}
.section-hint {
- font-size: 11px;
+ font-size: var(--fs-micro);
color: var(--muted);
}
+/* The body owns the vertical rhythm: every direct child — field, fact grid,
+ note, sub-editor — is spaced by the same gap instead of each carrying its
+ own margin. */
.section-body {
- padding: 2px 0 14px;
-}
-
-.col-viz {
display: flex;
flex-direction: column;
- gap: 8px;
- min-height: 0;
- min-width: 0;
-}
-
-/* Viewer tab: live activity (wider) beside on-demand analysis panels, so
- the stack that used to run the full tab height fits side by side instead.
- Both columns stretch to the row's full height (grid's default alignment)
- so the activity panel below can flex-fill whatever it doesn't need. */
-.viewer-grid {
- display: grid;
- grid-template-columns: 3fr 2fr;
gap: 12px;
- flex: 1 1 auto;
- min-height: 0;
-}
-
-@media (max-width: 1100px) {
- /* Stacked single column: let content take its natural height and the
- tab scroll again, rather than squeezing both halves to share one
- screen's height. */
- .viewer-grid {
- grid-template-columns: minmax(0, 1fr);
- flex: 0 1 auto;
- min-height: 0;
- }
-
- .viewer-grid .activity-panel {
- flex: 0 1 auto;
- }
-
- .viewer-grid .raster-row {
- flex: 0 1 auto;
- min-height: 220px;
- }
-}
-
-.col-viz .panel {
- padding: 10px;
-}
-
-/* Network activity absorbs whatever height TimeCursor/InputRow don't use,
- instead of stacking its 3 rasters to a fixed pixel height and forcing
- the tab to scroll (see RasterCanvas's measured-height resize). */
-.col-viz .activity-panel {
- flex: 1 1 0;
- min-height: 0;
- display: flex;
- flex-direction: column;
-}
-
-.col-viz .panel-title {
- margin-bottom: 6px;
+ padding: 2px 0 16px;
}
.metrics {
display: grid;
grid-template-columns: 1fr 1fr;
- gap: 6px;
- font-size: 13px;
+ gap: 6px 12px;
+ font-size: var(--fs-ui);
+ font-variant-numeric: tabular-nums;
}
/* Prediction result on a single row: mark, input, prediction, confidence. */
@@ -108,11 +55,12 @@
grid-template-columns: auto repeat(3, minmax(0, 1fr));
gap: 8px;
align-items: center;
- font-size: 13px;
+ font-size: var(--fs-ui);
+ font-variant-numeric: tabular-nums;
}
.pred-mark {
- font-size: 15px;
+ font-size: var(--fs-major);
font-weight: 700;
line-height: 1;
text-align: center;
@@ -128,7 +76,7 @@
.muted {
color: var(--muted);
- font-size: 13px;
+ font-size: var(--fs-ui);
}
.predictions {
@@ -139,31 +87,17 @@
.pred {
padding: 3px 7px;
- border-radius: 4px;
- font-size: 12px;
- border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: var(--fs-secondary);
+ background: var(--surface-2);
}
.pred.ok {
color: var(--ok);
- border-color: color-mix(in srgb, var(--ok) 50%, transparent);
}
.pred.bad {
color: var(--bad);
- border-color: color-mix(in srgb, var(--bad) 50%, transparent);
-}
-
-.apply.ghost {
- background: transparent;
- color: var(--accent);
- border: 1px solid var(--border);
-}
-
-.panel-title.subsection {
- margin-top: 14px;
- padding-top: 10px;
- border-top: 1px solid var(--border);
}
.model-row {
@@ -175,25 +109,20 @@
.text-input {
flex: 1;
min-width: 0;
- background: var(--bg);
+ height: var(--control-h);
+ padding: 0 8px;
+ background: var(--surface-3);
color: var(--text);
- border: 1px solid var(--border);
- border-radius: 4px;
- padding: 5px 7px;
- font-size: 12px;
-}
-
-.apply.small {
- width: auto;
- margin-top: 0;
- padding: 5px 10px;
- font-size: 12px;
+ border: none;
+ border-radius: var(--radius);
+ font: inherit;
+ font-size: var(--fs-secondary);
}
.model-list {
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: 2px;
margin-top: 8px;
max-height: 160px;
overflow-y: auto;
@@ -204,10 +133,10 @@
align-items: center;
justify-content: space-between;
gap: 6px;
- padding: 4px 6px;
- border: 1px solid var(--border);
- border-radius: 4px;
- font-size: 12px;
+ padding: 5px 7px;
+ border-radius: var(--radius);
+ background: var(--surface-2);
+ font-size: var(--fs-secondary);
}
.model-name {
@@ -228,7 +157,7 @@
border: none;
color: var(--accent);
cursor: pointer;
- font-size: 12px;
+ font-size: var(--fs-secondary);
padding: 0;
}
@@ -247,19 +176,30 @@
gap: 12px;
}
+/* Panels are a surface, not a rectangle: hierarchy comes from the surface
+ step and the spacing around it. */
.panel {
- background: var(--panel);
- border: 1px solid var(--border);
- border-radius: 8px;
+ background: var(--surface-1);
+ border-radius: var(--radius);
padding: 12px;
}
+/* The legacy title markup still used by the panels that have not been
+ migrated to PanelHeader. Sentence case, never upper case. */
.panel-title {
- font-size: 12px;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--muted);
- margin-bottom: 8px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-height: var(--panel-header-h);
+ margin-bottom: 10px;
+ font-size: var(--fs-panel);
+ font-weight: var(--fw-semibold);
+ color: var(--text);
+}
+
+.panel-title.subsection {
+ min-height: 0;
+ margin: 16px 0 8px;
}
.pair {
@@ -271,35 +211,76 @@
canvas {
display: block;
max-width: 100%;
- border: 1px solid var(--border);
- border-radius: 4px;
+ border-radius: var(--radius);
background: var(--canvas-bg);
}
-/* Line charts draw their own plot frame, so skip the extra canvas border. */
-canvas.chart-canvas {
+/*
+ * A field is one joined control group: a label cell and a control cell that
+ * share a single border and corner, so a setting and its value read as one
+ * unit rather than two unrelated boxes. The label cell is one surface step
+ * lighter than the control beside it — that step, not a second line, is what
+ * tells the two cells apart.
+ */
+.field {
+ display: grid;
+ grid-template-columns: var(--field-label-w) minmax(0, 1fr) auto;
+ align-items: stretch;
+ min-height: var(--control-h);
+ border: 1px solid var(--line-soft);
+ border-radius: var(--radius);
+ overflow: hidden;
+ font-size: var(--fs-label);
+ color: var(--muted);
+}
+
+/* The help mark is a trailing button of the group: it fills the row's height
+ and shares the label cell's surface, with a seam dividing it from the
+ control. Every row's question mark therefore lines up on one right edge. */
+.field > .help-tip {
+ align-self: stretch;
+ width: 30px;
+ height: auto;
border: none;
+ border-left: 1px solid var(--line-soft);
+ border-radius: 0;
+ background: var(--surface-2);
}
-.field {
- display: flex;
- flex-direction: column;
- gap: 4px;
- font-size: 13px;
- margin-bottom: 10px;
- color: var(--muted);
+.field > .help-tip:hover,
+.field > .help-tip:focus {
+ background: var(--surface-3);
+}
+
+.field:focus-within {
+ border-color: var(--accent);
}
.field-label {
display: flex;
align-items: center;
- justify-content: space-between;
- gap: 8px;
+ gap: 4px;
+ min-width: 0;
+ padding: 0 10px;
+ background: var(--surface-2);
+ border-right: 1px solid var(--line-soft);
}
.field b {
color: var(--text);
- font-weight: 600;
+ font-weight: var(--fw-semibold);
+ font-variant-numeric: tabular-nums;
+}
+
+/* The control cell is the quieter surface; the label carries the weight. */
+.field select,
+.field .text-input {
+ width: 100%;
+ min-width: 0;
+ height: 100%;
+ border: none;
+ border-radius: 0;
+ background: var(--surface-1);
}
.field input[type="range"] {
@@ -307,3 +288,41 @@ canvas.chart-canvas {
accent-color: var(--accent);
}
+/* A checkbox is its own label, so it takes the whole row and the help mark
+ keeps its place at the end. */
+.field.check {
+ grid-template-columns: minmax(0, 1fr) auto;
+}
+
+.field.check .field-label {
+ border-right: none;
+ gap: 8px;
+}
+
+/* --- measured line charts --- */
+/* LineChart draws at the container's real size, so the plot fills its column
+ with crisp gridlines and legend instead of a stretched fixed buffer. */
+.chart-fill {
+ width: 100%;
+ min-width: 0;
+}
+
+.chart-fill canvas.chart-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+/* The training stack fills the tab's height: its two charts share whatever
+ room the status grid and subsection headings leave. */
+.training-panel {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.training-panel .chart-fill {
+ flex: 1 1 0;
+ min-height: 120px;
+}
diff --git a/client/src/styles/shell.css b/client/src/styles/shell.css
new file mode 100644
index 0000000..f4bba08
--- /dev/null
+++ b/client/src/styles/shell.css
@@ -0,0 +1,259 @@
+/*
+ * The Studio shell: the left navigation rail and the product / session
+ * context header. Both are pure composition — they change nothing about what
+ * each panel does or when it is mounted.
+ */
+
+/* --- left navigation rail --------------------------------------------- */
+
+/* The rail is told apart from the workspace by its surface step, not by a
+ rule down the window: a line here would be ink spent on a boundary the
+ two surfaces already draw. */
+.nav-rail {
+ display: flex;
+ flex-direction: column;
+ width: var(--rail-collapsed);
+ min-width: var(--rail-collapsed);
+ background: var(--surface-2);
+ overflow: hidden;
+ transition: width var(--t-fast);
+}
+
+.nav-rail.expanded {
+ width: var(--rail-expanded);
+ min-width: var(--rail-expanded);
+}
+
+/* The product mark sits above the navigation at the toolbar's own height, so
+ the first nav row starts level with the workspace rather than the toolbar. */
+.nav-rail-mark {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 52px;
+ color: var(--accent);
+}
+
+.nav-rail.expanded .nav-rail-mark {
+ justify-content: flex-start;
+ padding: 0 13px;
+}
+
+.nav-rail-list {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 10px 7px;
+}
+
+/* One nav row: a 42px hit area holding an 18px icon and a label, with a
+ raised surface plus an accent strip when it is the active workspace.
+ Two groups — the workspaces that build a model, and the ones that fetch,
+ ship or chain one — separated by spacing alone, so the navigation adds no
+ line to the window. */
+.rail-item {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ height: var(--nav-row-h);
+ padding: 0 11px;
+ border-radius: var(--radius);
+ background: none;
+ color: var(--muted);
+ font: inherit;
+ font-size: var(--fs-ui);
+ text-align: left;
+ white-space: nowrap;
+ cursor: pointer;
+ transition: background var(--t-fast), color var(--t-fast);
+}
+
+.rail-item.group-start {
+ margin-top: 10px;
+}
+
+.rail-item:hover {
+ background: var(--surface-1);
+ color: var(--text);
+}
+
+.rail-item:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
+.rail-item.active {
+ background: var(--surface-3);
+ color: var(--text);
+}
+
+.rail-item.active::before {
+ content: "";
+ position: absolute;
+ top: 9px;
+ bottom: 9px;
+ left: 0;
+ width: 2px;
+ border-radius: 2px;
+ background: var(--accent);
+}
+
+.rail-item-icon {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--icon);
+ height: var(--icon);
+}
+
+.rail-item-label {
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Collapsed: icon only, centred, with the native title as the tooltip. */
+.nav-rail:not(.expanded) .rail-item {
+ justify-content: center;
+ padding: 0;
+}
+
+.nav-rail:not(.expanded) .rail-item-label {
+ display: none;
+}
+
+/* The shell's own settings, anchored at the bottom of the rail: the theme,
+ and the control that expands the labels. Stacked, because two 28px buttons
+ do not fit across a 54px rail. */
+.nav-rail-foot {
+ flex: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 0 10px;
+}
+
+/* Marks a section whose background work (training, download) is running. */
+.shell-tab-dot {
+ flex: 0 0 auto;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--ok);
+ animation: shell-tab-pulse 1.4s ease-in-out infinite;
+}
+
+@keyframes shell-tab-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+
+ 50% {
+ opacity: 0.25;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .shell-tab-dot {
+ animation: none;
+ }
+}
+
+/* --- product + session context header --------------------------------- */
+
+/* One surface step above the workspace below it, and that is the seam. */
+.topbar {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ min-height: 52px;
+ padding: 0 18px;
+ background: var(--surface-2);
+}
+
+.topbar-product {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ flex: 0 0 auto;
+ min-width: 0;
+}
+
+/* The workspace title: the top rank of the type scale, and the only thing in
+ the toolbar at it. */
+.topbar-workspace {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: var(--fs-workspace);
+ font-weight: var(--fw-semibold);
+ color: var(--text);
+}
+
+.session-context {
+ display: inline-flex;
+ align-items: center;
+ gap: 16px;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.session-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ font-size: var(--fs-label);
+ color: var(--muted);
+}
+
+.session-chip-icon {
+ flex: 0 0 auto;
+ display: inline-flex;
+ color: var(--muted);
+}
+
+.session-chip-value {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text);
+}
+
+.topbar-utils {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ flex: 0 0 auto;
+}
+
+/* --- shared compact controls in the header ---------------------------- */
+
+.language-select {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ color: var(--muted);
+}
+
+.language-select select {
+ height: var(--control-h-toolbar);
+ min-height: var(--control-h-toolbar);
+ padding: 0 6px;
+ border: none;
+ border-radius: var(--radius);
+ background: var(--surface-3);
+ color: var(--text);
+ font: inherit;
+ font-size: var(--fs-secondary);
+}
diff --git a/client/src/styles/tabs.css b/client/src/styles/tabs.css
index 5d63c06..d0e4d8c 100644
--- a/client/src/styles/tabs.css
+++ b/client/src/styles/tabs.css
@@ -1,85 +1,22 @@
-/* --- tabbed shell: the strip below the top bar, and the tab panels --- */
-
-/* Horizontal scroll keeps every tab reachable when the window is narrow. */
-.tabbar {
- display: flex;
- align-items: stretch;
- gap: 2px;
- margin-top: 10px;
- border-bottom: 1px solid var(--border);
- overflow-x: auto;
- scrollbar-width: thin;
-}
-
-/* Namespaced: `.tab` already belongs to the model panel's load/save switch. */
-.shell-tab {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 7px 12px;
- border: none;
- border-bottom: 2px solid transparent;
- background: none;
- color: var(--muted);
- font-family: inherit;
- font-size: 13px;
- white-space: nowrap;
- cursor: pointer;
-}
-
-.shell-tab:hover {
- color: var(--text);
-}
-
-.shell-tab:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: -2px;
-}
-
-.shell-tab.active {
- color: var(--text);
- border-bottom-color: var(--accent);
-}
-
-/* Marks a tab whose background work (training, download) is still running. */
-.shell-tab-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--ok);
- animation: shell-tab-pulse 1.4s ease-in-out infinite;
-}
-
-@keyframes shell-tab-pulse {
- 0%,
- 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.25;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .shell-tab-dot {
- animation: none;
- }
-}
-
-/* One tab at a time, centred and capped so wide screens stay readable.
- flex:1 lets a tab's content (e.g. the viewer's rasters) size itself to
- fill the tab strip's actual remaining height instead of a guessed pixel
- constant; a tab whose content is naturally shorter just leaves the extra
- space blank, same as today. */
+/* --- section panels: one visible at a time, sized to the whole window --- */
+
+/*
+ * A panel is a column that fills the workspace. There is no max-width: a
+ * desktop window is the workspace, so the content uses all of it.
+ *
+ * A workspace whose content is taller than the window scrolls here, in its own
+ * region, rather than pushing the shell out of shape. The docked workspace is
+ * unaffected: its panes take the panel's height exactly and scroll inside
+ * themselves, so this region has nothing left over to scroll.
+ */
.tab-panel {
display: flex;
flex-direction: column;
- gap: 12px;
- align-self: center;
+ gap: 14px;
width: 100%;
- max-width: 1440px;
flex: 1 1 auto;
min-height: 0;
+ overflow-y: auto;
}
/* An inactive tab is hidden rather than unmounted, so its panels keep
@@ -97,37 +34,41 @@
.tab-cols {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 12px;
+ gap: 14px;
align-items: start;
min-height: 0;
}
-/* Model & Data: the model panel is short next to the much longer controls
- stack, so give it just the width it needs and let Controls split into
- two columns (see .controls-cols) instead of one tall one. */
-.tab-cols-model {
- grid-template-columns: minmax(240px, 300px) 1fr;
+/* Training & Analysis: the training stack fills the tab's height so its
+ charts grow into the space, while the shorter analysis stack tiles into a
+ grid beside it (see .analysis-grid). */
+.tab-cols-training {
+ grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
+ align-items: stretch;
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+.tab-cols-training .tab-col {
+ min-height: 0;
+}
+
+/* Energy & Deployment: the target list and report need more room than the
+ energy readout beside them. */
+.tab-cols-deploy {
+ grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
}
.tab-col {
display: flex;
flex-direction: column;
- gap: 12px;
+ gap: 14px;
min-width: 0;
min-height: 0;
}
-/* Cap single-panel tabs (hub) so search and lists don't stretch forever. */
-.tab-col.narrow {
- max-width: 900px;
-}
-
@media (max-width: 900px) {
.tab-cols {
grid-template-columns: minmax(0, 1fr);
}
-
- .controls-cols {
- grid-template-columns: minmax(0, 1fr);
- }
}
diff --git a/client/src/styles/targets.css b/client/src/styles/targets.css
index 86e8c0c..d161be5 100644
--- a/client/src/styles/targets.css
+++ b/client/src/styles/targets.css
@@ -1,8 +1,8 @@
/* --- Phase 5c deployment targets panel --- */
.targets-panel .target-list {
- display: flex;
- flex-direction: column;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 4px;
margin: 0 0 8px;
padding: 0;
@@ -14,65 +14,62 @@
grid-template-columns: 1fr auto;
gap: 2px 8px;
width: 100%;
- padding: 5px 7px;
+ padding: 6px 8px;
+ border-radius: var(--radius);
text-align: left;
- background: var(--bg);
+ background: var(--surface-2);
color: var(--text);
- border: 1px solid var(--border);
- border-radius: 4px;
cursor: pointer;
font: inherit;
- font-size: 12px;
+ font-size: var(--fs-label);
+ transition: background var(--t-fast);
}
.target-item:hover {
- border-color: var(--accent);
+ background: var(--surface-3);
}
+/* The selected target is a tinted surface rather than a bar down one side. */
.target-item.selected {
- border-color: var(--accent);
- background: color-mix(in srgb, var(--accent) 10%, transparent);
+ background: color-mix(in srgb, var(--accent) 14%, var(--surface-2));
}
.target-name {
display: inline-flex;
align-items: center;
gap: 6px;
- font-weight: 600;
+ font-weight: var(--fw-semibold);
}
.target-kind {
color: var(--muted);
- font-size: 10px;
+ font-size: var(--fs-telemetry);
font-weight: 400;
text-transform: uppercase;
}
.target-badge {
justify-self: end;
- padding: 1px 6px;
- border: 1px solid var(--border);
+ padding: 1px 7px;
border-radius: 999px;
- font-size: 10px;
+ background: var(--surface-3);
+ font-size: var(--fs-telemetry);
white-space: nowrap;
}
.target-badge.ok {
color: var(--ok);
- border-color: color-mix(in srgb, var(--ok) 50%, transparent);
background: color-mix(in srgb, var(--ok) 12%, transparent);
}
.target-badge.off {
color: var(--muted);
- border-color: color-mix(in srgb, var(--muted) 50%, transparent);
- background: color-mix(in srgb, var(--muted) 12%, transparent);
}
.target-meta {
grid-column: 1 / -1;
color: var(--muted);
- font-size: 10px;
+ font-size: var(--fs-telemetry);
font-variant-numeric: tabular-nums;
}
@@ -85,42 +82,43 @@
}
.bucket {
- padding: 4px 6px;
- border: 1px solid var(--border);
- border-radius: 4px;
+ padding: 5px 8px;
+ border-radius: var(--radius);
+ background: var(--surface-2);
}
.bucket-head {
- font-size: 10px;
- font-weight: 600;
+ font-size: var(--fs-telemetry);
+ font-weight: var(--fw-semibold);
color: var(--muted);
text-transform: uppercase;
+ letter-spacing: 0.04em;
}
.bucket-names {
margin-top: 2px;
- font-size: 11px;
+ font-size: var(--fs-micro);
color: var(--text);
word-break: break-word;
}
.bucket-empty {
margin-top: 2px;
- font-size: 11px;
+ font-size: var(--fs-micro);
font-style: italic;
color: var(--muted);
}
.bucket.supported {
- border-color: color-mix(in srgb, var(--ok) 40%, transparent);
+ border-left-color: var(--ok);
}
.bucket.substituted {
- border-color: color-mix(in srgb, var(--warn) 45%, transparent);
+ border-left-color: var(--warn);
}
.bucket.unsupported {
- border-color: color-mix(in srgb, var(--bad) 55%, transparent);
+ border-left-color: var(--bad);
background: color-mix(in srgb, var(--bad) 10%, transparent);
}
@@ -131,9 +129,9 @@
/* --- executed backend run --- */
.backend-rewrite {
margin: 6px 0;
- padding: 4px 6px;
- border: 1px solid var(--border);
- border-radius: 4px;
+ padding: 5px 8px;
+ border-radius: var(--radius);
+ background: var(--surface-2);
}
.backend-rewrite .panel-caption {
diff --git a/client/src/styles/tour.css b/client/src/styles/tour.css
index f0cba53..ba5aaab 100644
--- a/client/src/styles/tour.css
+++ b/client/src/styles/tour.css
@@ -8,28 +8,22 @@
gap: 6px;
}
-.tour-launch {
- width: auto;
- padding: 4px 9px;
- font-size: 12px;
-}
-
.tour-menu {
position: absolute;
- top: 130%;
+ top: 140%;
right: 0;
z-index: 30;
width: 260px;
padding: 6px;
- border: 1px solid var(--border);
- border-radius: 6px;
- background: var(--popup);
+ border: 1px solid var(--line-soft);
+ border-radius: var(--radius);
+ background: var(--surface-3);
box-shadow: var(--shadow);
}
.tour-menu-head {
padding: 4px 6px 6px;
- font-size: 11px;
+ font-size: var(--fs-micro);
line-height: 1.4;
color: var(--muted);
}
@@ -41,7 +35,7 @@
width: 100%;
padding: 6px 8px;
border: none;
- border-radius: 4px;
+ border-radius: var(--radius);
background: none;
color: var(--text);
text-align: left;
@@ -50,17 +44,17 @@
.tour-menu-item:hover,
.tour-menu-item:focus {
- background: var(--bg);
+ background: var(--surface-2);
outline: none;
}
.tour-menu-name {
- font-size: 12px;
- font-weight: 600;
+ font-size: var(--fs-secondary);
+ font-weight: var(--fw-semibold);
}
.tour-menu-summary {
- font-size: 11px;
+ font-size: var(--fs-micro);
line-height: 1.4;
color: var(--muted);
}
@@ -70,9 +64,8 @@
scroll-margin: 16px;
outline: 2px solid var(--accent);
outline-offset: 2px;
- border-radius: 6px;
- box-shadow: 0 0 0 4px
- color-mix(in srgb, var(--accent) 22%, transparent);
+ border-radius: var(--radius);
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent);
transition: box-shadow 120ms ease;
}
@@ -84,9 +77,8 @@
z-index: 40;
width: 320px;
padding: 10px 12px 12px;
- border: 1px solid var(--border);
- border-radius: 8px;
- background: var(--popup);
+ border-radius: var(--radius);
+ background: var(--surface-3);
box-shadow: var(--shadow);
}
@@ -98,11 +90,9 @@
}
.tour-card-title {
- font-size: 11px;
- font-weight: 600;
- letter-spacing: 0.04em;
- text-transform: uppercase;
- color: var(--muted);
+ font-size: var(--fs-secondary);
+ font-weight: var(--fw-semibold);
+ color: var(--text);
}
.tour-card-close {
@@ -111,14 +101,14 @@
.tour-card-step {
margin-top: 4px;
- font-size: 14px;
- font-weight: 600;
+ font-size: var(--fs-major);
+ font-weight: var(--fw-semibold);
color: var(--text);
}
.tour-card-body {
margin: 6px 0 0;
- font-size: 12px;
+ font-size: var(--fs-secondary);
line-height: 1.5;
color: var(--text);
}
@@ -126,9 +116,9 @@
.tour-card-note {
margin: 8px 0 0;
padding: 6px 8px;
- border: 1px solid color-mix(in srgb, var(--warn) 45%, transparent);
- border-radius: 4px;
- font-size: 11px;
+ border-radius: var(--radius);
+ background: color-mix(in srgb, var(--warn) 12%, transparent);
+ font-size: var(--fs-helper);
font-style: italic;
line-height: 1.4;
color: var(--warn);
@@ -143,7 +133,7 @@
.tour-card-count {
flex: 1;
- font-size: 11px;
+ font-size: var(--fs-micro);
text-align: center;
color: var(--muted);
font-variant-numeric: tabular-nums;
diff --git a/client/src/styles/viewer.css b/client/src/styles/viewer.css
index fe2a7b5..04cb406 100644
--- a/client/src/styles/viewer.css
+++ b/client/src/styles/viewer.css
@@ -1,27 +1,105 @@
-/* --- square corners on containers and controls --- */
-.panel,
-canvas,
-.model-item,
-.mismatch,
-.pred-badge,
-.pred,
-.class-bars,
-.class-bar-fill,
-.class-bar.true,
-.res-track,
-.res-fill,
-.help-popup,
-.help-tip,
-.apply,
-.apply.small,
-.step-btn,
-.text-input,
-.field select,
-.error {
- border-radius: 0;
-}
-
-/* --- viewer rows: deterministic equal columns --- */
+/* --- viewer layout: input rows, rasters, and the transport strip --- */
+
+/* --- the viewer workspace: stage, inspector, transport ---------------- */
+
+/* The stage takes the width; the inspector is a fixed-ish column beside it;
+ the transport strip runs the full width along the bottom. The rasters are
+ the thing the app is built around, so they get the space. */
+.viewer-workspace {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(300px, 30%);
+ grid-template-rows: minmax(0, 1fr) auto;
+ gap: 12px;
+ flex: 1 1 auto;
+ min-height: 0;
+ /* The workspace is one screen. The stage sizes itself to the row, the
+ inspector scrolls inside its own column, and nothing here grows the tab
+ into a page scrollbar. */
+ overflow: hidden;
+}
+
+.viewer-stage {
+ grid-column: 1;
+ grid-row: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 0;
+ min-height: 0;
+}
+
+.viewer-inspector {
+ grid-column: 2;
+ grid-row: 1;
+ overflow-y: auto;
+ min-height: 0;
+ padding-right: 2px;
+}
+
+.viewer-transport {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ min-width: 0;
+}
+
+@media (max-width: 1100px) {
+ /* Stacked: let content take its natural height and the workspace scroll
+ again, rather than squeezing both halves into one screen. */
+ .viewer-workspace {
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto auto auto;
+ flex: 0 1 auto;
+ /* Stacked, the workspace is taller than the window again, so it has to
+ scroll through the tab rather than clip. */
+ overflow: visible;
+ }
+
+ .viewer-stage,
+ .viewer-inspector,
+ .viewer-transport {
+ grid-column: 1;
+ }
+
+ .viewer-stage {
+ grid-row: 1;
+ }
+
+ .viewer-stage .activity-panel {
+ flex: 0 1 auto;
+ }
+
+ .viewer-inspector {
+ grid-row: 2;
+ overflow: visible;
+ }
+
+ .viewer-stage .raster-row {
+ flex: 0 1 auto;
+ min-height: 220px;
+ }
+
+ .viewer-transport {
+ grid-row: 3;
+ }
+}
+
+.viewer-stage > .panel {
+ padding: 12px;
+}
+
+/* Network activity absorbs whatever height the input row doesn't use,
+ instead of stacking its 3 rasters to a fixed pixel height and forcing
+ the workspace to scroll (see RasterCanvas's measured-height resize). */
+.viewer-stage .activity-panel {
+ flex: 1 1 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+/* --- input rows and rasters ------------------------------------------ */
+
+/* Deterministic equal columns for a row of heatmaps. */
.viz-row {
display: block;
}
@@ -45,9 +123,11 @@ canvas,
min-width: 0;
}
-/* Cap the square sample heatmaps so the row can't grow with column width. */
+/* Cap the square sample heatmaps so the row can't grow with column width.
+ Kept small enough that the stage (input + three rasters) still fits one
+ screen, which is what keeps the viewer free of a page scrollbar. */
.viz-row .pair.grow {
- max-width: 380px;
+ max-width: 320px;
margin: 0 auto;
}
@@ -78,15 +158,17 @@ canvas,
.rail-label {
position: absolute;
right: 2px;
- font-size: 10px;
+ font-size: var(--fs-telemetry);
line-height: 1;
color: var(--muted);
+ font-variant-numeric: tabular-nums;
}
.raster-summary {
margin-top: 4px;
- font-size: 10px;
+ font-size: var(--fs-telemetry);
color: var(--muted);
+ font-variant-numeric: tabular-nums;
}
/* --- merged network-activity stack (compact rasters) --- */
@@ -99,13 +181,13 @@ canvas,
}
/* Each of the 3 layer rasters shares the stack's height equally, so the
- whole "network activity" panel fills whatever room the tab has instead
+ whole "network activity" panel fills whatever room the stage has instead
of stacking to a fixed pixel height and forcing the tab to scroll. */
.raster-row {
display: flex;
flex-direction: column;
flex: 1 1 0;
- min-height: 70px;
+ min-height: 56px;
}
.raster-row-head {
@@ -117,8 +199,8 @@ canvas,
}
.raster-row-label {
- font-size: 11px;
- font-weight: 600;
+ font-size: var(--fs-micro);
+ font-weight: var(--fw-semibold);
color: var(--text);
}
@@ -127,36 +209,11 @@ canvas,
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- font-size: 10px;
- color: var(--muted);
-}
-
-.cursor-readout {
- margin-top: 2px;
- font-size: 11px;
+ font-size: var(--fs-telemetry);
color: var(--muted);
font-variant-numeric: tabular-nums;
}
-/* Hide the number-input spin buttons for the sample index. */
-input[type="number"] {
- appearance: textfield;
- -moz-appearance: textfield;
-}
-
-input[type="number"]::-webkit-outer-spin-button,
-input[type="number"]::-webkit-inner-spin-button {
- -webkit-appearance: none;
- margin: 0;
-}
-
-/* Rasters fill the panel width, so the track spans the full width too. */
-.cursor-range {
- width: 100%;
- margin: 0;
- accent-color: var(--accent);
-}
-
.raster-wrap {
flex: 1 1 auto;
min-width: 0;
@@ -185,3 +242,43 @@ canvas.heatmap-fluid {
aspect-ratio: 1 / 1;
}
+/* --- transport strip --- */
+
+.time-cursor {
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.transport-title {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: var(--fs-secondary);
+ font-weight: var(--fw-semibold);
+ color: var(--text);
+}
+
+/* The transport title is the one editable thing in the strip, so it sets
+ the block's own minimum; the scrubber takes the rest. */
+.cursor-range {
+ flex: 1 1 180px;
+ min-width: 120px;
+ margin: 0;
+ accent-color: var(--accent);
+}
+
+.cursor-readout {
+ flex: 0 0 auto;
+ font-size: var(--fs-secondary);
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ font-variant-numeric: tabular-nums;
+}
+
+input[type="number"]::-webkit-outer-spin-button,
+input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
diff --git a/client/src/tabLabels.ts b/client/src/tabLabels.ts
new file mode 100644
index 0000000..7e82bc5
--- /dev/null
+++ b/client/src/tabLabels.ts
@@ -0,0 +1,22 @@
+import type { TabId } from "./tabs";
+import type { TranslationKey } from "./i18n/translations";
+
+/** Interface label of each section, as it appears in the rail and header. */
+export const TAB_LABEL_KEYS: Record = {
+ model: "tab.model",
+ viewer: "tab.viewer",
+ training: "tab.training",
+ hub: "tab.hub",
+ deploy: "tab.deploy",
+ pipeline: "tab.pipeline",
+};
+
+/** One-line description of what a section holds, used as its tooltip. */
+export const TAB_HINT_KEYS: Record = {
+ model: "tab.model.hint",
+ viewer: "tab.viewer.hint",
+ training: "tab.training.hint",
+ hub: "tab.hub.hint",
+ deploy: "tab.deploy.hint",
+ pipeline: "tab.pipeline.hint",
+};
diff --git a/client/src/theme.tsx b/client/src/theme.tsx
index da6eea8..d945a52 100644
--- a/client/src/theme.tsx
+++ b/client/src/theme.tsx
@@ -12,12 +12,18 @@ import { STORAGE_KEYS } from "./storage";
export type Theme = "dark" | "light";
-/** Canvas drawing colors, which cannot be driven by CSS variables. */
+/**
+ * Canvas drawing colors, which cannot be driven by CSS variables. This is the
+ * plot palette: icy cyan primary, violet-blue secondary trace, and a
+ * desaturated slate for gridlines and reference marks.
+ */
export interface CanvasColors {
bg: string;
grid: string;
text: string;
accent: string;
+ /** Secondary trace colour, paired with `accent` in two-series plots. */
+ trace: string;
accentSoft: string;
dot: string;
/** Translucent band drawn behind highlighted rows. */
@@ -26,19 +32,21 @@ export interface CanvasColors {
export const CANVAS_COLORS: Record = {
dark: {
- bg: "#020610",
- grid: "#2c3c55",
- text: "#a1b2cc",
+ bg: "#05070c",
+ grid: "#1b2231",
+ text: "#93a3bd",
accent: "#70cfff",
- accentSoft: "rgba(88, 166, 255, 0.16)",
+ trace: "#8b95ff",
+ accentSoft: "rgba(112, 207, 255, 0.16)",
dot: "#e3b341",
highlight: "rgba(63, 185, 80, 0.14)",
},
light: {
- bg: "#ffffff",
- grid: "#d0d7de",
- text: "#59636e",
+ bg: "#eef1f5",
+ grid: "#e7ebf0",
+ text: "#57606a",
accent: "#0969da",
+ trace: "#6f5bd6",
accentSoft: "rgba(9, 105, 218, 0.12)",
dot: "#9a6700",
highlight: "rgba(26, 127, 55, 0.14)",
diff --git a/client/vite.config.ts b/client/vite.config.ts
index 7f82717..3547424 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -1,16 +1,39 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
-// Proxy /ws, /health, and /api to the FastAPI dev server on :8877.
-// Must match the port in server/__main__.py (and the Docker mapping).
+// Proxy /ws, /health, and /api to the FastAPI dev server.
+// Defaults to :8877, which is the port server/__main__.py (and the Docker
+// mapping) uses. Override with SPIKEFORGE_DEV_BACKEND so a dev session can
+// attach to a backend already running on another port — for example the
+// end-to-end server on :8899 — without editing this file.
+const backend = process.env.SPIKEFORGE_DEV_BACKEND ?? "127.0.0.1:8877";
+const httpTarget = `http://${backend}`;
+const wsTarget = `ws://${backend}`;
+
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
+ watch: {
+ // `node_modules` is a symlink into the main checkout; following it makes
+ // the watcher consume a file descriptor per module and hits EMFILE.
+ // The generated and cache directories are not source, so they are not
+ // watched either.
+ followSymlinks: false,
+ ignored: [
+ "**/node_modules/**",
+ "**/.git/**",
+ "**/dist/**",
+ "**/.e2e-data/**",
+ "**/.headlesscode/**",
+ "**/.worktrees/**",
+ "**/.claude/**",
+ ],
+ },
proxy: {
- "/ws": { target: "ws://127.0.0.1:8877", ws: true },
- "/health": { target: "http://127.0.0.1:8877" },
- "/api": { target: "http://127.0.0.1:8877" },
+ "/ws": { target: wsTarget, ws: true },
+ "/health": { target: httpTarget },
+ "/api": { target: httpTarget },
},
},
});
diff --git a/pytest.ini b/pytest.ini
index 7ae7e0c..cbe04ac 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,6 +1,6 @@
[pytest]
testpaths = tests
-pythonpath = .
+pythonpath = . scripts
addopts = --cov=spikeforge --cov-report=term-missing
markers =
network: fetches a dataset, so it reaches the network on a cold
diff --git a/scripts/hub_verify/__init__.py b/scripts/hub_verify/__init__.py
new file mode 100644
index 0000000..2fa2202
--- /dev/null
+++ b/scripts/hub_verify/__init__.py
@@ -0,0 +1,27 @@
+"""The community-upload verification sandbox runner (issue #48, P4).
+
+Invoked by ``.github/workflows/hub-verify.yml`` on a ``repository_dispatch``
+from ``spikeforge-hub-api``, on a GitHub-hosted (never self-hosted) runner:
+the self-hosted ``spikeforge-ci`` runner holds the Hetzner deploy key, and
+running ``torch.load`` on a stranger's bytes there is the single most
+predictable way this design could be compromised
+(``plans/hub_accounts_plan.md`` §7.2).
+
+This package is CI scaffolding, not a distributed package: nothing under
+``spikeforge*`` imports it, and it ships in no wheel. It reuses the existing,
+already-shipped pipeline rather than reimplementing any of it:
+
+* :mod:`spikeforge.serving.bundle` -- checksum/manifest verification and the
+ ``weights_only=True`` load already used for every bundle.
+* :mod:`spikeforge_hub.inspect` / :mod:`spikeforge_hub.compat` -- the same
+ structural inspection and preset classification the curated catalog uses.
+* :mod:`spikeforge.nir_bridge` -- the same NIR export and independent
+ reference-interpreter drift check invariant 4 of ``rules.md`` requires.
+* :mod:`spikeforge_targets.energy` -- the same SOP/MAC/AC accounting used
+ elsewhere in the project.
+
+See :mod:`hub_verify.cli` for the orchestration entry point and
+:mod:`hub_verify.report` for the report/callback contract this runner
+implements against ``spikeforge-hub-api``'s (not yet built, as of this
+writing) ``POST /internal/v1/verifications/{version_id}``.
+"""
diff --git a/scripts/hub_verify/callback.py b/scripts/hub_verify/callback.py
new file mode 100644
index 0000000..423e506
--- /dev/null
+++ b/scripts/hub_verify/callback.py
@@ -0,0 +1,56 @@
+"""Post the signed verification report back to the hub API.
+
+Follows the same ``urllib``-only request pattern already used in this
+monorepo (:class:`spikeforge_clients.transport.HttpTransport`) rather than
+adding a new HTTP dependency: this script installs into a throwaway CI
+job, but the project's stated convention is stdlib-only HTTP regardless.
+"""
+
+import json
+import urllib.error
+import urllib.request
+from typing import Final
+
+from hub_verify.errors import CallbackError
+from hub_verify.signing import SIGNATURE_HEADER, sign_body
+
+_TIMEOUT_SECONDS: Final[int] = 30
+
+
+def _url(base_url: str, version_id: str) -> str:
+ """Return the internal verification-callback URL for ``version_id``."""
+ return f"{base_url.rstrip('/')}/internal/v1/verifications/{version_id}"
+
+
+def post_report(
+ base_url: str, version_id: str, report: dict, secret: str
+) -> None:
+ """POST ``report`` for ``version_id``, signed with ``secret``.
+
+ Raises :class:`CallbackError` on any failure to deliver it -- unlike an
+ artifact rejection, a delivery failure is not something this job can
+ itself report to the hub, so it must fail the job loudly instead of
+ silently leaving the version stuck in ``verifying``.
+ """
+ body = json.dumps(report, sort_keys=True).encode("utf-8")
+ request = urllib.request.Request(
+ _url(base_url, version_id),
+ data=body,
+ method="POST",
+ headers={
+ "Content-Type": "application/json",
+ SIGNATURE_HEADER: sign_body(body, secret),
+ },
+ )
+ try:
+ with urllib.request.urlopen(
+ request, timeout=_TIMEOUT_SECONDS
+ ) as reply:
+ if reply.status >= 400:
+ raise CallbackError(f"hub API replied {reply.status}")
+ except urllib.error.HTTPError as error:
+ raise CallbackError(
+ f"hub API replied {error.code}: {error.read()!r}"
+ ) from error
+ except (urllib.error.URLError, OSError) as error:
+ raise CallbackError(f"callback delivery failed: {error}") from error
diff --git a/scripts/hub_verify/cli.py b/scripts/hub_verify/cli.py
new file mode 100644
index 0000000..caf0101
--- /dev/null
+++ b/scripts/hub_verify/cli.py
@@ -0,0 +1,117 @@
+"""Fetch, verify, report, and post one dispatched artifact.
+
+Invoked by ``.github/workflows/hub-verify.yml`` as
+``python -m hub_verify.cli``. Every input is an environment variable the
+workflow sets from the ``repository_dispatch`` payload and this
+repository's own secrets/variables -- see the workflow file for exactly
+which ones, and for why the callback base URL is a repository-configured
+constant rather than something read out of the dispatch payload (a fixed
+destination this job trusts, not attacker-influenced data).
+"""
+
+import os
+import sys
+import tempfile
+from typing import Dict, List, Tuple
+
+from hub_verify.callback import post_report
+from hub_verify.energy import energy_reports
+from hub_verify.errors import CallbackError, VerificationStepError
+from hub_verify.fetch import fetch_artifact
+from hub_verify.funnel import run_funnel
+from hub_verify.pipeline import load_and_build
+from hub_verify.report import build_report, rejection_summary
+
+_EMPTY_CHECKS: Dict[str, object] = {
+ "bundle": None,
+ "inspect": None,
+ "compat": None,
+ "drift": None,
+ "energy": None,
+}
+
+
+def _env(name: str) -> str:
+ """Return the required environment variable ``name`` or exit loudly."""
+ value = os.environ.get(name, "")
+ if not value:
+ sys.exit(f"hub_verify: missing required env var {name}")
+ return value
+
+
+def _run_url() -> str:
+ """Return this job's run URL for the report, or an empty string."""
+ server = os.environ.get("GITHUB_SERVER_URL", "")
+ repo = os.environ.get("GITHUB_REPOSITORY", "")
+ run_id = os.environ.get("GITHUB_RUN_ID", "")
+ if not (server and repo and run_id):
+ return ""
+ return f"{server}/{repo}/actions/runs/{run_id}"
+
+
+def _run_checks(
+ tmp_dir: str, artifact_path: str
+) -> Tuple[Dict[str, object], List[str]]:
+ """Run the bundle/funnel/energy stages; return ``(checks, reasons)``.
+
+ A stage that raises :class:`VerificationStepError` stops the pipeline
+ there -- a later stage needs the module the failed stage would have
+ produced -- and its message becomes the sole rejection reason.
+ """
+ checks = dict(_EMPTY_CHECKS)
+ try:
+ bundle, module = load_and_build(artifact_path)
+ checks["bundle"] = {
+ "ok": True, "topology": bundle.manifest.get("topology")
+ }
+ funnel = run_funnel(bundle, module, tmp_dir)
+ checks["inspect"] = funnel["inspect"]
+ checks["compat"] = funnel["compat"]
+ checks["drift"] = funnel["drift"]
+ checks["energy"] = energy_reports(bundle.spec, module)
+ except VerificationStepError as error:
+ checks[error.step] = {"ok": False, "detail": error.reason}
+ return checks, [f"{error.step}: {error.reason}"]
+ return checks, []
+
+
+def _verify(artifact_url: str) -> Tuple[Dict[str, object], List[str]]:
+ """Fetch the artifact and run every check against it."""
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ try:
+ artifact_path = fetch_artifact(
+ artifact_url, os.path.join(tmp_dir, "artifact.spkf")
+ )
+ except VerificationStepError as error:
+ return dict(_EMPTY_CHECKS), [f"{error.step}: {error.reason}"]
+ return _run_checks(tmp_dir, artifact_path)
+
+
+def main() -> int:
+ """Verify the dispatched artifact and post its signed report."""
+ version_id = _env("SPIKEFORGE_HUB_VERIFY_VERSION_ID")
+ artifact_url = _env("SPIKEFORGE_HUB_VERIFY_ARTIFACT_URL")
+ base_url = _env("SPIKEFORGE_HUB_API_BASE_URL")
+ secret = _env("SPIKEFORGE_HUB_VERIFICATION_SECRET")
+
+ checks, reasons = _verify(artifact_url)
+ passed = not reasons
+ summary = "passed every check" if passed else rejection_summary(reasons)
+ report = build_report(
+ version_id=version_id,
+ passed=passed,
+ reasons=reasons,
+ checks=checks,
+ summary=summary,
+ run_url=_run_url(),
+ )
+ try:
+ post_report(base_url, version_id, report, secret)
+ except CallbackError as error:
+ sys.exit(f"hub_verify: could not deliver the report: {error}")
+ print(summary)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/hub_verify/energy.py b/scripts/hub_verify/energy.py
new file mode 100644
index 0000000..f8590d1
--- /dev/null
+++ b/scripts/hub_verify/energy.py
@@ -0,0 +1,29 @@
+"""Produce the SOP/MAC/AC energy/latency report for every known target.
+
+Reuses :func:`spikeforge_targets.energy.accounting.account_spikes` exactly
+as elsewhere in the project; a target whose SDK is not installed on this
+runner reports ``basis: "unavailable"`` rather than a fabricated number --
+the same honest-degradation convention ``ci.yml``'s ``test-deploy`` job
+checks for the curated catalog. Energy is descriptive here, never a pass
+gate: an artifact is not rejected for costing more than some threshold.
+"""
+
+from typing import Any, Dict, List
+
+from hub_verify.fixtures import probe_spikes
+from spikeforge.topology.spec import TopologySpec
+from spikeforge.topology.stage_module import StageModule
+from spikeforge_targets import registry
+from spikeforge_targets.energy.accounting import account_spikes
+
+
+def energy_reports(
+ spec: TopologySpec, module: StageModule
+) -> List[Dict[str, Any]]:
+ """Return one energy/latency report per registered target."""
+ spikes = probe_spikes(spec)
+ reports = []
+ for name in registry.target_names():
+ _, report = account_spikes(module, spikes, name)
+ reports.append(report.to_dict())
+ return reports
diff --git a/scripts/hub_verify/errors.py b/scripts/hub_verify/errors.py
new file mode 100644
index 0000000..304a9f0
--- /dev/null
+++ b/scripts/hub_verify/errors.py
@@ -0,0 +1,30 @@
+"""Typed errors naming which verification step failed, and why."""
+
+
+class VerificationStepError(Exception):
+ """Raised by one pipeline stage with an actionable, named reason.
+
+ ``step`` identifies which stage of the pipeline rejected the artifact
+ (e.g. ``"fetch"``, ``"bundle"``, ``"drift"``) and ``reason`` is the
+ human-readable detail shown to the uploader -- "weights are not
+ loadable" rather than "verification failed" (the honesty bar
+ ``docs/model-hub.md`` states for every rejection in this project).
+ """
+
+ def __init__(self, step: str, reason: str) -> None:
+ """Record ``step`` and ``reason`` and build a clear message."""
+ super().__init__(f"{step}: {reason}")
+ self.step: str = step
+ self.reason: str = reason
+
+
+class FetchError(VerificationStepError):
+ """Raised when the artifact cannot be fetched from its signed URL."""
+
+ def __init__(self, reason: str) -> None:
+ """Record the fetch failure's ``reason``."""
+ super().__init__("fetch", reason)
+
+
+class CallbackError(Exception):
+ """Raised when the signed report cannot be posted back to the hub."""
diff --git a/scripts/hub_verify/fetch.py b/scripts/hub_verify/fetch.py
new file mode 100644
index 0000000..ce846e0
--- /dev/null
+++ b/scripts/hub_verify/fetch.py
@@ -0,0 +1,59 @@
+"""Fetch the one artifact this job was dispatched to verify.
+
+The URL is a signed, read-only, single-object URL the hub API mints for
+this job alone (``plans/hub_accounts_plan.md`` §7.2); this module does not
+authenticate to anything else and never reuses the URL beyond one GET.
+"""
+
+import urllib.error
+import urllib.request
+from typing import Any, Final
+
+from hub_verify.errors import FetchError
+
+#: Matches the community-upload single-artifact cap
+#: (``plans/hub_accounts_plan.md`` §5.3). A ``Content-Length`` claim is
+#: never trusted alone -- the stream itself is cut off past this many bytes.
+MAX_ARTIFACT_BYTES: Final[int] = 256 * 1024 * 1024
+
+#: Refuse to hang on a stalled or hostile server.
+_TIMEOUT_SECONDS = 60
+_CHUNK_SIZE = 1 << 20
+
+
+def _read_capped(response: Any, dest: str) -> int:
+ """Stream ``response`` into ``dest``, raising past the byte cap."""
+ written = 0
+ with open(dest, "wb") as handle:
+ while True:
+ chunk = response.read(_CHUNK_SIZE)
+ if not chunk:
+ return written
+ written += len(chunk)
+ if written > MAX_ARTIFACT_BYTES:
+ raise FetchError(
+ f"artifact exceeds the {MAX_ARTIFACT_BYTES} byte cap"
+ )
+ handle.write(chunk)
+
+
+def fetch_artifact(url: str, dest: str) -> str:
+ """Download ``url`` to ``dest`` and return ``dest``.
+
+ Raises :class:`FetchError` naming the reason on any network failure or
+ a payload past :data:`MAX_ARTIFACT_BYTES`, so a hostile or broken
+ signed URL is reported like any other named rejection rather than an
+ unhandled traceback.
+ """
+ try:
+ with urllib.request.urlopen(
+ url, timeout=_TIMEOUT_SECONDS
+ ) as response:
+ _read_capped(response, dest)
+ except FetchError:
+ raise
+ except urllib.error.HTTPError as error:
+ raise FetchError(f"HTTP {error.code} fetching artifact") from error
+ except (urllib.error.URLError, OSError) as error:
+ raise FetchError(f"network fetch failed: {error}") from error
+ return dest
diff --git a/scripts/hub_verify/fixtures.py b/scripts/hub_verify/fixtures.py
new file mode 100644
index 0000000..e586a55
--- /dev/null
+++ b/scripts/hub_verify/fixtures.py
@@ -0,0 +1,22 @@
+"""The one deterministic spike probe shared by the drift and energy checks.
+
+Both checks need some input to run the module over; using the same seeded,
+low-density probe for both means one fixture to reason about instead of
+two, and matches the fixture :mod:`spikeforge_targets.energy.accounting`
+already uses for its own topology fixtures.
+"""
+
+import torch
+
+from spikeforge.topology.spec import TopologySpec
+from spikeforge_targets.event_runtime.spike_view import synthetic_spikes
+
+#: Matches ``spikeforge_targets/energy/accounting.py``'s own defaults.
+STEPS = 8
+BATCH = 2
+SEED = 0
+
+
+def probe_spikes(spec: TopologySpec) -> torch.Tensor:
+ """Return a seeded, low-density spike probe shaped for ``spec``."""
+ return synthetic_spikes(spec, STEPS, BATCH, SEED)
diff --git a/scripts/hub_verify/funnel.py b/scripts/hub_verify/funnel.py
new file mode 100644
index 0000000..5c9a633
--- /dev/null
+++ b/scripts/hub_verify/funnel.py
@@ -0,0 +1,69 @@
+"""Run the inspect -> compat -> NIR-export-and-drift funnel on a bundle.
+
+Reuses :mod:`spikeforge_hub.inspect`, :mod:`spikeforge_hub.compat`, and
+:func:`spikeforge.nir_bridge.validate` exactly as
+:mod:`spikeforge_hub.import_model` runs them for the curated catalog
+(``rules.md`` invariant 4: validation stays independent of the module it
+checks, so a drift check is a genuine cross-check). A bundle already
+carries its own explicit :class:`~spikeforge.topology.spec.TopologySpec`,
+so unlike a bare downloaded artifact this funnel never has to guess a
+topology before it can build a module: ``compat`` here is informational
+(does this map onto a *known, registered* preset?), not a gate -- the
+drift check always runs against the bundle's own declared architecture.
+"""
+
+import os
+from typing import Any, Dict
+
+import torch
+
+from hub_verify.errors import VerificationStepError
+from hub_verify.fixtures import probe_spikes
+from spikeforge.nir_bridge import validate
+from spikeforge.serving.bundle import DeploymentBundle
+from spikeforge_hub.compat import classify
+from spikeforge_hub.inspect import inspect_artifact
+
+
+def _write_weights(bundle: DeploymentBundle, tmp_dir: str) -> str:
+ """Write the bundle's already-verified weights to a bare ``.pt`` file.
+
+ The bytes have already passed a ``weights_only=True`` load in
+ :func:`hub_verify.pipeline.load_and_build`; re-loading the identical
+ bytes through :mod:`spikeforge_hub.inspect`'s bare-torch reader is
+ safe, because that reload can only reconstruct the same objects
+ already proven benign by the stricter load.
+ """
+ path = os.path.join(tmp_dir, "weights.pt")
+ torch.save(dict(bundle.weights), path)
+ return path
+
+
+def _drift_check(bundle: DeploymentBundle, module: Any) -> Dict[str, Any]:
+ """Run the reference-interpreter drift check, raising on drift."""
+ spikes = probe_spikes(bundle.spec)
+ result = validate(bundle.spec, module, spikes)
+ if not result["within_tolerance"]:
+ worst = result.get("worst") or {}
+ where = worst.get("layer", "unknown layer")
+ what = worst.get("quantity", "unknown quantity")
+ raise VerificationStepError(
+ "drift",
+ "the exported NIR graph drifts from the trained module "
+ f"at {where} ({what})",
+ )
+ return dict(result)
+
+
+def run_funnel(
+ bundle: DeploymentBundle, module: Any, tmp_dir: str
+) -> Dict[str, Any]:
+ """Return the inspect/compat/drift block of the verification report."""
+ weights_path = _write_weights(bundle, tmp_dir)
+ report = inspect_artifact(weights_path)
+ verdict = classify(report, bundle.manifest.get("topology"))
+ return {
+ "inspect": {"kind": report.kind},
+ "compat": verdict.to_dict(),
+ "drift": _drift_check(bundle, module),
+ }
diff --git a/scripts/hub_verify/pipeline.py b/scripts/hub_verify/pipeline.py
new file mode 100644
index 0000000..129e1a6
--- /dev/null
+++ b/scripts/hub_verify/pipeline.py
@@ -0,0 +1,34 @@
+"""Load and build the ``.spkf`` bundle under verification.
+
+Wraps :class:`spikeforge.serving.bundle.DeploymentBundle` so a load or
+build failure becomes a named
+:class:`~hub_verify.errors.VerificationStepError` instead of an unhandled
+traceback. The checksum/manifest verification and the
+``weights_only=True`` weight load are exactly the ones ``bundle.py``
+already performs for every bundle load (non-negotiable for untrusted
+content, per ``plans/hub_accounts_plan.md`` §7.2) -- nothing here
+reimplements them.
+"""
+
+from typing import Any, Tuple
+
+from hub_verify.errors import VerificationStepError
+from spikeforge.serving.bundle import DeploymentBundle
+from spikeforge.serving.errors import BundleError
+
+
+def load_and_build(path: str) -> Tuple[DeploymentBundle, Any]:
+ """Return ``(bundle, module)`` for the ``.spkf`` archive at ``path``.
+
+ :meth:`DeploymentBundle.load` runs the checksum/manifest verification
+ and the ``weights_only=True`` weight load; :meth:`build_module` then
+ strictly loads those weights into the module the manifest's own spec
+ describes -- a second, independent proof the weights actually fit the
+ declared architecture, not just that they deserialize.
+ """
+ try:
+ bundle = DeploymentBundle.load(path, strict=True)
+ module = bundle.build_module()
+ except BundleError as error:
+ raise VerificationStepError("bundle", error.detail) from error
+ return bundle, module
diff --git a/scripts/hub_verify/report.py b/scripts/hub_verify/report.py
new file mode 100644
index 0000000..58a64e9
--- /dev/null
+++ b/scripts/hub_verify/report.py
@@ -0,0 +1,57 @@
+"""Assemble the verification report posted to the hub API.
+
+The one binding field is ``passed``: ``spikeforge-hub-api``'s
+``hub_api/catalog/trust.py::label_for`` reads
+``version.verification["passed"]`` and shows the ``machine-checked`` trust
+label only when it is the JSON boolean ``true``. Everything else in this
+report (``reasons``, ``checks``, ``summary``) is this runner's proposed
+shape for the rest of the contract issue #48 and hub-api issue #4 both
+describe in prose ("pass/fail, reasons, NIR/compat/energy summary") but
+neither repository had committed code for as of this writing -- see the
+pull request description for the coordination note.
+"""
+
+import datetime
+from typing import Any, Dict, List, Optional
+
+
+def _now_iso() -> str:
+ """Return the current UTC time as an ISO-8601 string."""
+ return (
+ datetime.datetime.now(datetime.timezone.utc)
+ .isoformat(timespec="seconds")
+ )
+
+
+def build_report(
+ *,
+ version_id: str,
+ passed: bool,
+ reasons: List[str],
+ checks: Dict[str, Optional[Any]],
+ summary: str,
+ run_url: str,
+) -> Dict[str, Any]:
+ """Return the JSON-able report body for one verification run.
+
+ ``reasons`` names every failing step in the uploader's own words (see
+ :mod:`hub_verify.errors`); it is empty exactly when ``passed`` is True.
+ ``checks`` carries the bundle/inspect/compat/drift/energy detail a
+ listing page can render even for a passing artifact.
+ """
+ return {
+ "version_id": version_id,
+ "passed": bool(passed),
+ "reasons": list(reasons),
+ "checks": checks,
+ "summary": summary,
+ "generated_at": _now_iso(),
+ "runner": {"workflow": "hub-verify.yml", "run_url": run_url},
+ }
+
+
+def rejection_summary(reasons: List[str]) -> str:
+ """Return a one-line summary naming the first rejection reason."""
+ if not reasons:
+ return "rejected with no named reason (this is itself a bug)"
+ return f"rejected: {reasons[0]}"
diff --git a/scripts/hub_verify/signing.py b/scripts/hub_verify/signing.py
new file mode 100644
index 0000000..8677c89
--- /dev/null
+++ b/scripts/hub_verify/signing.py
@@ -0,0 +1,39 @@
+"""Sign the outgoing verification report for the hub API callback.
+
+Mirrors the HMAC-SHA256 pattern already used in this repository for a
+signed record (:func:`spikeforge_hub.registry.sign_entry`), adapted for an
+HTTP callback: the signature covers the exact request-body bytes, the same
+convention GitHub itself uses for webhook signatures
+(``X-Hub-Signature-256``), so the receiving side only needs to hash the raw
+body it read -- no canonical re-serialization step to keep in sync across
+two repositories.
+"""
+
+import hashlib
+import hmac
+
+#: The callback header carrying the signature, as ``sha256=``.
+SIGNATURE_HEADER = "X-Spikeforge-Hub-Signature"
+
+
+def _as_key(secret: str) -> bytes:
+ """Return ``secret`` as bytes, refusing an empty one."""
+ if not secret:
+ raise ValueError("verification secret must not be empty")
+ return secret.encode("utf-8")
+
+
+def sign_body(body: bytes, secret: str) -> str:
+ """Return the ``sha256=`` signature of ``body`` under ``secret``."""
+ digest = hmac.new(_as_key(secret), body, hashlib.sha256).hexdigest()
+ return f"sha256={digest}"
+
+
+def signature_matches(body: bytes, secret: str, header_value: str) -> bool:
+ """Return True when ``header_value`` is ``body``'s signature.
+
+ Provided for the hub-api side (or a test standing in for it) to verify
+ a callback with the same constant-time comparison this repository uses
+ elsewhere for signed records.
+ """
+ return hmac.compare_digest(sign_body(body, secret), header_value)
diff --git a/spikeforge/serving/bundle.py b/spikeforge/serving/bundle.py
index 1d50d1f..8cbb64a 100644
--- a/spikeforge/serving/bundle.py
+++ b/spikeforge/serving/bundle.py
@@ -185,6 +185,24 @@ def _meta_input_size(meta: Mapping[str, Any]) -> Optional[Tuple[int, int]]:
return None
+def _check_decompressed_size(
+ path: str, archive: zipfile.ZipFile, names: Any
+) -> None:
+ """Raise when ``names``' total decompressed size exceeds the cap.
+
+ Checked against each entry's recorded ``file_size`` before any entry is
+ decompressed, so a small compressed payload declaring an enormous
+ decompressed size (a zip bomb) is refused rather than read into memory.
+ """
+ total = sum(archive.getinfo(name).file_size for name in names)
+ if total > bm.MAX_DECOMPRESSED_BYTES:
+ raise BundleFormatError(
+ path,
+ f"decompressed size {total} exceeds the "
+ f"{bm.MAX_DECOMPRESSED_BYTES} byte cap",
+ )
+
+
def _read_archive(path: str) -> Dict[str, bytes]:
"""Return every entry of the zip at ``path``, or raise a typed error."""
if not os.path.exists(path):
@@ -197,6 +215,7 @@ def _read_archive(path: str) -> Dict[str, bytes]:
raise BundleFormatError(
path, f"missing required entries: {missing}"
)
+ _check_decompressed_size(path, archive, names)
return {name: archive.read(name) for name in names}
except zipfile.BadZipFile as error:
raise BundleFormatError(
diff --git a/spikeforge/serving/bundle_manifest.py b/spikeforge/serving/bundle_manifest.py
index 4c4e169..e3a5675 100644
--- a/spikeforge/serving/bundle_manifest.py
+++ b/spikeforge/serving/bundle_manifest.py
@@ -39,6 +39,15 @@
#: Entries a bundle may additionally carry.
OPTIONAL_ENTRIES = (GRAPH_NAME,)
+#: Refuse a bundle whose entries decompress past this many bytes in total.
+#: Checked against each entry's recorded ``file_size`` before any entry is
+#: read, so a small compressed payload declaring an enormous decompressed
+#: size (a zip bomb) is rejected rather than read into memory. Matches the
+#: community-upload single-artifact cap in
+#: ``plans/hub_accounts_plan.md`` §5.3; real artifacts today are
+#: 46 KB-414 KB (§1), so this is generous headroom, not a tight fit.
+MAX_DECOMPRESSED_BYTES = 256 * 1024 * 1024 # 256 MiB
+
def checksum(payload: bytes) -> str:
"""Return the lowercase hex SHA-256 of ``payload``."""
diff --git a/tests/test_hub_verify_callback.py b/tests/test_hub_verify_callback.py
new file mode 100644
index 0000000..894a16a
--- /dev/null
+++ b/tests/test_hub_verify_callback.py
@@ -0,0 +1,91 @@
+"""Posting the signed report back to the hub API."""
+
+import json
+from typing import Any
+from urllib import error as urlerror
+
+import pytest
+from hub_verify import callback
+from hub_verify.errors import CallbackError
+from hub_verify.signing import SIGNATURE_HEADER, signature_matches
+
+
+class _FakeReply:
+ """A minimal stand-in for the ``urlopen`` context manager."""
+
+ def __init__(self, status: int) -> None:
+ """Record the reply's HTTP ``status``."""
+ self.status = status
+
+ def __enter__(self) -> "_FakeReply":
+ """Support ``with urlopen(...) as reply``."""
+ return self
+
+ def __exit__(self, *exc_info: Any) -> None:
+ """Nothing to release for a fake reply."""
+
+
+def test_posts_a_correctly_signed_body(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The delivered request body is signed under the given secret."""
+ sent = {}
+
+ def _urlopen(request: Any, timeout: float) -> _FakeReply:
+ sent["url"] = request.full_url
+ sent["body"] = request.data
+ # ``Request.add_header`` stores every key through ``.capitalize()``
+ # (first character up, the rest down), so the header must be read
+ # back the same way rather than by its original spelling.
+ sent["signature"] = request.headers[SIGNATURE_HEADER.capitalize()]
+ return _FakeReply(200)
+
+ monkeypatch.setattr(callback.urllib.request, "urlopen", _urlopen)
+ report = {"version_id": "v1", "passed": True}
+ callback.post_report(
+ "https://hub.spikeforge.net", "v1", report, "sekrit"
+ )
+ assert sent["url"] == (
+ "https://hub.spikeforge.net/internal/v1/verifications/v1"
+ )
+ assert json.loads(sent["body"]) == report
+ assert signature_matches(sent["body"], "sekrit", sent["signature"])
+
+
+def test_error_status_is_a_callback_error(
+ monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A 4xx/5xx reply is reported by name, not swallowed."""
+ monkeypatch.setattr(
+ callback.urllib.request, "urlopen", lambda *a, **k: _FakeReply(500)
+ )
+ with pytest.raises(CallbackError, match="500"):
+ callback.post_report("https://hub.spikeforge.net", "v1", {}, "s")
+
+
+def test_http_error_is_a_callback_error(
+ monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """An HTTPError from urlopen is wrapped, not left to propagate raw."""
+
+ def _raise(*_args: Any, **_kwargs: Any) -> None:
+ raise urlerror.HTTPError(
+ "url", 404, "not found", {}, __import__("io").BytesIO(b"nope")
+ )
+
+ monkeypatch.setattr(callback.urllib.request, "urlopen", _raise)
+ with pytest.raises(CallbackError, match="404"):
+ callback.post_report("https://hub.spikeforge.net", "v1", {}, "s")
+
+
+def test_network_error_is_a_callback_error(
+ monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A connection failure is a named ``CallbackError``, not a crash."""
+
+ def _raise(*_args: Any, **_kwargs: Any) -> None:
+ raise urlerror.URLError("unreachable")
+
+ monkeypatch.setattr(callback.urllib.request, "urlopen", _raise)
+ with pytest.raises(CallbackError, match="callback delivery failed"):
+ callback.post_report("https://hub.spikeforge.net", "v1", {}, "s")
diff --git a/tests/test_hub_verify_cli.py b/tests/test_hub_verify_cli.py
new file mode 100644
index 0000000..572e6fa
--- /dev/null
+++ b/tests/test_hub_verify_cli.py
@@ -0,0 +1,119 @@
+"""CLI orchestration: env reading, stage sequencing, always-posts-a-report."""
+
+from typing import Any, Dict, List, Tuple
+
+import pytest
+from hub_verify import cli
+from hub_verify.errors import CallbackError, VerificationStepError
+
+_ENV = {
+ "SPIKEFORGE_HUB_VERIFY_VERSION_ID": "v1",
+ "SPIKEFORGE_HUB_VERIFY_ARTIFACT_URL": "https://cdn.example/a.spkf",
+ "SPIKEFORGE_HUB_API_BASE_URL": "https://hub.example",
+ "SPIKEFORGE_HUB_VERIFICATION_SECRET": "sekrit",
+}
+
+
+def _set_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Populate every required environment variable for ``main()``."""
+ for key, value in _ENV.items():
+ monkeypatch.setenv(key, value)
+
+
+class _FakeBundle:
+ """A stand-in with just the attributes ``_run_checks`` reads."""
+
+ manifest = {"topology": "fc_small"}
+ spec = None
+
+
+def test_missing_env_var_exits_loudly(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A missing required setting exits rather than crashing obscurely."""
+ monkeypatch.delenv(
+ "SPIKEFORGE_HUB_VERIFY_VERSION_ID", raising=False
+ )
+ with pytest.raises(SystemExit, match="SPIKEFORGE_HUB_VERIFY_VERSION_ID"):
+ cli.main()
+
+
+def test_a_passing_artifact_posts_passed_true(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Every stage succeeding posts ``passed: true`` with no reasons."""
+ _set_env(monkeypatch)
+ monkeypatch.setattr(
+ cli, "fetch_artifact", lambda url, dest: dest
+ )
+ monkeypatch.setattr(
+ cli, "load_and_build", lambda path: (_FakeBundle(), object())
+ )
+ monkeypatch.setattr(
+ cli, "run_funnel",
+ lambda bundle, module, tmp: {
+ "inspect": {"kind": "state_dict"},
+ "compat": {"verdict": "incompatible"},
+ "drift": {"within_tolerance": True},
+ },
+ )
+ monkeypatch.setattr(cli, "energy_reports", lambda spec, module: [])
+
+ posted: Dict[str, Any] = {}
+ monkeypatch.setattr(
+ cli,
+ "post_report",
+ lambda base, vid, report, secret: posted.update(report=report),
+ )
+ assert cli.main() == 0
+ assert posted["report"]["passed"] is True
+ assert posted["report"]["reasons"] == []
+
+
+def test_a_failing_stage_posts_the_named_reason(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A stage failure posts ``passed: false`` naming which step and why."""
+ _set_env(monkeypatch)
+ monkeypatch.setattr(cli, "fetch_artifact", lambda url, dest: dest)
+
+ def _raise(path: str) -> Tuple[Any, Any]:
+ raise VerificationStepError("bundle", "weights are not loadable")
+
+ monkeypatch.setattr(cli, "load_and_build", _raise)
+
+ posted: Dict[str, Any] = {}
+ monkeypatch.setattr(
+ cli,
+ "post_report",
+ lambda base, vid, report, secret: posted.update(report=report),
+ )
+ assert cli.main() == 0
+ assert posted["report"]["passed"] is False
+ reasons: List[str] = posted["report"]["reasons"]
+ assert reasons == ["bundle: weights are not loadable"]
+
+
+def test_a_failed_callback_exits_loudly(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The job fails when the signed report cannot be delivered at all."""
+ _set_env(monkeypatch)
+ monkeypatch.setattr(cli, "fetch_artifact", lambda url, dest: dest)
+ monkeypatch.setattr(
+ cli, "load_and_build", lambda path: (_FakeBundle(), object())
+ )
+ monkeypatch.setattr(
+ cli, "run_funnel",
+ lambda bundle, module, tmp: {
+ "inspect": {}, "compat": {}, "drift": {"within_tolerance": True}
+ },
+ )
+ monkeypatch.setattr(cli, "energy_reports", lambda spec, module: [])
+
+ def _raise(*_args: Any) -> None:
+ raise CallbackError("hub API unreachable")
+
+ monkeypatch.setattr(cli, "post_report", _raise)
+ with pytest.raises(SystemExit, match="could not deliver"):
+ cli.main()
diff --git a/tests/test_hub_verify_fetch.py b/tests/test_hub_verify_fetch.py
new file mode 100644
index 0000000..d9171f8
--- /dev/null
+++ b/tests/test_hub_verify_fetch.py
@@ -0,0 +1,84 @@
+"""Fetching the dispatched artifact, capped and named on failure."""
+
+import io
+from typing import Any
+from urllib import error as urlerror
+
+import pytest
+from hub_verify import fetch
+from hub_verify.errors import FetchError
+
+
+class _FakeResponse:
+ """A minimal stand-in for ``http.client.HTTPResponse``."""
+
+ def __init__(self, payload: bytes) -> None:
+ """Wrap ``payload`` behind a chunked ``read``."""
+ self._buffer = io.BytesIO(payload)
+
+ def read(self, size: int) -> bytes:
+ """Return up to ``size`` bytes, matching the real response API."""
+ return self._buffer.read(size)
+
+ def __enter__(self) -> "_FakeResponse":
+ """Support the ``with urlopen(...) as response`` pattern."""
+ return self
+
+ def __exit__(self, *exc_info: Any) -> None:
+ """Nothing to release for an in-memory buffer."""
+
+
+def test_fetch_writes_the_payload(
+ tmp_path: Any, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A normal response is streamed to ``dest`` unchanged."""
+ payload = b"spkf-bytes"
+ monkeypatch.setattr(
+ fetch.urllib.request,
+ "urlopen",
+ lambda *a, **k: _FakeResponse(payload),
+ )
+ dest = str(tmp_path / "artifact.spkf")
+ fetch.fetch_artifact("https://cdn.example/x", dest)
+ with open(dest, "rb") as handle:
+ assert handle.read() == payload
+
+
+def test_fetch_refuses_past_the_byte_cap(
+ tmp_path: Any, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A payload larger than the cap is refused, not buffered in full."""
+ monkeypatch.setattr(fetch, "MAX_ARTIFACT_BYTES", 4)
+ monkeypatch.setattr(
+ fetch.urllib.request,
+ "urlopen",
+ lambda *a, **k: _FakeResponse(b"way too much data"),
+ )
+ with pytest.raises(FetchError, match="byte cap"):
+ fetch.fetch_artifact("https://cdn.example/x", str(tmp_path / "a"))
+
+
+def test_http_error_is_a_named_fetch_error(
+ tmp_path: Any, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A non-2xx signed-URL response is a named, not a generic, failure."""
+
+ def _raise(*_args: Any, **_kwargs: Any) -> None:
+ raise urlerror.HTTPError("url", 403, "forbidden", {}, None)
+
+ monkeypatch.setattr(fetch.urllib.request, "urlopen", _raise)
+ with pytest.raises(FetchError, match="HTTP 403"):
+ fetch.fetch_artifact("https://cdn.example/x", str(tmp_path / "a"))
+
+
+def test_network_error_is_a_named_fetch_error(
+ tmp_path: Any, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A connection failure is reported by name, not as a raw traceback."""
+
+ def _raise(*_args: Any, **_kwargs: Any) -> None:
+ raise urlerror.URLError("no route to host")
+
+ monkeypatch.setattr(fetch.urllib.request, "urlopen", _raise)
+ with pytest.raises(FetchError, match="network fetch failed"):
+ fetch.fetch_artifact("https://cdn.example/x", str(tmp_path / "a"))
diff --git a/tests/test_hub_verify_pipeline.py b/tests/test_hub_verify_pipeline.py
new file mode 100644
index 0000000..844a186
--- /dev/null
+++ b/tests/test_hub_verify_pipeline.py
@@ -0,0 +1,74 @@
+"""End-to-end: load, funnel, and energy-account a real bundle."""
+
+import zipfile
+from typing import Any, Dict
+
+import pytest
+import torch
+from hub_verify.energy import energy_reports
+from hub_verify.errors import VerificationStepError
+from hub_verify.funnel import run_funnel
+from hub_verify.pipeline import load_and_build
+
+from spikeforge.network import model_store
+from spikeforge.serving import bundle_manifest as bm
+from spikeforge.serving.bundle import build
+from spikeforge.training.training_engine import TrainingEngine
+
+_NAME = "hub_verify_ckpt"
+
+
+@pytest.fixture(autouse=True)
+def _model_dir(tmp_path: Any, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Redirect checkpoint reads and writes into a per-test directory."""
+ monkeypatch.setattr(model_store, "MODEL_DIR", str(tmp_path))
+
+
+def _written(tmp_path: Any) -> str:
+ """Save a small checkpoint, build its bundle, and return its path."""
+ torch.manual_seed(0)
+ engine = TrainingEngine(
+ dataset="mnist",
+ num_steps=4,
+ device="cpu",
+ topology="fc_small",
+ topology_params={"hidden": 5, "num_classes": 3},
+ )
+ engine.save(_NAME)
+ out = str(tmp_path / "model.spkf")
+ build(_NAME, out=out)
+ return out
+
+
+def _tamper_weights(path: str) -> None:
+ """Flip a byte of the stored weights, invalidating its checksum."""
+ with zipfile.ZipFile(path) as archive:
+ entries = {n: archive.read(n) for n in archive.namelist()}
+ entries[bm.WEIGHTS_NAME] += b"\x00"
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
+ for name, payload in entries.items():
+ archive.writestr(name, payload)
+
+
+def test_a_good_bundle_passes_the_whole_funnel(tmp_path: Any) -> None:
+ """A real, untampered bundle loads, funnels, and accounts cleanly."""
+ out = _written(tmp_path)
+ bundle, module = load_and_build(out)
+ funnel: Dict[str, Any] = run_funnel(bundle, module, str(tmp_path))
+ assert funnel["inspect"]["kind"] == "state_dict"
+ assert funnel["drift"]["within_tolerance"] is True
+ reports = energy_reports(bundle.spec, module)
+ assert reports
+ assert all("target" in report for report in reports)
+
+
+def test_a_tampered_bundle_is_rejected_at_the_bundle_stage(
+ tmp_path: Any,
+) -> None:
+ """A checksum mismatch names the bundle stage, not a generic failure."""
+ out = _written(tmp_path)
+ _tamper_weights(out)
+ with pytest.raises(VerificationStepError) as info:
+ load_and_build(out)
+ assert info.value.step == "bundle"
+ assert info.value.reason
diff --git a/tests/test_hub_verify_report.py b/tests/test_hub_verify_report.py
new file mode 100644
index 0000000..63d7251
--- /dev/null
+++ b/tests/test_hub_verify_report.py
@@ -0,0 +1,44 @@
+"""The verification report's shape and the hub-api trust contract."""
+
+from hub_verify.report import build_report, rejection_summary
+
+
+def test_passed_report_carries_a_true_boolean() -> None:
+ """``passed`` is the JSON boolean hub-api's ``trust.label_for`` reads.
+
+ ``hub_api/catalog/trust.py::label_for`` only shows ``machine-checked``
+ when ``version.verification["passed"] is True`` -- not truthy, the
+ literal boolean -- so this is the one field this report must never get
+ wrong.
+ """
+ report = build_report(
+ version_id="v1",
+ passed=True,
+ reasons=[],
+ checks={"bundle": {"ok": True}},
+ summary="passed every check",
+ run_url="https://github.com/x/y/actions/runs/1",
+ )
+ assert report["passed"] is True
+ assert report["reasons"] == []
+ assert report["version_id"] == "v1"
+
+
+def test_failed_report_names_the_reason() -> None:
+ """A rejection carries the actionable reason, not just a flag."""
+ reasons = ["bundle: weights are not loadable: bad magic number"]
+ report = build_report(
+ version_id="v2",
+ passed=False,
+ reasons=reasons,
+ checks={"bundle": {"ok": False}},
+ summary=rejection_summary(reasons),
+ run_url="",
+ )
+ assert report["passed"] is False
+ assert "weights are not loadable" in report["summary"]
+
+
+def test_rejection_summary_of_no_reasons_names_the_bug() -> None:
+ """An empty reason list on a rejection is itself flagged, not hidden."""
+ assert "bug" in rejection_summary([])
diff --git a/tests/test_hub_verify_signing.py b/tests/test_hub_verify_signing.py
new file mode 100644
index 0000000..68d32d7
--- /dev/null
+++ b/tests/test_hub_verify_signing.py
@@ -0,0 +1,33 @@
+"""HMAC signing of the outgoing verification report."""
+
+import pytest
+from hub_verify import signing
+
+
+def test_matching_secret_verifies() -> None:
+ """A signature computed and checked with the same secret matches."""
+ body = b'{"passed": true}'
+ header = signing.sign_body(body, "sekrit")
+ assert header.startswith("sha256=")
+ assert signing.signature_matches(body, "sekrit", header)
+
+
+def test_wrong_secret_does_not_verify() -> None:
+ """A signature checked under a different secret does not match."""
+ body = b'{"passed": true}'
+ header = signing.sign_body(body, "sekrit")
+ assert not signing.signature_matches(body, "wrong", header)
+
+
+def test_tampered_body_does_not_verify() -> None:
+ """A body byte changed after signing breaks the signature."""
+ body = b'{"passed": true}'
+ header = signing.sign_body(body, "sekrit")
+ tampered = b'{"passed": false}'
+ assert not signing.signature_matches(tampered, "sekrit", header)
+
+
+def test_empty_secret_is_refused() -> None:
+ """Signing under an empty secret is refused rather than silently weak."""
+ with pytest.raises(ValueError):
+ signing.sign_body(b"{}", "")
diff --git a/tests/test_serving_bundle.py b/tests/test_serving_bundle.py
index c50e0c5..4d53f4d 100644
--- a/tests/test_serving_bundle.py
+++ b/tests/test_serving_bundle.py
@@ -193,6 +193,23 @@ def test_rejects_missing_required_entry(tmp_path: Any) -> None:
DeploymentBundle.load(out)
+def test_rejects_oversized_decompressed_bundle(
+ tmp_path: Any, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A bundle whose entries decompress past the cap is refused unread.
+
+ The cap is checked against each entry's declared size before any entry
+ is decompressed, so a normal small bundle is enough to trigger it once
+ the cap itself is lowered below the bundle's real total -- no need to
+ construct an actual multi-hundred-megabyte payload to prove the guard
+ works.
+ """
+ monkeypatch.setattr(bm, "MAX_DECOMPRESSED_BYTES", 8)
+ out = _written(tmp_path)
+ with pytest.raises(BundleFormatError, match="decompressed size"):
+ DeploymentBundle.load(out)
+
+
def test_rejects_non_zip_payload(tmp_path: Any) -> None:
"""A file that is not a zip archive is a format error."""
path = tmp_path / "broken.spkf"