feat: make the edge-lit LCD the site's nav bar

nav-bar.css (after gantry.css) draws the bargraph, caret, indicator,
chapter drop and ripple, route glints, brake, self-test and shimmer in
LCD pixels under gantry's mask, and masks the wave to the lower bezel.
Rules hang off html:root, the same weight the preview attribute had, so
they still beat gantry.css.

useNavBarSignals mirrors the speedometer, drive controls and chapter
onto the bar, writing only on change; the per-frame speed pair goes on
the LCD and wave rather than the nav, which keeps the per-frame restyle
small. SiteNavigation renders just the parts the bar uses.
This commit is contained in:
2026-10-13 06:48:33 +03:00
parent 2be15980ee
commit a505a31f31
5 changed files with 711 additions and 0 deletions

View File

@@ -13,6 +13,7 @@ import './styles/title-navigation.css';
import './styles/wayfinding.css'; import './styles/wayfinding.css';
import './styles/route.css'; import './styles/route.css';
import './styles/gantry.css'; import './styles/gantry.css';
import './styles/nav-bar.css';
import './styles/cards.css'; import './styles/cards.css';
import './styles/maintenance.css'; import './styles/maintenance.css';
import './styles/print.css'; import './styles/print.css';

View File

@@ -7,6 +7,7 @@ import type { BeamSample } from "@/journey/headlight-beam";
import type { JourneySectionsBinding } from "@/composables/useJourneySections"; import type { JourneySectionsBinding } from "@/composables/useJourneySections";
import { useSpeedometer } from "@/composables/useSpeedometer"; import { useSpeedometer } from "@/composables/useSpeedometer";
import { SIGN_ACCENTS } from "@/journey/sign-routes"; import { SIGN_ACCENTS } from "@/journey/sign-routes";
import { useNavBarSignals } from "@/composables/useNavBarSignals";
const props = defineProps<{ const props = defineProps<{
items: ReadonlyArray<{ id: SectionId; label: string }>; items: ReadonlyArray<{ id: SectionId; label: string }>;
@@ -25,6 +26,14 @@ const props = defineProps<{
animated?: boolean; animated?: boolean;
}>(); }>();
/** The bar's LCD layers (styles/nav-bar.css), in paint order: layers on the
* same z-index stack in this order. */
const LCD_PARTS = [
"fill-l", "fill-r", "unlit-l", "unlit-r", "ghost",
"glint", "glint-2", "fibre-glint", "fibre-glint-2",
"caret", "indicator", "test", "brake", "shimmer", "drop",
] as const;
const navigationLabels: Readonly<Record<SectionId, string>> = { const navigationLabels: Readonly<Record<SectionId, string>> = {
start: "Start", start: "Start",
profile: "Profile", profile: "Profile",
@@ -159,6 +168,7 @@ const speedometer = useSpeedometer({
return `${route} \u00b7 ${exit} \u00b7 ${ahead}`; return `${route} \u00b7 ${exit} \u00b7 ${ahead}`;
}, },
}); });
useNavBarSignals(speedo, activeIndex);
const speedoLabel = computed(() => (speedometer.units.value === "mph" const speedoLabel = computed(() => (speedometer.units.value === "mph"
? "Speedometer in miles per hour. Switch to kilometres per hour" ? "Speedometer in miles per hour. Switch to kilometres per hour"
: "Speedometer in kilometres per hour. Switch to miles per hour")); : "Speedometer in kilometres per hour. Switch to miles per hour"));
@@ -172,6 +182,8 @@ const navigate = (event: MouseEvent): void => {
<template> <template>
<nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale, '--route-index': activeIndex, '--route-count': items.length, '--sign-accent': SIGN_ACCENTS[items[activeIndex]?.id ?? 'start'] }"> <nav class="site-navigation" aria-label="Portfolio sections" :data-enhanced="enhanced" :data-docked="docked" :data-motion="allowMotion === true" :style="{ '--nav-rest-scale': restScale, '--route-index': activeIndex, '--route-count': items.length, '--sign-accent': SIGN_ACCENTS[items[activeIndex]?.id ?? 'start'] }">
<span class="nav-lcd" aria-hidden="true"><i v-for="part in LCD_PARTS" :key="part" :class="`nav-lcd__${part}`"></i></span>
<span class="nav-wave" aria-hidden="true"></span>
<button <button
ref="speedo" ref="speedo"
type="button" type="button"

View File

@@ -0,0 +1,208 @@
/**
* THE NAV BAR'S SIGNALS. The bar's lights (styles/nav-bar.css) and its wave
* (journey/nav-wave.ts) read the drive from custom properties and
* attributes on .site-navigation, so a static preview page can fake every
* one of them. This module only copies data the app already computes; it
* adds no frame loop:
*
* --nav-speed, --nav-throttle 0..1, mirrored from the speedometer's own
* --speed/--throttle writes (>= 1280 px).
* These change every frame while driving,
* so they go on .nav-lcd and .nav-wave,
* their only readers: on the nav they would
* restyle everything in it each frame
* (about 2 ms, against 0.7 ms on the LCD)
* data-nav-engine off | on | parked (the speedometer's)
* data-nav-brake, data-nav-jump present while braking / on an explicit drive
* --nav-here-x, --nav-next-x, --nav-from-x
* centre of the current, next and previous
* exit, in nav px
* data-nav-shift next | prev for SHIFT_MS after a chapter change
* data-nav-audio present while the soundtrack plays unmuted
* data-nav-hue 1 | 2 | 3: how fast the wave's hue cycle
* runs, from --nav-speed with hysteresis (on
* at 0.80 / 0.88 / 0.95, off below 0.76 /
* 0.85 / 0.92). It climbs at once and steps
* down one tier per 0.7 s; then 0 for 4 s
* while the colour settles back; absent
* otherwise
*
* Once the bar is mounted it also loads the wave (a lazy chunk). Reduced
* motion and forced colours are the stylesheet's and the wave's to handle:
* the signals still describe the drive, and the layers that read them
* either stand still or are not drawn.
*/
import { onBeforeUnmount, onMounted, watch, type Ref } from 'vue';
import type { NavWave } from '@/journey/nav-wave';
/** Long enough for the longest one-shot (the drop's ghost, 1.5 s). */
const SHIFT_MS = 1600;
const VARS = ['--nav-here-x', '--nav-next-x', '--nav-from-x'];
const LIVE_VARS = ['--nav-speed', '--nav-throttle'];
const ATTRS = ['data-nav-engine', 'data-nav-brake', 'data-nav-jump', 'data-nav-shift', 'data-nav-audio', 'data-nav-hue'];
/** The hue cycle: the speed each tier comes on at, and drops out below. */
const HUE_ON = [0.8, 0.88, 0.95];
const HUE_OFF = [0.76, 0.85, 0.92];
/** The least time on a tier before the next step down, and how long '0'
* (the colour settling back) lasts. */
const HUE_STEP_MS = 700;
const HUE_SETTLE_MS = 4000;
/** The hue tier (0..3) for a speed, given the current one: hysteresis, so
* a needle hovering on a threshold does not flicker the tier. */
export function hueTier(tier: number, speed: number): number {
let next = tier;
while (next < 3 && speed >= HUE_ON[next]!) next += 1;
while (next > 0 && speed < HUE_OFF[next - 1]!) next -= 1;
return next;
}
export function useNavBarSignals(anchor: Ref<HTMLElement | null>, activeIndex: Ref<number>): void {
let nav: HTMLElement | null = null;
/** Where the per-frame pair goes: the bar's LCD and wave. */
let live: HTMLElement[] = [];
let tier = 0;
let speedNow = 0;
let tierAt = 0;
let hueTimer = 0;
let shiftTimer = 0;
let shiftFrame = 0;
let wave: NavWave | null = null;
let disposed = false;
/** Last value written per property or attribute (null: absent), so the
* speedometer's per-frame writes only touch the nav when a value changes. */
const written = new Map<string, string | null>();
let speedObserver: MutationObserver | null = null;
let controlsObserver: MutationObserver | null = null;
let resizeObserver: ResizeObserver | null = null;
const set = (name: string, value: string): void => {
if (written.get(name) === value) return;
written.set(name, value);
nav?.style.setProperty(name, value);
};
const setLive = (name: string, value: string): void => {
if (written.get(name) === value) return;
written.set(name, value);
for (const element of live) element.style.setProperty(name, value);
};
const attr = (name: string, value: string | null): void => {
if (written.get(name) === value) return;
written.set(name, value);
if (value === null) nav?.removeAttribute(name);
else nav?.setAttribute(name, value);
};
const measure = (from: number): void => {
if (!nav) return;
const box = nav.getBoundingClientRect();
if (box.width === 0) return;
// The nav scales at rest (--nav-rest-scale): measure in its own px.
const scale = nav.offsetWidth / box.width;
const items = nav.querySelectorAll('li');
const centre = (index: number): number => {
const rect = items[Math.max(0, Math.min(items.length - 1, index))]?.getBoundingClientRect();
return rect === undefined ? 0 : (rect.left - box.left + rect.width / 2) * scale;
};
const here = centre(activeIndex.value);
set('--nav-here-x', `${here.toFixed(1)}px`);
set('--nav-next-x', `${centre(activeIndex.value + 1).toFixed(1)}px`);
set('--nav-from-x', `${(from < 0 ? here : centre(from)).toFixed(1)}px`);
};
// The hue tier. Up at once; down one tier per HUE_STEP_MS (a timer
// carries it on once the needle has settled and stopped writing), then
// '0' for HUE_SETTLE_MS, then the attribute goes.
const stepHue = (): void => {
window.clearTimeout(hueTimer);
const wanted = hueTier(tier, speedNow);
if (wanted === tier) return;
const now = performance.now();
if (wanted < tier && now - tierAt < HUE_STEP_MS) {
hueTimer = window.setTimeout(stepHue, tierAt + HUE_STEP_MS - now);
return;
}
tier = wanted > tier ? wanted : tier - 1;
tierAt = now;
attr('data-nav-hue', String(tier));
if (tier > 0) hueTimer = window.setTimeout(stepHue, HUE_STEP_MS);
else hueTimer = window.setTimeout(() => { if (tier === 0) attr('data-nav-hue', null); }, HUE_SETTLE_MS);
};
const mirrorSpeedo = (): void => {
const speedo = anchor.value;
if (!speedo) return;
const speed = speedo.style.getPropertyValue('--speed') || '0';
setLive('--nav-speed', speed);
speedNow = Number.parseFloat(speed) || 0;
if (hueTier(tier, speedNow) !== tier) stepHue();
setLive('--nav-throttle', speedo.style.getPropertyValue('--throttle') || '0');
attr('data-nav-engine', speedo.dataset.engine ?? 'off');
attr('data-nav-brake', speedo.hasAttribute('data-brake') ? '' : null);
attr('data-nav-jump', speedo.hasAttribute('data-jump') ? '' : null);
};
const mirrorControls = (): void => {
const controls = document.querySelector<HTMLElement>('.drive-controls');
attr('data-nav-audio', controls?.dataset.driveState === 'running' && controls.dataset.muted === 'false' ? '' : null);
};
// A chapter change: drop the one-shot now, then re-measure and restart it
// in a task after the next frame has rendered. That frame renders without
// the attribute, so the CSS animations restart even when changes come
// faster than SHIFT_MS, and the measuring reads a clean layout instead of
// forcing one inside the journey's frame callback.
watch(activeIndex, (index, previous) => {
if (!nav) return;
window.clearTimeout(shiftTimer);
window.cancelAnimationFrame(shiftFrame);
attr('data-nav-shift', null);
const direction = index > previous ? 'next' : 'prev';
shiftFrame = window.requestAnimationFrame(() => {
shiftTimer = window.setTimeout(() => {
measure(previous);
attr('data-nav-shift', direction);
shiftTimer = window.setTimeout(() => attr('data-nav-shift', null), SHIFT_MS);
}, 0);
});
});
onMounted(() => {
nav = anchor.value?.closest<HTMLElement>('.site-navigation') ?? null;
if (!nav || !anchor.value) return;
live = [...nav.querySelectorAll<HTMLElement>(':scope > .nav-lcd, :scope > .nav-wave')];
speedObserver = new MutationObserver(mirrorSpeedo);
speedObserver.observe(anchor.value, { attributes: true, attributeFilter: ['style', 'data-engine', 'data-brake', 'data-jump'] });
// The drive controls mount after the nav; watch the body for their state.
controlsObserver = new MutationObserver(mirrorControls);
controlsObserver.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['data-drive-state', 'data-muted'] });
resizeObserver = new ResizeObserver(() => measure(-1));
resizeObserver.observe(nav);
mirrorSpeedo();
mirrorControls();
measure(-1);
import('@/journey/nav-wave').then((module) => {
if (!disposed && nav && !wave) wave = module.mountNavWave(nav);
}, () => { /* No wave: the band stays dark, the bar works without it. */ });
});
onBeforeUnmount(() => {
disposed = true;
speedObserver?.disconnect();
controlsObserver?.disconnect();
resizeObserver?.disconnect();
speedObserver = controlsObserver = null;
resizeObserver = null;
window.clearTimeout(shiftTimer);
window.cancelAnimationFrame(shiftFrame);
window.clearTimeout(hueTimer);
wave?.dispose();
wave = null;
for (const name of VARS) nav?.style.removeProperty(name);
for (const element of live) for (const name of LIVE_VARS) element.style.removeProperty(name);
live = [];
for (const name of ATTRS) nav?.removeAttribute(name);
written.clear();
nav = null;
});
}

471
src/styles/nav-bar.css Normal file
View File

@@ -0,0 +1,471 @@
/* ==========================================================================
THE NAV BAR: an edge-lit LCD.
The bar is a dashboard display lit from its top edge. A fibre runs along
the top and feeds a backlight into the plate; the dash draws its gauges
in LCD pixels in front of it:
- the bargraph (two pixel rows near the top): how hard the drive is
pulling, filling outward from both ends toward the sign's window, cool
to warm to red, with light flowing behind its pixels;
- the caret over the current exit, and an indicator by the next one
while the gantry announces it;
- a drop of light that runs from the old exit to the new one on a
change of chapter, and lands with a ripple;
- route glints drifting from the current exit to the next one;
- the brake columns at both ends, the self-test when the engine starts
and the fibre's shimmer while the soundtrack plays;
- the wave in the lower bezel (journey/nav-wave.ts, a canvas): a calm
ribbon at cruise, a spectrum at speed, with a hue cycle near the top
of the dial.
Contract:
- The drive reaches the bar as custom properties and attributes on
.site-navigation (composables/useNavBarSignals.ts), so a static page
can fake every one: --nav-speed, --nav-throttle (0..1; the app
writes these two on .nav-lcd and .nav-wave instead, to keep the
per-frame restyle small),
data-nav-engine, data-nav-brake, data-nav-jump, data-nav-shift
(next|prev), data-nav-audio, data-nav-hue, --nav-here-x / --nav-next-x
/ --nav-from-x (px), html[data-gantry], html[data-underpass],
html[data-dawn].
- The moving parts live in two decorative spans (SiteNavigation.vue):
.nav-lcd carries a copy of gantry.css's mask, so the sign window, the
8 px bulge and the sign are untouched; .nav-wave is masked to the
wave's band.
- Light moves under static masks (pixels): only transform, translate,
scale and opacity animate, and the signals change custom properties,
never layout.
- State (the bargraph's level, the caret) is drawn in every mode;
animation and transitions run only with prefers-reduced-motion:
no-preference and data-motion='true'. Forced colours: no layers.
- The label band (numerals 21-30 px, labels 36-49 px at 72 px) is kept
clear: the light lives above it (0-20 px) and in the lower bezel.
- Specificity: these rules are written against `html:root
.site-navigation`. :root always matches; it gives the bar the one
class of weight over gantry.css's `html .site-navigation` rules that
the bar is tuned to win by.
========================================================================== */
/* ---------- Palette ---------- */
html:root {
--bar-mark: 191 174 211;
--bar-base-1: #110b1d;
--bar-base-2: #0c0815;
/* The wave's hue cycle at speed: neon pink, violet, cyan, coral. All
light enough to stand off the dark slab. */
--bar-hue-1: #ff6fb1;
--bar-hue-2: #a985ff;
--bar-hue-3: #55dcff;
--bar-hue-4: #ff8c6c;
}
html:root:is([data-dawn='sunrise'], [data-dawn='day']) {
--bar-mark: 255 206 150;
--bar-base-1: #161018;
--bar-base-2: #0f0a12;
/* Warmer with the sunrise: coral, amber, rose, lilac. */
--bar-hue-1: #ff8c6c;
--bar-hue-2: #ffc670;
--bar-hue-3: #ff70a6;
--bar-hue-4: #c39bff;
}
html:root .site-navigation {
--bar-on: 1;
--bar-light: var(--sign-accent, #ff83c2);
--bar-core: color-mix(in srgb, var(--bar-light) 45%, white);
--bar-cool: color-mix(in srgb, var(--bar-light) 78%, white);
--bar-glow: color-mix(in srgb, var(--bar-light) 70%, transparent);
--bar-warm: #ffd693;
--bar-red: #ff5a6e;
/* The backlight the plate sheds behind the LCD. */
--bar-spill: 30%;
--bar-dark: color-mix(in srgb, var(--bar-base-2) 80%, transparent);
/* Each half of the bargraph reaches from its outer end to the window. */
--bar-reach: calc(50cqw - var(--window-w) / 2 - 28px);
/* The drop's row: the middle of the bargraph. */
--bar-fy: 8px;
/* The wave's band in the lower bezel. The ribbon swings about its middle;
the spectrum rises from its floor to just under the labels. */
--bar-band-top: calc(var(--gantry-h) - 20px);
--bar-band-h: 18px;
/* The wave's standard colour (the canvas resolves it, and the palette). */
--bar-wave: color-mix(in srgb, var(--bar-cool) 55%, rgb(var(--bar-mark)));
}
html:root:is([data-dawn='sunrise'], [data-dawn='day']) .site-navigation { --bar-spill: 20%; }
html:root .site-navigation:is([data-nav-engine='off'], [data-nav-engine='parked']) { --bar-on: 0; }
/* The bargraph's level, and the fast run of light behind it that shows as
the drive picks up. They are resolved on .nav-lcd, not the nav: the
speed changes every frame while driving, and a custom property changing
on the nav restyles everything in it (about 2 ms a frame), where on the
LCD it stays inside the lights (see useNavBarSignals.ts). */
html:root .site-navigation > .nav-lcd {
--bar-level: calc(var(--bar-on) * (0.08 + var(--nav-speed, 0.37) * 0.6 + var(--nav-throttle, 0) * 0.32));
--bar-fast: clamp(0, var(--nav-speed, 0.37) * 1.5 + var(--nav-throttle, 0) - 0.45, 1);
}
html:root .site-navigation[data-nav-jump] > .nav-lcd { --bar-fast: 1; }
@media (max-width: 1279px) {
html:root .site-navigation { --bar-reach: calc(50cqw - 8px); }
}
@media (max-width: 767px) {
/* A 56 px bar: the band shrinks with it. */
html:root .site-navigation { --bar-band-top: calc(var(--gantry-h) - 16px); --bar-band-h: 14px; }
}
/* ---------- The slab. The LCD's black matrix is full over the bargraph's
two pixel rows and only a faint etch below them. ---------- */
html:root .site-navigation:is(*, [data-enhanced='true'][data-docked='true'])::before {
background:
/* The fibre along the top edge, feeding the plate: a hot core, then a
prismatic fringe where it enters (warm over cool). */
linear-gradient(180deg, var(--bar-core) 0 1px, color-mix(in srgb, var(--bar-light) 55%, transparent) 1px 2px, rgb(255 120 150 / 22%) 2px 3px, rgb(110 200 255 / 18%) 3px 4px, transparent 4px) 0 0 / 100% 4px no-repeat,
/* The bargraph's rows: the full black matrix, the backlight showing
through 3 px pixels... */
repeating-linear-gradient(90deg, transparent 0 3px, rgb(4 2 10 / 62%) 3px 4px) 0 4px / 100% 8px no-repeat,
repeating-linear-gradient(180deg, transparent 0 3px, rgb(4 2 10 / 62%) 3px 4px) 0 4px / 100% 8px no-repeat,
/* ...and below them only a faint etch, over an even tint that keeps
the slab as dark as a full matrix would. */
repeating-linear-gradient(90deg, transparent 0 3px, rgb(4 2 10 / 13%) 3px 4px) 0 12px / 100% calc(var(--gantry-h) - 14px) no-repeat,
repeating-linear-gradient(180deg, transparent 0 3px, rgb(4 2 10 / 13%) 3px 4px) 0 12px / 100% calc(var(--gantry-h) - 14px) no-repeat,
linear-gradient(rgb(4 2 10 / 22%), rgb(4 2 10 / 22%)) 0 12px / 100% calc(var(--gantry-h) - 14px) no-repeat,
/* Recessed lower bezel with the light's hairline. */
linear-gradient(180deg, rgb(0 0 0 / 60%) 0 1px, color-mix(in srgb, var(--bar-light) 30%, transparent) 1px 2px) 0 calc(var(--gantry-h) - 2px) / 100% 2px no-repeat,
/* The backlight: fed at the top edge and both ends, falling off downward. */
radial-gradient(22% 140% at 0 0, color-mix(in srgb, var(--bar-light) 16%, transparent), transparent 70%) 0 0 / 100% var(--gantry-h) no-repeat,
radial-gradient(22% 140% at 100% 0, color-mix(in srgb, var(--bar-light) 16%, transparent), transparent 70%) 0 0 / 100% var(--gantry-h) no-repeat,
linear-gradient(180deg, color-mix(in srgb, var(--bar-light) var(--bar-spill), transparent) 2px, color-mix(in srgb, var(--bar-light) 9%, transparent) 16px, color-mix(in srgb, var(--bar-light) 3%, transparent) 40%, transparent 72%) 0 0 / 100% var(--gantry-h) no-repeat,
/* The unlit pixels themselves, and the panel. */
linear-gradient(rgb(var(--bar-mark) / 4%), rgb(var(--bar-mark) / 4%)),
linear-gradient(90deg, var(--bar-base-1), var(--bar-base-2) 50%, var(--bar-base-1));
box-shadow: 0 1px 0 rgb(0 0 0 / 55%);
}
@media (forced-colors: active) {
html:root .site-navigation:is(*, [data-enhanced='true'][data-docked='true'])::before {
background: Canvas;
box-shadow: none;
}
}
/* ---------- The layers ---------- */
.nav-lcd,
.nav-wave { display: none; }
@media (forced-colors: none) {
html:root .site-navigation > .nav-lcd {
display: block;
position: absolute;
z-index: -1; /* After the bar's ::before in tree order: over it, under the labels. */
inset: 0 0 auto;
block-size: calc(var(--gantry-h) + var(--bulge));
overflow: hidden;
pointer-events: none;
contain: strict;
container-type: inline-size;
/* Same cut-out as the beam (gantry.css): the sign window stays clear. */
-webkit-mask:
linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat,
linear-gradient(#000 0 0) 50% 0 / calc(var(--window-w) + 56px) 100% no-repeat,
linear-gradient(#000 0 0) 0 0 / 100% var(--gantry-h) no-repeat;
-webkit-mask-composite: xor, source-over;
mask:
linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat,
linear-gradient(#000 0 0) 50% 0 / calc(var(--window-w) + 56px) calc(var(--gantry-h) + var(--bulge)) no-repeat,
linear-gradient(#000 0 0) 0 0 / 100% var(--gantry-h) no-repeat;
mask-composite: exclude, add;
}
html:root .site-navigation > .nav-lcd > i {
display: block;
position: absolute;
pointer-events: none;
}
html:root .site-navigation > .nav-lcd > i::before,
html:root .site-navigation > .nav-lcd > i::after {
content: '';
position: absolute;
pointer-events: none;
}
/* Exit numerals at full strength, so moving light never takes them below 4.5:1. */
html:root .site-navigation a::before { opacity: 1; }
/* The bargraph: two rows of LCD pixels, tinted cool, warm, red along the
scale, filling from each outer end toward the window. The light
flowing in the plate behind them is what lights them. */
html:root .site-navigation > .nav-lcd > .nav-lcd__fill-l, html:root .site-navigation > .nav-lcd > .nav-lcd__fill-r {
z-index: 1;
inset: 0;
-webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; mask-composite: intersect, intersect;
background: linear-gradient(90deg, var(--bar-cool) 0, var(--bar-cool) calc(var(--bar-reach) * 0.6), var(--bar-warm) calc(var(--bar-reach) * 0.78), var(--bar-red) calc(var(--bar-reach) * 0.92));
clip-path: inset(0 50% 0 0);
}
html:root .site-navigation > .nav-lcd > .nav-lcd__fill-r {
background: linear-gradient(270deg, var(--bar-cool) 0, var(--bar-cool) calc(var(--bar-reach) * 0.6), var(--bar-warm) calc(var(--bar-reach) * 0.78), var(--bar-red) calc(var(--bar-reach) * 0.92));
clip-path: inset(0 0 0 50%);
}
html:root .site-navigation > .nav-lcd > .nav-lcd__fill-l::before, html:root .site-navigation > .nav-lcd > .nav-lcd__fill-l::after, html:root .site-navigation > .nav-lcd > .nav-lcd__fill-r::before, html:root .site-navigation > .nav-lcd > .nav-lcd__fill-r::after {
inset: 0 auto 0 -600px;
inline-size: calc(100% + 1200px);
background: rgb(255 252 246 / 85%);
-webkit-mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='600' height='8' preserveAspectRatio='none'><linearGradient id='a'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.48'/><stop offset='0.025' stop-color='%23fff' stop-opacity='0.99'/><stop offset='0.064' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.123' stop-color='%23fff' stop-opacity='0.73'/><stop offset='0.169' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.197' stop-color='%23fff' stop-opacity='0.55'/><stop offset='0.253' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.309' stop-color='%23fff' stop-opacity='0.97'/><stop offset='0.370' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.419' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.469' stop-color='%23fff' stop-opacity='0.03'/><stop offset='0.496' stop-color='%23fff' stop-opacity='0.74'/><stop offset='0.531' stop-color='%23fff' stop-opacity='0.77'/><stop offset='0.563' stop-color='%23fff' stop-opacity='0.10'/><stop offset='0.588' stop-color='%23fff' stop-opacity='0.51'/><stop offset='0.627' stop-color='%23fff' stop-opacity='0.27'/><stop offset='0.653' stop-color='%23fff' stop-opacity='0.45'/><stop offset='0.687' stop-color='%23fff' stop-opacity='0.51'/><stop offset='0.729' stop-color='%23fff' stop-opacity='0.47'/><stop offset='0.765' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.800' stop-color='%23fff' stop-opacity='0.06'/><stop offset='0.845' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.887' stop-color='%23fff' stop-opacity='0.25'/><stop offset='0.936' stop-color='%23fff' stop-opacity='0.03'/><stop offset='0.989' stop-color='%23fff' stop-opacity='0.40'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.48'/></linearGradient><rect width='600' height='8' fill='url(%23a)'/></svg>") 0 0 / 600px 100% repeat-x; mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='600' height='8' preserveAspectRatio='none'><linearGradient id='a'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.48'/><stop offset='0.025' stop-color='%23fff' stop-opacity='0.99'/><stop offset='0.064' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.123' stop-color='%23fff' stop-opacity='0.73'/><stop offset='0.169' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.197' stop-color='%23fff' stop-opacity='0.55'/><stop offset='0.253' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.309' stop-color='%23fff' stop-opacity='0.97'/><stop offset='0.370' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.419' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.469' stop-color='%23fff' stop-opacity='0.03'/><stop offset='0.496' stop-color='%23fff' stop-opacity='0.74'/><stop offset='0.531' stop-color='%23fff' stop-opacity='0.77'/><stop offset='0.563' stop-color='%23fff' stop-opacity='0.10'/><stop offset='0.588' stop-color='%23fff' stop-opacity='0.51'/><stop offset='0.627' stop-color='%23fff' stop-opacity='0.27'/><stop offset='0.653' stop-color='%23fff' stop-opacity='0.45'/><stop offset='0.687' stop-color='%23fff' stop-opacity='0.51'/><stop offset='0.729' stop-color='%23fff' stop-opacity='0.47'/><stop offset='0.765' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.800' stop-color='%23fff' stop-opacity='0.06'/><stop offset='0.845' stop-color='%23fff' stop-opacity='0.76'/><stop offset='0.887' stop-color='%23fff' stop-opacity='0.25'/><stop offset='0.936' stop-color='%23fff' stop-opacity='0.03'/><stop offset='0.989' stop-color='%23fff' stop-opacity='0.40'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.48'/></linearGradient><rect width='600' height='8' fill='url(%23a)'/></svg>") 0 0 / 600px 100% repeat-x;
opacity: 0.5;
}
html:root .site-navigation > .nav-lcd > .nav-lcd__fill-l::after, html:root .site-navigation > .nav-lcd > .nav-lcd__fill-r::after {
-webkit-mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='440' height='8' preserveAspectRatio='none'><linearGradient id='b'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.81'/><stop offset='0.048' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.133' stop-color='%23fff' stop-opacity='0.03'/><stop offset='0.177' stop-color='%23fff' stop-opacity='0.38'/><stop offset='0.230' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.272' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.333' stop-color='%23fff' stop-opacity='0.06'/><stop offset='0.404' stop-color='%23fff' stop-opacity='0.71'/><stop offset='0.443' stop-color='%23fff' stop-opacity='0.01'/><stop offset='0.511' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.553' stop-color='%23fff' stop-opacity='0.95'/><stop offset='0.612' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.696' stop-color='%23fff' stop-opacity='0.46'/><stop offset='0.756' stop-color='%23fff' stop-opacity='0.48'/><stop offset='0.805' stop-color='%23fff' stop-opacity='0.18'/><stop offset='0.853' stop-color='%23fff' stop-opacity='0.81'/><stop offset='0.891' stop-color='%23fff' stop-opacity='0.10'/><stop offset='0.966' stop-color='%23fff' stop-opacity='0.99'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.81'/></linearGradient><rect width='440' height='8' fill='url(%23b)'/></svg>") 0 0 / 440px 100% repeat-x; mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='440' height='8' preserveAspectRatio='none'><linearGradient id='b'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.81'/><stop offset='0.048' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.133' stop-color='%23fff' stop-opacity='0.03'/><stop offset='0.177' stop-color='%23fff' stop-opacity='0.38'/><stop offset='0.230' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.272' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.333' stop-color='%23fff' stop-opacity='0.06'/><stop offset='0.404' stop-color='%23fff' stop-opacity='0.71'/><stop offset='0.443' stop-color='%23fff' stop-opacity='0.01'/><stop offset='0.511' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.553' stop-color='%23fff' stop-opacity='0.95'/><stop offset='0.612' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.696' stop-color='%23fff' stop-opacity='0.46'/><stop offset='0.756' stop-color='%23fff' stop-opacity='0.48'/><stop offset='0.805' stop-color='%23fff' stop-opacity='0.18'/><stop offset='0.853' stop-color='%23fff' stop-opacity='0.81'/><stop offset='0.891' stop-color='%23fff' stop-opacity='0.10'/><stop offset='0.966' stop-color='%23fff' stop-opacity='0.99'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.81'/></linearGradient><rect width='440' height='8' fill='url(%23b)'/></svg>") 0 0 / 440px 100% repeat-x;
opacity: calc(var(--bar-fast) * 0.8);
}
/* What covers the bargraph beyond its level (the unlit pixels), and the
peak pixel that lingers where the level last was. */
html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-l, html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-r {
z-index: 2;
inset: 0;
-webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; mask-composite: intersect, intersect;
clip-path: inset(0 50% 0 0);
}
html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-r { clip-path: inset(0 0 0 50%); }
html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-l::before, html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-r::before {
inset: 0;
background: linear-gradient(90deg, transparent, var(--bar-dark) 8px);
translate: calc(var(--bar-level) * var(--bar-reach)) 0;
--bar-dir: 1;
}
html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-r::before {
background: linear-gradient(270deg, transparent, var(--bar-dark) 8px);
translate: calc(var(--bar-level) * var(--bar-reach) * -1) 0;
--bar-dir: -1;
}
html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-l::after, html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-r::after {
inset: 0 auto 0 0;
inline-size: 4px;
background: rgb(255 250 240 / 95%);
translate: calc(var(--bar-level) * var(--bar-reach) - 8px) 0;
opacity: var(--bar-on);
}
html:root .site-navigation > .nav-lcd > .nav-lcd__unlit-r::after { inset: 0 0 0 auto; translate: calc((var(--bar-level) * var(--bar-reach) - 8px) * -1) 0; }
/* A chapter change: a drop of light runs through the bargraph from the
old exit to the new one, stretching as it goes and squashing as it
lands, and a ripple spreads where it lands. The pixels it ran through
hold a ghost of it for a moment. */
html:root .site-navigation > .nav-lcd > .nav-lcd__ghost { z-index: 3; inset: 0; -webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 16px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 16px no-repeat; mask-composite: intersect, intersect; }
html:root .site-navigation > .nav-lcd > .nav-lcd__ghost::after {
inset: 0 auto auto 0;
inline-size: 120px;
block-size: 24px;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--bar-cool) 75%, transparent));
opacity: 0;
}
html:root .site-navigation > .nav-lcd > .nav-lcd__drop { z-index: 4; inset: 0; }
html:root .site-navigation > .nav-lcd > .nav-lcd__drop::before {
inset: calc(var(--bar-fy) - 5px) auto auto 0;
inline-size: 40px;
block-size: 10px;
background: radial-gradient(closest-side, #fff 30%, var(--bar-glow) 60%, transparent);
opacity: 0;
}
html:root .site-navigation > .nav-lcd > .nav-lcd__drop::after {
inset: calc(var(--bar-fy) - 12px) auto auto 0;
inline-size: 56px;
block-size: 24px;
translate: calc(var(--nav-here-x, 0px) - 28px) 0;
border-radius: 50%;
background: radial-gradient(closest-side, transparent 52%, var(--bar-glow) 70%, transparent);
opacity: 0;
}
/* The plate's own light: a short glint launched from the current exit
drifts slowly to the next one and fades there. One at rest, a second
from 42 % of the dial; through the LCD it barely lifts the pixels, in
the fibre it is a faint run. */
html:root .site-navigation > .nav-lcd > .nav-lcd__glint, html:root .site-navigation > .nav-lcd > .nav-lcd__glint-2 { z-index: 3; inset: 0; -webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 16px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 16px no-repeat; mask-composite: intersect, intersect; opacity: 0.5; }
html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint, html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint-2 { z-index: 3; inset: 0 0 auto; block-size: 4px; opacity: 0.5; }
html:root .site-navigation > .nav-lcd > .nav-lcd__glint-2, html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint-2 { opacity: calc(0.5 * clamp(0, (var(--nav-speed, 0.37) - 0.42) * 4, 1)); }
html:root .site-navigation[data-nav-jump] > .nav-lcd > :is(.nav-lcd__glint-2, .nav-lcd__fibre-glint-2) { opacity: 0.5; }
:is(html:root .site-navigation > .nav-lcd > .nav-lcd__glint, html:root .site-navigation > .nav-lcd > .nav-lcd__glint-2)::before {
inset: 0 auto auto 0;
inline-size: 28px;
block-size: 20px;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--bar-cool) 40%, transparent) 55%, rgb(255 255 255 / 45%) 85%, transparent);
opacity: 0;
}
:is(html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint, html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint-2)::before {
inset: 0 auto auto 0;
inline-size: 28px;
block-size: 3px;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--bar-light) 70%, transparent) 60%, rgb(255 255 255 / 70%) 88%, transparent);
opacity: 0;
}
/* The caret over the current exit, and the indicator by the next one
while the gantry announces it. */
html:root .site-navigation > .nav-lcd > .nav-lcd__caret { z-index: 3; inset: 0; -webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 12px / 100% 8px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 12px / 100% 8px no-repeat; mask-composite: intersect, intersect; }
html:root .site-navigation > .nav-lcd > .nav-lcd__caret::before {
inset: 12px auto auto 0;
inline-size: 14px;
block-size: 8px;
clip-path: polygon(0 0, 100% 0, 50% 100%);
background: var(--bar-core);
translate: calc(var(--nav-here-x, -40px) - 7px) 0;
}
html:root .site-navigation > .nav-lcd > .nav-lcd__indicator { z-index: 3; inset: 0; -webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 12px / 100% 8px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 12px / 100% 8px no-repeat; mask-composite: intersect, intersect; opacity: 0; }
html:root .site-navigation > .nav-lcd > .nav-lcd__indicator::before {
inset: 12px auto auto 0;
inline-size: 20px;
block-size: 8px;
clip-path: polygon(0 0, 20% 0, 50% 50%, 20% 100%, 0 100%, 30% 50%, 0 0, 50% 0, 50% 0, 70% 0, 100% 50%, 70% 100%, 50% 100%, 80% 50%, 50% 0);
background: #74f0aa;
translate: calc(var(--nav-next-x, -60px) + 8px) 0;
}
html:root[data-gantry='incoming'] .site-navigation > .nav-lcd > .nav-lcd__indicator { opacity: 1; }
/* The self-test (all pixels on) as the engine starts, and the brake
columns at both ends. */
html:root .site-navigation > .nav-lcd > .nav-lcd__test { z-index: 3; inset: 0; -webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; mask-composite: intersect, intersect; background: color-mix(in srgb, var(--bar-cool) 80%, white); opacity: 0; }
html:root .site-navigation > .nav-lcd > .nav-lcd__brake {
z-index: 3;
inset: 0;
-webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 4px / 100% 8px no-repeat; mask-composite: intersect, intersect;
background: linear-gradient(90deg, var(--bar-red) 0 12px, transparent 12px calc(100% - 12px), var(--bar-red) calc(100% - 12px));
opacity: 0;
}
html:root .site-navigation[data-nav-brake] > .nav-lcd > .nav-lcd__brake { opacity: 1; }
/* The fibre's shimmer, brighter while the soundtrack plays. */
html:root .site-navigation > .nav-lcd > .nav-lcd__shimmer { z-index: 3; inset: 0 0 auto; block-size: 2px; opacity: 0.45; }
html:root .site-navigation > .nav-lcd > .nav-lcd__shimmer::before, html:root .site-navigation > .nav-lcd > .nav-lcd__shimmer::after {
inset: 0 auto 0 0;
inline-size: calc(100% + 600px);
background: #fff;
-webkit-mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='600' height='8' preserveAspectRatio='none'><linearGradient id='c'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.029' stop-color='%23fff' stop-opacity='0.74'/><stop offset='0.071' stop-color='%23fff' stop-opacity='0.01'/><stop offset='0.101' stop-color='%23fff' stop-opacity='0.92'/><stop offset='0.146' stop-color='%23fff' stop-opacity='0.77'/><stop offset='0.197' stop-color='%23fff' stop-opacity='0.02'/><stop offset='0.242' stop-color='%23fff' stop-opacity='0.47'/><stop offset='0.298' stop-color='%23fff' stop-opacity='0.95'/><stop offset='0.342' stop-color='%23fff' stop-opacity='0.89'/><stop offset='0.379' stop-color='%23fff' stop-opacity='0.75'/><stop offset='0.408' stop-color='%23fff' stop-opacity='0.01'/><stop offset='0.435' stop-color='%23fff' stop-opacity='0.09'/><stop offset='0.460' stop-color='%23fff' stop-opacity='0.93'/><stop offset='0.519' stop-color='%23fff' stop-opacity='0.04'/><stop offset='0.548' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.573' stop-color='%23fff' stop-opacity='0.90'/><stop offset='0.613' stop-color='%23fff' stop-opacity='0.77'/><stop offset='0.666' stop-color='%23fff' stop-opacity='0.24'/><stop offset='0.709' stop-color='%23fff' stop-opacity='0.99'/><stop offset='0.745' stop-color='%23fff' stop-opacity='0.38'/><stop offset='0.782' stop-color='%23fff' stop-opacity='0.71'/><stop offset='0.842' stop-color='%23fff' stop-opacity='0.05'/><stop offset='0.866' stop-color='%23fff' stop-opacity='0.34'/><stop offset='0.893' stop-color='%23fff' stop-opacity='0.26'/><stop offset='0.937' stop-color='%23fff' stop-opacity='0.42'/><stop offset='0.981' stop-color='%23fff' stop-opacity='0.08'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.11'/></linearGradient><rect width='600' height='8' fill='url(%23c)'/></svg>") 0 0 / 600px 100% repeat-x; mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='600' height='8' preserveAspectRatio='none'><linearGradient id='c'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.029' stop-color='%23fff' stop-opacity='0.74'/><stop offset='0.071' stop-color='%23fff' stop-opacity='0.01'/><stop offset='0.101' stop-color='%23fff' stop-opacity='0.92'/><stop offset='0.146' stop-color='%23fff' stop-opacity='0.77'/><stop offset='0.197' stop-color='%23fff' stop-opacity='0.02'/><stop offset='0.242' stop-color='%23fff' stop-opacity='0.47'/><stop offset='0.298' stop-color='%23fff' stop-opacity='0.95'/><stop offset='0.342' stop-color='%23fff' stop-opacity='0.89'/><stop offset='0.379' stop-color='%23fff' stop-opacity='0.75'/><stop offset='0.408' stop-color='%23fff' stop-opacity='0.01'/><stop offset='0.435' stop-color='%23fff' stop-opacity='0.09'/><stop offset='0.460' stop-color='%23fff' stop-opacity='0.93'/><stop offset='0.519' stop-color='%23fff' stop-opacity='0.04'/><stop offset='0.548' stop-color='%23fff' stop-opacity='0.08'/><stop offset='0.573' stop-color='%23fff' stop-opacity='0.90'/><stop offset='0.613' stop-color='%23fff' stop-opacity='0.77'/><stop offset='0.666' stop-color='%23fff' stop-opacity='0.24'/><stop offset='0.709' stop-color='%23fff' stop-opacity='0.99'/><stop offset='0.745' stop-color='%23fff' stop-opacity='0.38'/><stop offset='0.782' stop-color='%23fff' stop-opacity='0.71'/><stop offset='0.842' stop-color='%23fff' stop-opacity='0.05'/><stop offset='0.866' stop-color='%23fff' stop-opacity='0.34'/><stop offset='0.893' stop-color='%23fff' stop-opacity='0.26'/><stop offset='0.937' stop-color='%23fff' stop-opacity='0.42'/><stop offset='0.981' stop-color='%23fff' stop-opacity='0.08'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.11'/></linearGradient><rect width='600' height='8' fill='url(%23c)'/></svg>") 0 0 / 600px 100% repeat-x;
}
html:root .site-navigation > .nav-lcd > .nav-lcd__shimmer::after { inset: 0 auto 0 -437px; inline-size: calc(100% + 437px); -webkit-mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='437' height='8' preserveAspectRatio='none'><linearGradient id='d'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.00'/><stop offset='0.059' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.096' stop-color='%23fff' stop-opacity='0.96'/><stop offset='0.153' stop-color='%23fff' stop-opacity='0.24'/><stop offset='0.191' stop-color='%23fff' stop-opacity='0.50'/><stop offset='0.242' stop-color='%23fff' stop-opacity='0.88'/><stop offset='0.290' stop-color='%23fff' stop-opacity='0.72'/><stop offset='0.351' stop-color='%23fff' stop-opacity='0.41'/><stop offset='0.388' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.444' stop-color='%23fff' stop-opacity='0.35'/><stop offset='0.516' stop-color='%23fff' stop-opacity='0.07'/><stop offset='0.559' stop-color='%23fff' stop-opacity='0.00'/><stop offset='0.630' stop-color='%23fff' stop-opacity='0.06'/><stop offset='0.664' stop-color='%23fff' stop-opacity='0.45'/><stop offset='0.702' stop-color='%23fff' stop-opacity='0.09'/><stop offset='0.743' stop-color='%23fff' stop-opacity='0.98'/><stop offset='0.779' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.827' stop-color='%23fff' stop-opacity='0.02'/><stop offset='0.871' stop-color='%23fff' stop-opacity='0.71'/><stop offset='0.924' stop-color='%23fff' stop-opacity='0.26'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.00'/></linearGradient><rect width='437' height='8' fill='url(%23d)'/></svg>") 0 0 / 437px 100% repeat-x; mask: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='437' height='8' preserveAspectRatio='none'><linearGradient id='d'><stop offset='0.000' stop-color='%23fff' stop-opacity='0.00'/><stop offset='0.059' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.096' stop-color='%23fff' stop-opacity='0.96'/><stop offset='0.153' stop-color='%23fff' stop-opacity='0.24'/><stop offset='0.191' stop-color='%23fff' stop-opacity='0.50'/><stop offset='0.242' stop-color='%23fff' stop-opacity='0.88'/><stop offset='0.290' stop-color='%23fff' stop-opacity='0.72'/><stop offset='0.351' stop-color='%23fff' stop-opacity='0.41'/><stop offset='0.388' stop-color='%23fff' stop-opacity='0.11'/><stop offset='0.444' stop-color='%23fff' stop-opacity='0.35'/><stop offset='0.516' stop-color='%23fff' stop-opacity='0.07'/><stop offset='0.559' stop-color='%23fff' stop-opacity='0.00'/><stop offset='0.630' stop-color='%23fff' stop-opacity='0.06'/><stop offset='0.664' stop-color='%23fff' stop-opacity='0.45'/><stop offset='0.702' stop-color='%23fff' stop-opacity='0.09'/><stop offset='0.743' stop-color='%23fff' stop-opacity='0.98'/><stop offset='0.779' stop-color='%23fff' stop-opacity='0.94'/><stop offset='0.827' stop-color='%23fff' stop-opacity='0.02'/><stop offset='0.871' stop-color='%23fff' stop-opacity='0.71'/><stop offset='0.924' stop-color='%23fff' stop-opacity='0.26'/><stop offset='1.000' stop-color='%23fff' stop-opacity='0.00'/></linearGradient><rect width='437' height='8' fill='url(%23d)'/></svg>") 0 0 / 437px 100% repeat-x; }
html:root .site-navigation[data-nav-audio] > .nav-lcd > .nav-lcd__shimmer { opacity: 1; }
/* The wave's band, in LCD pixels along the lower bezel. The canvas
(journey/nav-wave.ts) draws the line; the mask stands still, so the
line moves under the pixels. */
html:root .site-navigation > .nav-wave {
display: block;
position: absolute;
z-index: -1;
inset: 0 0 auto;
block-size: calc(var(--gantry-h) + var(--bulge));
overflow: hidden;
pointer-events: none;
contain: strict;
-webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 var(--bar-band-top) / 100% var(--bar-band-h) no-repeat, linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat, linear-gradient(#000 0 0) 0 0 / 100% var(--gantry-h) no-repeat;
-webkit-mask-composite: source-in, source-in, source-in, xor;
mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 var(--bar-band-top) / 100% var(--bar-band-h) no-repeat, linear-gradient(#000 0 0) var(--window-mask-x) var(--window-y) / var(--window-w) var(--window-h) no-repeat, linear-gradient(#000 0 0) 0 0 / 100% var(--gantry-h) no-repeat;
mask-composite: intersect, intersect, intersect, exclude;
}
html:root .site-navigation > .nav-wave > canvas {
display: block;
position: absolute;
inset: var(--bar-band-top) 0 auto 0;
inline-size: 100%;
block-size: var(--bar-band-h);
}
/* Under 768 px the exits fold into the menu: no caret, no indicator, and
nothing to travel between, so no drop and no glints (they would only
pulse at the bar's left edge). */
@media (max-width: 767px) {
html:root .site-navigation > .nav-lcd > .nav-lcd__caret, html:root .site-navigation > .nav-lcd > .nav-lcd__indicator, html:root .site-navigation > .nav-lcd > .nav-lcd__drop, html:root .site-navigation > .nav-lcd > .nav-lcd__glint, html:root .site-navigation > .nav-lcd > .nav-lcd__glint-2, html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint, html:root .site-navigation > .nav-lcd > .nav-lcd__fibre-glint-2 { display: none; }
}
/* A pixel caret over the exit under the pointer or focus. */
@media (min-width: 768px) {
html:root .site-navigation li::before {
content: '' / '';
position: absolute;
inset: 12px auto auto calc(50% - 6px);
inline-size: 12px;
block-size: 8px;
background: linear-gradient(rgb(var(--bar-mark) / 85%), rgb(var(--bar-mark) / 85%));
-webkit-mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 0 / 100% 100% no-repeat; -webkit-mask-composite: source-in, source-in; mask: repeating-linear-gradient(90deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, repeating-linear-gradient(180deg, #000 0 3px, transparent 3px 4px) 0 0 / 100% 100%, linear-gradient(#000 0 0) 0 0 / 100% 100% no-repeat; mask-composite: intersect, intersect;
clip-path: polygon(0 0, 100% 0, 50% 100%);
opacity: 0;
translate: 0 -4px;
pointer-events: none;
}
html:root .site-navigation li:has(a:is(:hover, :focus-visible)):not([data-route='here'])::before { opacity: 1; translate: 0 0; }
}
}
/* ---------- Motion ---------- */
@media (prefers-reduced-motion: no-preference) and (forced-colors: none) {
/* The light flowing behind the bargraph: outward on the left, inward on
the right; the fast run fades in with the speed. */
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fill-l::before { animation: bar-flow-in 4.6s linear infinite; }
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fill-l::after { animation: bar-flow-in-fast 1.25s linear infinite; transition: opacity 500ms ease; }
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fill-r::before { animation: bar-flow-out 4.6s linear -2.3s infinite; }
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fill-r::after { animation: bar-flow-out-fast 1.25s linear -0.6s infinite; transition: opacity 500ms ease; }
/* The level eases to the speed and hunts a little about it; with the
engine off it falls away in steps, and the peak pixel follows late. */
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__unlit-l::before, html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__unlit-r::before {
transition: translate 320ms cubic-bezier(0.3, 0.7, 0.4, 1);
animation: bar-hunt 6.7s steps(1, end) infinite;
}
html:root .site-navigation[data-motion='true']:is([data-nav-engine='off'], [data-nav-engine='parked']) > .nav-lcd > :is(.nav-lcd__unlit-l, .nav-lcd__unlit-r)::before { transition-duration: 1.6s; transition-timing-function: steps(10, end); }
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__unlit-l::after, html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__unlit-r::after { transition: translate 1.6s steps(12, end) 500ms, opacity 600ms ease; }
html:root .site-navigation[data-motion='true'][data-nav-engine='on'] > .nav-lcd > .nav-lcd__test { animation: bar-self-test 1.5s linear both; }
/* The drop, its ripple, and the ghost in the pixels behind it. */
html:root .site-navigation[data-motion='true'][data-nav-shift] > .nav-lcd > .nav-lcd__drop::before { animation: bar-drop 900ms both; }
html:root .site-navigation[data-motion='true'][data-nav-shift] > .nav-lcd > .nav-lcd__drop::after { animation: bar-ripple 900ms cubic-bezier(0.2, 0.7, 0.3, 1) 600ms both; }
html:root .site-navigation[data-motion='true'][data-nav-shift] > .nav-lcd > .nav-lcd__ghost::after { animation: bar-ghost 1500ms both; }
/* The route glints, slow; they stop at the next exit. */
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__glint::before, html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fibre-glint::before { animation: bar-glint 5.8s cubic-bezier(0.45, 0.05, 0.35, 1) infinite; }
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__glint-2::before, html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fibre-glint-2::before { animation: bar-glint 5.8s cubic-bezier(0.45, 0.05, 0.35, 1) -2.9s infinite; }
:is(html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__glint-2, html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__fibre-glint-2) { transition: opacity 900ms ease; }
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__caret::before { transition: translate 820ms cubic-bezier(0.34, 1.3, 0.55, 1) 420ms; }
html:root[data-gantry='incoming'] .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__indicator { animation: bar-blink 760ms steps(1, end) infinite; }
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__brake { transition: opacity 600ms steps(3, end); }
html:root .site-navigation[data-motion='true'][data-nav-brake] > .nav-lcd > .nav-lcd__brake { transition: opacity 100ms; }
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__shimmer { transition: opacity 900ms ease; }
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__shimmer::before { animation: bar-shimmer 19.3s linear infinite; }
html:root .site-navigation[data-motion='true'] > .nav-lcd > .nav-lcd__shimmer::after { animation: bar-shimmer-back 27.1s linear infinite; }
html:root .site-navigation[data-motion='true'] > :is(.nav-lcd, .nav-wave) { transition: opacity 900ms ease; }
html:root[data-underpass='under'] .site-navigation > :is(.nav-lcd, .nav-wave) { opacity: 0.68; }
@media (min-width: 768px) {
html:root .site-navigation[data-motion='true'] li::before { transition: opacity 160ms steps(2, end), translate 360ms steps(2, end); }
}
}
@keyframes bar-flow-in { from { transform: translateX(0); } to { transform: translateX(600px); } }
@keyframes bar-flow-in-fast { from { transform: translateX(0); } to { transform: translateX(440px); } }
@keyframes bar-flow-out { from { transform: translateX(0); } to { transform: translateX(-600px); } }
@keyframes bar-flow-out-fast { from { transform: translateX(0); } to { transform: translateX(-440px); } }
@keyframes bar-hunt {
0%, 22%, 50%, 70%, 100% { transform: none; }
6% { transform: translateX(calc(var(--bar-dir) * 4px)); }
12% { transform: translateX(calc(var(--bar-dir) * -4px)); }
38% { transform: translateX(calc(var(--bar-dir) * 12px)); }
42% { transform: translateX(calc(var(--bar-dir) * 4px)); }
80% { transform: translateX(calc(var(--bar-dir) * 4px)); }
}
/* One pulse, 1.5 s: on at 8 %, off from 55 % to 62 %. */
@keyframes bar-self-test {
0% { opacity: 0; }
8%, 55% { opacity: 1; }
62%, 100% { opacity: 0; }
}
/* About 1.3 Hz, far under the 3 Hz flash threshold. */
@keyframes bar-blink {
0%, 52%, 100% { opacity: 0.1; }
6%, 44% { opacity: 1; }
}
@keyframes bar-shimmer { to { transform: translateX(-600px); } }
@keyframes bar-shimmer-back { to { transform: translateX(437px); } }
@keyframes bar-drop {
0% { opacity: 0; transform: translateX(calc(var(--nav-from-x, 0px) - 20px)) scale(0.4, 1); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1); }
18% { opacity: 1; transform: translateX(calc(var(--nav-from-x, 0px) - 20px)) scale(1.6, 1.4); animation-timing-function: cubic-bezier(0.7, 0, 0.25, 1); }
66% { opacity: 1; transform: translateX(calc(var(--nav-here-x, 0px) - 20px)) scale(2.6, 0.8); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1.4); }
80% { opacity: 1; transform: translateX(calc(var(--nav-here-x, 0px) - 20px)) scale(0.8, 1.2); }
100% { opacity: 0; transform: translateX(calc(var(--nav-here-x, 0px) - 20px)) scale(1); }
}
/* The ripple, spread flat: it widens along the bar but never grows down
into the exit numerals. */
@keyframes bar-ripple {
0% { opacity: 0; transform: scale(0.2); }
20% { opacity: 1; }
100% { opacity: 0; transform: scale(1.9, 1); }
}
@keyframes bar-ghost {
0%, 12% { opacity: 0; transform: translateX(calc(var(--nav-from-x, 0px) - 120px)); animation-timing-function: cubic-bezier(0.7, 0, 0.25, 1); }
40% { opacity: 0.8; transform: translateX(calc(var(--nav-here-x, 0px) - 120px)); }
100% { opacity: 0; transform: translateX(calc(var(--nav-here-x, 0px) - 120px)); }
}
@keyframes bar-glint {
0% { opacity: 0; transform: translateX(calc(var(--nav-here-x, 0px) - 14px)) scaleX(0.4); }
18% { opacity: 1; }
72% { opacity: 0.85; transform: translateX(calc(var(--nav-next-x, 0px) - 14px)) scaleX(1); }
100% { opacity: 0; transform: translateX(calc(var(--nav-next-x, 0px) - 14px)) scaleX(1); }
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { hueTier } from '@/composables/useNavBarSignals';
describe('nav bar hue tier', () => {
it('comes on at 80, 88 and 95 % of the dial', () => {
expect(hueTier(0, 0.79)).toBe(0);
expect(hueTier(0, 0.8)).toBe(1);
expect(hueTier(0, 0.88)).toBe(2);
expect(hueTier(0, 1)).toBe(3);
});
it('holds a tier until the speed falls below its own floor', () => {
expect(hueTier(1, 0.77)).toBe(1);
expect(hueTier(1, 0.75)).toBe(0);
expect(hueTier(3, 0.93)).toBe(3);
expect(hueTier(3, 0.9)).toBe(2);
expect(hueTier(3, 0.4)).toBe(0);
});
});