Merge upstream/main into pr/google-street-view

Conflicts with upstream's Street Level provider composition and share-state
codec, resolved:

- Street View's switch moves from its own share option (`g`) to upstream's
  provider-switch field (`r`, e.g. `0.r.google-1`); a link without it keeps
  Street View off. Docs and the browser gate follow.
- createApplicationStreetLevel keeps upstream's injectable provider list;
  its default, once the application supplies its Street Level source, is
  Mapillary plus Google Street View. Without that source there are none, as
  upstream intends.
- The coverage hint keeps upstream's empty-composition message and joins
  every switched-on provider's hint otherwise.
- CHANGELOG, README and SECURITY keep both sides' entries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-12 11:32:05 +01:00
commit 76db4114ff
80 files changed
+3845 -201

No files matched your search

+15 -5
View File
@@ -595,7 +595,12 @@ export class LayerPanel {
}
_syncToggleButton(button, layer) {
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
const unavailable = layer.stats?.sourceUnavailable === true;
const feedState = unavailable
? 'unavailable'
: layer.enabled
? layerFeedState(layer.stats)
: 'off';
const transitioning =
layer.lifecycleState === 'enabling' ||
layer.lifecycleState === 'disabling';
@@ -608,7 +613,7 @@ export class LayerPanel {
for (const state of Object.keys(FEED_STATE_LABELS)) {
button.classList.toggle(
`feed-${state}`,
layer.enabled && !uncertain && feedState === state,
(layer.enabled || unavailable) && !uncertain && feedState === state,
);
}
button.dataset.feedState = transitioning
@@ -620,16 +625,21 @@ export class LayerPanel {
// click guard above prevents repeat activation without the focus loss caused
// by native `disabled`.
button.disabled = false;
button.setAttribute('aria-disabled', String(transitioning));
button.setAttribute(
'aria-disabled',
String(transitioning || (unavailable && !layer.enabled)),
);
button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
? layer.lifecycleState.toUpperCase()
: uncertain
? 'UNCERTAIN'
: layer.enabled
: layer.enabled || unavailable
? FEED_STATE_LABELS[feedState]
: 'OFF';
const keyGuidance = layerKeyRequirementTooltip(layer);
const keyGuidance = unavailable
? String(layer.stats.error || 'Data source unavailable')
: layerKeyRequirementTooltip(layer);
// Name the missing key on the control itself: a row reading KEY REQUIRED
// without saying WHICH key leaves a dead control and no next step. Empty
// when the layer needs no key, or already has one.
+30
View File
@@ -228,3 +228,33 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou
globalThis.document = previousDocument;
}
});
test('an omitted source is visibly unavailable and cannot be toggled on', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const attrs = new Map();
const button = {
classList: { toggle() {} },
dataset: {},
setAttribute: (key, value) => attrs.set(key, value),
};
const layer = {
id: 'flights',
name: 'Flights',
enabled: false,
stats: {
sourceUnavailable: true,
status: 'unavailable',
error: 'Data source not configured: flights',
},
};
LayerPanel.prototype._syncToggleButton(button, layer);
assert.equal(button.textContent, 'UNAVAILABLE');
assert.equal(attrs.get('aria-disabled'), 'true');
assert.match(button.title, /not configured/);
LayerPanel.prototype._syncToggleButton(button, { ...layer, enabled: true });
assert.equal(
attrs.get('aria-disabled'),
'false',
'an active layer can always be switched off',
);
});
+2 -1
View File
@@ -184,6 +184,7 @@ function presentNearest(state) {
}
function presentMeta(state) {
if (state.providers.length === 0) return state.coverage.hint || '';
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
// Key-gated with another source ready: say how to use it.
if (state.keyRequired && state.keyAlternatives?.length)
@@ -215,7 +216,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
enabled,
keyRequired,
status: presentStatus(state),
controlsDisabled: keyRequired,
controlsDisabled: keyRequired || state.providers.length === 0,
providers: presentProviders(state),
error: keyMissing
? null
+17
View File
@@ -9,6 +9,23 @@ import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
const provider = providerSnapshot;
test('an empty composition explains unavailable imagery even while disabled', () => {
for (const enabled of [false, true]) {
const view = presentStreetLevelPanel(
snapshot({
enabled,
providers: [],
coverage: { hint: 'No street-level imagery providers configured' },
legend: [],
}),
);
assert.equal(view.controlsDisabled, true);
assert.equal(view.keyRequired, false);
assert.deepEqual(view.providers, []);
assert.equal(view.meta, 'No street-level imagery providers configured');
}
});
function snapshot(overrides = {}) {
const base = {
enabled: false,
+37
View File
@@ -300,6 +300,43 @@
color: rgba(148, 178, 188, 0.42);
}
.key-setup-cloud-auth {
display: flex;
align-items: center;
gap: 0.5rem;
}
.key-setup-cloud-auth-state {
color: rgba(190, 214, 222, 0.7);
font-family: var(--font-mono);
font-size: 0.56rem;
letter-spacing: 0.05rem;
}
.key-setup-cloud-auth-toggle {
margin-left: auto;
padding: 0.28rem 0.55rem;
color: rgba(54, 220, 255, 0.9);
font-family: var(--font-mono);
font-size: 0.55rem;
letter-spacing: 0.06rem;
background: rgba(54, 220, 255, 0.08);
border: 1px solid rgba(54, 220, 255, 0.28);
border-radius: 0.32rem;
cursor: pointer;
}
.key-setup-cloud-auth-toggle:hover,
.key-setup-cloud-auth-toggle:focus-visible {
border-color: rgba(54, 220, 255, 0.65);
outline: none;
}
.key-setup-cloud-auth-toggle[aria-disabled='true'] {
cursor: wait;
opacity: 0.5;
}
.key-setup-footer {
margin-top: 0.85rem;
}