Compare commits

..
12 changed files with 53 additions and 70 deletions

No files matched your search

@@ -0,0 +1,5 @@
- Keep single-key app shortcuts off Cmd, Ctrl and Alt combinations, so Cmd+F,
Cmd+C and Cmd+R reach the browser instead of hiding the layer panel or
switching layers on. `F` now collapses the layer panel the way its button
does, instead of hiding the panel and the button that brings it back
(asturksever, #1598).
@@ -1,4 +0,0 @@
- A layer that is only waiting for a key now ends the load chip on an amber
`KEY REQUIRED` instead of a red `LOAD FAILED`, so a keyless Environmental
first run no longer reports a failure while earthquakes load. A real error
still wins over a missing key (asturksever, #1600).
+9 -3
View File
@@ -98,9 +98,15 @@ export const FIRST_RUN_MISSIONS = Object.freeze({
// for the FULLY CONFIGURED experience (owner ruling, 2026-08-23): the tile // 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. // promises both, so it turns on both, and the subcopy in index.html says so.
// //
// Keyless, FIRMS's own row reads "KEY REQUIRED", and the quakes half of // Keyless, FIRMS is honest where it counts — its own layer row reads
// the tile still delivers in full; the global status chip ends on KEY // "UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED", and the quakes half of
// REQUIRED too (src/loadingFeedback.js) rather than LOAD FAILED. // 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.
layerIds: Object.freeze(['earthquakes', 'local-firms']), layerIds: Object.freeze(['earthquakes', 'local-firms']),
busyKey: msg('Scanning active events…'), busyKey: msg('Scanning active events…'),
}), }),
+6 -5
View File
@@ -606,10 +606,11 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
const environmental = FIRST_RUN_MISSIONS.environmental; const environmental = FIRST_RUN_MISSIONS.environmental;
assert.deepEqual(environmental.layerIds, ['earthquakes', 'local-firms']); assert.deepEqual(environmental.layerIds, ['earthquakes', 'local-firms']);
// Keyless, the LAYER ROW reads "KEY REQUIRED", and the global chip now ends // Keyless, the honest surface is the LAYER ROW ("KEY REQUIRED"), which the
// on KEY REQUIRED as well instead of LOAD FAILED (src/loadingFeedback.js, // FIRMS layer already reports. The misleading part is the GLOBAL chip folding
// covered in loadingFeedback.test.mjs). The note says so where the next // that row into LOAD FAILED — a defect in the shared state machine, ledgered
// editor will read it. // post-launch, and the note must stay where the next editor will read it
// rather than being re-discovered as a launcher bug.
const module = fs.readFileSync( const module = fs.readFileSync(
new URL('./firstRunExperience.js', import.meta.url), new URL('./firstRunExperience.js', import.meta.url),
'utf8', 'utf8',
@@ -620,7 +621,7 @@ test('the tile is the FULLY CONFIGURED experience: quakes and fires together', (
); );
assert.match(table, /KEY REQUIRED/); assert.match(table, /KEY REQUIRED/);
assert.match(table, /src\/loadingFeedback\.js/); assert.match(table, /src\/loadingFeedback\.js/);
assert.match(table, /rather than LOAD FAILED/); assert.match(table, /LEDGERED post-launch/);
}); });
test('every visitor gets the same tile — there is no degraded keyless variant', async () => { test('every visitor gets the same tile — there is no degraded keyless variant', async () => {
+7 -13
View File
@@ -85,18 +85,13 @@ export function normalizeLayerLoading(layer = {}) {
function terminalFromParticipantStats(summary, participantIds) { function terminalFromParticipantStats(summary, participantIds) {
if (!participantIds?.length) return null; if (!participantIds?.length) return null;
const participants = new Set(participantIds); const participants = new Set(participantIds);
const records = summary.records.filter((record) => return summary.records.some(
participants.has(record.id), (record) =>
); participants.has(record.id) &&
// A layer waiting for a key did not fail: its row names the key. Only an (record.error || record.unavailable || record.keyRequired),
// actual error makes the whole batch read LOAD FAILED.
if (
records.some(
(record) => !record.keyRequired && (record.error || record.unavailable),
)
) )
return 'error'; ? 'error'
return records.some((record) => record.keyRequired) ? 'key' : null; : null;
} }
/** Aggregate all manager layers without changing their lifecycle authority. */ /** Aggregate all manager layers without changing their lifecycle authority. */
@@ -287,7 +282,7 @@ function terminalFromEvent(event) {
} }
function mergeTerminalOutcome(current, next) { function mergeTerminalOutcome(current, next) {
const severity = { complete: 1, key: 2, cancelled: 3, error: 4 }; const severity = { complete: 1, cancelled: 2, error: 3 };
if (!next) return current || null; if (!next) return current || null;
if (!current || severity[next] > severity[current]) return next; if (!current || severity[next] > severity[current]) return next;
return current; return current;
@@ -429,7 +424,6 @@ export function presentLoadingFeedback(state, summary, nowMs) {
const labels = { const labels = {
complete: 'LOAD COMPLETE', complete: 'LOAD COMPLETE',
cancelled: 'LOAD CANCELLED', cancelled: 'LOAD CANCELLED',
key: 'KEY REQUIRED',
error: 'LOAD FAILED', error: 'LOAD FAILED',
}; };
const label = const label =
+3 -33
View File
@@ -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)); const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match( assert.match(
css, css,
/#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;/, /#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;/,
); );
assert.match( assert.match(
css, css,
/#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;/, /#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;/,
); );
}); });
@@ -346,7 +346,7 @@ test('participant stats failure outranks a simultaneous visibility completion',
name: 'AIS Vessels', name: 'AIS Vessels',
enabled: true, enabled: true,
lifecycleState: 'enabled', lifecycleState: 'enabled',
stats: { loading: false, error: 'Upstream closed' }, stats: { loading: false, keyRequired: true },
}]); }]);
state = reduceLoadingFeedback(state, missingKey, 250, { state = reduceLoadingFeedback(state, missingKey, 250, {
type: 'visibility', layerId: 'ais-live-vessels', enabled: true, type: 'visibility', layerId: 'ais-live-vessels', enabled: true,
@@ -355,36 +355,6 @@ test('participant stats failure outranks a simultaneous visibility completion',
assert.equal(state.terminal, 'error'); 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', () => { test('retains the worst terminal outcome until every concurrent load drains', () => {
const both = aggregateLayerLoading([ const both = aggregateLayerLoading([
{ id: 'a', name: 'A', lifecycleState: 'enabling' }, { id: 'a', name: 'A', lifecycleState: 'enabling' },
+1
View File
@@ -542,6 +542,7 @@ export class StyleManager extends ShellFacade {
_toggleOrbit: (...args) => this._toggleOrbit(...args), _toggleOrbit: (...args) => this._toggleOrbit(...args),
toggleCleanView: (...args) => this.toggleCleanView(...args), toggleCleanView: (...args) => this.toggleCleanView(...args),
_toggleCctvEnabled: (...args) => this._toggleCctvEnabled(...args), _toggleCctvEnabled: (...args) => this._toggleCctvEnabled(...args),
_togglePanel: (panelId) => this._panelChrome._togglePanel(panelId),
_setBloomEnabled: (...args) => this._setBloomEnabled(...args), _setBloomEnabled: (...args) => this._setBloomEnabled(...args),
_setBloomIntensity: (...args) => this._setBloomIntensity(...args), _setBloomIntensity: (...args) => this._setBloomIntensity(...args),
_setSharpenEnabled: (...args) => this._setSharpenEnabled(...args), _setSharpenEnabled: (...args) => this._setSharpenEnabled(...args),
+5
View File
@@ -27,6 +27,11 @@ export function bindApplicationShortcuts({
event.target?.matches?.('select, input, textarea') || event.target?.matches?.('select, input, textarea') ||
event.target === searchInput; event.target === searchInput;
if (isFormControl && event.key !== 'Escape') return; if (isFormControl && event.key !== 'Escape') return;
// Browser and OS shortcuts (Cmd+C, Ctrl+F, Alt+1…) are not ours.
if (event.metaKey || event.ctrlKey || event.altKey) {
if (event.key === 'Escape') actions.dismissSearch();
return;
}
if (STYLE_KEYS[event.key]) actions.setStyle(STYLE_KEYS[event.key]); if (STYLE_KEYS[event.key]) actions.setStyle(STYLE_KEYS[event.key]);
if (event.key === 'Escape') actions.dismissSearch(); if (event.key === 'Escape') actions.dismissSearch();
+2 -2
View File
@@ -64,8 +64,8 @@ export class DisplayBindings {
}, },
toggleOrbit: () => this._toggleOrbit(), toggleOrbit: () => this._toggleOrbit(),
toggleCleanView: () => this.toggleCleanView(), toggleCleanView: () => this.toggleCleanView(),
toggleLayers: () => // Same collapse as the panel's own button, so its rail entry stays.
document.getElementById('data-panel').classList.toggle('active'), toggleLayers: () => this._togglePanel('data-panel'),
cycleDetection: () => { cycleDetection: () => {
this.shareLinkManager?.claimRestoreLane?.('visual'); this.shareLinkManager?.claimRestoreLane?.('visual');
this.claimDetection(); this.claimDetection();
-1
View File
@@ -23,7 +23,6 @@ const STATUS_LABEL_KEYS = Object.freeze({
'LOAD COMPLETE': msg('LOAD COMPLETE'), 'LOAD COMPLETE': msg('LOAD COMPLETE'),
'LOAD CANCELLED': msg('LOAD CANCELLED'), 'LOAD CANCELLED': msg('LOAD CANCELLED'),
'LOAD FAILED': msg('LOAD FAILED'), 'LOAD FAILED': msg('LOAD FAILED'),
'KEY REQUIRED': msg('KEY REQUIRED'),
'LIVE DATA OFF': msg('LIVE DATA OFF'), 'LIVE DATA OFF': msg('LIVE DATA OFF'),
'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'), 'MAPPED SITES LOADED': msg('MAPPED SITES LOADED'),
'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'), 'RETRYING MAPPED SITES': msg('RETRYING MAPPED SITES'),
+3 -7
View File
@@ -131,7 +131,6 @@ html:not([lang='en']) #global-loading-detail {
#global-loading-status:is( #global-loading-status:is(
[data-state='complete'], [data-state='complete'],
[data-state='cancelled'], [data-state='cancelled'],
[data-state='key'],
[data-state='error'] [data-state='error']
) { ) {
justify-content: center; justify-content: center;
@@ -140,7 +139,6 @@ html:not([lang='en']) #global-loading-detail {
#global-loading-status:is( #global-loading-status:is(
[data-state='complete'], [data-state='complete'],
[data-state='cancelled'], [data-state='cancelled'],
[data-state='key'],
[data-state='error'] [data-state='error']
) )
#global-loading-detail { #global-loading-detail {
@@ -157,13 +155,11 @@ html:not([lang='en']) #global-loading-detail {
#global-loading-status[data-state='cancelled'] { #global-loading-status[data-state='cancelled'] {
color: #ffd488; color: #ffd488;
} }
#global-loading-status:is([data-state='retry'], [data-state='key']) { #global-loading-status[data-state='retry'] {
border-color: rgba(255, 212, 136, 0.55); border-color: rgba(255, 212, 136, 0.55);
} }
#global-loading-status:is([data-state='retry'], [data-state='key']) #global-loading-status[data-state='retry'] #global-loading-label,
#global-loading-label, #global-loading-status[data-state='retry'] #global-loading-detail {
#global-loading-status:is([data-state='retry'], [data-state='key'])
#global-loading-detail {
color: #ffd488; color: #ffd488;
} }
#global-loading-status[data-state='cancelled'] #global-loading-label, #global-loading-status[data-state='cancelled'] #global-loading-label,
+12 -2
View File
@@ -117,9 +117,19 @@ test('the supplied search target retains editing even without form markup', () =
assert.deepEqual(f.calls, [['dismissSearch']]); assert.deepEqual(f.calls, [['dismissSearch']]);
}); });
test('shortcut extraction does not change repeat or modifier policy', () => { test('browser shortcuts with Cmd, Ctrl or Alt never reach app shortcuts', () => {
const f = shortcuts(); const f = shortcuts();
f.press('h', new Element(), { repeat: true, ctrlKey: true }); for (const modifier of ['metaKey', 'ctrlKey', 'altKey'])
for (const key of ['1', 'h', 'o', 'v', 'f', 'd', 'c'])
f.press(key, new Element(), { [modifier]: true });
assert.deepEqual(f.calls, []);
f.press('Escape', new Element(), { metaKey: true });
assert.deepEqual(f.calls, [['dismissSearch']]);
});
test('a held key still repeats its shortcut', () => {
const f = shortcuts();
f.press('h', new Element(), { repeat: true });
assert.deepEqual(f.calls, [['toggleHud']]); assert.deepEqual(f.calls, [['toggleHud']]);
}); });