@motionstudies/web 0.1.0-alpha.2 → 0.1.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +231 -2
  2. package/components/AirportBoard.d.ts +27 -0
  3. package/components/AirportBoard.js +28 -0
  4. package/components/AirportHeroCard.d.ts +60 -0
  5. package/components/AirportHeroCard.js +31 -0
  6. package/components/BusStopHeroCard.d.ts +37 -0
  7. package/components/BusStopHeroCard.js +14 -0
  8. package/components/DotMatrixBoard.d.ts +31 -0
  9. package/components/DotMatrixBoard.js +84 -0
  10. package/components/LazyButtonTooltips.d.ts +2 -0
  11. package/components/LazyButtonTooltips.js +8 -0
  12. package/components/NetworkVehicleHeroCard.d.ts +13 -0
  13. package/components/NetworkVehicleHeroCard.js +13 -0
  14. package/components/RailStationHeroCard.d.ts +46 -0
  15. package/components/RailStationHeroCard.js +20 -0
  16. package/components/SbbDepartureBoard.d.ts +26 -0
  17. package/components/SbbDepartureBoard.js +29 -0
  18. package/components/SplitFlapBoard.d.ts +25 -0
  19. package/components/SplitFlapBoard.js +58 -0
  20. package/components/TransportHeroBoard.d.ts +28 -0
  21. package/components/TransportHeroBoard.js +15 -0
  22. package/components/VehicleHeroCard.d.ts +51 -0
  23. package/components/VehicleHeroCard.js +21 -0
  24. package/components/airport-hero-card.css +68 -0
  25. package/components/dot-matrix-board.css +49 -0
  26. package/components/dot-matrix-font.d.ts +2 -0
  27. package/components/dot-matrix-font.js +61 -0
  28. package/components/sbb-departure-board.css +49 -0
  29. package/components/split-flap-board.css +45 -0
  30. package/components/transport-hero-cards.css +39 -0
  31. package/components/vehicle-hero-card.css +64 -0
  32. package/fonts/DM-Mono-OFL.txt +93 -0
  33. package/fonts/Inter-OFL.txt +93 -0
  34. package/fonts/dm-mono-latin-300.woff2 +0 -0
  35. package/fonts/dm-mono-latin-400.woff2 +0 -0
  36. package/fonts/dm-mono-latin-500.woff2 +0 -0
  37. package/fonts/dm-mono-latin-ext-300.woff2 +0 -0
  38. package/fonts/dm-mono-latin-ext-400.woff2 +0 -0
  39. package/fonts/dm-mono-latin-ext-500.woff2 +0 -0
  40. package/fonts/inter-cyrillic-400-600.woff2 +0 -0
  41. package/fonts/inter-cyrillic-ext-400-600.woff2 +0 -0
  42. package/fonts/inter-greek-400-600.woff2 +0 -0
  43. package/fonts/inter-greek-ext-400-600.woff2 +0 -0
  44. package/fonts/inter-latin-400-600.woff2 +0 -0
  45. package/fonts/inter-latin-ext-400-600.woff2 +0 -0
  46. package/fonts/inter-vietnamese-400-600.woff2 +0 -0
  47. package/fonts.css +105 -0
  48. package/mount-motion-study.d.ts +7 -2
  49. package/mount-motion-study.js +2 -2
  50. package/package.json +82 -3
  51. package/study-layout.css +65 -0
  52. package/use-airport-feed.d.ts +9 -0
  53. package/use-airport-feed.js +72 -0
  54. package/use-browser-location.d.ts +13 -0
  55. package/use-browser-location.js +39 -0
  56. package/use-json-asset.d.ts +9 -0
  57. package/use-json-asset.js +45 -0
  58. package/use-now-clock.d.ts +11 -0
  59. package/use-now-clock.js +45 -0
  60. package/use-pattern-network-day.d.ts +17 -0
  61. package/use-pattern-network-day.js +41 -0
  62. package/use-progressive-air-day.d.ts +1 -0
  63. package/use-progressive-chunks.d.ts +3 -0
  64. package/use-progressive-chunks.js +13 -4
  65. package/use-progressive-network-day.d.ts +1 -0
  66. package/use-progressive-road-study.d.ts +1 -0
  67. package/use-transition-value.d.ts +12 -0
  68. package/use-transition-value.js +46 -0
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef, useState } from 'react';
3
+ import { matrixPath } from './dot-matrix-font.js';
4
+ function bounded(value, fallback, min, max) {
5
+ return Number.isFinite(value) ? Math.max(min, Math.min(max, Math.floor(value))) : fallback;
6
+ }
7
+ function MatrixText({ value, characters, align = 'left', preserveCase = false }) {
8
+ const patternId = useId();
9
+ const letters = Array.from(preserveCase ? value : value.toLocaleUpperCase());
10
+ const visible = letters.length > characters ? [...letters.slice(0, characters - 1), '…'] : letters;
11
+ const text = visible.join('');
12
+ const path = matrixPath(text);
13
+ const offset = align === 'right' ? (characters - visible.length) * 6 : 0;
14
+ return _jsxs("span", { className: "ms-matrix-text", title: value, children: [_jsx("span", { className: "ms-matrix-sr-only", children: value }), _jsxs("svg", { "aria-hidden": "true", viewBox: `0 0 ${characters * 6} 7`, preserveAspectRatio: align === 'right' ? 'xMaxYMid meet' : 'xMinYMid meet', children: [_jsx("defs", { children: _jsx("pattern", { id: patternId, width: "1", height: "1", patternUnits: "userSpaceOnUse", children: _jsx("circle", { cx: ".5", cy: ".5", r: ".36" }) }) }), _jsx("rect", { width: "100%", height: "7", fill: `url(#${patternId})`, className: "ms-matrix-text__unlit" }), path === undefined
15
+ ? _jsx("text", { x: align === 'right' ? characters * 6 : 0, y: "6.2", textAnchor: align === 'right' ? 'end' : 'start', fontSize: "7", fontFamily: "ui-monospace, monospace", textLength: Math.max(1, visible.length * 6 - 1), lengthAdjust: "spacingAndGlyphs", fill: "currentColor", children: text })
16
+ : _jsx("path", { transform: `translate(${offset},0)`, d: path, fill: "currentColor" })] })] });
17
+ }
18
+ /** The consumer owns row order, arrival estimates and freshness, as with SplitFlapBoard. */
19
+ export function DotMatrixBoard({ label, columns, rows, lineCount = 6, minRowHeight = 34, loading = false, loadingMessage = 'Loading departures…', emptyMessage = 'No departures.', onSelectRow, selectedRowId, selectionColumn, className = '', style, variant = 'bus', heading, headingColumnSpan = 1, footerLabel, clockLabel }) {
20
+ const screen = useRef(null);
21
+ const boardId = useId();
22
+ const [size, setSize] = useState({ width: 640, height: 244 });
23
+ useEffect(() => {
24
+ const element = screen.current;
25
+ if (!element)
26
+ return;
27
+ const observer = new ResizeObserver(([entry]) => {
28
+ const width = Math.floor(entry.contentRect.width);
29
+ const height = Math.floor(entry.contentRect.height);
30
+ setSize((previous) => previous.width === width && previous.height === height ? previous : { width, height });
31
+ });
32
+ observer.observe(element);
33
+ return () => observer.disconnect();
34
+ }, []);
35
+ const count = lineCount === 'auto'
36
+ ? bounded(size.height / bounded(minRowHeight, 34, 20, 120), 6, 1, 30)
37
+ : bounded(lineCount, 6, 1, 30);
38
+ const rowHeight = size.height / count;
39
+ const weights = columns.map((column) => bounded(column.characters, 12, 1, 80));
40
+ const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
41
+ const characterWidth = Math.min(15, Math.max(7, rowHeight * .45), Math.max(9, (size.width - columns.length * 16) / Math.max(1, totalWeight)));
42
+ const minimums = columns.map((column) => bounded(column.minCharacters ?? 3, 3, 1, 40) * characterWidth + 16);
43
+ const totalMinimum = minimums.reduce((sum, width) => sum + width, 0);
44
+ const widths = columns.map((_, index) => size.width < totalMinimum
45
+ ? size.width * minimums[index] / totalMinimum
46
+ : minimums[index] + (size.width - totalMinimum) * weights[index] / totalWeight);
47
+ const empty = !loading && rows.length === 0;
48
+ const message = loading ? loadingMessage : empty ? emptyMessage : '';
49
+ const rail = variant === 'uk-rail';
50
+ const span = bounded(headingColumnSpan, 1, 1, Math.max(1, columns.length));
51
+ const capacity = (width) => bounded((width - 16) / characterWidth, 1, 1, 80);
52
+ const slots = [];
53
+ if (!loading && !empty) {
54
+ for (const row of rows) {
55
+ if (slots.length >= count)
56
+ break;
57
+ // Keep a departure and its detail together unless the board has only one slot.
58
+ if (row.note && count > 1 && slots.length === count - 1)
59
+ break;
60
+ slots.push({ row });
61
+ if (row.note && slots.length < count)
62
+ slots.push({ row, detail: true });
63
+ }
64
+ }
65
+ while (slots.length < count)
66
+ slots.push({});
67
+ return _jsxs("div", { className: `ms-dot-matrix-board ${className}`, style: { ...style, '--matrix-text-height': `${Math.max(0, Math.min(26, rowHeight - 8))}px` }, "data-variant": variant, "data-lines": count, "data-loading": loading || undefined, children: [_jsx("div", { className: "ms-dot-matrix-board__head", "aria-hidden": "true", children: columns.map((column, index) => {
68
+ if (heading && index > 0 && index < span)
69
+ return null;
70
+ const width = heading && index === 0 ? widths.slice(0, span).reduce((sum, value) => sum + value, 0) : widths[index];
71
+ const value = heading && index === 0 ? heading : column.label;
72
+ return _jsx("span", { className: heading && index === 0 ? 'ms-dot-matrix-board__heading' : undefined, style: { width: `${size.width ? width / size.width * 100 : 0}%`, textAlign: column.align }, children: rail ? _jsx(MatrixText, { value: value, characters: capacity(width), align: column.align, preserveCase: true }) : value }, column.key);
73
+ }) }), _jsxs("div", { className: "ms-dot-matrix-board__screen", ref: screen, children: [_jsxs("table", { "aria-busy": loading, children: [_jsx("caption", { className: "ms-matrix-sr-only", children: label }), _jsx("colgroup", { children: columns.map((column, index) => _jsx("col", { style: { width: `${size.width ? widths[index] / size.width * 100 : 0}%` } }, column.key)) }), _jsx("thead", { className: "ms-matrix-sr-only", children: _jsx("tr", { children: columns.map((column) => _jsx("th", { scope: "col", children: column.label }, column.key)) }) }), _jsx("tbody", { children: slots.map(({ row, detail }, index) => {
74
+ const noteId = `${boardId}-note-${index}`;
75
+ if (detail && row)
76
+ return _jsx("tr", { className: "ms-dot-matrix-board__detail", "aria-hidden": "true", style: { height: `${100 / count}%` }, children: _jsx("td", { colSpan: columns.length, children: _jsx(MatrixText, { value: row.note, characters: capacity(size.width), preserveCase: rail }) }) }, `${row.id}-detail`);
77
+ return _jsx("tr", { "aria-hidden": !row || undefined, "data-tone": row?.tone, "data-selected": row && row.id === selectedRowId || undefined, style: { height: `${100 / count}%` }, children: columns.map((column, columnIndex) => {
78
+ const text = _jsx(MatrixText, { value: row ? row.cells[column.key] || '—' : '', characters: capacity(widths[columnIndex]), align: column.align, preserveCase: rail });
79
+ const selection = selectionColumn ? column.key === selectionColumn : columnIndex === 0;
80
+ return _jsxs("td", { children: [row && onSelectRow && selection
81
+ ? _jsx("button", { type: "button", "aria-pressed": row.id === selectedRowId, "aria-describedby": row.note ? noteId : undefined, onClick: () => onSelectRow(row.id), children: text }) : text, row?.note && selection && _jsx("span", { className: "ms-matrix-sr-only", id: noteId, children: row.note })] }, column.key);
82
+ }) }, row?.id ?? `blank-${index}`);
83
+ }) })] }), message && _jsx("div", { className: "ms-dot-matrix-board__message", role: "status", children: message })] }), (footerLabel || clockLabel) && _jsxs("div", { className: "ms-dot-matrix-board__footer", children: [footerLabel && _jsx(MatrixText, { value: footerLabel, characters: capacity(size.width * .6), preserveCase: rail }), clockLabel && _jsx("div", { className: "ms-dot-matrix-board__clock", children: _jsx(MatrixText, { value: clockLabel, characters: Math.max(1, Array.from(clockLabel).length), align: "right", preserveCase: rail }) })] })] });
84
+ }
@@ -0,0 +1,2 @@
1
+ /** Button help in its own Suspense boundary, so the application never waits for it. */
2
+ export declare function LazyButtonTooltips(): import("react").JSX.Element;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Suspense, lazy } from 'react';
3
+ // Desktop button help is not needed for the first view; keep it out of the opening bundle.
4
+ const ButtonTooltips = lazy(() => import('./ButtonTooltips.js').then((module) => ({ default: module.ButtonTooltips })));
5
+ /** Button help in its own Suspense boundary, so the application never waits for it. */
6
+ export function LazyButtonTooltips() {
7
+ return _jsx(Suspense, { fallback: null, children: _jsx(ButtonTooltips, {}) });
8
+ }
@@ -0,0 +1,13 @@
1
+ import { type NetworkSnapshot, type NetworkTrain } from '@motionstudies/core/domain/network';
2
+ import { type VehicleHeroCardProps } from './VehicleHeroCard.tsx';
3
+ export interface NetworkVehicleHeroCardProps extends Omit<VehicleHeroCardProps, 'vehicle' | 'stops'> {
4
+ readonly snapshot: NetworkSnapshot;
5
+ readonly train: NetworkTrain;
6
+ readonly time: number;
7
+ /** Hide clock values for frequency-based or otherwise approximate motion. */
8
+ readonly showTimes?: boolean;
9
+ readonly atStopLabel?: string;
10
+ readonly formatTime?: (time: number) => string;
11
+ }
12
+ /** Resolve the selected ID against the current chunk; never reuse stale stop indexes. */
13
+ export declare function NetworkVehicleHeroCard({ snapshot, train, time, showTimes, atStopLabel, formatTime, ...props }: NetworkVehicleHeroCardProps): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
3
+ import { formatServiceTime } from '@motionstudies/core/domain/network';
4
+ import { vehicleCalls } from '@motionstudies/core/domain/vehicle-calls';
5
+ import { VehicleHeroCard } from './VehicleHeroCard.js';
6
+ /** Resolve the selected ID against the current chunk; never reuse stale stop indexes. */
7
+ export function NetworkVehicleHeroCard({ snapshot, train, time, showTimes = true, atStopLabel = 'At stop', formatTime = (value) => formatServiceTime(value).slice(0, 5), ...props }) {
8
+ const current = useMemo(() => snapshot.trains.find(entry => entry.id === train.id), [snapshot, train.id]);
9
+ const calls = current ? vehicleCalls(snapshot, current, time) : [];
10
+ const identity = current ?? train;
11
+ return _jsx(VehicleHeroCard, { ...props, vehicle: { service: identity.route || identity.shortName,
12
+ destination: identity.headsign.trim() || undefined }, labels: { ...props.labels, ...(calls[0]?.atStop ? { nextStop: atStopLabel } : {}) }, stops: calls.map(call => ({ ...call, time: showTimes ? formatTime(call.time) : undefined })) });
13
+ }
@@ -0,0 +1,46 @@
1
+ import type { DotMatrixRow } from './DotMatrixBoard.tsx';
2
+ import { type TransportHeroOptions } from './TransportHeroBoard.tsx';
3
+ export interface RailDeparture {
4
+ readonly id: string;
5
+ /** Consumer-formatted scheduled departure time. */
6
+ readonly time?: string;
7
+ readonly destination: string;
8
+ readonly platform?: string;
9
+ readonly platformSector?: string;
10
+ readonly service?: string;
11
+ readonly serviceCategory?: 'intercity' | 'international' | 'regional' | 'suburban';
12
+ /** Consumer-formatted expected time or status, e.g. "17:08" or "Cancelled". */
13
+ readonly expected?: string;
14
+ readonly via?: string;
15
+ readonly serviceNote?: string;
16
+ readonly tone?: DotMatrixRow['tone'];
17
+ }
18
+ declare const defaultLabels: {
19
+ station: string;
20
+ departures: string;
21
+ service: string;
22
+ time: string;
23
+ destination: string;
24
+ platform: string;
25
+ expected: string;
26
+ via: string;
27
+ loading: string;
28
+ empty: string;
29
+ retry: string;
30
+ detail: string;
31
+ };
32
+ export interface RailStationHeroCardProps extends Omit<TransportHeroOptions, 'presentation'> {
33
+ readonly presentation?: TransportHeroOptions['presentation'] | 'sbb';
34
+ readonly station: {
35
+ readonly name: string;
36
+ readonly code?: string;
37
+ readonly locality?: string;
38
+ };
39
+ readonly departures: readonly RailDeparture[];
40
+ readonly labels?: Partial<typeof defaultLabels>;
41
+ readonly onSelectDeparture?: (id: string) => void;
42
+ readonly selectedDepartureId?: string;
43
+ }
44
+ /** Station identity with time, destination, platform, expected status and service details. */
45
+ export declare function RailStationHeroCard({ station, departures, labels, presentation, lineCount, boardHeight, minRowHeight, clockLabel, footerLabel, note, loading, error, onRetry, onSelectDeparture, selectedDepartureId, className }: RailStationHeroCardProps): import("react").JSX.Element;
46
+ export {};
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ import { TransportHeroBoard } from './TransportHeroBoard.js';
4
+ import { SbbDepartureBoard } from './SbbDepartureBoard.js';
5
+ const defaultLabels = { station: 'Rail station', departures: 'Departures', service: 'Train', time: 'Time', destination: 'Destination', platform: 'Plat', expected: 'Expt',
6
+ via: 'via', loading: 'Loading rail departures…', empty: 'No trains expected.', retry: 'Retry', detail: 'Information' };
7
+ /** Station identity with time, destination, platform, expected status and service details. */
8
+ export function RailStationHeroCard({ station, departures, labels, presentation = 'uk-rail', lineCount = 8, boardHeight = 400, minRowHeight, clockLabel, footerLabel, note, loading, error, onRetry, onSelectDeparture, selectedDepartureId, className = '' }) {
9
+ const titleId = useId();
10
+ const sbb = presentation === 'sbb';
11
+ const copy = { ...defaultLabels, ...(sbb ? { platform: 'Platform' } : {}), ...labels };
12
+ return _jsxs("section", { className: `ms-rail-station-hero ${className}`, "data-presentation": presentation, "aria-labelledby": titleId, children: [_jsxs("div", { className: "ms-rail-station-hero__masthead", children: [_jsxs("span", { children: [copy.station, station.locality ? ` / ${station.locality}` : ''] }), station.code && _jsx("strong", { children: station.code })] }), _jsxs("div", { className: "ms-rail-station-hero__identity", children: [_jsx("span", { "aria-hidden": "true", children: sbb ? _jsxs("svg", { viewBox: "0 0 40 32", children: [_jsx("rect", { x: "10", y: "3", width: "20", height: "22", rx: "4" }), _jsx("path", { d: "M10 15h20M15 25l-4 5m14-5 4 5" }), _jsx("circle", { cx: "15", cy: "20", r: "1" }), _jsx("circle", { cx: "25", cy: "20", r: "1" })] }) : _jsx("svg", { viewBox: "0 0 40 32", children: _jsx("path", { d: "M3 10h31L23 3M37 22H6l11 7M10 16h20" }) }) }), _jsx("h2", { id: titleId, children: station.name })] }), sbb ? error ? _jsxs("div", { className: "ms-transport-hero__error", role: "status", children: [error, onRetry && _jsx("button", { type: "button", onClick: onRetry, children: copy.retry })] })
13
+ : _jsx(SbbDepartureBoard, { label: `${station.name} ${copy.departures}`, departures: departures, labels: copy, lineCount: lineCount, height: boardHeight, minRowHeight: minRowHeight, loading: loading, clockLabel: clockLabel, footerLabel: footerLabel, onSelectDeparture: onSelectDeparture, selectedDepartureId: selectedDepartureId })
14
+ : _jsx(TransportHeroBoard, { label: `${station.name} ${copy.departures}`, presentation: presentation, boardHeight: boardHeight, heading: copy.departures, headingColumnSpan: 2, columns: [{ key: 'time', label: copy.time, characters: 5, minCharacters: 5 },
15
+ { key: 'destination', label: copy.destination, characters: 24, minCharacters: 5 },
16
+ { key: 'platform', label: copy.platform, characters: 3, minCharacters: 2, align: 'right' },
17
+ { key: 'expected', label: copy.expected, characters: 9, minCharacters: 9, align: 'right' }], rows: departures.map((entry) => ({ id: entry.id, tone: entry.tone,
18
+ note: [entry.via ? `${copy.via} ${entry.via}` : '', entry.serviceNote].filter(Boolean).join(' · ') || undefined,
19
+ cells: { time: entry.time, destination: entry.destination, platform: entry.platform, expected: entry.expected } })), lineCount: lineCount, minRowHeight: minRowHeight, clockLabel: clockLabel, footerLabel: footerLabel, loading: loading, loadingMessage: copy.loading, emptyMessage: copy.empty, error: error, onRetry: onRetry, retryLabel: copy.retry, detailLabel: copy.detail, onSelectRow: onSelectDeparture, selectedRowId: selectedDepartureId, selectionColumn: "destination" }), _jsx("div", { className: "ms-transport-hero__note", children: note })] });
20
+ }
@@ -0,0 +1,26 @@
1
+ import type { RailDeparture } from './RailStationHeroCard.tsx';
2
+ interface SbbDepartureBoardProps {
3
+ readonly label: string;
4
+ readonly departures: readonly RailDeparture[];
5
+ readonly labels: {
6
+ departures: string;
7
+ service: string;
8
+ time: string;
9
+ destination: string;
10
+ platform: string;
11
+ via: string;
12
+ loading: string;
13
+ empty: string;
14
+ };
15
+ readonly lineCount: number | 'auto';
16
+ readonly height: number;
17
+ readonly minRowHeight?: number;
18
+ readonly loading?: boolean;
19
+ readonly clockLabel?: string;
20
+ readonly footerLabel?: string;
21
+ readonly onSelectDeparture?: (id: string) => void;
22
+ readonly selectedDepartureId?: string;
23
+ }
24
+ /** A typographic station board; the surrounding rail hero owns identity and errors. */
25
+ export declare function SbbDepartureBoard({ label, departures, labels, lineCount, height, minRowHeight, loading, clockLabel, footerLabel, onSelectDeparture, selectedDepartureId }: SbbDepartureBoardProps): import("react").JSX.Element;
26
+ export {};
@@ -0,0 +1,29 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ /** A typographic station board; the surrounding rail hero owns identity and errors. */
4
+ export function SbbDepartureBoard({ label, departures, labels, lineCount, height, minRowHeight = 64, loading, clockLabel, footerLabel, onSelectDeparture, selectedDepartureId }) {
5
+ const body = useRef(null);
6
+ const [availableHeight, setAvailableHeight] = useState(300);
7
+ useEffect(() => {
8
+ const element = body.current;
9
+ if (!element)
10
+ return;
11
+ const observer = new ResizeObserver(([entry]) => setAvailableHeight(Math.floor(entry.contentRect.height)));
12
+ observer.observe(element);
13
+ return () => observer.disconnect();
14
+ }, []);
15
+ const targetHeight = Number.isFinite(minRowHeight) ? Math.max(32, Math.min(120, minRowHeight)) : 64;
16
+ const requested = lineCount === 'auto' ? Math.floor(availableHeight / targetHeight) : lineCount;
17
+ const count = Number.isFinite(requested) ? Math.max(1, Math.min(30, Math.floor(requested))) : 6;
18
+ const rowHeight = availableHeight / count;
19
+ const message = loading ? labels.loading : departures.length === 0 ? labels.empty : undefined;
20
+ return _jsxs("div", { className: "ms-sbb-board", "data-lines": count, style: { height: Number.isFinite(height) ? Math.max(140, height) : 400,
21
+ '--sbb-row-count': count, '--sbb-font-size': `${Math.max(8, Math.min(24, rowHeight * .38))}px`,
22
+ '--sbb-detail-size': `${Math.max(7, Math.min(13, rowHeight * .22))}px` }, children: [_jsxs("div", { className: "ms-sbb-board__title", children: [_jsx("strong", { children: labels.departures }), clockLabel && _jsx("span", { className: "ms-sbb-board__clock", children: clockLabel })] }), _jsxs("div", { className: "ms-sbb-board__table", role: "table", "aria-label": label, "aria-busy": loading || false, children: [_jsx("div", { role: "rowgroup", children: _jsx("div", { className: "ms-sbb-board__columns", role: "row", children: [labels.service, labels.time, labels.destination, labels.platform].map((text, index) => _jsx("div", { role: "columnheader", children: text }, index)) }) }), _jsxs("div", { className: "ms-sbb-board__body", ref: body, children: [_jsx("div", { className: "ms-sbb-board__rows", role: "rowgroup", children: Array.from({ length: count }, (_, index) => {
23
+ const entry = message ? undefined : departures[index];
24
+ const details = entry ? [entry.via ? `${labels.via} ${entry.via}` : '', entry.serviceNote].filter(Boolean).join(' · ') : '';
25
+ return _jsxs("div", { className: "ms-sbb-board__row", role: "row", "aria-hidden": !entry || undefined, "data-tone": entry?.tone, "data-selected": entry && selectedDepartureId === entry.id || undefined, children: [_jsx("div", { role: "cell", children: _jsx("span", { className: "ms-sbb-board__service", "data-category": entry?.serviceCategory, title: entry?.service, children: entry ? entry.service || '—' : '' }) }), _jsxs("div", { role: "cell", children: [_jsx("span", { className: "ms-sbb-board__time", children: entry ? entry.time || '—' : '' }), entry?.expected && _jsx("span", { className: "ms-sbb-board__expected", title: entry.expected, children: entry.expected })] }), _jsx("div", { role: "cell", children: entry && onSelectDeparture
26
+ ? _jsxs("button", { type: "button", "aria-pressed": selectedDepartureId === entry.id, onClick: () => onSelectDeparture(entry.id), title: entry.destination, children: [_jsx("span", { className: "ms-sbb-board__destination", children: entry.destination }), details && _jsx("span", { className: "ms-sbb-board__details", title: details, children: details })] })
27
+ : _jsxs(_Fragment, { children: [_jsx("span", { className: "ms-sbb-board__destination", title: entry?.destination, children: entry?.destination }), details && _jsx("span", { className: "ms-sbb-board__details", title: details, children: details })] }) }), _jsxs("div", { role: "cell", children: [_jsx("strong", { className: "ms-sbb-board__platform", title: entry?.platform, children: entry ? entry.platform || '—' : '' }), entry?.platformSector && _jsx("span", { className: "ms-sbb-board__sector", title: entry.platformSector, children: entry.platformSector })] })] }, entry?.id ?? `blank-${index}`);
28
+ }) }), message && _jsx("div", { className: "ms-sbb-board__message", role: "status", children: message })] })] }), footerLabel && _jsx("div", { className: "ms-sbb-board__footer", children: footerLabel })] });
29
+ }
@@ -0,0 +1,25 @@
1
+ export interface SplitFlapColumn {
2
+ readonly key: string;
3
+ readonly label: string;
4
+ /** Fixed number of flaps. Full values remain available to assistive technology. */
5
+ readonly characters: number;
6
+ }
7
+ export interface SplitFlapRow {
8
+ readonly id: string;
9
+ readonly cells: Readonly<Record<string, string | undefined>>;
10
+ readonly tone?: 'neutral' | 'accent' | 'warning';
11
+ }
12
+ export interface SplitFlapBoardProps {
13
+ readonly label: string;
14
+ readonly columns: readonly SplitFlapColumn[];
15
+ readonly rows: readonly SplitFlapRow[];
16
+ readonly emptyMessage?: string;
17
+ readonly loading?: boolean;
18
+ readonly loadingMessage?: string;
19
+ readonly loadingRows?: number;
20
+ readonly onSelectRow?: (id: string) => void;
21
+ readonly selectedRowId?: string;
22
+ readonly selectionColumn?: string;
23
+ }
24
+ /** Transport-neutral display: consumers own ordering, time formatting and data provenance. */
25
+ export declare function SplitFlapBoard({ label, columns, rows, emptyMessage, loading, loadingMessage, loadingRows, onSelectRow, selectedRowId, selectionColumn }: SplitFlapBoardProps): import("react").JSX.Element;
@@ -0,0 +1,58 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ function flapCount(characters) {
4
+ return Math.max(1, Math.min(40, Math.floor(characters) || 1));
5
+ }
6
+ /** Keep the message in the widest display column, wrapping without losing localized text. */
7
+ function emptyBoardRows(message, columns) {
8
+ const column = columns.reduce((widest, next) => !widest || flapCount(next.characters) > flapCount(widest.characters) ? next : widest, undefined);
9
+ if (!column)
10
+ return [];
11
+ const width = flapCount(column.characters);
12
+ const lines = [];
13
+ let line = [];
14
+ for (const word of message.toLocaleUpperCase().trim().split(/\s+/u)) {
15
+ const letters = Array.from(word);
16
+ if (line.length && line.length + 1 + letters.length > width) {
17
+ lines.push(line.join(''));
18
+ line = [];
19
+ }
20
+ while (letters.length > width)
21
+ lines.push(letters.splice(0, width).join(''));
22
+ if (line.length && letters.length)
23
+ line.push(' ');
24
+ line.push(...letters);
25
+ }
26
+ if (line.length)
27
+ lines.push(line.join(''));
28
+ return lines.map((text, index) => ({ id: `empty-${index}`, cells: { [column.key]: text } }));
29
+ }
30
+ function FlapText({ value, characters, offset = 0 }) {
31
+ const count = flapCount(characters);
32
+ const letters = Array.from(value.toLocaleUpperCase());
33
+ const visible = letters.length > count ? [...letters.slice(0, count - 1), '…'] : letters;
34
+ return _jsxs("span", { className: "ms-flap-text", title: value, children: [_jsx("span", { className: "ms-board-sr-only", children: value }), _jsx("span", { className: "ms-flap-text__tiles", "aria-hidden": "true", children: Array.from({ length: count }, (_, index) => {
35
+ const character = visible[index] ?? ' ';
36
+ const phase = index + offset;
37
+ return _jsx("span", { className: "ms-flap", style: {
38
+ '--flap-delay': `${Math.min(phase * 18, 360)}ms`,
39
+ '--flap-phase': `${-(phase % 8) * 240}ms`,
40
+ '--flap-motion-phase': `${-((phase * 37) % 240)}ms`,
41
+ }, children: _jsxs("span", { className: "ms-flap__face", children: [character === ' ' ? '\u00a0' : character, _jsx("span", { className: "ms-flap__drum" })] }) }, `${index}:${character}`);
42
+ }) })] });
43
+ }
44
+ /** Transport-neutral display: consumers own ordering, time formatting and data provenance. */
45
+ export function SplitFlapBoard({ label, columns, rows, emptyMessage = 'No movements in this window.', loading = false, loadingMessage = 'Loading movements…', loadingRows = 5, onSelectRow, selectedRowId, selectionColumn }) {
46
+ const hintId = useId();
47
+ const empty = !loading && rows.length === 0;
48
+ const displayedRows = loading
49
+ ? Array.from({ length: Number.isFinite(loadingRows) ? Math.max(1, Math.min(20, Math.floor(loadingRows))) : 5 }, (_, index) => ({ id: `loading-${index}`, cells: {} }))
50
+ : empty ? emptyBoardRows(emptyMessage, columns) : rows;
51
+ return _jsxs("div", { className: "ms-split-flap-board", "data-loading": loading || undefined, "data-empty": empty || undefined, children: [_jsx("span", { className: "ms-board-sr-only", id: hintId, children: "Scroll horizontally to read all columns." }), _jsx("div", { className: "ms-split-flap-board__scroll", role: "region", "aria-label": label, "aria-describedby": hintId, "aria-busy": loading, tabIndex: 0, children: _jsxs("table", { className: "ms-split-flap-board__table", children: [_jsx("caption", { className: "ms-board-sr-only", children: label }), _jsx("thead", { children: _jsx("tr", { children: columns.map((column) => _jsx("th", { scope: "col", children: column.label }, column.key)) }) }), _jsx("tbody", { "aria-hidden": loading || empty || undefined, children: displayedRows.map((row, rowIndex) => _jsx("tr", { "data-tone": row.tone ?? 'neutral', "data-selected": !loading && !empty && row.id === selectedRowId || undefined, children: columns.map((column, index) => {
52
+ const text = _jsx(FlapText, { value: loading ? ' ' : row.cells[column.key] || (empty ? ' ' : '—'), characters: column.characters, offset: rowIndex * 3 + index * 2 });
53
+ return _jsx("td", { children: !loading && !empty && onSelectRow && (selectionColumn ? column.key === selectionColumn : index === 0)
54
+ ? _jsx("button", { className: "ms-split-flap-board__select", type: "button", "aria-pressed": row.id === selectedRowId, onClick: () => onSelectRow(row.id), children: text })
55
+ : text }, column.key);
56
+ }) }, row.id)) })] }) }), loading ? _jsx("p", { className: "ms-split-flap-board__loading", role: "status", children: loadingMessage })
57
+ : empty && _jsx("span", { className: "ms-board-sr-only", role: "status", children: emptyMessage })] });
58
+ }
@@ -0,0 +1,28 @@
1
+ import type { ReactNode } from 'react';
2
+ import { type DotMatrixBoardProps } from './DotMatrixBoard.tsx';
3
+ /** Shared display options; each transport card supplies its own identity and columns. */
4
+ export interface TransportHeroOptions {
5
+ readonly presentation?: 'dot-matrix' | 'uk-rail' | 'split-flap';
6
+ readonly lineCount?: number | 'auto';
7
+ readonly boardHeight?: number;
8
+ readonly minRowHeight?: number;
9
+ readonly clockLabel?: string;
10
+ readonly footerLabel?: string;
11
+ /** Source/freshness explanation owned by the consumer. */
12
+ readonly note: ReactNode;
13
+ readonly loading?: boolean;
14
+ readonly error?: string;
15
+ readonly onRetry?: () => void;
16
+ readonly className?: string;
17
+ }
18
+ interface TransportHeroBoardProps extends Omit<DotMatrixBoardProps, 'style' | 'className'> {
19
+ readonly presentation: NonNullable<TransportHeroOptions['presentation']>;
20
+ readonly boardHeight: number;
21
+ readonly error?: string;
22
+ readonly onRetry?: () => void;
23
+ readonly retryLabel: string;
24
+ readonly detailLabel: string;
25
+ }
26
+ /** Internal adapter: error handling and presentation switching shared by rail and bus. */
27
+ export declare function TransportHeroBoard({ presentation, boardHeight, error, onRetry, retryLabel, detailLabel, ...board }: TransportHeroBoardProps): import("react").JSX.Element;
28
+ export {};
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { DotMatrixBoard } from './DotMatrixBoard.js';
3
+ import { SplitFlapBoard } from './SplitFlapBoard.js';
4
+ /** Internal adapter: error handling and presentation switching shared by rail and bus. */
5
+ export function TransportHeroBoard({ presentation, boardHeight, error, onRetry, retryLabel, detailLabel, ...board }) {
6
+ const height = Number.isFinite(boardHeight) ? Math.max(140, boardHeight) : 360;
7
+ if (error)
8
+ return _jsxs("div", { className: "ms-transport-hero__error", role: "status", children: [error, onRetry && _jsx("button", { type: "button", onClick: onRetry, children: retryLabel })] });
9
+ if (presentation !== 'split-flap')
10
+ return _jsx(DotMatrixBoard, { ...board, variant: presentation === 'uk-rail' ? 'uk-rail' : 'bus', style: { height } });
11
+ const count = board.lineCount === 'auto' ? Math.floor((height - 60) / (board.minRowHeight ?? 34)) : board.lineCount ?? 6;
12
+ const limit = Number.isFinite(count) ? Math.max(1, Math.min(30, Math.floor(count))) : 6;
13
+ const hasDetails = board.rows.some((row) => row.note);
14
+ return _jsxs("div", { className: "ms-transport-hero__flap", style: { height }, children: [_jsx(SplitFlapBoard, { ...board, columns: hasDetails ? [...board.columns, { key: '__detail', label: detailLabel, characters: 32 }] : board.columns, rows: board.rows.slice(0, limit).map((row) => ({ ...row, cells: { ...row.cells, __detail: row.note } })), loadingRows: limit }), (board.footerLabel || board.clockLabel) && _jsxs("div", { className: "ms-transport-hero__flap-footer", children: [_jsx("span", { children: board.footerLabel }), _jsx("span", { children: board.clockLabel })] })] });
15
+ }
@@ -0,0 +1,51 @@
1
+ import { type ReactNode } from 'react';
2
+ export type VehicleHeroPresentation = 'uk-bus' | 'uk-rail' | 'yellow-bus' | 'sbb';
3
+ export interface VehicleCallingPoint {
4
+ readonly id: string;
5
+ readonly name: string;
6
+ /** Consumer-formatted arrival time or estimate. Omit when unknown. */
7
+ readonly time?: string;
8
+ readonly platform?: string;
9
+ readonly detail?: string;
10
+ /** Set only for a confirmed destination, never just the end of a data chunk. */
11
+ readonly isDestination?: boolean;
12
+ }
13
+ declare const defaultLabels: {
14
+ bus: string;
15
+ rail: string;
16
+ destination: string;
17
+ destinationUnknown: string;
18
+ nextStop: string;
19
+ callingAt: string;
20
+ terminus: string;
21
+ platform: string;
22
+ loading: string;
23
+ empty: string;
24
+ retry: string;
25
+ };
26
+ export interface VehicleHeroCardProps {
27
+ readonly vehicle: {
28
+ readonly service: string;
29
+ readonly destination?: string;
30
+ readonly operator?: string;
31
+ readonly description?: string;
32
+ };
33
+ /** Remaining calls in journey order, starting with the next stop. The consumer owns playback/filtering. */
34
+ readonly stops: readonly VehicleCallingPoint[];
35
+ readonly presentation?: VehicleHeroPresentation;
36
+ /** Explicit source/freshness explanation. */
37
+ readonly note: ReactNode;
38
+ readonly status?: string;
39
+ readonly statusTone?: 'neutral' | 'warning';
40
+ readonly clockLabel?: string;
41
+ readonly labels?: Partial<typeof defaultLabels>;
42
+ readonly loading?: boolean;
43
+ readonly error?: string;
44
+ readonly onRetry?: () => void;
45
+ readonly onSelectStop?: (id: string) => void;
46
+ readonly selectedStopId?: string;
47
+ readonly className?: string;
48
+ }
49
+ /** An onboard view of one vehicle; no destination, timing or operational status is inferred. */
50
+ export declare function VehicleHeroCard({ vehicle, stops, presentation, note, status, statusTone, clockLabel, labels, loading, error, onRetry, onSelectStop, selectedStopId, className }: VehicleHeroCardProps): import("react").JSX.Element;
51
+ export {};
@@ -0,0 +1,21 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ const defaultLabels = {
4
+ bus: 'Bus', rail: 'Train', destination: 'Destination', destinationUnknown: 'Destination unavailable',
5
+ nextStop: 'Next stop', callingAt: 'Then calling at', terminus: 'Destination', platform: 'Platform',
6
+ loading: 'Loading upcoming stops…', empty: 'No upcoming stops available.', retry: 'Retry',
7
+ };
8
+ /** An onboard view of one vehicle; no destination, timing or operational status is inferred. */
9
+ export function VehicleHeroCard({ vehicle, stops, presentation = 'uk-rail', note, status, statusTone = 'neutral', clockLabel, labels, loading, error, onRetry, onSelectStop, selectedStopId, className = '' }) {
10
+ const titleId = useId();
11
+ const copy = { ...defaultLabels, ...labels };
12
+ const bus = presentation === 'uk-bus' || presentation === 'yellow-bus';
13
+ const [next, ...following] = stops;
14
+ const stopContent = (stop) => _jsxs(_Fragment, { children: [_jsxs("span", { className: "ms-vehicle-hero__stop-name", children: [stop.name, stop.isDestination && _jsx("small", { children: copy.terminus }), stop.detail && _jsx("span", { className: "ms-vehicle-hero__detail", children: stop.detail })] }), (stop.time || stop.platform) && _jsxs("span", { className: "ms-vehicle-hero__timing", children: [stop.time && _jsx("strong", { children: stop.time }), stop.platform && _jsxs("small", { children: [copy.platform, " ", stop.platform] })] })] });
15
+ const renderStop = (stop) => onSelectStop
16
+ ? _jsx("button", { type: "button", className: "ms-vehicle-hero__stop", "aria-pressed": selectedStopId === stop.id, onClick: () => onSelectStop(stop.id), children: stopContent(stop) })
17
+ : _jsx("div", { className: "ms-vehicle-hero__stop", children: stopContent(stop) });
18
+ return _jsxs("section", { className: `ms-vehicle-hero ${className}`, "data-presentation": presentation, "aria-labelledby": titleId, "aria-busy": loading || undefined, children: [_jsxs("div", { className: "ms-vehicle-hero__masthead", children: [_jsxs("span", { children: [bus ? copy.bus : copy.rail, vehicle.operator ? ` · ${vehicle.operator}` : ''] }), clockLabel && _jsx("span", { children: clockLabel })] }), _jsxs("div", { className: "ms-vehicle-hero__identity", children: [_jsx("strong", { className: "ms-vehicle-hero__service", children: vehicle.service }), _jsxs("div", { children: [_jsx("p", { className: "ms-vehicle-hero__eyebrow", children: copy.destination }), _jsx("h2", { id: titleId, children: vehicle.destination || copy.destinationUnknown }), vehicle.description && _jsx("p", { className: "ms-vehicle-hero__description", children: vehicle.description })] })] }), status && _jsx("div", { className: "ms-vehicle-hero__status", "data-tone": statusTone, children: status }), error ? _jsxs("div", { className: "ms-vehicle-hero__message", role: "status", children: [error, onRetry && _jsx("button", { type: "button", onClick: onRetry, children: copy.retry })] })
19
+ : loading ? _jsx("div", { className: "ms-vehicle-hero__message", role: "status", children: copy.loading })
20
+ : next ? _jsxs("div", { className: "ms-vehicle-hero__journey", children: [_jsxs("div", { className: "ms-vehicle-hero__next", children: [_jsx("p", { className: "ms-vehicle-hero__eyebrow", children: copy.nextStop }), renderStop(next)] }), following.length > 0 && _jsxs("div", { className: "ms-vehicle-hero__following", children: [_jsx("p", { className: "ms-vehicle-hero__eyebrow", children: copy.callingAt }), _jsx("ol", { children: following.map((stop) => _jsx("li", { "data-terminus": stop.isDestination || undefined, children: renderStop(stop) }, stop.id)) })] })] }) : _jsx("div", { className: "ms-vehicle-hero__message", role: "status", children: copy.empty }), _jsx("div", { className: "ms-vehicle-hero__note", children: note })] });
21
+ }
@@ -0,0 +1,68 @@
1
+ @import './split-flap-board.css';
2
+
3
+ .ms-airport-hero { --airport-accent: #edc96b; container-type: inline-size; color: #f0ecdd; background: linear-gradient(125deg, #292d27, #191d19 65%); border: 1px solid #4b4d40; border-radius: 12px; padding: clamp(16px, 3vw, 30px); box-shadow: 0 20px 60px #0003; font-family: ui-sans-serif, system-ui, sans-serif; min-width: 0; }
4
+ .ms-airport-hero__masthead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; }
5
+ .ms-airport-hero__eyebrow { color: var(--airport-accent); text-transform: uppercase; font-size: 10px; letter-spacing: .18em; }
6
+ .ms-airport-hero__eyebrow > span { font-size: 18px; margin-right: 6px; }
7
+ .ms-airport-hero__date { color: #bebfac; font: 11px/1.5 ui-monospace, monospace; letter-spacing: .06em; }
8
+ .ms-airport-hero__identity { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 18px 24px; padding: 18px 0 20px; }
9
+ .ms-airport-hero__code { color: var(--airport-accent); font-size: clamp(54px, 10cqi, 82px); font-weight: 650; line-height: .95; letter-spacing: -.065em; }
10
+ .ms-airport-hero__identity h2 { max-width: 300px; color: #f0ecdd; font: 400 clamp(20px, 3.3cqi, 27px)/1.15 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.025em; margin: 0; overflow-wrap: anywhere; }
11
+ .ms-airport-hero__clock { padding-left: 24px; border-left: 1px solid #797c6155; text-align: right; }
12
+ .ms-airport-hero__clock-label { display: block; color: #bebfac; font-size: 10px; line-height: 1.5; letter-spacing: .12em; text-transform: uppercase; }
13
+ .ms-airport-hero__clock-value { display: block; margin-top: 4px; color: #f0ecdd; font: 400 42px/1.1 ui-monospace, monospace; font-variant-numeric: tabular-nums; letter-spacing: -.06em; white-space: nowrap; }
14
+ .ms-airport-hero__board-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0 20px; border-bottom: 1px solid #4b4d40; margin-bottom: 14px; }
15
+ .ms-airport-hero__directions { display: flex; gap: 22px; }
16
+ .ms-airport-hero__directions button { min-height: 44px; padding: 10px 0 12px; margin-bottom: -1px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: #b8baaa; font: 500 13px/1.4 ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
17
+ .ms-airport-hero__directions button[aria-pressed='true'] { color: var(--airport-accent); border-bottom-color: var(--airport-accent); }
18
+ .ms-airport-hero__directions button:hover { color: #f0ecdd; }
19
+ .ms-airport-hero__window { color: #bebfac; font: 11px/1.5 ui-monospace, monospace; font-variant-numeric: tabular-nums; margin: 8px 0; }
20
+ .ms-airport-hero button:focus-visible { outline: 2px solid var(--airport-accent); outline-offset: 4px; }
21
+ .ms-airport-hero .ms-flap { font-size: 17px; }
22
+ .ms-airport-hero .ms-split-flap-board__table th { color: #c4c3b0; font-size: 11px; letter-spacing: .12em; padding-bottom: 5px; }
23
+ .ms-airport-hero__message { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 30px 0; margin: 0; font-size: 14px; color: #c5c7b6; }
24
+ .ms-airport-hero__message button { background: #30352a; border: 1px solid #666b50; border-radius: 4px; color: var(--airport-accent); padding: 6px 12px; font: inherit; cursor: pointer; }
25
+ .ms-airport-hero__note { color: #b8baaa; font-size: 11px; line-height: 1.6; margin-top: 14px; }
26
+ @container (max-width: 600px) {
27
+ .ms-airport-hero__identity { grid-template-columns: auto minmax(0, 1fr); gap: 16px; }
28
+ .ms-airport-hero__clock { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 12px 0 0; border-left: 0; border-top: 1px solid #797c6155; text-align: left; }
29
+ .ms-airport-hero__clock-value { margin-top: 0; font-size: 32px; }
30
+ }
31
+ @container (max-width: 440px) {
32
+ .ms-airport-hero__board-header { gap: 4px; }
33
+ .ms-airport-hero__directions { width: 100%; gap: 20px; }
34
+ .ms-airport-hero__window { margin: 6px 0 10px; }
35
+ }
36
+ .ms-airport-board__modes { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; color: #b8baaa; font: 12px/1.5 ui-sans-serif, system-ui, sans-serif; }
37
+ .ms-airport-board__modes button, .ms-airport-board__unavailable button { min-height: 44px; padding: 8px 16px; border: 1px solid #4b4d40; border-radius: 6px; color: #f0ecdd; background: #1d211c; font: inherit; cursor: pointer; }
38
+ .ms-airport-board__modes button[aria-pressed='true'] { border-color: #edc96b; color: #edc96b; }
39
+ .ms-airport-board__modes button:disabled { opacity: .5; cursor: default; }
40
+ .ms-airport-board__modes button:focus-visible, .ms-airport-board__unavailable button:focus-visible { outline: 2px solid #edc96b; outline-offset: 3px; }
41
+ .ms-airport-board__unavailable { padding: 28px; border: 1px solid #4b4d40; border-radius: 12px; color: #f0ecdd; background: #1d211c; font: 14px/1.6 ui-sans-serif, system-ui, sans-serif; }
42
+ .ms-airport-board__detail { color: #b8baaa; font: 13px/1.6 ui-sans-serif, system-ui, sans-serif; overflow-wrap: anywhere; }
43
+
44
+ /* Shared compact presentation, responsive to card width rather than the window. */
45
+ .ms-airport-hero[data-density='compact'] { padding: 12px; }
46
+ .ms-airport-hero[data-density='compact'] .ms-airport-hero__identity { padding: 8px 0; gap: 12px; }
47
+ .ms-airport-hero[data-density='compact'] .ms-airport-hero__code { font-size: 40px; }
48
+ .ms-airport-hero[data-density='compact'] .ms-airport-hero__identity h2 { font-size: 20px; }
49
+ .ms-airport-hero[data-density='compact'] .ms-airport-hero__clock-value { font-size: 28px; }
50
+ .ms-airport-hero[data-density='compact'] .ms-airport-hero__directions { margin-bottom: 8px; }
51
+ .ms-airport-hero[data-density='compact'] .ms-airport-hero__directions button { padding: 6px 0 8px; }
52
+ .ms-airport-hero[data-density='compact'] .ms-airport-hero__window { margin-bottom: 8px; }
53
+ .ms-airport-hero[data-density='compact'] .ms-split-flap-board { padding: 6px 8px 8px; }
54
+ .ms-airport-hero[data-density='compact'] .ms-split-flap-board__table { border-spacing: 0 4px; }
55
+ .ms-airport-hero[data-density='compact'] .ms-split-flap-board__select { min-height: 28px; }
56
+ .ms-airport-hero[data-density='compact'] .ms-flap { font-size: 13px; }
57
+ .ms-airport-hero[data-density='compact'] .ms-airport-hero__note { margin-top: 8px; font-size: 10px; line-height: 1.4; }
58
+ .ms-airport-hero[data-density='compact'] .ms-airport-hero__note a { color: inherit; }
59
+ @container (min-width: 441px) {
60
+ .ms-airport-hero[data-density='compact'] .ms-airport-hero__identity { grid-template-columns: auto minmax(0, 1fr) auto; }
61
+ .ms-airport-hero[data-density='compact'] .ms-airport-hero__clock { grid-column: auto; display: block; padding: 0 0 0 12px; border-top: 0; border-left: 1px solid #797c6155; text-align: right; }
62
+ }
63
+ @container (max-width: 440px) {
64
+ .ms-airport-hero[data-density='compact'] .ms-airport-hero__identity h2 { font-size: 16px; }
65
+ }
66
+ @media (pointer: coarse) {
67
+ .ms-airport-hero[data-density='compact'] .ms-split-flap-board__select { min-height: 44px; }
68
+ }
@@ -0,0 +1,49 @@
1
+ .ms-dot-matrix-board {
2
+ --matrix-ink: #ffb340;
3
+ --matrix-unlit: #3c2b1260;
4
+ box-sizing: border-box;
5
+ display: flex;
6
+ flex-direction: column;
7
+ width: 100%;
8
+ height: 320px;
9
+ min-width: 0;
10
+ min-height: 0;
11
+ padding: 16px;
12
+ color: var(--matrix-ink);
13
+ background: linear-gradient(115deg, #222523, #111412 60%);
14
+ border: 1px solid #3c403b;
15
+ border-radius: 5px;
16
+ box-shadow: inset 0 1px #ffffff12, 0 8px 24px #0003;
17
+ }
18
+ .ms-dot-matrix-board * { box-sizing: border-box; }
19
+ .ms-dot-matrix-board__head { display: flex; flex: 0 0 28px; color: #c7c9c1; font: 500 12px/20px ui-sans-serif, system-ui, sans-serif; }
20
+ .ms-dot-matrix-board__head > span { padding: 0 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
21
+ .ms-dot-matrix-board__screen { flex: 1; position: relative; min-height: 0; min-width: 0; background: #080b09; box-shadow: inset 0 2px 8px #000; overflow: hidden; }
22
+ .ms-dot-matrix-board table { table-layout: fixed; width: 100%; height: 100%; border-collapse: collapse; text-align: left; }
23
+ .ms-dot-matrix-board td { padding: 0 8px; border-bottom: 1px solid #b4ad7d12; overflow: hidden; }
24
+ .ms-dot-matrix-board tr:last-child td { border-bottom: 0; }
25
+ .ms-matrix-text { display: block; position: relative; width: 100%; }
26
+ .ms-matrix-text svg { display: block; width: 100%; height: var(--matrix-text-height); filter: drop-shadow(0 0 2px #ff9e292e); }
27
+ .ms-matrix-text__unlit { color: var(--matrix-unlit); }
28
+ .ms-matrix-text pattern circle { fill: var(--matrix-unlit); }
29
+ .ms-dot-matrix-board [data-tone='accent'] td:last-child { color: #ffd895; }
30
+ .ms-dot-matrix-board [data-tone='warning'] td:last-child { color: #ff8060; }
31
+ .ms-dot-matrix-board [data-selected] { background: #ffb34016; box-shadow: inset 2px 0 var(--matrix-ink); }
32
+ .ms-dot-matrix-board button { display: block; width: 100%; padding: 0; border: 0; border-radius: 0; background: none; color: inherit; cursor: pointer; font: inherit; }
33
+ .ms-dot-matrix-board button:hover { color: #ffe2ac; }
34
+ .ms-dot-matrix-board button:focus-visible { outline: 2px solid var(--matrix-ink); outline-offset: 3px; }
35
+ .ms-dot-matrix-board__message { position: absolute; inset: 0; display: grid; place-content: center; padding: 16px; overflow: auto; text-align: center; color: var(--matrix-ink); font: 15px/1.6 ui-monospace, monospace; background: #080b09e8; }
36
+ .ms-dot-matrix-board__footer { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex: 0 0 38px; min-width: 0; padding: 8px; }
37
+ .ms-dot-matrix-board__footer > .ms-matrix-text { width: 60%; min-width: 0; }
38
+ .ms-dot-matrix-board__footer svg { height: 18px; }
39
+ .ms-dot-matrix-board__clock { width: 34%; max-width: 160px; margin-left: auto; min-width: 0; }
40
+ .ms-dot-matrix-board__clock svg { height: 22px; }
41
+ .ms-dot-matrix-board__detail td { padding-left: 20px; }
42
+ .ms-dot-matrix-board[data-variant='uk-rail'] { --matrix-ink: #f6cd46; --matrix-unlit: #51400c60; border-radius: 2px; border: 1px solid #333431; background: #10120f; box-shadow: inset 0 0 0 5px #191b18, 0 12px 32px #0004; padding: 20px; }
43
+ .ms-dot-matrix-board[data-variant='uk-rail'] .ms-dot-matrix-board__head { flex-basis: 42px; align-items: center; color: var(--matrix-ink); background: #0a0c08; border-bottom: 5px solid #10120f; }
44
+ .ms-dot-matrix-board[data-variant='uk-rail'] .ms-dot-matrix-board__head svg { height: 18px; }
45
+ .ms-dot-matrix-board[data-variant='uk-rail'] .ms-dot-matrix-board__heading svg { height: 24px; }
46
+ .ms-dot-matrix-board[data-variant='uk-rail'] td { border-color: #10120f; border-bottom-width: 3px; background: #c7a53208; }
47
+ .ms-dot-matrix-board[data-variant='uk-rail'] [data-tone] td:last-child { color: var(--matrix-ink); }
48
+ .ms-dot-matrix-board[data-variant='uk-rail'] .ms-dot-matrix-board__footer { color: var(--matrix-ink); background: #0a0c08; border-top: 5px solid #10120f; }
49
+ .ms-matrix-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@@ -0,0 +1,2 @@
1
+ /** Unsupported scripts retain their original text through the SVG text fallback. */
2
+ export declare function matrixPath(value: string): string | undefined;