diff --git a/README.md b/README.md index 80d48fc..589a3ec 100644 --- a/README.md +++ b/README.md @@ -108,3 +108,7 @@ docker compose up --build -d Если API записывает ранний рейс 00:xx в новый календарный день, ночная логика дополнительно проверяет короткое продолжение предыдущего блока движения и первый длинный перерыв после него. Это позволяет включить режим после реального рейса 00:40 перед первым утренним в 05:44, не скрывая непрерывное ночное движение или дневные паузы. Даты рейсов и условия календаря при этом не изменяются. Схема маршрута использует порядок и временные смещения остановок из полного кеша (`routes[].stops`) и конечную конкретного рейса (`endStopId`). Метаданные загружаются вместе с расписаниями, дополнительных запросов при смене рейса нет. Старые кеши без этих полей остаются пригодны для табло, но схема появляется после обновления полного расписания. Переключатель «Дальше по маршруту» сохраняет прежний ключ `infoServiceEnabled`, поэтому существующие настройки локального и удалённого управления совместимы; миграция БД не требуется. + +В обоих профилях схема анимирует только `transform` и `opacity`: ячейки имеют постоянную ширину, крупный и компактный варианты названия подбираются заранее и не пересоздаются при каждом переходе. В уличном профиле движение спокойнее. Схема не перерисовывается из-за тиков часов, если её данные не изменились; на конечной линия заканчивается у последнего настоящего маркера. + +Для локального сравнения работы подбора шрифтов можно запустить `JOURNEY_PERF=1 node test/fixtures/night-server.mjs`. Параметр `BOARD_FIXTURE_DIST` позволяет использовать сохранённую старую сборку. Наблюдатели пишут `JOURNEY_PERF` в консоль: изменения размеров текстовых блоков, записи размера шрифта и замены заголовков за 1200 мс после смены остановки. Это диагностический стенд, а не замер FPS устройства; он не включён в рабочее табло и Docker runtime. diff --git a/artifacts/journey-motion-960x540.jpg b/artifacts/journey-motion-960x540.jpg new file mode 100644 index 0000000..063402a Binary files /dev/null and b/artifacts/journey-motion-960x540.jpg differ diff --git a/artifacts/verification-journey-motion.md b/artifacts/verification-journey-motion.md new file mode 100644 index 0000000..97f88ef --- /dev/null +++ b/artifacts/verification-journey-motion.md @@ -0,0 +1,37 @@ +# Прокрутка остановок — 11 октября 2026 + +Сообщение пользователя: тормозит на Xiaomi Mi Box 4 в Hi Browser в обоих профилях. Устройство не подключено к ADB (список устройств пуст). На физическом Mi Box FPS не измерялся; результат ниже — исходный код, сравнительные счётчики в Codex in-app browser и проверка компоновки. + +## Причина и изменение + +В `src/styles.css` ширина ячеек анимировалась через `flex-basis`. Каждый промежуточный размер вызывал `ResizeObserver` в `FittedDestination`, измерение текста canvas и подбор шрифта с чтением/записью геометрии. Заголовки дополнительно пересоздавались при смене роли «крупный/компактный». + +В `src/NextStops.tsx` выделен мемоизированный `JourneyRail`. Ячейки всегда одинаковой ширины, каждый заголовок имеет два постоянных слоя с заранее подобранным шрифтом. Положение ячеек, увеличение названия и длина линии меняются через `transform`, видимость слоёв — через `opacity`. Коэффициент увеличения согласован со свободным расстоянием до соседней остановки. Анимации ширины, цвета текста и тени маркера удалены. Во время обычного перехода размеры текста не меняются. Тики часов не запускают рендер схемы при неизменных данных; названия, минуты, количество остановок и продолжение сравниваются перед пропуском рендера. + +Это соответствует [рекомендациям web.dev по анимациям](https://web.dev/articles/animations-guide) и [предотвращению повторных синхронных перерасчётов layout](https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing). Фактический эффект на GPU конкретного браузера требует проверки на устройстве. + +## Сравнение + +Одинаковый стенд: 960×540, тёмная тема, расстояние «Близко», Автовокзал, фильтр №4. Сохранённое публичное расписание, тестовые часы/погода. Старая производственная сборка сохранена до правки; новая сравнивалась с ней через те же наблюдатели. По три перехода в каждом профиле, окно 1200 мс после смены остановки. Скрипт не меняет прототипы и состояние приложения, не передаёт данные наружу. Счётчики относятся только к названиям остановок, а не ко всему layout или FPS. + +| Профиль / переход | Изменения размеров до → после | Записи шрифта до → после | Замены заголовков до → после | +| --- | ---: | ---: | ---: | +| ТВ, 1 → 2 | 110 → 0 | 842 → 0 | 2 → 0 | +| ТВ, 2 → 3 | 110 → 0 | 632 → 0 | 2 → 0 | +| ТВ, 3 → 4 | 110 → 0 | 838 → 0 | 2 → 0 | +| Уличное табло, 1 → 2 | 38 → 0 | 82 → 0 | 2 → 0 | +| Уличное табло, 2 → 3 | 38 → 0 | 160 → 0 | 2 → 0 | +| Уличное табло, 3 → 4 | 38 → 0 | 241 → 0 | 2 → 0 | + +## Поведение + +- Сохранены интервалы 4/6 секунд и движение 900/300 мс для ТВ/уличного профиля. Пауза прекращает смену остановок. +- Переход 4 → 1 выполняет мягкое появление, без обратного движения всей схемы. +- В промежуточных кадрах ТВ: ширина ячейки постоянна **189,29 px**, высота области названия **51 px**, размер шрифта **41,91 px**. Матрица масштаба меняется 0,871 → 0,984 → 1, зазор до соседней остановки — 24,38 → 27,55 → 28,00 px. Наложения нет. +- На конечной демонстрационного маршрута без продолжения: длина линии **0 px**, её начало и центр последнего маркера совпадают (65,797 / 65,796 px). Ложного продолжения нет. +- 390×667: длинное название «Больница (Новка)» целиком, высота 72,42 px при доступных 76 px; кружок 22 px. +- 1920×1080, три карточки: тот же текст высотой 129,17 px при доступных 239 px; кружок 24 px. +- Accessibility показывает по одному названию остановки: второй визуальный слой скрыт через `aria-hidden`. +- Настройки и API не расширялись. Режим reduced motion отключает переходы и освобождает подсказки слоёв. Счётчики тестового стенда отсутствуют в production bundle и Docker runtime. + +Проверки: `npm run build` (TypeScript + Vite), 10 тестов маршрута/информационных блоков, синтаксис двух файлов стенда и `git diff --check` прошли. Снимок — `journey-motion-960x540.jpg`. Для обновления серверного табло необходимо развернуть файлы и пересобрать образ. diff --git a/src/NextStops.tsx b/src/NextStops.tsx index ba2a79b..5d3986e 100644 --- a/src/NextStops.tsx +++ b/src/NextStops.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { NumericText } from './NumericText'; import { RouteBadge } from './RouteBadge'; import { FittedDestination } from './FittedDestination'; @@ -9,6 +9,54 @@ import type { OnwardJourney } from './lib/journey'; import type { Departure, Settings } from './types'; import type { CSSProperties } from 'react'; +// Fixed-size cells move on the compositor. Both text sizes are fitted once, +// so moving to the next stop never resizes a cell or remounts its heading. +const JourneyRail = memo(function JourneyRail({ journey, focus }: { journey: OnwardJourney; focus: number }) { + const rail = useRef(null); + const [geometry, setGeometry] = useState({ slot: 0, focused: 0, width: 0 }); + useLayoutEffect(() => { + const element = rail.current; + if (!element) return; + const measure = () => { + const cell = element.querySelector('.journey-station'); + const heading = cell?.querySelector('.journey-name-focus'); + const next = { slot: cell?.getBoundingClientRect().width || 0, + focused: heading ? parseFloat(getComputedStyle(heading).width) : 0, width: element.getBoundingClientRect().width }; + setGeometry(previous => previous.slot === next.slot && previous.focused === next.focused && previous.width === next.width ? previous : next); + }; + const observer = new ResizeObserver(measure); + observer.observe(element); measure(); + return () => observer.disconnect(); + }, [journey.stops.length]); + // With no continuation the line ends at the last real stop, including a + // single-stop journey. Otherwise it meets the moving dashed continuation. + const remaining = journey.stops.length - 1 - focus; + const lineEnd = !journey.more && !remaining ? 0 + : (remaining + (journey.more ? 1 : 0)) * geometry.slot + geometry.focused - geometry.slot; + const scale = geometry.width > 24 && geometry.slot > 0 ? Math.max(0, lineEnd / (geometry.width - 24)) : 1; + return
0 ? geometry.slot / geometry.focused : .4 } as CSSProperties}> +
; +}, (previous, next) => previous.focus === next.focus && previous.journey.more === next.journey.more + && previous.journey.stops.length === next.journey.stops.length && previous.journey.stops.every((stop, index) => { + const other = next.journey.stops[index]; + return stop.id === other.id && stop.name === other.name && stop.minutes === other.minutes; + })); + export function NextStops({ departure, journey, loading, now, paused, settings, active, onPages }: { departure?: Departure; journey: OnwardJourney | null; loading: boolean; now: number; paused: boolean; settings: Settings; active: boolean; onPages: (pages: number) => void; @@ -30,18 +78,9 @@ export function NextStops({ departure, journey, loading, now, paused, settings, {!departure ?

{loading ? 'Получаем расписание' : 'Нет доступного рейса'}

: <>
{!journey ?

{loading ? 'Загружаем остановки' : 'Схема маршрута пока недоступна'}

: !count ?

Здесь конечная этого рейса

: <> -
0} data-loop={page > 0 && focus === 0}> +
0 && focus === 0}>
{settings.demo ? 'Демонстрация поездки' : 'После посадки'}{focus + 1}-я остановка
-
    - {journey.stops.map((stop, index) =>
  1. - - {stop.minutes === null ? '—' : stop.minutes === 0 ? <><1мин : durationParts(stop.minutes).map(part => {part.unit})} - -
    -
  2. )} - {journey.more > 0 &&
  3. Ещё {journey.more} остановок
  4. } -
+
Время пути по расписаниюПосле посадки · по расписанию{pages > 1 && {focus + 1} / {count}}
} diff --git a/src/styles.css b/src/styles.css index f2a6238..5a6d1ec 100644 --- a/src/styles.css +++ b/src/styles.css @@ -116,25 +116,27 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1 .journey-order { color: var(--accent); font-size: .85em; white-space: nowrap; } .journey-map-space { flex: 1 1 0; min-height: 0; display: flex; align-items: center; margin: 18px -8px 0; } .journey-rail-window { width: 100%; height: min(100%, 320px); position: relative; overflow: hidden; } -.journey-rail-window::after { content: ''; position: absolute; inset: 0 0 0 auto; width: 36px; background: linear-gradient(90deg, transparent, var(--background)); pointer-events: none; } -.journey-rail { display: flex; height: 100%; margin: 0; padding: 0 0 0 24px; list-style: none; transform: translateX(calc(-1 * var(--journey-focus) * var(--station-width))); transition: transform 900ms cubic-bezier(.22,.72,.18,1); } -.journey-station { flex: 0 0 var(--station-width); min-width: 0; position: relative; display: grid; grid-template-rows: auto minmax(0, 1fr) 40px; gap: 10px; padding-right: 28px; transition: flex-basis 900ms cubic-bezier(.22,.72,.18,1); } -.journey-station[data-focused=true] { flex-basis: var(--focused-width); } -.journey-station::after { content: ''; position: absolute; bottom: 17.5px; left: 0; width: 100%; height: 5px; background: var(--accent); transition: opacity 700ms ease; } -.journey-station:last-child::after { width: calc(var(--station-width) - 36px); } -.journey-metro[data-more=false] .journey-station:last-child::after { display: none; } -.journey-station[data-before=true]::after { opacity: .25; } -.journey-station-name { min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: flex-end; padding-bottom: 8px; transition: opacity 500ms ease; } -.journey-station[data-before=true] .journey-station-name, .journey-station[data-before=true] .journey-duration { opacity: 0; } +.journey-rail-window::after { content: ''; position: absolute; inset: 0 0 0 auto; width: 36px; z-index: 3; background: linear-gradient(90deg, transparent, var(--background)); pointer-events: none; } +.journey-rail { position: relative; display: flex; height: 100%; margin: 0; padding: 0 0 0 24px; list-style: none; } +.journey-line { position: absolute; left: 24px; bottom: 17.5px; width: calc(100% - 24px); height: 5px; background: var(--accent); transform-origin: left center; transition: transform 900ms cubic-bezier(.22,.72,.18,1); } +.journey-station, .journey-continuation { --journey-space: 0px; transform: translate3d(calc(-1 * var(--journey-focus) * var(--station-width) + var(--journey-space)), 0, 0); transition: transform 900ms cubic-bezier(.22,.72,.18,1); } +.journey-station { flex: 0 0 var(--station-width); min-width: 0; position: relative; display: grid; grid-template-rows: auto minmax(0, 1fr) 40px; gap: 10px; } +.journey-station[data-after=true], .journey-continuation { --journey-space: calc(var(--focused-width) - var(--station-width)); } +.journey-metro[data-active=true] .journey-station, .journey-metro[data-active=true] .journey-continuation, .journey-metro[data-active=true] .journey-line { will-change: transform; } +.journey-station-name { min-width: 0; min-height: 0; position: relative; } +.journey-name-layer { position: absolute; inset: 0 auto 0 0; display: flex; flex-direction: column; justify-content: flex-end; padding: 0 28px 8px 0; opacity: 0; transition: opacity 300ms ease-out, transform 900ms cubic-bezier(.22,.72,.18,1); } +.journey-name-context { width: var(--station-width); } +.journey-name-focus { width: var(--focused-width); transform: translate3d(0, 0, 0) scale(var(--journey-name-scale, .4)); transform-origin: left bottom; } +.journey-station[data-focused=true] .journey-name-focus { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } +.journey-station[data-focused=false][data-before=false] .journey-name-context { opacity: 1; } +.journey-station[data-before=true] .journey-duration { opacity: 0; } .service-info .journey-station-name .destination-heading { flex-shrink: 0; margin: 0; font-size: calc(clamp(22px, 4cqw, 32px) * var(--reading-scale, 1)); line-height: 1.13; font-weight: 600; letter-spacing: -.035em; overflow-wrap: anywhere; transition: color 600ms ease; } -.service-info .journey-station[data-focused=true] .destination-heading { font-size: calc(clamp(48px, 11cqw, 96px) * var(--reading-scale, 1)); font-weight: 650; letter-spacing: -.055em; color: var(--foreground); } -.journey-station[data-focused=true] .destination-current { transform-origin: left bottom; animation: journey-name-in 900ms cubic-bezier(.22,.72,.18,1) both; } -@keyframes journey-name-in { from { opacity: 0; transform: translateX(20px) scale(.84); } to { opacity: 1; transform: translateX(0) scale(1); } } -.journey-duration { align-self: start; display: flex; align-items: baseline; gap: 8px; color: var(--muted); font-size: clamp(18px, 3.4cqw, 26px); font-weight: 550; line-height: 1; white-space: nowrap; transition: color 600ms ease, opacity 500ms ease; } +.service-info .journey-name-focus .destination-heading { font-size: calc(clamp(48px, 11cqw, 96px) * var(--reading-scale, 1)); font-weight: 650; letter-spacing: -.055em; color: var(--foreground); } +.journey-duration { align-self: start; display: flex; align-items: baseline; gap: 8px; color: var(--muted); font-size: clamp(24px, 4.5cqw, 36px); font-weight: 550; line-height: 1; white-space: nowrap; transition: opacity 300ms ease-out; } .journey-duration > span { display: inline-flex; align-items: baseline; gap: 5px; } .journey-duration small { font-size: .72em; font-weight: 500; } -.journey-station[data-focused=true] .journey-duration { color: var(--accent); font-size: clamp(24px, 4.5cqw, 36px); } -.journey-node { align-self: center; display: block; width: 24px; height: 24px; border: 4px solid var(--accent); border-radius: 50%; background: var(--background); margin-left: -12px; z-index: 1; transition: background 600ms ease, box-shadow 600ms ease; } +.journey-station[data-focused=true] .journey-duration { color: var(--accent); } +.journey-node { align-self: center; display: block; width: 24px; height: 24px; border: 4px solid var(--accent); border-radius: 50%; background: var(--background); margin-left: -12px; z-index: 1; } .journey-station[data-focused=true] .journey-node { background: var(--accent); box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 18%, transparent); } .journey-station[data-before=true] .journey-node { border-color: var(--outline); } .journey-continuation { flex: 0 0 var(--station-width); min-width: 0; display: flex; align-items: center; position: relative; padding: 34px 20px 40px 0; } @@ -143,19 +145,18 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1 .journey-caption { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: space-between; color: var(--muted); font-size: 16px; line-height: 1.3; padding-top: 16px; } .journey-caption-compact { display: none; } .journey-page { color: var(--accent); margin-left: auto; } -.journey-metro[data-calm=true] .journey-rail { transition: transform 300ms ease-out; } -.journey-metro[data-calm=true] .journey-station { transition: flex-basis 300ms ease-out; } -.journey-metro[data-calm=true] .destination-heading { transition: color 250ms ease-out; } -.journey-metro[data-calm=true] .destination-current { animation: none; } -.journey-metro[data-loop=true] .journey-rail { transition: none; animation: journey-overview-reset 600ms ease-out both; } -.journey-metro[data-loop=true] .journey-station { transition: none; } +.journey-metro[data-calm=true] .journey-station, .journey-metro[data-calm=true] .journey-continuation, .journey-metro[data-calm=true] .journey-line { transition: transform 300ms ease-out; } +.journey-metro[data-calm=true] .journey-name-layer { transition: opacity 180ms ease-out, transform 300ms ease-out; } +.journey-metro[data-loop=true] .journey-rail-window { animation: journey-overview-reset 600ms ease-out both; } +.journey-metro[data-calm=true][data-loop=true] .journey-rail-window { animation-duration: 180ms; } +.journey-metro[data-loop=true] .journey-station, .journey-metro[data-loop=true] .journey-continuation, .journey-metro[data-loop=true] .journey-line, .journey-metro[data-loop=true] .journey-name-layer { transition: none; } @keyframes journey-overview-reset { from { opacity: 0; } to { opacity: 1; } } @container journey-card (max-width: 380px) { .journey-trip { display: block; } .journey-trip time { margin-top: 7px; } .journey-metro { --station-width: 140px; } - .service-info .journey-station[data-focused=true] .destination-heading { font-size: calc(clamp(34px, 11cqw, 46px) * var(--reading-scale, 1)); } - .journey-station { padding-right: 22px; } + .service-info .journey-name-focus .destination-heading { font-size: calc(clamp(34px, 11cqw, 46px) * var(--reading-scale, 1)); } + .journey-name-layer { padding-right: 22px; } .journey-tour-heading { font-size: 17px; } .journey-caption { font-size: 14px; gap: 4px 10px; } } @@ -167,10 +168,9 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1 .journey-metro { padding-top: 12px; } .journey-map-space { margin-top: 10px; } .journey-station { grid-template-rows: auto minmax(0, 1fr) 32px; gap: 6px; } - .journey-station-name { padding-bottom: 4px; } - .journey-duration { font-size: 22px; } - .journey-station[data-focused=true] .journey-duration { font-size: 26px; } - .journey-station::after { bottom: 14px; height: 4px; } + .journey-name-layer { padding-bottom: 4px; } + .journey-duration { font-size: 26px; } + .journey-line { bottom: 14px; height: 4px; } .journey-node { width: 22px; height: 22px; margin-left: -11px; border-width: 3px; } .journey-continuation { padding-top: 26px; padding-bottom: 32px; } .journey-continuation-line { bottom: 14px; border-top-width: 4px; } @@ -189,8 +189,8 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1 .journey-route { display: none; } } @media (prefers-reduced-motion: reduce) { - .journey-rail, .service-info .journey-station .destination-heading, .journey-station, .journey-station-name, .journey-duration, .journey-station::after, .journey-node { transition: none; } - .journey-metro .destination-current, .journey-metro[data-loop=true] .journey-rail { animation: none; } + .journey-station, .journey-continuation, .journey-line, .journey-name-layer, .journey-duration, .service-info .journey-station .destination-heading { transition: none !important; will-change: auto !important; } + .journey-metro .destination-current, .journey-metro[data-loop=true] .journey-rail-window { animation: none; } } .weather-info { container-name: weather-card; } .weather-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); align-content: center; align-items: center; justify-items: center; gap: 24px; } diff --git a/test/fixtures/journey-perf.js b/test/fixtures/journey-perf.js new file mode 100644 index 0000000..78e10ee --- /dev/null +++ b/test/fixtures/journey-perf.js @@ -0,0 +1,38 @@ +// Test-only observers: no prototype patches, production telemetry or state changes. +// Logs layout work over 1200 ms after each automatic stop change, not target FPS. +let focus = null, run = null; +const boxes = new Set(); +const resize = new ResizeObserver(entries => { + if (run) run.nameResizes += entries.filter(entry => boxes.has(entry.target)).length; +}); +const mutations = new MutationObserver(records => { + const cells = Array.from(document.querySelectorAll('.service-info[data-active="true"] .journey-station')); + const next = cells.findIndex(cell => cell.getAttribute('data-focused') === 'true'); + if (next < 0) { focus = null; return; } + if (focus !== null && focus !== next) { + if (run) { clearTimeout(run.timer); finish(); } + run = { from: focus + 1, to: next + 1, profile: document.querySelector('.board')?.getAttribute('data-profile'), + nameResizes: 0, fontWrites: 0, headingReplacements: 0, started: performance.now(), timer: null }; + run.timer = setTimeout(finish, 1200); + } + focus = next; + for (const box of document.querySelectorAll('.service-info[data-active="true"] .journey-station-name')) { + if (!boxes.has(box)) { boxes.add(box); resize.observe(box); } + } + for (const box of boxes) if (!document.contains(box)) { resize.unobserve(box); boxes.delete(box); } + if (!run) return; + for (const record of records) { + if (record.type === 'attributes' && record.attributeName === 'style' + && record.target.matches('.journey-station-name .destination-heading')) run.fontWrites++; + if (record.type === 'childList' && record.target.matches('.journey-station-name')) { + run.headingReplacements += Array.from(record.removedNodes).filter(node => node.nodeType === 1 && node.matches('.destination-heading')).length; + } + } +}); +function finish() { + if (!run) return; + const { timer, started, ...result } = run; + run = null; + console.info('JOURNEY_PERF ' + JSON.stringify({ ...result, windowMs: Math.round(performance.now() - started) })); +} +mutations.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['data-focused', 'style'] }); diff --git a/test/fixtures/night-server.mjs b/test/fixtures/night-server.mjs index 61cd9ab..010bd11 100644 --- a/test/fixtures/night-server.mjs +++ b/test/fixtures/night-server.mjs @@ -30,5 +30,11 @@ app.use(createApp(client,{weather:async(cityId,stopId)=>({cityId,stopId,updatedA app.use('/__fixture',express.urlencoded({extended:false})); app.post('/__fixture',(req,res)=>{if(scenarios[req.body.scenario]){scenario=req.body.scenario;started=Date.now();}if(req.body.alerts!==undefined)alertsOn=req.body.alerts==='true';if(req.body.expired!==undefined)expired=req.body.expired==='true';res.redirect(303,'/__fixture');}); app.get('/__fixture',(_req,res)=>res.type('html').send(`

Тестовые сценарии ночного режима

Текущий: ${scenario}; уведомление: ${alertsOn}; истёкший кеш: ${expired}

${Object.keys(scenarios).map(s=>``).join('')}

Часы приложения обновятся при опросе (5 секунд). После смены полного расписания обновите страницу табло.

`)); -const dist=fileURLToPath(new URL('../../dist/',import.meta.url));app.use(express.static(dist));app.get('/{*path}',(_req,res)=>res.sendFile(`${dist}/index.html`)); +// Optional local-only counters compare the saved and current production builds. +const perf=process.env.JOURNEY_PERF==='1'; +const dist=process.env.BOARD_FIXTURE_DIST || fileURLToPath(new URL('../../dist/',import.meta.url)); +if(perf)app.get('/__journey-perf.js',(_req,res)=>res.type('js').send(readFileSync(fileURLToPath(new URL('./journey-perf.js',import.meta.url)),'utf8'))); +app.use(express.static(dist,{index:!perf}));app.get('/{*path}',(_req,res)=>perf + ? res.type('html').send(readFileSync(`${dist}/index.html`,'utf8').replace('','')) + : res.sendFile(`${dist}/index.html`)); await new Promise(resolve=>app.listen(5193,'127.0.0.1',resolve));console.log('Night fixture http://127.0.0.1:5193/ ; controls /__fixture');