Skip to content

Commit fe2aea4

Browse files
committed
feat: simplify gateway admin navigation and setup flows
1 parent f34c887 commit fe2aea4

11 files changed

Lines changed: 252 additions & 47 deletions

File tree

app/templates/models/page.html

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
{% from "macros/icons.html" import filter as filter_icon, expand as expand_icon, collapse as collapse_icon, refresh as refresh_icon, collapse_left %}
22
<div class="page-head">
33
<div>
4+
<p class="eyebrow">SPEECH LIBRARY</p>
45
<h2>Models</h2>
56
<p>{{ installed }} of {{ total }} downloaded across {{ families }} families,
6-
all stored on this machine. Families start collapsed; open one to pick a model.</p>
7+
all stored on this machine. Open a family, download a model, then select it to start transcribing.</p>
78
</div>
89
<div class="models-toolbar-actions">
910
<button type="button" class="ghost filter-trigger" id="filter-rail-toggle"
@@ -29,6 +30,11 @@ <h2>Models</h2>
2930
hx-target="#models-list" hx-swap="innerHTML">{{ refresh_icon() }}</button>
3031
</div>
3132
</div>
33+
<div class="catalog-views" role="group" aria-label="Browse models">
34+
<button type="button" class="ghost" data-model-view="all" aria-pressed="true">All models</button>
35+
<button type="button" class="ghost" data-model-view="recommended" aria-pressed="false">Fits this machine</button>
36+
<button type="button" class="ghost" data-model-view="installed" aria-pressed="false">Downloaded</button>
37+
</div>
3238
<div class="models-layout" id="models-layout">
3339
<aside class="models-filter" id="models-filter" aria-label="Model filters">
3440
<div class="filter-side-head">
@@ -111,20 +117,20 @@ <h3 class="filter-side-title">Filters</h3>
111117
<div id="models-list" aria-live="polite">
112118
{{ list_html }}
113119
</div>
114-
<div class="card">
115-
<h2>Bring your own Whisper model</h2>
120+
<details class="card disclosure">
121+
<summary>Bring your own Whisper model</summary>
116122
<p class="muted">Paste a direct HTTPS link to a <code>.bin</code> or
117123
<code>.gguf</code> file. It runs through the standalone engine.
118124
Use a <code>/resolve/</code> URL on Hugging Face, not the repo home page.</p>
119125
<form hx-post="/ui/partials/models/custom" hx-include="#models-filter-form"
120126
hx-target="#models-list" hx-swap="innerHTML">
121127
<div class="row">
122-
<input name="url" type="url" required
128+
<input name="url" type="url" required aria-label="Model download URL"
123129
placeholder="https://huggingface.co/&hellip;/resolve/main/model.gguf" />
124130
<button type="submit" class="primary">Download</button>
125131
</div>
126132
<div class="row">
127-
<input name="sha256" type="text" spellcheck="false"
133+
<input name="sha256" type="text" aria-label="Optional SHA-256 digest" spellcheck="false"
128134
pattern="\s*(?:[Ss][Hh][Aa]256:)?[0-9a-fA-F]{64}\s*"
129135
title="64 hexadecimal characters, optionally prefixed with sha256:"
130136
placeholder="Optional SHA-256 from the model card (verified on download)" />
@@ -140,6 +146,6 @@ <h2>Bring your own Whisper model</h2>
140146
<a href="{{ request_model_issue_url }}" target="_blank"
141147
rel="noopener noreferrer">Request a catalog model</a>
142148
</p>
143-
</div>
149+
</details>
144150
</div>
145151
</div>

app/templates/overview/operations.html

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@
2323
hx-get="/ui/partials/operations" hx-trigger="every 5s"
2424
hx-swap="outerHTML" aria-label="Gateway operations">
2525
<div class="section-heading">
26-
<h2>Live operations <span class="muted">&middot; since start</span></h2>
26+
<h2>Activity <span class="muted">&middot; since start</span></h2>
2727
<span class="probe-age">Engine checked {{ probe_age }}s ago &middot;
2828
{{ sample_note }}</span>
2929
</div>
3030
<div class="operations-grid operations-grid-kpis">
31-
{% for card in metric_cards %}
31+
{% for card in metric_cards[:4] %}
3232
{{ metric_card(card) }}
3333
{% endfor %}
3434
</div>
@@ -44,11 +44,19 @@ <h2>Live operations <span class="muted">&middot; since start</span></h2>
4444
<span class="ops-bar-seg ops-bar-free" style="width:{{ capacity.free_pct }}%"></span>
4545
</div>
4646
</div>
47-
<div class="ops-charts ops-charts-inline" aria-label="Recent activity">
48-
<div class="ops-charts-grid ops-charts-grid-3">
49-
{{ ops_chart(latency_chart) }}
50-
{{ ops_chart(outcomes_chart) }}
51-
{{ ops_chart(pipeline_chart) }}
47+
<details class="activity-details">
48+
<summary>Performance details</summary>
49+
<div class="operations-grid operations-grid-kpis">
50+
{% for card in metric_cards[4:] %}
51+
{{ metric_card(card) }}
52+
{% endfor %}
5253
</div>
53-
</div>
54+
<div class="ops-charts ops-charts-inline" aria-label="Recent activity">
55+
<div class="ops-charts-grid ops-charts-grid-3">
56+
{{ ops_chart(latency_chart) }}
57+
{{ ops_chart(outcomes_chart) }}
58+
{{ ops_chart(pipeline_chart) }}
59+
</div>
60+
</div>
61+
</details>
5462
</section>

app/templates/overview/page.html

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
<div class="page-head">
2+
<div>
3+
<p class="eyebrow">YOUR WORKSPACE</p>
4+
<h2>Overview</h2>
5+
<p>Speech-to-text, on the machine you control.</p>
6+
</div>
7+
</div>
18
<section class="status-hero">
29
<div class="headline">
310
<span class="dot {{ "ok" if ready else "warn" }}" aria-hidden="true"></span>
@@ -7,7 +14,13 @@ <h2>{{ "Ready for dictation" if ready else "Setup still needed" }}</h2>
714
</div>
815
</div>
916
</section>
10-
{{ operations_html }}
11-
{{ system_panel_html }}
12-
{{ dependencies_html }}
1317
{{ onboarding_html }}
18+
{{ operations_html }}
19+
<details class="disclosure card">
20+
<summary>
21+
<span>Server details</span>
22+
<span class="muted small">Hardware, speech engines &amp; dependencies</span>
23+
</summary>
24+
{{ system_panel_html }}
25+
{{ dependencies_html }}
26+
</details>

app/templates/pairing/card.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,8 @@ <h2>Pair phone</h2>
9696
</div>
9797
</div>
9898

99-
<div class="pairing-advanced">
100-
<h3 class="pairing-advanced-title">Optional</h3>
99+
<details class="pairing-advanced">
100+
<summary>Custom address &amp; device access</summary>
101101
<div class="pairing-advanced-grid">
102102
<form class="pairing-panel" hx-get="/ui/partials/pairing"
103103
hx-target="#pairing-card" hx-swap="outerHTML">
@@ -143,7 +143,7 @@ <h4 class="pairing-subhead">Saved addresses</h4>
143143
</ul>
144144
</div>
145145
{% endif %}
146-
</div>
146+
</details>
147147

148148
<p class="pairing-note">
149149
Prefer LAN Wi-Fi when the phone is on the same network; use Tailscale when

app/templates/settings/page.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<div class="page-head">
22
<div>
3-
<h2>Settings</h2>
3+
<p class="eyebrow">PREFERENCES &amp; ACCESS</p><h2>Settings</h2>
44
<p>Engine, network paths, device tokens, and browser tools for this gateway.</p>
55
</div>
66
</div>
@@ -67,7 +67,7 @@ <h2>Speech engine</h2>
6767
<p class="muted small settings-hint">The next transcription loads an offloaded model again.
6868
One-shot engines such as Handy do not keep a model resident.</p>
6969
<div class="row settings-actions">
70-
<button type="submit" class="primary">Apply</button>
70+
<button type="submit" class="primary">Save settings</button>
7171
</div>
7272
</form>
7373
<p id="engine-result" class="muted settings-engine-result">{{ engine_hint }}</p>

app/templates/test_panel/page.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
swaps cannot stack duplicates. #}
44
<div class="page-head">
55
<div>
6-
<h2>Pair &amp; test</h2>
6+
<p class="eyebrow">CONNECT YOUR PHONE</p><h2>Pair &amp; test</h2>
77
<p>Connect the phone app with a QR, then record a short clip here to
88
verify the same path the phone uses.</p>
99
</div>
1010
</div>
11+
<div class="flow-step"><span class="step-number">1</span><div><h3>Connect VocaPhone</h3><p class="muted">Keep your phone and gateway on the same trusted network or VPN.</p></div></div>
1112
{{ pairing_html }}
13+
<div class="flow-step"><span class="step-number">2</span><div><h3>Try a transcription</h3><p class="muted">Use this browser to check that your selected model can turn speech into text.</p></div></div>
1214
{{ test_card_html }}
13-
{{ network_html }}
15+
<details class="card disclosure"><summary>Connection &amp; network help</summary>{{ network_html }}</details>

app/templates/test_panel/test_card.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
<div class="section-heading">
33
<h2>Test dictation</h2>
44
</div>
5-
<p class="muted">Uses this browser&rsquo;s microphone, then FFmpeg and the
6-
active engine (same path as the phone app).</p>
5+
<p class="muted">Record a short clip. Audio is sent to this gateway machine and transcribed
6+
with your selected speech-to-text model.</p>
77
<div class="test-controls" id="recorder-controls"
88
data-maximum-seconds="{{ maximum_duration_seconds }}">
99
<label class="test-field">

app/webui/app.js

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,19 @@
140140
tokenError.classList.toggle("hidden", !message);
141141
overlay.classList.remove("hidden");
142142
overlay.setAttribute("aria-hidden", "false");
143+
document.querySelectorAll(".app-header, .tabs, main, .site-footer").forEach((el) => {
144+
el.inert = true;
145+
});
143146
tokenInput.focus();
144147
}
145148

146149
function hideOverlay() {
147150
overlay.classList.add("hidden");
148151
overlay.setAttribute("aria-hidden", "true");
149152
tokenInput.value = "";
153+
document.querySelectorAll(".app-header, .tabs, main, .site-footer").forEach((el) => {
154+
el.inert = false;
155+
});
150156
}
151157

152158
// ------------------------------------------------------------------ token
@@ -159,7 +165,7 @@
159165
}
160166
localStorage.setItem(TOKEN_KEY, token);
161167
hideOverlay();
162-
htmx.ajax("GET", "/ui/partials/overview", { target: "#panel", swap: "innerHTML" });
168+
openTabByName(document.querySelector(".tab.active")?.dataset.tab || "overview");
163169
htmx.ajax("GET", "/ui/partials/engine-pill", { target: "#engine-pill", swap: "outerHTML" });
164170
htmx.ajax("GET", "/ui/partials/exposure-banner", {
165171
target: "#exposure-banner",
@@ -186,8 +192,9 @@
186192

187193
document.body.addEventListener("htmx:responseError", (event) => {
188194
if (event.detail.xhr.status === 401) {
195+
const hadToken = Boolean(getToken());
189196
localStorage.removeItem(TOKEN_KEY);
190-
showOverlay("Token rejected. Paste the current gateway token.");
197+
showOverlay(hadToken ? "Token rejected. Paste the current gateway token." : "");
191198
return;
192199
}
193200
let message = `Request failed (${event.detail.xhr.status}).`;
@@ -230,10 +237,13 @@
230237
activateTab(tab);
231238
});
232239
tab.addEventListener("keydown", (event) => {
233-
if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return;
240+
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
241+
event.preventDefault();
234242
const tabs = [...document.querySelectorAll(".tab")];
235-
const offset = event.key === "ArrowRight" ? 1 : -1;
236-
const next = tabs[(tabs.indexOf(tab) + offset + tabs.length) % tabs.length];
243+
const offset = ["ArrowRight", "ArrowDown"].includes(event.key) ? 1 : -1;
244+
const index = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1
245+
: (tabs.indexOf(tab) + offset + tabs.length) % tabs.length;
246+
const next = tabs[index];
237247
next.focus();
238248
next.click();
239249
});
@@ -261,8 +271,12 @@
261271
}
262272
});
263273

274+
let performanceDetailsOpen = false;
264275
document.body.addEventListener("htmx:beforeSwap", (event) => {
265276
const target = event.detail && event.detail.target;
277+
if (target && target.id === "operations") {
278+
performanceDetailsOpen = Boolean(target.querySelector(".activity-details[open]"));
279+
}
266280
// Download / Load / Cancel / poll refresh replace #models-list; keep open families.
267281
if (target && target.id === "models-list") {
268282
window.__openModelFamilies = openFamilyNames();
@@ -278,6 +292,10 @@
278292
});
279293

280294
document.body.addEventListener("htmx:afterSwap", (event) => {
295+
if (event.detail?.target?.id === "operations") {
296+
const details = document.querySelector("#operations .activity-details");
297+
if (details) details.open = performanceDetailsOpen;
298+
}
281299
scheduleModelPoll();
282300
if (document.getElementById("test-language")) syncTestLanguages();
283301
// Models tab shell (or list refresh) may reintroduce filter controls.
@@ -341,7 +359,7 @@
341359

342360
function isFilterRailCollapsed() {
343361
try {
344-
return localStorage.getItem(FILTER_RAIL_KEY) === "1";
362+
return localStorage.getItem(FILTER_RAIL_KEY) !== "0";
345363
} catch (_) {
346364
return false;
347365
}
@@ -503,7 +521,31 @@
503521
.forEach((tile) => placeModelsAfterRow(tile));
504522
}
505523

524+
function syncModelViews() {
525+
const installed = document.getElementById("installed-only-toggle")?.checked;
526+
const recommended = document.getElementById("recommended-only-toggle")?.checked;
527+
document.querySelectorAll("[data-model-view]").forEach((button) => {
528+
const view = button.dataset.modelView;
529+
button.setAttribute("aria-pressed", String(view === "all" ? !installed && !recommended
530+
: view === "installed" ? installed && !recommended : recommended && !installed));
531+
});
532+
}
533+
534+
document.body.addEventListener("click", (event) => {
535+
const button = event.target.closest("[data-model-view]");
536+
if (!button) return;
537+
const form = document.getElementById("models-filter-form");
538+
const installed = document.getElementById("installed-only-toggle");
539+
const recommended = document.getElementById("recommended-only-toggle");
540+
if (!form || !installed || !recommended) return;
541+
installed.checked = button.dataset.modelView === "installed";
542+
recommended.checked = button.dataset.modelView === "recommended";
543+
installed.dispatchEvent(new Event("change", { bubbles: true }));
544+
syncModelViews();
545+
});
546+
506547
function initModelFilters() {
548+
syncModelViews();
507549
const form = document.getElementById("models-filter-form");
508550
const clear = document.getElementById("filter-clear");
509551
if (clear && !clear.dataset.bound) {

app/webui/index.html

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,6 @@
1616
<!-- Favicon: circular mark. Header uses the square app icon (same kit as vocahq.com). -->
1717
<link rel="icon" type="image/svg+xml" href="/assets/favicon-light.svg" id="favicon" />
1818
<link rel="apple-touch-icon" href="/assets/favicon-light.svg" id="apple-touch-icon" />
19-
<link rel="preconnect" href="https://fonts.googleapis.com" />
20-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
21-
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=Epilogue:wght@500;600;700&display=swap" />
2219
<link rel="stylesheet" href="/assets/styles.css" />
2320
<!-- External boot script (CSP script-src 'self') so theme applies before paint. -->
2421
<script src="/assets/theme-boot.js"></script>
@@ -32,25 +29,26 @@
3229

3330
<div id="token-overlay" class="overlay hidden" aria-hidden="true">
3431
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="token-title">
35-
<h2 id="token-title">Gateway token</h2>
36-
<p class="muted">Stored only in this browser. On this host:</p>
32+
<p class="eyebrow">WELCOME TO VOCAGATEWAY</p>
33+
<h2 id="token-title">Connect to your gateway</h2>
34+
<p class="muted">Enter your gateway token to manage speech models and connect your phone. Stored only in this browser. On the gateway machine, run:</p>
3735
<code class="block">cat ~/.config/vocagateway/token</code>
38-
<label class="sr-only" for="token-input">Bearer token</label>
36+
<label for="token-input">Gateway token</label>
3937
<input id="token-input" type="password" placeholder="Bearer token" autocomplete="off" spellcheck="false" />
4038
<p id="token-error" class="error hidden" role="alert"></p>
4139
<button id="token-save" type="button" class="primary">Connect</button>
4240
</div>
4341
</div>
4442

45-
<header>
43+
<header class="app-header">
4644
<div class="brand">
4745
<span class="logo-stack" aria-hidden="true">
4846
<img class="logo logo-for-light" src="/assets/voca-logo.svg" width="28" height="28" alt="" />
4947
<img class="logo logo-for-dark" src="/assets/voca-logo.svg" width="28" height="28" alt="" />
5048
</span>
5149
<div>
5250
<h1>VocaGateway</h1>
53-
<span class="muted">Private transcription gateway</span>
51+
<span class="muted">Your self-hosted workspace</span>
5452
</div>
5553
</div>
5654
<div class="header-actions">
@@ -79,22 +77,22 @@ <h1>VocaGateway</h1>
7977
<nav class="tabs" role="tablist" aria-label="Gateway sections">
8078
<button type="button" class="tab active" data-tab="overview" role="tab"
8179
aria-selected="true" aria-controls="panel" id="tab-overview"
82-
hx-get="/ui/partials/overview" hx-target="#panel" hx-swap="innerHTML">Overview</button>
80+
hx-get="/ui/partials/overview" hx-target="#panel" hx-swap="innerHTML"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 10l9-7 9 7v10H3z M9 20v-7h6v7" /></svg><span>Overview</span></button>
8381
<button type="button" class="tab" data-tab="models" role="tab"
8482
aria-selected="false" aria-controls="panel" id="tab-models"
85-
hx-get="/ui/partials/models" hx-target="#panel" hx-swap="innerHTML">Models</button>
83+
hx-get="/ui/partials/models" hx-target="#panel" hx-swap="innerHTML"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16v6H4z M4 14h16v6H4z" /></svg><span>Models</span></button>
8684
<button type="button" class="tab" data-tab="pair" role="tab"
8785
aria-selected="false" aria-controls="panel" id="tab-pair"
88-
hx-get="/ui/partials/test" hx-target="#panel" hx-swap="innerHTML">Pair &amp; test</button>
86+
hx-get="/ui/partials/test" hx-target="#panel" hx-swap="innerHTML"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2h8v20H8z M11 18h2" /></svg><span>Pair &amp; test</span></button>
8987
<button type="button" class="tab" data-tab="settings" role="tab"
9088
aria-selected="false" aria-controls="panel" id="tab-settings"
91-
hx-get="/ui/partials/settings" hx-target="#panel" hx-swap="innerHTML">Settings</button>
89+
hx-get="/ui/partials/settings" hx-target="#panel" hx-swap="innerHTML"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16M8 3v6M16 9v6M10 15v6" /></svg><span>Settings</span></button>
9290
<button type="button" class="tab" data-tab="about" role="tab"
9391
aria-selected="false" aria-controls="panel" id="tab-about"
94-
hx-get="/ui/partials/about" hx-target="#panel" hx-swap="innerHTML">About</button>
92+
hx-get="/ui/partials/about" hx-target="#panel" hx-swap="innerHTML"><svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 11v6M12 7h.01 M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0" /></svg><span>About</span></button>
9593
</nav>
9694

97-
<main>
95+
<main id="workspace">
9896
<div id="panel" role="tabpanel" aria-labelledby="tab-overview" aria-live="polite"
9997
hx-get="/ui/partials/overview" hx-trigger="load" hx-swap="innerHTML">
10098
<p class="muted loading">Loading&hellip;</p>

0 commit comments

Comments
 (0)