diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8ee2e01c9003b0684a0697eecc572ed050cbab6d..47e25a2eeacfa79e53afb1a488994d3cd11ab753 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,7 +10,7 @@ on:
run_integration:
description: 'Run integration tests (requires HuggingFace download)'
required: false
- default: 'false'
+ default: false
type: boolean
jobs:
@@ -106,3 +106,112 @@ jobs:
run: uv run pytest -m integration --timeout=600
env:
HF_HOME: /tmp/hf_cache
+
+ # Frontend Jobs
+ frontend-lint:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: frontend
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - run: npm ci
+ - run: npm run lint
+
+ frontend-typecheck:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: frontend
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - run: npm ci
+ - run: npx tsc --noEmit
+
+ frontend-test:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: frontend
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - run: npm ci
+ - run: npm run test:coverage
+
+ - uses: codecov/codecov-action@v4
+ with:
+ files: frontend/coverage/coverage-final.json
+ flags: frontend
+ fail_ci_if_error: false
+ token: ${{ secrets.CODECOV_TOKEN }}
+
+ frontend-e2e:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: frontend
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - run: npm ci
+ - run: npx playwright install --with-deps chromium
+
+ - run: npm run test:e2e
+
+ - uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: playwright-report
+ path: frontend/playwright-report/
+ retention-days: 7
+
+ frontend-build:
+ runs-on: ubuntu-latest
+ needs: [frontend-lint, frontend-typecheck, frontend-test]
+ defaults:
+ run:
+ working-directory: frontend
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - run: npm ci
+ - run: npm run build
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: frontend-dist
+ path: frontend/dist/
+ retention-days: 7
diff --git a/docs/specs/00-context.md b/docs/specs/00-context.md
deleted file mode 100644
index 1c925edd1b17b3210166e9b6a745f46a01c55283..0000000000000000000000000000000000000000
--- a/docs/specs/00-context.md
+++ /dev/null
@@ -1,214 +0,0 @@
-# context: stroke-deepisles-demo
-
-> **Disclaimer**: This software is for research and demonstration purposes only. Not for clinical use.
-
-## overview
-
-This document explains **why** we're building `stroke-deepisles-demo` and the architectural context that informs our design decisions.
-
-## the problem we're solving
-
-We want to demonstrate an end-to-end neuroimaging inference pipeline:
-
-```
-CURRENT (Phase 1A):
- Local NIfTI files (extracted from ISLES24-MR-Lite ZIPs)
- ↓
- File-based loader (parse BIDS filenames)
- ↓
- DeepISLES Docker (stroke segmentation)
- ↓
- NiiVue visualization (Gradio Space)
-
-FUTURE (Phase 1C-D):
- HuggingFace Hub (properly uploaded dataset)
- ↓
- Tobias's datasets fork (BIDS loader + Nifti feature)
- ↓
- DeepISLES Docker (stroke segmentation)
- ↓
- NiiVue visualization (Gradio Space)
-```
-
-This showcases that:
-1. Neuroimaging data can be loaded from local BIDS-named files (NOW)
-2. Neuroimaging data can be consumed from HF Hub with proper BIDS/NIfTI support (FUTURE)
-3. Clinical-grade models can run via Docker as black boxes
-4. Results can be visualized interactively in a browser
-
-## critical discovery (2025-12-04)
-
-**The original ISLES24-MR-Lite dataset is NOT properly uploaded to HuggingFace.**
-
-It's just raw ZIP files dumped on HF, not a proper Dataset with parquet/Arrow format. This means `load_dataset()` fails. See `data/discovery/isles24_schema_report.txt` for full details.
-
-**Workaround**: We extracted the ZIPs locally to `data/isles24/` (git-ignored) and will implement a file-based loader first. Later, we'll re-upload properly and verify full HF consumption.
-
-## why we need tobias's datasets fork
-
-As of December 2025, the official `huggingface/datasets` library has **partial** NIfTI support but lacks critical features for neuroimaging workflows.
-
-### what's merged upstream
-
-| PR | Author | Status | Description |
-|----|--------|--------|-------------|
-| [#7874](https://github.com/huggingface/datasets/pull/7874) | CloseChoice (Tobias) | Merged Nov 21 | NIfTI visualization support |
-| [#7878](https://github.com/huggingface/datasets/pull/7878) | CloseChoice (Tobias) | Merged Nov 27 | Replace papaya with NiiVue |
-
-### what's NOT merged (and why we need the fork)
-
-| PR | Author | Status | Description |
-|----|--------|--------|-------------|
-| [#7886](https://github.com/huggingface/datasets/pull/7886) | The-Obstacle-Is-The-Way | Open | **BIDS dataset loader** - `load_dataset('bids', ...)` |
-| [#7887](https://github.com/huggingface/datasets/pull/7887) | The-Obstacle-Is-The-Way | Open | **NIfTI lazy loading fix** - use `dataobj` not `get_fdata()` |
-| [#7892](https://github.com/huggingface/datasets/pull/7892) | CloseChoice (Tobias) | Open | **NIfTI encoding for lazy upload** - fixes Arrow serialization |
-
-The fork branch bundles all these features:
-```
-https://github.com/CloseChoice/datasets/tree/feat/bids-loader-streaming-upload-fix
-```
-
-We pin to this branch until upstream merges the PRs.
-
-## key components
-
-### 1. data source: ISLES24-MR-Lite
-
-- **HF Dataset**: [YongchengYAO/ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite) (**BROKEN** - raw ZIPs, not proper dataset)
-- **Local extracted**: `data/isles24/` (git-ignored)
-- **Content**: 149 acute stroke MRI cases with DWI, ADC, and manual infarct masks
-- **Origin**: Subset of ISLES 2024 challenge data
-- **Why suitable**: DeepISLES was trained on ISLES 2022, so ISLES24 is an **external** test set (no data leakage)
-
-**File structure** (after extraction):
-```
-data/isles24/
-├── Images-DWI/sub-stroke{XXXX}_ses-02_dwi.nii.gz # 149 files
-├── Images-ADC/sub-stroke{XXXX}_ses-02_adc.nii.gz # 149 files
-└── Masks/sub-stroke{XXXX}_ses-02_lesion-msk.nii.gz # 149 files
-```
-
-**Schema reference**: `data/discovery/isles24_schema_report.txt`
-
-### 2. model: DeepISLES
-
-- **Paper**: Nature Communications 2025 - "DeepISLES: A clinically validated ischemic stroke segmentation model"
-- **GitHub**: [ezequieldlrosa/DeepIsles](https://github.com/ezequieldlrosa/DeepIsles)
-- **Docker**: `isleschallenge/deepisles`
-- **Inputs**: DWI + ADC (required), FLAIR (required for ensemble, optional for fast mode)
-- **Output**: 3D binary lesion mask (NIfTI)
-- **Mode**: `fast=True` runs **SEALS only** (the ISLES'22 challenge winner)
-
-#### Why we use `fast=True` (SEALS-only mode)
-
-DeepISLES is an ensemble of 3 models from the ISLES'22 challenge:
-
-| Model | Based On | Inputs Required | Notes |
-|-------|----------|-----------------|-------|
-| **SEALS** | nnUNet | DWI + ADC | 🏆 **ISLES'22 Winner** - runs in `--fast` mode |
-| NVAUTO | MONAI Auto3dseg | DWI + ADC + FLAIR | Requires FLAIR |
-| SWAN | FACTORIZER | DWI + ADC + FLAIR | Requires FLAIR |
-
-**Key insight**: ISLES24-MR-Lite contains only DWI + ADC (no FLAIR). Therefore:
-- `--fast True` → Runs SEALS only → **Perfect match** for our dataset
-- `--fast False` → Would try to run all 3 models → NVAUTO/SWAN would fail without FLAIR
-
-This is **not a downgrade**. SEALS won the ISLES'22 challenge and is state-of-the-art for stroke lesion segmentation using DWI+ADC alone.
-
-#### Scientific validity: External validation with zero data leakage
-
-| Dataset | Year | Used For |
-|---------|------|----------|
-| **ISLES 2022** | 2022 | SEALS training data (250 cases) |
-| **ISLES 2024** | 2024 | Our test data (149 cases from MR-Lite) |
-
-- Different patient cohorts (2 years apart, different hospitals)
-- SEALS has **never seen** ISLES24 patients
-- We have ground truth masks → can validate predictions
-- This constitutes a legitimate **external validation study**
-
-### 3. visualization: NiiVue
-
-- **Library**: [niivue/niivue](https://github.com/niivue/niivue)
-- **Type**: WebGL2-based neuroimaging viewer
-- **Formats**: Native NIfTI support, overlays, multiplanar views
-- **Integration**: Via Gradio custom HTML component or iframe
-
-### 4. UI framework: Gradio 5
-
-- **Version**: Gradio 5.x (latest as of Dec 2025)
-- **Features**: SSR for fast loading, improved components, WebRTC support
-- **Deployment**: Hugging Face Spaces
-
-## architecture diagram
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ stroke-deepisles-demo │
-├─────────────────────────────────────────────────────────────────┤
-│ │
-│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
-│ │ data/ │ │ inference/ │ │ ui/ │ │
-│ │ │ │ │ │ │ │
-│ │ - loader │───▶│ - docker │───▶│ - gradio │ │
-│ │ - adapter │ │ - wrapper │ │ - niivue │ │
-│ │ - staging │ │ - pipeline │ │ - viewer │ │
-│ └──────────────┘ └──────────────┘ └──────────────┘ │
-│ │ │ │ │
-│ ▼ ▼ ▼ │
-│ ┌──────────────────────────────────────────────────────┐ │
-│ │ core/ │ │
-│ │ - config (pydantic-settings) │ │
-│ │ - types (dataclasses, TypedDicts) │ │
-│ │ - exceptions │ │
-│ └──────────────────────────────────────────────────────┘ │
-│ │
-└─────────────────────────────────────────────────────────────────┘
- │ │ │
- ▼ ▼ ▼
- ┌──────────┐ ┌──────────┐ ┌──────────┐
- │ HF Hub │ │ Docker │ │ Browser │
- │ datasets │ │ Engine │ │ WebGL2 │
- └──────────┘ └──────────┘ └──────────┘
-```
-
-## design principles
-
-1. **Vertical slices**: Each phase delivers runnable functionality
-2. **TDD**: Tests written before implementation
-3. **Type safety**: Full type hints, mypy/pyright strict mode
-4. **Separation of concerns**: Data, inference, and UI are independent modules
-5. **Docker as black box**: We don't reimplement DeepISLES, we call it
-6. **Graceful degradation**: Mock Docker for tests, fallback viewers if NiiVue fails
-
-## reference repositories
-
-These are cloned locally (without git linkages) for reference:
-
-| Directory | Source | Purpose |
-|-----------|--------|---------|
-| `_reference_repos/datasets-tobias-bids-fork/` | CloseChoice/datasets@feat/bids-loader-streaming-upload-fix | BIDS loader + NIfTI lazy loading |
-| `_reference_repos/arc-aphasia-bids/` | The-Obstacle-Is-The-Way/arc-aphasia-bids | BIDS upload patterns (reference only) |
-| `_reference_repos/DeepIsles/` | ezequieldlrosa/DeepIsles | DeepISLES CLI interface reference |
-| `_reference_repos/bids-neuroimaging-space/` | [TobiasPitters/bids-neuroimaging](https://huggingface.co/spaces/TobiasPitters/bids-neuroimaging) | **Working NiiVue + FastAPI implementation** |
-
-### key reference: tobias's bids-neuroimaging space
-
-This is the most important reference for Phase 4 (UI). It demonstrates:
-
-1. **NiiVue working in HF Spaces** - Proof that WebGL2 viewer works in production
-2. **FastAPI + raw HTML approach** - Clean, no Gradio overhead for viewer
-3. **Base64 data URLs for NIfTI** - `data:application/octet-stream;base64,{b64}`
-4. **NiiVue CDN loading** - `https://unpkg.com/@niivue/niivue@0.57.0/dist/index.js`
-5. **Multiplanar + 3D rendering** - `setSliceType(sliceTypeMultiplanar)` + `setMultiplanarLayout(2)`
-
-Key file: `main.py` (~485 lines) - complete working implementation.
-
-## sources
-
-- [uv project configuration](https://docs.astral.sh/uv/concepts/projects/config/)
-- [Python packaging guide - pyproject.toml](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/)
-- [Real Python - Managing projects with uv](https://realpython.com/python-uv/)
-- [Gradio 5 announcement](https://huggingface.co/blog/gradio-5)
-- [NiiVue GitHub](https://github.com/niivue/niivue)
-- [Gradio custom HTML components](https://www.gradio.app/guides/custom_HTML_components)
diff --git a/docs/specs/24-bug-gradio-webgl-analysis.md b/docs/specs/24-bug-gradio-webgl-analysis.md
deleted file mode 100644
index 557ba62590f3546e37478c86a3e3fef476c9349a..0000000000000000000000000000000000000000
--- a/docs/specs/24-bug-gradio-webgl-analysis.md
+++ /dev/null
@@ -1,156 +0,0 @@
-# Bug #24: Gradio + WebGL/NiiVue Root Cause Analysis
-
-**Date:** 2025-12-10
-**Status:** ALL `gr.HTML` HACKS FAILED - Custom Component Required
-**Issue:** HF Spaces stuck on "Loading..." forever
-**Root Cause:** The `gr.HTML` + `js_on_load` + async `import()` pattern blocks Svelte hydration
-**Note:** Gradio CAN do WebGL via Custom Components (proven by gradio-litmodel3d)
-**Solution:** Build Gradio Custom Component (see spec #28)
-
----
-
-## CONFIRMED: All gr.HTML Hacks Have Failed
-
-| Attempt | Date | Result |
-|---------|------|--------|
-| CDN import in js_on_load | Dec 9 | FAILED - CSP blocks external imports |
-| Vendored + dynamic import() in js_on_load | Dec 9 | FAILED - Blocks Svelte hydration |
-| head_paths with loader HTML | Dec 9 | FAILED - Same hydration issue |
-| head= with inline import() | Dec 10 | **FAILED** - Confirmed DOA |
-
-**There is no hack that works.** The only path forward is spec #28 (Gradio Custom Component).
-
----
-
-## Why Are We Using Gradio?
-
-**What Gradio provides:**
-- Quick ML demo UIs with Python only (no frontend code needed)
-- Built-in components: file upload, sliders, dropdowns, image display
-- Easy deployment to HuggingFace Spaces
-- Handles backend/frontend communication automatically
-
-**What Gradio does NOT provide:**
-- Native support for NIfTI/DICOM medical imaging (closed as "not planned" - [Issue #4511](https://github.com/gradio-app/gradio/issues/4511))
-- Native WebGL canvas component (closed as "not planned" - [Issue #7649](https://github.com/gradio-app/gradio/issues/7649))
-- Clean way to embed custom WebGL libraries like NiiVue
-
----
-
-## The Root Cause: We're Fighting `gr.HTML`, Not Gradio
-
-### What We're Trying To Do
-Embed NiiVue (a WebGL2 library) into `gr.HTML` using JavaScript.
-
-### Why `gr.HTML` + JavaScript Doesn't Work
-1. **`gr.HTML` strips `
-
-
- {#if error}
-
{error}
- {:else if loading}
-
Loading viewer...
- {/if}
-
-
-
-
-```
-
-**Key improvements from audit feedback:**
-- WebGL2 capability check before initialization
-- WebGL context loss/restore handlers
-- Proper error UI states
-- Loading state management
-- Reactive update when value changes
-
-### Phase 3: Implement Python Backend (2-3 hours)
-
-```python
-# backend/gradio_niivue_viewer/__init__.py
-from __future__ import annotations
-from typing import Any
-from gradio.components.base import Component
-from gradio.data_classes import FileData, GradioModel
-
-class NiiVueViewerData(GradioModel):
- background_url: str | None = None
- overlay_url: str | None = None
-
-class NiiVueViewer(Component):
- """WebGL NIfTI viewer using NiiVue."""
-
- data_model = NiiVueViewerData
-
- def __init__(
- self,
- value: NiiVueViewerData | None = None,
- *,
- label: str | None = None,
- height: int = 500,
- **kwargs,
- ):
- self.height = height
- super().__init__(value=value, label=label, **kwargs)
-
- def preprocess(self, payload: NiiVueViewerData | None) -> dict[str, Any] | None:
- if payload is None:
- return None
- return {
- "background_url": payload.background_url,
- "overlay_url": payload.overlay_url,
- }
-
- def postprocess(self, value: dict[str, Any] | None) -> NiiVueViewerData | None:
- if value is None:
- return None
- return NiiVueViewerData(
- background_url=value.get("background_url"),
- overlay_url=value.get("overlay_url"),
- )
-```
-
-### Phase 4: Build and Test (2-3 hours)
-
-```bash
-# Build the component
-cd gradio-niivue-viewer
-gradio cc build
-
-# Install locally
-pip install -e .
-
-# Test in demo app
-python demo/app.py
-```
-
-### Phase 5: Integrate into stroke-deepisles-demo (1-2 hours)
-
-Replace `gr.HTML` with the custom component:
-
-```python
-# Before (broken)
-from stroke_deepisles_demo.ui.viewer import create_niivue_html
-viewer = gr.HTML(value="", elem_id="niivue-viewer")
-# ... then set viewer.value = create_niivue_html(...)
-
-# After (working)
-from gradio_niivue_viewer import NiiVueViewer
-viewer = NiiVueViewer(label="Interactive 3D Viewer")
-# ... then set viewer.value = {"background_url": dwi_url, "overlay_url": mask_url}
-```
-
-### Phase 6: HF Spaces Deployment (CRITICAL)
-
-**This phase is essential.** HF Spaces runs `pip install` only - it does NOT run `npm` or `gradio cc build`.
-
-#### 6a. Commit build artifacts to git
-
-```bash
-cd packages/gradio-niivue-viewer
-
-# Build the component (generates frontend/dist/ or templates/)
-gradio cc build
-
-# Force-add build artifacts (they may be gitignored by default)
-git add -f gradio_niivue_viewer/templates/
-# Or wherever the build output lands - check with:
-# find . -name "*.js" -path "*/dist/*" -o -name "*.css" -path "*/dist/*"
-
-git commit -m "chore: add compiled frontend assets for HF Spaces deployment"
-```
-
-**Why:** HF Spaces won't run npm/node build steps. The compiled JS/CSS must be in the repo.
-
-#### 6b. Update requirements.txt
-
-Add the local component to the main `requirements.txt`:
-
-```text
-# requirements.txt
-gradio>=5.0
-# ... other deps ...
-
-# Local custom component (editable install)
--e ./packages/gradio-niivue-viewer
-```
-
-**Alternative:** If the component is at repo root:
-```text
--e .
-```
-
-#### 6c. Verify .gitignore doesn't exclude build artifacts
-
-Check that `packages/gradio-niivue-viewer/.gitignore` doesn't exclude:
-- `gradio_niivue_viewer/templates/`
-- `frontend/dist/`
-- Any compiled `.js` or `.css` files needed at runtime
-
-If they're excluded, either:
-1. Remove those lines from `.gitignore`, OR
-2. Use `git add -f` to force-add them
-
-#### 6d. Test deployment flow
-
-```bash
-# Simulate what HF Spaces does
-pip install -r requirements.txt
-python -m stroke_deepisles_demo.ui.app
-
-# Should work WITHOUT running gradio cc build
-```
-
----
-
-## Existing References
-
-### Working WebGL Custom Components
-
-1. **[gradio-litmodel3d](https://pypi.org/project/gradio-litmodel3d/)**
- - WebGL Model3D viewer with HDR lighting
- - Source: https://github.com/gradio-app/gradio/tree/main/demo/model3d_component
- - Proof that WebGL works in Custom Components
-
-2. **[gradio-molecule3d](https://pypi.org/project/gradio-molecule3d/)**
- - 3D molecule viewer
- - Uses Three.js (WebGL)
-
-### Gradio Documentation
-
-- [Custom Components in 5 Minutes](https://www.gradio.app/guides/custom-components-in-five-minutes)
-- [Gradio Components Documentation](https://www.gradio.app/docs/gradio/components)
-- [Custom Component Gallery](https://www.gradio.app/custom-components/gallery)
-
-### NiiVue Resources
-
-- [NiiVue GitHub](https://github.com/niivue/niivue)
-- [NiiVue npm](https://www.npmjs.com/package/@niivue/niivue)
-- [NiiVue Examples](https://niivue.com/docs/)
-
----
-
-## Acceptance Criteria
-
-### Must Have (MVP)
-
-- [ ] Component loads NIfTI volumes from Gradio file URLs
-- [ ] Component displays background image (DWI)
-- [ ] Component displays overlay mask (segmentation) with colormap
-- [ ] Component works on HuggingFace Spaces
-- [ ] No "Loading..." hang - failures are graceful
-- [ ] All existing tests pass
-
-### Nice to Have (Future)
-
-- [ ] Crosshair controls
-- [ ] Slice orientation toggle (axial/coronal/sagittal)
-- [ ] Opacity slider for overlay
-- [ ] Pan/zoom/rotate controls
-- [ ] Screenshot/export functionality
-- [ ] Publish to PyPI for community use
-
----
-
-## Risk Assessment
-
-| Risk | Mitigation |
-|------|------------|
-| Svelte/TypeScript learning curve | Follow gradio-litmodel3d example closely |
-| NiiVue WebGL2 browser support | Explicit WebGL2 check in Svelte + graceful error UI |
-| Build system complexity | Use gradio cc tooling, don't customize |
-| HF Spaces static file serving | Component bundles NiiVue, no external deps |
-| **Build artifacts not in git** | Phase 6a: Force-add compiled assets with `git add -f` |
-| **requirements.txt missing component** | Phase 6b: Add `-e ./packages/gradio-niivue-viewer` |
-
----
-
-## Alternatives Considered
-
-### Alternative 1: Keep Hacking gr.HTML
-- **Effort:** Low
-- **Success probability:** 0% (CONFIRMED FAILED)
-- **Why rejected:** We tried 6 approaches over 2 days. ALL failed. This is not a viable path.
-
-### Alternative 2: Static HTML Space (No Gradio)
-- **Effort:** High (rebuild entire UI)
-- **Success probability:** 99%
-- **Why rejected:** Lose Gradio's file upload, dropdowns, layout features. Too much work.
-
-### Alternative 3: Remove 3D Viewer (2D Only)
-- **Effort:** Low
-- **Success probability:** 100%
-- **Why rejected:** Loses key feature. Static Report tab already works, but 3D is valuable.
-
----
-
-## Decision
-
-**Proceed with Gradio Custom Component approach.**
-
-This is the official Gradio-recommended solution. It's more work than hacking `gr.HTML`, but it's the architecturally correct approach with 90% success probability vs 30%.
-
----
-
-## Testing Matrix
-
-### Level 1: Local Build Verification
-
-```bash
-cd packages/gradio-niivue-viewer
-
-# Build component
-gradio cc build
-
-# Install locally
-pip install -e .
-
-# Run demo app
-python demo/app.py
-# → Verify: App loads, no console errors, viewer renders
-```
-
-**Pass criteria:**
-- [ ] `gradio cc build` completes without errors
-- [ ] Demo app launches at localhost:7860
-- [ ] No JavaScript console errors
-- [ ] Canvas renders (black background visible)
-
-### Level 2: Volume Loading Test
-
-```python
-# demo/app.py
-import gradio as gr
-from gradio_niivue_viewer import NiiVueViewer
-
-def load_sample():
- # Use a known good NIfTI file
- return {
- "background_url": "/gradio_api/file=/path/to/sample.nii.gz",
- "overlay_url": None
- }
-
-with gr.Blocks() as demo:
- viewer = NiiVueViewer()
- btn = gr.Button("Load Sample")
- btn.click(load_sample, outputs=viewer)
-
-demo.launch()
-```
-
-**Pass criteria:**
-- [ ] NIfTI file loads without errors
-- [ ] Multiplanar view displays correctly
-- [ ] Overlay mask renders with red colormap (when provided)
-
-### Level 3: HF Spaces Dry Run
-
-Deploy to a **private/throwaway Space** before production:
-
-```bash
-# Create test space
-huggingface-cli repo create test-niivue-viewer --type space --private
-
-# Push and test
-git push hf-test main
-```
-
-**Pass criteria:**
-- [ ] Space shows "Running" (not stuck on "Loading...")
-- [ ] Viewer initializes (no hydration deadlock)
-- [ ] Volume loading works via Gradio file serving
-- [ ] WebGL2 error shown gracefully if unsupported
-
-### Level 4: Integration Test
-
-Replace `gr.HTML` in stroke-deepisles-demo:
-
-```python
-# src/stroke_deepisles_demo/ui/components.py
-from gradio_niivue_viewer import NiiVueViewer
-
-def create_results_display():
- # ...
- niivue_viewer = NiiVueViewer(label="Interactive 3D Viewer")
- # ...
-```
-
-**Pass criteria:**
-- [ ] Existing 136 tests still pass
-- [ ] Segmentation pipeline works end-to-end
-- [ ] Viewer displays DWI + mask overlay
-- [ ] No "Loading..." hang on HF Spaces
-
----
-
-## Next Steps
-
-1. [x] Senior review of this spec (AUDIT_REPORT_2025_12_10.md)
-2. [x] Red team review - all gaps addressed (build artifacts, npm install, null handling)
-3. [ ] Create `packages/gradio-niivue-viewer/` subdirectory
-4. [ ] Scaffold component with `gradio cc create`
-5. [ ] Install NiiVue: `cd frontend && npm install @niivue/niivue@0.65.0`
-6. [ ] Implement Svelte frontend (with WebGL2 checks + null value handling)
-7. [ ] Implement Python backend
-8. [ ] Level 1 test: Local build verification
-9. [ ] Level 2 test: Volume loading
-10. [ ] Level 3 test: HF Spaces dry run
-11. [ ] Level 4 test: Integration
-12. [ ] **CRITICAL**: Commit build artifacts to git
-13. [ ] **CRITICAL**: Update requirements.txt with `-e ./packages/gradio-niivue-viewer`
-14. [ ] (Optional) Publish to PyPI
-
----
-
-## Appendix: Why WebGL + `gr.HTML` Doesn't Work
-
-From the ROOT_CAUSE_ANALYSIS.md and GRADIO_WEBGL_ANALYSIS.md research:
-
-1. **Gradio CAN do WebGL** - proven by `gradio-litmodel3d` custom component
-2. **But NOT via `gr.HTML`** - the `js_on_load` + `import()` pattern blocks Svelte hydration
-3. **Our A/B test proved it** - disabling `js_on_load` makes the app load perfectly
-4. **Gradio closed NIfTI support** (Issue #4511) - "Not planned for core"
-5. **Gradio closed WebGL canvas** (Issue #7649) - "Not planned for core"
-6. **gr.HTML strips script tags** - Security feature, can't bypass
-7. **HF Spaces CSP blocks external CDNs** - Must vendor or bundle dependencies
-8. **Gradio maintainer recommendation**: Custom Components
-
-The pattern is clear: **The `gr.HTML` + `js_on_load` + async `import()` pattern is fundamentally broken.** The Custom Component is the officially supported path for WebGL content.
diff --git a/docs/specs/29-codebase-status-audit.md b/docs/specs/29-codebase-status-audit.md
deleted file mode 100644
index ed4fc4886c977ab39ae36cb208f8ad5f57bdb3bf..0000000000000000000000000000000000000000
--- a/docs/specs/29-codebase-status-audit.md
+++ /dev/null
@@ -1,276 +0,0 @@
-# Spec #29: Codebase Status Audit (Issue #24 NiiVue/WebGL)
-
-**Date:** 2025-12-10
-**Status:** ALL `gr.HTML` HACKS CONFIRMED FAILED (Dec 10, 2025)
-**Purpose:** Top-down analysis of current frontend/NiiVue implementation state after multiple hotfix attempts
-
----
-
-## Executive Summary: The `gr.HTML` + `js_on_load` + `import()` Pattern is Broken
-
-After 6 iterations of attempted hotfixes for Issue #24 (HF Spaces "Loading..." forever), **every `gr.HTML`-based approach has failed**:
-
-| Attempt | Result |
-|---------|--------|
-| CDN import | FAILED - CSP blocked |
-| Vendored + js_on_load import() | FAILED - Blocks Svelte hydration |
-| head_paths | FAILED - Same hydration issue |
-| head= with import() | **FAILED** - Confirmed Dec 10 |
-
-**Root Cause (PROVEN):** Async `import()` inside `js_on_load` blocks Gradio's Svelte hydration. Our A/B test confirmed: disabling `js_on_load` makes the app load.
-
-**Clarification:** Gradio CAN do WebGL via Custom Components (`gradio-litmodel3d` proves this). The issue is the `gr.HTML` approach, not Gradio itself.
-
-**The correct solution is Gradio Custom Component (spec #28).**
-
----
-
-## Current Frontend Architecture
-
-### File Inventory
-
-| File | Purpose | Lines | Status |
-|------|---------|-------|--------|
-| `ui/viewer.py` | NiiVue HTML/JS generation | 643 | **BLOATED** - contains 5 approaches |
-| `ui/app.py` | Main Gradio app | 313 | Clean |
-| `ui/components.py` | UI components | 94 | Clean |
-| `app.py` (root) | Local dev entry | 61 | Clean |
-| `ui/assets/niivue.js` | Vendored NiiVue v0.65.0 | 2.9MB | **NECESSARY** |
-
-### What's in `viewer.py` Right Now
-
-| Component | Lines | Status | Notes |
-|-----------|-------|--------|-------|
-| `NIIVUE_VERSION` | 30 | OK | Version tracking |
-| `_ASSET_DIR`, `_NIIVUE_JS_PATH` | 31-32 | OK | Path constants |
-| `NIIVUE_JS_URL` | 36 | **UNUSED** | Computed but not actually used |
-| Module-level logging | 39-42 | **SLOP** | 4 log statements at import time |
-| `get_niivue_head_html()` | 45-77 | **PROBLEMATIC** | Still uses `await import()` |
-| `get_niivue_loader_path()` | 80-109 | **DEPRECATED** | Marked deprecated but still exists |
-| `nifti_to_gradio_url()` | 112-142 | OK | Issue #19 fix, working |
-| `get_slice_at_max_lesion()` | 145-187 | OK | Matplotlib helper |
-| `render_3panel_view()` | 190-281 | OK | Matplotlib 3-panel |
-| `render_slice_comparison()` | 284-380 | OK | Matplotlib comparison |
-| `create_niivue_html()` | 383-434 | OK | HTML generation |
-| `NIIVUE_ON_LOAD_JS` | 449-538 | **MOSTLY OK** | No import(), uses window.Niivue |
-| `NIIVUE_UPDATE_JS` | 546-642 | **MOSTLY OK** | No import(), uses window.Niivue |
-
----
-
-## The Core Problem: `get_niivue_head_html()` Still Uses `import()`
-
-The current "fix" in `get_niivue_head_html()` does this:
-
-```javascript
-// viewer.py:63-76
-
-```
-
-**This is the EXACT same `await import()` pattern that breaks on HF Spaces.**
-
-The only difference from our previous attempts:
-- Before: `await import()` in `js_on_load`
-- Now: `await import()` in `head=` script
-
-**Why this might not matter:** The A/B test proved that `js_on_load` with async code breaks Gradio. Moving the `import()` to `head=` might help, but it's still executing async code that could fail silently and leave `window.Niivue` undefined.
-
----
-
-## What's Necessary vs What's Slop
-
-### NECESSARY (Keep)
-
-| Item | Why |
-|------|-----|
-| `ui/assets/niivue.js` | HF Spaces CSP blocks CDN imports |
-| `gr.set_static_paths()` | Required for Gradio 6.x file serving |
-| `nifti_to_gradio_url()` | Issue #19 fix, working |
-| `create_niivue_html()` | Generates viewer HTML |
-| `NIIVUE_ON_LOAD_JS` | Initializes viewer (doesn't import) |
-| `NIIVUE_UPDATE_JS` | Re-initializes after updates |
-| Matplotlib functions | Working 2D fallback |
-| `allowed_paths` in launch() | Runtime file access |
-
-### SLOP (Should Remove/Refactor)
-
-| Item | Why It's Slop |
-|------|---------------|
-| `NIIVUE_JS_URL` module-level computation | Computed but unused in production |
-| Module-level logging (lines 39-42) | Noisy startup logs, not useful |
-| `get_niivue_loader_path()` | Deprecated, generates file we don't need |
-| `get_niivue_head_html()` with import() | Still uses broken pattern |
-| Multiple diagnostic docs | Overlapping, contradictory, stale |
-
-### UNCERTAIN (Depends on head= fix working)
-
-| Item | Status |
-|------|--------|
-| `head=get_niivue_head_html()` in launch() | **30% chance this works** |
-
----
-
-## Documentation Status
-
-### docs/specs/ Files
-
-| File | Status | Issue |
-|------|--------|-------|
-| `00-context.md` | **ACCURATE** | None |
-| `28-gradio-custom-component-niivue.md` | **ACCURATE** | Just written |
-| `AUDIT_JS_LOADING_ISSUES.md` | **OUTDATED** | Says `set_static_paths` is blocker, but we've moved past that |
-| `DIAGNOSTIC_HF_LOADING.md` | **OUTDATED** | Lists hypotheses we've since disproven |
-| `ROOT_CAUSE_ANALYSIS.md` | **PARTIALLY OUTDATED** | Says "IN PROGRESS", discusses head= as solution |
-| `GRADIO_WEBGL_ANALYSIS.md` | **ACCURATE** | Core analysis, identifies real problem |
-
-### docs/TECHNICAL_DEBT.md
-
-| Status | Issue |
-|--------|-------|
-| **OUTDATED** | Claims "Ironclad/Production-Ready" but doesn't mention P0 NiiVue/WebGL blocker |
-
----
-
-## Recommended Cleanup Actions
-
-### Immediate (If head= fix fails)
-
-1. **Delete deprecated code:**
- - Remove `get_niivue_loader_path()`
- - Remove module-level logging
- - Clean up `NIIVUE_JS_URL` if unused
-
-2. **Archive old diagnostic docs:**
- - Move `AUDIT_JS_LOADING_ISSUES.md` to `archive/`
- - Move `DIAGNOSTIC_HF_LOADING.md` to `archive/`
- - Update `ROOT_CAUSE_ANALYSIS.md` status
-
-3. **Update TECHNICAL_DEBT.md:**
- - Add P0 section for NiiVue/WebGL blocker
- - Link to spec #28 (Custom Component)
-
-### Long-term (After decision on path forward)
-
-1. **If Custom Component route:**
- - Remove all `head=` NiiVue loading code
- - Remove `get_niivue_head_html()`
- - Simplify `viewer.py` to just Matplotlib functions
- - NiiVue loading becomes the component's responsibility
-
-2. **If 2D fallback route:**
- - Remove entire NiiVue integration
- - Remove `ui/assets/niivue.js` (2.9MB)
- - Remove `NIIVUE_ON_LOAD_JS`, `NIIVUE_UPDATE_JS`
- - Keep only Matplotlib rendering
-
----
-
-## Honest Assessment
-
-### What We've Tried (6+ iterations)
-
-1. **CDN import** → Blocked by CSP
-2. **Vendored + dynamic import in js_on_load** → Blocks Svelte hydration
-3. **head_paths with loader HTML** → Complex, didn't work
-4. **head= with inline import()** → Current state, **probably won't work**
-5. **Various set_static_paths/allowed_paths combos** → File serving works, JS loading doesn't
-
-### The Pattern
-
-Every attempt has been a variation of:
-> "Load NiiVue via some JavaScript mechanism within Gradio"
-
-Every attempt has failed because:
-> **Gradio was not designed for custom WebGL content**
-
-### The Correct Solution
-
-**Stop fighting Gradio's architecture. Use a Gradio Custom Component.**
-
-This is:
-- What Gradio maintainers recommend (Issues #4511, #7649)
-- How existing WebGL components work (gradio-litmodel3d)
-- 90% success probability vs 30% for more hacks
-
-See spec #28 for implementation details.
-
----
-
-## Current Entry Point Flow
-
-```
-HF Spaces Docker
- ↓
-CMD ["python", "-m", "stroke_deepisles_demo.ui.app"]
- ↓
-ui/app.py __main__ block
- ↓
-gr.set_static_paths([_ASSETS_DIR]) # Enable file serving
- ↓
-get_demo() # Creates Blocks with js_on_load components
- ↓
-demo.launch(
- head=get_niivue_head_html(), # <-- Injects ')`
+
+**Result:** FAILED
+
+**Why:** This is Gradio 5 syntax. In Gradio 6, `head=`, `js=`, `css=` moved from `gr.Blocks()` to `launch()`.
+
+**Source:** [Gradio 6 Migration Guide](https://www.gradio.app/main/guides/gradio-6-migration-guide)
+
+---
+
+### 5. head= Parameter on launch() with import()
+
+**What we did:** `demo.launch(head='')`
+
+**Result:** FAILED
+
+**Why:** Same problem as #2 - async import() still blocks hydration even when in ``.
+
+**Additional Issue:** [GitHub Issue #10250](https://github.com/gradio-app/gradio/issues/10250) documents that JavaScript in `head` param has non-deterministic execution - "would sometimes execute only after extended waiting periods (5+ minutes), or occasionally not at all."
+
+---
+
+### 6. head_paths= Parameter
+
+**What we did:** Used `head_paths=['path/to/niivue-loader.html']`
+
+**Result:** FAILED
+
+**Why:** Multiple issues:
+- Files weren't served correctly without `gr.set_static_paths()`
+- [GitHub Issue #11649](https://github.com/gradio-app/gradio/issues/11649) - head= with file paths causes 404
+- Even when files served correctly, the async import() pattern still blocked hydration
+
+---
+
+### 7. Vendored NiiVue (Local File)
+
+**What we did:** Downloaded niivue.js (2.9MB) locally to bypass CDN/CSP issues
+
+**Result:** PARTIALLY WORKED (file served) but FAILED (still blocks hydration)
+
+**Why:** The file serving worked via `allowed_paths` and `gr.set_static_paths()`, but the async import() pattern still blocked Svelte hydration.
+
+**Secondary Issue:** Base64 payload risk - encoding DWI (~30MB) + ADC (~18MB) as base64 would create ~65MB payloads, risking browser memory issues and Gradio payload limits.
+
+---
+
+### 8. Head Script + MutationObserver Pattern
+
+**What we did:** Load NiiVue via `launch(head=...)` globally, then use MutationObserver to watch for DOM changes and trigger initialization
+
+**Result:** FAILED
+
+**Why:** The head script still uses `await import()` which blocks hydration. Even if NiiVue loads globally as `window.Niivue`, the async pattern during page load still freezes the UI.
+
+**What it should have done:** MutationObserver watches for `data-*` attribute changes on gr.HTML elements, then calls `initNiiVue()`. But the initial import never completes due to hydration blocking.
+
+---
+
+### 9. Custom Svelte Component (packages/niivueviewer/)
+
+**What we did:** Built a full Gradio custom component:
+- Svelte 5 frontend with NiiVue bundled via npm (`@niivue/niivue@0.65.0`)
+- Python backend component class
+- StatusTracker for loading states
+- Templates compiled via `gradio cc build`
+
+**Result:** FAILED - Froze entire UI
+
+**Issues encountered:**
+
+| Issue | Description | Fix Applied | Still Broken |
+|-------|-------------|-------------|--------------|
+| Missing `gradio` prop | StatusTracker requires `gradio.i18n` for translations | PR #31 added it | Yes |
+| Missing packages/ in Docker | Dockerfile didn't copy `packages/` directory | PR #30 added COPY | Yes |
+| style.css 404 | [Issue #7026](https://github.com/gradio-app/gradio/issues/7026) - causes loading hang | N/A | Yes |
+| CJS/ESM conflicts | [Issue #6087](https://github.com/gradio-app/gradio/issues/6087) - dev server stuck | N/A | Yes |
+| Templates undefined | [Issue #9879](https://github.com/gradio-app/gradio/issues/9879) | N/A | Yes |
+
+**Time spent:** ~1 day on this approach alone
+
+**Root cause unresolved:** Even after adding `gradio` prop and shipping templates, UI still completely frozen. The exact failure mode remains unknown.
+
+**Gradio team's stance on custom components:**
+- [Issue #12074](https://github.com/gradio-app/gradio/issues/12074) - Proposed `gr.Custom` class because custom components are "too complex"
+- Custom components have multiple fragile failure modes that make them riskier than simpler approaches
+
+---
+
+### 10. gradio-iframe Package
+
+**What we did:** Attempted to use `gradio-iframe` package to isolate NiiVue in an iframe
+
+**Result:** FAILED - Incompatible with Gradio 6
+
+**Why:** `gradio-iframe` version 0.0.10 (Jan 2024) is the latest. Installing it forces Gradio downgrade from 6.x to 4.x. Package is effectively abandoned.
+
+**Source:** [PyPI gradio-iframe](https://pypi.org/project/gradio-iframe/)
+
+---
+
+### 11. Inline iframe via gr.HTML (NOT TESTED)
+
+**What we planned:** Use `gr.HTML('')` with standalone viewer HTML
+
+**Result:** PLANNED BUT NOT EXECUTED
+
+**Why we stopped:** After 2+ days of failures, we stopped here because:
+- No definitive evidence it works on HF Spaces
+- CSP may still block JavaScript in iframes
+- 50/50 chance of working - yet another gamble
+
+**This remains the only untested approach** that might theoretically work.
+
+---
+
+## Root Causes
+
+### 1. Gradio's innerHTML Security Model
+
+Gradio uses innerHTML to update component values. Browsers intentionally do not execute `
- """
-```
-
-## interfaces and types
-
-### `ui/app.py`
-
-```python
-"""Main Gradio application for stroke-deepisles-demo."""
-
-from __future__ import annotations
-
-import gradio as gr
-
-from stroke_deepisles_demo.pipeline import run_pipeline_on_case
-from stroke_deepisles_demo.ui.components import create_case_selector, create_results_display
-from stroke_deepisles_demo.ui.viewer import render_comparison_view
-
-
-def create_app() -> gr.Blocks:
- """
- Create the Gradio application.
-
- Returns:
- Configured gr.Blocks application
- """
- with gr.Blocks(
- title="Stroke Lesion Segmentation Demo",
- theme=gr.themes.Soft(),
- ) as demo:
- # Header
- gr.Markdown("""
- # Stroke Lesion Segmentation Demo
-
- This demo runs [DeepISLES](https://github.com/ezequieldlrosa/DeepIsles)
- stroke segmentation on cases from
- [ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite).
-
- > **Disclaimer**: This is for research/demonstration only. Not for clinical use.
- """)
-
- with gr.Row():
- # Left column: Controls
- with gr.Column(scale=1):
- case_selector = create_case_selector()
- run_btn = gr.Button("Run Segmentation", variant="primary")
- status = gr.Textbox(label="Status", interactive=False)
-
- # Right column: Results
- with gr.Column(scale=2):
- results_display = create_results_display()
-
- # Event handlers
- run_btn.click(
- fn=run_segmentation,
- inputs=[case_selector],
- outputs=[results_display, status],
- )
-
- return demo
-
-
-def run_segmentation(case_id: str) -> tuple[dict, str]:
- """
- Run segmentation and return results for display.
-
- Args:
- case_id: Selected case identifier
-
- Returns:
- Tuple of (results_dict, status_message)
- """
- ...
-
-
-# Module-level app instance for Gradio CLI
-demo = create_app()
-
-if __name__ == "__main__":
- demo.launch()
-```
-
-### `ui/viewer.py`
-
-```python
-"""Neuroimaging visualization for Gradio."""
-
-from __future__ import annotations
-
-from pathlib import Path
-from typing import TYPE_CHECKING
-
-import numpy as np
-
-if TYPE_CHECKING:
- from matplotlib.figure import Figure
- from numpy.typing import NDArray
-
-
-def render_slice_comparison(
- dwi_path: Path,
- prediction_path: Path,
- ground_truth_path: Path | None = None,
- *,
- slice_idx: int | None = None,
- orientation: str = "axial",
-) -> Figure:
- """
- Render side-by-side comparison of DWI, prediction, and ground truth.
-
- Args:
- dwi_path: Path to DWI NIfTI
- prediction_path: Path to predicted mask NIfTI
- ground_truth_path: Optional path to ground truth mask
- slice_idx: Slice index (default: middle slice)
- orientation: One of "axial", "coronal", "sagittal"
-
- Returns:
- Matplotlib figure with comparison view
- """
- ...
-
-
-def render_3panel_view(
- nifti_path: Path,
- mask_path: Path | None = None,
- *,
- mask_alpha: float = 0.5,
- mask_color: str = "red",
-) -> Figure:
- """
- Render axial/coronal/sagittal slices with optional mask overlay.
-
- Args:
- nifti_path: Path to base NIfTI volume
- mask_path: Optional path to mask for overlay
- mask_alpha: Transparency of mask overlay
- mask_color: Color for mask overlay
-
- Returns:
- Matplotlib figure with 3-panel view
- """
- ...
-
-
-def create_niivue_html(
- volume_url: str,
- mask_url: str | None = None,
- *,
- height: int = 400,
-) -> str:
- """
- Create HTML/JS for NiiVue viewer.
-
- Args:
- volume_url: URL to volume NIfTI file
- mask_url: Optional URL to mask NIfTI file
- height: Viewer height in pixels
-
- Returns:
- HTML string with embedded NiiVue viewer
- """
- template = f"""
-
-
- """
- return template
-
-
-def get_slice_at_max_lesion(
- mask_path: Path,
- orientation: str = "axial",
-) -> int:
- """
- Find slice index with maximum lesion area.
-
- Useful for displaying the most informative slice.
-
- Args:
- mask_path: Path to lesion mask NIfTI
- orientation: Slice orientation
-
- Returns:
- Slice index with maximum lesion area
- """
- ...
-```
-
-### `ui/components.py`
-
-```python
-"""Reusable UI components."""
-
-from __future__ import annotations
-
-import gradio as gr
-
-from stroke_deepisles_demo.data import list_case_ids
-
-
-def create_case_selector() -> gr.Dropdown:
- """
- Create a dropdown for selecting cases.
-
- Returns:
- Configured gr.Dropdown component
- """
- try:
- case_ids = list_case_ids()
- except Exception:
- case_ids = ["Error loading cases"]
-
- return gr.Dropdown(
- choices=case_ids,
- value=case_ids[0] if case_ids else None,
- label="Select Case",
- info="Choose a case from ISLES24-MR-Lite",
- )
-
-
-def create_results_display() -> dict[str, gr.components.Component]:
- """
- Create results display components.
-
- Returns:
- Dictionary of component name -> gr.Component
- """
- with gr.Group():
- viewer = gr.Image(label="Segmentation Result", type="filepath")
- metrics = gr.JSON(label="Metrics")
- download = gr.File(label="Download Prediction")
-
- return {
- "viewer": viewer,
- "metrics": metrics,
- "download": download,
- }
-
-
-def create_settings_accordion() -> dict[str, gr.components.Component]:
- """
- Create expandable settings section.
-
- Returns:
- Dictionary of setting name -> gr.Component
- """
- with gr.Accordion("Advanced Settings", open=False):
- fast_mode = gr.Checkbox(
- value=True,
- label="Fast Mode (SEALS)",
- info="Run SEALS only (ISLES'22 winner, requires DWI+ADC). Disable for full ensemble (requires FLAIR).",
- )
- show_ground_truth = gr.Checkbox(
- value=True,
- label="Show Ground Truth",
- info="Display ground truth mask if available",
- )
-
- return {
- "fast_mode": fast_mode,
- "show_ground_truth": show_ground_truth,
- }
-```
-
-### Root `app.py` for HF Spaces
-
-```python
-"""Entry point for Hugging Face Spaces deployment."""
-
-from stroke_deepisles_demo.ui.app import demo
-
-if __name__ == "__main__":
- demo.launch()
-```
-
-## hugging face spaces configuration
-
-### `README.md` header for Spaces
-
-```yaml
----
-title: Stroke DeepISLES Demo
-emoji: 🧠
-colorFrom: blue
-colorTo: purple
-sdk: gradio
-sdk_version: 5.0.0
-app_file: app.py
-pinned: false
-license: mit
----
-```
-
-### `requirements.txt` for Spaces
-
-```
-# Note: HF Spaces uses requirements.txt, not pyproject.toml
-git+https://github.com/CloseChoice/datasets.git@feat/bids-loader-streaming-upload-fix
-huggingface-hub>=0.25.0
-nibabel>=5.2.0
-numpy>=1.26.0
-pydantic>=2.5.0
-pydantic-settings>=2.1.0
-gradio>=5.0.0
-matplotlib>=3.8.0
-```
-
-## tdd plan
-
-### test file structure
-
-```
-tests/
-├── ui/
-│ ├── __init__.py
-│ ├── test_viewer.py # Tests for visualization
-│ ├── test_components.py # Tests for UI components
-│ └── test_app.py # Smoke tests for app
-```
-
-### tests to write first (TDD order)
-
-#### 1. `tests/ui/test_viewer.py` - Pure visualization functions
-
-```python
-"""Tests for viewer module."""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-import matplotlib
-import matplotlib.pyplot as plt
-import numpy as np
-import pytest
-
-matplotlib.use("Agg") # Non-interactive backend for tests
-
-from stroke_deepisles_demo.ui.viewer import (
- create_niivue_html,
- get_slice_at_max_lesion,
- render_3panel_view,
- render_slice_comparison,
-)
-
-
-class TestRender3PanelView:
- """Tests for render_3panel_view."""
-
- def test_returns_matplotlib_figure(self, synthetic_nifti_3d: Path) -> None:
- """Returns a matplotlib Figure object."""
- fig = render_3panel_view(synthetic_nifti_3d)
-
- assert isinstance(fig, plt.Figure)
- plt.close(fig)
-
- def test_has_three_axes(self, synthetic_nifti_3d: Path) -> None:
- """Figure has 3 subplots (axial, coronal, sagittal)."""
- fig = render_3panel_view(synthetic_nifti_3d)
-
- assert len(fig.axes) == 3
- plt.close(fig)
-
- def test_overlay_mask_when_provided(
- self, synthetic_nifti_3d: Path, temp_dir: Path
- ) -> None:
- """Overlays mask when mask_path provided."""
- # Create a simple mask
- import nibabel as nib
-
- mask_data = np.zeros((10, 10, 10), dtype=np.uint8)
- mask_data[4:6, 4:6, 4:6] = 1
- mask_img = nib.Nifti1Image(mask_data, np.eye(4))
- mask_path = temp_dir / "mask.nii.gz"
- nib.save(mask_img, mask_path)
-
- fig = render_3panel_view(synthetic_nifti_3d, mask_path=mask_path)
-
- # Should not raise
- assert fig is not None
- plt.close(fig)
-
-
-class TestRenderSliceComparison:
- """Tests for render_slice_comparison."""
-
- def test_comparison_without_ground_truth(
- self, synthetic_nifti_3d: Path
- ) -> None:
- """Works when ground truth is None."""
- fig = render_slice_comparison(
- synthetic_nifti_3d,
- synthetic_nifti_3d, # Use same as prediction for test
- ground_truth_path=None,
- )
-
- assert isinstance(fig, plt.Figure)
- plt.close(fig)
-
- def test_comparison_with_ground_truth(
- self, synthetic_nifti_3d: Path
- ) -> None:
- """Works when ground truth is provided."""
- fig = render_slice_comparison(
- synthetic_nifti_3d,
- synthetic_nifti_3d,
- ground_truth_path=synthetic_nifti_3d,
- )
-
- assert isinstance(fig, plt.Figure)
- plt.close(fig)
-
-
-class TestGetSliceAtMaxLesion:
- """Tests for get_slice_at_max_lesion."""
-
- def test_finds_slice_with_lesion(self, temp_dir: Path) -> None:
- """Returns slice index where lesion is largest."""
- import nibabel as nib
-
- # Create mask with lesion at slice 7
- mask_data = np.zeros((10, 10, 10), dtype=np.uint8)
- mask_data[:, :, 7] = 1 # Full slice 7 is lesion
-
- mask_img = nib.Nifti1Image(mask_data, np.eye(4))
- mask_path = temp_dir / "mask.nii.gz"
- nib.save(mask_img, mask_path)
-
- slice_idx = get_slice_at_max_lesion(mask_path, orientation="axial")
-
- assert slice_idx == 7
-
- def test_returns_middle_for_empty_mask(self, temp_dir: Path) -> None:
- """Returns middle slice when mask is empty."""
- import nibabel as nib
-
- mask_data = np.zeros((10, 10, 20), dtype=np.uint8)
- mask_img = nib.Nifti1Image(mask_data, np.eye(4))
- mask_path = temp_dir / "mask.nii.gz"
- nib.save(mask_img, mask_path)
-
- slice_idx = get_slice_at_max_lesion(mask_path, orientation="axial")
-
- assert slice_idx == 10 # Middle of 20
-
-
-class TestCreateNiivueHtml:
- """Tests for create_niivue_html."""
-
- def test_includes_volume_url(self) -> None:
- """Generated HTML includes the volume URL."""
- html = create_niivue_html("http://example.com/brain.nii.gz")
-
- assert "http://example.com/brain.nii.gz" in html
-
- def test_includes_mask_when_provided(self) -> None:
- """Generated HTML includes mask URL when provided."""
- html = create_niivue_html(
- "http://example.com/brain.nii.gz",
- mask_url="http://example.com/mask.nii.gz",
- )
-
- assert "http://example.com/mask.nii.gz" in html
-
- def test_sets_height(self) -> None:
- """Generated HTML respects height parameter."""
- html = create_niivue_html(
- "http://example.com/brain.nii.gz",
- height=600,
- )
-
- assert "height:600px" in html
-```
-
-#### 2. `tests/ui/test_app.py` - Smoke tests
-
-```python
-"""Smoke tests for Gradio app."""
-
-from __future__ import annotations
-
-
-def test_app_module_imports() -> None:
- """App module imports without side effects."""
- # This should not launch the app or make network calls
- from stroke_deepisles_demo.ui import app
-
- assert hasattr(app, "create_app")
- assert hasattr(app, "demo")
-
-
-def test_create_app_returns_blocks() -> None:
- """create_app returns a gr.Blocks instance."""
- import gradio as gr
-
- from stroke_deepisles_demo.ui.app import create_app
-
- app = create_app()
-
- assert isinstance(app, gr.Blocks)
-
-
-def test_viewer_module_imports() -> None:
- """Viewer module imports without errors."""
- from stroke_deepisles_demo.ui import viewer
-
- assert hasattr(viewer, "render_3panel_view")
- assert hasattr(viewer, "create_niivue_html")
-
-
-def test_components_module_imports() -> None:
- """Components module imports without errors."""
- from stroke_deepisles_demo.ui import components
-
- assert hasattr(components, "create_case_selector")
- assert hasattr(components, "create_results_display")
-```
-
-### what to mock
-
-- `list_case_ids()` in components - Avoid network during import
-- Any data loading in app initialization
-
-### what to test for real
-
-- Matplotlib figure generation
-- NiiVue HTML string generation
-- Slice finding algorithms
-- Module imports (no network side effects)
-
-## "done" criteria
-
-Phase 4 is complete when:
-
-1. All unit tests pass: `uv run pytest tests/ui/ -v`
-2. App launches locally: `uv run python -m stroke_deepisles_demo.ui.app`
-3. Can select a case, click "Run", see visualization
-4. Visualization shows DWI with predicted mask overlay
-5. Metrics (Dice score) displayed
-6. Type checking passes: `uv run mypy src/stroke_deepisles_demo/ui/`
-7. Ready for HF Spaces deployment (README header, requirements.txt)
-
-## implementation notes
-
-- **NiiVue is primary** - Proven working in Tobias's Space, not "fragile"
-- **Base64 data URLs** - Avoids file serving complexity, works in all environments
-- **Lazy initialization** - Do NOT call `list_case_ids()` at module import time (causes network calls)
-- **Test on HF Spaces early** - Verify WebGL works in their environment
-- **Keep UI simple** - This is a demo, not a full application
-- **Cache case list** - Avoid repeated HF Hub calls
-
-### avoiding import-time side effects
-
-The reviewer correctly noted that `demo = create_app()` at module level triggers network calls. Fix:
-
-```python
-# BAD - triggers network call on import
-demo = create_app()
-
-# GOOD - lazy initialization
-_demo: gr.Blocks | None = None
-
-def get_demo() -> gr.Blocks:
- global _demo
- if _demo is None:
- _demo = create_app()
- return _demo
-
-# For Gradio CLI compatibility
-demo = None # Set lazily
-
-if __name__ == "__main__":
- get_demo().launch()
-```
-
-Or use a factory pattern in the root `app.py`:
-
-```python
-# app.py (HF Spaces entry point)
-from stroke_deepisles_demo.ui.app import create_app
-
-demo = create_app() # Only called when this file is executed
-
-if __name__ == "__main__":
- demo.launch()
-```
-
-## dependencies to add
-
-```toml
-# Add to pyproject.toml dependencies
-"matplotlib>=3.8.0", # For static slice rendering in viewer.py
-```
-
-## reference implementation
-
-Clone Tobias's working Space for reference:
-```
-_reference_repos/bids-neuroimaging-space/
-```
-
-Key file: `main.py` - Complete NiiVue + FastAPI implementation.
diff --git a/docs/specs/archive/06-phase-5-polish.md b/docs/specs/archive/06-phase-5-polish.md
deleted file mode 100644
index 396fb016399f8747f8dc4d2b136a98b5c7d509ba..0000000000000000000000000000000000000000
--- a/docs/specs/archive/06-phase-5-polish.md
+++ /dev/null
@@ -1,667 +0,0 @@
-# phase 5: polish, observability, and docs
-
-## purpose
-
-Add production-quality polish: structured logging, environment-driven configuration, comprehensive documentation, and CI readiness. At the end of this phase, the codebase is maintainable, debuggable, and ready for others to contribute.
-
-## deliverables
-
-- [ ] Structured logging throughout all modules
-- [ ] Environment-driven configuration via pydantic-settings
-- [ ] Developer documentation (CONTRIBUTING.md, architecture)
-- [ ] API documentation (docstrings, optional Sphinx/mkdocs)
-- [ ] CI configuration (GitHub Actions)
-- [ ] Final cleanup and code review checklist
-
-## logging strategy
-
-### centralized logging setup
-
-```python
-# src/stroke_deepisles_demo/core/logging.py
-
-"""Centralized logging configuration."""
-
-from __future__ import annotations
-
-import logging
-import sys
-from typing import Literal
-
-LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
-
-
-def setup_logging(
- level: LogLevel = "INFO",
- *,
- format_style: Literal["simple", "detailed", "json"] = "simple",
-) -> None:
- """
- Configure logging for the application.
-
- Args:
- level: Minimum log level
- format_style: Output format style
-
- Example:
- >>> setup_logging("DEBUG", format_style="detailed")
- """
- formats = {
- "simple": "%(levelname)s: %(message)s",
- "detailed": "%(asctime)s | %(name)s | %(levelname)s | %(message)s",
- "json": '{"time": "%(asctime)s", "name": "%(name)s", "level": "%(levelname)s", "message": "%(message)s"}',
- }
-
- logging.basicConfig(
- level=getattr(logging, level),
- format=formats[format_style],
- stream=sys.stderr,
- force=True,
- )
-
- # Reduce noise from libraries
- logging.getLogger("urllib3").setLevel(logging.WARNING)
- logging.getLogger("httpx").setLevel(logging.WARNING)
- logging.getLogger("datasets").setLevel(logging.WARNING)
-
-
-def get_logger(name: str) -> logging.Logger:
- """
- Get a logger for a module.
-
- Args:
- name: Logger name (typically __name__)
-
- Returns:
- Configured logger instance
- """
- return logging.getLogger(f"stroke_demo.{name}")
-```
-
-### logging usage pattern
-
-```python
-# In each module
-from stroke_deepisles_demo.core.logging import get_logger
-
-logger = get_logger(__name__)
-
-
-def run_deepisles_on_folder(input_dir: Path, *, fast: bool = True) -> DeepISLESResult:
- logger.info("Starting DeepISLES inference", extra={"input_dir": str(input_dir), "fast": fast})
-
- try:
- result = _run_docker(...)
- logger.info("Inference complete", extra={"elapsed": result.elapsed_seconds})
- return result
- except Exception as e:
- logger.error("Inference failed", extra={"error": str(e)}, exc_info=True)
- raise
-```
-
-## enhanced configuration
-
-### `src/stroke_deepisles_demo/core/config.py`
-
-```python
-"""Application configuration using pydantic-settings."""
-
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Literal
-
-from pydantic import Field, field_validator
-from pydantic_settings import BaseSettings, SettingsConfigDict
-
-
-class Settings(BaseSettings):
- """
- Application settings loaded from environment variables.
-
- All settings can be overridden via environment variables with
- the STROKE_DEMO_ prefix.
-
- Example:
- export STROKE_DEMO_LOG_LEVEL=DEBUG
- export STROKE_DEMO_HF_DATASET_ID=my/dataset
- """
-
- model_config = SettingsConfigDict(
- env_prefix="STROKE_DEMO_",
- env_file=".env",
- env_file_encoding="utf-8",
- extra="ignore",
- )
-
- # Logging
- log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
- log_format: Literal["simple", "detailed", "json"] = "simple"
-
- # HuggingFace
- hf_dataset_id: str = "YongchengYAO/ISLES24-MR-Lite"
- hf_cache_dir: Path | None = None
- hf_token: str | None = Field(default=None, repr=False) # Hidden from logs
-
- # DeepISLES
- deepisles_docker_image: str = "isleschallenge/deepisles"
- deepisles_fast_mode: bool = True # SEALS-only (ISLES'22 winner, no FLAIR needed)
- deepisles_timeout_seconds: int = 1800 # 30 minutes
- deepisles_use_gpu: bool = True
-
- # Paths
- temp_dir: Path | None = None
- results_dir: Path = Path("./results")
-
- # UI
- gradio_server_name: str = "0.0.0.0"
- gradio_server_port: int = 7860
- gradio_share: bool = False
-
- @field_validator("results_dir", mode="before")
- @classmethod
- def ensure_results_dir_exists(cls, v: Path | str) -> Path:
- """Create results directory if it doesn't exist."""
- path = Path(v)
- path.mkdir(parents=True, exist_ok=True)
- return path
-
-
-# Global settings instance
-settings = Settings()
-
-
-def get_settings() -> Settings:
- """Get the current settings instance."""
- return settings
-
-
-def reload_settings() -> Settings:
- """Reload settings from environment (useful for testing)."""
- global settings
- settings = Settings()
- return settings
-```
-
-## documentation structure
-
-```
-docs/
-├── specs/ # Design specs (these documents)
-│ ├── 00-context.md
-│ ├── 01-phase-0-repo-bootstrap.md
-│ ├── ...
-│ └── 06-phase-5-polish.md
-│
-├── guides/ # User guides
-│ ├── quickstart.md # Getting started
-│ ├── configuration.md # Environment variables
-│ └── deployment.md # HF Spaces deployment
-│
-└── reference/ # API reference (auto-generated)
- └── api.md
-
-# Root level
-README.md # Project overview
-CONTRIBUTING.md # Contribution guidelines
-CHANGELOG.md # Version history
-```
-
-### `CONTRIBUTING.md`
-
-```markdown
-# Contributing to stroke-deepisles-demo
-
-Thank you for your interest in contributing!
-
-## Development Setup
-
-1. **Clone the repository**
- ```bash
- git clone https://github.com/The-Obstacle-Is-The-Way/stroke-deepisles-demo.git
- cd stroke-deepisles-demo
- ```
-
-2. **Install uv** (if not already installed)
- ```bash
- curl -LsSf https://astral.sh/uv/install.sh | sh
- ```
-
-3. **Install dependencies**
- ```bash
- uv sync
- ```
-
-4. **Install pre-commit hooks**
- ```bash
- uv run pre-commit install
- ```
-
-## Running Tests
-
-```bash
-# All tests (excluding integration)
-uv run pytest
-
-# With coverage
-uv run pytest --cov
-
-# Integration tests (requires Docker)
-uv run pytest -m integration
-
-# Slow tests (requires Docker + DeepISLES image)
-uv run pytest -m "integration and slow"
-```
-
-## Code Quality
-
-```bash
-# Lint
-uv run ruff check .
-
-# Format
-uv run ruff format .
-
-# Type check
-uv run mypy src/
-```
-
-## Project Structure
-
-```
-src/stroke_deepisles_demo/
-├── core/ # Shared utilities (config, types, exceptions)
-├── data/ # HF dataset loading and case management
-├── inference/ # DeepISLES Docker integration
-├── ui/ # Gradio application
-├── pipeline.py # End-to-end orchestration
-└── metrics.py # Evaluation metrics
-```
-
-## Pull Request Process
-
-1. Create a feature branch from `main`
-2. Write tests for new functionality
-3. Ensure all tests pass and code quality checks pass
-4. Update documentation if needed
-5. Submit PR with clear description
-
-## Code Style
-
-- Type hints on all functions
-- Docstrings in Google style
-- Keep functions focused and small
-- Prefer explicit over implicit
-```
-
-### `docs/guides/quickstart.md`
-
-```markdown
-# Quickstart
-
-Get started with stroke-deepisles-demo in 5 minutes.
-
-## Prerequisites
-
-- Python 3.11+
-- Docker (for DeepISLES inference)
-- ~10GB disk space (for Docker image and datasets)
-
-## Installation
-
-```bash
-# Clone
-git clone https://github.com/The-Obstacle-Is-The-Way/stroke-deepisles-demo.git
-cd stroke-deepisles-demo
-
-# Install
-uv sync
-```
-
-## Pull DeepISLES Docker Image
-
-```bash
-docker pull isleschallenge/deepisles
-```
-
-## Run Locally
-
-### Option 1: Gradio UI
-
-```bash
-uv run python -m stroke_deepisles_demo.ui.app
-# Open http://localhost:7860
-```
-
-### Option 2: CLI
-
-```bash
-# List available cases
-uv run stroke-demo list
-
-# Run on a specific case
-uv run stroke-demo run --case sub-001 --fast
-```
-
-### Option 3: Python API
-
-```python
-from stroke_deepisles_demo.pipeline import run_pipeline_on_case
-
-result = run_pipeline_on_case("sub-001", fast=True)
-print(f"Dice score: {result.dice_score:.3f}")
-print(f"Prediction: {result.prediction_mask}")
-```
-
-## Configuration
-
-Set environment variables or create a `.env` file:
-
-```bash
-# .env
-STROKE_DEMO_LOG_LEVEL=DEBUG
-STROKE_DEMO_DEEPISLES_USE_GPU=false # If no GPU available
-```
-
-See [Configuration Guide](configuration.md) for all options.
-```
-
-### `docs/guides/configuration.md`
-
-```markdown
-# Configuration
-
-All settings can be configured via environment variables.
-
-## Environment Variables
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `STROKE_DEMO_LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
-| `STROKE_DEMO_LOG_FORMAT` | `simple` | Log format (simple, detailed, json) |
-| `STROKE_DEMO_HF_DATASET_ID` | `YongchengYAO/ISLES24-MR-Lite` | HuggingFace dataset ID |
-| `STROKE_DEMO_HF_CACHE_DIR` | `None` | Custom HF cache directory |
-| `STROKE_DEMO_HF_TOKEN` | `None` | HuggingFace API token (for private datasets) |
-| `STROKE_DEMO_DEEPISLES_DOCKER_IMAGE` | `isleschallenge/deepisles` | DeepISLES Docker image |
-| `STROKE_DEMO_DEEPISLES_FAST_MODE` | `true` | Use single-model mode |
-| `STROKE_DEMO_DEEPISLES_TIMEOUT_SECONDS` | `1800` | Inference timeout |
-| `STROKE_DEMO_DEEPISLES_USE_GPU` | `true` | Use GPU acceleration |
-| `STROKE_DEMO_RESULTS_DIR` | `./results` | Directory for output files |
-
-## Using .env File
-
-Create a `.env` file in the project root:
-
-```bash
-STROKE_DEMO_LOG_LEVEL=DEBUG
-STROKE_DEMO_DEEPISLES_USE_GPU=false
-STROKE_DEMO_RESULTS_DIR=/data/results
-```
-
-## Programmatic Configuration
-
-```python
-from stroke_deepisles_demo.core.config import settings, reload_settings
-import os
-
-# Check current settings
-print(settings.log_level)
-
-# Override via environment
-os.environ["STROKE_DEMO_LOG_LEVEL"] = "DEBUG"
-reload_settings()
-print(settings.log_level) # DEBUG
-```
-```
-
-## ci configuration
-
-### `.github/workflows/ci.yml`
-
-```yaml
-name: CI
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
-
-jobs:
- lint:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - name: Install uv
- uses: astral-sh/setup-uv@v4
-
- - name: Set up Python
- run: uv python install 3.12
-
- - name: Install dependencies
- run: uv sync
-
- - name: Lint with ruff
- run: uv run ruff check .
-
- - name: Check formatting
- run: uv run ruff format --check .
-
- typecheck:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - name: Install uv
- uses: astral-sh/setup-uv@v4
-
- - name: Set up Python
- run: uv python install 3.12
-
- - name: Install dependencies
- run: uv sync
-
- - name: Type check with mypy
- run: uv run mypy src/
-
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - name: Install uv
- uses: astral-sh/setup-uv@v4
-
- - name: Set up Python
- run: uv python install 3.12
-
- - name: Install dependencies
- run: uv sync
-
- - name: Run tests
- run: uv run pytest --cov --cov-report=xml
-
- - name: Upload coverage
- uses: codecov/codecov-action@v4
- with:
- files: ./coverage.xml
-
- integration:
- runs-on: ubuntu-latest
- if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- steps:
- - uses: actions/checkout@v4
-
- - name: Install uv
- uses: astral-sh/setup-uv@v4
-
- - name: Set up Python
- run: uv python install 3.12
-
- - name: Install dependencies
- run: uv sync
-
- - name: Run integration tests
- run: uv run pytest -m integration --timeout=600
-```
-
-## final code review checklist
-
-### code quality
-- [ ] All functions have type hints
-- [ ] All public functions have docstrings
-- [ ] No unused imports or variables
-- [ ] No hardcoded paths or secrets
-- [ ] Error messages are helpful
-
-### testing
-- [ ] Unit test coverage > 80%
-- [ ] Edge cases covered
-- [ ] Integration tests for critical paths
-- [ ] Tests are deterministic (no flakiness)
-
-### documentation
-- [ ] README is clear and accurate
-- [ ] CONTRIBUTING.md is complete
-- [ ] All configuration options documented
-- [ ] Example usage in docstrings
-
-### security
-- [ ] No secrets in code
-- [ ] HF_TOKEN is optional and hidden from logs
-- [ ] Docker commands are properly escaped
-- [ ] No arbitrary code execution vulnerabilities
-
-### production readiness
-- [ ] Logging is consistent and useful
-- [ ] Errors are handled gracefully
-- [ ] Configuration is environment-driven
-- [ ] CI passes on all checks
-
-## tdd plan
-
-### tests for logging
-
-```python
-"""Tests for logging configuration."""
-
-from __future__ import annotations
-
-import logging
-
-from stroke_deepisles_demo.core.logging import get_logger, setup_logging
-
-
-class TestSetupLogging:
- """Tests for setup_logging."""
-
- def test_sets_log_level(self) -> None:
- """Sets the root logger level."""
- setup_logging("DEBUG")
- assert logging.getLogger().level == logging.DEBUG
-
- def test_format_styles(self) -> None:
- """Different format styles work."""
- for style in ["simple", "detailed", "json"]:
- setup_logging("INFO", format_style=style)
- # Should not raise
-
-
-class TestGetLogger:
- """Tests for get_logger."""
-
- def test_returns_namespaced_logger(self) -> None:
- """Returns logger with stroke_demo prefix."""
- logger = get_logger("my_module")
- assert logger.name == "stroke_demo.my_module"
-```
-
-### tests for configuration
-
-```python
-"""Tests for configuration."""
-
-from __future__ import annotations
-
-import os
-from pathlib import Path
-
-import pytest
-
-from stroke_deepisles_demo.core.config import Settings, reload_settings
-
-
-class TestSettings:
- """Tests for Settings."""
-
- def test_default_values(self) -> None:
- """Has sensible defaults."""
- settings = Settings()
- assert settings.log_level == "INFO"
- assert settings.hf_dataset_id == "YongchengYAO/ISLES24-MR-Lite"
-
- def test_env_override(self, monkeypatch) -> None:
- """Environment variables override defaults."""
- monkeypatch.setenv("STROKE_DEMO_LOG_LEVEL", "DEBUG")
- settings = Settings()
- assert settings.log_level == "DEBUG"
-
- def test_hf_token_hidden_from_repr(self) -> None:
- """HF token is not visible in repr."""
- settings = Settings(hf_token="secret123")
- assert "secret123" not in repr(settings)
-
- def test_results_dir_created(self, tmp_path: Path) -> None:
- """Results directory is created if it doesn't exist."""
- new_dir = tmp_path / "new_results"
- settings = Settings(results_dir=new_dir)
- assert new_dir.exists()
-```
-
-## "done" criteria
-
-Phase 5 is complete when:
-
-1. Structured logging is in place throughout
-2. All settings are configurable via environment
-3. README.md and CONTRIBUTING.md are complete
-4. Developer guides are written
-5. CI workflow passes on GitHub Actions
-6. Code coverage > 80% overall
-7. All code review checklist items pass
-8. Repository is ready for others to contribute
-
-## final deliverables
-
-At the end of all phases, the repository contains:
-
-```
-stroke-deepisles-demo/
-├── .github/
-│ └── workflows/
-│ └── ci.yml
-├── docs/
-│ ├── specs/
-│ ├── guides/
-│ └── reference/
-├── src/
-│ └── stroke_deepisles_demo/
-│ ├── core/
-│ ├── data/
-│ ├── inference/
-│ ├── ui/
-│ ├── pipeline.py
-│ ├── metrics.py
-│ └── cli.py
-├── tests/
-├── pyproject.toml
-├── uv.lock
-├── README.md
-├── CONTRIBUTING.md
-├── CHANGELOG.md
-├── .pre-commit-config.yaml
-├── .gitignore
-├── .env.example
-└── app.py # HF Spaces entry point
-```
diff --git a/docs/specs/archive/07-hf-spaces-deployment.md b/docs/specs/archive/07-hf-spaces-deployment.md
deleted file mode 100644
index ac2b502be6927e9173fb8ecbba115164462f2607..0000000000000000000000000000000000000000
--- a/docs/specs/archive/07-hf-spaces-deployment.md
+++ /dev/null
@@ -1,969 +0,0 @@
-# spec: hugging face spaces + gradio deployment
-
-> **Version**: December 2025
-> **Status**: APPROVED - Ready for Implementation
-> **Last Updated**: 2025-12-05
-> **Verified**: Cold start claims, pause/restart behavior, ZeroGPU limitations
-
-## important: gradio 6 is now available
-
-As of December 2025, **Gradio 6.0.2** is the latest stable release. Our `pyproject.toml` currently specifies `gradio>=5.0.0`, which will install Gradio 6.x.
-
-**Key breaking changes affecting our codebase:**
-
-| Change | Impact | Our Code |
-|--------|--------|----------|
-| `theme`, `css`, `js` moved from `Blocks()` to `launch()` | HIGH | `app.py:111` uses `gr.Blocks()`, `app.py:170` passes theme to `launch()` - **OK** |
-| `gr.HTML` padding default `True` → `False` | LOW | No visual impact expected |
-| Chatbot tuple format removed | NONE | We don't use Chatbot |
-| `show_api` → `footer_links` | LOW | We don't customize this |
-
-**Recommendation**: Pin to `gradio>=6.0.0,<7.0.0` for stability, or test with latest and update as needed.
-
-**Migration guide**: [Gradio 6 Migration Guide](https://www.gradio.app/main/guides/gradio-6-migration-guide)
-
----
-
-## purpose
-
-This spec documents the requirements, constraints, and best practices for deploying the `stroke-deepisles-demo` Gradio application to Hugging Face Spaces. It identifies potential friction points between our current implementation and HF Spaces constraints, providing concrete guidance before deployment.
-
-## executive summary
-
-### critical friction points identified
-
-| Issue | Severity | Current State | Fix Required |
-|-------|----------|---------------|--------------|
-| **NVIDIA GPU required** | HIGH | DeepISLES needs CUDA | Use Docker SDK + GPU on HF Spaces |
-| **JavaScript in `gr.HTML`** | HIGH | `
- """
-```
-
-#### the problem
-
-From the [Gradio documentation](https://www.gradio.app/guides/custom-CSS-and-JS) and [HF Forums](https://discuss.huggingface.co/t/gradio-html-component-with-javascript-code-dont-work/37316):
-
-> "The `gr.HTML` component doesn't support loading scripts via traditional `
- """
-```
-
-**Note**: Even this may not work reliably. Testing on HF Spaces is required.
-
-#### alternative: gradio custom components (`gradio cc`)
-
-For production deployments, Gradio 6 supports first-class **Custom Components** via the `gradio cc` CLI. This is the recommended "production" solution (vs. the `js=` hack for MVP).
-
-```bash
-# Create a NiiVue custom component
-gradio cc create NiiVueViewer --template HTML
-
-# Development server with hot reload
-gradio cc dev
-
-# Build for distribution
-gradio cc build
-
-# Publish to PyPI and HF Spaces
-gradio cc publish
-```
-
-**Pros**:
-- First-class support, proper state management
-- No hacky string interpolation
-- Reusable across projects
-
-**Cons**:
-- Requires Node.js build step
-- Higher complexity than `js=` parameter
-- Overkill for MVP
-
-**Source**: [Custom Components In Five Minutes](https://www.gradio.app/guides/custom-components-in-five-minutes)
-
-#### alternative: `gradio-iframe` component
-
-The [`gradio-iframe`](https://pypi.org/project/gradio-iframe/) package (v0.0.10) provides an iframe component that may execute JavaScript more reliably:
-
-```python
-from gradio_iframe import iFrame
-
-viewer = iFrame(
- value="...NiiVue code...",
- label="NiiVue Viewer"
-)
-```
-
-**Warning**: This is experimental and "not fully tested" per the maintainer. Use with caution.
-
-### css restrictions
-
-Custom CSS should use `elem_id` and `elem_classes` rather than query selectors:
-
-> "The use of query selectors in custom JS and CSS is not guaranteed to work across Gradio versions as the Gradio HTML DOM may change."
-
-**Source**: [Custom CSS and JS Guide](https://www.gradio.app/guides/custom-CSS-and-JS)
-
-### security (gradio 5 audit, inherited by v6)
-
-The Trail of Bits security audit was performed on **Gradio 5.0**. All fixes are inherited by Gradio 6.x:
-
-- **CVE-2024-47872**: XSS via HTML/JS/SVG file uploads (fixed in 5.0.0)
-- File type restrictions enforced server-side
-- Our app uses `gradio>=6.0.0` - we're covered
-
-> **Note**: There was no separate Gradio 6 audit. The security improvements from Gradio 5 persist in v6.
-
-**Source**: [A Security Review of Gradio 5](https://huggingface.co/blog/gradio-5-security)
-
----
-
-## readme.md yaml configuration
-
-### required fields for gradio spaces
-
-```yaml
----
-title: Stroke DeepISLES Demo
-emoji: 🧠
-colorFrom: blue
-colorTo: purple
-sdk: gradio
-sdk_version: "6.0.2" # Latest stable as of Dec 2025
-python_version: "3.11"
-app_file: app.py
-pinned: false
-license: mit
-short_description: "Ischemic stroke lesion segmentation using DeepISLES"
-
-# Optional but recommended
-models:
- - isleschallenge/deepisles # If we reference it
-datasets:
- - YongchengYAO/ISLES24-MR-Lite
-tags:
- - medical-imaging
- - stroke
- - segmentation
- - neuroimaging
- - niivue
-
-# For CPU-only demo mode
-suggested_hardware: cpu-basic
-
-# If we need cross-origin isolation (e.g., SharedArrayBuffer)
-# custom_headers:
-# cross-origin-embedder-policy: require-corp
-# cross-origin-opener-policy: same-origin
----
-```
-
-### configuration reference
-
-| Field | Type | Description |
-|-------|------|-------------|
-| `sdk` | string | `gradio`, `docker`, or `static` |
-| `sdk_version` | string | Gradio version (e.g., "5.0.0") |
-| `python_version` | string | Python version (e.g., "3.11") |
-| `app_file` | string | Entry point (default: `app.py`) |
-| `suggested_hardware` | string | Hardware for duplicators |
-| `disable_embedding` | bool | Prevent iframe embedding |
-| `custom_headers` | dict | COEP/COOP/CORP headers |
-
-**Source**: [Spaces Configuration Reference](https://huggingface.co/docs/hub/en/spaces-config-reference)
-
----
-
-## dependencies
-
-### requirements.txt for hf spaces
-
-HF Spaces uses `requirements.txt`, not `pyproject.toml` for dependency installation.
-
-```text
-# requirements.txt for HF Spaces
-
-# Core - Tobias's fork with BIDS + NIfTI lazy loading
-git+https://github.com/CloseChoice/datasets.git@feat/bids-loader-streaming-upload-fix
-
-# HuggingFace
-huggingface-hub>=0.25.0
-
-# NIfTI handling
-nibabel>=5.2.0
-numpy>=1.26.0
-
-# Configuration
-pydantic>=2.5.0
-pydantic-settings>=2.1.0
-
-# UI - Gradio 6.x (latest stable as of Dec 2025)
-gradio>=6.0.0,<7.0.0
-matplotlib>=3.8.0
-
-# Networking
-requests>=2.0.0
-```
-
-### potential issues
-
-1. **Git dependencies**: HF Spaces supports `git+https://...` in requirements.txt
-2. **C extensions**: nibabel/numpy compile fine on HF Spaces
-3. **Size**: No bloated dependencies (no PyTorch required for demo mode)
-
----
-
-## deployment paths
-
-### hardware requirements
-
-| Component | Requirement | Notes |
-|-----------|-------------|-------|
-| GPU | NVIDIA with CUDA 11.3+ | **Mandatory** - no CPU/MPS fallback |
-| VRAM | 4GB minimum, 12GB+ recommended | For parallel processing |
-| Docker | Docker + nvidia-container-toolkit | Required for DeepISLES |
-| Python | 3.8+ (3.11 recommended) | Per project config |
-
-> ⚠️ **Apple Silicon (M1/M2/M3) is NOT supported.** DeepISLES requires NVIDIA CUDA.
-
-### path 1: local nvidia gpu (primary development)
-
-For day-to-day development and testing on your own NVIDIA GPU machine.
-
-```bash
-# 1. Ensure Docker + nvidia-container-toolkit installed
-docker run --rm --gpus all nvidia/cuda:11.3-base nvidia-smi
-
-# 2. Pull DeepISLES image
-docker pull isleschallenge/deepisles
-
-# 3. Run the app
-uv run python -m stroke_deepisles_demo.ui.app
-```
-
-**Pros**:
-- Free (you own the hardware)
-- Fast iteration
-- No network dependency
-
-**Cons**:
-- Requires NVIDIA GPU hardware
-
-### path 2: hf spaces docker sdk + gpu (on-demand demos)
-
-For showcasing to others. Spin up when needed, pause when done.
-
-#### dockerfile for hf spaces
-
-```dockerfile
-# Dockerfile for HF Spaces
-# CRITICAL: DeepISLES code lives at /app/src/ in the base image.
-# We install our demo at /home/user/demo to avoid overwriting DeepISLES.
-FROM isleschallenge/deepisles:latest
-
-# HF Spaces runs containers with user ID 1000
-RUN useradd -m -u 1000 user 2>/dev/null || true
-
-# IMPORTANT: Use /home/user/demo for our app, NOT /app
-WORKDIR /home/user/demo
-
-# Add our application
-COPY --chown=1000:1000 requirements.txt /home/user/demo/requirements.txt
-RUN pip install --no-cache-dir -r requirements.txt
-
-COPY --chown=1000:1000 pyproject.toml /home/user/demo/pyproject.toml
-COPY --chown=1000:1000 src/ /home/user/demo/src/
-COPY --chown=1000:1000 app.py /home/user/demo/app.py
-RUN pip install --no-cache-dir --no-deps -e .
-
-# Environment variables for HF Spaces + direct invocation
-ENV HF_SPACES=1
-ENV DEEPISLES_DIRECT_INVOCATION=1
-ENV DEEPISLES_PATH=/app
-
-USER user
-EXPOSE 7860
-ENTRYPOINT []
-CMD ["python", "-m", "stroke_deepisles_demo.ui.app"]
-```
-
-#### readme.md configuration
-
-```yaml
----
-title: Stroke DeepISLES Demo
-emoji: 🧠
-colorFrom: blue
-colorTo: purple
-sdk: docker
-app_port: 7860
-suggested_hardware: t4-small
-pinned: false
-license: mit
----
-```
-
-#### cost management: pause/restart api
-
-```python
-from huggingface_hub import HfApi
-
-api = HfApi()
-SPACE_ID = "your-username/stroke-deepisles-demo"
-
-# PAUSE - stops billing immediately
-api.pause_space(SPACE_ID)
-
-# RESTART - spin up for demo
-api.restart_space(SPACE_ID)
-
-# AUTO-SLEEP after 30 min inactivity
-api.set_space_sleep_time(SPACE_ID, sleep_time=1800)
-```
-
-#### billing breakdown
-
-| State | Billed? | How to Enter |
-|-------|---------|--------------|
-| Running | ✅ $0.40/hr (T4) | `restart_space()` or visitor wakes it |
-| Sleeping | ❌ $0 | Auto after `sleep_time` inactivity |
-| Paused | ❌ $0 | `pause_space()` - only owner can restart |
-
-**Typical demo session**: 30-60 minutes = **$0.20-$0.40**
-
-**Monthly cost if paused**: **$0.00**
-
----
-
-## niivue integration analysis
-
-### current implementation
-
-Our viewer uses NiiVue loaded from unpkg CDN with base64 data URLs:
-
-```python
-# viewer.py:289-324
-return f"""
-
-
-
-
-"""
-```
-
-### potential issues
-
-1. **Script execution**: `
- """
-
-# components.py:42 - Basic HTML component without js_on_load
-niivue_viewer = gr.HTML(label="Interactive 3D Viewer") # No js_on_load!
-```
-
-### Why It Fails
-
-1. `gr.HTML` receives our HTML string as `value`
-2. Gradio renders the `` and `