mavweb: unify UI — shared ui.css + nav partial across all pages

One theme (the PWA's dark palette) for dash/history/trace/notifications/
tools/passkey via static/ui.css; shared nav template with active-page
highlight; tables wrapped in .scroll so they pan on phones; PWA nav no
longer clips the RU/EN toggle; dash 'updated' timestamp fixed (selector
matched nothing). AGENTS.md documents the local preview/screenshot recipe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASstMtsZWLSRcD1Tq8T68Q
This commit is contained in:
kami
2026-07-05 18:36:31 +04:00
parent a158ffd1d2
commit d7c0cf89d3
9 changed files with 215 additions and 158 deletions
+40
View File
@@ -4,3 +4,43 @@
This repo maps to **Maven** (project ID: 2) in Vikunja.
Feature work, bugs, deployment tasks all go here.
MCP endpoint: `http://localhost:9100/mcp` (or `http://192.168.1.104:9100/mcp` from workpc)
## Rendering / previewing the web UI locally
To see mavweb pages with real data without touching the production stack:
```sh
R=/tmp/mvn-preview; mkdir -p $R
go build -o $R/mavend ./cmd/mavend/ && go build -o $R/mavweb ./cmd/mavweb/
cat > $R/mavend.json <<EOF
{ "db_path": "$R/maven.db", "socket_path": "$R/mavend.sock",
"state_dir": "$R", "tick_interval": "10s" }
EOF
$R/mavend -config $R/mavend.json &
$R/mavweb -addr 127.0.0.1:9299 -core $R/mavend.sock &
```
- No models/voice/phraser config needed — the phraser stub covers it; mavend
runs fine bare. mavweb serves `/`, `/dash`, `/history`, `/trace`,
`/notifications`, `/tools`.
- **Socket path must be short** — unix sockets cap at ~108 chars; a deep tmp
dir fails with `bind: invalid argument`.
- Seed data through `ipc.Client` (internal package — the seeder must live
inside the module, e.g. a throwaway `cmd/seedtmp/main.go`, deleted after):
`WriteFact`, `RecordNudge`+`ResolveNudge`, `CreateReminder`, `ProposeTool`.
- `/trace` is empty until the first tick fires (wait one `tick_interval`).
- Screenshots: `chromium --headless --disable-gpu --screenshot=out.png
--window-size=1280,900 --hide-scrollbars --virtual-time-budget=2000
http://127.0.0.1:9299/dash` (use `--window-size=430,900` for the phone/PWA
view). **Always pass `--virtual-time-budget`** — without it the screenshot
can snap mid-layout and silently drop elements (the PWA lang toggle
"disappeared" this way).
## Web UI conventions
- All server-rendered pages share `cmd/mavweb/static/ui.css` (served at
`/ui.css`) and the `nav` template partial (`navHTML` in `cmd/mavweb/main.go`,
invoked as `{{template "nav" "<active-page>"}}`). New pages must link both —
no per-page inline `<style>` beyond true one-offs.
- Wrap every table in `<div class=scroll>` so wide data pans on a phone
instead of breaking the layout.
+15 -23
View File
@@ -1,31 +1,23 @@
<!doctype html><meta charset=utf-8>
<title>maven dash</title>
<style>
body{font:14px monospace;background:#111;color:#ddd;margin:1rem}
h2{color:#8cf;margin:1.2rem 0 .3rem}
table{border-collapse:collapse;width:100%}
td,th{border-bottom:1px solid #333;padding:.2rem .5rem;text-align:left}
.pending{color:#fc6}.acted{color:#6c6}.ignored{color:#888}.snoozed{color:#c9f}
.pres{color:#6c6}.away{color:#888}
.updated{color:#666;font-size:.85rem;margin-top:-.5rem;margin-bottom:1rem}
nav a{color:#8cf;margin-right:1rem;font-size:.85rem}
</style>
<nav><a href=/history>history</a> <a href=/trace>trace</a> <a href=/notifications>notifications</a></nav>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>maven · dash</title>
<link rel=stylesheet href=/ui.css>
{{template "nav" "dash"}}
<h2>presence</h2>
<p><span class={{.Presence.Bucket}}>{{.Presence.Bucket}}</span> — score {{printf "%.2f" .Presence.Score}} ({{ago .Presence.Updated}})</p>
<div class=updated>обновляется каждые 10с</div>
<div class=updated id=updated>обновляется каждые 10с</div>
<h2>nudges</h2>
<table id=nudges><tr><th>when<th>rule<th>chan<th>outcome<th>message</tr>
{{range .Nudges}}<tr><td>{{ago .Ts}}<td>{{.Rule}}<td>{{.Channel}}<td class={{.Outcome}}>{{.Outcome}}<td>{{.Message}}</tr>{{end}}
</table>
<div class=scroll><table class=mono id=nudges><tr><th>when<th>rule<th>chan<th>outcome<th>message</tr>
{{range .Nudges}}<tr><td>{{ago .Ts}}<td>{{.Rule}}<td><span class=badge>{{.Channel}}</span><td class={{.Outcome}}>{{.Outcome}}<td>{{.Message}}</tr>{{end}}
</table></div>
<h2>facts</h2>
<table id=facts><tr><th>when<th>kind<th>key<th>value<th>source<th>conf</tr>
{{range .Facts}}<tr><td>{{ago .Ts}}<td>{{.Kind}}<td>{{.Key}}<td>{{.Value}}<td>{{.Source}}<td>{{printf "%.2f" .Confidence}}</tr>{{end}}
</table>
<div class=scroll><table class=mono id=facts><tr><th>when<th>kind<th>key<th>value<th>source<th>conf</tr>
{{range .Facts}}<tr><td>{{ago .Ts}}<td>{{.Kind}}<td class=key>{{.Key}}<td>{{.Value}}<td>{{.Source}}<td>{{printf "%.2f" .Confidence}}</tr>{{end}}
</table></div>
<h2>notes</h2>
<table id=notes><tr><th>when<th>source<th>text</tr>
<div class=scroll><table class=mono id=notes><tr><th>when<th>source<th>text</tr>
{{range .Notes}}<tr><td>{{ago .Ts}}<td>{{.Source}}<td>{{.Text}}</tr>{{end}}
</table>
</table></div>
<script>
setInterval(() => fetch('/dash').then(r => r.text()).then(html => {
const p = new DOMParser(), d = p.parseFromString(html, 'text/html');
@@ -33,6 +25,6 @@ setInterval(() => fetch('/dash').then(r => r.text()).then(html => {
const old = document.getElementById(id), nu = d.getElementById(id);
if (old && nu) old.replaceWith(nu);
}
document.querySelector('h2+div').textContent = 'обновлено ' + new Date().toLocaleTimeString();
document.getElementById('updated').textContent = 'обновлено ' + new Date().toLocaleTimeString();
}), 10000);
</script>
</script>
+15 -32
View File
@@ -1,43 +1,24 @@
<!doctype html>
<html lang=en>
<meta charset=utf-8>
<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>maven · history</title>
<style>
body{font:14px system-ui;margin:1.5rem;max-width:70rem}
h1{font-size:1.3rem;margin-bottom:.3rem}
nav a{margin-right:1rem}
table{border-collapse:collapse;width:100%;margin-top:.5rem}
th,td{border:1px solid #ccc;padding:.3rem .5rem;text-align:left;vertical-align:top}
th{background:#f6f6f6}
.voided{opacity:.45;text-decoration:line-through}
.void-badge{color:#999;font-size:.8rem;margin-left:.3rem}
.key{font-family:monospace;white-space:nowrap}
.val{font-family:monospace;word-break:break-all;max-width:20rem}
.src{font-size:.85rem;color:#666}
.count{color:#888;font-size:.85rem;margin-top:.3rem}
.revert-btn{font-size:.8rem;padding:.15rem .4rem;cursor:pointer}
.revert-btn:disabled{opacity:.4;cursor:not-allowed}
#msg{margin-top:.5rem;padding:.4rem;border-radius:.3rem;display:none}
.msg-ok{background:#e6ffed;display:block!important}
.msg-err{background:#ffe6e6;display:block!important}
</style>
<h1>maven · command history</h1>
<nav><a href=/dash>dash</a> <a href=/tools>tools</a> <a href=/auth/passkey>passkey</a> <a href=/notifications>notifications</a></nav>
<p class=count>{{len .Facts}} facts shown (newest first)</p>
<div id=msg></div>
<table>
<link rel=stylesheet href=/ui.css>
<style>.val{word-break:break-all;max-width:20rem}</style>
{{template "nav" "history"}}
<h1>command history</h1>
<p class=muted>{{len .Facts}} facts shown (newest first)</p>
<div id=msg class=msg hidden></div>
<div class=scroll><table class=mono>
<tr><th>when<th>kind<th>key<th>value<th>source<th>conf<th></tr>
{{range .Facts}}{{if .VoidsID}}<tr class=voided>{{else}}<tr>{{end}}
<td>{{if .VoidsID}}<span class=void-badge> voided</span>{{end}}{{.Ts.Format "2006-01-02 15:04"}}</td>
<td>{{.Kind}}</td>
<td class=key>{{.Key}}</td>
<td class=val>{{.Value}}</td>
<td class=src>{{.Source}}</td>
<td class=muted>{{.Source}}</td>
<td>{{printf "%.2f" .Confidence}}</td>
<td>{{if not .VoidsID}}<button class=revert-btn onclick="revert('{{.Key}}',this)">revert</button>{{end}}</td>
<td>{{if not .VoidsID}}<button onclick="revert('{{.Key}}',this)">revert</button>{{end}}</td>
</tr>{{end}}
</table>
</table></div>
<script>
const msg=document.getElementById('msg');
function revert(key,btn){
@@ -45,11 +26,13 @@ function revert(key,btn){
fetch('/api/revert',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:'key='+encodeURIComponent(key)})
.then(r=>r.json().then(d=>({ok:r.ok,data:d})))
.then(({ok,data})=>{
msg.className=ok?'msg-ok':'msg-err';
msg.className=ok?'msg msg-ok':'msg msg-err';
msg.hidden=false;
msg.textContent=ok?'reverted ✓':(data.error||'revert failed');
if(ok) btn.closest('tr').className='voided';
}).catch(e=>{
msg.className='msg-err';
msg.className='msg msg-err';
msg.hidden=false;
msg.textContent='error: '+e;
btn.disabled=false;
});
+41 -27
View File
@@ -54,10 +54,23 @@ var traceHTML string
//go:embed notifications.html
var notificationsHTML string
// dashTmpl — the monitoring read surface, server-rendered from dash.html (no JS,
// no client fetch); meta-refresh keeps it live. html/template escapes the user
// text in facts/nudges. Read-only: browses the append-only store via CoreAPI,
// never writes — the store IS the audit trail, this just shows it.
// navHTML — the shared site nav, parsed into every server-rendered template
// alongside ui.css so the pages read as one app. Invoke as
// {{template "nav" "<active-page>"}}; the argument highlights the current link.
const navHTML = `{{define "nav"}}<nav class=site>
<a href=/ class="{{if eq . "voice"}}active{{end}}">voice</a>
<a href=/dash class="{{if eq . "dash"}}active{{end}}">dash</a>
<a href=/history class="{{if eq . "history"}}active{{end}}">history</a>
<a href=/trace class="{{if eq . "trace"}}active{{end}}">trace</a>
<a href=/notifications class="{{if eq . "notifications"}}active{{end}}">notifications</a>
<a href=/tools class="{{if eq . "tools"}}active{{end}}">tools</a>
<a href=/auth/passkey class="{{if eq . "passkey"}}active{{end}}">passkey</a>
</nav>{{end}}`
// dashTmpl — the monitoring read surface, server-rendered from dash.html;
// a small fetch loop refreshes the tables in place. html/template escapes the
// user text in facts/nudges. Read-only: browses the append-only store via
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
var dashTmpl = template.Must(template.New("dash").Funcs(template.FuncMap{
"ago": func(t time.Time) string {
// A zero timestamp (no presence signal yet, fresh DB) would make
@@ -67,7 +80,7 @@ var dashTmpl = template.Must(template.New("dash").Funcs(template.FuncMap{
}
return time.Since(t).Round(time.Second).String() + " ago"
},
}).Parse(dashHTML))
}).Parse(navHTML + dashHTML))
func noCache(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -352,21 +365,22 @@ func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
// utterances (they came from voice STT — untrusted text).
var toolsTmpl = template.Must(template.New("tools").Funcs(template.FuncMap{
"join": strings.Join,
}).Parse(toolsHTML))
}).Parse(navHTML + toolsHTML))
const toolsHTML = `<!doctype html><meta charset=utf-8><title>maven · tools</title>
<style>body{font:15px system-ui;margin:2rem;max-width:52rem}h2{margin-top:2rem}
table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:.4rem .6rem;text-align:left}
input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
.d{color:#b00}.msg{background:#efe;border:1px solid #6c6;padding:.5rem;margin:1rem 0}</style>
<h1>maven · tools</h1>
<p><small>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</small></p>
{{if .Msg}}<div class=msg>{{.Msg}}</div>{{end}}
const toolsHTML = `<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>maven · tools</title>
<link rel=stylesheet href=/ui.css>
<style>input[type=text]{width:20rem}</style>
{{template "nav" "tools"}}
<h1>tools</h1>
<p class=muted>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<h2>proposed <small>({{len .Proposed}})</small></h2>
{{if .Proposed}}<p>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
<table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{if .Proposed}}<p class=muted>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr>
<td><code>{{.Name}}</code></td><td>{{.Scope}}</td><td>{{.Utterance}}</td>
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
<td><form method=post action=/tools>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
@@ -374,23 +388,23 @@ input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
<input type=text name=cmd placeholder="systemctl restart" required>
<label><input type=checkbox name=destructive> destructive</label>
<button>enable</button></form></td>
</tr>{{end}}</table>
{{else}}<p>none pending.</p>{{end}}
</tr>{{end}}</table></div>
{{else}}<p class=muted>none pending.</p>{{end}}
<h2>enabled <small>({{len .Enabled}})</small></h2>
{{if .Enabled}}<table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td>{{.Scope}}</td><td><code>{{join .Cmd " "}}</code></td>
<td>{{if .Destructive}}<span class=d>destructive</span>{{end}}</td>
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
<td><form method=post action=/tools style=display:inline>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=disable>
<button>disable</button></form></td></tr>{{end}}</table>
{{else}}<p>none enabled.</p>{{end}}
<button>disable</button></form></td></tr>{{end}}</table></div>
{{else}}<p class=muted>none enabled.</p>{{end}}
`
var historyTmpl = template.Must(template.New("history").Parse(historyHTML))
var historyTmpl = template.Must(template.New("history").Parse(navHTML + historyHTML))
var notificationsTmpl = template.Must(template.New("notifications").Parse(notificationsHTML))
var notificationsTmpl = template.Must(template.New("notifications").Parse(navHTML + notificationsHTML))
var traceTmpl = template.Must(template.New("trace").Funcs(template.FuncMap{
"ago": func(t time.Time) string {
@@ -406,7 +420,7 @@ var traceTmpl = template.Must(template.New("trace").Funcs(template.FuncMap{
return t.Format("15:04:05")
},
"join": strings.Join,
}).Parse(traceHTML))
}).Parse(navHTML + traceHTML))
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
+13 -45
View File
@@ -1,49 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Notifications — Maven</title>
<style>
body{background:#111;color:#ddd;font-family:system-ui,sans-serif;margin:0;padding:1rem;font-size:.9rem}
a{color:#00aaff;text-decoration:none}
a:hover{text-decoration:underline}
h1{font-size:1.2rem;font-weight:400;color:#888;letter-spacing:.1em;text-transform:uppercase}
nav{margin-bottom:1rem;display:flex;gap:1rem}
nav a{font-size:.85rem;letter-spacing:.05em;text-transform:uppercase;color:#666}
nav a:hover{color:#ddd}
table{width:100%;border-collapse:collapse;margin-top:.5rem}
th,td{padding:.4rem .5rem;text-align:left;border-bottom:1px solid #222;white-space:nowrap}
th{color:#555;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}
td{color:#aaa}
.rule{color:#00ff88;font-weight:600}
.chan{font-size:.75rem;color:#555;background:#1a1a2e;padding:.15rem .35rem;border-radius:3px}
.pending{color:#888}
.acted{color:#00ff88}
.snoozed{color:#ffaa00}
.ignored{color:#ff4444}
.msg{color:#666;max-width:300px;overflow:hidden;text-overflow:ellipsis}
.ts{color:#555;font-size:.8rem}
.empty{color:#555;padding:2rem;text-align:center}
</style>
</head>
<body>
<nav>
<a href=/dash>dash</a>
<a href=/trace>trace</a>
<a href=/tools>tools</a>
<a href=/history>history</a>
</nav>
<h1>Notifications</h1>
{{if .Nudges}}<table>
<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>maven · notifications</title>
<link rel=stylesheet href=/ui.css>
<style>.text{max-width:22rem}</style>
{{template "nav" "notifications"}}
<h1>notifications</h1>
{{if .Nudges}}<div class=scroll><table>
<tr><th>when</th><th>rule</th><th>channel</th><th>outcome</th><th>message</th></tr>
{{range .Nudges}}<tr>
<td class=ts>{{.Ts.Format "15:04"}}</td>
<td class=rule>{{.Rule}}</td>
<td><span class=chan>{{.Channel}}</span></td>
<td class=muted>{{.Ts.Format "15:04"}}</td>
<td class=key>{{.Rule}}</td>
<td><span class=badge>{{.Channel}}</span></td>
<td class={{.Outcome}}>{{.Outcome}}</td>
<td class=msg>{{.Message}}</td>
</tr>{{end}}</table>
<td class=text>{{.Message}}</td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>no notifications yet</div>{{end}}
</body>
</html>
+5 -3
View File
@@ -11,11 +11,13 @@
html,body{height:100%;background:#111;color:#ddd;font-family:system-ui,-apple-system,sans-serif}
body{display:flex;flex-direction:column}
nav{display:flex;background:#1a1a2e;border-bottom:1px solid #333;flex-shrink:0}
nav button{flex:1;padding:.6rem;background:none;border:none;color:#666;font-size:.85rem;cursor:pointer;font-family:inherit;letter-spacing:.05em;text-transform:uppercase;transition:color .15s;-webkit-tap-highlight-color:transparent}
nav{display:flex;align-items:stretch;background:#1a1a2e;border-bottom:1px solid #333;flex-shrink:0}
/* min-width:0 lets the tabs shrink below their text's min-content width —
without it they push the lang toggle past the right edge on phones */
nav button{flex:1 1 0;min-width:0;overflow:hidden;white-space:nowrap;padding:.6rem .2rem;background:none;border:none;color:#666;font-size:.85rem;cursor:pointer;font-family:inherit;letter-spacing:.05em;text-transform:uppercase;transition:color .15s;-webkit-tap-highlight-color:transparent}
nav button.active{color:#00aaff;border-bottom:2px solid #00aaff}
nav button:hover{color:#ddd}
nav .lang{flex:0 0 auto;padding:.6rem .8rem;font-size:.75rem;color:#555;cursor:pointer;letter-spacing:.1em}
nav .lang{flex:0 0 auto;display:flex;align-items:center;padding:.6rem .5rem;font-size:.75rem;color:#555;cursor:pointer;letter-spacing:.1em}
nav .lang.active{color:#00aaff}
.tab{display:none;flex-direction:column;align-items:center;flex:1;overflow:auto;padding:1rem}
+64
View File
@@ -0,0 +1,64 @@
/* maven web ui — one theme for every server-rendered page.
Palette and type mirror the PWA (static/index.html): dark, restrained,
#00aaff accent. Pages carry NO inline <style> beyond true one-offs. */
:root{
--bg:#111; --panel:#1a1a2e; --line:#222; --border:#333;
--fg:#ddd; --soft:#aaa; --muted:#888; --dim:#555;
--accent:#00aaff; --ok:#00ff88; --warn:#ffaa00; --err:#ff4444;
}
*{box-sizing:border-box}
body{background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,sans-serif;
margin:0;padding:1rem;-webkit-text-size-adjust:100%}
h1,h2{font-weight:400;color:var(--muted);letter-spacing:.1em;text-transform:uppercase}
h1{font-size:1.2rem;margin:.2rem 0 .6rem}
h2{font-size:.95rem;margin:1.4rem 0 .3rem}
h1 small,h2 small{color:var(--dim);letter-spacing:0;text-transform:none}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
/* shared site nav (the "nav" template partial) */
nav.site{display:flex;gap:1rem;flex-wrap:wrap;align-items:center;
border-bottom:1px solid var(--line);padding-bottom:.6rem;margin-bottom:1rem}
nav.site a{font-size:.8rem;letter-spacing:.08em;text-transform:uppercase;color:var(--dim)}
nav.site a:hover{color:var(--fg);text-decoration:none}
nav.site a.active{color:var(--accent)}
/* tables: always inside a .scroll wrapper so wide data pans instead of
breaking the page on a phone */
.scroll{overflow-x:auto}
table{width:100%;border-collapse:collapse;margin-top:.4rem}
th,td{padding:.35rem .5rem;text-align:left;vertical-align:top;
border-bottom:1px solid var(--line)}
th{color:var(--dim);font-size:.75rem;text-transform:uppercase;
letter-spacing:.05em;white-space:nowrap}
td{color:var(--soft)}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
code,.key{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--ok)}
/* status colors — nudge outcomes, presence, booleans */
.pending{color:var(--muted)}.acted{color:var(--ok)}
.snoozed{color:var(--warn)}.ignored{color:var(--err)}
.pres,.green{color:var(--ok)}.away,.gray{color:var(--muted)}.red{color:var(--err)}
.badge{font-size:.75rem;color:var(--dim);background:var(--panel);
padding:.15rem .35rem;border-radius:3px;white-space:nowrap}
.updated,.muted{color:var(--dim);font-size:.85rem}
.empty{color:var(--dim);padding:2rem;text-align:center}
.voided{opacity:.45;text-decoration:line-through}
.void-badge{color:var(--dim);font-size:.8rem;margin-right:.3rem}
/* controls */
button{font:inherit;font-size:.85rem;background:var(--panel);color:var(--fg);
border:1px solid var(--border);border-radius:3px;padding:.25rem .6rem;cursor:pointer}
button:hover{border-color:var(--accent)}
button:disabled{opacity:.4;cursor:not-allowed}
input[type=text]{font:inherit;font-size:.85rem;background:var(--bg);color:var(--fg);
border:1px solid var(--border);border-radius:3px;padding:.25rem .4rem;max-width:100%}
label{color:var(--soft)}
/* inline feedback (/history revert, /tools enable, /auth/passkey) */
.msg{margin:.6rem 0;padding:.5rem .7rem;border-radius:3px;border:1px solid var(--line)}
.msg-ok{border-color:var(--ok);color:var(--ok)}
.msg-err{border-color:var(--err);color:var(--err)}
details{color:var(--soft);font-size:.85rem}
summary{cursor:pointer;color:var(--dim)}
+12 -22
View File
@@ -1,21 +1,11 @@
<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>maven · rule trace</title>
<style>
body{font:14px monospace;background:#111;color:#ddd;margin:1rem}
h2{color:#8cf;margin:1.2rem 0 .3rem}
table{border-collapse:collapse;width:100%}
td,th{border-bottom:1px solid #333;padding:.2rem .5rem;text-align:left;vertical-align:top}
.green{color:#6c6}.red{color:#f66}.gray{color:#888}
.updated{color:#666;font-size:.85rem}
nav a{color:#8cf;margin-right:1rem;font-size:.85rem}
details{font-size:.85rem;color:#aaa;margin-top:.2rem}
summary{cursor:pointer}
.gl{color:#888}
</style>
<nav><a href=/dash>dash</a> <a href=/history>history</a></nav>
<h2>rule trace</h2>
<link rel=stylesheet href=/ui.css>
{{template "nav" "trace"}}
<h1>rule trace</h1>
<div class=updated>{{.Now | ago}} — winner: <strong>{{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}</strong></div>
<table>
<div class=scroll><table class=mono>
<tr><th>rule<th>sev<th>predicate<th>gate<th>blocked by<th>detail<th>selected<th>lost to</tr>
{{range .Rules}}<tr>
<td>{{.RuleName}}</td>
@@ -24,14 +14,14 @@
<td class={{if .GateResult}}green{{else if .GateBlockedBy}}red{{else}}gray{{end}}>{{.GateResult}}</td>
<td>{{.GateBlockedBy}}</td>
<td><details><summary>gate</summary>
<span class=gl>snooze_until:</span> {{.GateDetail.SnoozeUntil | fmtTime}}<br>
<span class=gl>cooldown_until:</span> {{.GateDetail.CooldownUntil | fmtTime}}<br>
<span class=gl>quiet_hours:</span> {{.GateDetail.QuietHours}}<br>
<span class=gl>calendar_busy:</span> {{.GateDetail.CalendarBusy}}<br>
<span class=gl>presence:</span> {{.GateDetail.Presence}}<br>
<span class=gl>inert_keys_missing:</span> {{if .GateDetail.InertKeysMissing}}{{join .GateDetail.InertKeysMissing ", "}}{{else}}—{{end}}
<span class=muted>snooze_until:</span> {{.GateDetail.SnoozeUntil | fmtTime}}<br>
<span class=muted>cooldown_until:</span> {{.GateDetail.CooldownUntil | fmtTime}}<br>
<span class=muted>quiet_hours:</span> {{.GateDetail.QuietHours}}<br>
<span class=muted>calendar_busy:</span> {{.GateDetail.CalendarBusy}}<br>
<span class=muted>presence:</span> {{.GateDetail.Presence}}<br>
<span class=muted>inert_keys_missing:</span> {{if .GateDetail.InertKeysMissing}}{{join .GateDetail.InertKeysMissing ", "}}{{else}}—{{end}}
</details></td>
<td class={{if .WasSelected}}green{{end}}>{{.WasSelected}}</td>
<td>{{.LostTo}}</td>
</tr>{{end}}
</table>
</table></div>
+10 -6
View File
@@ -67,11 +67,15 @@ func (h *PasskeyHandle) Page(w http.ResponseWriter, r *http.Request) {
const passkeyPageHTML = `<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>maven · passkey</title>
<style>body{font:16px system-ui;max-width:34rem;margin:3rem auto;padding:0 1rem}
button{font:inherit;padding:.5rem 1rem;margin:.3rem .3rem 0 0;cursor:pointer}
#msg{margin-top:1rem;padding:.6rem;border-radius:.3rem;white-space:pre-wrap}
.ok{background:#e6ffed}.err{background:#ffe6e6}</style>
<h1>maven passkey</h1>
<link rel=stylesheet href=/ui.css>
<style>button{padding:.5rem 1rem;margin:.3rem .3rem 0 0}
#msg{white-space:pre-wrap}</style>
<nav class=site>
<a href=/>voice</a> <a href=/dash>dash</a> <a href=/history>history</a>
<a href=/trace>trace</a> <a href=/notifications>notifications</a>
<a href=/tools>tools</a> <a href=/auth/passkey class=active>passkey</a>
</nav>
<h1>passkey</h1>
<p>Enroll a passkey once, then assert it to unlock destructive actions
(tool enable) for a few minutes.</p>
<button onclick=enroll()>enroll passkey</button>
@@ -81,7 +85,7 @@ button{font:inherit;padding:.5rem 1rem;margin:.3rem .3rem 0 0;cursor:pointer}
<script>
const b64u=b=>btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
const ub64=s=>{s=s.replace(/-/g,'+').replace(/_/g,'/');const b=atob(s),a=new Uint8Array(b.length);for(let i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return a;};
const say=(t,ok)=>{const m=document.getElementById('msg');m.textContent=t;m.className=ok?'ok':'err';};
const say=(t,ok)=>{const m=document.getElementById('msg');m.textContent=t;m.className=ok?'msg msg-ok':'msg msg-err';};
async function enroll(){try{
const {challenge,options}=await (await fetch('/auth/webauthn/register/begin')).json();
options.challenge=ub64(options.challenge);