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