UI style refresh: extract reusable components and refactor

This commit is contained in:
Jim Lancaster
2026-08-03 15:28:58 -05:00
parent 752346025b
commit f80834d589
6 changed files with 143 additions and 54 deletions
+100 -38
View File
@@ -1,50 +1,112 @@
# UI Design Specification # UI Style Guide (Invariant)
## 1. Overview and Aesthetic Philosophy ## 1. Purpose
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. 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 If this document conflicts with implementation, update this document to match the code immediately after intentional style changes.
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.
### 2.2. High-Density Flat Design (Flat 2.0) ## 3. Core Design Invariants
The styling deliberately avoids heavy drop shadows or aggressive 3D glassmorphism. 1. Flat, high-density surfaces over decorative depth.
* **Subtle Separation:** Relies on 1px borders and slight variations in background shade to separate functional zones. 2. Strong content hierarchy with subdued backgrounds and border-based separation.
* **Data Density:** Prioritizes fitting a large amount of structured data (processing metadata, related entities, tokens) into a compact space while remaining readable. 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 ## 4. Token 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.
## 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 Semantic tokens currently include:
* **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. 1. --theme-text and --theme-text-muted
* **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. 2. --theme-page, --theme-surface, --theme-surface-raised, --theme-surface-muted
* **The "Accents" (Interactive Elements):** A sophisticated forest green for primary actions/tabs, and warm earthy sepia/tan for badges and highlights. 3. --theme-border
* **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. 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) | ### 5.1 Text and Background
| :--- | :--- | :--- | :--- | 1. ui-text-primary
| App Background | Archival Cream | `#FAF9F6` | `stone-50` | 2. ui-text-muted
| Card Surface | Folder Stock | `#F4F0E6` | `stone-100` | 3. ui-text-inverse
| Image Viewer | Matte Slate | `#2B2D2C` | `zinc-800` | 4. ui-bg-page
| Primary Text | Iron Ink | `#333333` | `neutral-800` | 5. ui-bg-surface
| Secondary Text | Faded Type | `#6B6A65` | `stone-500` | 6. ui-bg-surface-raised
| Primary Accent | Library Green | `#2D5A4C` | `emerald-800` | 7. ui-bg-surface-muted
| Secondary Accent | Aged Sepia | `#E2C7A8` | `orange-200` | 8. ui-bg-viewer
9. ui-bg-viewer-overlay
10. ui-bg-viewer-overlay-soft
## 4. Implementation Guidelines ### 5.2 Borders and Surfaces
* When generating layout code, utilize UI framework grid utilities to construct the Bento Grid. 1. ui-border-subtle
* Map the custom hex codes to the application's global theme variables to ensure consistency across all components. 2. ui-border-viewer
* Prioritize static borders and background colors over hover effects or transition animations to maintain the academic utility of the interface. 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.
@@ -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())
+7 -5
View File
@@ -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 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.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 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.viewers import dark_room_viewer from transcription.ui.components.viewers import dark_room_viewer
@@ -147,7 +149,7 @@ 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 ui-header-divider"): with section_header_row():
page_header("Archival Documents") page_header("Archival Documents")
ui.button( ui.button(
"Create new document", "Create new document",
@@ -211,7 +213,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 ui-header-divider"): with section_header_row():
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"):
@@ -267,7 +269,7 @@ def register_page() -> None:
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 ui-text-muted italic") render_empty_state("No linked people yet.", italic=True)
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:
@@ -316,7 +318,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 ui-header-divider"): with section_header_row():
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(
@@ -332,7 +334,7 @@ def register_page() -> None:
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 ui-text-muted") render_empty_state("No transcription processing jobs created yet.")
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):
+8 -5
View File
@@ -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 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.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.components.table.jobs import render_jobs_table
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.ui.theme import page_header
@@ -38,7 +40,7 @@ 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 ui-header-divider"): with section_header_row():
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(
@@ -75,8 +77,9 @@ def register_page() -> None: # noqa: PLR0915
documents = await documents_service.list_documents() documents = await documents_service.list_documents()
if not documents: if not documents:
with archival_card(extra_classes="p-6 text-center"): with archival_card(extra_classes="p-6 text-center"):
ui.label("No documents available. Create a Document before creating a Job.").classes( render_empty_state(
"text-xs text-red-800 font-medium mb-4" "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"): with ui.row().classes("justify-center gap-2"):
ui.button( ui.button(
@@ -111,7 +114,7 @@ def register_page() -> None: # noqa: PLR0915
@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 ui-text-muted italic") render_empty_state("No files uploaded yet.", italic=True)
return return
def remove_file(index: int) -> None: def remove_file(index: int) -> None:
@@ -217,7 +220,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 ui-header-divider"): with section_header_row(classes="justify-between items-center"):
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())
+6 -4
View File
@@ -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.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.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.table.people import PersonTableRow, render_people_table
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
@@ -106,7 +108,7 @@ 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 ui-header-divider"): with section_header_row():
page_header("Archival Entities: People") page_header("Archival Entities: People")
ui.button( ui.button(
"Create new person", "Create new person",
@@ -230,7 +232,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 ui-header-divider"): with section_header_row():
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"):
@@ -273,8 +275,8 @@ def register_page() -> None: # noqa: PLR0915
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 ui-text-muted italic") render_empty_state("No linked documents yet.", italic=True)
ui.label("Link this person from a Document workflow.").classes("text-xs ui-text-muted") render_empty_state("Link this person from a Document workflow.")
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:
+3 -2
View File
@@ -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.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.primitives import section_header_row
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.theme import apply_archival_theme from transcription.ui.theme import apply_archival_theme
from transcription.ui.theme import page_header from transcription.ui.theme import page_header
@@ -76,7 +77,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 ui-header-divider"): with section_header_row():
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:
@@ -129,7 +130,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 ui-header-divider"): with section_header_row():
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: