signalk-pypilot-newui 2.4.0 → 2.4.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
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.1 — 2026-09-08 — Rev146..Rev153
4
+
5
+ Patch release. Fixes + UI polish on top of 2.4.0.
6
+
7
+ ### English
8
+
9
+ **Fixed**
10
+
11
+ - **TWS row in Sensor Quality no longer stuck as MISSING** on plain SK
12
+ setups. `environment.wind.speedTrue` (canonical) is rarely populated;
13
+ the canonical row now accepts `.speedOverGround` (what
14
+ `signalk-derived-data` emits) or `.speedOverWater` as alternatives.
15
+ Same treatment for TWA (`.angleTrueWater` / `.angleTrueGround` /
16
+ `.angleTrue`). Rev146-148.
17
+ - **Regression: AP mode selector went blank** after the Rev140 watch
18
+ refactor - `RESERVED_PYPILOT_KEYS` was blocking subscribe too, when
19
+ it should only block re-publish. Restored so the visor keeps
20
+ `ap.enabled/mode/heading_command` in sync. Rev142.
21
+ - **Watch focus was pinning 60+ keys** on `pypilot_web` when the Setup
22
+ launcher was open. Rev149-150: the launcher grid takes NO focus;
23
+ only the Calibration card (fullscreen) bumps the 7 documented
24
+ sliders. Empty focus now triggers explicit release, so the count
25
+ drops back to the ~15 core watches immediately instead of waiting
26
+ for the 60 s TTL. Well under Sean D'Epagnier's 40-path ceiling.
27
+ - **`[current]` prefix in Pi Zero log capture** replaced with the
28
+ parent directory (`[pypilot]`, `[pypilot_web]`, `[pypilot_hat]`) so
29
+ the merged file is actually readable. Rev149.
30
+
31
+ **Changed**
32
+
33
+ - **Rose colour palette (Carlos)**: Target/DIA diamond is now red
34
+ (`--target-red`, matches the AP engage button); AWS/TWS chip below
35
+ the rose is fully tinted amber for apparent, teal for true; corner
36
+ tiles tint value + sub in the matching wind colour; SOG back to
37
+ white; Rev151-152.
38
+ - **Configure-corners modal** — h4 + close button now sticky at the
39
+ top, body scrolls independently. X on the LEFT with the solid red
40
+ `--engaged` style used by the Setup fullscreen cards. Rev151-153.
41
+ - **Mode selector labels re-render on language change**: switching
42
+ language now redraws mode / pilot / profile selects immediately
43
+ instead of waiting for the next backend delta. Rev144.
44
+
3
45
  ## 2.4.0 — 2026-09-07 — Rev138..Rev145
4
46
 
5
47
  Feature release. The plugin has learnt to behave itself around the
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ const doctor_1 = require("./doctor");
14
14
  const session_recorder_1 = require("./session-recorder");
15
15
  // Rev counter bumped on every build so the user can distinguish deploys
16
16
  // from the webapp header (feedback_revision_bump_each_build).
17
- const PLUGIN_REVISION = "Rev145";
17
+ const PLUGIN_REVISION = "Rev153";
18
18
  // Rev59: read package.json once at load time so /status can report the
19
19
  // npm package version alongside the internal Rev counter.
20
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -1314,8 +1314,16 @@ module.exports = function (app) {
1314
1314
  const fresh = lines.slice(startIdx);
1315
1315
  if (fresh.length === 0)
1316
1316
  continue;
1317
+ // Rev149 (Carlos): tag each line with the parent directory
1318
+ // instead of the file basename. All three logs are named
1319
+ // "current" (svlogd convention) so the previous prefix
1320
+ // showed [current] for everything - useless when reviewing
1321
+ // the aggregated file. Now: [pypilot], [pypilot_web],
1322
+ // [pypilot_hat].
1323
+ const parts = src.split("/");
1324
+ const tag = parts.length >= 2 ? parts[parts.length - 2] : (parts[0] || "log");
1317
1325
  for (const l of fresh)
1318
- newLines.push(`[${src.split("/").pop()}] ${l}`);
1326
+ newLines.push(`[${tag}] ${l}`);
1319
1327
  logCaptureLastLineHash[src] = _lcHash(lines[lines.length - 1]);
1320
1328
  }
1321
1329
  if (newLines.length > 0) {
@@ -2015,7 +2023,11 @@ module.exports = function (app) {
2015
2023
  engaged: engagedNow,
2016
2024
  mode: s.mode,
2017
2025
  tws: s.tws,
2018
- twa: null,
2026
+ // Rev146: TWA comes from angleTrueWater in most SK setups
2027
+ // (derived-data emits that, not the legacy angleTrue).
2028
+ twa: (typeof (app.getSelfPath ? app.getSelfPath("environment.wind.angleTrueWater.value") : null) === "number")
2029
+ ? app.getSelfPath("environment.wind.angleTrueWater.value")
2030
+ : null,
2019
2031
  aws: s.aws,
2020
2032
  awa: s.awa,
2021
2033
  sog: s.sog,
@@ -2086,7 +2098,11 @@ module.exports = function (app) {
2086
2098
  servoVoltage: servoV,
2087
2099
  awa: skNum("environment.wind.angleApparent"),
2088
2100
  aws: skNum("environment.wind.speedApparent"),
2089
- tws: skNum("environment.wind.speedTrue"),
2101
+ // Rev146 (Carlos): signalk-derived-data does not emit
2102
+ // environment.wind.speedTrue - it publishes speedOverGround
2103
+ // instead. Try the canonical path first (some setups still
2104
+ // populate it via NMEA MWD) and fall back to the derived one.
2105
+ tws: skNum("environment.wind.speedTrue") ?? skNum("environment.wind.speedOverGround"),
2090
2106
  sog: skNum("navigation.speedOverGround"),
2091
2107
  heel: skAttitudeField("roll"),
2092
2108
  engaged,
@@ -2200,13 +2216,27 @@ module.exports = function (app) {
2200
2216
  if (!sensorQuality)
2201
2217
  return;
2202
2218
  const now = Date.now();
2203
- for (const path of Object.keys(sensor_quality_1.DEFAULT_QUALITY_WATCH)) {
2219
+ // Rev148 (Carlos): feed the primary path AND every alternative the
2220
+ // threshold advertises so the monitor can pick the freshest source
2221
+ // for that metric (TWS, TWA, ...) instead of failing on the
2222
+ // canonical path that most SK setups don't publish.
2223
+ for (const [path, thr] of Object.entries(sensor_quality_1.DEFAULT_QUALITY_WATCH)) {
2204
2224
  try {
2205
2225
  const entry = app.getSelfPath(path);
2206
2226
  if (entry)
2207
2227
  sensorQuality.observe(path, entry, now);
2208
2228
  }
2209
2229
  catch { /* silent - a missing path stays "missing" naturally */ }
2230
+ if (thr.alternatives) {
2231
+ for (const alt of thr.alternatives) {
2232
+ try {
2233
+ const entry = app.getSelfPath(alt);
2234
+ if (entry)
2235
+ sensorQuality.observe(alt, entry, now);
2236
+ }
2237
+ catch { /* silent */ }
2238
+ }
2239
+ }
2210
2240
  }
2211
2241
  }
2212
2242
  // Rev95: publish the current KPI snapshot as Signal K paths under
@@ -37,7 +37,28 @@ exports.DEFAULT_QUALITY_WATCH = {
37
37
  "navigation.position": { label: "GPS position", degradedAgeMs: 5000, lostAgeMs: 30000 },
38
38
  "environment.wind.angleApparent": { label: "AWA", degradedAgeMs: 5000, lostAgeMs: 30000 },
39
39
  "environment.wind.speedApparent": { label: "AWS", degradedAgeMs: 5000, lostAgeMs: 30000 },
40
- "environment.wind.speedTrue": { label: "TWS", degradedAgeMs: 5000, lostAgeMs: 30000 },
40
+ // Rev148 (Carlos): TWS row is fed by whichever true-wind speed
41
+ // path the SK setup publishes. speedTrue is the canonical spec
42
+ // path but almost nobody publishes it; signalk-derived-data emits
43
+ // speedOverGround; a few instruments publish speedOverWater. If
44
+ // ANY of the three is fresh, the row turns green.
45
+ "environment.wind.speedOverGround": {
46
+ label: "TWS", degradedAgeMs: 5000, lostAgeMs: 30000,
47
+ alternatives: [
48
+ "environment.wind.speedTrue",
49
+ "environment.wind.speedOverWater",
50
+ ],
51
+ },
52
+ // TWA row: same idea. Different derived-data / instrument setups
53
+ // emit angleTrueWater, angleTrueGround, or the deprecated
54
+ // angleTrue - accept any of them.
55
+ "environment.wind.angleTrueWater": {
56
+ label: "TWA", degradedAgeMs: 5000, lostAgeMs: 30000,
57
+ alternatives: [
58
+ "environment.wind.angleTrueGround",
59
+ "environment.wind.angleTrue",
60
+ ],
61
+ },
41
62
  "steering.rudderAngle": { label: "Rudder", degradedAgeMs: 3000, lostAgeMs: 20000 },
42
63
  };
43
64
  class SensorQualityMonitor {
@@ -56,6 +77,26 @@ class SensorQualityMonitor {
56
77
  lastValue: null,
57
78
  lastSource: null,
58
79
  });
80
+ // Rev148 (Carlos): register a hidden slot for every alternative
81
+ // path in the group so freshness/hz/jitter are all measured, but
82
+ // tag the threshold with the same label as the primary slot -
83
+ // the snapshot dedupes by label and keeps only the freshest.
84
+ if (thr.alternatives) {
85
+ for (const alt of thr.alternatives) {
86
+ if (this.slots.has(alt))
87
+ continue;
88
+ this.slots.set(alt, {
89
+ path: alt,
90
+ threshold: { ...thr, alternatives: undefined }, // avoid recursion
91
+ arrivals: new Array(this.bufferPerPath).fill(0),
92
+ head: 0,
93
+ count: 0,
94
+ lastSkTs: null,
95
+ lastValue: null,
96
+ lastSource: null,
97
+ });
98
+ }
99
+ }
59
100
  }
60
101
  }
61
102
  /** Feed one observation into the monitor. Called from the plugin's
@@ -97,14 +138,29 @@ class SensorQualityMonitor {
97
138
  slot.count += 1;
98
139
  }
99
140
  snapshot(nowMs = Date.now()) {
100
- const items = [];
141
+ // Rev148 (Carlos): classify every slot, then dedupe by label -
142
+ // paths that share a label (alternatives) collapse into a single
143
+ // row keeping the freshest (or the greenest) as the canonical.
144
+ // If no alternative has ever emitted, the row stays "missing".
145
+ const classified = [];
101
146
  for (const slot of this.slots.values()) {
102
- items.push(this.classify(slot, nowMs));
147
+ classified.push(this.classify(slot, nowMs));
148
+ }
149
+ const bestByLabel = new Map();
150
+ for (const it of classified) {
151
+ const key = it.label || it.path;
152
+ const prev = bestByLabel.get(key);
153
+ if (!prev) {
154
+ bestByLabel.set(key, it);
155
+ continue;
156
+ }
157
+ if (isBetter(it, prev))
158
+ bestByLabel.set(key, it);
103
159
  }
104
160
  return {
105
161
  computedTs: nowMs,
106
162
  windowSec: this.windowMs / 1000,
107
- items,
163
+ items: [...bestByLabel.values()],
108
164
  };
109
165
  }
110
166
  classify(slot, nowMs) {
@@ -172,6 +228,19 @@ class SensorQualityMonitor {
172
228
  }
173
229
  }
174
230
  exports.SensorQualityMonitor = SensorQualityMonitor;
231
+ /** Rev148: rank two PathQuality reports so the dedupe by-label picks
232
+ * the "greenest and freshest". Ordering: good beats degraded beats
233
+ * lost beats missing; within the same level, the smaller ageMs wins. */
234
+ function isBetter(a, b) {
235
+ const rank = (l) => (l === "good" ? 3 : l === "degraded" ? 2 : l === "lost" ? 1 : 0);
236
+ const ra = rank(a.level);
237
+ const rb = rank(b.level);
238
+ if (ra !== rb)
239
+ return ra > rb;
240
+ const aa = a.ageMs ?? Infinity;
241
+ const bb = b.ageMs ?? Infinity;
242
+ return aa < bb;
243
+ }
175
244
  function normaliseTs(raw) {
176
245
  if (raw == null)
177
246
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-pypilot-newui",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "Signal K friendly UI for the pypilot autopilot, with the capability to spread its functions and buttons to other programs like KIP, WilhelmSK and freeboard. Touch-first mobile visor + full pypilot data (servo, gains, calibration, tack, warnings) as first-class Signal K paths and Simple Switches. Coexists with pypilot-autopilot-provider.",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
package/public/app.js CHANGED
@@ -1554,6 +1554,10 @@
1554
1554
  "environment.wind.angleTrueWater",
1555
1555
  "environment.wind.angleTrueGround",
1556
1556
  "environment.wind.speedTrue",
1557
+ // Rev146 (Carlos): signalk-derived-data emits speedOverGround
1558
+ // in place of speedTrue on most SK setups - subscribe both so
1559
+ // the corner tiles and Aproado modal get a real value.
1560
+ "environment.wind.speedOverGround",
1557
1561
  "environment.wind.directionTrue",
1558
1562
  "environment.depth.belowKeel",
1559
1563
  "environment.depth.belowTransducer",
@@ -1712,6 +1716,12 @@
1712
1716
  state.windAngleTrue = numericOrNull(value); break;
1713
1717
  case "environment.wind.speedTrue":
1714
1718
  state.windSpeedTrue = numericOrNull(value); break;
1719
+ case "environment.wind.speedOverGround":
1720
+ // Rev146: use as TWS fallback only when the canonical
1721
+ // speedTrue path is missing (some SK setups do publish
1722
+ // speedTrue via NMEA MWD - respect that value first).
1723
+ if (state.windSpeedTrue == null) state.windSpeedTrue = numericOrNull(value);
1724
+ break;
1715
1725
  case "navigation.speedOverGround":
1716
1726
  state.sog = numericOrNull(value); renderCogAndCurrent(); break;
1717
1727
  case "navigation.courseOverGroundTrue":
@@ -1952,19 +1962,47 @@
1952
1962
  if (!meta) continue;
1953
1963
  const label = typeof meta.label === "function" ? meta.label() : meta.label;
1954
1964
  const val = meta.read();
1965
+ // Rev151 (Carlos): colour A/T inside the corner-tile label so
1966
+ // "AWA/AWS" gets an amber A and "TWA/TWS" a teal T. The rest of
1967
+ // the label stays neutral so the tile does not look shouty.
1968
+ const paintedLabel = _paintABLabel(label, key);
1955
1969
  // Wind returns {value, sub}; the rest returns a string.
1956
1970
  if (val && typeof val === "object" && "value" in val) {
1957
- el.innerHTML = `<div class="label">${label}</div><div class="value">${val.value}</div><div class="sub">${val.sub}</div>`;
1971
+ el.innerHTML = `<div class="label">${paintedLabel}</div><div class="value">${val.value}</div><div class="sub">${val.sub}</div>`;
1958
1972
  } else {
1959
- el.innerHTML = `<div class="label">${label}</div><div class="value">${val}</div>`;
1973
+ el.innerHTML = `<div class="label">${paintedLabel}</div><div class="value">${val}</div>`;
1960
1974
  }
1961
1975
  // Rev48: color coding by data type - keep classic B&G-ish palette.
1962
- el.classList.remove("heading","target","wind","sog","depth","cog","mode","twa","twd");
1963
- const colorMap = { hdg:"heading", tgt:"target", wind:"wind", awa:"wind", aws:"wind",
1964
- twa:"twa", tws:"wind", twd:"twd", sog:"sog", cog:"cog", depth:"depth", mode:"mode" };
1976
+ // Rev151: tgt goes red (matches the AP button); awa/aws lean amber;
1977
+ // twa/tws lean teal; wind combo picks amber/teal via the paint below.
1978
+ el.classList.remove("heading","target","wind","sog","depth","cog","mode","twa","twd","wind-app","wind-true");
1979
+ const colorMap = { hdg:"heading", tgt:"target",
1980
+ wind: state.windCornerShow === "twa" ? "wind-true" : "wind-app",
1981
+ awa:"wind-app", aws:"wind-app",
1982
+ twa:"wind-true", tws:"wind-true", twd:"twd",
1983
+ sog:"sog", cog:"cog", depth:"depth", mode:"mode" };
1965
1984
  if (colorMap[key]) el.classList.add(colorMap[key]);
1966
1985
  }
1967
1986
  }
1987
+ // Rev151: wrap the leading A / T of a corner label into a coloured
1988
+ // span so the eye maps the tile to the matching arrow instantly.
1989
+ function _paintABLabel(label, key) {
1990
+ if (typeof label !== "string" || label.length === 0) return label;
1991
+ // Cases we know: "AWA/AWS", "AWS", "AWA", "TWA/TWS", "TWS", "TWA".
1992
+ // For the toggling `wind` corner, check state.windCornerShow.
1993
+ const isApp = key === "awa" || key === "aws" || (key === "wind" && state.windCornerShow !== "twa");
1994
+ const isTru = key === "twa" || key === "tws" || (key === "wind" && state.windCornerShow === "twa");
1995
+ if (!isApp && !isTru) return label;
1996
+ const cls = isApp ? "lbl-app" : "lbl-true";
1997
+ // Split around the '/' so "AWA/AWS" -> both sides paint their leader.
1998
+ return label.split("/").map((part) => {
1999
+ const trimmed = part.trim();
2000
+ if (!trimmed) return part;
2001
+ const head = trimmed[0];
2002
+ const tail = trimmed.slice(1);
2003
+ return `<span class="${cls}"><b>${head}</b>${tail}</span>`;
2004
+ }).join("/");
2005
+ }
1968
2006
  function _dashPopulateSelectors() {
1969
2007
  // Rev58: the corner dropdown lists CORE keys + user-enabled extras +
1970
2008
  // any custom paths the user has added. Everything else stays hidden
@@ -2270,12 +2308,20 @@
2270
2308
  }
2271
2309
  const speedLabel = document.getElementById("rose-speed-label");
2272
2310
  const speedText = document.getElementById("rose-speed");
2311
+ const chip = document.getElementById("rose-speed-chip");
2273
2312
  if (speedLabel) speedLabel.textContent = windSpeed == null ? "" : tag;
2274
2313
  if (speedText) {
2275
2314
  speedText.textContent = windSpeed == null
2276
2315
  ? "--- kn"
2277
2316
  : (windSpeed * 1.94384).toFixed(1) + " kn";
2278
2317
  }
2318
+ // Rev152 (Carlos): tint the WHOLE chip (label + value) with the
2319
+ // matching arrow colour - AWS gets amber, TWS gets teal. Toggling
2320
+ // a class on the chip lets CSS colour both children at once.
2321
+ if (chip) {
2322
+ chip.classList.remove("wind-app", "wind-true");
2323
+ if (windSpeed != null) chip.classList.add(tag === "TWS" ? "wind-true" : "wind-app");
2324
+ }
2279
2325
  }
2280
2326
 
2281
2327
  // Rev57: target indicator - a small cyan diamond OUTSIDE the compass
@@ -5198,31 +5244,54 @@
5198
5244
  return keys;
5199
5245
  }
5200
5246
  function _setupFocusKeys() {
5201
- // Setup > Calibration shows every RangeSetting. The chip / status
5202
- // requests in the Setup grid do not need extra focus - they are
5203
- // already covered by the core set.
5204
- const keys = [];
5247
+ // Rev149 (Carlos, per Sean D'Epagnier): the launcher grid alone
5248
+ // (no card fullscreen) needs NO focus - the tile chips read
5249
+ // aggregate state from the SK server, not raw pypilot values.
5250
+ // Only bump keys when a card that actually shows raw values is
5251
+ // opened fullscreen, and only the keys that card renders. This
5252
+ // keeps the watch count well under Sean's 40-path ceiling.
5253
+ const fs = document.querySelector('.setup-block[data-fullscreen="1"]');
5254
+ if (!fs) return [];
5255
+ const blockKey = fs.dataset.blockKey || "";
5205
5256
  const cat = state.catalog || {};
5206
- for (const k of Object.keys(cat)) {
5207
- if (cat[k]?.type === "RangeSetting") keys.push(k);
5208
- }
5209
- // rudder / imu telemetry helps the Sensor Quality card too.
5210
- for (const k of Object.keys(cat)) {
5211
- if (k.startsWith("rudder.") || k.startsWith("imu.")) keys.push(k);
5257
+ const keys = [];
5258
+ if (blockKey === "calibration") {
5259
+ // Only the RangeSettings we ship help text for + the alignment
5260
+ // metadata the calibration card actually renders.
5261
+ const DOCUMENTED = [
5262
+ "servo.max_current", "servo.max_slew_speed", "servo.min_speed",
5263
+ "rudder.range", "imu.heading_offset",
5264
+ "ap.tack.angle", "ap.tack.rate",
5265
+ ];
5266
+ for (const k of DOCUMENTED) if (cat[k]) keys.push(k);
5212
5267
  }
5268
+ // Doctor / Sensor Quality / Paths / Language / Emergency / Connection
5269
+ // do not depend on any pypilot value the core set doesn't already
5270
+ // subscribe to. Adding them here would just re-inflate the count.
5213
5271
  return keys;
5214
5272
  }
5215
5273
  function _watchFocusForTab(id) {
5216
5274
  _focusCurrentTab = id;
5217
5275
  if (_focusRenewTimer) { clearInterval(_focusRenewTimer); _focusRenewTimer = null; }
5218
5276
  const kick = () => {
5277
+ let keys = [];
5278
+ let period = 1.0;
5219
5279
  if (_focusCurrentTab === "tune") {
5220
- _watchFocusPost(_tuneFocusKeys(), 0.5, 60);
5280
+ keys = _tuneFocusKeys();
5281
+ period = 0.5;
5221
5282
  } else if (_focusCurrentTab === "setup") {
5222
- _watchFocusPost(_setupFocusKeys(), 1.0, 60);
5223
- } else if (_focusCurrentTab === "chart") {
5224
- // Chart tab renders KPIs the historian populates on the backend
5225
- // side; no extra pypilot focus needed beyond the core set.
5283
+ keys = _setupFocusKeys();
5284
+ period = 1.0;
5285
+ }
5286
+ // Rev149 (Carlos, per Sean D'Epagnier): if this tab / card
5287
+ // combination needs no focus, actively release whatever the
5288
+ // previous state had rather than letting the 60 s TTL run down.
5289
+ // Otherwise Setup would keep 60+ keys pinned for a full minute
5290
+ // after the user closed the Calibration card.
5291
+ if (keys.length === 0) {
5292
+ _watchReleaseAll();
5293
+ } else {
5294
+ _watchFocusPost(keys, period, 60);
5226
5295
  }
5227
5296
  };
5228
5297
  kick();
@@ -7659,6 +7728,10 @@
7659
7728
  block.dataset.fullscreen = "1";
7660
7729
  block.scrollTop = 0;
7661
7730
  document.body.classList.add("setup-fullscreen-active");
7731
+ // Rev149 (Carlos, per Sean): re-evaluate focus now that the
7732
+ // fullscreen block is known, so the relevant card's paths get
7733
+ // bumped immediately instead of waiting for the 45 s heartbeat.
7734
+ if (typeof _watchFocusForTab === "function") _watchFocusForTab("setup");
7662
7735
  }
7663
7736
  function _setupFullscreenOff(block) {
7664
7737
  if (!block) {
@@ -7679,6 +7752,11 @@
7679
7752
  if (!document.querySelector('.setup-block[data-fullscreen="1"]')) {
7680
7753
  document.body.classList.remove("setup-fullscreen-active");
7681
7754
  }
7755
+ // Rev149: re-evaluate focus. With no card open, _watchFocusForTab
7756
+ // sees an empty key list and calls _watchReleaseAll itself, so
7757
+ // the previously bumped subscriptions drop back to the core set
7758
+ // immediately rather than after the 60 s TTL.
7759
+ if (typeof _watchFocusForTab === "function") _watchFocusForTab("setup");
7682
7760
  }
7683
7761
  function initSetupTiles() {
7684
7762
  const setup = document.getElementById("tab-setup");
package/public/index.html CHANGED
@@ -181,8 +181,12 @@
181
181
  sean afiladas (sin curvatura extra que crea bump
182
182
  asimétrico al renderizar en algunos ángulos de
183
183
  rotación). Path desde la punta (Rev80) mantenido. -->
184
+ <!-- Rev151 (Carlos): Target/DIA en rojo AP (var(--target-red))
185
+ para que el hito visual coincida con el color del boton
186
+ engage AP. Anteriormente cian; el cian queda libre para
187
+ otros overlays neutros. -->
184
188
  <path d="M 0,-68 L -21.15,-91.59 A 94 94 0 0 1 21.15,-91.59 Z"
185
- fill="#4dd4ff" stroke="#2ea6c9" stroke-width="0.75"
189
+ fill="#ff3b30" stroke="#a82420" stroke-width="0.75"
186
190
  stroke-linejoin="miter" stroke-miterlimit="10"/>
187
191
  </g>
188
192
  <!-- Raya: marca radial negra dibujada DESPUÉS de las 3
@@ -391,9 +395,13 @@
391
395
  any corner cell. 4 dropdowns, one per corner. Choice persists
392
396
  in localStorage so each user gets their own layout. -->
393
397
  <div class="popup-overlay" id="dash-help-pop" onclick="if(event.target.id==='dash-help-pop')this.classList.remove('open')">
398
+ <!-- Rev151 (Carlos): header (h4 + X) fijo, body scrolleable
399
+ en su propio contenedor. Estilos alineados con las cards
400
+ fullscreen de Setup (X rojo, header con fondo bg-3). -->
394
401
  <div class="popup-box" onclick="event.stopPropagation()">
395
- <button class="popup-close" onclick="document.getElementById('dash-help-pop').classList.remove('open')">✕</button>
396
402
  <h4 data-i18n="dash.help.title">Configure the rose corners</h4>
403
+ <button class="popup-close" onclick="document.getElementById('dash-help-pop').classList.remove('open')">✕</button>
404
+ <div class="popup-body">
397
405
  <p class="dash-help-body" data-i18n="dash.help.body">Pick which datum shows in each corner. Saved in your browser.</p>
398
406
  <div class="dash-corner-cfg">
399
407
  <label><span data-i18n="dash.corner.tl">Top left</span> <select id="dash-corner-tl" data-corner="tl"></select></label>
@@ -492,6 +500,7 @@
492
500
  <datalist id="dash-custom-path-list"></datalist>
493
501
  <div id="dash-custom-list"></div>
494
502
  </div>
503
+ </div><!-- /.popup-body Rev151 -->
495
504
  </div>
496
505
  </div>
497
506
 
@@ -1513,6 +1522,6 @@
1513
1522
  </div>
1514
1523
 
1515
1524
  <!-- Cache-busting: bump the ?v= on every Rev to force Firefox to fetch fresh app.js -->
1516
- <script src="app.js?v=Rev145"></script>
1525
+ <script src="app.js?v=Rev153"></script>
1517
1526
  </body>
1518
1527
  </html>
package/public/style.css CHANGED
@@ -16,6 +16,16 @@
16
16
  --border: #2b3542;
17
17
  --radius: 14px;
18
18
  --tap: 56px;
19
+ /* Rev151 (Carlos): rose data-tag palette - the corner tiles, chip
20
+ labels and Target diamond now inherit these tokens so the visual
21
+ link with each arrow is unambiguous. Apparent = amber, True =
22
+ teal, Target = the same red as the AP engaged button. */
23
+ --wind-app: #ffd54a;
24
+ --wind-app-dim: #cba822;
25
+ --wind-true: #5ce8c0;
26
+ --wind-true-dim: #38b596;
27
+ --target-red: #ff3b30;
28
+ --target-red-dim:#a82420;
19
29
  }
20
30
 
21
31
  * { box-sizing: border-box; }
@@ -394,6 +404,28 @@ main {
394
404
  white-space: nowrap;
395
405
  }
396
406
  .dash-rose .wind-chip:active { transform: translateX(-50%) scale(0.96); }
407
+
408
+ /* Rev151/152 (Carlos): the WHOLE AWS/TWS chip below the rose plus the
409
+ corner tiles gets tinted with the matching arrow colour - amber for
410
+ apparent, teal for true. In the tile label we still highlight the
411
+ leading A / T with a bold span (helps parse "AWA/AWS" in a glance)
412
+ but the rest of the text now shares the tint so the chip reads as
413
+ one unit. */
414
+ .dash-rose .wind-chip.wind-app .label,
415
+ .dash-rose .wind-chip.wind-app .value { color: var(--wind-app); }
416
+ .dash-rose .wind-chip.wind-true .label,
417
+ .dash-rose .wind-chip.wind-true .value { color: var(--wind-true); }
418
+ .lbl-app { color: var(--wind-app); }
419
+ .lbl-true { color: var(--wind-true); }
420
+ .lbl-app b, .lbl-true b { font-weight: 900; }
421
+ /* Corner tiles inherit the palette via body classes so the whole tile
422
+ gets a subtle tint, not just the label. */
423
+ .dash-rose .dash-cell.wind-app .value,
424
+ .dash-rose .dash-cell.wind-app .sub { color: var(--wind-app); }
425
+ .dash-rose .dash-cell.wind-true .value,
426
+ .dash-rose .dash-cell.wind-true .sub { color: var(--wind-true); }
427
+ .dash-rose .dash-cell.target .value,
428
+ .dash-rose .dash-cell.target .sub { color: var(--target-red); }
397
429
  .dash-rose .dash-cell {
398
430
  display: flex;
399
431
  flex-direction: column;
@@ -483,20 +515,59 @@ main {
483
515
  font-size: 22px;
484
516
  color: var(--accent, #37b6ff);
485
517
  }
518
+ /* Rev151 (Carlos): popups carrying a .popup-body get a sticky header
519
+ layout - the h4 + close button stay pinned while the body scrolls.
520
+ Legacy popups (no .popup-body) keep the previous block layout. */
521
+ .popup-box:has(> .popup-body) {
522
+ padding: 0;
523
+ max-height: 85vh;
524
+ display: grid;
525
+ grid-template-rows: auto minmax(0, 1fr);
526
+ overflow: hidden;
527
+ }
528
+ .popup-box:has(> .popup-body) > h4 {
529
+ /* Rev152 (Carlos): X now on the LEFT so reserve padding on that
530
+ side and let the right edge breathe. */
531
+ margin: 0;
532
+ padding: 14px 22px 14px 60px;
533
+ font-size: 20px;
534
+ font-weight: 800;
535
+ background: var(--bg-3, #1c2632);
536
+ border-bottom: 1px solid var(--border, #2b3542);
537
+ letter-spacing: 0.3px;
538
+ }
539
+ /* Rev151: dedicated scrolling wrapper for popup body. Everything
540
+ after the sticky h4 lives inside .popup-body. */
541
+ .popup-box > .popup-body {
542
+ overflow-y: auto;
543
+ padding: 18px 22px 22px;
544
+ -webkit-overflow-scrolling: touch;
545
+ }
486
546
  .popup-close {
547
+ /* Rev151/152/153 (Carlos): X on the LEFT of the header, styled EXACTLY
548
+ like the Setup fullscreen close button - solid red background with
549
+ white glyph, no border. Same visual language across the whole app. */
487
550
  position: absolute;
488
- top: 10px;
489
- right: 14px;
490
- background: rgba(255, 255, 255, 0.08);
491
- border: 1px solid rgba(255, 255, 255, 0.2);
492
- color: #ccc;
493
- width: 36px;
494
- height: 36px;
495
- border-radius: 8px;
496
- font-size: 18px;
551
+ top: 8px;
552
+ left: 10px;
553
+ background: var(--engaged, #ff3b30);
554
+ border: none;
555
+ color: #fff;
556
+ width: 40px;
557
+ height: 40px;
558
+ border-radius: 10px;
559
+ font-size: 20px;
560
+ font-weight: 900;
561
+ line-height: 1;
497
562
  cursor: pointer;
563
+ display: flex;
564
+ align-items: center;
565
+ justify-content: center;
566
+ padding: 0;
567
+ z-index: 1;
498
568
  }
499
- .popup-close:hover { background: rgba(255, 255, 255, 0.15); }
569
+ .popup-close:hover { filter: brightness(1.1); }
570
+ .popup-close:active { transform: scale(0.94); }
500
571
 
501
572
  /* Rev47: long-press dash-help popup content. */
502
573
  .dash-help-body {
@@ -704,13 +775,16 @@ body.nav-hidden .nav-toggle .tri { transform: rotate(180deg); }
704
775
  margin-top: 4px;
705
776
  }
706
777
 
707
- /* B&G-ish tile color coding: current heading in soft white, target in blue,
708
- wind in green, SOG in yellow. */
778
+ /* B&G-ish tile color coding.
779
+ Rev151/152 (Carlos): SOG back to white (was yellow), wind now split
780
+ into apparent (amber) / true (teal). The wind-app / wind-true rules
781
+ further down in this file override the legacy green .wind rules. */
709
782
  .dash-cell.heading .value { color: #ffffff; }
710
783
  .dash-cell.target .value { color: #37b6ff; }
711
784
  .dash-cell.wind .value { color: #4dd982; }
712
785
  .dash-cell.wind .sub { color: #4dd982; }
713
- .dash-cell.sog .value { color: #ffcf47; }
786
+ .dash-cell.sog .value { color: #ffffff; }
787
+ .dash-cell.sog .sub { color: #ffffff; }
714
788
 
715
789
  .controls { display: flex; flex-direction: column; gap: 10px; }
716
790