diff --git a/DESIGN.md b/DESIGN.md
index 294a008..572a661 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -723,6 +723,21 @@ Include this `:root` block in all generated CSS files:
}
```
+### Sync-status Icon Tokens
+
+Two-icon presence model (2026-05-30). Each is an intent-first alias of an existing
+token, so component CSS reads `var(--color-sync-safe)` over a bare green. Colour
+language: blue = here, green = safe on NAS, gray = absent, red = problem, amber = held.
+
+| Token | Resolves to | Hex | Meaning |
+|------------------------|------------------------|-----------|------------------------------|
+| `--color-sync-local` | `--color-blue` | `#1b75bc` | Present locally (here) |
+| `--color-sync-cached` | `--color-row-selected` | `#dceaff` | Present, also on NAS (cache) |
+| `--color-sync-safe` | `--color-success` | `#009E73` | Safe on NAS |
+| `--color-sync-absent` | `--color-rule` | `#e8ecf2` | Absent (fine) |
+| `--color-sync-problem` | `--color-danger` | `#D55E00` | Problem here |
+| `--color-sync-held` | `--color-warning` | `#E69F00` | Held here |
+
---
## 08 -- Usage Rules & Anti-Patterns
diff --git a/docs/superpowers/specs/2026-05-30-two-icon-sync-presence-design.md b/docs/superpowers/specs/2026-05-30-two-icon-sync-presence-design.md
new file mode 100644
index 0000000..1a93d39
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-30-two-icon-sync-presence-design.md
@@ -0,0 +1,289 @@
+# Two-icon sync-presence display — design
+
+**Date:** 2026-05-30
+**Status:** Approved (design); ready for implementation plan
+**Supersedes (UI only):** the single-glyph `sync_status_icon` display and the
+single-SVG run-tree rollup (`sync_local.svg` / `sync_cloud.svg`). `sync_cloud.svg`
+is **retired** — the tree now uses `sync_nas.svg` so it speaks the same vocabulary
+as the file rows. The backend sync states are unchanged.
+
+## 1. Problem
+
+The file browser currently shows sync status as a **single colour-coded glyph**
+drawn from **ten** backend states (`pending`, `acquiring`, `retrying`,
+`syncing`, `on_nas`, `synced`, `cleaned`, `failed`, `blocked_by_validation`,
+`override_active`). Most of those distinctions describe the *machinery* of
+syncing, which the operator does not act on. The only questions an operator
+actually has about a file are:
+
+1. Is it **here** (local)?
+2. Is it **on the NAS** (backed up)?
+3. Is anything **wrong**?
+
+This design collapses the ten states into a **two-icon presence display** — one
+icon for "local", one for "NAS" — using the existing `sync_local.svg` and
+`sync_nas.svg` assets, with the **background colour** of each icon encoding that
+location's state. The syncing machinery (pending / acquiring / syncing /
+retrying) is hidden: an in-progress upload simply reads as "local, not backed up
+yet". The backend states, queue, and `sync_state.json` schema are untouched —
+this is a presentation change plus one additive backend read.
+
+## 2. Design principle
+
+One unified colour language across **both** files (two icons) and folders (one
+rollup icon). The two per-file icons are always rendered in a fixed order —
+**local on the left, NAS on the right** — so "which location" never depends on
+colour (important for colour-blind operators).
+
+**blue = "here" · green = "safe on NAS" · gray = "absent" · red = "problem" ·
+amber = "held".**
+
+| Icon | Meaning | Colour | Token |
+|---|---|---|---|
+| **local** | here (only copy) | 🔵 solid blue | `--color-sync-local` |
+| **local** | here, also on NAS (cache — safe to clear) | 🔵 faded blue | `--color-sync-cached` |
+| **local** | not here, and that's fine | ⬜ gray | `--color-sync-absent` |
+| **local** | gone / lost | 🔴 red | `--color-sync-problem` |
+| **nas** | safe on NAS | 🟢 green | `--color-sync-safe` |
+| **nas** | not on NAS yet | ⬜ gray | `--color-sync-absent` |
+| **nas** | upload failed | 🔴 red | `--color-sync-problem` |
+| **nas** | held by validation | 🟠 amber | `--color-sync-held` |
+
+`sync_local.svg` is itself blue (`#1b75bc`), so blue-for-local reinforces the
+glyph rather than fighting it. The two blues both mean "here"; the **fade** is the
+only difference and consistently means "also backed up" — and the NAS icon's
+green is the primary "is it safe?" tell, so the fade is secondary.
+
+We introduce sync-specific semantic aliases (added to `design.py` + emitted by
+`theme.build_root_css`) so the sync UI does not couple to incidental palette
+edits:
+
+| Alias | Resolves to | Hex |
+|---|---|---|
+| `--color-sync-local` | solid blue | `--color-primary` `#1b75bc` |
+| `--color-sync-cached` | faded blue | `--color-row-selected` `#dceaff` |
+| `--color-sync-safe` | green | `--color-success` `#2e9e5b` |
+| `--color-sync-absent` | tinted gray | a light `--color-muted` tint |
+| `--color-sync-problem` | red | `--color-danger` `#d2492a` |
+| `--color-sync-held` | amber | `--color-warning` `#e8a13a` |
+
+## 3. State taxonomy
+
+The display is driven by a **UI-only derived view enum**, `FileSyncView`, not by
+the raw `SyncStatus`. Seven views cover every case:
+
+| # | `FileSyncView` | local bg | nas bg | Meaning |
+|---|---|---|---|---|
+| 1 | `LOCAL_ONLY` | 🔵 blue | ⬜ gray | Here, not backed up yet (incl. pending/syncing/retrying — detail hidden) |
+| 2 | `SYNCED` | 🔵 faded blue | 🟢 green | Safe on NAS; local is now just a cache |
+| 3 | `ON_NAS` | ⬜ gray | 🟢 green | Backed up; local copy reclaimed (cleaned) |
+| 4 | `UPLOAD_FAILED` | 🔵 blue | 🔴 red | Local copy fine; NAS push failed (retries exhausted) |
+| 5 | `BLOCKED` | 🔵 blue | 🟠 amber | Local fine; upload **held** by a hard validation finding |
+| 6 | `MISSING` | 🔴 red | 🔴 red | Tracked, but gone locally **and** never confirmed on NAS |
+| 7 | `NONE` | — | — | Untracked file / folder — no icons (neutral) |
+
+### 3.1 Mapping from backend signals
+
+`FileSyncView` is derived from four inputs available at the browse layer:
+
+- `on_disk: bool` — file exists locally (disk scan)
+- `nas_verified: bool` — `FileSyncRecord.verified_at is not None` (confirmed on NAS)
+- `record_present: bool` — a `FileSyncRecord` exists in `sync_state.json`
+- `job_status: SyncStatus | None` — **live** sync-queue status for this path
+ (`failed` / `blocked_by_validation` / others), or `None` if not queued
+
+```
+def file_sync_view(*, on_disk, nas_verified, record_present, job_status):
+ if job_status == FAILED and on_disk and not nas_verified: return UPLOAD_FAILED
+ if job_status == BLOCKED_BY_VALIDATION and on_disk and not nas_verified:
+ return BLOCKED
+ if on_disk and nas_verified: return SYNCED
+ if on_disk and not nas_verified: return LOCAL_ONLY
+ if not on_disk and nas_verified: return ON_NAS
+ if not on_disk and record_present and not nas_verified: return MISSING
+ return NONE
+```
+
+Precedence note: an active `FAILED`/`BLOCKED` job overrides the plain
+presence views so a stuck upload is never masked as "local only".
+
+## 4. Components
+
+### 4.1 `sync_status_icon.py` → two-icon pair
+
+Replace the single-glyph model. The module keeps its role as the **pure,
+NiceGUI-free single source of truth** for sync presentation, now exposing:
+
+- `FileSyncView` (enum, UI-only).
+- `file_sync_view(...)` — the pure mapping in §3.1.
+- `sync_pair_props(view) -> {"local": IconCell, "nas": IconCell}` where each
+ `IconCell` is `{"svg", "bg_var", "badge", "tooltip", "aria_label"}`. `svg` is
+ the asset path (`/assets/sync_local.svg`, `/assets/sync_nas.svg`); `bg_var` is
+ the background token; `badge` is an optional corner overlay glyph (see §6);
+ `tooltip`/`aria_label` are the worded state.
+- `sync_pair_icons(view)` — builds the NiceGUI two-icon row (local left, NAS
+ right), each icon an `
` on a coloured, rounded background, with the
+ tooltip and `data-sync-*` attributes for tests.
+- `sync_legend_entries()` — updated to the new six visible views (excludes
+ `NONE`), so the Files-header legend popover stays in lock-step with what rows
+ render.
+
+The old `_STATUS_TO_PROPS` glyph table, `STATUS_RETRYING`/`STATUS_OVERRIDE`/
+`STATUS_ACQUIRING`/`STATUS_SYNCING`/`STATUS_ON_NAS` constants, and the
+`strict`/`retry_n`/`retry_m` machinery are removed (no consumer survives the
+collapse). `override_active` and the retry counter disappear from the UI — they
+were syncing-machinery detail the new model deliberately hides.
+
+### 4.2 `file_list.py` — Status cell
+
+`_render_row`'s Status `
` calls `sync_pair_icons(view)` instead of
+`sync_status_icon(status, strict=False)`. The row resolves its `FileSyncView`
+from the `FileListEntry` (which gains `nas_verified`, `record_present`, and
+`job_status` fields, or a single precomputed `sync_view` field — see §5). The
+existing tombstone treatment (dim/italic, no "Open in OS") is unchanged; a
+tombstone row is simply one whose view is `ON_NAS` or `MISSING`.
+
+### 4.3 `sync_rollup.py` — folder / tree rollup (single icon)
+
+A folder is a **summary**, so it renders **one** icon (not the two-icon pair) —
+"is everything in here safe?". Two steps:
+
+1. **Worst-of reduction** over child `FileSyncView` values, with this severity
+ order (most-attention-worthy first):
+
+ ```
+ MISSING > UPLOAD_FAILED > BLOCKED > LOCAL_ONLY > SYNCED > ON_NAS
+ ```
+
+ Rationale: problems first; then **at-risk** local-only files (present but not
+ yet backed up) outrank fully-`SYNCED` ones; `ON_NAS` (done, reclaimed) is
+ calmest. `NONE` is ignored, as today.
+
+2. **Single-icon mapping** of the rolled-up view, via a new
+ `sync_rollup_icon(view) -> {"svg", "bg_var", "badge", "tooltip"}`:
+
+ | Rolled-up view | SVG | bg |
+ |---|---|---|
+ | `SYNCED` / `ON_NAS` (all safe) | `sync_nas.svg` | 🟢 green |
+ | `LOCAL_ONLY` (not fully synced) | `sync_local.svg` | 🔵 blue |
+ | `BLOCKED` (a held file) | `sync_nas.svg` | 🟠 amber |
+ | `UPLOAD_FAILED` / `MISSING` (an error) | `sync_nas.svg` | 🔴 red |
+ | `NONE` (empty / untracked) | — | none |
+
+ This is exactly the operator's stated model: fully synced → `sync_nas` green;
+ not synced → `sync_local` blue; error → `sync_nas` red (held → amber).
+
+### 4.4 Surfaces
+
+- **File rows** (`file_list.py`) — per-file **two-icon** pair (detail).
+- **Run tree** (`browse.py` tree headers) — per-run **single** rollup icon
+ (`sync_rollup_icon`), replacing the old `sync_local.svg` / `sync_cloud.svg`
+ glyph. `sync_cloud.svg` is retired.
+- **Metadata pane** — selected folder shows its **single** rollup icon.
+
+Files show both locations (detail); folders summarise to one icon. They share the
+same colour language, so the summary never contradicts the detail beneath it.
+
+## 5. Backend changes (additive)
+
+### 5.1 Surface the "missing" view (free — `sync_state.json` only)
+
+Today `_tombstone_entries` **skips** records that are absent on disk and
+unverified (`"An unverified, absent record is not a meaningful tombstone."`).
+That silently drops genuinely lost files from the listing. Change: emit such
+records as `MISSING` rows (tombstone, not openable) instead of skipping. Verified
+absent records remain `ON_NAS`. `_file_state_from_record` is extended/replaced to
+distinguish these.
+
+### 5.2 Propagate per-run failure from `creation.json` (Group B)
+
+**As built (refinement of the original "live queue" idea).** Per-file failure is
+never persisted, and the only per-file granularity the queue offers is a per-*run*
+job state reached through an accessor (`NASSyncClient.get_by_run_path`) that does
+not exist. The robust, persisted source is the run's **`creation.json`
+`sync_status`** (`pending` / `synced` / `cleaned` / `failed` /
+`blocked_by_validation`), already maintained by the orchestrator and read
+tolerantly in browse. So failure is surfaced at the **run** level and propagated
+to that run's not-yet-verified on-disk files:
+
+- `_run_failure_flags(run_root)` reads `creation.json` (`read_msgspec_json(..., CreationJson)`,
+ try/except → `(False, False)` on absence/corruption) and returns
+ `(run_failed, run_blocked)`.
+- `_file_state_from_record(record, *, on_disk, run_failed, run_blocked)` returns
+ `upload_failed` / `blocked` for an unverified on-disk file when the run is
+ failed / blocked (a **verified** file stays `synced` regardless), and `missing`
+ for an unverified, locally-absent record (the lost-file fix).
+- The backend emits these discriminator strings on the existing per-file
+ `sync_status` field (`upload_failed`, `blocked`, `missing` join
+ `synced`/`syncing`/`acquiring`/`on_nas`); the UI maps the string to a
+ `FileSyncView` via `file_sync_view` — no new wire field, no UI re-derivation.
+
+Affects `scan_folder_sync` (per-file rows) and `_run_rollup_status` (tree rollup,
+which short-circuits to `blocked`/`upload_failed` before the
+`SyncStateWriter.rollup_state` fallback).
+
+No change to `FileSyncRecord` / `SyncStateJson` / `SyncStatus` enum — only reads.
+
+## 6. Accessibility
+
+Position (local left / NAS right) and the two distinct SVG shapes already encode
+"which location" without colour. The remaining colour-only distinction is
+*green-present vs red-problem vs amber-held* on the **same** icon. Mitigations:
+
+- **Every icon carries a worded `tooltip` and `aria-label`** (e.g. "On NAS",
+ "Upload failed — local copy safe", "Held by validation", "Missing — not found
+ locally or on NAS").
+- **Problem/held views add a small corner badge glyph** so red/amber are not
+ colour-only: `✕` for `UPLOAD_FAILED`/`MISSING`, `!` for `BLOCKED`. Green / blue
+ / gray ("everything is fine") stay colour-only — lower stakes.
+
+## 7. Testing impact
+
+- **Rewrite** `tests/unit/ui/test_sync_status_icon.py` for `file_sync_view`
+ (mapping table, all seven views) and `sync_pair_props` (colours, badges,
+ tooltips). Drop the `strict`/neutral-dash tests.
+- **Update** `tests/unit/ui/test_sync_rollup.py` for the new `FileSyncView`
+ severity order, and add tests for `sync_rollup_icon` (the single-icon mapping:
+ `sync_nas` green/amber/red vs `sync_local` blue).
+- **Rewrite** `tests/e2e/test_flow_05_browse_view_sync_icons.py`: it currently
+ asserts one `sync_local.svg` vs one `sync_cloud.svg` per run. Assert the new
+ tree rollup icon instead (`sync_nas.svg` for synced/cleared runs, `sync_local.svg`
+ for not-fully-synced) plus the per-view backgrounds, and the file-row two-icon
+ pair. Update the asset-200 check to `sync_local.svg` + `sync_nas.svg`
+ (`sync_cloud.svg` is no longer served).
+- **New** unit tests for the `MISSING` surfacing in `browse.py` and the queue
+ status threading (`UPLOAD_FAILED` / `BLOCKED`).
+- Existing sync backend / `sync_state_writer` / `pre_sync_gate` tests are
+ unaffected.
+
+## 8. Out of scope
+
+- Any change to the sync engine, queue, retry policy, validation gating, or
+ `sync_state.json` schema.
+- The hidden orchestrator/staging surfaces (see `CLAUDE.md`) — untouched.
+- An operator action to *retry* a failed upload from the file row (the row only
+ *reports*; retry stays automatic). Could be a follow-up.
+
+## 9. Token summary (for the plan)
+
+**Per-file two icons:**
+
+| View | local bg token | nas bg token | badge |
+|---|---|---|---|
+| `LOCAL_ONLY` | `--color-sync-local` | `--color-sync-absent` | — |
+| `SYNCED` | `--color-sync-cached` | `--color-sync-safe` | — |
+| `ON_NAS` | `--color-sync-absent` | `--color-sync-safe` | — |
+| `UPLOAD_FAILED` | `--color-sync-local` | `--color-sync-problem` | `✕` on NAS |
+| `BLOCKED` | `--color-sync-local` | `--color-sync-held` | `!` on NAS |
+| `MISSING` | `--color-sync-problem` | `--color-sync-problem` | `✕` on both |
+| `NONE` | — (no icons) | — | — |
+
+**Folder / tree single rollup icon (`sync_rollup_icon`):**
+
+| Rolled-up view | SVG | bg token | badge |
+|---|---|---|---|
+| `SYNCED` / `ON_NAS` | `sync_nas.svg` | `--color-sync-safe` | — |
+| `LOCAL_ONLY` | `sync_local.svg` | `--color-sync-local` | — |
+| `BLOCKED` | `sync_nas.svg` | `--color-sync-held` | `!` |
+| `UPLOAD_FAILED` / `MISSING` | `sync_nas.svg` | `--color-sync-problem` | `✕` |
+| `NONE` | — (no icon) | — | — |
diff --git a/src/exlab_wizard/api/routers/browse.py b/src/exlab_wizard/api/routers/browse.py
index ca35f9e..19ae338 100644
--- a/src/exlab_wizard/api/routers/browse.py
+++ b/src/exlab_wizard/api/routers/browse.py
@@ -489,6 +489,9 @@ def _relay_label_from_first_run(equipment_dir: Path, fallback: str) -> str:
FILE_STATE_SYNCING = "syncing"
FILE_STATE_SYNCED = "synced"
FILE_STATE_ON_NAS = "on_nas"
+FILE_STATE_UPLOAD_FAILED = "upload_failed"
+FILE_STATE_BLOCKED = "blocked"
+FILE_STATE_MISSING = "missing"
def _find_run_root(folder: Path) -> Path | None:
@@ -528,23 +531,61 @@ def _read_sync_state(run_root: Path) -> Any:
return None
-def _file_state_from_record(record: Any | None, *, on_disk: bool) -> str | None:
- """Map a ``sync_state.json`` record + on-disk presence to a GUI state.
+def _run_failure_flags(run_root: Path | None) -> tuple[bool, bool]:
+ """Return (run_failed, run_blocked) from the run's creation.json sync_status.
+
+ The per-file sync queue never persists per-file failure; failure is a
+ per-run signal carried by ``creation.json``'s ``sync_status`` field
+ (``failed`` / ``blocked_by_validation``). Read tolerantly: an absent or
+ undecodable ``creation.json`` yields no failure signal.
+ """
+ if run_root is None:
+ return (False, False)
+ try:
+ payload = read_msgspec_json(creation_json_path(run_root), CreationJson)
+ except Exception: # absent / undecodable creation.json -> no failure signal
+ return (False, False)
+ status = getattr(payload, "sync_status", None)
+ return (status == "failed", status == "blocked_by_validation")
- The five-state table (operator-free per-file NAS sync design,
- 2026-05-21):
- * not recorded + on disk -> ``acquiring``
- * recorded, unverified + disk -> ``syncing``
- * recorded, verified + on disk -> ``synced``
- * recorded, verified + absent -> ``on_nas`` (tombstone)
+def _file_state_from_record(
+ record: Any | None,
+ *,
+ on_disk: bool,
+ run_failed: bool = False,
+ run_blocked: bool = False,
+) -> str | None:
+ """Map a sync_state.json record + on-disk presence to a GUI discriminator.
+
+ ``run_failed`` / ``run_blocked`` come from the run's persisted
+ ``creation.json`` ``sync_status`` and apply only to an on-disk file that
+ has not yet verified to the NAS (the files a failed/blocked run did not
+ finish uploading). A verified file stays ``synced`` regardless.
+
+ * not recorded + on disk -> ``acquiring``
+ * recorded, verified + on disk -> ``synced``
+ * recorded, unverified + disk -> ``blocked`` / ``upload_failed`` /
+ ``syncing`` (depending on the run flags)
+ * recorded, verified + absent -> ``on_nas`` (tombstone)
+ * recorded, unverified + absent -> ``missing`` (lost: tracked, gone
+ locally, never verified)
"""
if record is None:
return FILE_STATE_ACQUIRING if on_disk else None
verified = getattr(record, "verified_at", None) is not None
if on_disk:
- return FILE_STATE_SYNCED if verified else FILE_STATE_SYNCING
- return FILE_STATE_ON_NAS if verified else None
+ if verified:
+ return FILE_STATE_SYNCED
+ if run_blocked:
+ return FILE_STATE_BLOCKED
+ if run_failed:
+ return FILE_STATE_UPLOAD_FAILED
+ return FILE_STATE_SYNCING
+ # absent on disk
+ if verified:
+ return FILE_STATE_ON_NAS
+ return FILE_STATE_MISSING # tracked, gone locally, never verified = lost
def _per_file_sync_status(path: Path) -> str | None:
@@ -565,7 +606,10 @@ def _per_file_sync_status(path: Path) -> str | None:
return None
rel = _run_relative_posix(run_root, path)
record = state.files.get(rel) if rel is not None else None
- return _file_state_from_record(record, on_disk=True)
+ run_failed, run_blocked = _run_failure_flags(run_root)
+ return _file_state_from_record(
+ record, on_disk=True, run_failed=run_failed, run_blocked=run_blocked
+ )
def _run_relative_posix(run_root: Path, path: Path) -> str | None:
@@ -673,13 +717,21 @@ def _build_run_node(run_dir: Path, *, kind: str) -> RunNode:
def _run_rollup_status(run_dir: Path) -> str | None:
- """Return a run's derived ``RunSyncState`` rollup value, or ``None``.
+ """Return a run's rollup discriminator for the tree icon, or ``None``.
- Reads ``sync_state.json`` and applies the pure
- :meth:`SyncStateWriter.rollup_state` derivation.
+ A run whose ``creation.json`` ``sync_status`` is ``blocked_by_validation``
+ / ``failed`` folds to ``blocked`` / ``upload_failed`` (so the tree carries
+ the held/problem rollup). Otherwise the rollup is derived from
+ ``sync_state.json`` via the pure :meth:`SyncStateWriter.rollup_state`
+ derivation (``syncing`` / ``synced`` / ``cleared``).
"""
from exlab_wizard.cache.sync_state_writer import SyncStateWriter
+ run_failed, run_blocked = _run_failure_flags(run_dir)
+ if run_blocked:
+ return FILE_STATE_BLOCKED
+ if run_failed:
+ return FILE_STATE_UPLOAD_FAILED
try:
state = SyncStateWriter().read_sync(run_dir)
except Exception as exc: # pragma: no cover -- defensive
@@ -771,6 +823,7 @@ def scan_folder_sync(folder_path: str, config: Any) -> FolderResponse:
# whole folder (operator-free per-file NAS sync design, 2026-05-21).
run_root = _find_run_root(path)
sync_state = _read_sync_state(run_root) if run_root is not None else None
+ run_failed, run_blocked = _run_failure_flags(run_root)
on_disk_rel: set[str] = set()
for entry in sorted(scandir_entries, key=lambda e: e.name):
try:
@@ -792,7 +845,16 @@ def scan_folder_sync(folder_path: str, config: Any) -> FolderResponse:
is_dir=is_dir,
size_bytes=None if is_dir else stat.st_size,
modified_iso=dt_to_iso(datetime.fromtimestamp(stat.st_mtime, tz=UTC)),
- sync_status=(None if is_dir else _file_state_from_record(record, on_disk=True)),
+ sync_status=(
+ None
+ if is_dir
+ else _file_state_from_record(
+ record,
+ on_disk=True,
+ run_failed=run_failed,
+ run_blocked=run_blocked,
+ )
+ ),
keep_local=bool(getattr(record, "keep_local", False)),
)
)
@@ -832,8 +894,10 @@ def _tombstone_entries(
if abs_path.parent != folder:
continue
state = _file_state_from_record(record, on_disk=False)
+ # verified-absent -> "on_nas"; unverified-absent -> "missing" (lost file).
+ # state is None only for no-record inputs, which never occur in this loop
+ # (it iterates recorded files), so the guard below is a safety net.
if state is None:
- # An unverified, absent record is not a meaningful tombstone.
continue
out.append(
FolderEntry(
diff --git a/src/exlab_wizard/ui/components/file_list.py b/src/exlab_wizard/ui/components/file_list.py
index 1ba89fa..e89ee49 100644
--- a/src/exlab_wizard/ui/components/file_list.py
+++ b/src/exlab_wizard/ui/components/file_list.py
@@ -27,7 +27,7 @@
from exlab_wizard.ui.components.empty_state import empty_state
from exlab_wizard.ui.components.file_type_icon import file_type_icon
-from exlab_wizard.ui.components.sync_status_icon import STATUS_ON_NAS, sync_status_icon
+from exlab_wizard.ui.components.sync_status_icon import file_sync_view, sync_pair_icons
from exlab_wizard.ui.pages.staging import format_bytes
# Action discriminators consumed by the on_context_menu callback.
@@ -267,10 +267,11 @@ def _render_row(
# (Phase 2); the border-bottom rule is the row's only constant style.
state_style = row_background(entry, is_selected=is_selected, is_new=is_new, index=index)
row_style = f"{state_style} border-bottom: 1px solid var(--color-rule);".strip()
- # The Status cell renders an icon (tolerant: an untracked file or a
- # folder carries no status and shows a neutral dash); a tombstone with
- # no recorded status still reads as the "On NAS" cloud.
- status_for_icon = entry.sync_status or (STATUS_ON_NAS if entry.tombstone else None)
+ # The Status cell renders the two-icon (local + NAS) presence pair.
+ # Tombstones carry their own discriminator ("on_nas" or "missing"); a
+ # tombstone with no recorded status still reads as "on_nas". An untracked
+ # file / folder carries no status and the pair renders nothing.
+ view = file_sync_view(entry.sync_status or ("on_nas" if entry.tombstone else None))
keep_local_attr = ' data-keep-local="true"' if entry.keep_local else ""
tombstone_attr = ' data-tombstone="true"' if entry.tombstone else ""
selected_attr = ' data-selected="true"' if is_selected else ""
@@ -304,7 +305,7 @@ def _render_row(
with ui.element("td").classes("p-2"):
ui.label(modified_text)
with ui.element("td").classes("p-2"):
- sync_status_icon(status_for_icon, strict=False)
+ sync_pair_icons(view)
if on_context_menu is not None:
with ui.context_menu().props(
f'data-testid="file-context-menu" data-path="{entry.path}"'
diff --git a/src/exlab_wizard/ui/components/metadata_pane.py b/src/exlab_wizard/ui/components/metadata_pane.py
index 78cb68c..d135eaf 100644
--- a/src/exlab_wizard/ui/components/metadata_pane.py
+++ b/src/exlab_wizard/ui/components/metadata_pane.py
@@ -17,7 +17,12 @@
from exlab_wizard.ui.components.empty_state import empty_state
from exlab_wizard.ui.components.file_type_icon import file_type_icon
-from exlab_wizard.ui.components.sync_status_icon import STATUS_ON_NAS, sync_status_icon
+from exlab_wizard.ui.components.sync_status_icon import (
+ FileSyncView,
+ file_sync_view,
+ sync_pair_icons,
+ sync_rollup_icon,
+)
# Node-kind discriminators consumed by the dispatcher.
NODE_KIND_EQUIPMENT = "equipment"
@@ -130,12 +135,16 @@ def _kv(key: str, value: Any) -> None: # pragma: no cover -- NiceGUI render, dr
)
-def _kv_sync(key: str, status: Any) -> None: # pragma: no cover -- NiceGUI render, driven by e2e
- """Key/value row whose value is a sync-status icon (tolerant).
+def _kv_sync(
+ key: str, status: Any, render_icon: Callable[[FileSyncView], Any]
+) -> None: # pragma: no cover -- NiceGUI render, driven by e2e
+ """Key/value row whose value is a sync icon (file pair or folder rollup).
Mirrors :func:`_kv`'s label column but renders the status through the
- tolerant icon path (``strict=False``): a ``None`` / unknown status shows
- a neutral dash rather than raising (spec §4.5).
+ tolerant presentation map: a ``None`` / unknown status renders nothing
+ rather than raising (spec §4.5). ``render_icon`` is the icon renderer to
+ use for the value cell -- :func:`sync_pair_icons` for a file's two-icon
+ pair, :func:`sync_rollup_icon` for a folder's single rollup icon.
"""
try:
from nicegui import ui
@@ -143,7 +152,17 @@ def _kv_sync(key: str, status: Any) -> None: # pragma: no cover -- NiceGUI rend
return
with ui.row().classes("items-center w-full"):
ui.label(f"{key}:").style("color: var(--color-muted); width: 12rem; min-width: 12rem;")
- sync_status_icon(status, strict=False)
+ render_icon(file_sync_view(status))
+
+
+def _kv_sync_pair(key: str, status: Any) -> None: # pragma: no cover -- NiceGUI render
+ """Key/value row whose value is a file's two-icon (local + NAS) pair."""
+ _kv_sync(key, status, sync_pair_icons)
+
+
+def _kv_sync_rollup(key: str, status: Any) -> None: # pragma: no cover -- NiceGUI render
+ """Key/value row whose value is a folder's single rollup sync icon."""
+ _kv_sync(key, status, sync_rollup_icon)
def _render_selected_file_card(
@@ -183,16 +202,16 @@ def _render_selected_file_card(
)
if is_folder:
_kv("Items", payload.get("item_count"))
- _kv_sync("Sync", payload.get("rollup"))
+ _kv_sync_rollup("Sync", payload.get("rollup"))
_kv("Path", payload.get("path"))
else:
tombstone = bool(payload.get("tombstone"))
if not tombstone:
_kv("Size", payload.get("size"))
_kv("Modified", payload.get("modified"))
- _kv_sync(
+ _kv_sync_pair(
"Sync",
- payload.get("sync_status") or (STATUS_ON_NAS if tombstone else None),
+ payload.get("sync_status") or ("on_nas" if tombstone else None),
)
_kv("Path", payload.get("path"))
if tombstone:
diff --git a/src/exlab_wizard/ui/components/sync_rollup.py b/src/exlab_wizard/ui/components/sync_rollup.py
index 9f9ce85..3efbd02 100644
--- a/src/exlab_wizard/ui/components/sync_rollup.py
+++ b/src/exlab_wizard/ui/components/sync_rollup.py
@@ -1,61 +1,50 @@
-"""Folder-level sync-status rollup (GUI/Orchestrator Redesign §4.6).
-
-When a folder is selected in the centre file list, the metadata sub-card
-shows a single "worst-of" rollup of its children's per-file sync states.
-This module owns that reduction as a pure, NiceGUI-free function so the
-ordering is testable in isolation.
-
-**Why a UI-only ordering.** The per-file states are :class:`SyncStatus`
-values (``pending`` / ``synced`` / ``cleaned`` / ``failed`` /
-``blocked_by_validation``). ``SyncStatus`` is a schema-committed
-``StrEnum`` that deliberately carries *no* severity order -- the enum
-module forbids reordering without a coordinated schema-version bump
-(constants/enums.py). The rollup is a presentation concern, so the
-severity ranking lives here, beside its only consumer, rather than on the
-enum. If a second consumer ever needs the same order, promoting it onto
-``SyncStatus`` becomes a separate, coordinated change (spec §11).
+"""Folder/run-level sync-status rollup (two-icon presence model, 2026-05-30).
+
+When a run row (left tree) or a folder (metadata pane) is summarised, it carries
+a single "worst-of" rollup icon reduced from its children's per-file sync
+discriminators. This module owns that reduction as a pure, NiceGUI-free function
+so the severity ordering is testable in isolation.
+
+The reduction is over :class:`FileSyncView` (the UI presentation view), not the
+schema-committed ``SyncStatus`` enum: severity is a presentation concern, so it
+lives here beside its only consumer rather than on the wire enum.
"""
from __future__ import annotations
from collections.abc import Iterable
-from exlab_wizard.constants import SyncStatus
-
-# Most-attention-worthy first. A folder is summarised by the highest-ranked
-# state any child carries: a single failed file dominates a folder of
-# otherwise-synced files. ``cleaned`` (data on NAS, local copy gone) ranks
-# lowest -- it is the quiet terminal "done" state.
-_SEVERITY_ORDER: tuple[str, ...] = (
- SyncStatus.FAILED.value,
- SyncStatus.BLOCKED_BY_VALIDATION.value,
- SyncStatus.PENDING.value,
- SyncStatus.SYNCED.value,
- SyncStatus.CLEANED.value,
+from exlab_wizard.ui.components.sync_status_icon import FileSyncView, file_sync_view
+
+# Most-attention-worthy first. A folder/run is summarised by the highest-ranked
+# view any child carries.
+_SEVERITY_ORDER: tuple[FileSyncView, ...] = (
+ FileSyncView.MISSING,
+ FileSyncView.UPLOAD_FAILED,
+ FileSyncView.BLOCKED,
+ FileSyncView.LOCAL_ONLY,
+ FileSyncView.SYNCED,
+ FileSyncView.ON_NAS,
)
-_SEVERITY_RANK: dict[str, int] = {value: rank for rank, value in enumerate(_SEVERITY_ORDER)}
+_SEVERITY_RANK: dict[FileSyncView, int] = {v: i for i, v in enumerate(_SEVERITY_ORDER)}
def sync_rollup(statuses: Iterable[str | None]) -> str | None:
- """Reduce per-file sync statuses to a single worst-of rollup value.
-
- Returns the highest-severity recognised status among ``statuses``
- (``failed > blocked_by_validation > pending > synced > cleaned``), or
- ``None`` when there is nothing to roll up -- an empty input, or one
- holding only ``None`` / unrecognised values. ``None`` and unknown
- values are ignored rather than raising: a folder of unstatused files
- has no meaningful rollup, and the metadata card renders that as a
- neutral dash via the tolerant icon path (sync_status_icon, §4.5).
+ """Reduce per-file sync discriminators to a single worst-of view value.
+
+ Returns the highest-severity recognised view's value string
+ (``missing > upload_failed > blocked > local_only > synced > on_nas``),
+ or ``None`` when there is nothing to roll up (empty, or only ``None`` /
+ unrecognised values map to :attr:`FileSyncView.NONE`, which is ignored).
"""
- best: str | None = None
- best_rank = len(_SEVERITY_ORDER) # worse-than-any sentinel
+ best: FileSyncView | None = None
+ best_rank = len(_SEVERITY_ORDER)
for status in statuses:
- if status is None:
- continue
- rank = _SEVERITY_RANK.get(status)
- if rank is None:
+ view = file_sync_view(status)
+ rank = _SEVERITY_RANK.get(view)
+ if rank is None: # NONE
continue
if rank < best_rank:
best_rank = rank
- best = status
- return best
+ best = view
+ return best.value if best is not None else None
diff --git a/src/exlab_wizard/ui/components/sync_status_icon.py b/src/exlab_wizard/ui/components/sync_status_icon.py
index e5c09ed..c2501cd 100644
--- a/src/exlab_wizard/ui/components/sync_status_icon.py
+++ b/src/exlab_wizard/ui/components/sync_status_icon.py
@@ -1,205 +1,300 @@
-"""Sync-status icon component (Frontend Spec §3.2, §10.5.1).
-
-Distinct visual states with a fixed color mapping:
-
-* ``pending`` -- ``--color-muted``
-* ``acquiring`` -- ``--color-muted``
-* ``retrying`` (with N/M) -- ``--color-info``
-* ``syncing`` -- ``--color-info``
-* ``synced`` -- ``--color-success``
-* ``cleaned`` -- ``--color-success``
-* ``on_nas`` -- ``--color-muted``
-* ``failed`` -- ``--color-danger``
-* ``blocked_by_validation`` -- ``--color-warning``
-* ``override_active`` -- ``--color-info``
-
-The ``acquiring`` / ``syncing`` / ``on_nas`` states are the per-file GUI
-display states from the operator-free per-file NAS sync design
-(2026-05-21): a file still settling, a file mid-transfer, and an "On
-NAS" tombstone whose local copy has been cleared.
-
-The component returns a dict suitable for a NiceGUI icon factory; the
-layout (icon + optional ``(N/M)`` retry counter) is the caller's concern
-so the icon can be embedded in a tree row, a detail-pane title bar, or a
-staging-panel row identically.
+"""Two-icon sync-presence model + renderers (2026-05-30 design).
+
+Each file shows a fixed **local-left / NAS-right** icon pair; runs and folders
+show a single colour-coded rollup icon. The backend stays the state authority
+and emits a discriminator string; this module is a pure presentation map:
+``file_sync_view`` collapses the string to a :class:`FileSyncView`, then
+``sync_pair_props`` / ``sync_rollup_icon_props`` map the view to icon + colour
+props. Colour language: **blue = here · green = safe on NAS · gray = absent ·
+red = problem · amber = held.** NiceGUI renderers (``sync_pair_icons`` /
+``sync_rollup_icon``) live below the pure layer and import NiceGUI lazily.
"""
from __future__ import annotations
-from typing import Any, Final, Literal
+from enum import StrEnum
+from typing import Any, Final
+
+SYNC_LOCAL_SVG: Final[str] = "/assets/sync_local.svg"
+SYNC_NAS_SVG: Final[str] = "/assets/sync_nas.svg"
-from exlab_wizard.constants import SyncStatus
-from exlab_wizard.logging import get_logger
-_log = get_logger(__name__)
+class FileSyncView(StrEnum):
+ """UI-only collapsed view of a file/run's sync state (presentation, not wire)."""
+ LOCAL_ONLY = "local_only"
+ SYNCED = "synced"
+ ON_NAS = "on_nas"
+ UPLOAD_FAILED = "upload_failed"
+ BLOCKED = "blocked"
+ MISSING = "missing"
+ NONE = "none"
-# UI-only icon kinds: ``retrying`` and ``override_active`` are derived
-# in the UI from the SyncStatus + sync-job state, not wire enum values.
-type SyncStatusIconExtraKind = Literal["retrying", "override_active"]
-type SyncStatusOrIcon = SyncStatus | SyncStatusIconExtraKind | str
-STATUS_RETRYING: Final[str] = "retrying"
-STATUS_OVERRIDE: Final[str] = "override_active"
+# Backend discriminator string -> view. Backend is the state authority; the UI
+# only maps. Unknown / None -> NONE (render nothing).
+_STATUS_TO_VIEW: Final[dict[str, FileSyncView]] = {
+ "synced": FileSyncView.SYNCED,
+ "acquiring": FileSyncView.LOCAL_ONLY,
+ "syncing": FileSyncView.LOCAL_ONLY,
+ "pending": FileSyncView.LOCAL_ONLY,
+ "local_only": FileSyncView.LOCAL_ONLY,
+ "on_nas": FileSyncView.ON_NAS,
+ "cleaned": FileSyncView.ON_NAS,
+ "cleared": FileSyncView.ON_NAS,
+ "upload_failed": FileSyncView.UPLOAD_FAILED,
+ "failed": FileSyncView.UPLOAD_FAILED,
+ "blocked": FileSyncView.BLOCKED,
+ "blocked_by_validation": FileSyncView.BLOCKED,
+ "missing": FileSyncView.MISSING,
+}
+
-# Per-file GUI display states (operator-free per-file NAS sync design,
-# 2026-05-21). These mirror the discriminators emitted by
-# ``api.routers.browse._file_state_from_record``.
-STATUS_ACQUIRING: Final[str] = "acquiring"
-STATUS_SYNCING: Final[str] = "syncing"
-STATUS_ON_NAS: Final[str] = "on_nas"
+def file_sync_view(status: str | None) -> FileSyncView:
+ """Map a backend sync discriminator string to a :class:`FileSyncView`."""
+ if not status:
+ return FileSyncView.NONE
+ return _STATUS_TO_VIEW.get(str(status), FileSyncView.NONE)
-_STATUS_TO_PROPS: dict[str, dict[str, str]] = {
- SyncStatus.PENDING.value: {
- "icon_name": "schedule",
- "color_var": "--color-muted",
- "tooltip": "Queued for sync",
+# Per-view two-icon props. Each cell: svg, bg_var, badge, tooltip, faded.
+_PAIR_PROPS: Final[dict[FileSyncView, dict[str, dict[str, Any]]]] = {
+ FileSyncView.LOCAL_ONLY: {
+ "local": {
+ "svg": SYNC_LOCAL_SVG,
+ "bg_var": "--color-sync-local",
+ "badge": "",
+ "tooltip": "Stored locally — not backed up yet",
+ "faded": False,
+ },
+ "nas": {
+ "svg": SYNC_NAS_SVG,
+ "bg_var": "--color-sync-absent",
+ "badge": "",
+ "tooltip": "Not on the NAS yet",
+ "faded": True,
+ },
},
- STATUS_ACQUIRING: {
- "icon_name": "edit_note",
- "color_var": "--color-muted",
- "tooltip": "Acquiring -- new file, still settling",
+ FileSyncView.SYNCED: {
+ "local": {
+ "svg": SYNC_LOCAL_SVG,
+ "bg_var": "--color-sync-cached",
+ "badge": "",
+ "tooltip": "Local cache — safe to clear (backed up)",
+ "faded": False,
+ },
+ "nas": {
+ "svg": SYNC_NAS_SVG,
+ "bg_var": "--color-sync-safe",
+ "badge": "",
+ "tooltip": "Backed up on the NAS",
+ "faded": False,
+ },
},
- STATUS_RETRYING: {
- "icon_name": "history",
- "color_var": "--color-info",
- "tooltip": "Retrying with backoff",
+ FileSyncView.ON_NAS: {
+ "local": {
+ "svg": SYNC_LOCAL_SVG,
+ "bg_var": "--color-sync-absent",
+ "badge": "",
+ "tooltip": "Local copy reclaimed",
+ "faded": True,
+ },
+ "nas": {
+ "svg": SYNC_NAS_SVG,
+ "bg_var": "--color-sync-safe",
+ "badge": "",
+ "tooltip": "Backed up on the NAS",
+ "faded": False,
+ },
},
- STATUS_SYNCING: {
- "icon_name": "sync",
- "color_var": "--color-info",
- "tooltip": "Syncing -- settled, transferring to NAS",
+ FileSyncView.UPLOAD_FAILED: {
+ "local": {
+ "svg": SYNC_LOCAL_SVG,
+ "bg_var": "--color-sync-local",
+ "badge": "",
+ "tooltip": "Stored locally — safe",
+ "faded": False,
+ },
+ "nas": {
+ "svg": SYNC_NAS_SVG,
+ "bg_var": "--color-sync-problem",
+ "badge": "✕",
+ "tooltip": "Upload to NAS failed",
+ "faded": False,
+ },
},
- STATUS_ON_NAS: {
- "icon_name": "cloud",
- "color_var": "--color-muted",
- "tooltip": "On NAS -- local copy cleared, data on NAS only",
+ FileSyncView.BLOCKED: {
+ "local": {
+ "svg": SYNC_LOCAL_SVG,
+ "bg_var": "--color-sync-local",
+ "badge": "",
+ "tooltip": "Stored locally — safe",
+ "faded": False,
+ },
+ "nas": {
+ "svg": SYNC_NAS_SVG,
+ "bg_var": "--color-sync-held",
+ "badge": "!",
+ "badge_bg": "--color-sync-held",
+ "tooltip": "Upload held by a validation finding",
+ "faded": False,
+ },
+ },
+ FileSyncView.MISSING: {
+ "local": {
+ "svg": SYNC_LOCAL_SVG,
+ "bg_var": "--color-sync-problem",
+ "badge": "✕",
+ "tooltip": "Missing — not found locally",
+ "faded": False,
+ },
+ "nas": {
+ "svg": SYNC_NAS_SVG,
+ "bg_var": "--color-sync-problem",
+ "badge": "✕",
+ "tooltip": "Missing — not on the NAS",
+ "faded": False,
+ },
+ },
+}
+
+
+def sync_pair_props(view: FileSyncView) -> dict[str, dict[str, Any]] | None:
+ """Return ``{"local": cell, "nas": cell}`` for a file's two-icon pair, or None."""
+ props = _PAIR_PROPS.get(view)
+ if props is None:
+ return None
+ # Return a deep-ish copy so callers can't mutate the table.
+ return {side: dict(cell) for side, cell in props.items()}
+
+
+# Per-view single rollup icon (runs / folders): svg, bg_var, badge, tooltip.
+_ROLLUP_PROPS: Final[dict[FileSyncView, dict[str, str]]] = {
+ FileSyncView.SYNCED: {
+ "svg": SYNC_NAS_SVG,
+ "bg_var": "--color-sync-safe",
+ "badge": "",
+ "tooltip": "Fully backed up on the NAS",
},
- SyncStatus.SYNCED.value: {
- "icon_name": "check_circle",
- "color_var": "--color-success",
- "tooltip": "Synced and verified at NAS",
+ FileSyncView.ON_NAS: {
+ "svg": SYNC_NAS_SVG,
+ "bg_var": "--color-sync-safe",
+ "badge": "",
+ "tooltip": "Fully backed up on the NAS",
},
- SyncStatus.CLEANED.value: {
- "icon_name": "cloud_done",
- "color_var": "--color-success",
- "tooltip": "Synced and locally cleaned; data on NAS only",
+ FileSyncView.LOCAL_ONLY: {
+ "svg": SYNC_LOCAL_SVG,
+ "bg_var": "--color-sync-local",
+ "badge": "",
+ "tooltip": "Not fully synced — local files remain",
},
- SyncStatus.FAILED.value: {
- "icon_name": "error",
- "color_var": "--color-danger",
- "tooltip": "Sync failed; retry budget exhausted",
+ FileSyncView.BLOCKED: {
+ "svg": SYNC_NAS_SVG,
+ "bg_var": "--color-sync-held",
+ "badge": "!",
+ "badge_bg": "--color-sync-held",
+ "tooltip": "Sync held by a validation finding",
},
- SyncStatus.BLOCKED_BY_VALIDATION.value: {
- "icon_name": "warning",
- "color_var": "--color-warning",
- "tooltip": "Hard-tier validation finding gates sync",
+ FileSyncView.UPLOAD_FAILED: {
+ "svg": SYNC_NAS_SVG,
+ "bg_var": "--color-sync-problem",
+ "badge": "✕",
+ "tooltip": "Sync error",
},
- STATUS_OVERRIDE: {
- "icon_name": "lock_open",
- "color_var": "--color-info",
- "tooltip": "Sync allowed under operator override",
+ FileSyncView.MISSING: {
+ "svg": SYNC_NAS_SVG,
+ "bg_var": "--color-sync-problem",
+ "badge": "✕",
+ "tooltip": "Sync error",
},
}
-# Neutral props returned for an unknown / ``None`` status in tolerant mode:
-# a muted dash with no icon. Keys mirror the normal props shape so callers
-# (``sync_status_icon`` render fn, file-list Status cell, legend) can treat
-# both paths identically.
-_NEUTRAL_PROPS: Final[dict[str, str]] = {
- "icon_name": "",
- "color_var": "--color-muted",
- "tooltip": "",
-}
+def sync_rollup_icon_props(view: FileSyncView) -> dict[str, str] | None:
+ """Return single-icon props for a run/folder rollup, or None for NONE."""
+ props = _ROLLUP_PROPS.get(view)
+ return dict(props) if props is not None else None
-def sync_legend_entries() -> list[dict[str, str]]:
- """Return the sync-status legend rows, in declaration order (Phase 5).
-
- One dict per state in ``_STATUS_TO_PROPS`` -- ``{"status", "icon_name",
- "color_var", "tooltip"}`` -- so the Files-header legend popover lists
- exactly the icons + meanings the file list / metadata pane render and can't
- drift from them (``_STATUS_TO_PROPS`` is the single source of truth). Pure
- so it is testable without NiceGUI.
- """
-
- return [{"status": status, **props} for status, props in _STATUS_TO_PROPS.items()]
-
-
-def sync_status_props(
- status: SyncStatusOrIcon | None,
- *,
- retry_n: int | None = None,
- retry_m: int | None = None,
- strict: bool = True,
-) -> dict[str, Any]:
- """Compute icon + tooltip + retry-counter for a sync status.
-
- The ``retry_n``/``retry_m`` annotations are rendered only when
- ``status == "retrying"`` (Frontend §10.5.1).
-
- ``strict`` (default ``True``) preserves the original contract: an unknown
- status raises ``ValueError`` so a genuinely wrong value is caught loudly.
- With ``strict=False`` an unknown *or* ``None`` status returns neutral
- props (a muted dash, no icon) instead of raising -- used by callers that
- render optional per-file / per-folder status (the file-list Status cell,
- the folder rollup, the legend) where ``None`` is legitimate, not a bug.
- """
-
- key = (
- "" if status is None else (status.value if isinstance(status, SyncStatus) else str(status))
+def _icon_cell(cell: dict[str, Any], *, side: str) -> None: # pragma: no cover -- NiceGUI render
+ from nicegui import ui
+
+ box = (
+ ui.element("span")
+ .props(f'data-sync-cell="{side}" data-sync-bg="{cell["bg_var"]}"')
+ .style(
+ f"position: relative; display: inline-flex; align-items: center; "
+ f"justify-content: center; width: 1.5rem; height: 1.5rem; "
+ f"border-radius: var(--radius-sm); background: var({cell['bg_var']});"
+ )
+ .tooltip(cell["tooltip"])
)
- if key not in _STATUS_TO_PROPS:
- if strict:
- raise ValueError(
- f"unknown sync status {status!r}: must be one of {sorted(_STATUS_TO_PROPS)}",
+ with box:
+ opacity = "0.45" if cell.get("faded") else "1"
+ ui.element("img").props(f'src="{cell["svg"]}" alt="{cell["tooltip"]}"').style(
+ f"width: 1rem; height: 1rem; opacity: {opacity};"
+ )
+ if cell["badge"]:
+ # Badge dot matches the cell's alert hue: red for a problem, amber
+ # for a held cell -- never red-on-amber (the white glyph stays legible
+ # on both). Defaults to red for problem cells that omit the key.
+ badge_bg = cell.get("badge_bg", "--color-sync-problem")
+ ui.label(cell["badge"]).props('data-sync-badge="true"').style(
+ "position: absolute; top: -3px; right: -3px; font-size: 0.6rem; "
+ "line-height: 1; font-weight: 700; color: var(--color-surface); "
+ f"background: var({badge_bg}); border-radius: 50%; "
+ "width: 0.8rem; height: 0.8rem; display: flex; align-items: center; "
+ "justify-content: center;"
)
- return {**_NEUTRAL_PROPS, "status": key, "retry_label": ""}
- base = dict(_STATUS_TO_PROPS[key])
- base["status"] = key
- if key == STATUS_RETRYING and retry_n is not None and retry_m is not None:
- base["retry_label"] = f"({retry_n}/{retry_m})"
- base["tooltip"] = f"Retry {retry_n} of {retry_m}, awaiting backoff"
- else:
- base["retry_label"] = ""
- return base
-
-
-def sync_status_icon(
- status: SyncStatusOrIcon | None,
- *,
- retry_n: int | None = None,
- retry_m: int | None = None,
- strict: bool = True,
-) -> Any:
- """Build a NiceGUI row containing the icon and optional retry counter.
-
- ``strict`` is forwarded to :func:`sync_status_props`; with
- ``strict=False`` an unknown / ``None`` status renders a muted dash
- instead of raising (used by the file-list Status cell and the legend).
- """
-
- props = sync_status_props(status, retry_n=retry_n, retry_m=retry_m, strict=strict)
+
+
+def sync_pair_icons(view: FileSyncView) -> Any: # pragma: no cover -- NiceGUI render
+ """Render the per-file two-icon (local + NAS) row, or nothing for NONE."""
+ props = sync_pair_props(view)
try:
from nicegui import ui
except Exception:
return props
+ if props is None:
+ return ui.element("span").props('data-sync-view="none"')
+ row = (
+ ui.row()
+ .classes("items-center")
+ .props(f'data-sync-view="{view.value}"')
+ .style("gap: 0.3rem;")
+ )
+ with row:
+ _icon_cell(props["local"], side="local")
+ _icon_cell(props["nas"], side="nas")
+ return row
+
- row = ui.row().classes("items-center").style("gap: 0.25rem;")
+def sync_rollup_icon(view: FileSyncView) -> Any: # pragma: no cover -- NiceGUI render
+ """Render the single run/folder rollup icon, or nothing for NONE."""
+ props = sync_rollup_icon_props(view)
+ try:
+ from nicegui import ui
+ except Exception:
+ return props
+ if props is None:
+ return ui.element("span").props('data-sync-view="none"')
+ cell = {**props, "faded": False}
+ row = ui.row().classes("items-center").props(f'data-sync-view="{view.value}"')
with row:
- if props["icon_name"]:
- ui.icon(props["icon_name"]).style(
- f"color: var({props['color_var']}); font-size: 1rem;"
- ).tooltip(props["tooltip"])
- else:
- # Neutral / unknown status (tolerant mode): a muted dash.
- ui.label("-").style(f"color: var({props['color_var']});")
- if props["retry_label"]:
- ui.label(props["retry_label"]).style(
- "font-family: var(--font-mono); "
- "font-size: var(--text-xs); "
- f"color: var({props['color_var']});"
- )
+ _icon_cell(cell, side="rollup")
return row
+
+
+def sync_legend_entries() -> list[dict[str, str]]:
+ """Return legend rows (one per visible view) for the Files-header popover."""
+ return [
+ {"view": v.value, "tooltip": _ROLLUP_PROPS.get(v, {}).get("tooltip", "")}
+ for v in (
+ FileSyncView.LOCAL_ONLY,
+ FileSyncView.SYNCED,
+ FileSyncView.ON_NAS,
+ FileSyncView.UPLOAD_FAILED,
+ FileSyncView.BLOCKED,
+ FileSyncView.MISSING,
+ )
+ ]
diff --git a/src/exlab_wizard/ui/components/tree.py b/src/exlab_wizard/ui/components/tree.py
index da6c3fd..4086378 100644
--- a/src/exlab_wizard/ui/components/tree.py
+++ b/src/exlab_wizard/ui/components/tree.py
@@ -9,17 +9,21 @@
* Run node (test) -- dimmed styling + ``TestRun_`` prefix in
warning-tier color + a *"Test"* pill.
-Run rows also carry a small **sync icon** to the left of the label:
-
-* ``sync_local.svg`` -- run data is still on local disk (rollup
- ``syncing`` / ``synced``, any state other than ``cleared``).
-* ``sync_cloud.svg`` -- the run's staging copy has been cleared
- (rollup ``cleared``); only the ``.exlab-wizard/`` cache subtree
- remains on disk (§7.1.10).
-
-The run-node rollup is derived from the run's ``sync_state.json`` by the
-browse router (operator-free per-file NAS sync design, 2026-05-21);
-``RunNode.sync_status`` carries a :class:`RunSyncState` value.
+Run rows also carry a small colour-coded **rollup sync icon** to the left
+of the label (two-icon sync-presence design, 2026-05-30):
+
+* ``sync_local.svg`` on a blue background -- run data is still on local
+ disk (not fully backed up).
+* ``sync_nas.svg`` on a green background -- the run is fully backed up on
+ the NAS.
+* ``sync_nas.svg`` on a red / amber background with a ``✕`` / ``!`` badge
+ -- a problem (upload failed) or a held (blocked-by-validation) run.
+
+The run-node rollup discriminator is derived from the run's
+``sync_state.json`` + persisted ``creation.json`` failure status by the
+browse router; ``RunNode.sync_status`` carries that string, which
+:func:`~exlab_wizard.ui.components.sync_status_icon.file_sync_view` maps
+to a :class:`~exlab_wizard.ui.components.sync_status_icon.FileSyncView`.
``.exlab-wizard/`` folders are hidden by default (Frontend §13.1) and
hidden filtering is the caller's concern.
@@ -35,8 +39,12 @@
from dataclasses import dataclass, field
from typing import Any
-from exlab_wizard.constants.enums import RunKind, RunSyncState, TreeProjectStatus
+from exlab_wizard.constants.enums import RunKind, TreeProjectStatus
from exlab_wizard.logging import get_logger
+from exlab_wizard.ui.components.sync_status_icon import (
+ file_sync_view,
+ sync_rollup_icon_props,
+)
_log = get_logger(__name__)
@@ -79,9 +87,11 @@ def _node_type_props(kind: str) -> tuple[str, str]:
KIND_RUN_TEST: "run",
}
-# Static URLs served by ``ui/theme.py:register_static_assets``.
+# Static URLs served by ``ui/theme.py:register_static_assets``. The rollup
+# props come from :mod:`sync_status_icon`; these names are retained as the
+# module's public asset-URL surface (asserted by the tree tests).
SYNC_ICON_LOCAL_URL = "/assets/sync_local.svg"
-SYNC_ICON_CLOUD_URL = "/assets/sync_cloud.svg"
+SYNC_ICON_NAS_URL = "/assets/sync_nas.svg"
@dataclass(frozen=True)
@@ -249,20 +259,19 @@ def build_nodes(
return nodes
-def _sync_icon_url(node: TreeNode) -> str | None:
- """Return the per-row sync-icon URL, or ``None`` for non-run rows.
+def _sync_rollup_props(node: TreeNode) -> dict[str, str] | None:
+ """Return the single rollup-icon props for a run row, or None for non-run rows.
- Run rows get one of the two ``/assets/sync_*.svg`` URLs depending on
- the derived run rollup: a ``cleared`` run (staging copy cleaned, data
- on NAS only) gets ``sync_cloud.svg``; a ``syncing`` / ``synced`` run
- still has data on disk and gets ``sync_local.svg``. Equipment /
- project rows render unchanged.
+ Run rows are summarised by a single colour-coded rollup icon derived
+ from the run's discriminator (``node.sync_status``) via the two-icon
+ presentation map: ``sync_nas.svg`` (green) when fully backed up,
+ ``sync_local.svg`` (blue) while local files remain, plus red/amber
+ problem/held variants with a badge. Equipment / project rows render
+ no sync icon.
"""
if node.kind not in _RUN_KINDS:
return None
- if node.sync_status == RunSyncState.CLEARED.value:
- return SYNC_ICON_CLOUD_URL
- return SYNC_ICON_LOCAL_URL
+ return sync_rollup_icon_props(file_sync_view(node.sync_status))
def to_nicegui_nodes(nodes: Iterable[TreeNode]) -> list[dict[str, Any]]:
@@ -291,18 +300,14 @@ def to_nicegui_nodes(nodes: Iterable[TreeNode]) -> list[dict[str, Any]]:
"type_icon": type_icon,
"type_color": type_color,
}
- icon_url = _sync_icon_url(node)
- if icon_url is not None:
- payload["sync_icon"] = icon_url
- payload["sync_status"] = node.sync_status or ""
- # Friendly hover tooltip mirroring the icon's meaning: the cloud
- # (CLEARED) reads "on NAS only", the local-disk icon reads
- # "data on local disk" (Phase 5 sync tooltips).
- payload["sync_title"] = (
- "Cleared -- data on NAS only"
- if node.sync_status == RunSyncState.CLEARED.value
- else "Data on local disk"
- )
+ rollup = _sync_rollup_props(node)
+ if rollup is not None:
+ payload["sync_icon"] = rollup["svg"]
+ payload["sync_bg"] = rollup["bg_var"]
+ payload["sync_badge"] = rollup["badge"]
+ payload["sync_badge_bg"] = rollup.get("badge_bg", "--color-sync-problem")
+ payload["sync_title"] = rollup["tooltip"]
+ payload["sync_status"] = file_sync_view(node.sync_status).value
out.append(payload)
return out
@@ -342,10 +347,24 @@ def _tree_header_slot(*, tree_id: int, listener_id: str) -> str:
''
' "
- ' ![]() "
+ ' ![]() "
+ ' "
+ "{{ props.node.sync_badge }}"
+ ""
" None:
def _render_sync_legend() -> None: # pragma: no cover -- NiceGUI render, driven by e2e
"""Render the Files-header sync-status legend ("?") popover.
- Lists each sync state's icon + meaning, sourced from
- :func:`sync_status_icon.sync_legend_entries` (which reads ``_STATUS_TO_PROPS``,
- the single source of truth) so the legend can't drift from the icons the
- file list / metadata pane actually render.
+ Lists each visible sync view as a real two-icon (local + NAS) swatch +
+ its meaning, sourced from
+ :func:`sync_status_icon.sync_legend_entries` so the legend renders the
+ same icon pairs the file list / metadata pane actually draw and can't
+ drift from them.
"""
try:
from nicegui import ui
except Exception:
return
- from exlab_wizard.ui.components.sync_status_icon import sync_legend_entries
+ from exlab_wizard.ui.components.sync_status_icon import (
+ FileSyncView,
+ sync_legend_entries,
+ sync_pair_icons,
+ )
with (
ui.button(icon="help_outline")
@@ -615,9 +620,7 @@ def _render_sync_legend() -> None: # pragma: no cover -- NiceGUI render, driven
"gap: var(--sp-2, 0.5rem); padding: var(--sp-1) var(--sp-3); flex-wrap: nowrap;"
)
):
- ui.icon(entry["icon_name"]).style(
- f"color: var({entry['color_var']}); font-size: 1rem;"
- )
+ sync_pair_icons(FileSyncView(entry["view"]))
ui.label(entry["tooltip"]).style("font-size: var(--text-sm); white-space: nowrap;")
diff --git a/src/exlab_wizard/ui/theme.py b/src/exlab_wizard/ui/theme.py
index b7feefe..58d01c6 100644
--- a/src/exlab_wizard/ui/theme.py
+++ b/src/exlab_wizard/ui/theme.py
@@ -69,6 +69,13 @@ def build_root_css() -> str:
f" --color-info: {design.COLOR_INFO};\n"
f" --color-warning: {design.COLOR_WARNING};\n"
f" --color-danger: {design.COLOR_DANGER};\n"
+ # Sync-status icon tokens (two-icon presence model, 2026-05-30).
+ f" --color-sync-local: {design.COLOR_SYNC_LOCAL};\n"
+ f" --color-sync-cached: {design.COLOR_SYNC_CACHED};\n"
+ f" --color-sync-safe: {design.COLOR_SYNC_SAFE};\n"
+ f" --color-sync-absent: {design.COLOR_SYNC_ABSENT};\n"
+ f" --color-sync-problem: {design.COLOR_SYNC_PROBLEM};\n"
+ f" --color-sync-held: {design.COLOR_SYNC_HELD};\n"
f" --font-display: {design.FONT_DISPLAY};\n"
f" --font-body: {design.FONT_BODY};\n"
f" --font-mono: {design.FONT_MONO};\n"
diff --git a/tests/e2e/_test_app.py b/tests/e2e/_test_app.py
index 4d593ab..371bc0f 100644
--- a/tests/e2e/_test_app.py
+++ b/tests/e2e/_test_app.py
@@ -173,16 +173,21 @@ def _seeded_metadata_payload(node_id: str | None, node_kind: str | None) -> dict
return {}
-# Default synthetic file-list feed. Operator-free per-file NAS sync
-# design (2026-05-21): the rows exercise the five per-file display
-# states -- a synced+kept-local file, an acquiring file, a syncing
-# file, and an "On NAS" tombstone (cleared run, no local copy). Each
+# Default synthetic file-list feed. Two-icon sync-presence design
+# (2026-05-30): the rows exercise every per-file display state --
+# a synced+kept-local file, an acquiring file, a syncing file, an
+# "On NAS" tombstone (cleared run, no local copy), a "missing" lost
+# file (tracked, gone locally, never verified), an upload-failed
+# file (problem/red NAS), and a blocked file (held/amber NAS). Each
# tuple is (name, size|None, sync_status, keep_local, tombstone).
_DEFAULT_FEED_ROWS: list[tuple[str, int | None, str | None, bool, bool]] = [
("scan.tif", 1024, "synced", True, False),
("metadata.json", 256, "acquiring", False, False),
("frames.raw", 4096, "syncing", False, False),
("archived.tif", None, "on_nas", False, True),
+ ("lost.tif", None, "missing", False, True),
+ ("stuck.raw", 2048, "upload_failed", False, False),
+ ("held.json", 512, "blocked", False, False),
]
@@ -244,7 +249,7 @@ def build_test_app() -> FastAPI:
app.state.test_state = test_state
# Mount the project's ``assets/`` directory at ``/assets`` so the
- # tree component's sync-icon SVGs (sync_local.svg / sync_cloud.svg)
+ # tree component's sync-icon SVGs (sync_local.svg / sync_nas.svg)
# resolve under e2e tests. Idempotent.
register_static_assets()
# Inject the :root design-token block app-wide so the demo mirrors
@@ -328,16 +333,39 @@ def main_index(
hierarchy: dict[Any, Any] = {
tree_component.EquipmentNode("TEST_EQ1", relay=False): {
tree_component.ProjectNode("LIMS-001", "Demo Project"): [
- tree_component.RunNode("Run_2026-05-07", "experimental", "Demo run"),
+ # Two-icon sync-presence design (2026-05-30): the run
+ # rollup icon now comes from file_sync_view(sync_status)
+ # -> sync_rollup_icon_props(view). ``None`` renders NO
+ # icon, so each run below carries an explicit status that
+ # exercises a distinct rollup state:
+ # syncing -> LOCAL_ONLY -> sync_local.svg (blue)
+ # cleared -> ON_NAS -> sync_nas.svg (green)
+ # upload_failed -> sync_nas.svg (red, ✕)
+ # blocked -> sync_nas.svg (amber, !)
+ tree_component.RunNode(
+ directory_name="Run_2026-05-07",
+ run_kind="experimental",
+ label="Demo run",
+ sync_status="syncing",
+ ),
tree_component.RunNode(
directory_name="Run_2026-05-06",
run_kind="experimental",
label="Cleared run",
- # Operator-free per-file NAS sync design
- # (2026-05-21): the run rollup is a RunSyncState
- # value; ``cleared`` drives the cloud icon.
sync_status="cleared",
),
+ tree_component.RunNode(
+ directory_name="Run_2026-05-05",
+ run_kind="experimental",
+ label="Failed run",
+ sync_status="upload_failed",
+ ),
+ tree_component.RunNode(
+ directory_name="Run_2026-05-04",
+ run_kind="experimental",
+ label="Blocked run",
+ sync_status="blocked",
+ ),
tree_component.RunNode("TestRun_2026-05-07", "test", "Test run"),
],
},
diff --git a/tests/e2e/page_objects/main_page.py b/tests/e2e/page_objects/main_page.py
index cbbb2bf..f5ba6cb 100644
--- a/tests/e2e/page_objects/main_page.py
+++ b/tests/e2e/page_objects/main_page.py
@@ -87,3 +87,22 @@ def footer_clear_verified(self) -> Locator:
def footer_staging_segment(self) -> Locator:
"""Footer Staging status segment (Redesign §4.6)."""
return self._page.get_by_test_id("footer-staging-segment")
+
+ @property
+ def sync_local_icons(self) -> Locator:
+ """Run-row rollup icons resolving to the local-presence SVG.
+
+ Two-icon sync-presence design (2026-05-30): a run whose rollup
+ view is LOCAL_ONLY renders ``/assets/sync_local.svg`` (blue).
+ """
+ return self.tree.locator('img[src="/assets/sync_local.svg"]')
+
+ @property
+ def sync_nas_icons(self) -> Locator:
+ """Run-row rollup icons resolving to the NAS-presence SVG.
+
+ A run whose rollup view is ON_NAS / SYNCED / UPLOAD_FAILED /
+ BLOCKED renders ``/assets/sync_nas.svg`` (green / red / amber by
+ background).
+ """
+ return self.tree.locator('img[src="/assets/sync_nas.svg"]')
diff --git a/tests/e2e/test_flow_05_browse_view_sync_icons.py b/tests/e2e/test_flow_05_browse_view_sync_icons.py
index 45d407b..0dd5b72 100644
--- a/tests/e2e/test_flow_05_browse_view_sync_icons.py
+++ b/tests/e2e/test_flow_05_browse_view_sync_icons.py
@@ -1,13 +1,24 @@
-"""E2E flow 05b: per-run sync icons in the browse tree.
+"""E2E flow 05b: per-run sync rollup icons in the browse tree.
-Verifies that the project / equipment tree (Frontend §3.5) renders the
-correct SVG icon to the left of each run name based on its sync status:
+Two-icon sync-presence design (2026-05-30). The project / equipment
+tree (Frontend §3.5) renders a single colour-coded rollup icon to the
+left of each run name, derived from ``file_sync_view(sync_status)`` ->
+``sync_rollup_icon_props(view)``:
-* rollup other than ``cleared`` (or absent) -> ``/assets/sync_local.svg``
-* rollup ``cleared`` -> ``/assets/sync_cloud.svg``
+* ``syncing`` / ``pending`` / ``local_only`` -> LOCAL_ONLY -> ``/assets/sync_local.svg`` (blue)
+* ``synced`` / ``cleared`` / ``on_nas`` -> ON_NAS/SYNCED -> ``/assets/sync_nas.svg`` (green)
+* ``upload_failed`` / ``failed`` -> UPLOAD_FAILED -> ``/assets/sync_nas.svg`` (red, ✕ badge)
+* ``blocked`` / ``blocked_by_validation`` -> BLOCKED -> ``/assets/sync_nas.svg`` (amber, ! badge)
+* ``None`` / ``""`` / unknown -> NONE -> no icon rendered
-Also asserts the static asset mount actually serves the SVGs (200 OK)
-so a missing PyInstaller bundle entry would surface here.
+The seeded hierarchy in ``tests/e2e/_test_app.py`` carries one run of
+each visible state (plus a NONE test-run that renders nothing), so the
+tree shows exactly one ``sync_local.svg`` and three ``sync_nas.svg``
+icons, one problem background, and two badges.
+
+Also asserts the static asset mount serves the SVGs (200 OK) so a
+missing PyInstaller bundle entry would surface here. The retired cloud
+asset is no longer referenced anywhere.
"""
from __future__ import annotations
@@ -25,29 +36,34 @@ def test_flow_05_sync_icons_render_in_tree(page, server_url) -> None:
tree = page.locator('[data-testid="main-tree"]')
- # The seeded hierarchy in tests/e2e/_test_app.py contains:
- # - Run_2026-05-07 (local; sync_status=None) -> sync_local.svg
- # - Run_2026-05-06 (cleared; sync_status=cleared) -> sync_cloud.svg
- # - TestRun_2026-05-07 (local; sync_status=None) -> sync_local.svg
+ # Seeded runs under TEST_EQ1 / LIMS-001:
+ # Run_2026-05-07 (syncing) -> LOCAL_ONLY -> sync_local.svg
+ # Run_2026-05-06 (cleared) -> ON_NAS -> sync_nas.svg (green)
+ # Run_2026-05-05 (upload_failed) -> UPLOAD_FAILED -> sync_nas.svg (red, ✕)
+ # Run_2026-05-04 (blocked) -> BLOCKED -> sync_nas.svg (amber, !)
+ # TestRun_2026-05-07 (None) -> NONE -> no icon
local_icons = tree.locator('img[src="/assets/sync_local.svg"]')
- cloud_icons = tree.locator('img[src="/assets/sync_cloud.svg"]')
+ nas_icons = tree.locator('img[src="/assets/sync_nas.svg"]')
- # Two local runs (one experimental, one test) and one cleaned run.
- assert local_icons.count() == 2, f"expected 2 sync_local icons, got {local_icons.count()}"
- assert cloud_icons.count() == 1, f"expected 1 sync_cloud icon, got {cloud_icons.count()}"
+ # At least one local-only (blue) run and at least one NAS-presence run.
+ assert local_icons.count() >= 1, f"expected >= 1 sync_local icon, got {local_icons.count()}"
+ assert nas_icons.count() >= 1, f"expected >= 1 sync_nas icon, got {nas_icons.count()}"
- # The cleaned-run icon's parent header carries the canonical sync_status
- # marker on the label span (set by the default-header slot template).
- cloud_header = cloud_icons.first.locator("xpath=..")
- assert cloud_header.locator('span[data-sync-status="cleared"]').count() == 1, (
- "cleared run header missing data-sync-status='cleared' marker"
+ # The failed run carries the problem (red) background...
+ problem_bg = tree.locator('span[data-sync-bg="--color-sync-problem"]')
+ assert problem_bg.count() >= 1, (
+ f"expected >= 1 problem-background rollup, got {problem_bg.count()}"
)
+ # ...and a corner badge (✕ for failed, ! for blocked) is rendered.
+ badges = tree.locator('span[data-sync-badge="true"]')
+ assert badges.count() >= 1, f"expected >= 1 sync badge, got {badges.count()}"
+
def test_flow_05_sync_icons_static_assets_serve_200(server_url) -> None:
- """The ``/assets`` mount serves both SVGs as 200 OK."""
+ """The ``/assets`` mount serves both presence SVGs as 200 OK."""
- for url in (f"{server_url}/assets/sync_local.svg", f"{server_url}/assets/sync_cloud.svg"):
+ for url in (f"{server_url}/assets/sync_local.svg", f"{server_url}/assets/sync_nas.svg"):
response = httpx.get(url, timeout=5.0)
assert response.status_code == 200, f"{url} returned {response.status_code}"
body = response.text
diff --git a/tests/e2e/test_flow_28_selection_search_density.py b/tests/e2e/test_flow_28_selection_search_density.py
index 5d80c71..24e15bf 100644
--- a/tests/e2e/test_flow_28_selection_search_density.py
+++ b/tests/e2e/test_flow_28_selection_search_density.py
@@ -60,8 +60,13 @@ def test_flow_28_search_filters_tree_and_shows_count(page, server_url) -> None:
_goto(page, f"{server_url}/main?q=Demo")
count = page.locator('[data-testid="main-search-count"]')
count.wait_for(state="visible", timeout=10_000)
- # The seeded query surfaced exactly the Demo project + its 3 runs.
- assert count.inner_text().strip() == "4 results"
+ # The seeded query surfaced the Demo project + its run nodes. The
+ # two-icon sync-presence design (2026-05-30) added the failed/blocked
+ # runs to ``_test_app``'s hierarchy so the tree rollup states are
+ # covered, so "Demo" now matches the project plus five runs
+ # (Run_2026-05-07/06/05/04 + TestRun_2026-05-07). Keep in lockstep
+ # with that seeded hierarchy.
+ assert count.inner_text().strip() == "6 results"
def test_flow_28_search_no_matches_state(page, server_url) -> None:
@@ -87,8 +92,11 @@ def test_flow_28_sync_legend_popover_lists_states(page, server_url) -> None:
legend.click()
menu = page.locator('[data-testid="files-legend-menu"]')
menu.wait_for(state="visible", timeout=5_000)
- # A couple of known meanings from _STATUS_TO_PROPS are listed.
- assert "Synced and verified at NAS" in menu.inner_text()
+ # A couple of known meanings from the two-icon FileSyncView legend
+ # (sync_legend_entries) are listed.
+ text = menu.inner_text()
+ assert "Fully backed up on the NAS" in text
+ assert "Sync held by a validation finding" in text
def test_flow_28_selected_tree_node_carries_selected_class(page, server_url) -> None:
diff --git a/tests/unit/api/test_browse_sync_view.py b/tests/unit/api/test_browse_sync_view.py
new file mode 100644
index 0000000..dbe18bb
--- /dev/null
+++ b/tests/unit/api/test_browse_sync_view.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+from exlab_wizard.api.routers import browse
+
+
+class _Rec:
+ def __init__(self, verified: bool):
+ self.verified_at = "2026-05-30T00:00:00Z" if verified else None
+ self.keep_local = False
+
+
+def test_no_record_on_disk_is_acquiring():
+ assert browse._file_state_from_record(None, on_disk=True) == "acquiring"
+
+
+def test_no_record_absent_is_none():
+ assert browse._file_state_from_record(None, on_disk=False) is None
+
+
+def test_verified_on_disk_is_synced():
+ assert browse._file_state_from_record(_Rec(True), on_disk=True) == "synced"
+
+
+def test_unverified_on_disk_is_syncing():
+ assert browse._file_state_from_record(_Rec(False), on_disk=True) == "syncing"
+
+
+def test_verified_absent_is_on_nas():
+ assert browse._file_state_from_record(_Rec(True), on_disk=False) == "on_nas"
+
+
+def test_unverified_absent_is_missing():
+ # Previously None (silently dropped); now surfaced as a lost file.
+ assert browse._file_state_from_record(_Rec(False), on_disk=False) == "missing"
+
+
+def test_run_failed_makes_unverified_on_disk_upload_failed():
+ assert (
+ browse._file_state_from_record(_Rec(False), on_disk=True, run_failed=True)
+ == "upload_failed"
+ )
+
+
+def test_run_blocked_makes_unverified_on_disk_blocked():
+ assert browse._file_state_from_record(_Rec(False), on_disk=True, run_blocked=True) == "blocked"
+
+
+def test_run_failed_does_not_override_synced():
+ assert browse._file_state_from_record(_Rec(True), on_disk=True, run_failed=True) == "synced"
diff --git a/tests/unit/ui/test_components.py b/tests/unit/ui/test_components.py
index 0affde3..0918c4d 100644
--- a/tests/unit/ui/test_components.py
+++ b/tests/unit/ui/test_components.py
@@ -9,8 +9,6 @@
from __future__ import annotations
-import pytest
-
from exlab_wizard.constants import TreeProjectStatus
from exlab_wizard.ui.components import (
bandwidth_schedule_editor,
@@ -97,99 +95,115 @@ def test_override_badge_inactive_uses_muted() -> None:
# ---------------------------------------------------------------------------
-def test_sync_status_pending_uses_muted() -> None:
- props = sync_status_icon.sync_status_props("pending")
- assert props["color_var"] == "--color-muted"
- assert props["icon_name"] == "schedule"
-
+def test_file_sync_view_maps_backend_discriminators() -> None:
+ """The backend discriminator string collapses to the right view (two-icon
+ sync-presence design, 2026-05-30)."""
-def test_sync_status_synced_uses_success() -> None:
- props = sync_status_icon.sync_status_props("synced")
- assert props["color_var"] == "--color-success"
+ fsv = sync_status_icon.file_sync_view
+ FileSyncView = sync_status_icon.FileSyncView
+ assert fsv("synced") is FileSyncView.SYNCED
+ assert fsv("syncing") is FileSyncView.LOCAL_ONLY
+ assert fsv("acquiring") is FileSyncView.LOCAL_ONLY
+ assert fsv("on_nas") is FileSyncView.ON_NAS
+ assert fsv("cleaned") is FileSyncView.ON_NAS
+ assert fsv("cleared") is FileSyncView.ON_NAS
+ assert fsv("failed") is FileSyncView.UPLOAD_FAILED
+ assert fsv("upload_failed") is FileSyncView.UPLOAD_FAILED
+ assert fsv("blocked") is FileSyncView.BLOCKED
+ assert fsv("blocked_by_validation") is FileSyncView.BLOCKED
+ assert fsv("missing") is FileSyncView.MISSING
-def test_sync_status_failed_uses_danger() -> None:
- props = sync_status_icon.sync_status_props("failed")
- assert props["color_var"] == "--color-danger"
+def test_file_sync_view_unknown_and_none_map_to_none_view() -> None:
+ assert sync_status_icon.file_sync_view("invalid") is sync_status_icon.FileSyncView.NONE
+ assert sync_status_icon.file_sync_view(None) is sync_status_icon.FileSyncView.NONE
+ assert sync_status_icon.file_sync_view("") is sync_status_icon.FileSyncView.NONE
-def test_sync_status_blocked_uses_warning() -> None:
- """Blocked-by-validation maps to warning per Frontend §2.1.4."""
+def test_sync_pair_props_local_only_blue_local_gray_nas() -> None:
+ """LOCAL_ONLY: present locally (blue), not yet on the NAS (faded gray)."""
- props = sync_status_icon.sync_status_props("blocked_by_validation")
- assert props["color_var"] == "--color-warning"
+ p = sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.LOCAL_ONLY)
+ assert p["local"]["bg_var"] == "--color-sync-local"
+ assert p["nas"]["bg_var"] == "--color-sync-absent"
+ assert p["nas"]["faded"] is True
-def test_sync_status_override_uses_info() -> None:
- props = sync_status_icon.sync_status_props("override_active")
- assert props["color_var"] == "--color-info"
+def test_sync_pair_props_synced_cached_local_safe_nas() -> None:
+ p = sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.SYNCED)
+ assert p["local"]["bg_var"] == "--color-sync-cached"
+ assert p["nas"]["bg_var"] == "--color-sync-safe"
-def test_sync_status_cleaned_uses_success_with_cloud_icon() -> None:
- """Cleaned status (post-cleanup) reuses the success color with a cloud glyph."""
+def test_sync_pair_props_on_nas_gray_local_safe_nas() -> None:
+ p = sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.ON_NAS)
+ assert p["local"]["bg_var"] == "--color-sync-absent"
+ assert p["nas"]["bg_var"] == "--color-sync-safe"
- props = sync_status_icon.sync_status_props("cleaned")
- assert props["color_var"] == "--color-success"
- assert props["icon_name"] == "cloud_done"
+def test_sync_pair_props_upload_failed_red_nas_with_badge() -> None:
+ """A failed upload paints the NAS cell red with an ``✕`` badge."""
-def test_sync_status_acquiring_uses_muted() -> None:
- """``acquiring`` (new file, still settling) uses the muted token."""
+ p = sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.UPLOAD_FAILED)
+ assert p["local"]["bg_var"] == "--color-sync-local"
+ assert p["nas"]["bg_var"] == "--color-sync-problem"
+ assert p["nas"]["badge"] == "✕"
- props = sync_status_icon.sync_status_props("acquiring")
- assert props["color_var"] == "--color-muted"
- assert props["status"] == "acquiring"
- assert props["icon_name"]
+def test_sync_pair_props_blocked_amber_nas_with_badge() -> None:
+ """A held (blocked-by-validation) upload paints the NAS cell amber + ``!``."""
-def test_sync_status_syncing_uses_info() -> None:
- """``syncing`` (settled, transferring) uses the info token."""
+ p = sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.BLOCKED)
+ assert p["nas"]["bg_var"] == "--color-sync-held"
+ assert p["nas"]["badge"] == "!"
- props = sync_status_icon.sync_status_props("syncing")
- assert props["color_var"] == "--color-info"
- assert props["status"] == "syncing"
- assert props["icon_name"]
+def test_sync_pair_props_missing_red_both_with_badges() -> None:
+ p = sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.MISSING)
+ assert p["local"]["bg_var"] == "--color-sync-problem"
+ assert p["nas"]["bg_var"] == "--color-sync-problem"
+ assert p["local"]["badge"] == "✕" and p["nas"]["badge"] == "✕"
-def test_sync_status_on_nas_uses_muted_cloud() -> None:
- """``on_nas`` (tombstone, local copy cleared) uses a muted cloud glyph."""
- props = sync_status_icon.sync_status_props("on_nas")
- assert props["color_var"] == "--color-muted"
- assert props["status"] == "on_nas"
- assert props["icon_name"] == "cloud"
+def test_sync_pair_props_none_view_returns_none() -> None:
+ """The NONE view renders nothing (no pair props)."""
+ assert sync_status_icon.sync_pair_props(sync_status_icon.FileSyncView.NONE) is None
-def test_sync_status_retrying_with_counter() -> None:
- """Retry counter renders as ``(N/M)`` (Frontend §10.5.1)."""
- props = sync_status_icon.sync_status_props("retrying", retry_n=2, retry_m=5)
- assert props["color_var"] == "--color-info"
- assert props["retry_label"] == "(2/5)"
+def test_sync_rollup_icon_props_safe_local_problem() -> None:
+ """The single rollup icon: green NAS for safe, blue local while not synced,
+ red NAS + badge for a problem."""
+ safe = sync_status_icon.sync_rollup_icon_props(sync_status_icon.FileSyncView.SYNCED)
+ assert safe["svg"] == sync_status_icon.SYNC_NAS_SVG
+ assert safe["bg_var"] == "--color-sync-safe"
+ local = sync_status_icon.sync_rollup_icon_props(sync_status_icon.FileSyncView.LOCAL_ONLY)
+ assert local["svg"] == sync_status_icon.SYNC_LOCAL_SVG
+ assert local["bg_var"] == "--color-sync-local"
+ problem = sync_status_icon.sync_rollup_icon_props(sync_status_icon.FileSyncView.UPLOAD_FAILED)
+ assert problem["bg_var"] == "--color-sync-problem"
+ assert problem["badge"] == "✕"
-def test_sync_status_unknown_raises() -> None:
- """Unknown status values raise."""
- with pytest.raises(ValueError):
- sync_status_icon.sync_status_props("invalid")
+def test_sync_rollup_icon_props_none_view_returns_none() -> None:
+ assert sync_status_icon.sync_rollup_icon_props(sync_status_icon.FileSyncView.NONE) is None
-def test_sync_legend_entries_cover_every_state() -> None:
- """The legend lists one row per known state, each with icon + meaning,
- sourced from _STATUS_TO_PROPS (single source of truth, Phase 5)."""
+def test_sync_legend_entries_cover_every_visible_view() -> None:
+ """The legend lists one row per visible (non-NONE) view, each carrying the
+ view value the swatch renderer rehydrates + a human tooltip."""
entries = sync_status_icon.sync_legend_entries()
- # One row per documented state; each carries the keys the legend renders.
- assert len(entries) == len(sync_status_icon._STATUS_TO_PROPS)
+ assert entries
+ views = {e["view"] for e in entries}
+ assert sync_status_icon.FileSyncView.NONE.value not in views
for entry in entries:
- assert entry["status"]
- assert entry["icon_name"]
- assert entry["color_var"].startswith("--color-")
+ # ``view`` rehydrates to a real (non-NONE) FileSyncView.
+ assert (
+ sync_status_icon.FileSyncView(entry["view"]) is not sync_status_icon.FileSyncView.NONE
+ )
assert entry["tooltip"]
- # The mapping agrees with sync_status_props for a known state.
- synced = next(e for e in entries if e["status"] == "synced")
- assert synced["icon_name"] == "check_circle"
# ---------------------------------------------------------------------------
@@ -817,12 +831,9 @@ def test_tree_run_node_propagates_sync_status() -> None:
assert nodes[0].children[0].children[0].sync_status == "cleaned"
-def test_to_nicegui_nodes_cleared_run_uses_cloud_icon() -> None:
- """A ``cleared`` run row carries the cloud-icon URL and its sync_status.
-
- Operator-free per-file NAS sync design (2026-05-21): the run rollup
- is a :class:`RunSyncState` value; the cloud icon keys off ``cleared``.
- """
+def test_to_nicegui_nodes_cleared_run_uses_nas_icon() -> None:
+ """A ``cleared`` run rolls up (via file_sync_view) to ON_NAS: the green NAS
+ rollup icon (two-icon sync-presence design, 2026-05-30)."""
equipment = tree.EquipmentNode(equipment_id="CONFOCAL_01")
project = tree.ProjectNode(short_id="PROJ-1", name="Cortex Q3")
@@ -838,12 +849,16 @@ def test_to_nicegui_nodes_cleared_run_uses_cloud_icon() -> None:
)
)
run_dict = payload[0]["children"][0]["children"][0]
- assert run_dict["sync_icon"] == tree.SYNC_ICON_CLOUD_URL
- assert run_dict["sync_status"] == "cleared"
+ assert run_dict["sync_icon"] == tree.SYNC_ICON_NAS_URL
+ assert run_dict["sync_bg"] == "--color-sync-safe"
+ assert run_dict["sync_status"] == sync_status_icon.FileSyncView.ON_NAS.value
def test_to_nicegui_nodes_local_run_uses_local_icon() -> None:
- """Any non-``cleaned`` sync status (or unset) maps to the local-icon URL."""
+ """A still-local run (pending / syncing) gets the blue local rollup icon.
+
+ An unrecognised / unset status maps to NONE -> no sync icon is emitted.
+ """
equipment = tree.EquipmentNode(equipment_id="CONFOCAL_01")
project = tree.ProjectNode(short_id="PROJ-1", name="Cortex Q3")
@@ -865,7 +880,31 @@ def test_to_nicegui_nodes_local_run_uses_local_icon() -> None:
)
run_dicts = payload[0]["children"][0]["children"]
assert run_dicts[0]["sync_icon"] == tree.SYNC_ICON_LOCAL_URL
- assert run_dicts[1]["sync_icon"] == tree.SYNC_ICON_LOCAL_URL
+ assert run_dicts[0]["sync_bg"] == "--color-sync-local"
+ # A None / unknown status -> NONE view -> rollup props is None -> no icon.
+ assert "sync_icon" not in run_dicts[1]
+
+
+def test_to_nicegui_nodes_failed_run_carries_problem_badge() -> None:
+ """A failed run rolls up to the red NAS rollup icon + ``✕`` badge."""
+
+ equipment = tree.EquipmentNode(equipment_id="CONFOCAL_01")
+ project = tree.ProjectNode(short_id="PROJ-1", name="Cortex Q3")
+ run = tree.RunNode(
+ directory_name="Run_2026-05-07",
+ run_kind="experimental",
+ sync_status="failed",
+ )
+ payload = tree.to_nicegui_nodes(
+ tree.build_nodes(
+ hierarchy={equipment: {project: [run]}},
+ filters=tree.TreeFilters(),
+ )
+ )
+ run_dict = payload[0]["children"][0]["children"][0]
+ assert run_dict["sync_icon"] == tree.SYNC_ICON_NAS_URL
+ assert run_dict["sync_bg"] == "--color-sync-problem"
+ assert run_dict["sync_badge"] == "✕"
def test_to_nicegui_nodes_equipment_and_project_have_no_sync_icon() -> None:
@@ -873,7 +912,9 @@ def test_to_nicegui_nodes_equipment_and_project_have_no_sync_icon() -> None:
equipment = tree.EquipmentNode(equipment_id="CONFOCAL_01")
project = tree.ProjectNode(short_id="PROJ-1", name="Cortex Q3")
- run = tree.RunNode(directory_name="Run_2026-05-07", run_kind="experimental")
+ run = tree.RunNode(
+ directory_name="Run_2026-05-07", run_kind="experimental", sync_status="synced"
+ )
payload = tree.to_nicegui_nodes(
tree.build_nodes(
hierarchy={equipment: {project: [run]}},
@@ -887,8 +928,7 @@ def test_to_nicegui_nodes_equipment_and_project_have_no_sync_icon() -> None:
def test_to_nicegui_nodes_carries_sync_title() -> None:
"""Run rows carry a friendly ``sync_title`` hover tooltip mirroring the
- icon: cleared -> "on NAS only", anything else -> "data on local disk"
- (Phase 5 sync tooltips)."""
+ rollup icon's meaning (two-icon sync-presence design, 2026-05-30)."""
equipment = tree.EquipmentNode(equipment_id="CONFOCAL_01")
project = tree.ProjectNode(short_id="PROJ-1", name="Cortex Q3")
@@ -896,7 +936,7 @@ def test_to_nicegui_nodes_carries_sync_title() -> None:
directory_name="Run_2026-05-06", run_kind="experimental", sync_status="cleared"
)
local = tree.RunNode(
- directory_name="Run_2026-05-07", run_kind="experimental", sync_status="synced"
+ directory_name="Run_2026-05-07", run_kind="experimental", sync_status="syncing"
)
payload = tree.to_nicegui_nodes(
tree.build_nodes(
@@ -905,7 +945,9 @@ def test_to_nicegui_nodes_carries_sync_title() -> None:
)
)
run_dicts = payload[0]["children"][0]["children"]
+ # cleared -> ON_NAS rollup: "Fully backed up on the NAS".
assert "NAS" in run_dicts[0]["sync_title"]
+ # syncing -> LOCAL_ONLY rollup: "Not fully synced -- local files remain".
assert "local" in run_dicts[1]["sync_title"].lower()
@@ -1037,7 +1079,7 @@ def test_factories_smoke_outside_nicegui() -> None:
assert mode_badge.mode_badge("experimental") is not None
assert test_run_badge.test_run_badge() is not None
assert override_badge.override_badge(active=True) is not None
- assert sync_status_icon.sync_status_icon("synced") is not None
+ assert sync_status_icon.sync_pair_icons(sync_status_icon.file_sync_view("synced")) is not None
# Component factories that return rich payloads:
assert session_progress.session_progress(active_phase=None) is not None
diff --git a/tests/unit/ui/test_factories_smoke.py b/tests/unit/ui/test_factories_smoke.py
index 136309b..59bdd98 100644
--- a/tests/unit/ui/test_factories_smoke.py
+++ b/tests/unit/ui/test_factories_smoke.py
@@ -79,18 +79,27 @@ def test_smoke_override_badge_renders_with_callback() -> None:
def test_smoke_sync_status_icon_renders_each_state() -> None:
+ # Two-icon sync-presence (2026-05-30): each file renders a local+NAS pair;
+ # runs/folders render a single rollup icon. Both no-op safely per view.
for status in (
"pending",
"synced",
"failed",
"blocked_by_validation",
- "override_active",
+ "on_nas",
+ "missing",
):
+ view = sync_status_icon.file_sync_view(status)
with _slot():
- out = sync_status_icon.sync_status_icon(status)
+ out = sync_status_icon.sync_pair_icons(view)
assert out is not None
+ with _slot():
+ out = sync_status_icon.sync_rollup_icon(view)
+ assert out is not None
+ # The NONE view (unknown / None status) still renders a placeholder span.
+ none_view = sync_status_icon.file_sync_view("nope")
with _slot():
- out = sync_status_icon.sync_status_icon("retrying", retry_n=1, retry_m=3)
+ out = sync_status_icon.sync_pair_icons(none_view)
assert out is not None
diff --git a/tests/unit/ui/test_main_page_legend.py b/tests/unit/ui/test_main_page_legend.py
new file mode 100644
index 0000000..3354a16
--- /dev/null
+++ b/tests/unit/ui/test_main_page_legend.py
@@ -0,0 +1,29 @@
+"""Unit tests for the main-page sync-status legend popover.
+
+The legend lists each sync view as a two-icon (local + NAS) swatch + its
+meaning so operators can decode the per-file / per-run sync icons
+(two-icon sync-presence design, 2026-05-30).
+"""
+
+from __future__ import annotations
+
+from exlab_wizard.ui.components.sync_status_icon import FileSyncView, sync_legend_entries
+
+
+def test_legend_entries_have_view_and_tooltip() -> None:
+ """Each legend entry exposes the view value + human tooltip the popover needs."""
+ entries = sync_legend_entries()
+ assert entries, "legend must list at least one sync state"
+ for entry in entries:
+ # ``view`` must be a real FileSyncView value the renderer can rehydrate.
+ assert FileSyncView(entry["view"]) is not FileSyncView.NONE
+ assert entry["tooltip"], "each legend entry needs a tooltip"
+
+
+def test_legend_renders_without_error() -> None:
+ """The legend renderer tolerates a no-NiceGUI context (returns cleanly)."""
+ from exlab_wizard.ui.pages.main import _render_sync_legend
+
+ # Outside a NiceGUI app context the helper must no-op without raising.
+ _render_sync_legend()
+ return None
diff --git a/tests/unit/ui/test_mount.py b/tests/unit/ui/test_mount.py
index ad5836a..176616c 100644
--- a/tests/unit/ui/test_mount.py
+++ b/tests/unit/ui/test_mount.py
@@ -1495,8 +1495,9 @@ def test_build_selected_file_folder_delegates_to_aggregate(
assert payload["name"] == "Runs"
assert payload["path"] == "/d/EQ1/Runs"
assert payload["item_count"] == 3
- # Worst-of rollup: a single failed child dominates.
- assert payload["rollup"] == "failed"
+ # Worst-of rollup: a single failed child dominates. The two-icon view
+ # model maps the "failed" discriminator to the "upload_failed" view value.
+ assert payload["rollup"] == "upload_failed"
def test_build_selected_folder_degrades_on_scan_failure(
diff --git a/tests/unit/ui/test_sync_rollup.py b/tests/unit/ui/test_sync_rollup.py
index a11aae1..d90a40c 100644
--- a/tests/unit/ui/test_sync_rollup.py
+++ b/tests/unit/ui/test_sync_rollup.py
@@ -1,70 +1,36 @@
-"""Unit tests for the folder-level sync-status rollup (Redesign §4.6)."""
-
from __future__ import annotations
-from exlab_wizard.constants import SyncStatus
from exlab_wizard.ui.components.sync_rollup import sync_rollup
-FAILED = SyncStatus.FAILED.value
-BLOCKED = SyncStatus.BLOCKED_BY_VALIDATION.value
-PENDING = SyncStatus.PENDING.value
-SYNCED = SyncStatus.SYNCED.value
-CLEANED = SyncStatus.CLEANED.value
-
-def test_empty_input_returns_none() -> None:
+def test_empty_is_none():
assert sync_rollup([]) is None
-def test_all_none_returns_none() -> None:
- assert sync_rollup([None, None]) is None
-
-
-def test_unknown_values_ignored() -> None:
- assert sync_rollup(["bogus", None]) is None
-
-
-def test_single_status_rolls_up_to_itself() -> None:
- assert sync_rollup([SYNCED]) == SYNCED
-
-
-def test_failed_dominates_everything() -> None:
- assert sync_rollup([SYNCED, CLEANED, PENDING, BLOCKED, FAILED]) == FAILED
-
-
-def test_blocked_beats_pending_synced_cleaned() -> None:
- assert sync_rollup([CLEANED, SYNCED, PENDING, BLOCKED]) == BLOCKED
-
+def test_only_none_is_none():
+ assert sync_rollup([None, "", "bogus"]) is None
-def test_pending_beats_synced_and_cleaned() -> None:
- assert sync_rollup([CLEANED, SYNCED, PENDING]) == PENDING
+def test_all_synced():
+ assert sync_rollup(["synced", "synced"]) == "synced"
-def test_synced_beats_cleaned() -> None:
- assert sync_rollup([CLEANED, SYNCED]) == SYNCED
+def test_on_nas_is_calmest():
+ # on_nas/cleaned/cleared roll up to on_nas only when nothing louder present
+ assert sync_rollup(["cleaned", "on_nas"]) == "on_nas"
-def test_cleaned_is_lowest() -> None:
- assert sync_rollup([CLEANED, CLEANED]) == CLEANED
+def test_local_only_outranks_synced():
+ assert sync_rollup(["synced", "acquiring"]) == "local_only"
-def test_none_and_unknown_excluded_but_known_survives() -> None:
- assert sync_rollup([None, "bogus", SYNCED, None]) == SYNCED
+def test_blocked_outranks_local_only():
+ assert sync_rollup(["acquiring", "blocked"]) == "blocked"
-def test_full_severity_order() -> None:
- """failed > blocked > pending > synced > cleaned: each prefix's rollup is
- its first (highest-severity) element."""
- order = [FAILED, BLOCKED, PENDING, SYNCED, CLEANED]
- for i in range(len(order)):
- assert sync_rollup(order[i:]) == order[i]
+def test_upload_failed_outranks_blocked():
+ assert sync_rollup(["blocked", "failed"]) == "upload_failed"
-def test_accepts_syncstatus_enum_members_directly() -> None:
- """SyncStatus is a StrEnum, so passing members (not .value strings) works.
- Guards the contract that a future caller can feed enum instances from
- ``FileListEntry.sync_status`` without first calling ``.value``.
- """
- assert sync_rollup([SyncStatus.SYNCED, SyncStatus.FAILED]) == FAILED
- assert sync_rollup([SyncStatus.CLEANED, "failed"]) == FAILED
+def test_missing_is_worst():
+ assert sync_rollup(["failed", "missing", "synced"]) == "missing"
diff --git a/tests/unit/ui/test_sync_status_icon.py b/tests/unit/ui/test_sync_status_icon.py
index 1575775..bf0a515 100644
--- a/tests/unit/ui/test_sync_status_icon.py
+++ b/tests/unit/ui/test_sync_status_icon.py
@@ -1,51 +1,103 @@
-"""Unit tests for sync_status_icon tolerant mode (Redesign §4.5, OQ-5)."""
-
from __future__ import annotations
import pytest
-from exlab_wizard.constants import SyncStatus
-from exlab_wizard.ui.components.sync_status_icon import sync_status_props
+from exlab_wizard.ui.components.sync_status_icon import (
+ FileSyncView,
+ file_sync_view,
+ sync_pair_props,
+ sync_rollup_icon_props,
+)
+
+SYNC_LOCAL = "/assets/sync_local.svg"
+SYNC_NAS = "/assets/sync_nas.svg"
+
+
+@pytest.mark.parametrize(
+ "status, expected",
+ [
+ ("synced", FileSyncView.SYNCED),
+ ("acquiring", FileSyncView.LOCAL_ONLY),
+ ("syncing", FileSyncView.LOCAL_ONLY),
+ ("on_nas", FileSyncView.ON_NAS),
+ ("cleaned", FileSyncView.ON_NAS),
+ ("cleared", FileSyncView.ON_NAS),
+ ("upload_failed", FileSyncView.UPLOAD_FAILED),
+ ("failed", FileSyncView.UPLOAD_FAILED),
+ ("blocked", FileSyncView.BLOCKED),
+ ("blocked_by_validation", FileSyncView.BLOCKED),
+ ("missing", FileSyncView.MISSING),
+ (None, FileSyncView.NONE),
+ ("", FileSyncView.NONE),
+ ("bogus", FileSyncView.NONE),
+ ],
+)
+def test_file_sync_view_mapping(status, expected):
+ assert file_sync_view(status) is expected
+
+
+def test_pair_props_local_only_blue_local_gray_nas():
+ p = sync_pair_props(FileSyncView.LOCAL_ONLY)
+ assert p["local"]["svg"] == SYNC_LOCAL
+ assert p["local"]["bg_var"] == "--color-sync-local"
+ assert p["nas"]["svg"] == SYNC_NAS
+ assert p["nas"]["bg_var"] == "--color-sync-absent"
+ assert p["nas"]["faded"] is True
+
+
+def test_pair_props_synced_faded_blue_local_green_nas():
+ p = sync_pair_props(FileSyncView.SYNCED)
+ assert p["local"]["bg_var"] == "--color-sync-cached"
+ assert p["nas"]["bg_var"] == "--color-sync-safe"
+
+
+def test_pair_props_on_nas_gray_local_green_nas():
+ p = sync_pair_props(FileSyncView.ON_NAS)
+ assert p["local"]["bg_var"] == "--color-sync-absent"
+ assert p["nas"]["bg_var"] == "--color-sync-safe"
-def test_strict_default_still_raises_on_unknown() -> None:
- """The original contract is preserved: strict (default) raises."""
- with pytest.raises(ValueError, match="unknown sync status"):
- sync_status_props("bogus")
+def test_pair_props_upload_failed_red_nas_badge():
+ p = sync_pair_props(FileSyncView.UPLOAD_FAILED)
+ assert p["local"]["bg_var"] == "--color-sync-local"
+ assert p["nas"]["bg_var"] == "--color-sync-problem"
+ assert p["nas"]["badge"] == "✕"
-def test_strict_explicit_raises_on_unknown() -> None:
- with pytest.raises(ValueError, match="unknown sync status"):
- sync_status_props("bogus", strict=True)
+def test_pair_props_blocked_amber_nas_badge():
+ p = sync_pair_props(FileSyncView.BLOCKED)
+ assert p["nas"]["bg_var"] == "--color-sync-held"
+ assert p["nas"]["badge"] == "!"
-def test_tolerant_mode_returns_neutral_for_unknown() -> None:
- """strict=False yields neutral props (muted dash) instead of raising."""
- props = sync_status_props("bogus", strict=False)
- assert props["icon_name"] == ""
- assert props["color_var"] == "--color-muted"
- assert props["tooltip"] == ""
- assert props["retry_label"] == ""
- assert props["status"] == "bogus"
+def test_pair_props_missing_red_both_badges():
+ p = sync_pair_props(FileSyncView.MISSING)
+ assert p["local"]["bg_var"] == "--color-sync-problem"
+ assert p["nas"]["bg_var"] == "--color-sync-problem"
+ assert p["local"]["badge"] == "✕" and p["nas"]["badge"] == "✕"
-def test_tolerant_mode_handles_none() -> None:
- """strict=False accepts None (optional per-file / per-folder status)."""
- props = sync_status_props(None, strict=False)
- assert props["icon_name"] == ""
- assert props["color_var"] == "--color-muted"
- assert props["status"] == ""
+def test_pair_props_none_returns_none():
+ assert sync_pair_props(FileSyncView.NONE) is None
-def test_tolerant_mode_still_returns_real_props_for_known() -> None:
- """A recognised status is unaffected by strict=False."""
- props = sync_status_props(SyncStatus.SYNCED, strict=False)
- assert props["icon_name"]
- assert props["status"] == SyncStatus.SYNCED.value
+@pytest.mark.parametrize(
+ "view, svg, bg, badge",
+ [
+ (FileSyncView.SYNCED, SYNC_NAS, "--color-sync-safe", ""),
+ (FileSyncView.ON_NAS, SYNC_NAS, "--color-sync-safe", ""),
+ (FileSyncView.LOCAL_ONLY, SYNC_LOCAL, "--color-sync-local", ""),
+ (FileSyncView.BLOCKED, SYNC_NAS, "--color-sync-held", "!"),
+ (FileSyncView.UPLOAD_FAILED, SYNC_NAS, "--color-sync-problem", "✕"),
+ (FileSyncView.MISSING, SYNC_NAS, "--color-sync-problem", "✕"),
+ ],
+)
+def test_rollup_icon_props(view, svg, bg, badge):
+ p = sync_rollup_icon_props(view)
+ assert p["svg"] == svg
+ assert p["bg_var"] == bg
+ assert p["badge"] == badge
-def test_neutral_and_normal_props_share_key_shape() -> None:
- """Both return paths expose the same keys so callers treat them alike."""
- normal = sync_status_props(SyncStatus.SYNCED)
- neutral = sync_status_props(None, strict=False)
- assert set(normal) == set(neutral)
+def test_rollup_icon_props_none():
+ assert sync_rollup_icon_props(FileSyncView.NONE) is None
diff --git a/tests/unit/ui/test_theme.py b/tests/unit/ui/test_theme.py
index e511f9e..427bd71 100644
--- a/tests/unit/ui/test_theme.py
+++ b/tests/unit/ui/test_theme.py
@@ -119,6 +119,21 @@ def test_root_css_contains_semantic_aliases() -> None:
assert "--color-danger" in css
+def test_root_css_emits_sync_tokens() -> None:
+ """The two-icon sync-presence tokens are emitted into the :root block."""
+
+ css = theme.build_root_css()
+ for var in (
+ "--color-sync-local",
+ "--color-sync-cached",
+ "--color-sync-safe",
+ "--color-sync-absent",
+ "--color-sync-problem",
+ "--color-sync-held",
+ ):
+ assert var in css, f"{var} missing from :root block"
+
+
def test_root_css_contains_typography_tokens() -> None:
css = theme.build_root_css()
assert "IBM Plex Sans" in css
|