From f80834d5898e4479edd8fb98beea2c978936124c Mon Sep 17 00:00:00 2001 From: Jim Lancaster <40281233+zoltan57@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:28:58 -0500 Subject: [PATCH] UI style refresh: extract reusable components and refactor --- docs/invariant/ui_style_guide.md | 138 +++++++++++++----- src/transcription/ui/components/primitives.py | 19 +++ src/transcription/ui/pages/documents_page.py | 12 +- src/transcription/ui/pages/jobs_page.py | 13 +- src/transcription/ui/pages/people_page.py | 10 +- src/transcription/ui/pages/sources_page.py | 5 +- 6 files changed, 143 insertions(+), 54 deletions(-) create mode 100644 src/transcription/ui/components/primitives.py diff --git a/docs/invariant/ui_style_guide.md b/docs/invariant/ui_style_guide.md index b7cadf3..84eeda7 100644 --- a/docs/invariant/ui_style_guide.md +++ b/docs/invariant/ui_style_guide.md @@ -1,50 +1,112 @@ -# UI Design Specification +# UI Style Guide (Invariant) -## 1. Overview and Aesthetic Philosophy -This document outlines the design language for a high-density document transcription workflow application built in Python. The UI represents a deliberate blend of **Modern Modular Dashboard** design and an **Archival/Academic** aesthetic. +## 1. Purpose +This guide defines non-negotiable UI styling rules for the transcription application. -The interface balances modern structural trends with stylistic nods to historical preservation, ensuring maximum legibility, reduced eye strain during extended transcription sessions, and a clear presentation of complex relational data. +The design system is token-first and class-driven: +1. Theme tokens are defined in [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css). +2. Python UI code composes semantic classes instead of inline color values. +3. Pages and components should share a single visual language across Documents, Jobs, People, and Sources flows. -## 2. Key Structural Patterns +## 2. Source of Truth +Use these files as the style authority: +1. [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css) for color tokens, semantic utility classes, table styles, and viewer surfaces. +2. [src/transcription/ui/theme.py](src/transcription/ui/theme.py) for runtime NiceGUI theme bridge and shared UI helpers. -### 2.1. Modern Bento Grid Layout -The structure strictly adheres to the Bento Grid trend. -* The interface is divided into well-defined rectangular zones (e.g., image viewer, transcription editors, right-hand metadata column). -* This layout is highly functional for complex workflows, allowing users to cross-reference the original scanned document, raw AI output, human revision, and database relationships simultaneously without scrolling. +If this document conflicts with implementation, update this document to match the code immediately after intentional style changes. -### 2.2. High-Density Flat Design (Flat 2.0) -The styling deliberately avoids heavy drop shadows or aggressive 3D glassmorphism. -* **Subtle Separation:** Relies on 1px borders and slight variations in background shade to separate functional zones. -* **Data Density:** Prioritizes fitting a large amount of structured data (processing metadata, related entities, tokens) into a compact space while remaining readable. +## 3. Core Design Invariants +1. Flat, high-density surfaces over decorative depth. +2. Strong content hierarchy with subdued backgrounds and border-based separation. +3. Viewer area remains the highest contrast region in image/transcription workflows. +4. Primary actions are consistent and visually recognizable. +5. Accessible focus rings are always visible for keyboard users. -### 2.3. Dual-Typography System -The interface employs a classic typographic pairing standard in editorial or academic software: -* **Serif Headers:** Used for primary document titles. This lends a traditional, authoritative, and print-like feel appropriate for historical documents. -* **Sans-Serif UI/Body Text:** Functional UI elements, buttons, and transcription input fields use a clean, modern sans-serif font for maximum utility. +## 4. Token System -## 3. The "Archival" Color Palette +### 4.1 Palette Tokens +Base palette variables live under :root in [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css): +1. --palette-carbon-black: #1c2321 +2. --palette-cool-steel: #7d98a1 +3. --palette-blue-slate: #5e6572 +4. --palette-powder-blue: #a9b4c2 +5. --palette-platinum: #eef1ef -The color palette is strictly utilitarian: prioritizing glare elimination, minimizing eye strain for fading texts, and establishing hierarchy without distracting from the documents. +### 4.2 Semantic Theme Tokens +Do not style components directly with palette tokens when a semantic token exists. -### 3.1 Palette Philosophy -* **The "Canvas" (Backgrounds):** A warm off-white/cream mimicking standard archival folder stock to provide a low-contrast environment. Card surfaces use a slightly elevated manila/parchment hue for visual separation. -* **The "Ink" (Typography):** Deep charcoal or slate (aged iron-gall ink) instead of pure black to prevent visual vibration. Secondary text uses a muted grey-brown. -* **The "Accents" (Interactive Elements):** A sophisticated forest green for primary actions/tabs, and warm earthy sepia/tan for badges and highlights. -* **The "Dark Room" (Image Viewer):** A very dark, matte slate isolates the scanned original. This high contrast eliminates screen glare, ensuring faded handwriting remains the absolute focal point. +Semantic tokens currently include: +1. --theme-text and --theme-text-muted +2. --theme-page, --theme-surface, --theme-surface-raised, --theme-surface-muted +3. --theme-border +4. --theme-primary and --theme-primary-hover +5. --theme-secondary and --theme-focus +6. --theme-inverse-text +7. --theme-viewer, --theme-viewer-border, --theme-viewer-muted -### 3.2 Exact Color Specifications +## 5. Approved Semantic Classes -| UI Role | Color Name | Hex Code | Tailwind Equivalent (Approx) | -| :--- | :--- | :--- | :--- | -| App Background | Archival Cream | `#FAF9F6` | `stone-50` | -| Card Surface | Folder Stock | `#F4F0E6` | `stone-100` | -| Image Viewer | Matte Slate | `#2B2D2C` | `zinc-800` | -| Primary Text | Iron Ink | `#333333` | `neutral-800` | -| Secondary Text | Faded Type | `#6B6A65` | `stone-500` | -| Primary Accent | Library Green | `#2D5A4C` | `emerald-800` | -| Secondary Accent | Aged Sepia | `#E2C7A8` | `orange-200` | +### 5.1 Text and Background +1. ui-text-primary +2. ui-text-muted +3. ui-text-inverse +4. ui-bg-page +5. ui-bg-surface +6. ui-bg-surface-raised +7. ui-bg-surface-muted +8. ui-bg-viewer +9. ui-bg-viewer-overlay +10. ui-bg-viewer-overlay-soft -## 4. Implementation Guidelines -* When generating layout code, utilize UI framework grid utilities to construct the Bento Grid. -* Map the custom hex codes to the application's global theme variables to ensure consistency across all components. -* Prioritize static borders and background colors over hover effects or transition animations to maintain the academic utility of the interface. \ No newline at end of file +### 5.2 Borders and Surfaces +1. ui-border-subtle +2. ui-border-viewer +3. ui-header-divider +4. ui-card-surface +5. ui-row-surface +6. ui-note-box + +### 5.3 Interactive Elements +1. ui-btn-primary +2. ui-btn-secondary +3. ui-link-primary +4. ui-text-accent + +### 5.4 Table Patterns +1. ui-table +2. ui-table-header +3. ui-table-body + +Use existing class combinations from [src/transcription/ui/components](src/transcription/ui/components) and [src/transcription/ui/pages](src/transcription/ui/pages) as reference implementations. + +## 6. Legacy Class Policy +Legacy classes with vibe- prefix still exist in a few components and are allowed only for compatibility while migrating: +1. Existing usage may remain temporarily. +2. New usage of vibe- classes is not allowed. +3. When touching a file that uses vibe- classes, prefer migrating it to ui- semantic classes in the same change when safe. + +Current legacy usage examples are in: +1. [src/transcription/ui/components/document_panzoom.py](src/transcription/ui/components/document_panzoom.py) +2. [src/transcription/ui/components/error_presenter.py](src/transcription/ui/components/error_presenter.py) +3. [src/transcription/ui/components/transcript.py](src/transcription/ui/components/transcript.py) + +## 7. Prohibited Patterns +1. Inline hex colors in Python UI class strings or style blocks, except in isolated bridge code explicitly marked for migration. +2. Ad-hoc one-off class names that duplicate existing semantic class intent. +3. Page-specific palette forks that bypass theme tokens. +4. Hidden or low-contrast focus states on interactive controls. + +## 8. Implementation Rules For Contributors +1. Prefer composing existing semantic classes before creating new ones. +2. If a new class is required, add it to [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css) with a semantic name, then reuse it. +3. Keep behavior ownership in Python and appearance ownership in CSS. +4. Update UI tests that assert exact text or labels when intentional copy changes are made. +5. Avoid introducing class churn unrelated to the feature being changed. + +## 9. Verification Checklist +Before merging UI changes, verify: +1. No new inline hex colors were introduced in UI pages/components. +2. New styles are token-backed and added to [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css). +3. Primary buttons, links, cards, and tables still render with consistent semantics. +4. Keyboard focus ring visibility is preserved. +5. Relevant UI and integration tests pass. \ No newline at end of file diff --git a/src/transcription/ui/components/primitives.py b/src/transcription/ui/components/primitives.py new file mode 100644 index 0000000..ec58bf1 --- /dev/null +++ b/src/transcription/ui/components/primitives.py @@ -0,0 +1,19 @@ +from contextlib import contextmanager + +from nicegui import ui + + +@contextmanager +def section_header_row(*, classes: str = ""): + """Render a standardized section header row container.""" + base_classes = "w-full items-center justify-between pb-2 ui-header-divider" + with ui.row().classes(f"{base_classes} {classes}".strip()) as row: + yield row + + +def render_empty_state(message: str, *, italic: bool = False, extra_classes: str = "") -> None: + """Render standardized empty-state helper text.""" + classes = "text-xs ui-text-muted" + if italic: + classes = f"{classes} italic" + ui.label(message).classes(f"{classes} {extra_classes}".strip()) diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index 60adb87..450a80c 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -21,6 +21,8 @@ from transcription.ui.components.cards import archival_card 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.primitives import render_empty_state +from transcription.ui.components.primitives import section_header_row from transcription.ui.components.table.documents import DocumentTableRow from transcription.ui.components.table.documents import render_documents_table from transcription.ui.components.viewers import dark_room_viewer @@ -147,7 +149,7 @@ 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 ui-header-divider"): + with section_header_row(): page_header("Archival Documents") ui.button( "Create new document", @@ -211,7 +213,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 ui-header-divider"): + with section_header_row(): page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}") with ui.row().classes("items-center gap-2"): @@ -267,7 +269,7 @@ def register_page() -> None: 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 ui-text-muted italic") + render_empty_state("No linked people yet.", italic=True) else: with ui.column().classes("w-full gap-2"): for link in document.document_people: @@ -316,7 +318,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 ui-header-divider"): + with section_header_row(): page_header(f"Jobs for {document.name}") with ui.row().classes("gap-2"): ui.button( @@ -332,7 +334,7 @@ def register_page() -> None: if not document.jobs: with archival_card(extra_classes="p-6 text-center"): - ui.label("No transcription processing jobs created yet.").classes("text-xs ui-text-muted") + render_empty_state("No transcription processing jobs created yet.") return for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True): diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index c3edd84..5bf74a5 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -19,6 +19,8 @@ from transcription.ui.components.cards import archival_card 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.primitives import render_empty_state +from transcription.ui.components.primitives import section_header_row from transcription.ui.components.table.jobs import render_jobs_table from transcription.ui.theme import apply_archival_theme from transcription.ui.theme import page_header @@ -38,7 +40,7 @@ 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 ui-header-divider"): + with section_header_row(): 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( @@ -75,8 +77,9 @@ def register_page() -> None: # noqa: PLR0915 documents = await documents_service.list_documents() if not documents: with archival_card(extra_classes="p-6 text-center"): - ui.label("No documents available. Create a Document before creating a Job.").classes( - "text-xs text-red-800 font-medium mb-4" + render_empty_state( + "No documents available. Create a Document before creating a Job.", + extra_classes="text-red-800 font-medium mb-4", ) with ui.row().classes("justify-center gap-2"): ui.button( @@ -111,7 +114,7 @@ def register_page() -> None: # noqa: PLR0915 @ui.refreshable def render_upload_list() -> None: if not uploaded_files: - ui.label("No files uploaded yet.").classes("text-xs ui-text-muted italic") + render_empty_state("No files uploaded yet.", italic=True) return def remove_file(index: int) -> None: @@ -217,7 +220,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 ui-header-divider"): + with section_header_row(classes="justify-between items-center"): page_header(f"Job Record: {job.id}") with ui.row().classes("items-center gap-2"): archival_badge(job.status.value.upper()) diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py index 84c0dfe..1c4a6fd 100644 --- a/src/transcription/ui/pages/people_page.py +++ b/src/transcription/ui/pages/people_page.py @@ -22,6 +22,8 @@ from transcription.ui.components.app_shell import render_navigation_header 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.primitives import render_empty_state +from transcription.ui.components.primitives import section_header_row from transcription.ui.components.table.people import PersonTableRow, render_people_table from transcription.ui.components.viewers import dark_room_viewer from transcription.ui.theme import apply_archival_theme @@ -106,7 +108,7 @@ 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 ui-header-divider"): + with section_header_row(): page_header("Archival Entities: People") ui.button( "Create new person", @@ -230,7 +232,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 ui-header-divider"): + with section_header_row(): page_header(person.full_name, subtitle=f"Person ID: {person.id}") with ui.row().classes("items-center gap-2"): @@ -273,8 +275,8 @@ def register_page() -> None: # noqa: PLR0915 with archival_card(title="Linked Documents"): if not person.document_people: - 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") + render_empty_state("No linked documents yet.", italic=True) + render_empty_state("Link this person from a Document workflow.") else: with ui.column().classes("w-full gap-2"): for link in person.document_people: diff --git a/src/transcription/ui/pages/sources_page.py b/src/transcription/ui/pages/sources_page.py index 719e05e..a9b1141 100644 --- a/src/transcription/ui/pages/sources_page.py +++ b/src/transcription/ui/pages/sources_page.py @@ -21,6 +21,7 @@ from transcription.ui.components.cards import archival_card 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.primitives import section_header_row from transcription.ui.components.table.sources import SourceTableRow, render_sources_table from transcription.ui.theme import apply_archival_theme from transcription.ui.theme import page_header @@ -76,7 +77,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 ui-header-divider"): + with section_header_row(): if document_name is not None: header_title = f"Sources: {document_name}" elif job_label is not None: @@ -129,7 +130,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 ui-header-divider"): + with section_header_row(): page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}") if back_path is not None: