refactor(voice): isolate gemini live provider core

This commit is contained in:
manjunath committed 2026-10-19 21:40:40 +05:30
commit 10916227cb
655 files changed
+45946 -26483

No files matched your search

+126 -17
View File
@@ -12,7 +12,9 @@
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 6px;
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.48), inset 0 0 28px rgba(14, 78, 92, 0.12);
box-shadow:
0 18px 52px rgba(0, 0, 0, 0.48),
inset 0 0 28px rgba(14, 78, 92, 0.12);
backdrop-filter: blur(24px) saturate(1.4);
font-family: var(--font-mono);
letter-spacing: 0;
@@ -153,33 +155,69 @@
}
@keyframes bhote-embedded-anchor-enter {
from { opacity: 0; transform: scale(0.2); }
to { opacity: 1; transform: scale(1); }
from {
opacity: 0;
transform: scale(0.2);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes bhote-embedded-leader-enter {
from { opacity: 0; stroke-dashoffset: 1; }
to { opacity: 1; stroke-dashoffset: 0; }
from {
opacity: 0;
stroke-dashoffset: 1;
}
to {
opacity: 1;
stroke-dashoffset: 0;
}
}
@keyframes bhote-embedded-body-enter {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes bhote-embedded-body-exit {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(6px) scale(0.985); }
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(6px) scale(0.985);
}
}
@keyframes bhote-embedded-leader-exit {
from { opacity: 1; stroke-dashoffset: 0; }
to { opacity: 0; stroke-dashoffset: 1; }
from {
opacity: 1;
stroke-dashoffset: 0;
}
to {
opacity: 0;
stroke-dashoffset: 1;
}
}
@keyframes bhote-embedded-anchor-exit {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.2); }
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.2);
}
}
@media (prefers-reduced-motion: reduce) {
@@ -267,6 +305,27 @@
text-decoration: none;
}
/* The title takes the full card width and up to two lines, so a translated
title keeps its place name; the link sits beside the status below it. */
html:not([lang='en']) .bhote-embedded-callout footer strong {
grid-column: 1 / -1;
display: -webkit-box;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
html:not([lang='en']) .bhote-embedded-callout footer small {
align-self: center;
}
html:not([lang='en']) .bhote-embedded-callout footer a {
grid-row: 2;
white-space: nowrap;
}
.bhote-embedded-callout footer a:hover,
.bhote-embedded-callout footer a:focus-visible {
text-decoration: underline;
@@ -354,6 +413,12 @@
color: rgba(222, 240, 245, 0.88);
}
/* A longer translated heading wraps; the readout beside it stays one piece. */
html:not([lang='en']) .bhote-event-section-head span:last-child {
flex: none;
white-space: nowrap;
}
.bhote-event-range {
width: 100%;
height: 4px;
@@ -372,6 +437,12 @@
color: rgba(174, 208, 216, 0.62);
}
/* Keep a translated date ("16 OCT 2021") together so the year never wraps
alone. */
html:not([lang='en']) .bhote-event-date {
white-space: nowrap;
}
.bhote-event-story-nav {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 34px;
@@ -388,7 +459,11 @@
border-radius: 4px;
color: var(--accent);
background: rgba(7, 25, 35, 0.9);
font: 22px/1 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font:
22px/1 'JetBrains Mono',
'SFMono-Regular',
Consolas,
monospace;
cursor: pointer;
}
@@ -428,6 +503,31 @@
white-space: nowrap;
}
html:not([lang='en']) .bhote-event-story-current {
padding: 5px 10px;
}
/* Up to two lines: a translated beat title keeps its subject. Two title lines
and one meta line still fit the 54px arrow buttons, so the controls stay put. */
html:not([lang='en']) .bhote-event-story-current strong {
display: -webkit-box;
text-overflow: clip;
white-space: normal;
overflow-wrap: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
/* Phase / time / sources run long in translation: allow a second line. */
html:not([lang='en']) .bhote-event-story-current small {
display: -webkit-box;
margin-top: 3px;
text-overflow: clip;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.bhote-event-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -458,6 +558,15 @@
white-space: nowrap;
}
/* A longer translation wraps to a second line instead of being cut. */
html:not([lang='en']) .bhote-event-actions button {
padding: 3px 5px;
line-height: 1.2;
text-overflow: clip;
white-space: normal;
overflow-wrap: break-word;
}
.bhote-event-actions button:disabled,
.bhote-event-story-nav > button:disabled {
opacity: 0.3;
@@ -469,7 +578,7 @@
border-color: rgba(88, 196, 218, 0.36);
}
.bhote-event-actions button[data-action="cinematic"][aria-pressed="true"] {
.bhote-event-actions button[data-action='cinematic'][aria-pressed='true'] {
color: #071017;
border-color: var(--accent);
background: var(--accent);
@@ -593,7 +702,6 @@
max-height: 48vh;
overflow-y: auto;
}
}
* {
@@ -602,7 +710,8 @@
box-sizing: border-box;
}
html, body {
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
+52 -4
View File
@@ -70,6 +70,18 @@
margin-bottom: 0;
}
/* Spanish labels overflow the nowrap .scene-btn row of the fixed-width
panel; wrap the row under es only, English stays as is. */
html[lang='es'] .cctv-controls {
flex-wrap: wrap;
}
/* A label longer than its button wraps inside it, not past the panel edge. */
html:not([lang='en']) .cctv-controls .scene-btn {
white-space: normal;
overflow-wrap: break-word;
}
.cctv-calibration-block {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 9px;
@@ -157,6 +169,9 @@
padding: 6px 8px;
font-family: var(--font-sans);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
outline: none;
}
@@ -260,12 +275,12 @@
animation: cctv-frame-shimmer 1.4s linear infinite;
}
/* "Acquiring" caption only when no previous frame is showing underneath */
.cctv-frame-wrap.loading:not(.has-frame)::before {
content: 'ACQUIRING FRAME';
/* "Acquiring" caption only when no previous frame is showing underneath.
The text lives in the markup (data-i18n) so it is translated. */
.cctv-frame-caption {
display: none;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
@@ -275,6 +290,10 @@
pointer-events: none;
}
.cctv-frame-wrap.loading:not(.has-frame) .cctv-frame-caption {
display: flex;
}
@keyframes cctv-frame-shimmer {
to {
transform: translateX(100%);
@@ -365,3 +384,32 @@
color: #a8f7ff;
text-shadow: 0 0 10px rgba(0, 212, 255, 0.18);
}
/* Tighter calibration chips keep the seven values to two rows, so
SAVE / RESET stay in view. */
html:not([lang='en']) .cctv-cal-readout {
gap: 4px;
}
html:not([lang='en']) .cctv-cal-value {
flex: 1 1 auto;
white-space: nowrap;
letter-spacing: 0.3px;
padding: 4px;
}
html:not([lang='en']) #cctv-camera-select {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.04em;
}
/* The camera name gets its own row; PREV / NEXT share the row below it. */
html:not([lang='en']) .cctv-controls:has(#cctv-camera-select) {
flex-wrap: wrap;
}
html:not([lang='en']) #cctv-camera-select {
flex: 1 1 100%;
order: -1;
}
+36
View File
@@ -575,6 +575,11 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
text-align: center;
vertical-align: -0.08em;
}
/* Grouping and decimal separators (a space or comma in Spanish and Russian)
get a narrow cell; English keeps the full 0.56em cell. */
html:not([lang='en']) .cockpit-roll-token.is-separator {
min-width: 0.3em;
}
.cockpit-roll-token.is-rolling {
color: color-mix(in srgb, currentColor 92%, var(--cockpit-accent));
text-shadow: 0 0 10px
@@ -1506,6 +1511,14 @@ body.cockpit-mode #view-switcher button {
display: grid;
gap: 2px;
}
html:not([lang='en']) .cockpit-signal-header > div {
min-width: 0;
}
html:not([lang='en']) .cockpit-signal-kicker {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cockpit-signal-header strong {
color: #fff;
font-size: 8px;
@@ -1532,6 +1545,11 @@ body.cockpit-mode #view-switcher button {
align-items: center;
gap: 3px;
}
/* A longer translated state label widens the cycle button; the other
controls keep their size instead of being squeezed. */
html:not([lang='en']) .cockpit-brief-actions > * {
flex-shrink: 0;
}
.cockpit-brief-actions button {
display: grid;
width: 23px;
@@ -1559,6 +1577,12 @@ body.cockpit-mode #view-switcher button {
font: 700 6px/1 var(--font-mono);
letter-spacing: 0.05em;
}
html:not([lang='en']) #cockpit-brief-auto {
width: auto;
min-width: 50px;
padding: 0 4px;
white-space: nowrap;
}
#cockpit-brief-auto[aria-pressed='true'] {
border-color: var(--cockpit-accent);
background: color-mix(
@@ -1871,6 +1895,18 @@ body.cockpit-mode #view-switcher button {
display: flex;
gap: 3px;
}
/* Translated tab names are longer: the tabs keep their words whole and the
footer text beside them gives way. */
html:not([lang='en']) .cockpit-signal-footer > span {
flex: 1 1 auto;
min-width: 0;
}
html:not([lang='en']) .cockpit-brief-tabs {
flex: 0 0 auto;
}
html:not([lang='en']) .cockpit-brief-tabs button {
white-space: nowrap;
}
.cockpit-brief-tabs button {
padding: 2px 4px;
border: 0;
+5
View File
@@ -647,6 +647,11 @@
white-space: nowrap;
}
/* The ru/es push-to-talk hint is longer than the tray: wrap it instead of clipping. */
html:not([lang='en']) #gev-voice-control .gev-voice-help-detail {
white-space: normal;
}
#gev-voice-control:not([data-status='error']):hover .gev-voice-help-tray,
#gev-voice-control:not([data-status='error'])
#gev-voice-button:focus-visible
+6
View File
@@ -392,6 +392,12 @@
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* The side wings sit at z-index 100 (controls.css, location.css), so their
open trays would cover the alert; lift the voice column while it shows. */
#command-dock > #gev-voice-control[data-status='error']:not(.error-dismissed) {
z-index: 101;
}
#command-dock
#gev-voice-control[data-status='error']:not(.error-dismissed)
.gev-voice-error-tray {
+51
View File
@@ -221,6 +221,18 @@
white-space: nowrap;
}
/* A longer translated title in a collapsed rail launcher tightens its
tracking rather than pushing the disclosure button out of the card. */
html:not([lang='en'])
#right-context-rail
.panel-collapsible.collapsed
.panel-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 1.5px;
}
.panel-divider {
flex: 1;
height: 1px;
@@ -244,6 +256,11 @@
color var(--transition-fast);
}
/* A long translated title must not squeeze the toggle into a sliver. */
html:not([lang='en']) .panel-collapse-btn {
flex-shrink: 0;
}
.panel-collapse-btn:hover {
border-color: var(--glass-border-hover);
color: var(--text-primary);
@@ -770,6 +787,14 @@
white-space: nowrap;
}
/* One label column across the Display rows: --pp-label-chars is the longest
label of the active language (set in applicationShell.js); the labels are
monospace. 0.125em is the 1px letter-spacing at 8px. */
html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
flex: 0 0 auto;
min-width: calc(var(--pp-label-chars, 0) * (1ch + 0.125em));
}
.detection-slider-row {
min-width: 170px;
padding: 5px 8px 6px;
@@ -966,6 +991,11 @@
#draw-mode-row {
min-width: 184px;
}
/* Equal thirds with room around the label, so a long translated shape name
never runs edge to edge in its segment. */
html:not([lang='en']) #draw-mode-row .pp-mode-btn {
padding: 0 4px;
}
#draw-label-row {
flex-wrap: wrap;
row-gap: 6px;
@@ -990,6 +1020,11 @@
.draw-color-select {
flex: 0 0 74px;
}
/* Sized to its longest colour name so a translated one is never clipped. */
html:not([lang='en']) .draw-color-select {
flex: 0 0 auto;
min-width: 74px;
}
.draw-clear-btn {
flex: 0 0 auto;
padding: 0 8px;
@@ -1319,6 +1354,22 @@ body.ui-clean-view #scene-runtime.active {
margin-bottom: 0;
}
/* One label column for every parameter, as wide as the longest translated
label, so all slider tracks start and end at the same x. */
html:not([lang='en']) #param-sliders {
display: grid;
grid-template-columns: max-content minmax(0, 1fr) max-content;
align-items: center;
gap: 10px;
}
html:not([lang='en']) .param-slider-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
margin-bottom: 0;
}
.param-label {
font-family: var(--font-mono);
font-size: 9px;
+8 -1
View File
@@ -174,6 +174,8 @@
.context-mode-button,
.map-stack-chip,
.key-setup-chip,
.locale-picker-button,
.locale-picker-popup,
.first-run-card
) {
border-color: var(--glass-border);
@@ -1834,9 +1836,11 @@
display: none;
}
/* Sits above the language picker row in the bottom-right corner
(locale-picker.css: 0.9rem inset + one ~2rem button + a gap). */
#intel-hud[data-variant='cyber'] .hud-bottom-right {
right: 28px;
bottom: 28px;
bottom: 3.5rem;
}
}
@@ -2733,6 +2737,9 @@
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-position-readout {
right: 30px;
/* Clears the language picker row it shares the corner with, as in the
other themes (locale-picker.css). */
bottom: 3.5rem;
}
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-help {
+1
View File
@@ -3,6 +3,7 @@
body.ui-embed #clean-view-exit,
body.ui-embed #intel-hud,
body.ui-embed #key-setup-chip,
body.ui-embed #corner-actions,
body.ui-embed #key-setup,
body.ui-embed #first-run-launcher,
body.ui-embed #command-dock,
+218
View File
@@ -0,0 +1,218 @@
// Layout rules written for long translations must not change the English
// rendering: every rule checked here has no `html:not([lang='en'])` guard,
// so it applies to English too.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const css = (name) => readFileSync(join(here, name), 'utf8');
/** Declarations of every rule whose (unguarded) selector list is `selector`. */
function unguardedRules(source, selector) {
const norm = (s) => s.replace(/\s+/g, ' ').trim();
const want = norm(selector);
const out = [];
const re = /([^{}]+)\{([^{}]*)\}/g;
let m;
while ((m = re.exec(source))) {
// The captured "selector" may carry the tail of a preceding block
// (comments, an @media prelude); keep what follows the last `}` or `*/`.
const raw = m[1].split(/\*\/|\}/).pop();
const sel = norm(raw);
if (sel === want && !sel.includes("html:not([lang='en'])")) out.push(m[2]);
}
return out;
}
function declaration(block, prop) {
const m = block.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`));
return m ? m[1].replace(/\s+/g, ' ').trim() : undefined;
}
test('layer status chip keeps its English width (82px)', () => {
const rules = unguardedRules(
css('radio.css'),
`.data-toggle-btn.feed-loading, .data-toggle-btn.feed-degraded,
.data-toggle-btn.feed-stale, .data-toggle-btn.feed-partial,
.data-toggle-btn.feed-fallback, .data-toggle-btn.feed-unavailable`,
);
assert.equal(rules.length, 1);
assert.equal(declaration(rules[0], 'min-width'), '82px');
});
test('Directions steps keep one line with an ellipsis in English', () => {
const [rule] = unguardedRules(css('radio.css'), '.data-row-list-text');
assert.ok(rule);
assert.equal(declaration(rule, 'white-space'), 'nowrap');
assert.equal(declaration(rule, 'text-overflow'), 'ellipsis');
});
test('count-line layout is not reachable from an unguarded selector', () => {
const source = css('radio.css');
assert.equal(
unguardedRules(source, '.data-toggle-row--count-line .data-count').length,
0,
);
});
test('weather status line keeps its fixed English height', () => {
const [rule] = unguardedRules(
css('weather.css'),
".weather-card [data-line-id='status']",
);
assert.ok(rule);
assert.equal(declaration(rule, 'height'), '1.45em');
});
test('cockpit number separators keep the English cell width', () => {
assert.equal(
unguardedRules(css('cockpit.css'), '.cockpit-roll-token.is-separator')
.length,
0,
);
});
test('Bhote Koshi callout keeps its English footer layout', () => {
const source = css('bhote-koshi.css');
const [strong] = unguardedRules(
source,
'.bhote-embedded-callout footer strong',
);
assert.equal(declaration(strong, 'white-space'), 'nowrap');
const [link] = unguardedRules(source, '.bhote-embedded-callout footer a');
assert.equal(declaration(link, 'grid-row'), '1 / span 2');
});
test('SCENES actions stay on one line in English', () => {
assert.equal(
unguardedRules(css('scenes.css'), '#scene-panel .scene-controls .scene-btn')
.length,
0,
);
const [select] = unguardedRules(css('scenes.css'), '#scene-select');
assert.equal(declaration(select, 'white-space'), undefined);
});
test('Display and parameter rows keep their English layout', () => {
const source = css('controls.css');
assert.equal(
unguardedRules(source, '.pp-slider-row > .pp-slider-mini-label').length,
0,
);
assert.equal(unguardedRules(source, '#param-sliders').length, 0);
const [row] = unguardedRules(source, '.param-slider-row');
assert.equal(declaration(row, 'display'), 'flex');
const [color] = unguardedRules(source, '.draw-color-select');
assert.equal(declaration(color, 'flex'), '0 0 74px');
});
test('weather setting rows keep the 56px English label column', () => {
const source = css('weather.css');
assert.equal(unguardedRules(source, '.rail-card-settings').length, 0);
const [row] = unguardedRules(source, '.rail-card-setting');
assert.equal(
declaration(row, 'grid-template-columns'),
'56px minmax(0, 1fr)',
);
});
test('cockpit brief controls keep their English sizes', () => {
const source = css('cockpit.css');
const [auto] = unguardedRules(source, '#cockpit-brief-auto');
assert.equal(declaration(auto, 'width'), '50px');
assert.equal(unguardedRules(source, '.cockpit-brief-actions > *').length, 0);
});
test('the count-line class is set only outside English', async () => {
const { setLocale } = await import('../../i18n/index.js');
const { LayerPanel } = await import('../layerPanel.js');
const classes = () => {
const toggled = new Map();
const node = () => ({
textContent: '',
classList: { toggle: (name, on) => toggled.set(name, on) },
appendChild() {},
});
LayerPanel.prototype._syncCount.call(LayerPanel.prototype, node(), node(), {
countLabel: '693 nearby · 93 on screen',
});
return toggled.get('data-toggle-row--count-line');
};
globalThis.document ??= { createElement: () => ({}) };
try {
setLocale('en');
assert.equal(classes(), false);
setLocale('es');
assert.equal(classes(), true);
} finally {
setLocale('en');
}
});
test('Recent Imagery readout keeps its English layout', () => {
const source = css('recent-imagery.css');
const one = (selector) => {
const rules = unguardedRules(source, selector);
assert.equal(rules.length, 1, selector);
return rules[0];
};
// The SHOW chip shares its row with the reserved B half.
assert.equal(
unguardedRules(
source,
".recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='a']",
).length,
0,
);
assert.equal(
unguardedRules(
source,
".recent-imagery-readout:not([data-mode='ab']) .ri-card-chips .ri-chip[data-slot='b']",
).length,
0,
);
// Slot rows: their own fixed 44px tag column, no subgrid.
const selection = one('.recent-imagery-readout .ri-selection');
assert.equal(declaration(selection, 'grid-template-columns'), undefined);
assert.equal(declaration(selection, 'column-gap'), undefined);
assert.equal(
declaration(one('.recent-imagery-readout .ri-mode'), 'grid-column'),
undefined,
);
const slot = one('.recent-imagery-readout .ri-slot');
assert.equal(
declaration(slot, 'grid-template-columns'),
'44px minmax(0, 1fr) 22px',
);
assert.equal(declaration(slot, 'grid-column'), undefined);
assert.equal(declaration(slot, 'height'), '20px');
assert.equal(
declaration(one('.recent-imagery-readout .ri-slot-tag'), 'padding'),
undefined,
);
// Opacity / SWAP / export row: fixed English columns.
assert.equal(
declaration(
one('.recent-imagery-readout .ri-controls'),
'grid-template-columns',
),
'76px minmax(0, 1fr) 40px 128px',
);
const label = one('.recent-imagery-readout .ri-opacity-label');
assert.equal(declaration(label, 'flex-wrap'), undefined);
assert.equal(declaration(label, 'gap'), '4px');
assert.equal(declaration(label, 'line-height'), undefined);
const swap = one('.recent-imagery-readout .ri-swap');
assert.equal(declaration(swap, 'width'), '40px');
assert.equal(declaration(swap, 'min-width'), undefined);
});
test('CCTV control buttons stay on one line in English', () => {
assert.equal(
unguardedRules(css('cctv.css'), '.cctv-controls .scene-btn').length,
0,
);
});
+3 -1
View File
@@ -1371,10 +1371,12 @@
padding: 2px 4px;
}
/* ALPR keeps its nearby count in the legend while a wider status chip is shown. */
/* ALPR keeps its nearby count in the legend while a wider status chip
(including ENABLING / DISABLING) is shown. */
.data-toggle-row[data-layer-id='alpr-cameras']
.data-toggle-right:has(
.data-toggle-btn:is(
.transitioning,
.feed-loading,
.feed-degraded,
.feed-stale,
+299
View File
@@ -0,0 +1,299 @@
/* ============================================================
Bottom-right corner row: the POWER UP chip (provider-settings.css)
and, in the very corner, the interface language picker
(src/ui/localePicker.js). Logical properties throughout, so a
right-to-left page mirrors the row and the list.
============================================================ */
#corner-actions {
position: fixed;
inset-inline-end: 0.9rem;
inset-block-end: 0.9rem;
/* Above the dock (145), the cockpit HUD and panel stacks (146–147);
below the first-run launcher (175) and Provider Settings (190). */
z-index: 150;
display: flex;
align-items: stretch;
gap: 0.4rem;
pointer-events: none;
}
#corner-actions > * {
pointer-events: auto;
}
.locale-picker {
position: relative;
display: flex;
}
/* Same HUD frame as #key-setup-chip, so the two read as one group. */
.locale-picker-button {
display: flex;
align-items: center;
gap: 0.35rem;
margin: 0;
padding: 0.45rem 0.6rem;
color: rgba(225, 244, 248, 0.92);
font-family: var(--font-mono);
font-size: 0.6rem;
letter-spacing: 0.09rem;
line-height: 1;
background: linear-gradient(
145deg,
rgba(10, 27, 37, 0.92),
rgba(7, 13, 22, 0.9)
);
border: 1px solid rgba(54, 220, 255, 0.38);
border-radius: 0.42rem;
box-shadow:
0 0.4rem 1.6rem rgba(0, 0, 0, 0.5),
0 0 1.2rem rgba(0, 212, 255, 0.07);
backdrop-filter: blur(18px) saturate(1.2);
-webkit-backdrop-filter: blur(18px) saturate(1.2);
cursor: pointer;
transition:
border-color var(--transition-fast),
background var(--transition-fast),
transform var(--transition-fast);
}
.locale-picker-button:hover,
.locale-picker-button:focus-visible,
.locale-picker.is-open .locale-picker-button {
background: linear-gradient(
145deg,
rgba(14, 38, 51, 0.95),
rgba(9, 18, 28, 0.93)
);
border-color: rgba(54, 220, 255, 0.7);
outline: none;
}
.locale-picker-button:hover,
.locale-picker-button:focus-visible {
transform: translateY(-1px);
}
.locale-picker-globe {
flex: none;
width: 0.95rem;
height: 0.95rem;
color: #55dff5;
}
.locale-picker-caret {
flex: none;
width: 0.62rem;
height: 0.62rem;
margin-inline-start: -0.1rem;
opacity: 0.75;
transition: transform var(--transition-fast);
}
.locale-picker.is-open .locale-picker-caret {
transform: rotate(180deg);
}
/* Opens upward, aligned to the corner edge. */
.locale-picker-popup {
position: absolute;
inset-block-end: calc(100% + 0.4rem);
inset-inline-end: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
width: max-content;
min-width: 14rem;
max-width: calc(100vw - 1.8rem);
max-height: min(26rem, calc(100vh - 4.5rem));
max-height: min(26rem, calc(100dvh - 4.5rem));
padding: 0.45rem;
color: var(--text-primary);
background:
linear-gradient(145deg, rgba(10, 27, 37, 0.97), rgba(7, 13, 22, 0.95)),
rgba(7, 17, 25, 0.96);
border: 1px solid rgba(54, 220, 255, 0.42);
border-radius: 0.55rem;
box-shadow:
0 1rem 3rem rgba(0, 0, 0, 0.65),
0 0 2rem rgba(0, 212, 255, 0.08),
inset 0 0 0 1px rgba(255, 255, 255, 0.025);
backdrop-filter: blur(24px) saturate(1.25);
-webkit-backdrop-filter: blur(24px) saturate(1.25);
}
.locale-picker-popup[hidden] {
display: none;
}
.locale-picker-heading {
padding: 0.2rem 0.4rem 0.45rem;
color: rgba(85, 223, 245, 0.85);
font-family: var(--font-mono);
font-size: 0.55rem;
letter-spacing: 0.14rem;
text-transform: uppercase;
}
.locale-picker-search {
box-sizing: border-box;
width: 100%;
margin: 0 0 0.4rem;
padding: 0.45rem 0.55rem;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.7rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(54, 220, 255, 0.28);
border-radius: 0.35rem;
outline: none;
}
.locale-picker-search:focus {
border-color: rgba(54, 220, 255, 0.7);
}
.locale-picker-list {
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
outline: none;
overscroll-behavior: contain;
}
.locale-picker-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.45rem 0.5rem;
border: 1px solid transparent;
border-radius: 0.35rem;
cursor: pointer;
}
.locale-picker-option.is-active {
background: rgba(54, 220, 255, 0.09);
border-color: rgba(54, 220, 255, 0.28);
}
/* Keyboard focus lives on the list (aria-activedescendant): show it. */
.locale-picker-list:focus-visible .locale-picker-option.is-active,
.locale-picker-search:focus
~ .locale-picker-list
.locale-picker-option.is-active {
border-color: rgba(54, 220, 255, 0.75);
}
.locale-picker-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.locale-picker-native {
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 0.85rem;
line-height: 1.2;
}
.locale-picker-option[aria-selected='true'] .locale-picker-native {
color: #55dff5;
}
.locale-picker-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
color: rgba(199, 225, 231, 0.6);
font-family: var(--font-mono);
font-size: 0.55rem;
letter-spacing: 0.05rem;
}
.locale-picker-check {
flex: none;
width: 0.95rem;
height: 0.95rem;
margin-inline-start: auto;
color: #55dff5;
}
.locale-picker-empty {
margin: 0;
padding: 0.5rem;
color: rgba(199, 225, 231, 0.62);
font-family: var(--font-mono);
font-size: 0.6rem;
}
.locale-picker-empty[hidden] {
display: none;
}
/* The surfaces that hide the rest of the chrome hide the corner too. The
cockpit keeps the language picker (provider-settings.css hides the chip). */
body.ui-clean-view #corner-actions,
body.recording-mode #corner-actions,
body.scene-playback-mode #corner-actions {
display: none;
}
/* The cockpit's position readout shares this corner: it steps up to clear
the picker row (0.9rem inset + one ~2rem button + a gap). */
body.cockpit-mode .cockpit-position-readout {
bottom: max(clamp(22px, 4vh, 42px), 3.5rem);
}
/* Below this width the full chip label runs into the centred dock: the chip
keeps its bolt, and its count stays in its title and aria-label. */
@media (max-width: 1180px) {
#corner-actions #key-setup-chip [data-key-setup-chip-label] {
display: none;
}
}
@media (max-width: 767.98px) {
/* Phone: the corner beside the dock, clear of the attribution above it.
There is room for one button across, so the chip stacks on the globe. */
#corner-actions {
inset-inline-end: 10px;
inset-block-end: max(16px, env(safe-area-inset-bottom));
flex-direction: column;
}
#corner-actions #key-setup-chip {
justify-content: center;
padding-inline: 0.5rem;
}
/* Phone: the globe alone; the label stays in aria-label. */
.locale-picker-code,
.locale-picker-caret {
display: none;
}
.locale-picker-button {
padding-inline: 0.5rem;
}
/* The list becomes a bottom sheet across the screen. */
.locale-picker-popup {
position: fixed;
inset-inline: 16px;
inset-block-end: 16px;
width: auto;
min-width: 0;
max-width: none;
max-height: calc(100vh - 32px);
max-height: calc(100dvh - 32px);
padding: 0.6rem;
}
.locale-picker-option {
padding-block: 0.7rem;
}
}
+2 -11
View File
@@ -4,11 +4,9 @@
mono kickers. The chip retires once every key is configured.
============================================================ */
/* The chip sits in the bottom-right corner row (#corner-actions,
locale-picker.css), which owns the position. */
#key-setup-chip {
position: fixed;
right: 0.9rem;
bottom: 0.9rem;
z-index: 60;
display: flex;
align-items: center;
gap: 0.4rem;
@@ -389,13 +387,6 @@ body.scene-playback-mode #key-setup {
display: none;
}
@media (max-width: 620px) {
/* Clear the dock. Desktop-first app; this is a courtesy, not a layout. */
#key-setup-chip {
bottom: 5.8rem;
}
}
/* Provider Settings additions: externally-managed badge + per-row remove. */
.key-setup-external {
padding: 0.05rem 0.3rem;
+123
View File
@@ -260,6 +260,13 @@
letter-spacing: 0.06em;
cursor: pointer;
}
/* A longer translated action stays on one line, cut with an ellipsis. */
html:not([lang='en']) .radio-transport button {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.radio-transport button:hover:not(:disabled),
.radio-transport button:focus-visible:not(:disabled) {
border-color: #65f7ff;
@@ -644,6 +651,89 @@
text-align: right;
}
/* Longer translations below; English keeps the layout above. */
/* The name takes the room the status column leaves; a long word wraps inside
it instead of pushing the toggle out of line. */
html:not([lang='en']) .data-toggle-left {
flex: 1 1 auto;
min-width: 0;
}
/* A fixed icon box, so every name (and the meta line under it, 26px in)
starts at the same x whatever the glyph's width. */
html:not([lang='en']) .data-toggle-left .data-icon {
flex: none;
width: 18px;
text-align: center;
}
/* Translated layer names are longer: break between words, and hyphenate a
word that still does not fit instead of splitting it anywhere. */
html:not([lang='en']) .data-name {
min-width: 0;
overflow-wrap: break-word;
hyphens: auto;
/* No two-letter pieces such as "es-paciales": a word hyphenates only when
at least four letters stay on each side. */
hyphenate-limit-chars: 8 4 4;
}
html:not([lang='en']) .data-count {
min-width: auto;
text-wrap: balance;
}
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
the space before a " · " separator (see setRowText in layerPanel.js). */
html:not([lang='en']) .data-keep {
white-space: nowrap;
}
/* A count phrase ("24 nearby · 2 on screen") takes its own line under the
name instead of squeezing it beside the toggle. layerPanel.js sets the
class only outside English. */
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-top {
flex-wrap: wrap;
row-gap: 2px;
}
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-right {
display: contents;
}
/* A zero basis, so a long name wraps beside the toggle instead of pushing the
toggle onto the count's line. */
html:not([lang='en']) .data-toggle-row--count-line .data-toggle-left {
flex-basis: 0;
}
html:not([lang='en']) .data-toggle-row--count-line .data-count {
order: 1;
flex: 1 0 100%;
padding-left: 26px;
font-size: 10px;
text-align: left;
}
/* The "—" placeholder gives way to a wide status chip, so the name keeps the
room it needs. */
html:not([lang='en'])
.data-toggle-right:has(
.data-toggle-btn:is(
.transitioning,
.feed-loading,
.feed-degraded,
.feed-stale,
.feed-partial,
.feed-fallback,
.feed-unavailable
)
)
.data-count-empty {
display: none;
}
.data-toggle-btn {
padding: 3px 10px;
font-family: var(--font-mono);
@@ -666,6 +756,11 @@
text-align: center;
}
html:not([lang='en']) .data-toggle-btn {
flex: none;
white-space: nowrap;
}
/* An inset ring stays visible inside the scrolling layer list. */
.data-toggle-btn:focus-visible {
outline: 2px solid var(--text-primary);
@@ -682,6 +777,20 @@
letter-spacing: 0.8px;
}
/* Status chips up to eight characters share one width; a longer word grows
its own chip rather than widening every chip and squeezing every name. */
html:not([lang='en'])
.data-toggle-btn:is(
.feed-loading,
.feed-degraded,
.feed-stale,
.feed-partial,
.feed-fallback,
.feed-unavailable
) {
min-width: 72px;
}
.data-toggle-btn:hover {
border-color: var(--glass-border-hover);
background: rgba(255, 255, 255, 0.06);
@@ -893,6 +1002,20 @@
white-space: nowrap;
}
/* A translated instruction wraps under itself rather than losing its tail:
"Gira a la derecha hacia …" says nothing. */
html:not([lang='en']) .data-row-list-lead {
white-space: nowrap;
}
html:not([lang='en']) .data-row-list-text {
min-width: 0;
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
/* A list line that states a limit rather than offering an action. */
.data-row-list-item.note {
cursor: default;
+54
View File
@@ -293,6 +293,21 @@
height: 18px;
font-weight: 700;
}
/* Outside A / B there is one chip (SHOW): in a translation it takes the
whole row, so a long label is not cut to a stub. English keeps the
reserved B half. */
html:not([lang='en'])
.recent-imagery-readout:not([data-mode='ab'])
.ri-card-chips
.ri-chip[data-slot='a'] {
grid-column: 1 / -1;
}
html:not([lang='en'])
.recent-imagery-readout:not([data-mode='ab'])
.ri-card-chips
.ri-chip[data-slot='b'] {
display: none;
}
/* 4. The selection bar: mode, then two rows that are always there. */
.recent-imagery-readout .ri-selection {
@@ -347,6 +362,28 @@
color: var(--text-secondary);
font-style: italic;
}
/* The slot rows share the bar's columns (subgrid), so the tag column fits
the longest tag and both rows align; a long slot line (date · source ·
resolution · state) takes a second line instead of losing the state. */
html:not([lang='en']) .recent-imagery-readout .ri-selection {
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr) 22px;
column-gap: 6px;
}
html:not([lang='en']) .recent-imagery-readout .ri-mode {
grid-column: 1 / -1;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot {
grid-column: 1 / -1;
grid-template-columns: subgrid;
height: auto;
min-height: 20px;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot-tag {
padding: 0 3px;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
white-space: normal;
}
.recent-imagery-readout .ri-unpin {
width: 22px;
padding: 0;
@@ -371,6 +408,19 @@
letter-spacing: 1px;
white-space: nowrap;
}
/* In a translation a label longer than its 76px column widens it to the
label's longest word, and the value wraps under the label. */
html:not([lang='en']) .recent-imagery-readout .ri-controls {
grid-template-columns:
minmax(76px, min-content) minmax(0, 1fr) minmax(40px, max-content)
128px;
}
html:not([lang='en']) .recent-imagery-readout .ri-opacity-label {
flex-wrap: wrap;
align-content: center;
row-gap: 0;
line-height: 10px;
}
.recent-imagery-readout .ri-opacity-value {
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
@@ -388,6 +438,10 @@
width: 40px;
padding: 0 2px;
}
html:not([lang='en']) .recent-imagery-readout .ri-swap {
width: auto;
min-width: 40px;
}
.recent-imagery-readout .ri-exports .rail-card-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+20
View File
@@ -163,6 +163,26 @@
outline: none;
}
/* A translated scene name needs the width: the select takes its own row,
NEW / DEL share the next one. */
html:not([lang='en']) #scene-select {
flex: 1 1 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.04em;
}
/* A translated action longer than its button wraps inside the button instead
of running past it. English keeps its one-line buttons ("EXPORT PRESETS"
included). */
html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
white-space: normal;
overflow-wrap: break-word;
}
.scene-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.14);
+8 -46
View File
@@ -123,6 +123,11 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* Translated retry details ("cámaras ALPR · reintento en 53 s") run longer
than the English one; English keeps its 220px. */
html:not([lang='en']) #global-loading-detail {
max-width: min(300px, 46vw);
}
#global-loading-status:is(
[data-state='complete'],
[data-state='cancelled'],
@@ -384,17 +389,10 @@
/* ── Toast Notification ──────────────────────── */
#toast {
--toast-safe-top: 74px;
position: fixed;
bottom: 100px;
left: 0;
right: 0;
margin-inline: auto;
width: max-content;
max-width: calc(100vw - 32px);
box-sizing: border-box;
overflow-wrap: anywhere;
transform: translateY(20px);
left: 50%;
transform: translateX(-50%) translateY(20px);
z-index: 200;
padding: 10px 24px;
background: var(--glass-bg);
@@ -420,41 +418,5 @@
#toast.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
/* Keep transient feedback clear of the raised voice chooser and bottom dock. */
body:has(#gev-voice-control:hover) #toast,
body:has(#gev-voice-control:focus-within) #toast,
body:has(#command-dock.dock-has-pinned-tray) #toast {
top: calc(env(safe-area-inset-top, 0px) + var(--toast-safe-top));
bottom: auto;
transform: translateY(-20px);
}
body:has(#gev-voice-control:hover) #toast.visible,
body:has(#gev-voice-control:focus-within) #toast.visible,
body:has(#command-dock.dock-has-pinned-tray) #toast.visible {
transform: translateY(0);
}
@media (max-width: 900px), (max-height: 640px) {
#toast {
top: calc(env(safe-area-inset-top, 0px) + var(--toast-safe-top));
bottom: auto;
letter-spacing: 1px;
transform: translateY(-20px);
}
#toast.visible {
transform: translateY(0);
}
}
/* On a narrow portrait viewport the collapsed layer and scene headers occupy
the first two rows. Keep feedback below them, clear of the voice controls. */
@media (max-width: 600px) and (orientation: portrait) {
#toast {
--toast-safe-top: 214px;
}
transform: translateX(-50%) translateY(0);
}
+13
View File
@@ -216,6 +216,19 @@ button.sl-status:focus-visible {
display: none;
}
/* When the hint and the button do not both fit, the button takes its own line. */
html:not([lang='en']) .sl-meta-row {
flex-wrap: wrap;
}
html:not([lang='en']) .sl-meta-row .sl-meta {
flex: 1 1 14em;
}
html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
flex: 1 0 auto;
}
/* ── Errors ──────────────────────────────────────────────────────────────── */
.sl-error {
font-family: var(--font-sans);
+3
View File
@@ -231,6 +231,7 @@
font-family: var(--font-sans);
font-size: 0.78rem;
font-weight: 600;
overflow-wrap: break-word;
}
.gev-voice-card-chips {
@@ -271,6 +272,8 @@
font-size: 0.66rem;
line-height: 1.4;
color: rgba(223, 245, 250, 0.82);
/* A whole error line or a long headline wraps inside the card. */
overflow-wrap: break-word;
}
.gev-voice-card-referents {
+74 -9
View File
@@ -83,7 +83,65 @@
display: none;
}
#command-dock #gev-voice-control[data-provider] {
/* The heading row is only as wide as the voice column: the cost cluster
keeps its size and a long state (ПОДКЛЮЧЕНИЕ, CONECTANDO) ends in an
ellipsis. Dropping the kicker returns its gap to the state label; the
negative margin reaches over the gutter beside the mic ring, which has
curved away at heading height. */
html:not([lang='en']) #command-dock .gev-voice-heading {
gap: 0.2rem;
margin-left: -0.6rem;
}
html:not([lang='en']) #command-dock .gev-voice-heading > .gev-voice-kicker {
display: none;
}
html:not([lang='en']) #command-dock .gev-voice-heading > #gev-voice-status {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
html:not([lang='en']) #command-dock .gev-voice-cost {
gap: 0.16rem;
padding-left: 0.1rem;
}
html:not([lang='en']) #command-dock .gev-voice-tier-btn {
min-width: 0;
padding: 0 0.2rem;
}
html:not([lang='en']) #command-dock .gev-voice-cost-value {
letter-spacing: 0;
}
/* Live hints and the tier switch ("СТАНДАРТ — со следующей сессии") run
longer: a second line from the spare space under the readout, with no
tracking so the tier line does not need a third. */
html:not([lang='en'])
#command-dock
#gev-voice-control:is(
[data-status='connecting'],
[data-status='listening'],
[data-status='executing']
)
#gev-voice-detail {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 1.15;
letter-spacing: 0;
white-space: normal;
overflow-wrap: anywhere;
}
#gev-voice-control[data-provider-choice='true'] {
--gev-provider-lift: 0px;
}
@@ -125,12 +183,17 @@
opacity: 1;
pointer-events: auto;
}
#command-dock #gev-voice-control[data-provider] .gev-voice-help-tray,
#command-dock #gev-voice-control[data-provider] .gev-voice-card,
#command-dock #gev-voice-control[data-provider] .gev-voice-error-tray {
#command-dock
#gev-voice-control[data-provider-choice='true']
.gev-voice-help-tray,
#command-dock #gev-voice-control[data-provider-choice='true'] .gev-voice-card,
#command-dock
#gev-voice-control[data-provider-choice='true']
.gev-voice-error-tray {
bottom: calc(100% + 4.8rem + var(--gev-provider-lift));
}
#command-dock.dock-has-pinned-tray #gev-voice-control[data-provider] {
#command-dock.dock-has-pinned-tray
#gev-voice-control[data-provider-choice='true'] {
--gev-provider-lift: calc(1.7rem + var(--dock-pinned-stack-height));
}
/* Pinned trays separate the chooser from its hover owner. Keep it reachable
@@ -146,18 +209,20 @@
opacity: 1;
pointer-events: auto;
}
#command-dock #gev-voice-control[data-provider] {
#command-dock #gev-voice-control[data-provider-choice='true'] {
--gev-provider-lift: 3rem;
}
#command-dock.dock-has-pinned-tray #gev-voice-control[data-provider] {
#command-dock.dock-has-pinned-tray
#gev-voice-control[data-provider-choice='true'] {
--gev-provider-lift: calc(3.5rem + var(--dock-pinned-stack-height));
}
}
@media (max-width: 720px) {
#command-dock #gev-voice-control[data-provider] {
#command-dock #gev-voice-control[data-provider-choice='true'] {
--gev-provider-lift: calc(2vh + 2.5rem);
}
#command-dock.dock-has-pinned-tray #gev-voice-control[data-provider] {
#command-dock.dock-has-pinned-tray
#gev-voice-control[data-provider-choice='true'] {
--gev-provider-lift: calc(2vh + 3rem + var(--dock-pinned-stack-height));
}
}
+22
View File
@@ -152,6 +152,13 @@
overflow-y: auto;
color: var(--accent);
}
/* One line stays reserved so the card does not jump when a status appears;
a translated status runs longer and wraps instead of being cut. */
html:not([lang='en']) .weather-card [data-line-id='status'] {
height: auto;
min-height: 1.45em;
overflow-y: visible;
}
.rail-card-ramp {
position: relative;
height: 4px;
@@ -261,6 +268,9 @@ body.recording-mode .gev-wind-inspection-marker {
font: 9px/1.45 var(--font-mono);
color: var(--text-dim);
}
html:not([lang='en']) .rail-card-zero::after {
white-space: nowrap;
}
.weather-observed-group {
border: 1px solid var(--weather-card-border);
@@ -318,6 +328,18 @@ body.recording-mode .gev-wind-inspection-marker {
gap: 6px;
margin-top: 6px;
}
/* One label column per card, as wide as its longest label: chips line up
across rows, and a long translated label never runs under its chips. */
html:not([lang='en']) .rail-card-settings {
display: grid;
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
column-gap: 6px;
}
html:not([lang='en']) .rail-card-setting {
grid-column: 1 / -1;
grid-template-columns: minmax(56px, max-content) minmax(0, 1fr);
grid-template-columns: subgrid;
}
.rail-card-setting-label {
color: var(--text-dim);
font: 9px var(--font-mono);