Files
gods-eye-view/src/ui/views/CommandDockView.vue
T
Sameh Khamis 1de4f635ca feat(voice): render the voice control from a presenter
createVoiceControl now returns a presenter instead of building the
control with innerHTML: the session, cost, input, card and panel owners
write its state by part and listen for interactions, and VoiceControl.vue
renders it in the command dock, between the location bar and the visual
presets as before. Focus moves wait for the render; the waveform's bars
publish apart from the control and only when they change; the minutes
field keeps a draft until its owner writes it. Owner tests use a real
presenter on their own store, and view tests render the control.
2026-10-31 19:22:03 -07:00

141 lines
9.5 KiB
Vue

<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import { vClassToggle } from '../rail/classToggle.js';
import { useDisplayStore } from '../stores/display.js';
import { computed } from 'vue';
import { useLocationStore } from '../stores/location.js';
import { useVoiceStore } from '../stores/voice.js';
import PhoneTabBar from './panels/PhoneTabBar.vue';
import VoiceControl from './panels/VoiceControl.vue';
// VisualSettings publishes the active style and the map source controls
// their chip row; until they do, the dock shows its initial markup. The
// style buttons keep their owner's listeners; a chip calls the controls.
const { style, mapSource, mapSourceControls } = storeToRefs(useDisplayStore());
// LocationControls publishes the location tray; pills call the controls,
// and the search field keeps their listeners.
const { view: loc, controls: locationControls } = storeToRefs(useLocationStore());
// The voice control, while a voice session presents one. It sits between the
// location bar and the visual presets; the trays keep their elements when the
// order changes.
const { view: voice } = storeToRefs(useVoiceStore());
// Once voice has taken its place, the location bar stays first.
let voiceShown = false;
const dockOrder = computed(() => {
if (voice.value) voiceShown = true;
return voice.value
? ['location', 'voice', 'control']
: voiceShown
? ['location', 'control']
: ['control', 'location'];
});
function selectMapSource(chip) {
if (!chip.available) return;
void mapSourceControls.value?.select(chip.id).catch(() => {});
}
</script>
<template>
<!-- Unified bottom command dock: locations, voice, visual presets -->
<div id="command-dock" class="ui-scroll" aria-label="Navigation, voice, and visual preset controls" data-i18n-aria-label>
<template v-for="slot in dockOrder" :key="slot">
<!-- Control panel -->
<div v-if="slot === 'control'" id="control-panel" class="panel-collapsible collapsed">
<div class="panel-glow"></div>
<div class="panel-inner">
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
data-dock-toggle-target="control-panel" aria-expanded="false"
aria-controls="control-panel-popover" aria-label="Expand Visual Presets" data-i18n-aria-label>
<span class="panel-title" data-i18n-runtime><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
<span class="panel-divider"></span>
</button>
<div id="control-panel-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button>
<div id="style-buttons" class="button-grid">
<button class="style-btn active" data-style="normal" v-class-toggle="{ ...(style && { active: style.name === 'normal' }) }" title="Show the globe without a visual filter." data-i18n-title>
<span class="btn-icon">◯</span>
<span class="btn-label" data-i18n>Normal</span>
<span class="btn-key">1</span>
</button>
<button class="style-btn" data-style="retro" v-class-toggle="{ ...(style && { active: style.name === 'retro' }) }" title="Emulate a green phosphor CRT with scanlines and screen curvature." data-i18n-title>
<span class="btn-icon">▦</span>
<span class="btn-label" data-i18n>CRT</span>
<span class="btn-key">2</span>
</button>
<button class="style-btn" data-style="surveillance" v-class-toggle="{ ...(style && { active: style.name === 'surveillance' }) }" title="Simulate night-vision goggles with green intensification and a tube vignette." data-i18n-title>
<span class="btn-icon">🌙</span>
<span class="btn-label" data-i18n>NVG</span>
<span class="btn-key">3</span>
</button>
<button class="style-btn" data-style="thermal" v-class-toggle="{ ...(style && { active: style.name === 'thermal' }) }" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color." data-i18n-title>
<span class="btn-icon">🌡️</span>
<span class="btn-label" data-i18n>FLIR</span>
<span class="btn-key">4</span>
</button>
<button class="style-btn" data-style="anime" v-class-toggle="{ ...(style && { active: style.name === 'anime' }) }" title="Apply bright cel-shaded color and illustrated outlines." data-i18n-title>
<span class="btn-icon">✦</span>
<span class="btn-label" data-i18n>Anime</span>
<span class="btn-key">5</span>
</button>
<button class="style-btn" data-style="noir" v-class-toggle="{ ...(style && { active: style.name === 'noir' }) }" title="Apply high-contrast monochrome film-noir grading." data-i18n-title>
<span class="btn-icon">◐</span>
<span class="btn-label" data-i18n>Noir</span>
<span class="btn-key">6</span>
</button>
</div>
<section class="map-source-section" aria-labelledby="map-source-label">
<div class="map-source-heading">
<span data-i18n id="map-source-label">MAP SOURCE</span>
<span id="map-stack-status" class="pp-stack-status" v-class-toggle="{ warn: !!mapSource?.status.warn }" aria-live="polite">{{ mapSource ? mapSource.status.text : '3D' }}</span>
</div>
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source" data-i18n-aria-label><button v-for="chip in mapSource?.chips" :key="chip.id" type="button" class="map-stack-chip" v-class-toggle="{ active: chip.active, unavailable: !chip.available }" :data-stack-id="chip.id" :title="chip.title" :aria-pressed="String(chip.active)" :aria-disabled="String(!chip.available)" :aria-label="chip.ariaLabel" @click="selectMapSource(chip)"><span class="map-stack-chip-label">{{ chip.label }}</span><span v-if="chip.requirement" class="map-stack-chip-req">{{ chip.requirement }}</span></button></div>
</section>
</div>
<div id="style-mini-status" class="panel-mini-status" aria-live="polite">
<span class="panel-mini-label" data-i18n>Style</span>
<span id="style-mini-value" class="panel-mini-value">{{ style ? style.label : t('NORMAL') }}</span>
</div>
</div>
</div>
<VoiceControl v-else-if="slot === 'voice'" :key="voice.id" :view="voice" />
<!-- Location bar -->
<div v-else-if="slot === 'location'" id="location-bar" class="panel-collapsible collapsed">
<div class="panel-glow"></div>
<div class="location-inner">
<div class="location-toolbar">
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION" data-i18n-aria-label data-i18n-title>
<span class="location-toolbar-label" data-i18n-runtime><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel" data-i18n-title>+</button>
</div>
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
<div id="location-mini-city" class="location-mini-city">{{ loc ? loc.status.city : t('📍 Location: --') }}</div>
<div id="location-mini-poi" class="location-mini-poi">{{ loc ? loc.status.poi : t('Landmark: --') }}</div>
</div>
<div id="location-bar-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button>
<div id="poi-row" class="poi-row-container" v-class-toggle="{ expanded: !!loc?.poisExpanded }"><button v-for="poi in loc?.pois" :key="`${loc.poiCity}:${poi.index}`" type="button" class="poi-pill" v-class-toggle="{ active: poi.active }" :data-poi-index="poi.index" @click="locationControls?.choosePoi(poi.index)"><span class="poi-pill-key">{{ poi.key }}</span><span class="poi-pill-name">{{ poi.name }}</span></button></div>
<div class="location-bar-divider" id="location-bar-divider" v-class-toggle="{ visible: !!loc?.poisExpanded }"></div>
<div class="location-city-row">
<div id="location-pills" class="location-pills"><button v-for="city in loc?.cities" :key="city.id" type="button" class="location-pill" v-class-toggle="{ active: city.active }" :data-location-id="city.id" @click="locationControls?.chooseCity(city.id)">{{ city.label }}</button></div>
<div class="location-search-wrap">
<button id="search-toggle" class="search-toggle-btn" title="Search any location" data-i18n-title>
<span>&#x1F50E;</span>
</button>
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" :aria-invalid="loc?.searchError ? 'true' : null" :aria-describedby="loc?.searchError ? 'location-search-error' : null" data-i18n-aria-label data-i18n-placeholder />
</div>
</div>
<p id="location-search-error" class="location-search-error" role="alert">{{ loc?.searchError }}</p>
</div>
</div>
</div>
</template>
<PhoneTabBar />
</div>
</template>