UI style refresh continued

This commit is contained in:
Jim Lancaster
2026-08-03 15:15:58 -05:00
parent 4f6e1fd913
commit 752346025b
30 changed files with 328 additions and 651 deletions
+2 -1
View File
@@ -161,7 +161,8 @@ class JobService(ServiceBase):
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
)
.where(Job.status == JobStatus.QUEUED)
.order_by(Job.date_created) # pyright: ignore[reportArgumentType]
# Break ties by id so "next" is stable when two rows share close timestamps.
.order_by(Job.date_created, Job.id) # pyright: ignore[reportArgumentType]
)
return (await _session.exec(query)).first()
+1
View File
@@ -161,6 +161,7 @@ async def process_next_queued_job(
) -> bool:
"""Process the next queued job if one exists."""
job = await services.jobs.read_next_queued_job(session=session)
if job is None:
return False
@@ -4,14 +4,10 @@ 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_app_shell",
"render_document_panzoom",
"render_navigation_header",
"render_page_content",
"render_page_header",
]
+4 -4
View File
@@ -1,15 +1,15 @@
# transcription/ui/components/cards.py
from contextlib import contextmanager
from nicegui import ui
@contextmanager
def archival_card(title: str | None = None, extra_classes: str = ""):
"""Reusable container for Flat 2.0 Bento Grid cards."""
with ui.card().classes(
f"w-full bg-[#F4F0E6] border border-[#6B6A65]/30 rounded-sm p-4 {extra_classes}"
) as card:
with ui.card().classes(f"w-full ui-card-surface p-4 {extra_classes}") as card:
if title:
ui.label(title.upper()).classes(
"text-xs font-bold text-[#6B6A65] tracking-wider mb-3 border-b border-[#6B6A65]/20 pb-1"
"text-xs font-bold ui-text-muted tracking-wider mb-3 ui-header-divider pb-1"
)
yield card
@@ -1,12 +1,14 @@
# transcription/ui/components/data_display.py
from nicegui import ui
def metadata_row(label: str, value: str):
"""Render a high-density, low-contrast key-value pair."""
with ui.row().classes("justify-between w-full border-b border-[#6B6A65]/10 pb-1 text-xs"):
ui.label(label).classes("text-[#6B6A65]")
ui.label(value).classes("font-semibold text-[#333333]")
with ui.row().classes("justify-between w-full border-b ui-border-subtle pb-1 text-xs"):
ui.label(label).classes("ui-text-muted")
ui.label(value).classes("font-semibold ui-text-primary")
def archival_badge(text: str):
"""Standardized Aged Sepia badge."""
return ui.badge(text, color="#E2C7A8", text_color="#333333").classes("text-[10px]")
return ui.badge(text, color="secondary", text_color="dark").classes("text-[10px]")
@@ -1,90 +0,0 @@
"""Reusable job detail rendering helpers."""
from __future__ import annotations
import logging
from nicegui import ui
from transcription.db.models import Job
from transcription.db.models import Source
from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.transcript import render_original_transcription_card
from transcription.ui.components.transcript import render_revision_row
logger = logging.getLogger(__name__)
def _status_chip_classes(status: str) -> str:
if status == "queued":
return "vibe-status--queued"
if status == "processing":
return "vibe-status--processing"
if status == "transcribed":
return "vibe-status--transcribed"
if status == "failed":
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 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 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"):
_metadata_row("Upload name", source.upload_name)
_metadata_row("Stored filename", source.filename)
_metadata_row("File path", source.file_path)
_metadata_row("Uploaded", source.date_uploaded.isoformat())
ui.separator().classes("q-my-md")
render_document_panzoom(source=source)
def _render_revision_section(revision: Source | None) -> None:
with ui.card().classes("w-full q-pa-md vibe-card"):
ui.label("Source revision").classes("text-subtitle1 text-weight-medium")
ui.separator().classes("q-my-sm")
if revision is None:
ui.label("No source revision exists for this source.").classes("text-body2 vibe-text-muted")
return
render_revision_row(revision=revision, initially_expanded=True)
def render_job_detail(*, job: Job, source: Source | None, revision: Source | None) -> None:
"""Render all sections for the job detail page."""
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 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 vibe-text-muted")
status_chip_classes = (
"q-px-sm q-py-xs rounded-borders "
"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 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())
_metadata_row("Retries", str(job.retry_count))
render_original_transcription_card(job=job)
if source is not None:
_render_source_section(source)
_render_revision_section(revision)
@@ -1,55 +0,0 @@
"""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-reviewed text").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")
@@ -1,36 +0,0 @@
"""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")
@@ -57,8 +57,7 @@ def build_table(
pagination["sortBy"] = default_sort_by
pagination["descending"] = default_descending
# Quasar props to enforce flat, archival styling
# Styling table headers with Library Green (#2D5A4C) and rows with subtle borders
# Quasar props enforce behavior; visual styling is centralized in theme.css.
table = (
ui.table(
rows=rows,
@@ -66,28 +65,13 @@ def build_table(
row_key="id",
pagination=pagination,
)
.classes(
f"w-full bg-[#F4F0E6] border border-[#6B6A65]/30 rounded-sm {classes}"
)
.classes(f"w-full ui-table {classes}")
.props(
'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" '
'header-cell-class="bg-[#2D5A4C] text-white font-bold text-xs uppercase tracking-wider" '
'table-class="text-xs text-[#333333]"'
'header-cell-class="ui-table-header text-xs uppercase tracking-wider" '
'table-class="ui-table-body text-xs"'
)
)
# Custom CSS rules for row hover effects matching Archival Cream
ui.add_head_html("""
<style>
.q-table tbody tr:hover {
background-color: #FAF9F6 !important;
cursor: pointer;
}
.q-table td {
border-bottom: 1px solid rgba(107, 106, 101, 0.2) !important;
}
</style>
""")
logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
if on_row_click_id is not None:
@@ -41,7 +41,7 @@ def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
"""Render documents table and open detail page when clicking a row."""
if not rows:
with archival_card(extra_classes="p-8 text-center"):
ui.label("No documents in repository yet.").classes("text-xs text-[#6B6A65]")
ui.label("No documents in repository yet.").classes("text-xs ui-text-muted")
return
build_table(
@@ -57,14 +57,14 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"""Render jobs table and open a detail page when clicking a row."""
if not rows:
with archival_card(extra_classes="p-8 text-center"):
ui.label("No active or historical processing jobs found.").classes("text-xs text-[#6B6A65]")
ui.label("No active or historical processing jobs found.").classes("text-xs ui-text-muted")
return
build_table(
rows=_serialize_rows(rows),
columns=[
{"name": "id", "label": "Job ID", "field": "id", "sortable": True, "classes": "font-mono"},
{"name": "status", "label": "Status", "field": "status", "sortable": True, "classes": "font-semibold text-[#2D5A4C]"},
{"name": "status", "label": "Status", "field": "status", "sortable": True, "classes": "font-semibold ui-link-primary"},
{"name": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
{"name": "date_created", "label": "Created", "field": "date_created", "sortable": True},
@@ -41,7 +41,7 @@ def render_people_table(rows: Sequence[PersonTableRow]) -> None:
"""Render people table and open detail page when clicking a row."""
if not rows:
with archival_card(extra_classes="p-8 text-center"):
ui.label("No person records found in repository.").classes("text-xs text-[#6B6A65]")
ui.label("No person records found in repository.").classes("text-xs ui-text-muted")
return
build_table(
@@ -41,7 +41,7 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
"""Render sources table and open detail page when clicking a row."""
if not rows:
with archival_card(extra_classes="p-8 text-center"):
ui.label("No source file records found.").classes("text-xs text-[#6B6A65]")
ui.label("No source file records found.").classes("text-xs ui-text-muted")
return
build_table(
@@ -1,17 +0,0 @@
"""Typography helper components for Archival and Academic layouts."""
from nicegui import ui
# System-wide typography styles matching the UI Design Specification
STYLE_SERIF_HEADER = "font-family: 'Georgia', 'Times New Roman', serif;"
STYLE_SANS_BODY = "font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;"
def page_header(title: str, subtitle: str | None = None) -> None:
"""Render a standardized page title header using the Archival Serif font."""
with ui.column().classes("gap-0 pb-2 border-b border-[#6B6A65]/30 w-full"):
ui.label(title).style(
f"{STYLE_SERIF_HEADER} font-size: 1.75rem; font-weight: 700; color: #333333;"
)
if subtitle:
ui.label(subtitle).classes("text-xs text-[#6B6A65]")
-66
View File
@@ -1,66 +0,0 @@
"""Reusable upload widget for document submission."""
from __future__ import annotations
from collections.abc import Awaitable
from collections.abc import Callable
from nicegui import ui
from nicegui.binding import bindable_dataclass
from nicegui.events import UploadEventArguments
from transcription.errors import AppError
from transcription.services.documents import UploadJobResult
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.error_presenter import summarize_error
from transcription.worker import WorkerNotifier
type UploadSubmitter = Callable[[str, bytes], Awaitable[UploadJobResult]]
@bindable_dataclass
class UploadWidgetState:
"""Simple state container for upload feedback."""
loading: bool = False
message: str = ""
def render_upload_widget(*, submitter: UploadSubmitter, notifier: WorkerNotifier | None = None) -> None:
"""Render upload controls and common status/error handling."""
state = UploadWidgetState()
status_label = ui.label("Upload a document to start transcription.")
status_label.bind_text(state, "message")
async def on_upload(event: UploadEventArguments) -> None:
if state.loading:
ui.notify("Upload already in progress. Please wait.", type="warning")
return
state.loading = True
status_label.text = "Uploading..."
try:
payload = await event.file.read()
result = await submitter(event.file.name, payload)
job_id = result.job_id
state.message = f"Created job {job_id}" if job_id is not None else "Upload complete"
status_label.text = state.message
if notifier is not None:
notifier.notify()
ui.notify(state.message, type="positive")
except AppError as exc:
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
except Exception as exc: # noqa: BLE001
state.message = summarize_error(exc, operation="upload.submit")
status_label.text = f"Upload failed: {state.message}"
show_error(exc, title="Upload failed", operation="upload.submit")
finally:
state.loading = False
ui.upload(
on_upload=on_upload,
auto_upload=True,
label="Select document file",
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf"')
+7 -9
View File
@@ -10,19 +10,17 @@ def dark_room_viewer(
container_height: str = "500px",
) -> None:
"""Isolated high-contrast container for image inspection with pan and zoom capabilities."""
with ui.card().classes(
"bg-[#2B2D2C] border border-[#333333] rounded-sm p-3 flex flex-col justify-between w-full"
):
with ui.card().classes("ui-bg-viewer ui-border-viewer rounded-sm p-3 flex flex-col justify-between w-full"):
# Viewer Header Bar
with ui.row().classes("w-full justify-between items-center mb-2 text-[#FAF9F6] text-xs"):
with ui.row().classes("w-full justify-between items-center mb-2 ui-text-inverse text-xs"):
ui.label("SOURCE MEDIA VIEWER").classes("font-mono font-bold tracking-wider")
ui.label(count_label).classes("text-[#E2C7A8]")
ui.label(count_label).classes("ui-text-accent")
# Interactive Pan/Zoom Canvas Area
if image_path:
# Container with fixed height and hidden overflow for contained panning/zooming
with ui.element("div").classes(
"relative w-full overflow-hidden border border-[#333333] bg-black/50 rounded-sm flex items-center justify-center cursor-grab active:cursor-grabbing"
"relative w-full overflow-hidden border ui-border-viewer ui-bg-viewer-overlay rounded-sm flex items-center justify-center cursor-grab active:cursor-grabbing"
).style(f"height: {container_height};") as viewport:
# Image element targeted by client-side pan/zoom JS
@@ -89,7 +87,7 @@ def dark_room_viewer(
ui.run_javascript(js_pan_zoom)
# Control Toolbar
with ui.row().classes("w-full justify-center items-center gap-2 mt-2 pt-2 border-t border-[#333333]"):
with ui.row().classes("w-full justify-center items-center gap-2 mt-2 pt-2 border-t ui-border-viewer"):
ui.button(
icon="zoom_in",
on_click=lambda: ui.run_javascript(f"window.zoomIn_{img.id}()"),
@@ -108,6 +106,6 @@ def dark_room_viewer(
else:
# Fallback state when no image source is linked
with ui.column().classes(
"w-full flex-grow items-center justify-center border border-[#333333] bg-black/40 rounded-sm p-8"
"w-full flex-grow items-center justify-center border ui-border-viewer ui-bg-viewer-overlay-soft rounded-sm p-8"
).style(f"min-height: {container_height};"):
ui.label("No source media available for inspection.").classes("text-[#6B6A65] text-xs italic")
ui.label("No source media available for inspection.").classes("ui-text-muted text-xs italic")
+30 -30
View File
@@ -23,9 +23,9 @@ from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.documents import DocumentTableRow
from transcription.ui.components.table.documents import render_documents_table
from transcription.ui.components.typography import page_header
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
@@ -72,7 +72,7 @@ def register_page() -> None:
.props("outlined bg-white")
.classes("w-full")
)
ui.link("Create new person", "/people/new").classes("text-xs text-[#2D5A4C] font-medium")
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
return_to = request.query_params.get("return_to")
@@ -137,7 +137,7 @@ def register_page() -> None:
ui.navigate.to(f"/documents/{created.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save document", on_click=submit_create, icon="save").classes("bg-[#2D5A4C] text-white")
ui.button("Save document", on_click=submit_create, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back").props("flat")
@ui.page("/documents")
@@ -147,13 +147,13 @@ def register_page() -> None:
render_navigation_header(current_path="/documents")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
with ui.row().classes("w-full items-center justify-between pb-2 ui-header-divider"):
page_header("Archival Documents")
ui.button(
"Create new document",
on_click=lambda: ui.navigate.to("/documents/new"),
icon="note_add",
).classes("bg-[#2D5A4C] text-white")
).classes("ui-btn-primary")
try:
documents = sorted(
@@ -211,7 +211,7 @@ def register_page() -> None:
# Main Bento Grid Wrapper
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
# Header Bar
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
with ui.row().classes("w-full justify-between items-center pb-2 ui-header-divider"):
page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
with ui.row().classes("items-center gap-2"):
@@ -219,7 +219,7 @@ def register_page() -> None:
"Edit Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
icon="edit",
).classes("bg-[#2D5A4C] text-white text-xs")
).classes("ui-btn-primary text-xs")
ui.button(
"Delete",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
@@ -237,12 +237,12 @@ def register_page() -> None:
"View All Sources",
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
icon="description",
).props("flat dense text-xs").classes("text-[#2D5A4C]")
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"+ Add Source",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("bg-[#2D5A4C] text-white text-xs")
).classes("ui-btn-primary text-xs")
# ZONE 2: Metadata & Archival Attributes (Cols 6-8)
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
@@ -254,45 +254,45 @@ def register_page() -> None:
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
with ui.column().classes("w-full mt-2"):
ui.label("Archival Notes:").classes("text-[#6B6A65] text-xs mb-1")
ui.label("Archival Notes:").classes("ui-text-muted text-xs mb-1")
ui.label(document.notes or "No notes added.").classes(
"p-2 bg-[#FAF9F6] border border-[#6B6A65]/20 rounded-sm italic text-xs text-[#333333]"
"p-2 ui-note-box text-xs"
)
with archival_card(title="System Logistics"):
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
# ZONE 3: Related Entities & Pipeline Jobs (Cols 9-12)
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
with archival_card(title="Related People"):
if not document.document_people:
ui.label("No linked people yet.").classes("text-xs text-[#6B6A65] italic")
ui.label("No linked people yet.").classes("text-xs ui-text-muted italic")
else:
with ui.column().classes("w-full gap-2"):
for link in document.document_people:
person_label = link.person.full_name if link.person is not None else "Unknown person"
with ui.row().classes(
"w-full justify-between items-center bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm"
"w-full justify-between items-center ui-row-surface p-2"
):
ui.label(person_label).classes("text-xs font-semibold text-[#333333]")
ui.label(person_label).classes("text-xs font-semibold ui-text-primary")
archival_badge(link.role.value)
with archival_card(title="Pipeline Jobs"):
with ui.row().classes("w-full justify-between items-center mb-2"):
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs text-[#2D5A4C] font-bold")
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs ui-link-primary font-bold")
with ui.row().classes("w-full gap-2 mt-2"):
ui.button(
"View Jobs",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"),
icon="work_history",
).props("flat dense text-xs").classes("text-[#2D5A4C]")
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"+ Add Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("bg-[#2D5A4C] text-white text-xs")
).classes("ui-btn-primary text-xs")
@ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
@@ -316,7 +316,7 @@ def register_page() -> None:
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
with ui.row().classes("w-full justify-between items-center pb-2 ui-header-divider"):
page_header(f"Jobs for {document.name}")
with ui.row().classes("gap-2"):
ui.button(
@@ -328,11 +328,11 @@ def register_page() -> None:
"Create Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).classes("bg-[#2D5A4C] text-white")
).classes("ui-btn-primary")
if not document.jobs:
with archival_card(extra_classes="p-6 text-center"):
ui.label("No transcription processing jobs created yet.").classes("text-xs text-[#6B6A65]")
ui.label("No transcription processing jobs created yet.").classes("text-xs ui-text-muted")
return
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
@@ -340,12 +340,12 @@ def register_page() -> None:
with ui.row().classes("w-full items-center justify-between"):
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value)
ui.label(f"Job ID: {job.id}").classes("text-xs font-mono text-[#333333]")
ui.label(f"Job ID: {job.id}").classes("text-xs font-mono ui-text-primary")
ui.button(
"Open Job",
on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"),
icon="open_in_new",
).props("flat dense").classes("text-xs text-[#2D5A4C]")
).props("flat dense").classes("text-xs ui-link-primary")
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
@@ -434,7 +434,7 @@ def register_page() -> None:
.props("outlined bg-white")
.classes("w-full")
)
ui.link("Create new person", "/people/new").classes("text-xs text-[#2D5A4C] font-medium")
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
async def submit_edit() -> None:
candidate_name = (name_input.value or "").strip()
@@ -505,7 +505,7 @@ def register_page() -> None:
ui.navigate.to(f"/documents/{document.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("bg-[#2D5A4C] text-white")
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
"flat"
)
@@ -535,7 +535,7 @@ def register_page() -> None:
page_header("Delete Document")
with archival_card(extra_classes="gap-2"):
ui.label(f"Document: {document.name}").classes("text-sm font-semibold text-[#333333]")
ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary")
has_sources = bool(document.sources)
has_jobs = bool(document.jobs)
@@ -547,15 +547,15 @@ def register_page() -> None:
categories.append("Sources")
if has_jobs:
categories.append("Jobs")
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-xs text-[#6B6A65]")
ui.label("Remove related records first, then retry deletion.").classes("text-xs text-[#6B6A65] italic")
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-xs ui-text-muted")
ui.label("Remove related records first, then retry deletion.").classes("text-xs ui-text-muted italic")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
icon="arrow_back",
).classes("bg-[#2D5A4C] text-white text-xs")
).classes("ui-btn-primary text-xs")
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs"
)
+17 -17
View File
@@ -20,8 +20,8 @@ from transcription.ui.components.data_display import archival_badge
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.jobs import render_jobs_table
from transcription.ui.components.typography import page_header
from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from transcription.worker import resolve_worker_notifier
from ...db.session import SessionFactoryDep
@@ -38,11 +38,11 @@ def register_page() -> None: # noqa: PLR0915
render_navigation_header(current_path="/jobs")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
with ui.row().classes("w-full items-center justify-between pb-2 ui-header-divider"):
page_header("Transcription Pipeline Jobs")
with ui.row().classes("items-center gap-2"):
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes(
"bg-[#2D5A4C] text-white"
"ui-btn-primary"
)
ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
@@ -83,7 +83,7 @@ def register_page() -> None: # noqa: PLR0915
"Create document",
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
icon="note_add",
).classes("bg-[#2D5A4C] text-white")
).classes("ui-btn-primary")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
return
@@ -106,12 +106,12 @@ def register_page() -> None: # noqa: PLR0915
with archival_card(title="Source Files"):
ui.label(
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
).classes("text-xs text-[#6B6A65] mb-2")
).classes("text-xs ui-text-muted mb-2")
@ui.refreshable
def render_upload_list() -> None:
if not uploaded_files:
ui.label("No files uploaded yet.").classes("text-xs text-[#6B6A65] italic")
ui.label("No files uploaded yet.").classes("text-xs ui-text-muted italic")
return
def remove_file(index: int) -> None:
@@ -133,9 +133,9 @@ def register_page() -> None: # noqa: PLR0915
with ui.column().classes("gap-1 w-full mt-2"):
for index, (filename, _) in ordered_uploads:
with ui.row().classes(
"w-full items-center justify-between bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm"
"w-full items-center justify-between ui-row-surface p-2"
):
ui.label(Path(filename).name).classes("text-xs font-mono text-[#333333]")
ui.label(Path(filename).name).classes("text-xs font-mono ui-text-primary")
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
"flat round dense color=negative text-xs"
)
@@ -194,7 +194,7 @@ def register_page() -> None: # noqa: PLR0915
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes(
"bg-[#2D5A4C] text-white"
"ui-btn-primary"
)
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
@@ -217,7 +217,7 @@ def register_page() -> None: # noqa: PLR0915
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
with ui.row().classes("w-full justify-between items-center pb-2 ui-header-divider"):
page_header(f"Job Record: {job.id}")
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value.upper())
@@ -236,18 +236,18 @@ def register_page() -> None: # noqa: PLR0915
metadata_row("Last Updated:", job.date_updated.isoformat())
with archival_card(title="Document Links"):
ui.label("Navigate to related archival records:").classes("text-xs text-[#6B6A65] mb-3")
ui.label("Navigate to related archival records:").classes("text-xs ui-text-muted mb-3")
with ui.column().classes("w-full gap-2"):
ui.button(
"View Linked Document",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
icon="description",
).classes("bg-[#2D5A4C] text-white text-xs w-full")
).classes("ui-btn-primary text-xs w-full")
ui.button(
"View Linked Sources",
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
icon="description",
).props("flat text-xs").classes("text-[#2D5A4C] w-full")
).props("flat text-xs").classes("ui-link-primary w-full")
@ui.page("/jobs/{job_id}/delete")
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
@@ -271,19 +271,19 @@ def register_page() -> None: # noqa: PLR0915
page_header("Delete Processing Job")
with archival_card(extra_classes="gap-2"):
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono text-[#333333]")
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
if job.status == JobStatus.PROCESSING:
ui.label("Delete is blocked while the job is processing.").classes(
"text-xs text-red-800 font-bold mt-2"
)
ui.label("Wait for processing to complete, then retry delete.").classes("text-xs text-[#6B6A65] italic")
ui.label("Wait for processing to complete, then retry delete.").classes("text-xs ui-text-muted italic")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Job",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"),
icon="arrow_back",
).classes("bg-[#2D5A4C] text-white text-xs")
).classes("ui-btn-primary text-xs")
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs"
)
@@ -291,7 +291,7 @@ def register_page() -> None: # noqa: PLR0915
ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
if job.job_sources:
ui.label("Related JobSource links will be removed as part of delete.").classes("text-xs text-[#6B6A65]")
ui.label("Related JobSource links will be removed as part of delete.").classes("text-xs ui-text-muted")
async def submit_delete() -> None:
try:
+21 -21
View File
@@ -23,9 +23,9 @@ from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.people import PersonTableRow, render_people_table
from transcription.ui.components.typography import page_header
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
@@ -70,7 +70,7 @@ def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Setti
auto_upload=True,
label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
ui.label("Portraits are stored under uploads/portraits/person.").classes("text-xs text-[#6B6A65]")
ui.label("Portraits are stored under uploads/portraits/person.").classes("text-xs ui-text-muted")
def _resolve_portrait_src(path: str | None) -> str | None:
@@ -106,13 +106,13 @@ def register_page() -> None: # noqa: PLR0915
render_navigation_header(current_path="/people")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
with ui.row().classes("w-full items-center justify-between pb-2 ui-header-divider"):
page_header("Archival Entities: People")
ui.button(
"Create new person",
on_click=lambda: ui.navigate.to("/people/new"),
icon="person_add",
).classes("bg-[#2D5A4C] text-white")
).classes("ui-btn-primary")
try:
people = sorted(
@@ -203,7 +203,7 @@ def register_page() -> None: # noqa: PLR0915
ui.navigate.to(f"/people/{created.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save person", on_click=submit_create, icon="save").classes("bg-[#2D5A4C] text-white")
ui.button("Save person", on_click=submit_create, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}")
@@ -230,7 +230,7 @@ def register_page() -> None: # noqa: PLR0915
portrait_src = _resolve_portrait_src(person.portrait_path)
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
with ui.row().classes("w-full justify-between items-center pb-2 ui-header-divider"):
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
with ui.row().classes("items-center gap-2"):
@@ -238,7 +238,7 @@ def register_page() -> None: # noqa: PLR0915
"Edit Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
icon="edit",
).classes("bg-[#2D5A4C] text-white text-xs")
).classes("ui-btn-primary text-xs")
ui.button(
"Delete",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
@@ -262,19 +262,19 @@ def register_page() -> None: # noqa: PLR0915
metadata_row("Death Place:", person.death_place or "Not set")
with archival_card(title="System Logistics"):
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] text-[#6B6A65]")
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biography"):
ui.label(person.biography or "No biography recorded.").classes(
"p-2 bg-[#FAF9F6] border border-[#6B6A65]/20 rounded-sm text-xs text-[#333333] italic w-full"
"p-2 ui-note-box text-xs w-full"
)
with archival_card(title="Linked Documents"):
if not person.document_people:
ui.label("No linked documents yet.").classes("text-xs text-[#6B6A65] italic")
ui.label("Link this person from a Document workflow.").classes("text-xs text-[#6B6A65]")
ui.label("No linked documents yet.").classes("text-xs ui-text-muted italic")
ui.label("Link this person from a Document workflow.").classes("text-xs ui-text-muted")
else:
with ui.column().classes("w-full gap-2"):
for link in person.document_people:
@@ -282,18 +282,18 @@ def register_page() -> None: # noqa: PLR0915
if document is None:
continue
with ui.row().classes(
"w-full justify-between items-center bg-[#FAF9F6] p-2 border border-[#6B6A65]/20 rounded-sm"
"w-full justify-between items-center ui-row-surface p-2"
):
with ui.column().classes("gap-0"):
ui.label(document.name).classes("text-xs font-semibold text-[#333333]")
ui.label(f"Role: {link.role.value}").classes("text-[10px] text-[#6B6A65]")
ui.label(document.name).classes("text-xs font-semibold ui-text-primary")
ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
ui.button(
"Open",
on_click=lambda _=None, doc_id=document.id: ui.navigate.to(
f"/documents/{doc_id}"
),
icon="open_in_new",
).props("flat dense text-xs").classes("text-[#2D5A4C]")
).props("flat dense text-xs").classes("ui-link-primary")
@ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
@@ -396,7 +396,7 @@ def register_page() -> None: # noqa: PLR0915
ui.navigate.to(f"/people/{person.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save changes", on_click=submit_edit, icon="save").classes("bg-[#2D5A4C] text-white")
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}/delete")
@@ -424,18 +424,18 @@ def register_page() -> None: # noqa: PLR0915
page_header("Delete Person Record")
with archival_card(extra_classes="gap-2"):
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold text-[#333333]")
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold ui-text-primary")
if person.document_people:
ui.label("Delete is blocked because linked documents exist.").classes("text-xs text-red-800 font-bold mt-2")
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs text-[#6B6A65]")
ui.label("Remove document links first, then retry deletion.").classes("text-xs text-[#6B6A65] italic")
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs ui-text-muted")
ui.label("Remove document links first, then retry deletion.").classes("text-xs ui-text-muted italic")
with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button(
"Back to Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
icon="arrow_back",
).classes("bg-[#2D5A4C] text-white text-xs")
).classes("ui-btn-primary text-xs")
ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description").props(
"flat text-xs"
)
+6 -6
View File
@@ -22,8 +22,8 @@ from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
from transcription.ui.components.typography import page_header
from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
@@ -76,7 +76,7 @@ def register_page() -> None:
return
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with ui.row().classes("w-full items-center justify-between pb-2 border-b border-[#6B6A65]/20"):
with ui.row().classes("w-full items-center justify-between pb-2 ui-header-divider"):
if document_name is not None:
header_title = f"Sources: {document_name}"
elif job_label is not None:
@@ -89,7 +89,7 @@ def register_page() -> None:
if back_path is not None:
back_label = "Back to Document" if document_id is not None else "Back to Job"
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
"bg-[#2D5A4C] text-white text-xs"
"ui-btn-primary text-xs"
)
# Format source records into read-model rows for the table renderer
@@ -129,7 +129,7 @@ def register_page() -> None:
back_path = _back_path_from_query(request.query_params)
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with ui.row().classes("w-full justify-between items-center pb-2 border-b border-[#6B6A65]/30"):
with ui.row().classes("w-full justify-between items-center pb-2 ui-header-divider"):
page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}")
if back_path is not None:
@@ -141,7 +141,7 @@ def register_page() -> None:
else "Back to Sources"
)
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes(
"bg-[#2D5A4C] text-white text-xs"
"ui-btn-primary text-xs"
)
else:
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
@@ -193,7 +193,7 @@ def register_page() -> None:
ui.navigate.to(request.url.path + _back_query(request.query_params))
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save Revision", on_click=save_revision, icon="save").classes("bg-[#2D5A4C] text-white text-xs")
ui.button("Save Revision", on_click=save_revision, icon="save").classes("ui-btn-primary text-xs")
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str) -> RedirectResponse:
@@ -1,138 +0,0 @@
.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;
}
}
@@ -1,60 +0,0 @@
.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%;
}
}
+131
View File
@@ -102,4 +102,135 @@ input:focus-visible,
[tabindex="0"]:focus-visible {
outline: 3px solid var(--theme-focus);
outline-offset: 2px;
}
/* Semantic utility classes for incremental migration away from inline hex styles. */
.ui-text-primary {
color: var(--theme-text);
}
.ui-text-muted {
color: var(--theme-text-muted);
}
.ui-text-inverse {
color: var(--theme-inverse-text);
}
.ui-bg-page {
background: var(--theme-page);
}
.ui-bg-surface {
background: var(--theme-surface);
}
.ui-bg-surface-raised {
background: var(--theme-surface-raised);
}
.ui-bg-surface-muted {
background: var(--theme-surface-muted);
}
.ui-bg-viewer {
background: var(--theme-viewer);
}
.ui-bg-viewer-overlay {
background: color-mix(in srgb, var(--theme-viewer) 50%, transparent);
}
.ui-bg-viewer-overlay-soft {
background: color-mix(in srgb, var(--theme-viewer) 40%, transparent);
}
.ui-border-subtle {
border-color: var(--theme-border);
}
.ui-border-viewer {
border-color: var(--theme-viewer-border);
}
.ui-header-divider {
border-bottom: 1px solid var(--theme-border);
}
.ui-card-surface {
border: 1px solid var(--theme-border);
color: var(--theme-text);
background: var(--theme-surface-raised);
border-radius: 0.125rem;
}
.ui-row-surface {
border: 1px solid var(--theme-border);
color: var(--theme-text);
background: var(--theme-surface);
border-radius: 0.125rem;
}
.ui-note-box {
border: 1px solid var(--theme-border);
color: var(--theme-text);
background: var(--theme-surface);
border-radius: 0.125rem;
font-style: italic;
}
.ui-btn-primary {
color: var(--theme-inverse-text);
background: var(--theme-primary);
}
.ui-btn-primary:hover {
background: var(--theme-primary-hover);
}
.ui-btn-secondary {
color: var(--theme-primary);
background: transparent;
}
.ui-btn-secondary:hover {
color: var(--theme-primary-hover);
}
.ui-link-primary {
color: var(--theme-primary);
}
.ui-link-primary:hover {
color: var(--theme-primary-hover);
}
.ui-text-accent {
color: var(--theme-secondary);
}
.ui-table {
border: 1px solid var(--theme-border);
color: var(--theme-text);
background: var(--theme-surface-raised);
border-radius: 0.125rem;
}
.ui-table .q-table tbody tr:hover {
background: var(--theme-surface) !important;
cursor: pointer;
}
.ui-table .q-table td {
border-bottom: 1px solid var(--theme-border) !important;
}
.ui-table-header {
color: var(--theme-inverse-text);
background: var(--theme-primary);
font-weight: 700;
}
.ui-table-body {
color: var(--theme-text);
}
+9 -5
View File
@@ -15,7 +15,6 @@ from transcription.config import Settings
from transcription.config import get_settings
from transcription.db.engine import get_database_url
from transcription.db.engine import get_engine
from transcription.db.operations import create_all
from transcription.db.session import dispose_session_factory
from transcription.db.session import get_session_factory
from transcription.db.session import session_scope
@@ -42,8 +41,15 @@ async def default_settings():
"""Provide default settings for tests."""
settings = get_settings(database_url="sqlite:///:memory:")
db_url = get_database_url(settings)
await create_all(engine=get_engine(database_url=db_url))
return settings
engine = get_engine(database_url=db_url)
# Cached in-memory engines persist across tests; reset schema per test for isolation.
async with engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.drop_all)
await connection.run_sync(SQLModel.metadata.create_all)
yield settings
await dispose_session_factory(db_url)
@pytest_asyncio.fixture
@@ -53,8 +59,6 @@ async def async_session(default_settings: Settings):
async with session_scope(database_url=db_url) as async_session:
yield async_session
await dispose_session_factory(db_url)
@pytest.fixture
def default_session_factory(default_settings: Settings):
+12 -3
View File
@@ -8,8 +8,9 @@ from transcription.config import Settings
from transcription.db.models import Job
from transcription.db.models import JobStatus
from transcription.providers.base import TranscriptionResult
from transcription.services import ServiceBundle
from transcription.services.store import create_upload_job
from transcription.worker import process_next_queued_job
from transcription.services.workflows import advance_job
@pytest.mark.integration
@@ -58,7 +59,11 @@ class TestPipelineSuccessFlow:
_fake_transcribe_document_image,
)
processed = await process_next_queued_job(session=async_session)
services = ServiceBundle()
queued_job = await services.jobs.read_next_queued_job(session=async_session)
processed = queued_job is not None
if queued_job is not None:
await advance_job(job=queued_job, services=services, session=async_session)
job = await async_session.get(Job, upload_result.job_id)
assert processed is True
@@ -98,7 +103,11 @@ class TestPipelineFailureFlow:
_fake_transcribe_document_image,
)
processed = await process_next_queued_job(session=async_session)
services = ServiceBundle()
queued_job = await services.jobs.read_next_queued_job(session=async_session)
processed = queued_job is not None
if queued_job is not None:
await advance_job(job=queued_job, services=services, session=async_session)
job = await async_session.get(Job, upload_result.job_id)
assert processed is True
+10 -2
View File
@@ -1,4 +1,7 @@
from uuid import uuid4
from datetime import UTC
from datetime import datetime
from datetime import timedelta
import pytest
@@ -100,8 +103,13 @@ class TestJobService:
document = Document(id=uuid4(), name="ordered-doc")
await document_service.create_document(document=document)
first = Job(document_id=document.id, status=JobStatus.QUEUED)
second = Job(document_id=document.id, status=JobStatus.QUEUED)
created_at = datetime.now(UTC)
first = Job(document_id=document.id, status=JobStatus.QUEUED, date_created=created_at)
second = Job(
document_id=document.id,
status=JobStatus.QUEUED,
date_created=created_at + timedelta(microseconds=1),
)
await job_service.create_job(job=first)
await job_service.create_job(job=second)
+30 -24
View File
@@ -30,8 +30,7 @@ class TestDocumentsPageRendering:
assert response.status_code == 200
assert "Documents" in response.text
assert "Create new document" in response.text
assert "No documents yet." in response.text
assert "Create your first document" in response.text
assert "No documents in repository yet." in response.text
def test_document_create_page_renders_fields(self, app_client):
"""GET /ui/documents/new renders document-create form fields."""
@@ -40,7 +39,7 @@ class TestDocumentsPageRendering:
response = client.get("/ui/documents/new")
assert response.status_code == 200
assert "Create document" in response.text
assert "Create Document" in response.text
assert "Document name is required." in response.text
assert "Document name" in response.text
assert "Document type" in response.text
@@ -69,7 +68,7 @@ class TestDocumentsPageRendering:
assert response.status_code == 200
assert "Seeded Document" in response.text
assert "Type: letter" in response.text
assert "letter" in response.text
def test_document_detail_page_renders_metadata_and_empty_related_sections(self, app_client):
"""GET /ui/documents/{document_id} shows metadata and related empty states."""
@@ -97,24 +96,30 @@ class TestDocumentsPageRendering:
assert response.status_code == 200
assert "Zenna Letter" in response.text
assert "Document type: letter" in response.text
assert "Author: not set" in response.text
assert "Exact date: 1885-07-13" in response.text
assert "Approximate date: c. 1885" in response.text
assert "Location created: Ohio" in response.text
assert "Archive identifier: BOX-1-FOLDER-2" in response.text
assert "Notes: Family archive" in response.text
assert "Created at (read-only):" in response.text
assert "Updated at (read-only):" in response.text
assert "Type: letter" in response.text
assert "Author:" in response.text
assert "Not set" in response.text
assert "Exact Date:" in response.text
assert "1885-07-13" in response.text
assert "Approx. Date:" in response.text
assert "c. 1885" in response.text
assert "Location Created:" in response.text
assert "Ohio" in response.text
assert "Archive Identifier:" in response.text
assert "BOX-1-FOLDER-2" in response.text
assert "Archival Notes:" in response.text
assert "Family archive" in response.text
assert "Created:" in response.text
assert "Updated:" in response.text
assert "No linked people yet." in response.text
assert "0 source(s) linked" in response.text
assert "0 job(s) linked" in response.text
assert "0 Source(s) Linked" in response.text
assert "0 Active Jobs" in response.text
assert "+ Add Source" in response.text
assert "+ Add Job" in response.text
assert "Sources" in response.text
assert "Jobs" in response.text
assert "Edit document" in response.text
assert "Delete document" in response.text
assert "Edit Document" in response.text
assert "Delete" in response.text
def test_document_detail_page_renders_related_people_sources_and_jobs(self, app_client):
"""GET /ui/documents/{document_id} shows related records when present."""
@@ -158,10 +163,11 @@ class TestDocumentsPageRendering:
response = client.get(f"/ui/documents/{document_id}")
assert response.status_code == 200
assert "Jane Doe (author)" in response.text
assert "Author: Jane Doe" in response.text
assert "1 source(s) linked" in response.text
assert "1 job(s) linked" in response.text
assert "Jane Doe" in response.text
assert "author" in response.text
assert "Author:" in response.text
assert "1 Source(s) Linked" in response.text
assert "1 Active Jobs" in response.text
def test_document_jobs_page_filters_to_document_context(self, app_client):
_, client = app_client
@@ -223,7 +229,7 @@ class TestDocumentsPageRendering:
response = client.get(f"/ui/sources?document_id={document_id}")
assert response.status_code == 200
assert "Sources for Target" in response.text
assert "Sources: Target" in response.text
assert "Back to Document" in response.text
assert "target_page.png" in response.text
assert "other_page.png" not in response.text
@@ -267,7 +273,7 @@ class TestDocumentsPageRendering:
response = client.get(f"/ui/documents/{document_id}/edit")
assert response.status_code == 200
assert "Edit document" in response.text
assert "Edit Document Record" in response.text
assert "Document name and document type are required." in response.text
assert "Document name" in response.text
assert "Document type" in response.text
@@ -298,7 +304,7 @@ class TestDocumentsPageRendering:
response = client.get(f"/ui/documents/{document_id}/delete")
assert response.status_code == 200
assert "Delete document" in response.text
assert "Delete Document" in response.text
assert "This action permanently deletes the document." in response.text
assert "Delete document permanently" in response.text
+6 -6
View File
@@ -21,7 +21,7 @@ class TestPageRendering:
assert response.status_code == 200
assert "Create job" in response.text
assert "No jobs yet." in response.text
assert "No active or historical processing jobs found." in response.text
def test_job_create_page_requires_existing_documents(self, app_client):
"""GET /ui/jobs/new shows guidance when no Documents exist."""
@@ -30,7 +30,7 @@ class TestPageRendering:
response = client.get("/ui/jobs/new")
assert response.status_code == 200
assert "Create job" in response.text
assert "Create Processing Job" in response.text
assert "No documents available. Create a Document before creating a Job." in response.text
assert "Create document" in response.text
@@ -48,7 +48,7 @@ class TestPageRendering:
response = client.get("/ui/jobs/new")
assert response.status_code == 200
assert "Create job" in response.text
assert "Create Processing Job" in response.text
assert "Seeded Document" in response.text
assert "Files are processed alphabetically by original filename." in response.text
assert "No files uploaded yet." in response.text
@@ -81,9 +81,9 @@ class TestPageRendering:
assert "Provider:" in response.text
assert "Model:" in response.text
assert "Prompt:" in response.text
assert "Retry count:" in response.text
assert "Last updated:" in response.text
assert "Document Links" in response.text
assert "Retry Count:" in response.text
assert "Last Updated:" in response.text
assert "document links" in response.text.lower()
assert "Sources" in response.text
assert "Jobs" in response.text
assert "Delete job" not in response.text
+19 -17
View File
@@ -25,7 +25,7 @@ class TestPeoplePageRendering:
assert response.status_code == 200
assert "People" in response.text
assert "Create new person" in response.text
assert "No people yet." in response.text
assert "No person records found in repository." in response.text
def test_people_page_lists_seeded_people(self, app_client):
_, client = app_client
@@ -41,7 +41,7 @@ class TestPeoplePageRendering:
assert response.status_code == 200
assert "Ada Lovelace" in response.text
assert "Display name: Ada" in response.text
assert "Ada" in response.text
def test_person_create_page_renders_fields(self, app_client):
_, client = app_client
@@ -49,7 +49,7 @@ class TestPeoplePageRendering:
response = client.get("/ui/people/new")
assert response.status_code == 200
assert "Create person" in response.text
assert "Create Person Record" in response.text
assert "Full name is required." in response.text
assert "Birth date (YYYY-MM-DD)" in response.text
assert "Death date (YYYY-MM-DD)" in response.text
@@ -85,15 +85,17 @@ class TestPeoplePageRendering:
assert response.status_code == 200
assert "Grace Hopper" in response.text
assert "Full name: Grace Hopper" in response.text
assert "Display name: Grace" in response.text
assert "Maiden name: Murray" in response.text
assert "Birth date: 1906-12-09" in response.text
assert "Death date: 1992-01-01" in response.text
assert "Biography: Computer pioneer" in response.text
assert "Portrait path: /images/grace.jpg" in response.text
assert "Created at (read-only):" in response.text
assert "Updated at (read-only):" in response.text
assert "Full Name:" in response.text
assert "Display Name:" in response.text
assert "Maiden Name:" in response.text
assert "Birth Date:" in response.text
assert "1906-12-09" in response.text
assert "Death Date:" in response.text
assert "1992-01-01" in response.text
assert "biography" in response.text.lower()
assert "Computer pioneer" in response.text
assert "Created:" in response.text
assert "Updated:" in response.text
assert "No linked documents yet." in response.text
assert "Link this person from a Document workflow." in response.text
@@ -115,7 +117,6 @@ class TestPeoplePageRendering:
response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200
assert "Portrait path: portraits/person/seeded.png" in response.text
assert "/uploads/portraits/person/seeded.png" in response.text
def test_person_detail_page_renders_linked_documents(self, app_client):
@@ -145,7 +146,8 @@ class TestPeoplePageRendering:
response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200
assert "Linked Document (author)" in response.text
assert "Linked Document" in response.text
assert "Role: author" in response.text
def test_person_detail_page_handles_invalid_id(self, app_client):
_, client = app_client
@@ -179,7 +181,7 @@ class TestPeoplePageRendering:
response = client.get(f"/ui/people/{person_id}/edit")
assert response.status_code == 200
assert "Edit person" in response.text
assert "Edit Person Record" in response.text
assert "Full name is required." in response.text
assert "Full name" in response.text
assert "Save changes" in response.text
@@ -200,8 +202,8 @@ class TestPeoplePageRendering:
response = client.get(f"/ui/people/{person_id}/delete")
assert response.status_code == 200
assert "Delete person" in response.text
assert "This action permanently deletes the person." in response.text
assert "Delete Person Record" in response.text
assert "This action permanently deletes the person record." in response.text
assert "Delete person permanently" in response.text
def test_person_delete_page_shows_blocked_state_when_linked_documents_exist(self, app_client):
+8 -11
View File
@@ -23,7 +23,7 @@ class TestSourcesPageRendering:
assert response.status_code == 200
assert "Sources" in response.text
assert "No sources added yet." in response.text
assert "No source file records found." in response.text
def test_sources_page_lists_seeded_sources(self, app_client):
_, client = app_client
@@ -49,9 +49,8 @@ class TestSourcesPageRendering:
response = client.get("/ui/sources")
assert response.status_code == 200
assert "Page 1: page_one.png" in response.text
assert "page_one.png" in response.text
assert "stored_page_one.png" in response.text
assert "Open source detail" in response.text
def test_sources_page_filters_to_document_context(self, app_client):
_, client = app_client
@@ -90,11 +89,10 @@ class TestSourcesPageRendering:
response = client.get(f"/ui/sources?document_id={document_id}")
assert response.status_code == 200
assert "Sources for Target" in response.text
assert "Sources: Target" in response.text
assert "Back to Document" in response.text
assert "target_page.png" in response.text
assert "other_page.png" not in response.text
assert "Open source detail" in response.text
def test_sources_page_filters_to_job_context(self, app_client, seed_job):
_, client = app_client
@@ -106,7 +104,6 @@ class TestSourcesPageRendering:
assert "Sources for Job" in response.text
assert "Back to Job" in response.text
assert "job-page.png" in response.text
assert "Open source detail" in response.text
def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job):
_, client = app_client
@@ -133,11 +130,11 @@ class TestSourcesPageRendering:
response = client.get(f"/ui/sources/{source_id}")
assert response.status_code == 200
assert "Source detail-source.png" in response.text
assert "Source Page 1: detail-source.png" in response.text
assert "Back to Sources" in response.text
assert "Transcription text" in response.text
assert "automated raw transcription" in response.text.lower()
assert "original transcription text" in response.text
assert "Revision text" in response.text
assert "curated human transcription" in response.text.lower()
assert "human revision text" in response.text
assert "Page number:" in response.text
assert "Stored filename:" in response.text
assert "Page Number:" in response.text
assert "Stored Filename:" in response.text