generated from john/python-template
UI style refresh: extract reusable components and refactor
This commit is contained in:
@@ -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.
|
||||
### 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.
|
||||
@@ -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())
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user