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
+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.