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:
@@ -4,3 +4,43 @@
|
|||||||
This repo maps to **Maven** (project ID: 2) in Vikunja.
|
This repo maps to **Maven** (project ID: 2) in Vikunja.
|
||||||
Feature work, bugs, deployment tasks all go here.
|
Feature work, bugs, deployment tasks all go here.
|
||||||
MCP endpoint: `http://localhost:9100/mcp` (or `http://192.168.1.104:9100/mcp` from workpc)
|
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
@@ -1,31 +1,23 @@
|
|||||||
<!doctype html><meta charset=utf-8>
|
<!doctype html><meta charset=utf-8>
|
||||||
<title>maven dash</title>
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||||
<style>
|
<title>maven · dash</title>
|
||||||
body{font:14px monospace;background:#111;color:#ddd;margin:1rem}
|
<link rel=stylesheet href=/ui.css>
|
||||||
h2{color:#8cf;margin:1.2rem 0 .3rem}
|
{{template "nav" "dash"}}
|
||||||
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>
|
|
||||||
<h2>presence</h2>
|
<h2>presence</h2>
|
||||||
<p><span class={{.Presence.Bucket}}>{{.Presence.Bucket}}</span> — score {{printf "%.2f" .Presence.Score}} ({{ago .Presence.Updated}})</p>
|
<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>
|
<h2>nudges</h2>
|
||||||
<table id=nudges><tr><th>when<th>rule<th>chan<th>outcome<th>message</tr>
|
<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>{{.Channel}}<td class={{.Outcome}}>{{.Outcome}}<td>{{.Message}}</tr>{{end}}
|
{{range .Nudges}}<tr><td>{{ago .Ts}}<td>{{.Rule}}<td><span class=badge>{{.Channel}}</span><td class={{.Outcome}}>{{.Outcome}}<td>{{.Message}}</tr>{{end}}
|
||||||
</table>
|
</table></div>
|
||||||
<h2>facts</h2>
|
<h2>facts</h2>
|
||||||
<table id=facts><tr><th>when<th>kind<th>key<th>value<th>source<th>conf</tr>
|
<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>{{.Key}}<td>{{.Value}}<td>{{.Source}}<td>{{printf "%.2f" .Confidence}}</tr>{{end}}
|
{{range .Facts}}<tr><td>{{ago .Ts}}<td>{{.Kind}}<td class=key>{{.Key}}<td>{{.Value}}<td>{{.Source}}<td>{{printf "%.2f" .Confidence}}</tr>{{end}}
|
||||||
</table>
|
</table></div>
|
||||||
<h2>notes</h2>
|
<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}}
|
{{range .Notes}}<tr><td>{{ago .Ts}}<td>{{.Source}}<td>{{.Text}}</tr>{{end}}
|
||||||
</table>
|
</table></div>
|
||||||
<script>
|
<script>
|
||||||
setInterval(() => fetch('/dash').then(r => r.text()).then(html => {
|
setInterval(() => fetch('/dash').then(r => r.text()).then(html => {
|
||||||
const p = new DOMParser(), d = p.parseFromString(html, 'text/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);
|
const old = document.getElementById(id), nu = d.getElementById(id);
|
||||||
if (old && nu) old.replaceWith(nu);
|
if (old && nu) old.replaceWith(nu);
|
||||||
}
|
}
|
||||||
document.querySelector('h2+div').textContent = 'обновлено ' + new Date().toLocaleTimeString();
|
document.getElementById('updated').textContent = 'обновлено ' + new Date().toLocaleTimeString();
|
||||||
}), 10000);
|
}), 10000);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+15
-32
@@ -1,43 +1,24 @@
|
|||||||
<!doctype html>
|
<!doctype html><meta charset=utf-8>
|
||||||
<html lang=en>
|
|
||||||
<meta charset=utf-8>
|
|
||||||
<meta name=viewport content="width=device-width,initial-scale=1">
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||||
<title>maven · history</title>
|
<title>maven · history</title>
|
||||||
<style>
|
<link rel=stylesheet href=/ui.css>
|
||||||
body{font:14px system-ui;margin:1.5rem;max-width:70rem}
|
<style>.val{word-break:break-all;max-width:20rem}</style>
|
||||||
h1{font-size:1.3rem;margin-bottom:.3rem}
|
{{template "nav" "history"}}
|
||||||
nav a{margin-right:1rem}
|
<h1>command history</h1>
|
||||||
table{border-collapse:collapse;width:100%;margin-top:.5rem}
|
<p class=muted>{{len .Facts}} facts shown (newest first)</p>
|
||||||
th,td{border:1px solid #ccc;padding:.3rem .5rem;text-align:left;vertical-align:top}
|
<div id=msg class=msg hidden></div>
|
||||||
th{background:#f6f6f6}
|
<div class=scroll><table class=mono>
|
||||||
.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>
|
|
||||||
<tr><th>when<th>kind<th>key<th>value<th>source<th>conf<th></tr>
|
<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}}
|
{{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>{{if .VoidsID}}<span class=void-badge>⨯ voided</span>{{end}}{{.Ts.Format "2006-01-02 15:04"}}</td>
|
||||||
<td>{{.Kind}}</td>
|
<td>{{.Kind}}</td>
|
||||||
<td class=key>{{.Key}}</td>
|
<td class=key>{{.Key}}</td>
|
||||||
<td class=val>{{.Value}}</td>
|
<td class=val>{{.Value}}</td>
|
||||||
<td class=src>{{.Source}}</td>
|
<td class=muted>{{.Source}}</td>
|
||||||
<td>{{printf "%.2f" .Confidence}}</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}}
|
</tr>{{end}}
|
||||||
</table>
|
</table></div>
|
||||||
<script>
|
<script>
|
||||||
const msg=document.getElementById('msg');
|
const msg=document.getElementById('msg');
|
||||||
function revert(key,btn){
|
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)})
|
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(r=>r.json().then(d=>({ok:r.ok,data:d})))
|
||||||
.then(({ok,data})=>{
|
.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');
|
msg.textContent=ok?'reverted ✓':(data.error||'revert failed');
|
||||||
if(ok) btn.closest('tr').className='voided';
|
if(ok) btn.closest('tr').className='voided';
|
||||||
}).catch(e=>{
|
}).catch(e=>{
|
||||||
msg.className='msg-err';
|
msg.className='msg msg-err';
|
||||||
|
msg.hidden=false;
|
||||||
msg.textContent='error: '+e;
|
msg.textContent='error: '+e;
|
||||||
btn.disabled=false;
|
btn.disabled=false;
|
||||||
});
|
});
|
||||||
|
|||||||
+41
-27
@@ -54,10 +54,23 @@ var traceHTML string
|
|||||||
//go:embed notifications.html
|
//go:embed notifications.html
|
||||||
var notificationsHTML string
|
var notificationsHTML string
|
||||||
|
|
||||||
// dashTmpl — the monitoring read surface, server-rendered from dash.html (no JS,
|
// navHTML — the shared site nav, parsed into every server-rendered template
|
||||||
// no client fetch); meta-refresh keeps it live. html/template escapes the user
|
// alongside ui.css so the pages read as one app. Invoke as
|
||||||
// text in facts/nudges. Read-only: browses the append-only store via CoreAPI,
|
// {{template "nav" "<active-page>"}}; the argument highlights the current link.
|
||||||
// never writes — the store IS the audit trail, this just shows it.
|
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{
|
var dashTmpl = template.Must(template.New("dash").Funcs(template.FuncMap{
|
||||||
"ago": func(t time.Time) string {
|
"ago": func(t time.Time) string {
|
||||||
// A zero timestamp (no presence signal yet, fresh DB) would make
|
// 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"
|
return time.Since(t).Round(time.Second).String() + " ago"
|
||||||
},
|
},
|
||||||
}).Parse(dashHTML))
|
}).Parse(navHTML + dashHTML))
|
||||||
|
|
||||||
func noCache(h http.Handler) http.Handler {
|
func noCache(h http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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).
|
// utterances (they came from voice STT — untrusted text).
|
||||||
var toolsTmpl = template.Must(template.New("tools").Funcs(template.FuncMap{
|
var toolsTmpl = template.Must(template.New("tools").Funcs(template.FuncMap{
|
||||||
"join": strings.Join,
|
"join": strings.Join,
|
||||||
}).Parse(toolsHTML))
|
}).Parse(navHTML + toolsHTML))
|
||||||
|
|
||||||
const toolsHTML = `<!doctype html><meta charset=utf-8><title>maven · tools</title>
|
const toolsHTML = `<!doctype html><meta charset=utf-8>
|
||||||
<style>body{font:15px system-ui;margin:2rem;max-width:52rem}h2{margin-top:2rem}
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||||
table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:.4rem .6rem;text-align:left}
|
<title>maven · tools</title>
|
||||||
input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
|
<link rel=stylesheet href=/ui.css>
|
||||||
.d{color:#b00}.msg{background:#efe;border:1px solid #6c6;padding:.5rem;margin:1rem 0}</style>
|
<style>input[type=text]{width:20rem}</style>
|
||||||
<h1>maven · tools</h1>
|
{{template "nav" "tools"}}
|
||||||
<p><small>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</small></p>
|
<h1>tools</h1>
|
||||||
{{if .Msg}}<div class=msg>{{.Msg}}</div>{{end}}
|
<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>
|
<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>
|
{{if .Proposed}}<p class=muted>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>
|
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
||||||
{{range .Proposed}}<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>
|
<td><form method=post action=/tools>
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
<input type=hidden name=scope value="{{.Scope}}">
|
<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>
|
<input type=text name=cmd placeholder="systemctl restart" required>
|
||||||
<label><input type=checkbox name=destructive> destructive</label>
|
<label><input type=checkbox name=destructive> destructive</label>
|
||||||
<button>enable</button></form></td>
|
<button>enable</button></form></td>
|
||||||
</tr>{{end}}</table>
|
</tr>{{end}}</table></div>
|
||||||
{{else}}<p>none pending.</p>{{end}}
|
{{else}}<p class=muted>none pending.</p>{{end}}
|
||||||
<h2>enabled <small>({{len .Enabled}})</small></h2>
|
<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>
|
{{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>{{.Scope}}</td><td><code>{{join .Cmd " "}}</code></td>
|
{{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=d>destructive</span>{{end}}</td>
|
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
|
||||||
<td><form method=post action=/tools style=display:inline>
|
<td><form method=post action=/tools style=display:inline>
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
<input type=hidden name=scope value="{{.Scope}}">
|
<input type=hidden name=scope value="{{.Scope}}">
|
||||||
<input type=hidden name=action value=disable>
|
<input type=hidden name=action value=disable>
|
||||||
<button>disable</button></form></td></tr>{{end}}</table>
|
<button>disable</button></form></td></tr>{{end}}</table></div>
|
||||||
{{else}}<p>none enabled.</p>{{end}}
|
{{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{
|
var traceTmpl = template.Must(template.New("trace").Funcs(template.FuncMap{
|
||||||
"ago": func(t time.Time) string {
|
"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")
|
return t.Format("15:04:05")
|
||||||
},
|
},
|
||||||
"join": strings.Join,
|
"join": strings.Join,
|
||||||
}).Parse(traceHTML))
|
}).Parse(navHTML + traceHTML))
|
||||||
|
|
||||||
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
if core == nil {
|
if core == nil {
|
||||||
|
|||||||
@@ -1,49 +1,17 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html><meta charset=utf-8>
|
||||||
<html lang="en">
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||||
<head>
|
<title>maven · notifications</title>
|
||||||
<meta charset="UTF-8">
|
<link rel=stylesheet href=/ui.css>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<style>.text{max-width:22rem}</style>
|
||||||
<title>Notifications — Maven</title>
|
{{template "nav" "notifications"}}
|
||||||
<style>
|
<h1>notifications</h1>
|
||||||
body{background:#111;color:#ddd;font-family:system-ui,sans-serif;margin:0;padding:1rem;font-size:.9rem}
|
{{if .Nudges}}<div class=scroll><table>
|
||||||
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>
|
|
||||||
<tr><th>when</th><th>rule</th><th>channel</th><th>outcome</th><th>message</th></tr>
|
<tr><th>when</th><th>rule</th><th>channel</th><th>outcome</th><th>message</th></tr>
|
||||||
{{range .Nudges}}<tr>
|
{{range .Nudges}}<tr>
|
||||||
<td class=ts>{{.Ts.Format "15:04"}}</td>
|
<td class=muted>{{.Ts.Format "15:04"}}</td>
|
||||||
<td class=rule>{{.Rule}}</td>
|
<td class=key>{{.Rule}}</td>
|
||||||
<td><span class=chan>{{.Channel}}</span></td>
|
<td><span class=badge>{{.Channel}}</span></td>
|
||||||
<td class={{.Outcome}}>{{.Outcome}}</td>
|
<td class={{.Outcome}}>{{.Outcome}}</td>
|
||||||
<td class=msg>{{.Message}}</td>
|
<td class=text>{{.Message}}</td>
|
||||||
</tr>{{end}}</table>
|
</tr>{{end}}</table></div>
|
||||||
{{else}}<div class=empty>no notifications yet</div>{{end}}
|
{{else}}<div class=empty>no notifications yet</div>{{end}}
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -11,11 +11,13 @@
|
|||||||
html,body{height:100%;background:#111;color:#ddd;font-family:system-ui,-apple-system,sans-serif}
|
html,body{height:100%;background:#111;color:#ddd;font-family:system-ui,-apple-system,sans-serif}
|
||||||
body{display:flex;flex-direction:column}
|
body{display:flex;flex-direction:column}
|
||||||
|
|
||||||
nav{display:flex;background:#1a1a2e;border-bottom:1px solid #333;flex-shrink:0}
|
nav{display:flex;align-items:stretch;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}
|
/* 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.active{color:#00aaff;border-bottom:2px solid #00aaff}
|
||||||
nav button:hover{color:#ddd}
|
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}
|
nav .lang.active{color:#00aaff}
|
||||||
|
|
||||||
.tab{display:none;flex-direction:column;align-items:center;flex:1;overflow:auto;padding:1rem}
|
.tab{display:none;flex-direction:column;align-items:center;flex:1;overflow:auto;padding:1rem}
|
||||||
|
|||||||
@@ -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
@@ -1,21 +1,11 @@
|
|||||||
<!doctype html><meta charset=utf-8>
|
<!doctype html><meta charset=utf-8>
|
||||||
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||||
<title>maven · rule trace</title>
|
<title>maven · rule trace</title>
|
||||||
<style>
|
<link rel=stylesheet href=/ui.css>
|
||||||
body{font:14px monospace;background:#111;color:#ddd;margin:1rem}
|
{{template "nav" "trace"}}
|
||||||
h2{color:#8cf;margin:1.2rem 0 .3rem}
|
<h1>rule trace</h1>
|
||||||
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>
|
|
||||||
<div class=updated>{{.Now | ago}} — winner: <strong>{{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}</strong></div>
|
<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>
|
<tr><th>rule<th>sev<th>predicate<th>gate<th>blocked by<th>detail<th>selected<th>lost to</tr>
|
||||||
{{range .Rules}}<tr>
|
{{range .Rules}}<tr>
|
||||||
<td>{{.RuleName}}</td>
|
<td>{{.RuleName}}</td>
|
||||||
@@ -24,14 +14,14 @@
|
|||||||
<td class={{if .GateResult}}green{{else if .GateBlockedBy}}red{{else}}gray{{end}}>{{.GateResult}}</td>
|
<td class={{if .GateResult}}green{{else if .GateBlockedBy}}red{{else}}gray{{end}}>{{.GateResult}}</td>
|
||||||
<td>{{.GateBlockedBy}}</td>
|
<td>{{.GateBlockedBy}}</td>
|
||||||
<td><details><summary>gate</summary>
|
<td><details><summary>gate</summary>
|
||||||
<span class=gl>snooze_until:</span> {{.GateDetail.SnoozeUntil | fmtTime}}<br>
|
<span class=muted>snooze_until:</span> {{.GateDetail.SnoozeUntil | fmtTime}}<br>
|
||||||
<span class=gl>cooldown_until:</span> {{.GateDetail.CooldownUntil | fmtTime}}<br>
|
<span class=muted>cooldown_until:</span> {{.GateDetail.CooldownUntil | fmtTime}}<br>
|
||||||
<span class=gl>quiet_hours:</span> {{.GateDetail.QuietHours}}<br>
|
<span class=muted>quiet_hours:</span> {{.GateDetail.QuietHours}}<br>
|
||||||
<span class=gl>calendar_busy:</span> {{.GateDetail.CalendarBusy}}<br>
|
<span class=muted>calendar_busy:</span> {{.GateDetail.CalendarBusy}}<br>
|
||||||
<span class=gl>presence:</span> {{.GateDetail.Presence}}<br>
|
<span class=muted>presence:</span> {{.GateDetail.Presence}}<br>
|
||||||
<span class=gl>inert_keys_missing:</span> {{if .GateDetail.InertKeysMissing}}{{join .GateDetail.InertKeysMissing ", "}}{{else}}—{{end}}
|
<span class=muted>inert_keys_missing:</span> {{if .GateDetail.InertKeysMissing}}{{join .GateDetail.InertKeysMissing ", "}}{{else}}—{{end}}
|
||||||
</details></td>
|
</details></td>
|
||||||
<td class={{if .WasSelected}}green{{end}}>{{.WasSelected}}</td>
|
<td class={{if .WasSelected}}green{{end}}>{{.WasSelected}}</td>
|
||||||
<td>{{.LostTo}}</td>
|
<td>{{.LostTo}}</td>
|
||||||
</tr>{{end}}
|
</tr>{{end}}
|
||||||
</table>
|
</table></div>
|
||||||
|
|||||||
+10
-6
@@ -67,11 +67,15 @@ func (h *PasskeyHandle) Page(w http.ResponseWriter, r *http.Request) {
|
|||||||
const passkeyPageHTML = `<!doctype html><meta charset=utf-8>
|
const passkeyPageHTML = `<!doctype html><meta charset=utf-8>
|
||||||
<meta name=viewport content="width=device-width,initial-scale=1">
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||||
<title>maven · passkey</title>
|
<title>maven · passkey</title>
|
||||||
<style>body{font:16px system-ui;max-width:34rem;margin:3rem auto;padding:0 1rem}
|
<link rel=stylesheet href=/ui.css>
|
||||||
button{font:inherit;padding:.5rem 1rem;margin:.3rem .3rem 0 0;cursor:pointer}
|
<style>button{padding:.5rem 1rem;margin:.3rem .3rem 0 0}
|
||||||
#msg{margin-top:1rem;padding:.6rem;border-radius:.3rem;white-space:pre-wrap}
|
#msg{white-space:pre-wrap}</style>
|
||||||
.ok{background:#e6ffed}.err{background:#ffe6e6}</style>
|
<nav class=site>
|
||||||
<h1>maven passkey</h1>
|
<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
|
<p>Enroll a passkey once, then assert it to unlock destructive actions
|
||||||
(tool enable) for a few minutes.</p>
|
(tool enable) for a few minutes.</p>
|
||||||
<button onclick=enroll()>enroll passkey</button>
|
<button onclick=enroll()>enroll passkey</button>
|
||||||
@@ -81,7 +85,7 @@ button{font:inherit;padding:.5rem 1rem;margin:.3rem .3rem 0 0;cursor:pointer}
|
|||||||
<script>
|
<script>
|
||||||
const b64u=b=>btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
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 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{
|
async function enroll(){try{
|
||||||
const {challenge,options}=await (await fetch('/auth/webauthn/register/begin')).json();
|
const {challenge,options}=await (await fetch('/auth/webauthn/register/begin')).json();
|
||||||
options.challenge=ub64(options.challenge);
|
options.challenge=ub64(options.challenge);
|
||||||
|
|||||||
Reference in New Issue
Block a user