@motionstudies/web 0.1.0-alpha.3 → 0.1.0-alpha.5
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.
- package/README.md +43 -0
- package/components/AirportHeroCard.d.ts +58 -0
- package/components/AirportHeroCard.js +31 -0
- package/components/SplitFlapBoard.d.ts +25 -0
- package/components/SplitFlapBoard.js +58 -0
- package/components/airport-hero-card.css +21 -0
- package/components/split-flap-board.css +45 -0
- package/package.json +14 -2
package/README.md
CHANGED
|
@@ -25,8 +25,51 @@ Build release candidates with `npm run build:packages`. Distribution manifests a
|
|
|
25
25
|
|
|
26
26
|
Source workspace manifests always stay private. `npm run check:release` builds public candidates, tests their packed consumer and records the tested tarball hashes; `npm run release:dry-run` inspects the publication without writing to npm. The manual main-branch `release.yml` workflow publishes those same tarballs with public access and provenance. See [release instructions](https://github.com/emmettl/motionstudies/blob/main/docs/RELEASING.md) for bootstrap-token and trusted-publisher setup. All four shared packages are MIT-licensed; each distribution includes `LICENSE`.
|
|
27
27
|
|
|
28
|
+
## Selection labels
|
|
29
|
+
|
|
30
|
+
`NationalNetworkScene` gives the selected station first label priority, followed by the selected route's terminal stops (including branches), then intermediate stops. Selected services use their own endpoints. Priority precedes retained labels and ordinary rank/tier admission; clearing selection restores edition ranking. This is built in for every consumer, including geographic and diagram layouts. Supply complete enabled infrastructure as `referenceSnapshot` to preserve endpoints through timetable gaps; its stop indexes need not match the active snapshot. See the [edition behaviour contract](https://github.com/emmettl/motionstudies/blob/main/docs/EDITIONS.md#selection-and-station-labels).
|
|
31
|
+
|
|
28
32
|
## Button help
|
|
29
33
|
|
|
30
34
|
`mountMotionStudy` installs one shared tooltip surface. Independent consumers such as the lab can render `ButtonTooltips` from `@motionstudies/web/components/ButtonTooltips` once instead. Put concise, action-oriented help in each button’s `data-tooltip`; icon buttons fall back to their `aria-label`. An empty `data-tooltip` opts out. Avoid native `title` attributes on these buttons, which can also appear during touch interaction.
|
|
31
35
|
|
|
32
36
|
Help appears after a short mouse hover or on keyboard focus when the primary pointer is fine and supports hover. Touch input suppresses it, including on hybrid devices. Escape, activation, scrolling and blur dismiss it. The tooltip stays inside the viewport, can itself be hovered, and temporarily extends `aria-describedby` without replacing existing descriptions. Copy and translations stay in the edition; rendering and input handling stay in this package. The Controls specimen and packed-consumer tests exercise this contract.
|
|
37
|
+
|
|
38
|
+
## Airport heroes and split-flap boards
|
|
39
|
+
|
|
40
|
+
`AirportHeroCard` provides an airport identity header and switchable departure/arrival boards. `SplitFlapBoard` is the underlying transport-neutral widget, also suitable for rail stations. Both use scoped package styles, semantic tables, full accessible cell values, keyboard-operable selection, contained horizontal scrolling and reduced-motion support. Only changed characters remount for the flap animation.
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
import { AirportHeroCard } from '@motionstudies/web/components/AirportHeroCard'
|
|
44
|
+
import '@motionstudies/web/airport-hero-card.css'
|
|
45
|
+
|
|
46
|
+
<AirportHeroCard
|
|
47
|
+
key={airport.id}
|
|
48
|
+
airport={airport}
|
|
49
|
+
departures={departures}
|
|
50
|
+
arrivals={arrivals}
|
|
51
|
+
study={{ time, windowStart: metadata.windowStart, windowEnd: metadata.windowEnd }}
|
|
52
|
+
dateLabel={metadata.serviceDate}
|
|
53
|
+
note="Observed study · inferred directions; times are observations."
|
|
54
|
+
onSelectFlight={selectAirTrack}
|
|
55
|
+
selectedFlightId={selectedAirTrackId}
|
|
56
|
+
/>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Entries have a stable `id`, a `service` label, and optional numeric `time`, `place`, `stand`, `status` and `tone` (`neutral`, `accent` or `warning`). Movement times and `study.time`, `windowStart`, and `windowEnd` must use the same study-relative seconds and service date. Do not parse display strings or normalize numeric times at midnight: an event after 24:00 retains its value above 86,400. `formatTime` optionally controls display formatting; the default uses the study's `formatServiceTime` helper. The header clock is derived directly from `study.time`, with no independent wall clock.
|
|
60
|
+
|
|
61
|
+
The card sorts movements chronologically and shows up to eight rows per direction inside the intersection of the study bounds and a rolling window: ten minutes behind the playback clock and sixty minutes ahead, with inclusive endpoints. Override this with `horizon={{ lookBehindSeconds: 600, lookAheadSeconds: 3600 }}` and `maxRows`. Playback, backward seeking, changed study bounds and updated movement times all recalculate the rows. An out-of-study or invalid clock shows no movements; rows with missing or non-finite times are excluded because they cannot be placed in the window. Other unknown fields render as a dash. Labels include `studyTime`, `boardWindow` and `outsideWindow` for localization. Filtering does not infer operational statuses or clear the consumer's map selection when a row leaves the window.
|
|
62
|
+
|
|
63
|
+
Consumers still own time coordinates, source interpretation and data loading. Supply movements for the displayed horizon, not just aircraft active at the current second, and use the selected study's bounds rather than an individual progressive chunk's bounds. Do not turn an approach-envelope association into a confirmed departure/arrival: unclassified tracks should remain outside these direction lists. Current `AirTrack` data does not supply scheduled times, routes or gates; leave those fields absent, use observation times only when clearly labelled, and explain any inference in the required `note`.
|
|
64
|
+
|
|
65
|
+
Pass `labels` for edition translations, `loading`, or a localized `error` and `onRetry` for data states. The selected direction is local to each card; key the card by airport ID to reset it on selection changes. The Airports lab specimen exercises synthetic timetables, playback, scrubbing, study-window changes, incomplete observations, French labels, long destinations, updates and recovery. Edition adoption happens through their independently pinned package releases; adding this export does not update deployed studies.
|
|
66
|
+
|
|
67
|
+
For a custom rail or transport board, import `SplitFlapBoard` from `@motionstudies/web/components/SplitFlapBoard` and `@motionstudies/web/split-flap-board.css`. Supply `columns` (`key`, `label`, `characters`) and `rows` (`id`, `cells`, optional `tone`). Cell text longer than its flap count is visually ellipsized, with the full value retained for assistive technology and hover. `onSelectRow`, `selectedRowId` and `selectionColumn` optionally make one cell per row selectable.
|
|
68
|
+
|
|
69
|
+
The shared board also accepts `loading`, a localized `loadingMessage`, and `loadingRows` (default five). While loading, its decorative rows cycle through staggered letters and digits; they are hidden from assistive technology and cannot be selected. A single status message announces loading. When data arrives, characters flip through a short sequence and settle into their actual values; later changes animate only the changed characters. These CSS animations have no JavaScript timers and stop looping when loading ends or the board is removed. Reduced-motion users get static blank loading flaps and immediate final text. `AirportHeroCard` uses this shared loading treatment automatically. Use **Reload board** in the Airports lab to preview the complete loading-to-ready transition.
|
|
70
|
+
|
|
71
|
+
Empty messages also appear on the flaps, in the widest column (the destination/origin column in airport cards), with the other columns blank. Longer localized messages wrap across display rows instead of being truncated. They settle with the same animation as flight details, and one hidden status announces the complete message to assistive technology. This also applies when the study clock moves into a window with no movements.
|
|
72
|
+
|
|
73
|
+
Rail and other transport consumers can share the same time filtering through `movementBoardWindow(study, horizon)` and `movementsForBoard(entries, window, maxRows)` from `@motionstudies/core/domain/movement-board`. Format the returned numeric times when mapping them into `SplitFlapBoard` cells. The lab's rail board follows the same study clock and horizon as its airport card.
|
|
74
|
+
|
|
75
|
+
`@motionstudies/data/air-endpoints` provides offline `enrichAirEndpoints` for existing air manifests, chunks and opening snapshots. Supply cached same-date global ADSB.lol heatmaps, an OurAirports CSV and the service date's local UTC offset. It associates only unambiguous low-altitude endpoints near a reference airport; cruise-only traces and uncertain routes stay unknown. Optional `AirEndpoint` origin/destination fields carry airport identity, observed boundary time and `observed-endpoint` evidence. `airportBoardMovements` maps full manifest entries to board rows without confusing playback chunk boundaries with flight endpoints. Input hashes and source/licence attribution are recorded in fixture metadata. These fields describe inferred observations, never flight schedules, gates or live status.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type MovementBoardStudy, type MovementBoardHorizon } from '@motionstudies/core/domain/movement-board';
|
|
3
|
+
import { type SplitFlapRow } from './SplitFlapBoard.tsx';
|
|
4
|
+
export interface AirportBoardEntry {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
/** Movement time in the same seconds as the study clock. Untimed rows are omitted. */
|
|
7
|
+
readonly time?: number;
|
|
8
|
+
readonly service: string;
|
|
9
|
+
readonly place?: string;
|
|
10
|
+
readonly stand?: string;
|
|
11
|
+
readonly status?: string;
|
|
12
|
+
readonly tone?: SplitFlapRow['tone'];
|
|
13
|
+
}
|
|
14
|
+
declare const defaultLabels: {
|
|
15
|
+
airport: string;
|
|
16
|
+
departures: string;
|
|
17
|
+
arrivals: string;
|
|
18
|
+
time: string;
|
|
19
|
+
service: string;
|
|
20
|
+
destination: string;
|
|
21
|
+
origin: string;
|
|
22
|
+
stand: string;
|
|
23
|
+
status: string;
|
|
24
|
+
emptyDepartures: string;
|
|
25
|
+
emptyArrivals: string;
|
|
26
|
+
loading: string;
|
|
27
|
+
retry: string;
|
|
28
|
+
studyTime: string;
|
|
29
|
+
boardWindow: string;
|
|
30
|
+
outsideWindow: string;
|
|
31
|
+
};
|
|
32
|
+
export interface AirportHeroCardProps {
|
|
33
|
+
readonly airport: {
|
|
34
|
+
readonly iata: string;
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly city: string;
|
|
37
|
+
};
|
|
38
|
+
readonly departures: readonly AirportBoardEntry[];
|
|
39
|
+
readonly arrivals: readonly AirportBoardEntry[];
|
|
40
|
+
/** Explain the source, study date and whether directions/times are inferred. */
|
|
41
|
+
readonly note: ReactNode;
|
|
42
|
+
readonly study: MovementBoardStudy;
|
|
43
|
+
readonly horizon?: MovementBoardHorizon;
|
|
44
|
+
readonly maxRows?: number;
|
|
45
|
+
readonly dateLabel?: string;
|
|
46
|
+
readonly formatTime?: (seconds: number) => string;
|
|
47
|
+
readonly labels?: Partial<typeof defaultLabels>;
|
|
48
|
+
readonly loading?: boolean;
|
|
49
|
+
readonly error?: string;
|
|
50
|
+
readonly onRetry?: () => void;
|
|
51
|
+
readonly onSelectFlight?: (id: string) => void;
|
|
52
|
+
readonly selectedFlightId?: string;
|
|
53
|
+
readonly initialDirection?: 'departures' | 'arrivals';
|
|
54
|
+
readonly className?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Shared airport selection hero. Supply a key={airport.id} to reset direction on airport changes. */
|
|
57
|
+
export declare function AirportHeroCard({ airport, departures, arrivals, note, study, horizon, maxRows, dateLabel, formatTime, labels, loading, error, onRetry, onSelectFlight, selectedFlightId, initialDirection, className }: AirportHeroCardProps): import("react").JSX.Element;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useId, useState } from 'react';
|
|
3
|
+
import { formatServiceTime } from '@motionstudies/core/domain/network';
|
|
4
|
+
import { movementBoardWindow, movementsForBoard } from '@motionstudies/core/domain/movement-board';
|
|
5
|
+
import { SplitFlapBoard } from './SplitFlapBoard.js';
|
|
6
|
+
const defaultLabels = {
|
|
7
|
+
airport: 'Airport', departures: 'Departures', arrivals: 'Arrivals',
|
|
8
|
+
time: 'Time', service: 'Flight', destination: 'To', origin: 'From', stand: 'Gate', status: 'Remarks',
|
|
9
|
+
emptyDepartures: 'No departures in this window.', emptyArrivals: 'No arrivals in this window.',
|
|
10
|
+
loading: 'Loading airport movements…', retry: 'Retry',
|
|
11
|
+
studyTime: 'Study time', boardWindow: 'Board window', outsideWindow: 'Outside study window',
|
|
12
|
+
};
|
|
13
|
+
/** Shared airport selection hero. Supply a key={airport.id} to reset direction on airport changes. */
|
|
14
|
+
export function AirportHeroCard({ airport, departures, arrivals, note, study, horizon, maxRows = 8, dateLabel, formatTime = formatServiceTime, labels, loading = false, error, onRetry, onSelectFlight, selectedFlightId, initialDirection = 'departures', className = '' }) {
|
|
15
|
+
const [direction, setDirection] = useState(initialDirection);
|
|
16
|
+
const titleId = useId();
|
|
17
|
+
const boardId = useId();
|
|
18
|
+
const copy = { ...defaultLabels, ...labels };
|
|
19
|
+
const window = movementBoardWindow(study, horizon);
|
|
20
|
+
const entries = movementsForBoard(direction === 'departures' ? departures : arrivals, window, maxRows);
|
|
21
|
+
const rows = entries.map((entry) => ({ id: entry.id, tone: entry.tone,
|
|
22
|
+
cells: { time: formatTime(entry.time), service: entry.service, place: entry.place, stand: entry.stand, status: entry.status } }));
|
|
23
|
+
return _jsxs("section", { className: `ms-airport-hero ${className}`, "aria-labelledby": titleId, children: [_jsxs("div", { className: "ms-airport-hero__masthead", children: [_jsxs("span", { className: "ms-airport-hero__eyebrow", children: [_jsx("span", { "aria-hidden": "true", children: "\u2197" }), " ", copy.airport, " / ", airport.city] }), _jsxs("span", { className: "ms-airport-hero__clock", children: [dateLabel && _jsxs(_Fragment, { children: [dateLabel, " \u00B7 "] }), copy.studyTime, " ", Number.isFinite(study.time) ? formatTime(study.time) : '—'] })] }), _jsxs("div", { className: "ms-airport-hero__identity", children: [_jsx("strong", { className: "ms-airport-hero__code", children: airport.iata }), _jsx("h2", { id: titleId, children: airport.name }), _jsx("span", { className: "ms-airport-hero__runway", "aria-hidden": "true" })] }), _jsx("div", { className: "ms-airport-hero__directions", role: "group", "aria-label": `${copy.departures} / ${copy.arrivals}`, children: ['departures', 'arrivals'].map((value) => _jsxs("button", { type: "button", "aria-pressed": direction === value, "aria-controls": boardId, onClick: () => setDirection(value), children: [_jsx("span", { "aria-hidden": "true", children: value === 'departures' ? '↗' : '↘' }), " ", copy[value]] }, value)) }), _jsx("p", { className: "ms-airport-hero__window", children: window ? `${copy.boardWindow} ${formatTime(window.start)}–${formatTime(window.end)}` : copy.outsideWindow }), _jsx("div", { id: boardId, children: error ? _jsxs("div", { className: "ms-airport-hero__message", role: "status", children: [error, onRetry && _jsx("button", { type: "button", onClick: onRetry, children: copy.retry })] })
|
|
24
|
+
: _jsx(SplitFlapBoard, { label: `${airport.iata} ${copy[direction]}`, columns: [
|
|
25
|
+
{ key: 'time', label: copy.time, characters: 5 },
|
|
26
|
+
{ key: 'service', label: copy.service, characters: 7 },
|
|
27
|
+
{ key: 'place', label: direction === 'departures' ? copy.destination : copy.origin, characters: 16 },
|
|
28
|
+
{ key: 'stand', label: copy.stand, characters: 3 },
|
|
29
|
+
{ key: 'status', label: copy.status, characters: 10 },
|
|
30
|
+
], rows: rows, loading: loading, loadingMessage: copy.loading, loadingRows: rows.length || Math.min(maxRows, 5), emptyMessage: direction === 'departures' ? copy.emptyDepartures : copy.emptyArrivals, selectionColumn: "service", onSelectRow: onSelectFlight, selectedRowId: selectedFlightId }) }), _jsx("div", { className: "ms-airport-hero__note", children: note })] });
|
|
31
|
+
}
|
|
@@ -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,21 @@
|
|
|
1
|
+
@import './split-flap-board.css';
|
|
2
|
+
|
|
3
|
+
.ms-airport-hero { --airport-accent: #edc96b; 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; }
|
|
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__clock { color: #bebfac; font: 12px/1.5 ui-monospace, monospace; }
|
|
8
|
+
.ms-airport-hero__window { color: #bebfac; font: 11px/1.5 ui-monospace, monospace; margin: 0 0 12px; }
|
|
9
|
+
.ms-airport-hero__identity { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 24px; padding: 22px 0 26px; }
|
|
10
|
+
.ms-airport-hero__code { color: var(--airport-accent); font-size: clamp(54px, 8vw, 88px); font-weight: 650; line-height: .95; letter-spacing: -.065em; }
|
|
11
|
+
.ms-airport-hero__identity h2 { flex: 1; min-width: 140px; max-width: 300px; color: #f0ecdd; font: 400 clamp(20px, 3vw, 27px)/1.15 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.025em; margin: 0; overflow-wrap: anywhere; }
|
|
12
|
+
.ms-airport-hero__runway { margin-left: auto; width: 54px; height: 62px; border-inline: 1px solid #797c6166; transform: skewY(-20deg); background: repeating-linear-gradient(to bottom, #b5b79266 0 8px, transparent 8px 16px) center / 2px 100% no-repeat; }
|
|
13
|
+
.ms-airport-hero__directions { display: flex; gap: 22px; border-bottom: 1px solid #4b4d40; margin-bottom: 16px; }
|
|
14
|
+
.ms-airport-hero__directions button { 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; }
|
|
15
|
+
.ms-airport-hero__directions button[aria-pressed='true'] { color: var(--airport-accent); border-bottom-color: var(--airport-accent); }
|
|
16
|
+
.ms-airport-hero__directions button:hover { color: #f0ecdd; }
|
|
17
|
+
.ms-airport-hero button:focus-visible { outline: 2px solid var(--airport-accent); outline-offset: 4px; }
|
|
18
|
+
.ms-airport-hero__message { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 30px 0; margin: 0; font-size: 14px; color: #c5c7b6; }
|
|
19
|
+
.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; }
|
|
20
|
+
.ms-airport-hero__note { color: #b8baaa; font-size: 11px; line-height: 1.6; margin-top: 14px; }
|
|
21
|
+
@media (max-width: 480px) { .ms-airport-hero__runway { display: none; } .ms-airport-hero__directions { gap: 18px; } }
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
.ms-split-flap-board {
|
|
2
|
+
--board-ink: #eee9d7;
|
|
3
|
+
--board-accent: var(--airport-accent, #edc96b);
|
|
4
|
+
color: var(--board-ink);
|
|
5
|
+
background: #111311;
|
|
6
|
+
padding: 10px 14px 18px;
|
|
7
|
+
border: 1px solid #383a32;
|
|
8
|
+
border-radius: 5px;
|
|
9
|
+
box-shadow: inset 0 2px 10px #0008, 0 2px 0 #ffffff08;
|
|
10
|
+
min-width: 0;
|
|
11
|
+
}
|
|
12
|
+
.ms-split-flap-board *, .ms-airport-hero * { box-sizing: border-box; }
|
|
13
|
+
.ms-split-flap-board__scroll { position: relative; overflow-x: auto; scrollbar-width: thin; scrollbar-color: #656454 #171917; padding: 4px 1px 8px; }
|
|
14
|
+
.ms-split-flap-board__table { border-collapse: separate; border-spacing: 0 7px; width: 100%; text-align: left; }
|
|
15
|
+
.ms-split-flap-board__table th { color: #aaa996; font: 500 10px/1.5 ui-sans-serif, system-ui, sans-serif; text-transform: uppercase; letter-spacing: .14em; padding: 0 12px 3px 0; }
|
|
16
|
+
.ms-split-flap-board__table td { padding: 0 12px 0 0; }
|
|
17
|
+
.ms-split-flap-board__table :is(th, td):last-child { padding-right: 0; }
|
|
18
|
+
.ms-flap-text { position: relative; display: inline-block; vertical-align: middle; }
|
|
19
|
+
.ms-flap-text__tiles { display: flex; gap: 2px; white-space: pre; }
|
|
20
|
+
.ms-flap { position: relative; display: block; width: .72em; height: 1.65em; perspective: 100px; font: 600 15px/1.65 ui-monospace, 'SFMono-Regular', Consolas, monospace; }
|
|
21
|
+
.ms-flap__face { position: relative; display: block; height: 100%; text-align: center; background: linear-gradient(#30332e 49%, #1d201c 50%); border-radius: 2px; box-shadow: inset 0 1px #ffffff16, 0 1px 2px #000; animation: ms-flap-turn 135ms ease-in-out 5 backwards; animation-delay: calc(var(--flap-delay) * .75); }
|
|
22
|
+
.ms-flap__drum { position: absolute; inset: 0; border-radius: inherit; background: inherit; animation: ms-flap-reveal 675ms step-end both; animation-delay: calc(var(--flap-delay) * .75); }
|
|
23
|
+
.ms-flap__drum::before { content: 'A'; animation: ms-flap-characters 1440ms step-end both; animation-delay: calc(var(--flap-phase) * .75); }
|
|
24
|
+
.ms-split-flap-board[data-loading] .ms-flap__face { animation: ms-flap-turn 240ms ease-in-out infinite; animation-delay: var(--flap-motion-phase); }
|
|
25
|
+
.ms-split-flap-board[data-loading] .ms-flap__drum { animation: none; }
|
|
26
|
+
.ms-split-flap-board[data-loading] .ms-flap__drum::before { animation-duration: 1920ms; animation-delay: var(--flap-phase); animation-iteration-count: infinite; }
|
|
27
|
+
.ms-flap::after { content: ''; position: absolute; top: 50%; left: 0; right: 0; border-top: 1px solid #080a08; box-shadow: 0 1px #ffffff09; pointer-events: none; }
|
|
28
|
+
.ms-split-flap-board__table [data-tone='accent'] td:last-child { color: var(--board-accent); }
|
|
29
|
+
.ms-split-flap-board__table [data-tone='warning'] td:last-child { color: #ffb185; }
|
|
30
|
+
.ms-split-flap-board__table [data-selected] .ms-flap__face { box-shadow: inset 0 -2px var(--board-accent); }
|
|
31
|
+
.ms-split-flap-board__select { display: block; padding: 0; border: 0; border-radius: 2px; color: inherit; background: none; cursor: pointer; }
|
|
32
|
+
.ms-split-flap-board__select:hover { color: var(--board-accent); }
|
|
33
|
+
.ms-split-flap-board :focus-visible { outline: 2px solid var(--board-accent); outline-offset: 3px; }
|
|
34
|
+
.ms-split-flap-board__loading { margin: 8px 0 0; color: #bbbbaa; font: 12px/1.5 ui-sans-serif, system-ui, sans-serif; }
|
|
35
|
+
.ms-board-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
36
|
+
@keyframes ms-flap-turn { 0% { transform: rotateX(-80deg); } 65%, 100% { transform: rotateX(0); } }
|
|
37
|
+
@keyframes ms-flap-reveal { 0% { visibility: visible; } 100% { visibility: hidden; } }
|
|
38
|
+
@keyframes ms-flap-characters {
|
|
39
|
+
0% { content: 'A'; } 12.5% { content: 'E'; } 25% { content: 'K'; } 37.5% { content: 'N'; }
|
|
40
|
+
50% { content: 'R'; } 62.5% { content: 'S'; } 75% { content: '2'; } 87.5%, 100% { content: '7'; }
|
|
41
|
+
}
|
|
42
|
+
@media (prefers-reduced-motion: reduce) {
|
|
43
|
+
.ms-split-flap-board .ms-flap .ms-flap__face, .ms-split-flap-board .ms-flap .ms-flap__drum, .ms-split-flap-board .ms-flap .ms-flap__drum::before { animation: none; }
|
|
44
|
+
.ms-split-flap-board .ms-flap__drum { display: none; }
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motionstudies/web",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Browser controls and loaders for Motion Studies.",
|
|
@@ -20,6 +20,18 @@
|
|
|
20
20
|
"tag": "next"
|
|
21
21
|
},
|
|
22
22
|
"exports": {
|
|
23
|
+
"./components/SplitFlapBoard": {
|
|
24
|
+
"types": "./components/SplitFlapBoard.d.ts",
|
|
25
|
+
"import": "./components/SplitFlapBoard.js",
|
|
26
|
+
"default": "./components/SplitFlapBoard.js"
|
|
27
|
+
},
|
|
28
|
+
"./components/AirportHeroCard": {
|
|
29
|
+
"types": "./components/AirportHeroCard.d.ts",
|
|
30
|
+
"import": "./components/AirportHeroCard.js",
|
|
31
|
+
"default": "./components/AirportHeroCard.js"
|
|
32
|
+
},
|
|
33
|
+
"./split-flap-board.css": "./components/split-flap-board.css",
|
|
34
|
+
"./airport-hero-card.css": "./components/airport-hero-card.css",
|
|
23
35
|
"./components/MobilePicker": {
|
|
24
36
|
"types": "./components/MobilePicker.d.ts",
|
|
25
37
|
"import": "./components/MobilePicker.js",
|
|
@@ -92,7 +104,7 @@
|
|
|
92
104
|
"**/*.css"
|
|
93
105
|
],
|
|
94
106
|
"dependencies": {
|
|
95
|
-
"@motionstudies/core": "0.1.0-alpha.
|
|
107
|
+
"@motionstudies/core": "0.1.0-alpha.5"
|
|
96
108
|
},
|
|
97
109
|
"peerDependencies": {
|
|
98
110
|
"react": "^19.2.8",
|