signalk-chiplog 2.0.0 → 2.2.0

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 (51) hide show
  1. package/CHANGELOG.md +186 -37
  2. package/README.md +340 -134
  3. package/index.js +80 -40
  4. package/lib/api.js +109 -1
  5. package/lib/background-schedule.js +67 -0
  6. package/lib/crew.js +185 -0
  7. package/lib/database.js +103 -1
  8. package/lib/departure-forecast.js +150 -0
  9. package/lib/detection.js +45 -14
  10. package/lib/entries.js +96 -48
  11. package/lib/event-watcher.js +1 -3
  12. package/lib/events.js +64 -16
  13. package/lib/export.js +20 -1
  14. package/lib/landmark-finder.js +209 -0
  15. package/lib/landmarks.js +210 -0
  16. package/lib/logbook-pdf.js +368 -17
  17. package/lib/observation-recorder.js +11 -5
  18. package/lib/tide-forecaster.js +23 -116
  19. package/lib/weather-forecaster.js +147 -0
  20. package/package.json +2 -1
  21. package/public/app.css +212 -2
  22. package/public/entry/entry.css +71 -0
  23. package/public/entry/js/components/CrewDialog.mjs +200 -0
  24. package/public/entry/js/main.mjs +114 -0
  25. package/public/entry/sw.js +2 -0
  26. package/public/js/animation/camera.mjs +180 -0
  27. package/public/js/animation/formats.mjs +45 -0
  28. package/public/js/animation/mercator.mjs +50 -0
  29. package/public/js/animation/mp4.mjs +77 -0
  30. package/public/js/animation/player.mjs +120 -0
  31. package/public/js/animation/renderer.mjs +326 -0
  32. package/public/js/animation/schedule.mjs +49 -0
  33. package/public/js/animation/storyboard.mjs +195 -0
  34. package/public/js/animation/tiles.mjs +219 -0
  35. package/public/js/animation/timeline.mjs +186 -0
  36. package/public/js/components/AnimationExport.mjs +173 -0
  37. package/public/js/components/AnimationView.mjs +439 -0
  38. package/public/js/components/CrewCard.mjs +22 -0
  39. package/public/js/components/ExportView.mjs +3 -8
  40. package/public/js/components/PassageView.mjs +49 -19
  41. package/public/js/components/Timeline.mjs +30 -7
  42. package/public/js/components/WeatherCard.mjs +134 -0
  43. package/public/js/context.mjs +7 -0
  44. package/public/js/days.mjs +10 -0
  45. package/public/js/format.mjs +15 -0
  46. package/public/js/i18n.mjs +246 -0
  47. package/public/js/landmarks.mjs +128 -0
  48. package/public/js/main.mjs +8 -5
  49. package/public/js/weather.mjs +193 -0
  50. package/public/vendor/mediabunny-LICENSE +373 -0
  51. package/public/vendor/mediabunny.min.mjs +50 -0
@@ -1,4 +1,9 @@
1
- const { withTransaction } = require('./database');
1
+ const {
2
+ createDepartureForecaster,
3
+ readForecast,
4
+ getJson,
5
+ utcTime
6
+ } = require('./departure-forecast');
2
7
 
3
8
  const TIDE_DEFAULTS = {
4
9
  tidesEnabled: true,
@@ -13,53 +18,10 @@ const TIDE_DEFAULTS = {
13
18
  // level, not a substitute for a charted "hauteur d'eau".
14
19
  const DATUM = 'msl';
15
20
 
16
- const REQUEST_TIMEOUT_MS = 10 * 1000;
17
- const IDLE_MS = 60 * 1000;
18
- // Offline is the normal state at sea; retry patiently rather than hammering.
19
- const FIRST_RETRY_MS = 5 * 60 * 1000;
20
- const MAX_RETRY_MS = 60 * 60 * 1000;
21
- // A fetch this long after departure would no longer be "near departure" --
22
- // the forecast window has mostly gone by, so give up rather than keep trying.
23
- const MAX_FETCH_AGE_MS = 3 * 60 * 60 * 1000;
24
- const WINDOW_MS = 24 * 60 * 60 * 1000;
25
-
26
- function iso(ms) {
27
- return new Date(ms).toISOString();
28
- }
29
-
30
- // The entry most recently departed that has no forecast yet -- fetched or
31
- // found to have none, either way recorded as a row, so this never re-selects
32
- // it. Bounded to recent departures: a fetch long after departure would mean
33
- // requesting a window mostly in the past, which the forecast API does not
34
- // have, and repeatedly retrying an entry the boat was offline for the whole
35
- // relevant window of would never succeed anyway.
36
- function nextPending(db, now) {
37
- return db
38
- .prepare(
39
- `SELECT id, start_time, start_lat AS lat, start_lon AS lon FROM log_entries
40
- WHERE start_lat IS NOT NULL AND start_lon IS NOT NULL
41
- AND start_time >= ?
42
- AND id NOT IN (SELECT entry_id FROM tide_forecasts)
43
- ORDER BY id DESC LIMIT 1`
44
- )
45
- .get(iso(now - MAX_FETCH_AGE_MS));
46
- }
47
-
48
- function save(db, entryId, position, now, points) {
49
- withTransaction(db, () => {
50
- db.prepare(
51
- `INSERT INTO tide_forecasts (entry_id, lat, lon, fetched_at, points)
52
- VALUES (?, ?, ?, ?, ?)
53
- ON CONFLICT (entry_id) DO NOTHING`
54
- ).run(entryId, position.lat, position.lon, iso(now), JSON.stringify(points));
55
- });
56
- }
21
+ const TABLE = 'tide_forecasts';
57
22
 
58
23
  function createTideForecaster({ db, settings, userAgent, fetch = globalThis.fetch }) {
59
- const stopping = new AbortController();
60
- let failures = 0;
61
-
62
- async function fetchHeights(position) {
24
+ async function fetchHeights(position, signal) {
63
25
  const endpoint = new URL(settings.tideUrl);
64
26
  endpoint.searchParams.set('latitude', String(position.lat));
65
27
  endpoint.searchParams.set('longitude', String(position.lon));
@@ -68,87 +30,32 @@ function createTideForecaster({ db, settings, userAgent, fetch = globalThis.fetc
68
30
  endpoint.searchParams.set('past_days', '1');
69
31
  endpoint.searchParams.set('forecast_days', '2');
70
32
 
71
- const response = await fetch(endpoint, {
72
- headers: { 'User-Agent': userAgent, Accept: 'application/json' },
73
- signal: AbortSignal.any([stopping.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
74
- });
75
- // Rate limiting or server trouble is worth retrying later; anything else
76
- // (an unrecognised parameter, say) is a final answer of "nothing usable".
77
- if (response.status === 429 || response.status >= 500) {
78
- throw new Error(`Tide service answered HTTP ${response.status}`);
79
- }
80
- if (!response.ok) {
81
- return [];
82
- }
83
- const result = await response.json();
84
- const times = result.hourly?.time ?? [];
85
- const heights = result.hourly?.sea_level_height_msl ?? [];
33
+ const result = await getJson(fetch, endpoint, { userAgent, signal, label: 'Tide service' });
34
+ const times = result?.hourly?.time ?? [];
35
+ const heights = result?.hourly?.sea_level_height_msl ?? [];
86
36
  return times
87
- .map((time, index) => ({ time: `${time}:00.000Z`, height: heights[index] }))
37
+ .map((time, index) => ({ time: utcTime(time), height: heights[index] }))
88
38
  .filter((point) => typeof point.height === 'number' && Number.isFinite(point.height));
89
39
  }
90
40
 
91
- return {
92
- async resolveNext() {
93
- if (!settings.tidesEnabled) {
94
- return { outcome: 'disabled', retryInMs: IDLE_MS };
95
- }
96
- const now = Date.now();
97
- const pending = nextPending(db, now);
98
- if (!pending) {
99
- return { outcome: 'idle', retryInMs: IDLE_MS };
100
- }
101
-
102
- const departure = Date.parse(pending.start_time);
103
- const position = { lat: pending.lat, lon: pending.lon };
104
-
105
- let all;
106
- try {
107
- all = await fetchHeights(position);
108
- } catch (error) {
109
- if (stopping.signal.aborted) {
110
- return { outcome: 'stopped' };
111
- }
112
- failures += 1;
113
- return {
114
- outcome: 'failed',
115
- error,
116
- retryInMs: Math.min(FIRST_RETRY_MS * 2 ** (failures - 1), MAX_RETRY_MS)
117
- };
118
- }
119
- if (stopping.signal.aborted) {
120
- return { outcome: 'stopped' };
121
- }
122
- failures = 0;
123
-
124
- const points = all.filter((point) => {
125
- const time = Date.parse(point.time);
126
- return time >= departure && time < departure + WINDOW_MS;
127
- });
128
- save(db, pending.id, position, now, points);
129
- return { outcome: points.length > 0 ? 'fetched' : 'no_data', retryInMs: 0 };
130
- },
131
-
132
- stop() {
133
- stopping.abort();
134
- }
135
- };
41
+ return createDepartureForecaster({
42
+ db,
43
+ table: TABLE,
44
+ isEnabled: () => settings.tidesEnabled,
45
+ fetchPoints: fetchHeights
46
+ });
136
47
  }
137
48
 
138
49
  function getTideForecast(db, entryId) {
139
- const row = db.prepare('SELECT * FROM tide_forecasts WHERE entry_id = ?').get(entryId);
140
- if (!row) {
141
- return null;
142
- }
143
- const points = JSON.parse(row.points);
144
- if (points.length === 0) {
50
+ const forecast = readForecast(db, TABLE, entryId);
51
+ if (!forecast) {
145
52
  return null;
146
53
  }
147
54
  return {
148
- position: { lat: row.lat, lon: row.lon },
149
- fetchedAt: row.fetched_at,
55
+ position: forecast.position,
56
+ fetchedAt: forecast.fetchedAt,
150
57
  datum: DATUM,
151
- points
58
+ points: forecast.points
152
59
  };
153
60
  }
154
61
 
@@ -0,0 +1,147 @@
1
+ const {
2
+ createDepartureForecaster,
3
+ readForecast,
4
+ getJson,
5
+ utcTime
6
+ } = require('./departure-forecast');
7
+
8
+ const WEATHER_DEFAULTS = {
9
+ weatherEnabled: true,
10
+ weatherUrl: 'https://api.open-meteo.com/v1/forecast'
11
+ };
12
+
13
+ const TABLE = 'weather_forecasts';
14
+
15
+ // Open-Meteo variable → stored field, per service. The marine one is the
16
+ // same service as the tide (`tideUrl`). Directions keep the service's
17
+ // conventions, which are also Signal K's: wind, waves and swell are where
18
+ // they come *from*, the current where it flows *towards*.
19
+ const ATMOSPHERE = {
20
+ wind_speed_10m: 'windSpeed',
21
+ wind_direction_10m: 'windDirection',
22
+ wind_gusts_10m: 'windGust',
23
+ pressure_msl: 'pressure',
24
+ weather_code: 'weatherCode',
25
+ visibility: 'visibility',
26
+ precipitation: 'precipitation',
27
+ cloud_cover: 'cloudCover',
28
+ temperature_2m: 'airTemperature'
29
+ };
30
+ const SEA = {
31
+ wave_height: 'waveHeight',
32
+ wave_direction: 'waveDirection',
33
+ wave_period: 'wavePeriod',
34
+ swell_wave_height: 'swellHeight',
35
+ swell_wave_direction: 'swellDirection',
36
+ swell_wave_period: 'swellPeriod',
37
+ sea_surface_temperature: 'seaTemperature',
38
+ ocean_current_velocity: 'currentSpeed',
39
+ ocean_current_direction: 'currentDirection'
40
+ };
41
+ const FIELDS = [...Object.values(ATMOSPHERE), ...Object.values(SEA)];
42
+
43
+ // Values are converted to SI from the units each answer declares, rather than
44
+ // from what was asked for: the marine service ignores `wind_speed_unit` for
45
+ // the current, which comes in km/h.
46
+ const TO_SI = {
47
+ 'm/s': (v) => v,
48
+ 'km/h': (v) => v / 3.6,
49
+ kn: (v) => (v * 1852) / 3600,
50
+ mph: (v) => v * 0.44704,
51
+ '°': (v) => (v * Math.PI) / 180,
52
+ hPa: (v) => v * 100,
53
+ '°C': (v) => v + 273.15,
54
+ '°F': (v) => ((v - 32) * 5) / 9 + 273.15,
55
+ mm: (v) => v / 1000,
56
+ inch: (v) => v * 0.0254,
57
+ '%': (v) => v / 100,
58
+ m: (v) => v,
59
+ ft: (v) => v * 0.3048,
60
+ s: (v) => v,
61
+ 'wmo code': (v) => v
62
+ };
63
+
64
+ // { "2026-09-17T08:00:00.000Z": { windSpeed: 5.1, ... } } from one answer.
65
+ function readSeries(result, variables, into) {
66
+ const times = result?.hourly?.time ?? [];
67
+ const units = result?.hourly_units ?? {};
68
+ for (const [variable, field] of Object.entries(variables)) {
69
+ const values = result?.hourly?.[variable];
70
+ const convert = TO_SI[units[variable]];
71
+ if (!Array.isArray(values) || !convert) {
72
+ continue;
73
+ }
74
+ times.forEach((time, index) => {
75
+ const value = values[index];
76
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
77
+ return;
78
+ }
79
+ const key = utcTime(time);
80
+ if (!into.has(key)) {
81
+ into.set(key, {});
82
+ }
83
+ into.get(key)[field] = convert(value);
84
+ });
85
+ }
86
+ }
87
+
88
+ function createWeatherForecaster({ db, settings, userAgent, fetch = globalThis.fetch }) {
89
+ function request(base, variables, position) {
90
+ const endpoint = new URL(base);
91
+ endpoint.searchParams.set('latitude', String(position.lat));
92
+ endpoint.searchParams.set('longitude', String(position.lon));
93
+ endpoint.searchParams.set('hourly', Object.keys(variables).join(','));
94
+ endpoint.searchParams.set('timezone', 'UTC');
95
+ endpoint.searchParams.set('past_days', '1');
96
+ endpoint.searchParams.set('forecast_days', '2');
97
+ endpoint.searchParams.set('wind_speed_unit', 'ms');
98
+ return endpoint;
99
+ }
100
+
101
+ async function fetchWeather(position, signal) {
102
+ const options = { userAgent, signal };
103
+ const [atmosphere, sea] = await Promise.all([
104
+ getJson(fetch, request(settings.weatherUrl, ATMOSPHERE, position), {
105
+ ...options,
106
+ label: 'Weather service'
107
+ }),
108
+ // The sea state is a complement: far from the sea it has nothing, and a
109
+ // failure there is not worth losing the wind forecast for.
110
+ getJson(fetch, request(settings.tideUrl, SEA, position), {
111
+ ...options,
112
+ label: 'Marine service'
113
+ }).catch((error) => {
114
+ if (signal.aborted) {
115
+ throw error;
116
+ }
117
+ return null;
118
+ })
119
+ ]);
120
+
121
+ const byTime = new Map();
122
+ readSeries(atmosphere, ATMOSPHERE, byTime);
123
+ readSeries(sea, SEA, byTime);
124
+ return [...byTime.entries()]
125
+ .sort(([a], [b]) => a.localeCompare(b))
126
+ .map(([time, values]) => {
127
+ const point = { time };
128
+ for (const field of FIELDS) {
129
+ point[field] = values[field] ?? null;
130
+ }
131
+ return point;
132
+ });
133
+ }
134
+
135
+ return createDepartureForecaster({
136
+ db,
137
+ table: TABLE,
138
+ isEnabled: () => settings.weatherEnabled,
139
+ fetchPoints: fetchWeather
140
+ });
141
+ }
142
+
143
+ function getWeatherForecast(db, entryId) {
144
+ return readForecast(db, TABLE, entryId);
145
+ }
146
+
147
+ module.exports = { createWeatherForecaster, getWeatherForecast, WEATHER_DEFAULTS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-chiplog",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Automated digital logbook plugin for Signal K, with keyboard/handwritten entry on tablet",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -71,6 +71,7 @@
71
71
  "husky": "^9.1.7",
72
72
  "leaflet": "^1.9.4",
73
73
  "lint-staged": "^17.5.1",
74
+ "mediabunny": "1.58.0",
74
75
  "preact": "^10.29.8",
75
76
  "prettier": "^3.9.6"
76
77
  }
package/public/app.css CHANGED
@@ -111,6 +111,7 @@ main,
111
111
 
112
112
  .topbar {
113
113
  display: flex;
114
+ flex-wrap: wrap;
114
115
  align-items: center;
115
116
  justify-content: space-between;
116
117
  gap: 1rem;
@@ -130,6 +131,7 @@ main,
130
131
 
131
132
  .topbar nav {
132
133
  display: flex;
134
+ flex-wrap: wrap;
133
135
  gap: 0.25rem;
134
136
  }
135
137
 
@@ -215,10 +217,11 @@ main {
215
217
  gap: 0.5rem;
216
218
  }
217
219
 
218
- /* Side by side on a wide screen, each about half width; stacked below that. */
220
+ /* Side by side on a wide screen, each about half width; stacked below that,
221
+ never wider than the screen. */
219
222
  .card-row {
220
223
  display: grid;
221
- grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
224
+ grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
222
225
  gap: 1rem;
223
226
  margin: 1rem 0;
224
227
  }
@@ -554,6 +557,15 @@ main {
554
557
  min-width: 14rem;
555
558
  }
556
559
 
560
+ /* Where the position is in relation to the nearest landmark, under it: a
561
+ reading in its own right, not a heading, so it keeps the coordinates’
562
+ place and only its colour sets it back. */
563
+ .timeline-landmark {
564
+ display: block;
565
+ color: var(--muted);
566
+ font-size: 0.85em;
567
+ }
568
+
557
569
  .timeline-event .timeline-time {
558
570
  font-weight: 600;
559
571
  }
@@ -683,12 +695,90 @@ main {
683
695
  margin-bottom: 0;
684
696
  }
685
697
 
698
+ .crew-list {
699
+ margin: 0;
700
+ padding: 0;
701
+ list-style: none;
702
+ display: flex;
703
+ flex-direction: column;
704
+ gap: 0.4rem;
705
+ }
706
+
707
+ .crew-role {
708
+ color: var(--muted);
709
+ font-size: 0.9rem;
710
+ }
711
+
686
712
  .tide-attribution {
687
713
  font-size: 0.8rem;
688
714
  margin-top: 0.3rem;
689
715
  margin-bottom: 0;
690
716
  }
691
717
 
718
+ /* Weather forecast */
719
+
720
+ /* The table scrolls sideways inside its card on a narrow screen; the page never does. */
721
+ .weather-scroll {
722
+ overflow-x: auto;
723
+ -webkit-overflow-scrolling: touch;
724
+ }
725
+
726
+ .weather-table {
727
+ width: 100%;
728
+ border-collapse: collapse;
729
+ font-variant-numeric: tabular-nums;
730
+ white-space: nowrap;
731
+ }
732
+
733
+ .weather-table th,
734
+ .weather-table td {
735
+ padding: 0.35rem 1rem 0.35rem 0;
736
+ text-align: left;
737
+ vertical-align: top;
738
+ border-bottom: 1px solid var(--border);
739
+ }
740
+
741
+ .weather-table thead th {
742
+ color: var(--muted);
743
+ font-weight: 500;
744
+ }
745
+
746
+ .weather-table tbody th {
747
+ font-weight: 400;
748
+ }
749
+
750
+ .weather-main {
751
+ display: block;
752
+ font-weight: 600;
753
+ }
754
+
755
+ .weather-sub {
756
+ display: block;
757
+ color: var(--muted);
758
+ font-size: 0.85rem;
759
+ }
760
+
761
+ .weather-alert .weather-main {
762
+ color: var(--danger);
763
+ }
764
+
765
+ .weather-direction {
766
+ display: inline-flex;
767
+ align-items: center;
768
+ gap: 0.2rem;
769
+ }
770
+
771
+ .weather-arrow {
772
+ display: inline-block;
773
+ line-height: 1;
774
+ }
775
+
776
+ .weather-note {
777
+ font-size: 0.8rem;
778
+ margin-top: 0.5rem;
779
+ margin-bottom: 0;
780
+ }
781
+
692
782
  /* Corrections */
693
783
 
694
784
  .name-field {
@@ -740,6 +830,14 @@ main {
740
830
  }
741
831
 
742
832
  @media (max-width: 600px) {
833
+ .topbar {
834
+ gap: 0.25rem 1rem;
835
+ }
836
+
837
+ .topbar nav a {
838
+ padding: 0.35rem 0.5rem;
839
+ }
840
+
743
841
  .passage-header h1 {
744
842
  font-size: 1.25rem;
745
843
  }
@@ -807,3 +905,115 @@ main {
807
905
  color: var(--muted);
808
906
  font-weight: 500;
809
907
  }
908
+
909
+ /* Passage animation */
910
+
911
+ .card-header-actions {
912
+ display: flex;
913
+ flex-wrap: wrap;
914
+ align-items: baseline;
915
+ gap: 0.8rem;
916
+ }
917
+
918
+ .animation-stage {
919
+ display: flex;
920
+ justify-content: center;
921
+ }
922
+
923
+ .animation-canvas {
924
+ display: block;
925
+ max-width: 100%;
926
+ border-radius: 8px;
927
+ /* The sea the renderer paints, so a tile that has not arrived is not a hole. */
928
+ background: #aad3df;
929
+ }
930
+
931
+ .animation-controls {
932
+ display: flex;
933
+ flex-wrap: wrap;
934
+ align-items: center;
935
+ gap: 0.6rem;
936
+ margin-top: 0.75rem;
937
+ }
938
+
939
+ /* Wide enough that swapping Play for Pause does not shift the slider. */
940
+ .animation-play {
941
+ min-width: 7.5rem;
942
+ }
943
+
944
+ .animation-slider {
945
+ flex: 1 1 12rem;
946
+ min-width: 8rem;
947
+ }
948
+
949
+ /* These change every frame; proportional digits jitter. */
950
+ .animation-clock,
951
+ .animation-facts dd {
952
+ font-variant-numeric: tabular-nums;
953
+ }
954
+
955
+ .animation-speeds {
956
+ display: flex;
957
+ gap: 0.3rem;
958
+ }
959
+
960
+ .animation-speed {
961
+ padding: 0.35rem 0.6rem;
962
+ background: none;
963
+ color: var(--accent);
964
+ }
965
+
966
+ .animation-speed[aria-pressed='true'] {
967
+ background: var(--accent);
968
+ color: var(--accent-contrast);
969
+ }
970
+
971
+ .animation-facts {
972
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
973
+ margin: 0.75rem 0 0;
974
+ }
975
+
976
+ .animation-seamarks {
977
+ display: flex;
978
+ align-items: center;
979
+ gap: 0.4rem;
980
+ margin-top: 0.75rem;
981
+ color: var(--muted);
982
+ }
983
+
984
+ .animation-export-controls {
985
+ display: flex;
986
+ flex-wrap: wrap;
987
+ align-items: baseline;
988
+ gap: 1rem;
989
+ }
990
+
991
+ .animation-export-format {
992
+ display: flex;
993
+ flex-direction: column;
994
+ gap: 0.25rem;
995
+ }
996
+
997
+ .animation-export-format select {
998
+ font: inherit;
999
+ padding: 0.4rem 0.6rem;
1000
+ border-radius: 8px;
1001
+ border: 1px solid var(--border);
1002
+ background: var(--surface);
1003
+ color: var(--text);
1004
+ }
1005
+
1006
+ .animation-progress {
1007
+ margin-top: 1rem;
1008
+ }
1009
+
1010
+ .animation-progress progress {
1011
+ width: 100%;
1012
+ height: 0.6rem;
1013
+ }
1014
+
1015
+ @media (max-width: 600px) {
1016
+ .animation-facts {
1017
+ grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
1018
+ }
1019
+ }
@@ -350,6 +350,18 @@ textarea:focus-visible {
350
350
  margin-bottom: 0.75rem;
351
351
  }
352
352
 
353
+ .panel-header {
354
+ display: flex;
355
+ align-items: center;
356
+ justify-content: space-between;
357
+ gap: 0.5rem;
358
+ margin-bottom: 0.75rem;
359
+ }
360
+
361
+ .panel-header .panel-title {
362
+ margin-bottom: 0;
363
+ }
364
+
353
365
  /* Manoeuvres */
354
366
 
355
367
  .pad-grid {
@@ -362,6 +374,65 @@ textarea:focus-visible {
362
374
  min-height: 5.5rem;
363
375
  }
364
376
 
377
+ /* Crew */
378
+
379
+ .crew-list {
380
+ list-style: none;
381
+ margin: 0 0 0.75rem;
382
+ padding: 0;
383
+ display: flex;
384
+ flex-wrap: wrap;
385
+ gap: 0.5rem 1rem;
386
+ }
387
+
388
+ .crew-role {
389
+ /* Inherits its colour rather than fixing one, so it stays legible whether
390
+ it sits on the page background or inside a selected (primary) button. */
391
+ opacity: 0.75;
392
+ font-size: 0.85em;
393
+ }
394
+
395
+ .crew-grid {
396
+ display: grid;
397
+ grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
398
+ gap: 0.75rem;
399
+ }
400
+
401
+ .crew-button-label {
402
+ display: flex;
403
+ flex-direction: column;
404
+ align-items: center;
405
+ line-height: 1.3;
406
+ }
407
+
408
+ .crew-item {
409
+ display: flex;
410
+ flex-direction: column;
411
+ gap: 0.4rem;
412
+ }
413
+
414
+ .crew-item .big-button {
415
+ flex: 1;
416
+ }
417
+
418
+ .crew-item-actions {
419
+ display: flex;
420
+ gap: 0.4rem;
421
+ }
422
+
423
+ .crew-item-actions .icon-button {
424
+ flex: 1;
425
+ min-height: 2.5rem;
426
+ }
427
+
428
+ .crew-editing-note {
429
+ display: flex;
430
+ align-items: center;
431
+ gap: 0.5rem;
432
+ color: var(--muted);
433
+ margin: 0;
434
+ }
435
+
365
436
  /* Note and handwriting */
366
437
 
367
438
  .tabs {