diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index b1a38a273..07b702461 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -98,15 +98,9 @@ export const FIRST_RUN_MISSIONS = Object.freeze({ // for the FULLY CONFIGURED experience (owner ruling, 2026-08-23): the tile // promises both, so it turns on both, and the subcopy in index.html says so. // - // Keyless, FIRMS is honest where it counts — its own layer row reads - // "UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED", and the quakes half of - // the tile still delivers in full. What is NOT honest is the GLOBAL status - // chip, which has no key-required terminal state and folds that row into - // "LOAD FAILED". That aggregation is the defect, not this preset: fixing it - // means a KEY REQUIRED terminal state in src/loadingFeedback.js, a state - // machine shared by every layer and not a thing to refactor the night - // before a launch. LEDGERED post-launch. Until it lands, keyless visitors - // are judged on the layer row, which tells them the truth. + // Keyless, FIRMS's own row reads "KEY REQUIRED", and the quakes half of + // the tile still delivers in full; the global status chip ends on KEY + // REQUIRED too (src/loadingFeedback.js) rather than LOAD FAILED. layerIds: Object.freeze(['earthquakes', 'local-firms']), busyKey: msg('Scanning active events…'), }), diff --git a/src/loadingFeedback.js b/src/loadingFeedback.js index b4ea03ba1..f03066ff9 100644 --- a/src/loadingFeedback.js +++ b/src/loadingFeedback.js @@ -85,13 +85,18 @@ export function normalizeLayerLoading(layer = {}) { function terminalFromParticipantStats(summary, participantIds) { if (!participantIds?.length) return null; const participants = new Set(participantIds); - return summary.records.some( - (record) => - participants.has(record.id) && - (record.error || record.unavailable || record.keyRequired), + const records = summary.records.filter((record) => + participants.has(record.id), + ); + // A layer waiting for a key did not fail: its row names the key. Only an + // actual error makes the whole batch read LOAD FAILED. + if ( + records.some( + (record) => !record.keyRequired && (record.error || record.unavailable), + ) ) - ? 'error' - : null; + return 'error'; + return records.some((record) => record.keyRequired) ? 'key' : null; } /** Aggregate all manager layers without changing their lifecycle authority. */ @@ -282,7 +287,7 @@ function terminalFromEvent(event) { } function mergeTerminalOutcome(current, next) { - const severity = { complete: 1, cancelled: 2, error: 3 }; + const severity = { complete: 1, key: 2, cancelled: 3, error: 4 }; if (!next) return current || null; if (!current || severity[next] > severity[current]) return next; return current; @@ -424,6 +429,7 @@ export function presentLoadingFeedback(state, summary, nowMs) { const labels = { complete: 'LOAD COMPLETE', cancelled: 'LOAD CANCELLED', + key: 'KEY REQUIRED', error: 'LOAD FAILED', }; const label = diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index d892ddf1c..1aef710c0 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -247,11 +247,11 @@ test('terminal loading feedback centers its label without an empty detail slot', const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match( css, - /#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/, + /#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='key'\],\s*\[data-state='error'\]\s*\)\s*\{[\s\S]*?justify-content:\s*center;[\s\S]*?text-align:\s*center;/, ); assert.match( css, - /#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='error'\]\s*\)\s*#global-loading-detail\s*\{\s*display:\s*none;/, + /#global-loading-status:is\(\s*\[data-state='complete'\],\s*\[data-state='cancelled'\],\s*\[data-state='key'\],\s*\[data-state='error'\]\s*\)\s*#global-loading-detail\s*\{\s*display:\s*none;/, ); }); @@ -346,7 +346,7 @@ test('participant stats failure outranks a simultaneous visibility completion', name: 'AIS Vessels', enabled: true, lifecycleState: 'enabled', - stats: { loading: false, keyRequired: true }, + stats: { loading: false, error: 'Upstream closed' }, }]); state = reduceLoadingFeedback(state, missingKey, 250, { type: 'visibility', layerId: 'ais-live-vessels', enabled: true, @@ -355,6 +355,36 @@ test('participant stats failure outranks a simultaneous visibility completion', assert.equal(state.terminal, 'error'); }); +test('a layer waiting for a key ends the batch as KEY REQUIRED, not LOAD FAILED', () => { + const quakes = (stats) => ({ id: 'earthquakes', name: 'Earthquakes', ...stats }); + const fires = (stats) => ({ id: 'local-firms', name: 'Active Fires', ...stats }); + const enabling = aggregateLayerLoading([ + quakes({ lifecycleState: 'enabling', stats: { loading: true } }), + fires({ lifecycleState: 'enabling', stats: { loading: true } }), + ]); + let state = reduceLoadingFeedback(createLoadingFeedbackState(), enabling, 0); + state = reduceLoadingFeedback(state, enabling, 200); + const settled = aggregateLayerLoading([ + quakes({ enabled: true, stats: { count: 27, lastUpdate: 1 } }), + fires({ enabled: true, stats: { status: 'unavailable', keyRequired: true, error: 'KEY REQUIRED' } }), + ]); + state = reduceLoadingFeedback(state, settled, 250); + assert.equal(state.terminal, 'key'); + assert.deepEqual(state.failedEventIds, []); + const view = presentLoadingFeedback(state, settled, 260); + assert.equal(view.state, 'key'); + assert.equal(view.label, 'KEY REQUIRED'); + + // A real failure beside it still reads as one. + const broken = aggregateLayerLoading([ + quakes({ enabled: true, stats: { error: 'USGS 503' } }), + fires({ enabled: true, stats: { status: 'unavailable', keyRequired: true } }), + ]); + let failing = reduceLoadingFeedback(createLoadingFeedbackState(), enabling, 0); + failing = reduceLoadingFeedback(failing, enabling, 200); + assert.equal(reduceLoadingFeedback(failing, broken, 250).terminal, 'error'); +}); + test('retains the worst terminal outcome until every concurrent load drains', () => { const both = aggregateLayerLoading([ { id: 'a', name: 'A', lifecycleState: 'enabling' }, diff --git a/src/ui/shellFeedback.js b/src/ui/shellFeedback.js index 7193c3c3a..6a7abbfe7 100644 --- a/src/ui/shellFeedback.js +++ b/src/ui/shellFeedback.js @@ -23,6 +23,7 @@ const STATUS_LABEL_KEYS = Object.freeze({ 'LOAD COMPLETE': msg('LOAD COMPLETE'), 'LOAD CANCELLED': msg('LOAD CANCELLED'), 'LOAD FAILED': msg('LOAD FAILED'), + 'KEY REQUIRED': msg('KEY REQUIRED'), 'LIVE DATA OFF': msg('LIVE DATA OFF'), 'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'), 'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'), diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css index bba1ef788..12650982b 100644 --- a/src/ui/styles/status.css +++ b/src/ui/styles/status.css @@ -131,6 +131,7 @@ html:not([lang='en']) #global-loading-detail { #global-loading-status:is( [data-state='complete'], [data-state='cancelled'], + [data-state='key'], [data-state='error'] ) { justify-content: center; @@ -139,6 +140,7 @@ html:not([lang='en']) #global-loading-detail { #global-loading-status:is( [data-state='complete'], [data-state='cancelled'], + [data-state='key'], [data-state='error'] ) #global-loading-detail { @@ -155,11 +157,13 @@ html:not([lang='en']) #global-loading-detail { #global-loading-status[data-state='cancelled'] { color: #ffd488; } -#global-loading-status[data-state='retry'] { +#global-loading-status:is([data-state='retry'], [data-state='key']) { border-color: rgba(255, 212, 136, 0.55); } -#global-loading-status[data-state='retry'] #global-loading-label, -#global-loading-status[data-state='retry'] #global-loading-detail { +#global-loading-status:is([data-state='retry'], [data-state='key']) + #global-loading-label, +#global-loading-status:is([data-state='retry'], [data-state='key']) + #global-loading-detail { color: #ffd488; } #global-loading-status[data-state='cancelled'] #global-loading-label,