signalk-performance-processor 0.0.0-stage → 0.0.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/public/app.js ADDED
@@ -0,0 +1,336 @@
1
+ (function () {
2
+ 'use strict'
3
+
4
+ const API = '/plugins/signalk-performance-processor'
5
+ const LIVE_MS = 1000
6
+ const STALE_MS = 5000
7
+
8
+ // Display names and help for each correction, in pipeline order. A correction the
9
+ // plugin knows but this list does not still shows, under its id.
10
+ const CORRECTIONS = {
11
+ stw: { name: 'Boat speed (STW)', what: 'Paddlewheel linearity at low speed, plus the loss when heeled. Knots added to STW before anything else uses it.' },
12
+ leeway: { name: 'Leeway', what: 'Sideways slip: leeway = K × heel ÷ STW². Turns the boat’s velocity to leeward for true wind and current.' },
13
+ twa: { name: 'TWA table', what: 'Degrees added to |TWA| by TWS, upwind, reaching and downwind, H5000 style. Negative = TWA was reading too wide. Rows blend by TWA at the angles below.' },
14
+ tws: { name: 'TWS table', what: 'Percent applied to TWS by TWS, upwind, reaching and downwind. Blended at the TWA table’s angles. H5000 default is −10% downwind.' }
15
+ }
16
+
17
+ const SOURCE_LABELS = {
18
+ apparentWind: 'Apparent wind',
19
+ stw: 'Speed through water',
20
+ heading: 'Heading',
21
+ attitude: 'Heel (attitude)',
22
+ gps: 'SOG and COG',
23
+ variation: 'Magnetic variation'
24
+ }
25
+
26
+ const state = {
27
+ view: 'live',
28
+ saved: null, // config as stored by the plugin
29
+ draft: null, // config being edited
30
+ defaults: null,
31
+ selected: 'twa',
32
+ live: null,
33
+ liveAt: 0,
34
+ saving: false
35
+ }
36
+
37
+ const $ = id => document.getElementById(id)
38
+ const clone = o => JSON.parse(JSON.stringify(o))
39
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
40
+ const fmt = (v, d = 1) => v == null || !Number.isFinite(v) ? '--' : v.toFixed(d)
41
+ const dirty = () => JSON.stringify(state.saved) !== JSON.stringify(state.draft)
42
+
43
+ // ---- data ----------------------------------------------------------------
44
+
45
+ async function getJson (path) {
46
+ const res = await fetch(API + path, { credentials: 'include', cache: 'no-store' })
47
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
48
+ return res.json()
49
+ }
50
+
51
+ async function loadConfig () {
52
+ try {
53
+ const body = await getJson('/config')
54
+ state.saved = body.config
55
+ state.draft = clone(body.config)
56
+ state.defaults = body.defaults
57
+ $('conn').hidden = true
58
+ } catch (e) {
59
+ $('conn').textContent = `Cannot reach the plugin (${e.message}). Is Performance Processor enabled?`
60
+ $('conn').hidden = false
61
+ }
62
+ renderAll()
63
+ }
64
+
65
+ async function pollLive () {
66
+ try {
67
+ state.live = await getJson('/live')
68
+ state.liveAt = Date.now()
69
+ $('conn').hidden = true
70
+ } catch (e) {
71
+ $('conn').textContent = `Lost contact with the plugin (${e.message})`
72
+ $('conn').hidden = false
73
+ }
74
+ renderHeader()
75
+ if (state.view === 'live') renderLive()
76
+ if (state.view === 'settings') renderSourcesIfIdle()
77
+ }
78
+
79
+ async function save () {
80
+ state.saving = true
81
+ renderSaveBar()
82
+ try {
83
+ const res = await fetch(API + '/config', {
84
+ method: 'PUT',
85
+ credentials: 'include',
86
+ headers: { 'Content-Type': 'application/json' },
87
+ body: JSON.stringify(state.draft)
88
+ })
89
+ const body = await res.json().catch(() => ({}))
90
+ if (!res.ok) {
91
+ const why = body.errors ? body.errors.join('; ') : `${res.status} ${res.statusText}`
92
+ throw new Error(res.status === 401 || res.status === 403 ? 'not authorised — log in to Signal K with write access' : why)
93
+ }
94
+ state.saved = body.config
95
+ state.draft = clone(body.config)
96
+ $('saveErrors').textContent = ''
97
+ } catch (e) {
98
+ $('saveErrors').textContent = e.message
99
+ }
100
+ state.saving = false
101
+ renderAll()
102
+ }
103
+
104
+ // ---- editing ---------------------------------------------------------------
105
+
106
+ // data-path="corrections.twa.upwind.2" → state.draft.corrections.twa.upwind[2]
107
+ function setPath (path, value) {
108
+ const keys = path.split('.')
109
+ let o = state.draft
110
+ for (let i = 0; i < keys.length - 1; i++) o = o[keys[i]]
111
+ o[keys[keys.length - 1]] = value
112
+ }
113
+
114
+ function onEdit (e) {
115
+ const el = e.target
116
+ if (!el.dataset || !el.dataset.path || !state.draft) return
117
+ let value
118
+ if (el.type === 'checkbox') value = el.checked
119
+ else if (el.type === 'number') {
120
+ if (el.value === '') return // keep the old value until something is typed
121
+ value = Number(el.value)
122
+ } else value = el.value
123
+ setPath(el.dataset.path, value)
124
+ renderSaveBar()
125
+ if (el.type === 'checkbox' || el.type === 'radio' || el.tagName === 'SELECT') renderAll()
126
+ }
127
+
128
+ // ---- rendering -----------------------------------------------------------
129
+
130
+ function renderAll () {
131
+ renderHeader()
132
+ renderSaveBar()
133
+ for (const v of ['live', 'calibration', 'settings']) $('view-' + v).hidden = v !== state.view
134
+ document.querySelectorAll('nav a').forEach(a => a.classList.toggle('current', a.dataset.view === state.view))
135
+ if (state.view === 'live') renderLive()
136
+ if (state.view === 'calibration') renderCalibration()
137
+ if (state.view === 'settings') renderSettings()
138
+ }
139
+
140
+ function renderHeader () {
141
+ const mode = state.saved ? state.saved.mode : null
142
+ const badge = $('mode')
143
+ badge.textContent = mode === 'replace' ? 'Replace' : mode === 'shadow' ? 'Shadow' : '--'
144
+ badge.classList.toggle('replace', mode === 'replace')
145
+ $('status').textContent = state.live ? (state.live.running ? state.live.status : 'Plugin not running') : ''
146
+ }
147
+
148
+ function renderSaveBar () {
149
+ $('saveBar').hidden = !(state.draft && (dirty() || $('saveErrors').textContent))
150
+ $('save').disabled = state.saving || !dirty()
151
+ $('revert').disabled = state.saving
152
+ }
153
+
154
+ // -- Live
155
+
156
+ // True wind straight from the inputs, with no corrections, for comparison.
157
+ function uncorrected (input) {
158
+ const a = input.awa * Math.PI / 180
159
+ const x = input.aws * Math.cos(a) - input.stw
160
+ const y = input.aws * Math.sin(a)
161
+ return { twa: Math.atan2(y, x) * 180 / Math.PI, tws: Math.hypot(x, y) }
162
+ }
163
+
164
+ // Where true heading came from, so a fallback variation is visible at a glance.
165
+ function headingNote (o) {
166
+ if (o.headingFrom === 'true heading') return 'from true heading'
167
+ const v = o.variation == null ? '' : ` ${Math.abs(o.variation).toFixed(1)}° ${o.variation < 0 ? 'W' : 'E'}`
168
+ return o.headingFrom === 'magnetic + fallback variation' ? `fallback variation${v}` : `variation${v} from GPS`
169
+ }
170
+
171
+ function tile (label, value, unit, raw) {
172
+ return `<div class="tile"><div class="label">${esc(label)}</div>` +
173
+ `<div class="value">${esc(value)}<span class="unit">${esc(unit)}</span></div>` +
174
+ `<div class="raw">${esc(raw || '')}</div></div>`
175
+ }
176
+
177
+ function renderLive () {
178
+ const live = state.live
179
+ const r = live && live.result
180
+ const old = !r || live.ageMs > STALE_MS
181
+ $('liveAge').textContent = r ? (old ? `last update ${Math.round(live.ageMs / 1000)} s ago` : '') : ''
182
+
183
+ if (!r) {
184
+ $('tiles').innerHTML = ''
185
+ $('steps').innerHTML = `<tr><td colspan="4" class="muted center">${esc(live ? live.status || 'Waiting for data…' : 'Waiting for data…')}</td></tr>`
186
+ } else {
187
+ const o = r.out
188
+ const u = uncorrected(r.input)
189
+ const side = v => v < 0 ? 'port' : 'stbd'
190
+ $('tiles').innerHTML = [
191
+ tile('TWA', fmt(Math.abs(o.twa)), `° ${side(o.twa)}`, `uncorrected ${fmt(Math.abs(u.twa))}°`),
192
+ tile('TWS', fmt(o.tws), 'kn', `uncorrected ${fmt(u.tws)} kn`),
193
+ tile('TWD', o.twd == null ? '--' : fmt(o.twd, 0), '° T', o.twd == null ? 'no heading' : headingNote(o)),
194
+ tile('STW', fmt(o.stw, 2), 'kn', `paddlewheel ${fmt(r.input.stw, 2)} kn`),
195
+ tile('Leeway', fmt(Math.abs(o.leeway)), `° ${side(o.leeway)}`, r.input.heel == null ? 'no heel' : `heel ${fmt(r.input.heel, 0)}°`),
196
+ tile('Current', o.current ? fmt(o.current.drift, 2) : '--', o.current ? `kn · ${fmt(o.current.set, 0)}°` : 'kn', o.current ? '' : 'no SOG/COG or heading')
197
+ ].join('')
198
+ $('steps').innerHTML = r.steps.map(s => s.enabled
199
+ ? `<tr><th scope="row">${esc(s.name)}</th><td class="mono">${esc(s.input || '')}</td><td>${esc(s.skipped ? 'skipped — ' + s.skipped : s.detail || '')}</td><td class="mono">${esc(s.output || '')}</td></tr>`
200
+ : `<tr class="off"><th scope="row">${esc(s.name)}</th><td></td><td>off</td><td></td></tr>`
201
+ ).join('')
202
+ }
203
+
204
+ const inputs = live ? Object.entries(live.inputs) : []
205
+ $('inputs').innerHTML = inputs.length
206
+ ? inputs.sort().map(([path, i]) => `<tr><td>${esc(path)}</td><td>${esc(i.source)}</td><td class="num ${i.ageMs > STALE_MS ? 'warn' : ''}">${fmt(i.ageMs / 1000)} s</td></tr>`).join('')
207
+ : '<tr><td colspan="3" class="muted center">Nothing received yet</td></tr>'
208
+ }
209
+
210
+ // -- Calibration
211
+
212
+ function correctionIds () {
213
+ const known = Object.keys(CORRECTIONS)
214
+ const all = Object.keys(state.draft.corrections)
215
+ return known.filter(id => all.includes(id)).concat(all.filter(id => !known.includes(id)))
216
+ }
217
+
218
+ function renderCalibration () {
219
+ if (!state.draft) return
220
+ const ids = correctionIds()
221
+ if (!ids.includes(state.selected)) state.selected = ids[0]
222
+ $('corrList').innerHTML = ids.map(id => {
223
+ const c = state.draft.corrections[id]
224
+ const name = (CORRECTIONS[id] || {}).name || id
225
+ return `<li class="${id === state.selected ? 'selected' : ''}">` +
226
+ `<button type="button" data-select="${esc(id)}" aria-pressed="${id === state.selected}">${esc(name)}</button>` +
227
+ `<label><input type="checkbox" data-path="corrections.${esc(id)}.enabled" ${c.enabled ? 'checked' : ''} aria-label="Enable ${esc(name)}"></label></li>`
228
+ }).join('')
229
+
230
+ const id = state.selected
231
+ const c = state.draft.corrections[id]
232
+ const meta = CORRECTIONS[id] || { name: id, what: '' }
233
+ let body = ''
234
+ if (id === 'stw') {
235
+ body = grid('Linearity (kn added)', 'STW kn', `corrections.stw.linearity`, 'stw', [['Add', 'add']], c.linearity) +
236
+ grid('Heel correction (kn added)', 'Heel °', `corrections.stw.heel`, 'heel', [['Add', 'add']], c.heel)
237
+ } else if (id === 'leeway') {
238
+ body = `<div class="fields">${field('K', 'corrections.leeway.k', c.k, 0.01)}</div>`
239
+ } else if (id === 'twa' || id === 'tws') {
240
+ const rows = [['Upwind', 'upwind'], ['Reach', 'reach'], ['Downwind', 'downwind']]
241
+ body = grid(id === 'twa' ? 'Degrees added to |TWA|' : 'Percent applied to TWS', 'TWS kn', `corrections.${id}`, 'tws', rows, c)
242
+ if (id === 'twa') {
243
+ const a = c.angles
244
+ body += `<div class="fields">${field('Upwind row to (° TWA)', 'corrections.twa.angles.upwind', a.upwind, 1)}` +
245
+ `${field('Reach row at (° TWA)', 'corrections.twa.angles.reach', a.reach, 1)}` +
246
+ `${field('Downwind row from (° TWA)', 'corrections.twa.angles.downwind', a.downwind, 1)}</div>`
247
+ }
248
+ }
249
+ $('corrEditor').innerHTML =
250
+ `<div class="panel-head"><h2>${esc(meta.name)}</h2><span class="state ${c.enabled ? 'on' : ''}">${c.enabled ? 'Enabled' : 'Disabled'}</span></div>` +
251
+ `<p class="what muted">${esc(meta.what)}</p>${body}` +
252
+ `<div><button type="button" class="ghost" data-defaults="${esc(id)}">Reset to defaults</button></div>`
253
+ }
254
+
255
+ // A table whose first row is its axis (editable) and whose other rows are values.
256
+ function grid (title, axisLabel, base, axisKey, rows, obj) {
257
+ const axis = obj[axisKey]
258
+ const cell = (path, v, cls) => `<td><input type="number" step="any" class="${cls || ''}" data-path="${esc(path)}" value="${esc(v)}"></td>`
259
+ return `<div><h2>${esc(title)}</h2><div class="table-wrap"><table class="grid">` +
260
+ `<tr><th scope="row" class="muted">${esc(axisLabel)}</th>${axis.map((v, i) => cell(`${base}.${axisKey}.${i}`, v, 'axis')).join('')}</tr>` +
261
+ rows.map(([label, key]) => `<tr><th scope="row">${esc(label)}</th>${obj[key].map((v, i) => cell(`${base}.${key}.${i}`, v)).join('')}</tr>`).join('') +
262
+ '</table></div></div>'
263
+ }
264
+
265
+ function field (label, path, value, step) {
266
+ return `<label class="field">${esc(label)}<input type="number" step="${step}" data-path="${esc(path)}" value="${esc(value)}"></label>`
267
+ }
268
+
269
+ // -- Settings
270
+
271
+ const MODES = [
272
+ ['shadow', 'Shadow', 'Publish to separate …Corrected paths. Your instruments and displays carry on as before.'],
273
+ ['replace', 'Replace', 'Publish on the standard paths. Rank this plugin first in Server → Source Priorities, and zero the instrument’s own corrections so nothing is corrected twice.']
274
+ ]
275
+
276
+ function renderSettings () {
277
+ if (!state.draft) return
278
+ $('modes').innerHTML = '<legend class="sr-only">Publish mode</legend>' + MODES.map(([id, name, desc]) =>
279
+ `<label class="mode ${state.draft.mode === id ? 'checked' : ''}"><input type="radio" name="mode" value="${id}" data-path="mode" ${state.draft.mode === id ? 'checked' : ''}>` +
280
+ `<div><b>${esc(name)}</b><span>${esc(desc)}</span></div></label>`).join('')
281
+ const v = $('variation')
282
+ if (document.activeElement !== v) v.value = state.draft.variation
283
+ renderSources()
284
+ }
285
+
286
+ function renderSources () {
287
+ const seen = (state.live && state.live.sourcesSeen) || {}
288
+ $('sources').innerHTML = Object.entries(SOURCE_LABELS).map(([key, label]) => {
289
+ const chosen = state.draft.sources[key] || ''
290
+ const opts = Array.from(new Set([...(seen[key] || []), chosen].filter(Boolean)))
291
+ return `<label>${esc(label)}<select data-path="sources.${key}">` +
292
+ `<option value="">Any</option>` +
293
+ opts.map(s => `<option value="${esc(s)}" ${s === chosen ? 'selected' : ''}>${esc(s)}${(seen[key] || []).includes(s) ? '' : ' (not seen)'}</option>`).join('') +
294
+ '</select></label>'
295
+ }).join('')
296
+ }
297
+
298
+ // Sources arrive with live data; refresh the list unless a dropdown is in use.
299
+ function renderSourcesIfIdle () {
300
+ if (!state.draft || (document.activeElement && document.activeElement.tagName === 'SELECT')) return
301
+ renderSources()
302
+ }
303
+
304
+ // ---- wiring ----------------------------------------------------------------
305
+
306
+ function route () {
307
+ const v = location.hash.replace('#', '')
308
+ state.view = ['live', 'calibration', 'settings'].includes(v) ? v : 'live'
309
+ renderAll()
310
+ }
311
+
312
+ document.addEventListener('change', onEdit)
313
+ document.addEventListener('click', e => {
314
+ const sel = e.target.closest('[data-select]')
315
+ if (sel) { state.selected = sel.dataset.select; renderCalibration() }
316
+ const def = e.target.closest('[data-defaults]')
317
+ if (def && state.defaults) {
318
+ const id = def.dataset.defaults
319
+ state.draft.corrections[id] = clone(state.defaults.corrections[id])
320
+ renderAll()
321
+ }
322
+ })
323
+ $('save').addEventListener('click', save)
324
+ $('revert').addEventListener('click', () => {
325
+ state.draft = clone(state.saved)
326
+ $('saveErrors').textContent = ''
327
+ renderAll()
328
+ })
329
+ window.addEventListener('hashchange', route)
330
+ window.addEventListener('beforeunload', e => { if (state.draft && dirty()) { e.preventDefault(); e.returnValue = '' } })
331
+
332
+ route()
333
+ loadConfig()
334
+ pollLive()
335
+ setInterval(pollLive, LIVE_MS)
336
+ })()
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 64 64" role="img" aria-label="Performance processor">
2
+ <rect width="64" height="64" rx="10" fill="#0d1117"/>
3
+ <!-- hull, seen from above, heading up -->
4
+ <path d="M32 9 C 41 19 41 39 37.5 54 H 26.5 C 23 39 23 19 32 9 Z" fill="none" stroke="#e6edf3" stroke-width="3" stroke-linejoin="round"/>
5
+ <!-- measured wind, from ahead to starboard -->
6
+ <line x1="56" y1="12" x2="36" y2="30" stroke="#8b949e" stroke-width="3" stroke-linecap="round" stroke-dasharray="4 4"/>
7
+ <!-- corrected true wind -->
8
+ <line x1="58" y1="24" x2="38" y2="34" stroke="#4cd97b" stroke-width="3.5" stroke-linecap="round"/>
9
+ <path d="M42.5 29.5 l-4.5 4.5 6 1.5" fill="none" stroke="#4cd97b" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
10
+ <!-- the correction between them -->
11
+ <path d="M51 16.5 A 12 12 0 0 1 54 26" fill="none" stroke="#f0b429" stroke-width="3" stroke-linecap="round"/>
12
+ <!-- leeway: slip to leeward -->
13
+ <path d="M14 46 H 22" stroke="#58a6ff" stroke-width="3" stroke-linecap="round"/>
14
+ <path d="M17.5 42.5 l-3.5 3.5 3.5 3.5" fill="none" stroke="#58a6ff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
15
+ </svg>
@@ -0,0 +1,87 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Performance Processor</title>
7
+ <link rel="icon" href="icon.svg" type="image/svg+xml">
8
+ <link rel="stylesheet" href="style.css">
9
+ </head>
10
+ <body>
11
+ <header>
12
+ <div class="title">
13
+ <h1>Performance Processor</h1>
14
+ <span id="mode" class="badge">--</span>
15
+ </div>
16
+ <nav aria-label="Sections">
17
+ <a href="#live" data-view="live">Live</a>
18
+ <a href="#calibration" data-view="calibration">Calibration</a>
19
+ <a href="#settings" data-view="settings">Settings</a>
20
+ </nav>
21
+ <div id="status" class="muted status"></div>
22
+ <div id="conn" class="warn" hidden></div>
23
+ </header>
24
+
25
+ <div id="saveBar" class="save-bar" hidden>
26
+ <span>Unsaved changes</span>
27
+ <div id="saveErrors" class="errors"></div>
28
+ <button id="revert" type="button" class="ghost">Revert</button>
29
+ <button id="save" type="button" class="primary">Save</button>
30
+ </div>
31
+
32
+ <main>
33
+ <section id="view-live" class="view" hidden>
34
+ <div id="tiles" class="tiles"></div>
35
+ <div class="panel">
36
+ <div class="panel-head"><h2>Pipeline</h2><span id="liveAge" class="muted sub"></span></div>
37
+ <div class="table-wrap">
38
+ <table>
39
+ <thead><tr><th>Step</th><th>In</th><th>Correction</th><th>Out</th></tr></thead>
40
+ <tbody id="steps"><tr><td colspan="4" class="muted center">Waiting for data…</td></tr></tbody>
41
+ </table>
42
+ </div>
43
+ </div>
44
+ <div class="panel">
45
+ <div class="panel-head"><h2>Inputs</h2></div>
46
+ <div class="table-wrap">
47
+ <table>
48
+ <thead><tr><th>Path</th><th>Source</th><th class="num">Age</th></tr></thead>
49
+ <tbody id="inputs"></tbody>
50
+ </table>
51
+ </div>
52
+ </div>
53
+ </section>
54
+
55
+ <section id="view-calibration" class="view" hidden>
56
+ <p class="muted sub intro">Corrections run top to bottom. Tick to enable; select one to edit its values.</p>
57
+ <div class="cal">
58
+ <ul id="corrList" class="corr-list" aria-label="Corrections"></ul>
59
+ <div id="corrEditor" class="panel corr-editor"></div>
60
+ </div>
61
+ </section>
62
+
63
+ <section id="view-settings" class="view" hidden>
64
+ <div class="panel">
65
+ <h2>Output</h2>
66
+ <fieldset id="modes" class="modes">
67
+ <legend class="sr-only">Publish mode</legend>
68
+ </fieldset>
69
+ </div>
70
+ <div class="panel">
71
+ <h2>Fallback magnetic variation</h2>
72
+ <p class="muted sub">Used only when nothing on the boat sends true heading or magnetic variation. Normally your GPS supplies variation and this is never used; the Live page shows which applies.</p>
73
+ <label class="field">Degrees east (west negative)
74
+ <input id="variation" type="number" step="0.1" data-path="variation">
75
+ </label>
76
+ </div>
77
+ <div class="panel">
78
+ <h2>Input sources</h2>
79
+ <p class="muted sub">Choose the instrument to read for each input. "Any" takes every source except this plugin. Pick your instruments, not another correcting plugin, so nothing is corrected twice.</p>
80
+ <div id="sources" class="sources"></div>
81
+ </div>
82
+ </section>
83
+ </main>
84
+
85
+ <script src="app.js"></script>
86
+ </body>
87
+ </html>
@@ -0,0 +1,208 @@
1
+ :root {
2
+ --bg: #0d1117;
3
+ --panel: #161b22;
4
+ --fg: #e6edf3;
5
+ --muted: #8b949e;
6
+ --line: #30363d;
7
+ --active: #1f3a5f;
8
+ --good: #4cd97b;
9
+ --bad: #ff6b6b;
10
+ --warn: #f0b429;
11
+ --warn-bg: #3d2e05;
12
+ --accent: #58a6ff;
13
+ --cell: #12202d;
14
+ }
15
+
16
+ @media (prefers-color-scheme: light) {
17
+ :root {
18
+ --bg: #ffffff;
19
+ --panel: #f6f8fa;
20
+ --fg: #1f2328;
21
+ --muted: #656d76;
22
+ --line: #d0d7de;
23
+ --active: #dbeafe;
24
+ --good: #1b7f3b;
25
+ --bad: #c62828;
26
+ --warn: #8a5a00;
27
+ --warn-bg: #fff4d6;
28
+ --accent: #0969da;
29
+ --cell: #eef4fb;
30
+ }
31
+ }
32
+
33
+ * { box-sizing: border-box; }
34
+ [hidden] { display: none !important; }
35
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
36
+
37
+ body {
38
+ margin: 0;
39
+ padding: 16px;
40
+ background: var(--bg);
41
+ color: var(--fg);
42
+ font-family: system-ui, -apple-system, sans-serif;
43
+ font-size: 18px;
44
+ }
45
+
46
+ header {
47
+ display: flex;
48
+ flex-wrap: wrap;
49
+ align-items: center;
50
+ gap: 8px 20px;
51
+ margin-bottom: 16px;
52
+ }
53
+ .title { display: flex; align-items: baseline; gap: 10px; }
54
+ h1 { margin: 0; font-size: 1.4em; }
55
+ h2 { margin: 0 0 10px; font-size: 0.85em; color: var(--muted); text-transform: uppercase; font-weight: 600; }
56
+ .status { width: 100%; font-size: 0.8em; }
57
+
58
+ nav { display: flex; gap: 4px; }
59
+ nav a {
60
+ padding: 10px 14px;
61
+ border: 1px solid var(--line);
62
+ border-radius: 8px;
63
+ color: var(--fg);
64
+ text-decoration: none;
65
+ font-size: 0.9em;
66
+ }
67
+ nav a:hover { border-color: var(--accent); }
68
+ nav a.current { background: var(--active); border-color: var(--accent); font-weight: 600; }
69
+
70
+ .badge {
71
+ font-size: 0.7em;
72
+ font-weight: 700;
73
+ padding: 3px 8px;
74
+ border-radius: 999px;
75
+ background: var(--warn-bg);
76
+ color: var(--warn);
77
+ text-transform: uppercase;
78
+ }
79
+ .badge.replace { background: var(--active); color: var(--accent); }
80
+
81
+ .muted { color: var(--muted); }
82
+ .sub { font-size: 0.8em; }
83
+ .warn { color: var(--warn); font-weight: 600; }
84
+ .center { text-align: center; }
85
+ .num { text-align: right; font-variant-numeric: tabular-nums; }
86
+ .intro { margin: 0 0 12px; }
87
+
88
+ main { display: flex; flex-direction: column; gap: 16px; }
89
+ .view { display: flex; flex-direction: column; gap: 16px; }
90
+
91
+ .panel { border: 1px solid var(--line); border-radius: 8px; padding: 12px; background: var(--panel); }
92
+ .panel-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
93
+
94
+ /* Live */
95
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
96
+ .tile { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; background: var(--panel); }
97
+ .tile .label { font-size: 0.7em; color: var(--muted); text-transform: uppercase; font-weight: 600; }
98
+ .tile .value { font-size: 1.9em; font-weight: 600; font-variant-numeric: tabular-nums; }
99
+ .tile .unit { font-size: 0.5em; color: var(--muted); font-weight: 400; margin-left: 4px; }
100
+ .tile .raw { font-size: 0.7em; color: var(--muted); font-variant-numeric: tabular-nums; }
101
+
102
+ .table-wrap { overflow-x: auto; }
103
+ table { width: 100%; border-collapse: collapse; }
104
+ th, td { padding: 8px; border-bottom: 1px solid var(--line); white-space: nowrap; text-align: left; }
105
+ th { font-size: 0.75em; color: var(--muted); text-transform: uppercase; }
106
+ tr.off td { color: var(--muted); }
107
+ td.mono { font-variant-numeric: tabular-nums; }
108
+
109
+ /* Calibration */
110
+ .cal { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; }
111
+ .corr-list {
112
+ list-style: none;
113
+ margin: 0;
114
+ padding: 4px 0;
115
+ flex: 1 1 240px;
116
+ border: 1px solid var(--line);
117
+ border-radius: 8px;
118
+ background: var(--panel);
119
+ }
120
+ .corr-list li { display: flex; align-items: center; }
121
+ .corr-list li.selected { background: var(--active); box-shadow: inset 3px 0 0 var(--accent); }
122
+ .corr-list button {
123
+ flex: 1;
124
+ min-height: 48px;
125
+ padding: 6px 12px;
126
+ background: transparent;
127
+ color: var(--fg);
128
+ border: 0;
129
+ text-align: left;
130
+ font: inherit;
131
+ font-weight: 600;
132
+ cursor: pointer;
133
+ }
134
+ .corr-list label { display: flex; align-items: center; justify-content: center; width: 52px; min-height: 48px; cursor: pointer; }
135
+ .corr-list input, .modes input { width: 20px; height: 20px; accent-color: var(--accent); }
136
+
137
+ .corr-editor { flex: 999 1 520px; min-width: 0; display: flex; flex-direction: column; gap: 12px; }
138
+ .corr-editor h2 { font-size: 1.1em; color: var(--fg); text-transform: none; margin: 0; }
139
+ .corr-editor .what { margin: 0; font-size: 0.85em; }
140
+ .state { font-size: 0.8em; font-weight: 600; color: var(--muted); }
141
+ .state.on { color: var(--good); }
142
+
143
+ .grid input, .field input, .sources select {
144
+ font: inherit;
145
+ font-size: 0.9em;
146
+ background: var(--bg);
147
+ color: var(--fg);
148
+ border: 1px solid var(--line);
149
+ border-radius: 6px;
150
+ padding: 6px 8px;
151
+ }
152
+ .grid { border-collapse: separate; border-spacing: 4px; width: auto; }
153
+ .grid th, .grid td { border: 0; padding: 0; }
154
+ .grid th { padding: 0 6px; }
155
+ .grid input { width: 4.6em; text-align: right; font-variant-numeric: tabular-nums; background: var(--cell); }
156
+ .grid input.axis { background: var(--bg); color: var(--muted); }
157
+ .grid input:focus, .field input:focus { outline: 2px solid var(--accent); outline-offset: 0; }
158
+ .fields { display: flex; flex-wrap: wrap; gap: 16px; }
159
+ .field { display: flex; flex-direction: column; gap: 4px; font-size: 0.8em; font-weight: 600; }
160
+ .field input { width: 7em; }
161
+
162
+ /* Settings */
163
+ .modes { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
164
+ .mode { display: flex; gap: 12px; align-items: flex-start; padding: 10px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; }
165
+ .mode.checked { border-color: var(--accent); background: var(--active); }
166
+ .mode b { display: block; }
167
+ .mode span { font-size: 0.8em; color: var(--muted); }
168
+ .sources { display: flex; flex-direction: column; }
169
+ .sources label { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid var(--line); }
170
+ .sources select { min-width: 12em; min-height: 40px; }
171
+
172
+ /* Save bar stays in view while editing */
173
+ .save-bar {
174
+ position: sticky;
175
+ top: 0;
176
+ z-index: 2;
177
+ display: flex;
178
+ flex-wrap: wrap;
179
+ align-items: center;
180
+ gap: 8px 12px;
181
+ padding: 8px 12px;
182
+ margin-bottom: 12px;
183
+ border: 1px solid var(--warn);
184
+ border-radius: 8px;
185
+ background: var(--warn-bg);
186
+ color: var(--warn);
187
+ font-weight: 600;
188
+ }
189
+ .save-bar .errors { flex: 1; font-weight: 400; font-size: 0.8em; color: var(--bad); }
190
+ button.primary, button.ghost {
191
+ min-height: 44px;
192
+ padding: 0 16px;
193
+ font: inherit;
194
+ font-size: 0.9em;
195
+ font-weight: 600;
196
+ border-radius: 8px;
197
+ cursor: pointer;
198
+ }
199
+ button.primary { background: var(--accent); color: var(--bg); border: 1px solid var(--accent); }
200
+ button.ghost { background: transparent; color: var(--fg); border: 1px solid var(--line); }
201
+ button:disabled { opacity: 0.5; cursor: default; }
202
+
203
+ @media (max-width: 600px) {
204
+ body { font-size: 16px; padding: 10px; }
205
+ th, td { padding: 6px 5px; }
206
+ .tile .value { font-size: 1.5em; }
207
+ .grid input { width: 3.8em; }
208
+ }