diff --git a/CHANGELOG.md b/CHANGELOG.md index 234f08449..b5954b194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ - Director timeline seeks cross the dateline along the short longitude arc. -- Conflicting CCTV direction tokens remain unknown within the highest matching tier, preserving travel-token precedence over street quadrants and bare words. +- Recent Imagery thumbnails require explicit presence evidence before reporting available pixels. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 36e705bbc..6b7603a8f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1100,6 +1100,9 @@ collapse can still be undone. ## Surface keyboard lifecycle +Surface Tab cycling excludes controls with a negative tabindex, native disabled +state or inert ancestry; ordinary visible enabled controls retain their order. + `ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return focus shared by the first-run launcher and Provider Settings. Each caller activates it while open and deactivates it on dismissal; destruction releases @@ -4545,10 +4548,7 @@ are omitted rather than framing the wrong part of the globe. ### Recent Imagery (September 2026) -Programmatic boxes passed to the exported `setBox` and `searchHls` operations -reject missing, blank or boolean edges instead of interpreting them as zero -coordinates. Valid finite numeric edges remain accepted; the app’s drag tool, -pins and share links already supply complete boxes. +Thumbnail decoding requires explicit `Data-Present: true` evidence; false remains empty, while absent or malformed evidence reports an error. Injected `getThumbnail` sources must return an OK Response with readable `Data-Present: true` to admit image bytes, or `false` for confirmed empty coverage. Cross-origin sources must expose that header to browser JavaScript. - The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room. - DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes. diff --git a/src/layers/recentImagery/source.js b/src/layers/recentImagery/source.js index 258e8e2e1..9e6749cb7 100644 --- a/src/layers/recentImagery/source.js +++ b/src/layers/recentImagery/source.js @@ -1,7 +1,11 @@ import { searchHls } from './catalog.js'; import { gibsTemplate, wvsSnapshotUrl } from './model.js'; -/** NASA-compatible imagery acquisition; transport and URL builders are replaceable. */ +/** + * NASA-compatible imagery acquisition; transport and URL builders are replaceable. + * Thumbnail responses must expose Data-Present: true for image admission, or + * false for confirmed empty coverage, including injected getThumbnail sources. + */ export function createRecentImagerySource({ fetchImpl = (...args) => globalThis.fetch(...args), search = searchHls, diff --git a/src/layers/recentImagery/thumbnail-presence-proof.test.mjs b/src/layers/recentImagery/thumbnail-presence-proof.test.mjs new file mode 100644 index 000000000..4605079b1 --- /dev/null +++ b/src/layers/recentImagery/thumbnail-presence-proof.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createThumbnailLoader } from './thumbnails.js'; +test('missing or malformed presence evidence cannot prove thumbnail pixels', async () => { + for (const header of [null, 'unknown', '', 'true', 'false']) { + let decodes = 0; + const loader = createThumbnailLoader({ + requestThumbnail: async () => ({ + ok: true, + headers: { get: (key) => (key === 'Data-Present' ? header : null) }, + blob: async () => { + decodes++; + return new Blob(['fixture']); + }, + }), + createObjectUrl: () => 'blob:fixture', + }); + loader.request( + { key: 'fixture', product: 'S30', day: '2026-09-18' }, + { west: 0, east: 1, south: 0, north: 1 }, + ); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + loader.get('fixture').status, + header === 'true' ? 'present' : header === 'false' ? 'empty' : 'error', + ); + assert.equal(decodes, header === 'true' ? 1 : 0); + loader.destroy(); + } +}); diff --git a/src/layers/recentImagery/thumbnails.js b/src/layers/recentImagery/thumbnails.js index 23fe4dd49..4aaec294b 100644 --- a/src/layers/recentImagery/thumbnails.js +++ b/src/layers/recentImagery/thumbnails.js @@ -100,12 +100,12 @@ export function createThumbnailLoader({ cancelled = true; } else if (response?.ok) { const present = String( - response.headers?.get?.('Data-Present') ?? 'true', + response.headers?.get?.('Data-Present') ?? '', ).toLowerCase(); acquisitionTime = response.headers?.get?.('Acquisition-Time') || null; if (present === 'false') { status = 'empty'; - } else { + } else if (present === 'true') { const blob = await response.blob(); if (controller.signal.aborted) cancelled = true; else { diff --git a/src/ui/surface-tab-order.test.mjs b/src/ui/surface-tab-order.test.mjs new file mode 100644 index 000000000..c3f335407 --- /dev/null +++ b/src/ui/surface-tab-order.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSurfaceKeyboard } from './surfaceKeyboard.js'; +test('surface cycling never targets deliberately excluded controls', () => { + let handler; + const documentRef = { + activeElement: {}, + addEventListener: (_, fn) => (handler = fn), + removeEventListener() {}, + }; + const node = ({ + attributes = {}, + fieldsetDisabled = false, + inert = false, + } = {}) => ({ + hasAttribute: (name) => Object.hasOwn(attributes, name), + getClientRects: () => [{}], + getAttribute: (name) => attributes[name] ?? null, + matches: (selector) => selector === ':disabled' && fieldsetDisabled, + closest: (selector) => + selector === '[inert]' && inert ? { inert: true } : null, + focus() { + documentRef.activeElement = this; + }, + }); + const ignored = [ + node({ attributes: { tabindex: '-1' } }), + node({ fieldsetDisabled: true }), + node({ inert: true }), + ]; + const first = node({}), + last = node({}); + const controls = [...ignored, first, last]; + const root = { + ownerDocument: documentRef, + querySelectorAll: () => controls, + contains: (n) => controls.includes(n), + }; + const surface = createSurfaceKeyboard({ + root, + isActive: () => true, + onEscape() {}, + }); + surface.activate(); + const press = (shiftKey) => + handler({ + key: 'Tab', + shiftKey, + preventDefault() {}, + stopPropagation() {}, + }); + press(false); + assert.equal(documentRef.activeElement, first); + press(true); + assert.equal(documentRef.activeElement, last); + surface.destroy(); +}); diff --git a/src/ui/surfaceKeyboard.js b/src/ui/surfaceKeyboard.js index 38417975a..141e22fa6 100644 --- a/src/ui/surfaceKeyboard.js +++ b/src/ui/surfaceKeyboard.js @@ -39,7 +39,11 @@ export function createSurfaceKeyboard({ ), ].filter( (node) => - !node.hasAttribute('disabled') && node.getClientRects().length > 0, + !node.hasAttribute('disabled') && + !node.matches?.(':disabled') && + !(Number(node.getAttribute?.('tabindex')) < 0) && + !node.closest?.('[inert]') && + node.getClientRects().length > 0, ); if (!order.length) return; const first = order[0];