From d0a3ca028919198dd4e1169f3d08e89e044c76c5 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:34:12 -0500 Subject: [PATCH] WIP theming --- src/transcription/app.py | 3 +- src/transcription/ui/__init__.py | 24 +-- src/transcription/ui/components/__init__.py | 12 +- src/transcription/ui/components/app_shell.py | 53 +++---- .../ui/components/document_panzoom.py | 6 +- .../ui/components/error_presenter.py | 2 +- src/transcription/ui/components/job_detail.py | 28 ++-- .../ui/components/page_content.py | 55 +++++++ .../ui/components/page_header.py | 36 +++++ src/transcription/ui/components/transcript.py | 16 +- src/transcription/ui/pages/jobs_page.py | 8 +- src/transcription/ui/resources.py | 19 +++ src/transcription/ui/static/colors.css | 30 ---- .../ui/static/components/app_shell.css | 86 +++++++++++ .../ui/static/components/page_content.css | 138 ++++++++++++++++++ .../ui/static/components/page_header.css | 60 ++++++++ src/transcription/ui/static/theme.css | 105 +++++++++++++ tests/test_app.py | 4 +- tests/test_config.py | 25 ++++ tests/test_main.py | 58 ++++++++ tests/test_ui_theme.py | 38 +++++ tests/ui/test_upload_page.py | 1 + 22 files changed, 695 insertions(+), 112 deletions(-) create mode 100644 src/transcription/ui/components/page_content.py create mode 100644 src/transcription/ui/components/page_header.py create mode 100644 src/transcription/ui/resources.py delete mode 100644 src/transcription/ui/static/colors.css create mode 100644 src/transcription/ui/static/components/app_shell.css create mode 100644 src/transcription/ui/static/components/page_content.css create mode 100644 src/transcription/ui/static/components/page_header.css create mode 100644 src/transcription/ui/static/theme.css create mode 100644 tests/test_main.py create mode 100644 tests/test_ui_theme.py diff --git a/src/transcription/app.py b/src/transcription/app.py index 6a70841..c6dead8 100644 --- a/src/transcription/app.py +++ b/src/transcription/app.py @@ -34,9 +34,8 @@ logger = logging.getLogger(__name__) @asynccontextmanager async def _lifespan(app: FastAPI): - configure_logging() - settings = getattr(app.state, "settings", None) or get_settings() + configure_logging(settings) app.state.settings = settings app.state.services = ServiceBundle() app.state.runtime = initialize_database_runtime(settings=settings) diff --git a/src/transcription/ui/__init__.py b/src/transcription/ui/__init__.py index 0fa7e83..200d86a 100644 --- a/src/transcription/ui/__init__.py +++ b/src/transcription/ui/__init__.py @@ -1,38 +1,20 @@ """UI page registration exports.""" -from pathlib import Path - from fastapi import FastAPI -from nicegui import app as nicegui_app from nicegui import ui from transcription.ui.pages.jobs_page import register_page as register_jobs_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" -_THEME_COLORS: dict[str, str] = { - "primary": "#6f97e8", - "secondary": "#92b5f5", - "accent": "#7fc0de", - "dark": "#22304a", - "dark_page": "#1a2538", - "positive": "#86c8ad", - "negative": "#d98a9a", - "info": "#7ebdda", - "warning": "#e2c083", -} - def _register_global_styles(app: FastAPI) -> None: if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False): return - nicegui_app.colors(**_THEME_COLORS) - - css_path = Path(__file__).resolve().parent / "static" / "colors.css" - if css_path.exists(): - ui.add_css(css_path, shared=True) + ui.add_css(read_css("theme.css"), shared=True) setattr(app.state, _THEME_REGISTERED_STATE_KEY, True) @@ -42,4 +24,4 @@ def register_pages(app: FastAPI) -> None: _register_global_styles(app) register_upload_page() register_jobs_page() - ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=True) + ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False) diff --git a/src/transcription/ui/components/__init__.py b/src/transcription/ui/components/__init__.py index 01d5877..1a0c77d 100644 --- a/src/transcription/ui/components/__init__.py +++ b/src/transcription/ui/components/__init__.py @@ -1,7 +1,17 @@ """Reusable UI component exports.""" from transcription.ui.components.app_shell import NAV_ITEMS +from transcription.ui.components.app_shell import render_app_shell from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.document_panzoom import render_document_panzoom +from transcription.ui.components.page_content import render_page_content +from transcription.ui.components.page_header import render_page_header -__all__ = ["NAV_ITEMS", "render_document_panzoom", "render_navigation_header"] +__all__ = [ + "NAV_ITEMS", + "render_app_shell", + "render_document_panzoom", + "render_navigation_header", + "render_page_content", + "render_page_header", +] diff --git a/src/transcription/ui/components/app_shell.py b/src/transcription/ui/components/app_shell.py index 4cf531a..77b622f 100644 --- a/src/transcription/ui/components/app_shell.py +++ b/src/transcription/ui/components/app_shell.py @@ -4,6 +4,8 @@ from __future__ import annotations from nicegui import ui +from transcription.ui.resources import read_css + NAV_ITEMS: tuple[tuple[str, str, str], ...] = ( ("Upload", "/upload", "upload_file"), ("Jobs", "/jobs", "work_history"), @@ -16,27 +18,17 @@ def _is_active_path(*, current_path: str, item_path: str) -> bool: return current_path == item_path -def _button_props(*, icon: str, is_active: bool) -> str: - if is_active: - return f"icon={icon} no-caps unelevated color=primary text-color=white" - return f"icon={icon} no-caps outline color=secondary text-color=secondary" - - -def _button_classes(*, is_active: bool) -> str: - base = "w-full sm:w-auto min-h-[40px] px-3 rounded-lg text-body2 text-weight-medium" - if is_active: - return f"{base}" - return f"{base}" - - def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -> None: is_active = _is_active_path(current_path=current_path, item_path=path) - button = ui.button( + classes = "app-shell__nav-item" + if is_active: + classes = f"{classes} app-shell__nav-item--active" + + ui.button( label, icon=icon, on_click=lambda _=None, route=path: ui.navigate.to(route), - ) - button.props(_button_props(icon=icon, is_active=is_active)).classes(_button_classes(is_active=is_active)) + ).props("flat no-caps").classes(classes) def _normalize_path(current_path: str | None) -> str: @@ -46,14 +38,25 @@ def _normalize_path(current_path: str | None) -> str: return normalized.rstrip("/") or "/" -def render_navigation_header(*, current_path: str | None = None) -> None: - """Render a shared app header with links for top-level pages.""" +def render_app_shell(*, current_path: str | None = None) -> None: + """Render the shared application shell header.""" + ui.add_css(read_css("components/app_shell.css")) normalized_path = _normalize_path(current_path) - with ( - ui.header(elevated=True).props("bordered").classes("bg-dark text-white q-px-sm q-py-xs"), - ui.row().classes("w-full items-center justify-end q-gutter-xs"), - ui.element("div").classes("w-full grid grid-cols-2 gap-2 sm:flex sm:justify-start sm:gap-2"), - ): - for label, path, icon in NAV_ITEMS: - _render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path) + with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"): + with ui.row().classes("app-shell__brand no-wrap"): + ui.label("VS").classes("app-shell__brand-mark") + ui.label("VibeScribe").classes("app-shell__brand-name") + + with ui.element("nav").props('aria-label="Primary navigation"').classes("app-shell__nav"): + for label, path, icon in NAV_ITEMS: + _render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path) + + with ui.row().classes("app-shell__actions no-wrap"): + ui.label("Saved").classes("app-shell__save-state") + ui.button(icon="more_horiz").props("flat round dense").tooltip("More actions") + + +def render_navigation_header(*, current_path: str | None = None) -> None: + """Render the app shell using the legacy page-level entry point.""" + render_app_shell(current_path=current_path) diff --git a/src/transcription/ui/components/document_panzoom.py b/src/transcription/ui/components/document_panzoom.py index 1bd9bb6..cb96c0e 100644 --- a/src/transcription/ui/components/document_panzoom.py +++ b/src/transcription/ui/components/document_panzoom.py @@ -24,10 +24,10 @@ def render_document_panzoom(*, source: Source) -> None: document_url = _document_url(source) document_kind = _document_kind(source) - with ui.card().classes("w-full q-pa-md"): + with ui.card().classes("w-full q-pa-md vibe-card"): with ui.row().classes("w-full items-center justify-between no-wrap"): ui.label("Document preview").classes("text-subtitle1 text-weight-medium") - ui.label(source.filename).classes("text-caption text-grey-4 ellipsis").style( + ui.label(source.filename).classes("text-caption vibe-text-muted ellipsis").style( "max-width: 60%; text-align: right;" ) @@ -90,7 +90,7 @@ def _register_panzoom_assets() -> None: height: 100%; border: 0; pointer-events: none; - background: white; + background: var(--theme-surface-raised); } """, diff --git a/src/transcription/ui/components/error_presenter.py b/src/transcription/ui/components/error_presenter.py index 06148a9..9772b85 100644 --- a/src/transcription/ui/components/error_presenter.py +++ b/src/transcription/ui/components/error_presenter.py @@ -26,7 +26,7 @@ def show_error(exc: Exception, *, title: str, operation: str) -> None: close_button="Dismiss", ) - with ui.card().classes("bg-red-1 text-red-10 q-mt-md q-pa-md"): + with ui.card().classes("vibe-card--error q-mt-md q-pa-md"): ui.label(title).classes("text-subtitle1") ui.label(error.message) ui.label(f"Suggested action: {error.suggestion}").classes("text-weight-medium") diff --git a/src/transcription/ui/components/job_detail.py b/src/transcription/ui/components/job_detail.py index 3ae1e5d..8b35618 100644 --- a/src/transcription/ui/components/job_detail.py +++ b/src/transcription/ui/components/job_detail.py @@ -18,24 +18,24 @@ logger = logging.getLogger(__name__) def _status_chip_classes(status: str) -> str: if status == "queued": - return "bg-blue-1 text-blue-10" + return "vibe-status--queued" if status == "processing": - return "bg-amber-1 text-amber-10" + return "vibe-status--processing" if status == "transcribed": - return "bg-green-1 text-green-10" + return "vibe-status--transcribed" if status == "failed": - return "bg-red-1 text-red-10" - return "bg-grey-2 text-grey-9" + return "vibe-status--failed" + return "vibe-status--default" def _metadata_row(label: str, value: str) -> None: with ui.row().classes("w-full items-start justify-between q-gutter-x-md"): - ui.label(label).classes("text-caption text-grey-5 text-uppercase w-28") - ui.label(value).classes("text-body2 text-right text-grey-1 break-all") + ui.label(label).classes("text-caption vibe-text-muted text-uppercase w-28") + ui.label(value).classes("text-body2 text-right break-all") def _render_source_section(source: Source) -> None: - with ui.card().classes("w-full q-pa-md"): + with ui.card().classes("w-full q-pa-md vibe-card"): ui.label("Source").classes("text-subtitle1 text-weight-medium") ui.separator().classes("q-my-sm") with ui.column().classes("w-full q-gutter-y-xs"): @@ -49,12 +49,12 @@ def _render_source_section(source: Source) -> None: def _render_revision_section(revision: Revision | None) -> None: - with ui.card().classes("w-full q-pa-md"): + with ui.card().classes("w-full q-pa-md vibe-card"): ui.label("Revision").classes("text-subtitle1 text-weight-medium") ui.separator().classes("q-my-sm") if revision is None: - ui.label("No revision exists for this source.").classes("text-body2 text-grey-3") + ui.label("No revision exists for this source.").classes("text-body2 vibe-text-muted") return render_revision_row(revision=revision, initially_expanded=True) @@ -65,19 +65,19 @@ def render_job_detail(*, job: Job, source: Source | None, revision: Revision | N logger.debug("Rendering job detail for job ID %s", job.id) status_text = job.status.value with ui.column().classes("w-full max-w-4xl q-gutter-md"): - with ui.card().classes("w-full q-pa-lg"): + with ui.card().classes("w-full q-pa-lg vibe-card"): with ui.row().classes("w-full items-center justify-between q-gutter-md"): with ui.column().classes("q-gutter-none"): ui.label("Job overview").classes("text-h6 text-weight-bold") - ui.label(str(job.id)).classes("text-caption text-grey-5") + ui.label(str(job.id)).classes("text-caption vibe-text-muted") status_chip_classes = ( "q-px-sm q-py-xs rounded-borders " - "text-weight-medium text-capitalize " + "vibe-status text-weight-medium text-capitalize " f"{_status_chip_classes(status_text)}" ) ui.label(status_text).classes(status_chip_classes) - ui.separator().classes("q-my-md bg-blue-grey-7") + ui.separator().classes("q-my-md vibe-separator") with ui.column().classes("w-full q-gutter-y-xs"): _metadata_row("Created", job.date_created.isoformat()) _metadata_row("Updated", job.date_updated.isoformat()) diff --git a/src/transcription/ui/components/page_content.py b/src/transcription/ui/components/page_content.py new file mode 100644 index 0000000..f62dd6d --- /dev/null +++ b/src/transcription/ui/components/page_content.py @@ -0,0 +1,55 @@ +"""High-level placeholder content for a transcription workspace.""" + +from __future__ import annotations + +from nicegui import ui + +from transcription.ui.resources import read_css + + +def render_page_content( + *, + source_name: str = "document-placeholder.jpg", + raw_text: str = "AI transcription output will appear here.", + revised_text: str = "Human revision text will appear here.", +) -> None: + """Render the primary editor workspace and supporting context sidebar.""" + ui.add_css(read_css("components/page_content.css")) + + with ui.element("div").classes("page-content"): + with ui.element("section").classes("page-content__editor"): + with ui.row().classes("page-content__heading"): + with ui.column().classes("gap-0"): + ui.label("Active source").classes("page-content__kicker") + ui.label("Page transcription").classes("page-content__title") + ui.badge("Page 1 of 1").classes("page-content__badge") + + with ui.element("div").classes("page-content__workspace"): + with ui.element("section").classes("source-placeholder"): + with ui.row().classes("source-placeholder__toolbar"): + ui.label(source_name) + ui.icon("image", size="1.25rem") + with ui.column().classes("source-placeholder__body"): + ui.icon("description", size="4rem") + ui.label("Source preview") + + with ui.column().classes("transcription-placeholder"): + with ui.element("section").classes("transcription-placeholder__section"): + ui.label("AI raw output").classes("transcription-placeholder__title") + ui.label(raw_text).classes("transcription-placeholder__text") + + with ui.element("section").classes("transcription-placeholder__section"): + ui.label("Human revision").classes("transcription-placeholder__title") + ui.textarea(value=revised_text).props("outlined autogrow").classes("w-full") + + with ui.element("aside").props('aria-label="Document context"').classes("page-content__sidebar"): + with ui.element("section").classes("page-content__sidebar-section"): + ui.label("People").classes("page-content__sidebar-title") + ui.label("Author · Placeholder Person") + ui.label("Recipient · Placeholder Person") + + with ui.element("section").classes("page-content__sidebar-section"): + ui.label("AI processing").classes("page-content__sidebar-title") + ui.badge("Completed", color="positive") + ui.label("Provider · Placeholder provider") + ui.label("Model · Placeholder model") diff --git a/src/transcription/ui/components/page_header.py b/src/transcription/ui/components/page_header.py new file mode 100644 index 0000000..94c6af6 --- /dev/null +++ b/src/transcription/ui/components/page_header.py @@ -0,0 +1,36 @@ +"""High-level page header for document-oriented views.""" + +from __future__ import annotations + +from collections.abc import Awaitable +from collections.abc import Callable + +from nicegui import ui + +from transcription.ui.resources import read_css + +type PageHeaderAction = Callable[[], Awaitable[None] | None] + + +def render_page_header( + *, + eyebrow: str = "Letter · Placeholder Collection", + title: str = "Untitled archival document", + metadata: tuple[str, ...] = ("Date unknown", "Location unknown"), + on_details: PageHeaderAction | None = None, + on_review: PageHeaderAction | None = None, +) -> None: + """Render document identity, metadata, and page-level actions.""" + ui.add_css(read_css("components/page_header.css")) + + with ui.element("section").classes("page-header"): + with ui.column().classes("page-header__identity"): + ui.label(eyebrow).classes("page-header__eyebrow") + ui.label(title).classes("page-header__title") + with ui.row().classes("page-header__metadata"): + for value in metadata: + ui.label(value) + + with ui.row().classes("page-header__actions"): + ui.button("Document details", icon="info", on_click=on_details).props("outline no-caps") + ui.button("Mark reviewed", icon="task_alt", on_click=on_review).props("unelevated no-caps") diff --git a/src/transcription/ui/components/transcript.py b/src/transcription/ui/components/transcript.py index 82bc667..c7933b0 100644 --- a/src/transcription/ui/components/transcript.py +++ b/src/transcription/ui/components/transcript.py @@ -23,10 +23,10 @@ def render_original_transcription_card(*, job: Job, classes: str = "w-full") -> model = job.model or "unknown" caption = f"{provider} | {model} | {_format_created_at(job.date_updated)}" - card = ui.card().classes(f"{classes} q-pa-md bg-blue-grey-10") + card = ui.card().classes(f"{classes} q-pa-md vibe-card") with card, ui.column().classes("w-full q-gutter-y-sm"): ui.label(header).classes("text-subtitle1 text-weight-medium") - ui.label(caption).classes("text-caption text-grey-5") + ui.label(caption).classes("text-caption vibe-text-muted") _metadata_row(label="Prompt", value=job.prompt_name or "unknown") _metadata_row(label="Updated", value=_format_created_at(job.date_updated)) @@ -35,7 +35,7 @@ def render_original_transcription_card(*, job: Job, classes: str = "w-full") -> ui.markdown(job.text) if job.error_detail: - with ui.card().classes("w-full bg-red-1 text-red-10 q-pa-sm"): + with ui.card().classes("w-full vibe-card--error q-pa-sm"): ui.label("Failure detail").classes("text-caption text-uppercase") ui.label(job.error_detail).classes("text-body2") @@ -53,15 +53,13 @@ def render_revision_row( header = "Revision | User-authored" caption = _format_created_at(revision.date_created) - expansion = ui.expansion(value=initially_expanded, group="group").classes( - f"{classes} rounded-borders bg-blue-grey-10" - ) + expansion = ui.expansion(value=initially_expanded, group="group").classes(f"{classes} rounded-borders vibe-card") 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 ui.column().classes("q-gutter-none"): ui.label(header).classes("text-subtitle1 text-weight-medium") - ui.label(caption).classes("text-caption text-grey-5") + ui.label(caption).classes("text-caption vibe-text-muted") if on_delete is not None: with ui.dialog() as delete_dialog, ui.card().classes("q-pa-md"): @@ -102,5 +100,5 @@ def _format_created_at(value: datetime) -> str: def _metadata_row(*, label: str, value: str) -> None: with ui.row().classes("w-md items-start justify-between q-gutter-x-md"): - ui.label(label).classes("text-caption text-grey-5 text-uppercase") - ui.label(value).classes("text-body2 text-right text-grey-1 break-all") + ui.label(label).classes("text-caption vibe-text-muted text-uppercase") + ui.label(value).classes("text-body2 text-right break-all") diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index 0891501..63920ee 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -73,7 +73,7 @@ def register_page() -> None: # noqa: PLR0915 if source is not None: render_document_panzoom(source=source) else: - ui.label("No source preview is available for this job.").classes("text-body2 text-grey-3") + ui.label("No source preview is available for this job.").classes("text-body2 vibe-text-muted") with splitter.after, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"): with ui.row(): ui.button(icon="arrow_back", on_click=ui.navigate.back) @@ -81,7 +81,7 @@ def register_page() -> None: # noqa: PLR0915 ui.label(f"{job.id}").classes("text-h6 text-weight-bold") match job.status: case JobStatus.TRANSCRIBED: - ui.chip(job.status.value.upper(), color="green", text_color="white").props("outline") + ui.chip(job.status.value.upper(), color="positive", text_color="white").props("outline") case _: ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium") @@ -103,7 +103,7 @@ def register_page() -> None: # noqa: PLR0915 refreshed_job = await jobs_service.read_job(job_id=parsed_job_id) refreshed_source = _resolve_primary_source(refreshed_job) if refreshed_source is None: - ui.label("No source is available for revision editing.").classes("text-body2 text-grey-3") + ui.label("No source is available for revision editing.").classes("text-body2 vibe-text-muted") return current_revision = refreshed_source.revision @@ -141,7 +141,7 @@ def register_page() -> None: # noqa: PLR0915 ).props('unelevated color="primary"') if current_revision is None: - ui.label("No revision exists for this source.").classes("text-body2 text-grey-3") + ui.label("No revision exists for this source.").classes("text-body2 vibe-text-muted") return render_revision_row( diff --git a/src/transcription/ui/resources.py b/src/transcription/ui/resources.py new file mode 100644 index 0000000..4585a9d --- /dev/null +++ b/src/transcription/ui/resources.py @@ -0,0 +1,19 @@ +"""Package resource helpers for UI presentation assets.""" + +from __future__ import annotations + +from functools import cache +from importlib.resources import files +from pathlib import PurePosixPath + + +@cache +def read_css(relative_path: str) -> str: + """Read and cache a CSS resource relative to ``ui/static``.""" + resource_path = PurePosixPath(relative_path) + if resource_path.is_absolute() or ".." in resource_path.parts or resource_path.suffix != ".css": + msg = f"Invalid CSS resource path: {relative_path}" + raise ValueError(msg) + + resource = files("transcription.ui").joinpath("static", *resource_path.parts) + return resource.read_text(encoding="utf-8") diff --git a/src/transcription/ui/static/colors.css b/src/transcription/ui/static/colors.css deleted file mode 100644 index 4a529fc..0000000 --- a/src/transcription/ui/static/colors.css +++ /dev/null @@ -1,30 +0,0 @@ -:root { - /* Soft blue-night palette tokens */ - --ctp-rosewater: #f2dde5; - --ctp-flamingo: #edcfd8; - --ctp-pink: #dcc7de; - --ctp-mauve: #a9bde5; - --ctp-red: #d98a9a; - --ctp-maroon: #d39aa5; - --ctp-peach: #d7af8c; - --ctp-yellow: #e2c083; - --ctp-green: #86c8ad; - --ctp-teal: #77bfbe; - --ctp-sky: #7ebdda; - --ctp-sapphire: #74aed0; - --ctp-blue: #92b5f5; - --ctp-lavender: #6f97e8; - --ctp-text: #d8e2f5; - --ctp-subtext1: #bfcae0; - --ctp-subtext0: #a9b6cf; - --ctp-overlay2: #95a3bf; - --ctp-overlay1: #7c8ca9; - --ctp-overlay0: #657490; - --ctp-surface2: #4d5f7c; - --ctp-surface1: #394a65; - --ctp-surface0: #2a3954; - --ctp-base: #1f2b42; - --ctp-mantle: #1a2538; - --ctp-crust: #141e30; - -} diff --git a/src/transcription/ui/static/components/app_shell.css b/src/transcription/ui/static/components/app_shell.css new file mode 100644 index 0000000..5a6ae78 --- /dev/null +++ b/src/transcription/ui/static/components/app_shell.css @@ -0,0 +1,86 @@ +.app-shell { + min-height: 64px; + padding: 0.75rem 2rem; + border-bottom: 1px solid var(--theme-border); + color: var(--theme-text); + background: var(--theme-surface-raised); +} + +.app-shell__inner { + width: 100%; + display: grid; + grid-template-columns: minmax(180px, 1fr) auto minmax(180px, 1fr); + align-items: center; + gap: 1.5rem; +} + +.app-shell__brand, +.app-shell__actions { + align-items: center; +} + +.app-shell__brand { + gap: 0.75rem; + color: var(--theme-text); + font-family: Georgia, serif; + font-weight: 700; +} + +.app-shell__brand-mark { + width: 34px; + height: 34px; + display: grid; + place-items: center; + color: var(--theme-inverse-text); + background: var(--theme-primary); + font-family: "Trebuchet MS", sans-serif; + font-size: 0.72rem; +} + +.app-shell__nav { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.app-shell__nav-item { + min-height: 40px; + color: var(--theme-text-muted); +} + +.app-shell__nav-item--active { + color: var(--theme-primary-hover); + border-bottom: 3px solid var(--theme-primary); +} + +.app-shell__actions { + justify-content: flex-end; + gap: 0.75rem; +} + +.app-shell__save-state { + color: var(--theme-text-muted); + font-size: 0.82rem; + font-weight: 700; +} + +@media (max-width: 700px) { + .app-shell { + padding-inline: 0.75rem; + } + + .app-shell__inner { + grid-template-columns: 1fr auto; + } + + .app-shell__nav { + grid-column: 1 / -1; + grid-row: 2; + justify-content: center; + } + + .app-shell__brand-name, + .app-shell__save-state { + display: none; + } +} \ No newline at end of file diff --git a/src/transcription/ui/static/components/page_content.css b/src/transcription/ui/static/components/page_content.css new file mode 100644 index 0000000..af18b28 --- /dev/null +++ b/src/transcription/ui/static/components/page_content.css @@ -0,0 +1,138 @@ +.page-content { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 320px); + align-items: start; + border: 1px solid var(--theme-border); + color: var(--theme-text); + background: var(--theme-surface-raised); + box-shadow: var(--theme-shadow); +} + +.page-content__editor { + min-width: 0; + padding: 1.5rem; +} + +.page-content__heading { + width: 100%; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.page-content__kicker { + color: var(--theme-text-muted); + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; +} + +.page-content__title, +.page-content__sidebar-title { + color: var(--theme-text); + font-family: Georgia, serif; + font-weight: 700; +} + +.page-content__title { + font-size: 1.35rem; +} + +.page-content__badge { + color: var(--theme-text); + background: var(--theme-surface-muted); +} + +.page-content__workspace { + display: grid; + grid-template-columns: minmax(280px, 0.85fr) minmax(320px, 1.15fr); + gap: 1.5rem; +} + +.source-placeholder { + min-height: 440px; + display: grid; + grid-template-rows: auto 1fr; + border: 1px solid var(--theme-viewer-border); + background: var(--theme-viewer); +} + +.source-placeholder__toolbar { + width: 100%; + min-height: 48px; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + color: var(--theme-inverse-text); +} + +.source-placeholder__body { + align-items: center; + justify-content: center; + color: var(--theme-viewer-muted); +} + +.transcription-placeholder { + min-width: 0; + gap: 1rem; +} + +.transcription-placeholder__section { + min-width: 0; +} + +.transcription-placeholder__title { + margin-bottom: 0.5rem; + font-weight: 700; +} + +.transcription-placeholder__text { + min-height: 160px; + padding: 1rem; + border: 1px solid var(--theme-border); + background: var(--theme-surface); + font-family: Georgia, serif; +} + +.page-content__sidebar { + min-width: 0; + border-left: 1px solid var(--theme-border); + background: var(--theme-surface); +} + +.page-content__sidebar-section { + display: grid; + gap: 0.75rem; + padding: 1.5rem; + border-bottom: 1px solid var(--theme-border); +} + +.page-content__sidebar-title { + font-size: 1.05rem; +} + +@media (max-width: 1000px) { + .page-content { + grid-template-columns: 1fr; + } + + .page-content__sidebar { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid var(--theme-border); + border-left: 0; + } +} + +@media (max-width: 760px) { + .page-content__workspace, + .page-content__sidebar { + grid-template-columns: 1fr; + } + + .page-content__editor, + .page-content__sidebar-section { + padding: 1rem; + } +} \ No newline at end of file diff --git a/src/transcription/ui/static/components/page_header.css b/src/transcription/ui/static/components/page_header.css new file mode 100644 index 0000000..d9ea81a --- /dev/null +++ b/src/transcription/ui/static/components/page_header.css @@ -0,0 +1,60 @@ +.page-header { + width: 100%; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 2rem; + padding: 2rem 0 1.5rem; +} + +.page-header__identity { + gap: 0.25rem; +} + +.page-header__eyebrow { + color: var(--theme-text-muted); + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; +} + +.page-header__title { + color: var(--theme-text); + font-family: Georgia, serif; + font-size: 2rem; + font-weight: 700; + line-height: 1.15; +} + +.page-header__metadata { + gap: 0.75rem; + color: var(--theme-text-muted); + font-size: 0.92rem; +} + +.page-header__metadata > * + *::before { + margin-right: 0.75rem; + content: "·"; +} + +.page-header__actions { + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.75rem; +} + +@media (max-width: 700px) { + .page-header { + align-items: flex-start; + flex-direction: column; + } + + .page-header__title { + font-size: 1.65rem; + } + + .page-header__actions, + .page-header__actions .q-btn { + width: 100%; + } +} \ No newline at end of file diff --git a/src/transcription/ui/static/theme.css b/src/transcription/ui/static/theme.css new file mode 100644 index 0000000..3c9398c --- /dev/null +++ b/src/transcription/ui/static/theme.css @@ -0,0 +1,105 @@ +:root { + --palette-carbon-black: #1c2321; + --palette-cool-steel: #7d98a1; + --palette-blue-slate: #5e6572; + --palette-powder-blue: #a9b4c2; + --palette-platinum: #eef1ef; + + --theme-text: var(--palette-carbon-black); + --theme-text-muted: var(--palette-blue-slate); + --theme-page: var(--palette-platinum); + --theme-surface: color-mix(in srgb, var(--palette-platinum) 88%, var(--palette-powder-blue)); + --theme-surface-raised: var(--palette-platinum); + --theme-surface-muted: color-mix(in srgb, var(--palette-platinum) 68%, var(--palette-powder-blue)); + --theme-border: var(--palette-powder-blue); + --theme-primary: var(--palette-blue-slate); + --theme-primary-hover: var(--palette-carbon-black); + --theme-secondary: var(--palette-cool-steel); + --theme-focus: var(--palette-cool-steel); + --theme-inverse-text: var(--palette-platinum); + --theme-viewer: var(--palette-carbon-black); + --theme-viewer-border: var(--palette-blue-slate); + --theme-viewer-muted: var(--palette-powder-blue); + --theme-shadow: 0 10px 28px color-mix(in srgb, var(--palette-carbon-black) 14%, transparent); + + --q-primary: var(--palette-blue-slate); + --q-secondary: var(--palette-cool-steel); + --q-accent: var(--palette-powder-blue); + --q-dark: var(--palette-carbon-black); + --q-dark-page: var(--palette-carbon-black); + --q-positive: var(--palette-cool-steel); + --q-negative: var(--palette-carbon-black); + --q-info: var(--palette-cool-steel); + --q-warning: var(--palette-powder-blue); +} + +body, +.q-layout, +.q-page-container { + color: var(--theme-text); + background: var(--theme-page); +} + +body { + 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, +a:focus-visible, +textarea:focus-visible, +input:focus-visible, +[tabindex="0"]:focus-visible { + outline: 3px solid var(--theme-focus); + outline-offset: 2px; +} \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py index b9721a1..f5f3da3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -27,7 +27,7 @@ class TestAppLifespan: """Startup initializes logging, schema, directories, and worker resources.""" calls = [] - monkeypatch.setattr("transcription.app.configure_logging", lambda: calls.append("logging")) + monkeypatch.setattr("transcription.app.configure_logging", lambda _settings: calls.append("logging")) async def _create_all(**_kwargs): calls.append("schema") @@ -80,7 +80,7 @@ class TestAppLifespan: """Shutdown signals and stops worker resources cleanly.""" calls = [] - monkeypatch.setattr("transcription.app.configure_logging", lambda: calls.append("logging")) + monkeypatch.setattr("transcription.app.configure_logging", lambda _settings: calls.append("logging")) async def _create_all(**_kwargs): calls.append("schema") diff --git a/tests/test_config.py b/tests/test_config.py index de2f7a3..cf050b8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,6 +7,7 @@ from pydantic import ValidationError from transcription.config import Provider from transcription.config import Settings +from transcription.config import parse_cli_settings def _make_settings(**overrides) -> Settings: @@ -31,6 +32,30 @@ class TestSettingsLoading: with pytest.raises(ValidationError): Settings(_env_file=None) + def test_ignores_process_cli_arguments(self, monkeypatch): + """Ordinary settings construction does not consume tooling arguments.""" + monkeypatch.setattr("sys.argv", ["pytest", "--rootdir=/tmp/project"]) + + settings = _make_settings() + + assert settings.port == 8000 + + def test_explicit_cli_parser_reads_arguments(self): + """The executable settings boundary accepts application CLI flags.""" + settings = parse_cli_settings( + [ + "--openrouter-api-key", + "test-key", + "--port", + "8123", + "--reload", + ] + ) + + assert settings.openrouter_api_key == "test-key" + assert settings.port == 8123 + assert settings.reload is True + class TestProviderSettings: """Verify provider enum defaults and validation.""" diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..f2bb68c --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,58 @@ +"""Tests for the executable application entry point.""" + +from types import SimpleNamespace + +import pytest + +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.""" + 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", + lambda app, **kwargs: captured.update(application=app, **kwargs), + ) + + entrypoint.main() + + assert captured == { + "application": application, + "factory": False, + "host": "127.0.0.1", + "port": 8123, + "log_level": "debug", + "reload": False, + } + + +@pytest.mark.unit +def test_main_uses_cli_factory_for_reload(monkeypatch): + """Reload execution gives Uvicorn an importable CLI-aware factory.""" + settings = SimpleNamespace(host="127.0.0.1", port=8123, log_level="info", reload=True) + captured = {} + monkeypatch.setattr(entrypoint, "parse_cli_settings", lambda: settings) + monkeypatch.setattr( + entrypoint.uvicorn, + "run", + lambda app, **kwargs: captured.update(application=app, **kwargs), + ) + + entrypoint.main() + + assert captured["application"] == "transcription.__main__:create_cli_app" + assert captured["factory"] is True + assert captured["reload"] is True diff --git a/tests/test_ui_theme.py b/tests/test_ui_theme.py new file mode 100644 index 0000000..5003d2f --- /dev/null +++ b/tests/test_ui_theme.py @@ -0,0 +1,38 @@ +"""Tests for global UI theme registration.""" + +import re + +import pytest +from fastapi import FastAPI + +from transcription.ui import register_pages +from transcription.ui.resources import read_css + + +@pytest.mark.unit +def test_page_registration_uses_vibescribe_theme(monkeypatch): + """Global UI registration loads the standalone VibeScribe theme in light mode.""" + registered_css: list[str] = [] + run_options: dict[str, object] = {} + + monkeypatch.setattr("transcription.ui.ui.add_css", lambda css, **_kwargs: registered_css.append(css)) + monkeypatch.setattr("transcription.ui.register_upload_page", lambda: None) + monkeypatch.setattr("transcription.ui.register_jobs_page", lambda: None) + monkeypatch.setattr( + "transcription.ui.ui.run_with", + lambda _app, **options: run_options.update(options), + ) + + register_pages(FastAPI()) + + theme_css = read_css("theme.css") + assert registered_css == [theme_css] + assert set(re.findall(r"#[0-9a-fA-F]{6}", theme_css)) == { + "#1c2321", + "#7d98a1", + "#5e6572", + "#a9b4c2", + "#eef1ef", + } + assert "--q-primary" in theme_css + assert run_options["dark"] is False diff --git a/tests/ui/test_upload_page.py b/tests/ui/test_upload_page.py index ec65558..0ab2dfa 100644 --- a/tests/ui/test_upload_page.py +++ b/tests/ui/test_upload_page.py @@ -29,6 +29,7 @@ class TestPageRendering: response = client.get("/ui/upload") assert response.status_code == 200 + assert "VibeScribe" in response.text assert "Upload Document" in response.text assert "Select document file" in response.text assert "Upload" in response.text