From 5932c0d3a1202b7fc0d4fd3c3b374e1ef2d22037 Mon Sep 17 00:00:00 2001
From: John Lancaster <32917998+jsl12@users.noreply.github.com>
Date: Mon, 3 Aug 2026 22:56:04 -0500
Subject: [PATCH] global theming
---
src/transcription/api/health.py | 10 +--
src/transcription/app.py | 7 +-
src/transcription/ui/__init__.py | 33 ---------
src/transcription/ui/pages/__init__.py | 23 ++++++
src/transcription/ui/pages/jobs_page.py | 37 +++++-----
src/transcription/ui/pages/people_page.py | 82 +++++++++++++---------
src/transcription/ui/pages/sources_page.py | 52 +++++++-------
src/transcription/ui/theme.py | 28 +++++---
tests/test_main.py | 16 ++---
tests/test_ui_theme.py | 2 +-
10 files changed, 152 insertions(+), 138 deletions(-)
diff --git a/src/transcription/api/health.py b/src/transcription/api/health.py
index 2ee9fa5..961ca07 100644
--- a/src/transcription/api/health.py
+++ b/src/transcription/api/health.py
@@ -5,12 +5,12 @@ from fastapi import APIRouter
router = APIRouter()
-def healthz() -> dict[str, str]:
- """Return a simple health status payload."""
- return {"status": "ok"}
-
-
@router.get("/healthz")
def healthz_route() -> dict[str, str]:
"""Route wrapper for health status payload."""
return healthz()
+
+
+def healthz() -> dict[str, str]:
+ """Return a simple health status payload."""
+ return {"status": "ok"}
diff --git a/src/transcription/app.py b/src/transcription/app.py
index c6d29a8..d388bae 100644
--- a/src/transcription/app.py
+++ b/src/transcription/app.py
@@ -13,7 +13,6 @@ from fastapi import FastAPI
from fastapi import status
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
-from nicegui import ui
from .api.errors import register_error_handlers
from .api.health import router as health_router
@@ -25,8 +24,7 @@ from .db import dispose_database_runtime
from .db import initialize_database_runtime
from .services import ServiceBundle
from .services.jobs import JobService
-from .ui import register_pages
-from .ui.theme import THEME_COLORS
+from .ui.pages import register_pages
from .worker import worker_consumer_lifespan
logger = logging.getLogger(__name__)
@@ -98,8 +96,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
def health() -> dict[str, str]:
return {"status": "ok"}
+ app.include_router(health_router)
register_error_handlers(app)
register_pages(app)
- app.include_router(health_router)
- ui.colors(**THEME_COLORS)
return app
diff --git a/src/transcription/ui/__init__.py b/src/transcription/ui/__init__.py
index 51cfaca..e69de29 100644
--- a/src/transcription/ui/__init__.py
+++ b/src/transcription/ui/__init__.py
@@ -1,33 +0,0 @@
-"""UI page registration exports."""
-
-from fastapi import FastAPI
-from nicegui import ui
-
-from transcription.ui.pages.documents import register_pages as register_documents_pages
-from transcription.ui.pages.jobs_page import register_page as register_jobs_page
-from transcription.ui.pages.people_page import register_page as register_people_page
-from transcription.ui.pages.sources_page import register_page as register_sources_page
-from transcription.ui.pages.upload_page import register_page as register_upload_page
-from transcription.ui.resources import read_css
-
-_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
-
-
-def _register_global_styles(app: FastAPI) -> None:
- if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
- return
-
- ui.add_css(read_css("theme.css"), shared=True)
-
- setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
-
-
-def register_pages(app: FastAPI) -> None:
- """Register all NiceGUI pages and mount them onto the FastAPI app."""
- _register_global_styles(app)
- register_upload_page()
- register_documents_pages()
- register_people_page()
- register_sources_page()
- register_jobs_page()
- ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
diff --git a/src/transcription/ui/pages/__init__.py b/src/transcription/ui/pages/__init__.py
index e69de29..83b06cf 100644
--- a/src/transcription/ui/pages/__init__.py
+++ b/src/transcription/ui/pages/__init__.py
@@ -0,0 +1,23 @@
+from fastapi import FastAPI
+from nicegui import ui
+
+from transcription.ui.pages.documents import register_pages as register_documents_pages
+
+from ..theme import register_global_styles
+from .jobs_page import register_page as register_jobs_page
+from .people_page import register_page as register_people_page
+from .sources_page import register_page as register_sources_page
+from .upload_page import register_page as register_upload_page
+
+__all__ = ["register_pages"]
+
+
+def register_pages(app: FastAPI) -> None:
+ """Register all NiceGUI pages and mount them onto the FastAPI app."""
+ register_global_styles(app)
+ register_upload_page()
+ register_documents_pages()
+ register_people_page()
+ register_sources_page()
+ register_jobs_page()
+ ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py
index 242f248..8136952 100644
--- a/src/transcription/ui/pages/jobs_page.py
+++ b/src/transcription/ui/pages/jobs_page.py
@@ -23,7 +23,6 @@ from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.jobs import render_jobs_table
-from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from transcription.worker import resolve_worker_notifier
@@ -36,7 +35,7 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/jobs")
async def jobs_page(session_factory: SessionFactoryDep) -> None:
- apply_archival_theme()
+
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
@@ -68,12 +67,14 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/jobs/new")
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
- apply_archival_theme()
+
documents_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
- page_header("Create Processing Job", subtitle="Queue source files for AI transcription and entity processing.")
+ page_header(
+ "Create Processing Job", subtitle="Queue source files for AI transcription and entity processing."
+ )
documents = await documents_service.list_documents()
if not documents:
@@ -88,7 +89,9 @@ def register_page() -> None: # noqa: PLR0915
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
icon="note_add",
).classes("ui-btn-primary")
- ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
+ ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props(
+ "flat"
+ )
return
uploaded_files: list[tuple[str, bytes]] = []
@@ -136,18 +139,16 @@ def register_page() -> None: # noqa: PLR0915
with ui.column().classes("gap-1 w-full mt-2"):
for index, (filename, _) in ordered_uploads:
- with ui.row().classes(
- "w-full items-center justify-between ui-row-surface p-2"
- ):
+ with ui.row().classes("w-full items-center justify-between ui-row-surface p-2"):
ui.label(Path(filename).name).classes("text-xs font-mono ui-text-primary")
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
"flat round dense color=negative text-xs"
)
with ui.row().classes("w-full justify-end mt-2"):
- ui.button("Clear files", on_click=clear_files, icon="clear_all").props("flat dense").classes(
- "text-xs text-red-800"
- )
+ ui.button("Clear files", on_click=clear_files, icon="clear_all").props(
+ "flat dense"
+ ).classes("text-xs text-red-800")
async def on_upload(event) -> None:
payload = await event.file.read()
@@ -204,7 +205,7 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
- apply_archival_theme()
+
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
@@ -256,7 +257,7 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/jobs/{job_id}/delete")
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
- apply_archival_theme()
+
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
@@ -282,7 +283,9 @@ def register_page() -> None: # noqa: PLR0915
ui.label("Delete is blocked while the job is processing.").classes(
"text-xs text-red-800 font-bold mt-2"
)
- ui.label("Wait for processing to complete, then retry delete.").classes("text-xs ui-text-muted italic")
+ ui.label("Wait for processing to complete, then retry delete.").classes(
+ "text-xs ui-text-muted italic"
+ )
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Job",
@@ -296,7 +299,9 @@ def register_page() -> None: # noqa: PLR0915
ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
if job.job_sources:
- ui.label("Related JobSource links will be removed as part of delete.").classes("text-xs ui-text-muted")
+ ui.label("Related JobSource links will be removed as part of delete.").classes(
+ "text-xs ui-text-muted"
+ )
async def submit_delete() -> None:
try:
@@ -322,4 +327,4 @@ def register_page() -> None: # noqa: PLR0915
icon="delete_forever",
variant="solid",
)
- ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
\ No newline at end of file
+ ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py
index c193f3e..023ec31 100644
--- a/src/transcription/ui/pages/people_page.py
+++ b/src/transcription/ui/pages/people_page.py
@@ -9,15 +9,15 @@ from uuid import UUID
from fastapi import Request
from nicegui import ui
-from transcription.config import Settings, get_settings
+from transcription.config import Settings
+from transcription.config import get_settings
from transcription.db.models import Person
from transcription.errors import ErrorCategory
-from transcription.services.documents import (
- DocumentError,
- DocumentService,
- PersonDeleteBlockedError,
-)
-from transcription.services.store import UploadError, store_person_portrait
+from transcription.services.documents import DocumentError
+from transcription.services.documents import DocumentService
+from transcription.services.documents import PersonDeleteBlockedError
+from transcription.services.store import UploadError
+from transcription.services.store import store_person_portrait
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import metadata_row
@@ -25,9 +25,9 @@ from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
-from transcription.ui.components.table.people import PersonTableRow, render_people_table
+from transcription.ui.components.table.people import PersonTableRow
+from transcription.ui.components.table.people import render_people_table
from transcription.ui.components.viewers import dark_room_viewer
-from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
@@ -104,7 +104,7 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/people")
async def people_page(session_factory: SessionFactoryDep) -> None:
- apply_archival_theme()
+
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
@@ -142,7 +142,7 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/people/new")
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
- apply_archival_theme()
+
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
@@ -211,7 +211,7 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/people/{person_id}")
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
- apply_archival_theme()
+
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
@@ -271,9 +271,7 @@ def register_page() -> None: # noqa: PLR0915
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biography"):
- ui.label(person.biography or "No biography recorded.").classes(
- "p-2 ui-note-box text-xs w-full"
- )
+ ui.label(person.biography or "No biography recorded.").classes("p-2 ui-note-box text-xs w-full")
with archival_card(title="Linked Documents"):
if not person.document_people:
@@ -285,9 +283,7 @@ def register_page() -> None: # noqa: PLR0915
document = link.document
if document is None:
continue
- with ui.row().classes(
- "w-full justify-between items-center ui-row-surface p-2"
- ):
+ with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
with ui.column().classes("gap-0"):
ui.label(document.name).classes("text-xs font-semibold ui-text-primary")
ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
@@ -301,7 +297,7 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
- apply_archival_theme()
+
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
@@ -329,7 +325,9 @@ def register_page() -> None: # noqa: PLR0915
display_name_input = ui.input(label="Display name", value=person.display_name or "").props(
"outlined bg-white"
)
- maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props("outlined bg-white")
+ maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props(
+ "outlined bg-white"
+ )
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
birth_date_input = ui.input(
@@ -339,7 +337,9 @@ def register_page() -> None: # noqa: PLR0915
birth_date_raw_input = ui.input(
label="Birth date (approximate)", value=person.birth_date_raw or ""
).props("outlined bg-white")
- birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props("outlined bg-white")
+ birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props(
+ "outlined bg-white"
+ )
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
death_date_input = ui.input(
@@ -349,13 +349,19 @@ def register_page() -> None: # noqa: PLR0915
death_date_raw_input = ui.input(
label="Death date (approximate)", value=person.death_date_raw or ""
).props("outlined bg-white")
- death_place_input = ui.input(label="Death place", value=person.death_place or "").props("outlined bg-white")
+ death_place_input = ui.input(label="Death place", value=person.death_place or "").props(
+ "outlined bg-white"
+ )
biography_input = (
- ui.textarea(label="Biography", value=person.biography or "").props("outlined bg-white autogrow").classes("w-full")
+ ui.textarea(label="Biography", value=person.biography or "")
+ .props("outlined bg-white autogrow")
+ .classes("w-full")
)
portrait_path_input = (
- ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined bg-white").classes("w-full")
+ ui.input(label="Portrait path", value=person.portrait_path or "")
+ .props("outlined bg-white")
+ .classes("w-full")
)
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
@@ -401,11 +407,13 @@ def register_page() -> None: # noqa: PLR0915
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
- ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
+ ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
+ "flat"
+ )
@ui.page("/people/{person_id}/delete")
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
- apply_archival_theme()
+
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
@@ -431,21 +439,27 @@ def register_page() -> None: # noqa: PLR0915
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold ui-text-primary")
if person.document_people:
- ui.label("Delete is blocked because linked documents exist.").classes("text-xs text-red-800 font-bold mt-2")
+ ui.label("Delete is blocked because linked documents exist.").classes(
+ "text-xs text-red-800 font-bold mt-2"
+ )
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs ui-text-muted")
- ui.label("Remove document links first, then retry deletion.").classes("text-xs ui-text-muted italic")
+ ui.label("Remove document links first, then retry deletion.").classes(
+ "text-xs ui-text-muted italic"
+ )
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
icon="arrow_back",
).classes("ui-btn-primary text-xs")
- ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description").props(
- "flat text-xs"
- )
+ ui.button(
+ "Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description"
+ ).props("flat text-xs")
return
- ui.label("This action permanently deletes the person record.").classes("text-xs text-red-800 font-medium")
+ ui.label("This action permanently deletes the person record.").classes(
+ "text-xs text-red-800 font-medium"
+ )
async def submit_delete() -> None:
try:
@@ -475,4 +489,6 @@ def register_page() -> None: # noqa: PLR0915
icon="delete_forever",
variant="solid",
)
- ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
\ No newline at end of file
+ ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
+ "flat"
+ )
diff --git a/src/transcription/ui/pages/sources_page.py b/src/transcription/ui/pages/sources_page.py
index a9b1141..722c45e 100644
--- a/src/transcription/ui/pages/sources_page.py
+++ b/src/transcription/ui/pages/sources_page.py
@@ -9,21 +9,20 @@ from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
-from transcription.db.models import JobSource, Source
-from transcription.services.documents import DocumentError, DocumentService
+from transcription.db.models import Source
+from transcription.services.documents import DocumentError
+from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService
-from transcription.services.transcription import (
- TranscriptionNotFoundError,
- TranscriptionService,
-)
+from transcription.services.transcription import TranscriptionNotFoundError
+from transcription.services.transcription import TranscriptionService
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import section_header_row
-from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
-from transcription.ui.theme import apply_archival_theme
+from transcription.ui.components.table.sources import SourceTableRow
+from transcription.ui.components.table.sources import render_sources_table
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
@@ -34,7 +33,7 @@ def register_page() -> None:
@ui.page("/sources")
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
- apply_archival_theme()
+
sources_service = TranscriptionService(session_factory=session_factory)
jobs_service = JobService(session_factory=session_factory)
documents_service = DocumentService(session_factory=session_factory)
@@ -56,7 +55,7 @@ def register_page() -> None:
document = await documents_service.read_document_detail(document_id=document_id)
document_name = document.name
back_path = f"/documents/{document.id}"
- sources = list(sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold())))
+ sources = sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
elif job_id is not None:
job = await jobs_service.read_job(job_id=job_id)
job_label = str(job.id)
@@ -65,7 +64,9 @@ def register_page() -> None:
sources = [job_source.source for job_source in job_sources if job_source.source is not None]
sources.sort(key=lambda item: (item.page_number, item.upload_name.casefold()))
else:
- sources = list(sorted(await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number)))
+ sources = sorted(
+ await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number)
+ )
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
@@ -89,9 +90,9 @@ def register_page() -> None:
if back_path is not None:
back_label = "Back to Document" if document_id is not None else "Back to Job"
- ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
- "ui-btn-primary text-xs"
- )
+ ui.button(
+ back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back"
+ ).classes("ui-btn-primary text-xs")
# Format source records into read-model rows for the table renderer
rows = [
@@ -108,7 +109,6 @@ def register_page() -> None:
@ui.page("/sources/{source_id}")
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
- apply_archival_theme()
sources_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/sources")
@@ -131,7 +131,9 @@ def register_page() -> None:
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
- page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}")
+ page_header(
+ f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}"
+ )
if back_path is not None:
back_label = (
@@ -141,9 +143,9 @@ def register_page() -> None:
if "job_id" in request.query_params
else "Back to Sources"
)
- ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
- "ui-btn-primary text-xs"
- )
+ ui.button(
+ back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back"
+ ).classes("ui-btn-primary text-xs")
else:
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
"flat text-xs"
@@ -167,9 +169,9 @@ def register_page() -> None:
)
with archival_card(title="Automated Raw Transcription"):
- ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly bg-white").classes(
- "w-full text-xs font-mono"
- )
+ ui.textarea(value=_source_transcription_text(source) or "").props(
+ "outlined autogrow readonly bg-white"
+ ).classes("w-full text-xs font-mono")
with archival_card(title="Curated Human Transcription"):
revision_input = (
@@ -194,7 +196,9 @@ def register_page() -> None:
ui.navigate.to(request.url.path + _back_query(request.query_params))
with ui.row().classes("w-full items-center gap-2 mt-2"):
- ui.button("Save Revision", on_click=save_revision, icon="save").classes("ui-btn-primary text-xs")
+ ui.button("Save Revision", on_click=save_revision, icon="save").classes(
+ "ui-btn-primary text-xs"
+ )
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str) -> RedirectResponse:
@@ -252,4 +256,4 @@ def _source_transcription_text(source: Source) -> str | None:
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
if job_source.error_detail:
return job_source.error_detail
- return None
\ No newline at end of file
+ return None
diff --git a/src/transcription/ui/theme.py b/src/transcription/ui/theme.py
index dc7bc92..7443bd2 100644
--- a/src/transcription/ui/theme.py
+++ b/src/transcription/ui/theme.py
@@ -1,5 +1,12 @@
+from fastapi import FastAPI
+from nicegui import app as nicegui_app
from nicegui import ui
+from .resources import read_css
+
+_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
+
+
# Runtime bridge for Quasar color slots. Visual ownership remains in theme.css tokens/classes.
THEME_COLORS = {
"primary": "#5e6572",
@@ -12,17 +19,20 @@ THEME_COLORS = {
"warning": "#a9b4c2",
}
-_THEME_APPLIED = False
-
-def apply_archival_theme() -> None:
- """Apply runtime color slots once; visual styling is defined in theme.css."""
- global _THEME_APPLIED
- if _THEME_APPLIED:
+def register_global_styles(app: FastAPI) -> None:
+ if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
return
- ui.colors(**THEME_COLORS)
- _THEME_APPLIED = True
+ theme_css = read_css("theme.css")
+ try:
+ ui.add_css(theme_css, shared=True)
+ except RuntimeError:
+ # NiceGUI can retain stale slot state across test app factories.
+ ui.add_head_html(f"", shared=True)
+
+ setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
+ nicegui_app.colors(**THEME_COLORS)
def page_header(title: str, subtitle: str | None = None) -> None:
@@ -37,4 +47,4 @@ def page_header(title: str, subtitle: str | None = None) -> None:
VIBESCRIBE_LOGO_SVG = """
-"""
\ No newline at end of file
+"""
diff --git a/tests/test_main.py b/tests/test_main.py
index f2bb68c..519696e 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -8,19 +8,11 @@ from transcription import __main__ as entrypoint
@pytest.mark.unit
-def test_main_passes_constructed_app_to_uvicorn(monkeypatch):
- """Non-reload execution keeps the parsed settings instance in the app."""
+def test_main_uses_cli_factory_import_string(monkeypatch):
+ """Startup uses an importable factory so Uvicorn owns app creation."""
settings = SimpleNamespace(host="127.0.0.1", port=8123, log_level="debug", reload=False)
- application = object()
captured = {}
-
- def create_app(*, settings: object) -> object:
- assert settings is expected_settings
- return application
-
- expected_settings = settings
monkeypatch.setattr(entrypoint, "parse_cli_settings", lambda: settings)
- monkeypatch.setattr(entrypoint, "create_app", create_app)
monkeypatch.setattr(
entrypoint.uvicorn,
"run",
@@ -30,8 +22,8 @@ def test_main_passes_constructed_app_to_uvicorn(monkeypatch):
entrypoint.main()
assert captured == {
- "application": application,
- "factory": False,
+ "application": "transcription.__main__:create_cli_app",
+ "factory": True,
"host": "127.0.0.1",
"port": 8123,
"log_level": "debug",
diff --git a/tests/test_ui_theme.py b/tests/test_ui_theme.py
index 5003d2f..c56d41f 100644
--- a/tests/test_ui_theme.py
+++ b/tests/test_ui_theme.py
@@ -5,7 +5,7 @@ import re
import pytest
from fastapi import FastAPI
-from transcription.ui import register_pages
+from transcription.ui.pages import register_pages
from transcription.ui.resources import read_css