Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8de9fe4ee1 |
@@ -1,45 +0,0 @@
|
||||
# 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
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
# PROTOTYPE launcher
|
||||
exec python3 "$(dirname "$0")/tui_prototype.py" "$@"
|
||||
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 78 KiB |
@@ -1,488 +0,0 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,67 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,89 @@
|
||||
<!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"> ▼</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>
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/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
|
||||