From 2be15980ee1fb5062456d00c299c16a5fe5d088c Mon Sep 17 00:00:00 2001 From: LucaP Date: Tue, 13 Oct 2026 06:48:33 +0300 Subject: [PATCH 1/5] feat: add the nav bar's lower-bezel wave canvas A lazy chunk (journey/nav-wave.ts) that draws the bar's wave: a calm ribbon at cruise, a spectrum at speed, with a hue cycle near the top of the dial. It follows the chapter's accent and the dawn, repaints at once on resize, and draws one still frame under reduced motion. Budgeted as a deferred chunk. --- scripts/lib/bundle-budgets.mjs | 2 + src/journey/nav-wave.ts | 397 ++++++++++++++++++++++++++++++ tests/performance/budgets.test.ts | 1 + tests/unit/nav-wave.test.ts | 103 ++++++++ 4 files changed, 503 insertions(+) create mode 100644 src/journey/nav-wave.ts create mode 100644 tests/unit/nav-wave.test.ts diff --git a/scripts/lib/bundle-budgets.mjs b/scripts/lib/bundle-budgets.mjs index 9d920fd..c4820fe 100644 --- a/scripts/lib/bundle-budgets.mjs +++ b/scripts/lib/bundle-budgets.mjs @@ -50,6 +50,8 @@ export const classifyArtifact = (path, source = '') => { if (/^assets\/tab-icon-[^/]+\.js$/u.test(path)) return 'deferred-non-video'; // SPEEDOMETER: the gantry dial's model and DOM driver, loaded after mount. if (/^assets\/speedometer-dial-[^/]+\.js$/u.test(path)) return 'deferred-non-video'; + // NAV BAR: the lower-bezel wave canvas, loaded after the bar mounts. + if (/^assets\/nav-wave-[^/]+\.js$/u.test(path)) return 'deferred-non-video'; if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene'; if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts'; if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster'; diff --git a/src/journey/nav-wave.ts b/src/journey/nav-wave.ts new file mode 100644 index 0000000..567d991 --- /dev/null +++ b/src/journey/nav-wave.ts @@ -0,0 +1,397 @@ +/** + * THE NAV BAR'S WAVE (a lazy chunk: composables/useNavBarSignals.ts loads + * it once the bar mounts). The lower bezel's line, drawn on a small canvas + * inside .nav-wave, so the band's LCD pixel mask and the sign's window + * cut-out (styles/nav-bar.css) apply to it. + * + * It reads two signals, so a static preview drives it by setting them: + * --nav-speed 0..1 the wave's energy: inline on .nav-wave (where + * the app writes it each frame) or on the nav + * data-nav-hue 0..3 the hue cycle's pace, on the nav; absent is off + * Its colours come from CSS (--bar-wave, --bar-hue-1..4), re-read when the + * dawn or the chapter's accent changes. + * + * Low speed: a ribbon in the manner of a console's menu waves. Three long + * sines travel at different paces, two one way and one the other, under a + * slow swell, so the line drifts and folds over itself calmly and never + * repeats or scrolls as one piece. Speed quickens the drift at once. + * High speed: a spectrum. The axis sinks to the band's floor and the line + * becomes a smooth envelope through bins that jump up fast and fall back + * slower (attack and release), grouped into travelling swells so its peaks + * read as waves rather than noise; a burst of acceleration kicks them all. + * The two blend continuously in between. The line never leaves its band. + * The hue cycle and its slow return follow data-nav-hue: in over 1.2 s, + * out over 4 s, the pace eased. + * + * One rAF loop while motion is allowed and the canvas is laid out (the + * browser pauses it in a hidden tab). Reduced motion, or data-motion not + * 'true', draws one still frame and redraws only when an input changes. + */ + +/** The spectrum's bins, px apart; the envelope is a smooth curve through them. */ +const BIN = 18; +/** The wave repaints at 30 Hz; the model still steps every frame. */ +const DRAW_INTERVAL = 1 / 30; +const LINE_STEP = 3; // px between line samples; the spectrum's peaks are 18 px apart +/** The hue palette's period along the bar, px. */ +const HUE_PERIOD = 1440; +/** Hue tiers' pace, cycles per second: 1 slow, 2 fast, 3 both; 0 (settling) slow. */ +const HUE_RATE = [1 / 8, 1 / 8, 1 / 3.4, 1 / 8 + 1 / 3.4]; +/** The line's clearance from the band's edges, px. */ +const EDGE = 1.6; +const TAU = Math.PI * 2; +/** The ribbon: [wavelength px, cycles per second (sign is direction), weight, phase]. */ +const RIBBON: ReadonlyArray = [ + [430, 0.09, 0.55, 0.3], + [265, -0.13, 0.4, 1.9], + [150, 0.2, 0.22, 4.1], +]; +/** The ribbon's soft limit: full where the strands meet, never clipped flat. */ +const RIBBON_DRIVE = 1.5; + +const smoothstep = (a: number, b: number, v: number): number => { + const t = Math.min(1, Math.max(0, (v - a) / (b - a))); + return t * t * (3 - 2 * t); +}; +const ease = (dt: number, tau: number): number => 1 - Math.exp(-dt / tau); +const clamp01 = (v: number): number => Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0)); + +export interface WaveFrame { + /** Smoothed speed, 0..1. */ + speed: number; + /** 0 ribbon .. 1 spectrum: the blend, and how far the axis has sunk. */ + spectrum: number; + /** 0 standard colour .. 1 palette, eased. */ + hueMix: number; + /** The palette's offset along the bar, in periods (0..1). */ + hueOffset: number; +} + +export interface WaveModel { + /** Advance by dt seconds toward a raw speed and a hue tier (-1: absent). */ + step(dt: number, speed: number, hueTier: number): WaveFrame; + /** The still frame (reduced motion): the ribbon at rest at this speed's + * amplitude, whatever the speed, in the standard colour. */ + still(speed: number): WaveFrame; + /** Fit the spectrum's bins to a width in px. */ + resize(width: number): void; + /** The axis's y (px from the band's top), for a band of height h. */ + axisY(h: number): number; + /** The line's y (px from the band's top) at x, for a band of height h. */ + lineY(x: number, h: number): number; + /** A bin's level, 0..1. */ + level(index: number): number; + readonly binCount: number; + readonly frame: WaveFrame; +} + +/** A small seeded generator, so the visualizer is the same on every load. */ +function random(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 4294967296; + }; +} + +export function createWaveModel(seed = 17): WaveModel { + const rnd = random(seed); + const frame: WaveFrame = { speed: 0.37, spectrum: 0, hueMix: 0, hueOffset: 0 }; + /** The ribbon's own clock, in seconds of drift: it runs faster with speed. */ + let drift = 0; + /** The spectrum's swells' clock. */ + let swell = 0; + let lastSpeed = 0.37; + let kick = 0; + let hueRate = 0; + let hueLinear = 0; + let levels = new Float32Array(0); + let target = new Float32Array(0); + let timer = new Float32Array(0); + + const ribbon = (x: number): number => { + let v = 0; + for (const [length, rate, weight, offset] of RIBBON) v += weight * Math.sin((x * TAU) / length - TAU * rate * drift + offset); + // A slow swell along the bar, so the ribbon is fuller in places. + const swellAt = 0.82 + 0.18 * Math.sin((x * TAU) / 1900 + TAU * 0.03 * drift); + return (Math.tanh(RIBBON_DRIVE * v) / Math.tanh(RIBBON_DRIVE * 1.2)) * swellAt; + }; + + /** The spectrum at x: Catmull-Rom through the bins, 0..1. */ + const envelope = (x: number): number => { + const n = levels.length; + if (n === 0) return 0; + const at = x / BIN; + const i = Math.floor(at); + const t = at - i; + const p = (k: number): number => levels[Math.min(n - 1, Math.max(0, k))]!; + const p0 = p(i - 1), p1 = p(i), p2 = p(i + 1), p3 = p(i + 2); + const v = p1 + 0.5 * t * (p2 - p0 + t * (2 * p0 - 5 * p1 + 4 * p2 - p3 + t * (3 * (p1 - p2) + p3 - p0))); + return clamp01(v); + }; + + const model: WaveModel = { + frame, + get binCount() { return levels.length; }, + resize(width) { + const count = Math.max(2, Math.ceil(width / BIN) + 1); + if (count === levels.length) return; + levels = new Float32Array(count); + target = new Float32Array(count); + timer = new Float32Array(count).map(() => rnd() * 0.2); + }, + step(dt, speed, hueTier) { + const raw = clamp01(speed); + frame.speed += (raw - frame.speed) * ease(dt, 0.06); + const s = frame.speed; + frame.spectrum = smoothstep(0.5, 0.9, s); + // The ribbon drifts faster with speed, from a calm 1x at rest to 5x flat out. + drift = (drift + dt * (1 + 4 * s * s)) % 10000; + swell = (swell + dt * (0.6 + 1.6 * s)) % 10000; + + // A rise in speed kicks the spectrum; it settles over a quarter second. + kick = Math.min(0.6, kick * (1 - ease(dt, 0.25)) + Math.max(0, raw - lastSpeed) * 1.5); + lastSpeed = raw; + + // Bins re-roll their targets, faster with energy; each rises fast + // (attack) and falls back slower (release). Targets are shaped by two + // travelling swells, so the peaks gather into waves. + const energy = smoothstep(0.3, 0.9, s); + const pace = 1.5 - 0.9 * energy; + for (let i = 0; i < levels.length; i += 1) { + timer[i] = timer[i]! - dt; + if (timer[i]! <= 0) { + const shape = 0.5 + 0.3 * Math.sin((i * TAU) / 11 - swell * 2.1) + 0.2 * Math.sin((i * TAU) / 5.3 + swell * 3.3); + target[i] = Math.min(1, energy * (0.06 + Math.pow(rnd(), 1.5)) * (0.3 + 0.9 * shape) + kick); + timer[i] = (0.05 + 0.13 * rnd()) * pace; + } + const level = levels[i]!; + const want = target[i]!; + levels[i] = level + (want - level) * ease(dt, want > level ? 0.035 : 0.09); + } + + // Hue: in over 1.2 s, out over 4 s; the pace eases between tiers. + const on = hueTier >= 1; + hueLinear = on ? Math.min(1, hueLinear + dt / 1.2) : Math.max(0, hueLinear - dt / 4); + frame.hueMix = hueLinear * hueLinear * (3 - 2 * hueLinear); + const wantRate = hueTier >= 0 ? HUE_RATE[Math.min(3, hueTier)]! : hueLinear > 0 ? HUE_RATE[0]! : 0; + hueRate += (wantRate - hueRate) * ease(dt, 0.8); + frame.hueOffset = (frame.hueOffset + hueRate * dt) % 1; + return frame; + }, + still(speed) { + frame.speed = clamp01(speed); + lastSpeed = frame.speed; + // Always the calm ribbon: a silent spectrum would be a flat line. + frame.spectrum = 0; + drift = 0; + kick = 0; + levels.fill(0); + target.fill(0); + hueLinear = 0; + hueRate = 0; + frame.hueMix = 0; + return frame; + }, + axisY(h) { + // Centred for the ribbon; on the floor for the spectrum, so its peaks + // have the whole band to rise into. + return h / 2 + (h / 2 - EDGE - 0.4) * frame.spectrum; + }, + lineY(x, h) { + const m = frame.spectrum; + const half = h / 2 - EDGE; + const amp = half * (0.74 + 0.22 * smoothstep(0.3, 0.7, frame.speed)); + // Each keeps more of itself through the blend than a straight mix, so + // the middle of the dial is lively rather than the two cancelling out. + const up = Math.pow(1 - m, 0.7) * amp * ribbon(x) + Math.pow(m, 0.8) * (h - 2 * EDGE - 0.4) * envelope(x); + return Math.min(h - EDGE, Math.max(EDGE, model.axisY(h) - up)); + }, + level(index) { + return levels[index] ?? 0; + }, + }; + return model; +} + +type Rgb = [number, number, number]; + +function parseColour(value: string): Rgb | null { + const rgb = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(value); + if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]; + const srgb = /color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/.exec(value); + if (srgb) return [Number(srgb[1]) * 255, Number(srgb[2]) * 255, Number(srgb[3]) * 255]; + return null; +} +const css = ([r, g, b]: Rgb): string => `rgb(${Math.round(r)} ${Math.round(g)} ${Math.round(b)})`; +const mix = (a: Rgb, b: Rgb, t: number): Rgb => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; + +export interface NavWave { + dispose(): void; +} + +export function mountNavWave(nav: HTMLElement): NavWave | null { + const slot = nav.querySelector(':scope > .nav-wave'); + if (!slot) return null; + const canvas = document.createElement('canvas'); + canvas.setAttribute('aria-hidden', 'true'); + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + slot.append(canvas); + + const model = createWaveModel(); + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)'); + const forced = window.matchMedia('(forced-colors: active)'); + let width = 0; + let height = 0; + let raf = 0; + let last = 0; + let sinceDraw = 0; + let standard: Rgb = [214, 160, 214]; + let palette: Rgb[] = []; + + // Colours stay in CSS (--bar-wave, --bar-hue-1..4); resolve them through + // the canvas's own color, only on mount and when the dawn or the accent + // (the chapter's --sign-accent, which --bar-wave mixes in) changes. + let accent = ''; + const readColours = (): void => { + const read = (name: string): Rgb | null => { + canvas.style.color = `var(${name})`; + return parseColour(getComputedStyle(canvas).color); + }; + standard = read('--bar-wave') ?? standard; + palette = [1, 2, 3, 4].map((n) => read(`--bar-hue-${n}`)).filter((c): c is Rgb => c !== null); + canvas.style.removeProperty('color'); + accent = nav.style.getPropertyValue('--sign-accent'); + }; + + const inputs = (): [number, number] => { + const speed = Number.parseFloat(slot.style.getPropertyValue('--nav-speed') || nav.style.getPropertyValue('--nav-speed')); + const hue = nav.getAttribute('data-nav-hue'); + return [Number.isFinite(speed) ? speed : 0.37, hue === null ? -1 : Number(hue) || 0]; + }; + + const draw = (): void => { + if (width === 0 || height === 0) return; + const dpr = canvas.width / width; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + const f = model.frame; + let ink: string | CanvasGradient = css(standard); + if (f.hueMix > 0.002 && palette.length === 4) { + // The palette, one period per HUE_PERIOD px, drifting with the offset. + const start = -f.hueOffset * HUE_PERIOD; + const periods = Math.ceil((width - start) / HUE_PERIOD); + const gradient = ctx.createLinearGradient(start, 0, start + periods * HUE_PERIOD, 0); + for (let p = 0; p < periods; p += 1) { + for (let k = 0; k < 4; k += 1) gradient.addColorStop((p + k / 4) / periods, css(mix(standard, palette[k]!, f.hueMix))); + } + gradient.addColorStop(1, css(mix(standard, palette[0]!, f.hueMix))); + ink = gradient; + } + ctx.fillStyle = ink; + ctx.strokeStyle = ink; + const axis = model.axisY(height); + // The axis, faint, so the ribbon reads as a function on a line; at speed + // it is the spectrum's floor. + ctx.globalAlpha = 0.16; + ctx.fillRect(0, axis - 0.5, width, 1); + ctx.beginPath(); + for (let x = 0; x <= width + LINE_STEP; x += LINE_STEP) { + const at = Math.min(x, width); + const y = model.lineY(at, height); + if (x === 0) ctx.moveTo(at, y); + else ctx.lineTo(at, y); + } + ctx.globalAlpha = 0.78; + ctx.lineWidth = 2.6; + ctx.lineJoin = 'round'; + ctx.stroke(); + // A body between the axis and the line: faint for the ribbon, fuller + // for the spectrum, whose silhouette it fills in. + ctx.lineTo(width, axis); + ctx.lineTo(0, axis); + ctx.closePath(); + ctx.globalAlpha = 0.16 + 0.22 * f.spectrum; + ctx.fill(); + ctx.globalAlpha = 1; + }; + + const moving = (): boolean => nav.dataset.motion === 'true' && !reduce.matches && !forced.matches && width > 0; + + const tick = (now: number): void => { + raf = 0; + if (!moving()) return; + const dt = last === 0 ? 1 / 60 : Math.min(0.05, Math.max(0, (now - last) / 1000)); + last = now; + const [speed, hue] = inputs(); + model.step(dt, speed, hue); + // Repaint at most 30 times a second: each repaint costs the page a + // canvas upload, and the eye reads the wave as smooth at that rate. + sinceDraw += dt; + if (sinceDraw >= DRAW_INTERVAL - 0.004) { + sinceDraw = 0; + draw(); + } + raf = window.requestAnimationFrame(tick); + }; + + // A still frame: the wave at its crest, the visualizer at rest. + const still = (): void => { + model.still(inputs()[0]); + draw(); + }; + + const update = (): void => { + if (moving()) { + if (raf === 0) { last = 0; raf = window.requestAnimationFrame(tick); } + } else { + if (raf !== 0) { window.cancelAnimationFrame(raf); raf = 0; } + still(); + } + }; + + const resize = new ResizeObserver(([entry]) => { + if (!entry) return; + width = entry.contentRect.width; + height = entry.contentRect.height; + const dpr = Math.min(2, window.devicePixelRatio || 1); + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + model.resize(width); + // Resizing the canvas clears it: repaint now rather than on the loop's + // next 30 Hz tick, so a resize never shows an empty band. + if (raf === 0) update(); + else draw(); + }); + resize.observe(canvas); + + // Without motion the still frame follows the inputs; with motion the + // loop reads them itself and this only notices data-motion changing, and + // a new chapter's accent (an inline style read, so no style recalc unless + // it changed). + const navObserver = new MutationObserver(() => { + if (nav.style.getPropertyValue('--sign-accent') !== accent) readColours(); + if (raf === 0 || !moving()) update(); + }); + navObserver.observe(nav, { attributes: true, attributeFilter: ['style', 'data-nav-hue', 'data-motion'] }); + navObserver.observe(slot, { attributes: true, attributeFilter: ['style'] }); + const dawnObserver = new MutationObserver(() => { readColours(); if (raf === 0) still(); }); + dawnObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-dawn'] }); + reduce.addEventListener('change', update); + forced.addEventListener('change', update); + + readColours(); + return { + dispose() { + if (raf !== 0) window.cancelAnimationFrame(raf); + raf = 0; + resize.disconnect(); + navObserver.disconnect(); + dawnObserver.disconnect(); + reduce.removeEventListener('change', update); + forced.removeEventListener('change', update); + canvas.remove(); + }, + }; +} diff --git a/tests/performance/budgets.test.ts b/tests/performance/budgets.test.ts index e12addd..b10e6e0 100644 --- a/tests/performance/budgets.test.ts +++ b/tests/performance/budgets.test.ts @@ -74,6 +74,7 @@ describe('release budgets', () => { expect(classifyArtifact('assets/DashcamFrame-c.css', '.dashcam-frame{}')).toBe('deferred-non-video'); expect(classifyArtifact('assets/tab-icon-c.js', 'renderTabIcon')).toBe('deferred-non-video'); expect(classifyArtifact('assets/speedometer-dial-c.js', 'createSpeedometerDial')).toBe('deferred-non-video'); + expect(classifyArtifact('assets/nav-wave-c.js', 'mountNavWave')).toBe('deferred-non-video'); expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u); }); diff --git a/tests/unit/nav-wave.test.ts b/tests/unit/nav-wave.test.ts new file mode 100644 index 0000000..bcf0aae --- /dev/null +++ b/tests/unit/nav-wave.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { createWaveModel } from '@/journey/nav-wave'; + +const run = (model: ReturnType, seconds: number, speed: number, hue = -1): void => { + for (let t = 0; t < seconds; t += 1 / 60) model.step(1 / 60, speed, hue); +}; +const line = (model: ReturnType, h: number, width = 1440, step = 3): number[] => { + const ys: number[] = []; + for (let x = 0; x <= width; x += step) ys.push(model.lineY(x, h)); + return ys; +}; + +describe('nav bar wave', () => { + it('never leaves its band, at any speed or band height', () => { + // One expectation per band rather than per sample: half a million + // expect() calls ran past the timeout on a loaded machine. + for (const h of [18, 14]) { + const model = createWaveModel(); + model.resize(1440); + let low = Infinity; + let high = -Infinity; + for (const speed of [0, 0.37, 0.7, 1, 0.4, 1]) { + for (let k = 0; k < 90; k += 1) { + model.step(1 / 60, speed, -1); + for (const y of line(model, h, 1440, 6)) { + low = Math.min(low, y); + high = Math.max(high, y); + } + } + } + expect(low).toBeGreaterThanOrEqual(1.6); + expect(high).toBeLessThanOrEqual(h - 1.6); + } + }); + + it('is a smooth ribbon at cruise that drifts rather than standing still', () => { + const model = createWaveModel(); + model.resize(1440); + run(model, 2, 0.37); + expect(model.frame.spectrum).toBe(0); + const before = line(model, 18); + // Smooth: samples 3 px apart move by well under a pixel. + for (let i = 1; i < before.length; i += 1) expect(Math.abs(before[i]! - before[i - 1]!)).toBeLessThan(0.6); + // It travels: the highest crest moves along the bar within a second, + // gently (no more than a third of the bar). + const crest = (ys: number[]): number => ys.indexOf(Math.min(...ys)) * 3; + const a = crest(before); + run(model, 1, 0.37); + const moved = Math.abs(crest(line(model, 18)) - a); + expect(moved).toBeGreaterThan(6); + expect(moved).toBeLessThan(480); + }); + + it('is a spectrum flat out: peaks rising from the floor into the band', () => { + const model = createWaveModel(); + model.resize(1440); + run(model, 2, 1); + expect(model.frame.spectrum).toBeGreaterThan(0.99); + const h = 18; + expect(model.axisY(h)).toBeGreaterThan(h - 2.5); + const ys = line(model, h); + const tall = ys.filter((y) => y < h / 2).length; + expect(tall).toBeGreaterThan(ys.length / 5); + // Well-defined peaks: many local maxima, not one blob. + let peaks = 0; + for (let i = 1; i < ys.length - 1; i += 1) if (ys[i]! < ys[i - 1]! && ys[i]! <= ys[i + 1]! && ys[i]! < h - 6) peaks += 1; + expect(peaks).toBeGreaterThan(20); + // Smooth, not blocky: no vertical cliffs between samples 3 px apart. + for (let i = 1; i < ys.length; i += 1) expect(Math.abs(ys[i]! - ys[i - 1]!)).toBeLessThan(6); + }); + + it('follows a change of speed within a fraction of a second', () => { + const model = createWaveModel(); + model.resize(800); + run(model, 1, 0.37); + run(model, 0.2, 1); + expect(model.frame.speed).toBeGreaterThan(0.9); + run(model, 0.2, 0.37); + expect(model.frame.speed).toBeLessThan(0.45); + }); + + it('brings the hue in over 1.2 s and lets it go over 4 s', () => { + const model = createWaveModel(); + model.resize(800); + run(model, 1.25, 1, 3); + expect(model.frame.hueMix).toBe(1); + run(model, 2, 0.4, 0); + expect(model.frame.hueMix).toBeGreaterThan(0.1); + expect(model.frame.hueMix).toBeLessThan(0.9); + run(model, 2.1, 0.4, -1); + expect(model.frame.hueMix).toBe(0); + }); + + it('has a still frame for reduced motion', () => { + const model = createWaveModel(); + model.resize(800); + run(model, 1, 1, 3); + const frame = model.still(1); + expect(frame.hueMix).toBe(0); + expect(frame.spectrum).toBe(0); + expect(model.level(10)).toBe(0); + }); +}); From a505a31f3191f790cc545148c36822eb07e2f0ec Mon Sep 17 00:00:00 2001 From: LucaP Date: Tue, 13 Oct 2026 06:48:33 +0300 Subject: [PATCH 2/5] 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. --- src/App.vue | 1 + src/components/SiteNavigation.vue | 12 + src/composables/useNavBarSignals.ts | 208 ++++++++++++ src/styles/nav-bar.css | 471 ++++++++++++++++++++++++++++ tests/unit/nav-bar-hue.test.ts | 19 ++ 5 files changed, 711 insertions(+) create mode 100644 src/composables/useNavBarSignals.ts create mode 100644 src/styles/nav-bar.css create mode 100644 tests/unit/nav-bar-hue.test.ts diff --git a/src/App.vue b/src/App.vue index c6a01a9..1210378 100644 --- a/src/App.vue +++ b/src/App.vue @@ -13,6 +13,7 @@ import './styles/title-navigation.css'; import './styles/wayfinding.css'; import './styles/route.css'; import './styles/gantry.css'; +import './styles/nav-bar.css'; import './styles/cards.css'; import './styles/maintenance.css'; import './styles/print.css'; diff --git a/src/components/SiteNavigation.vue b/src/components/SiteNavigation.vue index e825372..9a56ba2 100644 --- a/src/components/SiteNavigation.vue +++ b/src/components/SiteNavigation.vue @@ -7,6 +7,7 @@ import type { BeamSample } from "@/journey/headlight-beam"; import type { JourneySectionsBinding } from "@/composables/useJourneySections"; import { useSpeedometer } from "@/composables/useSpeedometer"; import { SIGN_ACCENTS } from "@/journey/sign-routes"; +import { useNavBarSignals } from "@/composables/useNavBarSignals"; const props = defineProps<{ items: ReadonlyArray<{ id: SectionId; label: string }>; @@ -25,6 +26,14 @@ const props = defineProps<{ 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> = { start: "Start", profile: "Profile", @@ -159,6 +168,7 @@ const speedometer = useSpeedometer({ return `${route} \u00b7 ${exit} \u00b7 ${ahead}`; }, }); +useNavBarSignals(speedo, activeIndex); const speedoLabel = computed(() => (speedometer.units.value === "mph" ? "Speedometer in miles per hour. Switch to kilometres per hour" : "Speedometer in kilometres per hour. Switch to miles per hour")); @@ -172,6 +182,8 @@ const navigate = (event: MouseEvent): void => {