Compare commits

..
15 changed files with 2071 additions and 162 deletions
+45
View File
@@ -0,0 +1,45 @@
# PROTOTYPE — graph encoding & hourly drill-down (throwaway)
Answers wayfinder ticket **Choose daily graph encoding and hourly drill-down**
on map **Fenris TUI polish and hourly history**.
Not production code. Do not merge onto main.
## Question
Which graph encoding best communicates hourly writes and a day's usage
without misleading: daily bars vs one candle per day vs rolling hourly strip,
with range -> selected day -> hourly detail -> back navigation.
## Run
./run # or: python3 tui_prototype.py
## Variants (switch with [ and ])
- **A — Daily bars**: one bar per local display day (bytes written), drill
into 24 hourly bars with Enter / click.
- **B — Daily candles**: one candle per day derived from hourly values
(open = first evidenced hour, close = last, high/low = max/min hourly).
Same drill-down.
- **C — Rolling hourly strip**: continuous last-72-hours columns with day
separators; click an hour for its readout (no day level).
## Data states encoded (synthetic)
measured zero (0 B) · gap / no evidence · unallocated usage (measured,
attribution unknown) · partial day ("so far") · deliberate-disable pause band ·
day before monitoring began.
Gaps never render as zero. Unallocated is a separate visual segment, never
spread into hours.
## Framework facts verified
Textual 8.2.8 (installed): no BarChart widget (roadmap-only), Sparkline is
non-interactive. Bars/candles here are a custom Static renderable; mouse
clicks map via widget-local x. No new dependencies.
## Screenshots
screenshots/ holds SVG exports at 80x24 and 140x40 per variant.
Regenerate with: python3 capture.py
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
# PROTOTYPE helper — regenerate screenshots/*.svg via headless Textual run.
import asyncio
from tui_prototype import GraphPrototype
async def shot(app, size, presses, path):
async with app.run_test(size=size) as pilot:
for p in presses:
await pilot.press(p)
svg = app.export_screenshot()
with open(path, "w") as fh:
fh.write(svg)
print("wrote", path)
async def main():
for w, h in ((80, 24), (140, 40)):
for v in "ABC":
presses = ["]"] * "ABC".index(v)
await shot(GraphPrototype(), (w, h), presses,
"screenshots/v%s_%dx%d_range.svg" % (v, w, h))
# drill-down detail (variant A) and a mid-range selection (variant B)
await shot(GraphPrototype(), (80, 24), ["left", "enter"],
"screenshots/vA_80x24_hourly-detail.svg")
await shot(GraphPrototype(), (140, 40), ["]", "left", "left"],
"screenshots/vB_140x40_selected-gap-day.svg")
if __name__ == "__main__":
asyncio.run(main())
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
# PROTOTYPE launcher
exec python3 "$(dirname "$0")/tui_prototype.py" "$@"
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 78 KiB

+488
View File
@@ -0,0 +1,488 @@
#!/usr/bin/env python3
# PROTOTYPE — throwaway. Wayfinder ticket "Choose daily graph encoding and
# hourly drill-down" on map "Fenris TUI polish and hourly history".
# Synthetic data only. Not production code; no tests by design.
#
# Variants (switch with [ / ]):
# A daily bars (day -> Enter -> hourly bars -> Esc back)
# B daily candles (OHLC derived from hourly values, same drill-down)
# C rolling 72-hour strip with day separators (no day level)
#
# Encoding contract under test (accepted hourly-history decision):
# measured zero = 0 B, distinct from gap (no evidence), distinct from
# unallocated usage (measured total, hour attribution unknown).
from __future__ import annotations
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from textual import events
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.widgets import Static
# ---------------------------------------------------------------------------
# Synthetic evidence
# ---------------------------------------------------------------------------
@dataclass
class Hour:
gb: float = 0.0 # measured bytes written; 0.0 == measured zero
coverage: float = 1.0 # share of hour classified known
@dataclass
class Day:
hours: list = field(default_factory=list) # list[Hour | None]; None = gap
unallocated: float = 0.0 # GB measured, attribution unknown
elapsed_h: float = 24.0 # elapsed span (partial < 24)
note: str = ""
pause: str = "" # deliberate-disable band
@property
def evidenced(self):
return [h for h in self.hours if h is not None]
@property
def allocated_gb(self):
return sum(h.gb for h in self.evidenced)
@property
def gap_hours(self):
return sum(1 for h in self.hours if h is None)
def _profile(mult=1.0, zero=False):
shape = [0.3, 0.2, 0.1, 0.1, 0.2, 0.5, 1.4, 2.6, 3.1, 2.2,
1.8, 2.0, 2.4, 3.0, 3.4, 2.8, 2.1, 1.9, 2.3, 1.6,
1.1, 0.8, 0.5, 0.4]
return [Hour(0.0 if zero else round(v * mult, 2), 1.0) for v in shape]
def synth_days(now):
"""14 local display days ending today (partial). Every ticket state present."""
days = []
days.append(Day([None] * 24, note="no evidence — before monitoring began"))
days.append(Day(_profile(1.0)))
g = _profile(0.9)
for i in range(10, 16):
g[i] = None
days.append(Day(g, note="gap 10:00–16:00 (no evidence, not zero)"))
days.append(Day(_profile(0.0), note="measured zero day"))
days.append(Day(_profile(1.2), unallocated=8.5,
note="8.5 GB unallocated (interval attribution unknown)"))
days.append(Day(_profile(4.0), note="heavy write day"))
days.append(Day(_profile(0.7),
pause="paused 09:00–11:00 (deliberate disable — excluded)"))
days.append(Day(_profile(1.1)))
days.append(Day(_profile(0.9)))
m = _profile(1.0)
m[3] = Hour(0.0, 1.0)
m[17] = None
days.append(Day(m, note="zero hour 03:00, gap hour 17:00"))
days.append(Day(_profile(1.3), unallocated=1.9))
days.append(Day(_profile(1.0)))
days.append(Day(_profile(0.8)))
days.append(Day(_profile(1.5)[:14], elapsed_h=14,
note="today — 14 h elapsed, so far"))
assert len(days) == 14
return days
# ---------------------------------------------------------------------------
# Rendering (Rich markup into Static)
# ---------------------------------------------------------------------------
AMBER = "#e0a458" # default-preset graph colour (approved input)
UNALLOC = "#8a7f70" # measured, attribution unknown
GAP = "#5f6b73"
ZERO = "#9bb0bf"
SELECT = "#f2d5a0"
FILL = "█"
EIGHTHS = " ▁▂▃▄▅▆▇█"
def _fmt_gb(v):
if v >= 100:
return "%d GB" % v
return "%s GB" % ("%.1f" % v if v < 10 else "%.0f" % v)
def render_days_bars(days, height, sel):
"""Variant A: one bar per day. Unallocated stacked on top, visually separate."""
n = len(days)
mx = max((d.allocated_gb + d.unallocated) for d in days) or 1.0
grid = [[" "] * n for _ in range(height)]
style = [[None] * n for _ in range(height)]
for i, d in enumerate(days):
if not d.evidenced and not d.unallocated:
for r in range(height):
grid[r][i], style[r][i] = "·", GAP
continue
rows = []
ua_cells = d.unallocated / mx * height
for _ in range(int(ua_cells + 1e-9)):
rows.append(("▒", UNALLOC))
if ua_cells - int(ua_cells) > 0.12:
rows.append((EIGHTHS[int((ua_cells % 1) * 8)], UNALLOC))
cells = (d.allocated_gb + d.unallocated) / mx * height
for _ in range(int(cells + 1e-9)):
rows.append((FILL, AMBER))
if cells - int(cells) > 0.12:
rows.append((EIGHTHS[int((cells % 1) * 8)], AMBER))
if not rows:
rows = [("·", ZERO)]
rr = height - 1
for ch, st in rows:
if rr < 0:
break
grid[rr][i], style[rr][i] = ch, st
rr -= 1
if d.elapsed_h < 24:
rr = height - len(rows)
if 0 <= rr < height:
grid[rr][i], style[rr][i] = "┄", SELECT
lines = []
for r in range(height):
parts = []
for i in range(n):
ch, st = grid[r][i], style[r][i]
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
parts.append(" ")
lines.append("".join(parts).rstrip())
marks = [" "] * (2 * n)
marks[2 * sel] = "▼"
lines.insert(0, "".join(marks).rstrip())
return lines
def render_days_candles(days, height, sel):
"""Variant B: one candle per day. open = first evidenced hour, close = last,
high/low = max/min hourly GB. Body solid when close >= open, hollow otherwise;
partial day renders dashed. 3-wide slot + 1 space."""
n = len(days)
stats = []
for d in days:
ev = [h.gb for h in d.evidenced]
stats.append(None if not ev else
dict(open=ev[0], close=ev[-1], hi=max(ev), lo=min(ev)))
mx = max((s["hi"] for s in stats if s), default=1.0) or 1.0
def row(v):
return height - 1 - min(int(v / mx * (height - 1)), height - 1)
cols = [] # per day: list[height] of 3-char cell strings + styles
for i, s in enumerate(stats):
d = days[i]
if s is None:
cols.append([("╎╎╎", GAP)] * height)
continue
top, bot = row(s["hi"]), row(s["lo"])
body_hi, body_lo = row(max(s["open"], s["close"])), row(min(s["open"], s["close"]))
solid = s["close"] >= s["open"]
partial = d.elapsed_h < 24
body_fill = "▒" if partial else (FILL if solid else "░")
body_st = AMBER if (solid or partial) else GAP
col = []
for r in range(height):
if body_lo <= r <= body_hi:
col.append((body_fill * 3, body_st))
elif top <= r <= bot:
col.append((" │ ", AMBER if solid else GAP))
else:
col.append((" ", None))
cols.append(col)
lines = [(" " * (4 * sel)) + "▼"]
for r in range(height):
parts = []
for i in range(n):
cells, st = cols[i][r]
parts.append(cells if st is None else "[%s]%s[/]" % (st, cells))
parts.append(" ")
lines.append("".join(parts).rstrip())
return lines
def render_hours_bars(day, height, sel):
"""Day detail: one bar per hour. Gaps never zero."""
n = len(day.hours)
mx = max([h.gb for h in day.evidenced] + [day.unallocated, 1e-9])
grid = [[" "] * n for _ in range(height)]
style = [[None] * n for _ in range(height)]
for i, h in enumerate(day.hours):
if h is None:
grid[height - 1][i], style[height - 1][i] = "░", GAP
continue
if h.gb == 0:
grid[height - 1][i], style[height - 1][i] = "·", ZERO
continue
cells = h.gb / mx * height
rr = height - 1
for _ in range(int(cells + 1e-9)):
if rr < 0:
break
grid[rr][i], style[rr][i] = FILL, AMBER
rr -= 1
if cells % 1 > 0.12 and rr >= 0:
grid[rr][i], style[rr][i] = EIGHTHS[int((cells % 1) * 8)], AMBER
lines = []
for r in range(height):
parts = []
for i in range(n):
ch, st = grid[r][i], style[r][i]
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
lines.append("".join(parts).rstrip())
marks = [" "] * n
marks[sel % n] = "▼"
lines.insert(0, "".join(marks).rstrip())
return lines
def render_strip(days, height, sel):
"""Variant C: rolling hourly strip, last 72 h, day separators."""
flat = [] # (day_idx, hour_idx, Hour|None|'future')
for di, d in enumerate(days[-4:]):
real = len(days) - 4 + di
for hi in range(24):
h = d.hours[hi] if hi < len(d.hours) else "future"
flat.append((real, hi, h))
flat = flat[-72:]
mx = max([f[2].gb for f in flat if f[2] not in (None, "future")] + [1e-9])
cols = []
for _, _, h in flat:
col = [(" ", None)] * height
if h == "future":
col[height - 1] = ("·", None)
elif h is None:
col[height - 1] = ("░", GAP)
elif h.gb == 0:
col[height - 1] = ("·", ZERO)
else:
cells = h.gb / mx * height
rr = height - 1
for _ in range(int(cells + 1e-9)):
if rr < 0:
break
col[rr] = (FILL, AMBER)
rr -= 1
if cells % 1 > 0.12 and rr >= 0:
col[rr] = (EIGHTHS[int((cells % 1) * 8)], AMBER)
cols.append(col)
lines = []
for r in range(height):
parts = []
prev = None
for i, f in enumerate(flat):
if prev is not None and f[0] != prev:
parts.append("[%s]│[/]" % SELECT)
prev = f[0]
ch, st = cols[i][r]
parts.append(ch if st is None else "[%s]%s[/]" % (st, ch))
lines.append("".join(parts).rstrip())
marks = []
prev = None
for i, f in enumerate(flat):
if prev is not None and f[0] != prev:
marks.append(" ")
prev = f[0]
marks.append("▼" if i == sel else " ")
lines.insert(0, "".join(marks).rstrip())
return lines, flat
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
VARIANT_NAMES = {"A": "Daily bars", "B": "Daily candles", "C": "Rolling 72 h strip"}
GH = 10 # graph rows
class GraphPrototype(App):
TITLE = "PROTOTYPE — usage-history graph encoding"
CSS = """
#col { height: 100%; }
#hdr { height: 2; color: $text-muted; }
#graph { height: auto; padding: 0 1; }
#readout { height: auto; padding: 0 1; }
#legend { height: 3; padding: 0 1; color: $text-muted; }
#footer { dock: bottom; height: 2; background: $panel; padding: 0 1; }
"""
BINDINGS = [
Binding("[", "prev_variant", "variant ←"),
Binding("]", "next_variant", "variant →"),
Binding("left", "left", "←"),
Binding("right", "right", "→"),
Binding("enter", "drill", "open"),
Binding("escape,backspace", "back", "back"),
Binding("q", "quit", "quit"),
]
def __init__(self):
super().__init__()
self.now = datetime.now().astimezone()
self.days = synth_days(self.now)
self.variant = "A"
self.view = "days" # days | hours (A/B); strip (C)
self.sel_day = len(self.days) - 1
self.sel_hour = 13
self.sel_strip = 71
def day_label(self, i):
d = (self.now - timedelta(days=len(self.days) - 1 - i)).date()
return d, d.strftime("%a %d %b")
def tz_label(self):
off = self.now.strftime("%z")
return "Local · UTC%s · %s" % (off[:3] + ":" + off[3:], time.tzname[0])
def day_readout(self, d, i):
_, lab = self.day_label(i)
if not d.evidenced and not d.unallocated:
return "%s — no evidence (not zero): %s" % (lab, d.note or "")
bits = ["%s — %s written" % (lab, _fmt_gb(d.allocated_gb))]
span = 24 if d.elapsed_h == 24 else len(d.hours)
bits.append("%d/%d h evidenced" % (span - d.gap_hours, span))
if d.unallocated:
bits.append("+ %s unallocated" % _fmt_gb(d.unallocated))
if d.elapsed_h < 24:
bits.append("partial · %d h elapsed · so far" % int(d.elapsed_h))
cov = sum(h.coverage for h in d.evidenced) / max(len(d.hours), 1)
bits.append("coverage %.0f%%" % (100 * cov))
txt = " · ".join(bits)
if d.note:
txt += " — %s" % d.note
if d.pause:
txt += " — %s" % d.pause
return txt
def compose(self) -> ComposeResult:
with Vertical(id="col"):
yield Static("", id="hdr")
yield Static("", id="graph")
yield Static("", id="readout")
yield Static("", id="legend")
yield Static("", id="footer")
def on_mount(self) -> None:
self.refresh_all()
def refresh_all(self):
self.query_one("#hdr", Static).update(
"🐺 Fenris · usage history · %s · writes per hour" % self.tz_label())
g = self.query_one("#graph", Static)
ro = self.query_one("#readout", Static)
if self.view == "hours":
d = self.days[self.sel_day]
lines = render_hours_bars(d, GH, self.sel_hour)
lines.append("".join(("%02d" % i) if i % 3 == 0 else " " for i in range(len(d.hours))))
lines.append(" midnight → 23:00 local")
g.update("\n".join(lines))
h = d.hours[self.sel_hour]
if h is None:
dtxt = "hour %02d:00 — no evidence (not zero)" % self.sel_hour
elif h.gb == 0:
dtxt = "hour %02d:00 — 0 B measured" % self.sel_hour
else:
dtxt = "hour %02d:00 — %s written · coverage %.0f%%" % (
self.sel_hour, _fmt_gb(h.gb), 100 * h.coverage)
_, lab = self.day_label(self.sel_day)
extra = ""
if d.unallocated:
extra = " · %s unallocated (shown separately, never spread into hours)" % _fmt_gb(d.unallocated)
ro.update("%s ▸ %s%s" % (lab, dtxt, extra))
elif self.variant in ("A", "B"):
lines = (render_days_bars(self.days, GH, self.sel_day) if self.variant == "A"
else render_days_candles(self.days, GH, self.sel_day))
_, last = self.day_label(len(self.days) - 1)
lines.append(" " + " ".join(
self.day_label(i)[1].split()[1] for i in range(len(self.days))))
g.update("\n".join(lines))
ro.update(self.day_readout(self.days[self.sel_day], self.sel_day))
else:
lines, flat = render_strip(self.days, GH, self.sel_strip)
g.update("\n".join(lines))
txt = ""
if 0 <= self.sel_strip < len(flat):
di, hi, h = flat[self.sel_strip]
_, lab = self.day_label(di)
if h == "future":
txt = "future — not counted"
elif h is None:
txt = "no evidence (not zero)"
elif h.gb == 0:
txt = "0 B measured"
else:
txt = "%s written · coverage %.0f%%" % (_fmt_gb(h.gb), 100 * h.coverage)
ro.update("%s %02d:00 — %s" % (lab, hi, txt))
self.query_one("#legend", Static).update(
"legend: [%(a)s]█ allocated[/] [%(u)s]▒ unallocated (attribution unknown)[/] "
"[%(g)s]░ gap · no evidence — never zero[/] [%(z)s]· measured 0 B[/] "
"[%(s)s]┄ partial day (so far)[/]" % dict(a=AMBER, u=UNALLOC, g=GAP, z=ZERO, s=SELECT))
v = "%s: %s" % (self.variant, VARIANT_NAMES[self.variant])
mode = "hourly strip" if self.variant == "C" else (
"day detail" if self.view == "hours" else "range view")
self.query_one("#footer", Static).update(
"PROTOTYPE ▸ %s ▸ %s [ / ] variant · ←/→ select · Enter open · Esc back · click" % (v, mode))
def action_prev_variant(self):
order = "ABC"
self.variant = order[(order.index(self.variant) - 1) % 3]
self.view = "strip" if self.variant == "C" else "days"
self.refresh_all()
def action_next_variant(self):
order = "ABC"
self.variant = order[(order.index(self.variant) + 1) % 3]
self.view = "strip" if self.variant == "C" else "days"
self.refresh_all()
def action_left(self):
if self.view == "hours":
self.sel_hour = max(0, self.sel_hour - 1)
elif self.variant == "C":
self.sel_strip = max(0, self.sel_strip - 1)
else:
self.sel_day = max(0, self.sel_day - 1)
self.refresh_all()
def action_right(self):
if self.view == "hours":
self.sel_hour = min(23, self.sel_hour + 1)
elif self.variant == "C":
self.sel_strip = min(71, self.sel_strip + 1)
else:
self.sel_day = min(len(self.days) - 1, self.sel_day + 1)
self.refresh_all()
def action_drill(self):
if self.variant in ("A", "B") and self.view == "days":
self.view = "hours"
self.sel_hour = 13
self.refresh_all()
def action_back(self):
if self.view == "hours":
self.view = "days"
self.refresh_all()
def on_click(self, event: events.Click) -> None:
w = event.widget
if w is None or w.id != "graph":
return
x = event.x
if self.view == "hours":
self.sel_hour = max(0, min(23, x))
elif self.variant == "C":
self.sel_strip = max(0, min(71, x))
else:
self.sel_day = max(0, min(len(self.days) - 1, x // 2))
self.refresh_all()
if __name__ == "__main__":
GraphPrototype().run()
@@ -1,67 +0,0 @@
# PROTOTYPE — titlebox, drive health and colour presets (throwaway)
Answers wayfinder ticket **Approve titlebox, health layout and colour presets**
on map **Fenris TUI polish and hourly history**.
Not production code. Do not merge onto main.
## Question
What concrete layout and preset contract makes Fenris feel polished while
preserving monitoring clarity and accessibility?
## Run
```sh
./run
# then open http://127.0.0.1:8765/prototype/titlebox-health-presets/
```
Or open `index.html` directly.
## What this prototype asks the human to react to
Use the bottom control bar to switch:
- preset: **Amber**, **Nord**, **High Contrast**;
- status: Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale,
Unknown;
- terminal size: 80×24 baseline, 140×40 wide, or a constrained/narrow layout;
- title glyph: wolf emoji or fallback text;
- motion: normal blink or reduced motion.
## Contract under test
1. **Title hierarchy** — the only maker-credit surface is the top titlebox,
`🐺 Fenris by Bongbetic`; the old service-strip credit is removed. The
lifespan headline stays a product data surface, not the app title.
2. **Glyph fallback** — when the wolf is unsupported, explicitly fall back to
`Fenris by Bongbetic` rather than rendering tofu or disturbing border width.
3. **Drive health vs settings** — vendor wear lives in **Drive health** beside
temperature/spare/error facts. **Settings** stays read-only for device,
baseline, retention and display preferences.
4. **Palette roles** — themes style chrome, graph and accent colours; status
semantics (green/amber/red/grey plus glyph+text) outrank the theme and are
never the sole carrier.
5. **Display preferences** — preset and reduced-motion preferences are scoped
to the unprivileged TUI user, not `/etc/fenris/fenris.conf` and not the
observation store. Proposed persistence: `${XDG_CONFIG_HOME:-~/.config}/fenris/tui.json`.
6. **Reduced motion** — normal Monitoring blinks only the dot at the settled
750 ms cadence; reduced motion renders the same `● Monitoring` state steady.
7. **Constrained terminal** — below 80×24, the graph region hides and leaves a
one-line textual summary plus `graph needs ≥80×24`; status, title, drive
health and action/quit affordances stay visible.
## Framework facts verified
Current Textual docs via Context7 library `/websites/textual_textualize_io`:
- `App.register_theme(theme)` registers a theme; setting `App.theme` activates
it. Textual themes generate CSS variables from base colours.
- `$text`, `$text-muted`, `$text-disabled` and `color: auto` exist to maintain
text legibility against theme backgrounds.
- Mouse events expose coordinates relative to the screen or widget; focusable
widgets can be resolved at coordinates, and Pilot can click widgets/offsets
for automated proof of keyboard+mouse paths.
Local repo lockfile still pins `textual==8.2.8`; this prototype is static HTML
so it deliberately does not depend on the Python environment.
@@ -1,89 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fenris titlebox, health layout and presets prototype</title>
<style>
:root{color-scheme:dark;font-family:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace}
*{box-sizing:border-box}body{margin:0;background:#06070a;color:var(--text);font:14px/1.35 ui-monospace,SFMono-Regular,Consolas,Menlo,monospace}.note{max-width:1180px;margin:18px auto 8px;padding:0 16px;color:#c0c6d0}.terminal{width:min(var(--term-w),calc(100vw - 32px));min-height:var(--term-h);margin:0 auto 108px;background:var(--bg);border:1px solid var(--line);box-shadow:0 24px 80px #000;border-radius:8px;overflow:hidden}.screen{padding:8px}.titlebox{height:38px;border:2px solid var(--accent);display:flex;align-items:center;justify-content:center;font-weight:900;font-size:17px;letter-spacing:.01em;color:var(--title);background:linear-gradient(180deg,var(--title-bg),transparent)}.titlebox .fallback{display:none}body[data-glyph=plain] .titlebox .emoji{display:none}body[data-glyph=plain] .titlebox .fallback{display:inline}.auth{margin-top:6px;color:var(--muted);border-left:4px solid var(--accent);padding:4px 8px;background:var(--panel-soft)}.grid{display:grid;grid-template-columns:3fr 2fr;gap:8px;margin-top:8px}.pane{border:1px solid var(--line);background:var(--panel);padding:9px 11px;min-height:130px}.headline{grid-column:1/-1;min-height:92px}.pane-title{color:var(--muted);margin:-19px 0 6px -2px;background:var(--panel);width:max-content;padding:0 6px}.big{font-size:18px;font-weight:900}.muted{color:var(--muted)}.accent{color:var(--accent)}.status-line{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-top:5px}.state{font-weight:900}.state .glyph{display:inline-block;width:2ch;text-align:center}.state.monitoring{color:var(--ok)}body:not(.reduced) .state.monitoring .glyph{animation:blink 1.5s steps(1,end) infinite}.state.collecting{color:var(--ok)}.state.paused,.state.waiting{color:var(--warn)}.state.interrupted,.state.error,.state.stale{color:var(--bad)}.state.unknown{color:var(--neutral)}@keyframes blink{50%{opacity:.18}}.pause-banner{display:none;margin-top:8px;border:2px solid var(--warn);background:var(--warn-bg);color:var(--warn-text);padding:7px 10px;font-weight:900}body[data-state=paused] .pause-banner{display:block}.facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:5px;margin-top:7px}.fact{border:1px solid var(--line-dim);padding:4px 6px;background:var(--panel-soft)}.fact b{color:var(--muted);font-weight:700}.history{min-height:240px}.range{display:flex;gap:5px;align-items:center;flex-wrap:wrap}.pill{border:1px solid var(--line);padding:2px 7px;border-radius:999px;background:var(--panel-soft);color:var(--text)}.pill.active{border-color:var(--accent);color:var(--accent);font-weight:900}.bars{display:grid;grid-template-columns:repeat(14,1fr);gap:3px;align-items:end;height:82px;margin:8px 0 2px;border-bottom:1px solid var(--line)}.bar{display:flex;flex-direction:column;justify-content:flex-end;align-items:center;min-width:0}.bar span{display:block;width:100%;text-align:center}.allocated{color:var(--graph)}.unalloc{color:var(--unalloc)}.gap{color:var(--gap)}.zero{color:var(--zero)}.partial{border-top:2px dashed var(--select)}.selected{outline:1px solid var(--select);outline-offset:1px}.day-row{display:grid;grid-template-columns:repeat(14,1fr);gap:3px;color:var(--muted);font-size:12px;text-align:center}.marker{color:var(--select);text-align:center;letter-spacing:.5ch}.legend{color:var(--muted);font-size:12px;margin-top:7px}.readout{border-top:1px dashed var(--line);padding-top:7px;margin-top:7px}.health{display:grid;grid-template-rows:auto auto;gap:8px}.subbox{border:1px solid var(--line-dim);padding:7px 8px;background:var(--panel-soft)}.subbox h3{margin:0 0 5px;font-size:14px;color:var(--accent)}.settings-grid{display:grid;grid-template-columns:auto 1fr;gap:3px 9px}.setting-control{color:var(--accent);font-weight:900}.service{margin-top:8px;border:1px solid var(--line);background:var(--panel);display:grid;grid-template-columns:1fr auto;gap:8px;padding:8px 10px}.continuity{margin-top:3px}.actions{display:flex;align-items:center;gap:10px;white-space:nowrap}.quit{border:2px solid var(--text);padding:3px 8px;font-weight:900;color:var(--text);background:var(--quit-bg)}.long-reason{display:none;color:var(--bad);margin-top:5px}body[data-state=error] .long-reason,body[data-state=stale] .long-reason,body[data-state=interrupted] .long-reason{display:block}.narrow-only{display:none}.switcher{position:fixed;z-index:10;bottom:14px;left:50%;transform:translateX(-50%);display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:center;max-width:calc(100vw - 20px);background:#f5f7fb;color:#151923;border-radius:999px;padding:8px 11px;box-shadow:0 8px 32px #000}.switcher label{font-weight:800}.switcher select,.switcher button{font:700 13px ui-monospace,monospace;border:1px solid #c8ced9;border-radius:999px;background:white;color:#151923;padding:6px 9px}.switcher button.active{background:#151923;color:white}.switcher .check{display:flex;align-items:center;gap:4px}.switcher input{accent-color:#151923}
body[data-preset=amber]{--bg:#0d0b07;--panel:#17120b;--panel-soft:#20180f;--title-bg:#22170a;--title:#f8e1ad;--text:#f3eadb;--muted:#c2ad8a;--line:#8a6432;--line-dim:#5a4022;--accent:#f0b35a;--graph:#f0b35a;--unalloc:#d38f3f;--gap:#6f604e;--zero:#d8c5a3;--select:#f8e08e;--ok:#5ee08b;--warn:#ffd166;--bad:#ff6b6b;--neutral:#b8bec9;--warn-bg:#3a2c0c;--warn-text:#fff3c7;--quit-bg:#2a1e10;--term-w:960px;--term-h:650px}
body[data-preset=nord]{--bg:#0b1118;--panel:#111827;--panel-soft:#162033;--title-bg:#172235;--title:#e5e9f0;--text:#e5e9f0;--muted:#a9b4c8;--line:#4c566a;--line-dim:#354154;--accent:#88c0d0;--graph:#88c0d0;--unalloc:#d08770;--gap:#677285;--zero:#c5ccd8;--select:#ebcb8b;--ok:#5ee08b;--warn:#ffd166;--bad:#ff6b6b;--neutral:#b8bec9;--warn-bg:#2f2a17;--warn-text:#fff0bd;--quit-bg:#172235;--term-w:960px;--term-h:650px}
body[data-preset=high]{--bg:#000;--panel:#000;--panel-soft:#111;--title-bg:#000;--title:#fff;--text:#fff;--muted:#d8d8d8;--line:#fff;--line-dim:#888;--accent:#00ffff;--graph:#ffff00;--unalloc:#ffaf00;--gap:#9b9b9b;--zero:#fff;--select:#00ffff;--ok:#00ff00;--warn:#ffff00;--bad:#ff4040;--neutral:#fff;--warn-bg:#333300;--warn-text:#fff;--quit-bg:#000;--term-w:960px;--term-h:650px}
body[data-width=wide]{--term-w:1180px;--term-h:760px}.wide-note{display:none}body[data-width=wide] .wide-note{display:inline}
body[data-width=narrow]{--term-w:620px;--term-h:600px;font-size:13px}body[data-width=narrow] .grid{grid-template-columns:1fr}body[data-width=narrow] .headline{grid-column:auto}body[data-width=narrow] .facts{grid-template-columns:repeat(2,minmax(0,1fr))}body[data-width=narrow] .bars,body[data-width=narrow] .day-row,body[data-width=narrow] .marker,body[data-width=narrow] .legend{display:none}body[data-width=narrow] .narrow-only{display:block;border:1px dashed var(--line);padding:8px;margin:8px 0;color:var(--warn)}body[data-width=narrow] .history{min-height:auto}body[data-width=narrow] .service{grid-template-columns:1fr}body[data-width=narrow] .actions{white-space:normal;justify-content:space-between}.sr{position:absolute;left:-10000px}
</style>
</head>
<body data-preset="amber" data-state="monitoring" data-width="normal" data-glyph="emoji">
<p class="note"><b>PROTOTYPE:</b> titlebox, health/settings grouping, theme presets and status accessibility for <i>Approve titlebox, health layout and colour presets</i>. Static throwaway; no production code.</p>
<main class="terminal" aria-label="Fenris terminal mockup">
<div class="screen">
<div class="titlebox" aria-label="Application title"><span class="emoji">🐺 Fenris by Bongbetic</span><span class="fallback">Fenris by Bongbetic</span></div>
<div class="auth">privileged actions will prompt for authentication (polkit) <span class="wide-note">· clears after first refresh</span></div>
<section class="pane headline">
<div class="pane-title">lifespan + monitoring</div>
<div class="big">Usage-adjusted theoretical lifespan: <span class="accent">8 yr 103 d remaining</span></div>
<div>if current habits continue · sustained regime: 46 days · scenario range: 7d 12.4 / 28d 11.1 / 90d 10.7 GB/day</div>
<div class="status-line"><div class="state monitoring" id="stateLine"><span class="glyph">●</span><span id="stateLabel">Monitoring</span></div><div id="stateReason">Last sample: 4 min ago · blink means enabled + fresh, not data refresh</div></div>
<div class="long-reason" id="longReason">last run failed (exit 3) · last good sample 4 min ago · collection stopped outside Fenris details stay visible without changing the status vocabulary</div>
<div class="pause-banner">‖ Paused — monitoring paused · paused time excluded from your usage habit</div>
<div class="facts"><div class="fact"><b>Boot</b><br><span id="bootFact">on</span></div><div class="fact"><b>Runtime</b><br><span id="runtimeFact">timer active</span></div><div class="fact"><b>Last collect</b><br><span id="collectFact">ok · 4 min ago</span></div><div class="fact"><b>Freshness</b><br><span id="freshFact">fresh</span></div></div>
</section>
<div class="grid">
<section class="pane history" tabindex="0" aria-label="Usage history graph pane">
<div class="pane-title">usage history</div>
<div class="range"><b>usage history · Local · UTC+05:30 · Asia/Kolkata</b><span class="pill">7</span><span class="pill active">14</span><span class="pill">28</span><span class="pill">90</span></div>
<div class="narrow-only">14-day write history: 9.2–18.6 GB/day · selected Wed 09 Sep · graph needs ≥80×24</div>
<div class="bars" aria-hidden="true">
<div class="bar"><span class="gap">░</span><span class="gap">░</span></div><div class="bar"><span class="zero">·</span></div><div class="bar"><span class="allocated">█</span><span class="allocated">█</span></div><div class="bar"><span class="allocated">█</span><span class="unalloc">▒</span><span class="unalloc">▒</span></div><div class="bar selected"><span class="allocated">█</span><span class="allocated">█</span><span class="allocated">█</span><span class="unalloc">▒</span></div><div class="bar"><span class="allocated">█</span></div><div class="bar"><span class="allocated">█</span><span class="allocated">█</span></div><div class="bar"><span class="gap">░</span><span class="gap">░</span><span class="gap">░</span></div><div class="bar"><span class="allocated">█</span><span class="allocated">█</span><span class="allocated">█</span><span class="allocated">█</span></div><div class="bar"><span class="allocated">█</span><span class="unalloc">▒</span></div><div class="bar"><span class="zero">·</span></div><div class="bar"><span class="allocated">█</span><span class="allocated">█</span></div><div class="bar partial"><span class="allocated">█</span><span class="allocated">█</span><span class="unalloc">▒</span></div><div class="bar partial"><span class="allocated">█</span></div>
</div>
<div class="marker">&nbsp;&nbsp;&nbsp;&nbsp;▼</div>
<div class="day-row"><span>27</span><span>28</span><span>29</span><span>30</span><span>31</span><span>01</span><span>02</span><span>03</span><span>04</span><span>05</span><span>06</span><span>07</span><span>08</span><span>09</span></div>
<div class="legend">legend: <span class="allocated">█ allocated</span> · <span class="unalloc">▒ unallocated</span> · <span class="gap">░ gap/no evidence</span> · <span class="zero">· 0 B</span> · ┄ partial · ▼ selected</div>
<div class="readout"><b>Wed 09 Sep</b> · writes 14.2 GB allocated + 2.0 GB unallocated · 21 evidenced hours · coverage 87% · partial · 13 h elapsed · so far<br><span class="muted">Enter/click drills into hourly detail; Esc/Backspace returns. Footer shows graph keys while graph is focused.</span></div>
</section>
<section class="pane health" tabindex="0" aria-label="Drive health and settings pane">
<div class="subbox"><h3>Drive health</h3><b>Samsung SSD 990 PRO</b><br>temperature 41°C · spare 100%<br>media errors 0 · unsafe shutdowns 2<br>power-on 1,184 h · 46 cycles · 2 TB<br><b>vendor wear</b>: 3% used · 18.4 TB written <span class="muted">(context, not a second projection)</span></div>
<div class="subbox"><h3>Settings</h3><div class="settings-grid"><span>device</span><span>/dev/disk/by-id/nvme-Samsung_990</span><span>baseline</span><span>verified override · 1,200 TBW</span><span>retention</span><span>raw 14 d · intervals/hours/days indefinite</span><span>theme</span><span class="setting-control" id="themeText">Amber preset · user preference</span><span>motion</span><span class="setting-control" id="motionText">normal blink</span></div></div>
</section>
</div>
<section class="service" aria-label="Service strip and actions">
<div><b>CONTINUITY</b> <span id="continuity">monitoring: active in background · persists across reboots</span><div class="continuity muted">p pause · r resume · c collect · d disclosures · graph focus: ←/→ select · Enter drill · 1/2/3/4 ranges · t preset · m motion</div></div>
<div class="actions"><span class="quit">q QUIT TUI</span></div>
</section>
</div>
</main>
<nav class="switcher" aria-label="Prototype controls">
<label>preset <select id="preset"><option value="amber">Amber</option><option value="nord">Nord</option><option value="high">High Contrast</option></select></label>
<label>state <select id="state"><option value="monitoring">Monitoring</option><option value="collecting">Collecting</option><option value="paused">Paused</option><option value="waiting">Waiting</option><option value="interrupted">Interrupted</option><option value="error">Error</option><option value="stale">Stale</option><option value="unknown">Unknown</option></select></label>
<label>size <select id="width"><option value="normal">80×24</option><option value="wide">140×40</option><option value="narrow">narrow</option></select></label>
<button id="glyph">wolf title</button>
<label class="check"><input id="motion" type="checkbox"> reduced motion</label>
</nav>
<script>
const states={
monitoring:{glyph:'●',label:'Monitoring',cls:'monitoring',reason:'Last sample: 4 min ago · blink means enabled + fresh, not data refresh',boot:'on',runtime:'timer active',collect:'ok · 4 min ago',fresh:'fresh',cont:'monitoring: active in background · persists across reboots'},
collecting:{glyph:'◐',label:'Collecting',cls:'collecting',reason:'run in flight (≤90 s) · underlying state: monitoring',boot:'on',runtime:'service activating',collect:'running now',fresh:'fresh',cont:'monitoring: active in background · persists across reboots'},
paused:{glyph:'‖',label:'Paused',cls:'paused',reason:'monitoring paused — paused time excluded from your usage habit',boot:'off',runtime:'timer inactive',collect:'ok before pause',fresh:'last sample 49 days ago',cont:'monitoring: does not start on next boot'},
waiting:{glyph:'○',label:'Waiting',cls:'waiting',reason:'awaiting another sample · first graph data is independent of lifespan warm-up',boot:'on',runtime:'timer active',collect:'ok · one sample',fresh:'empty / insufficient pair',cont:'monitoring: active in background · persists across reboots'},
interrupted:{glyph:'⊘',label:'Interrupted',cls:'interrupted',reason:'collection stopped outside Fenris — monitoring period still open',boot:'on',runtime:'timer inactive',collect:'unknown stop',fresh:'last sample 3 days ago',cont:'enabled but not currently collecting'},
error:{glyph:'✖',label:'Error',cls:'error',reason:'last run failed (exit 3) · last good sample 4 min ago',boot:'on',runtime:'timer active',collect:'FAILED exit 3',fresh:'fresh data, failed last run',cont:'monitoring: active in background · persists across reboots'},
stale:{glyph:'◌',label:'Stale',cls:'stale',reason:'last sample 3 days ago · timer active, no failure recorded',boot:'on',runtime:'timer active',collect:'ok then silent',fresh:'stale',cont:'monitoring: active in background · persists across reboots'},
unknown:{glyph:'?',label:'Unknown',cls:'unknown',reason:'service state unavailable',boot:'unknown',runtime:'unknown',collect:'unknown',fresh:'store facts unavailable',cont:'service state unavailable'}
};
function apply(){
const preset=document.getElementById('preset').value; const state=document.getElementById('state').value; const width=document.getElementById('width').value;
document.body.dataset.preset=preset; document.body.dataset.state=state; document.body.dataset.width=width;
const s=states[state]; const line=document.getElementById('stateLine'); line.className='state '+s.cls; line.querySelector('.glyph').textContent=s.glyph; document.getElementById('stateLabel').textContent=s.label; document.getElementById('stateReason').textContent=s.reason;
document.getElementById('bootFact').textContent=s.boot; document.getElementById('runtimeFact').textContent=s.runtime; document.getElementById('collectFact').textContent=s.collect; document.getElementById('freshFact').textContent=s.fresh; document.getElementById('continuity').textContent=s.cont;
document.getElementById('themeText').textContent={amber:'Amber preset · user preference',nord:'Nord preset · user preference',high:'High Contrast preset · user preference'}[preset];
const reduced=document.getElementById('motion').checked; document.body.classList.toggle('reduced',reduced); document.getElementById('motionText').textContent=reduced?'reduced motion · steady status dot':'normal blink';
const params=new URLSearchParams({preset,state,width,glyph:document.body.dataset.glyph,motion:reduced?'reduced':'normal'}); history.replaceState(null,'','?'+params);
}
const params=new URLSearchParams(location.search); for(const id of ['preset','state','width']) if(params.get(id)) document.getElementById(id).value=params.get(id)); if(params.get('glyph')==='plain') document.body.dataset.glyph='plain'; if(params.get('motion')==='reduced') document.getElementById('motion').checked=true;
document.getElementById('glyph').onclick=()=>{document.body.dataset.glyph=document.body.dataset.glyph==='emoji'?'plain':'emoji'; document.getElementById('glyph').textContent=document.body.dataset.glyph==='emoji'?'wolf title':'plain title'; apply()};
for(const id of ['preset','state','width','motion']) document.getElementById(id).addEventListener('change',apply);
apply();
</script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
# PROTOTYPE: static local server; no dependencies or persistence.
set -eu
cd "$(git rev-parse --show-toplevel)"
printf '%s\n' 'Open http://127.0.0.1:8765/prototype/titlebox-health-presets/?preset=amber&state=monitoring'
exec python3 -m http.server 8765 --bind 127.0.0.1