@motionstudies/web 0.1.0-alpha.0 → 0.1.0-alpha.10

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 (38) hide show
  1. package/README.md +171 -2
  2. package/components/AirportBoard.d.ts +27 -0
  3. package/components/AirportBoard.js +28 -0
  4. package/components/AirportHeroCard.d.ts +58 -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/ButtonTooltips.d.ts +5 -0
  9. package/components/ButtonTooltips.js +146 -0
  10. package/components/DotMatrixBoard.d.ts +31 -0
  11. package/components/DotMatrixBoard.js +84 -0
  12. package/components/RailStationHeroCard.d.ts +46 -0
  13. package/components/RailStationHeroCard.js +20 -0
  14. package/components/SbbDepartureBoard.d.ts +26 -0
  15. package/components/SbbDepartureBoard.js +29 -0
  16. package/components/SplitFlapBoard.d.ts +25 -0
  17. package/components/SplitFlapBoard.js +58 -0
  18. package/components/TransportHeroBoard.d.ts +28 -0
  19. package/components/TransportHeroBoard.js +15 -0
  20. package/components/airport-hero-card.css +42 -0
  21. package/components/dot-matrix-board.css +49 -0
  22. package/components/dot-matrix-font.d.ts +2 -0
  23. package/components/dot-matrix-font.js +61 -0
  24. package/components/sbb-departure-board.css +49 -0
  25. package/components/split-flap-board.css +45 -0
  26. package/components/transport-hero-cards.css +39 -0
  27. package/mount-motion-study.js +3 -2
  28. package/package.json +67 -3
  29. package/use-airport-feed.d.ts +9 -0
  30. package/use-airport-feed.js +72 -0
  31. package/use-browser-location.d.ts +13 -0
  32. package/use-browser-location.js +39 -0
  33. package/use-json-asset.d.ts +9 -0
  34. package/use-json-asset.js +45 -0
  35. package/use-now-clock.d.ts +11 -0
  36. package/use-now-clock.js +45 -0
  37. package/use-transition-value.d.ts +12 -0
  38. package/use-transition-value.js +46 -0
@@ -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
+ }
@@ -0,0 +1,39 @@
1
+ @import './dot-matrix-board.css';
2
+ @import './split-flap-board.css';
3
+ @import './sbb-departure-board.css';
4
+
5
+ .ms-bus-stop-hero, .ms-rail-station-hero { box-sizing: border-box; min-width: 0; container-type: inline-size; padding: clamp(12px, 4%, 24px); font-family: ui-sans-serif, system-ui, sans-serif; }
6
+ .ms-bus-stop-hero *, .ms-rail-station-hero * { box-sizing: border-box; }
7
+ .ms-bus-stop-hero { color: #e8ebe2; background: #2b3532; border: 1px solid #56645d; border-radius: 6px; }
8
+ .ms-bus-stop-hero__identity { display: flex; align-items: center; gap: 18px; padding: 4px 0 24px; }
9
+ .ms-bus-stop-hero__identity > div { min-width: 0; }
10
+ .ms-bus-stop-hero__identity p { margin: 0; color: #b9c7be; font-size: 12px; line-height: 1.6; }
11
+ .ms-bus-stop-hero__identity h2 { margin: 4px 0; font-size: clamp(24px, 5cqi, 40px); font-weight: 500; line-height: 1.1; letter-spacing: -.03em; overflow-wrap: anywhere; }
12
+ .ms-bus-stop-hero__symbol { display: grid; place-items: center; flex-shrink: 0; width: 48px; height: 58px; background: #dfe7d9; color: #2b3532; border-radius: 2px; }
13
+ .ms-bus-stop-hero__symbol svg { width: 32px; fill: none; stroke: currentColor; stroke-width: 2; }
14
+ .ms-bus-stop-hero__code { margin-left: auto; text-align: right; flex-shrink: 0; }
15
+ .ms-bus-stop-hero__code span { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .1em; }
16
+ .ms-bus-stop-hero__code strong { font-size: 28px; line-height: 1.2; font-weight: 500; }
17
+ .ms-rail-station-hero { color: #f3f0e3; background: linear-gradient(120deg, #1d272c, #11191d); border: 1px solid #425057; border-top: 4px solid #d0b874; border-radius: 3px; }
18
+ .ms-rail-station-hero__masthead { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px; color: #c9c8b9; font-size: 10px; text-transform: uppercase; letter-spacing: .14em; }
19
+ .ms-rail-station-hero__masthead strong { padding: 4px 7px; border: 1px solid #5a605b; font: 500 12px/1.2 ui-monospace, monospace; }
20
+ .ms-rail-station-hero__identity { display: flex; align-items: center; gap: 18px; padding: 20px 0 24px; }
21
+ .ms-rail-station-hero__identity > span { flex-shrink: 0; width: 42px; color: #e4d7b2; }
22
+ .ms-rail-station-hero__identity svg { width: 100%; fill: none; stroke: currentColor; stroke-width: 3; }
23
+ .ms-rail-station-hero__identity h2 { margin: 0; font-size: clamp(25px, 5cqi, 42px); line-height: 1.1; letter-spacing: -.035em; font-weight: 500; overflow-wrap: anywhere; }
24
+ .ms-rail-station-hero[data-presentation='sbb'] { background: #0c1730; border-color: #405375; border-top: 4px solid #eb001b; }
25
+ .ms-rail-station-hero[data-presentation='sbb'] .ms-rail-station-hero__identity > span { width: 44px; height: 40px; padding: 5px; color: #fff; background: #d70019; }
26
+ .ms-rail-station-hero[data-presentation='sbb'] .ms-rail-station-hero__masthead { color: #b7c6e0; }
27
+ .ms-transport-hero__note { margin-top: 16px; color: #b8c0b9; font-size: 11px; line-height: 1.6; overflow-wrap: anywhere; }
28
+ .ms-transport-hero__error { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; min-height: 140px; padding: 24px; background: #111713; color: #ece8d6; }
29
+ .ms-transport-hero__error button { min-height: 40px; padding: 8px 16px; font: inherit; background: #29352c; color: inherit; border: 1px solid #627465; border-radius: 3px; cursor: pointer; }
30
+ .ms-transport-hero__error button:focus-visible { outline: 2px solid #f6cd46; outline-offset: 3px; }
31
+ .ms-transport-hero__flap { overflow: auto; min-width: 0; }
32
+ .ms-transport-hero__flap-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 12px 0; font: 12px/1.5 ui-monospace, monospace; color: #d8d6c1; }
33
+ @container (max-width: 440px) {
34
+ .ms-bus-stop-hero__identity, .ms-rail-station-hero__identity { gap: 12px; }
35
+ .ms-bus-stop-hero__symbol { width: 36px; height: 46px; }
36
+ .ms-rail-station-hero__identity > span { width: 32px; }
37
+ .ms-bus-stop-hero__code strong { font-size: 20px; }
38
+ .ms-rail-station-hero .ms-dot-matrix-board { padding: 12px 8px; }
39
+ }
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { StrictMode } from 'react';
3
3
  import { createRoot } from 'react-dom/client';
4
+ import { ButtonTooltips } from './components/ButtonTooltips.js';
4
5
  import { applyVisualTheme } from './visual-theme.js';
5
6
  export function mountMotionStudy(edition, application) {
6
7
  const root = document.getElementById('root');
@@ -9,5 +10,5 @@ export function mountMotionStudy(edition, application) {
9
10
  document.documentElement.classList.add('motion-study');
10
11
  document.documentElement.dataset.edition = edition.id;
11
12
  applyVisualTheme(edition.theme);
12
- createRoot(root).render(_jsx(StrictMode, { children: application }));
13
+ createRoot(root).render(_jsxs(StrictMode, { children: [application, _jsx(ButtonTooltips, {})] }));
13
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/web",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser controls and loaders for Motion Studies.",
@@ -20,6 +20,28 @@
20
20
  "tag": "next"
21
21
  },
22
22
  "exports": {
23
+ "./use-now-clock": {
24
+ "types": "./use-now-clock.d.ts",
25
+ "import": "./use-now-clock.js",
26
+ "default": "./use-now-clock.js"
27
+ },
28
+ "./use-browser-location": {
29
+ "types": "./use-browser-location.d.ts",
30
+ "import": "./use-browser-location.js",
31
+ "default": "./use-browser-location.js"
32
+ },
33
+ "./components/SplitFlapBoard": {
34
+ "types": "./components/SplitFlapBoard.d.ts",
35
+ "import": "./components/SplitFlapBoard.js",
36
+ "default": "./components/SplitFlapBoard.js"
37
+ },
38
+ "./components/AirportHeroCard": {
39
+ "types": "./components/AirportHeroCard.d.ts",
40
+ "import": "./components/AirportHeroCard.js",
41
+ "default": "./components/AirportHeroCard.js"
42
+ },
43
+ "./split-flap-board.css": "./components/split-flap-board.css",
44
+ "./airport-hero-card.css": "./components/airport-hero-card.css",
23
45
  "./components/MobilePicker": {
24
46
  "types": "./components/MobilePicker.d.ts",
25
47
  "import": "./components/MobilePicker.js",
@@ -72,7 +94,49 @@
72
94
  },
73
95
  "./tokens.css": "./tokens.css",
74
96
  "./shell.css": "./shell.css",
75
- "./mobile-picker.css": "./components/mobile-picker.css"
97
+ "./mobile-picker.css": "./components/mobile-picker.css",
98
+ "./components/ButtonTooltips": {
99
+ "types": "./components/ButtonTooltips.d.ts",
100
+ "import": "./components/ButtonTooltips.js",
101
+ "default": "./components/ButtonTooltips.js"
102
+ },
103
+ "./use-airport-feed": {
104
+ "types": "./use-airport-feed.d.ts",
105
+ "import": "./use-airport-feed.js",
106
+ "default": "./use-airport-feed.js"
107
+ },
108
+ "./components/AirportBoard": {
109
+ "types": "./components/AirportBoard.d.ts",
110
+ "import": "./components/AirportBoard.js",
111
+ "default": "./components/AirportBoard.js"
112
+ },
113
+ "./components/DotMatrixBoard": {
114
+ "types": "./components/DotMatrixBoard.d.ts",
115
+ "import": "./components/DotMatrixBoard.js",
116
+ "default": "./components/DotMatrixBoard.js"
117
+ },
118
+ "./dot-matrix-board.css": "./components/dot-matrix-board.css",
119
+ "./components/BusStopHeroCard": {
120
+ "types": "./components/BusStopHeroCard.d.ts",
121
+ "import": "./components/BusStopHeroCard.js",
122
+ "default": "./components/BusStopHeroCard.js"
123
+ },
124
+ "./components/RailStationHeroCard": {
125
+ "types": "./components/RailStationHeroCard.d.ts",
126
+ "import": "./components/RailStationHeroCard.js",
127
+ "default": "./components/RailStationHeroCard.js"
128
+ },
129
+ "./transport-hero-cards.css": "./components/transport-hero-cards.css",
130
+ "./use-json-asset": {
131
+ "types": "./use-json-asset.d.ts",
132
+ "import": "./use-json-asset.js",
133
+ "default": "./use-json-asset.js"
134
+ },
135
+ "./use-transition-value": {
136
+ "types": "./use-transition-value.d.ts",
137
+ "import": "./use-transition-value.js",
138
+ "default": "./use-transition-value.js"
139
+ }
76
140
  },
77
141
  "files": [
78
142
  "**/*.js",
@@ -87,7 +151,7 @@
87
151
  "**/*.css"
88
152
  ],
89
153
  "dependencies": {
90
- "@motionstudies/core": "0.1.0-alpha.0"
154
+ "@motionstudies/core": "0.1.0-alpha.10"
91
155
  },
92
156
  "peerDependencies": {
93
157
  "react": "^19.2.8",
@@ -0,0 +1,9 @@
1
+ import { type AirportFeedResponse } from '@motionstudies/core/domain/live-airport';
2
+ export interface AirportFeedState {
3
+ readonly available: boolean;
4
+ readonly checking: boolean;
5
+ readonly loading: boolean;
6
+ readonly response?: AirportFeedResponse;
7
+ }
8
+ /** Fetch capabilities without paid work; poll boards only while Now is selected. */
9
+ export declare function useAirportFeed(baseUrl: string, edition: string, airport: string, enabled: boolean): AirportFeedState;
@@ -0,0 +1,72 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { isAirportFeedResponse } from '@motionstudies/core/domain/live-airport';
3
+ /** Fetch capabilities without paid work; poll boards only while Now is selected. */
4
+ export function useAirportFeed(baseUrl, edition, airport, enabled) {
5
+ const source = `${baseUrl.replace(/\/$/, '')}/api/airports/v1/${encodeURIComponent(edition)}`;
6
+ const identity = `${source}/${airport}/${enabled}`;
7
+ const empty = { available: false, checking: true, loading: false };
8
+ const [stored, setStored] = useState({ ...empty, identity });
9
+ const state = stored.identity === identity ? stored : empty;
10
+ useEffect(() => {
11
+ const controller = new AbortController();
12
+ let timer, failures = 0;
13
+ const update = (patch) => {
14
+ if (!controller.signal.aborted)
15
+ setStored((old) => ({ ...(old.identity === identity ? old : empty), ...patch, identity }));
16
+ };
17
+ update(empty);
18
+ const tick = async () => {
19
+ let delay = 60_000;
20
+ if (document.hidden) {
21
+ timer = window.setTimeout(() => void tick(), delay);
22
+ return;
23
+ }
24
+ try {
25
+ const options = { cache: 'no-store', signal: AbortSignal.any([controller.signal, AbortSignal.timeout(20_000)]) };
26
+ const capabilitiesResponse = await fetch(`${source}/capabilities`, options);
27
+ if (!capabilitiesResponse.ok)
28
+ throw new Error('Capabilities unavailable');
29
+ const capabilities = await capabilitiesResponse.json();
30
+ if (capabilities.version !== 1 || typeof capabilities.enabled !== 'boolean' || !Array.isArray(capabilities.airports))
31
+ throw new Error('Invalid capabilities');
32
+ const available = capabilities.enabled && capabilities.airports.includes(airport);
33
+ update({ available, checking: false });
34
+ if (!available) {
35
+ update({ loading: false, response: { version: 1, status: 'disabled', reason: 'disabled', retryAfterSeconds: 60 } });
36
+ }
37
+ else if (enabled) {
38
+ update({ loading: true });
39
+ const r = await fetch(`${source}/${encodeURIComponent(airport)}/board`, options);
40
+ const response = await r.json();
41
+ if (!isAirportFeedResponse(response))
42
+ throw new Error('Invalid board');
43
+ if (response.snapshot && (response.snapshot.airport.iata !== airport || response.snapshot.fetchedAt > Date.now() / 1000 + 60))
44
+ throw new Error('Mismatched board');
45
+ update({ response, loading: false, available: response.status !== 'disabled' });
46
+ // Check capabilities every minute even when the upstream circuit is paused.
47
+ }
48
+ failures = 0;
49
+ }
50
+ catch {
51
+ failures += 1;
52
+ delay = Math.min(300_000, 30_000 * 2 ** Math.min(failures, 4));
53
+ if (!controller.signal.aborted)
54
+ setStored((old) => {
55
+ const snapshot = old.identity === identity ? old.response?.snapshot : undefined;
56
+ return { identity, available: old.identity === identity && old.available, checking: false, loading: false,
57
+ response: { version: 1, status: snapshot && snapshot.expiresAt > Date.now() / 1000 ? 'stale' : 'unavailable', reason: 'provider', retryAfterSeconds: delay / 1000,
58
+ ...(snapshot && snapshot.expiresAt > Date.now() / 1000 ? { snapshot } : {}) } };
59
+ });
60
+ }
61
+ finally {
62
+ if (!controller.signal.aborted)
63
+ timer = window.setTimeout(() => void tick(), delay);
64
+ }
65
+ };
66
+ void tick();
67
+ return () => { controller.abort(); window.clearTimeout(timer); };
68
+ // empty is deliberately reconstructed only when the source effect restarts.
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [identity, source, airport, enabled]);
71
+ return state;
72
+ }
@@ -0,0 +1,13 @@
1
+ export interface BrowserLocation {
2
+ readonly longitude: number;
3
+ readonly latitude: number;
4
+ readonly accuracy: number;
5
+ }
6
+ export type BrowserLocationStatus = 'idle' | 'locating' | 'ready' | 'denied' | 'timeout' | 'unavailable';
7
+ /** One explicit request, kept in memory. Clearing or unmounting ignores late replies. */
8
+ export declare function useBrowserLocation(): {
9
+ location: BrowserLocation | undefined;
10
+ status: BrowserLocationStatus;
11
+ locate: () => void;
12
+ clear: () => void;
13
+ };
@@ -0,0 +1,39 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ /** One explicit request, kept in memory. Clearing or unmounting ignores late replies. */
3
+ export function useBrowserLocation() {
4
+ const [location, setLocation] = useState();
5
+ const [status, setStatus] = useState('idle');
6
+ const request = useRef(0);
7
+ useEffect(() => () => { request.current += 1; }, []);
8
+ const clear = useCallback(() => {
9
+ request.current += 1;
10
+ setLocation(undefined);
11
+ setStatus('idle');
12
+ }, []);
13
+ const locate = useCallback(() => {
14
+ const id = ++request.current;
15
+ setLocation(undefined);
16
+ if (!navigator.geolocation) {
17
+ setStatus('unavailable');
18
+ return;
19
+ }
20
+ setStatus('locating');
21
+ navigator.geolocation.getCurrentPosition(({ coords }) => {
22
+ if (request.current !== id)
23
+ return;
24
+ if (!Number.isFinite(coords.longitude) || !Number.isFinite(coords.latitude) ||
25
+ Math.abs(coords.longitude) > 180 || Math.abs(coords.latitude) > 90 ||
26
+ !Number.isFinite(coords.accuracy) || coords.accuracy < 0) {
27
+ setStatus('unavailable');
28
+ return;
29
+ }
30
+ setLocation({ longitude: coords.longitude, latitude: coords.latitude, accuracy: coords.accuracy });
31
+ setStatus('ready');
32
+ }, (error) => {
33
+ if (request.current !== id)
34
+ return;
35
+ setStatus(error.code === 1 ? 'denied' : error.code === 3 ? 'timeout' : 'unavailable');
36
+ }, { enableHighAccuracy: false, maximumAge: 0, timeout: 10_000 });
37
+ }, []);
38
+ return { location, status, locate, clear };
39
+ }
@@ -0,0 +1,9 @@
1
+ /** A single optional asset, cached until its URL/parser changes or retry is called.
2
+ * Keep parse stable. Source compatibility and schema checks belong in that parser. */
3
+ export declare function useJsonAsset<T>(url: string | undefined, enabled?: boolean, parse?: (value: unknown) => T, optional?: boolean): {
4
+ data: T | undefined;
5
+ error: boolean;
6
+ unavailable: boolean;
7
+ loading: boolean;
8
+ retry: () => void;
9
+ };
@@ -0,0 +1,45 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ const identity = (value) => value;
3
+ /** A single optional asset, cached until its URL/parser changes or retry is called.
4
+ * Keep parse stable. Source compatibility and schema checks belong in that parser. */
5
+ export function useJsonAsset(url, enabled = true, parse = identity, optional = false) {
6
+ const [attempt, setAttempt] = useState(0);
7
+ const [stored, setStored] = useState({ url, parse, attempt });
8
+ const state = stored.url === url && stored.parse === parse && stored.attempt === attempt
9
+ ? stored : { url, parse, attempt };
10
+ if (stored !== state)
11
+ setStored(state);
12
+ const { data, error, unavailable } = state;
13
+ useEffect(() => {
14
+ if (!enabled || !url || data !== undefined || unavailable)
15
+ return;
16
+ const controller = new AbortController();
17
+ const save = (result) => {
18
+ if (!controller.signal.aborted)
19
+ setStored(current => current.url === url && current.parse === parse && current.attempt === attempt
20
+ ? { ...current, ...result } : current);
21
+ };
22
+ void (async () => {
23
+ try {
24
+ const response = await fetch(url, { signal: controller.signal });
25
+ if (optional && response.status === 404) {
26
+ save({ unavailable: true, error: false });
27
+ return;
28
+ }
29
+ if (!response.ok)
30
+ throw new Error(`Asset returned ${response.status}`);
31
+ const next = parse(await response.json());
32
+ if (next === undefined)
33
+ throw new Error('Asset parser returned no data');
34
+ save({ data: next, error: false });
35
+ }
36
+ catch {
37
+ save({ error: true });
38
+ }
39
+ })();
40
+ return () => controller.abort();
41
+ }, [url, enabled, parse, optional, attempt, data, unavailable]);
42
+ const retry = useCallback(() => setAttempt(value => value + 1), []);
43
+ return { data, error: Boolean(error), unavailable: Boolean(unavailable),
44
+ loading: Boolean(enabled && url && data === undefined && !error && !unavailable), retry };
45
+ }
@@ -0,0 +1,11 @@
1
+ /** The consumer maps an instant into its own service date, timezone and data window.
2
+ * The consumer may explicitly choose a representative timetable for the present.
3
+ * Return null when its data cannot meaningfully represent that instant.
4
+ */
5
+ export declare function useNowClock(resolveTime: (instant: Date) => number | null): {
6
+ active: boolean;
7
+ time: number | null;
8
+ unavailable: boolean;
9
+ start: () => void;
10
+ stop: () => void;
11
+ };
@@ -0,0 +1,45 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ /** The consumer maps an instant into its own service date, timezone and data window.
3
+ * The consumer may explicitly choose a representative timetable for the present.
4
+ * Return null when its data cannot meaningfully represent that instant.
5
+ */
6
+ export function useNowClock(resolveTime) {
7
+ const [active, setActive] = useState(false);
8
+ const [time, setTime] = useState(null);
9
+ const [unavailable, setUnavailable] = useState(false);
10
+ const running = useRef(false);
11
+ const resolver = useRef(resolveTime);
12
+ useLayoutEffect(() => { resolver.current = resolveTime; }, [resolveTime]);
13
+ const update = useCallback(() => {
14
+ const next = resolver.current(new Date());
15
+ if (next === null || !Number.isFinite(next)) {
16
+ running.current = false;
17
+ setActive(false);
18
+ setUnavailable(true);
19
+ return false;
20
+ }
21
+ setTime(next);
22
+ return true;
23
+ }, []);
24
+ const start = useCallback(() => {
25
+ setUnavailable(false);
26
+ running.current = update();
27
+ setActive(running.current);
28
+ }, [update]);
29
+ const stop = useCallback(() => {
30
+ running.current = false;
31
+ setActive(false);
32
+ }, []);
33
+ useEffect(() => {
34
+ if (!active)
35
+ return;
36
+ let frame;
37
+ const tick = () => {
38
+ if (running.current && update())
39
+ frame = requestAnimationFrame(tick);
40
+ };
41
+ frame = requestAnimationFrame(tick);
42
+ return () => cancelAnimationFrame(frame);
43
+ }, [active, update]);
44
+ return { active, time, unavailable, start, stop };
45
+ }
@@ -0,0 +1,12 @@
1
+ export declare const smoothTransition: (progress: number) => number;
2
+ export declare const cosineTransition: (progress: number) => number;
3
+ /** Animate a scalar from its current value; interruptions reverse from the last frame.
4
+ * Reduced-motion changes settle immediately, including during an active transition. */
5
+ export declare function useTransitionValue(target: number, { durationMs, easing, steps, }?: {
6
+ readonly durationMs?: number;
7
+ readonly easing?: (progress: number) => number;
8
+ readonly steps?: number;
9
+ }): {
10
+ value: number;
11
+ transitioning: boolean;
12
+ };
@@ -0,0 +1,46 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ export const smoothTransition = (progress) => progress * progress * (3 - 2 * progress);
3
+ export const cosineTransition = (progress) => 0.5 - Math.cos(progress * Math.PI) / 2;
4
+ /** Animate a scalar from its current value; interruptions reverse from the last frame.
5
+ * Reduced-motion changes settle immediately, including during an active transition. */
6
+ export function useTransitionValue(target, { durationMs = 1600, easing = smoothTransition, steps, } = {}) {
7
+ const current = useRef(target);
8
+ const [value, setValue] = useState(target);
9
+ const [transitioning, setTransitioning] = useState(false);
10
+ useEffect(() => {
11
+ const from = current.current;
12
+ const motion = matchMedia('(prefers-reduced-motion: reduce)');
13
+ let frame = 0;
14
+ const finish = () => {
15
+ cancelAnimationFrame(frame);
16
+ current.current = target;
17
+ setValue(target);
18
+ setTransitioning(false);
19
+ };
20
+ if (!Number.isFinite(target) || !Number.isFinite(from))
21
+ return;
22
+ if (motion.matches || from === target || !(durationMs > 0) || !Number.isFinite(durationMs)) {
23
+ finish();
24
+ return;
25
+ }
26
+ const started = performance.now();
27
+ setTransitioning(true);
28
+ const tick = (now) => {
29
+ const progress = Math.min(1, (now - started) / durationMs);
30
+ const eased = easing(progress);
31
+ const fraction = steps && Number.isFinite(steps) && steps > 0 ? Math.round(eased * steps) / steps : eased;
32
+ current.current = from + (target - from) * fraction;
33
+ setValue(current.current);
34
+ if (progress < 1)
35
+ frame = requestAnimationFrame(tick);
36
+ else
37
+ finish();
38
+ };
39
+ frame = requestAnimationFrame(tick);
40
+ const changed = () => { if (motion.matches)
41
+ finish(); };
42
+ motion.addEventListener('change', changed);
43
+ return () => { cancelAnimationFrame(frame); motion.removeEventListener('change', changed); };
44
+ }, [target, durationMs, easing, steps]);
45
+ return { value, transitioning };
46
+ }