WIP theming

This commit is contained in:
John Lancaster
2026-07-31 19:34:12 -05:00
parent 209c48987c
commit d0a3ca0289
22 changed files with 695 additions and 112 deletions
+1 -2
View File
@@ -34,9 +34,8 @@ logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def _lifespan(app: FastAPI): async def _lifespan(app: FastAPI):
configure_logging()
settings = getattr(app.state, "settings", None) or get_settings() settings = getattr(app.state, "settings", None) or get_settings()
configure_logging(settings)
app.state.settings = settings app.state.settings = settings
app.state.services = ServiceBundle() app.state.services = ServiceBundle()
app.state.runtime = initialize_database_runtime(settings=settings) app.state.runtime = initialize_database_runtime(settings=settings)
+3 -21
View File
@@ -1,38 +1,20 @@
"""UI page registration exports.""" """UI page registration exports."""
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from nicegui import app as nicegui_app
from nicegui import ui from nicegui import ui
from transcription.ui.pages.jobs_page import register_page as register_jobs_page 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.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_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: def _register_global_styles(app: FastAPI) -> None:
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False): if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
return return
nicegui_app.colors(**_THEME_COLORS) ui.add_css(read_css("theme.css"), shared=True)
css_path = Path(__file__).resolve().parent / "static" / "colors.css"
if css_path.exists():
ui.add_css(css_path, shared=True)
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True) setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
@@ -42,4 +24,4 @@ def register_pages(app: FastAPI) -> None:
_register_global_styles(app) _register_global_styles(app)
register_upload_page() register_upload_page()
register_jobs_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)
+11 -1
View File
@@ -1,7 +1,17 @@
"""Reusable UI component exports.""" """Reusable UI component exports."""
from transcription.ui.components.app_shell import NAV_ITEMS 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.app_shell import render_navigation_header
from transcription.ui.components.document_panzoom import render_document_panzoom 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",
]
+26 -23
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from nicegui import ui from nicegui import ui
from transcription.ui.resources import read_css
NAV_ITEMS: tuple[tuple[str, str, str], ...] = ( NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
("Upload", "/upload", "upload_file"), ("Upload", "/upload", "upload_file"),
("Jobs", "/jobs", "work_history"), ("Jobs", "/jobs", "work_history"),
@@ -16,27 +18,17 @@ def _is_active_path(*, current_path: str, item_path: str) -> bool:
return current_path == item_path 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: 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) 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, label,
icon=icon, icon=icon,
on_click=lambda _=None, route=path: ui.navigate.to(route), on_click=lambda _=None, route=path: ui.navigate.to(route),
) ).props("flat no-caps").classes(classes)
button.props(_button_props(icon=icon, is_active=is_active)).classes(_button_classes(is_active=is_active))
def _normalize_path(current_path: str | None) -> str: def _normalize_path(current_path: str | None) -> str:
@@ -46,14 +38,25 @@ def _normalize_path(current_path: str | None) -> str:
return normalized.rstrip("/") or "/" return normalized.rstrip("/") or "/"
def render_navigation_header(*, current_path: str | None = None) -> None: def render_app_shell(*, current_path: str | None = None) -> None:
"""Render a shared app header with links for top-level pages.""" """Render the shared application shell header."""
ui.add_css(read_css("components/app_shell.css"))
normalized_path = _normalize_path(current_path) normalized_path = _normalize_path(current_path)
with ( with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"):
ui.header(elevated=True).props("bordered").classes("bg-dark text-white q-px-sm q-py-xs"), with ui.row().classes("app-shell__brand no-wrap"):
ui.row().classes("w-full items-center justify-end q-gutter-xs"), ui.label("VS").classes("app-shell__brand-mark")
ui.element("div").classes("w-full grid grid-cols-2 gap-2 sm:flex sm:justify-start sm:gap-2"), 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: for label, path, icon in NAV_ITEMS:
_render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path) _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)
@@ -24,10 +24,10 @@ def render_document_panzoom(*, source: Source) -> None:
document_url = _document_url(source) document_url = _document_url(source)
document_kind = _document_kind(source) document_kind = _document_kind(source)
with ui.card().classes("w-full q-pa-md"): with ui.card().classes("w-full q-pa-md vibe-card"):
with ui.row().classes("w-full items-center justify-between no-wrap"): with ui.row().classes("w-full items-center justify-between no-wrap"):
ui.label("Document preview").classes("text-subtitle1 text-weight-medium") ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
ui.label(source.filename).classes("text-caption text-grey-4 ellipsis").style( ui.label(source.filename).classes("text-caption vibe-text-muted ellipsis").style(
"max-width: 60%; text-align: right;" "max-width: 60%; text-align: right;"
) )
@@ -90,7 +90,7 @@ def _register_panzoom_assets() -> None:
height: 100%; height: 100%;
border: 0; border: 0;
pointer-events: none; pointer-events: none;
background: white; background: var(--theme-surface-raised);
} }
</style> </style>
""", """,
@@ -26,7 +26,7 @@ def show_error(exc: Exception, *, title: str, operation: str) -> None:
close_button="Dismiss", 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(title).classes("text-subtitle1")
ui.label(error.message) ui.label(error.message)
ui.label(f"Suggested action: {error.suggestion}").classes("text-weight-medium") ui.label(f"Suggested action: {error.suggestion}").classes("text-weight-medium")
+14 -14
View File
@@ -18,24 +18,24 @@ logger = logging.getLogger(__name__)
def _status_chip_classes(status: str) -> str: def _status_chip_classes(status: str) -> str:
if status == "queued": if status == "queued":
return "bg-blue-1 text-blue-10" return "vibe-status--queued"
if status == "processing": if status == "processing":
return "bg-amber-1 text-amber-10" return "vibe-status--processing"
if status == "transcribed": if status == "transcribed":
return "bg-green-1 text-green-10" return "vibe-status--transcribed"
if status == "failed": if status == "failed":
return "bg-red-1 text-red-10" return "vibe-status--failed"
return "bg-grey-2 text-grey-9" return "vibe-status--default"
def _metadata_row(label: str, value: str) -> None: def _metadata_row(label: str, value: str) -> None:
with ui.row().classes("w-full items-start justify-between q-gutter-x-md"): 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(label).classes("text-caption vibe-text-muted text-uppercase w-28")
ui.label(value).classes("text-body2 text-right text-grey-1 break-all") ui.label(value).classes("text-body2 text-right break-all")
def _render_source_section(source: Source) -> None: 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.label("Source").classes("text-subtitle1 text-weight-medium")
ui.separator().classes("q-my-sm") ui.separator().classes("q-my-sm")
with ui.column().classes("w-full q-gutter-y-xs"): 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: 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.label("Revision").classes("text-subtitle1 text-weight-medium")
ui.separator().classes("q-my-sm") ui.separator().classes("q-my-sm")
if revision is None: 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 return
render_revision_row(revision=revision, initially_expanded=True) 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) logger.debug("Rendering job detail for job ID %s", job.id)
status_text = job.status.value status_text = job.status.value
with ui.column().classes("w-full max-w-4xl q-gutter-md"): 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.row().classes("w-full items-center justify-between q-gutter-md"):
with ui.column().classes("q-gutter-none"): with ui.column().classes("q-gutter-none"):
ui.label("Job overview").classes("text-h6 text-weight-bold") 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 = ( status_chip_classes = (
"q-px-sm q-py-xs rounded-borders " "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)}" f"{_status_chip_classes(status_text)}"
) )
ui.label(status_text).classes(status_chip_classes) 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"): with ui.column().classes("w-full q-gutter-y-xs"):
_metadata_row("Created", job.date_created.isoformat()) _metadata_row("Created", job.date_created.isoformat())
_metadata_row("Updated", job.date_updated.isoformat()) _metadata_row("Updated", job.date_updated.isoformat())
@@ -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")
@@ -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")
@@ -23,10 +23,10 @@ def render_original_transcription_card(*, job: Job, classes: str = "w-full") ->
model = job.model or "unknown" model = job.model or "unknown"
caption = f"{provider} | {model} | {_format_created_at(job.date_updated)}" caption = f"{provider} | {model} | {_format_created_at(job.date_updated)}"
card = ui.card().classes(f"{classes} q-pa-md 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"): with card, ui.column().classes("w-full q-gutter-y-sm"):
ui.label(header).classes("text-subtitle1 text-weight-medium") ui.label(header).classes("text-subtitle1 text-weight-medium")
ui.label(caption).classes("text-caption 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="Prompt", value=job.prompt_name or "unknown")
_metadata_row(label="Updated", value=_format_created_at(job.date_updated)) _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) ui.markdown(job.text)
if job.error_detail: 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("Failure detail").classes("text-caption text-uppercase")
ui.label(job.error_detail).classes("text-body2") ui.label(job.error_detail).classes("text-body2")
@@ -53,15 +53,13 @@ def render_revision_row(
header = "Revision | User-authored" header = "Revision | User-authored"
caption = _format_created_at(revision.date_created) caption = _format_created_at(revision.date_created)
expansion = ui.expansion(value=initially_expanded, group="group").classes( expansion = ui.expansion(value=initially_expanded, group="group").classes(f"{classes} rounded-borders vibe-card")
f"{classes} rounded-borders bg-blue-grey-10"
)
with expansion, ui.column().classes("w-full q-gutter-y-sm q-pa-sm"): with expansion, ui.column().classes("w-full q-gutter-y-sm q-pa-sm"):
with expansion.add_slot("header"), ui.row().classes("w-full items-start justify-between q-gutter-md"): with expansion.add_slot("header"), ui.row().classes("w-full items-start justify-between q-gutter-md"):
with ui.column().classes("q-gutter-none"): with ui.column().classes("q-gutter-none"):
ui.label(header).classes("text-subtitle1 text-weight-medium") ui.label(header).classes("text-subtitle1 text-weight-medium")
ui.label(caption).classes("text-caption text-grey-5") ui.label(caption).classes("text-caption vibe-text-muted")
if on_delete is not None: if on_delete is not None:
with ui.dialog() as delete_dialog, ui.card().classes("q-pa-md"): with ui.dialog() as delete_dialog, ui.card().classes("q-pa-md"):
@@ -102,5 +100,5 @@ def _format_created_at(value: datetime) -> str:
def _metadata_row(*, label: str, value: str) -> None: def _metadata_row(*, label: str, value: str) -> None:
with ui.row().classes("w-md items-start justify-between q-gutter-x-md"): with ui.row().classes("w-md items-start justify-between q-gutter-x-md"):
ui.label(label).classes("text-caption text-grey-5 text-uppercase") ui.label(label).classes("text-caption vibe-text-muted text-uppercase")
ui.label(value).classes("text-body2 text-right text-grey-1 break-all") ui.label(value).classes("text-body2 text-right break-all")
+4 -4
View File
@@ -73,7 +73,7 @@ def register_page() -> None: # noqa: PLR0915
if source is not None: if source is not None:
render_document_panzoom(source=source) render_document_panzoom(source=source)
else: 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 splitter.after, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
with ui.row(): with ui.row():
ui.button(icon="arrow_back", on_click=ui.navigate.back) 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") ui.label(f"{job.id}").classes("text-h6 text-weight-bold")
match job.status: match job.status:
case JobStatus.TRANSCRIBED: 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 _: case _:
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium") 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_job = await jobs_service.read_job(job_id=parsed_job_id)
refreshed_source = _resolve_primary_source(refreshed_job) refreshed_source = _resolve_primary_source(refreshed_job)
if refreshed_source is None: 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 return
current_revision = refreshed_source.revision current_revision = refreshed_source.revision
@@ -141,7 +141,7 @@ def register_page() -> None: # noqa: PLR0915
).props('unelevated color="primary"') ).props('unelevated color="primary"')
if current_revision is None: 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 return
render_revision_row( render_revision_row(
+19
View File
@@ -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")
-30
View File
@@ -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;
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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%;
}
}
+105
View File
@@ -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;
}
+2 -2
View File
@@ -27,7 +27,7 @@ class TestAppLifespan:
"""Startup initializes logging, schema, directories, and worker resources.""" """Startup initializes logging, schema, directories, and worker resources."""
calls = [] 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): async def _create_all(**_kwargs):
calls.append("schema") calls.append("schema")
@@ -80,7 +80,7 @@ class TestAppLifespan:
"""Shutdown signals and stops worker resources cleanly.""" """Shutdown signals and stops worker resources cleanly."""
calls = [] 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): async def _create_all(**_kwargs):
calls.append("schema") calls.append("schema")
+25
View File
@@ -7,6 +7,7 @@ from pydantic import ValidationError
from transcription.config import Provider from transcription.config import Provider
from transcription.config import Settings from transcription.config import Settings
from transcription.config import parse_cli_settings
def _make_settings(**overrides) -> Settings: def _make_settings(**overrides) -> Settings:
@@ -31,6 +32,30 @@ class TestSettingsLoading:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
Settings(_env_file=None) 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: class TestProviderSettings:
"""Verify provider enum defaults and validation.""" """Verify provider enum defaults and validation."""
+58
View File
@@ -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
+38
View File
@@ -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
+1
View File
@@ -29,6 +29,7 @@ class TestPageRendering:
response = client.get("/ui/upload") response = client.get("/ui/upload")
assert response.status_code == 200 assert response.status_code == 200
assert "VibeScribe" in response.text
assert "Upload Document" in response.text assert "Upload Document" in response.text
assert "Select document file" in response.text assert "Select document file" in response.text
assert "Upload" in response.text assert "Upload" in response.text