global theming

This commit is contained in:
John Lancaster
2026-08-03 22:56:04 -05:00
parent edb6967888
commit 5932c0d3a1
10 changed files with 152 additions and 138 deletions
+5 -5
View File
@@ -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"}
+2 -5
View File
@@ -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
-33
View File
@@ -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)
+23
View File
@@ -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)
+20 -15
View File
@@ -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:
+49 -33
View File
@@ -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")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
"flat"
)
+27 -23
View File
@@ -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:
+18 -8
View File
@@ -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"<style>{theme_css}</style>", 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:
+4 -12
View File
@@ -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",
+1 -1
View File
@@ -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