diff --git a/.github/workflows/qc.yml b/.github/workflows/qc.yml index 968d955..48a57ed 100644 --- a/.github/workflows/qc.yml +++ b/.github/workflows/qc.yml @@ -9,7 +9,7 @@ # interaction that breaks aiosqlite-backed unit tests when both run # in the same pytest invocation as the e2e flows. # -# Triggers: pull requests targeting ``main`` and direct pushes to +# Triggers: every pull request (any base branch) and direct pushes to # ``main``. Runs are auto-cancelled on the same ref when a newer commit # arrives, mirroring the convention in ``build.yml``. # @@ -19,7 +19,6 @@ name: qc on: pull_request: - branches: ["main"] push: branches: ["main"] diff --git a/.gitignore b/.gitignore index a76eebf..7f99485 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,5 @@ tests/docker/nas-data/* # superpowers brainstorming visual companion .superpowers/ +graphify-out/ +/*.png diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b248c00 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +# ExLabWizard — project notes for Claude + +## Orchestrator / staging is intentionally hidden (not removed) + +The per-equipment `stage` sync mode and the staging-PC relay +(orchestrator) are **hidden at the UI layer only**. The operator sees a +single model: one instance hosts multiple equipment folders, each syncing +**directly to the NAS** (`sync_mode = "nas"`). + +- The staging **backend is still present and still tested** — `orchestrator/`, + `api/routers/staging.py`, `ui/pages/staging.py`, the `nas_client` stage + branch, `SyncMode.STAGE`, and the `orchestrator.staging_*` config fields all + remain. They are dormant (no equipment is `stage` mode; `staging_root` is + never surfaced). +- **Do not** "fix" the missing staging dock, the absent sync-mode wizard step, + or the missing staging-root setting — their removal from the GUI is + deliberate (see `docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md`). +- **Do not** assume the feature is gone: `orchestrator/quiescence_poller.py` is + the auto-sync engine for **both** nas- and stage-mode runs — never delete it. +- `orchestrator.label` is still **required** (workstation identity in every + `creation.json`); it is unrelated to staging. The Settings section that + collects it is titled "Workstation" (its section id stays `"orchestrator"`). +- To re-enable staging, restore the four UI surfaces listed in the spec's §3.6. + A full backend removal, if ever wanted, is a separate spec. diff --git a/DESIGN.md b/DESIGN.md index 5143815..dacf98f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -97,6 +97,23 @@ plot series, progress bars, status badges, and semantic states. | `--color-body` | `#2e3a4e` | Primary body text | | `--color-heading` | `#003660` | All heading text (alias of navy) | +#### Main-window surface & interaction tokens (2026-05-29 UI refresh) + +Several are intentional aliases of existing chrome tokens, kept named so +component CSS reads intent-first (e.g. `--color-row-selected-bar` over a bare +blue). + +| Token | Hex | Usage | +|----------------------------|-----------|--------------------------------------------------------| +| `--color-bg-subtle` | `#f4f6f9` | Breadcrumb bar, pane title strips (subtle surface tint)| +| `--color-pane-header` | `#f4f6f9` | Framed-pane title-strip background (= bg-subtle) | +| `--color-link` | `#1b75bc` | Hyperlink / breadcrumb segment text (= blue) | +| `--color-highlight` | `#fff6e0` | New-file row highlight; "kept local" badge fill | +| `--color-zebra` | `#f7f9fb` | File-list even-row stripe | +| `--color-row-selected` | `#dceaff` | Selected file / tree row fill | +| `--color-row-selected-bar` | `#1b75bc` | Selected-row left accent bar (= blue) | +| `--color-on-info` | `#003660` | Text on a solid `--color-info` fill (= navy; ~6:1 on sky) | + ### Semantic Color Assignments | State | Token | Hex | Use Case | @@ -629,6 +646,16 @@ Include this `:root` block in all generated CSS files: --color-body: #2e3a4e; --color-heading: #003660; + /* Surface & interaction (2026-05-29 UI refresh); aliases noted */ + --color-bg-subtle: #f4f6f9; + --color-pane-header: #f4f6f9; /* = bg-subtle */ + --color-link: #1b75bc; /* = blue */ + --color-highlight: #fff6e0; + --color-zebra: #f7f9fb; + --color-row-selected: #dceaff; + --color-row-selected-bar: #1b75bc; /* = blue */ + --color-on-info: #003660; /* = navy; text on --color-info */ + /* Data palette -- Okabe-Ito -- visualization only */ --oi-orange: #E69F00; --oi-sky: #56B4E9; diff --git a/docs/UX_INTERACTIONS.md b/docs/UX_INTERACTIONS.md index 437d3b4..6de42df 100644 --- a/docs/UX_INTERACTIONS.md +++ b/docs/UX_INTERACTIONS.md @@ -121,3 +121,8 @@ flow test. | `/main?view=explorer` | `run-context-view-log` | menu item | Right-click a run tree node and choose 'View log' | Opens the per-run log viewer. | | `/main?view=explorer` | `file-context-open-in-os` | menu item | Right-click a file-list row and choose 'Open in OS' | Asks the OS to open the file in its default application. | | `/main?view=explorer` | `file-context-copy-path` | menu item | Right-click a file-list row and choose 'Copy path' | Copies the file's absolute path to the system clipboard. | +| `/main` | `file-list-row` | table row | Single-click a file row in the centre list | Selects the row (highlight + accent bar) and threads ?file= so the metadata popover shows the file's sub-card (Phase 4 / Option B). | +| `/main` | `toggle-right-pane` | button | Click the vertical 'Metadata' tab | Opens / collapses the metadata popover over the Files pane via ?right_pane=. | +| `/main` | `main-search` | input | Type in the Explorer search box | Filters the tree (debounced ?q=) and shows a result-count / no-matches pill below the box (OQ-2). | +| `/main` | `files-density-toggle` | button | Click the Files-header density toggle | Flips the file list between comfortable and compact row padding via ?density= (§4.8). | +| `/main` | `files-legend` | button | Click the Files-header '?' legend button | Opens a popover listing every sync-status icon and its meaning. | diff --git a/docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md b/docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md new file mode 100644 index 0000000..65fec83 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md @@ -0,0 +1,273 @@ +# Hide orchestrator / staging from the UI — Design Spec + +**Date:** 2026-05-29 +**Status:** Draft for review +**Scope:** Make the orchestrator / staging-relay feature invisible to the +operator by gating its four GUI surfaces. The device model the operator sees +becomes: *one wizard instance hosts multiple equipment folders, each syncing +directly to the NAS.* No backend code is deleted — the staging pipeline stays +in the tree, dormant and still tested, so the feature is one small change away +from being re-enabled (or cleanly removed later under a separate spec). + +--- + +## 1. Summary + +Today every device is "always multi-equipment" and each equipment carries a +per-equipment `sync_mode` (GUI/Orchestrator Redesign §3.2): + +- **`nas`** — acquire into `local_root`, sync **directly to the NAS** (the + single `nas:` rclone remote). This is the default (`config/models.py:287`). +- **`stage`** — acquire into `local_root`, **push to a connected PC's staging + area**; that PC relays onward to the NAS. + +The `stage` path — plus the staging-PC side that receives, relays, monitors, +and cleans up staged runs — is the "orchestrator / staging feature." The +direct-to-NAS model the product now wants is **already the primary path**; this +spec retires the *secondary* path from view. + +This is a **hide, not a remove**: + +- The `nas` direct-sync path is untouched. +- The staging backend (`orchestrator/` package, `api/routers/staging.py`, + the `nas_client` stage branch, the `staging_*` config fields, `SyncMode.STAGE`) + stays in the tree, **dormant**. The 2026-05-28 `staging-root-opt-in` work + already proved this code no-ops cleanly when no staging is configured: *"the + quiescence poller, the staging query, and the validator all guard on an empty + `staging_root`."* With every equipment in `nas` mode and `staging_root` blank, + the device already behaves exactly like the target model. +- Only the **four operator-facing GUI surfaces** that expose `stage` / + staging are changed. + +The app has **not been deployed**, so there is no backward-compatibility or +migration burden. + +### Why hide rather than a feature flag + +The `staging-root-opt-in` spec already rejected a `staging_enabled` boolean as +YAGNI ("blank already means off"). Adding a flag now would repeat that mistake. +Hiding is a UI-only gate; re-enabling is restoring four small surfaces. + +### Critical implementation note — do not delete the `orchestrator/` package + +Despite its name, `orchestrator/quiescence_poller.py` is the **single auto-sync +engine for *both* modes** (its docstring: *"the single auto-sync trigger for +every run pending NAS sync — orchestrator-staged runs *and* runs acquired +directly on nas-mode equipment"*). The direct-NAS path **depends on it.** This +spec touches none of it. + +--- + +## 2. Goals & non-goals + +### Goals +- The operator never sees the words *stage*, *staging*, or *orchestrator* in + the GUI, and never chooses a sync mode. +- The Add-Equipment wizard produces only `nas`-mode equipment. +- Settings no longer exposes a staging root; the workstation **label** (still + required) is collected under a neutrally named section. +- No `/staging` route; no footer "Staging" segment. +- All staging backend code remains present, importable, and covered by its + existing tests (reversibility + a clean base for a future removal spec). +- `CLAUDE.md` documents that the feature is intentionally hidden-but-present so + future work does not "fix" the missing UI or assume the feature was deleted. + +### Non-goals +- **No deletion** of the `orchestrator/` package, `api/routers/staging.py`, + `ui/pages/staging.py`, the `nas_client` stage branch, or any staging-specific + helper. (That is a separate "remove orchestrator/staging" spec.) +- **No config schema change.** `EquipmentConfig.sync_mode`, `SyncMode.STAGE`, + and the `orchestrator.staging_*` fields all remain. No schema-version bump. +- **No feature flag** (see §1). +- **No change** to NAS sync data flow, transports, the quiescence poller, or + the `nas:` remote. +- **No change** to the `--test` sandbox wiring (`tray/main.py`), which is + dev-only; the e2e tests that drive `/staging` are simply skipped (§5). +- **No removal of `orchestrator.label`.** It is the workstation identity + stamped into every run's `creation.json` (`controller/creation.py:1040`) and + is independent of staging. It stays required. + +--- + +## 3. Design + +Four GUI surfaces change. Each subsection names exact files / lines. + +### 3.1 Add-Equipment wizard — `ui/pages/wizard_equipment.py` + +Drop the "Sync mode" step so the wizard is **identity → paths → review**, and +emit `nas` unconditionally. + +- `EQUIPMENT_WIZARD_STEPS` (`:35`) → `("identity", "paths", "review")`. +- `EQUIPMENT_STEP_TITLES` (`:42`) → remove the `"sync_mode"` row. +- `_STEP_RENDERERS` (`:343`) → remove the `"sync_mode"` entry. +- `EquipmentWizardState.sync_mode` (`:66`) → **keep**, default stays `"nas"`. + `assemble_equipment_config` (`:111`) continues to pass `sync_mode="nas"`, so + `build_equipment_config` and the persisted `EquipmentConfig` are unchanged. +- `_render_sync_mode_step` (`:270`) → **keep the function defined but + unreferenced** (dormant). Re-listing it in the two maps above restores the + step verbatim — that is the reversibility hook. +- `can_advance` `"sync_mode"` case (`:87`) → keep (harmless; never reached). +- `_render_review_step` (`:310`) → drop the `Sync mode: …` summary line + (`:326`); keep the nas hint (`:327`). The operator no longer sees a mode. + +### 3.2 Settings section — `ui/pages/settings.py` + +Keep the required **label** field; remove the staging-root input; rename the +section so "Orchestrator" disappears from the operator's view. + +- `SECTION_TITLES["orchestrator"]` (`:57`) → `"Workstation"` (or + `"Workstation Identity"`). **Keep the section *id* `"orchestrator"`** so the + setup-state gate, `settings_sections_for`, and `first_incomplete_section` + (which key on the id) are untouched. +- Render branch `elif section == "orchestrator":` (`:611`): + - **Keep** the "Workstation label" input (`:618`) bound to + `draft.orchestrator.label`. + - **Remove** the "Staging root (optional)" input (`:621`–`:625`). + - Remove the now-unused `from exlab_wizard.paths import suggested_staging_root` + import (`:17`) to satisfy ruff. +- `staging_root` continues to default to `""` in the model and is simply never + surfaced; nothing creates a staging directory. + +### 3.3 Staging route — `ui/mount.py` + +- Remove the `@ui.page("/staging")` block (`:447`–`:458`). +- Remove the bulk **clear-verified** wiring that fed the main page footer: + the `on_clear_verified` handler and its `render_main(..., on_clear_verified=…)` + argument, plus the `_build_staging_state` call that populated the footer. +- **Keep** `_run_staging_action` / `on_run_staging_action` (`:216`, `:1272`): + these back the tree context-menu run actions (force-sync / clear), which + apply to **nas-mode runs too** — they are *not* staging-dock-specific. +- `ui/pages/staging.py`, `_build_staging_state` (`:2085`), and the + `staging_query` / `staging_clear` imports become unreferenced. Remove + imports that ruff flags as unused; the standalone `staging.py` module stays + in the tree (dormant). + +### 3.4 Main-window footer — `ui/pages/main.py` + +- Remove the footer **"Staging" segment** and the **"Clear verified runs"** + button (`:474`–`:483`), including the `on_clear_verified` parameter on + `render_main` / `render_main_page`. +- `MainPageState.staging_dock` (`:64`) and the legacy `orchestrator_enabled` + (`:63`) are left inert (no operator-visible effect); they are removed in the + future "remove" spec, not here. The `StagingDockState` import (`:27`) stays + used by the inert field's annotation. +- The Sync / Validator / LIMS segments and the tree context-menu run actions + are unchanged. + +### 3.5 What stays dormant (explicitly untouched) + +To keep the diff minimal and fully reversible, these are **not** changed: + +| Surface | Why it can stay | +|---|---| +| `orchestrator/` package (poller, `_scan`, `staging_query`, `staging_clear`) | Poller serves nas-mode too; staging read-side simply isn't called. | +| `api/routers/staging.py` (still registered in `api/app.py`) | Internal REST plumbing, not operator-visible; no-ops with no staged runs; stays covered by `test_staging_router`. | +| `sync/nas_client.py` stage branch (`:655`–`:703`) | Never selected — no equipment is `stage` mode. | +| `config/models.py` `staging_*` fields, `SyncMode.STAGE` | No schema change; default `nas` means they're inert. | +| `validator/engine.py` staging-root branch (`:439`–`:506`) | Guards on empty `staging_root`; no-ops. | +| `tray/dependencies.py` poller boot (`:767`–`:784`) | Already boots on `has_staging_root OR has_nas_equipment`; with staging hidden it boots on nas equipment, which is correct. | +| `tray/main.py` `--test` sandbox `staging_root` (`:307`–`:342`) | Dev-only; out of scope. | +| `controller/creation.py` orchestrator block stamping (`:1040`) | Harmless metadata; unchanged. | + +### 3.6 Reversibility contract + +Re-enabling the feature is: (1) restore the `"sync_mode"` step in §3.1's two +maps, (2) restore the staging-root input in §3.2, (3) restore the `/staging` +route in §3.3, (4) restore the footer segment in §3.4. No backend work. + +--- + +## 4. `CLAUDE.md` update + +There is currently **no repo-root `CLAUDE.md`**. Create +`/Users/alex/Projects/ExLabWizard/CLAUDE.md` (or, if one exists by the time of +implementation, append a section) documenting the hidden-but-present state so a +future session does not misread the codebase. Required content: + +```markdown +## Orchestrator / staging is intentionally hidden (not removed) + +The per-equipment `stage` sync mode and the staging-PC relay +(orchestrator) are **hidden at the UI layer only**. The operator sees a +single model: one instance hosts multiple equipment folders, each syncing +**directly to the NAS** (`sync_mode = "nas"`). + +- The staging **backend is still present and still tested** — `orchestrator/`, + `api/routers/staging.py`, `ui/pages/staging.py`, the `nas_client` stage + branch, `SyncMode.STAGE`, and the `orchestrator.staging_*` config fields all + remain. They are dormant (no equipment is `stage` mode; `staging_root` is + never surfaced). +- **Do not** "fix" the missing staging dock, the absent sync-mode wizard step, + or the missing staging-root setting — their removal from the GUI is + deliberate (see `docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md`). +- **Do not** assume the feature is gone: `orchestrator/quiescence_poller.py` is + the auto-sync engine for **both** nas- and stage-mode runs — never delete it. +- `orchestrator.label` is still **required** (workstation identity in every + `creation.json`); it is unrelated to staging. +- To re-enable staging, restore the four UI surfaces listed in the spec's §3.6. + A full backend removal, if ever wanted, is a separate spec. +``` + +The implementer should also run a quick check for the `graphify claude` / +graphify integration footer convention already used in this repo's tooling and +keep this section above any auto-generated blocks. + +--- + +## 5. Testing + +### Tests that must change +- **Page objects:** `tests/e2e/page_objects/wizard_equipment_page.py` — drop + the sync-mode interaction. `tests/e2e/page_objects/staging_page.py` — becomes + unused (its driven tests are skipped below). +- **e2e — skip/remove** (drive hidden surfaces): `test_flow_09_orchestrator`, + `test_flow_23_footer_staging`, `test_flow_18_relay_receive`, + `test_flow_21_stage_ceiling`. +- **e2e — update:** `test_flow_16_add_equipment` (wizard now 3 steps, no + sync-mode), `test_flow_08_settings` (section title `Workstation`, no + staging-root field). +- **unit — update:** `tests/unit/ui/test_wizard_equipment.py` (steps tuple, + renderer map, review summary), `tests/unit/ui/test_settings_page.py` (section + title, staging-root input absent), `tests/unit/ui/test_staging_page.py` + (skip — page unrouted; or keep, since `render_staging_dock` still exists). + +### Tests that stay GREEN unchanged (by design) +`test_quiescence_poller`, `test_scan`, `test_staging_query`, +`test_orchestrator_lifecycle`, `test_nas_client`, `test_staging_router`, +`test_models`, `test_enums`. Keeping these green is a **feature**: it proves the +dormant backend still works and underpins the §3.6 reversibility contract. + +### Tests to add +- Wizard has exactly the three steps `("identity", "paths", "review")` and + `assemble_equipment_config` always yields `sync_mode == SyncMode.NAS`. +- `GET /staging` is not reachable from the GUI route table (the `@ui.page` + is gone); the REST `api/routers/staging` endpoints are out of scope here. +- Settings render for a typical config exposes no input bound to + `orchestrator.staging_root`. + +### Verification +- `uv run ruff check` (catch the unused imports flagged in §3.1–§3.4). +- `uv run pytest` (unit + integration + e2e) green. + +--- + +## 6. Risks / notes + +- **Reversibility is the whole point.** Standalone dormant *modules* are fine to + leave; within *edited* files, unused imports/params must be removed or ruff + fails. Keep the cut at module boundaries where possible. +- **Don't conflate two action surfaces.** The tree context-menu run actions + (force-sync / clear) serve nas-mode runs and **stay**; only the footer + *Staging dock* segment and *bulk clear-verified* are removed. +- **`orchestrator.label` stays required.** Hiding staging does not relax the + setup gate; the renamed "Workstation" section still collects it. +- **The staging REST router stays registered.** It is internal and not + operator-visible, so it does not violate "hide." Flag it for the future + removal spec. +- **Lint vs. dormancy tension.** If leaving `ui/pages/staging.py` in the tree + triggers a coverage or dead-code gate, prefer a `# pragma: no cover` / + per-file ignore over deleting it — deletion is the removal spec's job. +- **Future removal is clean.** Because nothing here changes the schema or data + flow, the eventual "remove" spec starts from a known-dormant, fully tested + baseline with **no migration burden** (app not deployed). diff --git a/docs/superpowers/specs/2026-05-29-main-window-ui-refresh-design.md b/docs/superpowers/specs/2026-05-29-main-window-ui-refresh-design.md new file mode 100644 index 0000000..bcdc642 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-main-window-ui-refresh-design.md @@ -0,0 +1,686 @@ +# Main-window UI refresh — Design Spec + +**Date:** 2026-05-29 +**Status:** Approved → **implemented (Phases 0–6, branch `feature/gui-imrprovement-v2`)**, with two shipped divergences recorded below. +**Scope:** Visual + interaction refresh of the main window (`/main`). Frame the +three content panes as elevated, titled cards; add Excel-style zebra striping to +the centre file list with a defined row-state precedence; extend the right +metadata pane to describe a selected **file** (and **folder**) within its +current tree context; and land a set of low-cost intuitiveness wins (unified +selection styling, sync tooltips + legend, live status-bar segments, friendlier +empty states, toolbar grouping + density, search affordances). All visual +values flow through the existing design-token system; no hard-coded literals. + +**Mockups:** `2026-05-29-main-window-ui-refresh-mockups/` (standalone HTML; +colors there are placeholders — the tokens in §3 are authoritative). + +> **Reconciliation (2026-05-30) — partially superseded.** Phases 0–6 shipped. +> Two deliberate, operator-directed changes during the Phase-4 demo now diverge +> from this spec and are the **approved** behaviour; where they disagree, this +> spec defers to +> [`…-remaining-work.md`](2026-05-29-main-window-ui-refresh-remaining-work.md) §2: +> +> 1. **Metadata is a floating popover, not a docked card.** It renders as an +> absolute overlay over the right ~40% of the Files pane, toggled by the +> vertical "Metadata" tab on its left edge (still via `right_pane`). The +> selection → sub-card *logic* (§4.4) is unchanged — only the container moved +> from a docked column to an overlay. (Amends §4.1 / §4.4 / §5.) +> 2. **`register_theme` is wired app-wide.** The `:root` token block is now +> injected on every page (`ui.add_head_html(…, shared=True)` from `mount_ui`), +> so the §3.2 tokens resolve live everywhere — the inline `var(--x, #literal)` +> fallbacks are a safety net, not the only source. (Amends §3.2 / §7.) + +--- + +## 1. Summary + +The main window (`ui/pages/main.py:render_file_explorer_page`) renders a header +toolbar → breadcrumb → `ui.splitter` (left: search + chips + tree; right: centre +file list + collapse tab + metadata/problems pane) → footer status bar. Today it +reads very flat: the three regions float on one white canvas separated only by +the splitter handle and a single hairline rule, with no per-pane framing and a +lot of dead space. + +This refresh does five things: + +1. **Frames every pane** as a white elevated card with an uppercase title strip, + on a light-grey canvas (the "B + C hybrid"). +2. **Zebra-stripes the file list** (subtle grey on even rows) and formalises the + row-background precedence now that selection is added: + **Selected > New-file > Tombstone > Zebra**. +3. **Extends the metadata pane** so selecting a file or folder in the centre list + shows its metadata *nested within* the current tree context (Option B — + anchored, not last-click-wins). +4. **Unifies selection styling** across the tree and the file list. +5. **Lands smaller wins:** sync tooltips + legend, live (coloured) status-bar + segments, friendlier empty states, toolbar grouping + a row-density toggle, + and search affordances (count / clear / no-matches). + +The work is **token-first**: §3 adds the handful of CSS variables the rest of the +spec consumes, and fixes three variables that components already reference but +that the generated `:root` block never defines (latent dead styling). + +The app has **not been deployed**, so there is no backward-compatibility or +migration burden. + +--- + +## 2. Goals & non-goals + +### Goals +- Each content pane (Explorer, Files, Metadata, footer) reads as a distinct, + titled, framed region. +- The file list has Excel-style intermittent row shading with a single, + unambiguous precedence rule when row states collide. +- Selecting a file or folder surfaces its metadata in the right pane without + losing the equipment/project/run context the tree established. +- Selection looks identical on the tree and the file list. +- Sync iconography is self-explanatory (hover tooltips everywhere + a legend). +- The footer status bar conveys health at a glance (colour + counts). +- Every colour/space/radius/shadow resolves to a token in `design.py`. + +### Non-goals +- **File-type icons** per extension (deferred; explicitly out of scope). +- **Keyboard navigation** of tree/file list (deferred; its own work item). +- A dark theme (the tokens are chosen to make one easy later, but it is not + built here). +- Any change to backend sync/relay/LIMS data flow, the wizards, or Settings + beyond what the listed UI items require. +- Replacing the URL-driven (`/main?selected=…&right_pane=…`) render model. The + metadata extension is designed to fit it, not change it (see §4.4 / §5). + +--- + +## 3. Design tokens (foundation) + +All tokens are defined in `src/exlab_wizard/ui/design.py` and emitted into the +`:root { … }` block by `ui/theme.py:build_root_css`. Per Frontend Spec §2.1.1 +this is the single source of truth; component CSS references `var(--…)` only. +`tests/unit/ui/test_design.py` keeps `design.py` and DESIGN.md in lock-step, so +DESIGN.md §07 (the token table) is updated alongside. + +### 3.1 New tokens + +| Token | Value (source) | Purpose | +|-------|----------------|---------| +| `--color-zebra` | `#f7f9fb` (new const `COLOR_ZEBRA`) | Even-row stripe in the file list. | +| `--color-row-selected` | `#dceaff` (new const `COLOR_ROW_SELECTED`) | Selected file/tree-row fill. | +| `--color-row-selected-bar` | `var(--color-blue)` (alias) | 3 px left accent bar on a selected row. | +| `--color-pane-header` | `#f4f6f9` (new const `COLOR_PANE_HEADER`) | Pane title-strip background. | + +`--color-canvas` is **not** added — the framed-card canvas reuses the existing +`--color-bg` (`#f5f7fa`), which is already the body background. The cards sit on +`--color-surface` (`#ffffff`), so the contrast comes for free. + +The new-file highlight reuses the existing-but-currently-undefined +`--color-highlight` (see §3.2); it is **not** a new token. + +### 3.2 Fix latent debt — referenced-but-undefined variables + +Three CSS variables are referenced by shipping components but are **never +emitted** by `build_root_css`, so today they resolve to empty and their +declarations are silently dropped: + +| Variable | Referenced at | Visible symptom today | +|----------|---------------|------------------------| +| `--color-highlight` | `components/file_list.py:191,214` ("new file" row bg + "kept local" badge) | New-file highlight is invisible; kept-local badge has no fill. | +| `--color-link` | `components/breadcrumb.py:77` (segment colour) | Breadcrumb segments fall back to inherited colour, not a link colour. | +| `--color-bg-subtle` | `components/breadcrumb.py:63` (bar background) | Breadcrumb bar has no background tint. | + +Add all three as tokens (consumed by this refresh anyway): + +| Token | Value (source const) | +|-------|----------------------| +| `--color-highlight` | `#fff6e0` (`COLOR_HIGHLIGHT`) — warm amber, matches the new-file mock. | +| `--color-link` | `var(--color-blue)` (alias to `COLOR_BLUE`). | +| `--color-bg-subtle` | `#f4f6f9` (alias of `COLOR_PANE_HEADER`; the breadcrumb strip and pane headers share one tint). | + +This is the only behavioural change outside the listed features and is in scope +because the zebra/selection work depends on a correct row-background layer. + +*(Verification note for planning: grep for any other `var(--…)` not present in +`build_root_css` before implementing, and fold any stragglers in here.)* + +**Shipped (2026-05-30):** all three vars are emitted, **and** `register_theme` +is now actually injected app-wide (`ui.add_head_html(…, shared=True)` from +`mount_ui`) — it had previously only ever been called by a unit test, so every +`var(--…)` reference resolved to its inline literal fallback (or to empty where +a component had none). The tokens are now live on every page; the scattered +fallbacks remain as a defensive net for a headless/cron render that skips the +injection. + +--- + +## 4. Design + +### 4.1 Panel framing — `components/framed_pane.py` (new) + `pages/main.py` + +A small, pure render helper used by every pane: + +``` +framed_pane(title: str, *, count: str | None = None, testid: str) -> context manager +``` + +- Outer element: `background: var(--color-surface); border: 1px solid + var(--color-border); border-radius: var(--radius-md); box-shadow: + var(--shadow-sm);` with `display:flex; flex-direction:column; + overflow:hidden;` and `data-testid="{testid}"`. +- Title strip: `background: var(--color-pane-header); border-bottom: 1px solid + var(--color-rule);` text in `var(--text-xs)`, uppercase, letter-spaced, + `var(--color-muted)`, weight 600. When `count` is supplied, a right-aligned + pill (`--color-rule` bg, `--color-muted` text, `--radius-lg`). +- Body: `flex:1; min-height:0; overflow:auto; padding: var(--sp-3) var(--sp-4)`. + +Applied in `render_file_explorer_page`: + +- The page background is already `var(--color-bg)` via `theme.py` body CSS, so + the grey canvas is automatic. Add `gap: var(--sp-3)` between the splitter + panels so the cards visibly separate. +- **Explorer card** wraps the search + chips + tree column (`main.py:264-276`), + title `EXPLORER`. +- **Files card** wraps the centre file list (`main.py:296-303`), title `FILES`, + `count` = `"{n} items"` from `len(file_list_entries)`. +- **Metadata pane** keeps its existing tabs (`_render_right_pane`, + `main.py:469-475`) — **no** title strip (approved default; the tabs name it). + It still gets the card frame (border + shadow + radius) so all three regions + match; the tab row sits where the title strip would be. +- **Footer status bar** (`main.py:376-418`) becomes a framed card (border + + `--shadow-sm`), keeping its current contents. + +The vertical collapse tab between the centre and metadata panes +(`main.py:310-364`) is unchanged in behaviour; it already paints its own raised +surface and sits *between* the cards. + +> **Shipped divergence (2026-05-30):** the metadata region is **not** a docked +> card in the splitter. It floats as an absolute overlay popover over the right +> ~40% of the Files pane (`position:absolute; top:0; right:0; width:40%; +> z-index` above the Files card, with a strong left shadow so it reads as +> raised). The vertical collapse tab became the **"Metadata" tab** on the +> popover's left edge that opens/closes it via `right_pane` (open → raised panel +> over Files; closed → full-width file list, tab parked at the right edge). The +> Explorer + Files cards and the footer card are exactly as described above; +> only the metadata container changed. See remaining-work §2. + +### 4.2 File list: zebra + row-state precedence — `components/file_list.py` + +Add a single pure resolver so precedence lives in one place and is unit-testable: + +``` +def row_background(entry, *, is_selected: bool, is_new: bool) -> str +``` + +Returns the background/decoration fragment for a row, applying precedence +**Selected > New-file > Tombstone > Zebra**: + +| State (highest first) | Background | Extra | +|-----------------------|------------|-------| +| Selected | `var(--color-row-selected)` | `box-shadow: inset 3px 0 0 var(--color-row-selected-bar)` | +| New-file (`is_new`) | `var(--color-highlight)` | — | +| Tombstone (`entry.tombstone`) | *(none)* | `opacity: 0.65; font-style: italic` (existing dim) | +| Even-row zebra | `var(--color-zebra)` | — | +| Odd row, no state | *(none)* | — | + +- Zebra parity is computed from the row's index in `state.entries` (passed to + `_render_row`), **not** CSS `:nth-child`, because tombstone/selected rows must + not shift the stripe pattern and the table is re-rendered server-side. +- The existing per-row `border-bottom: 1px solid var(--color-rule)` stays. +- `state.new_paths` already drives `is_new` (`file_list.py:148,191`). + +**Selection mechanic** (file rows): + +- Add `selected_path: str | None` to `FileListState`. +- Single click on a `
B's elevated cards (soft shadow, rounded corners, light-grey canvas) with C's titled header strip on each pane. Rendered with realistic content. Colors/radius/shadow shown here are placeholders — the real build pulls --color-border, --radius-md, --shadow-sm, --color-surface from your token set.
| Name | Size | Modified |
|---|---|---|
| ▤ readme.md | 2.1 KiB | 14:02 |
| ▤ protocol.yaml | 0.8 KiB | 14:02 |
| ▤ calibration.csv | 318 KiB | 14:03 |
| ▤ spectra_001.dat | 4.2 MiB | 14:05 |
| ▤ spectra_002.dat | 4.2 MiB | 14:06 |
| ▤ notes.txt | 1.1 KiB | 14:09 |
| ▤ manifest.json | 0.4 KiB | 14:09 |
Each region is a white card (rounded, soft shadow) on a light-grey canvas, topped by an uppercase title strip. The Metadata pane keeps its tabs instead of a static title (tabs already name it). The footer status bar also becomes a card. Zebra striping is previewed in the Files table — that's the next decision.
+Does this hybrid match what you pictured? A few sub-choices to confirm or correct in the terminal:
+