merge main bb6f501 into feat/voice-outlines

This commit is contained in:
manjunath committed 2026-10-30 22:15:41 +05:30
commit 24228b145e
11 files changed
+436 -48

No files matched your search

+250
View File
@@ -0,0 +1,250 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
const css = readStylesheet(new URL('../../style.css', import.meta.url));
const clean = (selector) =>
selector
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\s+/g, ' ')
.trim();
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(
([, selector, body]) => ({
selector: clean(selector),
body: body.replace(/\/\*[\s\S]*?\*\//g, ''),
}),
);
const railRules = rules.filter(({ selector }) =>
selector.includes('#left-panel-stack, #right-context-rail'),
);
const hoverBlock =
css.match(
/@media \(hover: hover\) and \(pointer: fine\) \{((?:[^{}]*\{[^{}]*\})*)\s*\}/g,
) || [];
test('a side panel lights up on hover only where a pointer can hover', () => {
const lit = railRules.filter(({ selector }) => selector.includes(':hover'));
assert.ok(lit.length > 0, 'the rail hover rule is missing');
for (const { selector } of lit) {
assert.ok(
hoverBlock.some((block) => clean(block).includes(selector)),
`${selector} must sit inside @media (hover: hover) and (pointer: fine)`,
);
}
});
test('hover matches the dock tray hover, not its pinned look', () => {
const [hover] = railRules.filter(({ selector }) =>
selector.endsWith(':hover'),
);
assert.ok(hover, 'the rail surface hover rule is missing');
assert.match(hover.body, /border-color:\s*var\(--glass-border-hover\)/);
assert.doesNotMatch(hover.body, /box-shadow/);
assert.match(hover.body, /--rail-label-hover:\s*var\(--accent\)/);
// A low-specificity list: no ID or state selector inside it lifts the rule.
assert.match(hover.selector, /:where\(/);
});
test('hover recolours only the hovered panel header, never nested sections', () => {
for (const { selector } of railRules) {
assert.ok(
!/\.radio-panel-inner/.test(selector),
`Radio sits inside Context and must not light up as its own panel: ${selector}`,
);
assert.doesNotMatch(
selector,
/:hover\s+:is\(/,
'a descendant colour list would reach labels in the panel body',
);
}
const colour = railRules.find(
({ body }) => /--rail-label-hover/.test(body) && /color:/.test(body),
);
assert.ok(colour, 'the rail title colour rule is missing');
assert.match(colour.selector, /\.panel-header > \.panel-title/);
const radio = rules.find(({ selector }) => selector === '#radio-panel');
assert.match(radio?.body || '', /--rail-label-hover:\s*initial/);
});
test('no later rule dims the open Context title below its siblings', () => {
const dimmed = rules.filter(
({ selector, body }) =>
/#global-context-panel:not\(\.collapsed\) \.panel-title$/.test(
selector,
) && /color:/.test(body),
);
assert.deepEqual(dimmed, []);
});
test('collapsed launchers on both rails keep titles inside the card', () => {
const ellipsis = railRules.find(
({ selector, body }) =>
selector.endsWith(
':is(.panel-header > .panel-title, .pp-header-label)',
) && /text-overflow:\s*ellipsis/.test(body),
);
assert.ok(
ellipsis,
'rail titles must end in an ellipsis rather than overflow',
);
assert.match(ellipsis.body, /min-width:\s*0/);
const translated = rules.find(
({ selector }) =>
selector.startsWith("html:not([lang='en'])") &&
selector.includes('#left-panel-stack, #right-context-rail') &&
selector.includes('.panel-collapsible.collapsed'),
);
assert.ok(translated, 'translated collapsed titles must fit on both rails');
assert.match(translated.body, /font-size:\s*8px/);
// Chinese and Japanese titles fit at full size; 8px would put them under
// the 9px CJK floor in cjk.css.
assert.match(translated.selector, /:not\(:lang\(zh\)\):not\(:lang\(ja\)\)/);
// Only the rail's own launchers: Radio, nested in Context, keeps its type.
assert.match(
translated.selector,
/#right-context-rail\) > \.panel-collapsible\.collapsed > \[class\$='-panel-inner'\] > \.panel-header > \.panel-title$/,
);
});
test('a collapsed launcher drops its divider so English titles fit whole', () => {
const launcher = (tail) =>
railRules.filter(({ selector }) =>
selector
.split(/,(?![^(]*\))/)
.some((part) =>
part
.trim()
.endsWith(
`.collapsed > [class$='-panel-inner'] > .panel-header${tail}`,
),
),
);
const [header] = launcher('');
const [divider] = launcher(' > .panel-divider');
const [toggle] = launcher(' > .panel-collapse-btn');
assert.match(header?.body || '', /gap:\s*8px/, 'the launcher header gap');
assert.match(divider?.body || '', /display:\s*none/, 'the launcher divider');
assert.match(
toggle?.body || '',
/margin-left:\s*auto/,
'the launcher toggle',
);
const button = rules.find(
({ selector }) => selector === '.panel-collapse-btn',
);
assert.match(
button?.body || '',
/flex-shrink:\s*0/,
'a long title must not squeeze the toggle',
);
});
test('the default theme geometry change leaves cyber gutters alone', () => {
for (const surface of ['.data-panel-inner', '.global-context-panel-inner']) {
const base = rules.find(
({ selector, body }) => selector === surface && /padding:/.test(body),
);
assert.match(base?.body || '', /padding:\s*14px 16px 12px/, surface);
}
const display = rules.find(
({ selector }) =>
selector === '#pp-toggles > .pp-header-row .pp-collapse-btn',
);
assert.match(display?.body || '', /font-size:\s*12px/);
});
test('rail titles and dock labels share one set of label tokens', () => {
// command-dock-trays.css sets the dock's label type; earlier dock rules
// for the same selector are overridden by it.
const dock = rules.find(
({ selector, body }) =>
selector ===
'#command-dock .panel-title, #command-dock .location-toolbar-label' &&
/--dock-label-/.test(body),
);
const rail = railRules.find(({ body }) =>
/--dock-label-font-size/.test(body),
);
assert.match(dock?.body || '', /font-size:\s*var\(--dock-label-font-size\)/);
assert.match(
dock?.body || '',
/letter-spacing:\s*var\(--dock-label-letter-spacing\)/,
);
// The dock's rem type, capped at its 16px-root size: a collapsed launcher
// is a fixed 176px, so a larger browser font must not cut titles short.
assert.match(
rail?.body || '',
/font-size:\s*min\(var\(--dock-label-font-size\),\s*9\.28px\)/,
);
assert.match(
rail?.body || '',
/letter-spacing:\s*min\(var\(--dock-label-letter-spacing\),\s*2\.08px\)/,
);
});
test('default theme overrides never reach the cyber theme', () => {
const notCyber = ":root:not([data-ui-theme='cyber'])";
const padding = rules.find(
({ selector, body }) =>
selector.endsWith(
':is(.data-panel-inner, .global-context-panel-inner)',
) && /padding-left:\s*14px/.test(body),
);
assert.ok(
padding?.selector.startsWith(notCyber),
'the 14px gutters must skip cyber',
);
const display = rules.find(
({ selector, body }) =>
selector.endsWith('#pp-toggles > .pp-header-row .pp-collapse-btn') &&
/font-size:\s*13px/.test(body),
);
assert.ok(
display?.selector.startsWith(notCyber),
"Display's 22px toggle must skip cyber",
);
// Cyber draws its title marker at left: -8px; a clip on the title would cut it.
for (const { selector, body } of rules) {
if (!/\.panel-title/.test(selector) || !/overflow:\s*hidden/.test(body))
continue;
if (!/#left-panel-stack|#right-context-rail/.test(selector)) continue;
assert.ok(
selector.startsWith(notCyber),
`${selector} clips the cyber title marker`,
);
}
});
test('launcher compaction and header colour stay on the rail panels themselves', () => {
// The divider, gap and toggle rules; the translated type rule is checked
// above.
const compact = railRules.filter(
({ selector }) =>
/\.collapsed > \[class\$='-panel-inner'\] > \.panel-header/.test(
selector,
) && !selector.startsWith('html:'),
);
assert.equal(compact.length, 3);
for (const { selector } of compact) {
// Each launcher is a direct child of its rail: Radio, nested inside
// Context, is not a launcher. The header must not itself be required to
// be a rail child.
assert.match(
selector,
/#right-context-rail\) > \.panel-collapsible\.collapsed > \[class\$='-panel-inner'\] > \.panel-header/,
);
assert.match(
selector,
/#right-context-rail\) > #pp-toggles\.collapsed > \.pp-header-row/,
);
}
const colour = railRules.find(({ body }) =>
/--rail-label-hover, /.test(body),
);
assert.match(
colour.selector,
/:is\(\.panel-header, \.pp-header-row\) > \.panel-collapse-btn/,
);
assert.doesNotMatch(colour.selector, /, \.panel-collapse-btn\)/);
});
+4 -4
View File
@@ -17,10 +17,10 @@
display: inline-flex;
align-items: center;
gap: 0.42rem;
color: rgba(206, 232, 239, 0.72);
font-size: 0.58rem;
font-weight: 500;
letter-spacing: 0.13rem;
color: var(--dock-label-color);
font-size: var(--dock-label-font-size);
font-weight: var(--dock-label-font-weight);
letter-spacing: var(--dock-label-letter-spacing);
}
#command-dock .dock-label-icon {
+157 -15
View File
@@ -81,16 +81,104 @@
white-space: nowrap;
}
/* A longer translated title in a collapsed rail launcher tightens its
tracking rather than pushing the disclosure button out of the card. */
html:not([lang='en'])
#right-context-rail
.panel-collapsible.collapsed
.panel-title {
/* Side panel titles read like the command dock's labels: one type for every
panel, collapsed or open, and bright enough to read over the globe. The
dock's type is in rem, but a collapsed launcher is a fixed 176px, so the
rail caps it at its 16px-root size: a larger browser font must not cut
Recent Imagery short. */
:is(#left-panel-stack, #right-context-rail)
:is(.panel-header > .panel-title, .pp-header-label) {
font-family: var(--font-mono);
font-size: min(var(--dock-label-font-size), 9.28px);
font-weight: var(--dock-label-font-weight);
letter-spacing: min(var(--dock-label-letter-spacing), 2.08px);
text-transform: uppercase;
}
/* A hovered panel sets --rail-label-hover on itself (see below); its own
header picks it up. Radio sits inside Context and keeps its own colour. */
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
:is(
.panel-header > .panel-title,
.pp-header-label,
:is(.panel-header, .pp-header-row) > .panel-collapse-btn
) {
color: var(--rail-label-hover, var(--dock-label-color));
}
#radio-panel {
--rail-label-hover: initial;
}
/* A title never pushes its toggle out of the card: one that does not fit
ends in an ellipsis. Cyber draws a marker left of the title, which a clip
would cut, and keeps its own header layout. */
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
:is(.panel-header > .panel-title, .pp-header-label) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 1.5px;
}
/* A collapsed launcher is 176px wide and its divider has no room to draw,
so the header drops it and packs closer. English titles such as Recent
Imagery then fit whole. Only the rail's own launchers: Radio, nested in
Context, keeps its divider. */
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
> .panel-collapsible.collapsed
> [class$='-panel-inner']
> .panel-header,
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
> #pp-toggles.collapsed
> .pp-header-row {
gap: 8px;
}
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
> .panel-collapsible.collapsed
> [class$='-panel-inner']
> .panel-header
> .panel-divider,
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
> #pp-toggles.collapsed
> .pp-header-row
> .panel-divider {
display: none;
}
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
> .panel-collapsible.collapsed
> [class$='-panel-inner']
> .panel-header
> .panel-collapse-btn,
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
> #pp-toggles.collapsed
> .pp-header-row
> .panel-collapse-btn {
margin-left: auto;
}
/* Translated titles run longer (Aktuelle Bilddaten), so a collapsed rail
launcher sets them in compact type rather than pushing the disclosure
button out of the card or cutting the word short. Chinese and Japanese
titles fit at full size and stay at or above their 9px floor (cjk.css);
Radio, nested in Context, is not a launcher. */
html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
:is(#left-panel-stack, #right-context-rail)
> .panel-collapsible.collapsed
> [class$='-panel-inner']
> .panel-header
> .panel-title {
font-size: 8px;
letter-spacing: 1px;
}
.panel-divider {
@@ -109,6 +197,8 @@ html:not([lang='en'])
font-family: var(--font-mono);
font-size: 13px;
line-height: 1;
/* A long title or a header action must not squeeze the toggle. */
flex-shrink: 0;
cursor: pointer;
transition:
background-color var(--transition-fast),
@@ -116,17 +206,50 @@ html:not([lang='en'])
color var(--transition-fast);
}
/* A long translated title must not squeeze the toggle into a sliver. */
html:not([lang='en']) .panel-collapse-btn {
flex-shrink: 0;
}
.panel-collapse-btn:hover {
border-color: var(--glass-border-hover);
color: var(--text-primary);
background: rgba(255, 255, 255, 0.07);
}
/* A side panel answers the pointer as the command dock's trays do: a
brighter edge and an accent title and toggle. Touch screens have no hover,
so a tap must not leave a panel lit. */
@media (hover: hover) and (pointer: fine) {
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
:where(
.data-panel-inner,
.scene-panel-inner,
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.global-context-panel-inner,
#pp-toggles:not(.collapsed),
#pp-toggles.collapsed > .pp-header-row
) {
transition: border-color var(--transition-fast);
}
:root:not([data-ui-theme='cyber'])
:is(#left-panel-stack, #right-context-rail)
:where(
.data-panel-inner,
.scene-panel-inner,
.cctv-panel-inner,
.weather-panel-inner,
.recent-imagery-panel-inner,
.street-level-panel-inner,
.global-context-panel-inner,
#pp-toggles:not(.collapsed),
#pp-toggles.collapsed > .pp-header-row
):hover {
--rail-label-hover: var(--accent);
border-color: var(--glass-border-hover);
}
}
/* ── Button Grid ──────────────────────────────── */
.button-grid {
display: flex;
@@ -431,12 +554,21 @@ html:not([lang='en']) .panel-collapse-btn {
}
#pp-toggles .pp-header-label {
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.4px;
color: var(--text-dim);
}
/* Display's title and toggle sit where the other panels' do: 14px of panel
padding plus a 4px header inset. The cyber theme sets its own insets. */
:root:not([data-ui-theme='cyber']) #pp-toggles.collapsed > .pp-header-row {
padding: 8px 18px;
}
:root:not([data-ui-theme='cyber'])
#pp-toggles:not(.collapsed)
> .pp-header-row {
padding: 0 10px;
}
#pp-toggles > .pp-header-row .pp-collapse-btn {
width: 20px;
height: 20px;
@@ -445,6 +577,16 @@ html:not([lang='en']) .panel-collapse-btn {
padding: 0;
}
/* In the default theme Display's toggle matches the other panels' 22px. */
:root:not([data-ui-theme='cyber'])
#pp-toggles
> .pp-header-row
.pp-collapse-btn {
width: 22px;
height: 22px;
font-size: 13px;
}
#pp-toggles.collapsed {
gap: 0;
}
+5
View File
@@ -21,6 +21,11 @@
--panel-radius: 16px;
--btn-radius: 10px;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
/* Label type shared by the command dock and the side panel titles. */
--dock-label-color: rgba(206, 232, 239, 0.72);
--dock-label-font-size: 0.58rem;
--dock-label-font-weight: 500;
--dock-label-letter-spacing: 0.13rem;
--transition-smooth: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--left-stack-x: 52px;
--right-rail-x: 52px;
+10 -8
View File
@@ -249,7 +249,7 @@
box-sizing: border-box;
justify-content: center;
overflow: visible;
padding: 8px 10px 8px 14px;
padding: 8px 14px;
}
#right-context-rail #global-context-panel.collapsed .panel-header {
margin: 0;
@@ -989,13 +989,6 @@
#global-context-panel .panel-glow {
display: none;
}
/* Expanded, its section titles (Context, Radio) stay legible over bright
imagery. Cyber keeps its own red titles; collapsed launchers match siblings. */
:root:not([data-ui-theme='cyber'])
#global-context-panel:not(.collapsed)
.panel-title {
color: var(--text-secondary);
}
.global-context-panel-inner {
position: relative;
display: flex;
@@ -1011,6 +1004,15 @@
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
}
/* Data Layers and Context share the 14px side padding of the other default
theme panels, so every title and toggle sits 19px from its edge. Cyber
keeps its own gutters. */
:root:not([data-ui-theme='cyber'])
:is(.data-panel-inner, .global-context-panel-inner) {
padding-right: 14px;
padding-left: 14px;
}
.global-context-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
-4
View File
@@ -56,10 +56,6 @@
#recent-imagery-split-line[hidden] {
display: none !important;
}
#recent-imagery-panel.collapsed .panel-title {
font-size: 8px;
letter-spacing: 1.8px;
}
#recent-imagery-panel.collapsed .panel-header {
margin-bottom: 2px;
}
-8
View File
@@ -378,14 +378,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
width: 100%;
}
#data-panel.collapsed .panel-title,
#cctv-panel.collapsed .panel-title,
#radio-panel.collapsed .panel-title,
#scene-panel.collapsed .panel-title {
font-size: 8px;
letter-spacing: 1.8px;
}
#control-panel.collapsed .panel-inner,
#data-panel.collapsed .data-panel-inner,
#cctv-panel.collapsed .cctv-panel-inner,
-4
View File
@@ -50,10 +50,6 @@
.weather-readout[hidden] {
display: none !important;
}
#weather-panel.collapsed .panel-title {
font-size: 8px;
letter-spacing: 1.8px;
}
#weather-panel.collapsed .panel-header {
margin-bottom: 2px;
}