From f8f9899f438eb1c4b25ec15a1dc9a41be7acd358 Mon Sep 17 00:00:00 2001 From: Tate McCauley Date: Fri, 10 Jul 2026 09:13:58 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat(tui):=20port=20Posting's=20design=20la?= =?UTF-8?q?nguage=20=E2=80=94=20border=20titles,=20status=20pills,=20dense?= =?UTF-8?q?=20controls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 of the Posting design port (both apps are Textual, so the patterns port as CSS): - Panel titles move into the border (border_title, right-aligned); panels rest at 40%-alpha borders and brighten to full $primary + bold title on focus-within. Evidence's files panel carries the run scoreboard in its border title. - New palette.pill()/status_pill() render status cells as text on a same-hue muted swatch (the tokyo-night text-*/*-muted pairs), replacing hand-picked green/red literals across run/manifest/evidence/upload. - Inputs and buttons flatten to one row; focused inputs show a wide $primary left edge with padding swapped so text never shifts. - Thin 1-cell scrollbars tinted $primary, muted-bold DataTable headers, and render.py detail panes moved onto the palette hexes. Verified with a headless Pilot tour (screenshots of every tab, modals, and a synthetic-event run); 209 tests pass. Co-Authored-By: Claude Fable 5 --- framework/tui/palette.py | 54 ++++++++++++++++++- framework/tui/render.py | 40 +++++++------- framework/tui/screens/catalog.py | 12 ++--- framework/tui/screens/evidence.py | 26 +++++----- framework/tui/screens/manifest.py | 12 ++--- framework/tui/screens/run.py | 44 ++++++---------- framework/tui/screens/upload.py | 43 ++++++++-------- framework/tui/styles/index.tcss | 86 ++++++++++++++++++++++++++++--- 8 files changed, 217 insertions(+), 100 deletions(-) diff --git a/framework/tui/palette.py b/framework/tui/palette.py index 6c14572..8da0513 100644 --- a/framework/tui/palette.py +++ b/framework/tui/palette.py @@ -5,8 +5,14 @@ constants are for the Rich Text styles built in Python (DataTable cells, detail panes, etc.) which don't resolve Textual CSS $variables. Use the semantic roles (OK/WARN/FAIL/ACCENT/INFO/MUTED) in screen code so intent stays readable. + +Status cells in tables should go through pill()/status_pill() rather than +hand-picked colors, so every screen renders the same pass/warn/fail language. """ +from rich.style import Style +from rich.text import Text + # raw palette BG = "#1A1B26" FG = "#C0CAF5" @@ -21,8 +27,52 @@ # semantic roles ACCENT = PURPLE # keys, cursors, primary accents -INFO = CYAN # identifiers, breadcrumb -MUTED = SUBTLE # dim / secondary text +INFO = CYAN # identifiers, breadcrumb +MUTED = SUBTLE # dim / secondary text OK = GREEN WARN = YELLOW FAIL = RED + +# Pill pairs: readable text on a muted swatch of the same hue. These are the +# text-*/*-muted values Textual derives for tokyo-night, frozen here for the +# same reason as the raw palette above (Rich can't resolve $variables). +OK_TEXT, OK_MUTED = "#BEDE9C", "#41503A" +WARN_TEXT, WARN_MUTED = "#EACA9B", "#554739" +FAIL_TEXT, FAIL_MUTED = "#F9A4B4", "#5C3645" + +_TONES = { + "ok": (OK_TEXT, OK_MUTED), + "warn": (WARN_TEXT, WARN_MUTED), + "fail": (FAIL_TEXT, FAIL_MUTED), +} + +# Every status word the screens emit, mapped to a tone. None means "recede": +# plain muted text instead of a pill, so pending rows don't compete with results. +STATUS_TONE = { + "queued": None, + "running": "warn", + "ok": "ok", + "failed": "fail", + "partial": "warn", + "timeout": "fail", + "skipped": "warn", + "error": "fail", +} + + +def pill(label: str, tone: str) -> Text: + """A status pill for table cells. Unknown tones fall back to muted text so + a new status word can never crash a repaint.""" + spec = _TONES.get(tone) + if spec is None: + return Text(label, style=MUTED) + fg, bg = spec + return Text(f" {label} ", style=Style(color=fg, bgcolor=bg, bold=True)) + + +def status_pill(status: str) -> Text: + """The shared renderer for run/entry status cells across all screens.""" + tone = STATUS_TONE.get(status) + if tone is None: + return Text(status.upper(), style=MUTED) + return pill(status.upper(), tone) diff --git a/framework/tui/render.py b/framework/tui/render.py index 015ec15..d77fc18 100644 --- a/framework/tui/render.py +++ b/framework/tui/render.py @@ -11,6 +11,8 @@ from rich.table import Table from rich.text import Text +from framework.tui import palette + def _fmt_default(value: Any) -> str: if value is None: @@ -27,16 +29,16 @@ def _field_table(title: str, fields: List[dict]) -> RenderableType: return Group(heading, Text(" (none)", style="dim")) table = Table(box=None, pad_edge=False, expand=True, show_edge=False) - table.add_column("name", style="cyan", no_wrap=True) + table.add_column("name", style=palette.INFO, no_wrap=True) table.add_column("type", style="dim") table.add_column("req", justify="center") table.add_column("default") - table.add_column("env var", style="green") + table.add_column("env var", style=palette.OK) table.add_column("description", style="dim", overflow="fold") for f in fields: required = f.get("required") - req_cell = Text("yes", style="yellow") if required else Text("no", style="dim") + req_cell = Text("yes", style=palette.WARN) if required else Text("no", style="dim") per_target = " ·per-target" if f.get("per_target") else "" table.add_row( f.get("name", ""), @@ -52,15 +54,15 @@ def _field_table(title: str, fields: List[dict]) -> RenderableType: def fetcher_detail(f: dict) -> RenderableType: """Full detail view for one fetcher descriptor.""" title = Text() - title.append(f.get("name", ""), style="bold white") + title.append(f.get("name", ""), style=f"bold {palette.FG}") if f.get("version"): title.append(f" v{f['version']}", style="dim") meta = Text() meta.append("category: ", style="dim") - meta.append(f.get("category") or "—", style="white") + meta.append(f.get("category") or "—", style=palette.FG) meta.append(" targets: ", style="dim") - meta.append("yes" if f.get("supports_targets") else "no", style="white") + meta.append("yes" if f.get("supports_targets") else "no", style=palette.FG) description = Text(f.get("description") or "(no description)", style="italic") @@ -96,7 +98,7 @@ def _env_name(ref: Any) -> str: def _kv_table(rows: List[tuple]) -> Table: table = Table(box=None, pad_edge=False, expand=True, show_edge=False, show_header=False) - table.add_column(style="cyan", no_wrap=True) + table.add_column(style=palette.INFO, no_wrap=True) table.add_column() for name, value in rows: table.add_row(name, value) @@ -105,8 +107,8 @@ def _kv_table(rows: List[tuple]) -> Table: def _status(set_: bool, required: bool) -> Text: if set_: - return Text("set", style="green") - return Text("required — unset", style="yellow") if required else Text("unset", style="dim") + return Text("set", style=palette.OK) + return Text("required — unset", style=palette.WARN) if required else Text("unset", style="dim") def entry_detail(descriptor: Optional[dict], entry: dict, errors: Optional[List[str]] = None) -> RenderableType: @@ -114,13 +116,13 @@ def entry_detail(descriptor: Optional[dict], entry: dict, errors: Optional[List[ use = entry.get("use", "?") if descriptor is None: return Group( - Text(use, style="bold white"), - Text("unknown fetcher — not discovered in the catalog", style="yellow"), + Text(use, style=f"bold {palette.FG}"), + Text("unknown fetcher — not discovered in the catalog", style=palette.WARN), ) fanout = descriptor.get("supports_targets") header = Text() - header.append(use, style="bold white") + header.append(use, style=f"bold {palette.FG}") header.append(" [fanout]" if fanout else " [single]", style="dim") cfg = entry.get("config") or {} @@ -133,7 +135,7 @@ def entry_detail(descriptor: Optional[dict], entry: dict, errors: Optional[List[ rows = [] for s in top_secrets: current = _env_name(secs.get(s["name"])) - value = Text(f"${{env:{current}}}", style="green") if current else _status(False, True) + value = Text(f"${{env:{current}}}", style=palette.OK) if current else _status(False, True) rows.append((s["name"], value)) parts += [Text("secrets", style="bold"), _kv_table(rows), Text()] @@ -143,7 +145,7 @@ def entry_detail(descriptor: Optional[dict], entry: dict, errors: Optional[List[ rows = [] for c in config_fields: if c["name"] in cfg: - rows.append((c["name"], Text(str(cfg[c["name"]]), style="white"))) + rows.append((c["name"], Text(str(cfg[c["name"]]), style=palette.FG))) elif c.get("default") is not None: rows.append((c["name"], Text(f"{c['default']} (default)", style="dim"))) else: @@ -155,21 +157,21 @@ def entry_detail(descriptor: Optional[dict], entry: dict, errors: Optional[List[ targets = entry.get("targets") or [] parts.append(Text(f"targets ({len(targets)})", style="bold")) if not targets: - parts.append(Text(" none — press 't' to add", style="yellow")) + parts.append(Text(" none — press 't' to add", style=palette.WARN)) for i, t in enumerate(targets): values = {k: v for k, v in t.items() if k != "secrets"} summary = " ".join(f"{k}={v}" for k, v in values.items()) or "(empty)" line = Text(f" [{i}] ", style="dim") - line.append(summary, style="white") + line.append(summary, style=palette.FG) tsec = t.get("secrets") or {} if tsec: - line.append(" " + ", ".join(f"{k}→{_env_name(v)}" for k, v in tsec.items()), style="green") + line.append(" " + ", ".join(f"{k}→{_env_name(v)}" for k, v in tsec.items()), style=palette.OK) parts.append(line) parts.append(Text()) if errors: - parts.append(Text("issues", style="bold red")) + parts.append(Text("issues", style=f"bold {palette.FAIL}")) for e in errors: - parts.append(Text(f" ✗ {e}", style="yellow")) + parts.append(Text(f" ✗ {e}", style=palette.WARN)) return Group(*parts) diff --git a/framework/tui/screens/catalog.py b/framework/tui/screens/catalog.py index 37bb200..568189c 100644 --- a/framework/tui/screens/catalog.py +++ b/framework/tui/screens/catalog.py @@ -32,16 +32,16 @@ class CatalogPage(Horizontal): def compose(self) -> ComposeResult: with Vertical(id="catalog-left", classes="panel"): - yield Static("fetchers", id="catalog-left-title", classes="panel-title") yield Input(placeholder="/ filter fetchers…", id="catalog-search") yield Tree("fetchers", id="catalog-tree") with VerticalScroll(id="catalog-detail-scroll", classes="panel"): - yield Static("contract", id="catalog-detail-title", classes="panel-title") yield Static(render.empty_detail(), id="catalog-detail") def on_mount(self) -> None: self.query_one("#catalog-tree", Tree).show_root = False - self.query_one("#catalog-detail-scroll", VerticalScroll).can_focus = True + detail = self.query_one("#catalog-detail-scroll", VerticalScroll) + detail.can_focus = True + detail.border_title = "contract" self.rebuild() # -- data ------------------------------------------------------------- # @@ -50,11 +50,11 @@ def rebuild(self) -> None: """Repopulate the tree from the App's cached catalog, applying the filter.""" data = getattr(self.app, "catalog_data", None) tree = self.query_one("#catalog-tree", Tree) - title = self.query_one("#catalog-left-title", Static) + panel = self.query_one("#catalog-left", Vertical) tree.clear() if not data: - title.update("fetchers (none discovered)") + panel.border_title = "fetchers (none discovered)" return flt = self._filter.strip().lower() @@ -69,7 +69,7 @@ def rebuild(self) -> None: total += 1 tree.root.expand() - title.update(f"fetchers ({total})") + panel.border_title = f"fetchers ({total})" # -- events ----------------------------------------------------------- # diff --git a/framework/tui/screens/evidence.py b/framework/tui/screens/evidence.py index 6c1e195..3ada7bc 100644 --- a/framework/tui/screens/evidence.py +++ b/framework/tui/screens/evidence.py @@ -21,6 +21,7 @@ from textual.widgets import Button, DataTable, Static from framework import api +from framework.tui import palette from framework.tui.modals import PreviewModal @@ -35,14 +36,14 @@ def compose(self) -> ComposeResult: yield Button("Refresh", id="evidence-refresh") with Horizontal(id="evidence-body"): with Vertical(id="evidence-left", classes="panel"): - yield Static("runs", classes="panel-title") yield DataTable(id="evidence-runs") with Vertical(id="evidence-right", classes="panel"): - yield Static("", id="evidence-run-header", classes="panel-title") yield DataTable(id="evidence-files") def on_mount(self) -> None: self._runs: List[dict] = [] + self.query_one("#evidence-left", Vertical).border_title = "runs" + self.query_one("#evidence-right", Vertical).border_title = "files" runs = self.query_one("#evidence-runs", DataTable) runs.cursor_type = "row" runs.zebra_stripes = True @@ -77,14 +78,14 @@ def rebuild_runs(self) -> None: dt.clear() for r in self._runs: if not r.get("complete", True): - when = Text("incomplete", style="yellow") # no _run_metadata.json (aborted run) + when = Text("incomplete", style=palette.WARN) # no _run_metadata.json (aborted run) else: when = r.get("completed_at") or r.get("started_at") or "" - fail_style = "red" if r["fail"] else "dim" + fail_style = palette.FAIL if r["fail"] else "dim" dt.add_row( r["run_id"], when, - Text(str(r["ok"]), style="green"), + Text(str(r["ok"]), style=palette.OK), Text(str(r["fail"]), style=fail_style), key=r["dir"], ) @@ -92,9 +93,7 @@ def rebuild_runs(self) -> None: if self._runs: self._show_run(self._runs[0]["dir"]) else: - self.query_one("#evidence-run-header", Static).update( - Text(f"no runs found under {out}", style="dim") - ) + self.query_one("#evidence-right", Vertical).border_title = "files (no runs)" self.query_one("#evidence-files", DataTable).clear() def _show_run(self, run_dir: str) -> None: @@ -103,9 +102,10 @@ def _show_run(self, run_dir: str) -> None: files.clear() if run is None: return - header = Text(f"{run['run_id']} ", style="bold") - header.append(f"✓ {run['ok']} ✗ {run['fail']} {run.get('completed_at') or ''}", style="dim") - self.query_one("#evidence-run-header", Static).update(header) + # The run's scoreboard lives in the panel border, Posting-style. + self.query_one("#evidence-right", Vertical).border_title = ( + f"{run['run_id']} [{palette.OK}]✓ {run['ok']}[/] [{palette.FAIL}]✗ {run['fail']}[/]" + ) for f in run["files"]: tgt = f.get("target") tlabel = " ".join(f"{k}={v}" for k, v in tgt.items()) if isinstance(tgt, dict) else "" @@ -113,9 +113,9 @@ def _show_run(self, run_dir: str) -> None: if code is None: ecell = Text("—", style="dim") elif code == 0: - ecell = Text("0", style="green") + ecell = Text("0", style=palette.OK) else: - ecell = Text(str(code), style="red") + ecell = palette.pill(str(code), "fail") files.add_row(f["name"], f.get("fetcher") or "—", tlabel or "—", ecell, key=f["path"]) # -- events ----------------------------------------------------------- # diff --git a/framework/tui/screens/manifest.py b/framework/tui/screens/manifest.py index 416d61d..fdd21a8 100644 --- a/framework/tui/screens/manifest.py +++ b/framework/tui/screens/manifest.py @@ -19,7 +19,7 @@ from textual.widgets import Button, DataTable, Input, Static from framework import api -from framework.tui import render +from framework.tui import palette, render from framework.tui.components.forms import env_name_from_ref from framework.tui.modals import ( ConfirmModal, @@ -55,16 +55,16 @@ def compose(self) -> ComposeResult: yield Button("Save", id="btn-save") with Horizontal(id="manifest-body"): with Vertical(id="manifest-entries-panel", classes="panel"): - yield Static("fetchers", classes="panel-title") yield DataTable(id="manifest-entries") with VerticalScroll(id="manifest-detail-scroll", classes="panel"): - yield Static("detail", classes="panel-title") yield Static(render.empty_detail("No fetcher selected."), id="manifest-detail") yield Static(id="manifest-issues") def on_mount(self) -> None: self._selected: Optional[str] = None self._errors: List[str] = [] + self.query_one("#manifest-entries-panel", Vertical).border_title = "fetchers" + self.query_one("#manifest-detail-scroll", VerticalScroll).border_title = "detail" dt = self.query_one("#manifest-entries", DataTable) dt.cursor_type = "row" dt.zebra_stripes = True @@ -127,7 +127,7 @@ def rebuild(self) -> None: cset, ctot = self._config_counts(d, e) ntargets = len(e.get("targets") or []) errs = by_use.get(use, []) - status = Text("✓", style="green") if not errs else Text(f"⚠ {len(errs)}", style="yellow") + status = palette.pill("✓", "ok") if not errs else palette.pill(f"⚠ {len(errs)}", "warn") dt.add_row( use, "fanout" if fanout else "single", @@ -169,9 +169,9 @@ def _refresh_detail(self) -> None: def _set_issues(self, errors: List[str]) -> None: issues = self.query_one("#manifest-issues", Static) if not errors: - issues.update(Text("✓ manifest is runnable", style="green")) + issues.update(Text("✓ manifest is runnable", style=palette.OK)) return - head = Text(f"{len(errors)} issue(s): ", style="yellow") + head = Text(f"{len(errors)} issue(s): ", style=palette.WARN) head.append(" · ".join(errors[:3]), style="dim") if len(errors) > 3: head.append(f" (+{len(errors) - 3} more — press p to preview)", style="dim") diff --git a/framework/tui/screens/run.py b/framework/tui/screens/run.py index d3adc44..c4a7477 100644 --- a/framework/tui/screens/run.py +++ b/framework/tui/screens/run.py @@ -25,19 +25,9 @@ from textual.widgets import Button, DataTable, RichLog, Static from framework import api +from framework.tui import palette from framework.tui.modals import ConfirmModal -_STATUS_STYLE = { - "queued": "dim", - "running": "yellow", - "ok": "green", - "failed": "red", - "partial": "yellow", - "timeout": "red", - "skipped": "yellow", - "error": "red", -} - _BAR_WIDTH = 20 @@ -64,10 +54,8 @@ def compose(self) -> ComposeResult: yield Static("", id="run-banner") with Horizontal(id="run-body"): with Vertical(id="run-status-panel", classes="panel"): - yield Static("status", classes="panel-title") yield DataTable(id="run-status") with Vertical(id="run-log-panel", classes="panel"): - yield Static("log", classes="panel-title") yield RichLog(id="run-log", markup=False, wrap=True, highlight=False) yield Static("", id="run-summary") @@ -75,6 +63,8 @@ def on_mount(self) -> None: self._running: bool = False self._state: Dict[str, dict] = {} self._rows: dict = {} # use -> RowKey, for in-place cell updates + self.query_one("#run-status-panel", Vertical).border_title = "status" + self.query_one("#run-log-panel", Vertical).border_title = "log" dt = self.query_one("#run-status", DataTable) dt.cursor_type = "row" dt.zebra_stripes = True @@ -126,7 +116,7 @@ def _start_run(self) -> None: self.query_one("#run-status", DataTable).clear() self.query_one("#run-log", RichLog).clear() self.query_one("#btn-run", Button).disabled = True - self._set_banner(Text("running…", style="yellow")) + self._set_banner(Text("running…", style=palette.WARN)) self._refresh_summary() # Snapshot the manifest: the worker runs on another thread and the # Manifest tab can mutate the shared dict on the UI thread mid-run. @@ -159,7 +149,7 @@ def _handle_event(self, ev: dict) -> None: } self.query_one("#run-status", DataTable).clear() self._rows = {} - self._set_banner(Text(f"running → {ev.get('run_dir', '')}", style="yellow")) + self._set_banner(Text(f"running → {ev.get('run_dir', '')}", style=palette.WARN)) log.write(Text(f"▶ run {ev.get('run_id', '')} → {ev.get('run_dir', '')}", style="bold")) for use in self._state: self._paint_row(use) @@ -169,7 +159,7 @@ def _handle_event(self, ev: dict) -> None: st["status"] = "running" st["fanout"] = bool(ev.get("fanout")) st["total"] = ev.get("targets", 1) - log.write(Text(f" ▸ {ev['fetcher']} …", style="cyan")) + log.write(Text(f" ▸ {ev['fetcher']} …", style=palette.INFO)) self._paint_row(ev["fetcher"]) elif etype == "log_line": @@ -182,7 +172,7 @@ def _handle_event(self, ev: dict) -> None: st = self._row(ev["fetcher"]) st["status"] = "skipped" st["reason"] = ev.get("reason", "") - log.write(Text(f" ⊘ {ev['fetcher']} skipped — {ev.get('reason', '')}", style="yellow")) + log.write(Text(f" ⊘ {ev['fetcher']} skipped — {ev.get('reason', '')}", style=palette.WARN)) self._paint_row(ev["fetcher"]) elif etype == "fetcher_error": @@ -190,14 +180,14 @@ def _handle_event(self, ev: dict) -> None: st = self._row(use) st["status"] = "error" st["reason"] = ev.get("error", "") - log.write(Text(f" ✗ {use} error — {ev.get('error', '')}", style="red")) + log.write(Text(f" ✗ {use} error — {ev.get('error', '')}", style=palette.FAIL)) self._paint_row(use) elif etype == "run_complete": self._finalize(ev.get("ok"), ev.get("metadata_path", "")) elif etype == "_run_failed": - log.write(Text(f"✗ run failed: {ev.get('error', '')}", style="bold red")) + log.write(Text(f"✗ run failed: {ev.get('error', '')}", style=f"bold {palette.FAIL}")) self._finalize(False, "") def _apply_result(self, ev: dict, log: RichLog) -> None: @@ -220,7 +210,7 @@ def _apply_result(self, ev: dict, log: RichLog) -> None: target = ev.get("target") tlabel = " ".join(f"{k}={v}" for k, v in target.items()) if isinstance(target, dict) else "" - icon, style = ("✓", "green") if code == 0 else ("✗", "red") + icon, style = ("✓", palette.OK) if code == 0 else ("✗", palette.FAIL) line = Text(f" {icon} {ev['fetcher']}", style=style) if tlabel: line.append(f" [{tlabel}]", style="dim") @@ -236,7 +226,7 @@ def _finalize(self, ok, metadata_path: str) -> None: self._running = False self.query_one("#btn-run", Button).disabled = False icon = "✓" if ok else "✗" - style = "green" if ok else "red" + style = palette.OK if ok else palette.FAIL msg = Text(f"{icon} run complete — ok={ok}", style=style) if metadata_path: msg.append(f" {metadata_path}", style="dim") @@ -274,7 +264,7 @@ def _paint_row(self, use: str) -> None: return dt = self.query_one("#run-status", DataTable) status = st.get("status", "queued") - cell = Text(status.upper(), style=_STATUS_STYLE.get(status, "white")) + cell = palette.status_pill(status) mode = "fanout" if st.get("fanout") else "single" info = self._info(st) if use in self._rows: @@ -298,12 +288,12 @@ def _refresh_summary(self) -> None: total = ok + fail_total if total: nok = max(0, min(_BAR_WIDTH, round(_BAR_WIDTH * ok / total))) - summary.append("█" * nok, style="green") - summary.append("█" * (_BAR_WIDTH - nok), style="red") + summary.append("█" * nok, style=palette.OK) + summary.append("█" * (_BAR_WIDTH - nok), style=palette.FAIL) summary.append(" ") - summary.append(f"✓ {ok} ", style="green") - summary.append(f"✗ {fail_total} ", style="red") - summary.append(f"⊘ {skip}", style="yellow") + summary.append(f"✓ {ok} ", style=palette.OK) + summary.append(f"✗ {fail_total} ", style=palette.FAIL) + summary.append(f"⊘ {skip}", style=palette.WARN) self.query_one("#run-summary", Static).update(summary) def _set_banner(self, renderable) -> None: diff --git a/framework/tui/screens/upload.py b/framework/tui/screens/upload.py index 55ecba8..9fdba34 100644 --- a/framework/tui/screens/upload.py +++ b/framework/tui/screens/upload.py @@ -15,6 +15,7 @@ from textual.widgets import Button, DataTable, RichLog, Static from framework import api +from framework.tui import palette from framework.tui.modals import ConfirmModal @@ -41,16 +42,16 @@ def compose(self) -> ComposeResult: yield Static("", id="upload-banner") with Horizontal(id="upload-body"): with Vertical(id="upload-summary-panel", classes="panel"): - yield Static("ready to upload", classes="panel-title") yield DataTable(id="upload-summary") with Vertical(id="upload-log-panel", classes="panel"): - yield Static("upload log", classes="panel-title") yield RichLog(id="upload-log", markup=False, wrap=True, highlight=False) def on_mount(self) -> None: self._uploading = False self._run_dir: str | None = None self._preflight: dict | None = None + self.query_one("#upload-summary-panel", Vertical).border_title = "ready to upload" + self.query_one("#upload-log-panel", Vertical).border_title = "log" table = self.query_one("#upload-summary", DataTable) table.cursor_type = "row" table.zebra_stripes = True @@ -87,8 +88,8 @@ def rebuild(self) -> None: try: runs = api.list_runs(out) except Exception as exc: - self._set_banner(Text(f"cannot list runs: {exc}", style="red")) - table.add_row("status", Text(f"cannot list runs: {exc}", style="red")) + self._set_banner(Text(f"cannot list runs: {exc}", style=palette.FAIL)) + table.add_row("status", Text(f"cannot list runs: {exc}", style=palette.FAIL)) return if not runs: @@ -106,22 +107,22 @@ def rebuild(self) -> None: try: preflight = api.upload_preflight(self._run_dir, self.app.root_path) except Exception as exc: - self._set_banner(Text(f"upload setup failed: {exc}", style="red")) - table.add_row("preflight", Text(str(exc), style="red")) + self._set_banner(Text(f"upload setup failed: {exc}", style=palette.FAIL)) + table.add_row("preflight", Text(str(exc), style=palette.FAIL)) return self._preflight = preflight table.add_row("Paramify API", preflight["base_url"]) - table.add_row("API token", Text("present", style="green") if preflight["token_present"] else Text("missing", style="red")) + table.add_row("API token", palette.pill("present", "ok") if preflight["token_present"] else palette.pill("missing", "fail")) table.add_row("upload files", str(preflight["file_count"])) if preflight["ok"]: upload.disabled = False - self._set_banner(Text("ready — upload after reviewing the evidence", style="green")) + self._set_banner(Text("ready — upload after reviewing the evidence", style=palette.OK)) else: for err in preflight["errors"]: - table.add_row("preflight error", Text(err, style="red")) - self._set_banner(Text("upload preflight failed", style="red")) + table.add_row("preflight error", Text(err, style=palette.FAIL)) + self._set_banner(Text("upload preflight failed", style=palette.FAIL)) @staticmethod def _result_text(run: dict) -> Text: @@ -129,10 +130,10 @@ def _result_text(run: dict) -> Text: ok = run.get("ok", 0) total = ok + fail if not run.get("complete", True): - return Text("incomplete", style="yellow") + return Text("incomplete", style=palette.WARN) if fail: - return Text(f"{ok}/{total} ok, {fail} failed", style="yellow") - return Text(f"{ok}/{total} ok", style="green") + return Text(f"{ok}/{total} ok, {fail} failed", style=palette.WARN) + return Text(f"{ok}/{total} ok", style=palette.OK) # -- actions ---------------------------------------------------------- # @@ -172,7 +173,7 @@ def _start_upload(self, run_dir: str) -> None: self.query_one("#upload-refresh", Button).disabled = True self.query_one("#upload-submit", Button).disabled = True self.query_one("#upload-log", RichLog).clear() - self._set_banner(Text("uploading to Paramify...", style="yellow")) + self._set_banner(Text("uploading to Paramify...", style=palette.WARN)) self._upload_worker(run_dir, self.app.root_path) @work(thread=True, exclusive=True) @@ -197,17 +198,17 @@ def _handle_upload_event(self, ev: dict) -> None: if etype == "upload_start": mode = " (dry-run)" if ev.get("dry_run") else "" - self._set_banner(Text(f"uploading {ev.get('files', 0)} file(s) to {ev.get('base_url', '')}{mode}", style="yellow")) + self._set_banner(Text(f"uploading {ev.get('files', 0)} file(s) to {ev.get('base_url', '')}{mode}", style=palette.WARN)) log.write(Text(f"upload {ev.get('files', 0)} file(s) from {ev.get('run_dir', '')}{mode}", style="bold")) elif etype == "upload_file": outcome = ev.get("outcome") if outcome == "uploaded": - icon, style = "OK", "green" + icon, style = "OK", palette.OK elif outcome in ("skipped_duplicate", "skipped_failed", "would_upload"): - icon, style = "SKIP", "yellow" + icon, style = "SKIP", palette.WARN else: - icon, style = "FAIL", "red" + icon, style = "FAIL", palette.FAIL ref = f" set={ev.get('reference_id')}" if ev.get("reference_id") else "" reason = ev.get("reason") or ev.get("error") suffix = f" {reason}" if reason else "" @@ -220,15 +221,15 @@ def _handle_upload_event(self, ev: dict) -> None: self._uploading = False self.query_one("#upload-refresh", Button).disabled = False self.query_one("#upload-submit", Button).disabled = self._preflight is None or not self._preflight.get("ok") - log.write(Text(f"upload failed: {ev.get('error', '')}", style="bold red")) - self._set_banner(Text(f"upload failed: {ev.get('error', '')}", style="red")) + log.write(Text(f"upload failed: {ev.get('error', '')}", style=f"bold {palette.FAIL}")) + self._set_banner(Text(f"upload failed: {ev.get('error', '')}", style=palette.FAIL)) def _finalize_upload(self, ev: dict) -> None: self._uploading = False self.query_one("#upload-refresh", Button).disabled = False self.query_one("#upload-submit", Button).disabled = False ok = ev.get("ok") - style = "green" if ok else "red" + style = palette.OK if ok else palette.FAIL msg = Text( "upload complete — " f"uploaded={ev.get('uploaded', 0)} " diff --git a/framework/tui/styles/index.tcss b/framework/tui/styles/index.tcss index 5a0a1a3..5e7bbb5 100644 --- a/framework/tui/styles/index.tcss +++ b/framework/tui/styles/index.tcss @@ -1,5 +1,73 @@ /* TUI styles. Layout + chrome; colors come from the tokyo-night theme. */ +/* ---- global widget language (Posting-style) ---- + Thin $primary-tinted scrollbars; dense one-row inputs and buttons whose + focus is an accent left edge (padding swaps for the border so text never + shifts); muted-bold table headers. */ +* { + scrollbar-color: $primary 10%; + scrollbar-color-hover: $primary 80%; + scrollbar-color-active: $primary; + scrollbar-background: $surface-darken-1; + scrollbar-size-vertical: 1; +} +*:focus { + scrollbar-color: $primary 50%; +} + +Input { + height: 1; + border: none; + padding: 0 1; + background: $surface; +} +Input:focus { + border: none; + border-left: wide $primary; + padding-left: 0; + background: $surface; +} + +/* Flatten Button's tall top/bottom borders in every state (base, hover, + active, variants) so buttons sit on one row like the inputs. */ +Button, Button:hover, Button.-active, +Button.-primary, Button.-primary:hover, Button.-primary.-active, +Button.-error, Button.-error:hover, Button.-error.-active { + border: none; +} +Button { + height: 1; + min-width: 12; + padding: 0 2; + background: $panel; +} +Button.-primary { + background: $primary-muted; + color: $text-primary; +} +Button.-primary:hover { + background: $primary-darken-2; +} +Button.-error { + background: $error-muted; + color: $text-error; +} + +DataTable > .datatable--header { + background: transparent; + color: $text-muted; + text-style: bold; +} + +RichLog { + background: transparent; + padding: 0 1; +} +RichLog:focus { + border-left: wide $primary; + padding-left: 0; +} + /* ---- shared chrome: header bar, footer hint bar, focus-aware panels ---- */ #app-header { height: auto; @@ -20,14 +88,21 @@ TabbedContent { height: 1fr; } -/* A titled box whose border highlights when it (or a child) holds focus. */ +/* A titled box whose border highlights when it (or a child) holds focus. + Titles live in the border (border_title, set by each screen) — dim border + at rest, full accent + bold title when focused, the Posting .section look. */ .panel { - border: round $panel; + border: round $primary 40%; + border-title-color: $text-muted; + border-title-align: right; padding: 0 1; } .panel:focus-within { border: round $primary; + border-title-color: $text; + border-title-style: bold; } +/* In-content section label (modal right column etc.). */ .panel-title { color: $text-muted; text-style: bold; @@ -60,7 +135,7 @@ ManifestPage { #manifest-top .inline-label { width: auto; - padding: 1 1 0 0; + padding: 0 1 0 0; } #manifest-output-dir { @@ -103,7 +178,7 @@ RunPage { #run-banner { width: 1fr; - padding: 1 0 0 2; + padding: 0 0 0 2; } #run-body { @@ -134,7 +209,6 @@ EvidencePage { #evidence-dir { width: 1fr; - padding-top: 1; } #evidence-body { @@ -164,7 +238,7 @@ UploadPage { #upload-banner { width: 1fr; - padding: 1 0 0 1; + padding: 0 0 0 1; } #upload-body { From 0f9e369dfe88632aa3da988b24e527748ef59fa1 Mon Sep 17 00:00:00 2001 From: Tate McCauley Date: Fri, 10 Jul 2026 09:17:23 -0600 Subject: [PATCH 2/2] feat(tui): hatched empty states for run, evidence, upload, and manifest panels A .panel tagged .empty hides its DataTable/RichLog and shows a hatched, centered .empty-hint placeholder instead (Posting's empty-state idiom), with the relevant keybind accented in the hint: - Run: both panels hatch until a run starts ("ctrl+r executes the manifest"); cleared by _start_run and by the run_start event so the synthetic-event test path behaves the same; reset_state restores them. - Evidence: runs panel hatches when no runs exist, files panel when the selected run produced no files. - Upload: the log panel hatches until an upload starts. - Manifest: the entries panel hatches with a message that adapts to "no manifest loaded" vs "manifest is empty". Verified with the headless Pilot screenshot tour; 209 tests pass. Co-Authored-By: Claude Fable 5 --- framework/tui/screens/evidence.py | 14 ++++++++++++-- framework/tui/screens/manifest.py | 15 +++++++++++++++ framework/tui/screens/run.py | 14 ++++++++++++++ framework/tui/screens/upload.py | 9 ++++++++- framework/tui/styles/index.tcss | 18 ++++++++++++++++++ 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/framework/tui/screens/evidence.py b/framework/tui/screens/evidence.py index 3ada7bc..2dd8f0a 100644 --- a/framework/tui/screens/evidence.py +++ b/framework/tui/screens/evidence.py @@ -37,8 +37,13 @@ def compose(self) -> ComposeResult: with Horizontal(id="evidence-body"): with Vertical(id="evidence-left", classes="panel"): yield DataTable(id="evidence-runs") + yield Static( + f"no runs yet — execute the manifest on tab [bold {palette.ACCENT}]3[/]", + classes="empty-hint", + ) with Vertical(id="evidence-right", classes="panel"): yield DataTable(id="evidence-files") + yield Static("evidence files from the selected run appear here", classes="empty-hint") def on_mount(self) -> None: self._runs: List[dict] = [] @@ -90,10 +95,13 @@ def rebuild_runs(self) -> None: key=r["dir"], ) + self.query_one("#evidence-left", Vertical).set_class(not self._runs, "empty") if self._runs: self._show_run(self._runs[0]["dir"]) else: - self.query_one("#evidence-right", Vertical).border_title = "files (no runs)" + right = self.query_one("#evidence-right", Vertical) + right.border_title = "files" + right.set_class(True, "empty") self.query_one("#evidence-files", DataTable).clear() def _show_run(self, run_dir: str) -> None: @@ -103,9 +111,11 @@ def _show_run(self, run_dir: str) -> None: if run is None: return # The run's scoreboard lives in the panel border, Posting-style. - self.query_one("#evidence-right", Vertical).border_title = ( + right = self.query_one("#evidence-right", Vertical) + right.border_title = ( f"{run['run_id']} [{palette.OK}]✓ {run['ok']}[/] [{palette.FAIL}]✗ {run['fail']}[/]" ) + right.set_class(not run["files"], "empty") for f in run["files"]: tgt = f.get("target") tlabel = " ".join(f"{k}={v}" for k, v in tgt.items()) if isinstance(tgt, dict) else "" diff --git a/framework/tui/screens/manifest.py b/framework/tui/screens/manifest.py index fdd21a8..dad8322 100644 --- a/framework/tui/screens/manifest.py +++ b/framework/tui/screens/manifest.py @@ -56,6 +56,7 @@ def compose(self) -> ComposeResult: with Horizontal(id="manifest-body"): with Vertical(id="manifest-entries-panel", classes="panel"): yield DataTable(id="manifest-entries") + yield Static("", id="manifest-empty-hint", classes="empty-hint") with VerticalScroll(id="manifest-detail-scroll", classes="panel"): yield Static(render.empty_detail("No fetcher selected."), id="manifest-detail") yield Static(id="manifest-issues") @@ -101,6 +102,7 @@ def rebuild(self) -> None: dt = self.query_one("#manifest-entries", DataTable) if self._manifest is None: dt.clear() + self._set_empty(f"no manifest loaded — press [bold {palette.ACCENT}]m[/] to pick one") self._set_issues(["(no manifest loaded)"]) return @@ -139,6 +141,12 @@ def rebuild(self) -> None: ) row_keys.append(use) + self._set_empty( + None + if entries + else f"manifest is empty — press [bold {palette.ACCENT}]a[/] to add fetchers" + ) + # preserve selection across rebuilds if row_keys: target = self._selected if self._selected in row_keys else row_keys[0] @@ -153,6 +161,13 @@ def rebuild(self) -> None: self._refresh_detail() self._set_issues(self._errors) + def _set_empty(self, hint: Optional[str]) -> None: + """Show the hatched placeholder (with the given message) instead of the + entries table, or the table again when hint is None.""" + if hint: + self.query_one("#manifest-empty-hint", Static).update(hint) + self.query_one("#manifest-entries-panel", Vertical).set_class(bool(hint), "empty") + def _refresh_detail(self) -> None: detail = self.query_one("#manifest-detail", Static) use = self._selected diff --git a/framework/tui/screens/run.py b/framework/tui/screens/run.py index c4a7477..53d0d9a 100644 --- a/framework/tui/screens/run.py +++ b/framework/tui/screens/run.py @@ -55,8 +55,13 @@ def compose(self) -> ComposeResult: with Horizontal(id="run-body"): with Vertical(id="run-status-panel", classes="panel"): yield DataTable(id="run-status") + yield Static( + f"no run yet — [bold {palette.ACCENT}]ctrl+r[/] executes the manifest", + classes="empty-hint", + ) with Vertical(id="run-log-panel", classes="panel"): yield RichLog(id="run-log", markup=False, wrap=True, highlight=False) + yield Static("run output streams here, fetcher by fetcher", classes="empty-hint") yield Static("", id="run-summary") def on_mount(self) -> None: @@ -70,6 +75,7 @@ def on_mount(self) -> None: dt.zebra_stripes = True self._cols = dt.add_columns("fetcher", "mode", "status", "info") self._set_banner(Text("press Ctrl+R (or Run) to execute the manifest", style="dim")) + self._set_empty(True) def focus_default(self) -> None: self.query_one("#btn-run", Button).focus() @@ -84,6 +90,7 @@ def reset_state(self) -> None: self.query_one("#run-status", DataTable).clear() self.query_one("#run-log", RichLog).clear() self._set_banner(Text("press Ctrl+R (or Run) to execute the manifest", style="dim")) + self._set_empty(True) self._refresh_summary() # -- run control ------------------------------------------------------ # @@ -109,10 +116,16 @@ def go(ok: bool) -> None: else: self._start_run() + def _set_empty(self, empty: bool) -> None: + """Swap the status table / log for their hatched placeholders (and back).""" + self.query_one("#run-status-panel", Vertical).set_class(empty, "empty") + self.query_one("#run-log-panel", Vertical).set_class(empty, "empty") + def _start_run(self) -> None: self._running = True self._state = {} self._rows = {} + self._set_empty(False) self.query_one("#run-status", DataTable).clear() self.query_one("#run-log", RichLog).clear() self.query_one("#btn-run", Button).disabled = True @@ -143,6 +156,7 @@ def _handle_event(self, ev: dict) -> None: log = self.query_one("#run-log", RichLog) if etype == "run_start": + self._set_empty(False) # the worker isn't the only caller: synthetic events too self._state = { use: {"status": "queued", "total": None, "ok": 0, "fail": 0, "fanout": False} for use in ev.get("fetchers", []) diff --git a/framework/tui/screens/upload.py b/framework/tui/screens/upload.py index 9fdba34..8109be8 100644 --- a/framework/tui/screens/upload.py +++ b/framework/tui/screens/upload.py @@ -45,13 +45,19 @@ def compose(self) -> ComposeResult: yield DataTable(id="upload-summary") with Vertical(id="upload-log-panel", classes="panel"): yield RichLog(id="upload-log", markup=False, wrap=True, highlight=False) + yield Static( + f"upload progress streams here — [bold {palette.ACCENT}]ctrl+u[/] to upload", + classes="empty-hint", + ) def on_mount(self) -> None: self._uploading = False self._run_dir: str | None = None self._preflight: dict | None = None self.query_one("#upload-summary-panel", Vertical).border_title = "ready to upload" - self.query_one("#upload-log-panel", Vertical).border_title = "log" + log_panel = self.query_one("#upload-log-panel", Vertical) + log_panel.border_title = "log" + log_panel.set_class(True, "empty") table = self.query_one("#upload-summary", DataTable) table.cursor_type = "row" table.zebra_stripes = True @@ -172,6 +178,7 @@ def _start_upload(self, run_dir: str) -> None: self._uploading = True self.query_one("#upload-refresh", Button).disabled = True self.query_one("#upload-submit", Button).disabled = True + self.query_one("#upload-log-panel", Vertical).set_class(False, "empty") self.query_one("#upload-log", RichLog).clear() self._set_banner(Text("uploading to Paramify...", style=palette.WARN)) self._upload_worker(run_dir, self.app.root_path) diff --git a/framework/tui/styles/index.tcss b/framework/tui/styles/index.tcss index 5e7bbb5..f49ce04 100644 --- a/framework/tui/styles/index.tcss +++ b/framework/tui/styles/index.tcss @@ -108,6 +108,24 @@ TabbedContent { text-style: bold; } +/* Hatched empty states: tag a .panel with .empty to swap its table/log for + the instructional .empty-hint placeholder (Posting's empty-state idiom). */ +.empty-hint { + display: none; + height: 1fr; + color: $text-muted; + hatch: right $surface-lighten-1 50%; + content-align: center middle; + text-align: center; + padding: 1 4; +} +.panel.empty > .empty-hint { + display: block; +} +.panel.empty > DataTable, .panel.empty > RichLog { + display: none; +} + /* ---- Catalog ---- */ CatalogPage { height: 1fr; padding: 1 1 0 1; }