diff --git a/README.md b/README.md index 1c6f862..ae702a6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ not infrastructure around accounts, queues, consensus, or model training. - CSV, TSV, JSONL, NDJSON, and plain-text input - Schema-driven labels with validated, deterministic colors +- Read-only schema preview with label colors and number-key mappings - Offset-based span annotations with overlap protection - Multiple-event navigation with unsaved-change prompts - UTF-8 JSONL export containing source text, metadata, schema, and annotations diff --git a/src/spanselector/assets/app.css b/src/spanselector/assets/app.css index 87030f7..104b555 100644 --- a/src/spanselector/assets/app.css +++ b/src/spanselector/assets/app.css @@ -197,6 +197,60 @@ body { text-overflow: ellipsis; white-space: nowrap; } +.schema-preview-trigger { + color: #b9adff !important; +} +.schema-preview-dialog { + width: min(92vw, 620px); + max-height: 86vh; + padding: 24px; + gap: 18px; + overflow-y: auto; +} +.schema-preview-name { + overflow: hidden; + color: var(--text); + font-size: 16px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} +.schema-preview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + width: 100%; +} +.schema-preview-item { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: #101014; +} +.schema-color-swatch { + flex: 0 0 auto; + width: 14px; + height: 32px; + border: 1px solid rgb(255 255 255 / 16%); + border-radius: 999px; + background: var(--schema-color); +} +.schema-preview-field { + overflow: hidden; + color: #e7e7ec; + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} +.schema-preview-color { + color: var(--text-muted); + font-size: 10px; +} .preview-panel { min-height: 112px; padding: 18px; @@ -454,6 +508,7 @@ body { .content-panel, .setup-panel { padding: 18px; } .navigation-actions .q-btn { flex: 1 1 auto; } .label-grid { grid-template-columns: 1fr 1fr; } + .schema-preview-grid { grid-template-columns: 1fr; } .shortcut-item:nth-of-type(n+4) { display: none; } } @media (prefers-reduced-motion: reduce) { diff --git a/src/spanselector/models.py b/src/spanselector/models.py index 1263d9d..2da191b 100644 --- a/src/spanselector/models.py +++ b/src/spanselector/models.py @@ -157,6 +157,7 @@ class UIRefs(BaseModel): text_box: Any = None confirm_dialog: Any = None schema_confirm_dialog: Any = None + schema_preview_dialog: Any = None selected_text_label: Any = None selected_range_label: Any = None total_assignments_label: Any = None diff --git a/src/spanselector/web_ui.py b/src/spanselector/web_ui.py index 6e76f9b..df8c0bb 100644 --- a/src/spanselector/web_ui.py +++ b/src/spanselector/web_ui.py @@ -260,6 +260,43 @@ def apply_schema_change(new_schema: str, *, discard_existing: bool) -> None: selection_action_tray.refresh() +def close_schema_preview() -> None: + if refs.schema_preview_dialog is not None: + refs.schema_preview_dialog.close() + + +def open_schema_preview() -> None: + schema_preview_content.refresh() + if refs.schema_preview_dialog is not None: + refs.schema_preview_dialog.open() + + +@ui.refreshable +def schema_preview_content() -> None: + schema = SCHEMAS[state.selected_schema] + with ui.row().classes("items-center justify-between gap-3 w-full"): + with ui.column().classes("gap-0 min-w-0"): + ui.label(state.selected_schema).classes("schema-preview-name") + ui.label(f"{len(schema.fields)} labels in selection order").classes("supporting-text") + ui.badge(f"{len(schema.fields)} labels").classes("count-pill") + + with ui.element("div").classes("schema-preview-grid"): + for index, field in enumerate(schema.fields, start=1): + color = schema.colors[field] + with ui.element("div").classes("schema-preview-item"): + ui.element("span").classes("schema-color-swatch").style( + f"--schema-color: {color}" + ).props("aria-hidden=true") + with ui.column().classes("gap-0 min-w-0"): + ui.label(field).classes("schema-preview-field") + ui.label(color.upper()).classes("schema-preview-color font-mono") + ui.space() + if index <= 9: + ui.label(str(index)).classes("keycap").props( + f"aria-label='Keyboard shortcut {index}'" + ) + + @ui.refreshable def annotated_preview() -> None: ui.html( @@ -382,6 +419,15 @@ def on_schema_change(e) -> None: on_change=on_schema_change, ).classes("w-full") + with ui.row().classes("justify-end w-full"): + ui.button( + "Preview schema", + icon="visibility", + on_click=open_schema_preview, + ).classes("schema-preview-trigger").props( + "flat dense no-caps aria-label='Preview selected schema'" + ) + build_schema_confirm_dialog(schema_select) assignment_section() @@ -551,6 +597,25 @@ def discard_and_change() -> None: ) +def build_schema_preview_dialog() -> None: + refs.schema_preview_dialog = ui.dialog() + with refs.schema_preview_dialog: + with ui.card().classes("schema-preview-dialog"): + with ui.row().classes("items-start justify-between gap-4 w-full"): + with ui.column().classes("gap-1"): + ui.label("Schema preview").classes("dialog-title") + ui.label("Labels available for the active annotation schema.").classes( + "supporting-text" + ) + ui.button(icon="close", on_click=close_schema_preview).props( + "flat round dense aria-label='Close schema preview'" + ) + ui.separator() + schema_preview_content() + with ui.row().classes("justify-end w-full"): + ui.button("Close", on_click=close_schema_preview).props("outline no-caps") + + def create_ui() -> None: configure_theme() refs.app_shell = ( @@ -568,5 +633,6 @@ def create_ui() -> None: build_main_panel() build_navigation() build_confirm_dialog() + build_schema_preview_dialog() build_shortcuts_dialog() load_event(state.current_index, refresh=False) diff --git a/tests/e2e/test_selection.py b/tests/e2e/test_selection.py index b238f10..9915dca 100644 --- a/tests/e2e/test_selection.py +++ b/tests/e2e/test_selection.py @@ -273,6 +273,24 @@ def test_shortcut_reference_is_available_from_bottom_helper(page: Page, live_ser expect(page.get_by_text("Keyboard shortcuts", exact=True)).to_be_hidden() +@pytest.mark.e2e +def test_selected_schema_can_be_previewed_and_closed(page: Page, live_server: str) -> None: + page.goto(live_server) + + page.get_by_role("button", name="Preview selected schema").click() + + dialog = page.locator(".schema-preview-dialog") + expect(dialog).to_be_visible() + expect(dialog.get_by_text("Schema preview", exact=True)).to_be_visible() + expect(dialog.get_by_text("Customer Support Ticket", exact=True)).to_be_visible() + expect(dialog.get_by_text("9 labels in selection order", exact=True)).to_be_visible() + expect(dialog.get_by_text("TicketId", exact=True)).to_be_visible() + expect(dialog.get_by_text("Resolution", exact=True)).to_be_visible() + + dialog.get_by_role("button", name="Close schema preview").click() + expect(dialog).to_be_hidden() + + @pytest.mark.e2e def test_shortcuts_can_be_disabled_from_reference(page: Page, live_server: str) -> None: page.goto(live_server)