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
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)
+3 -21
View File
@@ -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)
+11 -1
View File
@@ -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",
]
+28 -25
View File
@@ -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)
@@ -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);
}
</style>
""",
@@ -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")
+14 -14
View File
@@ -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())
@@ -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"
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")
+4 -4
View File
@@ -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(
+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."""
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")
+25
View File
@@ -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."""
+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")
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