@ham2k/extension-sdk 0.2.0 → 0.3.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.
@@ -26,6 +26,10 @@ function opValues(operation, extra = {}) {
26
26
  return {
27
27
  ...atMillis > 0 ? dateValues(atMillis) : { date: "", dateCompact: "", time: "", at: "" },
28
28
  station,
29
+ // `call` says the same thing, for symmetry with `qso.call` — the name an
30
+ // operator writing a CW message reaches for first; without it,
31
+ // `{{ op.call }}` renders empty with nothing to say why.
32
+ call: station,
29
33
  // Deduped, like halo_core's `Operation.stationCalls`: "N0DEV, N0DEV" is a
30
34
  // typo, and a template fanning out over this would otherwise repeat
31
35
  // itself once per typo.
@@ -47,7 +47,7 @@ The same manifest a built-in extension carries, plus `api`:
47
47
  it does not speak, so an older app meeting a newer bundle says so instead of
48
48
  failing somewhere deep in a hook.
49
49
 
50
- Two fields are **refused** in a distributed bundle, and it is worth
50
+ Three fields are **refused** in a distributed bundle, and it is worth
51
51
  understanding why:
52
52
 
53
53
  - **No `category`** makes an extension *core* to the host: always enabled, and
@@ -57,6 +57,79 @@ understanding why:
57
57
  - **`experiments`** gates availability against the *app's* experiment catalog.
58
58
  A key the host doesn't define hides the extension permanently, with nothing
59
59
  in the UI to explain it.
60
+ - **`enabledByDefault`** is a shipping app's answer for the extensions it
61
+ carries whether the operator asked for them or not. An installed bundle is
62
+ one the operator went and got, so the answer is always yes, and a manifest
63
+ saying otherwise describes a state it cannot reach.
64
+
65
+ **`geo` is now `relevance`**, and a manifest still carrying the old name is
66
+ refused at packing and at publishing rather than ignored. Nothing reads
67
+ `geo`, and no check anywhere looks at unknown top-level keys, so accepting it
68
+ would publish a regional extension as a worldwide one and tell its author
69
+ nothing. Rename the key; the four geographic lists inside it are unchanged.
70
+
71
+ The app itself does NOT refuse either of these at install. The packer and the
72
+ catalog answer to the author, who can fix the manifest and publish again;
73
+ refusing at the door tells the operator instead, who can do nothing about a
74
+ published manifest but go without an extension that would have worked — and
75
+ every bundle published before these rules existed carries one. Neither costs
76
+ them anything: an installed extension is enabled because they installed it,
77
+ and an unread `geo` makes it worldwide rather than regional. What the app
78
+ refuses is what it cannot run.
79
+
80
+ ### Relevance
81
+
82
+ `relevance` says where, when and for whom the extension matters. The catalog
83
+ ranks its listing by it, for the operators the extension was written for, and
84
+ the Extensions panel's Catalog section shows the catalog's rows in that
85
+ order; nothing hides an extension over it, and a manifest without one is
86
+ worldwide and undated. The build index carries `relevance` for the catalog
87
+ and for later use; the panel does not rank the app's own extensions by it.
88
+
89
+ ```json
90
+ "relevance": {
91
+ "countries": ["us"],
92
+ "dates": ["2026-09-19", "2027-09-18"],
93
+ "interests": ["cw"]
94
+ }
95
+ ```
96
+
97
+ **Where.** Four lists, each an allowlist over the operator's own callsign:
98
+ `entities` (DXCC prefixes as the country file names them: `K`, `VE`, `CE`),
99
+ `countries` (ISO alpha-2: `us`, `cl`), `continents` (`AF AS EU NA OC SA AN`)
100
+ and `ituRegions` (`1`, `2`, `3`). A missing or empty list does not gate;
101
+ values within a list are ORed, the lists ANDed; case is the matcher's job.
102
+ These are the geo keys of HaLo's notices, rule for rule.
103
+
104
+ **When.** `dates` lists the UTC days the extension's events start, as
105
+ `YYYY-MM-DD`. Start days, not spans: a reader asking "is this soon" needs no
106
+ more, and the extension's own code stays the only place that knows when an
107
+ event opens and closes to the minute. An event on a fixed calendar lists the
108
+ occurrences it knows about; one that runs to a weekly rule lists none,
109
+ because a list that must be right every week is a list that goes stale. An
110
+ absent or empty `dates` says nothing about timing — never "never".
111
+
112
+ **For whom.** `interests` draws on a closed vocabulary: the modes `cw`,
113
+ `phone`, `digital`; the band groups `hf`, `vhf`, `uhf`, `microwave`; the
114
+ styles `satellite`, `portable`, `qrp`. Only for an extension *dedicated* to
115
+ one — a contest that permits CW is not a `cw` extension, while the CWops CWT
116
+ is. Listing what an extension merely allows makes every interest match
117
+ everything, which is the same as listing none. The list is closed because an
118
+ interest nothing else spells the same way matches nobody while looking like
119
+ it works.
120
+
121
+ The packer refuses:
122
+
123
+ - a `relevance` that is not an object, or a key inside it other than those six;
124
+ - a list that is not a list, or an element that is not a non-empty string;
125
+ - a continent outside `AF AS EU NA OC SA AN`, or an ITU region outside `1`–`3`;
126
+ - a country that is not two letters, or an entity prefix that is not letters,
127
+ digits and `/` (with an optional leading `*`);
128
+ - a date that is not `YYYY-MM-DD`, or that names a day which does not exist;
129
+ - an interest outside the vocabulary above.
130
+
131
+ The matcher reads a key it cannot parse as "does not gate", so each of the
132
+ shape errors would otherwise ship the bundle as worldwide.
60
133
 
61
134
  ## Using the host's libraries
62
135
 
@@ -95,6 +168,15 @@ The host checks those before loading, and refuses a bundle it cannot satisfy —
95
168
  which is a message naming the library and both versions, instead of a call
96
169
  failing somewhere deep in a hook against a major you never tested.
97
170
 
171
+ **Declare the version you actually build against, not the oldest that
172
+ compiles.** That check compares versions, and cannot see which *functions* a
173
+ version has. A library that gained an export — `qsonToCabrillo` arrived in
174
+ `@ham2k/lib-qson-cabrillo` 1.2.0, whose earlier releases only read Cabrillo —
175
+ satisfies a `^1.0.0` declaration on a host too old to have it, so the bundle
176
+ installs, loads, and throws the first time an operator asks for the feature
177
+ that needs it. The build's own message suggests the version installed beside
178
+ you for exactly this reason; take it rather than rounding down.
179
+
98
180
  The packer refuses a bundle that reaches for a shared module **without**
99
181
  declaring it, and one that declares a package the host doesn't carry. Anything
100
182
  outside that list you bundle yourself, as normal.
@@ -262,6 +344,80 @@ from whoever it claims; it is not worth it while a key is all there is.
262
344
 
263
345
  Removing an extension removes what it stored with it.
264
346
 
347
+ ### Installing from a link
348
+
349
+ A web page can hand the app a bundle to install:
350
+
351
+ ```
352
+ com.ham2k.logger:///install_extension?url=https://example.org/my-ext.h2kext
353
+ ```
354
+
355
+ Every edition of Ham2K Logger answers that scheme, so on a device with more
356
+ than one installed the platform decides which one opens; the catalog keeps a
357
+ plain download link beside its install button for that reason. The
358
+ edition-specific `com.ham2k.logger.dev` / `.next` / `.prod` schemes exist
359
+ but a link should not need them. The URL must be `https`, redirects are followed by hand and refused the
360
+ moment one leaves `https`, and the consent screen names the host the bytes
361
+ came from. Nothing is written until the operator agrees.
362
+
363
+ ### Pre-loaded extensions
364
+
365
+ The app ships eight of the catalog's own extensions already packaged, under
366
+ `app/assets/preloaded-extensions/`, and installs them into the ordinary store
367
+ on first run: `ham2k-pota`, `ham2k-sota`, `ham2k-wwff`, and the five lookups.
368
+ Afterwards they are ordinary installed extensions — the same rows, the same
369
+ update check, the same Update and uninstall — so an operator starts with a
370
+ working app and the catalog can move it forward from there.
371
+
372
+ They are installed once, not every launch. A record of the highest version
373
+ ever pre-loaded per key is what makes that true: an app update carrying a
374
+ newer bundle installs it, a catalog release already ahead of it is left
375
+ alone, and a key the operator uninstalled stays gone.
376
+
377
+ Each one that renames a built-in — `ham2k-pota` is the built-in `pota` under
378
+ a name the catalog can serve — carries the operator's settings, panel values
379
+ and account credentials across on its first install. Copied, not moved: the
380
+ built-in's own state stays where it is.
381
+
382
+ One rule bends for these, and only for them:
383
+
384
+ - **Built-in collisions.** An installed bundle may not claim a key the app
385
+ ships — judged against what the app is currently OFFERING, which under the
386
+ catalog experiment is the core extensions and nothing else. `ham2k-lookup`
387
+ is both a built-in and a pre-load, and could otherwise never install.
388
+
389
+ **Build secrets are not among them, and used to be.** A pre-load holding a
390
+ `ham2k-` key could once read the values its name granted. Nothing needs that
391
+ now: every value it carried — SOTA's OAuth client id, WWFF's API key — ships
392
+ as a constant inside the extension that uses it, because these bundles are
393
+ served to anyone who asks and a `.h2kext` is a zip. An installed extension
394
+ sees no build secret, whatever key it claims and wherever it came from.
395
+
396
+ ### Installing from the catalog
397
+
398
+ **Settings → Extensions → Catalog** lists what [catalog.ham2k.net](https://catalog.ham2k.net)
399
+ publishes on its `stable` channel, in the catalog's own order for the
400
+ operator's callsign (`relevance`, above). Install and Update go through the same consent screen as a
401
+ file, over bytes that must hash to the sha256 the listing gives — a mismatch
402
+ is refused before the zip is opened. A bundle that arrived this way is
403
+ recorded as such (`extensionInstalledFrom`: host, sha256, version), and that
404
+ record is what lets it carry a `ham2k-` key: the rule that refuses the prefix
405
+ to a file stands, waived on every read only for a key whose record names
406
+ `catalog.ham2k.net`, and cleared by an uninstall or by a file install of the
407
+ same key. A key the app ships stays refused whatever the catalog says. The
408
+ catalog's own bundle links (`…/api/v1/extensions/<key>/versions/<version>/bundle`)
409
+ take this verified path when they arrive as a deep link; every other host
410
+ takes the plain one above.
411
+
412
+ At most once every six hours — on launch and on resume — the app asks the
413
+ catalog whether anything installed has a newer release, and says so in the
414
+ status bar and on the row; the notice comes down with the last update taken.
415
+ It never installs unasked. A release the catalog has revoked is put on record
416
+ (`extensionRevoked`: version and note) and stops loading on the spot,
417
+ whatever the operator's own switch says — the row shows "Revoked" with the
418
+ note and no switch, and the record dies with an install of another version
419
+ or an uninstall.
420
+
265
421
  ## What is checked at install
266
422
 
267
423
  Beyond what the packer already refused:
@@ -269,6 +425,8 @@ Beyond what the packer already refused:
269
425
  - the key is not one the app ships, and does not begin `ham2k-`;
270
426
  - the `api` version is one this build speaks;
271
427
  - every `sharedDependencies` range is satisfied by what this build carries;
428
+ - `relevance`, if present, passes the packer's rules — the shape the matcher
429
+ reads and its vocabulary — applied again by `ExtensionRelevance.validate`;
272
430
  - entry names are sanitised on extract, so `../` in one writes nothing.
273
431
 
274
432
  Each has its own message. A bundle that cannot be installed says why.
@@ -276,4 +434,12 @@ Each has its own message. A bundle that cannot be installed says why.
276
434
  ## Native only, for now
277
435
 
278
436
  Installed bundles are files on disk. On web the app runs from its own assets
279
- and, for authors, the dev server — [development.md](https://github.com/ham2k/halo/blob/main/docs/extensions/development.md).
437
+ and, for authors, the dev server — [development.md](https://github.com/ham2k/halo/blob/main/docs/extensions/development.md). So on
438
+ web there is nothing to install into: no install from a file, no install or
439
+ update from the catalog, and none of the pre-loaded extensions above.
440
+
441
+ "For now" is a deferral, not a fact about the platform. Almost everything
442
+ here is already platform-neutral — the zip walk, the manifest rules, the
443
+ consent screen, the hash check — and what is left is a persistence seam of
444
+ about six operations. `HALO-583` is the card that picks it up, and says what
445
+ has to be decided first.
package/docs/forms.md CHANGED
@@ -70,8 +70,8 @@ export interface FormField {
70
70
  // fields — a whole section header can be dev-mode-only too.
71
71
  devMode?: boolean;
72
72
  // Settings-panel-only (ignored in ad hoc forms) — see settings.md's
73
- // "Common Preferences and environment gating". Also shows this field on
74
- // the app's Common Preferences quick-access panel.
73
+ // "Common Settings and environment gating". Also shows this field on
74
+ // the app's Common Settings quick-access panel.
75
75
  common?: boolean;
76
76
  // Settings-panel-only. Restricts which platform(s) show this field at
77
77
  // all: one or more platform tokens (`ios`, `android`, `macos`,
@@ -93,6 +93,14 @@ export interface FormHeader {
93
93
  type: 'header';
94
94
  title: string;
95
95
  subtitle?: string;
96
+ // How the heading is drawn. Omitted, it is plain text above the fields
97
+ // that follow. 'section' is the app's section band — the accent title
98
+ // over a hairline, which holds the top of the surface while its own rows
99
+ // scroll under it. 'category' is the FILLED band, for a heading that is
100
+ // the top-level grouping of its surface rather than one section inside
101
+ // one (docs/design/user-interface.md § Settings lists). A settings panel
102
+ // is already inside a category, so its own headings take 'section'.
103
+ style?: 'default' | 'section' | 'category';
96
104
  devMode?: boolean;
97
105
  environment?: string | string[]; // settings-panel-only; see FormField.environment (not `common` — see settings.md)
98
106
  }
package/docs/hooks.md CHANGED
@@ -29,6 +29,10 @@ hook method receives `(args, ctx)` where `ctx: HookContext` is:
29
29
  whole-extension `manifest.experiments` grain would also hide any other
30
30
  hook the same extension registers (e.g. `radio-commands`' POWER command
31
31
  checks `gato` here; its BAND/MODE/frequency siblings don't).
32
+ * `experiments?: { key, name, aliases }[]` — The experiment catalog itself,
33
+ on or off, so a hook can resolve a typed key-or-alias to one of the keys
34
+ `enabledExperiments` lists (the EXP preview says "Enable"/"Disable" rather
35
+ than "Toggle"). The app remains the authority when it flips the state.
32
36
  * `getOperation?(uuid)` / `getQsos?(operationUuid)` — log-data reads, for
33
37
  hooks that only receive part of the picture as arguments (e.g. a `command`
34
38
  hook handed the operation it's typed into, needing the existing QSOs —
@@ -170,12 +174,23 @@ interface LookupServiceHook {
170
174
  least as specific as the guess's current scope — a more general-scoped
171
175
  result can never populate a slot a more specific one already claimed,
172
176
  even if that slot is currently empty.
177
+ - Location fields merge as a group, never per field. A same-scope result
178
+ bringing a pin (a grid, or a full lat/lon pair) to a guess that has none
179
+ replaces the established city/state/county/country/location with its own
180
+ values (or just clears them, if it carries only the pin) — even where
181
+ per-field priority would have kept the earlier town. A result whose own
182
+ pin loses to an already-established one contributes no location fields at
183
+ all, and a pinless result's town is used only where the higher-priority
184
+ lookups left no town or pin (an entity-level `country` alone doesn't
185
+ count as located): a town is never spliced with a pin — or a town half —
186
+ it wasn't reported with.
173
187
  - Grid/lat-lon reconciliation, scoped to fields the result actually set:
174
188
  grid-only → lat/lon computed from the grid; lat/lon-only → a 6-digit grid
175
189
  computed from them; both given → taken verbatim. Setting grid/lat/lon
176
190
  clears city/state/country/county unless the *same* result also supplies
177
- them; conversely, setting city/state/country (without county, and without
178
- also setting grid/lat/lon in the same result) clears grid/lat/lon.
191
+ them. The converse clause — a town write clearing grid/lat/lon — can only
192
+ sweep a leftover half lat/lon pair: the group rule above blocks a town
193
+ from landing at all where a full pin already stands.
179
194
  - `notes`/`history` are per-`CallInfoLookup` fields, not part of the merged
180
195
  `guess` — they simply accumulate, unconditionally, in the returned
181
196
  `lookups[]` array.
@@ -237,7 +252,7 @@ interface ExportHook {
237
252
  generateExport(args: ExportRequest, ctx): Promise<ExportResult>
238
253
  }
239
254
  // ExportOptionsRequest: {operation, qsos, compactFilenames?}
240
- // ExportOption: {exportType, format, label, filename?, icon?, priority?, selectedByDefault?}
255
+ // ExportOption: {exportType, format, label, filename?, icon?, color?, refType?, priority?, selectedByDefault?}
241
256
  // ExportRequest: {operation, qsos, exportType?, compactFilenames?} — full QSON, one coarse call
242
257
  // ExportResult: {filename, mimeType, content}
243
258
  ```
@@ -251,6 +266,15 @@ Exporters should compose per-QSO program fields from `adifFields` hooks (see
251
266
  below) rather than knowing about specific activities. Implemented by: `adif`
252
267
  (key `adif`, one option); Cabrillo will register here too.
253
268
 
269
+ **How a row looks.** `refType` names the activity the export covers — the
270
+ core slices the file's QSOs by it, and the panel takes the row's icon and
271
+ accent colour from that activity's own control, so a contest's file is
272
+ recognisably the contest's without saying anything about glyphs. `icon` and
273
+ `color` override that, and are how an export that claims no activity — the
274
+ whole-log ADIF — asks for a glyph of its own instead of borrowing one. An
275
+ `icon` name that doesn't resolve falls back to the activity's, same as an
276
+ unreadable `color` does.
277
+
254
278
  **Filenames.** Build them with the SDK's `exportFilename` rather than
255
279
  spelling one out, passing the request's `compactFilenames` straight through —
256
280
  that is what makes the user's "Use compact file names" setting apply to your
@@ -280,6 +304,11 @@ One category **per ref type** (e.g. `ref:pota`, `ref:potaActivation`),
280
304
  following app-polo's QSON conventions. Handles validation and enrichment of
281
305
  activity references on QSOs and operations.
282
306
 
307
+ `registerHook` takes the type alone. The **qualified** `ref:<type>/<code>` form
308
+ is a manifest claim, not a category — it tells the host which legacy reference
309
+ an extension answers for so it can rewrite one (docs/extensions/README.md), and
310
+ registering a hook under it would register a category nothing ever invokes.
311
+
283
312
  ```ts
284
313
  interface RefHandlerHook {
285
314
  validateRef?(args: { ref: Ref }, ctx): Promise<{valid: boolean; normalized?: string}>
@@ -400,7 +429,10 @@ interface ActivityHook {
400
429
  //
401
430
  // `pattern` is anchored (`^(?:…)$`) and matched case-insensitively for every
402
431
  // kind; a malformed regex disables validation rather than throwing. A
403
- // mismatch tints the field but still accepts the value.
432
+ // mismatch tints the field but still accepts the value — a `refList` segment
433
+ // that fails it becomes a ref like any other, and reaches the log and the
434
+ // exports as typed. The pattern is HaLo's idea of the program rather than the
435
+ // program's own, so it may never cost the operator a reference.
404
436
  //
405
437
  // `text`, `options` and `serial` values round-trip onto a ref of `refType` under
406
438
  // `field`, through the same core-owned path refList writes refs — several may
@@ -432,8 +464,9 @@ an unrecognized name falls back to a placeholder glyph rather than erroring.
432
464
 
433
465
  `color` is an optional `'#RRGGBB'` accent (e.g. POTA's `'#068541'`) the core
434
466
  may use sparingly to highlight this control's icon — a tinted circle behind
435
- it in the Activities picker, or the icon itself once a reference is entered
436
- in the logging panel's secondary pills. Missing or unrecognized values just
467
+ it in the Activities picker, the icon itself once a reference is entered
468
+ in the logging panel's secondary pills, or the reference icon on a spot row
469
+ whose ref is of this control's type. Missing or unrecognized values just
437
470
  mean no accent.
438
471
 
439
472
  `processQsoBeforeSave` is the last chance to shape a QSO before it is written,
@@ -441,7 +474,10 @@ for projecting data the extension owns into the generic QSON fields the rest of
441
474
  the app reads. A contest keeps its exchange on its own ref (`theirZone`,
442
475
  `theirSerial`, …), which nothing generic knows how to read — mirroring it into
443
476
  `their.exchange` is what fills the QSO row's exchange column and a plain ADIF
444
- export. Return a PATCH, not a mutated QSO: it merges shallowly except `our` and
477
+ export. A contest whose sent exchange changes per QSO (a serial) mirrors that
478
+ into `our.exchange` too, and the column shows it ahead of theirs, in the RST
479
+ cell's order — keep it to the part that varies, since it repeats on every row.
480
+ Return a PATCH, not a mutated QSO: it merges shallowly except `our` and
445
481
  `their`, which merge one level deep, so `{their: {exchange: 'ZN5'}}` sets that
446
482
  field and leaves the callsign alone. `refs` and `uuid` in a patch are ignored —
447
483
  refs are shared with the core and with other activities. Return null to do
@@ -475,15 +511,36 @@ a set of placeless suggestions (dated contest events, say) among themselves, but
475
511
  never lifts one above a located park. Implemented by: `pota`, `sota`,
476
512
  `stateparks`, `cwt`.
477
513
 
478
- The activity search box also treats a leading `key:` as a scope — typing
479
- `pota:` (or tapping a row under "Activity Types") asks that extension alone,
480
- via a key-scoped `invokeHook` rather than the usual fan-out. For that to
481
- work, an extension's `activity` hook must be registered under its own
482
- **extension key**, and its control descriptors keyed `<extensionKey>/<name>`
483
- (`pota/activation`) — the core derives the scope from the part before the
484
- slash. An extension that deviates simply never gets scoped: its rows still
485
- add references, but `key:` would ask a hook that doesn't exist and come back
486
- empty.
514
+ **A scoped search offers the reference the operator typed, listed or not.** The
515
+ reference programs (`pota`, `sota` and every one built on `referenceActivity`)
516
+ answer a scoped search with the typed code itself when no row matched it —
517
+ named `unknownReference` where it fits the program's pattern, `invalidReference`
518
+ where it does not. The search is the only way onto the operation, so a program
519
+ whose list is stale, or whose pattern this app has wrong, would otherwise answer
520
+ a reference the operator was handed on the air with "no results". The scope is
521
+ what keeps the MALFORMED half of that narrow: unscoped, every enabled program
522
+ would answer any text at all with an invented reference of its own, burying the
523
+ real matches. (A well-formed code needs no scope — POTA has always offered one,
524
+ since only that program's own pattern can accept it.) A search term that is not
525
+ code-shaped is left alone either way: a single word is how a park or summit gets
526
+ looked up BY NAME, and `pota: beersel` must not answer with an invented BEERSEL.
527
+ The row is marked, and the reference is stored, logged and exported as typed —
528
+ the same rule ADIF import follows for a malformed `*_REF` (below).
529
+
530
+ The activity search box also treats a leading scope word as a scope — typing
531
+ `pota:` (or tapping a row under "Activity Types") asks the extension serving
532
+ that reference type alone, via a key-scoped `invokeHook` rather than the usual
533
+ fan-out. The word is the **reference type's base name**, lowercased —
534
+ `potaActivation` and `pota` both scope as `pota:` — never the extension key: a
535
+ user-published `ki2d-pota` serving `potaActivation` is still scoped as
536
+ `pota:`, since the operator is adding POTA parks whichever extension supplies
537
+ them. Two enabled extensions serving one type share the word, and the scoped
538
+ search asks both. For the hook call to reach the extension, its `activity`
539
+ hook must be registered under its own **extension key**, and its control
540
+ descriptors keyed `<extensionKey>/<name>` (`pota/activation`) — the core
541
+ derives the hook key from the part before the slash. An extension that
542
+ deviates simply never gets scoped: its rows still add references, but the
543
+ scope would ask a hook that doesn't exist and come back empty.
487
544
 
488
545
  `editable` declares whether a reference of this type has anything to edit
489
546
  once it's on the operation. It defaults to **false**: a plain reference
@@ -702,8 +759,8 @@ interface CommandHook {
702
759
  // CommandInterpretation: {expectsParams?, mixedCase?, error?, describe?, confirm?, commands?: CommandAction[]}
703
760
  // CommandCatalogEntry: {command, describe, params?, category?, needsOperation?, expectsParams?, suggest?}
704
761
  // CommandAction (closed set): {setVfo?, setPower?, updateQso?, addQso?,
705
- // sendSpots?, syncAll?, updateSettings?, updateSetting?, openSettings?,
706
- // updateOperation?, setCallField?, reloadDataFiles?, devNotice?, devStatus?,
762
+ // setKeyerSpeed?, showKeyer?, sendSpots?, syncAll?, updateSettings?, updateSetting?, openSettings?,
763
+ // updateOperation?, markSegment?, setCallField?, reloadDataFiles?, devNotice?, devStatus?,
707
764
  // devProgress?, devHammer?, devSplash?, devOnboard?, devTester?, devPopup?,
708
765
  // devTimeTravel?, toggleExperiment?}
709
766
  ```
@@ -817,9 +874,10 @@ the lookup service's offline `their.guess` decoration. The placement rule
817
874
  needs the existing log: SEED reads it through `ctx.getQsos`, using the
818
875
  `uuid` the logging panel folds into the `operation` map it passes to
819
876
  `interpret`; EXP toggles an experimental feature via the `toggleExperiment`
820
- action field — the hook only parses "EXP <key-or-alias>", and the app
821
- resolves the token against `app/experiments.yaml` and reports the outcome,
822
- since the catalog and the stored per-experiment state both live app-side;
877
+ action field — the hook parses "EXP <key-or-alias>" and resolves the token
878
+ against `ctx.experiments` only for its preview ("Enable"/"Disable"); the app
879
+ resolves it again when it flips the state and reports the outcome, since the
880
+ stored per-experiment state lives app-side;
823
881
  DEVTT/DEVTIMETRAVEL move the app clock via the `devTimeTravel` action field —
824
882
  "DEVTT +3h", "DEVTT -1d", "DEVTT 2x", bare "DEVTT" to return to real time — so
825
883
  anything that turns on the date or on elapsed time can be tested without
@@ -923,6 +981,36 @@ can't, since it also blocks execution — which is why SEED returns it on
923
981
  every result despite its parameters not being prose: bare "SEED" must be
924
982
  executable (default count) yet still extendable to "SEED 15 3H".
925
983
 
984
+ ## ✅ `template` — rendering on the app's behalf
985
+
986
+ The Liquid renderer (templates.md) lives in the SDK and runs only inside the
987
+ extension runtime. A Dart-side surface that wants a template filled in — a
988
+ CW message about to be keyed (docs/design/cat.md § CW keying) — reaches it
989
+ through this hook, registered once by the core `templates` extension under
990
+ the key `templates`:
991
+
992
+ ```ts
993
+ interface TemplateHook {
994
+ render(
995
+ args: { template: string; operation?; qso?; qsoCount?: number },
996
+ ctx,
997
+ ): Promise<{ text: string } | { error: string }>
998
+ // Several templates against one context in one crossing (the keyer
999
+ // area's button labels): one result per template, in order.
1000
+ renderMany(
1001
+ args: { templates: string[]; operation?; qso?; qsoCount?: number },
1002
+ ctx,
1003
+ ): Promise<{ results: ({ text: string } | { error: string })[] }>
1004
+ }
1005
+ ```
1006
+
1007
+ `operation` and `qso` are the same objects a `panel` hook's `render` gets,
1008
+ and the result is built with the same `templateContext`, so a placeholder
1009
+ means the same thing wherever the app renders it. A template that does not
1010
+ parse comes back as `error` carrying the renderer's own message — a typo in
1011
+ a message the operator typed, to show them, not a bridge failure to report.
1012
+ The app calls them as `ExtensionService.renderTemplate` and `renderTemplates`.
1013
+
926
1014
  ## ✅ `scoring` — per-operation score batches
927
1015
 
928
1016
  One hook call scores an entire operation's QSOs at once (DESIGN.md §4:
@@ -964,7 +1052,12 @@ about a prior contact (`invalidBand`, `missingExchange`, a bare `{value}`)
964
1052
  displaces nothing: core's dupe warning is then the only one there is.
965
1053
 
966
1054
  So emit `dupe` whenever your dupe test actually ran, `dupe: false` included —
967
- that is what tells the host you have ruled on the question. And when your
1055
+ that is what tells the host you have ruled on the question. It travels on the
1056
+ live paths too (`scoreQso`, `scoreCandidates`), not just in `qsoScores`. Rule
1057
+ only on what you have seen: a scorer folds only the segments it was running
1058
+ for, so a station absent from YOUR history may have been worked in a stretch
1059
+ you skipped, and staying silent there hands the question to the scorer holding
1060
+ the whole log. And when your
968
1061
  verdict means "worked before, and this one counts anyway", say it in one of
969
1062
  those notice keys (`newPark` is state-parks' spelling of `newRef`) rather than
970
1063
  inventing a spelling of your own: an unrecognized key leaves core's `duplicate`
@@ -999,6 +1092,10 @@ three pure `ContestScorer` functions and gets the loop, the checkpointing and
999
1092
  both live paths for free.
1000
1093
 
1001
1094
  `scoreQso` answers for ONE not-yet-logged QSO — the callsign being typed.
1095
+ A candidate that carries no `startAtMillis` (the draft's time is still
1096
+ automatic) reaches the scorer stamped with the current time on both live
1097
+ paths, so a day-bucketed rule judges it as of now rather than at the epoch; a
1098
+ candidate that states a time keeps it.
1002
1099
  `scoreCandidates` answers the same question for many at once, which is what the
1003
1100
  Spots Panel asks to mark the spots already worked: the log is folded once per
1004
1101
  scorer and every candidate scored against a COPY of the result, so no candidate
@@ -1199,7 +1296,7 @@ and optional OAuth2, surfaced in `SettingsView`'s Accounts panel via
1199
1296
  platform keychain, keyed by the account's `kvKey`. Implemented by: `qrz`
1200
1297
  (username/password) and `sota` (OAuth2 via Keycloak — `sso.sota.org.uk`; the
1201
1298
  host runs the whole PKCE flow itself, see `_doOAuth` in
1202
- `app/lib/views/settings_view.dart`).
1299
+ `app/lib/views/settings/accounts_panel.dart`).
1203
1300
 
1204
1301
  **`label` / `description` / `fields`** — each either a plain value, or a
1205
1302
  function `(args, ctx) => value` called the same way any other hook method is,
package/docs/settings.md CHANGED
@@ -372,17 +372,18 @@ Aliases are for names an operator would plausibly *reach for* — not a thesauru
372
372
  Every alias is one more thing that can collide with another setting's real name,
373
373
  and a collision costs a disambiguation prompt on a query that used to be exact.
374
374
 
375
- ## Common Preferences and environment gating
375
+ ## Common Settings and environment gating
376
376
 
377
- The app has two settings screens: **All Settings**, listing every declared
378
- group and panel, and **Common Preferences**, a short list of the handful of
379
- settings most users ever touch. Which fields land on Common Preferences —
377
+ The app has one settings screen, **Settings**: a collapsible section per
378
+ declared group and panel, led by **Common Settings**, a short list of the
379
+ handful of settings most users ever touch. Which fields land on Common
380
+ Settings —
380
381
  and which platforms show a field at all — is declared per-element, not
381
382
  maintained as a separate list somewhere else in the app. Any field, link, or
382
383
  action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
383
384
 
384
- - **`common: true`** — also show this element on Common Preferences, in
385
- addition to All Settings. Off by default. Not the same thing as
385
+ - **`common: true`** — also show this element in the Common Settings
386
+ section, on top of its own group's. Off by default. Not the same thing as
386
387
  `FormFieldOption.common`/`uncommon` above — that's a `multiselect`
387
388
  option's own disclosure tier (upfront vs. behind "Show more"), a
388
389
  different scope (an option inside one field, not the field itself).
@@ -393,8 +394,8 @@ action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
393
394
 
394
395
  - **`environment: 'ios,android'`** (or `'-web'`, or an array either way) —
395
396
  restrict which platform(s) show this element at all. This is a platform
396
- gate, not a Common-vs-All one: an excluded field disappears from every
397
- settings surface on that platform, not just Common Preferences. Every
397
+ gate, not a common-vs-everything one: an excluded field disappears from every
398
+ settings surface on that platform, not just Common Settings. Every
398
399
  token unprefixed is a whitelist (show ONLY there); every token
399
400
  `-`-prefixed is a blacklist (show everywhere EXCEPT there); mixing the two
400
401
  forms in one attribute is invalid and treated as if `environment` were
@@ -445,7 +446,7 @@ target but niche enough it shouldn't clutter the default view:
445
446
  ```
446
447
 
447
448
  Omitting `target` doesn't hide a field from anywhere it already appears —
448
- Common Preferences and All Settings are unaffected either way. It's purely
449
+ The Settings panel is unaffected either way. It's purely
449
450
  additive: an extra place a field can be reached from, on top of wherever
450
451
  `common`/`environment` already put it.
451
452
 
@@ -453,8 +454,8 @@ additive: an extra place a field can be reached from, on top of wherever
453
454
  elements can both set `target` and both appear in a target-scoped modal —
454
455
  an action's method dispatches through the same extension host call the
455
456
  full settings screens use, and an account field's Manage/Connect button
456
- opens that account's own standalone dialog, exactly as it would from All
457
- Settings.
457
+ opens that account's own standalone dialog, exactly as it would from the
458
+ Settings panel.
458
459
 
459
460
  ## Restarting the runtime after a Tier 2 field commits
460
461
 
package/docs/templates.md CHANGED
@@ -38,14 +38,14 @@ the namespaces are deliberately polo's. The syntax around them does not:
38
38
 
39
39
  ## What a template can name
40
40
 
41
- | namespace | panel `render` | export filename | ADIF fields |
42
- |---|---|---|---|
43
- | `app` | ✓ | — | ✓ |
44
- | `now` | ✓ | ✓ | ✓ |
45
- | `op` | ✓ | dates only | ✓ |
46
- | `qso` | when the placement's triggers ask for it | — | ✓ |
47
- | `config` | ✓ | — | — |
48
- | `log` | — | ✓ | ✓ |
41
+ | namespace | panel `render` | export filename | ADIF fields | CW message |
42
+ |---|---|---|---|---|
43
+ | `app` | ✓ | — | ✓ | ✓ |
44
+ | `now` | ✓ | ✓ | ✓ | ✓ |
45
+ | `op` | ✓ | dates only | ✓ | ✓ |
46
+ | `qso` | when the placement's triggers ask for it | — | ✓ | the draft contact, as far as it is typed |
47
+ | `config` | ✓ | — | — | — |
48
+ | `log` | — | ✓ | ✓ | — |
49
49
 
50
50
  A namespace a surface has nothing for is **absent**, not blank — which is
51
51
  what makes `{% if qso %}` an honest question. Filenames get only the date
@@ -61,7 +61,8 @@ An ISO-8601 UTC string, for the `date` filter: `{{ now | date: '%H:%M' }}`.
61
61
 
62
62
  ### `op` — the operation
63
63
  `station` (the field as typed, which may hold several comma-separated
64
- callsigns), `stations` (that list, uppercased and deduped), `operator`,
64
+ callsigns; `call` is the same value, the name `qso.call` has), `stations`
65
+ (that list, uppercased and deduped), `operator`,
65
66
  `title` (the generated, ref-derived one), `userTitle` (the operator's own
66
67
  words), `grid`, `refs`, `uuid`, `qsoCount`, `date`, `dateCompact`, `time`,
67
68
  `at`, `startDate`, `startTime`, `startAt`, `endDate`, `endTime`, `endAt`.
@@ -192,6 +193,7 @@ will jump rather than count. Show HH:MM.
192
193
  | Panel documents | `custom-text`'s content and tab name | by the operator, in the panel's config form |
193
194
  | Export filenames | `sdk/src/exportNames.ts` `NAME_TEMPLATES` | not yet |
194
195
  | ADIF NOTES / COMMENT / QSLMSG | `core/adif`'s `TEXT_FIELD_TEMPLATES` | not yet |
196
+ | CW messages | Radio settings `cwMessage1..8`, keyed on F1-F8 through the radio (docs/design/cat.md § CW keying) — rendered by the `template` hook (hooks.md) | by the operator, in the Station dialog's Messages… dialog |
195
197
 
196
198
  COMMENT and QSLMSG are empty, so nothing is written for them. app-polo
197
199
  defaults COMMENT to the QSO's notes and QSLMSG to the operation's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",
@@ -10,7 +10,7 @@
10
10
  "sdk",
11
11
  "logging"
12
12
  ],
13
- "license": "MPL-2.0",
13
+ "license": "MIT",
14
14
  "author": "Sebastian Delmont <sd@ham2k.com>",
15
15
  "homepage": "https://ham2k.com",
16
16
  "repository": {
@@ -25,8 +25,17 @@
25
25
  "subpath, so a tool asking this package its own version is told the package",
26
26
  "does not exist. Common enough that npm's own docs call it out."
27
27
  ],
28
+ "//ham2k-source": [
29
+ "A condition only this repo's own test run asks for (`--conditions=ham2k-source`),",
30
+ "so a test can import the SDK by name and get the TypeScript sources rather",
31
+ "than a build of them — the same thing esbuild's alias does when it bundles an",
32
+ "extension. Deliberately not the conventional `development`: the published",
33
+ "tarball ships `dist` and not `src`, and a consumer who happened to run with",
34
+ "that common condition would be pointed at files their install does not have."
35
+ ],
28
36
  "exports": {
29
37
  ".": {
38
+ "ham2k-source": "./src/index.ts",
30
39
  "types": "./dist/index.d.ts",
31
40
  "default": "./dist/index.js"
32
41
  },
@@ -5,7 +5,6 @@
5
5
  "version": "1.0.0",
6
6
  "description": "CQ WW DX points and multipliers, as a worked example",
7
7
  "category": "contest",
8
- "enabledByDefault": false,
9
8
  "icon": "earth",
10
9
  "accentColor": "#B03A2E",
11
10
  "api": 1,
@@ -1,5 +1,5 @@
1
1
  // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
- // SPDX-License-Identifier: MPL-2.0
2
+ // SPDX-License-Identifier: MIT
3
3
  //
4
4
  // SAMPLE — a contest: an exchange to type, and a score to keep.
5
5
  //