Continue GC code review: UI

This commit is contained in:
Jim Lancaster
2026-08-11 16:54:03 -05:00
parent b8be27f0c9
commit 888a8c380a
14 changed files with 205 additions and 163 deletions
+3 -3
View File
@@ -40,10 +40,10 @@ Pages may depend on application services and framework-provided dependencies. Co
## CSS Assets ## CSS Assets
- Keep CSS under `ui/static` and split it into manageable, feature-oriented files. Do not grow a monolithic stylesheet or embed substantial style blocks in Python components. - Keep all application CSS in `ui/static/theme.css`; do not add page- or component-specific stylesheets or embed style blocks in Python components.
- Load each stylesheet from the page, component, or composition root that needs it with `ui.add_css(...)`. Use shared registration only for genuinely application-wide styles. - Load `theme.css` once from the composition root with `ui.add_css(..., shared=True)`.
- Read stylesheet text through `importlib.resources.files(...)` so loading works from installed packages and is independent of the working directory. - Read stylesheet text through `importlib.resources.files(...)` so loading works from installed packages and is independent of the working directory.
- Centralize CSS reading in one typed helper cached by relative resource path with `functools.cache` or an equivalent unbounded `lru_cache`. Cache the immutable stylesheet text to avoid repeated resource I/O during component renders; keep NiceGUI registration decisions at the caller. - Centralize CSS reading in one typed helper cached by resource path with `functools.cache` or an equivalent unbounded `lru_cache`. Cache the immutable stylesheet text to avoid repeated resource I/O; keep NiceGUI registration at the composition root.
- Do not encode application behavior in CSS or other static assets. - Do not encode application behavior in CSS or other static assets.
## State and Side Effects ## State and Side Effects
+7 -9
View File
@@ -65,12 +65,16 @@ Semantic tokens currently include:
4. ui-card-surface 4. ui-card-surface
5. ui-row-surface 5. ui-row-surface
6. ui-note-box 6. ui-note-box
7. ui-card-error
### 5.3 Interactive Elements ### 5.3 Interactive Elements
1. ui-btn-primary 1. ui-btn-primary
2. ui-btn-secondary 2. ui-btn-secondary
3. ui-link-primary 3. ui-link-primary
4. ui-text-accent 4. ui-text-accent
5. ui-chip-primary
6. ui-badge-secondary
7. ui-status and ui-status--<status>
### 5.4 Table Patterns ### 5.4 Table Patterns
1. ui-table 1. ui-table
@@ -80,21 +84,15 @@ Semantic tokens currently include:
Use existing class combinations from [src/transcription/ui/components](src/transcription/ui/components) and [src/transcription/ui/pages](src/transcription/ui/pages) as reference implementations. Use existing class combinations from [src/transcription/ui/components](src/transcription/ui/components) and [src/transcription/ui/pages](src/transcription/ui/pages) as reference implementations.
## 6. Legacy Class Policy ## 6. Legacy Class Policy
Legacy classes with vibe- prefix still exist in a few components and are allowed only for compatibility while migrating: Legacy `vibe-` presentation classes are prohibited. Use `ui-` semantic classes from `theme.css`.
1. Existing usage may remain temporarily.
2. New usage of vibe- classes is not allowed.
3. When touching a file that uses vibe- classes, prefer migrating it to ui- semantic classes in the same change when safe.
Current legacy usage examples are in:
1. [src/transcription/ui/components/document_panzoom.py](src/transcription/ui/components/document_panzoom.py)
2. [src/transcription/ui/components/error_presenter.py](src/transcription/ui/components/error_presenter.py)
3. [src/transcription/ui/components/transcript.py](src/transcription/ui/components/transcript.py)
## 7. Prohibited Patterns ## 7. Prohibited Patterns
1. Inline hex colors in Python UI class strings or style blocks, except in isolated bridge code explicitly marked for migration. 1. Inline hex colors in Python UI class strings or style blocks, except in isolated bridge code explicitly marked for migration.
2. Ad-hoc one-off class names that duplicate existing semantic class intent. 2. Ad-hoc one-off class names that duplicate existing semantic class intent.
3. Page-specific palette forks that bypass theme tokens. 3. Page-specific palette forks that bypass theme tokens.
4. Hidden or low-contrast focus states on interactive controls. 4. Hidden or low-contrast focus states on interactive controls.
5. Embedded `<style>` blocks or NiceGUI `.style(...)` calls in Python UI code.
6. Additional page- or component-specific stylesheets; `theme.css` is the single CSS source.
## 8. Implementation Rules For Contributors ## 8. Implementation Rules For Contributors
1. Prefer composing existing semantic classes before creating new ones. 1. Prefer composing existing semantic classes before creating new ones.
+3 -4
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from nicegui import ui from nicegui import ui
from transcription.ui.theme import VIBESCRIBE_LOGO_SVG
NAV_ITEMS: tuple[tuple[str, str, str], ...] = ( NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
("Documents", "/documents", "description"), ("Documents", "/documents", "description"),
("People", "/people", "group"), ("People", "/people", "group"),
@@ -11,7 +13,6 @@ NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
("Jobs", "/jobs", "work_history"), ("Jobs", "/jobs", "work_history"),
) )
from transcription.ui.theme import VIBESCRIBE_LOGO_SVG
def _is_active_path(*, current_path: str, item_path: str) -> bool: def _is_active_path(*, current_path: str, item_path: str) -> bool:
if item_path == "/jobs": if item_path == "/jobs":
@@ -50,9 +51,7 @@ def render_app_shell(*, current_path: str | None = None) -> None:
normalized_path = _normalize_path(current_path) normalized_path = _normalize_path(current_path)
with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"): with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"):
with ui.element("a").props('href="/ui/homepage"').style( with ui.element("a").props('href="/ui/homepage"').classes("app-shell__brand no-wrap"):
"display:flex; align-items:center; gap:0.75rem; text-decoration:none; color:inherit;"
).classes("app-shell__brand no-wrap"):
ui.html(VIBESCRIBE_LOGO_SVG).classes("app-shell__brand-mark") ui.html(VIBESCRIBE_LOGO_SVG).classes("app-shell__brand-mark")
ui.label("VibeScribe").classes("app-shell__brand-name") ui.label("VibeScribe").classes("app-shell__brand-name")
@@ -11,4 +11,4 @@ def metadata_row(label: str, value: str):
def archival_badge(text: str): def archival_badge(text: str):
"""Standardized Aged Sepia badge.""" """Standardized Aged Sepia badge."""
return ui.badge(text, color="secondary", text_color="dark").classes("text-[10px]") return ui.badge(text).classes("text-[10px] ui-badge-secondary")
@@ -24,17 +24,12 @@ def render_document_panzoom(*, source: Source) -> None:
document_url = _document_url(source) document_url = _document_url(source)
document_kind = _document_kind(source) document_kind = _document_kind(source)
with ui.card().classes("w-full q-pa-md vibe-card"): with ui.card().classes("w-full q-pa-md ui-card-surface"):
with ui.row().classes("w-full items-center justify-between no-wrap"): with ui.row().classes("w-full items-center justify-between no-wrap"):
ui.label("Document preview").classes("text-subtitle1 text-weight-medium") ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
ui.label(source.filename).classes("text-caption vibe-text-muted ellipsis").style( ui.label(source.filename).classes("text-caption ui-text-muted ellipsis document-panzoom-filename")
"max-width: 60%; text-align: right;"
)
with ( with ui.element("div").classes("w-full document-panzoom-host q-mt-md") as host:
ui.element("div").classes("w-full document-panzoom-host rounded-borders q-mt-md")
# .style(f"height: {height};")
) as host:
host.props(f"id={host_id}") host.props(f"id={host_id}")
with ui.element("div").classes("document-panzoom-surface"): with ui.element("div").classes("document-panzoom-surface"):
if document_kind == "pdf": if document_kind == "pdf":
@@ -59,43 +54,6 @@ def _register_panzoom_assets() -> None:
f'<script src="{PANGOZOOM_CDN_URL}"></script>', f'<script src="{PANGOZOOM_CDN_URL}"></script>',
shared=True, shared=True,
) )
ui.add_head_html(
"""
<style>
.document-panzoom-host {
overflow: hidden;
touch-action: none;
}
.document-panzoom-surface {
width: 100%;
height: 100%;
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
.document-panzoom-media {
width: auto;
height: auto;
display: block;
max-width: 100%;
max-height: 100%;
user-select: none;
-webkit-user-drag: none;
}
.document-panzoom-iframe {
width: 100%;
height: 100%;
border: 0;
pointer-events: none;
background: var(--theme-surface-raised);
}
</style>
""",
shared=True,
)
def _document_url(source: Source) -> str: def _document_url(source: Source) -> str:
@@ -26,7 +26,7 @@ def show_error(exc: Exception, *, title: str, operation: str) -> None:
close_button="Dismiss", close_button="Dismiss",
) )
with ui.card().classes("vibe-card--error q-mt-md q-pa-md"): with ui.card().classes("ui-card-error q-mt-md q-pa-md"):
ui.label(title).classes("text-subtitle1") ui.label(title).classes("text-subtitle1")
ui.label(error.message) ui.label(error.message)
ui.label(f"Suggested action: {error.suggestion}").classes("text-weight-medium") ui.label(f"Suggested action: {error.suggestion}").classes("text-weight-medium")
@@ -4,7 +4,8 @@ import logging
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from nicegui import events, ui from nicegui import events
from nicegui import ui
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -61,7 +62,7 @@ def build_table(
search_input = ( search_input = (
ui.input(placeholder=search_placeholder) ui.input(placeholder=search_placeholder)
.props("dense outlined clearable icon=search") .props("dense outlined clearable icon=search")
.classes("w-64 text-xs bg-white") .classes("w-64 text-xs ui-form-surface")
) )
table = ( table = (
@@ -89,8 +89,7 @@ def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
dense dense
square square
size="sm" size="sm"
color="primary" class="ui-chip-primary"
text-color="white"
> >
{{ props.value }} {{ props.value }}
</q-chip> </q-chip>
@@ -4,7 +4,8 @@ from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC
from datetime import datetime
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -12,6 +13,7 @@ from nicegui import ui
from transcription.ui.components.cards import archival_card from transcription.ui.components.cards import archival_card
from transcription.ui.components.primitives import render_empty_state from transcription.ui.components.primitives import render_empty_state
from .common import build_table from .common import build_table
@@ -118,13 +120,7 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
dense dense
square square
size="sm" size="sm"
:color=" :class="`ui-status ui-status--${props.value}`"
props.value === 'completed' || props.value === 'transcribed' ? 'positive' :
props.value === 'failed' ? 'negative' :
props.value === 'processing' ? 'secondary' :
props.value === 'queued' ? 'warning' : 'grey-6'
"
text-color="white"
> >
{{ props.value.toUpperCase() }} {{ props.value.toUpperCase() }}
</q-chip> </q-chip>
@@ -93,7 +93,7 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
"label": "Error Detail", "label": "Error Detail",
"field": "job_source_error_detail", "field": "job_source_error_detail",
"sortable": False, "sortable": False,
"classes": "font-mono text-xs truncate max-w-xs vibe-text-muted", "classes": "font-mono text-xs truncate max-w-xs ui-text-muted",
}, },
], ],
default_sort_by="page_number", default_sort_by="page_number",
@@ -109,8 +109,7 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
dense dense
square square
size="sm" size="sm"
:color="props.value === 'transcribed' ? 'positive' : props.value === 'failed' ? 'negative' : 'grey-5'" :class="`ui-status ui-status--${props.value}`"
text-color="white"
> >
{{ props.value }} {{ props.value }}
</q-chip> </q-chip>
+9 -10
View File
@@ -10,7 +10,6 @@ from typing import Any
from nicegui import ui from nicegui import ui
from transcription.db.models import Job from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import Source from transcription.db.models import Source
type RevisionAction = Callable[[Source], Awaitable[None] | None] type RevisionAction = Callable[[Source], Awaitable[None] | None]
@@ -25,21 +24,21 @@ def render_original_transcription_card(*, job: Job, classes: str = "w-full") ->
model = job.model or "unknown" model = job.model or "unknown"
caption = f"{provider} | {model} | {_format_created_at(job.date_updated)}" caption = f"{provider} | {model} | {_format_created_at(job.date_updated)}"
card = ui.card().classes(f"{classes} q-pa-md vibe-card") card = ui.card().classes(f"{classes} q-pa-md ui-card-surface")
with card, ui.column().classes("w-full q-gutter-y-sm"): with card, ui.column().classes("w-full q-gutter-y-sm"):
ui.label(header).classes("text-subtitle1 text-weight-medium") ui.label(header).classes("text-subtitle1 text-weight-medium")
ui.label(caption).classes("text-caption vibe-text-muted") ui.label(caption).classes("text-caption ui-text-muted")
_metadata_row(label="Prompt", value=_latest_job_prompt(job) or "unknown") _metadata_row(label="Prompt", value=_latest_job_prompt(job) or "unknown")
_metadata_row(label="Updated", value=_format_created_at(job.date_updated)) _metadata_row(label="Updated", value=_format_created_at(job.date_updated))
latest_transcription = _latest_job_transcription(job) latest_transcription = _latest_job_transcription(job)
if latest_transcription: if latest_transcription:
with ui.card().classes("w-full q-pa-sm"): with ui.card().classes("w-full q-pa-sm ui-card-surface"):
ui.markdown(latest_transcription) ui.markdown(latest_transcription)
if latest_error_detail: if latest_error_detail:
with ui.card().classes("w-full vibe-card--error q-pa-sm"): with ui.card().classes("w-full ui-card-error q-pa-sm"):
ui.label("Failure detail").classes("text-caption text-uppercase") ui.label("Failure detail").classes("text-caption text-uppercase")
ui.label(latest_error_detail).classes("text-body2") ui.label(latest_error_detail).classes("text-body2")
@@ -60,16 +59,16 @@ def render_revision_row(
header = "Source revision | User-authored" header = "Source revision | User-authored"
caption = _format_created_at(revision.date_revised or revision.date_uploaded) caption = _format_created_at(revision.date_revised or revision.date_uploaded)
expansion = ui.expansion(value=initially_expanded, group="group").classes(f"{classes} rounded-borders vibe-card") expansion = ui.expansion(value=initially_expanded, group="group").classes(f"{classes} ui-card-surface")
with expansion, ui.column().classes("w-full q-gutter-y-sm q-pa-sm"): with expansion, ui.column().classes("w-full q-gutter-y-sm q-pa-sm"):
with expansion.add_slot("header"), ui.row().classes("w-full items-start justify-between q-gutter-md"): with expansion.add_slot("header"), ui.row().classes("w-full items-start justify-between q-gutter-md"):
with ui.column().classes("q-gutter-none"): with ui.column().classes("q-gutter-none"):
ui.label(header).classes("text-subtitle1 text-weight-medium") ui.label(header).classes("text-subtitle1 text-weight-medium")
ui.label(caption).classes("text-caption vibe-text-muted") ui.label(caption).classes("text-caption ui-text-muted")
if on_delete is not None: if on_delete is not None:
with ui.dialog() as delete_dialog, ui.card().classes("q-pa-md"): with ui.dialog() as delete_dialog, ui.card().classes("q-pa-md ui-card-surface"):
ui.label("Delete this source revision?").classes("text-body1") ui.label("Delete this source revision?").classes("text-body1")
with ui.row().classes("w-full justify-end q-gutter-sm"): with ui.row().classes("w-full justify-end q-gutter-sm"):
ui.button("Cancel", on_click=lambda: delete_dialog.submit(False)).props("flat") ui.button("Cancel", on_click=lambda: delete_dialog.submit(False)).props("flat")
@@ -94,7 +93,7 @@ def render_revision_row(
_metadata_row(label="Created", value=_format_created_at(revision.date_revised or revision.date_uploaded)) _metadata_row(label="Created", value=_format_created_at(revision.date_revised or revision.date_uploaded))
if revision.revised_text: if revision.revised_text:
with ui.card().classes("w-full q-pa-sm"): with ui.card().classes("w-full q-pa-sm ui-card-surface"):
ui.markdown(revision.revised_text) ui.markdown(revision.revised_text)
return expansion return expansion
@@ -128,5 +127,5 @@ def _format_created_at(value: datetime) -> str:
def _metadata_row(*, label: str, value: str) -> None: def _metadata_row(*, label: str, value: str) -> None:
with ui.row().classes("w-md items-start justify-between q-gutter-x-md"): with ui.row().classes("w-md items-start justify-between q-gutter-x-md"):
ui.label(label).classes("text-caption vibe-text-muted text-uppercase") ui.label(label).classes("text-caption ui-text-muted text-uppercase")
ui.label(value).classes("text-body2 text-right break-all") ui.label(value).classes("text-body2 text-right break-all")
+4 -5
View File
@@ -6,17 +6,16 @@ from nicegui import ui
def dark_room_viewer( def dark_room_viewer(
image_path: str | None, image_path: str | None,
count_label: str = "1 Source Linked", count_label: str = "1 Source Linked",
*,
container_height: str = "500px",
) -> None: ) -> None:
"""Render a plain responsive image that fills available width.""" """Render a plain responsive image that fills available width."""
del count_label del count_label
if image_path: if image_path:
ui.image(image_path).classes("w-full rounded-sm block").style("height: auto;") ui.image(image_path).classes("w-full rounded-sm block ui-media-image")
return return
with ui.column().classes( with ui.column().classes(
"w-full items-center justify-center border ui-border-viewer ui-bg-viewer-overlay-soft rounded-sm p-8" "w-full items-center justify-center border ui-border-viewer "
).style(f"min-height: {container_height};"): "ui-bg-viewer-overlay-soft rounded-sm p-8 ui-media-placeholder"
):
ui.label("No source media available for inspection.").classes("ui-text-muted text-xs italic") ui.label("No source media available for inspection.").classes("ui-text-muted text-xs italic")
+105 -56
View File
@@ -45,57 +45,6 @@ body {
font-family: "Aptos", "Trebuchet MS", sans-serif; font-family: "Aptos", "Trebuchet MS", sans-serif;
} }
.q-card,
.vibe-card {
border: 1px solid var(--theme-border);
color: var(--theme-text);
background: var(--theme-surface-raised);
box-shadow: none;
}
.vibe-card--error {
border-color: var(--palette-carbon-black);
color: var(--theme-inverse-text);
background: var(--palette-carbon-black);
}
.vibe-text-muted {
color: var(--theme-text-muted);
}
.vibe-separator {
background: var(--theme-border);
}
.vibe-status {
border: 1px solid currentColor;
}
.vibe-status--queued {
color: var(--palette-blue-slate);
background: var(--palette-platinum);
}
.vibe-status--processing {
color: var(--palette-carbon-black);
background: var(--palette-powder-blue);
}
.vibe-status--transcribed {
color: var(--palette-carbon-black);
background: var(--palette-cool-steel);
}
.vibe-status--failed {
color: var(--palette-platinum);
background: var(--palette-carbon-black);
}
.vibe-status--default {
color: var(--palette-blue-slate);
background: var(--theme-surface-muted);
}
button:focus-visible, button:focus-visible,
a:focus-visible, a:focus-visible,
textarea:focus-visible, textarea:focus-visible,
@@ -105,7 +54,7 @@ input:focus-visible,
outline-offset: 2px; outline-offset: 2px;
} }
/* Semantic utility classes for incremental migration away from inline hex styles. */ /* Semantic utility classes */
.ui-text-primary { .ui-text-primary {
color: var(--theme-text); color: var(--theme-text);
} }
@@ -163,6 +112,15 @@ input:focus-visible,
color: var(--theme-text); color: var(--theme-text);
background: var(--theme-surface-raised); background: var(--theme-surface-raised);
border-radius: 0.125rem; border-radius: 0.125rem;
box-shadow: none;
}
.ui-card-error {
border: 1px solid var(--theme-danger);
color: var(--theme-inverse-text);
background: var(--theme-danger);
border-radius: 0.125rem;
box-shadow: none;
} }
.ui-row-surface { .ui-row-surface {
@@ -214,6 +172,45 @@ input:focus-visible,
color: var(--theme-danger); color: var(--theme-danger);
} }
.ui-chip-primary {
color: var(--theme-inverse-text);
background: var(--theme-primary);
}
.ui-badge-secondary {
color: var(--theme-text);
background: var(--theme-secondary);
}
.ui-status {
color: var(--theme-text-muted);
background: var(--theme-surface-muted);
border: 1px solid currentColor;
}
.ui-status--queued,
.ui-status--pending {
color: var(--theme-primary);
background: var(--theme-surface-raised);
}
.ui-status--processing {
color: var(--theme-text);
background: var(--theme-surface-muted);
}
.ui-status--completed,
.ui-status--partial_success,
.ui-status--transcribed {
color: var(--theme-text);
background: var(--theme-secondary);
}
.ui-status--failed {
color: var(--theme-inverse-text);
background: var(--theme-danger);
}
.ui-form-surface .q-field__control { .ui-form-surface .q-field__control {
background: var(--theme-surface-raised); background: var(--theme-surface-raised);
} }
@@ -262,18 +259,18 @@ input:focus-visible,
.ui-table .q-table th, .ui-table .q-table th,
.ui-table-header { .ui-table-header {
color: var(--theme-inverse-text) !important; color: var(--theme-inverse-text);
background-color: var(--theme-primary) !important; background-color: var(--theme-primary);
font-weight: 700; font-weight: 700;
} }
.ui-table .q-table td { .ui-table .q-table td {
border-bottom: 1px solid var(--theme-border) !important; border-bottom: 1px solid var(--theme-border);
color: var(--theme-text); color: var(--theme-text);
} }
.ui-table .q-table tbody tr:hover { .ui-table .q-table tbody tr:hover {
background-color: var(--theme-surface) !important; background-color: var(--theme-surface);
cursor: pointer; cursor: pointer;
} }
@@ -304,10 +301,13 @@ input:focus-visible,
} }
.app-shell__brand { .app-shell__brand {
display: flex;
align-items: center;
gap: 0.75rem; gap: 0.75rem;
color: var(--theme-text); color: var(--theme-text);
font-family: Georgia, serif; font-family: Georgia, serif;
font-weight: 700; font-weight: 700;
text-decoration: none;
} }
.app-shell__brand-mark { .app-shell__brand-mark {
@@ -348,6 +348,55 @@ input:focus-visible,
font-weight: 700; font-weight: 700;
} }
/* Media and source viewers */
.ui-media-image {
height: auto;
}
.ui-media-placeholder {
min-height: 31.25rem;
}
.document-panzoom-filename {
max-width: 60%;
text-align: right;
}
.document-panzoom-host {
height: min(70vh, 52rem);
overflow: hidden;
touch-action: none;
border: 1px solid var(--theme-viewer-border);
border-radius: 0.125rem;
background: var(--theme-viewer);
}
.document-panzoom-surface {
width: 100%;
height: 100%;
display: flex;
align-items: flex-start;
justify-content: flex-start;
}
.document-panzoom-media {
width: auto;
height: auto;
display: block;
max-width: 100%;
max-height: 100%;
user-select: none;
-webkit-user-drag: none;
}
.document-panzoom-iframe {
width: 100%;
height: 100%;
border: 0;
pointer-events: none;
background: var(--theme-surface-raised);
}
@media (max-width: 700px) { @media (max-width: 700px) {
.app-shell { .app-shell {
padding-inline: 0.75rem; padding-inline: 0.75rem;
+45
View File
@@ -1,6 +1,7 @@
"""Tests for global UI theme registration.""" """Tests for global UI theme registration."""
import re import re
from pathlib import Path
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
@@ -8,6 +9,8 @@ from fastapi import FastAPI
from transcription.ui import register_pages from transcription.ui import register_pages
from transcription.ui.resources import read_css from transcription.ui.resources import read_css
UI_ROOT = Path(__file__).parents[1] / "src" / "transcription" / "ui"
@pytest.mark.unit @pytest.mark.unit
def test_page_registration_uses_vibescribe_theme(monkeypatch): def test_page_registration_uses_vibescribe_theme(monkeypatch):
@@ -35,3 +38,45 @@ def test_page_registration_uses_vibescribe_theme(monkeypatch):
} }
assert "--q-primary" in theme_css assert "--q-primary" in theme_css
assert run_options["dark"] is False assert run_options["dark"] is False
@pytest.mark.unit
def test_theme_is_the_only_ui_stylesheet():
stylesheets = sorted(path.relative_to(UI_ROOT).as_posix() for path in UI_ROOT.rglob("*.css"))
assert stylesheets == ["static/theme.css"]
@pytest.mark.unit
def test_ui_python_uses_class_driven_theme():
prohibited_patterns = {
".style(": "inline NiceGUI style",
"<style": "embedded style block",
"vibe-": "legacy presentation class",
}
violations: list[str] = []
for path in sorted((*UI_ROOT.glob("components/**/*.py"), *UI_ROOT.glob("pages/**/*.py"))):
source = path.read_text(encoding="utf-8")
for pattern, description in prohibited_patterns.items():
if pattern in source:
violations.append(f"{path.relative_to(UI_ROOT)}: {description}")
assert violations == []
@pytest.mark.unit
def test_theme_defines_shared_semantic_surfaces():
theme_css = read_css("theme.css")
for class_name in (
"ui-card-surface",
"ui-card-error",
"ui-form-surface",
"ui-table",
"ui-chip-primary",
"ui-badge-secondary",
"ui-status",
"document-panzoom-host",
):
assert f".{class_name}" in theme_css