feat(ui): give every panel one scrollbar, select and slider skin

Every scrolling surface carries the kit's .ui-scroll, which
draws the thin themed scrollbar from --scroll-thumb and --scroll-track; the
Context panel and others drew the platform's light scrollbar because the
page declares no dark color-scheme. The per-panel scrollbar rules go; Cyber
sets the two tokens and Cockpit's HUD windows keep their accent tint.

Selects draw .gev-field's own chevron instead of the platform arrow, the
voice selects join .gev-field, number fields keep dark spinners, and the
Street Level range uses the shared slider in its green.
This commit is contained in:
Sameh Khamis committed 2026-10-31 17:28:46 -07:00
1 parent 1072df325e
commit 484243fe4b
42 files changed
+133 -152

No files matched your search

+10
View File
@@ -42,6 +42,16 @@ one-panel rule too. Share links continue to record which panels are open.
this repository and styled by its existing stylesheet and custom properties.
They contain no application state.
Controls share one skin, so every panel reads the same. Every scrolling
surface carries the kit's `.ui-scroll` (ui-vue's name), which draws the thin
themed scrollbar from `--scroll-thumb` and `--scroll-track`; the Cyber theme
sets those two tokens, and Cockpit's HUD windows keep their accent tint. A
surface without it would draw the platform's light scrollbar, because the
page declares no dark `color-scheme`. Selects and number fields are
`.gev-field`, which draws its own chevron in place of the platform's arrow
and keeps native spinners dark; ranges are `.gev-quantitative-slider`, with
a feature supplying only its accent.
## Phases
1. **Foundation.** Vue, Pinia and the Vite Vue plugin join the browser build
+2 -2
View File
@@ -95,9 +95,9 @@ test('Context async action buttons remain focused while busy', () => {
});
test('Context header holds only its title and collapse control', () => {
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body"/);
const panel = html.match(/<div id="global-context-panel"[\s\S]*?<div class="cyber-panel-body[ "]/);
assert.ok(panel, 'Global Context panel is missing');
const header = panel[0].match(/<div class="panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body"/);
const header = panel[0].match(/<div class="panel-header">([\s\S]*?)<\/div>\s*<div class="cyber-panel-body[ "]/);
assert.ok(header, 'Global Context header is missing');
const tags = [...header[1].matchAll(/<(\w+)\s+class="([^"]+)"/g)].map(([, tag, cls]) => `${tag}.${cls}`);
assert.deepEqual(tags, ['span.panel-title', 'span.panel-divider', 'button.panel-collapse-btn']);
+1 -1
View File
@@ -1054,7 +1054,7 @@ export function createBhoteKoshiEmbeddedMedia({
leader.innerHTML =
'<path pathLength="1" fill="none" stroke="rgba(147,213,228,.72)" stroke-width="1.5"/><circle r="4" fill="#6be8ff" stroke="#ffffff" stroke-width="1.5"/>';
const player = documentRef.createElement('div');
player.className = 'bhote-embedded-callout-player';
player.className = 'bhote-embedded-callout-player ui-scroll';
const footer = documentRef.createElement('footer');
const title = documentRef.createElement('strong');
title.textContent = bhoteEventText(
+1 -1
View File
@@ -348,7 +348,7 @@ test('camera updates retain a preload until the visible player loads', () => {
assert.equal(frame.parent, root, 'keep the preload while the visible player starts');
const card = root.children.find(node => node.tag === 'section');
const body = card.children.find(node => node.className === 'bhote-embedded-callout-body');
const player = body.children.find(node => node.className === 'bhote-embedded-callout-player');
const player = body.children.find(node => node.className.split(' ').includes('bhote-embedded-callout-player'));
player.children.find(node => node.tag === 'iframe').dispatchEvent(new Event('load'));
assert.equal(frame.parent, null, 'visible player readiness releases the preload');
media.destroy();
+1 -1
View File
@@ -32,7 +32,7 @@ test('Street Level is a collapsible right-rail panel that starts collapsed', ()
assert.doesNotMatch(html, /sl-mark|mly-mark/);
assert.match(
html,
/<div class="street-level-panel-inner" data-rail-scroller>/,
/<div class="street-level-panel-inner ui-scroll" data-rail-scroller>/,
);
assert.doesNotMatch(
html,
+6 -2
View File
@@ -22,10 +22,14 @@ test('CCTV and Context keep their headers outside Cyber-only scroll bodies', ()
new URL(`./views/${file}`, import.meta.url),
'utf8',
);
assert.match(markup, /class="cyber-panel-body" data-rail-scroller/);
// The body wears the shared scrollbar skin where Cyber makes it scroll.
assert.match(
markup,
/class="cyber-panel-body ui-scroll" data-rail-scroller/,
);
assert.ok(
markup.indexOf('class="panel-header"') <
markup.indexOf('class="cyber-panel-body"'),
markup.indexOf('class="cyber-panel-body ui-scroll"'),
);
}
const shared = readFileSync(
+1 -1
View File
@@ -431,7 +431,7 @@ export function mountLocalePicker({
heading.textContent = t('Interface language');
popup.append(heading);
const listbox = element(documentRef, 'ul', 'locale-picker-list');
const listbox = element(documentRef, 'ul', 'locale-picker-list ui-scroll');
listbox.id = ids.listbox;
listbox.setAttribute('role', 'listbox');
listbox.setAttribute('aria-labelledby', ids.heading);
+1 -1
View File
@@ -35,7 +35,7 @@ onUpdated(followCurrent);
<template>
<ol
ref="root"
class="data-row-list"
class="data-row-list ui-scroll"
:data-block-id="blockId"
:hidden="!list?.items?.length"
:aria-label="list?.ariaLabel || ''"
@@ -54,7 +54,10 @@ defineProps({
></div
><div
id="ri-strip"
:class="['ri-block ri-strip', { searching: view.strip.searching }]"
:class="[
'ri-block ri-strip ui-scroll',
{ searching: view.strip.searching },
]"
tabindex="0"
role="listbox"
aria-orientation="horizontal"
-2
View File
@@ -106,8 +106,6 @@
min-height: 0;
overflow: hidden;
background: #03080d;
scrollbar-width: thin;
scrollbar-color: rgba(107, 232, 255, 0.36) transparent;
}
.bhote-embedded-callout.is-youtube {
-21
View File
@@ -35,8 +35,6 @@
gap: 8px;
max-height: calc(100vh - 24px);
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.46) rgba(255, 255, 255, 0.06);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
box-shadow:
@@ -45,25 +43,6 @@
0 1px 0 rgba(255, 255, 255, 0.06) inset;
}
.cctv-panel-inner::-webkit-scrollbar {
width: 8px;
}
.cctv-panel-inner::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.04);
border-radius: 999px;
}
.cctv-panel-inner::-webkit-scrollbar-thumb {
background: linear-gradient(
180deg,
rgba(0, 212, 255, 0.6),
rgba(0, 212, 255, 0.28)
);
border: 1px solid rgba(0, 212, 255, 0.4);
border-radius: 999px;
}
.cctv-controls {
display: flex;
gap: 8px;
+8 -4
View File
@@ -41,8 +41,6 @@
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(165, 188, 199, 0.35) transparent;
}
:root[data-ui-theme='cyber'] .cctv-panel-inner > .cyber-panel-body {
@@ -68,8 +66,6 @@
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(165, 188, 199, 0.35) transparent;
}
:root[data-ui-theme='cyber'] #pp-toggles > .pp-panel-body > * {
@@ -82,6 +78,8 @@
:root[data-ui-theme='cyber'] {
color-scheme: dark;
--scroll-thumb: rgba(165, 188, 199, 0.35);
--scroll-track: transparent;
--cyber-red: #c45454;
--cyber-red-bright: #e16666;
--cyber-slate: #26343a;
@@ -796,6 +794,12 @@
accent-color: var(--cyber-red);
}
/* Street Level's range wears Cyber's slider accent like every other range. */
:root[data-ui-theme='cyber'] .sl-range {
--accent: inherit;
--accent-glow: inherit;
}
:root[data-ui-theme='cyber']
:where(button, a[href], input, select, textarea):focus-visible {
outline-color: var(--cyber-red-bright) !important;
+2 -11
View File
@@ -110,20 +110,11 @@
overflow-y: auto;
overscroll-behavior: contain;
scroll-behavior: smooth;
/* A visible, non-overlaying affordance that the list continues. */
scrollbar-width: thin;
scrollbar-color: rgba(54, 220, 255, 0.45) transparent;
/* A visible, non-overlaying affordance that the list continues; the
scrollbar itself is the kit's .ui-scroll. */
scrollbar-gutter: stable;
}
.first-run-choices::-webkit-scrollbar {
width: 6px;
}
.first-run-choices::-webkit-scrollbar-thumb {
background: rgba(54, 220, 255, 0.45);
border-radius: 3px;
}
.first-run-choices button {
display: grid;
grid-template-columns: 1.7rem 1fr 1.3rem;
+19
View File
@@ -10,6 +10,20 @@
font-family: var(--font-sans);
font-size: 12px;
outline: none;
/* Native number spinners and the open select list follow the dark skin. */
color-scheme: dark;
}
/* A select draws the field's own chevron, not the platform's arrow, so every
dropdown in the app reads the same. The open list stays native. */
select.gev-field {
-webkit-appearance: none;
appearance: none;
padding-right: 22px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='rgba(255,255,255,0.62)' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 10px 6px;
}
.gev-field:focus-visible {
@@ -30,6 +44,11 @@
padding: 2px 6px;
}
select.gev-field--compact {
padding-right: 18px;
background-position: right 6px center;
}
/* Compact inline editors share density and hide native number spinners.
Their containing feature sets width to fit the surrounding readout. */
.gev-field--inline {
+3
View File
@@ -9,6 +9,9 @@
--glass-border: rgba(255, 255, 255, 0.08);
--glass-border-hover: rgba(255, 255, 255, 0.15);
--accent: #00d4ff;
/* Every scrolling surface's scrollbar (the kit's .ui-scroll). */
--scroll-thumb: rgba(0, 212, 255, 0.46);
--scroll-track: rgba(255, 255, 255, 0.06);
--accent-dim: rgba(0, 212, 255, 0.15);
--accent-glow: rgba(0, 212, 255, 0.4);
--text-primary: #e8eaed;
+29
View File
@@ -1,6 +1,35 @@
/* Generic components in src/ui/kit. Names follow Halfpixel's ui-vue kit; the
surfaces use this application's glass, accent and type tokens. */
/* The thin themed scrollbar every scrolling surface wears, as ui-vue's
.ui-scroll. Without it a surface draws the platform's light scrollbar,
because the page declares no dark color-scheme. */
/* Cesium builds its credit list and the rail card builder its lines, so
those two surfaces are named here instead of carrying the class. */
.ui-scroll,
.cesium-credit-lightbox > ul,
.weather-card [data-line-id='status'] {
scrollbar-width: thin;
scrollbar-color: var(--scroll-thumb) var(--scroll-track);
}
.ui-scroll::-webkit-scrollbar,
.cesium-credit-lightbox > ul::-webkit-scrollbar,
.weather-card [data-line-id='status']::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.ui-scroll::-webkit-scrollbar-thumb,
.cesium-credit-lightbox > ul::-webkit-scrollbar-thumb,
.weather-card [data-line-id='status']::-webkit-scrollbar-thumb {
border-radius: 999px;
background: var(--scroll-thumb);
}
.ui-scroll::-webkit-scrollbar-track,
.cesium-credit-lightbox > ul::-webkit-scrollbar-track,
.weather-card [data-line-id='status']::-webkit-scrollbar-track {
background: var(--scroll-track);
}
.ui-icon {
display: inline-block;
line-height: 1;
-24
View File
@@ -84,29 +84,9 @@
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.5) rgba(255, 255, 255, 0.04);
scrollbar-gutter: stable;
}
.data-toggle-list::-webkit-scrollbar {
width: 6px;
}
.data-toggle-list::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.04);
border-radius: 999px;
}
.data-toggle-list::-webkit-scrollbar-thumb {
background: linear-gradient(
180deg,
rgba(0, 212, 255, 0.65),
rgba(0, 212, 255, 0.2)
);
border-radius: 999px;
}
.data-layer-group-heading {
margin: 8px 10px 2px;
padding-top: 12px;
@@ -311,8 +291,6 @@ body.recording-mode .gev-panel-rail {
#right-context-rail.layout-focus > #pp-toggles:not(.collapsed) {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.4) transparent;
}
#right-context-rail.layout-focus
> #weather-panel:not(.collapsed)
@@ -445,8 +423,6 @@ body.recording-mode .gev-panel-rail {
overflow-x: hidden;
overflow-y: auto;
padding-right: 3px;
scrollbar-color: rgba(34, 230, 230, 0.38) transparent;
scrollbar-width: thin;
}
#space-mission-panel-host .space-mission-roster-item {
position: relative;
+1 -1
View File
@@ -152,7 +152,7 @@
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--glass-border-hover) transparent;
scrollbar-color: var(--scroll-thumb) var(--scroll-track);
/* Keep an inset focus ring inside the intentional scrolling boundary. */
padding: 3px;
}
-10
View File
@@ -184,19 +184,9 @@
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(54, 220, 255, 0.45) transparent;
scrollbar-gutter: stable;
}
.key-setup-rows::-webkit-scrollbar {
width: 6px;
}
.key-setup-rows::-webkit-scrollbar-thumb {
background: rgba(54, 220, 255, 0.45);
border-radius: 3px;
}
.key-setup-row {
display: grid;
gap: 0.35rem;
+5 -6
View File
@@ -18,8 +18,6 @@
border-radius: 8px;
background: rgba(2, 15, 20, 0.42);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.46) rgba(255, 255, 255, 0.06);
}
#radio-panel .panel-header {
margin-bottom: 0;
@@ -1092,11 +1090,12 @@ html:not([lang='en']) .data-row-list-text {
.sdr-radio-card select,
.sdr-radio-card input[type='number'] {
min-height: 28px;
border: 1px solid rgba(255, 183, 82, 0.28);
border-radius: 5px;
color: #ffd79a;
background: rgba(38, 25, 8, 0.52);
font: 8px/1 var(--font-mono);
/* The shared field paints the surface (and the select's chevron). */
--gev-field-border-color: rgba(255, 183, 82, 0.28);
--gev-field-color: #ffd79a;
--gev-field-background: rgba(38, 25, 8, 0.52);
}
.sdr-radio-card button {
padding: 6px 8px;
@@ -1129,7 +1128,7 @@ html:not([lang='en']) .data-row-list-text {
.sdr-gain-row select {
flex: 1;
min-width: 0;
padding: 5px 7px;
padding: 5px 22px 5px 7px;
}
.sdr-adsb-stats {
display: grid;
-3
View File
@@ -46,8 +46,6 @@
max-height: calc(var(--right-stack-max-height, 50svh) - 64px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.46) transparent;
}
#recent-imagery-panel.collapsed #recent-imagery-panel-body,
#recent-imagery-panel.collapsed #recent-imagery-panel-count,
@@ -168,7 +166,6 @@
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x proximity;
scrollbar-width: thin;
outline: none;
border-radius: 6px;
}
-9
View File
@@ -265,15 +265,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
counter-reset: scene-shot;
}
.scene-shot-list::-webkit-scrollbar {
width: 6px;
}
.scene-shot-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.16);
border-radius: 999px;
}
.scene-shot-empty {
border: 1px dashed rgba(255, 255, 255, 0.14);
border-radius: 8px;
+3 -12
View File
@@ -39,8 +39,6 @@
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.03) inset,
0 1px 0 rgba(255, 255, 255, 0.06) inset;
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.46) rgba(255, 255, 255, 0.06);
}
/* Class-qualified, not id-qualified, so a theme's header rule still wins. */
@@ -236,17 +234,10 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
gap: 4px 10px;
}
/* The shared slider surface, in Street Level's green. */
.sl-range {
width: 100%;
min-width: 0;
margin: 0;
accent-color: var(--sl-green);
cursor: pointer;
}
.sl-range:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 3px;
--accent: var(--sl-green);
--accent-glow: color-mix(in srgb, var(--sl-green) 55%, transparent);
}
/* The value and its note share a line: ANY DATE (by upload date). */
+3 -3
View File
@@ -467,7 +467,8 @@ body.recording-mode .gev-pointer-reticle {
min-height: 2rem;
box-sizing: border-box;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.04);
/* background-color, so a .gev-field select keeps its chevron. */
background-color: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: 0.3rem;
font: inherit;
@@ -504,7 +505,6 @@ body.recording-mode .gev-pointer-reticle {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.55rem 0.75rem 3px 3px;
line-height: 1.45;
overflow-wrap: anywhere;
@@ -553,7 +553,7 @@ body.recording-mode .gev-pointer-reticle {
#gev-voice-timeout-select {
flex: 1 1 auto;
min-width: 0;
padding: 0 0.45rem;
padding: 0 22px 0 0.45rem;
}
#gev-voice-timeout-input {
flex: 0 0 4.5rem;
+2 -3
View File
@@ -180,12 +180,11 @@ html:not([lang='en'])
min-width: 0;
width: 100%;
min-height: 30px;
color: var(--text-primary);
background: #09121b;
border: 1px solid var(--glass-border);
border-radius: 4px;
font: inherit;
font-size: 0.65rem;
--gev-field-background: #09121b;
--gev-field-border-color: var(--glass-border);
}
#gev-voice-control:hover .gev-voice-provider-field,
#gev-voice-control:focus-within .gev-voice-provider-field {
-2
View File
@@ -42,8 +42,6 @@
max-height: calc(var(--right-stack-max-height, 50svh) - 64px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(0, 212, 255, 0.46) transparent;
}
#weather-panel.collapsed #weather-panel-body,
#weather-panel[hidden],
+1 -1
View File
@@ -22,7 +22,7 @@ function selectMapSource(chip) {
<template>
<!-- Unified bottom command dock: locations, voice (injected at runtime), visual presets -->
<div id="command-dock" aria-label="Navigation, voice, and visual preset controls" data-i18n-aria-label>
<div id="command-dock" class="ui-scroll" aria-label="Navigation, voice, and visual preset controls" data-i18n-aria-label>
<!-- Control panel -->
<div id="control-panel" class="panel-collapsible collapsed">
<div class="panel-glow"></div>
+5 -5
View File
@@ -14,7 +14,7 @@ const { view: ctx } = storeToRefs(useContextStore());
<template>
<!-- Global Context is deliberately independent from the left layer accordion. -->
<aside id="right-context-rail">
<aside id="right-context-rail" class="ui-scroll">
<div id="weather-panel" class="panel-collapsible" data-panel-id="weather-panel" hidden>
<div class="panel-glow"></div>
<div class="weather-panel-inner" data-rail-scroller>
@@ -24,7 +24,7 @@ const { view: ctx } = storeToRefs(useContextStore());
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="weather-panel" type="button" title="Collapse panel" data-i18n-title>▼</button>
</div>
<div id="weather-panel-body" data-rail-scroller></div>
<div id="weather-panel-body" class="ui-scroll" data-rail-scroller></div>
</div>
</div>
<div id="recent-imagery-panel" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
@@ -36,18 +36,18 @@ const { view: ctx } = storeToRefs(useContextStore());
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="recent-imagery-panel" type="button" title="Collapse panel" data-i18n-title>▼</button>
</div>
<div id="recent-imagery-panel-body" data-rail-scroller></div>
<div id="recent-imagery-panel-body" class="ui-scroll" data-rail-scroller></div>
</div>
</div>
<div id="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" data-rail-scroller>
<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>
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel" data-i18n-title>▶</button>
</div>
<div class="cyber-panel-body" data-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>
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
+3 -3
View File
@@ -6,7 +6,7 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue';
</script>
<template>
<div id="left-panel-stack">
<div id="left-panel-stack" class="ui-scroll">
<!-- Data Layer Toggle Panel -->
<div id="data-panel" class="panel-collapsible collapsed active" data-panel-id="data-panel">
<div class="panel-glow"></div>
@@ -16,7 +16,7 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue';
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="data-panel" title="Collapse panel" data-i18n-title>+</button>
</div>
<div id="data-toggles" class="data-toggle-list"></div>
<div id="data-toggles" class="data-toggle-list ui-scroll"></div>
</div>
</div>
@@ -29,7 +29,7 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue';
<!-- Scene Director Panel -->
<div id="scene-panel" class="panel-collapsible collapsed" data-panel-id="scene-panel">
<div class="panel-glow"></div>
<div class="scene-panel-inner">
<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>
+1 -1
View File
@@ -22,7 +22,7 @@
</header>
<h2 id="key-setup-title" data-i18n>Power up the globe</h2>
<p id="key-setup-description" data-i18n>The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.</p>
<div class="key-setup-rows" data-key-setup-rows></div>
<div class="key-setup-rows ui-scroll" data-key-setup-rows></div>
<div class="key-setup-footer">
<button type="button" class="key-setup-apply" data-key-setup-apply data-i18n>SAVE KEYS</button>
<span class="key-setup-hint" data-i18n>ESC to close</span>
+1 -1
View File
@@ -70,7 +70,7 @@ function onRenameKey(shot, event) {
</script>
<template>
<div id="scene-shot-list" class="scene-shot-list">
<div id="scene-shot-list" class="scene-shot-list ui-scroll">
<template v-if="attached">
<div v-if="!scene?.shots.length" class="scene-shot-empty">{{
t('No shots yet. Use CAPTURE SHOT to save current look.')
+1 -1
View File
@@ -14,7 +14,7 @@
like something you should not have access to → the sources are all
public → the data is real. Tile subcopy stays purely functional. -->
<p id="first-run-description" data-i18n-runtime>It feels like a forbidden cockpit—then you realize the sources are public and the data is real.</p>
<div class="first-run-choices">
<div class="first-run-choices ui-scroll">
<button type="button" data-first-run-choice="contacts">
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span><strong data-i18n>LIVE CONTACTS</strong><small data-i18n>Aircraft, vessels and nearby intelligence</small></span>
+1 -1
View File
@@ -4,5 +4,5 @@ defineProps({ names: { type: Object, required: true } });
</script>
<template>
<details class="military-awareness-names"><summary>{{ names.summary }}</summary><div><template v-for="(name, index) in names.names" :key="index"><br v-if="index">{{ name }}</template></div></details>
<details class="military-awareness-names"><summary>{{ names.summary }}</summary><div class="ui-scroll"><template v-for="(name, index) in names.names" :key="index"><br v-if="index">{{ name }}</template></div></details>
</template>
+1 -1
View File
@@ -41,7 +41,7 @@ onUpdated(() => {
</script>
<template>
<aside id="military-awareness-panel" ref="root">
<aside id="military-awareness-panel" class="ui-scroll" ref="root">
<div v-if="!view" class="military-awareness-standby">
<strong>{{ t('CONTACTS CONTEXT OFF') }}</strong>
<span>{{ t('SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY') }}</span>
+2 -2
View File
@@ -28,13 +28,13 @@ const frameData = computed(() =>
<template>
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
<div class="panel-glow"></div>
<div class="cctv-panel-inner" data-rail-scroller>
<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>
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel" data-i18n-title>+</button>
</div>
<div class="cyber-panel-body" data-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>
<img id="cctv-frame" alt="CCTV feed frame" :class="{ active: frame?.hasFrame }" :src="frame?.src || undefined" :hidden="live" v-bind="frameData" />
@@ -28,20 +28,20 @@ const radioLabel = computed(() =>
<template>
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls" data-i18n-aria-label>
<div class="cockpit-utility-control" v-class-toggle="{ 'is-expanded': open.display }">
<div class="cockpit-utility-control ui-scroll" v-class-toggle="{ 'is-expanded': open.display }">
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
<span data-i18n>DISPLAY</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" :aria-expanded="String(open.display)" aria-controls="cockpit-display-panel" :aria-label="displayLabel" :title="displayLabel">{{ open.display ? '▶' : '◀' }}</button>
</div>
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" :hidden="!open.display" data-i18n-aria-label>
<div id="cockpit-display-panel" class="cockpit-utility-popover ui-scroll" role="group" aria-label="Cockpit display options" :hidden="!open.display" data-i18n-aria-label>
<div class="cockpit-display-slot" data-cockpit-display-slot="hud"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="models3d"></div>
</div>
</div>
<div class="cockpit-utility-control" v-class-toggle="{ 'is-expanded': open.radio }">
<div class="cockpit-utility-control ui-scroll" v-class-toggle="{ 'is-expanded': open.radio }">
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
<span data-i18n>RADIO</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
+2 -2
View File
@@ -42,13 +42,13 @@ const geoListed = computed(() => geoRows.value.length > 0);
<template>
<!-- Post-processing toggles (top-right) -->
<div id="pp-toggles" class="panel-collapsible collapsed" data-panel-id="pp-toggles">
<div id="pp-toggles" class="panel-collapsible collapsed ui-scroll" data-panel-id="pp-toggles">
<div class="compact pp-header-row">
<span data-i18n class="pp-header-label">DISPLAY</span>
<span class="panel-divider" aria-hidden="true"></span>
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel" data-i18n-title>&#x25C0;</button>
</div>
<div class="pp-panel-body" data-rail-scroller>
<div class="pp-panel-body ui-scroll" data-rail-scroller>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: hud?.visible }" id="hud-toggle" title="Intelligence HUD (H)" data-i18n-title>
<span class="pp-icon">&#x25CE;</span>
+2 -2
View File
@@ -40,7 +40,7 @@ watch(
</div>
<section class="mission-data-section">
<h4>{{ t('PAYLOAD') }}</h4>
<div class="mission-table-scroll">
<div class="mission-table-scroll ui-scroll">
<table class="mission-data-table">
<thead><tr><th>{{ t('NAME') }}</th><th>{{ t('TYPE') }}</th><th>{{ t('DESTINATION') }}</th></tr></thead>
<tbody data-mission-payloads>
@@ -51,7 +51,7 @@ watch(
</section>
<section class="mission-data-section" data-mission-stages-section :hidden="m && !m.stages.length">
<h4>{{ t('STAGE / RE-ENTRY / RECOVERY') }}</h4>
<div class="mission-table-scroll">
<div class="mission-table-scroll ui-scroll">
<table class="mission-data-table">
<thead><tr><th>{{ t('STAGE') }}</th><th>{{ t('STATUS') }}</th><th>{{ t('FINAL POSITION') }}</th></tr></thead>
<tbody data-mission-stages>
+1 -1
View File
@@ -68,7 +68,7 @@ onUpdated(() => {
</div>
<output data-mission-roster-count>{{ roster ? roster.count : '—' }}</output>
</div>
<div class="space-mission-roster-list" data-mission-roster-list>
<div class="space-mission-roster-list ui-scroll" data-mission-roster-list>
<div v-if="!roster" class="space-mission-roster-empty">{{ t('LOADING 30-DAY MISSION INDEX') }}</div>
<div v-else-if="!roster.entries.length" class="space-mission-roster-empty">{{ roster.empty }}</div>
<button v-for="entry in roster?.entries" v-else :key="entry.id" type="button" class="space-mission-roster-item" :data-mission-roster-index="entry.index" :data-mission-roster-id="entry.id" :aria-label="entry.ariaLabel" @click="layer?.selectMissionAt(entry.index)" @mouseenter="enter(entry.index)" @mousemove="move(entry.index)" @mouseleave="layer?.rosterPointerLeave()" @focus="layer?.rosterFocus(entry.index)" @blur="blur"><span class="space-mission-roster-marker" :style="{ '--mission-roster-color': entry.color }" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>{{ entry.label }}</strong><small>{{ entry.provider }} · {{ entry.date }}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>
+1 -1
View File
@@ -21,7 +21,7 @@ const sdrConnected = computed(() => sdr.value?.connectionActive ?? null);
<template>
<section id="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">
<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>
+2 -2
View File
@@ -102,7 +102,7 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
<template>
<div id="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" data-rail-scroller>
<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>
@@ -153,7 +153,7 @@ const vThumb = { mounted: placeThumb, updated: placeThumb };
</div>
<div id="sl-since-row" class="sl-since" :hidden="!shows.since">
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
<input id="sl-since" v-thumb="view?.since" class="sl-range" type="range" min="0" max="8" step="1" value="0" :aria-valuetext="sinceLabel" aria-describedby="sl-since-label sl-since-note" @input="onSinceInput" @change="onSinceChange" />
<input id="sl-since" v-thumb="view?.since" class="sl-range gev-quantitative-slider" type="range" min="0" max="8" step="1" value="0" :aria-valuetext="sinceLabel" aria-describedby="sl-since-label sl-since-note" @input="onSinceInput" @change="onSinceChange" />
<span class="sl-since-value">
<output id="sl-since-label" class="sl-since-label" for="sl-since">{{ sinceLabel }}</output>
<small id="sl-since-note" class="sl-since-note" :hidden="!view?.sinceNote">{{ view?.sinceNote || '' }}</small>
+4 -4
View File
@@ -41,7 +41,7 @@ export function createVoiceControl({ reset = false, panel = false } = {}) {
root.innerHTML = `
<label id="gev-voice-provider-field" class="gev-voice-provider-field" hidden>
<span>${t('Voice provider')}</span>
<select id="gev-voice-provider" aria-label="${t('Voice provider')}" title="${t('Changing provider reconnects voice if it is on.')}"></select>
<select id="gev-voice-provider" class="gev-field" aria-label="${t('Voice provider')}" title="${t('Changing provider reconnects voice if it is on.')}"></select>
</label>
${panel ? `<button id="gev-voice-settings-button" class="gev-voice-settings-button" type="button" aria-label="${t('Voice Settings')}" aria-controls="gev-voice-panel" aria-expanded="false"><span class="material-symbols-outlined gev-voice-settings-icon" aria-hidden="true">settings</span></button>` : ''}
<div class="gev-voice-heading">
@@ -81,12 +81,12 @@ export function createVoiceControl({ reset = false, panel = false } = {}) {
</div>
<button id="gev-voice-settings-close" type="button" aria-label="${t('Close voice panel')}">×</button>
</div>
<div class="gev-voice-panel-body">
<div class="gev-voice-panel-body ui-scroll">
<section id="gev-voice-preview-panel" role="tabpanel" aria-labelledby="gev-voice-preview-tab">
<p id="gev-voice-preview-empty">${t('Voice replies appear here.')}</p>`
: ''
}
<section id="gev-voice-card" class="gev-voice-card" hidden aria-label="${t('Voice assistant')}">
<section id="gev-voice-card" class="gev-voice-card ui-scroll" hidden aria-label="${t('Voice assistant')}">
<div class="gev-voice-card-header">
<span class="gev-voice-card-kicker">${t('VOICE')}</span>
<span id="gev-voice-card-phase" class="gev-voice-card-phase"></span>
@@ -117,7 +117,7 @@ export function createVoiceControl({ reset = false, panel = false } = {}) {
<div class="gev-voice-timeout-group">
<label class="gev-voice-preference gev-voice-setting-title" for="gev-voice-timeout-select">${t('Voice inactivity timeout')}</label>
<div class="gev-voice-timeout-controls">
<select id="gev-voice-timeout-select" aria-describedby="gev-voice-timeout-note gev-voice-timeout-status">
<select id="gev-voice-timeout-select" class="gev-field" aria-describedby="gev-voice-timeout-note gev-voice-timeout-status">
<option value="3">3 ${t('Minutes')}</option><option value="5" selected>5 ${t('Minutes')}</option><option value="10">10 ${t('Minutes')}</option><option value="15">15 ${t('Minutes')}</option>
<option value="custom">${t('Custom')}</option><option value="0">${t('None')}</option>
</select>