mavweb: ui design system refresh — shared nav, cards, component classes

- Rewrote ui.css with design tokens, card/btn/badge/dot components
- All pages wrapped in <main class=page> with max-width container
- Replaced inline <style> blocks with ui.css classes
- passkey page now uses shared nav.site template
- PWA voice page unified under shared nav.site (no more separate tab nav)
- Inline lang toggle moved from nav to voice page body
This commit is contained in:
kami
2026-07-06 00:41:16 +04:00
parent 2506327b62
commit 311c5cb1cd
9 changed files with 551 additions and 131 deletions
+347
View File
@@ -0,0 +1,347 @@
# Overnight Session — 2026-07-06
Branch: `overnight-jul6` (from `master`)
Executor: a single unsupervised agent working through the night.
---
## READ THIS FIRST — Operating rules (do not skip)
You are working **unsupervised**. Optimize for *not breaking anything* over
finishing every task. A half-finished task that compiles and is committed is a
success; a clever half-rewrite that breaks the build is a failure.
**Hard rules:**
1. **One task = one commit.** Never batch two tasks into one commit. Commit
message: `maven: <task-title> (task N)`. Sign-off line required (see repo
convention — Co-Authored-By trailer).
2. **TDD, always.** For every task that touches Go: write the test first, watch
it fail, then write code until it passes. Tests live next to the code as
`*_test.go`. Copy the style of the nearest existing test file.
3. **After every task, run the gate before committing:**
```
gofmt -l . # must print nothing
go build ./... # must succeed
go vet ./... # must be clean
go test ./... # must be green
```
If any step fails and you cannot fix it in ~15 min, **`git stash` or revert
that task, write a note in the task's Status cell ("BLOCKED: <reason>"), and
move to the next task.** Do not leave a broken tree.
4. **Never invent config keys, function names, or file paths.** Every new thing
copies an existing pattern named in the task. If you can't find the pattern,
mark the task BLOCKED and skip it.
5. **Tools/acts: never add a tool without `"destructive": true` unless it is
provably read-only** (see Task 2). A destructive act that runs from voice
without a confirm gate is the worst possible bug. When unsure → destructive.
6. **Do NOT attempt the "DEFERRED — needs human" section at the bottom.** Those
need hardware or protocol decisions. Touching them unsupervised will waste
the night. They are listed only so you don't rediscover them.
7. Prefer additive changes. Do not refactor existing packages. Do not touch
`cmd/mavweb/`, encryption, or the store schema unless a task says to.
**Work top-to-bottom.** Tasks are ordered by value-per-risk: safest and most
self-contained first. If you run out of night, the earlier tasks are the ones
that matter.
---
## Key facts about the codebase (so you don't have to rediscover them)
- **Router cascade**: `internal/router/`. Intents are the constants in
`intent.go` (`act, reminder, fact, note, query, system`). Adding an intent =
add a const there + seed examples + a handler case.
- **Intent seeds**: `models/seeds/<intent>.txt`, one example per line, `#`
comments allowed. Loaded by `seedClassifier` in `cmd/mavend/voice.go`. To
teach the classifier a new phrase, add a line to the right seed file — no code
change needed.
- **Voice intent dispatch**: `cmd/mavend/voice.go`, the big `switch dec.Intent`
(search `case router.IntentQuery:` ~line 412). Each intent returns a Russian
reply string. This is where a new intent's behaviour hangs.
- **Tools/acts**: enabled allowlist lives in config `voice.tools` (see
`internal/config/config.go` `ToolConfig`). Executor: `internal/tool/tool.go`.
Args are argv, never shell. Destructive tools return `ErrNeedsConfirm`.
- **Config**: `internal/config/config.go`. Seed/prod config: `deploy/mavend.json`.
- **Store** (facts, notes, reminders, tools): `internal/store/`. CalDAV events
are written as facts with `source=caldav` plus a `calendar_busy` key (per the
poller in `mavpoll`/`mavcaldav`).
- **Embedder**: `voice.embedder` config → ONNX; nil → `router.NewHashEmbedder`
floor. Wiring is in `cmd/mavend/voice.go` ~line 144.
- **Language is Russian.** Maven refers to herself in the **feminine**. All
user-facing reply strings are RU. Copy tone from existing replies.
---
## Phase 0 — Setup (do this once, first)
- [ ] `git checkout master && git pull` (if remote), then
`git checkout -b overnight-jul6`
- [ ] Run the full gate (`go build ./... && go vet ./... && go test ./...`) on a
clean tree to confirm a green baseline **before** you change anything. If
baseline is red, STOP and record it here — do not build on a broken tree.
---
## Task 1 — Embedder config validation + docs (safest, do first)
**Goal:** make embedder misconfiguration fail loudly instead of silently
falling back to the Hash floor.
**Files:** `internal/config/config.go` (Validate path), its `*_test.go`,
`deploy/mavend.json`, and a short note in `START.md` or `PROGRESS.md`.
**Do:**
1. Find where `VoiceConfig` / `EmbedderConfig` is validated (look for a
`Validate()` method or the load path in `config.go`). Add a check: if
`Embedder` is non-nil, then **all three** of `ModelPath`, `TokenizerPath`,
`LibPath` must be non-empty — a partially-filled embedder block is a config
error (`return fmt.Errorf(...)`). If `Embedder` is nil, that's fine (Hash
floor) — no error.
2. In `cmd/mavend/voice.go` around the embedder wiring (~line 144159), make the
"falling back to HashEmbedder" path an explicit `log.Printf("voice: embedder
not configured, using HashEmbedder floor")` if it isn't already.
3. Add a test to `config_test.go` covering: all-three-set → ok; one-missing →
error; nil → ok.
4. Document the `voice.embedder` block (all three paths, and "omit the block to
use the floor") in `START.md` near other config docs.
**Done when:** new test passes, gate green, docs updated. One commit.
---
## Task 2 — Seed the tool allowlist with safe homelab acts
**Goal:** give the voice `act` path a useful, SAFE starter allowlist.
**Files:** `deploy/mavend.json` (`voice.tools`), and `models/seeds/act.txt`.
**Do:**
1. Add tools to `voice.tools` in `deploy/mavend.json`. Each: `name`, `cmd`
(argv prefix), `scope`, `destructive`. Classify carefully:
- **Read-only (destructive: false)** — safe to fire from voice:
`systemctl status`, `docker ps`, `uptime`, `df`, `free`, journal *reads*
(`journalctl -n 50 -u <unit>` — note the unit comes as an arg).
- **Destructive: true** — must confirm: `systemctl restart`, `systemctl stop`,
`docker restart`, `reboot`, `docker stop`.
- When unsure → `destructive: true`.
2. Add matching spoken RU phrasings to `models/seeds/act.txt` (e.g. «покажи
статус nginx», «перезапусти nginx», «сколько места на диске») so the
classifier routes them to `act`. One per line.
3. There is **no Go change** here if the executor already reads `voice.tools`.
Verify by reading the wiring — if tools are loaded from config into the store
allowlist at boot, you're done. If not, mark BLOCKED (don't build new wiring).
**Done when:** `go test ./...` still green (config parses), the JSON is valid
(`go run` the daemon far enough to parse, or a small config-load test). One commit.
**Guardrail:** double-check no `restart`/`stop`/`reboot`/`rm`/`kill` entry has
`destructive: false`. This is the single most important check of the night.
---
## Task 3 — Calendar event querying ("что у меня завтра?")
**Goal:** answer calendar questions from CalDAV facts already in the store.
**Files:** `internal/router/` (slots + a `query` sub-path, or reuse `IntentQuery`
with a calendar slot), `cmd/mavend/voice.go` (handler), `models/seeds/query.txt`,
and tests.
**Approach (keep it simple — don't add a new intent if you can avoid it):**
1. The data is already there: CalDAV events are facts with `source=caldav`. Find
the store method that reads facts by source/date (grep `caldav` in
`internal/store/`). If none scopes by date, add a small read helper
`CalendarEvents(ctx, from, to time.Time)` next to the existing facts queries —
copy the style of an existing `store/facts.go` query, with a test.
2. Add date-scope parsing: «сегодня» → today, «завтра» → tomorrow. Put this in a
small helper in `internal/router/slots.go` (copy the RU parsing style in
`slots_ru_test.go`). Test it directly.
3. In the `IntentQuery` handler in `voice.go`, detect a calendar question (the
utterance mentions планы/календарь/завтра/сегодня + no note match, OR a
dedicated keyword check *before* the notes RAG lookup). Read events for the
scoped day, format an RU reply: empty → «на сегодня ничего нет», one/many →
list them. Keep formatting in a tested pure helper.
4. Seed `models/seeds/query.txt` with the example phrasings.
**Done when:** helper tests + a handler-level test pass, gate green. One commit
(or two: store helper, then handler — that's fine, keep them separate).
**If store scoping turns out hard:** ship just the date parser + formatter as
pure tested helpers and wire them to a naive "read all caldav facts, filter in
Go" — personal scale, correctness over efficiency. Do not add schema.
---
## Task 4 — General-knowledge routing to the phraser
**Goal:** route open factual questions to the phraser with an anti-hallucination
system prompt and a fallback.
**Files:** `cmd/mavend/voice.go` (query handler), phraser call site (grep
`phraser` / `Phrase` in voice.go and `internal/phraser/`), `models/seeds/query.txt`,
tests.
**Do:**
1. In the `IntentQuery` handler, **after** the notes-RAG lookup fails to clear
`queryMinScore` (currently returns «у меня нет заметок…»), instead of giving
up, hand the question to the phraser with a system prompt like: «Ответь кратко
из своих знаний. Если не знаешь — скажи "не знаю". Не выдумывай.» (feminine
self-reference).
2. **Fallback gate:** if the phraser returns empty, errors, or the phraser is the
Stub (not configured), return «не знаю» / the existing no-answer reply. Never
fabricate.
3. Keep the prompt construction in a small pure function so you can unit-test it
(assert the system prompt text + that empty phraser output → fallback).
**Done when:** prompt-construction test + fallback test pass, gate green. One
commit.
**Risk note:** the phraser is a small model and will hallucinate. The fallback is
the point of this task — test it hard. Do not remove the notes-RAG path; this is
a *fallback after* it.
---
## Task 5 — Weather module skeleton (pure, no network at night)
**Goal:** a pluggable weather provider interface + an unconfigured stub. **No
live API calls.**
**Files:** new `internal/weather/` package, `internal/config/config.go`
(a `WeatherConfig` block, copy `PhraserConfig` shape), tests.
**Do:**
1. `internal/weather/weather.go`: define
`type Provider interface { CurrentWeather(ctx, location string) (Weather, error) }`
and a `Weather` struct (temp, condition, location). Add a `StubProvider` that
returns a sentinel `ErrNotConfigured` (or a "погода не настроена" message).
2. Config: add `Weather *WeatherConfig` to `VoiceConfig` (fields: `Provider`,
`APIKey`, `DefaultLocation` — all omitempty). **No API key in the repo.**
3. Optionally add an Open-Meteo provider *struct that is not called at night*
(no key needed) — but if you write it, do NOT make a network call in tests;
test against a mocked HTTP round-tripper only. If that's too fiddly, ship just
the interface + stub and leave a `// TODO: open-meteo provider` — that's fine.
4. **Add a real Open-Meteo provider** (keyless — no API key needed). Endpoint:
`https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..&current_weather=true`.
Geocode via `https://geocoding-api.open-meteo.com/v1/search?name=<location>`.
Keep the `*http.Client` injectable (a struct field) so tests use a mocked
round-tripper — **no real network call in any test.** Config selects
provider by `voice.weather.provider` ("open-meteo" | "" → stub).
5. **Wire it into voice.go.** In the `IntentQuery` handler, detect a weather
question (keywords погода/градус/температура, or a `query_weather` sub-path)
→ call the configured provider with `DefaultLocation` or a parsed location →
format an RU reply. Unconfigured → the stub's «погода не настроена» message.
Seed `models/seeds/query.txt` with «какая погода», «какая погода в москве».
Bound the provider call with a context timeout (~5s) so a slow API can't hang
the voice turn.
**Done when:** stub + mocked-Open-Meteo tests pass (round-trip against a fake
transport, unconfigured → stub message, location parsing), gate green. Split into
two commits if helpful: provider+interface, then voice wiring.
---
## Task 6 — Dialogue state scaffold (pure data structures)
**Goal:** a session/context data layer for future multi-turn. **No LLM, no
wiring into the live path unless trivial and tested.**
**Files:** new `internal/dialogue/` package + tests only.
**Do:**
1. `internal/dialogue/session.go`: a `Session` holding last-turn intent + slots,
a timestamp, and a TTL (default ~2min, configurable via a field). A
`SessionStore` (in-memory map keyed by session id) with `Get`, `Put`, and
TTL-based expiry.
2. A pure `InheritSlots(prev, cur Slots) Slots` helper: carry forward slots the
current turn is missing (e.g. previous had a location, current didn't → use
previous). Copy the `Slots` shape from `internal/router/intent.go`.
3. Tests: context carry-over, slot inheritance, session expiry, missing prior
session. This is the whole task — it's a tested library, not a feature.
**Done when:** tests pass, gate green. Then (only if the library is solid and
gate is green) **wire a minimal read seam into voice.go**: on a follow-up-shaped
utterance, look up the prior session's slots and fill the current turn's missing
slots via `InheritSlots` before routing. Keep the session store's lifetime owned
by the handler struct. If wiring gets fiddly or risks the live path, ship the
tested library and mark the wiring BLOCKED — the library is the required part.
Separate commits: library, then wiring.
---
## Task 7 — Long-term memory vector-store interface (pure)
**Goal:** an interface + in-memory implementation for a future vector backend.
**Files:** new `internal/memory/store.go` + tests only.
**Do:**
1. `type Store interface { Insert(ctx, id string, vec []float32, meta map[string]string) error; Search(ctx, vec []float32, topK int) ([]Result, error) }`.
`Result` = id, score, meta.
2. An `InMemoryStore` implementing it with cosine similarity (copy the `cosine`
function idea from `internal/router/classifier.go` — you may factor a shared
helper, but simplest is to reimplement locally; don't refactor the router).
3. Tests: insert→search round-trip, cosine ordering (nearest first), topK
truncation, metadata filtering if you add it. In-memory only.
**Done when:** tests pass, gate green (library commit). Then **wire the embedding
pipeline**: in the `IntentNote` handler in `voice.go`, after `WriteNote`, also
`Insert` the note's embedding + metadata (id, source, ts) into the memory Store.
Use the **same embedder** the classifier uses (already in scope as `h.embedder`).
Make the memory Store a field on the handler, defaulting to `InMemoryStore` so
nothing external is required. Wrap the Insert in its own error branch — a memory
Insert failure must **not** fail the note write (log and continue). Separate
commit for the wiring.
---
## Phase Final — Verification pass (always do this last)
- [ ] `gofmt -l .` prints nothing
- [ ] `go build ./...` succeeds
- [ ] `go vet ./...` clean
- [ ] `go test ./...` green
- [ ] `docker compose build` succeeds (all daemons compile) — if docker is
unavailable in the environment, note it and rely on `go build ./...`.
- [ ] `git log --oneline master..HEAD` — confirm one commit per completed task,
each message names its task, no "wip"/debug commits.
- [ ] Grep for accidents: `grep -rn "destructive.*false" deploy/mavend.json` and
eyeball every hit; `grep -rniE "TODO|FIXME|panic\(|fmt.Println" cmd internal`
— no stray debug prints, no new panics in live paths.
- [ ] Update the Status column of each task in this file (Done / BLOCKED:reason /
Skipped) so the human can see what happened at a glance.
---
## Status board (fill this in as you go)
| # | Task | Commit | Status |
|---|------|--------|--------|
| 1 | Embedder config validation + docs | | |
| 2 | Seed safe tool allowlist | | |
| 3 | Calendar querying | | |
| 4 | General-knowledge phraser routing | | |
| 5 | Weather skeleton (pure) | | |
| 6 | Dialogue scaffold (pure) | | |
| 7 | Memory vector interface (pure) | | |
| F | Final verification pass | | |
---
## DEFERRED — needs a human, DO NOT ATTEMPT unsupervised
These were in the original plan. They require hardware or protocol decisions and
will burn the night if attempted blind. Left here only so you don't rediscover
them and think they were forgotten.
- **Always-on listening / wake word** (`internal/wake/`, `cmd/mavmic/`): needs a
hardware decision (USB mic vs Pi vs smart speaker) and a Porcupine license/key.
Human input required.
- **Streaming STT over WebSocket + barge-in** (`internal/voice` receive path):
changes the wire protocol (`PROTOCOL.md`) and the STT worker contract. Too
invasive to do safely unsupervised; risks breaking the working full-clip path.
- **Streaming TTS**: blocked on Piper. Out of scope.
If you finish Tasks 17 with time to spare, do NOT start these. Instead: improve
test coverage on what you built, expand the seed files, and improve docs.
+15 -5
View File
@@ -3,21 +3,31 @@
<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>
<main class=page>
<section class=card>
<h2 class=card-title><span class="dot dot-ok" style="margin-right:.2rem"></span>presence</h2>
<p><span class={{.Presence.Bucket}}>{{.Presence.Bucket}}</span> — score {{printf "%.2f" .Presence.Score}} <span class=muted>({{ago .Presence.Updated}})</span></p>
<div class=updated id=updated>обновляется каждые 10с</div>
<h2>nudges</h2>
</section>
<section class=card>
<h2 class=card-title>nudges</h2>
<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>
</section>
<section class=card>
<h2 class=card-title>facts</h2>
<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>
</section>
<section class=card>
<h2 class=card-title>notes</h2>
<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></div>
</section>
</main>
<script>
setInterval(() => fetch('/dash').then(r => r.text()).then(html => {
const p = new DOMParser(), d = p.parseFromString(html, 'text/html');
+3 -2
View File
@@ -2,8 +2,8 @@
<meta name=viewport content="width=device-width,initial-scale=1">
<title>maven · history</title>
<link rel=stylesheet href=/ui.css>
<style>.val{word-break:break-all;max-width:20rem}</style>
{{template "nav" "history"}}
<main class=page>
<h1>command history</h1>
<p class=muted>{{len .Facts}} facts shown (newest first)</p>
<div id=msg class=msg hidden></div>
@@ -16,9 +16,10 @@
<td class=val>{{.Value}}</td>
<td class=muted>{{.Source}}</td>
<td>{{printf "%.2f" .Confidence}}</td>
<td>{{if not .VoidsID}}<button onclick="revert('{{.Key}}',this)">revert</button>{{end}}</td>
<td>{{if not .VoidsID}}<button class=btn onclick="revert('{{.Key}}',this)">revert</button>{{end}}</td>
</tr>{{end}}
</table></div>
</main>
<script>
const msg=document.getElementById('msg');
function revert(key,btn){
+13 -6
View File
@@ -371,12 +371,13 @@ 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"}}
<main class=page>
<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>
<section class=card>
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
{{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>
@@ -385,12 +386,14 @@ const toolsHTML = `<!doctype html><meta charset=utf-8>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=enable>
<input type=text name=cmd placeholder="systemctl restart" required>
<input type=text name=cmd class=input-wide placeholder="systemctl restart" required>
<label><input type=checkbox name=destructive> destructive</label>
<button>enable</button></form></td>
<button class=btn>enable</button></form></td>
</tr>{{end}}</table></div>
{{else}}<p class=muted>none pending.</p>{{end}}
<h2>enabled <small>({{len .Enabled}})</small></h2>
</section>
<section class=card>
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
{{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>
@@ -398,14 +401,18 @@ const toolsHTML = `<!doctype html><meta charset=utf-8>
<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></div>
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>
{{else}}<p class=muted>none enabled.</p>{{end}}
</section>
</main>
`
var historyTmpl = template.Must(template.New("history").Parse(navHTML + historyHTML))
var notificationsTmpl = template.Must(template.New("notifications").Parse(navHTML + notificationsHTML))
var passkeyTmpl = template.Must(template.New("passkey").Parse(navHTML + passkeyPageHTML))
var traceTmpl = template.Must(template.New("trace").Funcs(template.FuncMap{
"ago": func(t time.Time) string {
if t.IsZero() {
+3 -2
View File
@@ -2,8 +2,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"}}
<main class=page>
<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>
@@ -12,6 +12,7 @@
<td class=key>{{.Rule}}</td>
<td><span class=badge>{{.Channel}}</span></td>
<td class={{.Outcome}}>{{.Outcome}}</td>
<td class=text>{{.Message}}</td>
<td class=text-max>{{.Message}}</td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>no notifications yet</div>{{end}}
</main>
+44 -58
View File
@@ -5,58 +5,57 @@
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#111">
<link rel="manifest" href="/manifest.json">
<link rel=stylesheet href=/ui.css>
<script defer src="/app.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;background:#111;color:#ddd;font-family:system-ui,-apple-system,sans-serif}
body{display:flex;flex-direction:column}
/* Voice page layout — the shared nav.site handles everything above */
html,body{height:100%}
body{display:flex;flex-direction:column;padding:1rem}
.voice{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1.5rem;padding:1rem 0}
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;display:flex;align-items:center;padding:.6rem .5rem;font-size:.75rem;color:#555;cursor:pointer;letter-spacing:.1em}
nav .lang.active{color:#00aaff}
/* Voice PTT button */
#btn{width:140px;height:140px;border-radius:50%;border:4px solid var(--accent);background:var(--panel);color:var(--accent);font-size:1rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all var(--transition);user-select:none;-webkit-tap-highlight-color:transparent;touch-action:manipulation}
#btn:active,#btn.active{background:rgba(0,170,255,.13);border-color:var(--ok);color:var(--ok);transform:scale(1.05)}
#btn:disabled{opacity:.3;border-color:var(--border)}
.tab{display:none;flex-direction:column;align-items:center;flex:1;overflow:auto;padding:1rem}
.tab.active{display:flex}
#tab-voice{gap:2rem}
#tab-dash{padding:0}
h1{font-size:1.2rem;font-weight:400;color:#888;letter-spacing:.1em;text-transform:uppercase}
#status{font-size:.85rem;color:#666;min-height:1.2em}
#btn{width:140px;height:140px;border-radius:50%;border:4px solid #00aaff;background:#1a1a2e;color:#00aaff;font-size:1rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;user-select:none;-webkit-tap-highlight-color:transparent;touch-action:manipulation}
#btn:active,#btn.active{background:#00aaff22;border-color:#00ff88;color:#00ff88;transform:scale(1.05)}
#btn:disabled{opacity:.3;border-color:#444}
#log{width:100%;max-width:480px;max-height:40vh;overflow-y:auto;font-size:.8rem;color:#666;line-height:1.6;padding:.5rem;border-top:1px solid #222;margin-top:1rem}
#log .reply{color:#8f8}
#log .error{color:#f88}
#status{font-size:var(--fs-sm);color:var(--muted);min-height:1.2em}
#log{width:100%;max-width:480px;max-height:40vh;overflow-y:auto;font-size:var(--fs-sm);color:var(--muted);line-height:1.6;padding:var(--space-sm);border-top:1px solid var(--line)}
#log .reply{color:var(--ok)}
#log .error{color:var(--err)}
#log .push{color:#88f}
#dash-frame{width:100%;flex:1;border:none;background:#111;min-height:0}
#cheat{width:100%;max-width:480px;font-size:.8rem;border-top:1px solid #222;color:#888}
#cheat summary{cursor:pointer;padding:.5rem 0;color:#00aaff;letter-spacing:.05em;text-transform:uppercase;font-size:.75rem;user-select:none}
/* Cheatsheet */
#cheat{width:100%;max-width:480px;font-size:var(--fs-sm);border-top:1px solid var(--line);color:var(--muted)}
#cheat summary{cursor:pointer;padding:var(--space-sm) 0;color:var(--accent);letter-spacing:.05em;text-transform:uppercase;font-size:var(--fs-xs);user-select:none}
#cheat dl{display:grid;grid-template-columns:auto 1fr;gap:.35rem .8rem;padding:.25rem 0 .5rem}
#cheat dt{color:#00ff88;font-weight:600;white-space:nowrap}
#cheat dd{color:#aaa;line-height:1.5}
#cheat dd span{color:#555}
#cheat dt{color:var(--ok);font-weight:600;white-space:nowrap}
#cheat dd{color:var(--soft);line-height:1.5}
#cheat dd span{color:var(--dim)}
.cheat-en{display:revert}
.cheat-ru{display:none}
html[data-lang=ru] .cheat-en { display: none; }
html[data-lang=ru] .cheat-ru { display: revert; }
html[data-lang=ru] .cheat-en{display:none}
html[data-lang=ru] .cheat-ru{display:revert}
/* Lang toggle inline (not in nav) */
.lang-toggle{display:flex;gap:0;justify-content:center;margin-top:1rem}
.lang-toggle span{font-size:var(--fs-xs);color:var(--dim);cursor:pointer;padding:.2rem .5rem;letter-spacing:.1em;text-transform:uppercase;border:1px solid var(--border);transition:color var(--transition);user-select:none}
.lang-toggle span:first-child{border-radius:var(--radius-sm) 0 0 var(--radius-sm);border-right:none}
.lang-toggle span:last-child{border-radius:0 var(--radius-sm) var(--radius-sm) 0;border-left:none}
.lang-toggle span.active{color:var(--accent);border-color:var(--accent);background:rgba(0,170,255,.08)}
.lang-toggle span:hover{color:var(--fg)}
</style>
</head>
<body>
<nav>
<button class="active" data-tab="voice">Voice</button>
<button data-tab="dash">Dash</button>
<span class="lang active" data-lang="ru">RU</span>
<span class="lang" data-lang="en">EN</span>
<nav class=site>
<a href=/ class=active>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>passkey</a>
</nav>
<div id="tab-voice" class="tab active">
<div class=voice>
<h1>Maven Voice</h1>
<div id="status">tap &amp; hold to speak</div>
<button id="btn" type="button">&#x1f399;</button>
@@ -84,36 +83,23 @@ html[data-lang=ru] .cheat-ru { display: revert; }
<dd class="cheat-en">quiet mode on · system load</dd>
</dl>
</details>
</div>
<div id="tab-dash" class="tab">
<iframe id="dash-frame" src="/dash"></iframe>
<div class=lang-toggle>
<span class="active" data-lang="ru">RU</span>
<span data-lang="en">EN</span>
</div>
</div>
<script>
(function() {
// Read lang from URL param or default to ru
var lang = new URLSearchParams(window.location.search).get('lang') || 'ru';
if (lang !== 'en') lang = 'ru';
document.documentElement.setAttribute('data-lang', lang);
// Nav buttons
document.querySelectorAll('nav button[data-tab]').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('nav button[data-tab]').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
btn.classList.add('active');
var tab = document.getElementById('tab-' + btn.dataset.tab);
if (tab) tab.classList.add('active');
});
});
// Lang toggle
document.querySelectorAll('nav .lang').forEach(function(el) {
document.querySelectorAll('.lang-toggle span').forEach(function(el) {
el.addEventListener('click', function() {
var l = el.dataset.lang;
document.documentElement.setAttribute('data-lang', l);
document.querySelectorAll('nav .lang').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.lang-toggle span').forEach(function(b) { b.classList.remove('active'); });
el.classList.add('active');
// Update URL without reload
var url = new URL(window.location);
url.searchParams.set('lang', l);
history.replaceState(null, '', url);
+114 -44
View File
@@ -1,65 +1,135 @@
/* 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. */
/* maven design system — dark, restrained, #00aaff accent.
Every server-rendered page uses this single stylesheet.
Pages carry NO inline <style> beyond true one-offs. */
/* ── Design Tokens ── */
: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;
--bg:#111;--panel:#1a1a2e;--line:#222;--border:#333;
--fg:#ddd;--soft:#aaa;--muted:#888;--dim:#555;
--accent:#00aaff;--ok:#00ff88;--warn:#ffaa00;--err:#ff4444;
--surface-page:var(--bg);
--surface-card:var(--panel);
--surface-raised:#222;
--surface-input:#181818;
--surface-hover:rgba(255,255,255,.04);
--fs-xs:.7rem;--fs-sm:.8rem;--fs-base:14px;
--fs-lg:1.25rem;--fs-xl:1.5rem;
--space-xs:.25rem;--space-sm:.5rem;--space-md:.75rem;
--space-lg:1.25rem;--space-xl:2rem;
--radius-sm:3px;--radius-md:6px;--radius-lg:10px;
--shadow-sm:0 1px 3px rgba(0,0,0,.3);
--shadow-md:0 2px 8px rgba(0,0,0,.4);
--transition:150ms ease;
}
/* ── Reset / Base ── */
*{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}
body{background:var(--surface-page);color:var(--fg);font:var(--fs-base)/1.6 system-ui,sans-serif;
margin:0;padding:1rem;-webkit-text-size-adjust:100%;min-height:100vh}
/* ── Typography ── */
h1,h2,h3{font-weight:400;color:var(--muted);letter-spacing:.1em;text-transform:uppercase}
h1{font-size:var(--fs-lg);margin:0 0 var(--space-lg)}
h2{font-size:.95rem;margin:var(--space-lg) 0 var(--space-sm)}
h3{font-size:.85rem;margin:var(--space-md) 0 var(--space-xs)}
h1 small,h2 small,h3 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) */
/* ── Page Layout ── */
.page{max-width:80rem;margin:0 auto;padding:0 .5rem var(--space-xl)}
/* ── Nav (shared site nav) ── */
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)}
border-bottom:1px solid var(--line);padding-bottom:.6rem;margin-bottom:var(--space-lg)}
nav.site a{font-size:var(--fs-sm);letter-spacing:.08em;text-transform:uppercase;
color:var(--dim);transition:color var(--transition)}
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;
/* ── Cards ── */
.card{background:var(--surface-card);border-radius:var(--radius-md);
padding:var(--space-md) var(--space-lg);margin-bottom:var(--space-lg);
box-shadow:var(--shadow-sm)}
.card-title{font-size:.95rem;margin:0 0 var(--space-sm);color:var(--muted);
display:flex;align-items:center;gap:var(--space-sm)}
.card-sub{font-size:var(--fs-sm);color:var(--dim);margin:0 0 var(--space-sm)}
/* ── Tables ── */
.scroll{overflow-x:auto;margin-bottom:var(--space-sm)}
table{width:100%;border-collapse:collapse}
th,td{padding:.4rem .6rem;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}
th{color:var(--dim);font-size:var(--fs-xs);text-transform:uppercase;
letter-spacing:.05em;white-space:nowrap;font-weight:500}
td{color:var(--soft)}
tr:hover td{background:var(--surface-hover)}
.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}
/* ── Status / Semantic Colors ── */
.pending,.away,.gray{color:var(--muted)}
.acted,.pres,.green{color:var(--ok)}
.snoozed{color:var(--warn)}
.ignored,.red{color:var(--err)}
.updated,.muted{color:var(--dim);font-size:var(--fs-sm)}
.empty{color:var(--dim);padding:var(--space-xl);text-align:center}
.voided{opacity:.45;text-decoration:line-through}
.void-badge{color:var(--dim);font-size:.8rem;margin-right:.3rem}
.void-badge{color:var(--dim);font-size:var(--fs-sm);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%}
input[type=checkbox]{accent-color:var(--accent)}
label{color:var(--soft)}
/* ── Badges ── */
.badge{font-size:var(--fs-xs);color:var(--dim);background:var(--surface-raised);
padding:.15rem .4rem;border-radius:var(--radius-sm);white-space:nowrap}
.badge-accent{color:var(--accent);background:rgba(0,170,255,.12)}
.badge-ok{color:var(--ok);background:rgba(0,255,136,.1)}
.badge-warn{color:var(--warn);background:rgba(255,170,0,.12)}
.badge-err{color:var(--err);background:rgba(255,68,68,.12)}
/* inline feedback (/history revert, /tools enable, /auth/passkey) */
.msg{margin:.6rem 0;padding:.5rem .7rem;border-radius:3px;border:1px solid var(--line)}
/* ── Status Dot ── */
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle}
.dot-ok{background:var(--ok);box-shadow:0 0 5px var(--ok)}
.dot-warn{background:var(--warn);box-shadow:0 0 5px var(--warn)}
.dot-err{background:var(--err);box-shadow:0 0 5px var(--err)}
.dot-dim{background:var(--dim)}
/* ── Buttons ── */
.btn{font:inherit;font-size:var(--fs-sm);background:var(--surface-card);color:var(--fg);
border:1px solid var(--border);border-radius:var(--radius-sm);padding:.35rem .7rem;
cursor:pointer;transition:border-color var(--transition),background var(--transition);
display:inline-flex;align-items:center;gap:.3rem}
.btn:hover{border-color:var(--accent);background:var(--surface-hover)}
.btn:disabled{opacity:.4;cursor:not-allowed}
.btn-primary{background:var(--accent);color:#111;border-color:var(--accent);font-weight:500}
.btn-primary:hover{background:#0099ee;border-color:#0099ee}
.btn-danger{border-color:var(--err);color:var(--err)}
.btn-danger:hover{border-color:var(--err);background:rgba(255,68,68,.1)}
/* ── Form Elements ── */
input[type=text],input[type=url]{font:inherit;font-size:var(--fs-sm);
background:var(--surface-input);color:var(--fg);
border:1px solid var(--border);border-radius:var(--radius-sm);
padding:.35rem .5rem;max-width:100%;transition:border-color var(--transition)}
input[type=text]:focus,input[type=url]:focus{border-color:var(--accent);outline:none}
input[type=checkbox]{accent-color:var(--accent);width:1rem;height:1rem;vertical-align:middle}
label{color:var(--soft);cursor:pointer;display:inline-flex;align-items:center;gap:.3rem}
/* ── Messages / Feedback ── */
.msg{margin:.6rem 0;padding:.5rem .7rem;border-radius:var(--radius-sm);
border:1px solid var(--line);font-size:var(--fs-sm)}
.msg-ok{border-color:var(--ok);color:var(--ok)}
.msg-err{border-color:var(--err);color:var(--err)}
#msg{white-space:pre-wrap}
details{color:var(--soft);font-size:.85rem}
summary{cursor:pointer;color:var(--dim)}
/* ── Collapsible Details ── */
details{color:var(--soft);font-size:var(--fs-sm)}
summary{cursor:pointer;color:var(--dim);transition:color var(--transition)}
summary:hover{color:var(--fg)}
details[open]{margin-top:var(--space-xs)}
/* ── Utilities ── */
.val{word-break:break-all;max-width:20rem}
.text-max{max-width:22rem}
.input-wide{width:20rem}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
+3 -1
View File
@@ -3,8 +3,9 @@
<title>maven · rule trace</title>
<link rel=stylesheet href=/ui.css>
{{template "nav" "trace"}}
<main class=page>
<h1>rule trace</h1>
<div class=updated>{{.Now | ago}} — winner: <strong>{{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}</strong></div>
<div class=updated style=margin-bottom:var(--space-lg)>{{.Now | ago}} — winner: <strong>{{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}</strong></div>
<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>
@@ -25,3 +26,4 @@
<td>{{.LostTo}}</td>
</tr>{{end}}
</table></div>
</main>
+9 -13
View File
@@ -61,27 +61,23 @@ func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string, s
// enable a tool on /tools within that window.
func (h *PasskeyHandle) Page(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(passkeyPageHTML))
passkeyTmpl.Execute(w, nil)
}
// passkeyPageHTML — rendered via passkeyTmpl (main.go) which prepends navHTML.
const passkeyPageHTML = `<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>maven · passkey</title>
<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>
{{template "nav" "passkey"}}
<main class=page>
<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>
<button onclick=assert()>assert (step-up)</button>
<a href=/tools><button>→ tools</button></a>
<p class=muted>Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.</p>
<button class=btn onclick=enroll()>enroll passkey</button>
<button class=btn onclick=assert()>assert (step-up)</button>
<a href=/tools><button class=btn-primary>→ tools</button></a>
<div id=msg></div>
</main>
<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;};