feat(kit): render the pressed toggles with UiButton

UiButton gains pressedClass, Core's class on a pressed button. The pressed
classes toggle through classList, so classes other code adds survive a
press, a caller's class change keeps them, a renamed pressedClass leaves
the button, and server-rendered markup carries them.

The Display, SDR, Radio and Cockpit enable, Cockpit weather and briefing,
mission replay, Street Level, TR-3B, overlay, voice and CCTV toggles now
render through it with the same ids, classes and aria-pressed values.
This commit is contained in:
Sameh Khamis committed 2026-11-01 11:59:30 -08:00
1 parent 5019bd9e8a
commit d98a7c81be
23 files changed
+212 -62

No files matched your search

+4 -3
View File
@@ -64,9 +64,10 @@
`connect`/`act`; the card labels its gain options itself. `act` returns
what the answering action returned. The Display, Radio, RTL-SDR, Cockpit,
mission replay and style parameter sliders are the kit's `UiSlider`, a
native range input that emits numbers. `syncScopeControls` (`./ui/display`)
and `shouldHideCollapsedRightPanels` (`./ui/layout`) are deprecated: the
Display view and the rail now own what they did. `./ui/input` imports Vue
native range input that emits numbers, and the pressed toggles are the
kit's `UiButton`, which gains `pressedClass`. `syncScopeControls`
(`./ui/display`) and `shouldHideCollapsedRightPanels` (`./ui/layout`) are
deprecated: the Display view and the rail now own what they did. `./ui/input` imports Vue
(samehkhamis).
- `./ui/cctv` and `./ui/radio` import Vue and Pinia: their controllers
+8 -2
View File
@@ -37,8 +37,9 @@ one-panel rule too. Share links continue to record which panels are open.
## Component kit
`src/ui/kit/` holds the generic components: `UiRail`, `UiTooltip`, `UiIcon`,
`UiButton`, `UiPanel` and `UiSegmented` so far, with more (`UiSlider`,
`UiSelect` and so on) joining as the controls they serve are converted. One component
`UiButton`, `UiPanel`, `UiSegmented` and `UiSlider` so far, with more
(`UiSelect`, `UiChip` and so on) joining as the controls they serve are
converted. One component
serves every use of a control, with props for the differences (`UiPanel`'s
`sideways`, `glow`, `scroll`), rather than a copy per panel. Names, props,
events and class names follow Halfpixel's `ui-vue` kit so the two can merge
@@ -56,6 +57,11 @@ Home and End), since each of Core's groups keeps its own keyboard policy, and
once and asks for a change only when it lands on another option. Every
segmented group in Core is a `UiSegmented`; the old `segmentedControl.js`
adapter is gone.
`UiButton` adds `pressedClass`, Core's class on a pressed toggle (most use
`active`); its pressed classes toggle through `classList`, so a class other
code adds (a voice highlight) survives a press. Every pressed toggle in the
views is a `UiButton` except Bhote Koshi's cinematic button and the
tilt-map button, whose owners still write their state into the DOM.
`UiSlider` keeps the native range input where ui-vue draws a
`role="slider"` track, so Core's keyboard, form behaviour and styling stay;
it takes ui-vue's `v-model`, `min`, `max`, `step` and `disabled`, emits a
+2
View File
@@ -1130,6 +1130,8 @@
"src/ui/kit/UiSegmented.test.mjs",
"src/ui/kit/UiSlider.js",
"src/ui/kit/UiSlider.test.mjs",
"src/ui/kit/UiButton.vue",
"src/ui/kit/UiButton.test.mjs",
"src/ui/displayPanelActions.test.mjs",
"src/testSupport/vueDom.mjs",
"build/vueLoader.js",
+2 -2
View File
@@ -1176,7 +1176,7 @@ test('cockpit aircraft handoff invalidates the prior world-position anchor', ()
test('cockpit weather control is off before JavaScript restores an explicit opt-in', () => {
const match = html.match(
/<button\s+id="cockpit-weather-toggle"[\s\S]*?<\/button>/,
/<button[^>]*\sid="cockpit-weather-toggle"[\s\S]*?<\/button>/,
);
assert.ok(match, 'cockpit weather toggle markup is missing');
assert.match(match[0], /aria-label="Enable cockpit weather effects"/);
@@ -1262,7 +1262,7 @@ test('Global Context standby describes both chooser modes', () => {
test('cockpit briefing cycle control keeps its state as the accessible name', () => {
const match = html.match(
/<button\s+id="cockpit-brief-auto"[\s\S]*?<\/button>/,
/<button[^>]*\sid="cockpit-brief-auto"[\s\S]*?<\/button>/,
);
assert.ok(match, 'cockpit briefing cycle toggle is missing');
assert.match(match[0], /aria-label="CYCLE OFF"/);
+2 -2
View File
@@ -78,12 +78,12 @@ test('Context async action buttons remain focused while busy', () => {
assert.match(radio, /trigger\.getAttribute\('aria-busy'\) === 'true'/);
assert.doesNotMatch(radio, /trigger\.disabled\s*=\s*true/);
const cockpitRadio = readFileSync(new URL('./ui/views/panels/CockpitUtilityControls.vue', import.meta.url), 'utf8');
const cockpitEnable = cockpitRadio.match(/<button id="cockpit-radio-enable-btn"[^>]*>/)?.[0];
const cockpitEnable = cockpitRadio.match(/<UiButton id="cockpit-radio-enable-btn"[^>]*>/)?.[0];
assert.ok(cockpitEnable, 'Cockpit Radio Enable button is missing');
assert.doesNotMatch(cockpitEnable, /disabled(?!=")|:disabled/);
assert.match(cockpitEnable, /:aria-busy=/);
// The Radio view never disables its Enable button; it marks it busy.
const enable = radioPanel.match(/<button id="radio-enable-btn"[^>]*>/)?.[0];
const enable = radioPanel.match(/<UiButton id="radio-enable-btn"[^>]*>/)?.[0];
assert.ok(enable, 'Radio Enable button is missing');
assert.doesNotMatch(enable, /disabled(?!=")|:disabled/);
assert.match(enable, /:aria-busy=/);
+2 -2
View File
@@ -739,7 +739,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
);
assert.match(
html,
/class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
/<button(?=[^>]*\sid="models3d-toggle")(?=[^>]*\sclass="(?=[^"]*\bpp-toggle-btn\b)(?=[^"]*\bactive\b))(?=[^>]*\saria-pressed="true")/,
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so',
);
assert.match(
@@ -753,7 +753,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
);
assert.match(
panel,
/id="models3d-toggle" @click="act\('toggleModels'\)" :aria-pressed="String\(models3dOn\)"/,
/:pressed="models3dOn" pressed-class="active" id="models3d-toggle"/,
'DisplayPanel.vue: the view renders aria-pressed with the lit state',
);
assert.match(
+2 -2
View File
@@ -293,9 +293,9 @@ test('Radio is nested inside Context with its own disclosure and power controls'
// The Context header carries no Radio shortcut; Radio lives in its own
// section below the modes, with one power control.
assert.doesNotMatch(html, /context-radio-/);
assert.match(html, /id="radio-enable-btn"[^>]*aria-pressed="false"/);
assert.match(html, /<button(?=[^>]*\sid="radio-enable-btn")(?=[^>]*\saria-pressed="false")/);
assert.match(html, /id="cockpit-radio-panel"[^>]*aria-label="Cockpit compact Radio controls"[^>]*hidden/);
assert.match(html, /id="cockpit-radio-enable-btn"[^>]*aria-pressed="false"/);
assert.match(html, /<button(?=[^>]*\sid="cockpit-radio-enable-btn")(?=[^>]*\saria-pressed="false")/);
assert.match(html, /id="cockpit-radio-(?:prev|play|next)-btn"/);
assert.match(html, /id="cockpit-radio-volume"/);
assert.match(html, /id="radio-tuner"[^>]*hidden/);
+9 -6
View File
@@ -10,6 +10,9 @@ import DisplayPanel from './views/panels/DisplayPanel.vue';
import { AircraftDisplay } from './aircraftDisplay.js';
import { VisualSettings } from './visualSettings.js';
import { initGeoFileImport } from './geoFileImport.js';
// Core's classes on an element, without the kit's `ui-` classes.
const coreClasses = (el) =>
[...el.classList].filter((name) => !name.startsWith('ui-')).join(' ');
function fixture(t) {
document.body.replaceChildren();
@@ -167,7 +170,7 @@ test('detection renders its mode, rows, sliders and allocation', async (t) => {
assert.equal(button.querySelector('.pp-label').textContent, 'DENSE');
assert.equal(button.getAttribute('aria-pressed'), 'true');
assert.equal(button.getAttribute('aria-label'), 'Detection overlay: dense');
assert.equal(button.className, 'pp-toggle-btn active panoptic');
assert.equal(coreClasses(button), 'pp-toggle-btn active panoptic');
for (const id of [
'detection-slider-row',
'detection-allocation-row',
@@ -204,7 +207,7 @@ test('detection renders its mode, rows, sliders and allocation', async (t) => {
await nextTick();
assert.equal(button.querySelector('.pp-label').textContent, 'DETECT');
assert.equal(button.getAttribute('aria-label'), 'Detection overlay: off');
assert.equal(button.className, 'pp-toggle-btn');
assert.equal(coreClasses(button), 'pp-toggle-btn');
assert.equal($('detection-slider-row').classList.contains('visible'), false);
});
@@ -314,7 +317,7 @@ test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
const button = $('models3d-toggle');
const row = $('models3d-mode-row');
const all = $('models3d-mode-all');
assert.equal(button.className, 'pp-toggle-btn active');
assert.equal(coreClasses(button), 'pp-toggle-btn active');
assert.equal(button.getAttribute('aria-pressed'), 'true');
assert.equal(row.classList.contains('visible'), true);
assert.equal($('models3d-mode-proximity').getAttribute('aria-checked'), 'true');
@@ -322,7 +325,7 @@ test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
// The button leaves the row to its own sync.
owner._setModels3dEnabled(false);
await nextTick();
assert.equal(button.className, 'pp-toggle-btn');
assert.equal(coreClasses(button), 'pp-toggle-btn');
assert.equal(button.getAttribute('aria-pressed'), 'false');
assert.equal(row.classList.contains('visible'), true);
owner._syncModels3dModeRow();
@@ -356,7 +359,7 @@ test('the Draw controls render what the draw tool publishes', async (t) => {
store.publishDraw({ active: true, shape: 'line', hint: 'Click to add points.' });
await nextTick();
assert.equal(toggle.className, 'pp-toggle-btn active');
assert.equal(coreClasses(toggle), 'pp-toggle-btn active');
assert.equal(toggle.getAttribute('aria-pressed'), 'true');
assert.equal($('draw-mode-row').classList.contains('visible'), true);
assert.equal($('draw-label-row').classList.contains('visible'), true);
@@ -367,7 +370,7 @@ test('the Draw controls render what the draw tool publishes', async (t) => {
store.publishDraw({ active: false, shape: 'line', hint: '' });
await nextTick();
assert.equal(toggle.className, 'pp-toggle-btn');
assert.equal(coreClasses(toggle), 'pp-toggle-btn');
assert.equal($('draw-mode-row').classList.contains('visible'), false);
assert.equal($('draw-label-row').classList.contains('visible'), false);
});
+102
View File
@@ -0,0 +1,102 @@
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { installVueDom } from '../../testSupport/vueDom.mjs';
let mount;
let vue;
let renderToString;
let UiButton;
let uninstall;
before(async () => {
uninstall = installVueDom();
({ mount } = await import('@vue/test-utils'));
vue = await import('vue');
({ renderToString } = await import('vue/server-renderer'));
UiButton = (await import('./UiButton.vue')).default;
});
after(() => uninstall());
function mountButton(props = {}, attrs = {}) {
return mount(UiButton, {
attachTo: document.body,
props,
attrs,
slots: { default: 'Go' },
});
}
test('a pressed toggle carries aria-pressed, the kit class and Core’s', async () => {
const wrapper = mountButton(
{ pressed: false, pressedClass: 'active' },
{ id: 'go', class: 'pp-toggle-btn' },
);
const button = wrapper.get('button');
assert.equal(button.attributes('aria-pressed'), 'false');
assert.equal(button.classes().includes('active'), false);
await wrapper.setProps({ pressed: true });
assert.equal(button.attributes('aria-pressed'), 'true');
assert.ok(button.classes().includes('active'));
assert.ok(button.classes().includes('ui-button--pressed'));
assert.ok(button.classes().includes('pp-toggle-btn'));
assert.equal(button.attributes('type'), 'button');
wrapper.unmount();
});
test('no aria-pressed unless pressed is passed', () => {
const wrapper = mountButton();
assert.equal(wrapper.get('button').attributes('aria-pressed'), undefined);
wrapper.unmount();
});
test('a class other code adds survives a press', async () => {
const wrapper = mountButton({ pressed: false, pressedClass: 'active' });
const button = wrapper.get('button').element;
button.classList.add('voice-highlight');
await wrapper.setProps({ pressed: true });
await wrapper.setProps({ pressed: false });
assert.ok(button.classList.contains('voice-highlight'));
assert.equal(button.classList.contains('active'), false);
wrapper.unmount();
});
test('a caller’s class change keeps the pressed classes', async () => {
const wrapper = mountButton(
{ pressed: true, pressedClass: 'active' },
{ class: { panoptic: false } },
);
await wrapper.setProps({ class: { panoptic: true } });
const classes = wrapper.get('button').classes();
assert.ok(classes.includes('panoptic'));
assert.ok(classes.includes('active'));
assert.ok(classes.includes('ui-button--pressed'));
wrapper.unmount();
});
test('a renamed pressedClass leaves the button', async () => {
const wrapper = mountButton({ pressed: true, pressedClass: 'active' });
await wrapper.setProps({ pressedClass: 'selected' });
let classes = wrapper.get('button').classes();
assert.equal(classes.includes('active'), false);
assert.ok(classes.includes('selected'));
await wrapper.setProps({ pressed: false });
classes = wrapper.get('button').classes();
assert.equal(classes.includes('selected'), false);
assert.equal(classes.includes('active'), false);
wrapper.unmount();
});
test('server-rendered markup carries the pressed classes', async () => {
const { createSSRApp, h } = vue;
const html = await renderToString(
createSSRApp({
render: () =>
h(
UiButton,
{ id: 'fm', pressed: true, pressedClass: 'active' },
() => 'FM',
),
}),
);
assert.match(html, /<button[^>]*class="[^"]*\bactive\b[^"]*"/);
assert.match(html, /aria-pressed="true"/);
});
+30 -7
View File
@@ -1,11 +1,17 @@
<script setup>
import { computed } from 'vue';
import { vClassToggle } from '../rail/classToggle.js';
// Core's button, with @halfpixel/ui-vue UiButton's props, classes and
// attribute contract so the two kits can merge: the button is always the root,
// attributes passed in win over its own (a `title` replaces the label's), and
// `pressed` renders aria-pressed only when it is passed. Core styles the
// button through the classes its callers pass; the kit classes carry no Core
// styles yet. Not mirrored yet: icon, loading, disabledReason and the
// missing-label warning.
// styles yet. Core adds `pressedClass`, its class on a pressed button (most
// toggles use `active`). The pressed classes toggle through `classList`, so
// classes other code adds to the button (a voice highlight) survive a press.
// Not mirrored yet: icon, loading, disabledReason and the missing-label
// warning.
defineOptions({ inheritAttrs: false });
const props = defineProps({
@@ -14,6 +20,8 @@ const props = defineProps({
/** The accessible name (aria-label) and, with nativeTitle, the tip. */
label: { type: String, default: undefined },
pressed: { type: Boolean, default: undefined },
/** Core's class on the button while it is pressed. */
pressedClass: { type: String, default: undefined },
disabled: { type: Boolean, default: false },
type: { type: String, default: 'button' },
/** The id of the form a submit button belongs to, where it sits outside it. */
@@ -21,6 +29,24 @@ const props = defineProps({
nativeTitle: { type: Boolean, default: true },
});
const emit = defineEmits(['click']);
// A pressedClass that changes keeps its old name, switched off, so the class
// leaves the button with it.
const pressedClasses = computed((previous) => {
const on = Boolean(props.pressed);
const classes = { 'ui-button--pressed': on };
for (const name of Object.keys(previous || {}))
if (name !== 'ui-button--pressed') classes[name] = false;
if (props.pressedClass) classes[props.pressedClass] = on;
return classes;
});
// Server-rendered markup carries the pressed classes in its class attribute.
const vPressedClasses = {
...vClassToggle,
getSSRProps: ({ value }) => ({
class: Object.keys(value).filter((name) => value[name]),
}),
};
</script>
<template>
@@ -28,11 +54,7 @@ const emit = defineEmits(['click']);
:type="props.type"
:form="props.form"
class="ui-button ui-focus"
:class="[
`ui-button--${props.variant}`,
`ui-button--${props.tone}`,
{ 'ui-button--pressed': props.pressed },
]"
:class="[`ui-button--${props.variant}`, `ui-button--${props.tone}`]"
:aria-label="props.label"
:title="props.nativeTitle ? props.label : undefined"
:aria-pressed="
@@ -40,6 +62,7 @@ const emit = defineEmits(['click']);
"
:disabled="props.disabled"
v-bind="$attrs"
v-pressed-classes="pressedClasses"
@click="emit('click', $event)"
>
<slot />
+8 -5
View File
@@ -14,6 +14,9 @@ import {
} from './radioPresentation.js';
import { setLocale } from '../i18n/index.js';
import { LocalSdrControls } from './localSdrControls.js';
// Core's classes on an element, without the kit's `ui-` classes.
const coreClasses = (el) =>
[...el.classList].filter((name) => !name.startsWith('ui-')).join(' ');
function fixture(t) {
document.body.replaceChildren();
@@ -96,7 +99,7 @@ test('the panel renders the presented directory, station, transport and playback
assert.equal($('radio-layer-state').className, 'radio-layer-state active');
assert.equal($('radio-layer-state').textContent, '3/3');
const enable = $('radio-enable-btn');
assert.equal(enable.className, 'panel-layer-toggle active');
assert.equal(coreClasses(enable), 'panel-layer-toggle active');
assert.equal(enable.textContent, 'DISABLE');
assert.equal(enable.getAttribute('aria-pressed'), 'true');
assert.equal(enable.getAttribute('aria-label'), 'Disable Radio');
@@ -259,7 +262,7 @@ test('the RTL-SDR card renders what LocalSdrControls presents', async (t) => {
assert.equal($('sdr-connection-state').getAttribute('class'), null);
assert.equal($('sdr-connect-btn').textContent, 'CONNECT');
assert.equal($('sdr-connect-btn').disabled, false);
assert.equal($('sdr-mode-fm-btn').className, 'active');
assert.equal(coreClasses($('sdr-mode-fm-btn')), 'active');
assert.equal($('sdr-mode-adsb-btn').getAttribute('aria-pressed'), 'false');
assert.equal($('sdr-adsb-stats').hidden, true);
assert.equal($('sdr-frequency-input').value, '98.5');
@@ -280,9 +283,9 @@ test('the RTL-SDR card renders what LocalSdrControls presents', async (t) => {
assert.equal(panel.classList.contains('collapsed'), true, 'chrome keeps its class');
assert.equal($('sdr-connection-state').className, 'active');
assert.equal($('sdr-connect-btn').textContent, 'DISCONNECT');
assert.equal($('sdr-connect-btn').className, 'panel-layer-toggle active');
assert.equal($('sdr-mode-fm-btn').className, '');
assert.equal($('sdr-mode-adsb-btn').className, 'active');
assert.equal(coreClasses($('sdr-connect-btn')), 'panel-layer-toggle active');
assert.equal(coreClasses($('sdr-mode-fm-btn')), '');
assert.equal(coreClasses($('sdr-mode-adsb-btn')), 'active');
assert.equal($('sdr-adsb-stats').hidden, false);
assert.equal($('sdr-stat-heard').textContent, '3');
assert.equal($('sdr-stat-positioned').textContent, '2');
+2 -1
View File
@@ -13,6 +13,7 @@ import BhoteKoshiPanel from './panels/BhoteKoshiPanel.vue';
import MissionDetail from './panels/MissionDetail.vue';
import MissionRoster from './panels/MissionRoster.vue';
import RadioPanel from './panels/RadioPanel.vue';
import UiButton from '../kit/UiButton.vue';
// ContextControls presents the mode switch and the nearby sites search and
// publishes them; until it does, the panel shows its initial markup.
@@ -59,7 +60,7 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<span data-i18n>COCKPIT</span>
</button>
<button id="installations-search-btn" class="panel-layer-toggle" type="button" :aria-disabled="ctx && String(ctx.installationsBusy)" :aria-busy="ctx && String(ctx.installationsBusy)" data-i18n>SEARCH NEARBY SITES</button>
<button id="tr3b-toggle" class="tr3b-toggle" type="button" :aria-pressed="String(!!cockpit?.tr3b?.converted)" :hidden="!cockpit?.tr3b" aria-label="Reclassify tracked contact as TR-3B" :title="cockpit?.tr3b?.converted ? t('Restore real aircraft') : t('Reclassify as TR-3B')" data-i18n-aria-label>🛸</button>
<UiButton id="tr3b-toggle" class="tr3b-toggle" :pressed="!!cockpit?.tr3b?.converted" :hidden="!cockpit?.tr3b" aria-label="Reclassify tracked contact as TR-3B" :title="cockpit?.tr3b?.converted ? t('Restore real aircraft') : t('Reclassify as TR-3B')" data-i18n-aria-label>🛸</UiButton>
</div>
<AwarenessPanel />
</section>
+2 -1
View File
@@ -15,6 +15,7 @@ import WindInspectionMarker from './panels/WindInspectionMarker.vue';
import CelestialRing from './panels/CelestialRing.vue';
import ScreenAnnotations from './panels/ScreenAnnotations.vue';
import AwarenessDirection from './panels/AwarenessDirection.vue';
import UiButton from '../kit/UiButton.vue';
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
const { style } = storeToRefs(useDisplayStore());
@@ -59,7 +60,7 @@ const vToastText = {
<canvas id="world-overlay-canvas"></canvas>
</div>
<div id="world-overlay-actions" class="world-overlay-accessibility" role="region" aria-label="Visible map targets" data-i18n-aria-label>
<div id="world-overlay-action-list"><button v-for="item in targets?.items ?? []" :key="item.key" type="button" :aria-label="item.label" :aria-pressed="String(item.selected)" :data-overlay-action-key="item.key" :onClick="onOverlayAction(targets.owner, item.key)">{{ item.label }}</button></div>
<div id="world-overlay-action-list"><UiButton v-for="item in targets?.items ?? []" :key="item.key" :aria-label="item.label" :pressed="Boolean(item.selected)" :data-overlay-action-key="item.key" :onClick="onOverlayAction(targets.owner, item.key)">{{ item.label }}</UiButton></div>
<div id="world-overlay-status" v-overlay-status="targets" role="status" aria-live="polite" aria-atomic="true"></div>
</div>
+5 -5
View File
@@ -38,7 +38,7 @@ const frameData = computed(() =>
<div id="cctv-source-badge" class="gev-status-badge" role="status" aria-live="polite" :data-frame-state="sourceBadge?.state">{{ sourceBadge?.label ?? t('SOURCE · UNKNOWN') }}</div>
<div id="cctv-meta">{{ view?.meta ?? t('Enable CCTV to load camera intersections') }}</div>
<div class="cctv-controls">
<UiButton :pressed="Boolean(view?.enabled)" id="cctv-enable-btn" :class="['scene-btn', { active: view?.enabled }]">{{ view?.enabled ? t('CCTV ON') : t('CCTV OFF') }}</UiButton>
<UiButton class="scene-btn" :pressed="Boolean(view?.enabled)" pressed-class="active" id="cctv-enable-btn">{{ view?.enabled ? t('CCTV ON') : t('CCTV OFF') }}</UiButton>
<UiButton id="cctv-nearest-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.navDisabled" data-i18n>NEAREST</UiButton>
</div>
<div class="cctv-controls">
@@ -48,17 +48,17 @@ const frameData = computed(() =>
</div>
<div class="cctv-controls">
<UiButton id="cctv-focus-btn" type="button" class="gev-action-button scene-btn" :disabled="view?.focusDisabled" data-i18n>FOCUS</UiButton>
<UiButton :pressed="Boolean(view?.coverage.active)" id="cctv-coverage-btn" :class="['scene-btn', { active: view?.coverage.active }]" :disabled="view?.toggleDisabled">{{ view?.coverage.text ?? t('COVERAGE OFF') }}</UiButton>
<UiButton :pressed="Boolean(view?.autoHop.active)" id="cctv-auto-hop-btn" :class="['scene-btn', { active: view?.autoHop.active }]" :disabled="view?.toggleDisabled">{{ view?.autoHop.text ?? t('AUTO HOP OFF') }}</UiButton>
<UiButton class="scene-btn" :pressed="Boolean(view?.coverage.active)" pressed-class="active" id="cctv-coverage-btn" :disabled="view?.toggleDisabled">{{ view?.coverage.text ?? t('COVERAGE OFF') }}</UiButton>
<UiButton class="scene-btn" :pressed="Boolean(view?.autoHop.active)" pressed-class="active" id="cctv-auto-hop-btn" :disabled="view?.toggleDisabled">{{ view?.autoHop.text ?? t('AUTO HOP OFF') }}</UiButton>
</div>
<div class="cctv-controls">
<UiButton :pressed="Boolean(view?.projection.active)" id="cctv-projection-btn" :class="['scene-btn', { active: view?.projection.active }]" :disabled="view?.toggleDisabled">{{ view?.projection.text ?? t('PROJECTION ON') }}</UiButton>
<UiButton class="scene-btn" :pressed="Boolean(view?.projection.active)" pressed-class="active" id="cctv-projection-btn" :disabled="view?.toggleDisabled">{{ view?.projection.text ?? t('PROJECTION ON') }}</UiButton>
<span id="cctv-quality-chip" class="gev-status-badge gev-status-badge--box" :data-cal-badge="view?.quality.calBadge">{{ view?.quality.label ?? t('CAL · {badge}', { badge: '--' }) }}</span>
</div>
<div class="cctv-calibration-block">
<div class="cctv-calibration-header">
<div class="cctv-summary-label" data-i18n>CALIBRATION</div>
<UiButton :pressed="Boolean(view?.calibration.adjust.active)" id="cctv-adjust-btn" :class="['scene-btn', { active: view?.calibration.adjust.active }]" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title :disabled="view?.calibration.disabled">{{ view?.calibration.adjust.text ?? t('ADJUST') }}</UiButton>
<UiButton class="scene-btn" :pressed="Boolean(view?.calibration.adjust.active)" pressed-class="active" id="cctv-adjust-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV" data-i18n-title :disabled="view?.calibration.disabled">{{ view?.calibration.adjust.text ?? t('ADJUST') }}</UiButton>
</div>
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type" data-i18n-aria-label>
<CctvCalValue field="heading" label="HDG" title="Heading (compass °) — click to type" data-i18n-title />
+2 -1
View File
@@ -6,6 +6,7 @@ import { createBriefState } from '../../cockpitBriefPages.js';
import { useCockpitStore } from '../../stores/cockpit.js';
import CockpitRoute from './CockpitRoute.vue';
import CockpitSignalList from './CockpitSignalList.vue';
import UiButton from '../../kit/UiButton.vue';
// The Cockpit briefing card. The Cockpit controller presents its pages,
// cycle toggle, regional news and local conditions; until it does, the card
@@ -51,7 +52,7 @@ function leaveSignals() {
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
<button id="cockpit-brief-auto" type="button" :aria-label="b.autoLabel" :aria-pressed="String(b.autoRotate)" :title="b.autoHelp">{{ b.autoLabel }}</button>
<UiButton id="cockpit-brief-auto" :aria-label="b.autoLabel" :pressed="Boolean(b.autoRotate)" :title="b.autoHelp">{{ b.autoLabel }}</UiButton>
<button id="cockpit-signal-toggle" type="button" :aria-label="collapsed ? t('Expand cockpit briefing panel') : t('Collapse cockpit briefing panel')" :aria-expanded="String(!collapsed)" :title="collapsed ? t('Expand briefing panel') : t('Collapse briefing panel')">
<span class="material-symbols-outlined" aria-hidden="true">{{ collapsed ? 'right_panel_open' : 'right_panel_close' }}</span>
</button>
+3 -2
View File
@@ -5,6 +5,7 @@ import { t } from '../../../i18n/index.js';
import { createContextState } from '../../cockpitContextState.js';
import { vClassToggle } from '../../rail/classToggle.js';
import { useCockpitStore } from '../../stores/cockpit.js';
import UiButton from '../../kit/UiButton.vue';
// The Cockpit Contact card. The Cockpit controller presents its scope,
// navigation, cohort counts, nearest contact and weather toggle; until it
@@ -64,10 +65,10 @@ const weatherLabel = computed(() =>
</div>
<footer class="cockpit-context-footer">
<span id="cockpit-context-uncertainty">{{ c.uncertainty }}</span>
<button id="cockpit-weather-toggle" class="cockpit-context-weather" type="button" :aria-label="weatherLabel" :aria-pressed="String(c.weather)" :title="weatherLabel">
<UiButton id="cockpit-weather-toggle" class="cockpit-context-weather" :aria-label="weatherLabel" :pressed="Boolean(c.weather)" :title="weatherLabel">
<span data-i18n>WX</span>
<strong id="cockpit-weather-state">{{ c.weather ? t('ON') : t('OFF') }}</strong>
</button>
</UiButton>
<time id="cockpit-context-updated">{{ c.updated }}</time>
</footer>
</aside>
@@ -5,6 +5,7 @@ import { t } from '../../../i18n/index.js';
import { vClassToggle } from '../../rail/classToggle.js';
import UiSlider from '../../kit/UiSlider.js';
import { useRadioStore } from '../../stores/radio.js';
import UiButton from '../../kit/UiButton.vue';
// The Cockpit's Display and Radio launchers and their popovers. RadioControls
// presents which popover is open and the compact Radio controls; until it
@@ -52,7 +53,7 @@ const radioLabel = computed(() =>
</div>
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" :hidden="!open.radio" data-i18n-aria-label>
<strong id="cockpit-radio-station" class="cockpit-radio-station">{{ cockpit ? cockpit.station : t('READY') }}</strong>
<button id="cockpit-radio-enable-btn" @click="act('toggleRadio', 'cockpit')" class="panel-layer-toggle" v-class-toggle="{ active: cockpit?.enable.active }" type="button" :aria-pressed="String(cockpit?.enable.active ?? false)" :aria-label="cockpit?.enable.label" :aria-disabled="cockpit ? String(cockpit.enable.busy) : undefined" :aria-busy="cockpit ? String(cockpit.enable.busy) : undefined">{{ cockpit ? cockpit.enable.text : t('ENABLE') }}</button>
<UiButton id="cockpit-radio-enable-btn" @click="act('toggleRadio', 'cockpit')" class="panel-layer-toggle" :pressed="cockpit?.enable.active ?? false" pressed-class="active" :aria-label="cockpit?.enable.label" :aria-disabled="cockpit ? String(cockpit.enable.busy) : undefined" :aria-busy="cockpit ? String(cockpit.enable.busy) : undefined">{{ cockpit ? cockpit.enable.text : t('ENABLE') }}</UiButton>
<div class="cockpit-radio-transport">
<button id="cockpit-radio-prev-btn" @click="act('cycleRadio', -1, { rotate: false })" type="button" aria-label="Previous filtered radio station" :disabled="radio ? radio.transport.stepDisabled : true" data-i18n-aria-label>◀◀</button>
<button id="cockpit-radio-play-btn" @click="act('toggleRadioPlayback')" type="button" v-class-toggle="{ active: radio?.transport.play.active }" :aria-label="radio ? radio.transport.play.label : t('Play selected radio station')" :title="cockpit?.play.title" :disabled="radio ? radio.transport.play.disabled : true">{{ cockpit ? cockpit.play.glyph : '▶' }}</button>
+10 -9
View File
@@ -9,6 +9,7 @@ import UiSegmented from '../../kit/UiSegmented.vue';
import UiSlider from '../../kit/UiSlider.js';
import GeoImport from './GeoImport.vue';
import { useDisplayStore } from '../../stores/display.js';
import UiButton from '../../kit/UiButton.vue';
// The Display panel. VisualSettings presents the HUD, Sonar, Scope,
// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and
@@ -75,7 +76,7 @@ const allocationOptions = [
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-toggle-row" id="cyber-sonar-row">
<span class="pp-slider-mini-label" data-i18n>Sonar</span>
<button class="pp-mode-btn" :class="{ active: sonarOn }" id="cyber-sonar-toggle" @click="act('toggleSonar')" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" :aria-pressed="String(sonarOn)" data-i18n-title data-i18n-aria-label>{{ sonarOn ? t('ON') : t('OFF') }}</button>
<UiButton class="pp-mode-btn" :pressed="sonarOn" pressed-class="active" id="cyber-sonar-toggle" @click="act('toggleSonar')" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" data-i18n-title data-i18n-aria-label>{{ sonarOn ? t('ON') : t('OFF') }}</UiButton>
</div>
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
@@ -105,10 +106,10 @@ const allocationOptions = [
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: detectButton?.active, panoptic: detectButton?.panoptic }" id="detection-toggle" @click="act('cycleDetection')" title="Detection Overlay (D)" :aria-label="detectButton?.ariaLabel ?? t('Detection overlay')" :aria-pressed="String(detectButton?.pressed ?? false)" data-i18n-title>
<UiButton class="pp-toggle-btn" :class="{ active: detectButton?.active, panoptic: detectButton?.panoptic }" :pressed="detectButton?.pressed ?? false" id="detection-toggle" @click="act('cycleDetection')" title="Detection Overlay (D)" :aria-label="detectButton?.ariaLabel ?? t('Detection overlay')" data-i18n-title>
<span class="pp-icon">&#x25A3;</span>
<span class="pp-label">{{ detectButton?.label ?? t('DETECT') }}</span>
</button>
</UiButton>
<div class="pp-slider-row detection-slider-row" :class="{ visible: detection?.rowsVisible }" id="detection-slider-row">
<label class="pp-slider-mini-label" for="detection-density-slider" data-i18n>Density</label>
<UiSlider class="pp-slider" id="detection-density-slider" @update:model-value="act('setDensity', $event)" min="0" max="100" step="25" :model-value="detection?.densityPct ?? 50" aria-label="Detection label density" data-i18n-aria-label />
@@ -143,10 +144,10 @@ const allocationOptions = [
button ships lit and the mode row ships open — the same markup-carries-the-default
convention as #scope-toggle. ui.js re-syncs both from durable layer state, which
is what flips them back for a share link or a stored session carrying 3D off. -->
<button class="pp-toggle-btn" :class="{ active: models3dOn }" id="models3d-toggle" @click="act('toggleModels')" :aria-pressed="String(models3dOn)" title="3D aircraft — flat icons zoomed out, 3D models up close" data-i18n-title>
<UiButton class="pp-toggle-btn" :pressed="models3dOn" pressed-class="active" id="models3d-toggle" @click="act('toggleModels')" title="3D aircraft — flat icons zoomed out, 3D models up close" data-i18n-title>
<span class="pp-icon">&#x2708;</span>
<span class="pp-label">3D</span>
</button>
</UiButton>
<div class="pp-slider-row" :class="{ visible: models3d?.rowVisible ?? true }" id="models3d-mode-row">
<span class="pp-slider-mini-label" data-i18n>Models</span>
<UiSegmented class="pp-mode-seg" item-class="pp-mode-btn" keyboard="none" :label="t('3D model coverage')" :options="modelsOptions" :model-value="modelsMode" @update:model-value="act('setModelsMode', $event)" />
@@ -165,10 +166,10 @@ const allocationOptions = [
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: draw?.active }" id="draw-toggle" @click="act('toggleDraw')" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" :aria-pressed="String(draw?.active ?? false)" data-i18n-title>
<UiButton class="pp-toggle-btn" :pressed="draw?.active ?? false" pressed-class="active" id="draw-toggle" @click="act('toggleDraw')" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" data-i18n-title>
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
<span class="pp-label" data-i18n>Draw</span>
</button>
</UiButton>
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-mode-row">
<span class="pp-slider-mini-label" data-i18n>Shape</span>
<UiSegmented class="pp-mode-seg" item-class="pp-mode-btn" keyboard="none" :label="t('Shape to draw')" :options="drawShapeOptions" :model-value="drawShape" @update:model-value="act('setDrawShape', $event)" />
@@ -188,10 +189,10 @@ const allocationOptions = [
</div>
<GeoImport />
<div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: celestial?.active }" id="celestial-toggle" @click="act('toggleCelestial')" :title="celestialTitle" :aria-pressed="String(celestial?.active ?? false)" :disabled="celestial ? !celestial.supported : undefined" :aria-disabled="celestial ? String(!celestial.supported) : undefined">
<UiButton class="pp-toggle-btn" :pressed="celestial?.active ?? false" pressed-class="active" id="celestial-toggle" @click="act('toggleCelestial')" :title="celestialTitle" :disabled="celestial ? !celestial.supported : undefined" :aria-disabled="celestial ? String(!celestial.supported) : undefined">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
<span class="pp-label" data-i18n>Celestial</span>
</button>
</UiButton>
</div>
<button class="pp-toggle-btn" id="clean-view-toggle" @click="act('toggleCleanView')" title="Hide UI chrome" data-i18n-title>
<span class="pp-icon">&#x25A1;</span>
+2 -1
View File
@@ -4,6 +4,7 @@ import { nextTick, ref, watch } from 'vue';
import { t } from '../../../i18n/index.js';
import UiSlider from '../../kit/UiSlider.js';
import { useContextStore } from '../../stores/context.js';
import UiButton from '../../kit/UiButton.vue';
// The selected space mission inside Context. The launches layer presents it
// and hides the panel while none is selected; the roster beside it hides
@@ -67,7 +68,7 @@ watch(
</div>
<div class="mission-action-row">
<button type="button" class="mission-focus-button" data-mission-focus @click="layer?.focusSelectedLaunchSite()">{{ t('FOCUS') }}</button>
<button type="button" class="mission-replay-button" data-mission-replay :aria-pressed="String(m?.replay.active ?? false)" :title="t('Replay the estimated ascent with a following camera')" :hidden="m && (m.replay.active || !m.replay.available)" :disabled="m && !m.replay.available" @click="layer?.startSelectedReplay()">{{ t('REPLAY ASCENT') }}</button>
<UiButton class="mission-replay-button" data-mission-replay :pressed="m?.replay.active ?? false" :title="t('Replay the estimated ascent with a following camera')" :hidden="m && (m.replay.active || !m.replay.available)" :disabled="m && !m.replay.available" @click="layer?.startSelectedReplay()">{{ t('REPLAY ASCENT') }}</UiButton>
<div class="mission-replay-transport" :class="{ 'is-paused': m?.replay.paused }" data-mission-replay-transport :hidden="!m?.replay.active" :aria-label="m?.replay.transportLabel">
<button type="button" data-mission-replay-toggle :title="m ? m.replay.toggleLabel : t('Pause replay')" :aria-label="m ? m.replay.toggleLabel : t('Pause replay')" :disabled="m && !m.replay.active" @click="layer?.toggleReplayPause()">{{ m ? m.replay.toggleText : 'Ⅱ' }}</button>
<button type="button" class="cancel" data-mission-replay-cancel :title="t('Cancel replay')" :aria-label="t('Cancel replay')" @click="layer?.cancelReplay()"><span aria-hidden="true">×</span></button>
+2 -1
View File
@@ -9,6 +9,7 @@ import { vRangeValue } from '../../rail/rangeValue.js';
import { useRadioStore } from '../../stores/radio.js';
import RadioTuner from './RadioTuner.vue';
import SdrCard from './SdrCard.vue';
import UiButton from '../../kit/UiButton.vue';
// The Radio panel inside Context. RadioControls presents the directory row,
// station card, transport, volume and playback line and publishes them;
@@ -38,7 +39,7 @@ const radioWords = {
<RailPanel id="radio-panel" tag="section" start-collapsed v-class-toggle="{ ...(radio && { 'radio-enabled': radio.enabled, 'lifecycle-uncertain': radio.uncertain }), ...(sdrConnected !== null && { 'sdr-connected': sdrConnected }) }" aria-label="Internet radio and local SDR" data-i18n-aria-label :title="t('RADIO')" :toggle-words="radioWords" :glow="false" scroll>
<template #badge><span id="radio-layer-state" class="radio-layer-state" :class="{ active: radio?.layerState.active }">{{ radio ? radio.layerState.text : t('OFF') }}</span></template>
<div class="radio-directory-row">
<button id="radio-enable-btn" @click="act('toggleRadio', 'panel')" class="panel-layer-toggle" :class="{ active: radio?.enable.active }" type="button" :aria-pressed="String(radio?.enable.active ?? false)" :aria-label="radio?.enable.label" :aria-disabled="radio ? String(radio.enable.busy) : undefined" :aria-busy="radio ? String(radio.enable.busy) : undefined">{{ radio ? radio.enable.text : t('ENABLE') }}</button>
<UiButton id="radio-enable-btn" @click="act('toggleRadio', 'panel')" class="panel-layer-toggle" :pressed="radio?.enable.active ?? false" pressed-class="active" :aria-label="radio?.enable.label" :aria-disabled="radio ? String(radio.enable.busy) : undefined" :aria-busy="radio ? String(radio.enable.busy) : undefined">{{ radio ? radio.enable.text : t('ENABLE') }}</UiButton>
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
<select class="gev-field" id="radio-filter" @change="setFilter($event.target)" aria-label="Filter stations by station tag" :disabled="radio ? radio.filter.disabled : true" v-bind="radio?.filter.color ? { style: { color: radio.filter.color } } : {}" v-range-value="radio ? radio.filter.value : 'all'" data-i18n-aria-label>
<option v-if="!radio?.filter.options" value="all">{{ t('All', { context: 'radio tag' }) }}</option><template v-else><option v-for="option in radio.filter.options" :key="option.id" :value="option.id" :data-radio-color="option.color" :style="{ color: option.color }" :aria-label="option.label">● {{ option.label }}</option></template>
+4 -3
View File
@@ -8,6 +8,7 @@ import { vClassToggle } from '../../rail/classToggle.js';
import { vFieldValue } from '../../rail/fieldValue.js';
import UiSlider from '../../kit/UiSlider.js';
import { useRadioStore } from '../../stores/radio.js';
import UiButton from '../../kit/UiButton.vue';
// The local RTL-SDR card in the Radio panel. LocalSdrControls presents it and
// publishes it; until it does, the card shows its initial markup. Its
@@ -29,13 +30,13 @@ const tune = () => act('tuneSdr', frequency.value.value);
</div>
<p data-i18n>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
<div class="sdr-radio-actions">
<button id="sdr-connect-btn" @click="act('toggleSdrConnection')" class="panel-layer-toggle" :class="{ active: sdr?.connectPressed }" type="button" :aria-pressed="String(sdr?.connectPressed ?? false)" :disabled="sdr?.connectDisabled">{{ sdr ? sdr.connectLabel : t('CONNECT') }}</button>
<UiButton id="sdr-connect-btn" @click="act('toggleSdrConnection')" class="panel-layer-toggle" :pressed="sdr?.connectPressed ?? false" pressed-class="active" :disabled="sdr?.connectDisabled">{{ sdr ? sdr.connectLabel : t('CONNECT') }}</UiButton>
<button id="sdr-locate-btn" type="button" @click="act('locateSdr')" v-class-toggle="{ active: sdr?.locateActive }" title="Use your location for faster ADS-B position decoding" :disabled="sdr?.locateDisabled" data-i18n-title>{{ sdr ? sdr.locateLabel : t('LOCATE') }}</button>
<button id="sdr-change-device-btn" type="button" @click="act('changeSdrDevice')" title="Choose a different RTL-SDR or receiver channel" :disabled="sdr?.changeDeviceDisabled" data-i18n-title data-i18n>CHANGE DEVICE</button>
</div>
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode" data-i18n-aria-label>
<button id="sdr-mode-fm-btn" @click="act('setSdrMode', 'fm')" class="active" v-class-toggle="sdr ? { active: sdr.fmActive } : null" type="button" :aria-pressed="String(sdr ? sdr.fmActive : true)" :disabled="sdr?.modeDisabled" translate="no">FM</button>
<button id="sdr-mode-adsb-btn" type="button" @click="act('setSdrMode', 'adsb')" v-class-toggle="{ active: sdr?.adsbActive }" :aria-pressed="String(sdr?.adsbActive ?? false)" :disabled="sdr?.modeDisabled" translate="no">ADS-B · 1090</button>
<UiButton id="sdr-mode-fm-btn" @click="act('setSdrMode', 'fm')" :pressed="sdr ? sdr.fmActive : true" pressed-class="active" :disabled="sdr?.modeDisabled" translate="no">FM</UiButton>
<UiButton id="sdr-mode-adsb-btn" @click="act('setSdrMode', 'adsb')" :pressed="sdr?.adsbActive ?? false" pressed-class="active" :disabled="sdr?.modeDisabled" translate="no">ADS-B · 1090</UiButton>
</div>
<div class="sdr-gain-row">
<label for="sdr-gain-select" data-i18n>GAIN</label>
+2 -2
View File
@@ -121,12 +121,12 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
<template>
<RailPanel id="street-level-panel" start-collapsed :data-sl-enabled="view ? String(view.enabled) : undefined" :title="t('STREET LEVEL')" sideways scroll rail-scroller>
<template #actions><button id="sl-status" :class="`sl-status${status.tone ? ` is-${status.tone}` : ''}`" type="button" :aria-pressed="String(status.pressed)" :title="status.title">{{ status.text }}</button></template>
<template #actions><UiButton id="sl-status" :class="`sl-status${status.tone ? ` is-${status.tone}` : ''}`" :pressed="Boolean(status.pressed)" :title="status.title">{{ status.text }}</UiButton></template>
<div id="sl-body" class="sl-body">
<div id="sl-viewer-wrap" class="sl-viewer-wrap" :hidden="!viewer.open">
<div class="sl-viewer-bar">
<div class="sl-viewer-tools">
<button id="sl-viewer-expand" class="scene-btn" type="button" :hidden="!expandAvailable" :aria-pressed="String(Boolean(expanded))" :aria-label="expanded === null ? undefined : expanded ? t('Shrink') : t('Expand')" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">{{ expanded ? '⤡' : '⤢' }}</span><span class="sl-btn-text">{{ expanded ? t('SHRINK') : t('EXPAND') }}</span></button>
<UiButton id="sl-viewer-expand" class="scene-btn" :hidden="!expandAvailable" :pressed="Boolean(expanded)" :aria-label="expanded === null ? undefined : expanded ? t('Shrink') : t('Expand')" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">{{ expanded ? '⤡' : '⤢' }}</span><span class="sl-btn-text">{{ expanded ? t('SHRINK') : t('EXPAND') }}</span></UiButton>
<UiSegmented class="sl-seg" item-class="sl-seg-btn" active-class="is-active" keyboard="radio" :hidden="!viewer.renderModes" :label="t('Image fit')" :options="renderModeOptions" :model-value="viewer.renderMode" @update:model-value="store.act('setRenderMode', $event)" />
</div>
<div class="sl-viewer-tools sl-viewer-tools-right">
+5 -4
View File
@@ -5,6 +5,7 @@ import { vClassToggle } from '../../rail/classToggle.js';
import { useVoiceStore } from '../../stores/voice.js';
import VoiceCard from './VoiceCard.vue';
import VoiceVisualizer from './VoiceVisualizer.vue';
import UiButton from '../../kit/UiButton.vue';
// The voice control in the command dock, as its presenter
// (src/voice/control.js) holds it. The session owners write the presenter;
@@ -59,14 +60,14 @@ const rootData = (root) => ({
<div id="gev-voice-status">{{ view.status.text }}</div>
<span v-if="view.panel" id="gev-voice-cost-warning" class="gev-voice-cost-value gev-voice-cost-warning" :hidden="view.costWarning.hidden" :title="optional(view.costWarning.title)" :data-level="optional(view.costWarning.level)" role="status" aria-live="polite">{{ view.costWarning.text }}</span>
<div v-else class="gev-voice-cost">
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" :hidden="view.tier.hidden" :aria-pressed="String(view.tier.pressed)" :title="view.tier.title" @click="emit('tier')">{{ view.tier.text }}</button>
<UiButton id="gev-voice-tier" class="gev-voice-tier-btn" :hidden="view.tier.hidden" :pressed="Boolean(view.tier.pressed)" :title="view.tier.title" @click="emit('tier')">{{ view.tier.text }}</UiButton>
<span id="gev-voice-cost-value" class="gev-voice-cost-value" :hidden="view.cost.hidden" :data-level="view.cost.level" :title="view.cost.title">{{ view.cost.text }}</span>
</div>
</div>
<button id="gev-voice-button" type="button" :aria-label="view.button.label" :aria-pressed="optional(view.button.pressed)" aria-describedby="gev-voice-help" @click="emit('button')">
<UiButton id="gev-voice-button" :aria-label="view.button.label" :pressed="view.button.pressed ?? undefined" aria-describedby="gev-voice-help" @click="emit('button')">
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
<span class="gev-mic-label">{{ view.mic.text }}</span>
</button>
</UiButton>
<VoiceVisualizer />
<div class="gev-voice-readout">
<div id="gev-voice-detail" :title="optional(view.detail.title)">{{ view.detail.text }}</div>
@@ -108,7 +109,7 @@ const rootData = (root) => ({
<div class="gev-voice-cost-settings">
<div class="gev-voice-cost-setting-row">
<span>{{ t('Voice model tier — applies next session') }}</span>
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" :hidden="view.tier.hidden" :aria-pressed="String(view.tier.pressed)" :title="view.tier.title" @click="emit('tier')">{{ view.tier.text }}</button>
<UiButton id="gev-voice-tier" class="gev-voice-tier-btn" :hidden="view.tier.hidden" :pressed="Boolean(view.tier.pressed)" :title="view.tier.title" @click="emit('tier')">{{ view.tier.text }}</UiButton>
</div>
<div class="gev-voice-cost-setting-row">
<span>{{ t('Estimated session cost') }}</span>