Keep Directions reachable and tested in the Utilities panel

Voice show_data_layers_menu opens the panel that holds the row, so asking
for Directions opens UTILITIES and reports that panel, not a hidden row
under Data Layers. In Cockpit the panel stays with Directions alone, as
Data Layers does; only Draw and Import step aside there.

Draw's options sit flat under its name instead of keeping the card that
hung under a DISPLAY button, and its Shape label no longer takes DISPLAY's
label column in other languages. Cyber marks Draw on with its red edge.

LayerPanel routing, the voice panel choice, and the panel's order, mode
and phone-sheet CSS gain tests that fail on the earlier code. The form-field
and segmented-control gates open Utilities for Draw's controls, measuring
Scenes and Utilities in turn since the left rail fits one tall panel.
README, CONTRIBUTING, the PR template, CURRENT-STATE and comments now say
UTILITIES for Draw and Import and list the Audio group, and two files
outside the format scope lose an unrelated reflow.
This commit is contained in:
Said Turksever committed 2026-10-29 11:53:15 +00:00
1 parent d730b0ce87
commit 8543b7be43
22 files changed
+319 -97

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Manual whiteboard drawing: the Cesium + DOM half.
*
* DISPLAY ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* UTILITIES ▸ Draw turns the globe into a whiteboard you draw on by hand: pick a
* shape (area, line or pin), click the vertices on the real world, double-click
* or press Enter to finish, type a label. Each finished shape goes through the
* SAME `annotationEngine.annotate()` the voice agent uses, with its geometry
+7 -5
View File
@@ -138,12 +138,14 @@ test('nothing promises a GeoJSON export for drawn shapes', () => {
`${file} still claims a GeoJSON export`,
);
}
for (const file of ['README.md', 'CHANGELOG.md', 'docs/CURRENT-STATE.md']) {
// Draw lives in UTILITIES; only the changelog's history still says DISPLAY.
for (const [file, heading] of [
['README.md', 'UTILITIES ▸ **Draw**'],
['CHANGELOG.md', 'DISPLAY ▸ Draw'],
['docs/CURRENT-STATE.md', 'UTILITIES ▸ **Draw**'],
]) {
const source = read(file);
const start =
['UTILITIES ▸ **Draw**', 'DISPLAY ▸ **Draw**', 'DISPLAY ▸ Draw']
.map((text) => source.indexOf(text))
.find((at) => at >= 0) ?? -1;
const start = source.indexOf(heading);
assert.ok(start >= 0, `${file} should describe the Draw control`);
const section = source.slice(start, start + 1200);
assert.doesNotMatch(
+2 -2
View File
@@ -53,12 +53,12 @@ export function createApplicationTools({
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy();
});
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
// UTILITIES ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
// while a session is open, so its teardown belongs to the application
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
defer(() => drawTool?.destroy());
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// UTILITIES ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// own data source. They live for the page session, so the application
// lifetime removes them and the drop listeners on the viewer container.
const geoFileImport = initGeoFileImport({
+1
View File
@@ -680,6 +680,7 @@ test('fresh Cockpit entry temporarily collapses map panels and exit restores the
for (const panelId of [
'data-panel',
'cctv-panel',
'utilities-panel',
'scene-panel',
'pp-toggles',
'global-context-panel',
+13
View File
@@ -294,3 +294,16 @@ test('expanded right panels highlight the title divider without changing collaps
/#param-slider-panel:not\(\.collapsed\) \.param-panel-divider\s*\{[\s\S]*?linear-gradient\(\s*90deg,\s*rgb\(0 212 255 \/ 28%\),\s*rgba\(0, 212, 255, 0\.18\) 58%,\s*transparent\s*\);/,
);
});
test('Utilities sits between Data Layers and Scenes and follows the left panels through every mode', () => {
const css = readStylesheet(new URL('../style.css', import.meta.url));
const order = (id) => Number(new RegExp(`#left-panel-stack > #${id} \\{\\s*order: (\\d+);`).exec(css)?.[1]);
assert.ok(order('data-panel') < order('utilities-panel'));
assert.ok(order('utilities-panel') < order('scene-panel'));
assert.match(css, /body\.ui-clean-view #utilities-panel,/);
assert.match(css, /body\.recording-mode #utilities-panel,/);
// Directions stays reachable in Cockpit; only Draw and Import step aside.
assert.match(css, /body\.cockpit-mode #utilities-panel \.utilities-tools \{\s*display: none !important;/);
assert.doesNotMatch(css, /body\.cockpit-mode #left-panel-stack > #utilities-panel[,\s{]/);
assert.match(css, /body\.mobile-shell\[data-mobile-sheet='layers'\]\s+#left-panel-stack\s+> #utilities-panel,/);
});
+3 -14
View File
@@ -1,9 +1,4 @@
import {
getScopeMaskFeather,
isScopeMaskEnabled,
setScopeMaskEnabled,
setScopeMaskFeather,
} from '../scopeMask.js';
import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
@@ -279,14 +274,8 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
const { owner } = styleOwner(t, 'tactical');
const previous = {
enabled: isScopeMaskEnabled(),
feather: getScopeMaskFeather(),
};
t.after(() => {
setScopeMaskEnabled(previous.enabled);
setScopeMaskFeather(previous.feather);
});
const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() };
t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); });
Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather });
owner._scopeBtn = { checked: true };
owner._scopeFeatherSlider = { value: '11', disabled: false };
+1 -1
View File
@@ -1,4 +1,4 @@
// Behavioral tests for DISPLAY ▸ Import: the controller is driven against
// Behavioral tests for UTILITIES ▸ Import: the controller is driven against
// fake DOM and viewer doubles with an injected loader, and its observable
// effects are asserted — data sources added and removed, list rows, the hint,
// drop handling, the file limit, and what destroy() gives back. Real parsing
+109
View File
@@ -378,3 +378,112 @@ test('the Weather readout receives enabled air-quality alongside weather layers'
],
);
});
// Directions renders in the Utilities panel's list; Data Layers keeps the rest.
async function utilitiesFixture({ withUtilities = true } = {}) {
const { LayerPanel } = await import('./layerPanel.js');
const { railFixture } = await import('./railTestFixture.mjs');
const f = railFixture();
// Rows are looked up by layer id on refresh; a render clears with innerHTML.
const findable = (node) => {
Object.defineProperty(node, 'innerHTML', {
set(html) {
if (html === '') for (const child of [...node.children]) child.remove();
},
});
const querySelector = node.querySelector.bind(node);
node.querySelector = (selector) => {
const id = /^\[data-layer-id="(.+)"\]$/.exec(selector)?.[1];
return id
? f.find((child) => child.dataset?.layerId === id, node)
: querySelector(selector);
};
return node;
};
findable(f.container);
const utilities = findable(f.document.createElement('div'));
f.document.getElementById = (id) =>
withUtilities && id === 'utilities-toggles' ? utilities : null;
const previousDocument = globalThis.document;
globalThis.document = f.document;
const layers = ['radio', 'directions', 'flights'].map((id) => ({
id,
name: id,
icon: '*',
source: 'S',
enabled: false,
showInTogglePanel: true,
stats: {},
}));
const panel = new LayerPanel({
getLayers: () => layers,
isEnabled: (id) => layers.find((layer) => layer.id === id).enabled,
setEnabled() {},
setLayerParams() {},
getRowControls: () => null,
hasRowControls: () => false,
subscribeRowControls: () => () => {},
});
panel.mount(f.container);
const headings = (root) =>
root.children
.filter((node) => node.tagName === 'H3')
.map((node) => node.textContent);
const rowIds = (root) =>
root.children
.filter((node) => node.dataset?.layerId)
.map((node) => node.dataset.layerId);
const restore = () => {
panel.destroy();
globalThis.document = previousDocument;
};
return { f, panel, utilities, layers, headings, rowIds, restore };
}
test('Directions renders in the Utilities list without a heading, and Radio under Audio', async () => {
const { f, panel, utilities, headings, rowIds, restore } =
await utilitiesFixture();
try {
assert.deepEqual(rowIds(utilities), ['directions']);
assert.deepEqual(headings(utilities), []);
assert.deepEqual(rowIds(f.container), ['flights', 'radio']);
assert.deepEqual(headings(f.container), ['Movement', 'Audio']);
panel.mount(f.container);
assert.deepEqual(
rowIds(utilities),
['directions'],
'a remount replaces it',
);
} finally {
restore();
}
});
test('a refresh updates the Directions row in the Utilities list', async () => {
const { f, panel, utilities, layers, restore } = await utilitiesFixture();
try {
const row = f.find(
(node) => node.dataset?.layerId === 'directions',
utilities,
);
const toggle = row.querySelector('.data-toggle-btn');
assert.ok(!toggle.className.includes('active'));
layers.find(({ id }) => id === 'directions').enabled = true;
panel._refreshTogglePanel();
assert.ok(toggle.className.includes('active'));
} finally {
restore();
}
});
test('without the Utilities list, Directions stays in Data Layers under Utilities', async () => {
const { f, headings, rowIds, restore } = await utilitiesFixture({
withUtilities: false,
});
try {
assert.deepEqual(rowIds(f.container), ['flights', 'directions', 'radio']);
assert.deepEqual(headings(f.container), ['Movement', 'Utilities', 'Audio']);
} finally {
restore();
}
});
+5 -1
View File
@@ -2007,10 +2007,14 @@ body.cockpit-mode #left-panel-stack {
max-height: none;
transition: none;
}
body.cockpit-mode #left-panel-stack > #utilities-panel,
body.cockpit-mode #left-panel-stack > #scene-panel {
display: none !important;
}
/* Directions stays reachable in Cockpit, as in Data Layers; Draw and Import
were never there (DISPLAY sits in the hidden right rail). */
body.cockpit-mode #utilities-panel .utilities-tools {
display: none !important;
}
body.cockpit-mode #right-context-rail {
display: none !important;
}
+2 -2
View File
@@ -750,7 +750,7 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
min-width: 0;
}
/* DISPLAY ▸ Draw: shape segment, label field, colour, and the one-line hint */
/* UTILITIES ▸ Draw: shape segment, label field, colour, and the one-line hint */
#draw-mode-row {
min-width: 184px;
}
@@ -801,7 +801,7 @@ body.gev-drawing #cesiumContainer canvas {
cursor: crosshair;
}
/* DISPLAY ▸ Import: one row per imported file, and the drop-target outline */
/* UTILITIES ▸ Import: one row per imported file, and the drop-target outline */
.geo-import-list {
display: none;
flex-direction: column;
+9 -3
View File
@@ -519,6 +519,14 @@
color: #f7f8f7;
}
:root[data-ui-theme='cyber']
#utilities-panel
.utilities-tools
.pp-toggle-btn:is([aria-pressed='true'], .active) {
background: rgba(64, 45, 46, 0.94);
box-shadow: 3px 0 0 var(--cyber-red) inset;
}
:root[data-ui-theme='cyber'] .pp-toggle-btn.active .pp-label {
color: #f5f7f6;
}
@@ -1662,9 +1670,7 @@
:root[data-ui-theme='cyber']
body:not(.cockpit-mode)
#left-panel-stack:has(> [data-panel-id]:not(.collapsed))
> :is(#data-panel, #cctv-panel, #utilities-panel, #scene-panel):not(
.collapsed
) {
> :is(#data-panel, #cctv-panel, #utilities-panel, #scene-panel):not(.collapsed) {
flex: 1 1 auto;
height: auto;
max-height: none;
+16 -3
View File
@@ -1478,7 +1478,8 @@ body.recording-mode #right-context-rail > [data-panel-id] {
color: var(--text-primary);
}
/* Draw reads as on with the accent of an enabled layer row. */
/* Draw marks its on state with an accent edge, like an active DISPLAY
button; Cyber swaps in its red edge (cyber.css). */
#utilities-panel
.utilities-tools
.pp-toggle-btn:is([aria-pressed='true'], .active) {
@@ -1493,6 +1494,18 @@ body.recording-mode #right-context-rail > [data-panel-id] {
margin-right: 10px;
}
#utilities-panel.collapsed .utilities-tools {
display: none;
/* Draw's options sit flat under its name, like a layer row's controls, not
as the card that hangs under a DISPLAY button. DISPLAY's shared label
column means nothing here, so the Shape label takes its own width. */
#utilities-panel .utilities-tools .pp-slider-row {
padding: 4px 0;
background: none;
border: 0;
border-radius: 0;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
#utilities-panel .utilities-tools .pp-slider-mini-label {
min-width: 0;
}
+12 -7
View File
@@ -1046,15 +1046,18 @@ export function createGevActionRunner({
if (name === 'show_data_layers_menu') {
const layerId = normalizeLayerId(args.layerId || args.layer);
setPanelOpen(styleManager, 'data-panel', true);
const focusedLayer =
const row =
layerId && dataManager.layers.has(layerId)
? focusDataLayerRow(layerId)
? findDataLayerRow(layerId)
: null;
// Open the panel that holds the row: Directions lives in Utilities.
const panelId = row?.closest('[data-panel-id]')?.id || 'data-panel';
setPanelOpen(styleManager, panelId, true);
const focusedLayer = row ? focusDataLayerRow(row, layerId) : null;
return {
ok: true,
action: 'show_data_layers_menu',
panelId: 'data-panel',
panelId,
focusedLayer,
layers: dataManager
.getAll()
@@ -3190,12 +3193,14 @@ function normalizeCockpitAction(value) {
return null;
}
function focusDataLayerRow(layerId) {
function findDataLayerRow(layerId) {
const selector = `[data-layer-id="${CSS.escape(layerId)}"]`;
const row = document.querySelector(
return document.querySelector(
`#data-toggles ${selector}, #utilities-toggles ${selector}`,
);
if (!row) return null;
}
function focusDataLayerRow(row, layerId) {
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
row.classList.remove('gev-voice-focus');
void row.offsetWidth;
+47
View File
@@ -743,6 +743,53 @@ test('Data Layers voice inventory hides the Context coordinator while current-vi
assert.deepEqual(current.cockpit, { active: false, entryAllowed: true });
});
test('showing a layer opens the panel that holds its row: Directions lives in Utilities', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const previous = { document: globalThis.document, CSS: globalThis.CSS };
const focused = [];
const rowIn = (panelId) => ({
dataset: { layerName: panelId === 'utilities-panel' ? 'Directions' : 'Live Flights' },
classList: { add: (name) => focused.push(name), remove() {} },
scrollIntoView() {},
closest: (selector) => (selector === '[data-panel-id]' ? { id: panelId } : null),
});
const rows = { directions: rowIn('utilities-panel'), flights: rowIn('data-panel') };
globalThis.CSS = { escape: (value) => value };
globalThis.document = {
querySelector: (selector) => rows[/data-layer-id="([^"]+)"/.exec(selector)?.[1]] || null,
};
try {
const opened = [];
const layers = [
{ id: 'flights', name: 'Live Flights', enabled: false, showInTogglePanel: true, stats: {} },
{ id: 'directions', name: 'Directions', enabled: false, showInTogglePanel: true, stats: {} },
];
const dataManager = {
layers: new Map(layers.map((layer) => [layer.id, { module: {} }])),
getAll: () => layers,
};
const styleManager = {
setPanelCollapsed: (panelId, collapsed) => opened.push([panelId, collapsed]),
};
const viewer = {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: { moveEnd: { addEventListener() {} } },
};
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
const directions = await runner('show_data_layers_menu', { layerId: 'directions' });
assert.equal(directions.panelId, 'utilities-panel');
assert.deepEqual(directions.focusedLayer, { id: 'directions', name: 'Directions' });
const flights = await runner('show_data_layers_menu', { layerId: 'flights' });
assert.equal(flights.panelId, 'data-panel');
assert.deepEqual(opened, [['utilities-panel', false], ['data-panel', false]]);
assert.deepEqual(focused, ['gev-voice-focus', 'gev-voice-focus']);
} finally {
globalThis.document = previous.document;
globalThis.CSS = previous.CSS;
}
});
test('generic layer visibility forwards cancellation and reports semantic failure', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const viewer = {