diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 62dfc056b..4aa856d3d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3063,7 +3063,11 @@ The header pill is the layer switch. With one provider its chip is a layer switch too; with several, darkening the last lit chip turns the layer off. A chip without a usable key explains the key instead of lighting (or of switching the layer off as the last lit chip); it still darkens beside a lit -one, and the other chips keep switching. The +one, and the other chips keep switching. When the switched-on providers lack +their keys but a switched-off one would draw with a key already set (no +Mapillary token, Street View on Google 3D with a Google key), the layer +reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage +line and the layer row's tooltip say to light it. The viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and stops when the stack changes. Camera moves go through the application's @@ -3102,7 +3106,7 @@ for other maps, so it draws none: with it on, a click on the map itself Mapillary line under the pointer wins; no tool holding the pointer; camera below 3 km) opens its nearest Google-collected outdoor panorama within 50 m and leaves the camera where it is (`groundClick.js`). Higher up the hint -says to zoom in. **OPEN STREET VIEW** in the PROVIDERS row (`#sl-nearest-btn`) +says to zoom in. **OPEN STREET VIEW** beside that hint (`#sl-nearest-btn`) does the same at the view centre, so Street View needs no pointer; it is disabled until the camera is at street zoom. The viewer is Google's `StreetViewPanorama` in its own element in the viewer host, without Google's fullscreen, close and address diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs index e9826ef23..d4c5b9287 100644 --- a/scripts/qa-panel-resize.mjs +++ b/scripts/qa-panel-resize.mjs @@ -384,23 +384,33 @@ async function main() { // Grow it from the corner once the restored window holds still, with the // pointer really on the handle (a press beside it selects page text). let chosen = await readPanel(); + const tried = []; for ( let attempt = 0; attempt < 3 && chosen.height <= MIN_SIZE.height + 150; attempt++ ) { await settle(); - const corner = handlePoint(await readPanel(), 'se'); - const onHandle = await page.evaluate( - ({ x, y }) => document.elementFromPoint(x, y)?.dataset?.dir === 'se', - corner, + const box = await readPanel(); + const corner = handlePoint(box, 'se'); + const hit = await page.evaluate(({ x, y }) => { + const node = document.elementFromPoint(x, y); + return { + dir: node?.dataset?.dir || null, + target: node + ? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}` + : 'nothing', + }; + }, corner); + tried.push( + `${Math.round(corner.x)},${Math.round(corner.y)} on ${hit.target} (box ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)})`, ); - if (onHandle) await drag(corner, 0, 200); + if (hit.dir === 'se') await drag(corner, 0, 200); chosen = await readPanel(); } assert.ok( chosen.height > MIN_SIZE.height + 150, - `the window grew to ${chosen.height}px`, + `the window grew to ${chosen.height}px; tried ${tried.join('; ')}`, ); await setCollapsed(true); const strip = await readPanel(); diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index 4e5f1e9d0..d366e22b2 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -352,6 +352,30 @@ export function keySetupRequirement(id) { return `Needs ${entry.envVars.join(' + ')} — add it in Provider Settings`; } +/** + * Key guidance that also names the switched-off sources the layer could use + * with a key that is already set: `[{label, requiresKeyId}]`, e.g. Street + * View beside a Mapillary layer with no token. + * @param {string} requirement What `keySetupRequirement` said ('' allowed). + * @param {Array<{label: string, requiresKeyId?: string|null}>} [alternatives] + */ +export function keySetupWithAlternatives(requirement, alternatives = []) { + const options = alternatives.map(({ label, requiresKeyId }) => { + const entry = KEY_SETUP_KEYS.find( + (candidate) => candidate.id === requiresKeyId, + ); + const name = entry + ? entry.title + .toLowerCase() + .replace(/\b\w/g, (letter) => letter.toUpperCase()) + : null; + return name ? `light ${label} to use your ${name} key` : `light ${label}`; + }); + if (!options.length) return requirement; + const offer = `or ${options.join(', or ')}`; + return requirement ? `${requirement}, ${offer}` : offer.replace(/^or l/, 'L'); +} + /** * Decide whether a live provider value belongs to a source outside the store * Provider Settings is allowed to edit. `wasExternalAtBoot` carries source diff --git a/src/keySetupCore.test.mjs b/src/keySetupCore.test.mjs index 05f406009..643c0c2a5 100644 --- a/src/keySetupCore.test.mjs +++ b/src/keySetupCore.test.mjs @@ -9,6 +9,7 @@ import { isKeySetupExternallyManaged, keySetupStatus, keySetupRequirement, + keySetupWithAlternatives, knownKeySetupEnvVars, parseWindowsUserSid, upsertDotenvValues, @@ -28,7 +29,10 @@ test('the boot provenance snapshot survives in-process Vite config re-evaluation // panel save has already set its values live on process.env. A recomputed // snapshot would classify the panel's own keys as external (read-only) until // a full process relaunch, so the first evaluation's snapshot must win. - const source = readFileSync(new URL('../server/standalone/key-setup.js', import.meta.url), 'utf8'); + const source = readFileSync( + new URL('../server/standalone/key-setup.js', import.meta.url), + 'utf8', + ); assert.match( source, /const PROVIDER_ENV_AT_BOOT\s*=\s*\(?globalThis\.__GEV_PROVIDER_ENV_AT_BOOT\s*\?\?=\s*Object\.freeze\(/, @@ -36,24 +40,40 @@ test('the boot provenance snapshot survives in-process Vite config re-evaluation }); test('external ownership uses boot provenance even when store and shell values match', () => { - assert.equal(isKeySetupExternallyManaged({ - effectiveValue: 'same-value', - storedValue: 'same-value', - wasExternalAtBoot: true, - }), true, 'equal bytes cannot turn a shell/Keychain value into a file-owned value'); - assert.equal(isKeySetupExternallyManaged({ - effectiveValue: 'file-value', - storedValue: 'file-value', - }), false, 'a value loaded only from the owned store remains editable'); - assert.equal(isKeySetupExternallyManaged({ - effectiveValue: 'shell-value', - storedValue: 'stale-file-value', - }), true, 'a differing live value remains external'); - assert.equal(isKeySetupExternallyManaged({ - effectiveValue: '', - storedValue: 'stale-file-value', - wasExternalAtBoot: true, - }), false, 'an absent live credential has no external owner'); + assert.equal( + isKeySetupExternallyManaged({ + effectiveValue: 'same-value', + storedValue: 'same-value', + wasExternalAtBoot: true, + }), + true, + 'equal bytes cannot turn a shell/Keychain value into a file-owned value', + ); + assert.equal( + isKeySetupExternallyManaged({ + effectiveValue: 'file-value', + storedValue: 'file-value', + }), + false, + 'a value loaded only from the owned store remains editable', + ); + assert.equal( + isKeySetupExternallyManaged({ + effectiveValue: 'shell-value', + storedValue: 'stale-file-value', + }), + true, + 'a differing live value remains external', + ); + assert.equal( + isKeySetupExternallyManaged({ + effectiveValue: '', + storedValue: 'stale-file-value', + wasExternalAtBoot: true, + }), + false, + 'an absent live credential has no external owner', + ); }); test('the status payload reports presence without any credential material', () => { @@ -63,17 +83,35 @@ test('the status payload reports presence without any credential material', () = // Secret missing: the OpenSky pair must read as NOT set. }; const status = keySetupStatus(env); - assert.equal(status.total, KEY_SETUP_KEYS.filter((key) => !key.hidden).length); + assert.equal( + status.total, + KEY_SETUP_KEYS.filter((key) => !key.hidden).length, + ); const google = status.keys.find((key) => key.id === 'google-maps'); assert.equal(google.set, true); const opensky = status.keys.find((key) => key.id === 'opensky'); assert.equal(opensky.set, false, 'half a credential pair is not configured'); const serialized = JSON.stringify(status); - assert.ok(!serialized.includes('AIzaSyFakeFakeFakeFake1234'), 'a value leaked into status'); - assert.ok(!serialized.includes('client-id-abcdef'), 'a value leaked into status'); - assert.ok(!serialized.includes('1234'), 'a credential suffix leaked into status'); - assert.ok(!serialized.includes('abcdef'), 'a credential suffix leaked into status'); - assert.ok(!serialized.includes('tails'), 'status must not expose a credential-tail field'); + assert.ok( + !serialized.includes('AIzaSyFakeFakeFakeFake1234'), + 'a value leaked into status', + ); + assert.ok( + !serialized.includes('client-id-abcdef'), + 'a value leaked into status', + ); + assert.ok( + !serialized.includes('1234'), + 'a credential suffix leaked into status', + ); + assert.ok( + !serialized.includes('abcdef'), + 'a credential suffix leaked into status', + ); + assert.ok( + !serialized.includes('tails'), + 'status must not expose a credential-tail field', + ); assert.equal(status.setCount, 1); }); @@ -84,25 +122,57 @@ test('whitespace-only env values do not count as configured', () => { test('subprocess success requires a clean zero exit', () => { assert.equal(commandCompletedSuccessfully({ status: 0, signal: null }), true); - assert.equal(commandCompletedSuccessfully({ status: 1, signal: null }), false); - assert.equal(commandCompletedSuccessfully({ status: 0, signal: 'SIGTERM' }), false); - assert.equal(commandCompletedSuccessfully({ status: 0, signal: null, error: new Error('spawn failed') }), false); + assert.equal( + commandCompletedSuccessfully({ status: 1, signal: null }), + false, + ); + assert.equal( + commandCompletedSuccessfully({ status: 0, signal: 'SIGTERM' }), + false, + ); + assert.equal( + commandCompletedSuccessfully({ + status: 0, + signal: null, + error: new Error('spawn failed'), + }), + false, + ); assert.equal(commandCompletedSuccessfully(null), false); }); test('Windows owner SID parsing reads only the structured user-SID CSV field', () => { const localUser = 'S-1-5-21-1111111111-2222222222-3333333333-1001'; const entraUser = 'S-1-12-1-1111111111-2222222222-3333333333-4444444444'; - assert.equal(parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}"\r\n`), localUser); - assert.equal(parseWindowsUserSid(`"AzureAD\\alice","${entraUser}"`), entraUser); + assert.equal( + parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}"\r\n`), + localUser, + ); + assert.equal( + parseWindowsUserSid(`"AzureAD\\alice","${entraUser}"`), + entraUser, + ); assert.equal( parseWindowsUserSid(`"${localUser}","S-1-5-32-545"`), null, 'an SID-looking account name must never be mistaken for the token SID', ); - assert.equal(parseWindowsUserSid('"WORKSTATION\\alice","S-1-5-32-545"'), null, 'broad group SID refused'); - assert.equal(parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}"\n"extra","${localUser}"`), null); - assert.equal(parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}`), null, 'unterminated CSV refused'); + assert.equal( + parseWindowsUserSid('"WORKSTATION\\alice","S-1-5-32-545"'), + null, + 'broad group SID refused', + ); + assert.equal( + parseWindowsUserSid( + `"WORKSTATION\\alice","${localUser}"\n"extra","${localUser}"`, + ), + null, + ); + assert.equal( + parseWindowsUserSid(`"WORKSTATION\\alice","${localUser}`), + null, + 'unterminated CSV refused', + ); }); test('validation accepts every registry env var and only those', () => { @@ -112,8 +182,16 @@ test('validation accepts every registry env var and only those', () => { assert.equal(verdict.ok, true, `${name} should validate`); assert.equal(verdict.updates[name], 'valid-value-123'); } - assert.equal(validateKeySetupUpdates({ PATH: '/usr/bin' }).ok, false, 'PATH must be refused'); - assert.equal(validateKeySetupUpdates({ NODE_OPTIONS: '--x' }).ok, false, 'NODE_OPTIONS must be refused'); + assert.equal( + validateKeySetupUpdates({ PATH: '/usr/bin' }).ok, + false, + 'PATH must be refused', + ); + assert.equal( + validateKeySetupUpdates({ NODE_OPTIONS: '--x' }).ok, + false, + 'NODE_OPTIONS must be refused', + ); }); test('validation trims, and refuses empties, newlines, spaces, and oversize values', () => { @@ -122,11 +200,25 @@ test('validation trims, and refuses empties, newlines, spaces, and oversize valu assert.equal(trimmed.updates.OPENAI_API_KEY, 'sk-abc123'); assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: '' }).ok, false); assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: ' ' }).ok, false); - assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: 'a\nb' }).ok, false, 'newline injection'); - assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: 'a b' }).ok, false, 'inner space'); - assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: 'kéy' }).ok, false, 'non-ASCII'); assert.equal( - validateKeySetupUpdates({ OPENAI_API_KEY: 'x'.repeat(KEY_SETUP_VALUE_LIMIT + 1) }).ok, + validateKeySetupUpdates({ OPENAI_API_KEY: 'a\nb' }).ok, + false, + 'newline injection', + ); + assert.equal( + validateKeySetupUpdates({ OPENAI_API_KEY: 'a b' }).ok, + false, + 'inner space', + ); + assert.equal( + validateKeySetupUpdates({ OPENAI_API_KEY: 'kéy' }).ok, + false, + 'non-ASCII', + ); + assert.equal( + validateKeySetupUpdates({ + OPENAI_API_KEY: 'x'.repeat(KEY_SETUP_VALUE_LIMIT + 1), + }).ok, false, ); assert.equal(validateKeySetupUpdates(null).ok, false); @@ -144,13 +236,16 @@ test('upsert replaces the last active assignment in place', () => { '', ].join('\n'); const next = upsertDotenvValues(text, { OPENAI_API_KEY: 'new-key' }); - assert.equal(next, [ - '# comment stays', - 'OPENAI_API_KEY=old-one', - 'PORT=4173', - 'OPENAI_API_KEY=new-key', - '', - ].join('\n')); + assert.equal( + next, + [ + '# comment stays', + 'OPENAI_API_KEY=old-one', + 'PORT=4173', + 'OPENAI_API_KEY=new-key', + '', + ].join('\n'), + ); }); test('upsert uncomments a commented assignment in place, keeping file shape', () => { @@ -161,36 +256,49 @@ test('upsert uncomments a commented assignment in place, keeping file shape', () 'PORT=4173', ].join('\n'); const next = upsertDotenvValues(text, { FIRMS_MAP_KEY: 'firms-123' }); - assert.equal(next, [ - '# Optional: NASA FIRMS live active fires.', - 'FIRMS_MAP_KEY=firms-123', - '', - 'PORT=4173', - ].join('\n') + '\n'); + assert.equal( + next, + [ + '# Optional: NASA FIRMS live active fires.', + 'FIRMS_MAP_KEY=firms-123', + '', + 'PORT=4173', + ].join('\n') + '\n', + ); }); test('upsert appends unknown keys under one shared header, once', () => { const first = upsertDotenvValues('PORT=4173\n', { OPENAI_API_KEY: 'sk-1' }); - assert.equal(first, [ - 'PORT=4173', - '', - KEY_SETUP_APPEND_HEADER, - 'OPENAI_API_KEY=sk-1', - ].join('\n') + '\n'); + assert.equal( + first, + ['PORT=4173', '', KEY_SETUP_APPEND_HEADER, 'OPENAI_API_KEY=sk-1'].join( + '\n', + ) + '\n', + ); const second = upsertDotenvValues(first, { FIRMS_MAP_KEY: 'f-2' }); - assert.equal(second, [ - 'PORT=4173', - '', - KEY_SETUP_APPEND_HEADER, - 'OPENAI_API_KEY=sk-1', - 'FIRMS_MAP_KEY=f-2', - ].join('\n') + '\n'); - assert.equal(second.split(KEY_SETUP_APPEND_HEADER).length, 2, 'header written once'); + assert.equal( + second, + [ + 'PORT=4173', + '', + KEY_SETUP_APPEND_HEADER, + 'OPENAI_API_KEY=sk-1', + 'FIRMS_MAP_KEY=f-2', + ].join('\n') + '\n', + ); + assert.equal( + second.split(KEY_SETUP_APPEND_HEADER).length, + 2, + 'header written once', + ); }); test('upsert births a well-formed file from nothing', () => { const next = upsertDotenvValues('', { GOOGLE_MAPS_API_KEY: 'AIza-x' }); - assert.equal(next, `${KEY_SETUP_APPEND_HEADER}\nGOOGLE_MAPS_API_KEY=AIza-x\n`); + assert.equal( + next, + `${KEY_SETUP_APPEND_HEADER}\nGOOGLE_MAPS_API_KEY=AIza-x\n`, + ); }); test('upsert handles export-prefixed lines and never touches lookalike keys', () => { @@ -207,15 +315,21 @@ test('upsert handles export-prefixed lines and never touches lookalike keys', () }); test('upsert is idempotent for a repeated save', () => { - const once = upsertDotenvValues('', { OPENAI_API_KEY: 'sk-1', FIRMS_MAP_KEY: 'f-1' }); - const twice = upsertDotenvValues(once, { OPENAI_API_KEY: 'sk-1', FIRMS_MAP_KEY: 'f-1' }); + const once = upsertDotenvValues('', { + OPENAI_API_KEY: 'sk-1', + FIRMS_MAP_KEY: 'f-1', + }); + const twice = upsertDotenvValues(once, { + OPENAI_API_KEY: 'sk-1', + FIRMS_MAP_KEY: 'f-1', + }); assert.equal(once, twice); }); test('a real .env.example round-trip: the curated file keeps its shape', () => { // A representative slice of the shipped .env.example. const example = [ - '# God\'s Eye View — environment variables', + "# God's Eye View — environment variables", 'GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here', '', '# Optional: OpenAI Realtime voice control. Do not prefix with VITE_.', @@ -232,8 +346,16 @@ test('a real .env.example round-trip: the curated file keeps its shape', () => { const lines = next.split('\n'); assert.equal(lines[1], 'GOOGLE_MAPS_API_KEY=AIza-real'); assert.equal(lines[4], 'OPENAI_API_KEY=sk-real'); - assert.equal(lines[5], 'OPENAI_REALTIME_MODEL=gpt-realtime-2', 'sibling key untouched'); - assert.equal(lines[7], 'TOMTOM_API_KEY=tt-real', 'commented key uncommented in place'); + assert.equal( + lines[5], + 'OPENAI_REALTIME_MODEL=gpt-realtime-2', + 'sibling key untouched', + ); + assert.equal( + lines[7], + 'TOMTOM_API_KEY=tt-real', + 'commented key uncommented in place', + ); }); test('the admission gate refuses every non-local shape, one assertion per refusal', async () => { @@ -246,29 +368,111 @@ test('the admission gate refuses every non-local shape, one assertion per refusa contentType: 'application/json', env: {}, }; - assert.equal(admitKeySetupRequest(local).ok, true, 'the honest local request is admitted'); - assert.equal(admitKeySetupRequest({ ...local, method: 'GET', contentType: undefined }).ok, true, 'local GET needs no content type'); - assert.equal(admitKeySetupRequest({ ...local, origin: undefined }).ok, false, 'POST without Origin is refused'); - assert.equal(admitKeySetupRequest({ ...local, method: 'GET', origin: undefined, contentType: undefined }).ok, true, 'local GET may omit Origin'); - assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '::ffff:127.0.0.1', hostHeader: '[::1]:4173', origin: 'http://[::1]:4173' }).ok, true, 'IPv6 loopback forms are local'); + assert.equal( + admitKeySetupRequest(local).ok, + true, + 'the honest local request is admitted', + ); + assert.equal( + admitKeySetupRequest({ ...local, method: 'GET', contentType: undefined }) + .ok, + true, + 'local GET needs no content type', + ); + assert.equal( + admitKeySetupRequest({ ...local, origin: undefined }).ok, + false, + 'POST without Origin is refused', + ); + assert.equal( + admitKeySetupRequest({ + ...local, + method: 'GET', + origin: undefined, + contentType: undefined, + }).ok, + true, + 'local GET may omit Origin', + ); + assert.equal( + admitKeySetupRequest({ + ...local, + remoteAddress: '::ffff:127.0.0.1', + hostHeader: '[::1]:4173', + origin: 'http://[::1]:4173', + }).ok, + true, + 'IPv6 loopback forms are local', + ); // Tunnel/LAN sharing of any kind removes the surface outright — tunnel // traffic arrives FROM loopback, so no socket check can carry this boundary. - assert.equal(admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_CLOUDFLARE: 'true' } }).ok, false, 'sharing disables the surface'); - assert.equal(admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: '1' } }).ok, false, 'LAN sharing disables the surface'); + assert.equal( + admitKeySetupRequest({ + ...local, + env: { PINOKIO_SHARE_CLOUDFLARE: 'true' }, + }).ok, + false, + 'sharing disables the surface', + ); + assert.equal( + admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: '1' } }).ok, + false, + 'LAN sharing disables the surface', + ); // A LAN peer reaching a wide-bound server. - assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }).ok, false, 'non-loopback socket refused'); + assert.equal( + admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }).ok, + false, + 'non-loopback socket refused', + ); // Tunnel and DNS-rebinding traffic carries a foreign Host over a loopback socket. - assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'abc.trycloudflare.com' }).ok, false, 'foreign Host refused'); - assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }).ok, false, 'non-localhost hostnames refused'); - assert.equal(admitKeySetupRequest({ ...local, hostHeader: '' }).ok, false, 'missing Host refused'); - assert.equal(admitKeySetupRequest({ ...local, hostHeader: '[::1].evil:4173' }).ok, false, 'malformed bracketed Host refused'); + assert.equal( + admitKeySetupRequest({ ...local, hostHeader: 'abc.trycloudflare.com' }).ok, + false, + 'foreign Host refused', + ); + assert.equal( + admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }).ok, + false, + 'non-localhost hostnames refused', + ); + assert.equal( + admitKeySetupRequest({ ...local, hostHeader: '' }).ok, + false, + 'missing Host refused', + ); + assert.equal( + admitKeySetupRequest({ ...local, hostHeader: '[::1].evil:4173' }).ok, + false, + 'malformed bracketed Host refused', + ); // A hostile web page POSTing at localhost carries its own Origin. - assert.equal(admitKeySetupRequest({ ...local, origin: 'https://evil.example' }).ok, false, 'cross-origin refused'); - assert.equal(admitKeySetupRequest({ ...local, origin: 'not a url' }).ok, false, 'unparseable Origin refused'); - assert.equal(admitKeySetupRequest({ ...local, origin: 'http://localhost:4174' }).ok, false, 'cross-port Origin refused'); - assert.equal(admitKeySetupRequest({ ...local, origin: 'https://localhost:4173' }).ok, false, 'cross-scheme Origin refused'); - assert.equal(admitKeySetupRequest({ ...local, origin: 'http://127.0.0.1:4173' }).ok, false, 'different loopback host Origin refused'); + assert.equal( + admitKeySetupRequest({ ...local, origin: 'https://evil.example' }).ok, + false, + 'cross-origin refused', + ); + assert.equal( + admitKeySetupRequest({ ...local, origin: 'not a url' }).ok, + false, + 'unparseable Origin refused', + ); + assert.equal( + admitKeySetupRequest({ ...local, origin: 'http://localhost:4174' }).ok, + false, + 'cross-port Origin refused', + ); + assert.equal( + admitKeySetupRequest({ ...local, origin: 'https://localhost:4173' }).ok, + false, + 'cross-scheme Origin refused', + ); + assert.equal( + admitKeySetupRequest({ ...local, origin: 'http://127.0.0.1:4173' }).ok, + false, + 'different loopback host Origin refused', + ); // A simple-request POST (no JSON content type) is the CSRF write shape. const noJson = admitKeySetupRequest({ ...local, contentType: 'text/plain' }); assert.equal(noJson.ok, false, 'non-JSON POST refused'); @@ -279,8 +483,16 @@ test('a null value validates as a removal; an empty string still does not', () = const removal = validateKeySetupUpdates({ OPENAI_API_KEY: null }); assert.equal(removal.ok, true); assert.equal(removal.updates.OPENAI_API_KEY, null); - assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: '' }).ok, false, 'empty is a mistake, not a removal'); - assert.equal(validateKeySetupUpdates({ PATH: null }).ok, false, 'removal is registry-bound too'); + assert.equal( + validateKeySetupUpdates({ OPENAI_API_KEY: '' }).ok, + false, + 'empty is a mistake, not a removal', + ); + assert.equal( + validateKeySetupUpdates({ PATH: null }).ok, + false, + 'removal is registry-bound too', + ); }); test('removal comments the assignment back out, returning the file to template shape', () => { @@ -291,7 +503,11 @@ test('removal comments the assignment back out, returning the file to template s ].join('\n'); const next = upsertDotenvValues(text, { OPENAI_API_KEY: null }); const lines = next.split('\n'); - assert.equal(lines[1], '# OPENAI_API_KEY=', 'active line commented out, not deleted'); + assert.equal( + lines[1], + '# OPENAI_API_KEY=', + 'active line commented out, not deleted', + ); assert.equal(lines[2], 'PORT=4173', 'neighbors untouched'); // Removing a key with no active assignment changes nothing. assert.equal(upsertDotenvValues(next, { FIRMS_MAP_KEY: null }), next); @@ -303,62 +519,161 @@ test('removal comments the assignment back out, returning the file to template s test('the sharing gate treats a real PINOKIO_SHARE_VAR as sharing, but not the empty/sentinel normal state', async () => { const { admitKeySetupRequest } = await import('./keySetupCore.mjs'); const base = { - method: 'POST', remoteAddress: '127.0.0.1', hostHeader: 'localhost:4173', - origin: 'http://localhost:4173', contentType: 'application/json', + method: 'POST', + remoteAddress: '127.0.0.1', + hostHeader: 'localhost:4173', + origin: 'http://localhost:4173', + contentType: 'application/json', }; // The ordinary launch states: unset, empty, or the explicit disabled sentinel. - assert.equal(admitKeySetupRequest({ ...base, env: {} }).ok, true, 'unset SHARE_VAR is normal'); - assert.equal(admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: '' } }).ok, true, 'empty SHARE_VAR is normal'); - assert.equal(admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: '__gev_sharing_disabled__' } }).ok, true, 'the disabled sentinel is normal'); + assert.equal( + admitKeySetupRequest({ ...base, env: {} }).ok, + true, + 'unset SHARE_VAR is normal', + ); + assert.equal( + admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: '' } }).ok, + true, + 'empty SHARE_VAR is normal', + ); + assert.equal( + admitKeySetupRequest({ + ...base, + env: { PINOKIO_SHARE_VAR: '__gev_sharing_disabled__' }, + }).ok, + true, + 'the disabled sentinel is normal', + ); // A real tunnel var disables the surface. - assert.equal(admitKeySetupRequest({ ...base, env: { PINOKIO_SHARE_VAR: 'MY_TUNNEL_TOKEN' } }).ok, false, 'a real share var is sharing'); + assert.equal( + admitKeySetupRequest({ + ...base, + env: { PINOKIO_SHARE_VAR: 'MY_TUNNEL_TOKEN' }, + }).ok, + false, + 'a real share var is sharing', + ); }); test('the gate refuses proxied requests even from a loopback socket with local headers', async () => { const { admitKeySetupRequest } = await import('./keySetupCore.mjs'); const base = { - method: 'POST', remoteAddress: '127.0.0.1', hostHeader: 'localhost:4173', - origin: 'http://localhost:4173', contentType: 'application/json', env: {}, + method: 'POST', + remoteAddress: '127.0.0.1', + hostHeader: 'localhost:4173', + origin: 'http://localhost:4173', + contentType: 'application/json', + env: {}, }; - assert.equal(admitKeySetupRequest(base).ok, true, 'no proxy headers → admitted'); - for (const header of ['x-forwarded-for', 'forwarded', 'via', 'cf-connecting-ip', 'cf-ray', 'x-real-ip', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto']) { + assert.equal( + admitKeySetupRequest(base).ok, + true, + 'no proxy headers → admitted', + ); + for (const header of [ + 'x-forwarded-for', + 'forwarded', + 'via', + 'cf-connecting-ip', + 'cf-ray', + 'x-real-ip', + 'x-forwarded-host', + 'x-forwarded-port', + 'x-forwarded-proto', + ]) { assert.equal( - admitKeySetupRequest({ ...base, proxyHeaders: { [header]: 'anything' } }).ok, + admitKeySetupRequest({ ...base, proxyHeaders: { [header]: 'anything' } }) + .ok, false, `${header} present → refused`, ); } // An empty forwarding header is not a proxy signal. - assert.equal(admitKeySetupRequest({ ...base, proxyHeaders: { 'x-forwarded-for': '' } }).ok, true); + assert.equal( + admitKeySetupRequest({ ...base, proxyHeaders: { 'x-forwarded-for': '' } }) + .ok, + true, + ); }); test('validation rejects dotenv metacharacters that would round-trip wrong', () => { for (const bad of ['abc#def', 'ab"cd', "ab'cd", 'ab$cd', 'ab\\cd', 'ab`cd']) { - assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: bad }).ok, false, `${JSON.stringify(bad)} refused`); + assert.equal( + validateKeySetupUpdates({ OPENAI_API_KEY: bad }).ok, + false, + `${JSON.stringify(bad)} refused`, + ); } // Real key alphabets still pass: base64url, JWT dots, hex, plus/slash. - for (const good of ['sk-AbC0-9_x', 'eyJhbGc.eyJzdWI.QWxpY2U', 'a1b2c3d4e5f6', 'AB+cd/ef=']) { - assert.equal(validateKeySetupUpdates({ OPENAI_API_KEY: good }).ok, true, `${good} accepted`); + for (const good of [ + 'sk-AbC0-9_x', + 'eyJhbGc.eyJzdWI.QWxpY2U', + 'a1b2c3d4e5f6', + 'AB+cd/ef=', + ]) { + assert.equal( + validateKeySetupUpdates({ OPENAI_API_KEY: good }).ok, + true, + `${good} accepted`, + ); } }); test('server Google key remains supported without appearing in setup or its missing count', () => { const secret = 'server-key-fixture'; - assert.deepEqual(validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: secret }), { - ok: true, updates: { GOOGLE_MAPS_SERVER_API_KEY: secret }, - }); - assert.equal(validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: null }).ok, true); + assert.deepEqual( + validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: secret }), + { + ok: true, + updates: { GOOGLE_MAPS_SERVER_API_KEY: secret }, + }, + ); + assert.equal( + validateKeySetupUpdates({ GOOGLE_MAPS_SERVER_API_KEY: null }).ok, + true, + ); const status = keySetupStatus({ GOOGLE_MAPS_SERVER_API_KEY: secret }); assert.deepEqual(status, keySetupStatus({})); - assert.equal(status.keys.some((key) => key.id === 'google-maps-server'), false); + assert.equal( + status.keys.some((key) => key.id === 'google-maps-server'), + false, + ); assert.equal(keySetupRequirement('google-maps-server'), ''); - assert.equal(status.keys.find((key) => key.id === 'google-maps').title, 'GOOGLE MAPS'); - const allVisibleConfigured = Object.fromEntries(status.keys.flatMap((key) => - key.envVars.map((name) => [name, 'configured-fixture']), - )); + assert.equal( + status.keys.find((key) => key.id === 'google-maps').title, + 'GOOGLE MAPS', + ); + const allVisibleConfigured = Object.fromEntries( + status.keys.flatMap((key) => + key.envVars.map((name) => [name, 'configured-fixture']), + ), + ); const complete = keySetupStatus(allVisibleConfigured); - assert.equal(complete.setCount, complete.total, 'an absent server key must not leave setup incomplete'); - assert.deepEqual(complete, keySetupStatus({ ...allVisibleConfigured, GOOGLE_MAPS_SERVER_API_KEY: secret })); + assert.equal( + complete.setCount, + complete.total, + 'an absent server key must not leave setup incomplete', + ); + assert.deepEqual( + complete, + keySetupStatus({ + ...allVisibleConfigured, + GOOGLE_MAPS_SERVER_API_KEY: secret, + }), + ); assert.ok(!JSON.stringify(status).includes('GOOGLE_MAPS_SERVER_API_KEY')); assert.ok(!JSON.stringify(status).includes(secret)); }); + +test('key guidance can offer another source that works with a key already set', () => { + const streetView = { label: 'STREET VIEW', requiresKeyId: 'google-maps' }; + assert.equal( + keySetupWithAlternatives(keySetupRequirement('mapillary'), [streetView]), + 'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key', + ); + assert.equal( + keySetupWithAlternatives('', [streetView]), + 'Light STREET VIEW to use your Google Maps key', + ); + assert.equal(keySetupWithAlternatives('Needs X', []), 'Needs X'); +}); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 6cee26b71..0d5f5945a 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -9,7 +9,11 @@ import { createViewerHost } from './viewerHost.js'; import { requiresKeyIdFor, validateProviders } from './registry.js'; import { normalizeFilter, resolveFilter, sameFilter } from './filter.js'; import { decodeParams, encodeParams } from './params.js'; -import { composeUIState, summarizeCoverage } from './uiState.js'; +import { + composeUIState, + keyAlternatives, + summarizeCoverage, +} from './uiState.js'; import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js'; import { createGroundClick, groundClickHeightOk } from './groundClick.js'; import { createGroundCaster, nextSurfaceMode } from './groundCast.js'; @@ -501,7 +505,8 @@ export function createStreetLevelLayer({ getStats() { // The lifecycle polls this every second: summarise, don't snapshot. - const coverage = summarizeCoverage(providerSnapshots()); + const snapshots = providerSnapshots(); + const coverage = summarizeCoverage(snapshots); let loadingLabel = ''; const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED'; if (coverage.keyRequired) loadingLabel = keyLabel; @@ -512,6 +517,8 @@ export function createStreetLevelLayer({ count: coverage.count, loading: coverage.loading, keyRequired: coverage.keyRequired, + /** Switched-off sources that would draw with a key already set. */ + keyAlternatives: keyAlternatives(snapshots), error: coverage.keyRequired ? coverage.keyRejected ? coverage.error || keyLabel diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 88d921e38..7b7d4dbf0 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -484,7 +484,10 @@ test('switching a provider on while the layer is off activates nothing (M01)', a }); /** The real Google provider over the Street View fakes, beside a Mapillary stand-in. */ -async function withStreetView(t, { mapStack = 'photoreal', nearest } = {}) { +async function withStreetView( + t, + { mapStack = 'photoreal', nearest, mapillaryKeyless = false } = {}, +) { const { createGoogleProvider } = await import('./providers/google/index.js'); const { fakeHost, fakeMapsLoader, fakeStreetViewLibrary } = await import('../../testSupport/googleStreetViewFakes.mjs'); @@ -502,6 +505,14 @@ async function withStreetView(t, { mapStack = 'photoreal', nearest } = {}) { }, }); mapillary.requiresKeyId = 'mapillary'; + if (mapillaryKeyless) { + mapillary.stats.keyRequired = true; + const create = mapillary.create; + mapillary.create = (context) => ({ + ...create(context), + status: async () => ({ configured: false }), + }); + } const loader = fakeMapsLoader(fake.library); const google = createGoogleProvider({ getApiKey: () => 'browser-key', @@ -637,3 +648,20 @@ test('the button’s “centre of the view” is the ground under the middle of assert.ok(Math.abs(lookups[0].lat - 38.5799) < 1e-9); assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9); }); + +test('with no Mapillary token, the layer points at Street View, on Google 3D only', async (t) => { + const { layer, stack } = await withStreetView(t, { mapillaryKeyless: true }); + await settle(); + const stats = layer.getStats(); + assert.equal(stats.keyRequired, true); + assert.deepEqual(stats.keyAlternatives, [ + { id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' }, + ]); + assert.deepEqual(layer.getUIState().keyAlternatives, stats.keyAlternatives); + stack.switchTo('esri-imagery'); + assert.deepEqual(layer.getStats().keyAlternatives, [], 'not off Google 3D'); + // Lit, Street View draws: the layer is no longer key-gated. + stack.switchTo('photoreal'); + layer.setProviderEnabled('google', true); + assert.equal(layer.getStats().keyRequired, false); +}); diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index 700ca41c3..6c7246597 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -38,6 +38,20 @@ export function summarizeCoverage(providers) { }; } +/** + * Switched-off providers that would draw with a key already set, offered + * while the switched-on ones lack theirs (Street View beside a Mapillary + * layer with no token). + * @param {Array} providers + * @returns {Array<{id: string, label: string, requiresKeyId: string|null}>} + */ +export function keyAlternatives(providers) { + if (!summarizeCoverage(providers).keyRequired) return []; + return providers + .filter((p) => !p.on && p.configured === true && p.keyRequired !== true) + .map(({ id, label, requiresKeyId }) => ({ id, label, requiresKeyId })); +} + /** * The panel snapshot from core state and provider snapshots. The legend has * one swatch per active source, then the shared selection colour. @@ -67,6 +81,7 @@ export function composeUIState({ /** The camera is at street zoom: a ground-click provider can open here. */ groundClickReady: groundClickReady === true, keyRequired, + keyAlternatives: keyAlternatives(providers), keyRejected, filter: { ...filter }, providers: providers.map((p) => ({ ...p })), diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index 6eb56c1c9..e1e0fd4eb 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -168,3 +168,27 @@ test('every switched-on provider’s hint is kept, once each', async () => { 'zoom in for sequences · click a street to open Street View', ); }); + +test('a key-gated layer offers the switched-off sources that would draw with a key already set', async () => { + const { keyAlternatives } = await import('./uiState.js'); + const mapillary = providerSnapshot({ keyRequired: true, configured: false }); + const streetView = providerSnapshot({ + id: 'google', + label: 'STREET VIEW', + on: false, + configured: true, + requiresKeyId: 'google-maps', + }); + assert.deepEqual(keyAlternatives([mapillary, streetView]), [ + { id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' }, + ]); + // Nothing to offer: Mapillary has its key, or Street View has none either. + assert.deepEqual(keyAlternatives([providerSnapshot(), streetView]), []); + assert.deepEqual( + keyAlternatives([ + mapillary, + { ...streetView, configured: false, keyRequired: true }, + ]), + [], + ); +}); diff --git a/src/ui/layerKeyRequirement.test.mjs b/src/ui/layerKeyRequirement.test.mjs index c8ce008b3..bc38e89f3 100644 --- a/src/ui/layerKeyRequirement.test.mjs +++ b/src/ui/layerKeyRequirement.test.mjs @@ -141,3 +141,19 @@ test('the manager publishes the declared key id on the row', async () => { ); assert.equal(layerKeyRequirementTooltip(ungated), ''); }); + +test('a key-gated layer with another source ready says how to use it', () => { + const text = layerKeyRequirementTooltip({ + requiresKeyId: 'mapillary', + stats: { + keyRequired: true, + keyAlternatives: [ + { id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' }, + ], + }, + }); + assert.equal( + text, + 'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key', + ); +}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index ca97e2635..66d753045 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -3,7 +3,10 @@ import { syncRowList } from './rowList.js'; import { layerFeedState } from '../data/feedState.js'; export { layerFeedState } from '../data/feedState.js'; import { GUIDANCE_STATUSES } from '../loadingFeedback.js'; -import { keySetupRequirement } from '../keySetupCore.mjs'; +import { + keySetupRequirement, + keySetupWithAlternatives, +} from '../keySetupCore.mjs'; import { createWeatherPanel } from './weatherPanel.js'; const FEED_STATE_LABELS = Object.freeze({ nominal: 'ON', @@ -100,7 +103,11 @@ function panelLabel(layer) { export function layerKeyRequirementTooltip(layer = {}) { if (layer?.stats?.keyRequired !== true) return ''; const requiresKeyId = String(layer.requiresKeyId || '').trim(); - return requiresKeyId ? keySetupRequirement(requiresKeyId) : ''; + // A layer with several sources also names the ones it could use instead. + return keySetupWithAlternatives( + requiresKeyId ? keySetupRequirement(requiresKeyId) : '', + layer.stats.keyAlternatives || [], + ); } /** Layer row presentation over supplied state and actions; no layer imports. */ diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index 20c8115e0..18c72b8b2 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -305,7 +305,6 @@ function panelDom() { const settings = add(main, 'div', { classes: ['sl-settings'] }); for (const id of ['sl-provider-chips', 'sl-error', 'sl-error-text']) add(settings, 'div', { id }); - add(settings, 'button', { id: 'sl-nearest-btn' }); // A missing key gates the filters only; the chips stay outside the gate. const controls = add(settings, 'fieldset', { id: 'sl-controls' }); for (const pano of ['all', 'pano', 'flat']) @@ -313,7 +312,9 @@ function panelDom() { add(controls, 'input', { id: 'sl-since' }); add(controls, 'output', { id: 'sl-since-label' }); add(settings, 'ul', { id: 'sl-legend' }); - add(settings, 'div', { id: 'sl-coverage-meta' }); + const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] }); + add(metaRow, 'div', { id: 'sl-coverage-meta' }); + add(metaRow, 'button', { id: 'sl-nearest-btn' }); // The globe's canvas is focusable too (tabindex=0). const globe = add( add(document.body, 'div', { id: 'cesiumContainer' }), diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 40f8d9adf..d5d089404 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -1,4 +1,7 @@ -import { keySetupRequirement } from '../keySetupCore.mjs'; +import { + keySetupRequirement, + keySetupWithAlternatives, +} from '../keySetupCore.mjs'; // Street Level UI state to panel strings and flags. Pure, so it is testable. @@ -54,13 +57,33 @@ function formatDate(ms, precision = 'day') { } } +/** + * What the key-gated layer needs: each switched-on provider's key, and the + * switched-off ones that would draw with a key already set. + */ +function keyGuidance(state) { + const needs = (state.providers || []) + .filter((p) => p.on && p.keyRequired && p.requiresKeyId) + .map((p) => keySetupRequirement(p.requiresKeyId)) + .filter(Boolean); + return keySetupWithAlternatives( + [...new Set(needs)].join('; '), + state.keyAlternatives || [], + ); +} + function presentStatus(state) { const pressed = state.enabled === true; const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on'; if (state.keyRejected) return { text: 'KEY REJECTED', tone: 'warn', pressed, title }; if (state.keyRequired) - return { text: 'KEY REQUIRED', tone: 'warn', pressed, title }; + return { + text: 'KEY REQUIRED', + tone: 'warn', + pressed, + title: keyGuidance(state) || title, + }; if (state.coverage.loading) return { text: 'LOADING', tone: 'busy', pressed, title }; return pressed @@ -162,6 +185,9 @@ function presentNearest(state) { function presentMeta(state) { 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) + return keyGuidance(state); if (state.sequence.selectedId) return state.sequence.loading ? 'Loading this sequence…' diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index d837f5333..1fb217196 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -483,3 +483,42 @@ test('OPEN STREET VIEW shows while Street View is on, and waits for street zoom' false, ); }); + +test('KEY REQUIRED points at Street View when it would draw with the Google key', () => { + const view = presentStreetLevelPanel( + snapshot({ + enabled: true, + keyRequired: true, + providers: [ + provider({ keyRequired: true, configured: false }), + provider({ + id: 'google', + name: 'Google Street View', + label: 'STREET VIEW', + on: false, + configured: true, + requiresKeyId: 'google-maps', + }), + ], + keyAlternatives: [ + { id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' }, + ], + }), + ); + const guidance = + 'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key'; + assert.equal(view.status.text, 'KEY REQUIRED'); + assert.equal(view.status.title, guidance); + assert.equal(view.meta, guidance); + // Off Google 3D Street View is not listed: plain KEY REQUIRED. + const plain = presentStreetLevelPanel( + snapshot({ + enabled: true, + keyRequired: true, + providers: [provider({ keyRequired: true, configured: false })], + keyAlternatives: [], + }), + ); + assert.equal(plain.status.text, 'KEY REQUIRED'); + assert.notEqual(plain.meta, guidance); +}); diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index d9d0590a9..98a88cad1 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -183,8 +183,20 @@ button.sl-status:focus-visible { border-color: rgba(255, 179, 0, 0.55); } +/* The coverage hint and OPEN STREET VIEW share a line; the hint wraps. */ +.sl-meta-row { + display: flex; + align-items: center; + gap: 8px; +} + +.sl-meta-row .sl-meta { + flex: 1 1 auto; + min-width: 0; +} + .sl-nearest-btn { - margin-left: auto; + flex: 0 0 auto; } .sl-nearest-btn[hidden] { diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 89da6d54b..f78a07589 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -113,7 +113,6 @@
PROVIDERS
-
+
+
+ +