@ham2k/extension-sdk 0.2.0 → 0.4.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.
@@ -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
@@ -14,6 +14,7 @@ All types are defined in `extensions/sdk/src/types.ts`.
14
14
  export type FormFieldType =
15
15
  | 'text'
16
16
  | 'multiline'
17
+ | 'textTemplate'
17
18
  | 'email'
18
19
  | 'callsign'
19
20
  | 'number'
@@ -93,6 +94,14 @@ export interface FormHeader {
93
94
  type: 'header';
94
95
  title: string;
95
96
  subtitle?: string;
97
+ // How the heading is drawn. Omitted, it is plain text above the fields
98
+ // that follow. 'section' is the app's section band — the accent title
99
+ // over a hairline, which holds the top of the surface while its own rows
100
+ // scroll under it. 'category' is the FILLED band, for a heading that is
101
+ // the top-level grouping of its surface rather than one section inside
102
+ // one (docs/design/user-interface.md § Settings lists). A settings panel
103
+ // is already inside a category, so its own headings take 'section'.
104
+ style?: 'default' | 'section' | 'category';
96
105
  devMode?: boolean;
97
106
  environment?: string | string[]; // settings-panel-only; see FormField.environment (not `common` — see settings.md)
98
107
  }
@@ -121,6 +130,8 @@ export interface FormMarkdownBlock {
121
130
  // SDK already permits (see settings.md's settingsPanel note). Not used for
122
131
  // account credentials — those have their own testCredentials on AccountHook.
123
132
  export interface FormActionElement {
133
+ style?: 'row'; // icon row with a chevron; omit for a button
134
+ icon?: string;
124
135
  type: 'action';
125
136
  key: string;
126
137
  label: string;
@@ -269,3 +280,28 @@ api.registerHook('form', {
269
280
  - Any validation error returns back to Dart and is displayed underneath the field. If any fields have errors, submission is aborted.
270
281
  3. **Submit transformation**: When all fields are successfully validated, Dart invokes any JS transformation callbacks for each field.
271
282
  4. **Completion**: The final transformed state is resolved and returned back to the caller (or saved in the view context).
283
+
284
+ ## Text templates
285
+
286
+ `fieldType: 'textTemplate'` is a string field displayed as an editor row in
287
+ forms and settings. It opens a multiline Liquid editor with a sample preview,
288
+ syntax errors, insertable attributes and flow-control examples, and a read-only
289
+ filter reference inside collapsed Templating Docs. Editing and preview stay
290
+ above the scrollable reference. Template rows also display rendered samples. Save commits
291
+ the draft; Cancel, Escape and dismissal discard it. `defaultValue` supplies
292
+ the Reset button. Disabled fields cannot open the editor.
293
+
294
+ ```ts
295
+ {
296
+ type: 'field', fieldType: 'textTemplate', key: 'filename',
297
+ label: 'File name', value: '{{ op.date }} {{ log.station }}',
298
+ templateContext: 'export', // 'adif' adds contact attributes; 'text' is generic
299
+ templateSample: { log: { activity: 'POTA', ref: 'US-1234' } },
300
+ }
301
+ ```
302
+
303
+ The preview uses the same runtime and vocabulary as exports; sample values
304
+ are illustrative. `templateSample` can replace values in `operation`, `qso`
305
+ and `log`. The app provides the rendering callback through
306
+ `TemplateEditorScope`, keeping the form widget independent of extensions.
307
+ See [templates.md](templates.md) for Liquid syntax and supported namespaces.
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.
@@ -233,23 +248,100 @@ fetch-only for now.
233
248
 
234
249
  ```ts
235
250
  interface ExportHook {
251
+ getExportTypes?(args: {}, ctx): Promise<ExportTypeDefinition[]>
236
252
  suggestExportOptions?(args: ExportOptionsRequest, ctx): Promise<ExportOption[]>
237
253
  generateExport(args: ExportRequest, ctx): Promise<ExportResult>
238
254
  }
239
255
  // ExportOptionsRequest: {operation, qsos, compactFilenames?}
240
- // ExportOption: {exportType, format, label, filename?, icon?, priority?, selectedByDefault?}
241
- // ExportRequest: {operation, qsos, exportType?, compactFilenames?} — full QSON, one coarse call
256
+ // ExportOption: {exportType, exportKey?, format, label, filename?, icon?, color?, refType?, priority?, selectedByDefault?}
257
+ // ExportRequest: {operation, qsos, exportType?, exportKey?, compactFilenames?} — full QSON, one coarse call
242
258
  // ExportResult: {filename, mimeType, content}
243
259
  ```
244
260
 
245
261
  Two-step flow: the Exports Panel calls `suggestExportOptions` on every
246
262
  `export` hook to build its selectable list (a hook that omits it never
247
263
  appears in the panel), then calls `generateExport` — passing back the
248
- chosen option's `exportType` — only for the options the user selected. The
249
- core owns file I/O and save/share; the hook only produces content.
264
+ chosen option's `exportType` and `exportKey` — only for the options the user
265
+ selected. The core owns file I/O and save/share; the hook only produces content.
266
+
267
+ `exportType` is the stable kind (for example `potaActivation-adif`); `exportKey`
268
+ identifies an individual option within the hook (for example
269
+ `pota-adif:US-1234`). Options sharing a type must have distinct keys. Omit
270
+ `exportKey` for a single option of a type: selection and generation default
271
+ it to `exportType`. Both values round-trip unchanged when an explicit key is
272
+ provided; the core does not interpret them. Selection also includes the
273
+ hook key and station callsign, keeping different hooks and stations independent.
274
+
250
275
  Exporters should compose per-QSO program fields from `adifFields` hooks (see
251
- below) rather than knowing about specific activities. Implemented by: `adif`
252
- (key `adif`, one option); Cabrillo will register here too.
276
+ below) rather than knowing about specific activities. Implemented by the whole-log `adif` exporter, activity exports, and contest
277
+ ADIF/Cabrillo exporters.
278
+
279
+ **Registering types and settings.** `getExportTypes` runs without an operation,
280
+ so Settings can list types even before the operator has a matching log:
281
+
282
+ ```ts
283
+ async getExportTypes() {
284
+ return [{
285
+ exportType: 'potaActivation-adif',
286
+ activationType: 'potaActivation',
287
+ format: 'adif',
288
+ label: 'POTA',
289
+ defaults: { includePrivateData: false, includeLookupData: true },
290
+ }]
291
+ }
292
+ ```
293
+
294
+ Prefer `<activationType>-<format>` to an extension-key namespace. Two
295
+ extensions offering that type share one settings entry. The highest-priority
296
+ registration supplies its definition; other exporters using it must honor
297
+ that contract. Distinct sponsor requirements can use a distinct type. The
298
+ SDK's `exportTypeDefinition(activationType, format, label, defaults?)` builds
299
+ the conventional definition; the activity and hunting helpers register their
300
+ own types. An exporter without `getExportTypes` still works, but does not
301
+ appear in the export settings list.
302
+
303
+ `ExportSettings` contains `includePrivateData`, `includeLookupData`,
304
+ `customTemplates`, `filenameTemplate`, `compactFilenameTemplate`,
305
+ `titleTemplate`, `adifNotesTemplate`, `adifCommentTemplate`, and
306
+ `adifQslMessageTemplate`. All are optional. Defaults resolve from the SDK,
307
+ then global preferences, then the type's declared defaults.
308
+ `GlobalExportSettings` adds `referenceFilenameTemplate`,
309
+ `referenceCompactFilenameTemplate`, `referenceTitleTemplate` and the matching
310
+ `other…` fields. A type with `templateCategory: 'reference'` uses the reference
311
+ set; other types use the other set. The activity export helper registers
312
+ reference types automatically. ADIF field templates remain common to both sets.
313
+ Saved common filename/title defaults remain fallbacks for categories without
314
+ an explicit setting. Per-type data
315
+ choices override those; per-type template overrides apply only when
316
+ `customTemplates` is on. Empty templates intentionally suppress text. Turning
317
+ custom templates off preserves the operator's edits for later. Non-ADIF
318
+ formats expose only the filename templates and their custom-template switch.
319
+
320
+ Type definitions may supply `templateSample: {operation, log}` for representative
321
+ editor examples. Each format supplies its own filename extension in the sample.
322
+
323
+ An option can supply `qsoCount` when its hook further filters the contacts
324
+ (for example, a hunter export). Otherwise the host derives the count from
325
+ activity and station scope. Filename and title templates use this count.
326
+
327
+ Options can carry `templateData: {ref, refName, activity, modifier, ...}`,
328
+ which populates the `log` namespace. The app renders filenames and titles
329
+ before showing the option, and passes the same `exportSettings`, `exportData`
330
+ and `exportTitle` to generation. It also passes the resolved
331
+ `includePrivateData` and `includeLookupData` for ADIF. Forward those fields
332
+ when delegating through `adifForExport`, or a program's preferences will not
333
+ reach the ADIF writer. Settings apply globally and per type, never per operation.
334
+ The whole-log ADIF defaults to including private data, but its type setting
335
+ can override that default.
336
+
337
+ **How a row looks.** `refType` names the activity the export covers — the
338
+ core slices the file's QSOs by it, and the panel takes the row's icon and
339
+ accent colour from that activity's own control, so a contest's file is
340
+ recognisably the contest's without saying anything about glyphs. `icon` and
341
+ `color` override that, and are how an export that claims no activity — the
342
+ whole-log ADIF — asks for a glyph of its own instead of borrowing one. An
343
+ `icon` name that doesn't resolve falls back to the activity's, same as an
344
+ unreadable `color` does.
253
345
 
254
346
  **Filenames.** Build them with the SDK's `exportFilename` rather than
255
347
  spelling one out, passing the request's `compactFilenames` straight through —
@@ -280,6 +372,11 @@ One category **per ref type** (e.g. `ref:pota`, `ref:potaActivation`),
280
372
  following app-polo's QSON conventions. Handles validation and enrichment of
281
373
  activity references on QSOs and operations.
282
374
 
375
+ `registerHook` takes the type alone. The **qualified** `ref:<type>/<code>` form
376
+ is a manifest claim, not a category — it tells the host which legacy reference
377
+ an extension answers for so it can rewrite one (docs/extensions/README.md), and
378
+ registering a hook under it would register a category nothing ever invokes.
379
+
283
380
  ```ts
284
381
  interface RefHandlerHook {
285
382
  validateRef?(args: { ref: Ref }, ctx): Promise<{valid: boolean; normalized?: string}>
@@ -400,7 +497,10 @@ interface ActivityHook {
400
497
  //
401
498
  // `pattern` is anchored (`^(?:…)$`) and matched case-insensitively for every
402
499
  // kind; a malformed regex disables validation rather than throwing. A
403
- // mismatch tints the field but still accepts the value.
500
+ // mismatch tints the field but still accepts the value — a `refList` segment
501
+ // that fails it becomes a ref like any other, and reaches the log and the
502
+ // exports as typed. The pattern is HaLo's idea of the program rather than the
503
+ // program's own, so it may never cost the operator a reference.
404
504
  //
405
505
  // `text`, `options` and `serial` values round-trip onto a ref of `refType` under
406
506
  // `field`, through the same core-owned path refList writes refs — several may
@@ -432,8 +532,9 @@ an unrecognized name falls back to a placeholder glyph rather than erroring.
432
532
 
433
533
  `color` is an optional `'#RRGGBB'` accent (e.g. POTA's `'#068541'`) the core
434
534
  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
535
+ it in the Activities picker, the icon itself once a reference is entered
536
+ in the logging panel's secondary pills, or the reference icon on a spot row
537
+ whose ref is of this control's type. Missing or unrecognized values just
437
538
  mean no accent.
438
539
 
439
540
  `processQsoBeforeSave` is the last chance to shape a QSO before it is written,
@@ -441,7 +542,10 @@ for projecting data the extension owns into the generic QSON fields the rest of
441
542
  the app reads. A contest keeps its exchange on its own ref (`theirZone`,
442
543
  `theirSerial`, …), which nothing generic knows how to read — mirroring it into
443
544
  `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
545
+ export. A contest whose sent exchange changes per QSO (a serial) mirrors that
546
+ into `our.exchange` too, and the column shows it ahead of theirs, in the RST
547
+ cell's order — keep it to the part that varies, since it repeats on every row.
548
+ Return a PATCH, not a mutated QSO: it merges shallowly except `our` and
445
549
  `their`, which merge one level deep, so `{their: {exchange: 'ZN5'}}` sets that
446
550
  field and leaves the callsign alone. `refs` and `uuid` in a patch are ignored —
447
551
  refs are shared with the core and with other activities. Return null to do
@@ -475,15 +579,36 @@ a set of placeless suggestions (dated contest events, say) among themselves, but
475
579
  never lifts one above a located park. Implemented by: `pota`, `sota`,
476
580
  `stateparks`, `cwt`.
477
581
 
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.
582
+ **A scoped search offers the reference the operator typed, listed or not.** The
583
+ reference programs (`pota`, `sota` and every one built on `referenceActivity`)
584
+ answer a scoped search with the typed code itself when no row matched it —
585
+ named `unknownReference` where it fits the program's pattern, `invalidReference`
586
+ where it does not. The search is the only way onto the operation, so a program
587
+ whose list is stale, or whose pattern this app has wrong, would otherwise answer
588
+ a reference the operator was handed on the air with "no results". The scope is
589
+ what keeps the MALFORMED half of that narrow: unscoped, every enabled program
590
+ would answer any text at all with an invented reference of its own, burying the
591
+ real matches. (A well-formed code needs no scope — POTA has always offered one,
592
+ since only that program's own pattern can accept it.) A search term that is not
593
+ code-shaped is left alone either way: a single word is how a park or summit gets
594
+ looked up BY NAME, and `pota: beersel` must not answer with an invented BEERSEL.
595
+ The row is marked, and the reference is stored, logged and exported as typed —
596
+ the same rule ADIF import follows for a malformed `*_REF` (below).
597
+
598
+ The activity search box also treats a leading scope word as a scope — typing
599
+ `pota:` (or tapping a row under "Activity Types") asks the extension serving
600
+ that reference type alone, via a key-scoped `invokeHook` rather than the usual
601
+ fan-out. The word is the **reference type's base name**, lowercased —
602
+ `potaActivation` and `pota` both scope as `pota:` — never the extension key: a
603
+ user-published `ki2d-pota` serving `potaActivation` is still scoped as
604
+ `pota:`, since the operator is adding POTA parks whichever extension supplies
605
+ them. Two enabled extensions serving one type share the word, and the scoped
606
+ search asks both. For the hook call to reach the extension, its `activity`
607
+ hook must be registered under its own **extension key**, and its control
608
+ descriptors keyed `<extensionKey>/<name>` (`pota/activation`) — the core
609
+ derives the hook key from the part before the slash. An extension that
610
+ deviates simply never gets scoped: its rows still add references, but the
611
+ scope would ask a hook that doesn't exist and come back empty.
487
612
 
488
613
  `editable` declares whether a reference of this type has anything to edit
489
614
  once it's on the operation. It defaults to **false**: a plain reference
@@ -702,8 +827,8 @@ interface CommandHook {
702
827
  // CommandInterpretation: {expectsParams?, mixedCase?, error?, describe?, confirm?, commands?: CommandAction[]}
703
828
  // CommandCatalogEntry: {command, describe, params?, category?, needsOperation?, expectsParams?, suggest?}
704
829
  // CommandAction (closed set): {setVfo?, setPower?, updateQso?, addQso?,
705
- // sendSpots?, syncAll?, updateSettings?, updateSetting?, openSettings?,
706
- // updateOperation?, setCallField?, reloadDataFiles?, devNotice?, devStatus?,
830
+ // setKeyerSpeed?, showKeyer?, sendSpots?, syncAll?, updateSettings?, updateSetting?, openSettings?,
831
+ // updateOperation?, markSegment?, setCallField?, reloadDataFiles?, devNotice?, devStatus?,
707
832
  // devProgress?, devHammer?, devSplash?, devOnboard?, devTester?, devPopup?,
708
833
  // devTimeTravel?, toggleExperiment?}
709
834
  ```
@@ -817,9 +942,10 @@ the lookup service's offline `their.guess` decoration. The placement rule
817
942
  needs the existing log: SEED reads it through `ctx.getQsos`, using the
818
943
  `uuid` the logging panel folds into the `operation` map it passes to
819
944
  `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;
945
+ action field — the hook parses "EXP <key-or-alias>" and resolves the token
946
+ against `ctx.experiments` only for its preview ("Enable"/"Disable"); the app
947
+ resolves it again when it flips the state and reports the outcome, since the
948
+ stored per-experiment state lives app-side;
823
949
  DEVTT/DEVTIMETRAVEL move the app clock via the `devTimeTravel` action field —
824
950
  "DEVTT +3h", "DEVTT -1d", "DEVTT 2x", bare "DEVTT" to return to real time — so
825
951
  anything that turns on the date or on elapsed time can be tested without
@@ -923,6 +1049,36 @@ can't, since it also blocks execution — which is why SEED returns it on
923
1049
  every result despite its parameters not being prose: bare "SEED" must be
924
1050
  executable (default count) yet still extendable to "SEED 15 3H".
925
1051
 
1052
+ ## ✅ `template` — rendering on the app's behalf
1053
+
1054
+ The Liquid renderer (templates.md) lives in the SDK and runs only inside the
1055
+ extension runtime. A Dart-side surface that wants a template filled in — a
1056
+ CW message about to be keyed (docs/design/cat.md § CW keying) — reaches it
1057
+ through this hook, registered once by the core `templates` extension under
1058
+ the key `templates`:
1059
+
1060
+ ```ts
1061
+ interface TemplateHook {
1062
+ render(
1063
+ args: { template: string; operation?; qso?; qsoCount?: number },
1064
+ ctx,
1065
+ ): Promise<{ text: string } | { error: string }>
1066
+ // Several templates against one context in one crossing (the keyer
1067
+ // area's button labels): one result per template, in order.
1068
+ renderMany(
1069
+ args: { templates: string[]; operation?; qso?; qsoCount?: number },
1070
+ ctx,
1071
+ ): Promise<{ results: ({ text: string } | { error: string })[] }>
1072
+ }
1073
+ ```
1074
+
1075
+ `operation` and `qso` are the same objects a `panel` hook's `render` gets,
1076
+ and the result is built with the same `templateContext`, so a placeholder
1077
+ means the same thing wherever the app renders it. A template that does not
1078
+ parse comes back as `error` carrying the renderer's own message — a typo in
1079
+ a message the operator typed, to show them, not a bridge failure to report.
1080
+ The app calls them as `ExtensionService.renderTemplate` and `renderTemplates`.
1081
+
926
1082
  ## ✅ `scoring` — per-operation score batches
927
1083
 
928
1084
  One hook call scores an entire operation's QSOs at once (DESIGN.md §4:
@@ -964,7 +1120,12 @@ about a prior contact (`invalidBand`, `missingExchange`, a bare `{value}`)
964
1120
  displaces nothing: core's dupe warning is then the only one there is.
965
1121
 
966
1122
  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
1123
+ that is what tells the host you have ruled on the question. It travels on the
1124
+ live paths too (`scoreQso`, `scoreCandidates`), not just in `qsoScores`. Rule
1125
+ only on what you have seen: a scorer folds only the segments it was running
1126
+ for, so a station absent from YOUR history may have been worked in a stretch
1127
+ you skipped, and staying silent there hands the question to the scorer holding
1128
+ the whole log. And when your
968
1129
  verdict means "worked before, and this one counts anyway", say it in one of
969
1130
  those notice keys (`newPark` is state-parks' spelling of `newRef`) rather than
970
1131
  inventing a spelling of your own: an unrecognized key leaves core's `duplicate`
@@ -999,6 +1160,10 @@ three pure `ContestScorer` functions and gets the loop, the checkpointing and
999
1160
  both live paths for free.
1000
1161
 
1001
1162
  `scoreQso` answers for ONE not-yet-logged QSO — the callsign being typed.
1163
+ A candidate that carries no `startAtMillis` (the draft's time is still
1164
+ automatic) reaches the scorer stamped with the current time on both live
1165
+ paths, so a day-bucketed rule judges it as of now rather than at the epoch; a
1166
+ candidate that states a time keeps it.
1002
1167
  `scoreCandidates` answers the same question for many at once, which is what the
1003
1168
  Spots Panel asks to mark the spots already worked: the log is folded once per
1004
1169
  scorer and every candidate scored against a COPY of the result, so no candidate
@@ -1199,7 +1364,7 @@ and optional OAuth2, surfaced in `SettingsView`'s Accounts panel via
1199
1364
  platform keychain, keyed by the account's `kvKey`. Implemented by: `qrz`
1200
1365
  (username/password) and `sota` (OAuth2 via Keycloak — `sso.sota.org.uk`; the
1201
1366
  host runs the whole PKCE flow itself, see `_doOAuth` in
1202
- `app/lib/views/settings_view.dart`).
1367
+ `app/lib/views/settings/accounts_panel.dart`).
1203
1368
 
1204
1369
  **`label` / `description` / `fields`** — each either a plain value, or a
1205
1370
  function `(args, ctx) => value` called the same way any other hook method is,
package/docs/settings.md CHANGED
@@ -374,15 +374,17 @@ and a collision costs a disambiguation prompt on a query that used to be exact.
374
374
 
375
375
  ## Common Preferences 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**, whose **Application
378
+ Preferences** panel has a collapsible section per declared group and panel,
379
+ led by **Common Preferences**, a short list of the
380
+ handful of settings most users ever touch. Which fields land on Common
381
+ Preferences —
380
382
  and which platforms show a field at all — is declared per-element, not
381
383
  maintained as a separate list somewhere else in the app. Any field, link, or
382
384
  action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
383
385
 
384
- - **`common: true`** — also show this element on Common Preferences, in
385
- addition to All Settings. Off by default. Not the same thing as
386
+ - **`common: true`** — also show this element in the Common Preferences
387
+ section, on top of its own group's. Off by default. Not the same thing as
386
388
  `FormFieldOption.common`/`uncommon` above — that's a `multiselect`
387
389
  option's own disclosure tier (upfront vs. behind "Show more"), a
388
390
  different scope (an option inside one field, not the field itself).
@@ -393,7 +395,7 @@ action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
393
395
 
394
396
  - **`environment: 'ios,android'`** (or `'-web'`, or an array either way) —
395
397
  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
398
+ gate, not a common-vs-everything one: an excluded field disappears from every
397
399
  settings surface on that platform, not just Common Preferences. Every
398
400
  token unprefixed is a whitelist (show ONLY there); every token
399
401
  `-`-prefixed is a blacklist (show everywhere EXCEPT there); mixing the two
@@ -445,7 +447,7 @@ target but niche enough it shouldn't clutter the default view:
445
447
  ```
446
448
 
447
449
  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
450
+ The Settings panel is unaffected either way. It's purely
449
451
  additive: an extra place a field can be reached from, on top of wherever
450
452
  `common`/`environment` already put it.
451
453
 
@@ -453,8 +455,8 @@ additive: an extra place a field can be reached from, on top of wherever
453
455
  elements can both set `target` and both appear in a target-scoped modal —
454
456
  an action's method dispatches through the same extension host call the
455
457
  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.
458
+ opens that account's own standalone dialog, exactly as it would from the
459
+ Settings panel.
458
460
 
459
461
  ## Restarting the runtime after a Tier 2 field commits
460
462