feat(mavweb): /ecosystem page consuming Nexus/Praxis/Hexis + shell fixes

Add a read-only /ecosystem page that consumes the sibling services'
JSON APIs (Nexus entities, Praxis attention, Hexis capabilities),
fetched concurrently with honest per-panel error states. Siblings stay
headless — mavweb is their human surface (arch §16). Wired via mavweb
-nexus/-praxis/-hexis flags; mavweb joins the ecosystem compose network.

Fix mobile horizontal overflow across all pages: .content is a flex
child with default min-width:auto, so it refused to shrink below the
tables' intrinsic width. min-width:0 lets wide tables pan inside .scroll
instead of dragging the page sideways. Verified via CDP geometry check
(scrollWidth === clientWidth at 430px).

Also includes in-progress Ethos UI redesign, ecosystem deploy compose,
and planning docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kami
2026-07-19 22:04:23 +04:00
parent 6c92f85d10
commit 5fe8f228c1
43 changed files with 1782 additions and 668 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<h1>Dashboard</h1>
<section class=card>
<h2 class=card-title>
<span class="dot dot-ok" style="margin-right:4px"></span>
<span class="dot dot-ok" style="margin-right:6px"></span>
presence
</h2>
<p>
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
)
// The three sibling services Maven coordinates are headless JSON APIs (no web UI
// of their own — arch §16: mavweb IS their human surface). This page is that
// surface: a read-only observability panel that GETs each sibling's list
// endpoints and renders them. Never mutates — enabling/executing stays on the
// authed /tools + voice paths, never here.
// ecoURLs holds the sibling base URLs; empty ⇒ that panel shows "not configured".
type ecoURLs struct {
nexus, praxis, hexis string
}
var ecoClient = &http.Client{Timeout: 6 * time.Second}
// getEco GETs path off base and decodes the JSON array into out. Returns a
// human error string (empty on success) — honest per-panel state, no spinner.
func getEco(ctx context.Context, base, path string, out any) string {
if base == "" {
return "not configured"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
if err != nil {
return err.Error()
}
resp, err := ecoClient.Do(req)
if err != nil {
return "unreachable"
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Sprintf("http %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return "bad json"
}
return ""
}
// Minimal projections of each sibling's list payload — only the columns the
// panel shows. Extra JSON fields are ignored.
type ecoEntity struct {
ID string `json:"id"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
State string `json:"state"`
UpdatedAt time.Time `json:"updated_at"`
}
type ecoItem struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
State string `json:"state"`
Importance int `json:"importance"`
LastSeenAt time.Time `json:"last_seen_at"`
}
type ecoCap struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Operation string `json:"operation"`
Risk string `json:"risk"`
ReadOnly bool `json:"read_only"`
}
type ecoPanel[T any] struct {
Rows []T
Err string
}
type ecoData struct {
Nexus ecoPanel[ecoEntity]
Praxis ecoPanel[ecoItem]
Hexis ecoPanel[ecoCap]
}
func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs) {
ctx := r.Context()
var d ecoData
var wg sync.WaitGroup
wg.Add(3)
go func() { defer wg.Done(); d.Nexus.Err = getEco(ctx, urls.nexus, "/api/v1/entities?limit=50", &d.Nexus.Rows) }()
go func() {
defer wg.Done()
d.Praxis.Err = getEco(ctx, urls.praxis, "/api/v1/items?limit=50", &d.Praxis.Rows)
}()
go func() { defer wg.Done(); d.Hexis.Err = getEco(ctx, urls.hexis, "/api/v1/capabilities", &d.Hexis.Rows) }()
wg.Wait()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := ecosystemTmpl.Execute(w, d); err != nil {
log.Printf("ecosystem render: %v", err)
}
}
+54
View File
@@ -0,0 +1,54 @@
{{template "shellTop" "ecosystem"}}
<h1>Ecosystem</h1>
<p class=hint>The sibling services Maven coordinates — read-only. Identity, attention, capabilities. Enabling &amp; execution live on the authed surfaces, never here.</p>
<section class=card>
<div class=section-header>
<h2>Nexus <span class=card-sub>identity</span></h2>
</div>
{{with .Nexus}}
{{if .Err}}<div class=empty>nexus — {{.Err}}</div>
{{else if not .Rows}}<div class=empty>no entities yet.</div>
{{else}}<div class=scroll><table class=mono><tr><th>id<th>type<th>name<th>state<th>updated</tr>
{{range .Rows}}<tr><td class=key>{{.ID}}<td><span class=badge>{{.Type}}</span><td class=en>{{.DisplayName}}<td>{{.State}}<td>{{ago .UpdatedAt}}</tr>{{end}}
</table></div>{{end}}
{{end}}
</section>
<section class=card>
<div class=section-header>
<h2>Praxis <span class=card-sub>attention</span></h2>
</div>
{{with .Praxis}}
{{if .Err}}<div class=empty>praxis — {{.Err}}</div>
{{else if not .Rows}}<div class=empty>nothing needs attention.</div>
{{else}}<div class=scroll><table class=mono><tr><th>imp<th>kind<th>title<th>state<th>last seen</tr>
{{range .Rows}}<tr><td>{{.Importance}}<td><span class=badge>{{.Kind}}</span><td class=en>{{.Title}}<td>{{.State}}<td>{{ago .LastSeenAt}}</tr>{{end}}
</table></div>{{end}}
{{end}}
</section>
<section class=card>
<div class=section-header>
<h2>Hexis <span class=card-sub>capabilities</span></h2>
</div>
{{with .Hexis}}
{{if .Err}}<div class=empty>hexis — {{.Err}}</div>
{{else if not .Rows}}<div class=empty>no capabilities registered.</div>
{{else}}<div class=scroll><table class=mono><tr><th>id<th>name<th>provider<th>operation<th>risk<th>access</tr>
{{range .Rows}}<tr><td class=key>{{.ID}}<td class=en>{{.Name}}<td><span class=badge>{{.Provider}}</span><td>{{.Operation}}<td>{{.Risk}}<td>{{if .ReadOnly}}<span class="badge badge-ok">read-only</span>{{else}}<span class="badge badge-warn">mutating</span>{{end}}</tr>{{end}}
</table></div>{{end}}
{{end}}
</section>
{{template "shellBottom"}}
<script>
setInterval(() => fetch('/ecosystem').then(r => r.text()).then(html => {
const d = new DOMParser().parseFromString(html, 'text/html');
document.querySelectorAll('main.content section.card').forEach((old, i) => {
const nu = d.querySelectorAll('main.content section.card')[i];
if (nu) old.replaceWith(nu);
});
}), 15000);
</script>
</html>
+1 -1
View File
@@ -17,7 +17,7 @@
</table></div>
{{else}}
<div class=empty>
<svg class=icon width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<svg class=icon width="24" height="24"><use href="/ethos-icons.svg#i-clock"/></svg>
<div>no facts recorded yet</div>
<div class=hint>facts appear here when maven observes or records events</div>
</div>
+51 -34
View File
@@ -60,6 +60,9 @@ var remindersHTML string
//go:embed voice.html
var voiceHTML string
//go:embed ecosystem.html
var ecosystemHTML string
// ── Ethos Workstation Shell ──
//
// Two template pieces that wrap every page:
@@ -94,6 +97,12 @@ var sidebarSections = []struct {
{Label: "Routines", URL: "/routines", Key: "routines"},
},
},
{
Label: "Ecosystem",
Pages: []struct{ Label, URL, Key string }{
{Label: "Siblings", URL: "/ecosystem", Key: "ecosystem"},
},
},
{
Label: "AI",
Pages: []struct{ Label, URL, Key string }{
@@ -145,31 +154,33 @@ func sidebarHTML(active string) template.HTML {
return template.HTML(b.String())
}
// pageIcon returns a lucide-style inline SVG icon path for the given page.
// pageIcon returns an ethos-icons.svg <use> reference for the given page.
func pageIcon(key string) string {
switch key {
case "dash":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>`
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
case "history":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-clock"/></svg>`
case "trace":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
case "notifications":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>`
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
case "reminders":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
case "routines":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-repeat"/></svg>`
case "chat":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-message"/></svg>`
case "voice":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-mic"/></svg>`
case "ecosystem":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
case "tools":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
case "passkey":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
default:
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/></svg>`
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-search"/></svg>`
}
}
@@ -192,6 +203,8 @@ func pageTitle(key string) string {
return "Chat"
case "voice":
return "Voice"
case "ecosystem":
return "Ecosystem"
case "tools":
return "Tools"
case "passkey":
@@ -209,19 +222,19 @@ const shellTopHTML = `{{define "shellTop"}}<!doctype html><meta charset=utf-8>
<link rel=manifest href=/manifest.json>
<title>maven · {{pageTitle .}}</title>
<link rel=stylesheet href=/ui.css>
<div class=shell>
<div class=shell data-app=maven>
<header class=topbar>
<div class=breadcrumbs>
<span class=current>{{pageTitle .}}</span>
</div>
<div class=topbar-actions>
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<svg class=icon width="13" height="13"><use href="/ethos-icons.svg#i-search"/></svg>
Search
<span class=kbd-hint>Ctrl+/</span>
</div>
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 7 4"/><polyline points="17 4 20 4 20 7"/><polyline points="20 17 20 20 17 20"/><polyline points="7 20 4 20 4 17"/></svg>
<svg class=icon width="15" height="15"><use href="/ethos-icons.svg#i-grid"/></svg>
</button>
<span class=conn-status>
<span class="dot online" id=connDot></span>
@@ -274,6 +287,10 @@ func shellFuncs() template.FuncMap {
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellTopHTML + dashHTML + shellBottomHTML))
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
// human surface is here (they ship no web UI of their own).
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
func noCache(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
@@ -298,6 +315,9 @@ func main() {
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
flag.Parse()
var core ipc.CoreAPI
@@ -363,6 +383,10 @@ func main() {
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
handleRoutines(w, r, core)
})
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
handleEcosystem(w, r, ecoURLsCfg)
})
// ----- passkey (WebAuthn) endpoints -----
// Wired when both -core and a configured origin are present. The origin
// must match the browser's view of mavweb (e.g. https://maven.kvmx.ru).
@@ -601,13 +625,13 @@ const toolsHTML = `{{template "shellTop" "tools"}}
<input type=text name=cmd class=input-wide placeholder="systemctl restart" required>
<label><input type=checkbox name=destructive> destructive</label>
<button class=btn>enable</button></form>
<form method=post action=/tools style=display:inline>
<form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form></td>
</tr>{{end}}</table></div>
{{else}}<div class=empty style=padding:var(--ethos-space-5)>
<svg class=icon width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-search"/></svg>
<div>no proposed tools</div>
<div class=hint>maven will propose tools here when she needs help running an action</div>
</div>{{end}}
@@ -617,13 +641,13 @@ const toolsHTML = `{{template "shellTop" "tools"}}
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
<td><form method=post action=/tools style=display:inline>
<td><form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=disable>
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>
{{else}}<div class=empty style=padding:var(--ethos-space-5)>
<svg class=icon width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
<div>no tools enabled</div>
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
</div>{{end}}
@@ -641,14 +665,14 @@ const routinesHTML = `{{template "shellTop" "routines"}}
{{range .Proposed}}<tr>
<td><code>{{.Action}}</code></td><td><code>{{.Object}}</code></td><td>{{.IntervalDays}} days</td>
<td>
<form method=post action=/routines style=display:inline>
<form method=post action=/routines class=inline-form>
<input type=hidden name=id value="{{.ID}}">
<input type=hidden name=action value=dismiss>
<button class="btn btn-muted">dismiss</button></form>
</td>
</tr>{{end}}</table></div>
{{else}}<div class=empty style=padding:var(--ethos-space-5)>
<svg class=icon width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-wave"/></svg>
<div>no proposed routines</div>
<div class=hint>maven will propose routines here when she detects a recurring pattern</div>
</div>{{end}}
@@ -1054,12 +1078,12 @@ const chatPageHTML = `{{template "shellTop" "chat"}}
<h1>Chat</h1>
<section class=card>
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
<div class=scroll style="max-height:60vh;overflow-y:auto;margin-bottom:var(--ethos-space-4)" id=chatHistory>
<div class="scroll chat-scroll" id=chatHistory>
{{range .Messages}}
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
{{else}}
<div class=empty style=padding:var(--ethos-space-5)>
<svg class=icon width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
<div>start a conversation</div>
</div>
{{end}}
@@ -1073,13 +1097,6 @@ const chatPageHTML = `{{template "shellTop" "chat"}}
var ch = document.getElementById('chatHistory');
if(ch) ch.scrollTop = ch.scrollHeight;
</script>
<style>
.chat-msg { padding: var(--ethos-space-2) var(--ethos-space-3); border-radius: var(--ethos-radius-m); margin-bottom: var(--ethos-space-1); }
.chat-msg.user { background: var(--ethos-bg-subtle); }
.chat-msg.assistant { background: var(--ethos-bg-elevated); }
.chat-form { display:flex; gap:var(--ethos-space-2); }
.chat-form input { flex:1; }
</style>
{{template "shellBottom"}}`
// handleChatPage renders the chat conversation page.
+1 -1
View File
@@ -10,7 +10,7 @@
<td class=text-max>{{.Message}}</td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<svg class=icon width="24" height="24"><use href="/ethos-icons.svg#i-bell"/></svg>
<div>no notifications yet</div>
<div class=hint>check back later or ask maven a question</div>
</div>{{end}}
+1 -1
View File
@@ -9,7 +9,7 @@
<td class=text-max>{{.Payload}}</td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<svg class=icon width="24" height="24"><use href="/ethos-icons.svg#i-calendar"/></svg>
<div>no reminders yet</div>
<div class=hint>ask maven to remind you of something</div>
</div>{{end}}
+121
View File
@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
ETHOS — icon set
One hand, one grid. Every icon: viewBox 0 0 24 24, stroke="currentColor",
stroke-width 1.7, round caps/joins, fill="none". Transport glyphs (play,
pause, prev, next, more) are the only filled exceptions.
USE: <svg class="icon" width="22" height="22"><use href="/ethos-icons.svg#i-search"/></svg>
color + width/height come from the consumer; the icon inherits them.
EXTEND: add a new <symbol id="i-NAME"> on the same 24px grid, same stroke,
same corner feel. Match the existing hand — do not import lucide or
any other pack. Keep ids prefixed i- and kebab-cased.
-->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<!-- ── app mark / motif seeds ── -->
<symbol id="i-wave" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
<path d="M2 12h1M6 8v8M10 4v16M14 7v10M18 5v14M21 11v2"/>
</symbol>
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/>
<rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>
</symbol>
<!-- ── navigation ── -->
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 11l8-6 8 6M6 10v9h12v-9"/>
</symbol>
<symbol id="i-listen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 18V9l14-3v9"/><circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="15" r="2.4"/>
</symbol>
<symbol id="i-library" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<rect x="4" y="4" width="6" height="16" rx="1.5"/><rect x="14" y="4" width="6" height="16" rx="1.5"/>
</symbol>
<symbol id="i-album" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2"/>
</symbol>
<symbol id="i-artist" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="4"/><path d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6"/>
</symbol>
<symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h11M4 12h11M4 17h7M18 9v8"/><circle cx="18" cy="18.5" r="1.6"/>
</symbol>
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<path d="M4 7a1 1 0 011-1h4.5l2 2H19a1 1 0 011 1v8a1 1 0 01-1 1H5a1 1 0 01-1-1z"/>
</symbol>
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<path d="M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V8z"/><path d="M14 3v5h5"/>
</symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>
</symbol>
<!-- ── transport (filled) ── -->
<symbol id="i-play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5h3v14H8zM13 5h3v14h-3z"/></symbol>
<symbol id="i-prev" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6z"/><path d="M20 6v12l-9-6z"/></symbol>
<symbol id="i-next" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2z"/><path d="M6 6v12l9-6z"/></symbol>
<symbol id="i-shuffle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h4v4M20 4l-6 6M4 20l16-16M16 20h4v-4M14 14l6 6M4 4l4 4"/>
</symbol>
<symbol id="i-repeat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 2l3 3-3 3M20 5H8a4 4 0 00-4 4v1M7 22l-3-3 3-3M4 19h12a4 4 0 004-4v-1"/>
</symbol>
<!-- ── actions / status ── -->
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>
</symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4.5 4.5L20 7"/></symbol>
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9a6 6 0 0112 0c0 5 2 6 2 6H4s2-1 2-6M10 21h4"/>
</symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 4v11M8 11l4 4 4-4M5 20h14"/>
</symbol>
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 15V4M8 8l4-4 4 4M5 20h14"/>
</symbol>
<!-- ── terminal / code (tmux-view, drive) ── -->
<symbol id="i-terminal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4l8 8-8 8M16 19h4"/>
</symbol>
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 4v6h-6M3 20v-6h6"/>
<path d="M20.5 10a8.5 8.5 0 00-16 3M3.5 14a8.5 8.5 0 0016-3"/>
</symbol>
<symbol id="i-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<rect x="9" y="9" width="12" height="12" rx="1.5"/>
<path d="M15 9V6a1 1 0 00-1-1H5a1 1 0 00-1 1v9a1 1 0 001 1h3"/>
</symbol>
<symbol id="i-send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12h16M13 6l6 6-6 6"/>
</symbol>
<!-- ── maven-specific ── -->
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>
</symbol>
<symbol id="i-calendar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="5" width="16" height="16" rx="1.5"/><path d="M4 10h16M8 3v3M16 3v3"/>
<path d="M10 15l2 2 4-4"/>
</symbol>
<symbol id="i-mic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="10" y="2" width="4" height="12" rx="2"/><path d="M6 11a6 6 0 0012 0M12 19v4M9 23h6"/>
</symbol>
<symbol id="i-message" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h16a2 2 0 012 2v12a2 2 0 01-2 2H8l-4 4V6a2 2 0 012-2z"/>
</symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<rect x="6" y="11" width="12" height="10" rx="1.5"/><path d="M9 11V7a3 3 0 016 0v4"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 8.4 KiB

+9 -9
View File
@@ -47,15 +47,15 @@
let paletteEl = null;
let paletteInput = null;
// Page entries for the command palette
// Page entries for the command palette — uses ethos-icons.svg sprite
const palettePages = [
{ label: 'Dashboard', url: '/dash', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>' },
{ label: 'History', url: '/history', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' },
{ label: 'Rule Trace', url: '/trace', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>' },
{ label: 'Notifications', url: '/notifications', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>' },
{ label: 'Voice', url: '/', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>' },
{ label: 'Tools', url: '/tools', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>' },
{ label: 'Passkey', url: '/auth/passkey', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' },
{ label: 'Dashboard', url: '/dash', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>' },
{ label: 'History', url: '/history', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-clock"/></svg>' },
{ label: 'Rule Trace', url: '/trace', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>' },
{ label: 'Notifications', url: '/notifications', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>' },
{ label: 'Voice', url: '/', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-mic"/></svg>' },
{ label: 'Tools', url: '/tools', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>' },
{ label: 'Passkey', url: '/auth/passkey', icon: '<svg width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>' },
];
function buildPalette() {
@@ -65,7 +65,7 @@
paletteEl.innerHTML =
'<div class=cmd-palette>' +
'<div class=cmd-palette-input>' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>' +
'<svg width="14" height="14"><use href="/ethos-icons.svg#i-search"/></svg>' +
'<input type=text placeholder="Go to page or run action…" spellcheck=false autofocus>' +
'</div>' +
'<div class=cmd-palette-list id=paletteList></div>' +
+492 -374
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{{template "shellTop" "trace"}}
<h1>Rule Trace</h1>
<div class=hint style=margin-bottom:var(--ethos-space-4)>{{.Now | ago}} — winner: <strong>{{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}</strong></div>
<div class="hint mb-4">{{.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>
+24 -49
View File
@@ -1,61 +1,36 @@
{{template "shellTop" "voice"}}
<style>
.voice{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1.5rem;padding:1rem 0}
#btn{width:140px;height:140px;border-radius:50%;border:3px solid var(--ethos-accent);background:var(--ethos-surface0);color:var(--ethos-accent);font-size:1rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all var(--ethos-motion);user-select:none;-webkit-tap-highlight-color:transparent;touch-action:manipulation}
#btn:active,#btn.active{background:rgba(111,191,139,.12);border-color:var(--ethos-green);color:var(--ethos-green);transform:scale(1.05)}
#btn:disabled{opacity:.3;border-color:var(--ethos-border)}
#status{font-size:var(--ethos-fs-sm);color:var(--ethos-muted);min-height:1.2em}
#log{width:100%;max-width:480px;max-height:40vh;overflow-y:auto;font-size:var(--ethos-fs-sm);color:var(--ethos-muted);line-height:1.6;padding:var(--ethos-space-2);border-top:1px solid var(--ethos-border)}
#log .reply{color:var(--ethos-green)}
#log .error{color:var(--ethos-red)}
#log .push{color:var(--ethos-purple)}
#cheat{width:100%;max-width:480px;font-size:var(--ethos-fs-sm);border-top:1px solid var(--ethos-border);color:var(--ethos-muted)}
#cheat summary{cursor:pointer;padding:var(--ethos-space-2) 0;color:var(--ethos-accent);letter-spacing:.05em;text-transform:uppercase;font-size:var(--ethos-fs-xs);user-select:none}
#cheat dl{display:grid;grid-template-columns:auto 1fr;gap:var(--ethos-space-1) var(--ethos-space-3);padding:var(--ethos-space-1) 0 var(--ethos-space-3)}
#cheat dt{color:var(--ethos-green);font-weight:500;white-space:nowrap}
#cheat dd{color:var(--ethos-secondary);line-height:1.5}
#cheat dd span{color:var(--ethos-muted)}
.cheat-en{display:revert}
.cheat-ru{display:none}
html[data-lang=ru] .cheat-en{display:none}
html[data-lang=ru] .cheat-ru{display:revert}
.lang-toggle{display:flex;gap:0;justify-content:center;margin-top:1rem}
.lang-toggle span{font-size:var(--ethos-fs-xs);color:var(--ethos-muted);cursor:pointer;padding:.2rem .5rem;letter-spacing:.1em;text-transform:uppercase;border:1px solid var(--ethos-border);transition:color var(--ethos-motion);user-select:none}
.lang-toggle span:first-child{border-radius:var(--ethos-radius) 0 0 var(--ethos-radius);border-right:none}
.lang-toggle span:last-child{border-radius:0 var(--ethos-radius) var(--ethos-radius) 0;border-left:none}
.lang-toggle span.active{color:var(--ethos-accent);border-color:var(--ethos-accent);background:var(--ethos-accent-dim)}
.lang-toggle span:hover{color:var(--ethos-text)}
</style>
<div class=voice>
<div id="status">tap &amp; hold to speak</div>
<button id="btn" type="button">&#x1f399;</button>
<div id="log"></div>
<details id="cheat">
<div class=voice-page>
<div class=voice-status id=status>tap &amp; hold to speak</div>
<button class=voice-btn id=btn type=button>
<svg width="36" height="36"><use href="/ethos-icons.svg#i-mic"/></svg>
</button>
<div class=voice-log id=log></div>
<details class=voice-cheat id=cheat>
<summary>Cheatsheet</summary>
<dl>
<dt>Act</dt>
<dd class="cheat-ru">включи свет · сделай громче</dd>
<dd class="cheat-en">restart nginx · turn on the light</dd>
<dd class=ru>включи свет · сделай громче</dd>
<dd class=en>restart nginx · turn on the light</dd>
<dt>Reminder</dt>
<dd class="cheat-ru">напомни через 4 часа размяться</dd>
<dd class="cheat-en">remind me tomorrow at 8am to call the doctor</dd>
<dd class=ru>напомни через 4 часа размяться</dd>
<dd class=en>remind me tomorrow at 8am to call the doctor</dd>
<dt>Fact</dt>
<dd class="cheat-ru">выпил кофе · вес 73 кг</dd>
<dd class="cheat-en">ran 3 kilometers</dd>
<dd class=ru>выпил кофе · вес 73 кг</dd>
<dd class=en>ran 3 kilometers</dd>
<dt>Note</dt>
<dd class="cheat-ru">запиши рецепт блинов</dd>
<dd class="cheat-en">note: add a backup job for the db</dd>
<dd class=ru>запиши рецепт блинов</dd>
<dd class=en>note: add a backup job for the db</dd>
<dt>Query</dt>
<dd class="cheat-ru">какой сегодня день · какой прогноз погоды</dd>
<dd class="cheat-en">what version are you · what's the weather</dd>
<dd class=ru>какой сегодня день · какой прогноз погоды</dd>
<dd class=en>what version are you · what's the weather</dd>
<dt>System</dt>
<dd class="cheat-ru">включи тихий режим · как загрузка системы</dd>
<dd class="cheat-en">quiet mode on · system load</dd>
<dd class=ru>включи тихий режим · как загрузка системы</dd>
<dd class=en>quiet mode on · system load</dd>
</dl>
</details>
<div class=lang-toggle>
<span class="active" data-lang="ru">RU</span>
<span data-lang="en">EN</span>
<div class=voice-lang>
<span class="active" data-lang=ru>RU</span>
<span data-lang=en>EN</span>
</div>
</div>
<script defer src="/app.js"></script>
@@ -64,11 +39,11 @@ html[data-lang=ru] .cheat-ru{display:revert}
var lang = new URLSearchParams(window.location.search).get('lang') || 'ru';
if (lang !== 'en') lang = 'ru';
document.documentElement.setAttribute('data-lang', lang);
document.querySelectorAll('.lang-toggle span').forEach(function(el) {
document.querySelectorAll('.voice-lang span').forEach(function(el) {
el.addEventListener('click', function() {
var l = el.dataset.lang;
document.documentElement.setAttribute('data-lang', l);
document.querySelectorAll('.lang-toggle span').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.voice-lang span').forEach(function(b) { b.classList.remove('active'); });
el.classList.add('active');
var url = new URL(window.location);
url.searchParams.set('lang', l);