diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f8245b4..21c308b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Separate map source factories from switching and resource ownership; retain current source IDs, attribution and fallbacks. + - Separate radio directory loading, station selection, globe presentation and playback into composed components with an explicit metadata source. - Separate submarine cable sources and rendering components, and export bundled geography lookup modules. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index abb07a114..30a9f11c6 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -363,3 +363,25 @@ return an unsubscribe function. Initial state is emitted by default; pass immutable plain data. Reentrant publications retain delivery order; removing a listener or destroying its owner prevents further queued delivery. These APIs perform no network requests and discover no additional modules. + +## Maps + +`maps/controller` coordinates scene changes and lifetimes. `maps/imagery`, +`maps/terrain` and `maps/3d` supply constructors; `maps/defaults` selects the +standard sources and their setup/fallback policy. The standalone facade wires +these to the application's render governor. + +A registry supplies `sources`, `defaultId`, `unknownId` and optional `recoveryId`. +Each source has a user-facing `descriptor`, availability/reason, and either an +`imagery({ signal })` factory or a supplied `tileset`/`createTileset({ signal })`. +Imagery sources can share a terrain definition with a stable `id` and +`create({ signal })` returning `{ provider }` or `{ terrain }`. Cache IDs must +identify the same source for the lifetime of that registry. Sources may supply +trusted credit markup and construction/tile-error fallback policy. Provider +configuration stays in factories; descriptor values are the presentation API. + +Factories should honor cancellation where their SDK supports it. The controller +also checks scene ownership after asynchronous work, so late results cannot +replace a newer selection. Supplied tilesets are caller-owned; factory-created +tilesets and provider caches are controller-owned. Construct a fresh controller +for a new viewer or configuration lifetime. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f8d8d7bd3..a009d68f7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3318,3 +3318,21 @@ Natural Earth regions and neighborhood polygon lookup are package exports. Their existing lazy loaders, retry behavior, bundled datasets and attribution are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by the project's MIT license; see `DATA_SOURCES.md`. + +## Map source factories and coordination + +Map selection is composed from separate imagery, terrain and 3D factories. +The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and +OSM, including their setup guidance and existing preset/share IDs. Esri still +falls back to OSM on construction failure or two active tile failures, and +credits follow the source actually displayed. Terrain remains lazy while the +photoreal globe is hidden. + +Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults. + +The controller accepts other source registries without adding provider branches. +Each instance caches source construction, ignores superseded scene changes and +releases imagery layers/listeners on replacement. Destroy invalidates pending +work and releases owned resources, including late factory results. Supplied 3D +tilesets remain owned by the caller; tilesets created through the controller's +factory are added to its viewer and removed on destruction. diff --git a/package.json b/package.json index 2452b819a..c861e10b3 100644 --- a/package.json +++ b/package.json @@ -160,6 +160,12 @@ "./layers/submarine-cables": "./src/layers/submarineCables/index.js", "./sources/bundled-cables": "./src/layers/submarineCables/bundledSource.js", "./geography/natural-earth": "./src/data/naturalEarthRegions.js", - "./geography/neighborhoods": "./src/data/neighborhoodPolygons.js" + "./geography/neighborhoods": "./src/data/neighborhoodPolygons.js", + "./maps/controller": "./src/maps/controller.js", + "./maps/catalog": "./src/maps/catalog.js", + "./maps/imagery": "./src/maps/imagery.js", + "./maps/terrain": "./src/maps/terrain.js", + "./maps/3d": "./src/maps/google3d.js", + "./maps/defaults": "./src/maps/defaultSources.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ffbeac327..a2e60502c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -495,5 +495,21 @@ "src/data/telegeographySubmarineCables.test.mjs", "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", - "src/layers/submarineCables/bundledSource.test.mjs" + "src/layers/submarineCables/bundledSource.test.mjs", + "src/maps/availability.js", + "src/maps/catalog.js", + "src/maps/controller.js", + "src/maps/controller.test.mjs", + "src/maps/credits.js", + "src/maps/defaultSources.js", + "src/maps/google3d.js", + "src/maps/imagery.js", + "src/maps/registry.js", + "src/maps/terrain.js", + "src/mapStackController.js", + "src/mapStartup.js", + "src/mapStackChips.test.mjs", + "src/mapStackUnavailableReason.test.mjs", + "src/mapStartup.test.mjs", + "src/maps/sourceFactories.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ebee63435..655511dfb 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -962,5 +962,28 @@ "src/data/local_data/natural_earth/marine.json" ], "external": [] + }, + "maps": { + "exports": [ + "./maps/controller", + "./maps/catalog", + "./maps/imagery", + "./maps/terrain", + "./maps/3d", + "./maps/defaults" + ], + "modules": [ + "src/maps/availability.js", + "src/maps/catalog.js", + "src/maps/controller.js", + "src/maps/credits.js", + "src/maps/defaultSources.js", + "src/maps/google3d.js", + "src/maps/imagery.js", + "src/maps/registry.js", + "src/maps/terrain.js", + "src/keySetupCore.mjs" + ], + "external": ["cesium"] } } diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index f6c0b0f54..8449097a0 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -714,6 +714,14 @@ try { if (controller.googleTileset) controller.googleTileset.show = false; controller.googleTileset = null; controller.cesiumToken = ''; + // Availability is now composed in the source registry. Override only + // this fixture's choices, as the previous token-field seam did. + for (const source of controller._sources.values()) { + if (source.descriptor.requiresIon || source.descriptor.kind === 'photoreal') { + source.available = false; + } + } + controller._registry.state.hasCesiumIonToken = false; await styleManager._setMapStack('osm', { syncShare: false }); styleManager._initMapStackControl(); }); diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs index 7106cd2e7..befb7b673 100644 --- a/src/mapStackChips.test.mjs +++ b/src/mapStackChips.test.mjs @@ -34,7 +34,9 @@ function makeElement(tagName = 'div') { children: [], classList: { toggle(name, force) { - const classes = new Set(String(element.className).split(/\s+/).filter(Boolean)); + const classes = new Set( + String(element.className).split(/\s+/).filter(Boolean), + ); const next = force === undefined ? !classes.has(name) : !!force; if (next) classes.add(name); else classes.delete(name); @@ -44,15 +46,30 @@ function makeElement(tagName = 'div') { return String(element.className).split(/\s+/).includes(name); }, }, - appendChild(child) { element.children.push(child); return child; }, - setAttribute(name, value) { element.attributes[name] = String(value); }, - getAttribute(name) { return element.attributes[name] ?? null; }, - addEventListener(type, handler) { (element.listeners[type] ||= []).push(handler); }, - click() { for (const handler of element.listeners.click || []) handler(); }, + appendChild(child) { + element.children.push(child); + return child; + }, + setAttribute(name, value) { + element.attributes[name] = String(value); + }, + getAttribute(name) { + return element.attributes[name] ?? null; + }, + addEventListener(type, handler) { + (element.listeners[type] ||= []).push(handler); + }, + click() { + for (const handler of element.listeners.click || []) handler(); + }, }; Object.defineProperty(element, 'innerHTML', { - get() { return ''; }, - set() { element.children.length = 0; }, + get() { + return ''; + }, + set() { + element.children.length = 0; + }, }); return element; } @@ -60,37 +77,93 @@ function makeElement(tagName = 'div') { const doc = { createElement: (tagName) => makeElement(tagName) }; /** Text a chip renders, label + optional requirement badge. */ -const chipText = (chip) => chip.children.map((child) => child.textContent).join(' '); +const chipText = (chip) => + chip.children.map((child) => child.textContent).join(' '); // Shaped exactly like MapStackController.getStacks() output. const CONTROLLER_STACKS = [ - { id: 'photoreal', label: 'Google 3D', requiresIon: false, available: true, unavailableReason: null }, - { id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: true, unavailableReason: null }, - { id: 'bing-labels', label: 'Bing Labels', requiresIon: true, available: true, unavailableReason: null }, - { id: 'esri-imagery', label: 'Esri Satellite', requiresIon: false, available: true, unavailableReason: null }, - { id: 'osm', label: 'OSM', requiresIon: false, available: true, unavailableReason: null }, + { + id: 'photoreal', + label: 'Google 3D', + requiresIon: false, + available: true, + unavailableReason: null, + }, + { + id: 'bing-aerial', + label: 'Bing Aerial', + requiresIon: true, + available: true, + unavailableReason: null, + }, + { + id: 'bing-labels', + label: 'Bing Labels', + requiresIon: true, + available: true, + unavailableReason: null, + }, + { + id: 'esri-imagery', + label: 'Esri Satellite', + requiresIon: false, + available: true, + unavailableReason: null, + }, + { + id: 'osm', + label: 'OSM', + requiresIon: false, + available: true, + unavailableReason: null, + }, ]; test('the row renders exactly the five owner-approved sources', () => { const container = makeElement(); - renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc }); + renderMapStackChips(container, CONTROLLER_STACKS, { + activeId: 'photoreal', + doc, + }); - assert.deepEqual(container.children.map((chip) => chip.dataset.stackId), [ - 'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm', - ]); + assert.deepEqual( + container.children.map((chip) => chip.dataset.stackId), + ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'], + ); assert.deepEqual(container.children.map(chipText), [ - 'Google 3D', 'Bing Aerial', 'Bing Labels', 'Esri Satellite', 'OSM', + 'Google 3D', + 'Bing Aerial', + 'Bing Labels', + 'Esri Satellite', + 'OSM', ]); - assert.deepEqual(PRESENTED_MAP_STACK_IDS, ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm']); - assert.ok(container.children.every((chip) => chip.tagName === 'button' && chip.type === 'button')); - assert.ok(container.children.every((chip) => chip.classList.contains(MAP_STACK_CHIP_CLASS))); + assert.deepEqual(PRESENTED_MAP_STACK_IDS, [ + 'photoreal', + 'bing-aerial', + 'bing-labels', + 'esri-imagery', + 'osm', + ]); + assert.ok( + container.children.every( + (chip) => chip.tagName === 'button' && chip.type === 'button', + ), + ); + assert.ok( + container.children.every((chip) => + chip.classList.contains(MAP_STACK_CHIP_CLASS), + ), + ); }); test('internal and future stacks stay outside the approved presentation set', () => { const container = makeElement(); // A future Hybrid stack may land in the controller, but it must not appear // until the owner-approved presentation allowlist explicitly includes it. - const withHybrid = [...CONTROLLER_STACKS, { id: 'hybrid', label: 'Hybrid', available: true }]; + const withHybrid = [ + ...CONTROLLER_STACKS, + { id: 'hybrid', label: 'Hybrid', available: true }, + ]; renderMapStackChips(container, withHybrid, { activeId: 'photoreal', doc }); assert.equal(container.children.length, 5); @@ -99,7 +172,10 @@ test('internal and future stacks stay outside the approved presentation set', () test('re-rendering replaces the previous chips instead of stacking a second row', () => { const container = makeElement(); - renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc }); + renderMapStackChips(container, CONTROLLER_STACKS, { + activeId: 'photoreal', + doc, + }); renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'osm', doc }); assert.equal(container.children.length, PRESENTED_MAP_STACK_IDS.length); @@ -122,19 +198,32 @@ test('clicking a chip dispatches that stack id — the same selection the dropdo test('the active chip is the pressed chip, and exactly one is pressed', () => { const container = makeElement(); - renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'bing-labels', doc }); + renderMapStackChips(container, CONTROLLER_STACKS, { + activeId: 'bing-labels', + doc, + }); - const pressed = container.children.filter((chip) => chip.getAttribute('aria-pressed') === 'true'); - assert.deepEqual(pressed.map((chip) => chip.dataset.stackId), ['bing-labels']); + const pressed = container.children.filter( + (chip) => chip.getAttribute('aria-pressed') === 'true', + ); + assert.deepEqual( + pressed.map((chip) => chip.dataset.stackId), + ['bing-labels'], + ); assert.ok(pressed[0].classList.contains('active')); - assert.ok(container.children - .filter((chip) => chip.dataset.stackId !== 'bing-labels') - .every((chip) => !chip.classList.contains('active'))); + assert.ok( + container.children + .filter((chip) => chip.dataset.stackId !== 'bing-labels') + .every((chip) => !chip.classList.contains('active')), + ); }); test('the lit chip tracks controller state, not the click', () => { const container = makeElement(); - renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc }); + renderMapStackChips(container, CONTROLLER_STACKS, { + activeId: 'photoreal', + doc, + }); // A rejected/superseded switch reports the stack that is genuinely active. syncMapStackChips(container, 'photoreal'); @@ -151,11 +240,16 @@ test('the lit chip tracks controller state, not the click', () => { test('keyless ion stacks stay focusable, aria-disabled, and say why', () => { const container = makeElement(); - const keyless = CONTROLLER_STACKS.map((stack) => (stack.requiresIon ? { - ...stack, - available: false, - unavailableReason: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings', - } : stack)); + const keyless = CONTROLLER_STACKS.map((stack) => + stack.requiresIon + ? { + ...stack, + available: false, + unavailableReason: + 'Needs CESIUM_ION_TOKEN — add it in Provider Settings', + } + : stack, + ); const selected = []; renderMapStackChips(container, keyless, { activeId: 'photoreal', @@ -164,59 +258,110 @@ test('keyless ion stacks stay focusable, aria-disabled, and say why', () => { }); const bingAerial = container.children[1]; - assert.equal(bingAerial.disabled, false, 'unavailable sources remain keyboard-reachable'); + assert.equal( + bingAerial.disabled, + false, + 'unavailable sources remain keyboard-reachable', + ); assert.equal(bingAerial.getAttribute('aria-disabled'), 'true'); assert.equal( bingAerial.getAttribute('aria-label'), 'Bing Aerial unavailable: Needs CESIUM_ION_TOKEN — add it in Provider Settings', ); assert.ok(bingAerial.classList.contains('unavailable')); - assert.equal(bingAerial.title, 'Needs CESIUM_ION_TOKEN — add it in Provider Settings'); + assert.equal( + bingAerial.title, + 'Needs CESIUM_ION_TOKEN — add it in Provider Settings', + ); assert.equal(chipText(bingAerial), 'Bing Aerial ION'); bingAerial.click(); - assert.deepEqual(selected, [], 'an unavailable stack must not reach the switch path'); + assert.deepEqual( + selected, + [], + 'an unavailable stack must not reach the switch path', + ); - assert.equal(container.children[3].getAttribute('aria-disabled'), 'false', 'OSM stays selectable'); + assert.equal( + container.children[3].getAttribute('aria-disabled'), + 'false', + 'OSM stays selectable', + ); }); test('a non-ion stack that fails never claims an ion token is required', () => { // The startup fallback-to-OSM case: Google 3D tiles failed to load, so // photoreal is unavailable for a reason that has nothing to do with ion. const container = makeElement(); - const tilesFailed = CONTROLLER_STACKS.map((stack) => (stack.id === 'photoreal' ? { - ...stack, - available: false, - unavailableReason: 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings', - } : stack)); + const tilesFailed = CONTROLLER_STACKS.map((stack) => + stack.id === 'photoreal' + ? { + ...stack, + available: false, + unavailableReason: + 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings', + } + : stack, + ); renderMapStackChips(container, tilesFailed, { activeId: 'osm', doc }); const google = container.children[0]; assert.equal(google.getAttribute('aria-disabled'), 'true'); - assert.equal(google.getAttribute('aria-label'), 'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings'); - assert.equal(chipText(google), 'Google 3D', 'no ION badge on a stack that does not need ion'); - assert.equal(google.title, 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings'); - assert.equal(chipText(container.children[1]), 'Bing Aerial', 'available ion stacks stay unbadged'); + assert.equal( + google.getAttribute('aria-label'), + 'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings', + ); + assert.equal( + chipText(google), + 'Google 3D', + 'no ION badge on a stack that does not need ion', + ); + assert.equal( + google.title, + 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings', + ); + assert.equal( + chipText(container.children[1]), + 'Bing Aerial', + 'available ion stacks stay unbadged', + ); }); -test('models carry the stack\'s own reason and never invent an active chip', () => { - assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [{ - id: 'osm', - label: 'OSM', - available: true, - active: false, - requiresIon: false, - requirement: '', - unavailableHint: '', - title: 'OSM', - }]); +test("models carry the stack's own reason and never invent an active chip", () => { + assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [ + { + id: 'osm', + label: 'OSM', + available: true, + active: false, + requiresIon: false, + requirement: '', + unavailableHint: '', + title: 'OSM', + }, + ]); // A stack list without a controller-supplied reason still explains itself. assert.deepEqual( [ - mapStackChipModel({ id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: false }, null), - mapStackChipModel({ id: 'hybrid', label: 'Hybrid', available: false }, null), - ].map(({ requirement, unavailableHint, title }) => ({ requirement, unavailableHint, title })), + mapStackChipModel( + { + id: 'bing-aerial', + label: 'Bing Aerial', + requiresIon: true, + available: false, + }, + null, + ), + mapStackChipModel( + { id: 'hybrid', label: 'Hybrid', available: false }, + null, + ), + ].map(({ requirement, unavailableHint, title }) => ({ + requirement, + unavailableHint, + title, + })), [ { requirement: 'ION', @@ -234,31 +379,12 @@ test('models carry the stack\'s own reason and never invent an active chip', () assert.deepEqual(mapStackChipModels(undefined, 'osm'), []); }); -test('the controller is the single source of the unavailability reason', () => { - const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8'); - - assert.doesNotMatch( - controller, - /bing-road/, - 'Bing Road is retired: an old map=bing-road link must take the unknown-id photoreal fallback', - ); - - assert.match( - controller, - /getStacks\(\) \{[\s\S]*?unavailableReason: available \? null : this\._unavailableReason\(stack\)/, - 'getStacks() must expose why a stack is unavailable, not just that it is', - ); - assert.match( - controller, - /if \(!this\.isStackAvailable\(stack\.id\)\) \{\s*const message = this\._unavailableReason\(stack\);/, - 'the toast and the chip tooltip must read the same reason', - ); - assert.match(controller, /_unavailableReason\(stack\) \{[\s\S]*?stack\?\.requiresIon/); -}); - test('a missing row or document is inert rather than throwing during boot', () => { assert.deepEqual(renderMapStackChips(null, CONTROLLER_STACKS, { doc }), []); - assert.deepEqual(renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }), []); + assert.deepEqual( + renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }), + [], + ); assert.doesNotThrow(() => syncMapStackChips(null, 'osm')); }); @@ -268,8 +394,14 @@ test('the active cyan survives hover', () => { const active = css.indexOf('.map-stack-chip.active {'); const unavailable = css.indexOf('.map-stack-chip.unavailable'); - assert.ok(hover > 0 && active > hover, 'active must follow hover so it wins at equal specificity'); - assert.ok(unavailable > active, 'unavailable must follow both so a keyless chip never lights up'); + assert.ok( + hover > 0 && active > hover, + 'active must follow hover so it wins at equal specificity', + ); + assert.ok( + unavailable > active, + 'unavailable must follow both so a keyless chip never lights up', + ); assert.doesNotMatch( css.slice(hover, active), /:not\(/, @@ -285,18 +417,25 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => { // chip-state rule sets. const css = readStylesheet(new URL('../style.css', import.meta.url)); const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, ''); - const chipRules = [...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g)] - .map(([, selector, body], order) => ({ - selector: stripComments(selector).trim(), - body: stripComments(body), - order, - })); + const chipRules = [ + ...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g), + ].map(([, selector, body], order) => ({ + selector: stripComments(selector).trim(), + body: stripComments(body), + order, + })); assert.ok(chipRules.length >= 5, 'expected the chip state rules to be found'); - const ringIndex = chipRules.findIndex(({ selector, body }) => selector.includes(':focus-visible') - && /outline:\s*(?!none)\S/.test(body) - && /outline-offset:/.test(body)); - assert.ok(ringIndex >= 0, 'a :focus-visible rule must draw a real outline ring'); + const ringIndex = chipRules.findIndex( + ({ selector, body }) => + selector.includes(':focus-visible') && + /outline:\s*(?!none)\S/.test(body) && + /outline-offset:/.test(body), + ); + assert.ok( + ringIndex >= 0, + 'a :focus-visible rule must draw a real outline ring', + ); // Nothing after it may touch outline again, so the ring cannot be erased by // .active, :disabled, or anything added later. @@ -307,7 +446,10 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => { `"${rule.selector}" must not touch outline — it would erase the focus ring`, ); } - for (const selector of ['.map-stack-chip.active', '.map-stack-chip.unavailable']) { + for (const selector of [ + '.map-stack-chip.active', + '.map-stack-chip.unavailable', + ]) { assert.ok( chipRules.some((rule) => rule.selector.includes(selector)), `expected a ${selector} rule to exist for this check to mean anything`, @@ -317,83 +459,104 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => { // `transition: all` animates outline-width off the `outline: none` base, and // Chrome parks that transition at 0px for chips on a wrapped line — the ring // never appeared on rows 2 and 3. The base rule must list its properties. - const base = chipRules.find((rule) => rule.selector.endsWith('.map-stack-chip')); + const base = chipRules.find((rule) => + rule.selector.endsWith('.map-stack-chip'), + ); assert.ok(base, 'expected the base .map-stack-chip rule'); assert.doesNotMatch( base.body, /transition:\s*all\b/, 'transition: all animates outline-width and kills the focus ring on wrapped rows', ); - assert.doesNotMatch(base.body, /transition:[^;]*outline/, 'the focus ring must not be animated'); - assert.match(base.body, /transition:\s*\n?\s*color/, 'the hover/active treatment still animates'); + assert.doesNotMatch( + base.body, + /transition:[^;]*outline/, + 'the focus ring must not be animated', + ); + assert.match( + base.body, + /transition:\s*\n?\s*color/, + 'the hover/active treatment still animates', + ); }); test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => { const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); - const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); + const ui = readFileSync( + new URL('./ui/applicationShell.js', import.meta.url), + 'utf8', + ); - assert.doesNotMatch(html, /map-stack-select/, 'the SOURCE dropdown is replaced by the chip row'); + assert.doesNotMatch( + html, + /map-stack-select/, + 'the SOURCE dropdown is replaced by the chip row', + ); assert.match( html, /
<\/div>/, ); - assert.doesNotMatch(html, /id="stack-panel"/, 'the duplicate left MAP STACK panel is retired'); - assert.match(html, /id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/); + assert.doesNotMatch( + html, + /id="stack-panel"/, + 'the duplicate left MAP STACK panel is retired', + ); + assert.match( + html, + /id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/, + ); assert.match( html, /