App shell with sidebar and theme switch; attribute-set management; project rename; new pages for projects, runbooks and activity
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:
parent
a6ee6a0a6e
commit
6579618737
9 changed files with 663 additions and 13 deletions
109
rtut/ui.py
109
rtut/ui.py
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue