sailkick-boat 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,8 +52,13 @@ files to send later — nothing is lost in the gap.
52
52
 
53
53
  - Network errors, `429` and `5xx` are retried with backoff (1 s → 60 s); the data stays
54
54
  on disk.
55
- - A `4xx` is quarantined to `spool/dead/` rather than retried forever, because a
56
- malformed or unauthorised batch would otherwise wedge the queue behind it.
55
+ - A malformed batch (`4xx` other than the three below) is quarantined to `spool/dead/`
56
+ rather than retried forever, because it would otherwise wedge the queue behind it.
57
+ - **`404`, `401` and `403` are held, not quarantined.** A missing bucket or a rejected
58
+ token rejects *every* batch identically, so quarantining would pour the whole telemetry
59
+ stream into `spool/dead/` while the plugin looked busy. Instead the spool is left
60
+ intact, the status line names the setting to fix, and retries continue slowly so a
61
+ correction recovers on its own.
57
62
  - The buffer is bounded (500 MB). On overflow the **oldest** files are dropped and
58
63
  logged — a long-offline boat fills its own disk otherwise.
59
64
  - Timestamps are nanosecond-precise, so replaying after a reconnect overwrites rather
@@ -257,8 +262,12 @@ worth designing properly rather than guessing at, so for now they are simply sep
257
262
  ## Local history (offline Trends + track)
258
263
  **One source: a live ring**, sampled from the same BoatState that feeds `/ws/telemetry`
259
264
  — no database, works on a Victron GX with nothing else installed. `historyAvailable` is
260
- forced on so the app shows the Trends panel, and the eight channels match what the cloud
261
- serves, `stw` included.
265
+ forced on so the app shows the Trends panel, and the **nineteen channels match what the
266
+ cloud serves** — wind (true/apparent, speed/angle/direction), SOG/STW/COG/heading, depth,
267
+ VMG, sea and air temperature, port and starboard revs, and the four active-waypoint
268
+ values. So an instrument cell's history flyout shows the same thing whichever provider is
269
+ answering. A channel with no data is simply absent: a boat with no paddlewheel has no
270
+ `stw`, and the waypoint channels appear only while a destination is active.
262
271
 
263
272
  There is deliberately **no way to point this at a local InfluxDB**. Until v0.15.0 a read
264
273
  token did exactly that, and it was a trap: the app only ever asks for a *relative* window
@@ -424,12 +433,24 @@ online boat is never worse off than before.
424
433
  is needed — see above). The signup screen shows your boat's ingest credentials.
425
434
  2. **Copy the "Write token"** — it is shown **once** and cannot be recovered. If you
426
435
  lose it you need a new one minted.
427
- 3. In Plugin Config, fill the **Sailkick account** section — **boat name + write
428
- token** — and save.
429
-
430
- That is the whole handshake. The plugin resolves everything else locally (`bucket` =
431
- `<slug>_raw`, `org` = `sailkick`) and never calls the app for configuration, so setup
432
- works with no internet and there is nothing to re-fetch after a restart.
436
+ 3. In Plugin Config, fill the **Sailkick account** section — **write token**, plus
437
+ either the **boat name** or the **data bucket** — and save.
438
+
439
+ That is the whole handshake. The plugin resolves everything else locally (`org` =
440
+ `sailkick`) and never calls the app for configuration, so setup works with no internet
441
+ and there is nothing to re-fetch after a restart.
442
+
443
+ **Data bucket.** Leave it blank and the bucket is derived as `<boat name>_raw`, which is
444
+ what older accounts use. Newer accounts are identified by a UUID instead — their bucket
445
+ looks like `1fcad258-c422-4e93-a6f9-6811938499f6_raw` — so paste that into **Data
446
+ bucket** and the boat name becomes optional. If your bucket is ever renamed, this is the
447
+ one field to change: the write token survives a rename, because InfluxDB scopes tokens by
448
+ bucket *id* rather than by name.
449
+
450
+ A bucket that does not exist is treated as a **configuration error, not bad data**:
451
+ telemetry is held on disk, nothing is quarantined, the status line reads
452
+ `sync: HELD — bucket "…" not found`, and it sends itself as soon as the name is
453
+ corrected — no restart needed. The same applies to a rejected token.
433
454
 
434
455
  > **Upgrading from before 0.14?** Old versions had visible `influxUrl` / `sailkickUrl`
435
456
  > fields. Whatever was typed into them is still in your saved config, and since 0.14.4
package/index.js CHANGED
@@ -121,8 +121,9 @@ module.exports = function (app) {
121
121
  title: 'Sailkick account',
122
122
  description: 'Register your boat at www.sailkick.io first — the signup screen shows a write token. Paste it here together with your boat name. Keep that token safe: it is shown only once and cannot be recovered.',
123
123
  properties: {
124
- slug: { type: 'string', title: 'Boat name', description: 'Exactly as registered on the website.' },
125
- writeToken: { type: 'string', title: 'Write token', description: 'From the signup screen ("Write token"). Everything else — Influx URL, organization, bucket — is derived from it and your boat name.' }
124
+ slug: { type: 'string', title: 'Boat name', description: 'Exactly as registered on the website. Newer accounts are identified by a data bucket instead — if you have one, fill that in below and leave this blank.' },
125
+ writeToken: { type: 'string', title: 'Write token', description: 'From the signup screen ("Write token"). The Influx URL and organization are fleet-wide and set for you.' },
126
+ bucket: { type: 'string', title: 'Data bucket', description: 'Leave blank to derive it from the boat name. Set it when your account has an explicit bucket — newer ones are a UUID, e.g. 1fcad258-c422-4e93-a6f9-6811938499f6_raw. Copy it exactly; a bucket that does not exist stops sync rather than losing data, and the status line will say so.' }
126
127
  }
127
128
  },
128
129
  sync: {
@@ -191,7 +192,9 @@ module.exports = function (app) {
191
192
  // Purely local: the boat registers on the website, the owner pastes the write
192
193
  // token here. Nothing to fetch, so nothing that can fail while offline.
193
194
  const r = resolveAccountConfig(app, opts.account)
194
- if (r.bundle) accountStatus = `account: ${r.bundle.slug}`
195
+ // Name the BUCKET, not just the boat: a bucket that no longer exists (a rename, a
196
+ // typo) is the one misconfiguration that stops sync dead, and it was invisible here.
197
+ if (r.bundle) accountStatus = `account: ${r.bundle.slug || '(no name)'} -> ${r.bundle.bucket}`
195
198
  else if (r.error) accountStatus = `account: ${r.error}`
196
199
  else accountStatus = 'account: not configured — register at www.sailkick.io, then paste your write token'
197
200
  startModules(opts, r.bundle)
@@ -8,10 +8,19 @@
8
8
  // is the whole handshake. The plugin deliberately does NOT sign up on its own —
9
9
  // one registration path means no half-created accounts and no spent-invite dead ends.
10
10
  //
11
- // Everything else about the bundle is derivable, so only two fields are asked for:
11
+ // Everything else about the bundle WAS derivable, so only two fields used to be asked for:
12
12
  // bucket = "<slug>_raw" (the app's convention — server/auth/registry.js bucketFor)
13
13
  // org = "sailkick" (single org, fleet-wide)
14
14
  //
15
+ // That derivation is no longer reliable. The fleet is moving boat identity from a slug to
16
+ // a UUID, and buckets are being renamed with it — this boat went from "addiction_raw" to
17
+ // "1fcad258-c422-4e93-a6f9-6811938499f6_raw" while keeping the same token, because
18
+ // InfluxDB scopes tokens by bucket ID and a rename preserves it. So `bucket` is now an
19
+ // explicit field: blank keeps the old derivation (every pre-existing install is
20
+ // untouched), set wins verbatim. The bucket is the actual contract with InfluxDB, so it
21
+ // is the thing worth being able to state directly — it survives whatever identity the
22
+ // fleet settles on.
23
+ //
15
24
  // This bundle deliberately carries NO Influx URL. "Which boat am I" and "where is the
16
25
  // cloud" are separate questions, and the second one has a single fleet-wide answer that
17
26
  // index.js owns. Letting a pasted or cached value set the host is how telemetry ends up
@@ -56,15 +65,20 @@ const clean = (v) => String(v == null ? '' : v).trim()
56
65
  function resolveAccountConfig (app, account) {
57
66
  const acct = account || {}
58
67
  const slug = clean(acct.slug).toLowerCase()
68
+ // NOT lowercased: a bucket name is taken verbatim from whatever the server called it.
69
+ const bucket = clean(acct.bucket)
59
70
  const writeToken = clean(acct.writeToken)
60
71
 
61
- if (slug && writeToken) {
72
+ // Either identifier will do. A boat given an explicit bucket needs no slug at all —
73
+ // newer accounts are issued a UUID bucket and may never see a slug — while an older
74
+ // install that only has a boat name keeps deriving as before.
75
+ if (writeToken && (slug || bucket)) {
62
76
  return {
63
77
  source: 'config',
64
78
  bundle: {
65
79
  slug,
66
80
  org: clean(acct.org) || DEFAULT_ORG,
67
- bucket: clean(acct.bucket) || `${slug}_raw`,
81
+ bucket: bucket || `${slug}_raw`,
68
82
  writeToken
69
83
  }
70
84
  }
@@ -74,8 +88,8 @@ function resolveAccountConfig (app, account) {
74
88
  if (cached) return { bundle: cached, source: 'cache' }
75
89
 
76
90
  // Half-filled: say which half, so the status line is actionable.
77
- if (writeToken) return { bundle: null, source: null, error: 'boat name is missing' }
78
- if (slug) return { bundle: null, source: null, error: 'write token is missing' }
91
+ if (writeToken) return { bundle: null, source: null, error: 'boat name or data bucket is missing' }
92
+ if (slug || bucket) return { bundle: null, source: null, error: 'write token is missing' }
79
93
  return { bundle: null, source: null }
80
94
  }
81
95
 
package/lib/ais/index.js CHANGED
@@ -205,7 +205,12 @@ function createAis (app, options) {
205
205
  if (!body.trim()) { await state.spool.remove(file); continue }
206
206
  const res = await writeLines({ influxUrl: cfg.influxUrl, org: cfg.org, bucket: cfg.bucket, token: cfg.token, timeoutMs: cfg.requestTimeoutMs }, body)
207
207
  if (res.ok) {
208
- await state.spool.remove(file); state.lastOkAt = new Date(); state.backoff = cfg.retryMinMs
208
+ await state.spool.remove(file); state.lastOkAt = new Date(); state.backoff = cfg.retryMinMs; state.configError = null
209
+ } else if (res.configError) {
210
+ // Same rule as telemetry: a missing bucket or bad token rejects everything, so
211
+ // hold rather than quarantine and let a corrected config drain the spool.
212
+ state.configError = res.status === 404 ? `bucket "${cfg.bucket}" not found` : `token rejected (HTTP ${res.status})`
213
+ state.pumping = false; scheduleRetry(res); return
209
214
  } else if (res.retryable) {
210
215
  state.pumping = false; scheduleRetry(res); return
211
216
  } else {
@@ -238,6 +243,10 @@ function createAis (app, options) {
238
243
  }
239
244
  function refreshStatus (suffix) {
240
245
  if (!state) return
246
+ if (state.configError) {
247
+ state.statusLine = `ais: HELD — ${state.configError}; ${state.forwarded} point(s) on disk`
248
+ return
249
+ }
241
250
  state.statusLine = `ais: ${state.targets.size} target(s), ${state.forwarded} point(s)${state.dropped ? `, ${state.dropped} skipped` : ''}${suffix ? '; ' + suffix : ''}`
242
251
  }
243
252
 
@@ -227,7 +227,11 @@ function createBackfill (app, options) {
227
227
  const res = await writeLines({ influxUrl: cfg.dst.url, org: cfg.dst.org, bucket: cfg.dst.bucket, token: cfg.dst.token, timeoutMs: cfg.queryTimeoutMs }, body)
228
228
  if (!res.ok) {
229
229
  // 4xx is fatal: bad credentials or malformed data, and retrying cannot fix it.
230
- if (!res.retryable) { warn(`write REJECTED (HTTP ${res.status}) — ${res.body ? String(res.body).slice(0, 160) : 'no detail'}`); return 'fatal' }
230
+ if (!res.retryable) {
231
+ // Name the destination: after a bucket rename this is the first thing to check.
232
+ warn(`write to ${cfg.dst.bucket} REJECTED (HTTP ${res.status}) — ${res.body ? String(res.body).slice(0, 160) : 'no detail'}`)
233
+ return 'fatal'
234
+ }
231
235
  warn(`write ${iso(startMs)} failed — ${res.status ? 'HTTP ' + res.status : 'unreachable'}; will retry`)
232
236
  return 'retry'
233
237
  }
@@ -21,6 +21,23 @@ const fsp = fs.promises
21
21
  const path = require('path')
22
22
 
23
23
  const DEG = Math.PI / 180
24
+
25
+ // The channel set the Trends panel and the instrument history flyouts expect. It must
26
+ // match the cloud's provider (sailkick server/history/influx-provider.js MAP + the
27
+ // three active-waypoint prefixes), so the same cell shows the same history whether the
28
+ // boat or the cloud is answering.
29
+ //
30
+ // This drifted once already: it was frozen at the eight channels that existed before
31
+ // v0.18.6, while the app grew cells for true wind angle, VMG, temperatures, engine revs
32
+ // and the four waypoint values — and `cog` was sampled into the ring but left out of the
33
+ // output entirely. Eleven of nineteen cells had a live number and an empty flyout.
34
+ // test/history-ring.test.js pins this list against the app's own channel names.
35
+ const CHANNELS = [
36
+ 'tws', 'twd', 'aws', 'awa', 'twa', 'vmg',
37
+ 'sog', 'stw', 'cog', 'heading', 'depth',
38
+ 'seaTemp', 'airTemp', 'rpmPort', 'rpmStbd',
39
+ 'wptBrg', 'wptDist', 'wptVmg', 'wptTtg'
40
+ ]
24
41
  const wrap360 = (d) => ((d % 360) + 360) % 360
25
42
  const MAX_SAMPLES = 50000
26
43
 
@@ -51,7 +68,7 @@ function trueWind (s) {
51
68
  class RingHistoryProvider {
52
69
  constructor ({ source, sampleSec, windowSec, persistFile } = {}) {
53
70
  this._source = source
54
- this._ring = [] // [{ t, sog, cog, heading, aws, awa, depth, tws, twd, lat, lon }]
71
+ this._ring = [] // [{ t, ...CHANNELS, lat, lon }]
55
72
  const win = windowSec || 3600
56
73
  this._windowMs = win * 1000
57
74
  this._persistFile = persistFile || null
@@ -80,6 +97,16 @@ class RingHistoryProvider {
80
97
  stw: num(s.stwKt),
81
98
  aws: num(s.awsKt), awa: num(s.awaDeg), depth: num(s.depthM),
82
99
  tws: tw ? tw.tws : null, twd: tw ? tw.twd : null,
100
+ // Everything below arrived in BoatState with v0.18.6 but was never sampled here,
101
+ // so these instrument cells had a live value and an empty history flyout.
102
+ twa: num(s.twaDeg), vmg: num(s.vmgKt),
103
+ seaTemp: num(s.seaTempC), airTemp: num(s.airTempC),
104
+ rpmPort: num(s.rpmPort), rpmStbd: num(s.rpmStbd),
105
+ // Waypoint channels are legitimately null when no destination is active — num()
106
+ // maps that to null and CHANNELS omits empty ones, so the flyout stays blank
107
+ // rather than showing a flat line at zero.
108
+ wptBrg: num(s.wptBrgDeg), wptDist: num(s.wptDistNm),
109
+ wptVmg: num(s.wptVmgKt), wptTtg: num(s.wptTtgSec),
83
110
  lat: num(s.lat), lon: num(s.lon)
84
111
  }
85
112
  this._ring.push(row)
@@ -97,12 +124,8 @@ class RingHistoryProvider {
97
124
 
98
125
  getSeries ({ windowSec } = {}) {
99
126
  const rows = this._window(windowSec)
100
- // Matches the InfluxDB provider's channel set exactly (lib/history/index.js MAP), so
101
- // the Trends panel looks the same whichever provider is serving. Empty channels are
102
- // omitted, so a boat with no paddlewheel simply has no `stw` key.
103
- const chans = ['tws', 'twd', 'aws', 'awa', 'sog', 'stw', 'heading', 'depth']
104
127
  const series = {}
105
- for (const c of chans) {
128
+ for (const c of CHANNELS) {
106
129
  const pts = rows.filter((r) => r[c] != null).map((r) => [r.t, r[c]])
107
130
  if (pts.length) series[c] = pts
108
131
  }
@@ -179,4 +202,4 @@ class RingHistoryProvider {
179
202
  }
180
203
  }
181
204
 
182
- module.exports = { RingHistoryProvider, MAX_SAMPLES }
205
+ module.exports = { RingHistoryProvider, MAX_SAMPLES, CHANNELS }
package/lib/sync/index.js CHANGED
@@ -66,6 +66,27 @@ function createSync (app, options) {
66
66
  if (state.failing) warn(`recovered — writing to ${cfg.influxUrl} again after ${state.failCount} failed attempt(s)`)
67
67
  state.failing = false
68
68
  state.failCount = 0
69
+ state.configError = null
70
+ }
71
+
72
+ // A wrong bucket or token rejects every batch identically, so this is a standing
73
+ // condition rather than an outage: name the exact setting to fix, once, and keep the
74
+ // reason on the status line until it clears.
75
+ const noteConfigError = (res) => {
76
+ state.failCount++
77
+ const notFound = res.status === 404
78
+ state.configError = notFound
79
+ ? `bucket "${cfg.bucket}" not found`
80
+ : `write token rejected (HTTP ${res.status})`
81
+ const now = Date.now()
82
+ if (state.failing && now - state.lastWarnAt < WARN_REPEAT_MS) return
83
+ state.failing = true
84
+ state.lastWarnAt = now
85
+ warn(`sync HELD — ${state.configError}. ` +
86
+ (notFound
87
+ ? 'Set "Data bucket" in the plugin config to the bucket your account actually uses — newer accounts use a UUID. '
88
+ : 'Check the write token, and that it is valid for this bucket. ') +
89
+ 'Telemetry is kept on disk (nothing quarantined) and will send itself once this is corrected.')
69
90
  }
70
91
 
71
92
  const handleDelta = (delta) => {
@@ -100,13 +121,21 @@ function createSync (app, options) {
100
121
  if (res.ok) {
101
122
  await spool.remove(file); state.lastOkAt = new Date(); state.backoff = state.retryMin
102
123
  noteSuccess()
124
+ } else if (res.configError) {
125
+ // The settings are wrong, not this batch — every batch would be rejected the
126
+ // same way. Quarantining here would pour the entire telemetry stream into
127
+ // spool/dead/ while the plugin looked busy. Hold everything on disk, say so
128
+ // once, and keep retrying slowly so a corrected bucket or token recovers
129
+ // without a restart.
130
+ noteConfigError(res)
131
+ state.pumping = false; scheduleRetry(res); return
103
132
  } else if (res.retryable) {
104
133
  noteFailure(res)
105
134
  state.pumping = false; scheduleRetry(res); return
106
135
  } else {
107
- // 4xx: bad credentials or malformed data. Never retried, so if this is not
108
- // loud the batch is simply gone.
109
- warn(`batch REJECTED (HTTP ${res.status}) and quarantined to ${state.spool.deadDir} — ${res.status === 401 ? 'the write token is not valid for this bucket' : 'check bucket/org and the data'}`)
136
+ // Other 4xx: malformed data. Never retried, so if this is not loud the batch
137
+ // is simply gone.
138
+ warn(`batch REJECTED (HTTP ${res.status}) and quarantined to ${state.spool.deadDir} — check the data`)
110
139
  await spool.quarantine(file)
111
140
  }
112
141
  }
@@ -133,7 +162,13 @@ function createSync (app, options) {
133
162
  try {
134
163
  const { count, bytes } = await spool.stats()
135
164
  const last = state.lastOkAt ? state.lastOkAt.toISOString() : 'never'
136
- state.statusLine = `sync: buffered ${count}f/${Math.round(bytes / 1024)}KB; last ok ${last}${suffix ? '; ' + suffix : ''}`
165
+ // A config error is the headline — it needs the owner, not just time.
166
+ const head = state.configError
167
+ ? `sync: HELD — ${state.configError}; fix the plugin config`
168
+ : `sync: buffered ${count}f/${Math.round(bytes / 1024)}KB; last ok ${last}`
169
+ state.statusLine = state.configError
170
+ ? `${head}; ${count}f/${Math.round(bytes / 1024)}KB held on disk`
171
+ : `${head}${suffix ? '; ' + suffix : ''}`
137
172
  } catch {}
138
173
  }
139
174
 
@@ -4,12 +4,22 @@
4
4
  //
5
5
  // Return shape:
6
6
  // { ok: true, status: 204 }
7
- // { ok: false, retryable: true, networkError: true } connection failed
8
- // { ok: false, retryable: true, status } 429 / 5xx (transient)
9
- // { ok: false, retryable: false, status, body } 4xx (bad data/auth)
7
+ // { ok: false, retryable: true, networkError: true } connection failed
8
+ // { ok: false, retryable: true, status } 429 / 5xx (transient)
9
+ // { ok: false, configError: true, status, body } 404 / 401 / 403
10
+ // { ok: false, retryable: false, status, body } other 4xx (bad data)
10
11
  //
11
- // 4xx is non-retryable: retrying a malformed or unauthorized batch forever would
12
- // wedge the queue, so the caller quarantines it instead.
12
+ // Most 4xx is non-retryable: retrying a malformed batch forever would wedge the queue,
13
+ // so the caller quarantines it instead.
14
+ //
15
+ // 404/401/403 are different in kind and must NOT be quarantined. They say "your settings
16
+ // are wrong", not "this batch is bad" — a missing bucket or a bad token rejects EVERY
17
+ // batch equally, so quarantining would feed the whole telemetry stream into spool/dead/
18
+ // while looking like normal operation. That is not hypothetical: the fleet renamed this
19
+ // boat's bucket from `addiction_raw` to a UUID, and a plugin left running through it
20
+ // would have shredded every point it collected. The caller holds these on disk instead
21
+ // and keeps retrying slowly, so correcting the config recovers on its own.
22
+ const CONFIG_ERROR_STATUS = new Set([401, 403, 404])
13
23
 
14
24
  const zlib = require('zlib')
15
25
 
@@ -42,6 +52,9 @@ async function writeLines (cfg, body) {
42
52
 
43
53
  let text = ''
44
54
  try { text = await res.text() } catch {}
55
+ if (CONFIG_ERROR_STATUS.has(res.status)) {
56
+ return { ok: false, configError: true, retryable: false, status: res.status, body: text }
57
+ }
45
58
  const retryable = res.status === 429 || res.status >= 500
46
59
  return { ok: false, retryable, status: res.status, body: text }
47
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Run the sailkick app on board with no internet: charts, weather, climatology, trends and AIS all served from the boat itself. With a sailkick account it also syncs your metrics to the cloud in real time. Alpha, invite-only \u2014 info@sailkick.io",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -9,6 +9,9 @@
9
9
  "keywords": [
10
10
  "signalk-node-server-plugin",
11
11
  "signalk-webapp",
12
+ "signalk-category-chart-plotters",
13
+ "signalk-category-instruments",
14
+ "signalk-category-ais",
12
15
  "signalk",
13
16
  "sailkick",
14
17
  "influxdb",
@@ -17,6 +20,10 @@
17
20
  "proxy"
18
21
  ],
19
22
  "signalk-plugin-enabled-by-default": true,
23
+ "signalk": {
24
+ "appIcon": "./icon.png",
25
+ "displayName": "Sailkick"
26
+ },
20
27
  "author": "lauchat",
21
28
  "license": "MIT",
22
29
  "repository": {
Binary file
package/public/index.html CHANGED
@@ -27,7 +27,8 @@
27
27
  body { margin:0; padding:2rem 1rem; background:var(--bg); color:var(--fg);
28
28
  font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif }
29
29
  main { max-width:44rem; margin:0 auto }
30
- h1 { margin:0 0 .25rem; font-size:1.5rem; letter-spacing:-.01em }
30
+ h1 { margin:0 0 .25rem; font-size:1.5rem; letter-spacing:-.01em; display:flex; align-items:center; gap:.5rem }
31
+ h1 img { border-radius:.35rem }
31
32
  .sub { color:var(--mut); margin:0 0 1.5rem }
32
33
  .grid { display:grid; gap:1rem; grid-template-columns:repeat(auto-fit,minmax(15rem,1fr)) }
33
34
  a.card { display:block; padding:1.1rem 1.2rem; background:var(--card); border:1px solid var(--line);
@@ -44,7 +45,7 @@
44
45
  </head>
45
46
  <body>
46
47
  <main>
47
- <h1>Sailkick</h1>
48
+ <h1><img src="icon.png" alt="" width="28" height="28">Sailkick</h1>
48
49
  <p class="sub">Charts, instruments and AIS, served from this boat.</p>
49
50
 
50
51
  <div class="grid">