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:
@@ -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 144–159), 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=..¤t_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 1–7 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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & hold to speak</div>
|
||||
<button id="btn" type="button">🎙</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
@@ -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,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
@@ -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;};
|
||||
|
||||
Reference in New Issue
Block a user