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] selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
) )
.where(Job.status == JobStatus.QUEUED) .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() return (await _session.exec(query)).first()
+1
View File
@@ -161,6 +161,7 @@ async def process_next_queued_job(
) -> bool: ) -> bool:
"""Process the next queued job if one exists.""" """Process the next queued job if one exists."""
job = await services.jobs.read_next_queued_job(session=session) job = await services.jobs.read_next_queued_job(session=session)
if job is None: if job is None:
return False 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_app_shell
from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.document_panzoom import render_document_panzoom from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.page_content import render_page_content
from transcription.ui.components.page_header import render_page_header
__all__ = [ __all__ = [
"NAV_ITEMS", "NAV_ITEMS",
"render_app_shell", "render_app_shell",
"render_document_panzoom", "render_document_panzoom",
"render_navigation_header", "render_navigation_header",
"render_page_content",
"render_page_header",
] ]
+4 -4
View File
@@ -1,15 +1,15 @@
# transcription/ui/components/cards.py # transcription/ui/components/cards.py
from contextlib import contextmanager from contextlib import contextmanager
from nicegui import ui from nicegui import ui
@contextmanager @contextmanager
def archival_card(title: str | None = None, extra_classes: str = ""): def archival_card(title: str | None = None, extra_classes: str = ""):
"""Reusable container for Flat 2.0 Bento Grid cards.""" """Reusable container for Flat 2.0 Bento Grid cards."""
with ui.card().classes( with ui.card().classes(f"w-full ui-card-surface p-4 {extra_classes}") as card:
f"w-full bg-[#F4F0E6] border border-[#6B6A65]/30 rounded-sm p-4 {extra_classes}"
) as card:
if title: if title:
ui.label(title.upper()).classes( 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 yield card
@@ -1,12 +1,14 @@
# transcription/ui/components/data_display.py # transcription/ui/components/data_display.py
from nicegui import ui from nicegui import ui
def metadata_row(label: str, value: str): def metadata_row(label: str, value: str):
"""Render a high-density, low-contrast key-value pair.""" """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"): with ui.row().classes("justify-between w-full border-b ui-border-subtle pb-1 text-xs"):
ui.label(label).classes("text-[#6B6A65]") ui.label(label).classes("ui-text-muted")
ui.label(value).classes("font-semibold text-[#333333]") ui.label(value).classes("font-semibold ui-text-primary")
def archival_badge(text: str): def archival_badge(text: str):
"""Standardized Aged Sepia badge.""" """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["sortBy"] = default_sort_by
pagination["descending"] = default_descending pagination["descending"] = default_descending
# Quasar props to enforce flat, archival styling # Quasar props enforce behavior; visual styling is centralized in theme.css.
# Styling table headers with Library Green (#2D5A4C) and rows with subtle borders
table = ( table = (
ui.table( ui.table(
rows=rows, rows=rows,
@@ -66,28 +65,13 @@ def build_table(
row_key="id", row_key="id",
pagination=pagination, pagination=pagination,
) )
.classes( .classes(f"w-full ui-table {classes}")
f"w-full bg-[#F4F0E6] border border-[#6B6A65]/30 rounded-sm {classes}"
)
.props( .props(
'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" ' '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" ' 'header-cell-class="ui-table-header text-xs uppercase tracking-wider" '
'table-class="text-xs text-[#333333]"' '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)) logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
if on_row_click_id is not None: 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.""" """Render documents table and open detail page when clicking a row."""
if not rows: if not rows:
with archival_card(extra_classes="p-8 text-center"): 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 return
build_table( 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.""" """Render jobs table and open a detail page when clicking a row."""
if not rows: if not rows:
with archival_card(extra_classes="p-8 text-center"): 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 return
build_table( build_table(
rows=_serialize_rows(rows), rows=_serialize_rows(rows),
columns=[ columns=[
{"name": "id", "label": "Job ID", "field": "id", "sortable": True, "classes": "font-mono"}, {"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": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True}, {"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
{"name": "date_created", "label": "Created", "field": "date_created", "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.""" """Render people table and open detail page when clicking a row."""
if not rows: if not rows:
with archival_card(extra_classes="p-8 text-center"): 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 return
build_table( 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.""" """Render sources table and open detail page when clicking a row."""
if not rows: if not rows:
with archival_card(extra_classes="p-8 text-center"): 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 return
build_table( 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", container_height: str = "500px",
) -> None: ) -> None:
"""Isolated high-contrast container for image inspection with pan and zoom capabilities.""" """Isolated high-contrast container for image inspection with pan and zoom capabilities."""
with ui.card().classes( with ui.card().classes("ui-bg-viewer ui-border-viewer rounded-sm p-3 flex flex-col justify-between w-full"):
"bg-[#2B2D2C] border border-[#333333] rounded-sm p-3 flex flex-col justify-between w-full"
):
# Viewer Header Bar # 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("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 # Interactive Pan/Zoom Canvas Area
if image_path: if image_path:
# Container with fixed height and hidden overflow for contained panning/zooming # Container with fixed height and hidden overflow for contained panning/zooming
with ui.element("div").classes( 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: ).style(f"height: {container_height};") as viewport:
# Image element targeted by client-side pan/zoom JS # Image element targeted by client-side pan/zoom JS
@@ -89,7 +87,7 @@ def dark_room_viewer(
ui.run_javascript(js_pan_zoom) ui.run_javascript(js_pan_zoom)
# Control Toolbar # 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( ui.button(
icon="zoom_in", icon="zoom_in",
on_click=lambda: ui.run_javascript(f"window.zoomIn_{img.id}()"), on_click=lambda: ui.run_javascript(f"window.zoomIn_{img.id}()"),
@@ -108,6 +106,6 @@ def dark_room_viewer(
else: else:
# Fallback state when no image source is linked # Fallback state when no image source is linked
with ui.column().classes( 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};"): ).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.error_presenter import show_error
from transcription.ui.components.table.documents import DocumentTableRow from transcription.ui.components.table.documents import DocumentTableRow
from transcription.ui.components.table.documents import render_documents_table 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.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
@@ -72,7 +72,7 @@ def register_page() -> None:
.props("outlined bg-white") .props("outlined bg-white")
.classes("w-full") .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") return_to = request.query_params.get("return_to")
@@ -137,7 +137,7 @@ def register_page() -> None:
ui.navigate.to(f"/documents/{created.id}") ui.navigate.to(f"/documents/{created.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"): 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.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back").props("flat")
@ui.page("/documents") @ui.page("/documents")
@@ -147,13 +147,13 @@ def register_page() -> None:
render_navigation_header(current_path="/documents") render_navigation_header(current_path="/documents")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): 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") page_header("Archival Documents")
ui.button( ui.button(
"Create new document", "Create new document",
on_click=lambda: ui.navigate.to("/documents/new"), on_click=lambda: ui.navigate.to("/documents/new"),
icon="note_add", icon="note_add",
).classes("bg-[#2D5A4C] text-white") ).classes("ui-btn-primary")
try: try:
documents = sorted( documents = sorted(
@@ -211,7 +211,7 @@ def register_page() -> None:
# Main Bento Grid Wrapper # Main Bento Grid Wrapper
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"): with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
# Header Bar # 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}") page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
with ui.row().classes("items-center gap-2"): with ui.row().classes("items-center gap-2"):
@@ -219,7 +219,7 @@ def register_page() -> None:
"Edit Document", "Edit Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"), on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
icon="edit", icon="edit",
).classes("bg-[#2D5A4C] text-white text-xs") ).classes("ui-btn-primary text-xs")
ui.button( ui.button(
"Delete", "Delete",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"), on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
@@ -237,12 +237,12 @@ def register_page() -> None:
"View All Sources", "View All Sources",
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"), on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
icon="description", icon="description",
).props("flat dense text-xs").classes("text-[#2D5A4C]") ).props("flat dense text-xs").classes("ui-link-primary")
ui.button( ui.button(
"+ Add Source", "+ Add Source",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add", icon="add",
).classes("bg-[#2D5A4C] text-white text-xs") ).classes("ui-btn-primary text-xs")
# ZONE 2: Metadata & Archival Attributes (Cols 6-8) # ZONE 2: Metadata & Archival Attributes (Cols 6-8)
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"): 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") metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
with ui.column().classes("w-full mt-2"): 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( 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"): with archival_card(title="System Logistics"):
ui.label(f"Created: {document.created_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] text-[#6B6A65]") ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
# ZONE 3: Related Entities & Pipeline Jobs (Cols 9-12) # ZONE 3: Related Entities & Pipeline Jobs (Cols 9-12)
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"): with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
with archival_card(title="Related People"): with archival_card(title="Related People"):
if not document.document_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: else:
with ui.column().classes("w-full gap-2"): with ui.column().classes("w-full gap-2"):
for link in document.document_people: for link in document.document_people:
person_label = link.person.full_name if link.person is not None else "Unknown person" person_label = link.person.full_name if link.person is not None else "Unknown person"
with ui.row().classes( 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) archival_badge(link.role.value)
with archival_card(title="Pipeline Jobs"): with archival_card(title="Pipeline Jobs"):
with ui.row().classes("w-full justify-between items-center mb-2"): 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"): with ui.row().classes("w-full gap-2 mt-2"):
ui.button( ui.button(
"View Jobs", "View Jobs",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"),
icon="work_history", icon="work_history",
).props("flat dense text-xs").classes("text-[#2D5A4C]") ).props("flat dense text-xs").classes("ui-link-primary")
ui.button( ui.button(
"+ Add Job", "+ Add Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add", icon="add",
).classes("bg-[#2D5A4C] text-white text-xs") ).classes("ui-btn-primary text-xs")
@ui.page("/documents/{document_id}/jobs") @ui.page("/documents/{document_id}/jobs")
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None: async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
@@ -316,7 +316,7 @@ def register_page() -> None:
return return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): 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}") page_header(f"Jobs for {document.name}")
with ui.row().classes("gap-2"): with ui.row().classes("gap-2"):
ui.button( ui.button(
@@ -328,11 +328,11 @@ def register_page() -> None:
"Create Job", "Create Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add", icon="add",
).classes("bg-[#2D5A4C] text-white") ).classes("ui-btn-primary")
if not document.jobs: if not document.jobs:
with archival_card(extra_classes="p-6 text-center"): 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 return
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True): 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("w-full items-center justify-between"):
with ui.row().classes("items-center gap-2"): with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value) 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( ui.button(
"Open Job", "Open Job",
on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"), on_click=lambda _=None, job_id=job.id: ui.navigate.to(f"/jobs/{job_id}"),
icon="open_in_new", 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") @ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse: async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
@@ -434,7 +434,7 @@ def register_page() -> None:
.props("outlined bg-white") .props("outlined bg-white")
.classes("w-full") .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: async def submit_edit() -> None:
candidate_name = (name_input.value or "").strip() candidate_name = (name_input.value or "").strip()
@@ -505,7 +505,7 @@ def register_page() -> None:
ui.navigate.to(f"/documents/{document.id}") ui.navigate.to(f"/documents/{document.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"): 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( ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
"flat" "flat"
) )
@@ -535,7 +535,7 @@ def register_page() -> None:
page_header("Delete Document") page_header("Delete Document")
with archival_card(extra_classes="gap-2"): 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_sources = bool(document.sources)
has_jobs = bool(document.jobs) has_jobs = bool(document.jobs)
@@ -547,15 +547,15 @@ def register_page() -> None:
categories.append("Sources") categories.append("Sources")
if has_jobs: if has_jobs:
categories.append("Jobs") categories.append("Jobs")
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-xs text-[#6B6A65]") 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 text-[#6B6A65] italic") 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"): with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button( ui.button(
"Back to Document", "Back to Document",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
icon="arrow_back", 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( ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs" "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.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.jobs import render_jobs_table 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 apply_archival_theme
from transcription.ui.theme import page_header
from transcription.worker import resolve_worker_notifier from transcription.worker import resolve_worker_notifier
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
@@ -38,11 +38,11 @@ def register_page() -> None: # noqa: PLR0915
render_navigation_header(current_path="/jobs") render_navigation_header(current_path="/jobs")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): 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") page_header("Transcription Pipeline Jobs")
with ui.row().classes("items-center gap-2"): with ui.row().classes("items-center gap-2"):
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes( 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") 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", "Create document",
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"), on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
icon="note_add", 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") ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
return return
@@ -106,12 +106,12 @@ def register_page() -> None: # noqa: PLR0915
with archival_card(title="Source Files"): with archival_card(title="Source Files"):
ui.label( ui.label(
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order." "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 @ui.refreshable
def render_upload_list() -> None: def render_upload_list() -> None:
if not uploaded_files: 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 return
def remove_file(index: int) -> None: 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"): with ui.column().classes("gap-1 w-full mt-2"):
for index, (filename, _) in ordered_uploads: for index, (filename, _) in ordered_uploads:
with ui.row().classes( 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( ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
"flat round dense color=negative text-xs" "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"): 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( 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") 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 return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"): 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}") page_header(f"Job Record: {job.id}")
with ui.row().classes("items-center gap-2"): with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value.upper()) archival_badge(job.status.value.upper())
@@ -236,18 +236,18 @@ def register_page() -> None: # noqa: PLR0915
metadata_row("Last Updated:", job.date_updated.isoformat()) metadata_row("Last Updated:", job.date_updated.isoformat())
with archival_card(title="Document Links"): 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"): with ui.column().classes("w-full gap-2"):
ui.button( ui.button(
"View Linked Document", "View Linked Document",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"), on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
icon="description", icon="description",
).classes("bg-[#2D5A4C] text-white text-xs w-full") ).classes("ui-btn-primary text-xs w-full")
ui.button( ui.button(
"View Linked Sources", "View Linked Sources",
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"), on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
icon="description", 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") @ui.page("/jobs/{job_id}/delete")
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None: 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") page_header("Delete Processing Job")
with archival_card(extra_classes="gap-2"): 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: if job.status == JobStatus.PROCESSING:
ui.label("Delete is blocked while the job is processing.").classes( ui.label("Delete is blocked while the job is processing.").classes(
"text-xs text-red-800 font-bold mt-2" "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"): with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button( ui.button(
"Back to Job", "Back to Job",
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"),
icon="arrow_back", 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( ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
"flat text-xs" "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") ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
if job.job_sources: 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: async def submit_delete() -> None:
try: 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.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.people import PersonTableRow, render_people_table 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.components.viewers import dark_room_viewer
from transcription.ui.theme import apply_archival_theme from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
@@ -70,7 +70,7 @@ def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Setti
auto_upload=True, auto_upload=True,
label="Choose portrait file", label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full") ).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: 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") render_navigation_header(current_path="/people")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): 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") page_header("Archival Entities: People")
ui.button( ui.button(
"Create new person", "Create new person",
on_click=lambda: ui.navigate.to("/people/new"), on_click=lambda: ui.navigate.to("/people/new"),
icon="person_add", icon="person_add",
).classes("bg-[#2D5A4C] text-white") ).classes("ui-btn-primary")
try: try:
people = sorted( people = sorted(
@@ -203,7 +203,7 @@ def register_page() -> None: # noqa: PLR0915
ui.navigate.to(f"/people/{created.id}") ui.navigate.to(f"/people/{created.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"): 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.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}") @ui.page("/people/{person_id}")
@@ -230,7 +230,7 @@ def register_page() -> None: # noqa: PLR0915
portrait_src = _resolve_portrait_src(person.portrait_path) 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.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}") page_header(person.full_name, subtitle=f"Person ID: {person.id}")
with ui.row().classes("items-center gap-2"): with ui.row().classes("items-center gap-2"):
@@ -238,7 +238,7 @@ def register_page() -> None: # noqa: PLR0915
"Edit Person", "Edit Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"), on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
icon="edit", icon="edit",
).classes("bg-[#2D5A4C] text-white text-xs") ).classes("ui-btn-primary text-xs")
ui.button( ui.button(
"Delete", "Delete",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/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") metadata_row("Death Place:", person.death_place or "Not set")
with archival_card(title="System Logistics"): with archival_card(title="System Logistics"):
ui.label(f"Created: {person.created_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] text-[#6B6A65]") 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 ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Biography"): with archival_card(title="Biography"):
ui.label(person.biography or "No biography recorded.").classes( 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"): with archival_card(title="Linked Documents"):
if not person.document_people: if not person.document_people:
ui.label("No linked documents yet.").classes("text-xs text-[#6B6A65] italic") 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 text-[#6B6A65]") ui.label("Link this person from a Document workflow.").classes("text-xs ui-text-muted")
else: else:
with ui.column().classes("w-full gap-2"): with ui.column().classes("w-full gap-2"):
for link in person.document_people: for link in person.document_people:
@@ -282,18 +282,18 @@ def register_page() -> None: # noqa: PLR0915
if document is None: if document is None:
continue continue
with ui.row().classes( 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"): with ui.column().classes("gap-0"):
ui.label(document.name).classes("text-xs font-semibold text-[#333333]") ui.label(document.name).classes("text-xs font-semibold ui-text-primary")
ui.label(f"Role: {link.role.value}").classes("text-[10px] text-[#6B6A65]") ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
ui.button( ui.button(
"Open", "Open",
on_click=lambda _=None, doc_id=document.id: ui.navigate.to( on_click=lambda _=None, doc_id=document.id: ui.navigate.to(
f"/documents/{doc_id}" f"/documents/{doc_id}"
), ),
icon="open_in_new", 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") @ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None: 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}") ui.navigate.to(f"/people/{person.id}")
with ui.row().classes("w-full items-center gap-2 mt-2"): 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.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}/delete") @ui.page("/people/{person_id}/delete")
@@ -424,18 +424,18 @@ def register_page() -> None: # noqa: PLR0915
page_header("Delete Person Record") page_header("Delete Person Record")
with archival_card(extra_classes="gap-2"): 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: 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("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(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 text-[#6B6A65] italic") 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"): with ui.row().classes("w-full items-center gap-2 mt-4"):
ui.button( ui.button(
"Back to Person", "Back to Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}"), on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
icon="arrow_back", 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( ui.button("Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description").props(
"flat text-xs" "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.document_panzoom import render_document_panzoom
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table 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 apply_archival_theme
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
@@ -76,7 +76,7 @@ def register_page() -> None:
return return
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): 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: if document_name is not None:
header_title = f"Sources: {document_name}" header_title = f"Sources: {document_name}"
elif job_label is not None: elif job_label is not None:
@@ -89,7 +89,7 @@ def register_page() -> None:
if back_path is not None: if back_path is not None:
back_label = "Back to Document" if document_id is not None else "Back to Job" 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( 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 # 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) 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.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}") page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}")
if back_path is not None: if back_path is not None:
@@ -141,7 +141,7 @@ def register_page() -> None:
else "Back to Sources" else "Back to Sources"
) )
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").classes( 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: else:
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props( 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)) ui.navigate.to(request.url.path + _back_query(request.query_params))
with ui.row().classes("w-full items-center gap-2 mt-2"): 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") @ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str) -> RedirectResponse: 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 { [tabindex="0"]:focus-visible {
outline: 3px solid var(--theme-focus); outline: 3px solid var(--theme-focus);
outline-offset: 2px; 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.config import get_settings
from transcription.db.engine import get_database_url from transcription.db.engine import get_database_url
from transcription.db.engine import get_engine 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 dispose_session_factory
from transcription.db.session import get_session_factory from transcription.db.session import get_session_factory
from transcription.db.session import session_scope from transcription.db.session import session_scope
@@ -42,8 +41,15 @@ async def default_settings():
"""Provide default settings for tests.""" """Provide default settings for tests."""
settings = get_settings(database_url="sqlite:///:memory:") settings = get_settings(database_url="sqlite:///:memory:")
db_url = get_database_url(settings) db_url = get_database_url(settings)
await create_all(engine=get_engine(database_url=db_url)) engine = get_engine(database_url=db_url)
return settings
# 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 @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: async with session_scope(database_url=db_url) as async_session:
yield async_session yield async_session
await dispose_session_factory(db_url)
@pytest.fixture @pytest.fixture
def default_session_factory(default_settings: Settings): 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 Job
from transcription.db.models import JobStatus from transcription.db.models import JobStatus
from transcription.providers.base import TranscriptionResult from transcription.providers.base import TranscriptionResult
from transcription.services import ServiceBundle
from transcription.services.store import create_upload_job 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 @pytest.mark.integration
@@ -58,7 +59,11 @@ class TestPipelineSuccessFlow:
_fake_transcribe_document_image, _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) job = await async_session.get(Job, upload_result.job_id)
assert processed is True assert processed is True
@@ -98,7 +103,11 @@ class TestPipelineFailureFlow:
_fake_transcribe_document_image, _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) job = await async_session.get(Job, upload_result.job_id)
assert processed is True assert processed is True
+10 -2
View File
@@ -1,4 +1,7 @@
from uuid import uuid4 from uuid import uuid4
from datetime import UTC
from datetime import datetime
from datetime import timedelta
import pytest import pytest
@@ -100,8 +103,13 @@ class TestJobService:
document = Document(id=uuid4(), name="ordered-doc") document = Document(id=uuid4(), name="ordered-doc")
await document_service.create_document(document=document) await document_service.create_document(document=document)
first = Job(document_id=document.id, status=JobStatus.QUEUED) created_at = datetime.now(UTC)
second = Job(document_id=document.id, status=JobStatus.QUEUED) 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=first)
await job_service.create_job(job=second) await job_service.create_job(job=second)
+30 -24
View File
@@ -30,8 +30,7 @@ class TestDocumentsPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Documents" in response.text assert "Documents" in response.text
assert "Create new document" in response.text assert "Create new document" in response.text
assert "No documents yet." in response.text assert "No documents in repository yet." in response.text
assert "Create your first document" in response.text
def test_document_create_page_renders_fields(self, app_client): def test_document_create_page_renders_fields(self, app_client):
"""GET /ui/documents/new renders document-create form fields.""" """GET /ui/documents/new renders document-create form fields."""
@@ -40,7 +39,7 @@ class TestDocumentsPageRendering:
response = client.get("/ui/documents/new") response = client.get("/ui/documents/new")
assert response.status_code == 200 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 is required." in response.text
assert "Document name" in response.text assert "Document name" in response.text
assert "Document type" in response.text assert "Document type" in response.text
@@ -69,7 +68,7 @@ class TestDocumentsPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Seeded Document" in response.text 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): 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.""" """GET /ui/documents/{document_id} shows metadata and related empty states."""
@@ -97,24 +96,30 @@ class TestDocumentsPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Zenna Letter" in response.text assert "Zenna Letter" in response.text
assert "Document type: letter" in response.text assert "Type: letter" in response.text
assert "Author: not set" in response.text assert "Author:" in response.text
assert "Exact date: 1885-07-13" in response.text assert "Not set" in response.text
assert "Approximate date: c. 1885" in response.text assert "Exact Date:" in response.text
assert "Location created: Ohio" in response.text assert "1885-07-13" in response.text
assert "Archive identifier: BOX-1-FOLDER-2" in response.text assert "Approx. Date:" in response.text
assert "Notes: Family archive" in response.text assert "c. 1885" in response.text
assert "Created at (read-only):" in response.text assert "Location Created:" in response.text
assert "Updated at (read-only):" 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 "No linked people yet." in response.text
assert "0 source(s) linked" in response.text assert "0 Source(s) Linked" in response.text
assert "0 job(s) linked" in response.text assert "0 Active Jobs" in response.text
assert "+ Add Source" in response.text assert "+ Add Source" in response.text
assert "+ Add Job" in response.text assert "+ Add Job" in response.text
assert "Sources" in response.text assert "Sources" in response.text
assert "Jobs" in response.text assert "Jobs" in response.text
assert "Edit document" in response.text assert "Edit Document" in response.text
assert "Delete document" in response.text assert "Delete" in response.text
def test_document_detail_page_renders_related_people_sources_and_jobs(self, app_client): def test_document_detail_page_renders_related_people_sources_and_jobs(self, app_client):
"""GET /ui/documents/{document_id} shows related records when present.""" """GET /ui/documents/{document_id} shows related records when present."""
@@ -158,10 +163,11 @@ class TestDocumentsPageRendering:
response = client.get(f"/ui/documents/{document_id}") response = client.get(f"/ui/documents/{document_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Jane Doe (author)" in response.text assert "Jane Doe" in response.text
assert "Author: Jane Doe" in response.text assert "author" in response.text
assert "1 source(s) linked" in response.text assert "Author:" in response.text
assert "1 job(s) linked" 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): def test_document_jobs_page_filters_to_document_context(self, app_client):
_, client = app_client _, client = app_client
@@ -223,7 +229,7 @@ class TestDocumentsPageRendering:
response = client.get(f"/ui/sources?document_id={document_id}") response = client.get(f"/ui/sources?document_id={document_id}")
assert response.status_code == 200 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 "Back to Document" in response.text
assert "target_page.png" in response.text assert "target_page.png" in response.text
assert "other_page.png" not 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") response = client.get(f"/ui/documents/{document_id}/edit")
assert response.status_code == 200 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 and document type are required." in response.text
assert "Document name" in response.text assert "Document name" in response.text
assert "Document type" in response.text assert "Document type" in response.text
@@ -298,7 +304,7 @@ class TestDocumentsPageRendering:
response = client.get(f"/ui/documents/{document_id}/delete") response = client.get(f"/ui/documents/{document_id}/delete")
assert response.status_code == 200 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 "This action permanently deletes the document." in response.text
assert "Delete document permanently" 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 response.status_code == 200
assert "Create job" in response.text 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): def test_job_create_page_requires_existing_documents(self, app_client):
"""GET /ui/jobs/new shows guidance when no Documents exist.""" """GET /ui/jobs/new shows guidance when no Documents exist."""
@@ -30,7 +30,7 @@ class TestPageRendering:
response = client.get("/ui/jobs/new") response = client.get("/ui/jobs/new")
assert response.status_code == 200 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 "No documents available. Create a Document before creating a Job." in response.text
assert "Create document" in response.text assert "Create document" in response.text
@@ -48,7 +48,7 @@ class TestPageRendering:
response = client.get("/ui/jobs/new") response = client.get("/ui/jobs/new")
assert response.status_code == 200 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 "Seeded Document" in response.text
assert "Files are processed alphabetically by original filename." in response.text assert "Files are processed alphabetically by original filename." in response.text
assert "No files uploaded yet." in response.text assert "No files uploaded yet." in response.text
@@ -81,9 +81,9 @@ class TestPageRendering:
assert "Provider:" in response.text assert "Provider:" in response.text
assert "Model:" in response.text assert "Model:" in response.text
assert "Prompt:" in response.text assert "Prompt:" in response.text
assert "Retry count:" in response.text assert "Retry Count:" in response.text
assert "Last updated:" in response.text assert "Last Updated:" in response.text
assert "Document Links" in response.text assert "document links" in response.text.lower()
assert "Sources" in response.text assert "Sources" in response.text
assert "Jobs" in response.text assert "Jobs" in response.text
assert "Delete job" not 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 response.status_code == 200
assert "People" in response.text assert "People" in response.text
assert "Create new person" 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): def test_people_page_lists_seeded_people(self, app_client):
_, client = app_client _, client = app_client
@@ -41,7 +41,7 @@ class TestPeoplePageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Ada Lovelace" in response.text 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): def test_person_create_page_renders_fields(self, app_client):
_, client = app_client _, client = app_client
@@ -49,7 +49,7 @@ class TestPeoplePageRendering:
response = client.get("/ui/people/new") response = client.get("/ui/people/new")
assert response.status_code == 200 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 "Full name is required." in response.text
assert "Birth date (YYYY-MM-DD)" in response.text assert "Birth date (YYYY-MM-DD)" in response.text
assert "Death 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 response.status_code == 200
assert "Grace Hopper" in response.text assert "Grace Hopper" in response.text
assert "Full name: Grace Hopper" in response.text assert "Full Name:" in response.text
assert "Display name: Grace" in response.text assert "Display Name:" in response.text
assert "Maiden name: Murray" in response.text assert "Maiden Name:" in response.text
assert "Birth date: 1906-12-09" in response.text assert "Birth Date:" in response.text
assert "Death date: 1992-01-01" in response.text assert "1906-12-09" in response.text
assert "Biography: Computer pioneer" in response.text assert "Death Date:" in response.text
assert "Portrait path: /images/grace.jpg" in response.text assert "1992-01-01" in response.text
assert "Created at (read-only):" in response.text assert "biography" in response.text.lower()
assert "Updated at (read-only):" in response.text 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 "No linked documents yet." in response.text
assert "Link this person from a Document workflow." 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}") response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Portrait path: portraits/person/seeded.png" in response.text
assert "/uploads/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): 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}") response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200 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): def test_person_detail_page_handles_invalid_id(self, app_client):
_, client = app_client _, client = app_client
@@ -179,7 +181,7 @@ class TestPeoplePageRendering:
response = client.get(f"/ui/people/{person_id}/edit") response = client.get(f"/ui/people/{person_id}/edit")
assert response.status_code == 200 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 is required." in response.text
assert "Full name" in response.text assert "Full name" in response.text
assert "Save changes" in response.text assert "Save changes" in response.text
@@ -200,8 +202,8 @@ class TestPeoplePageRendering:
response = client.get(f"/ui/people/{person_id}/delete") response = client.get(f"/ui/people/{person_id}/delete")
assert response.status_code == 200 assert response.status_code == 200
assert "Delete person" in response.text assert "Delete Person Record" in response.text
assert "This action permanently deletes the person." in response.text assert "This action permanently deletes the person record." in response.text
assert "Delete person permanently" 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): 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 response.status_code == 200
assert "Sources" in response.text 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): def test_sources_page_lists_seeded_sources(self, app_client):
_, client = app_client _, client = app_client
@@ -49,9 +49,8 @@ class TestSourcesPageRendering:
response = client.get("/ui/sources") response = client.get("/ui/sources")
assert response.status_code == 200 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 "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): def test_sources_page_filters_to_document_context(self, app_client):
_, client = app_client _, client = app_client
@@ -90,11 +89,10 @@ class TestSourcesPageRendering:
response = client.get(f"/ui/sources?document_id={document_id}") response = client.get(f"/ui/sources?document_id={document_id}")
assert response.status_code == 200 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 "Back to Document" in response.text
assert "target_page.png" in response.text assert "target_page.png" in response.text
assert "other_page.png" not 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): def test_sources_page_filters_to_job_context(self, app_client, seed_job):
_, client = app_client _, client = app_client
@@ -106,7 +104,6 @@ class TestSourcesPageRendering:
assert "Sources for Job" in response.text assert "Sources for Job" in response.text
assert "Back to Job" in response.text assert "Back to Job" in response.text
assert "job-page.png" 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): def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job):
_, client = app_client _, client = app_client
@@ -133,11 +130,11 @@ class TestSourcesPageRendering:
response = client.get(f"/ui/sources/{source_id}") response = client.get(f"/ui/sources/{source_id}")
assert response.status_code == 200 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 "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 "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 "human revision text" in response.text
assert "Page number:" in response.text assert "Page Number:" in response.text
assert "Stored filename:" in response.text assert "Stored Filename:" in response.text