diff --git a/prototype/tui-ia/.gitignore b/prototype/tui-ia/.gitignore
new file mode 100644
index 0000000..a230a78
--- /dev/null
+++ b/prototype/tui-ia/.gitignore
@@ -0,0 +1,2 @@
+.venv/
+__pycache__/
diff --git a/prototype/tui-ia/README.md b/prototype/tui-ia/README.md
new file mode 100644
index 0000000..cab38aa
--- /dev/null
+++ b/prototype/tui-ia/README.md
@@ -0,0 +1,46 @@
+# Fenris TUI information-architecture PROTOTYPE (throwaway)
+
+**This is throwaway code answering [ticket #3](https://git.bongbetic.com/xavierk/Fenris/issues/3).** It is not the redesign, reads nothing real, and never ships. Branch: `prototype/tui-information-architecture`.
+
+## Question
+
+What screen hierarchy, navigation, and action model makes Fenris's projection contract (ADR 0002 §13), the four separate service facts (ADR 0003 §8), warming-up, unexplained gaps, and changing habits understandable in a keyboard-first terminal?
+
+## Run (one command)
+
+```sh
+./run
+```
+
+(creates `.venv` and installs `textual` on first use)
+
+## What to flip through
+
+**Variants (← / →)** — three structurally different answers, not restylings:
+
+| Key | Variant | Idea |
+|-----|---------|------|
+| A | **Panes** | everything on one dense screen, btop-style; no navigation, panes are zones |
+| B | **Pages** | persistent three-fact header (lifespan · confidence · freshness) + pages 1–5 |
+| C | **Ledger** | one scrolling document in reading order, headline sentence first |
+
+**States (s)** — same variants, six shapes of the contract:
+
+1. steady · Supported (with one unexplained 3-hour gap)
+2. warming up · Limited (11 of 14 days)
+3. habit changed · Limited (regime 6 days old, scenario spread visible)
+4. stale · Supported→Limited (last collect FAILED, 61 h old)
+5. no baseline · Unavailable (wear too coarse to imply endurance)
+6. paused · Limited (period closed by deliberate disable)
+
+**Actions** — `p` pause (asks confirmation) · `r` resume (doesn't) · `c` collect now (synchronous outcome). Each suspends the TUI and runs `polkit_stub.py` on the real terminal: this validates the tty passthrough ADR 0003 requires for the polkit prompt. Results land in the tty log (variant B service page; every variant's log is the same list).
+
+## What to react to
+
+- Which variant's hierarchy matches how you think about the drive? (Mixing — "header from B, density of A" — is a valid answer and the point.)
+- Are the four service facts separable at a glance?
+- Do confidence states + contributing facts read as evidence, not as a percentage?
+- Is the pause confirmation the right amount of friction?
+- Did the polkit tty stub actually prompt in your terminal? (That's the mechanism check.)
+
+`screenshots/` holds headless captures (`smoke_test.py`) of each variant at 80×24 and 140×40.
diff --git a/prototype/tui-ia/polkit_stub.py b/prototype/tui-ia/polkit_stub.py
new file mode 100755
index 0000000..a87bb25
--- /dev/null
+++ b/prototype/tui-ia/polkit_stub.py
@@ -0,0 +1,29 @@
+#!/usr/bin/env python3
+"""STUB polkit-agent stand-in for the Fenris TUI prototype (throwaway).
+
+Runs attached to the real terminal while the Textual app is suspended, exactly
+where the platform polkit agent would prompt for `com.bongbetic.fenris.monitor`.
+Accepts any password; the point is validating tty passthrough, not auth.
+"""
+import getpass
+import sys
+import time
+
+op = sys.argv[1] if len(sys.argv) > 1 else "unknown"
+print("=" * 56)
+print(" polkit STUB · com.bongbetic.fenris.monitor")
+print(f" operation: {op}")
+print(" Authentication required to manage Fenris monitoring")
+print("=" * 56)
+try:
+ getpass.getpass(" password (anything works): ")
+except (EOFError, KeyboardInterrupt):
+ print("\n(cancelled — operation not performed)")
+ sys.exit(1)
+time.sleep(0.6) # pretend systemctl + monitoring-period bookkeeping
+print(f" fenris-monitor {op}: done")
+try:
+ input(" [press Enter to return to the TUI] ")
+except EOFError:
+ pass
+sys.exit(0)
diff --git a/prototype/tui-ia/requirements.txt b/prototype/tui-ia/requirements.txt
new file mode 100644
index 0000000..7f46a43
--- /dev/null
+++ b/prototype/tui-ia/requirements.txt
@@ -0,0 +1 @@
+textual>=0.60
diff --git a/prototype/tui-ia/run b/prototype/tui-ia/run
new file mode 100755
index 0000000..8179479
--- /dev/null
+++ b/prototype/tui-ia/run
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+# PROTOTYPE runner — throwaway, see README.md
+set -euo pipefail
+cd "$(dirname "$0")"
+if [ ! -x .venv/bin/python ]; then
+ if command -v uv >/dev/null 2>&1; then
+ uv venv -q .venv
+ uv pip install -q --python .venv/bin/python -r requirements.txt
+ else
+ python3 -m venv .venv
+ .venv/bin/pip -q install -r requirements.txt
+ fi
+fi
+exec .venv/bin/python tui_prototype.py
diff --git a/prototype/tui-ia/screenshots/vA_140x40_steady.svg b/prototype/tui-ia/screenshots/vA_140x40_steady.svg
new file mode 100644
index 0000000..ba945d6
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vA_140x40_steady.svg
@@ -0,0 +1,224 @@
+
diff --git a/prototype/tui-ia/screenshots/vA_80x24_steady.svg b/prototype/tui-ia/screenshots/vA_80x24_steady.svg
new file mode 100644
index 0000000..c31a428
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vA_80x24_steady.svg
@@ -0,0 +1,157 @@
+
diff --git a/prototype/tui-ia/screenshots/vB_140x40_changed.svg b/prototype/tui-ia/screenshots/vB_140x40_changed.svg
new file mode 100644
index 0000000..80ee7f7
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vB_140x40_changed.svg
@@ -0,0 +1,226 @@
+
diff --git a/prototype/tui-ia/screenshots/vB_140x40_nobaseline.svg b/prototype/tui-ia/screenshots/vB_140x40_nobaseline.svg
new file mode 100644
index 0000000..64d4f93
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vB_140x40_nobaseline.svg
@@ -0,0 +1,225 @@
+
diff --git a/prototype/tui-ia/screenshots/vB_140x40_paused.svg b/prototype/tui-ia/screenshots/vB_140x40_paused.svg
new file mode 100644
index 0000000..c6c134f
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vB_140x40_paused.svg
@@ -0,0 +1,227 @@
+
diff --git a/prototype/tui-ia/screenshots/vB_140x40_stale.svg b/prototype/tui-ia/screenshots/vB_140x40_stale.svg
new file mode 100644
index 0000000..e4471d2
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vB_140x40_stale.svg
@@ -0,0 +1,226 @@
+
diff --git a/prototype/tui-ia/screenshots/vB_140x40_steady.svg b/prototype/tui-ia/screenshots/vB_140x40_steady.svg
new file mode 100644
index 0000000..236056e
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vB_140x40_steady.svg
@@ -0,0 +1,226 @@
+
diff --git a/prototype/tui-ia/screenshots/vB_140x40_warming.svg b/prototype/tui-ia/screenshots/vB_140x40_warming.svg
new file mode 100644
index 0000000..1e43ea9
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vB_140x40_warming.svg
@@ -0,0 +1,226 @@
+
diff --git a/prototype/tui-ia/screenshots/vB_80x24_changed.svg b/prototype/tui-ia/screenshots/vB_80x24_changed.svg
new file mode 100644
index 0000000..8538a6b
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vB_80x24_changed.svg
@@ -0,0 +1,160 @@
+
diff --git a/prototype/tui-ia/screenshots/vB_80x24_nobaseline.svg b/prototype/tui-ia/screenshots/vB_80x24_nobaseline.svg
new file mode 100644
index 0000000..3c314f1
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vB_80x24_nobaseline.svg
@@ -0,0 +1,159 @@
+
diff --git a/prototype/tui-ia/screenshots/vB_80x24_paused.svg b/prototype/tui-ia/screenshots/vB_80x24_paused.svg
new file mode 100644
index 0000000..6bb0ed0
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vB_80x24_paused.svg
@@ -0,0 +1,161 @@
+
diff --git a/prototype/tui-ia/screenshots/vB_80x24_stale.svg b/prototype/tui-ia/screenshots/vB_80x24_stale.svg
new file mode 100644
index 0000000..4f2b7bb
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vB_80x24_stale.svg
@@ -0,0 +1,162 @@
+
diff --git a/prototype/tui-ia/screenshots/vB_80x24_steady.svg b/prototype/tui-ia/screenshots/vB_80x24_steady.svg
new file mode 100644
index 0000000..3806dac
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vB_80x24_steady.svg
@@ -0,0 +1,160 @@
+
diff --git a/prototype/tui-ia/screenshots/vB_80x24_warming.svg b/prototype/tui-ia/screenshots/vB_80x24_warming.svg
new file mode 100644
index 0000000..4e0fc66
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vB_80x24_warming.svg
@@ -0,0 +1,161 @@
+
diff --git a/prototype/tui-ia/screenshots/vC_140x40_steady.svg b/prototype/tui-ia/screenshots/vC_140x40_steady.svg
new file mode 100644
index 0000000..236056e
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vC_140x40_steady.svg
@@ -0,0 +1,226 @@
+
diff --git a/prototype/tui-ia/screenshots/vC_80x24_steady.svg b/prototype/tui-ia/screenshots/vC_80x24_steady.svg
new file mode 100644
index 0000000..3806dac
--- /dev/null
+++ b/prototype/tui-ia/screenshots/vC_80x24_steady.svg
@@ -0,0 +1,160 @@
+
diff --git a/prototype/tui-ia/smoke_test.py b/prototype/tui-ia/smoke_test.py
new file mode 100644
index 0000000..6be34de
--- /dev/null
+++ b/prototype/tui-ia/smoke_test.py
@@ -0,0 +1,67 @@
+# Headless smoke test for the prototype: drives every variant × state through
+# Textual's test pilot, exports SVG screenshots, and asserts contract strings render.
+import asyncio, inspect, os, sys
+os.environ["FENRIS_PROTOTYPE_NO_TTY"] = "1"
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from tui_prototype import FenrisPrototypeApp, VARIANTS
+
+OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "screenshots")
+os.makedirs(OUT, exist_ok=True)
+
+async def snap(app, name):
+ r = app.export_screenshot()
+ if inspect.isawaitable(r):
+ r = await r
+ with open(os.path.join(OUT, name + ".svg"), "w") as f:
+ f.write(r)
+
+async def main():
+ checks = []
+ for size in [(80, 24), (140, 40)]:
+ app = FenrisPrototypeApp()
+ async with app.run_test(size=size) as pilot:
+ await pilot.pause()
+ for i, (key, _name) in enumerate(VARIANTS):
+ if i:
+ await pilot.press("right"); await pilot.pause()
+ tag = f"v{key}_{size[0]}x{size[1]}"
+ await snap(app, tag + "_steady")
+ # rotate states on variant B
+ await pilot.press("left"); await pilot.pause() # back to A
+ await pilot.press("right"); await pilot.pause() # B
+ for st in ["warming", "changed", "stale", "nobaseline", "paused", "steady"]:
+ await pilot.press("s"); await pilot.pause()
+ await snap(app, f"vB_{size[0]}x{size[1]}_{st}")
+ # pages on B
+ for k in ["1", "2", "3", "4", "5"]:
+ await pilot.press(k); await pilot.pause()
+ body = app.query_one("#vB-service").content
+ checks.append(("service facts", "boot enablement" in str(body) and "freshness" in str(body)))
+ # pause flow: confirm modal, y, stub skipped headless -> state becomes paused? (skip branch)
+ await pilot.press("4"); await pilot.pause()
+ await pilot.press("p"); await pilot.pause()
+ await pilot.press("n"); await pilot.pause() # cancel, no state change
+ head = str(app.query_one("#vB-overview").content)
+ checks.append(("confidence renders", "Projection confidence" in head))
+ # disclosure modal
+ await pilot.press("d"); await pilot.pause()
+ await pilot.press("escape"); await pilot.pause()
+ steady = FenrisPrototypeApp()
+ async with steady.run_test(size=(140, 40)) as pilot:
+ await pilot.pause()
+ doc = str(steady.query_one("#vC-doc").content)
+ checks += [
+ ("headline", "Usage-adjusted theoretical lifespan" in doc),
+ ("scenario range", "Scenario range" in doc),
+ ("wear line", "Vendor wear" in doc),
+ ("gap marker", "unexplained gap" in doc),
+ ]
+ failed = [n for n, ok in checks if not ok]
+ print("CHECKS:", "all ok" if not failed else f"FAILED: {failed}")
+ for n, ok in checks:
+ print(f" {'ok ' if ok else 'FAIL'} {n}")
+ if failed:
+ sys.exit(1)
+
+asyncio.run(main())
diff --git a/prototype/tui-ia/tui_prototype.py b/prototype/tui-ia/tui_prototype.py
new file mode 100644
index 0000000..767006c
--- /dev/null
+++ b/prototype/tui-ia/tui_prototype.py
@@ -0,0 +1,573 @@
+# PROTOTYPE (THROWAWAY) — Fenris TUI information-architecture prototype.
+# Question (ticket #3): what screen hierarchy, navigation, and action model makes
+# the projection contract (ADR 0002 §13) and the service facts (ADR 0003 §8)
+# understandable in a keyboard-first terminal?
+# Plan: three structurally different variants (A Panes / B Pages / C Ledger),
+# switchable live with ←/→, plus a scenario rotator (s) that drives the same
+# variants through warming-up / habit-change / stale / paused / no-baseline states.
+# Data is synthetic but modeled on the real drive (Micron 2400 512GB, ~101 TB
+# written, 50 % used). Nothing here reads or writes the observation store.
+
+from __future__ import annotations
+
+import os
+import random
+import subprocess
+import sys
+from pathlib import Path
+
+from textual.app import App, ComposeResult
+from textual.binding import Binding
+from textual.containers import Horizontal, Vertical, VerticalScroll
+from textual.screen import ModalScreen
+from textual.widgets import Static
+
+STUB = Path(__file__).with_name("polkit_stub.py")
+
+# ---------------------------------------------------------------- fake data
+
+DRIVE = {
+ "model": "Micron_2400_MTFDKBA512QFM",
+ "capacity": "512 GB",
+ "percentage_used": 50,
+ "written_tb": 101.1,
+ "temp": 38,
+ "spare": 100,
+ "media_errors": 0,
+ "power_on_hours": 11126,
+ "power_cycles": 5188,
+ "unsafe_shutdowns": 142,
+}
+
+BASELINE = {"tb": 220.0, "source": "Micron 2400 datasheet (PROTOTYPE placeholder provenance)"}
+
+
+def _years(gb_per_day: float) -> str:
+ days = (BASELINE["tb"] - DRIVE["written_tb"]) * 1000.0 / gb_per_day
+ if days >= 365.25:
+ return f"~{days / 365.25:.1f} years"
+ return f"~{days:.0f} days"
+
+
+def _days_hist(n: int, rate: float, seed: int) -> list[float]:
+ rng = random.Random(seed)
+ return [max(2.0, rate + rng.gauss(0, rate * 0.18)) for _ in range(n)]
+
+
+def _sparkline(vals: list[float], width: int = 40) -> str:
+ if not vals:
+ return ""
+ mx = max(vals) or 1.0
+ blocks = " ▁▂▃▄▅▆▇█"
+ step = max(1, len(vals) // width or 1)
+ picked = vals[-width * step:][::step][-width:]
+ return "".join(blocks[min(len(blocks) - 1, int(v / mx * (len(blocks) - 1)) + (1 if v > 0 else 0))] for v in picked)
+
+
+def _habit_bar(active: float, idle: float, off: float, unknown: float, width: int = 44) -> str:
+ total = active + idle + off + unknown or 1.0
+ segs = [("active", active, "green"), ("idle", idle, "yellow"), ("off", off, "cyan"), ("?", unknown, "magenta")]
+ out = []
+ for label, v, color in segs:
+ n = max(1 if v else 0, round(v / total * width))
+ out.append((f"[{color}]{label[0] * n}[/{color}]"))
+ legend = f" active {active / total:.0%} · idle {idle / total:.0%} · powered-off {off / total:.0%} · unknown {unknown / total:.0%}"
+ return "".join(out) + "\n " + legend
+
+
+def build_scenarios() -> list[dict]:
+ """Six states of the ADR-0002 §13 contract + ADR-0003 service facts."""
+ hist_steady = _days_hist(34, 55, seed=7)
+ hist_warm = _days_hist(11, 62, seed=11)
+ hist_changed = _days_hist(35, 70, seed=3) + _days_hist(6, 130, seed=4)
+ hist_stale = _days_hist(34, 55, seed=7)
+ hist_nobase = _days_hist(26, 48, seed=5)
+ hist_paused = _days_hist(28, 51, seed=9)
+
+ def svc(enabled, timer, outcome, freshness, period):
+ return {"enabled": enabled, "timer": timer, "outcome": outcome, "freshness": freshness, "period": period}
+
+ return [
+ {
+ "key": "steady",
+ "name": "steady · Supported",
+ "days": 34,
+ "history": hist_steady,
+ "rate": 55,
+ "headline": _years(55),
+ "confidence": "Supported",
+ "facts": [
+ "34 qualifying days (≥ 14), coverage 92 %",
+ "7- and 28-day rates within a factor of 2",
+ "no single day ≥ 50 % of trailing 28-day writes",
+ "1 day with 3 unknown hours — unexplained gap inside the period",
+ ],
+ "horizons": [("last 7 days", _years(48)), ("last 28 days", _years(57))],
+ "habit": (0.34, 0.52, 0.10, 0.04),
+ "gap_days": {-9},
+ "habit_change": None,
+ "service": svc(True, True, "ok · 3 min ago (5 min cadence)", "fresh · newest sample 3 min old", "open since Aug 3 · deliberate disables: 0"),
+ },
+ {
+ "key": "warming",
+ "name": "warming up · Limited",
+ "days": 11,
+ "history": hist_warm,
+ "rate": 62,
+ "headline": _years(62),
+ "confidence": "Limited",
+ "facts": [
+ "warming up: 11 of 14 qualifying days",
+ "coverage 84 %",
+ ],
+ "horizons": [("last 7 days", _years(66))],
+ "habit": (0.38, 0.46, 0.12, 0.04),
+ "gap_days": set(),
+ "habit_change": None,
+ "service": svc(True, True, "ok · 2 min ago", "fresh · newest sample 2 min old", "open since Aug 20"),
+ },
+ {
+ "key": "changed",
+ "name": "habit changed · Limited",
+ "days": 41,
+ "history": hist_changed,
+ "rate": 130,
+ "headline": _years(130),
+ "confidence": "Limited",
+ "facts": [
+ "usage habit changed 6 days ago — new regime adopted",
+ "regime 6 days old (young — Limited evidence)",
+ "coverage 88 %",
+ "vendor wear line disagrees ×2.1 with observed write rate",
+ ],
+ "horizons": [("last 7 days", _years(128)), ("last 28 days", _years(71))],
+ "habit": (0.47, 0.41, 0.08, 0.04),
+ "gap_days": set(),
+ "habit_change": 6,
+ "service": svc(True, True, "ok · 4 min ago", "fresh · newest sample 4 min old", "open since Jul 15 · habit change noted Aug 26"),
+ },
+ {
+ "key": "stale",
+ "name": "stale · Supported→Limited",
+ "days": 34,
+ "history": hist_stale,
+ "rate": 55,
+ "headline": _years(55),
+ "confidence": "Limited",
+ "facts": [
+ "newest evidence 61 h old (missed — older than 48 h)",
+ "34 qualifying days, coverage 92 %",
+ ],
+ "horizons": [("last 7 days", _years(48)), ("last 28 days", _years(57))],
+ "habit": (0.34, 0.52, 0.10, 0.04),
+ "gap_days": {-9},
+ "habit_change": None,
+ "service": svc(True, True, "FAILED · exit 1 · 61 h ago (device busy)", "missed · newest sample 61 h old", "open since Aug 3 · gap is unknown time inside the period"),
+ },
+ {
+ "key": "nobaseline",
+ "name": "no baseline · Unavailable",
+ "days": 26,
+ "history": hist_nobase,
+ "rate": 48,
+ "headline": None,
+ "confidence": "Unavailable",
+ "facts": [
+ "no verified rated-TBW override on record",
+ "vendor wear estimate too coarse to imply endurance (1 of ≥ 2 Percentage Used increments)",
+ ],
+ "horizons": [("last 7 days", "48 GB/day (no baseline to project)"), ("last 28 days", "44 GB/day (no baseline to project)")],
+ "habit": (0.31, 0.55, 0.10, 0.04),
+ "gap_days": set(),
+ "habit_change": None,
+ "service": svc(True, True, "ok · 3 min ago", "fresh · newest sample 3 min old", "open since Aug 8"),
+ },
+ {
+ "key": "paused",
+ "name": "paused · Limited",
+ "days": 28,
+ "history": hist_paused,
+ "rate": 51,
+ "headline": _years(51),
+ "confidence": "Limited",
+ "facts": [
+ "no open monitoring period — paused 2 days ago",
+ "paused time is excluded from the usage habit by your choice",
+ ],
+ "horizons": [("last 7 days (pre-pause)", _years(53))],
+ "habit": (0.33, 0.51, 0.12, 0.04),
+ "gap_days": set(),
+ "habit_change": None,
+ "service": svc(False, False, "ok · 2 d ago (period closed by pause)", "stale · monitoring paused 2 days ago", "closed 2 days ago · end cause: deliberate disable"),
+ },
+ ]
+
+
+# ------------------------------------------------------------- renderers
+
+
+def headline_block(sc: dict) -> str:
+ if sc["headline"]:
+ return (
+ f"[bold]Usage-adjusted theoretical lifespan: [white]{sc['headline']}[/white][/bold]\n"
+ f" if current habits continue · sustained regime: {sc['days'] if not sc['habit_change'] else sc['habit_change']} days at {sc['rate']} GB/day"
+ )
+ return "[bold]Usage-adjusted theoretical lifespan: [red]no projection from this history yet[/red][/bold]\n " + "\n ".join(sc["facts"][:2])
+
+
+def confidence_block(sc: dict) -> str:
+ color = {"Supported": "green", "Limited": "yellow", "Unavailable": "red"}[sc["confidence"]]
+ lines = [f"[bold]Projection confidence: [{color}]{sc['confidence']}[/{color}][/bold]"]
+ lines += [f" · {f}" for f in sc["facts"]]
+ return "\n".join(lines)
+
+
+def horizon_block(sc: dict) -> str:
+ rows = [f" {label:<28} → [cyan]{value}[/cyan]" for label, value in sc["horizons"]]
+ return "[bold]Scenario range[/bold] (same endurance, other horizons)\n" + "\n".join(rows)
+
+
+def wear_line(sc: dict) -> str:
+ return f"Vendor wear: {DRIVE['percentage_used']} % used · {DRIVE['written_tb']} TB of {BASELINE['tb']:.0f} TB rated (context, not a second projection)"
+
+
+def disclosure_lines() -> list[str]:
+ return [
+ "Rated endurance is a vendor guarantee boundary, not a predicted failure date.",
+ "Powered-off time counts toward the projection while monitoring is enabled; deliberately paused time does not.",
+ "The scenario range is a spread of horizons, not a statistical interval.",
+ f"Baseline provenance: {BASELINE['source']}.",
+ ]
+
+
+def history_block(sc: dict, width: int = 60) -> str:
+ vals = sc["history"]
+ spark = _sparkline(vals, width)
+ marks = [" "] * len(spark)
+ if sc["habit_change"]:
+ idx = len(spark) - max(1, round(sc["habit_change"] / max(1, len(vals) // width or 1)))
+ if 0 <= idx < len(marks):
+ marks[idx] = "▲"
+ for g in sc["gap_days"]:
+ idx = len(spark) + g - 1
+ if 0 <= idx < len(marks) and marks[idx] == " ":
+ marks[idx] = "?"
+ head = f"[bold]Usage history[/bold] · {sc['days']} days · {min(vals):.0f}–{max(vals):.0f} GB/day"
+ bar = f"[green]{spark}[/green]"
+ markline = "".join(marks)
+ a, i, o, u = sc["habit"]
+ return head + "\n " + bar + "\n " + markline + " ▲ habit change · ? unexplained gap\n " + _habit_bar(a, i, o, u)
+
+
+def health_block() -> str:
+ d = DRIVE
+ rows = [
+ f"[bold]Drive health[/bold] · {d['model']}",
+ f" temperature {d['temp']} °C · spare {d['spare']} %",
+ f" media errors {d['media_errors']} · unsafe shutdowns {d['unsafe_shutdowns']}",
+ f" power-on {d['power_on_hours']:,} h · {d['power_cycles']:,} cycles · {d['capacity']}",
+ ]
+ return "\n".join(rows)
+
+
+def service_block(sc: dict) -> str:
+ s = sc["service"]
+ en = "[green]enabled[/green]" if s["enabled"] else "[red]disabled[/red]"
+ tm = "[green]timer active[/green]" if s["timer"] else "[red]timer inactive[/red]"
+ return "\n".join([
+ "[bold]Service[/bold] (four separate facts)",
+ f" boot enablement: {en}",
+ f" runtime activity: {tm}",
+ f" last collect outcome: {s['outcome']}",
+ f" freshness: {s['freshness']}",
+ f" monitoring period: {s['period']}",
+ ])
+
+
+def settings_block() -> str:
+ return "\n".join([
+ "[bold]Settings[/bold] (read view · edit via CLI / drop-ins)",
+ " device: /dev/disk/by-id/nvme-Micron_2400_MTFDKBA512QFM_2341ABCD",
+ " cadence: every 5 min (systemd drop-in to change) · raw retention 14 d",
+ f" endurance baseline: {BASELINE['tb']:.0f} TB rated — {BASELINE['source']}",
+ ])
+
+
+def actions_legend(paused: bool) -> str:
+ resume = "[bold green]r resume[/bold green]" if paused else "r resume"
+ pause = "[bold yellow]p pause[/bold yellow]" if not paused else "p pause"
+ return f"{pause} (asks) · {resume} · c collect now · s state · ←/→ variant · d disclosures · q quit"
+
+
+VARIANTS = [
+ ("A", "Panes — one dense screen"),
+ ("B", "Pages — persistent header + tabbed body"),
+ ("C", "Ledger — scrolling narrative document"),
+]
+
+# ----------------------------------------------------------------- screens
+
+
+class ConfirmPause(ModalScreen[bool]):
+ """Pause asks for confirmation (ADR 0003 §8)."""
+
+ BINDINGS = [
+ Binding("y", "yes", "Pause"),
+ Binding("n", "no", "Cancel"),
+ Binding("escape", "no", "Cancel", show=False),
+ ]
+
+ def compose(self) -> ComposeResult:
+ yield Static(
+ "[bold]Pause monitoring?[/bold]\n\n"
+ "This closes the current monitoring period.\n"
+ "Paused time is [bold]excluded[/bold] from your usage habit\n"
+ "(powered-off time would still count).\n\n"
+ "[dim]y pause · n cancel[/dim]",
+ id="confirm-text",
+ )
+
+ def action_yes(self) -> None:
+ self.dismiss(True)
+
+ def action_no(self) -> None:
+ self.dismiss(False)
+
+
+class Disclosures(ModalScreen):
+ BINDINGS = [Binding("escape", "close", "Close"), Binding("d", "close", "Close")]
+
+ def compose(self) -> ComposeResult:
+ yield VerticalScroll(Static("\n".join(["[bold]Disclosures[/bold]"] + [f" · {d}" for d in disclosure_lines()]) + "\n\n[dim]esc to close[/dim]", id="disc-text"), id="disc-wrap")
+
+ def action_close(self) -> None:
+ self.dismiss()
+
+
+# -------------------------------------------------------------------- app
+
+
+class FenrisPrototypeApp(App):
+ TITLE = "Fenris — PROTOTYPE (throwaway)"
+ SUB_TITLE = "TUI information architecture · ticket #3"
+
+ BINDINGS = [
+ Binding("left", "prev_variant", "‹ variant", show=False),
+ Binding("right", "next_variant", "variant ›", show=False),
+ Binding("s", "cycle_state", "state", show=False),
+ Binding("p", "pause", "pause", show=False),
+ Binding("r", "resume", "resume", show=False),
+ Binding("c", "collect", "collect now", show=False),
+ Binding("d", "disclose", "disclosures", show=False),
+ Binding("q", "quit", "quit", show=False),
+ Binding("1", "page('overview')", "overview", show=False),
+ Binding("2", "page('history')", "history", show=False),
+ Binding("3", "page('drive')", "drive", show=False),
+ Binding("4", "page('service')", "service", show=False),
+ Binding("5", "page('settings')", "settings", show=False),
+ Binding("j", "scroll_down", "scroll down", show=False),
+ Binding("k", "scroll_up", "scroll up", show=False),
+ ]
+
+ CSS = """
+ #vhost { height: 1fr; }
+ #vA { layout: grid; grid-size: 2 3; grid-columns: 3fr 2fr; grid-rows: 8 1fr 7; height: 1fr; }
+ #vA-head, #vA-bottom { column-span: 2; }
+ #vA-history { overflow-y: auto; }
+ .pane { border: round #555555; padding: 0 1; }
+ #vB-head { height: 6; border-bottom: thick #555555; padding: 0 1; }
+ #vB-body { height: 1fr; padding: 0 1; }
+ #vB-legend { height: 3; }
+ .page { height: 1fr; padding: 0 1; }
+ #vC { height: 1fr; padding: 0 2; }
+ #switchbar { height: 3; dock: bottom; background: $boost; }
+ #sb-variant { width: 1fr; text-style: reverse; }
+ #sb-state { width: 1fr; }
+ #sb-actions { width: 2fr; }
+ #confirm-text { padding: 1 2; }
+ #disc-wrap { padding: 1 2; height: auto; max-height: 80%; }
+ """
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.scenarios = build_scenarios()
+ self.scenario_idx = 0
+ self.variant_idx = 0
+ self.b_page = "overview"
+ self.tty_log: list[str] = []
+
+ # ---- composition
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="vhost"):
+ with Vertical(id="vA"):
+ yield Static("", id="vA-head", classes="pane")
+ yield Static("", id="vA-history", classes="pane")
+ yield Static("", id="vA-health", classes="pane")
+ yield Static("", id="vA-bottom", classes="pane")
+ with Vertical(id="vB"):
+ yield Static("", id="vB-head")
+ with Vertical(id="vB-body"):
+ yield Static("", id="vB-overview", classes="page")
+ yield Static("", id="vB-history", classes="page")
+ yield Static("", id="vB-drive", classes="page")
+ yield Static("", id="vB-service", classes="page")
+ yield Static("", id="vB-settings", classes="page")
+ yield Static("", id="vB-legend")
+ with VerticalScroll(id="vC"):
+ yield Static("", id="vC-doc")
+ with Horizontal(id="switchbar"):
+ yield Static("", id="sb-variant")
+ yield Static("", id="sb-state")
+ yield Static("", id="sb-actions")
+
+ def on_mount(self) -> None:
+ for vid in ("vA-head", "vA-history", "vA-health", "vA-bottom"):
+ w = self.query_one(f"#{vid}", Static)
+ w.border_title = {"vA-head": "headline", "vA-history": "usage history", "vA-health": "drive", "vA-bottom": "service + actions"}[vid]
+ self.render_all()
+
+ # ---- helpers
+
+ @property
+ def sc(self) -> dict:
+ return self.scenarios[self.scenario_idx]
+
+ def w(self, vid: str) -> Static:
+ return self.query_one(f"#{vid}", Static)
+
+ def render_all(self) -> None:
+ sc = self.sc
+ paused = not sc["service"]["enabled"]
+ key, name = VARIANTS[self.variant_idx]
+
+ # variant A: everything on one dense screen
+ self.w("vA-head").update(headline_block(sc) + "\n" + confidence_block(sc))
+ self.w("vA-history").update(history_block(sc) + "\n" + horizon_block(sc))
+ self.w("vA-health").update(health_block() + "\n\n" + settings_block())
+ self.w("vA-bottom").update(service_block(sc) + "\n " + actions_legend(paused))
+
+ # variant B: persistent header, tabbed pages
+ head = "\n".join([
+ headline_block(sc),
+ confidence_block(sc).split("\n")[0] + f" · {sc['confidence']}",
+ f"freshness: {sc['service']['freshness']} · wear: {DRIVE['percentage_used']} %",
+ ])
+ self.w("vB-head").update(head)
+ self.w("vB-overview").update(confidence_block(sc) + "\n\n" + horizon_block(sc) + "\n\n" + wear_line(sc))
+ self.w("vB-history").update(history_block(sc, 70))
+ self.w("vB-drive").update(health_block() + "\n\n" + wear_line(sc))
+ self.w("vB-service").update(service_block(sc) + "\n\n " + actions_legend(paused) + "\n\n tty log:\n" + ("\n".join(self.tty_log) if self.tty_log else " (no privileged action taken yet)"))
+ self.w("vB-settings").update(settings_block() + "\n\n" + "\n".join(" · " + d for d in disclosure_lines()))
+ self.w("vB-legend").update(f"pages: 1 overview · 2 history · 3 drive · 4 service · 5 settings [now: {self.b_page}]")
+ for p in ("overview", "history", "drive", "service", "settings"):
+ self.w(f"vB-{p}").styles.display = "block" if p == self.b_page else "none"
+
+ # variant C: one scrolling document in reading order
+ doc = "\n\n".join([
+ "[dim]═" * 70 + "[/dim]",
+ headline_block(sc),
+ confidence_block(sc),
+ horizon_block(sc),
+ wear_line(sc),
+ history_block(sc, 70),
+ health_block(),
+ service_block(sc),
+ settings_block(),
+ "[bold]Disclosures[/bold]\n" + "\n".join(" · " + d for d in disclosure_lines()),
+ "[dim]═" * 70 + "[/dim]",
+ ])
+ self.w("vC-doc").update(doc)
+
+ # variant visibility + switcher
+ for i, vid in enumerate(("vA", "vB", "vC")):
+ self.query_one(f"#{vid}").styles.display = "block" if i == self.variant_idx else "none"
+ self.w("sb-variant").update(f" ← {key} · {name} → ")
+ self.w("sb-state").update(f" state [{self.scenario_idx + 1}/{len(self.scenarios)}]: {sc['name']} (s to cycle) ")
+ self.w("sb-actions").update(" " + actions_legend(paused))
+
+ # ---- actions
+
+ def action_prev_variant(self) -> None:
+ self.variant_idx = (self.variant_idx - 1) % len(VARIANTS)
+ self.render_all()
+
+ def action_next_variant(self) -> None:
+ self.variant_idx = (self.variant_idx + 1) % len(VARIANTS)
+ self.render_all()
+
+ def action_cycle_state(self) -> None:
+ self.scenario_idx = (self.scenario_idx + 1) % len(self.scenarios)
+ self.render_all()
+
+ def action_page(self, page: str) -> None:
+ if VARIANTS[self.variant_idx][0] != "B":
+ return
+ self.b_page = page
+ self.render_all()
+
+ def action_scroll_down(self) -> None:
+ if VARIANTS[self.variant_idx][0] == "C":
+ self.query_one("#vC").scroll_down(animated=False)
+
+ def action_scroll_up(self) -> None:
+ if VARIANTS[self.variant_idx][0] == "C":
+ self.query_one("#vC").scroll_up(animated=False)
+
+ def action_disclose(self) -> None:
+ self.push_screen(Disclosures())
+
+ def action_pause(self) -> None:
+ self.push_screen(ConfirmPause(), callback=self._pause_confirmed)
+
+ def _pause_confirmed(self, confirmed: bool) -> None:
+ if not confirmed:
+ self.tty_log.append("pause: cancelled at confirmation")
+ self.render_all()
+ return
+ result = self._run_tty_stub("pause (disable --now)")
+ if "OK" in result:
+ self.scenarios[self.scenario_idx]["service"] = {
+ "enabled": False, "timer": False,
+ "outcome": "ok · period closed by pause",
+ "freshness": "paused · no collection while disabled",
+ "period": "closed just now · end cause: deliberate disable",
+ }
+ self.tty_log.append(result)
+ self.render_all()
+
+ def action_resume(self) -> None:
+ result = self._run_tty_stub("resume (enable --now)") # no confirmation (ADR 0003 §8)
+ if "OK" in result or "skipped" in result:
+ self.scenarios[self.scenario_idx]["service"] = {
+ "enabled": True, "timer": True,
+ "outcome": "ok · resumed just now",
+ "freshness": "fresh · collection resuming",
+ "period": "open just now",
+ }
+ self.tty_log.append(result)
+ self.render_all()
+
+ def action_collect(self) -> None:
+ result = self._run_tty_stub("collect now", blocking=True) # synchronous outcome (ADR 0003 §7)
+ self.tty_log.append(result)
+ self.render_all()
+
+ # ---- tty passthrough validation (the point of the stub)
+
+ def _run_tty_stub(self, op: str, blocking: bool = False) -> str:
+ if os.environ.get("FENRIS_PROTOTYPE_NO_TTY") or not sys.stdin.isatty():
+ return f"{op}: tty stub SKIPPED (headless run — mechanism not exercised)"
+ try:
+ with self.suspend():
+ proc = subprocess.run([sys.executable, str(STUB), op])
+ verdict = "OK — suspend + terminal passthrough works" if proc.returncode == 0 else f"FAILED (exit {proc.returncode})"
+ return f"{op}: {verdict}"
+ except Exception as exc: # SuspendNotSupported and friends
+ return f"{op}: suspend failed: {type(exc).__name__} — this terminal may not support passthrough"
+
+
+if __name__ == "__main__":
+ app = FenrisPrototypeApp()
+ print("[fenris prototype] throwaway UI for ticket #3 — variants switch with ←/→, states with s\n")
+ app.run()