generated from john/python-template
WIP theming
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user