add synchronized dual-display workbench
GeoIntel release gates / Compile, test, contracts and builds (push) Failing after 31s
GeoIntel release gates / Python and npm vulnerability policy (push) Failing after 1m2s
GeoIntel release gates / GIS image, SBOM and container scan (push) Failing after 9m48s

This commit is contained in:
Jens
2026-08-21 21:50:40 +02:00
parent f085891630
commit 178892fc66
10 changed files with 567 additions and 22 deletions
+5
View File
@@ -1,5 +1,10 @@
# GeoIntel # GeoIntel
GeoIntel ondersteunt een gesynchroniseerde tweeschermwerkruimte: de kaart blijft
maximaal zichtbaar op het hoofdscherm terwijl analyses, kwaliteitsbewijs en
resultaten live in een tweede venster staan. Zie
[`docs/DUAL_DISPLAY_WORKFLOW.md`](docs/DUAL_DISPLAY_WORKFLOW.md).
<p align="center"> <p align="center">
<img src="frontend/public/geointel-icon.png" width="92" alt="GeoIntel logo"> <img src="frontend/public/geointel-icon.png" width="92" alt="GeoIntel logo">
</p> </p>
+20
View File
@@ -1,3 +1,23 @@
## 2026-08-21 - Gesynchroniseerde tweeschermwerkruimte
### Opgeleverd
- Een nieuwe knop in de werkbalk opent een afzonderlijke analyseconsole voor
een tweede monitor.
- De bestaande resultatenlade wordt via een React-portal verplaatst. Kaart en
console delen daardoor exact dezelfde selectie-, analyse- en QA-state zonder
dubbele API-aanvragen.
- Het venster onthoudt positie en afmetingen, meldt popupblokkering en valt bij
sluiten zonder dataverlies terug naar de gewone éénschermlade.
- Operator en demo krijgen dezelfde functie; bestaande rechten wijzigen niet.
### Verificatie
- TypeScript typecheck, gerichte Vitest-regressies en productiebuild slagen.
- De live gastwerkstroom is lokaal tegen de Tower-backend doorlopen: tweede
scherm openen, gebied selecteren, thema analyseren en dezelfde resultaten na
sluiten terugvinden in de hoofdinterface.
## 2026-08-10 - V74 root-cause remediation en authority-first uitvoer ## 2026-08-10 - V74 root-cause remediation en authority-first uitvoer
### Opgeleverd ### Opgeleverd
+30
View File
@@ -0,0 +1,30 @@
# Tweeschermwerkruimte
GeoIntel kan de kaart en de analyseconsole over twee browservensters verdelen
zonder een tweede sessie of dubbele analyse te starten.
## Gebruik
1. Open de kaartwerkruimte.
2. Kies **Open tweede scherm** in de bovenbalk.
3. Verplaats de analyseconsole naar het tweede beeldscherm wanneer de browser
dit niet automatisch doet.
4. Selecteer gebieden, bronnen en modellen op het hoofdscherm. Voortgang,
resultaten, GRB-controle, grafieken en downloads verschijnen live in de
analyseconsole.
5. Gebruik **Sluit console** in het tweede venster of **Sluit tweede scherm**
in de bovenbalk om terug te keren naar de normale resultatenlade.
De laatst gebruikte vensterpositie en -afmetingen worden lokaal onthouden. Als
de browser pop-ups blokkeert, toont GeoIntel een concrete melding. Er gaat geen
analyse verloren wanneer het tweede venster wordt gesloten.
## Technisch contract
- De console is een React-portal van dezelfde actieve werkruimte.
- Er ontstaat geen tweede API-sessie, modelrun of onafhankelijke statekopie.
- Kaartselectie, modelkeuze, laadstatus, QA-bewijs en resultaten blijven daarom
synchroon.
- De normale éénscherminterface blijft de fail-safe fallback.
- De functie is beschikbaar voor operator- en demosessies; bestaande
autorisatiegrenzen veranderen niet.
+7
View File
@@ -1,5 +1,12 @@
# GeoIntel TODO # GeoIntel TODO
## Tweeschermwerkruimte (2026-08-21)
- [x] Voeg een gesynchroniseerde analyseconsole voor een tweede beeldscherm toe.
- [x] Verplaats dezelfde React-resultaatstate zonder dubbele API-calls of runs.
- [x] Onthoud vensterpositie en herstel veilig naar de éénschermlade.
- [x] Voorzie popupfoutmelding, toetsenbordlabels en operator-/demopariteit.
## V74 authority-first gebouwresultaten (2026-08-10) ## V74 authority-first gebouwresultaten (2026-08-10)
- [x] Voer een V72-foutanalyse, hard-negative proposal-filter en rehearsal - [x] Voer een V72-foutanalyse, hard-negative proposal-filter en rehearsal
+26 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { CircleAlert, LogOut, ShieldCheck, UserRound } from 'lucide-react' import { CircleAlert, LogOut, MonitorUp, ShieldCheck, UserRound } from 'lucide-react'
import '@fontsource/manrope/latin-500.css' import '@fontsource/manrope/latin-500.css'
import '@fontsource/manrope/latin-600.css' import '@fontsource/manrope/latin-600.css'
import '@fontsource/manrope/latin-700.css' import '@fontsource/manrope/latin-700.css'
@@ -37,6 +37,7 @@ import {
type WorkspaceNavigationItem, type WorkspaceNavigationItem,
} from './components/shell/WorkbenchNavigation' } from './components/shell/WorkbenchNavigation'
import { WorkspaceSignal } from './components/shell/WorkspaceSignal' import { WorkspaceSignal } from './components/shell/WorkspaceSignal'
import { useSecondaryDisplay } from './components/shell/SecondaryDisplay'
import { useChangeDetectionWorkflow } from './hooks/useChangeDetectionWorkflow' import { useChangeDetectionWorkflow } from './hooks/useChangeDetectionWorkflow'
import { useDemoWorkflow } from './hooks/useDemoWorkflow' import { useDemoWorkflow } from './hooks/useDemoWorkflow'
import { useCoverageResolver } from './hooks/useCoverageResolver' import { useCoverageResolver } from './hooks/useCoverageResolver'
@@ -119,6 +120,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map') const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')
const [inspectorOpen, setInspectorOpen] = useState(false) const [inspectorOpen, setInspectorOpen] = useState(false)
const [guestDemoReady, setGuestDemoReady] = useState(!isGuest) const [guestDemoReady, setGuestDemoReady] = useState(!isGuest)
const secondaryDisplay = useSecondaryDisplay()
const guestDemoStartedRef = useRef(false) const guestDemoStartedRef = useRef(false)
const visibleWorkspaceItems = useMemo( const visibleWorkspaceItems = useMemo(
() => isGuest ? workspaceNavItems.filter((item) => guestWorkspaceKeys.has(item.key)) : workspaceNavItems, () => isGuest ? workspaceNavItems.filter((item) => guestWorkspaceKeys.has(item.key)) : workspaceNavItems,
@@ -830,7 +832,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
: 'Geen actieve laag' : 'Geen actieve laag'
return ( return (
<div className={isGuest ? "app-shell workbench-shell workbench-shell-guest" : "app-shell workbench-shell"}> <div className={`${isGuest ? 'app-shell workbench-shell workbench-shell-guest' : 'app-shell workbench-shell'}${secondaryDisplay.isOpen ? ' workbench-shell-dual-display' : ''}`}>
<a <a
className="skip-link" className="skip-link"
href="#workspace-main" href="#workspace-main"
@@ -887,6 +889,20 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
<span aria-hidden="true" /> <span aria-hidden="true" />
<strong>{workspaceDataLoading ? 'Laden' : errorMessage ? 'Aandacht' : 'Gereed'}</strong> <strong>{workspaceDataLoading ? 'Laden' : errorMessage ? 'Aandacht' : 'Gereed'}</strong>
</div> </div>
<button
type="button"
className={secondaryDisplay.isOpen ? 'secondary-display-launch secondary-display-launch-active' : 'secondary-display-launch'}
aria-pressed={secondaryDisplay.isOpen}
aria-describedby={secondaryDisplay.error ? 'secondary-display-error' : undefined}
onClick={() => {
setActiveWorkspace('map')
if (secondaryDisplay.isOpen) secondaryDisplay.close()
else secondaryDisplay.open()
}}
>
<MonitorUp aria-hidden="true" />
<span>{secondaryDisplay.isOpen ? 'Sluit tweede scherm' : 'Open tweede scherm'}</span>
</button>
{username || isGuest ? ( {username || isGuest ? (
<div <div
className={isGuest ? 'context-account context-account-guest' : 'context-account'} className={isGuest ? 'context-account context-account-guest' : 'context-account'}
@@ -902,6 +918,13 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
) : null} ) : null}
</header> </header>
{secondaryDisplay.error ? (
<div className="secondary-display-error" id="secondary-display-error" role="alert">
<CircleAlert aria-hidden="true" />
<span>{secondaryDisplay.error}</span>
</div>
) : null}
{isGuest ? ( {isGuest ? (
<div className="guest-mode-banner" role="status"> <div className="guest-mode-banner" role="status">
<ShieldCheck aria-hidden="true" /> <ShieldCheck aria-hidden="true" />
@@ -1031,6 +1054,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
<div className="workspace-persistent-map" hidden={activeWorkspace !== 'map'}> <div className="workspace-persistent-map" hidden={activeWorkspace !== 'map'}>
<MapWorkspace <MapWorkspace
readOnly={false} readOnly={false}
secondaryResultsContainer={secondaryDisplay.container}
selectedProjectId={selectedProjectId} selectedProjectId={selectedProjectId}
projects={projects} projects={projects}
areas={areas} areas={areas}
+26 -19
View File
@@ -9,6 +9,7 @@ import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/da
import { TemporalTrendChart } from './TemporalTrendChart' import { TemporalTrendChart } from './TemporalTrendChart'
import { MunicipalitySearch } from './MunicipalitySearch' import { MunicipalitySearch } from './MunicipalitySearch'
import { LiveAnalysisJourney } from './LiveAnalysisJourney' import { LiveAnalysisJourney } from './LiveAnalysisJourney'
import { SecondaryDisplayTarget } from '../shell/SecondaryDisplay'
import { terrainImageUrl } from '../../lib/terrainImage' import { terrainImageUrl } from '../../lib/terrainImage'
import { floodHazardImageUrl } from '../../lib/floodHazardImage' import { floodHazardImageUrl } from '../../lib/floodHazardImage'
import { thematicRasterImageUrl, walousRasterImageUrl } from '../../lib/thematicRaster' import { thematicRasterImageUrl, walousRasterImageUrl } from '../../lib/thematicRaster'
@@ -645,6 +646,7 @@ function floodScenarioLabel(dataset: DatasetCreateResponse): string {
interface MapWorkspaceProps { interface MapWorkspaceProps {
readOnly?: boolean readOnly?: boolean
secondaryResultsContainer?: HTMLElement | null
selectedProjectId: string | null selectedProjectId: string | null
projects: ProjectRead[] projects: ProjectRead[]
areas: AreaRead[] areas: AreaRead[]
@@ -740,6 +742,7 @@ interface MapWorkspaceProps {
export function MapWorkspace({ export function MapWorkspace({
readOnly = false, readOnly = false,
secondaryResultsContainer = null,
selectedProjectId, selectedProjectId,
projects, projects,
areas, areas,
@@ -2689,25 +2692,28 @@ export function MapWorkspace({
</div> </div>
</div> </div>
<button {secondaryResultsContainer ? null : (
className={resultsPanelOpen ? 'geo-results-toggle geo-results-toggle-open' : 'geo-results-toggle'} <button
type="button" className={resultsPanelOpen ? 'geo-results-toggle geo-results-toggle-open' : 'geo-results-toggle'}
aria-controls="geo-explorer-results" type="button"
aria-expanded={resultsPanelOpen} aria-controls="geo-explorer-results"
aria-label={resultsPanelOpen ? 'Sluit inzichten' : 'Open inzichten'} aria-expanded={resultsPanelOpen}
onClick={() => setResultsPanelOpen((open) => !open)} aria-label={resultsPanelOpen ? 'Sluit inzichten' : 'Open inzichten'}
> onClick={() => setResultsPanelOpen((open) => !open)}
{resultsPanelOpen ? <ChevronRight aria-hidden="true" /> : <ChevronLeft aria-hidden="true" />} >
<span>{resultsPanelOpen ? 'Sluit inzichten' : 'Inzichten'}</span> {resultsPanelOpen ? <ChevronRight aria-hidden="true" /> : <ChevronLeft aria-hidden="true" />}
</button> <span>{resultsPanelOpen ? 'Sluit inzichten' : 'Inzichten'}</span>
</button>
)}
<aside <SecondaryDisplayTarget container={secondaryResultsContainer}>
id="geo-explorer-results" <aside
className={resultsPanelOpen ? 'geo-results-panel geo-results-panel-open' : 'geo-results-panel'} id={secondaryResultsContainer ? 'geo-secondary-results' : 'geo-explorer-results'}
aria-label="Gebiedsanalyse" className={secondaryResultsContainer ? 'geo-results-panel geo-results-panel-open geo-results-panel-secondary' : resultsPanelOpen ? 'geo-results-panel geo-results-panel-open' : 'geo-results-panel'}
aria-live="polite" aria-label="Gebiedsanalyse"
aria-hidden={!resultsPanelOpen} aria-live="polite"
> aria-hidden={secondaryResultsContainer ? false : !resultsPanelOpen}
>
<div className="geo-panel-heading"> <div className="geo-panel-heading">
<div> <div>
<h3>Inzichten</h3> <h3>Inzichten</h3>
@@ -3031,7 +3037,8 @@ export function MapWorkspace({
: ''} : ''}
</p> </p>
) : null} ) : null}
</aside> </aside>
</SecondaryDisplayTarget>
</div> </div>
<footer className="geo-explorer-footer"> <footer className="geo-explorer-footer">
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { parseSecondaryDisplayGeometry, secondaryDisplayFeatures } from './secondaryDisplayGeometry'
describe('secondary display geometry', () => {
it('restores a valid remembered window position', () => {
expect(parseSecondaryDisplayGeometry('{"left":1920,"top":0,"width":720,"height":1040}')).toEqual({
left: 1920,
top: 0,
width: 720,
height: 1040,
})
})
it('rejects malformed state and bounds unsafe dimensions', () => {
expect(parseSecondaryDisplayGeometry('not-json')).toBeNull()
expect(parseSecondaryDisplayGeometry('{"left":0,"top":0,"width":2,"height":99999}')).toEqual({
left: 0,
top: 0,
width: 420,
height: 2160,
})
})
it('builds a resizable popup feature string', () => {
expect(secondaryDisplayFeatures({ left: 1920, top: 10, width: 700, height: 900 })).toContain(
'left=1920,top=10,width=700,height=900,resizable=yes,scrollbars=yes',
)
})
})
@@ -0,0 +1,166 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import {
parseSecondaryDisplayGeometry,
secondaryDisplayFeatures,
type SecondaryDisplayGeometry,
} from './secondaryDisplayGeometry'
const SECONDARY_DISPLAY_NAME = 'geointel-analysis-console'
const SECONDARY_DISPLAY_GEOMETRY_KEY = 'geointel.secondary-display.geometry.v1'
function defaultGeometry(): SecondaryDisplayGeometry {
return {
left: window.screenX + Math.max(80, window.outerWidth - 32),
top: window.screenY,
width: 620,
height: Math.max(640, Math.min(1_080, window.outerHeight)),
}
}
function copyDocumentStyles(target: Document): void {
document.head.querySelectorAll<HTMLLinkElement | HTMLStyleElement>('link[rel="stylesheet"], style').forEach((node) => {
const clone = node.cloneNode(true) as HTMLLinkElement | HTMLStyleElement
if (clone instanceof HTMLLinkElement) clone.href = node instanceof HTMLLinkElement ? node.href : ''
target.head.append(clone)
})
}
function initialiseSecondaryDocument(target: Window, onClose: () => void): HTMLElement {
const targetDocument = target.document
targetDocument.title = 'GeoIntel · Analyseconsole'
targetDocument.documentElement.lang = document.documentElement.lang || 'nl'
targetDocument.head.replaceChildren()
const viewport = targetDocument.createElement('meta')
viewport.name = 'viewport'
viewport.content = 'width=device-width, initial-scale=1'
targetDocument.head.append(viewport)
copyDocumentStyles(targetDocument)
const shell = targetDocument.createElement('div')
shell.className = 'secondary-display-shell'
const header = targetDocument.createElement('header')
header.className = 'secondary-display-header'
header.innerHTML = `
<div class="secondary-display-brand">
<img src="/geointel-icon.svg" alt="" aria-hidden="true" />
<span><small>LIVE GEKOPPELD</small><strong>Analyseconsole</strong></span>
</div>
<div class="secondary-display-status"><i aria-hidden="true"></i><span>Gesynchroniseerd met de kaart</span></div>
`
const closeButton = targetDocument.createElement('button')
closeButton.type = 'button'
closeButton.className = 'secondary-display-close'
closeButton.textContent = 'Sluit console'
closeButton.addEventListener('click', onClose)
header.append(closeButton)
const content = targetDocument.createElement('main')
content.className = 'secondary-display-content'
content.id = 'secondary-display-content'
content.setAttribute('aria-label', 'Live analyse en resultaten')
shell.append(header, content)
targetDocument.body.replaceChildren(shell)
targetDocument.body.className = 'secondary-display-body'
return content
}
export function useSecondaryDisplay() {
const popupRef = useRef<Window | null>(null)
const monitorTimerRef = useRef<number | null>(null)
const [container, setContainer] = useState<HTMLElement | null>(null)
const [error, setError] = useState<string | null>(null)
const stopMonitoring = useCallback(() => {
if (monitorTimerRef.current !== null) {
window.clearInterval(monitorTimerRef.current)
monitorTimerRef.current = null
}
}, [])
const rememberGeometry = useCallback((popup: Window) => {
const geometry: SecondaryDisplayGeometry = {
left: popup.screenX,
top: popup.screenY,
width: popup.outerWidth,
height: popup.outerHeight,
}
try {
window.localStorage.setItem(SECONDARY_DISPLAY_GEOMETRY_KEY, JSON.stringify(geometry))
} catch {
// Window positioning is a convenience; storage restrictions must never
// break the live analysis console.
}
}, [])
const close = useCallback(() => {
const popup = popupRef.current
if (popup && !popup.closed) {
rememberGeometry(popup)
popup.close()
}
popupRef.current = null
setContainer(null)
stopMonitoring()
}, [rememberGeometry, stopMonitoring])
const open = useCallback(() => {
const existing = popupRef.current
if (existing && !existing.closed) {
existing.focus()
return
}
let saved: SecondaryDisplayGeometry | null = null
try {
saved = parseSecondaryDisplayGeometry(window.localStorage.getItem(SECONDARY_DISPLAY_GEOMETRY_KEY))
} catch {
saved = null
}
const popup = window.open('', SECONDARY_DISPLAY_NAME, secondaryDisplayFeatures(saved ?? defaultGeometry()))
if (!popup) {
setError('De browser blokkeerde het tweede venster. Sta pop-ups voor GeoIntel toe en probeer opnieuw.')
return
}
setError(null)
popupRef.current = popup
const content = initialiseSecondaryDocument(popup, close)
setContainer(content)
popup.focus()
stopMonitoring()
monitorTimerRef.current = window.setInterval(() => {
const current = popupRef.current
if (!current || current.closed) {
popupRef.current = null
setContainer(null)
stopMonitoring()
return
}
rememberGeometry(current)
}, 1_000)
}, [close, rememberGeometry, stopMonitoring])
useEffect(() => close, [close])
return {
container,
error,
isOpen: container !== null,
open,
close,
}
}
interface SecondaryDisplayTargetProps {
container: HTMLElement | null
children: ReactNode
}
export function SecondaryDisplayTarget({ container, children }: SecondaryDisplayTargetProps) {
return container ? createPortal(children, container) : children
}
@@ -0,0 +1,40 @@
export interface SecondaryDisplayGeometry {
left: number
top: number
width: number
height: number
}
function boundedInteger(value: unknown, minimum: number, maximum: number): number | null {
const parsed = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(parsed)) return null
return Math.min(maximum, Math.max(minimum, Math.round(parsed)))
}
export function parseSecondaryDisplayGeometry(value: string | null): SecondaryDisplayGeometry | null {
if (!value) return null
try {
const parsed = JSON.parse(value) as Partial<SecondaryDisplayGeometry>
const left = boundedInteger(parsed.left, -20_000, 20_000)
const top = boundedInteger(parsed.top, -20_000, 20_000)
const width = boundedInteger(parsed.width, 420, 3_840)
const height = boundedInteger(parsed.height, 520, 2_160)
return left === null || top === null || width === null || height === null
? null
: { left, top, width, height }
} catch {
return null
}
}
export function secondaryDisplayFeatures(geometry: SecondaryDisplayGeometry): string {
return [
'popup=yes',
`left=${geometry.left}`,
`top=${geometry.top}`,
`width=${geometry.width}`,
`height=${geometry.height}`,
'resizable=yes',
'scrollbars=yes',
].join(',')
}
+218 -1
View File
@@ -1990,6 +1990,223 @@ button.overview-command-card { cursor: pointer; }
.context-health { min-width: auto; border: 0; padding-inline: 0.45rem; background: transparent; box-shadow: none; } .context-health { min-width: auto; border: 0; padding-inline: 0.45rem; background: transparent; box-shadow: none; }
.context-account { border-color: var(--gi-line); box-shadow: none; } .context-account { border-color: var(--gi-line); box-shadow: none; }
/* Synchronized dual-display workbench */
.secondary-display-launch {
display: inline-flex;
flex: 0 0 auto;
gap: 0.45rem;
align-items: center;
justify-content: center;
min-height: 2.35rem;
border: 1px solid var(--gi-line);
border-radius: 999px;
padding: 0.45rem 0.75rem;
background: var(--gi-surface);
color: var(--gi-ink-700);
font: inherit;
font-size: var(--gi-text-2xs);
font-weight: 750;
cursor: pointer;
transition: border-color var(--gi-dur-2) var(--gi-ease), background var(--gi-dur-2) var(--gi-ease), color var(--gi-dur-2) var(--gi-ease);
}
.secondary-display-launch svg {
width: 1rem;
height: 1rem;
}
.secondary-display-launch:hover,
.secondary-display-launch:focus-visible {
border-color: var(--gi-brand-400);
background: var(--gi-brand-50);
color: var(--gi-brand-800);
}
.secondary-display-launch-active {
border-color: var(--gi-brand-600);
background: var(--gi-brand-800);
color: #fff;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--gi-brand-500) 14%, transparent);
}
.secondary-display-launch-active:hover,
.secondary-display-launch-active:focus-visible {
border-color: var(--gi-brand-700);
background: var(--gi-brand-700);
color: #fff;
}
.secondary-display-error {
display: flex;
gap: 0.55rem;
align-items: center;
border-bottom: 1px solid #f2c8c2;
padding: 0.55rem 0.9rem;
background: #fff4f2;
color: #8b2f25;
font-size: var(--gi-text-xs);
}
.secondary-display-error svg {
width: 1rem;
height: 1rem;
}
.secondary-display-body {
min-width: 420px;
min-height: 100dvh;
margin: 0;
overflow: hidden;
background: var(--gi-canvas);
color: var(--gi-ink-900);
}
.secondary-display-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: 100vw;
height: 100dvh;
background: var(--gi-canvas);
}
.secondary-display-header {
position: relative;
z-index: 20;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.75rem;
align-items: center;
min-height: 4.5rem;
border-bottom: 1px solid var(--gi-line);
padding: 0.65rem 0.85rem;
background: color-mix(in srgb, var(--gi-surface) 96%, transparent);
box-shadow: 0 8px 24px rgba(5, 46, 43, 0.06);
}
.secondary-display-brand {
display: flex;
gap: 0.65rem;
align-items: center;
min-width: 0;
}
.secondary-display-brand img {
width: 2.35rem;
height: 2.35rem;
}
.secondary-display-brand span {
display: grid;
min-width: 0;
}
.secondary-display-brand small {
color: var(--gi-brand-600);
font-size: 0.58rem;
font-weight: 800;
letter-spacing: 0.09em;
}
.secondary-display-brand strong {
overflow: hidden;
font-family: var(--gi-font-display);
font-size: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.secondary-display-status {
display: inline-flex;
gap: 0.4rem;
align-items: center;
border-radius: 999px;
padding: 0.45rem 0.65rem;
background: var(--gi-brand-50);
color: var(--gi-brand-800);
font-size: 0.68rem;
font-weight: 700;
}
.secondary-display-status i {
width: 0.48rem;
height: 0.48rem;
border-radius: 50%;
background: var(--gi-accent-500);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--gi-accent-500) 15%, transparent);
}
.secondary-display-close {
min-height: 2.3rem;
border: 1px solid var(--gi-line);
border-radius: var(--gi-radius-md);
padding: 0.45rem 0.65rem;
background: var(--gi-surface);
color: var(--gi-ink-700);
font: inherit;
font-size: 0.68rem;
font-weight: 700;
cursor: pointer;
}
.secondary-display-close:hover,
.secondary-display-close:focus-visible {
border-color: var(--gi-brand-400);
color: var(--gi-brand-800);
}
.secondary-display-content {
min-width: 0;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: 0.85rem;
}
.secondary-display-content > .geo-results-panel,
.secondary-display-content > .geo-results-panel.geo-results-panel-open {
position: static !important;
inset: auto !important;
display: flex;
width: 100% !important;
max-width: none !important;
height: auto !important;
max-height: none !important;
min-height: calc(100dvh - 6.2rem);
overflow: visible !important;
border: 1px solid var(--gi-line) !important;
border-radius: var(--gi-radius-lg) !important;
background: var(--gi-surface) !important;
box-shadow: var(--gi-shadow-sm) !important;
visibility: visible !important;
opacity: 1 !important;
pointer-events: auto !important;
transform: none !important;
animation: none !important;
}
.secondary-display-content .geo-results-panel > .geo-panel-heading {
position: sticky;
z-index: 4;
top: -0.85rem;
border-bottom: 1px solid var(--gi-line);
background: color-mix(in srgb, var(--gi-surface) 96%, transparent);
backdrop-filter: blur(12px);
}
.workbench-shell-dual-display .geo-map-stage {
border-right-color: transparent;
}
@media (max-width: 820px) {
.secondary-display-launch span { display: none; }
.secondary-display-launch { width: 2.5rem; padding-inline: 0; }
}
@media (max-width: 560px) {
.secondary-display-header { grid-template-columns: minmax(0, 1fr) auto; }
.secondary-display-status { display: none; }
}
.geo-explorer-layout, .geo-explorer-layout,
.workbench-shell-guest .geo-explorer-layout { .workbench-shell-guest .geo-explorer-layout {
grid-template-columns: clamp(18rem, 18vw, 22rem) minmax(0, 1fr); grid-template-columns: clamp(18rem, 18vw, 22rem) minmax(0, 1fr);
@@ -2092,4 +2309,4 @@ button.overview-command-card { cursor: pointer; }
.geo-explorer-layout, .workbench-shell-guest .geo-explorer-layout { grid-template-rows: 15rem minmax(25rem, 62vh); } .geo-explorer-layout, .workbench-shell-guest .geo-explorer-layout { grid-template-rows: 15rem minmax(25rem, 62vh); }
.geo-theme-panel { max-height: 15rem; } .geo-theme-panel { max-height: 15rem; }
.geo-theme-list { max-height: 7.2rem; min-height: 4.75rem; } .geo-theme-list { max-height: 7.2rem; min-height: 4.75rem; }
} }