Add tabbed workbench inspector
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 21:23:04 +02:00
parent 36821fe099
commit bb1890b40f
10 changed files with 461 additions and 73 deletions
+8
View File
@@ -587,3 +587,11 @@ Added:
- Preserved existing API clients, callbacks and export/QA behavior. - Preserved existing API clients, callbacks and export/QA behavior.
- Added regression coverage for the QA/QC and Exports workspace structure. - Added regression coverage for the QA/QC and Exports workspace structure.
- No API contracts, migrations, product capabilities, live provider fetching or AI/model dependency changes were introduced. - No API contracts, migrations, product capabilities, live provider fetching or AI/model dependency changes were introduced.
## Sprint 52 selected context inspector tabs (2026-06-17)
- Replaced the fixed dataset-only right inspector with tabbed Context, Dataset, QA/Exports and AI Runs inspection.
- Reused the existing dataset detail component for raster/vector operations so dataset behavior and callbacks remain unchanged.
- Added context cards for selected project, AOI, map feature, latest QA/QC result, latest export and selected detection/segmentation run state.
- Added regression coverage for inspector wiring and tab structure.
- No API contracts, migrations, product capabilities, live provider fetching or AI/model dependency changes were introduced.
@@ -52,19 +52,20 @@ def test_app_still_wires_dataset_ui_callbacks() -> None:
assert "onUploadDataset={uploadDataset}" in app assert "onUploadDataset={uploadDataset}" in app
assert "onLoadDatasetDetails={loadDatasetDetails}" in app assert "onLoadDatasetDetails={loadDatasetDetails}" in app
assert "onRefreshMetadata={refreshMetadata}" in app assert "onRefreshMetadata={refreshMetadata}" in app
assert "<DatasetDetailPanel" in app assert "<WorkbenchInspector" in app
assert "onRunRasterInspect={runRasterInspect}" in app assert "datasetDetailProps={{" in app
assert "onRunRasterPreview={runRasterPreview}" in app assert "onRunRasterInspect: runRasterInspect" in app
assert "onRunRasterStats={runRasterStats}" in app assert "onRunRasterPreview: runRasterPreview" in app
assert "onRunRasterReproject={runRasterReproject}" in app assert "onRunRasterStats: runRasterStats" in app
assert "onRunRasterClip={runRasterClip}" in app assert "onRunRasterReproject: runRasterReproject" in app
assert "onRunRasterTile={runRasterTile}" in app assert "onRunRasterClip: runRasterClip" in app
assert "onRunRasterNdvi={runRasterNdvi}" in app assert "onRunRasterTile: runRasterTile" in app
assert "onRunRasterNdwi={runRasterNdwi}" in app assert "onRunRasterNdvi: runRasterNdvi" in app
assert "onRunRasterNdbi={runRasterNdbi}" in app assert "onRunRasterNdwi: runRasterNdwi" in app
assert "onRunVectorClip={runVectorClip}" in app assert "onRunRasterNdbi: runRasterNdbi" in app
assert "onRunVectorBuffer={runVectorBuffer}" in app assert "onRunVectorClip: runVectorClip" in app
assert "onRunVectorIntersect={() => runVectorIntersect(availableVectorTargets)}" in app assert "onRunVectorBuffer: runVectorBuffer" in app
assert "onRunVectorIntersect: () => runVectorIntersect(availableVectorTargets)" in app
assert '<form className="dataset-upload-form" onSubmit={onUploadDataset}' in dataset_panel assert '<form className="dataset-upload-form" onSubmit={onUploadDataset}' in dataset_panel
assert "onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)}" in dataset_panel assert "onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)}" in dataset_panel
assert "onClick={() => onRefreshMetadata(dataset.id)}" in dataset_panel assert "onClick={() => onRefreshMetadata(dataset.id)}" in dataset_panel
@@ -8,11 +8,16 @@ ROOT = Path(__file__).resolve().parents[2]
def test_app_uses_dataset_presentational_components() -> None: def test_app_uses_dataset_presentational_components() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
inspector = (
ROOT / "frontend" / "src" / "components" / "inspector" / "WorkbenchInspector.tsx"
).read_text(encoding="utf-8")
assert "from './components/datasets/DatasetPanel'" in app assert "from './components/datasets/DatasetPanel'" in app
assert "from './components/datasets/DatasetDetailPanel'" in app assert "from './components/inspector/WorkbenchInspector'" in app
assert "<DatasetPanel" in app assert "<DatasetPanel" in app
assert "<DatasetDetailPanel" in app assert "<WorkbenchInspector" in app
assert "from '../datasets/DatasetDetailPanel'" in inspector
assert "<DatasetDetailPanel {...datasetDetailProps} />" in inspector
assert "<form onSubmit={uploadDataset}" not in app assert "<form onSubmit={uploadDataset}" not in app
assert "<h3>Raster operations</h3>" not in app assert "<h3>Raster operations</h3>" not in app
assert "<h3>Vector operations</h3>" not in app assert "<h3>Vector operations</h3>" not in app
@@ -0,0 +1,45 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_app_wires_tabbed_workbench_inspector() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
assert "from './components/inspector/WorkbenchInspector'" in app
assert "<WorkbenchInspector" in app
assert "selectedProject={selectedProject}" in app
assert "selectedArea={selectedArea}" in app
assert "qualityChecks={qualityChecks}" in app
assert "latestExport={latestExport}" in app
assert "detectionRuns={detectionRuns}" in app
assert "segmentationRuns={segmentationRuns}" in app
assert "datasetDetailProps={{" in app
def test_workbench_inspector_exposes_context_dataset_quality_and_ai_tabs() -> None:
inspector = (
ROOT / "frontend" / "src" / "components" / "inspector" / "WorkbenchInspector.tsx"
).read_text(encoding="utf-8")
assert "type InspectorTab = 'context' | 'dataset' | 'quality' | 'ai'" in inspector
assert 'data-testid="workbench-inspector-panel"' in inspector
assert "data-testid={`inspector-tab-${tab.key}`}" in inspector
assert "<DatasetDetailPanel {...datasetDetailProps} />" in inspector
assert "Latest QA/QC" in inspector
assert "Latest export" in inspector
assert "Detection run" in inspector
assert "Segmentation run" in inspector
assert "fetch(" not in inspector
def test_dataset_detail_props_remain_exported_for_inspector_reuse() -> None:
dataset_panel = (
ROOT / "frontend" / "src" / "components" / "datasets" / "DatasetDetailPanel.tsx"
).read_text(encoding="utf-8")
assert "export interface DatasetDetailPanelProps" in dataset_panel
assert 'className="dataset-detail-panel"' in dataset_panel
assert "onRunRasterInspect" in dataset_panel
assert "onRunVectorIntersect" in dataset_panel
+26
View File
@@ -2118,3 +2118,29 @@ Limitations:
Next recommended pass: Next recommended pass:
- Verify QA/QC and Exports on the deployed Tower workbench, then continue with selected-object inspector detail tabs. - Verify QA/QC and Exports on the deployed Tower workbench, then continue with selected-object inspector detail tabs.
## Sprint 52 selected context inspector tabs (2026-06-17)
Changed:
- Replaced the dataset-only right inspector with a tabbed `WorkbenchInspector`.
- Added Context, Dataset, QA/Exports and AI Runs tabs using existing project, AOI, dataset, QA, export and AI-run state.
- Kept the existing `DatasetDetailPanel` as the Dataset tab so raster/vector operations and callbacks remain behavior-compatible.
- Added inspector cards for selected map feature properties, latest QA/QC, latest export and selected detection/segmentation run summaries.
- Added regression coverage for inspector wiring and tab structure.
Tested:
- `cd frontend && npm run typecheck`
- `cd backend && python -m pytest tests/test_sprint49_workbench_shell_refactor.py tests/test_sprint50_workspace_usability_polish.py tests/test_sprint51_quality_export_polish.py -q`
- `cd backend && python -m pytest tests/test_sprint28_dataset_workflow_hook.py tests/test_sprint29_dataset_components.py tests/test_sprint52_workbench_inspector_tabs.py -q`
- `cd backend && python -m pytest tests/test_sprint52_workbench_inspector_tabs.py -q`
- `cd frontend && npm run build`
- `bash scripts/run_readiness_check.sh` (`210 passed`)
Open:
- Commit, push, deploy to Tower and verify browser-facing runtime.
Limitations:
- This pass remains UI orchestration only. It does not add features, change API contracts, alter migrations, fetch live providers or enable new AI models.
Next recommended pass:
- Verify the tabbed inspector live on Tower, then continue with map/dataset selection ergonomics.
+2 -1
View File
@@ -321,4 +321,5 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Deploy the shell refactor to Tower and run live browser smoke on port 1202. - [x] Deploy the shell refactor to Tower and run live browser smoke on port 1202.
- [x] Polish Data, Map and AI Labs workspaces. - [x] Polish Data, Map and AI Labs workspaces.
- [x] Polish QA/QC and Exports workspaces. - [x] Polish QA/QC and Exports workspaces.
- [ ] Add selected-object inspector detail tabs for project, AOI, dataset, QA check, export and AI run context. - [x] Add selected-object inspector detail tabs for project, AOI, dataset, QA check, export and AI run context.
- [ ] Improve map/dataset selection ergonomics from the workbench canvas and inspector.
+70 -54
View File
@@ -1,11 +1,11 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import './styles/app.css' import './styles/app.css'
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel' import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
import { DatasetDetailPanel } from './components/datasets/DatasetDetailPanel'
import { DatasetPanel } from './components/datasets/DatasetPanel' import { DatasetPanel } from './components/datasets/DatasetPanel'
import { DetectionLab } from './components/detection/DetectionLab' import { DetectionLab } from './components/detection/DetectionLab'
import { ExportCenter } from './components/exports/ExportCenter' import { ExportCenter } from './components/exports/ExportCenter'
import { ExportPreview } from './components/exports/ExportPreview' import { ExportPreview } from './components/exports/ExportPreview'
import { WorkbenchInspector } from './components/inspector/WorkbenchInspector'
import { MapWorkspace } from './components/map/MapWorkspace' import { MapWorkspace } from './components/map/MapWorkspace'
import { AreaPanel } from './components/project/AreaPanel' import { AreaPanel } from './components/project/AreaPanel'
import { ProjectPanel } from './components/project/ProjectPanel' import { ProjectPanel } from './components/project/ProjectPanel'
@@ -690,60 +690,76 @@ function App(): JSX.Element {
</main> </main>
<aside className="workbench-inspector" aria-label="Current selection inspector"> <aside className="workbench-inspector" aria-label="Current selection inspector">
<DatasetDetailPanel <WorkbenchInspector
selectedProject={selectedProject}
selectedArea={selectedArea}
selectedMapFeature={selectedMapFeature}
areas={areas} areas={areas}
availableVectorTargets={availableVectorTargets} datasetsCount={datasets.length}
selectedDatasetId={selectedDatasetId} qualityChecks={qualityChecks}
selectedDataset={selectedDataset} exports={exports}
selectedDatasetSummary={selectedDatasetSummary} latestExport={latestExport}
selectedRasterMetadata={selectedRasterMetadata} detectionRuns={detectionRuns}
selectedRasterStats={selectedRasterStats} selectedDetectionRunId={selectedDetectionRunId}
rasterPreview={rasterPreview} detectionItems={detectionItems}
rasterUnavailableMessage={rasterUnavailableMessage} segmentationRuns={segmentationRuns}
selectedClipAreaId={selectedClipAreaId} selectedSegmentationRunId={selectedSegmentationRunId}
selectedIntersectTargetId={selectedIntersectTargetId} segmentationItems={segmentationItems}
rasterTileSize={rasterTileSize} datasetDetailProps={{
rasterTileOverlap={rasterTileOverlap} areas,
rasterTileOutputName={rasterTileOutputName} availableVectorTargets,
rasterReprojectCrs={rasterReprojectCrs} selectedDatasetId,
rasterReprojectResampling={rasterReprojectResampling} selectedDataset,
ndviNirBand={ndviNirBand} selectedDatasetSummary,
ndviRedBand={ndviRedBand} selectedRasterMetadata,
ndwiGreenBand={ndwiGreenBand} selectedRasterStats,
ndwiNirBand={ndwiNirBand} rasterPreview,
ndbiSwirBand={ndbiSwirBand} rasterUnavailableMessage,
ndbiNirBand={ndbiNirBand} selectedClipAreaId,
isRasterTileInputValid={isRasterTileInputValid} selectedIntersectTargetId,
jobs={jobs} rasterTileSize,
loadingDatasetDetails={loadingDatasetDetails} rasterTileOverlap,
datasetDetailError={datasetDetailError} rasterTileOutputName,
isVectorDatasetType={isVectorDatasetType} rasterReprojectCrs,
onSetSelectedClipAreaId={setSelectedClipAreaId} rasterReprojectResampling,
onSetSelectedIntersectTargetId={setSelectedIntersectTargetId} ndviNirBand,
onSetRasterTileSize={setRasterTileSize} ndviRedBand,
onSetRasterTileOverlap={setRasterTileOverlap} ndwiGreenBand,
onSetRasterTileOutputName={setRasterTileOutputName} ndwiNirBand,
onSetRasterReprojectCrs={setRasterReprojectCrs} ndbiSwirBand,
onSetRasterReprojectResampling={setRasterReprojectResampling} ndbiNirBand,
onSetNdviNirBand={setNdviNirBand} isRasterTileInputValid,
onSetNdviRedBand={setNdviRedBand} jobs,
onSetNdwiGreenBand={setNdwiGreenBand} loadingDatasetDetails,
onSetNdwiNirBand={setNdwiNirBand} datasetDetailError,
onSetNdbiSwirBand={setNdbiSwirBand} isVectorDatasetType,
onSetNdbiNirBand={setNdbiNirBand} onSetSelectedClipAreaId: setSelectedClipAreaId,
onRunRasterInspect={runRasterInspect} onSetSelectedIntersectTargetId: setSelectedIntersectTargetId,
onRunRasterPreview={runRasterPreview} onSetRasterTileSize: setRasterTileSize,
onRunRasterStats={runRasterStats} onSetRasterTileOverlap: setRasterTileOverlap,
onRunRasterReproject={runRasterReproject} onSetRasterTileOutputName: setRasterTileOutputName,
onRunRasterClip={runRasterClip} onSetRasterReprojectCrs: setRasterReprojectCrs,
onRunRasterTile={runRasterTile} onSetRasterReprojectResampling: setRasterReprojectResampling,
onRunRasterNdvi={runRasterNdvi} onSetNdviNirBand: setNdviNirBand,
onRunRasterNdwi={runRasterNdwi} onSetNdviRedBand: setNdviRedBand,
onRunRasterNdbi={runRasterNdbi} onSetNdwiGreenBand: setNdwiGreenBand,
onRunVectorClip={runVectorClip} onSetNdwiNirBand: setNdwiNirBand,
onRunVectorBuffer={runVectorBuffer} onSetNdbiSwirBand: setNdbiSwirBand,
onRunVectorIntersect={() => runVectorIntersect(availableVectorTargets)} onSetNdbiNirBand: setNdbiNirBand,
onPickDerivedDataset={pickDerivedDataset} onRunRasterInspect: runRasterInspect,
onRunRasterPreview: runRasterPreview,
onRunRasterStats: runRasterStats,
onRunRasterReproject: runRasterReproject,
onRunRasterClip: runRasterClip,
onRunRasterTile: runRasterTile,
onRunRasterNdvi: runRasterNdvi,
onRunRasterNdwi: runRasterNdwi,
onRunRasterNdbi: runRasterNdbi,
onRunVectorClip: runVectorClip,
onRunVectorBuffer: runVectorBuffer,
onRunVectorIntersect: () => runVectorIntersect(availableVectorTargets),
onPickDerivedDataset: pickDerivedDataset,
}}
/> />
</aside> </aside>
</div> </div>
@@ -10,7 +10,7 @@ import type {
import { RasterControls } from './RasterControls' import { RasterControls } from './RasterControls'
import { VectorControls } from './VectorControls' import { VectorControls } from './VectorControls'
interface DatasetDetailPanelProps { export interface DatasetDetailPanelProps {
areas: AreaRead[] areas: AreaRead[]
availableVectorTargets: DatasetCreateResponse[] availableVectorTargets: DatasetCreateResponse[]
selectedDatasetId: string | null selectedDatasetId: string | null
@@ -147,7 +147,7 @@ export function DatasetDetailPanel({
onPickDerivedDataset, onPickDerivedDataset,
}: DatasetDetailPanelProps) { }: DatasetDetailPanelProps) {
return ( return (
<section> <section className="dataset-detail-panel">
<h2>Dataset details</h2> <h2>Dataset details</h2>
{selectedDatasetId ? <p>Selected dataset: {selectedDatasetId}</p> : <p>No dataset selected</p>} {selectedDatasetId ? <p>Selected dataset: {selectedDatasetId}</p> : <p>No dataset selected</p>}
{selectedDataset ? ( {selectedDataset ? (
@@ -0,0 +1,186 @@
import { useMemo, useState } from 'react'
import type {
AreaRead,
DetectionRead,
DetectionRunRead,
ExportCreateResponse,
ExportRead,
ProjectRead,
QualityCheckRead,
SegmentationRead,
SegmentationRunRead,
} from '../../types'
import { DatasetDetailPanel, type DatasetDetailPanelProps } from '../datasets/DatasetDetailPanel'
type InspectorTab = 'context' | 'dataset' | 'quality' | 'ai'
interface WorkbenchInspectorProps {
selectedProject: ProjectRead | null
selectedArea: AreaRead | null
selectedMapFeature: GeoJSON.Feature | null
areas: AreaRead[]
datasetsCount: number
qualityChecks: QualityCheckRead[]
exports: ExportRead[]
latestExport: ExportCreateResponse | null
detectionRuns: DetectionRunRead[]
selectedDetectionRunId: string
detectionItems: DetectionRead[]
segmentationRuns: SegmentationRunRead[]
selectedSegmentationRunId: string
segmentationItems: SegmentationRead[]
datasetDetailProps: DatasetDetailPanelProps
}
function formatValue(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') {
return 'n/a'
}
return String(value)
}
function InspectorField({ label, value }: { label: string; value: string | number | null | undefined }): JSX.Element {
return (
<div className="inspector-field">
<span>{label}</span>
<strong>{formatValue(value)}</strong>
</div>
)
}
export function WorkbenchInspector({
selectedProject,
selectedArea,
selectedMapFeature,
areas,
datasetsCount,
qualityChecks,
exports,
latestExport,
detectionRuns,
selectedDetectionRunId,
detectionItems,
segmentationRuns,
selectedSegmentationRunId,
segmentationItems,
datasetDetailProps,
}: WorkbenchInspectorProps): JSX.Element {
const [activeTab, setActiveTab] = useState<InspectorTab>('context')
const selectedDetectionRun = useMemo(
() => detectionRuns.find((run) => run.id === selectedDetectionRunId) ?? null,
[detectionRuns, selectedDetectionRunId],
)
const selectedSegmentationRun = useMemo(
() => segmentationRuns.find((run) => run.id === selectedSegmentationRunId) ?? null,
[segmentationRuns, selectedSegmentationRunId],
)
const latestQualityCheck = qualityChecks[0] ?? null
const latestPersistedExport = exports[0] ?? null
const tabs: Array<{ key: InspectorTab; label: string }> = [
{ key: 'context', label: 'Context' },
{ key: 'dataset', label: 'Dataset' },
{ key: 'quality', label: 'QA/Exports' },
{ key: 'ai', label: 'AI Runs' },
]
return (
<section className="workbench-inspector-panel" data-testid="workbench-inspector-panel">
<div className="inspector-header">
<div>
<p className="eyebrow">Inspector</p>
<h2>Selection details</h2>
</div>
</div>
<div className="inspector-tabs" role="tablist" aria-label="Inspector detail tabs">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
className={activeTab === tab.key ? 'inspector-tab inspector-tab-active' : 'inspector-tab'}
onClick={() => setActiveTab(tab.key)}
role="tab"
aria-selected={activeTab === tab.key}
data-testid={`inspector-tab-${tab.key}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'context' ? (
<div className="inspector-tab-panel">
<div className="inspector-card">
<h3>Project context</h3>
<InspectorField label="Project" value={selectedProject?.name} />
<InspectorField label="Region" value={selectedProject?.region} />
<InspectorField label="Status" value={selectedProject?.status} />
<InspectorField label="AOIs" value={areas.length} />
<InspectorField label="Datasets" value={datasetsCount} />
</div>
<div className="inspector-card">
<h3>Active AOI</h3>
<InspectorField label="Name" value={selectedArea?.name} />
<InspectorField label="Area m2" value={selectedArea?.area_m2} />
<InspectorField label="CRS" value={selectedArea?.original_crs} />
<InspectorField label="Geometry" value={selectedArea?.geometry?.type} />
</div>
<div className="inspector-card">
<h3>Map feature</h3>
{selectedMapFeature ? (
<pre className="job-result">{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}</pre>
) : (
<p className="muted">No map feature selected.</p>
)}
</div>
</div>
) : null}
{activeTab === 'dataset' ? (
<div className="inspector-tab-panel">
<DatasetDetailPanel {...datasetDetailProps} />
</div>
) : null}
{activeTab === 'quality' ? (
<div className="inspector-tab-panel">
<div className="inspector-card">
<h3>Latest QA/QC</h3>
<InspectorField label="Check type" value={latestQualityCheck?.check_type} />
<InspectorField label="Status" value={latestQualityCheck?.status} />
<InspectorField label="Score" value={latestQualityCheck?.score} />
<InspectorField label="Metrics" value={latestQualityCheck?.metrics.length} />
<InspectorField label="Reference" value={latestQualityCheck?.reference_dataset_id} />
</div>
<div className="inspector-card">
<h3>Latest export</h3>
<InspectorField label="Created now" value={latestExport?.export_type} />
<InspectorField label="Persisted type" value={latestPersistedExport?.export_type} />
<InspectorField label="Status" value={latestPersistedExport?.status ?? latestExport?.status} />
<InspectorField label="Path" value={latestPersistedExport?.storage_path ?? latestExport?.path} />
<InspectorField label="Export count" value={exports.length} />
</div>
</div>
) : null}
{activeTab === 'ai' ? (
<div className="inspector-tab-panel">
<div className="inspector-card">
<h3>Detection run</h3>
<InspectorField label="Selected run" value={selectedDetectionRunId} />
<InspectorField label="Status" value={selectedDetectionRun?.status} />
<InspectorField label="Model" value={selectedDetectionRun?.model_name} />
<InspectorField label="Detections loaded" value={detectionItems.length} />
</div>
<div className="inspector-card">
<h3>Segmentation run</h3>
<InspectorField label="Selected run" value={selectedSegmentationRunId} />
<InspectorField label="Status" value={selectedSegmentationRun?.status} />
<InspectorField label="Model" value={selectedSegmentationRun?.model_name} />
<InspectorField label="Segmentations loaded" value={segmentationItems.length} />
</div>
</div>
) : null}
</section>
)
}
+101 -1
View File
@@ -764,7 +764,7 @@ section th {
max-height: 16rem; max-height: 16rem;
} }
.workbench-inspector h2 { .inspector-header h2 {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 2; z-index: 2;
@@ -1121,6 +1121,106 @@ button.entity-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.workbench-inspector-panel {
display: grid;
gap: 0.8rem;
}
.inspector-header {
display: grid;
gap: 0.2rem;
}
.inspector-header h2 {
margin-bottom: 0;
}
.inspector-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.35rem;
position: sticky;
top: 3.4rem;
z-index: 3;
border-bottom: 1px solid var(--line);
padding-bottom: 0.65rem;
background: #f8fbf9;
}
.inspector-tab {
min-height: 2.2rem;
padding: 0.38rem 0.42rem;
background: #ffffff;
box-shadow: none;
font-size: 0.78rem;
line-height: 1.2;
}
.inspector-tab-active {
border-color: rgba(15, 118, 110, 0.46);
background: var(--accent-soft);
color: var(--accent-strong);
}
.inspector-tab-panel {
display: grid;
gap: 0.75rem;
}
.inspector-card {
display: grid;
gap: 0.55rem;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.8rem;
background: #ffffff;
}
.inspector-card h3 {
margin-top: 0;
}
.inspector-field {
display: grid;
gap: 0.16rem;
min-width: 0;
border-top: 1px solid var(--line);
padding-top: 0.5rem;
}
.inspector-field:first-of-type {
border-top: 0;
padding-top: 0;
}
.inspector-field span {
color: var(--muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.inspector-field strong {
overflow: hidden;
font-size: 0.88rem;
line-height: 1.28;
text-overflow: ellipsis;
}
.workbench-inspector-panel .dataset-detail-panel {
border: 0;
padding: 0;
background: transparent;
box-shadow: none;
}
.dataset-detail-panel > div,
.dataset-detail-panel ul {
display: grid;
gap: 0.55rem;
}
@media (max-width: 1120px) { @media (max-width: 1120px) {
.workbench-layout { .workbench-layout {
grid-template-columns: 12rem minmax(0, 1fr); grid-template-columns: 12rem minmax(0, 1fr);