signalk-underkeel 0.6.0 → 0.6.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  All notable changes to signalk-underkeel are documented here.
4
4
 
5
+ ## [0.6.2] - 2026-11-13
6
+
7
+ ### Fixed
8
+
9
+ - Closing the details panel no longer logs an error in the Signal K server log. The panel
10
+ subscribed to `notifications.navigation.underKeel` itself, and on close Freeboard sent
11
+ Signal K an unsubscribe for that one path, which Signal K refuses (it takes only an
12
+ unsubscribe of everything). The background page, which never closes, now holds that
13
+ subscription and passes the message to the panel.
14
+
15
+ ## [0.6.1] - 2026-11-13
16
+
17
+ ### Fixed
18
+
19
+ - The details panel reads in dark mode (#67): it had fixed light-theme colours and no
20
+ background of its own, so on Freeboard's dark background most of its text was dark grey on
21
+ dark. The status chip and widget also put white on amber (Alert, 2.4:1) and on green
22
+ (Normal, 3.7:1), below the 4.5:1 that small text needs.
23
+ - The SignalK plugin CI workflow is pinned to a commit again. Without the `@ref`, GitHub could
24
+ not read the workflow file, so no plugin CI ran for 0.6.0 and the plugin registry took 10
25
+ points from its score.
26
+
27
+ ### Changed
28
+
29
+ - The widget, the details panel and the setup steps share one set of colours in three
30
+ schemes: day, dusk (the device's dark mode) and night (Freeboard's night mode: black, and
31
+ nothing brighter than a dim grey). Every text colour reads at WCAG AA in each scheme.
32
+
5
33
  ## [0.6.0] - 2026-11-10
6
34
 
7
35
  ### Changed
package/README.md CHANGED
@@ -275,6 +275,11 @@ Extensions API shows it without any change to Freeboard:
275
275
  | Widget grid | Under-keel status: state colour, minimum clearance, time to shoal and the reason |
276
276
  | Toolbar button (boat icon) | details panel: state, water level and source, a leg table per route, and **Open setup** for boat, water level and warning style (Settings) |
277
277
 
278
+ The widget, the panel and the setup steps follow Freeboard's look: light by day, dark when the
279
+ device is in dark mode, and a dim night scheme, black with nothing brighter than a dim grey,
280
+ while Freeboard's night mode is on, so a glance at the panel keeps the eyes adapted to the
281
+ dark. Every text colour reads at WCAG AA in each scheme.
282
+
278
283
  Routes shown on the chart are checked 1 s after each edit and every minute; the panel shows
279
284
  each result's age and marks it "out of date" after 3 minutes, when the chart draws its legs
280
285
  `unverified`. Stale status is shown as Unknown,
@@ -502,7 +507,10 @@ panel with its toolbar button, a hidden background page and the experimental
502
507
  - `runtime.js`, the background page, keeps the envelope, route results and tiles current;
503
508
  - `widget.js` and `panel.js` render the status and the route legs;
504
509
  - `ukc-shared.js` holds their pure logic (freshness, badges, labels), the part the Jest tests
505
- cover.
510
+ cover;
511
+ - `theme.css` holds every colour of the pages in the day, dusk and night schemes, and
512
+ `theme.js` picks the scheme from Freeboard's night mode (`nightMode`) or the device's dark
513
+ mode; a test holds each text colour to WCAG AA contrast in every scheme.
506
514
 
507
515
  The pages talk to the host through the vendored `signalk-plotterext-bus` client, copied into
508
516
  `public/vendor/` by the build. The [Plotter Extensions services RFC](docs/rfcs/plotter-extensions-services.md)
@@ -155,6 +155,7 @@ function buildManifest(version) {
155
155
  "signalk.stream",
156
156
  "routes",
157
157
  "units",
158
+ "nightMode",
158
159
  "ui",
159
160
  "events.publish",
160
161
  "x-freeboard.services.navigationSafety"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-underkeel",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Under-keel clearance awareness for Signal K: look-ahead grounding risk, route checks and a living chart.",
5
5
  "main": "lib/index.js",
6
6
  "files": [
package/public/panel.html CHANGED
@@ -3,18 +3,26 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <title>Under-keel details</title>
6
+ <link rel="stylesheet" href="theme.css" />
6
7
  <style>
7
- body { margin: 0; padding: 10px 12px; font: 14px/1.4 system-ui, sans-serif; }
8
+ body { margin: 0; padding: 10px 12px; font: 14px/1.4 system-ui, sans-serif; color: var(--ink); background: var(--page); }
8
9
  h2 { font-size: 15px; margin: 14px 0 6px; }
9
- .advisory { font-size: 12px; padding: 6px 8px; background: #fff3cd; border-radius: 4px; }
10
- #state { display: inline-block; padding: 2px 8px; border-radius: 4px; color: #fff; font-weight: 700; }
10
+ .advisory { font-size: 12px; padding: 6px 8px; border-radius: 4px; color: var(--note-ink); background: var(--note); border: 1px solid var(--note-line); }
11
+ #state { display: inline-block; padding: 2px 8px; border-radius: 4px; font-weight: 700; color: var(--on-st-unknown); background: var(--st-unknown); }
12
+ #state[data-state="normal"] { color: var(--on-st-normal); background: var(--st-normal); }
13
+ #state[data-state="alert"] { color: var(--on-st-alert); background: var(--st-alert); }
14
+ #state[data-state="warn"] { color: var(--on-st-warn); background: var(--st-warn); }
15
+ #state[data-state="alarm"] { color: var(--on-st-alarm); background: var(--st-alarm); }
11
16
  #message { margin-top: 6px; }
12
17
  table { border-collapse: collapse; width: 100%; font-size: 13px; }
13
- td, th { text-align: left; padding: 3px 4px; border-bottom: 1px solid #ddd; }
14
- .ok { color: #1a9850; } .marginal { color: #b35806; } .fail { color: #d7301f; } .unverified { color: #6a51a3; }
15
- button { margin-top: 8px; }
16
- .muted { color: #666; font-size: 12px; }
18
+ td, th { text-align: left; padding: 3px 4px; border-bottom: 1px solid var(--line); }
19
+ th { color: var(--muted); font-weight: 600; }
20
+ .ok { color: var(--ok); } .marginal { color: var(--warn); } .fail { color: var(--todo); } .unverified { color: var(--unverified); }
21
+ button { margin-top: 8px; min-height: 32px; padding: 0 12px; font: inherit; color: var(--ink); background: var(--panel); border: 1px solid var(--field); border-radius: 6px; cursor: pointer; }
22
+ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
23
+ .muted { color: var(--muted); font-size: 12px; }
17
24
  </style>
25
+ <script src="theme.js"></script>
18
26
  <script src="ukc-shared.js"></script>
19
27
  <script type="module" src="panel.js"></script>
20
28
  </head>
package/public/panel.js CHANGED
@@ -3,11 +3,16 @@
3
3
  import { connectExtension } from "./vendor/plotterext-bus/extension.js";
4
4
 
5
5
  const UKC = globalThis.UKC;
6
+ const THEME = globalThis.UKC_THEME;
7
+ /** The host's night mode as this page shows it; setup opens in the same scheme. */
8
+ let scheme = null;
6
9
  // Setup opens in this panel rather than one of its own, so the toolbar keeps one button.
7
10
  document.getElementById("open-setup").addEventListener("click", () => {
8
- location.assign("setup.html?from=details");
11
+ location.assign(`setup.html?from=details${scheme ? `&scheme=${scheme}` : ""}`);
9
12
  });
10
13
  const client = await connectExtension();
14
+ // Not awaited: a host that offers night mode but never answers must not hold back the status.
15
+ void THEME.followHost(client, document, (s) => (scheme = s));
11
16
  let unit = "m";
12
17
 
13
18
  if (client.hasCapability("units")) {
@@ -19,11 +24,15 @@ function text(id, value) {
19
24
  document.getElementById(id).textContent = value;
20
25
  }
21
26
 
22
- function showUnknownStatus(why) {
23
- const style = UKC.stateStyle("unknown");
27
+ /** The state chip: its label, and its colours from theme.css for the state. */
28
+ function showState(state) {
24
29
  const chip = document.getElementById("state");
25
- chip.textContent = style.label;
26
- chip.style.background = style.colour;
30
+ chip.textContent = UKC.stateStyle(state).label;
31
+ chip.dataset.state = UKC.stateKey(state);
32
+ }
33
+
34
+ function showUnknownStatus(why) {
35
+ showState("unknown");
27
36
  text("source", `Under-keel status unavailable: ${why}`);
28
37
  }
29
38
 
@@ -53,10 +62,7 @@ async function refreshStatus() {
53
62
  return;
54
63
  }
55
64
  statusAt = Date.now();
56
- const style = UKC.stateStyle(s.engine.state);
57
- const chip = document.getElementById("state");
58
- chip.textContent = style.label;
59
- chip.style.background = style.colour;
65
+ showState(s.engine.state);
60
66
  text("source", UKC.statusLines(s, unit).join(" · "));
61
67
  } catch (err) {
62
68
  if (id !== statusRequest) {
@@ -139,19 +145,45 @@ async function refreshRoutes() {
139
145
  }
140
146
  }
141
147
 
142
- if (client.hasCapability("signalk.stream")) {
143
- await client.signalk.subscribe(["notifications.navigation.underKeel"], (ev) => {
144
- text("message", ev.value && typeof ev.value === "object" ? ev.value.message : "");
145
- if (!document.hidden) {
146
- void refreshStatus();
148
+ /** Reads overlap as the status ones do: only the latest may update the message. */
149
+ let noticeRequest = 0;
150
+
151
+ /** The notification's message, which the background page keeps in the extension state. */
152
+ async function refreshNotice() {
153
+ const id = ++noticeRequest;
154
+ try {
155
+ const { ukcNotice } = await client.state.get(["ukcNotice"], "extension");
156
+ if (id !== noticeRequest) {
157
+ return;
158
+ }
159
+ text("message", ukcNotice && typeof ukcNotice.message === "string" ? ukcNotice.message : "");
160
+ } catch (err) {
161
+ if (id !== noticeRequest) {
162
+ return;
147
163
  }
148
- });
164
+ // An old message left on screen could say normal over a warning; the poll reads it again.
165
+ text("message", "Under-keel message unavailable: the chart host did not answer.");
166
+ console.warn("under-keel notice", err);
167
+ }
149
168
  }
169
+
170
+ // No Signal K subscription of its own: the host would unsubscribe it path by path when the
171
+ // panel closes, which Signal K refuses and logs as an error. The background page relays it.
150
172
  await client.subscribe(["state.changed"], (_name, params) => {
151
- if (params && params.scope === "extension" && params.keys.includes("routeCheck")) {
173
+ if (!params || params.scope !== "extension") {
174
+ return;
175
+ }
176
+ if (params.keys.includes("routeCheck")) {
152
177
  void refreshRoutes();
153
178
  }
179
+ if (params.keys.includes("ukcNotice")) {
180
+ void refreshNotice();
181
+ if (!document.hidden) {
182
+ void refreshStatus();
183
+ }
184
+ }
154
185
  });
186
+ void refreshNotice();
155
187
  // The background runtime answers the button, over an event the host must carry.
156
188
  const recheck = document.getElementById("recheck");
157
189
  recheck.hidden = !["events.publish", "routes", "background.iframe"].every((c) => client.hasCapability(c));
@@ -170,6 +202,7 @@ function checkStatusAge() {
170
202
  function poll() {
171
203
  void refreshStatus();
172
204
  void refreshRoutes();
205
+ void refreshNotice();
173
206
  }
174
207
 
175
208
  // A hidden page polls nothing; back on screen an old status reads unknown until the poll answers.
package/public/runtime.js CHANGED
@@ -427,11 +427,64 @@ if (has("routes")) {
427
427
  }, RECHECK_MS);
428
428
  }
429
429
 
430
+ // ---- the notification, relayed to the details panel ----
431
+ /**
432
+ * The panel opens and closes, and the chart host unsubscribes a closed page's Signal K paths
433
+ * one by one, which Signal K refuses: it only takes an unsubscribe of everything, and logs the
434
+ * rest as errors. So this page, which never closes, holds the notification's subscription and
435
+ * keeps its message in the extension state, where the panel reads it.
436
+ */
437
+ const NOTICE = "notifications.navigation.underKeel";
438
+ /** A write that fails is tried again this many times, each wait longer by this much. */
439
+ const NOTICE_RETRIES = 5;
440
+ const NOTICE_RETRY_MS = 2000;
441
+ /** The last notice received and not given up on, as JSON; a newer one replaces a retry of an older one. */
442
+ let latestNotice;
443
+ let noticeRetry;
444
+
445
+ function relayNotice(value) {
446
+ const n = value && typeof value === "object" ? value : {};
447
+ const notice = { message: typeof n.message === "string" ? n.message : "", state: n.state };
448
+ const key = JSON.stringify(notice);
449
+ if (key === latestNotice) {
450
+ return;
451
+ }
452
+ latestNotice = key;
453
+ clearTimeout(noticeRetry);
454
+ void writeNotice(notice, key, 0);
455
+ }
456
+
457
+ /**
458
+ * Writes the notice to the extension state. A notification is sent only when it changes, so
459
+ * a write that fails is tried again rather than left until the next one.
460
+ */
461
+ async function writeNotice(notice, key, tries) {
462
+ try {
463
+ await client.state.set({ ukcNotice: notice }, "extension");
464
+ } catch (err) {
465
+ if (key !== latestNotice) {
466
+ return;
467
+ }
468
+ if (tries < NOTICE_RETRIES) {
469
+ noticeRetry = setTimeout(() => void writeNotice(notice, key, tries + 1), NOTICE_RETRY_MS * (tries + 1));
470
+ } else {
471
+ // Given up: the same notice, sent again on a reconnect, is written rather than dropped.
472
+ latestNotice = undefined;
473
+ console.warn("under-keel notice", err);
474
+ }
475
+ }
476
+ }
477
+
430
478
  /** Without the Signal K stream, or when subscribing to it fails, the envelope is polled. */
431
479
  let streaming = false;
432
480
  if (has("signalk.stream")) {
433
481
  try {
434
- await client.signalk.subscribe(Object.keys(onPath), (ev) => {
482
+ await client.signalk.subscribe([...Object.keys(onPath), NOTICE], (ev) => {
483
+ // A notification is sent only when it changes, so its age says nothing about the plugin.
484
+ if (ev.path === NOTICE) {
485
+ relayNotice(ev.value);
486
+ return;
487
+ }
435
488
  const left = fresh(ev.path, ev.timestamp, Date.now());
436
489
  if (left > 0) {
437
490
  onPath[ev.path](ev.value, left);
package/public/setup.html CHANGED
@@ -4,21 +4,8 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Under-keel setup</title>
7
+ <link rel="stylesheet" href="theme.css" />
7
8
  <style>
8
- :root {
9
- --ink: #12233a; --muted: #4a5b70; --line: #d5dce5; --field: #b8c3d1; --accent: #1f5fa8;
10
- --accent-soft: #eaf2fb; --page: #ffffff; --panel: #f6f8fb; --water: #dcebf7; --bed: #b9a98f;
11
- --ok: #1a7340; --warn: #8a4b00; --todo: #b5261a; --margin: #e66101; --margin-ink: #a84300;
12
- --note: #fff4e5; --note-line: #f0c27a; --note-ink: #5c3300; --on-accent: #ffffff;
13
- }
14
- @media (prefers-color-scheme: dark) {
15
- :root {
16
- --ink: #e6edf5; --muted: #a9b6c6; --line: #33414f; --field: #4b5b6c; --accent: #6ea8ef;
17
- --accent-soft: #1b2c42; --page: #121a23; --panel: #1a2430; --water: #173049; --bed: #5d5243;
18
- --ok: #6fcf97; --warn: #f2b25c; --todo: #ff8a80; --margin: #ff9d4d; --margin-ink: #ffb27a;
19
- --note: #3a2a12; --note-line: #7a5420; --note-ink: #f6d7a8; --on-accent: #0b1520;
20
- }
21
- }
22
9
  * { box-sizing: border-box; }
23
10
  body { margin: 0; font: 14px/1.4 system-ui, sans-serif; color: var(--ink); background: var(--page); }
24
11
  .back { padding: 10px 16px 0; font-size: 14px; }
@@ -64,7 +51,10 @@
64
51
  .seg button[aria-checked="true"] { background: var(--accent); color: var(--on-accent); font-weight: 600; }
65
52
  .levels { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
66
53
  .levels div { display: flex; gap: 10px; align-items: center; }
67
- .chip { width: 60px; flex: none; text-align: center; border-radius: 4px; font-size: 12px; font-weight: 600; padding: 3px 0; color: #fff; }
54
+ .chip { width: 60px; flex: none; text-align: center; border-radius: 4px; font-size: 12px; font-weight: 600; padding: 3px 0; }
55
+ .chip.alert { color: var(--on-st-alert); background: var(--st-alert); }
56
+ .chip.warn { color: var(--on-st-warn); background: var(--st-warn); }
57
+ .chip.alarm { color: var(--on-st-alarm); background: var(--st-alarm); }
68
58
  .switches { border: 1px solid var(--line); border-radius: 8px; margin-top: 6px; }
69
59
  .switches label { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 44px; padding: 0 12px; border-bottom: 1px solid var(--line); }
70
60
  .switches label:last-child { border-bottom: 0; }
@@ -83,6 +73,7 @@
83
73
  a { color: var(--accent); }
84
74
  :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
85
75
  </style>
76
+ <script src="theme.js"></script>
86
77
  <script src="setup-model.js"></script>
87
78
  <script type="module" src="setup.js"></script>
88
79
  </head>
package/public/setup.js CHANGED
@@ -4,12 +4,6 @@ const M = globalThis.UKC_SETUP;
4
4
  const SVG_NS = "http://www.w3.org/2000/svg";
5
5
  const ADMIN_PAGE = "/admin/#/serverConfiguration/plugins/signalk-underkeel";
6
6
  const STEPS = ["Boat", "Water level", "Warnings"];
7
- /** The plugin's state colours, each with a text colour that reads on it. */
8
- const STATE_COLOURS = {
9
- alert: ["#d4a000", "#12233a"],
10
- warn: ["#e66101", "#12233a"],
11
- alarm: ["#b5261a", "#ffffff"]
12
- };
13
7
  const REFERENCE_KNOTS = 6;
14
8
 
15
9
  const s = { data: null, patch: {}, step: 0, saving: false, dragScale: undefined };
@@ -493,7 +487,7 @@ function warningsStep() {
493
487
  "div",
494
488
  { class: "levels" },
495
489
  ["alert", "warn", "alarm"].map((k) =>
496
- h("div", {}, h("span", { class: "chip", style: `background:${STATE_COLOURS[k][0]};color:${STATE_COLOURS[k][1]}` }, k[0].toUpperCase() + k.slice(1)), lines[k])
490
+ h("div", {}, h("span", { class: `chip ${k}` }, k[0].toUpperCase() + k.slice(1)), lines[k])
497
491
  ),
498
492
  h("span", { class: "hint" }, lines.ahead),
499
493
  custom.length > 0 ? h("span", { class: "hint" }, `Your own values for ${custom.join(", ")} still apply; change them in the admin page.`) : null
@@ -646,4 +640,6 @@ async function readBack(sent, tries = 5) {
646
640
 
647
641
  // Opened from the details panel, the page offers the way back to it.
648
642
  document.getElementById("back").hidden = new URLSearchParams(location.search).get("from") !== "details";
643
+ // Opened from the details panel, setup shows the scheme the panel had from the host's night mode.
644
+ globalThis.UKC_THEME.applyScheme(document, globalThis.UKC_THEME.schemeFromUrl(location.href));
649
645
  await load();
@@ -0,0 +1,59 @@
1
+ /*
2
+ * Colours of the extension pages, one set per scheme. The pages use these tokens only, so a
3
+ * colour is changed here once; __tests__/ext/theme.test.ts holds every text colour to WCAG
4
+ * contrast against the surfaces it is drawn on, in every scheme.
5
+ *
6
+ * day: light, the default.
7
+ * dusk: dark, when the OS prefers it (prefers-color-scheme) or the page is told to.
8
+ * night: the chart host's night mode (`nightMode`): black, and nothing brighter than a dim
9
+ * grey, so the helmsman's eyes stay adapted to the dark (theme.js sets it).
10
+ */
11
+ :root {
12
+ color-scheme: light;
13
+ --page: #ffffff; --panel: #f6f8fb; --ink: #12233a; --muted: #4a5b70; --line: #d5dce5;
14
+ --field: #b8c3d1; --accent: #1f5fa8; --accent-soft: #eaf2fb; --on-accent: #ffffff;
15
+ --water: #dcebf7; --bed: #b9a98f; --margin: #e66101; --margin-ink: #a84300;
16
+ --ok: #1a7340; --warn: #8a4b00; --todo: #b5261a; --unverified: #5b3f9e;
17
+ --note: #fff4e5; --note-line: #f0c27a; --note-ink: #5c3300;
18
+ --st-normal: #1a7340; --on-st-normal: #ffffff; --st-alert: #d4a000; --on-st-alert: #12233a;
19
+ --st-warn: #e66101; --on-st-warn: #12233a; --st-alarm: #b5261a; --on-st-alarm: #ffffff;
20
+ --st-unknown: #5f6b78; --on-st-unknown: #ffffff;
21
+ }
22
+ @media (prefers-color-scheme: dark) {
23
+ :root:not([data-scheme]) {
24
+ color-scheme: dark;
25
+ --page: #121a23; --panel: #1a2430; --ink: #e6edf5; --muted: #a9b6c6; --line: #33414f;
26
+ --field: #4b5b6c; --accent: #6ea8ef; --accent-soft: #1b2c42; --on-accent: #0b1520;
27
+ --water: #173049; --bed: #5d5243; --margin: #ff9d4d; --margin-ink: #ffb27a;
28
+ --ok: #6fcf97; --warn: #f2b25c; --todo: #ff8a80; --unverified: #c3b0f7;
29
+ --note: #3a2a12; --note-line: #7a5420; --note-ink: #f6d7a8;
30
+ --st-normal: #6fcf97; --on-st-normal: #0b1520; --st-alert: #f2c94c; --on-st-alert: #0b1520;
31
+ --st-warn: #ff9d4d; --on-st-warn: #0b1520; --st-alarm: #ff6f61; --on-st-alarm: #0b1520;
32
+ --st-unknown: #8a96a3; --on-st-unknown: #0b1520;
33
+ }
34
+ }
35
+ :root[data-scheme="dusk"] {
36
+ color-scheme: dark;
37
+ --page: #121a23; --panel: #1a2430; --ink: #e6edf5; --muted: #a9b6c6; --line: #33414f;
38
+ --field: #4b5b6c; --accent: #6ea8ef; --accent-soft: #1b2c42; --on-accent: #0b1520;
39
+ --water: #173049; --bed: #5d5243; --margin: #ff9d4d; --margin-ink: #ffb27a;
40
+ --ok: #6fcf97; --warn: #f2b25c; --todo: #ff8a80; --unverified: #c3b0f7;
41
+ --note: #3a2a12; --note-line: #7a5420; --note-ink: #f6d7a8;
42
+ --st-normal: #6fcf97; --on-st-normal: #0b1520; --st-alert: #f2c94c; --on-st-alert: #0b1520;
43
+ --st-warn: #ff9d4d; --on-st-warn: #0b1520; --st-alarm: #ff6f61; --on-st-alarm: #0b1520;
44
+ --st-unknown: #8a96a3; --on-st-unknown: #0b1520;
45
+ }
46
+ :root[data-scheme="night"] {
47
+ color-scheme: dark;
48
+ --page: #000000; --panel: #0d0807; --ink: #b8786c; --muted: #a86f63; --line: #2a1814;
49
+ --field: #3a221d; --accent: #b9805a; --accent-soft: #1a0e08; --on-accent: #000000;
50
+ --water: #0a1014; --bed: #1c140e; --margin: #b9805a; --margin-ink: #b9805a;
51
+ --ok: #6f9160; --warn: #b08a45; --todo: #e0675a; --unverified: #9c809b;
52
+ --note: #1a0e08; --note-line: #3a221d; --note-ink: #b8786c;
53
+ --st-normal: #1d2a14; --on-st-normal: #7c9a69; --st-alert: #2b220c; --on-st-alert: #ad8e47;
54
+ --st-warn: #2e1909; --on-st-warn: #c07f4c; --st-alarm: #3a0c08; --on-st-alarm: #e66e61;
55
+ --st-unknown: #1c1716; --on-st-unknown: #9a817b;
56
+ }
57
+ :root[data-scheme="day"] {
58
+ color-scheme: light;
59
+ }
@@ -0,0 +1,75 @@
1
+ // Which colour scheme of theme.css a page shows. Plain script: the pages load it with
2
+ // <script>, the tests require() it.
3
+ //
4
+ // The chart host's night mode wins: on, the page is "night"; off, or not offered, the page
5
+ // follows the OS preference (day, or dusk when it prefers dark), with no data-scheme set.
6
+ (function (root) {
7
+ "use strict";
8
+
9
+ const SCHEMES = ["day", "dusk", "night"];
10
+
11
+ /** Shows `scheme` on `doc`; null or an unknown name follows the OS preference. */
12
+ function applyScheme(doc, scheme) {
13
+ const el = doc.documentElement;
14
+ if (SCHEMES.includes(scheme)) {
15
+ el.setAttribute("data-scheme", scheme);
16
+ } else {
17
+ el.removeAttribute("data-scheme");
18
+ }
19
+ }
20
+
21
+ /** The scheme a host night-mode state asks for: night when enabled, else none (the OS's). */
22
+ function schemeOf(state) {
23
+ return state && state.enabled === true ? "night" : null;
24
+ }
25
+
26
+ /** The scheme named in a page URL's `scheme` parameter, or null. */
27
+ function schemeFromUrl(href) {
28
+ try {
29
+ const s = new URL(href).searchParams.get("scheme");
30
+ return SCHEMES.includes(s) ? s : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Follows the host's night mode on `doc` while the page lives, when the host offers it
38
+ * (capability `nightMode`). Resolves with the scheme shown; a host that does not answer
39
+ * leaves the page following the OS.
40
+ */
41
+ async function followHost(client, doc, onChange) {
42
+ if (!client.hasCapability("nightMode")) {
43
+ return null;
44
+ }
45
+ const show = (state) => {
46
+ const scheme = schemeOf(state);
47
+ applyScheme(doc, scheme);
48
+ if (onChange) {
49
+ onChange(scheme);
50
+ }
51
+ return scheme;
52
+ };
53
+ try {
54
+ // Subscribed first, so a change while the state is read is not missed.
55
+ await client.subscribe(["nightMode.changed"], (_name, params) => show(params));
56
+ return show(await client.call("nightMode.get"));
57
+ } catch (err) {
58
+ console.warn("under-keel night mode", err);
59
+ return null;
60
+ }
61
+ }
62
+
63
+ const api = {
64
+ SCHEMES: SCHEMES,
65
+ applyScheme: applyScheme,
66
+ schemeOf: schemeOf,
67
+ schemeFromUrl: schemeFromUrl,
68
+ followHost: followHost
69
+ };
70
+ if (typeof module !== "undefined" && module.exports) {
71
+ module.exports = api;
72
+ } else {
73
+ root.UKC_THEME = api;
74
+ }
75
+ })(typeof globalThis !== "undefined" ? globalThis : this);
@@ -6,19 +6,25 @@
6
6
  /** Plugin REST base; the pages are same-origin with the Signal K server. */
7
7
  const API = "/plugins/signalk-underkeel/v1";
8
8
 
9
+ /** Each state's label; its colours are theme.css's `--st-<state>` tokens. */
9
10
  const STATES = {
10
- normal: { label: "Normal", colour: "#1a9850" },
11
- alert: { label: "Alert", colour: "#d4a000" },
12
- warn: { label: "Warning", colour: "#e66101" },
13
- alarm: { label: "ALARM", colour: "#d7301f" },
14
- unknown: { label: "Unknown", colour: "#808080" },
15
- off: { label: "Off", colour: "#808080" }
11
+ normal: { label: "Normal" },
12
+ alert: { label: "Alert" },
13
+ warn: { label: "Warning" },
14
+ alarm: { label: "ALARM" },
15
+ unknown: { label: "Unknown" },
16
+ off: { label: "Off" }
16
17
  };
17
18
 
18
19
  function stateStyle(state) {
19
20
  return STATES[state] || STATES.unknown;
20
21
  }
21
22
 
23
+ /** The state a page colours: one of STATES, unknown for anything else (theme.css `--st-*`). */
24
+ function stateKey(state) {
25
+ return Object.prototype.hasOwnProperty.call(STATES, state) ? state : "unknown";
26
+ }
27
+
22
28
  /** Metres in the user's depth unit ("m" or "foot"); "—" when there is no number. */
23
29
  function depth(metres, unit) {
24
30
  if (typeof metres !== "number" || !isFinite(metres)) {
@@ -35,16 +41,17 @@
35
41
  }
36
42
 
37
43
  /**
38
- * The status widget's texts and colour at `now`. A stale view, or one past
44
+ * The status widget's texts and state at `now` (`key`, whose colours theme.css holds). A stale view, or one past
39
45
  * its `freshUntil`, reads Unknown with no numbers, whatever values it last
40
46
  * held: a timer can fire late in a hidden page, the clock cannot.
41
47
  */
42
48
  function widgetView(view, unit, now) {
43
49
  const stale = view.stale || (typeof view.freshUntil === "number" && !(now < view.freshUntil));
44
- const s = stateStyle(stale ? "unknown" : view.state);
50
+ const key = stateKey(stale ? "unknown" : view.state);
51
+ const s = stateStyle(key);
45
52
  const tts = stale ? "" : timeToShoal(view.timeToShoal);
46
53
  return {
47
- colour: s.colour,
54
+ key: key,
48
55
  state: "UKC " + s.label,
49
56
  line: "min " + depth(stale ? null : view.clearance, unit) + (tts ? " · shoal " + tts : ""),
50
57
  reason: stale ? "no data from the under-keel plugin" : reason(view.message)
@@ -422,6 +429,7 @@
422
429
  retryAfterMs: retryAfterMs,
423
430
  STALE_MS: STALE_MS,
424
431
  widgetValue: widgetValue,
432
+ stateKey: stateKey,
425
433
  freshFor: freshFor,
426
434
  freshness: freshness,
427
435
  routeBadge: routeBadge,
@@ -3,14 +3,20 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <title>Under-keel status</title>
6
+ <link rel="stylesheet" href="theme.css" />
6
7
  <style>
7
8
  html, body { margin: 0; height: 100%; font: 13px/1.25 system-ui, sans-serif; }
8
- body { box-sizing: border-box; padding: 6px 8px; cursor: pointer; color: #fff; background: #808080; }
9
+ body { box-sizing: border-box; padding: 6px 8px; cursor: pointer; color: var(--on-st-unknown); background: var(--st-unknown); }
10
+ body[data-state="normal"] { color: var(--on-st-normal); background: var(--st-normal); }
11
+ body[data-state="alert"] { color: var(--on-st-alert); background: var(--st-alert); }
12
+ body[data-state="warn"] { color: var(--on-st-warn); background: var(--st-warn); }
13
+ body[data-state="alarm"] { color: var(--on-st-alarm); background: var(--st-alarm); }
9
14
  #state { font-size: 18px; font-weight: 700; letter-spacing: 0.02em; }
10
15
  #line { margin-top: 2px; }
11
- #reason { margin-top: 2px; font-size: 11px; opacity: 0.9; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
12
- #advisory { margin-top: 2px; font-size: 10px; opacity: 0.8; }
16
+ #reason { margin-top: 2px; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
17
+ #advisory { margin-top: 2px; font-size: 10px; }
13
18
  </style>
19
+ <script src="theme.js"></script>
14
20
  <script src="ukc-shared.js"></script>
15
21
  <script type="module" src="widget.js"></script>
16
22
  </head>
package/public/widget.js CHANGED
@@ -3,6 +3,8 @@ import { connectExtension } from "./vendor/plotterext-bus/extension.js";
3
3
 
4
4
  const UKC = globalThis.UKC;
5
5
  const client = await connectExtension();
6
+ // Not awaited: a host that offers night mode but never answers must not hold back the status.
7
+ void globalThis.UKC_THEME.followHost(client, document);
6
8
  const view = { state: "unknown", clearance: null, timeToShoal: null, message: "", stale: false };
7
9
  let unit = "m";
8
10
  /** A state not seen for this long is shown as unknown (the liveness contract in UKC.STALE_MS). */
@@ -25,8 +27,8 @@ function render() {
25
27
  return;
26
28
  }
27
29
  const next = UKC.widgetView(view, unit, Date.now());
28
- if (next.colour !== shown.colour) {
29
- document.body.style.background = next.colour;
30
+ if (next.key !== shown.key) {
31
+ document.body.dataset.state = next.key;
30
32
  }
31
33
  for (const id of ["state", "line", "reason"]) {
32
34
  if (next[id] !== shown[id]) {