diff --git a/app/web1/__init__.py b/app/web1/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/web1/main.py b/app/web1/main.py
new file mode 100644
index 0000000..006143b
--- /dev/null
+++ b/app/web1/main.py
@@ -0,0 +1,507 @@
+"""
+web1 — "Scientific instrument / research notebook"
+
+A FastAPI UI for kicking off the embedding-flow Prefect deployment and
+viewing the resulting HTML animations.
+
+Design: restrained, typography-driven, two-column notebook layout. No CSS
+framework; hand-written styles.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+import httpx
+from fastapi import FastAPI, Form, Request
+from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+
+
+# ---------------------------------------------------------------------------
+# Paths / constants
+# ---------------------------------------------------------------------------
+
+BASE_DIR = Path(__file__).resolve().parent
+PROJECT_ROOT = BASE_DIR.parent.parent # /home/mm/work/dr-sandbox
+FIGS_DIR = PROJECT_ROOT / "figs"
+FIGS_DIR.mkdir(parents=True, exist_ok=True)
+
+PREFECT_API = os.environ.get("PREFECT_API_URL", "http://localhost:4200/api")
+DEPLOYMENT_NAME = "embedding-flow/embedding-flow"
+
+GENERATOR_OPTIONS = [
+ ("sklearn.datasets.make_s_curve", "make_s_curve"),
+ ("sklearn.datasets.make_swiss_roll", "make_swiss_roll"),
+ ("sklearn.datasets.make_blobs", "make_blobs"),
+]
+
+
+# ---------------------------------------------------------------------------
+# Reducer catalogue
+# ---------------------------------------------------------------------------
+# Each field tuple: (name, kind, default, choices_or_none, help_or_none)
+# kinds: "int", "float", "str", "bool", "str_or_float", "int_or_null"
+
+REDUCERS: Dict[str, Dict[str, Any]] = {
+ "sklearn.decomposition.PCA": {
+ "pkg": "sklearn",
+ "label": "PCA",
+ "blurb": "Principal component analysis. Linear, fast, deterministic.",
+ "key": [
+ ("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
+ ],
+ "advanced": [
+ ("svd_solver", "str", "auto", ["auto", "full", "arpack", "randomized"], None),
+ ("random_state", "int", 42, None, None),
+ ("whiten", "bool", False, None, None),
+ ],
+ },
+ "sklearn.decomposition.FactorAnalysis": {
+ "pkg": "sklearn",
+ "label": "FactorAnalysis",
+ "blurb": "Gaussian latent-factor model with per-feature noise.",
+ "key": [
+ ("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
+ ("random_state", "int", 42, None, None),
+ ],
+ "advanced": [
+ ("tol", "float", 0.01, None, None),
+ ("max_iter", "int", 1000, None, None),
+ ("rotation", "str", "", ["", "varimax", "quartimax"], "Empty = None."),
+ ],
+ },
+ "sklearn.manifold.TSNE": {
+ "pkg": "sklearn",
+ "label": "t-SNE",
+ "blurb": "Stochastic neighbour embedding. Local structure preserved.",
+ "key": [
+ ("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
+ ("perplexity", "float", 30.0, None, None),
+ ("random_state", "int", 42, None, None),
+ ],
+ "advanced": [
+ ("learning_rate", "str_or_float", "auto", None, "'auto' or a float."),
+ ("n_iter", "int", 1000, None, None),
+ ("metric", "str", "euclidean", None, None),
+ ("early_exaggeration", "float", 12.0, None, None),
+ ("init", "str", "pca", ["pca", "random"], None),
+ ],
+ },
+ "umap.UMAP": {
+ "pkg": "umap",
+ "label": "UMAP",
+ "blurb": "Uniform manifold approximation. Preserves local + some global structure.",
+ "key": [
+ ("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
+ ("n_neighbors", "int", 15, None, None),
+ ("min_dist", "float", 0.1, None, None),
+ ("random_state", "int", 42, None, None),
+ ],
+ "advanced": [
+ ("metric", "str", "euclidean", None, None),
+ ("n_epochs", "int_or_null", "", None, "Empty = None (auto)."),
+ ("spread", "float", 1.0, None, None),
+ ("init", "str", "spectral", ["spectral", "random"], None),
+ ],
+ },
+ "pacmap.PaCMAP": {
+ "pkg": "pacmap",
+ "label": "PaCMAP",
+ "blurb": "Pairwise-controlled manifold approximation. Balanced local/global.",
+ "key": [
+ ("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
+ ("n_neighbors", "int", 10, None, None),
+ ("MN_ratio", "float", 0.5, None, None),
+ ("FP_ratio", "float", 2.0, None, None),
+ ("random_state", "int", 42, None, None),
+ ],
+ "advanced": [
+ ("lr", "float", 1.0, None, None),
+ ("num_iters", "int", 450, None, None),
+ ("apply_pca", "bool", True, None, None),
+ ],
+ },
+ "trimap.TRIMAP": {
+ "pkg": "trimap",
+ "label": "TriMap",
+ "blurb": "Triplet-based dimensionality reduction. Emphasises global structure.",
+ "key": [
+ ("n_dims", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
+ ("n_inliers", "int", 10, None, None),
+ ("n_outliers", "int", 5, None, None),
+ ("n_random", "int", 5, None, None),
+ ],
+ "advanced": [
+ ("lr", "float", 0.1, None, None),
+ ("n_iters", "int", 400, None, None),
+ ("weight_adj", "float", 500.0, None, None),
+ ],
+ },
+}
+
+
+def available_reducers() -> List[Tuple[str, Dict[str, Any]]]:
+ out = []
+ for key, spec in REDUCERS.items():
+ if importlib.util.find_spec(spec["pkg"]) is not None:
+ out.append((key, spec))
+ return out
+
+
+# ---------------------------------------------------------------------------
+# Parameter coercion
+# ---------------------------------------------------------------------------
+
+
+def _coerce(kind: str, raw: Optional[str], default: Any) -> Any:
+ if raw is None:
+ return default
+ s = raw.strip() if isinstance(raw, str) else raw
+ if kind == "int":
+ if s == "" or s is None:
+ return default
+ return int(s)
+ if kind == "float":
+ if s == "" or s is None:
+ return default
+ return float(s)
+ if kind == "bool":
+ # Checkbox: "on" / absent
+ return bool(s) and s not in ("0", "false", "False", "")
+ if kind == "str":
+ if s == "":
+ return None if default in (None, "") else default if default else ""
+ return s
+ if kind == "str_or_float":
+ if s == "":
+ return default
+ try:
+ return float(s)
+ except (ValueError, TypeError):
+ return s
+ if kind == "int_or_null":
+ if s == "":
+ return None
+ return int(s)
+ return s
+
+
+def build_embed_args(reducer_key: str, form: Dict[str, str]) -> Dict[str, Any]:
+ spec = REDUCERS[reducer_key]
+ out: Dict[str, Any] = {}
+ all_fields = list(spec["key"]) + list(spec["advanced"])
+ for (name, kind, default, _choices, _help) in all_fields:
+ raw = form.get(f"embed__{name}")
+ if kind == "bool":
+ raw_v = "on" if f"embed__{name}" in form else ""
+ value = bool(raw_v)
+ else:
+ value = _coerce(kind, raw, default)
+ # Null-stripping: drop empty rotations etc.
+ if value is None:
+ continue
+ if isinstance(value, str) and value == "" and default in (None, ""):
+ continue
+ out[name] = value
+
+ # Always force n_components / n_dims to 2 (flow assertion)
+ if "n_components" in out:
+ out["n_components"] = 2
+ if "n_dims" in out:
+ out["n_dims"] = 2
+ return out
+
+
+# ---------------------------------------------------------------------------
+# Output-path synthesis (mirrors flows/embedding_flow.py lines ~162–168)
+# ---------------------------------------------------------------------------
+
+
+def synthesize_output_paths(
+ generator_path: str,
+ embedder: str,
+ num_points: int,
+ num_snapshots: int,
+ jitter_scale: float,
+ seed: int,
+) -> Tuple[str, str]:
+ gen = generator_path.split(".")[-1]
+ emb = embedder.split(".")[-1]
+ ref = f"{gen}_Reference_N{num_points}_S{num_snapshots}_J{jitter_scale}_s{seed}.html"
+ embf = f"{gen}_{emb}_N{num_points}_S{num_snapshots}_J{jitter_scale}_s{seed}.html"
+ return ref, embf
+
+
+# ---------------------------------------------------------------------------
+# Prefect client
+# ---------------------------------------------------------------------------
+
+
+class Prefect:
+ def __init__(self, base: str = PREFECT_API) -> None:
+ self.base = base.rstrip("/")
+ self._deployment_id: Optional[str] = None
+
+ async def deployment_id(self, client: httpx.AsyncClient) -> Optional[str]:
+ if self._deployment_id:
+ return self._deployment_id
+ try:
+ r = await client.get(f"{self.base}/deployments/name/{DEPLOYMENT_NAME}")
+ if r.status_code == 200:
+ self._deployment_id = r.json()["id"]
+ return self._deployment_id
+ except httpx.HTTPError:
+ return None
+ return None
+
+ async def create_run(
+ self, client: httpx.AsyncClient, parameters: Dict[str, Any]
+ ) -> Optional[Dict[str, Any]]:
+ dep = await self.deployment_id(client)
+ if not dep:
+ return None
+ r = await client.post(
+ f"{self.base}/deployments/{dep}/create_flow_run",
+ json={"parameters": parameters},
+ )
+ if r.status_code >= 400:
+ return {"error": r.text, "status": r.status_code}
+ return r.json()
+
+ async def recent_runs(
+ self, client: httpx.AsyncClient, limit: int = 10
+ ) -> List[Dict[str, Any]]:
+ dep = await self.deployment_id(client)
+ if not dep:
+ return []
+ try:
+ r = await client.post(
+ f"{self.base}/flow_runs/filter",
+ json={
+ "sort": "START_TIME_DESC",
+ "limit": limit,
+ "flow_runs": {"deployment_id": {"any_": [dep]}},
+ },
+ )
+ if r.status_code == 200:
+ return r.json()
+ except httpx.HTTPError:
+ return []
+ return []
+
+
+PREFECT = Prefect()
+
+
+# ---------------------------------------------------------------------------
+# In-memory mapping: flow_run_id -> synthesized output file names
+# (best-effort; lost on restart, which is fine)
+# ---------------------------------------------------------------------------
+
+RUN_OUTPUTS: Dict[str, Dict[str, str]] = {}
+
+
+# ---------------------------------------------------------------------------
+# App
+# ---------------------------------------------------------------------------
+
+app = FastAPI(title="web1 — embedding notebook", docs_url=None, redoc_url=None)
+
+app.mount("/figs", StaticFiles(directory=str(FIGS_DIR)), name="figs")
+app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
+
+templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
+
+
+def _run_view(run: Dict[str, Any]) -> Dict[str, Any]:
+ """Normalise a flow-run dict for the template."""
+ rid = run.get("id", "")
+ state_type = (run.get("state_type") or "PENDING").upper()
+ state_name = run.get("state_name") or state_type.title()
+ start = run.get("start_time") or run.get("expected_start_time") or run.get("created")
+ params = run.get("parameters") or {}
+ # Try to look up synthesised outputs either from memory or from params
+ ref_file = None
+ emb_file = None
+ outs = RUN_OUTPUTS.get(rid)
+ if outs:
+ ref_file = outs["ref"]
+ emb_file = outs["embed"]
+ elif params:
+ try:
+ ref_file, emb_file = synthesize_output_paths(
+ params.get("generator_path", "sklearn.datasets.make_s_curve"),
+ params.get("embedder", "sklearn.decomposition.FactorAnalysis"),
+ int(params.get("num_points", 5000)),
+ int(params.get("num_snapshots", 48)),
+ float(params.get("jitter_scale", 0.01)),
+ int(params.get("seed", 42)),
+ )
+ except Exception:
+ ref_file, emb_file = None, None
+
+ ref_exists = bool(ref_file) and (FIGS_DIR / ref_file).exists()
+ emb_exists = bool(emb_file) and (FIGS_DIR / emb_file).exists()
+
+ return {
+ "id": rid,
+ "short_id": rid[:8] if rid else "",
+ "name": run.get("name", ""),
+ "state_type": state_type,
+ "state_name": state_name,
+ "start": start,
+ "params": params,
+ "ref_file": ref_file,
+ "emb_file": emb_file,
+ "ref_exists": ref_exists,
+ "emb_exists": emb_exists,
+ "embedder_short": (params.get("embedder") or "").split(".")[-1],
+ "generator_short": (params.get("generator_path") or "").split(".")[-1],
+ }
+
+
+def _reducer_choices() -> List[Dict[str, str]]:
+ return [
+ {"key": k, "label": spec["label"], "blurb": spec["blurb"]}
+ for k, spec in available_reducers()
+ ]
+
+
+# ---------------------------------------------------------------------------
+# Routes
+# ---------------------------------------------------------------------------
+
+
+@app.get("/", response_class=HTMLResponse)
+async def index(request: Request) -> HTMLResponse:
+ reducers = _reducer_choices()
+ default_reducer = reducers[0]["key"] if reducers else None
+ default_spec = REDUCERS.get(default_reducer) if default_reducer else None
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ runs = await PREFECT.recent_runs(client, limit=10)
+ dep_id = await PREFECT.deployment_id(client)
+ views = [_run_view(r) for r in runs]
+ return templates.TemplateResponse(
+ request,
+ "index.html",
+ {
+ "reducers": reducers,
+ "default_reducer": default_reducer,
+ "default_spec": default_spec,
+ "generators": GENERATOR_OPTIONS,
+ "runs": views,
+ "deployment_id": dep_id,
+ "prefect_api": PREFECT_API,
+ },
+ )
+
+
+@app.get("/reducer-form", response_class=HTMLResponse)
+async def reducer_form(request: Request, name: str) -> HTMLResponse:
+ spec = REDUCERS.get(name)
+ if not spec:
+ return HTMLResponse("
unknown reducer
", status_code=404)
+ return templates.TemplateResponse(
+ request,
+ "_reducer_form.html",
+ {"reducer_key": name, "spec": spec},
+ )
+
+
+@app.get("/runs", response_class=HTMLResponse)
+async def runs_partial(request: Request) -> HTMLResponse:
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ runs = await PREFECT.recent_runs(client, limit=10)
+ views = [_run_view(r) for r in runs]
+ return templates.TemplateResponse(
+ request, "_runs.html", {"runs": views}
+ )
+
+
+@app.post("/submit", response_class=HTMLResponse)
+async def submit(request: Request) -> HTMLResponse:
+ form = await request.form()
+ data: Dict[str, str] = {k: str(v) for k, v in form.items()}
+
+ reducer = data.get("reducer") or ""
+ if reducer not in REDUCERS:
+ return HTMLResponse(
+ f"unknown reducer: {reducer}
",
+ status_code=400,
+ )
+
+ # Data params
+ try:
+ num_points = int(data.get("num_points", "5000") or 5000)
+ num_snapshots = int(data.get("num_snapshots", "48") or 48)
+ jitter_scale = float(data.get("jitter_scale", "0.01") or 0.01)
+ seed = int(data.get("seed", "42") or 42)
+ except ValueError as e:
+ return HTMLResponse(
+ f"bad numeric input: {e}
", status_code=400
+ )
+ generator_path = data.get("generator_path") or "sklearn.datasets.make_s_curve"
+
+ embed_args = build_embed_args(reducer, data)
+
+ generator_kwargs: Dict[str, Any] = {}
+ if generator_path.endswith("make_blobs"):
+ generator_kwargs["n_features"] = 3
+
+ parameters: Dict[str, Any] = {
+ "num_points": num_points,
+ "num_snapshots": num_snapshots,
+ "jitter_scale": jitter_scale,
+ "seed": seed,
+ "generator_path": generator_path,
+ "embedder": reducer,
+ "embed_args": embed_args,
+ }
+ if generator_kwargs:
+ parameters["generator_kwargs"] = generator_kwargs
+
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ run = await PREFECT.create_run(client, parameters)
+
+ if not run:
+ return HTMLResponse(
+ "could not reach Prefect API at "
+ f"{PREFECT_API}
",
+ status_code=502,
+ )
+ if "error" in run:
+ return HTMLResponse(
+ f"prefect error ({run.get('status')}): "
+ f"{run.get('error')[:500]}
",
+ status_code=502,
+ )
+
+ ref_file, emb_file = synthesize_output_paths(
+ generator_path, reducer, num_points, num_snapshots, jitter_scale, seed
+ )
+ RUN_OUTPUTS[run["id"]] = {"ref": ref_file, "embed": emb_file}
+
+ # Return freshly refreshed runs partial so htmx can swap the right column
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ runs = await PREFECT.recent_runs(client, limit=10)
+ views = [_run_view(r) for r in runs]
+ return templates.TemplateResponse(
+ request,
+ "_runs.html",
+ {"runs": views, "just_submitted": run["id"]},
+ )
+
+
+@app.get("/health")
+async def health() -> JSONResponse:
+ async with httpx.AsyncClient(timeout=3.0) as client:
+ dep = await PREFECT.deployment_id(client)
+ return JSONResponse(
+ {"ok": True, "deployment_id": dep, "prefect_api": PREFECT_API}
+ )
diff --git a/app/web1/static/style.css b/app/web1/static/style.css
new file mode 100644
index 0000000..706443e
--- /dev/null
+++ b/app/web1/static/style.css
@@ -0,0 +1,515 @@
+/* ---------------------------------------------------------------------------
+ * web1 — scientific instrument / research notebook
+ *
+ * Palette:
+ * page #fafaf8 off-white
+ * ink #1a1a1a charcoal
+ * mute #6b6b68 secondary text
+ * faint #b9b8b2 hairlines, disabled
+ * rule #e3e1db dividers
+ * accent #1f4e5f muted deep teal
+ * alarm #8a3a2a rust, for failures (sparingly)
+ * ------------------------------------------------------------------------- */
+
+:root {
+ --page: #fafaf8;
+ --panel: #ffffff;
+ --ink: #1a1a1a;
+ --mute: #6b6b68;
+ --faint: #b9b8b2;
+ --rule: #e3e1db;
+ --rule-2: #ccc9c1;
+ --accent: #1f4e5f;
+ --accent-tint: #e9eff1;
+ --alarm: #8a3a2a;
+ --warm: #a77a2c;
+ --good: #3a6f3f;
+
+ --serif: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
+ "Inter", "Arial", sans-serif;
+ --mono: "JetBrains Mono", "SF Mono", "Menlo", "Consolas", ui-monospace,
+ monospace;
+
+ --fs-base: 14px;
+ --lh-base: 1.55;
+ --space: 1rem;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ padding: 0;
+ background: var(--page);
+ color: var(--ink);
+ font-family: var(--sans);
+ font-size: var(--fs-base);
+ line-height: var(--lh-base);
+ font-feature-settings: "tnum" 1, "cv11" 1; /* tabular numerals */
+ -webkit-font-smoothing: antialiased;
+}
+
+code, kbd, pre, samp, .mono, .num {
+ font-family: var(--mono);
+ font-feature-settings: "tnum" 1;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+ border-bottom: 1px solid transparent;
+ transition: border-color 120ms ease;
+}
+a:hover { border-bottom-color: var(--accent); }
+
+hr {
+ border: 0;
+ border-top: 1px solid var(--rule);
+ margin: 2rem 0;
+}
+
+/* ---------- shell ------------------------------------------------------- */
+
+.masthead {
+ border-bottom: 1px solid var(--rule);
+ padding: 1.6rem 2.2rem 1.2rem;
+ background: var(--page);
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 2rem;
+}
+.masthead .title {
+ font-family: var(--serif);
+ font-size: 1.55rem;
+ letter-spacing: -0.01em;
+ font-weight: 500;
+ margin: 0;
+}
+.masthead .title em {
+ font-style: italic;
+ color: var(--mute);
+ font-weight: 400;
+}
+.masthead .meta {
+ text-align: right;
+ color: var(--mute);
+ font-size: 0.78rem;
+ line-height: 1.5;
+ font-family: var(--mono);
+}
+.masthead .meta .dot {
+ display: inline-block; width: 6px; height: 6px; border-radius: 50%;
+ background: var(--good); margin-right: 4px; vertical-align: 1px;
+}
+.masthead .meta .dot.bad { background: var(--alarm); }
+
+main {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
+ gap: 0;
+ max-width: 1440px;
+ margin: 0 auto;
+ min-height: calc(100vh - 72px);
+}
+
+.col-left {
+ padding: 1.8rem 2rem 2rem 2.2rem;
+ border-right: 1px solid var(--rule);
+}
+.col-right {
+ padding: 1.8rem 2.2rem 2rem 2rem;
+}
+
+@media (max-width: 940px) {
+ main { grid-template-columns: 1fr; }
+ .col-left { border-right: 0; border-bottom: 1px solid var(--rule); }
+}
+
+/* ---------- typography / section labels -------------------------------- */
+
+.section-label {
+ font-family: var(--sans);
+ font-size: 0.70rem;
+ font-weight: 600;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--mute);
+ margin: 0 0 0.6rem;
+ padding-bottom: 0.35rem;
+ border-bottom: 1px solid var(--rule);
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+}
+.section-label .ordinal {
+ font-family: var(--mono);
+ font-weight: 500;
+ color: var(--faint);
+ font-size: 0.72rem;
+ letter-spacing: 0;
+}
+
+.section + .section { margin-top: 1.9rem; }
+
+.lead {
+ color: var(--mute);
+ font-size: 0.87rem;
+ font-style: italic;
+ font-family: var(--serif);
+ margin: 0.4rem 0 0.9rem;
+ max-width: 44ch;
+}
+
+/* ---------- form ------------------------------------------------------- */
+
+.form-grid {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ row-gap: 0.55rem;
+ column-gap: 1.2rem;
+ align-items: baseline;
+}
+
+.form-grid label {
+ font-size: 0.82rem;
+ color: var(--ink);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.form-grid label .hint {
+ display: block;
+ font-size: 0.72rem;
+ color: var(--mute);
+ font-style: italic;
+ font-family: var(--serif);
+ margin-top: 1px;
+ white-space: normal;
+ max-width: 24ch;
+}
+
+.form-grid input[type="text"],
+.form-grid input[type="number"],
+.form-grid select {
+ font-family: var(--mono);
+ font-size: 0.85rem;
+ color: var(--ink);
+ background: transparent;
+ border: 0;
+ border-bottom: 1px solid var(--rule-2);
+ padding: 3px 2px 4px;
+ width: 100%;
+ outline: none;
+ transition: border-color 120ms ease, background 120ms ease;
+ border-radius: 0;
+}
+.form-grid input[type="text"]:focus,
+.form-grid input[type="number"]:focus,
+.form-grid select:focus {
+ border-bottom-color: var(--accent);
+ background: var(--accent-tint);
+}
+.form-grid input[readonly] {
+ color: var(--mute);
+ background: transparent;
+ border-bottom-style: dotted;
+}
+
+.form-grid input[type="checkbox"] {
+ accent-color: var(--accent);
+ margin: 0;
+ transform: translateY(1px);
+}
+
+select {
+ appearance: none;
+ -webkit-appearance: none;
+ background-image: linear-gradient(45deg, transparent 50%, var(--mute) 50%),
+ linear-gradient(-45deg, transparent 50%, var(--mute) 50%);
+ background-position: calc(100% - 10px) 50%, calc(100% - 5px) 50%;
+ background-size: 5px 5px;
+ background-repeat: no-repeat;
+ padding-right: 18px;
+}
+
+/* reducer picker — a typographic list, not a dropdown */
+.reducer-list {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 0.4rem;
+ display: flex;
+ flex-direction: column;
+ border-top: 1px solid var(--rule);
+}
+.reducer-list li { border-bottom: 1px solid var(--rule); }
+.reducer-list input[type="radio"] { display: none; }
+.reducer-list label {
+ display: grid;
+ grid-template-columns: 1.4rem 1fr auto;
+ gap: 0.6rem;
+ padding: 0.55rem 0.2rem 0.55rem 0.1rem;
+ cursor: pointer;
+ transition: background 100ms ease;
+}
+.reducer-list label:hover { background: var(--accent-tint); }
+.reducer-list .mark {
+ font-family: var(--mono);
+ color: var(--faint);
+ text-align: center;
+ font-size: 0.9rem;
+ line-height: 1.1;
+}
+.reducer-list input:checked + label {
+ background: var(--accent-tint);
+}
+.reducer-list input:checked + label .mark { color: var(--accent); }
+.reducer-list input:checked + label .name { color: var(--accent); font-weight: 600; }
+.reducer-list .name {
+ font-family: var(--sans);
+ font-size: 0.88rem;
+ font-weight: 500;
+}
+.reducer-list .blurb {
+ display: block;
+ color: var(--mute);
+ font-size: 0.76rem;
+ font-style: italic;
+ font-family: var(--serif);
+ margin-top: 1px;
+}
+.reducer-list .pkg {
+ font-family: var(--mono);
+ color: var(--faint);
+ font-size: 0.72rem;
+ align-self: start;
+}
+
+/* advanced disclosure */
+details.advanced {
+ margin-top: 0.8rem;
+ border-top: 1px dashed var(--rule);
+ padding-top: 0.6rem;
+}
+details.advanced > summary {
+ cursor: pointer;
+ list-style: none;
+ color: var(--mute);
+ font-size: 0.74rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-weight: 600;
+ padding: 0.2rem 0;
+ user-select: none;
+}
+details.advanced > summary::-webkit-details-marker { display: none; }
+details.advanced > summary::before {
+ content: "[ + ]";
+ font-family: var(--mono);
+ color: var(--faint);
+ margin-right: 0.5rem;
+ letter-spacing: 0;
+}
+details.advanced[open] > summary::before { content: "[ − ]"; color: var(--accent); }
+
+.advanced-body { padding-top: 0.5rem; }
+
+/* submit */
+.actions {
+ margin-top: 1.4rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--rule);
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+button.submit {
+ font-family: var(--sans);
+ font-size: 0.82rem;
+ font-weight: 600;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: var(--page);
+ background: var(--accent);
+ border: 1px solid var(--accent);
+ padding: 0.55rem 1.2rem;
+ border-radius: 1px;
+ cursor: pointer;
+ transition: background 120ms ease;
+}
+button.submit:hover { background: #143642; }
+button.submit:disabled { background: var(--faint); border-color: var(--faint); cursor: not-allowed; }
+
+.actions .foot-note {
+ font-size: 0.72rem;
+ color: var(--mute);
+ font-style: italic;
+ font-family: var(--serif);
+}
+
+.flash {
+ padding: 0.6rem 0.8rem;
+ margin: 0.6rem 0;
+ border-left: 2px solid var(--accent);
+ background: var(--accent-tint);
+ font-size: 0.82rem;
+}
+.flash.err { border-left-color: var(--alarm); background: #f4e8e4; color: var(--alarm); }
+
+/* ---------- runs list -------------------------------------------------- */
+
+.run-count {
+ font-family: var(--mono);
+ color: var(--faint);
+ font-size: 0.72rem;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+.runs {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ border-top: 1px solid var(--rule);
+}
+.runs li.run {
+ border-bottom: 1px solid var(--rule);
+ padding: 0.85rem 0 0.85rem;
+ display: grid;
+ grid-template-columns: 5.5rem 1fr;
+ column-gap: 1rem;
+ align-items: start;
+}
+.runs li.run.just-submitted {
+ background: linear-gradient(to right, var(--accent-tint), transparent 60%);
+ padding-left: 0.55rem;
+ margin-left: -0.55rem;
+}
+
+.run .stamp {
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ color: var(--mute);
+ line-height: 1.3;
+ padding-top: 2px;
+}
+.run .stamp .id {
+ display: block;
+ color: var(--faint);
+ font-size: 0.68rem;
+ margin-top: 3px;
+}
+
+.run .body { min-width: 0; }
+
+.run .line1 {
+ display: flex;
+ gap: 0.55rem;
+ align-items: baseline;
+ flex-wrap: wrap;
+}
+.run .badge {
+ font-family: var(--mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ padding: 1px 6px;
+ border: 1px solid currentColor;
+ border-radius: 1px;
+ line-height: 1.2;
+}
+.badge.running { color: var(--accent); background: var(--accent-tint); }
+.badge.scheduled,
+.badge.pending { color: var(--mute); }
+.badge.completed { color: var(--good); background: #edf1ed; }
+.badge.failed,
+.badge.crashed,
+.badge.cancelled { color: var(--alarm); background: #f4e8e4; }
+
+.run .recipe {
+ font-family: var(--serif);
+ font-size: 0.92rem;
+ font-style: italic;
+ color: var(--ink);
+}
+.run .recipe .embedder { font-style: normal; font-family: var(--mono); font-size: 0.82rem; color: var(--accent); }
+.run .recipe .generator { font-style: normal; font-family: var(--mono); font-size: 0.82rem; color: var(--mute); }
+
+.run .paramline {
+ margin-top: 0.25rem;
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ color: var(--mute);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0 1.1rem;
+ row-gap: 2px;
+}
+.run .paramline .k { color: var(--faint); }
+
+.run .outputs {
+ margin-top: 0.45rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0 1.2rem;
+ row-gap: 4px;
+ font-size: 0.8rem;
+ font-family: var(--sans);
+}
+.run .outputs .tag {
+ font-family: var(--mono);
+ font-size: 0.7rem;
+ color: var(--faint);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin-right: 0.25rem;
+}
+.run .outputs a[aria-disabled="true"] {
+ color: var(--faint);
+ font-style: italic;
+ cursor: default;
+ pointer-events: none;
+}
+.run .outputs a[aria-disabled="true"]::after {
+ content: " — waiting";
+ color: var(--faint);
+ font-size: 0.72rem;
+ font-family: var(--serif);
+ font-style: italic;
+}
+
+/* empty runs */
+.empty {
+ padding: 1.4rem 0;
+ color: var(--mute);
+ font-style: italic;
+ font-family: var(--serif);
+ border-bottom: 1px solid var(--rule);
+}
+
+/* footer */
+.colophon {
+ max-width: 1440px;
+ margin: 0 auto;
+ padding: 1rem 2.2rem 2rem;
+ color: var(--faint);
+ font-size: 0.72rem;
+ font-family: var(--mono);
+ letter-spacing: 0;
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ border-top: 1px solid var(--rule);
+}
+.colophon span.k { color: var(--mute); }
+
+/* subtle htmx indicator */
+.htmx-indicator {
+ opacity: 0;
+ transition: opacity 180ms ease;
+ color: var(--mute);
+ font-family: var(--mono);
+ font-size: 0.74rem;
+}
+.htmx-request .htmx-indicator { opacity: 1; }
+.htmx-request.htmx-indicator { opacity: 1; }
diff --git a/app/web1/templates/_reducer_form.html b/app/web1/templates/_reducer_form.html
new file mode 100644
index 0000000..1f8510e
--- /dev/null
+++ b/app/web1/templates/_reducer_form.html
@@ -0,0 +1,89 @@
+{#
+ Partial: the parameter rows for one reducer.
+ Expects: reducer_key (str) OR default_reducer (on first render)
+ spec (dict) OR default_spec
+#}
+{% set rk = reducer_key if reducer_key is defined else default_reducer %}
+{% set s = spec if spec is defined else default_spec %}
+
+{% if not s %}
+ No reducers available — no supported package importable.
+{% else %}
+
+
+ {% for name, kind, default, choices, help in s.key %}
+ {% set locked = (name in ('n_components', 'n_dims')) %}
+
+ {{ name }}
+ {% if help %}{{ help }} {% endif %}
+
+
+ {% if kind == 'bool' %}
+
+
+
+ {% elif choices %}
+
+ {% for c in choices %}
+ {{ c if c != "" else "(none)" }}
+ {% endfor %}
+
+ {% elif kind == 'int' %}
+
+ {% elif kind == 'float' %}
+
+ {% elif kind == 'int_or_null' %}
+
+ {% else %}
+
+ {% endif %}
+ {% endfor %}
+
+
+ {% if s.advanced %}
+
+ show advanced
+
+
+ {% endif %}
+
+{% endif %}
diff --git a/app/web1/templates/_runs.html b/app/web1/templates/_runs.html
new file mode 100644
index 0000000..673783f
--- /dev/null
+++ b/app/web1/templates/_runs.html
@@ -0,0 +1,68 @@
+{#
+ Partial: runs list (right column).
+ Expects: runs (list of normalised view dicts), optional just_submitted (id)
+#}
+{% if not runs %}
+ No runs yet. Dispatch one from the form on the left.
+{% else %}
+
+{% endif %}
diff --git a/app/web1/templates/index.html b/app/web1/templates/index.html
new file mode 100644
index 0000000..65134f7
--- /dev/null
+++ b/app/web1/templates/index.html
@@ -0,0 +1,153 @@
+
+
+
+
+
+ embedding notebook — web1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ § 4 recent runs
+
+ {{ runs|length }} / 10 · refresh 3s
+ ●
+
+
+
+
+ {% include "_runs.html" with context %}
+
+
+
+
+
+
+
+ web1 · scientific instrument · port 8001
+ fastapi · htmx · no build step
+
+
+
+