feat(panels): build the rail panels on one UiPanel

The Data, Authoring, Weather, Recent Imagery, Context, CCTV, Street Level
and Radio panels each wrote the same frame by hand. They are now one kit
component, UiPanel, with a title, badge/actions/default slots and
v-model:collapsed, drawing Core's panel markup and classes beside the kit's;
props cover the differences (sideways, glow, scroll, railScroller, tag,
toggleWords). RailPanel binds it to the panel chrome store: v-panel-state
writes its state classes at once, and its button's clicks reach PanelChrome.
UiPanelToggle is the collapse button UiPanel and PanelCollapseButton share.

UiPanel's collapsed drives the button and ui-panel--collapsed
only; the owner hides the body by writing the root's collapsed class, since
the rails measure a panel in the task that opens it. PanelCollapseButton
takes startCollapsed instead of initial and initialTitle, so a button shows
its state's glyph and words before startup presents the panel. Titles render
through t() instead of data-i18n, and the unused "Expand panel" string is
pruned. v-panel-state redraws the presented state after a re-render, in
case a root's class binding ever changes.
This commit is contained in:
Sameh Khamis committed 2026-11-01 08:33:18 -08:00
1 parent a3fc475cbe
commit cd231faae9
30 files changed
+427 -197

No files matched your search

+13 -3
View File
@@ -53,7 +53,10 @@ function fixture(t) {
test('before ContextControls renders, the switch shows its initial markup', (t) => {
const { $ } = fixture(t);
const panel = $('global-context-panel');
assert.equal(panel.className, 'panel-collapsible collapsed');
assert.equal(
panel.className,
'ui-panel panel-collapsible collapsed ui-panel--collapsed',
);
assert.equal(panel.hasAttribute('data-context-mode'), false);
for (const id of [
'global-context-flights-btn',
@@ -118,14 +121,21 @@ test('the panel carries the mode beside the classes its chrome writes', async (t
panel.classList.remove('collapsed');
controls._contextMode = 'flights';
await sync();
assert.equal(panel.className, 'panel-collapsible context-enabled');
// Without an owner the button, and the kit class, keep the start state.
assert.equal(
panel.className,
'ui-panel panel-collapsible ui-panel--collapsed context-enabled',
);
assert.equal(panel.getAttribute('data-context-mode'), 'flights');
assert.equal($('context-flights-view').hidden, false);
panel.classList.add('collapsed');
controls._contextMode = null;
await sync();
assert.equal(panel.className, 'panel-collapsible collapsed');
assert.equal(
panel.className,
'ui-panel panel-collapsible ui-panel--collapsed collapsed',
);
assert.equal(panel.getAttribute('data-context-mode'), 'none');
assert.equal($('context-mode-standby').hidden, false);
assert.equal($('context-flights-view').hidden, true);
+3 -1
View File
@@ -28,7 +28,9 @@ test('CCTV and Context keep their headers outside Cyber-only scroll bodies', ()
/class="cyber-panel-body ui-scroll" data-rail-scroller/,
);
assert.ok(
markup.indexOf('class="panel-header"') <
// The header is RailPanel's; the body is its first content.
markup.indexOf('<RailPanel ') > -1 &&
markup.indexOf('<RailPanel ') <
markup.indexOf('class="cyber-panel-body ui-scroll"'),
);
}
+74
View File
@@ -0,0 +1,74 @@
<script setup>
import { vClassToggle } from '../rail/classToggle.js';
import UiPanelToggle from './UiPanelToggle.vue';
// Core's panel, with @halfpixel/ui-vue UiPanel's name, title, slots and
// v-model:collapsed so the two kits can merge. It draws Core's panel markup
// (glow, inner, a header with the title, badges, divider, actions and the
// collapse button, then the body) and keeps Core's classes beside the kit's.
// Unlike ui-vue's, `collapsed` does not hide the body: the root's classes
// stay as rendered, and whatever owns the panel's state writes its
// `collapsed` class at once (PanelChrome, through vPanelState), because the
// rails measure a panel in the task that opens it. `collapsed` drives the
// button and the kit's `ui-panel--collapsed`, toggled without a re-render.
// There is no Close: Core's panels collapse.
const props = defineProps({
/** The root's id, its inner's class (`{id}-inner`) and the button's target. */
id: { type: String, required: true },
/** The heading, already translated; also the button's "Expand {name}". */
title: { type: String, required: true },
/** The heading's tip. */
hint: { type: String, default: undefined },
tag: { type: String, default: 'div' },
collapsible: { type: Boolean, default: false },
collapsed: { type: Boolean, default: false },
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
sideways: { type: Boolean, default: false },
/** Words that replace "Expand/Collapse {title}" on the button. */
toggleWords: { type: Object, default: null },
glow: { type: Boolean, default: true },
/** The inner scrolls with Core's scroll bar. */
scroll: { type: Boolean, default: false },
/** The inner is a scroller the right rail keeps in view. */
railScroller: { type: Boolean, default: false },
});
const emit = defineEmits(['update:collapsed']);
</script>
<template>
<component
:is="props.tag"
:id="props.id"
v-class-toggle="{
'ui-panel--collapsed': props.collapsible && props.collapsed,
}"
class="ui-panel panel-collapsible"
>
<div v-if="props.glow" class="panel-glow"></div>
<div
:class="[`${props.id}-inner`, { 'ui-scroll': props.scroll }]"
:data-rail-scroller="props.railScroller ? '' : undefined"
>
<div class="ui-panel__head panel-header">
<slot name="title">
<span class="ui-panel__title panel-title" :title="props.hint">{{
props.title
}}</span>
</slot>
<slot name="badge" />
<span class="panel-divider"></span>
<slot name="actions" />
<UiPanelToggle
v-if="props.collapsible"
:target="props.id"
:name="props.title"
:collapsed="props.collapsed"
:sideways="props.sideways"
:words="props.toggleWords"
@update:collapsed="emit('update:collapsed', $event)"
/>
</div>
<slot />
</div>
</component>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup>
import { computed } from 'vue';
import { msg, t } from '../../i18n/index.js';
import UiButton from './UiButton.vue';
// A panel's collapse button, internal to the kit: UiPanel draws it, and so
// does PanelCollapseButton for the panels not built on UiPanel. Its glyph and
// words follow `collapsed`; a click asks for the other state.
const props = defineProps({
/** The panel's id, which PanelChrome finds the button by. */
target: { type: String, required: true },
/** The panel's name in "Expand {name}", already translated. */
name: { type: String, default: () => t('panel') },
collapsed: { type: Boolean, required: true },
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
sideways: { type: Boolean, default: false },
/** Words that replace "Expand/Collapse {name}" (Radio's own wording). */
words: { type: Object, default: null },
});
const emit = defineEmits(['update:collapsed']);
const glyph = computed(() => {
if (props.sideways) return props.collapsed ? '◀' : '▶';
return props.collapsed ? '+' : '−';
});
const label = computed(() => {
if (props.words)
return props.collapsed
? props.words.expandLabel
: props.words.collapseLabel;
const key = props.collapsed ? msg('Expand {name}') : msg('Collapse {name}');
return t(key, { name: props.name });
});
const title = computed(() => {
if (props.words)
return props.collapsed
? props.words.expandTitle
: props.words.collapseTitle;
return label.value;
});
</script>
<template>
<UiButton
variant="icon"
class="panel-collapse-btn"
:data-collapse-target="target"
:label="label"
:title="title"
:aria-expanded="String(!collapsed)"
@click="emit('update:collapsed', !collapsed)"
>{{ glyph }}</UiButton
>
</template>
+149 -17
View File
@@ -11,6 +11,8 @@ import { PanelChrome } from './panelChrome.js';
import { followPanelState, vPanelState } from './rail/panelState.js';
import { bindPanelDisclosure } from './panelDisclosure.js';
import PanelCollapseButton from './rail/PanelCollapseButton.vue';
import RailPanel from './rail/RailPanel.vue';
import UiPanel from './kit/UiPanel.vue';
import { usePanelChromeStore } from './stores/panelChrome.js';
import CommandDockView from './views/CommandDockView.vue';
@@ -57,38 +59,33 @@ test('the store republishes a panel only when its state changes', () => {
);
});
test('a collapse button keeps its markup until presented, then follows the panel', async (t) => {
test('a collapse button follows its markup until presented, then the panel', async (t) => {
const store = mount(t, {
render: () => [
h(PanelCollapseButton, {
target: 'data-panel',
name: 'DATA LAYERS',
initial: '+',
startCollapsed: true,
}),
h(PanelCollapseButton, {
target: 'cctv-panel',
name: 'CCTV',
sideways: true,
initial: '+',
startCollapsed: true,
}),
],
});
const [data, cctv] = document.querySelectorAll('.panel-collapse-btn');
assert.equal(data.textContent, '+');
assert.equal(data.title, 'Collapse panel');
assert.equal(data.hasAttribute('aria-expanded'), false);
assert.equal(data.hasAttribute('aria-label'), false);
assert.equal(data.getAttribute('aria-expanded'), 'false');
assert.equal(data.getAttribute('aria-label'), 'Expand DATA LAYERS');
assert.equal(data.dataset.collapseTarget, 'data-panel');
assert.equal(cctv.textContent, '◀');
const toggled = [];
const token = store.connect({ toggle: (id) => toggled.push(id) });
store.publish(token, 'data-panel', { collapsed: true });
store.publish(token, 'cctv-panel', { collapsed: false });
await nextTick();
assert.equal(data.textContent, '+');
assert.equal(data.getAttribute('aria-expanded'), 'false');
assert.equal(data.getAttribute('aria-label'), 'Expand DATA LAYERS');
assert.equal(data.title, 'Expand DATA LAYERS');
assert.equal(cctv.textContent, '▶');
assert.equal(cctv.getAttribute('aria-expanded'), 'true');
assert.equal(cctv.title, 'Collapse CCTV');
@@ -101,9 +98,144 @@ test('a collapse button keeps its markup until presented, then follows the panel
assert.deepEqual(toggled, ['data-panel']);
});
test("UiPanel draws Core's panel markup and asks for the other state", async (t) => {
const asked = [];
let collapsed = true;
mount(t, {
render: () =>
h(
UiPanel,
{
id: 'demo-panel',
title: 'DEMO',
hint: 'A demo',
collapsible: true,
collapsed,
scroll: true,
railScroller: true,
'onUpdate:collapsed': (next) => asked.push(next),
},
{
badge: () => h('span', { id: 'demo-badge' }),
actions: () => h('button', { id: 'demo-action' }),
default: () => h('div', { id: 'demo-body' }),
},
),
});
const panel = document.getElementById('demo-panel');
assert.equal(
panel.className,
'ui-panel panel-collapsible ui-panel--collapsed',
);
assert.equal(panel.children[0].className, 'panel-glow');
const inner = panel.children[1];
assert.equal(inner.className, 'demo-panel-inner ui-scroll');
assert.equal(inner.hasAttribute('data-rail-scroller'), true);
const [head, body] = inner.children;
assert.deepEqual(
[...head.children].map(
(el) => el.id || el.classList[el.classList.length - 1],
),
[
'panel-title',
'demo-badge',
'panel-divider',
'demo-action',
'panel-collapse-btn',
],
);
assert.equal(head.querySelector('.panel-title').title, 'A demo');
assert.equal(body.id, 'demo-body');
const toggle = head.querySelector('.panel-collapse-btn');
assert.equal(toggle.getAttribute('aria-label'), 'Expand DEMO');
assert.equal(toggle.dataset.collapseTarget, 'demo-panel');
toggle.click();
assert.deepEqual(asked, [false]);
});
test('UiPanel without collapsible has no button, and glow is optional', (t) => {
mount(t, {
render: () =>
h(UiPanel, {
id: 'plain-panel',
title: 'PLAIN',
tag: 'section',
glow: false,
}),
});
const panel = document.getElementById('plain-panel');
assert.equal(panel.tagName, 'SECTION');
assert.equal(panel.querySelector('.panel-glow'), null);
assert.equal(panel.querySelector('.panel-collapse-btn'), null);
});
test('RailPanel follows the store, forwards clicks, and never restores a class its owner removed', async (t) => {
const store = mount(t, {
render: () =>
h(
RailPanel,
{
id: 'radio-panel',
startCollapsed: true,
title: 'RADIO',
class: 'extra',
hidden: '',
},
{ default: () => h('p', { id: 'radio-body' }) },
),
});
const panel = document.getElementById('radio-panel');
assert.equal(
panel.className,
'ui-panel panel-collapsible extra collapsed ui-panel--collapsed',
);
assert.equal(panel.dataset.panelId, 'radio-panel');
assert.equal(panel.hidden, true);
assert.ok(document.getElementById('radio-body'));
const toggle = panel.querySelector('.panel-collapse-btn');
assert.equal(toggle.textContent, '+');
const toggled = [];
const token = store.connect({ toggle: (id) => toggled.push(id) });
store.publish(token, 'radio-panel', { collapsed: false });
assert.equal(panel.classList.contains('collapsed'), false, 'written at once');
await nextTick();
assert.equal(toggle.textContent, '−');
// The re-render above left the class as written; pinning keeps it too.
store.publish(token, 'radio-panel', { pinned: true });
await nextTick();
assert.equal(panel.classList.contains('collapsed'), false);
assert.equal(panel.classList.contains('dock-pinned'), true);
toggle.click();
assert.deepEqual(toggled, ['radio-panel']);
});
test('a panel whose class binding changes keeps the presented state', async (t) => {
const { ref } = await import('vue');
const extra = ref('one');
const store = mount(t, {
render: () =>
h(RailPanel, {
id: 'cctv-panel',
startCollapsed: true,
title: 'CCTV',
class: extra.value,
}),
});
const panel = document.getElementById('cctv-panel');
store.publish(store.connect({}), 'cctv-panel', {
collapsed: false,
pinned: true,
});
extra.value = 'two';
await nextTick();
assert.equal(panel.classList.contains('two'), true);
assert.equal(panel.classList.contains('collapsed'), false);
assert.equal(panel.classList.contains('dock-pinned'), true);
});
test("Radio's collapse button keeps its own wording", async (t) => {
const words = {
initialLabel: 'Expand Radio section',
expandLabel: 'Expand Radio section',
collapseLabel: 'Collapse Radio section',
expandTitle: 'Expand Radio',
@@ -111,11 +243,11 @@ test("Radio's collapse button keeps its own wording", async (t) => {
};
const store = mount(t, {
render: () =>
h(PanelCollapseButton, {
target: 'radio-panel',
initial: '+',
initialTitle: 'Expand Radio',
words,
h(RailPanel, {
id: 'radio-panel',
startCollapsed: true,
title: 'RADIO',
toggleWords: words,
}),
});
const button = document.querySelector('.panel-collapse-btn');
+17 -49
View File
@@ -1,66 +1,34 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { msg, t } from '../../i18n/index.js';
import UiButton from '../kit/UiButton.vue';
import UiPanelToggle from '../kit/UiPanelToggle.vue';
import { usePanelChromeStore } from '../stores/panelChrome.js';
// A panel's collapse button: its glyph, aria-expanded and words follow the
// panel's state in the panel chrome store, and a click asks PanelChrome to
// toggle it. Until PanelChrome presents the panel it shows its markup's glyph
// and tip.
// The collapse button of a panel not built on UiPanel: it follows the panel's
// state in the panel chrome store, and a click asks PanelChrome to toggle it.
// Until PanelChrome presents the panel it follows the markup's state.
const props = defineProps({
target: { type: String, required: true },
/** The panel's name in "Expand {name}", already translated. */
name: { type: String, default: () => t('panel') },
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
name: { type: String, default: undefined },
sideways: { type: Boolean, default: false },
/** The glyph and tip before the panel is presented. */
initial: { type: String, required: true },
initialTitle: { type: String, default: () => t('Collapse panel') },
/** Words that replace "Expand/Collapse {name}" (Radio's own wording). */
words: { type: Object, default: null },
/** Whether the panel's markup starts collapsed. */
startCollapsed: { type: Boolean, default: false },
});
const chrome = usePanelChromeStore();
const { panels } = storeToRefs(chrome);
const state = computed(() => panels.value[props.target]);
const glyph = computed(() => {
if (!state.value) return props.initial;
if (props.sideways) return state.value.collapsed ? '◀' : '▶';
return state.value.collapsed ? '+' : '−';
});
const label = computed(() => {
if (!state.value) return props.words?.initialLabel;
if (props.words)
return state.value.collapsed
? props.words.expandLabel
: props.words.collapseLabel;
const key = state.value.collapsed
? msg('Expand {name}')
: msg('Collapse {name}');
return t(key, { name: props.name });
});
const title = computed(() => {
if (!state.value) return props.initialTitle;
if (props.words)
return state.value.collapsed
? props.words.expandTitle
: props.words.collapseTitle;
return label.value;
});
const collapsedNow = computed(
() => panels.value[props.target]?.collapsed ?? props.startCollapsed,
);
</script>
<template>
<UiButton
variant="icon"
class="panel-collapse-btn"
:data-collapse-target="target"
:label="label"
:title="title"
:aria-expanded="state ? String(!state.collapsed) : undefined"
@click="chrome.act('toggle', target)"
>{{ glyph }}</UiButton
>
<UiPanelToggle
:target="target"
:name="name"
:sideways="sideways"
:collapsed="collapsedNow"
@update:collapsed="chrome.act('toggle', target)"
/>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import UiPanel from '../kit/UiPanel.vue';
import { usePanelChromeStore } from '../stores/panelChrome.js';
import { vPanelState } from './panelState.js';
// A panel PanelChrome owns: a collapsible UiPanel whose state classes
// (vPanelState) and collapse button follow the panel chrome store, with the
// button's clicks forwarded to PanelChrome. Every other prop, attribute and
// slot passes through to UiPanel.
defineOptions({ inheritAttrs: false });
const props = defineProps({
id: { type: String, required: true },
/** Whether the markup starts collapsed, until PanelChrome presents it. */
startCollapsed: { type: Boolean, default: false },
});
const chrome = usePanelChromeStore();
const { panels } = storeToRefs(chrome);
const collapsedNow = computed(
() => panels.value[props.id]?.collapsed ?? props.startCollapsed,
);
</script>
<template>
<UiPanel
v-panel-state="props.id"
v-bind="$attrs"
:id="props.id"
:class="{ collapsed: props.startCollapsed }"
:data-panel-id="props.id"
collapsible
:collapsed="collapsedNow"
@update:collapsed="chrome.act('toggle', props.id)"
>
<template v-for="(_, name) in $slots" #[name]
><slot :name="name"
/></template>
</UiPanel>
</template>
+7
View File
@@ -42,8 +42,15 @@ export function followPanelState(store, panelId, el) {
export const vPanelState = {
mounted(el, binding) {
const store = usePanelChromeStore(binding.instance.$pinia);
el._panelStateStore = store;
el._stopPanelState = followPanelState(store, binding.value, el);
},
// A re-render that rewrites the root's class (its class binding changed)
// would restore the markup's state; draw the presented state again.
updated(el, binding) {
const state = el._panelStateStore?.panels[binding.value];
if (state) applyPanelState(el, state);
},
beforeUnmount(el) {
el._stopPanelState?.();
},
+11 -36
View File
@@ -2,8 +2,7 @@
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import { vPanelState } from '../rail/panelState.js';
import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
import RailPanel from '../rail/RailPanel.vue';
import { vClassToggle } from '../rail/classToggle.js';
import { useCockpitStore } from '../stores/cockpit.js';
import { useBhoteKoshiPanelStore } from '../stores/bhoteKoshiPanel.js';
@@ -29,38 +28,15 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<template>
<!-- Global Context is deliberately independent from the left layer accordion. -->
<aside id="right-context-rail" class="ui-scroll" v-class-toggle="{ 'bhote-event-active': !!bhotePanel }">
<div id="weather-panel" v-panel-state="'weather-panel'" class="panel-collapsible" data-panel-id="weather-panel" hidden>
<div class="panel-glow"></div>
<div class="weather-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n>WEATHER</span>
<span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span>
<span class="panel-divider"></span>
<PanelCollapseButton target="weather-panel" :name="t('WEATHER')" sideways initial="▼" />
</div>
<div id="weather-panel-body" class="ui-scroll" data-rail-scroller></div>
</div>
</div>
<div id="recent-imagery-panel" v-panel-state="'recent-imagery-panel'" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
<div class="panel-glow"></div>
<div class="recent-imagery-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n>RECENT IMAGERY</span>
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span>
<span class="panel-divider"></span>
<PanelCollapseButton target="recent-imagery-panel" :name="t('RECENT IMAGERY')" sideways initial="▼" />
</div>
<div id="recent-imagery-panel-body" class="ui-scroll" data-rail-scroller></div>
</div>
</div>
<div id="global-context-panel" v-panel-state="'global-context-panel'" class="panel-collapsible collapsed" v-class-toggle="{ ...(ctx && { 'context-enabled': ctx.enabled }) }" :data-context-mode="ctx?.mode" data-panel-id="global-context-panel">
<div class="panel-glow"></div>
<div class="global-context-panel-inner ui-scroll" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n>CONTEXT</span>
<span class="panel-divider"></span>
<PanelCollapseButton target="global-context-panel" :name="t('CONTEXT')" sideways initial="▶" :initial-title="t('Expand panel')" />
</div>
<RailPanel id="weather-panel" hidden :title="t('WEATHER')" sideways rail-scroller>
<template #badge><span id="weather-panel-count" aria-label="Active weather products" data-i18n-aria-label>0</span></template>
<div id="weather-panel-body" class="ui-scroll" data-rail-scroller></div>
</RailPanel>
<RailPanel id="recent-imagery-panel" hidden :title="t('RECENT IMAGERY')" sideways rail-scroller>
<template #badge><span id="recent-imagery-panel-count" aria-label="Imagery days in the box" data-i18n-aria-label></span></template>
<div id="recent-imagery-panel-body" class="ui-scroll" data-rail-scroller></div>
</RailPanel>
<RailPanel id="global-context-panel" start-collapsed v-class-toggle="{ ...(ctx && { 'context-enabled': ctx.enabled }) }" :data-context-mode="ctx?.mode" :title="t('CONTEXT')" sideways scroll rail-scroller>
<div class="cyber-panel-body ui-scroll" data-rail-scroller>
<div class="global-context-modes" role="tablist" aria-label="Context mode" data-i18n-aria-label>
<button id="global-context-flights-btn" class="context-mode-button" v-class-toggle="{ active: ctx?.flightsActive }" type="button" role="tab" tabindex="0" :aria-selected="String(ctx?.flightsActive ?? false)" :aria-disabled="ctx && String(ctx.busy)" :aria-busy="ctx && String(ctx.busy)" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently." data-i18n-aria-label data-i18n-title>
@@ -95,8 +71,7 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
</section>
<RadioPanel />
</div>
</div>
</div>
</RailPanel>
<BhoteKoshiPanel />
</aside>
+6 -23
View File
@@ -1,8 +1,7 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../i18n/index.js';
import { vPanelState } from '../rail/panelState.js';
import PanelCollapseButton from '../rail/PanelCollapseButton.vue';
import RailPanel from '../rail/RailPanel.vue';
import { useSceneSharingStore } from '../stores/sceneSharing.js';
import { useSceneStore } from '../stores/scenes.js';
import { vClassToggle } from '../rail/classToggle.js';
@@ -38,17 +37,9 @@ async function importChosen(event) {
<template>
<div id="left-panel-stack" class="ui-scroll">
<!-- Data Layer Toggle Panel -->
<div id="data-panel" v-panel-state="'data-panel'" class="panel-collapsible collapsed active" data-panel-id="data-panel">
<div class="panel-glow"></div>
<div class="data-panel-inner">
<div class="panel-header">
<span class="panel-title" data-i18n>DATA LAYERS</span>
<span class="panel-divider"></span>
<PanelCollapseButton target="data-panel" :name="t('DATA LAYERS')" initial="+" />
</div>
<div id="data-toggles" class="data-toggle-list ui-scroll"></div>
</div>
</div>
<RailPanel id="data-panel" start-collapsed class="active" :title="t('DATA LAYERS')">
<div id="data-toggles" class="data-toggle-list ui-scroll"></div>
</RailPanel>
<!-- CCTV Coverage Panel -->
<CctvPanel />
@@ -57,14 +48,7 @@ async function importChosen(event) {
<StreetLevelPanel />
<!-- Scene Director Panel -->
<div id="scene-panel" v-panel-state="'scene-panel'" v-class-toggle="{ running: scene?.running, 'has-shots': scene?.hasShots }" class="panel-collapsible collapsed" data-panel-id="scene-panel">
<div class="panel-glow"></div>
<div class="scene-panel-inner ui-scroll">
<div class="panel-header">
<span class="panel-title" title="Create and edit scenes" data-i18n data-i18n-title>AUTHORING</span>
<span class="panel-divider"></span>
<PanelCollapseButton target="scene-panel" :name="t('AUTHORING')" initial="+" />
</div>
<RailPanel id="scene-panel" start-collapsed v-class-toggle="{ running: scene?.running, 'has-shots': scene?.hasShots }" :title="t('AUTHORING')" :hint="t('Create and edit scenes')" scroll>
<p class="scene-step-heading" data-i18n>SCENE</p>
<div class="scene-controls">
<select class="gev-field" id="scene-select" aria-label="Scene" data-i18n-aria-label :disabled="off('select')" @change="scenes.act('selectScene', $event.currentTarget.value)"><SceneOptions /></select>
@@ -99,8 +83,7 @@ async function importChosen(event) {
<div id="scene-progress-fill" :style="scene ? { width: `${scene.progress}%` } : undefined">{{ scene?.progress ?? 0 }}%</div>
</div>
<div id="scene-status" role="status" aria-live="polite">{{ scene?.status ?? t('Ready') }}</div>
</div>
</div>
</RailPanel>
</div>
<Teleport to="body">
+3 -12
View File
@@ -2,8 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import RailPanel from '../../rail/RailPanel.vue';
import CctvCalValue from '../../cctv/CctvCalValue.vue';
import CctvPicker from '../../cctv/CctvPicker.vue';
import { useCctvStore } from '../../stores/cctv.js';
@@ -28,14 +27,7 @@ const frameData = computed(() =>
</script>
<template>
<div id="cctv-panel" v-panel-state="'cctv-panel'" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
<div class="panel-glow"></div>
<div class="cctv-panel-inner ui-scroll" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n>CCTV</span>
<span class="panel-divider"></span>
<PanelCollapseButton target="cctv-panel" :name="t('CCTV')" sideways initial="+" />
</div>
<RailPanel id="cctv-panel" start-collapsed :title="t('CCTV')" sideways scroll rail-scroller>
<div class="cyber-panel-body ui-scroll" data-rail-scroller>
<div id="cctv-frame-wrap" :class="['cctv-frame-wrap', { loading: frame?.wrapLoading, 'has-frame': frame?.hasFrame }]">
<span class="cctv-frame-caption" aria-hidden="true" data-i18n>ACQUIRING FRAME</span>
@@ -84,6 +76,5 @@ const frameData = computed(() =>
<div class="cctv-summary-label" data-i18n>SCENE SUMMARY</div>
<div id="cctv-summary">{{ summary ?? t('Enable CCTV to start camera-linked intelligence summaries.') }}</div>
</div>
</div>
</div>
</RailPanel>
</template>
+2 -2
View File
@@ -48,7 +48,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
<div class="compact pp-header-row">
<span data-i18n class="pp-header-label">DISPLAY</span>
<span class="panel-divider" aria-hidden="true"></span>
<PanelCollapseButton class="pp-collapse-btn" target="pp-toggles" :name="t('DISPLAY')" sideways initial="◀" />
<PanelCollapseButton class="pp-collapse-btn" target="pp-toggles" :name="t('DISPLAY')" sideways start-collapsed />
</div>
<div class="pp-panel-body ui-scroll" data-rail-scroller>
<div class="pp-toggle-group">
@@ -128,7 +128,7 @@ const geoListed = computed(() => geoRows.value.length > 0);
<div class="param-panel-header">
<span class="param-panel-title" data-i18n>PARAMETERS</span>
<span class="param-panel-divider"></span>
<PanelCollapseButton id="param-panel-collapse-btn" target="param-slider-panel" initial="−" />
<PanelCollapseButton id="param-panel-collapse-btn" target="param-slider-panel" />
</div>
<div id="param-sliders"></div>
</div>
+4 -13
View File
@@ -2,8 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import RailPanel from '../../rail/RailPanel.vue';
import { vClassToggle } from '../../rail/classToggle.js';
import { vFieldValue } from '../../rail/fieldValue.js';
import { vRangeValue } from '../../rail/rangeValue.js';
@@ -21,7 +20,6 @@ const { view: radio, sdr } = storeToRefs(useRadioStore());
const sdrConnected = computed(() => sdr.value?.connectionActive ?? null);
// The collapse button keeps Radio's own wording, not "Expand RADIO".
const radioWords = {
initialLabel: t('Expand Radio section'),
expandLabel: t('Expand Radio section'),
collapseLabel: t('Collapse Radio section'),
expandTitle: t('Expand Radio'),
@@ -30,14 +28,8 @@ const radioWords = {
</script>
<template>
<section id="radio-panel" v-panel-state="'radio-panel'" class="panel-collapsible collapsed" v-class-toggle="{ ...(radio && { 'radio-enabled': radio.enabled, 'lifecycle-uncertain': radio.uncertain }), ...(sdrConnected !== null && { 'sdr-connected': sdrConnected }) }" data-panel-id="radio-panel" aria-label="Internet radio and local SDR" data-i18n-aria-label>
<div class="radio-panel-inner ui-scroll">
<div class="panel-header">
<span class="panel-title" data-i18n>RADIO</span>
<span id="radio-layer-state" class="radio-layer-state" :class="{ active: radio?.layerState.active }">{{ radio ? radio.layerState.text : t('OFF') }}</span>
<span class="panel-divider"></span>
<PanelCollapseButton target="radio-panel" initial="+" :initial-title="t('Expand Radio')" :words="radioWords" />
</div>
<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" 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>
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
@@ -69,6 +61,5 @@ const radioWords = {
</div>
<p class="radio-privacy" data-i18n>Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
<SdrCard />
</div>
</section>
</RailPanel>
</template>
+4 -13
View File
@@ -2,8 +2,7 @@
import { storeToRefs } from 'pinia';
import { computed, shallowRef, watch } from 'vue';
import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import RailPanel from '../../rail/RailPanel.vue';
import RailChips from '../../rail/RailChips.vue';
import { useStreetLevelStore } from '../../stores/streetLevel.js';
@@ -102,15 +101,8 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
</script>
<template>
<div id="street-level-panel" v-panel-state="'street-level-panel'" class="panel-collapsible collapsed" data-panel-id="street-level-panel" :data-sl-enabled="view ? String(view.enabled) : undefined">
<div class="panel-glow"></div>
<div class="street-level-panel-inner ui-scroll" data-rail-scroller>
<div class="panel-header">
<span class="panel-title" data-i18n>STREET LEVEL</span>
<span class="panel-divider"></span>
<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>
<PanelCollapseButton target="street-level-panel" :name="t('STREET LEVEL')" sideways initial="◀" :initial-title="t('Expand panel')" />
</div>
<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>
<div id="sl-body" class="sl-body">
<div id="sl-viewer-wrap" class="sl-viewer-wrap" :hidden="!viewer.open">
<div class="sl-viewer-bar">
@@ -168,6 +160,5 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" :hidden="!view?.nearest.visible" :title="nearest.title || undefined" :disabled="nearest.disabled" data-i18n-runtime>{{ nearest.label }}</button>
</div>
</div>
</div>
</div>
</RailPanel>
</template>