From 06daa4d166f36680b226d6bb00e2653a552c0e98 Mon Sep 17 00:00:00 2001 From: jatinkrmalik Date: Sat, 8 Aug 2026 17:36:45 -0700 Subject: [PATCH 01/27] style: align gateway WebUI with VocaHQ paper design Match vocahq.com design tokens (warm paper, ink, teal accent) and type (Epilogue + Atkinson Hyperlegible). Add basic SEO/meta, a skip link, and a11y polish on the token field and tab panel. --- app/webui/app.js | 2 + app/webui/index.html | 34 +++-- app/webui/styles.css | 313 +++++++++++++++++++++++++++---------------- 3 files changed, 225 insertions(+), 124 deletions(-) diff --git a/app/webui/app.js b/app/webui/app.js index 7569cb3..9134be6 100644 --- a/app/webui/app.js +++ b/app/webui/app.js @@ -86,6 +86,8 @@ other.setAttribute("aria-selected", String(active)); other.tabIndex = active ? 0 : -1; }); + const panel = document.getElementById("panel"); + if (panel && tab.id) panel.setAttribute("aria-labelledby", tab.id); if (updateLocation) history.replaceState(null, "", `#${tab.dataset.tab}`); } diff --git a/app/webui/index.html b/app/webui/index.html index 227ec2d..a5cc8bb 100644 --- a/app/webui/index.html +++ b/app/webui/index.html @@ -3,17 +3,33 @@ - vocaphone gateway - + vocaphone gateway · private speech transcription + + + + + + + + + + + + + + + + """ diff --git a/app/fragments/models.py b/app/fragments/models.py index 19e0075..986e848 100644 --- a/app/fragments/models.py +++ b/app/fragments/models.py @@ -36,7 +36,8 @@ '' ) _COLLAPSE_ICON = ( - '' @@ -59,7 +60,8 @@ def models_fragment(entries: list[AdminModelEntry]) -> str:

Models

-

{installed} of {len(entries)} downloaded across {families} families, all stored on this machine. Families start collapsed; open one to pick a model.

+

{installed} of {len(entries)} downloaded across {families} families, + all stored on this machine. Families start collapsed; open one to pick a model.

Bring your own Whisper model

-

Paste a direct HTTPS link to a .bin or .gguf file. It runs through the standalone engine. Use a /resolve/ URL on Hugging Face, not the repo home page.

+

Paste a direct HTTPS link to a .bin or + .gguf file. It runs through the standalone engine. + Use a /resolve/ URL on Hugging Face, not the repo home page.

@@ -169,9 +173,11 @@ def models_fragment(entries: list[AdminModelEntry]) -> str:
@@ -193,8 +199,7 @@ def _request_model_issue_url() -> str: Thanks. """ return ( - "https://github.com/VocaHQ/vocagateway/issues/new" - f"?title={quote(title)}&body={quote(body)}" + f"https://github.com/VocaHQ/vocagateway/issues/new?title={quote(title)}&body={quote(body)}" ) @@ -225,9 +230,7 @@ def _family_filter_options(entries: list[AdminModelEntry]) -> str: def _engine_filter_options(entries: list[AdminModelEntry]) -> str: """Engines present in the live catalogue view.""" engines = sorted({entry.engine for entry in entries if entry.engine}) - return "".join( - _filter_check("engine", eng, ENGINE_LABELS.get(eng, eng)) for eng in engines - ) + return "".join(_filter_check("engine", eng, ENGINE_LABELS.get(eng, eng)) for eng in engines) def _size_filter_options() -> str: @@ -262,7 +265,9 @@ def models_list_fragment( name = entry.family or ENGINE_LABELS.get(entry.engine, entry.engine) groups.setdefault(name, []).append(entry) - def family_sort_key(item: tuple[str, list[AdminModelEntry]]) -> tuple: + def family_sort_key( + item: tuple[str, list[AdminModelEntry]], + ) -> tuple[bool, bool, int, str]: name, items = item has_active = any(entry.active for entry in items) has_recommended = any(entry.recommended for entry in items) @@ -309,18 +314,11 @@ def _empty_filter_state( if families: bits.append("families " + ", ".join(escape(f) for f in families)) if languages: - bits.append( - ", ".join(escape(LANGUAGE_NAMES.get(code, code)) for code in languages) - ) + bits.append(", ".join(escape(LANGUAGE_NAMES.get(code, code)) for code in languages)) if engines: - bits.append( - "engines " - + ", ".join(escape(ENGINE_LABELS.get(eng, eng)) for eng in engines) - ) + bits.append("engines " + ", ".join(escape(ENGINE_LABELS.get(eng, eng)) for eng in engines)) if max_size: - label = next( - (lab for key, lab in _SIZE_FILTER_OPTIONS if key == max_size), max_size - ) + label = next((lab for key, lab in _SIZE_FILTER_OPTIONS if key == max_size), max_size) bits.append(escape(label.lower())) if recommended_only: bits.append("fits this machine") @@ -385,7 +383,8 @@ def _family_tile(family: str, items: list[AdminModelEntry]) -> str: - + """ @@ -401,9 +400,8 @@ def _active_label() -> str: "" ) -def _dictation_language_hint( - entries: list[AdminModelEntry], language: str | list[str] -) -> str: + +def _dictation_language_hint(entries: list[AdminModelEntry], language: str | list[str]) -> str: """Warn when a filtered list mixes pinnable and auto-detecting models. Testing showed every auto-detecting model returns the wrong writing system on @@ -426,7 +424,9 @@ def _dictation_language_hint( return ( '
' f"Picking a model for {name}" - "Models tagged auto language pick the language themselves. They do well on full sentences but often guess the wrong script on short phrases (most of dictation). Prefer one of the other " + "Models tagged auto language pick the language themselves. " + "They do well on full sentences but often guess the wrong script on short " + "phrases (most of dictation). Prefer one of the other " f"{len(pinnable)} models here, which are forced to use {name}." "
" ) @@ -440,8 +440,7 @@ def _language_disclosure(entry: AdminModelEntry) -> str: if len(entry.language_names) < 2: return "" chips = "".join( - f'{escape(name)}' - for name in entry.language_names + f'{escape(name)}' for name in entry.language_names ) note = ( '

' @@ -560,7 +559,7 @@ def _model_card( ) ) action = ( - f'{select_button}' + f"{select_button}" f' @@ -351,13 +343,9 @@ def _onboarding_steps( steps: list[str] = [] if not status.setup.ffmpeg_available: - steps.append( - f"

  • Install FFmpeg: {escape(ffmpeg_hint)}.
  • " - ) + steps.append(f"
  • Install FFmpeg: {escape(ffmpeg_hint)}.
  • ") if not status.setup.engine_binary_available: - steps.append( - f"
  • Install a speech engine: {escape(engine_hint)}.
  • " - ) + steps.append(f"
  • Install a speech engine: {escape(engine_hint)}.
  • ") if not status.setup.model_installed or not status.setup.engine_ready: steps.append( f"
  • Download a model for this {escape(machine_label)}. " @@ -420,18 +408,10 @@ def operations_fragment(metrics: OperationalMetricsStatus, readiness: ReadinessS if metrics.average_latency_ms is not None else "—" ) - rtf_value = ( - f"{metrics.real_time_factor:.2f}×" - if metrics.real_time_factor is not None - else "—" - ) + rtf_value = f"{metrics.real_time_factor:.2f}×" if metrics.real_time_factor is not None else "—" rtf_detail = ( f"{_format_latency(metrics.audio_duration_ms)} audio" - + ( - f" · {metrics.peak_memory_mb:g} MB peak" - if metrics.peak_memory_mb is not None - else "" - ) + + (f" · {metrics.peak_memory_mb:g} MB peak" if metrics.peak_memory_mb is not None else "") if metrics.real_time_factor is not None else "After first job" ) @@ -562,9 +542,7 @@ def _sparkline_card( else: latest_label = f"{latest:.2f}" peak = max(numeric) - peak_label = ( - _format_latency(int(round(peak))) if unit_suffix == "ms" else f"{peak:g}" - ) + peak_label = _format_latency(int(round(peak))) if unit_suffix == "ms" else f"{peak:g}" footer = f"Now {escape(latest_label)} · peak {escape(peak_label)}" else: footer = escape(empty) @@ -627,9 +605,7 @@ def _sparkline_svg( ) -def _pipeline_chart( - metrics: OperationalMetricsStatus, rtf_value: str, rtf_detail: str -) -> str: +def _pipeline_chart(metrics: OperationalMetricsStatus, rtf_value: str, rtf_detail: str) -> str: stages = [ ("Normalize", metrics.normalization_ms or 0, "ops-bar-normalize"), ("Load", metrics.model_load_ms or 0, "ops-bar-load"), @@ -652,9 +628,7 @@ def _pipeline_chart( f'aria-label="Last pipeline {_format_latency(total)}">' f"{segs}" ) - footer = " · ".join( - f"{label} {_format_latency(ms)}" for label, ms, _ in stages if ms > 0 - ) + footer = " · ".join(f"{label} {_format_latency(ms)}" for label, ms, _ in stages if ms > 0) sub = f"RTF {rtf_value}" + (f" · {rtf_detail}" if rtf_detail else "") return f"""
    diff --git a/app/fragments/pairing.py b/app/fragments/pairing.py index 91659e8..d808b75 100644 --- a/app/fragments/pairing.py +++ b/app/fragments/pairing.py @@ -39,16 +39,16 @@ def pairing_fragment( longer exists at all (typically already revoked). """ network_warning = ( - exposure_network_panel(is_mac=is_mac) - if bind_host in WILDCARD_BIND_HOSTS - else "" + exposure_network_panel(is_mac=is_mac) if bind_host in WILDCARD_BIND_HOSTS else "" ) if not selected_url: return f""" {network_warning}

    Pair phone

    -

    No address the phone can reach was found. Set VOCAPHONE_PUBLIC_URL to the URL the phone should use (for example http://192.168.1.20:8765), then reload.

    +

    No address the phone can reach was found. Set + VOCAPHONE_PUBLIC_URL to the URL the phone should use + (for example http://192.168.1.20:8765), then reload.

    """ options = "".join( @@ -69,7 +69,10 @@ def pairing_fragment( stale_label = escape(requested_token_label or "That device token") unavailable_notice = f"""
    - {stale_label} is still paired and working normally. After a server restart this session cannot show its secret again (we never store it for recovery). Showing the bootstrap token instead. Only rotate if you need a new QR, for example to re-pair a lost phone. + {stale_label} is still paired and working normally. + After a server restart this session cannot show its secret again + (we never store it for recovery). Showing the bootstrap token instead. + Only rotate if you need a new QR, for example to re-pair a lost phone.
    - Rotate only if you need it: the current secret stops working immediately and the device must pair again. + Rotate only if you need it: the current secret + stops working immediately and the device must pair again.
    """ elif token_status == "unknown": unavailable_notice = """
    - That device token no longer exists (it may have been revoked). Showing the bootstrap token instead. Create a new device token below for a fresh QR. + That device token no longer exists (it may have been revoked). + Showing the bootstrap token instead. Create a new device token below + for a fresh QR.
    """ else: @@ -118,7 +124,8 @@ def pairing_fragment(

    Pair phone

    -

    Scan in VocaPhone on iPhone or Android. The QR includes the live token, so keep this page private while it is on screen.

    +

    Scan in VocaPhone on iPhone or Android. + The QR includes the live token, so keep this page private while it is on screen.

    {unavailable_notice}
    @@ -126,7 +133,8 @@ def pairing_fragment( data-url="{escape(selected_url or "", quote=True)}" data-token="{escape(token_plaintext or "", quote=True)}" title="Click to copy gateway address and token" - aria-label="Pairing QR for {escape(selected_url)}. Click to copy gateway address and token."> + aria-label="Pairing QR for {escape(selected_url)}. Click to copy + gateway address and token."> {inline_svg} @@ -172,7 +180,8 @@ def pairing_fragment(
    diff --git a/app/fragments/test_panel.py b/app/fragments/test_panel.py index e85b044..ecdccdd 100644 --- a/app/fragments/test_panel.py +++ b/app/fragments/test_panel.py @@ -7,7 +7,8 @@ def pair_and_test_fragment(pairing_html: str, maximum_duration_seconds: int) ->

    Pair & test

    -

    Connect the phone app with a QR, then record a short clip here to verify the same path the phone uses.

    +

    Connect the phone app with a QR, then record a short clip here to + verify the same path the phone uses.

    {pairing_html} @@ -21,7 +22,8 @@ def test_fragment(maximum_duration_seconds: int) -> str:

    Test dictation

    -

    Uses this browser’s microphone, then FFmpeg and the active engine (same path as the phone app).

    +

    Uses this browser’s microphone, then FFmpeg and the + active engine (same path as the phone app).