signalk-underkeel 0.6.0 → 0.6.1

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,24 @@
2
2
 
3
3
  All notable changes to signalk-underkeel are documented here.
4
4
 
5
+ ## [0.6.1] - 2026-11-13
6
+
7
+ ### Fixed
8
+
9
+ - The details panel reads in dark mode (#67): it had fixed light-theme colours and no
10
+ background of its own, so on Freeboard's dark background most of its text was dark grey on
11
+ dark. The status chip and widget also put white on amber (Alert, 2.4:1) and on green
12
+ (Normal, 3.7:1), below the 4.5:1 that small text needs.
13
+ - The SignalK plugin CI workflow is pinned to a commit again. Without the `@ref`, GitHub could
14
+ not read the workflow file, so no plugin CI ran for 0.6.0 and the plugin registry took 10
15
+ points from its score.
16
+
17
+ ### Changed
18
+
19
+ - The widget, the details panel and the setup steps share one set of colours in three
20
+ schemes: day, dusk (the device's dark mode) and night (Freeboard's night mode: black, and
21
+ nothing brighter than a dim grey). Every text colour reads at WCAG AA in each scheme.
22
+
5
23
  ## [0.6.0] - 2026-11-10
6
24
 
7
25
  ### 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.1",
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) {
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]) {