Polish workbench visual shell
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-18 23:24:08 +02:00
parent c85953f389
commit 6fa41e11e4
11 changed files with 317 additions and 70 deletions
+10
View File
@@ -7,6 +7,16 @@
# Changelog
## Sprint 62 Workbench visual polish (2026-06-18)
- Added a compact workspace command bar for fast switching between the primary workbench surfaces.
- Polished the shell visual system with raised/sunken surfaces, softer shadows, tighter topbar spacing and more consistent panel styling.
- Replaced raw empty-state text in project/dataset panels with structured empty-state blocks.
- Improved Detection Lab and Segmentation Lab result summaries and wrapped long result tables in scroll-safe containers.
- Improved mobile workbench navigation by using horizontal rails for the primary nav and command chips, reducing vertical crowding without adding new behavior.
- Added static regression coverage for the visual polish contracts.
- No API contracts, migrations, backend behavior, provider fetching or AI model behavior changed.
## Sprint 61 Golden QA scenario expansion (2026-06-18)
- Expanded the deterministic QA/QC golden benchmark from one building scenario to four local fixture scenarios: partial match, perfect match, no-overlap and MultiPolygon match.
@@ -0,0 +1,41 @@
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_frontend_shell_has_visual_polish_contracts() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
css = (ROOT / "frontend" / "src" / "styles" / "app.css").read_text(encoding="utf-8")
assert "workspace-command-bar" in app
assert "workspace-nav-cluster" in app
assert "--surface-raised" in css
assert ".workspace-command-bar" in css
assert ".workspace-panel" in css
assert ".data-density-grid" in css
assert ".result-summary-card" in css
def test_primary_panels_use_empty_state_components() -> None:
project_panel = (ROOT / "frontend" / "src" / "components" / "project" / "ProjectPanel.tsx").read_text(
encoding="utf-8"
)
dataset_panel = (ROOT / "frontend" / "src" / "components" / "datasets" / "DatasetPanel.tsx").read_text(
encoding="utf-8"
)
detection_lab = (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(
encoding="utf-8"
)
segmentation_lab = (
ROOT / "frontend" / "src" / "components" / "segmentation" / "SegmentationLab.tsx"
).read_text(encoding="utf-8")
assert "empty-state" in project_panel
assert "empty-state" in dataset_panel
assert "result-summary-card" in detection_lab
assert "result-summary-card" in segmentation_lab
assert "table-scroll" in detection_lab
assert "table-scroll" in segmentation_lab
+27
View File
@@ -2407,3 +2407,30 @@ Limitations:
Next recommended pass:
- Continue with frontend visual polish backlog or add a live golden benchmark smoke only if a running PostGIS environment needs that extra release signal.
## Sprint 62 Workbench visual polish (2026-06-18)
Changed:
- Added a compact workspace command bar under the active workspace heading for quick movement between the main workbench surfaces.
- Polished the shared frontend visual system with raised/sunken surface tokens, softer shadows, tighter topbar spacing and consistent workspace panel styling.
- Replaced raw project/dataset empty-state text with structured empty-state blocks.
- Wrapped Detection Lab and Segmentation Lab result summaries in scan-friendly result cards and long result tables in scroll-safe containers.
- Improved mobile navigation density by making the primary nav and command chips horizontal rails on narrow screens.
- Added `backend/tests/test_sprint62_frontend_visual_polish.py` to guard the visual polish contracts.
- Updated `frontend/README.md`, `docs/TODO.md` and `CHANGELOG.md`.
Tested:
- Red step: `cd backend && python -m pytest tests/test_sprint62_frontend_visual_polish.py -q` failed on missing command bar, panel polish and empty/result wrappers.
- `cd backend && python -m pytest tests/test_sprint62_frontend_visual_polish.py tests/test_sprint49_workbench_shell_refactor.py tests/test_sprint50_workspace_usability_polish.py -q` (`6 passed`)
- `cd frontend && npm run typecheck`
- `cd frontend && npm run build`
- Local Chrome/Playwright visual check against `http://127.0.0.1:5175` with live API proxy: desktop and mobile screenshots captured, no console warnings/errors, no horizontal page overflow.
Open:
- Run full readiness and deploy Tower after this visual polish pass.
Limitations:
- This is a frontend usability polish pass only. It does not add new API behavior, data processing, provider fetching or AI model functionality.
Next recommended pass:
- Continue with map/result overlay ergonomics, especially making selected dataset/result provenance easier to see beside the map.
+2
View File
@@ -61,6 +61,7 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Optional final bootstrap-effect extraction.
- [x] Decide next V1 stabilization focus: golden dataset expansion or frontend visual polish backlog.
- [x] Expand golden QA/QC benchmark coverage across partial, perfect, no-overlap and MultiPolygon scenarios.
- [x] Improve workbench shell visual polish, mobile navigation density and AI result readability.
## Sprint 8 status
@@ -332,3 +333,4 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Add browser screenshot artifact automation for visual regression handoff.
- [x] Add backend error-envelope audit for expected user-error paths.
- [x] Expand golden datasets beyond the original single building QA fixture pair.
- [x] Add workbench visual polish pass for command bar, panel surfaces, empty states and mobile nav density.
+2
View File
@@ -219,6 +219,7 @@ The workbench now uses a task-based shell instead of a single long panel stack.
- Area fallback selection now lives with the owning workflow hooks: clip-area selection in `useDatasetWorkflow.ts` and map-area selection in `useMapWorkspaceState.ts`.
- Offline demo workflow orchestration lives in `src/hooks/useDemoWorkflow.ts`, because it coordinates project, dataset, map, QA/QC, detection, segmentation and export state after the backend fixture seed.
- Workbench bootstrap and reload effects live in `src/hooks/useWorkbenchBootstrap.ts`, keeping `App.tsx` focused on composing hooks into panels.
- The workbench shell includes a compact command bar, consistent raised/sunken surfaces, structured empty states and scroll-safe AI result tables to keep the V1 workflow usable across desktop and mobile widths.
## Workbench shell refactor
@@ -230,6 +231,7 @@ The workbench now uses a task-based shell instead of a single long panel stack.
- `workbench-inspector`: persistent selected dataset details and raster/vector operation controls.
- Existing API calls, hooks, MapLibre rendering and QA/AI/export flows are unchanged.
- Stable navigation test anchors use `data-testid="workspace-nav-{workspace}"`.
- Mobile workbench navigation uses horizontal rails for the primary nav and command bar, avoiding a tall menu stack before the active workspace content.
## Raster dependency visibility
+16
View File
@@ -459,6 +459,22 @@ function App(): JSX.Element {
</div>
<p>{activeWorkspaceItem.description}</p>
</div>
<div className="workspace-command-bar" aria-label="Workspace shortcuts">
<div className="workspace-nav-cluster">
{workspaceNavItems.slice(0, 5).map((item) => (
<button
key={item.key}
type="button"
className={item.key === activeWorkspace ? 'command-chip command-chip-active' : 'command-chip'}
onClick={() => setActiveWorkspace(item.key)}
aria-pressed={item.key === activeWorkspace}
>
{item.label}
</button>
))}
</div>
<span>{selectedProject ? `Active project: ${selectedProject.name}` : 'Start with a project or load the demo workflow.'}</span>
</div>
{activeWorkspace === 'overview' ? (
<div className="workspace-stack">
@@ -70,7 +70,7 @@ export function DatasetPanel({
const readyDatasets = datasets.filter((dataset) => dataset.status === 'ready').length
return (
<section data-testid="dataset-panel">
<section className="workspace-panel" data-testid="dataset-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Data catalog</p>
@@ -127,7 +127,12 @@ export function DatasetPanel({
</form>
{loadingDatasets ? <p>Loading datasets...</p> : null}
{datasets.length === 0 ? <p>No datasets yet</p> : null}
{datasets.length === 0 ? (
<div className="empty-state">
<strong>No datasets yet</strong>
<p>Upload vector, GeoJSON or raster data after selecting a project.</p>
</div>
) : null}
<ul className="dataset-list">
{datasets.map((dataset) => (
<li className={dataset.id === selectedDatasetId ? 'dataset-card dataset-card-active' : 'dataset-card'} key={dataset.id}>
@@ -144,7 +149,7 @@ export function DatasetPanel({
{dataset.status === 'ready' ? 'ready' : dataset.status === 'failed' ? 'failed' : 'pending'}
</span>
</div>
<div className="dataset-metrics">
<div className="dataset-metrics data-density-grid">
<span>size: {formatBytes(dataset.size_bytes)}</span>
<span>features: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'n/a'}</span>
<span>bbox: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</span>
@@ -85,7 +85,7 @@ export function DetectionLab({
onRunQa,
}: DetectionLabProps): JSX.Element {
return (
<section>
<section className="workspace-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Object detection</p>
@@ -166,7 +166,7 @@ export function DetectionLab({
</div>
{detectionRunError ? <p className="error">{detectionRunError}</p> : null}
{detectionRunResult ? (
<div>
<div className="result-summary-card">
<p>Status: {detectionRunResult.status}</p>
<p>Message: {detectionRunResult.message}</p>
<p>Analysis run: {detectionRunResult.analysis_run_id}</p>
@@ -221,26 +221,28 @@ export function DetectionLab({
{loadingDetectionResults ? <p>Loading detection results...</p> : null}
<p>Detections loaded: {detectionItems.length}</p>
{detectionItems.length > 0 ? (
<table>
<thead>
<tr>
<th>Class</th>
<th>Confidence</th>
<th>Model</th>
<th>Source tile</th>
</tr>
</thead>
<tbody>
{detectionItems.map((detection) => (
<tr key={detection.id}>
<td>{detection.class_name}</td>
<td>{detection.confidence.toFixed(2)}</td>
<td>{detection.model_name}</td>
<td>{detection.source_tile_path || 'n/a'}</td>
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Class</th>
<th>Confidence</th>
<th>Model</th>
<th>Source tile</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{detectionItems.map((detection) => (
<tr key={detection.id}>
<td>{detection.class_name}</td>
<td>{detection.confidence.toFixed(2)}</td>
<td>{detection.model_name}</td>
<td>{detection.source_tile_path || 'n/a'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
<div className="lab-block">
@@ -261,7 +263,7 @@ export function DetectionLab({
</button>
{detectionQaError ? <p className="error">{detectionQaError}</p> : null}
{detectionQaResult ? (
<div>
<div className="result-summary-card">
<p>Status: {detectionQaResult.status}</p>
<p>Quality check: {detectionQaResult.quality_check_id}</p>
<p>Precision: {detectionQaResult.precision?.toFixed(3) ?? 'n/a'}</p>
@@ -27,7 +27,7 @@ export function ProjectPanel({
onLoadDemoWorkflow,
}: ProjectPanelProps): JSX.Element {
return (
<section data-testid="project-panel">
<section className="workspace-panel" data-testid="project-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Context</p>
@@ -89,7 +89,12 @@ export function ProjectPanel({
</button>
</li>
))}
{projects.length === 0 ? <li>No projects yet</li> : null}
{projects.length === 0 ? (
<li className="empty-state">
<strong>No projects yet</strong>
<p>Create a project or load the offline demo workflow to populate the workbench.</p>
</li>
) : null}
</ul>
</section>
)
@@ -85,7 +85,7 @@ export function SegmentationLab({
onRunQa,
}: SegmentationLabProps): JSX.Element {
return (
<section>
<section className="workspace-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Polygon segmentation</p>
@@ -163,7 +163,7 @@ export function SegmentationLab({
) : null}
{segmentationRunError ? <p className="error">{segmentationRunError}</p> : null}
{segmentationRunResult ? (
<div>
<div className="result-summary-card">
<p>Status: {segmentationRunResult.status}</p>
<p>Message: {segmentationRunResult.message}</p>
<p>Analysis run: {segmentationRunResult.analysis_run_id}</p>
@@ -218,30 +218,32 @@ export function SegmentationLab({
{loadingSegmentationResults ? <p>Loading segmentation results...</p> : null}
<p>Segmentations loaded: {segmentationItems.length}</p>
{segmentationItems.length > 0 ? (
<table>
<thead>
<tr>
<th>Class</th>
<th>Confidence</th>
<th>Area m2</th>
<th>Model</th>
<th>Tile</th>
<th>Mask path</th>
</tr>
</thead>
<tbody>
{segmentationItems.map((segmentation) => (
<tr key={segmentation.id}>
<td>{segmentation.class_name}</td>
<td>{segmentation.confidence?.toFixed(2) ?? 'n/a'}</td>
<td>{segmentation.area_m2?.toFixed(2) ?? 'n/a'}</td>
<td>{segmentation.model_name}</td>
<td>{segmentation.source_tile_path || (segmentation.tile_index ?? 'n/a')}</td>
<td>{segmentation.mask_path || 'n/a'}</td>
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Class</th>
<th>Confidence</th>
<th>Area m2</th>
<th>Model</th>
<th>Tile</th>
<th>Mask path</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{segmentationItems.map((segmentation) => (
<tr key={segmentation.id}>
<td>{segmentation.class_name}</td>
<td>{segmentation.confidence?.toFixed(2) ?? 'n/a'}</td>
<td>{segmentation.area_m2?.toFixed(2) ?? 'n/a'}</td>
<td>{segmentation.model_name}</td>
<td>{segmentation.source_tile_path || (segmentation.tile_index ?? 'n/a')}</td>
<td>{segmentation.mask_path || 'n/a'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
<div className="lab-block">
@@ -262,7 +264,7 @@ export function SegmentationLab({
</button>
{segmentationQaError ? <p className="error">{segmentationQaError}</p> : null}
{segmentationQaResult ? (
<div>
<div className="result-summary-card">
<p>Status: {segmentationQaResult.status}</p>
<p>Quality check: {segmentationQaResult.quality_check_id}</p>
<p>Precision: {segmentationQaResult.precision?.toFixed(3) ?? 'n/a'}</p>
+152 -17
View File
@@ -4,6 +4,8 @@
--bg: #eef4f1;
--panel: #ffffff;
--panel-soft: #f8fbf9;
--surface-raised: #ffffff;
--surface-sunken: #f3f8f5;
--text: #132018;
--muted: #5f6f67;
--line: #cbd8d0;
@@ -14,6 +16,7 @@
--warning: #b45309;
--danger: #991b1b;
--shadow: 0 12px 32px rgba(33, 48, 41, 0.08);
--shadow-soft: 0 8px 24px rgba(33, 48, 41, 0.06);
color-scheme: light;
}
@@ -32,6 +35,8 @@ body {
background:
linear-gradient(180deg, rgba(15, 118, 110, 0.08), rgba(238, 244, 241, 0) 18rem),
var(--bg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button,
@@ -183,8 +188,8 @@ section {
border: 1px solid var(--line);
border-radius: 8px;
padding: 1rem;
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow);
background: rgba(255, 255, 255, 0.96);
box-shadow: var(--shadow-soft);
}
.workspace-grid {
@@ -519,12 +524,12 @@ section li strong + div {
top: 0;
z-index: 20;
display: grid;
grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr);
gap: 0.75rem;
grid-template-columns: minmax(12rem, 17rem) minmax(0, 1fr);
gap: 0.9rem;
align-items: center;
border-bottom: 1px solid var(--line);
padding: 0.55rem 0.85rem;
background: rgba(248, 251, 249, 0.96);
padding: 0.58rem 1rem;
background: rgba(250, 253, 251, 0.97);
backdrop-filter: blur(12px);
}
@@ -545,11 +550,11 @@ section li strong + div {
}
.context-bar > div {
min-height: 3.2rem;
min-height: 3rem;
border: 1px solid var(--line);
border-radius: 8px;
border-radius: 7px;
padding: 0.44rem 0.58rem;
background: #ffffff;
background: var(--surface-raised);
}
.context-bar span {
@@ -581,7 +586,7 @@ section li strong + div {
min-height: 0;
overflow: auto;
border-color: var(--line);
background: #f8fbf9;
background: var(--surface-sunken);
}
.workbench-sidebar {
@@ -636,7 +641,7 @@ section li strong + div {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 0.85rem 1rem 1rem;
padding: 0.9rem 1.05rem 1.15rem;
}
.workbench-inspector {
@@ -656,11 +661,12 @@ section li strong + div {
gap: 1rem;
align-items: end;
justify-content: space-between;
margin-bottom: 0.85rem;
margin-bottom: 0.65rem;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.72rem 0.85rem;
background: #ffffff;
border-radius: 7px;
padding: 0.76rem 0.9rem;
background: var(--surface-raised);
box-shadow: var(--shadow-soft);
}
.workspace-heading h2 {
@@ -674,6 +680,50 @@ section li strong + div {
text-align: right;
}
.workspace-command-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.8rem;
align-items: center;
margin-bottom: 0.85rem;
border: 1px solid var(--line);
border-radius: 7px;
padding: 0.48rem;
background: rgba(255, 255, 255, 0.74);
}
.workspace-command-bar > span {
max-width: 24rem;
padding-right: 0.35rem;
color: var(--muted);
font-size: 0.82rem;
line-height: 1.3;
text-align: right;
}
.workspace-nav-cluster {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.command-chip {
width: auto;
min-height: 2.05rem;
border-color: transparent;
padding: 0.32rem 0.62rem;
background: transparent;
box-shadow: none;
color: var(--muted);
font-size: 0.8rem;
}
.command-chip-active {
border-color: rgba(15, 118, 110, 0.34);
background: var(--accent-soft);
color: var(--accent-strong);
}
.workspace-stack {
display: grid;
gap: 1rem;
@@ -706,6 +756,11 @@ section li strong + div {
overflow: visible;
}
.workspace-panel {
border-radius: 7px;
background: var(--surface-raised);
}
.workspace-grid-data > section:nth-child(3) {
grid-column: 1 / -1;
}
@@ -768,6 +823,28 @@ section th {
text-transform: uppercase;
}
.table-scroll {
width: 100%;
max-height: 22rem;
overflow: auto;
margin-top: 0.75rem;
border: 1px solid var(--line);
border-radius: 7px;
background: #ffffff;
}
.table-scroll table {
min-width: 42rem;
margin-top: 0;
}
.table-scroll thead th {
position: sticky;
top: 0;
z-index: 1;
background: #f8fbf9;
}
.workbench-inspector .job-result,
.workbench-inspector pre {
max-height: 16rem;
@@ -854,6 +931,13 @@ section th {
background: var(--panel-soft);
}
.data-density-grid {
border: 1px solid var(--line);
border-radius: 7px;
padding: 0.55rem;
background: #ffffff;
}
.entity-list,
.dataset-list,
.model-list,
@@ -922,6 +1006,14 @@ button.entity-card {
padding: 0.85rem;
}
.dataset-card,
.model-card,
.quality-check-card,
.export-card,
.lab-block {
box-shadow: 0 1px 0 rgba(19, 32, 24, 0.03);
}
.dataset-card-active {
border-color: rgba(15, 118, 110, 0.5);
box-shadow: inset 4px 0 0 var(--accent);
@@ -974,6 +1066,24 @@ button.entity-card {
width: auto;
}
.result-summary-card {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 0.45rem 0.7rem;
margin-top: 0.75rem;
border: 1px solid rgba(15, 118, 110, 0.22);
border-radius: 7px;
padding: 0.72rem;
background: var(--accent-soft);
}
.result-summary-card p {
margin: 0;
color: #23423b;
font-size: 0.86rem;
line-height: 1.35;
}
.quality-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1056,6 +1166,7 @@ button.entity-card {
gap: 0.25rem;
margin-top: 0.75rem;
background: #ffffff;
color: var(--text);
}
.empty-state p,
@@ -1379,11 +1490,13 @@ button.entity-card {
}
.workspace-heading,
.workspace-command-bar,
.status-strip-header {
display: block;
}
.workspace-heading p:last-child,
.workspace-command-bar > span,
.status-next-action {
margin-top: 0.45rem;
text-align: left;
@@ -1419,11 +1532,33 @@ button.entity-card {
}
.context-bar,
.workbench-sidebar nav,
.quick-action-grid {
.quick-action-grid,
.workspace-nav-cluster {
grid-template-columns: 1fr;
}
.workbench-sidebar nav {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.2rem;
}
.workbench-sidebar .nav-item {
min-width: 8.75rem;
}
.workspace-nav-cluster {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 0.1rem;
}
.command-chip {
min-width: 6.75rem;
}
.status-strip-grid {
grid-template-columns: 1fr;
}