App shell with sidebar and theme switch; attribute-set management; project rename; new pages for projects, runbooks and activity
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 5s
Work Records / validate (push) Successful in 12s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
tegwick 2026-09-29 17:38:06 +02:00
parent a6ee6a0a6e
commit 6579618737
9 changed files with 663 additions and 13 deletions

View file

@ -14,13 +14,19 @@ from . import pack as P, run as R
from . import ui_projects as UP
from . import ui_v2 as UI2
from . import activity as ACT, user as USER
from . import ui_attrs as UA
from .attrs import Registry
LIGHT = "--fg:#141414;--bg:#fbfbf9;--mut:#6b6b66;--line:#d9d9d2;--card:#fff;--hi:#f2e35a;--ok:#1a7f37;--warn:#9a6700;--bad:#c62828;--side:#f3f3ef"
DARK = "--fg:#ececea;--bg:#121212;--mut:#9a9a94;--line:#2e2e2b;--card:#191918;--hi:#f2e35a;--ok:#56d364;--warn:#e3b341;--bad:#ff7b72;--side:#0e0e0d"
CSS = """
:root{--fg:#141414;--bg:#fbfbf9;--mut:#6b6b66;--line:#d9d9d2;--card:#fff;--hi:#f2e35a;--ok:#1a7f37;--warn:#9a6700;--bad:#c62828;--radius:6px}
@media(prefers-color-scheme:dark){:root{--fg:#ececea;--bg:#121212;--mut:#9a9a94;--line:#2e2e2b;--card:#191918;--hi:#f2e35a;--ok:#56d364;--warn:#e3b341;--bad:#ff7b72}}
:root{%s;--radius:6px;color-scheme:light}
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){%s;color-scheme:dark}}
:root[data-theme=dark]{%s;color-scheme:dark}
:root[data-theme=light]{color-scheme:light}
""" % (LIGHT, DARK, DARK) + """
*{box-sizing:border-box}
body{font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;max-width:56rem;margin:2rem auto;padding:0 1.2rem;color:var(--fg);background:var(--bg)}
body{font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;margin:0;color:var(--fg);background:var(--bg)}
h1{font-size:1.6rem;font-weight:650;letter-spacing:-.01em;margin:.4rem 0 .6rem}
h2{font-size:1.1rem;font-weight:650;margin:1.8rem 0 .5rem;padding-bottom:.25rem;border-bottom:1px solid var(--line)}
h3{font-size:.95rem;margin:1.2rem 0 .3rem}
@ -42,6 +48,7 @@ table{border-collapse:collapse;width:100%;margin:.5rem 0;background:var(--card);
td,th{border-bottom:1px solid var(--line);padding:.4rem .6rem;text-align:left;vertical-align:top}th{font:11px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.06em;color:var(--mut)}
tr:last-child td{border-bottom:0}
table.data td:first-child{width:42%}
table.edit input,table.edit select{width:100%}
td label{display:block}
td input:not([type=checkbox]):not([type=hidden]):not([type=radio]){width:100%}
.row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}.grow{flex:1;min-width:12rem}[hidden]{display:none}
@ -70,16 +77,47 @@ a.chip:hover{background:var(--hi);color:#141414;border-color:var(--hi)}.chip.on{
.pill[data-s=nil],.pill[data-s=todo],.pill[data-s=open],.pill[data-s=planned],.pill[data-s="not checked"],.pill[data-s=not-applied]{color:var(--mut);border-style:dashed}
.feed{list-style:none;padding:0}.feed li{padding:.3rem 0;border-bottom:1px dashed var(--line)}
.checks{list-style:none;padding:0;margin:.4rem 0}.checks li{padding:.35rem 0;border-bottom:1px dashed var(--line)}.checks li:last-child{border:0}
#current{scroll-margin-top:1rem}details>summary{list-style-position:outside}
#current{scroll-margin-top:1rem}
.app{display:grid;grid-template-columns:15.5rem minmax(0,1fr);min-height:100vh}
main{max-width:58rem;width:100%;padding:1.8rem 1.6rem 4rem}
.side{position:sticky;top:0;align-self:start;height:100vh;display:flex;flex-direction:column;gap:.2rem;padding:1.1rem .8rem;background:var(--side);border-right:1px solid var(--line)}
.brand{font:12px ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;text-decoration:none;padding:.3rem .6rem 1rem;color:var(--fg)}
.side nav{display:flex;flex-direction:column;gap:.1rem}
.side a[data-nav]{display:block;padding:.4rem .7rem;border-radius:var(--radius);text-decoration:none;color:var(--mut)}
.side a[data-nav]:hover{background:var(--hi);color:#141414}
.side a[data-nav][aria-current=page]{background:var(--card);color:var(--fg);font-weight:650;box-shadow:inset 3px 0 0 var(--fg)}
.side-foot{margin-top:auto;display:flex;flex-direction:column;gap:.5rem;border-top:1px solid var(--line);padding-top:.8rem}
.side-foot a.me{display:flex;gap:.5rem;align-items:center}
.avatar{display:inline-grid;place-items:center;width:1.7rem;height:1.7rem;border-radius:99px;background:var(--fg);color:var(--bg);font:12px ui-monospace,monospace}
.side-foot button{background:var(--card);color:var(--fg);border-color:var(--line);font-size:.85rem}
@media(max-width:52rem){.app{grid-template-columns:1fr}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line);padding:.6rem .8rem}
.brand{padding:.3rem .6rem}.side nav{flex-direction:row;flex-wrap:wrap}.side-foot{margin:0 0 0 auto;border:0;padding:0;flex-direction:row;align-items:center}main{padding:1.2rem .9rem 3rem}}details>summary{list-style-position:outside}
@media(max-width:40rem){body{margin:1rem auto}.head{display:block;width:auto}.tabs{overflow:auto}}
@media print{button,.tabs,form{display:none}.step,.stage{break-inside:avoid}}
@media print{.side,button,.tabs,form{display:none}.app{display:block}.step,.stage{break-inside:avoid}}
"""
NAV = (("Home", "/"), ("Projects", "/projects"), ("Runbooks", "/runbooks"), ("Attribute sets", "/attribute-sets"), ("Activity", "/activity"))
# applied before the page paints, so a saved theme never flashes; ?theme=dark|light|system also sets it
THEME_INIT = ("(function(){try{var q=new URLSearchParams(location.search).get('theme');"
"if(q==='dark'||q==='light')localStorage.setItem('rtut-theme',q);else if(q==='system')localStorage.removeItem('rtut-theme');"
"var t=localStorage.getItem('rtut-theme');"
"if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t);"
"else document.documentElement.removeAttribute('data-theme')}catch(x){}})()")
def page(title: str, body: str) -> bytes:
return (f"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width'>"
f"<title>{html.escape(title)}</title><style>{CSS}</style>"
f"<p class=eyebrow><a href='/'>runbook-tutorials</a></p>{body}"
u = USER.current()
label = u["display_name"] or u["operator"]
nav = "".join(f"<a href='{h}' data-nav>{html.escape(t)}</a>" for t, h in NAV)
side = (f"<aside class=side><a class=brand href='/'>runbook-tutorials</a><nav>{nav}</nav>"
f"<div class=side-foot><a class=me href='/user' data-nav title='Your settings'>"
f"<span class=avatar>{html.escape(label[:1].upper())}</span><span>{html.escape(label)}</span></a>"
"<button type=button data-theme-toggle title='Switch between system, light and dark'>Theme: system</button></div></aside>")
return (f"<!doctype html><html lang=en><head><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>"
f"<title>{html.escape(title)}</title><style>{CSS}</style><script>{THEME_INIT}</script></head>"
f"<body><div class=app>{side}<main>{body}</main></div>"
"<script>"
"document.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>"
"navigator.clipboard.writeText(b.previousElementSibling.textContent));"
@ -87,9 +125,17 @@ def page(title: str, body: str) -> bytes:
"document.querySelectorAll('[data-fail]').forEach(b=>b.addEventListener('click',ev=>{"
"const f=b.closest('form'),n=f.querySelector('[name=note]'),w=f.querySelector('[data-warn]');"
"if(n&&!n.value.trim()&&!b.dataset.warned){ev.preventDefault();b.dataset.warned='1';w.hidden=false;n.focus();}}));"
# sidebar: mark where we are; theme switch cycles system -> light -> dark
"const p=location.pathname;document.querySelectorAll('a[data-nav]').forEach(a=>{const h=a.getAttribute('href');"
"if(h==='/'?p==='/':p===h||p.startsWith(h+'/'))a.setAttribute('aria-current','page')});"
"const tb=document.querySelector('[data-theme-toggle]');const cur=()=>{try{return localStorage.getItem('rtut-theme')||'system'}catch(x){return 'system'}};"
"const show=()=>{if(tb)tb.textContent='Theme: '+cur()};show();"
"if(tb)tb.onclick=()=>{const n={system:'light',light:'dark',dark:'system'}[cur()];"
"try{n==='system'?localStorage.removeItem('rtut-theme'):localStorage.setItem('rtut-theme',n)}catch(x){};"
"if(n==='system')document.documentElement.removeAttribute('data-theme');else document.documentElement.setAttribute('data-theme',n);show()};"
# keep the operator on the current step after every action
"const c=document.getElementById(location.hash==='#feedback'?'feedback':location.hash==='#close'?'close':'current');if(c)c.scrollIntoView({block:'center'});"
"</script>").encode()
"</script></body></html>").encode()
def e(s) -> str:
@ -130,15 +176,16 @@ class App:
return (f"<input type=hidden name={name} value='{e(u['operator'])}'>"
f"<p class=note>Running as <b>{label}</b> · <a href='/user'>change</a></p>")
def activities_html(self, limit: int = 12) -> str:
def activities_html(self, limit: int = 12, level: str = "h2") -> str:
events = ACT.latest(self.packs, limit)
if not events:
return "<h2>Latest activities</h2><p class=note>Nothing has happened yet. Start a run or a project.</p>"
return f"<{level}>Latest activities</{level}><p class=note>Nothing has happened yet. Start a run or a project.</p>"
rows = "".join(
f"<li><span class=note>{e(x['at'][:16].replace('T', ' '))}</span> · <b>{e(x['who'])}</b> {e(x['text'])} "
f"<span class=note>· {e(x['pack'])}{' · project ' + e(x['project']) if x.get('project') else ''}</span>"
f"{' · <a href=' + chr(39) + x['link'] + chr(39) + '>open</a>' if x['link'] else ''}</li>" for x in events)
return f"<h2>Latest activities <span class=note style='font-weight:400;font-size:.85rem'>(times in UTC)</span></h2><ul class=feed>{rows}</ul>"
return (f"<{level}>Latest activities <span class=note style='font-weight:400;font-size:.85rem'>(times in UTC)</span></{level}>"
f"<ul class=feed>{rows}</ul>")
def user_page(self, msg: str = "") -> bytes:
u = USER.current()
@ -161,6 +208,18 @@ class App:
return f"<input type=hidden name=_t value='{self.token}'>"
def index(self) -> bytes:
return page("Home", UP.index_sections(self) + self.activities_html() + self.runbooks_html())
def runbooks_page(self) -> bytes:
return page("Runbooks", self.runbooks_html("h1"))
def projects_page(self) -> bytes:
return page("Projects", UP.index_sections(self))
def activity_page(self) -> bytes:
return page("Activity", self.activities_html(100, "h1"))
def runbooks_html(self, level: str = "h2") -> str:
rows = []
for i, p in enumerate(self.packs):
d = p.data
@ -172,7 +231,7 @@ class App:
else:
rows.append(f"<li><a href='/pack/{i}'>{e(d['title'])}</a> {UP.maturity_pill(p)} "
f"<span class=note>{e(score_line(R.scorecard(p)))}</span></li>")
return page("Runbooks", UP.index_sections(self) + self.activities_html() + f"<h2>Runbooks</h2><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>")
return f"<{level}>Runbooks</{level}><ul>{''.join(rows) or '<li>No packs found.</li>'}</ul>"
def pack_page(self, i: int) -> bytes:
p = self.packs[i]; d = p.data
@ -407,6 +466,18 @@ def make_handler(app: App, port_holder: list):
return self._send(app.pack_page(int(parts[1])))
if parts == ["user"]:
return self._send(app.user_page())
if parts == ["projects"]:
return self._send(app.projects_page())
if parts == ["runbooks"]:
return self._send(app.runbooks_page())
if parts == ["activity"]:
return self._send(app.activity_page())
if parts == ["attribute-sets"]:
return self._send(UA.sets_page(app))
if len(parts) == 2 and parts[0] == "attribute-set":
body = UA.set_page(app, parts[1])
if body:
return self._send(body)
if parts[0] == "run" and len(parts) == 5 and parts[3] == "artefact":
status, body, ctype, dl = UI2.serve_artefact(app, int(parts[1]), parts[2], parts[4])
want_dl = bool(parse_qs(urlparse(self.path).query).get("download"))
@ -437,6 +508,13 @@ def make_handler(app: App, port_holder: list):
form["operator"] = app.operator() # the active user, unless a form names someone
if not form.get("by", "").strip():
form["by"] = app.operator()
if parts == ["attribute-sets", "new"] or (len(parts) == 2 and parts[0] == "attribute-set"):
try:
return self._redirect(UA.handle_post(app, parts, form))
except (ValueError, KeyError) as ex:
if parts[0] == "attribute-set" and len(parts) == 2:
return self._send(UA.set_page(app, parts[1], str(ex)) or page("Not found", "<p>Not found.</p>"), 400)
return self._send(UA.sets_page(app, str(ex)), 400)
if parts == ["user"]:
try:
USER.save(form.get("operator", ""), form.get("display_name", ""))
@ -455,6 +533,11 @@ def make_handler(app: App, port_holder: list):
except M.ReviewError as ex:
raise ValueError(str(ex))
return self._redirect(f"/pack/{i}#maturity")
if len(parts) == 3 and parts[0] == "project" and parts[2] == "edit":
from . import project as J
doc = app.registry().projects[parts[1]]
J.update_project(doc, form.get("title", ""), form.get("description", ""), form.get("goal", ""))
return self._redirect(f"/project/{parts[1]}")
if len(parts) == 3 and parts[0] == "project" and parts[2] in ("attributes", "run"):
from . import project as J
reg = app.registry(); doc = reg.projects[parts[1]]