@ham2k/extension-sdk 0.1.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.
package/docs/hooks.md ADDED
@@ -0,0 +1,1379 @@
1
+ # Hook reference
2
+
3
+ Hooks are registered under a **category** with an optional **key** (defaults
4
+ to the extension key) and **priority** (higher runs first; default 0). The
5
+ host invokes them two ways:
6
+
7
+ - `invokeHook(category, key?, method, args)` — best (highest-priority) hook.
8
+ - `invokeHookAll(category, method, args)` — every hook in the category, one
9
+ bridge round-trip, per-source error isolation.
10
+
11
+ All argument and result types below are defined in
12
+ [`extensions/sdk/src/types.ts`](https://github.com/ham2k/halo/blob/main/extensions/sdk/src/types.ts). Every
13
+ hook method receives `(args, ctx)` where `ctx: HookContext` is:
14
+ * `online: boolean` — True if the device has an active network connection.
15
+ * `developerMode: boolean` — True if developer options are enabled in settings.
16
+ * `locale: string` — The active locale code of the application (e.g. `'en'`, `'es'`).
17
+ * `appName?: string` — Platform-appropriate app name ("Ham2K Logger" on
18
+ desktop, "Ham2K Portable Logger" on mobile) — use this instead of a
19
+ hardcoded name when identifying the app to an external service or file
20
+ (e.g. a spot's `source` field, an ADIF `PROGRAMID` header).
21
+ * `edition?: string` — The running build's edition: `'dev' | 'next' | 'prod'`
22
+ (see `packages/halo_build_tools/lib/src/edition.dart`). For a hook whose
23
+ static config must vary per-edition — e.g. SOTA's OAuth redirect URL, one
24
+ per edition and each registered separately with the provider.
25
+ * `enabledExperiments?: string[]` — Keys of every experiment
26
+ (`app/experiments.yaml`) currently on for this user. For a hook whose own
27
+ effect must stay invisible until an experiment gating the surface it
28
+ belongs to has shipped ungated — checked per-call, since gating at the
29
+ whole-extension `manifest.experiments` grain would also hide any other
30
+ hook the same extension registers (e.g. `radio-commands`' POWER command
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.
36
+ * `getOperation?(uuid)` / `getQsos?(operationUuid)` — log-data reads, for
37
+ hooks that only receive part of the picture as arguments (e.g. a `command`
38
+ hook handed the operation it's typed into, needing the existing QSOs —
39
+ dev-commands' SEED). `getOperation` resolves the operation's QSON with
40
+ `uuid` folded in (null for an unknown uuid); `getQsos` resolves every QSO's
41
+ raw QSON, oldest first, excluding soft-deleted records but *including*
42
+ event markers (`band: 'event'`) — or null when the operation is unknown or
43
+ the host has no log access, distinct from `[]` (the operation exists and
44
+ simply has no QSOs yet). Each call is a bridge round-trip carrying a whole
45
+ log — fine at command-execution or export scale, not on per-keystroke
46
+ paths.
47
+ * `getSpotsForCall?(call, maxAgeMinutes = 30)` — the spots the host has
48
+ already fetched for `call`, no older than `maxAgeMinutes` (measured on the
49
+ spot's own `spot.timeInMillis`, not on when it was fetched), newest first.
50
+ Reads the host's cache and **never triggers a fetch**, so unlike the log
51
+ reads above it is cheap enough for the lookup path; it resolves `[]`
52
+ whenever the cache is cold or the host keeps no spots. Used by
53
+ `spot-history` to pull a hunted park or summit into the QSO being logged.
54
+ * `getHistoryForCall?(call)` — every non-deleted QSO ever logged with `call`,
55
+ across every operation, most recent first, as raw QSON — the one log read
56
+ that is cross-operation rather than scoped to a single one, since it
57
+ answers "who is this station" from the whole log rather than "what's in
58
+ this operation". Resolves `[]` for no match or no log access; unlike
59
+ `getQsos` there is no "unknown call" to distinguish from "no history".
60
+ Used by `call-history` to fold the most recent past QSO's fields into a
61
+ lookup result.
62
+
63
+ Status legend: ✅ implemented · 🔜 planned (design in DESIGN.md §4).
64
+
65
+ ---
66
+
67
+ ## ✅ `lookup` — callsign lookups
68
+
69
+ Data sources for callsign/QSO enrichment (QRZ, HamDB, POTA, SOTA…). Never
70
+ called directly from Dart — the `lookupService/core` hook below fans out to
71
+ every registered `lookup` hook **sequentially, in priority order**, folding
72
+ each result into the next hook's input, and applies the locationScope-aware
73
+ merge rules (see below). Fired debounced from the logging panel, never
74
+ per-keystroke.
75
+
76
+ ```ts
77
+ interface LookupHook {
78
+ lookupCall(args: { callInfo: AnnotatedCallInfo; qso; operation }, ctx): Promise<LookupResult>
79
+ }
80
+ // AnnotatedCallInfo: CallInfo & {guess?: CallInfo, lookups?: CallInfoLookup[]}
81
+ // LookupResult: (CallInfoLookup & {locAccuracy?, refs?: RefInfo[], online?: boolean})[] —
82
+ // zero or more records; [] to skip
83
+ // CallInfoLookup: CallInfo & {source?, scope?: 'general'|'operation'|'qso', notes?, history?}
84
+ ```
85
+
86
+ Set `online: true` on a result ONLY when answering it required an actual
87
+ network round-trip — never on one your source could have produced with
88
+ `ctx.online` false. `LookupQueueService._hasOnlineLookup` reads this flag
89
+ generically to decide whether a saved QSO already has a real online answer
90
+ worth not re-fetching; omitting it (the default) is correct for anything
91
+ from local/offline data — a log query, a cached reference file, notes — even
92
+ if that source usually needs the network for OTHER calls (POTA/SOTA's
93
+ `fetchPark`/`fetchSummit` try a local reference-data cache before falling
94
+ back to a live fetch, so they leave it unset rather than risk mistagging a
95
+ cache hit). The one-time offline annotation pre-step never sets it either.
96
+
97
+ A result's `refs` **proposes** activity references for the QSO being logged.
98
+ The host applies only those whose `type` the QSO does not already carry — a
99
+ lookup never overrides a reference the operator entered — and re-runs the
100
+ lookup once afterwards so QSO-scoped hooks (POTA/SOTA) see the new ref and
101
+ can resolve its grid. Because an already-applied type is filtered out on the
102
+ next pass, that re-run settles instead of cycling.
103
+
104
+ Return `[]` for "can't answer" (not configured, not found, or `!ctx.online`
105
+ and this source has no offline data) — never throw for expected conditions.
106
+ `callInfo`/`qso`/`operation` are the full QSON-shaped objects (all from
107
+ `@ham2k/lib-qson-tools`'s `CallInfo`/`QSON` types), so e.g. a POTA lookup can
108
+ inspect `qso.refs` for a hunted park reference. Implemented by: `qrz`,
109
+ `ham2k-lookup` (`scope: 'general'`), `pota`, `sota` (`scope: 'qso'`, grid of
110
+ any hunted ref on the current QSO), `call-notes` (`scope: 'general'`,
111
+ offline — combined `note`/`emoji` extra fields from the Hams of Note file,
112
+ issue #17), and `call-history` (`scope: 'general'`, offline — the most
113
+ recent past QSO's own identity fields, from `ctx.getHistoryForCall`).
114
+ Registers at **priority -1** — below every other lookup source, and below
115
+ the 0 an undeclared hook defaults to: the log is a fallback, filling only
116
+ what nothing else answered, and that holds even for a field the operator
117
+ TYPED on the past QSO — a live source describes the station today, the log
118
+ describes it whenever it was last worked. It still labels a carried-forward
119
+ field by where it came from (`Call History` for a typed one, `Call History
120
+ (guess)` for a guessed one), and a station's whole location travels in one
121
+ of the two so the merge's grid/city reconciliation cannot split it — see
122
+ `extensions/lookups/call-history/src/index.ts`.
123
+
124
+ ## ✅ `recentContextLookup` — live-context lookups
125
+
126
+ Same `LookupHook` interface, run by the same orchestrator immediately after
127
+ the `lookup` fan-out and folded through the same merge — but **only when the
128
+ request sets `recentContext`**, which the logging panel does solely while
129
+ composing a new contact.
130
+
131
+ The split is about what the answer depends on. A `lookup` hook answers *who
132
+ is this callsign*, which is true whenever asked. A `recentContextLookup` hook
133
+ answers *what is this station doing right now* — from spots, from the last
134
+ minutes of the log, from whatever else is live — and that is a question about
135
+ a contact in progress. Asked about a QSO logged an hour ago, or about every
136
+ callsign on the spots board, the answer is not just useless but wrong.
137
+
138
+ Registering here rather than under `lookup` is therefore correctness first
139
+ and economy second, though the economy is real: the spots board runs a lookup
140
+ per distinct spotted call, and the background queue one per saved QSO, so an
141
+ ordinary `lookup` hook is asked hundreds of times for answers nobody reads.
142
+
143
+ Implemented by: `spot-history` (offline — `refs` from the host's recent
144
+ spots, see `ctx.getSpotsForCall`).
145
+
146
+ ## ✅ `lookupService` — the lookup orchestrator
147
+
148
+ One hook call resolves an entire callsign decoration pass: the hardcoded
149
+ callsign-annotation pre-step (via `@ham2k/lib-country-files`, always first,
150
+ regardless of any other hook's priority) plus every registered `lookup` hook
151
+ above — and, when `recentContext` is set, the `recentContextLookup` hooks
152
+ after them — merged according to the `locationScope` rules, all inside the
153
+ runtime, mirroring `scoring`'s "one coarse call in, everything back out"
154
+ shape.
155
+
156
+ ```ts
157
+ interface LookupServiceHook {
158
+ lookupCallInfo(
159
+ args: { callInfo: CallInfo; qso; operation; recentContext?: boolean },
160
+ ctx,
161
+ ): Promise<{ lookups: CallInfoLookup[]; guess: CallInfo }>
162
+ }
163
+ ```
164
+
165
+ **locationScope merge rules** (`CallInfo.locationScope`, ranked
166
+ `qth < prefixed < portable`, most general to most specific):
167
+ - A result whose `locationScope` is strictly more specific than the current
168
+ guess's clears all location fields (`locSource, lat, lon, grid, city,
169
+ state, county, country`) before merging the result in, and the guess
170
+ adopts the more specific scope.
171
+ - Otherwise, priority order applies as usual: a field already present in the
172
+ guess is never overwritten by a later (lower-priority) result. A
173
+ location field additionally requires the result's own scope to be at
174
+ least as specific as the guess's current scope — a more general-scoped
175
+ result can never populate a slot a more specific one already claimed,
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.
187
+ - Grid/lat-lon reconciliation, scoped to fields the result actually set:
188
+ grid-only → lat/lon computed from the grid; lat/lon-only → a 6-digit grid
189
+ computed from them; both given → taken verbatim. Setting grid/lat/lon
190
+ clears city/state/country/county unless the *same* result also supplies
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.
194
+ - `notes`/`history` are per-`CallInfoLookup` fields, not part of the merged
195
+ `guess` — they simply accumulate, unconditionally, in the returned
196
+ `lookups[]` array.
197
+ - **`locSource: 'prefix'` is reserved**, and a `lat`/`lon` merged under it is
198
+ never read as a coordinate (`locationFromGuess`, halo_core). It marks
199
+ lib-country-files' DXCC entity centroid, whose longitude follows cty.dat's
200
+ west-positive convention — the opposite of every other coordinate here. A
201
+ source reporting a real position must leave `locSource` unset or name
202
+ itself; tagging one `'prefix'` gets its coordinate discarded.
203
+
204
+ Implemented by: `lookups` (key `core`). The merge algorithm itself
205
+ (`mergeLookupIntoGuess`/`runLookupPipeline` in
206
+ `extensions/core/lookups/src/mergeLookup.ts`) is a pure, host-independent
207
+ function with its own unit test suite
208
+ (`extensions/core/lookups/src/mergeLookup.test.ts`, run via `npm test` in
209
+ `extensions/`) — this is the highest-defect-risk logic in the feature, so it
210
+ is tested in isolation from the hook/bridge plumbing.
211
+
212
+ ## ✅ `spots` — spot sources and spotting (ham2k/halo-dist#9, #10)
213
+
214
+ ```ts
215
+ interface SpotsHook {
216
+ sourceName: string
217
+ fetchSpots(args: {}, ctx): Promise<Spot[]>
218
+ isSelfSpotEnabled?(args: { operation }, ctx): Promise<SpotEligibility>
219
+ isOtherSpotEnabled?(args: { qso }, ctx): Promise<SpotEligibility>
220
+ postSelfSpot?(args: PostSelfSpotRequest, ctx): Promise<PostResult>
221
+ postOtherSpot?(args: PostOtherSpotRequest, ctx): Promise<PostResult>
222
+ }
223
+ // Spot: {their: {call}, freq?, band?, mode?, refs?: Ref[],
224
+ // spot: {timeInMillis, source, label?, sourceInfo?}}
225
+ // SpotEligibility: {enabled, icon?}
226
+ // PostResult: {ok, message?}
227
+ // PostSelfSpotRequest: {operation, freq, mode?, comment?}
228
+ // PostOtherSpotRequest: {qso, comment?, spotterCall?}
229
+ ```
230
+
231
+ Map upstream spots into QSON-shaped spot objects; filter out QRT/stale
232
+ entries at the source. `Spot` may also carry an `icon` (MDI/FontAwesome
233
+ name, same convention as `activity` controls). Implemented by: `pota`,
234
+ `sota` (SOTAWatch) — fetch only.
235
+
236
+ The four posting methods are all optional; a hook that omits them just
237
+ never appears in the Spotting control's icon row (same "silently absent"
238
+ convention as `export`'s `suggestExportOptions`). `isSelfSpotEnabled`/
239
+ `isOtherSpotEnabled` are checked once to decide whether to show a source's
240
+ icon at all (e.g. only when the operation/QSO has a matching activation
241
+ ref); `postSelfSpot`/`postOtherSpot` are only called for sources that
242
+ reported `enabled: true`. Implemented by: `pota` (self + other, posts
243
+ directly to the POTA API, no auth required). `sota`'s spot API requires an
244
+ authenticated account app-halo doesn't have yet, so `sota` stays
245
+ fetch-only for now.
246
+
247
+ ## ✅ `export` — log file generation
248
+
249
+ ```ts
250
+ interface ExportHook {
251
+ suggestExportOptions?(args: ExportOptionsRequest, ctx): Promise<ExportOption[]>
252
+ generateExport(args: ExportRequest, ctx): Promise<ExportResult>
253
+ }
254
+ // ExportOptionsRequest: {operation, qsos, compactFilenames?}
255
+ // ExportOption: {exportType, format, label, filename?, icon?, color?, refType?, priority?, selectedByDefault?}
256
+ // ExportRequest: {operation, qsos, exportType?, compactFilenames?} — full QSON, one coarse call
257
+ // ExportResult: {filename, mimeType, content}
258
+ ```
259
+
260
+ Two-step flow: the Exports Panel calls `suggestExportOptions` on every
261
+ `export` hook to build its selectable list (a hook that omits it never
262
+ appears in the panel), then calls `generateExport` — passing back the
263
+ chosen option's `exportType` — only for the options the user selected. The
264
+ core owns file I/O and save/share; the hook only produces content.
265
+ Exporters should compose per-QSO program fields from `adifFields` hooks (see
266
+ below) rather than knowing about specific activities. Implemented by: `adif`
267
+ (key `adif`, one option); Cabrillo will register here too.
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
+
278
+ **Filenames.** Build them with the SDK's `exportFilename` rather than
279
+ spelling one out, passing the request's `compactFilenames` straight through —
280
+ that is what makes the user's "Use compact file names" setting apply to your
281
+ export, and what keeps every extension's names consistent:
282
+
283
+ ```ts
284
+ filename: exportFilename({
285
+ stationCall: operation.stationCall,
286
+ ref: 'US-1234', // or `activity: 'CQ-WW-CW'`, or neither
287
+ startAtMillis: startMillisOf(operation, qsos),
288
+ extension: 'adi',
289
+ compact: args.compactFilenames,
290
+ })
291
+ // normal → "2026-07-27 N0CALL at US-1234.adi"
292
+ // compact → "N0CALL@US-1234-20260727.adi"
293
+ ```
294
+
295
+ Set `filename` on the OPTION, not just on the result: the panel shows the
296
+ option's name, and the core writes the file under it. A name computed again
297
+ in `generateExport` sees a different input — that call is handed only the
298
+ QSOs the option's `refType` covers — so the two would otherwise disagree in
299
+ front of the user.
300
+
301
+ ## ✅ `ref:<type>` — reference handlers
302
+
303
+ One category **per ref type** (e.g. `ref:pota`, `ref:potaActivation`),
304
+ following app-polo's QSON conventions. Handles validation and enrichment of
305
+ activity references on QSOs and operations.
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
+
312
+ ```ts
313
+ interface RefHandlerHook {
314
+ validateRef?(args: { ref: Ref }, ctx): Promise<{valid: boolean; normalized?: string}>
315
+ decorateRef?(args: { ref: Ref }, ctx): Promise<Ref> // may hit the network
316
+ suggestOperationTitle?(args: { ref: Ref; operation }, ctx): Promise<TitleSuggestion | null>
317
+ linkForRef?(args: { ref: Ref }, ctx): Promise<RefLink | null> // {url, label?}
318
+ geojsonUrlForRef?(args: { ref: Ref }, ctx): Promise<RefGeojson | null>
319
+ }
320
+ // Ref: {type, ref?, name?, label?, shortLabel?, grid?, location?, program?, ...}
321
+ // TitleSuggestion: {title?, for?, at?, subtitle?, priority?} — a fragment of
322
+ // the operation's title/subtitle sentence (ham2k/halo-dist#174). Only
323
+ // return one for the ref type that means "this operation is happening AT/FOR
324
+ // this reference" (e.g. an activation, not a hunted ref) — a hunting ref
325
+ // contributes nothing here. `priority` breaks ties when multiple refs supply
326
+ // the same fragment kind (higher wins); default 0.
327
+ ```
328
+
329
+ `decorateRef` fills names/locations/labels (park names via the POTA API,
330
+ etc.); results update ref chips asynchronously in the UI. Implemented by:
331
+ `pota` (both types).
332
+
333
+ `linkForRef` says where a reference can be read about on the web — the
334
+ program's own page for it (POTA's park page, SOTA's summit page), or, for a
335
+ contest, the rules the event is run under. The Operation Setup sheet asks for
336
+ one link per ref every time the operation's refs change and renders a web
337
+ button on the row; **nothing is stored on the ref**, so a program that moves
338
+ its URLs takes effect with the extension rather than needing saved operations
339
+ rewritten. Build the URL from the reference and return: this hook must not hit
340
+ the network. Return `null` for a reference the program would not accept (a
341
+ half-typed code links to a page that cannot exist) and implement nothing at all
342
+ for a program whose list is a download with no page per reference — a link into
343
+ a search form is worse than no link, and the row simply shows no button.
344
+ `label` names the destination for assistive technology, wrapped in the app's
345
+ own localized phrasing — set it only where the destination is NOT the row's own
346
+ subject, as a contest's rules are; leave it off for a reference's own page and
347
+ the app names the row instead. A handler registered for another program's ref
348
+ type (WCA answers for `ecaActivation`) must leave it off: a label built from
349
+ its own program name reads the wrong program aloud. Programs built on the SDK's `referenceActivity` factory get this
350
+ from one `linkUrl` config line.
351
+
352
+ `geojsonUrlForRef` says where the reference's own footprint can be fetched as
353
+ GeoJSON — a SOTA summit's activation zone, and whatever equivalent another
354
+ program publishes. Same contract as `linkForRef`: build the URL from the
355
+ reference, **never hit the network**, return `null` for a reference the program
356
+ has no shape for. The core does the fetching and the caching (`GeoshapeService`),
357
+ so an extension never decides how long a device keeps a file or how large one may
358
+ be; it only names the two ages the core should use:
359
+
360
+ ```ts
361
+ // RefGeojson: {url, maxAgeInDays?, missMaxAgeInDays?}
362
+ ```
363
+
364
+ `maxAgeInDays` (default 30) is how long a fetched shape stays usable offline —
365
+ boundaries move on the scale of surveys. `missMaxAgeInDays` (default 1) is how
366
+ long the core remembers that a reference has NO shape, deliberately shorter so a
367
+ newly surveyed zone appears within a day rather than a month. Both are ignored
368
+ unless positive: a zero would re-fetch on every rebuild of the map.
369
+
370
+ The URL must be `https`, and the core does not follow redirects to reach it.
371
+ Unlike a link, this one is fetched rather than handed to the platform, and a
372
+ plaintext one — named outright, or arrived at through a `Location:` header —
373
+ would downgrade every activator's connection on a program's say-so.
374
+
375
+ Shapes are asked for sparingly: the Map tab asks about the operation's own
376
+ references, and the Activities pane's results map about those plus the one
377
+ suggestion whose card is open. Neither fetches a shape per search hit — a search
378
+ answers with dozens of references, and at the zoom that shows dozens of them a
379
+ footprint is a few pixels across. Implemented by: `sota` (both types, via
380
+ SOTLAS's `az.sotl.as`, used with the operators' permission).
381
+
382
+ `suggestOperationTitle` lets an activity build a natural-language operation
383
+ title/subtitle from its refs — "at Yosemite NP" (POTA), "for Field Day"
384
+ (contest programs, when ported) — mirroring app-polo's
385
+ `buildOperationTitle`. The core (`OperationTitleService`) calls this once per
386
+ ref, groups the non-null results by fragment kind (`title`/`for`/`at`,
387
+ highest `priority` first within a kind), and joins them with a **localized**
388
+ "for"/"at" — unlike app-polo, which hardcodes those joiner words in English;
389
+ HaLo threads them through `strings.g.dart` instead, so extensions only ever
390
+ return proper nouns/names (locale-agnostic or self-translated via their own
391
+ `tFor`), never the connective prose. Implemented by: `pota`, `sota`
392
+ (activation ref types only).
393
+
394
+ ## ✅ `activity` — declarative UI contributions
395
+
396
+ The UI-catalog seam (DESIGN.md §5): activities describe controls as data;
397
+ the core renders them with native widgets and calls back into ref handlers
398
+ for behavior.
399
+
400
+ ```ts
401
+ interface ActivityHook {
402
+ // `qso` is the in-progress QSO, including `their.guess` from the callsign
403
+ // lookup — re-supplied when a lookup resolves, so a control can key a
404
+ // dynamic label/placeholder/suggestedValue off who's being worked.
405
+ loggingControls?(args: { operation, qso? }, ctx): Promise<LoggingControlDescriptor[]>
406
+ operationControls?(args: { operation, qso? }, ctx): Promise<LoggingControlDescriptor[]>
407
+ suggest?(args: SuggestArgs, ctx): Promise<ActivitySuggestion[]>
408
+ processQsoBeforeSave?(args: { qso, operation }, ctx): Promise<Patch | null>
409
+ }
410
+ // LoggingControlDescriptor: {key, label, icon?, color?, order?, optionType?,
411
+ // allowsMultiple?, editable?, input}
412
+ // SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped?}
413
+ // ActivitySuggestion: Ref & {distance?, relevance?, allowsMultiple?}
414
+
415
+ // The input catalog. A new kind is earned by a different WIDGET, never by
416
+ // different validation — so a pattern-constrained field is `text` with a
417
+ // `pattern`, not a kind of its own (docs/design/contests.md §5.3).
418
+ // {kind: 'refList', refType, placeholder?, pattern?, transforms?}
419
+ // {kind: 'text', refType, field, placeholder?, pattern?, transforms?,
420
+ // numeric?, uppercase? /* default true */, maxLength?,
421
+ // suggestedValue?}
422
+ // {kind: 'options', refType, field, options: [{code, name?}], matchOn?,
423
+ // allowFreeform?, preferredCodes?, minCharsForSuggestions?,
424
+ // placeholder?, pattern?, transforms?, numeric?, uppercase?,
425
+ // maxLength?, suggestedValue?}
426
+ // {kind: 'serial', refType, field, sequence: {key, scope?, start?, padTo?},
427
+ // editable?}
428
+ // {kind: 'form', refType, form} // operationControls only
429
+ //
430
+ // `pattern` is anchored (`^(?:…)$`) and matched case-insensitively for every
431
+ // kind; a malformed regex disables validation rather than throwing. A
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.
436
+ //
437
+ // `text`, `options` and `serial` values round-trip onto a ref of `refType` under
438
+ // `field`, through the same core-owned path refList writes refs — several may
439
+ // share one refType and are merged into a single ref. `suggestedValue` fills the
440
+ // field only while the operator hasn't typed into it. A `serial`'s number is
441
+ // allocated by the core (docs/design/contests.md §5.8), and all three kinds
442
+ // render as PRIMARY fields, shown only when the operation is running that
443
+ // activity.
444
+ //
445
+ // An `options` field searches a KNOWN SET: it shows a ranked suggestion line and
446
+ // Space accepts the top hit before advancing, and a value outside the set tints
447
+ // the field unless `allowFreeform`. Space accepts only a suggestion the typed
448
+ // text is a PREFIX of, and never from an empty field — a substring hit is worth
449
+ // showing, but swapping one in rewrites the operator's own text into something it
450
+ // isn't the start of. The suggestion line is added to the info area, above
451
+ // whatever it was already showing, so a DUPE stays visible while an exchange
452
+ // field has focus. The set is inline — §5.3's registered `optionSet` is deferred
453
+ // (nothing can write rows into the lookups table today).
454
+ // `text`, `serial` and `options` are logging-panel kinds: operation settings
455
+ // render `refList` and `form` and silently ignore the rest.
456
+ //
457
+ // `optionType` is NOT yet honored by the core.
458
+ ```
459
+
460
+ `icon` is a Material Design Icons name (e.g. `'pine-tree'`), or a FontAwesome6
461
+ solid-style name prefixed with `'fa-'` (e.g. `'fa-tree'`) — same convention
462
+ app-polo itself uses. Any name in either set works with no core-repo change;
463
+ an unrecognized name falls back to a placeholder glyph rather than erroring.
464
+
465
+ `color` is an optional `'#RRGGBB'` accent (e.g. POTA's `'#068541'`) the core
466
+ may use sparingly to highlight this control's icon — a tinted circle behind
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
470
+ mean no accent.
471
+
472
+ `processQsoBeforeSave` is the last chance to shape a QSO before it is written,
473
+ for projecting data the extension owns into the generic QSON fields the rest of
474
+ the app reads. A contest keeps its exchange on its own ref (`theirZone`,
475
+ `theirSerial`, …), which nothing generic knows how to read — mirroring it into
476
+ `their.exchange` is what fills the QSO row's exchange column and a plain ADIF
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
481
+ `their`, which merge one level deep, so `{their: {exchange: 'ZN5'}}` sets that
482
+ field and leaves the callsign alone. `refs` and `uuid` in a patch are ignored —
483
+ refs are shared with the core and with other activities. Return null to do
484
+ nothing. Called on every save path (single QSO, call list, call stack, edit),
485
+ and skipped entirely when the operation has no refs, so ordinary logging pays
486
+ no bridge crossing. A serial the CORE allocates at commit is NOT visible here
487
+ for the second and later calls of a call list (docs/design/contests.md §5.8).
488
+
489
+ `loggingControls` appear on the logging panel (per-QSO refs);
490
+ `operationControls` appear in the operation's Activities dialog (e.g. "which
491
+ park am I activating"). Catalog components are versioned core code —
492
+ proposing a new `input.kind` is a core PR, deliberately (DESIGN.md §5's
493
+ "catalog creep" risk). Implemented by: `pota`.
494
+
495
+ `suggest` powers the Activities view's "add activity" search: called with a
496
+ `location`/`callsign` the core resolves from the operation if set, else the
497
+ station defaults (extensions never need to know which). Called with no
498
+ `searchTerm` for nearby suggestions, or with one for name/code search — an
499
+ extension may honor either, both, or neither (returning `[]`). `scoped` is
500
+ true when THIS extension alone was asked — the operator tapped its "Activity
501
+ Types" row or typed its key as a scope — which a bare scope otherwise cannot be
502
+ told from the nearby list, since neither carries a `searchTerm`. An extension
503
+ that keeps itself out of the nearby list (`cwt`, offered only around a session)
504
+ answers a scoped call anyway: it is the one being asked. The core fans
505
+ out to every `activity` hook via `invokeHookAll`, merges results, and ranks them
506
+ in TWO BANDS (`app/lib/tools/activity_suggestions.dart`): everything carrying a
507
+ `distance` first, by `distance / relevance` ascending — a more relevant hit
508
+ counts as nearer — then everything without one, by DESCENDING `relevance`, with
509
+ ties broken on the order the extension emitted them. So `relevance` alone orders
510
+ a set of placeless suggestions (dated contest events, say) among themselves, but
511
+ never lifts one above a located park. Implemented by: `pota`, `sota`,
512
+ `stateparks`, `cwt`.
513
+
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.
544
+
545
+ `editable` declares whether a reference of this type has anything to edit
546
+ once it's on the operation. It defaults to **false**: a plain reference
547
+ activity carries only the code its own `suggest` already resolved, so the
548
+ Current Activities row stays inert — the user removes it and picks again
549
+ rather than retyping a code no lookup would re-check. `pota` and `sota` leave
550
+ it off. Set it true for a type whose references carry more than the code.
551
+
552
+ A **`kind: 'form'` control prompts on add.** Tapping one of its suggestions
553
+ saves the reference and then opens the setup form over it, because a contest
554
+ that has not been set up cannot score and nothing on its row says so. The
555
+ save comes first, so cancelling the form leaves the activity added and
556
+ unconfigured rather than undoing the tap. Two consequences for the hook:
557
+
558
+ - The suggestion still has to carry its own `label`/`shortLabel`/`program`
559
+ (§5.4 of contests.md) — the cancel path never reaches `decorateRef`, so a
560
+ suggestion that relies on being decorated reads as a blank row.
561
+ - `decorateRef` runs on whatever the form submits, immediately after. It is
562
+ handed the suggestion's own fields merged with the form's, so it must
563
+ tolerate being called with a reference it has already decorated once.
564
+
565
+ A `refList` control does **not** prompt: its suggestion already carries the
566
+ code that identifies it, so a form could only ask the user to retype what
567
+ they just picked.
568
+
569
+ `allowsMultiple` declares whether an operation may carry more than one
570
+ reference of that type at a time. It defaults to **false**: picking one of
571
+ the type's references **replaces** the operation's existing reference of
572
+ that type rather than adding alongside it. A type that genuinely stacks
573
+ declares `allowsMultiple: true` — `pota` does, for n-fers. Declare it on the
574
+ suggestion (for the search/nearby list) *and* on the type's
575
+ `LoggingControlDescriptor` (for references the user types in by hand); the
576
+ core reads the suggestion's value first and falls back to the control's.
577
+ Selecting a reference the operation already has is still a duplicate, not a
578
+ replacement — the core flashes the existing row instead of adding it twice.
579
+
580
+ ## ✅ `adifFields` — per-QSO export field contributions
581
+
582
+ How activities inject program fields into ADIF exports without the exporter
583
+ knowing them. Called by the `adif` extension **inside the runtime** via
584
+ `hooks.invokeAll` — no bridge crossing.
585
+
586
+ ```ts
587
+ interface AdifFieldsHook {
588
+ fieldsForOneQSO(args: { qso, operation }, ctx): Promise<{name, value}[]>
589
+ // One field set PER ADIF RECORD this contact should produce.
590
+ fieldCombinationsForOneQSO?(args: { qso, operation }, ctx): Promise<{name, value}[][]>
591
+ }
592
+ ```
593
+
594
+ POTA contributes `SIG/SIG_INFO/POTA_REF` (hunted refs on the QSO) and
595
+ `MY_SIG/MY_SIG_INFO/MY_POTA_REF` (activation refs on the operation).
596
+
597
+ `fieldCombinationsForOneQSO` exists because **one contact can owe several ADIF
598
+ records**: work a station standing in two parks and each park is submitted
599
+ separately, which is also why it counts twice toward an activation
600
+ (docs/design/activities.md §4). Return one field set per record; the exporter
601
+ writes that many, shifting each successive record's `QSO_DATE`/`TIME_ON` by a
602
+ second so otherwise-identical records aren't read as duplicates (app-polo's
603
+ `qsonToADIF.js:144` does the same).
604
+
605
+ Record count is the largest any hook asks for. A hook returning a single set
606
+ is describing the *contact*, so its fields go on every record; only a hook
607
+ returning several is describing individual records. Declaring both methods is
608
+ fine and expected — a hook that does is asked only for its combinations, so
609
+ its plain fields aren't written twice. Implemented by: `pota`.
610
+
611
+ **Only the export's OWN hook is asked.** A file submitted to one program is
612
+ that program's log, so an export that delegates the ADIF build names itself as
613
+ `mainHandler` in the `ExportRequest` — app-polo's "main handler"
614
+ (`qsonToADIF`'s `handler`) — and the `adif` extension invokes that hook alone,
615
+ not the whole category. Activate a park that is also a lighthouse and each
616
+ file claims its own reference; ask every hook and both programs'
617
+ `MY_SIG`/`MY_SIG_INFO` pairs land in every record, leaving the receiving
618
+ program to guess which is its. The delegate cannot work out who called it, so
619
+ naming yourself is not optional.
620
+
621
+ The full ADIF export names no `mainHandler` and every hook contributes —
622
+ polo's `includeOtherRefs`, which only its full export sets. That is the export
623
+ claiming no program: the complete copy the operator keeps.
624
+
625
+ An export can also name a **list** of other hooks it accepts,
626
+ `includeFieldsFrom` — polo's all-or-nothing flag narrowed to what the program
627
+ itself says it reads. It is for what DESCRIBES a contact rather than claiming
628
+ it: POTA's exports list `satellites`, because a park worked through a bird is
629
+ still a park activation and POTA's uploader reads `PROP_MODE`/`SAT_NAME`. A
630
+ listed key that answers nothing is fine — the operator may have that extension
631
+ off — where a `mainHandler` that answers nothing is an error, since that file
632
+ would claim no reference at all.
633
+
634
+ **A field name is written once per record, and the first to carry a value
635
+ wins.** ADIF gives no meaning to a repeated field, so where two hooks answer
636
+ the same name — the full export asks everyone, and a park and a summit both
637
+ answer `MY_SIG` — the later one is dropped, and the contact's own fields
638
+ outrank all of them. Hooks are asked main handler first, then each
639
+ `includeFieldsFrom` key in the order given, so that order decides — whichever
640
+ of the two methods each hook answered. app-polo resolves a collision the same
641
+ way ("keep the first one defined"). The **full export**, which asks everyone,
642
+ has no such order: hooks answering `fieldCombinationsForOneQSO` are asked as one
643
+ group and hooks answering `fieldsForOneQSO` as another, so which of two programs
644
+ keeps a shared `MY_SIG` there is not something either of them chose.
645
+
646
+ Because the winner is picked per field name, **answer a pair whole or not at
647
+ all**: a hook offering `MY_SIG` without `MY_SIG_INFO` takes the name and leaves
648
+ the next hook's reference under it, which claims another program's park for
649
+ yours. `custom` is the one activity whose two halves are free text, and it
650
+ drops both when either is empty.
651
+
652
+ ## ✅ `adifImport` — per-QSO reference recovery on import
653
+
654
+ The inverse of `adifFields`. The core's ADIF parser
655
+ (`packages/halo_core/lib/src/adif/`) maps **universal ADIF only** and produces
656
+ **no references at all** — `SIG`/`SIG_INFO`, `SOTA`, `IOTA`, `CONTEST_ID` and
657
+ every `*_REF` field mean nothing to it. This hook is the entire ref vocabulary
658
+ on import, not a refinement of a core guess. An import with no `adifImport`
659
+ hook registered yields ref-less QSOs.
660
+
661
+ ```ts
662
+ interface AdifImportHook {
663
+ refsForRecords(
664
+ args: { records: { fields: Record<string, string> }[] },
665
+ ctx: HookContext,
666
+ ): Promise<({ refs?: Ref[] } | null)[]>
667
+ }
668
+ ```
669
+
670
+ `fields` is the record's raw ADIF, names lowercased and values entity-decoded.
671
+
672
+ **Batched, unlike every other per-QSO hook.** `adifFields` is called by the
673
+ `adif` extension *inside the runtime*, so a call per QSO costs nothing there.
674
+ This one is driven from Dart (`app/lib/services/adif_import_service.dart`), so
675
+ each call is a bridge crossing — a ten-thousand-contact import is twenty
676
+ crossings, not ten thousand. Batch size is 500.
677
+
678
+ Return **one entry per record, positionally**; `null` for a record the hook has
679
+ nothing to say about. Never filter the empties out: the core matches results
680
+ back to records by index, and a shortened array would land one QSO's references
681
+ on another. A reply whose length doesn't match the batch is discarded whole for
682
+ that reason. Refs from different hooks accumulate (a park-to-park contact made
683
+ during a summit activation legitimately carries both), deduped by `type`+`ref`.
684
+
685
+ A returned ref may carry **`for: 'operation'`** to say it belongs on the
686
+ operation rather than the QSO — an activation. ADIF has no operation, so it
687
+ writes the activation onto every record, and a hook only ever sees one record;
688
+ tagging is how it says where the reference actually goes. The importer moves it
689
+ and **consumes the tag**, since once the reference sits on the operation its
690
+ position is the answer. Untagged means the QSO. Without this the only test
691
+ available to the importer is "every record carries it", which cannot tell an
692
+ activation from a short log where everyone hunted the same reference.
693
+ `activityAdifImport` tags the activation side for you.
694
+
695
+ Returning an object rather than a bare `Ref[]` leaves room for the other things
696
+ an importer may eventually owe a QSO — a contest serial out of `STX`/`SRX`,
697
+ an exchange — as an additive change.
698
+
699
+ Most reference programs write the same `SIG`/`SIG_INFO`/`MY_SIG`/`MY_SIG_INFO`
700
+ pair plus their own comma-separated `*_REF` list, so the SDK's
701
+ `activityAdifImport({sig, huntingType, activationType, refField, normalize})`
702
+ builds the whole hook.
703
+
704
+ **A reference the program's own pattern rejects is imported anyway**, as
705
+ written. By the time `normalize` runs, the file has already said whose the
706
+ reference is — the `*_REF` list field is program-specific, and `SIG_INFO` is
707
+ gated on `SIG` — so a pattern failure means *malformed*, not *foreign*.
708
+ `decorateRef` labels it invalid, which puts it in front of the operator to fix;
709
+ discarding it would lose the only text a correction could work from, and lose it
710
+ without a word. `normalize` still repairs what it can (POTA turns a dashless
711
+ `us1234` into `US-1234`); only its failures fall through as raw text. The same
712
+ rule governs `satellites`, which imports a bird it cannot resolve to a
713
+ transponder rather than dropping the QSO's satellite.
714
+
715
+ It reads **both** sources and merges them, deduped on the normalized value.
716
+ Neither subsumes the other: `*_REF` lists every reference but not every program
717
+ writes it (SOTA's own exports carry `SOTA_REF` and no `SIG` at all), while
718
+ other software writes the same reference only as `SIG`/`SIG_INFO`. The union is
719
+ safe *because* it dedupes — taking `SIG_INFO` in preference to the list would
720
+ import an n-fer as a one-fer, since `SIG_INFO` names only the primary
721
+ reference; taking both cannot, because the primary is already in the list. The
722
+ `SIG` value is still checked before `SIG_INFO` is claimed, or every program
723
+ would import every other program's references.
724
+
725
+ `referenceActivity()` returns a ready-built `adifImportHook` from the config it
726
+ already has, so an activity using that factory opts in with one `registerHook`
727
+ line. Implemented by every activity except two:
728
+
729
+ - **`satellites`** writes `SAT_NAME`/`BAND_RX` and no `SIG` at all, so it
730
+ hand-writes its own. Its reference is a *transponder* while ADIF names only
731
+ the bird, so the hook recovers the transponder where it can — narrowing by
732
+ `BAND_RX` (the downlink) and the record's own `BAND`/`FREQ` (the uplink) —
733
+ and otherwise imports the bare satellite the file named. It validates
734
+ nothing: an unknown bird, a contradictory `BAND_RX` and a record with no band
735
+ all import. The reference degrades cleanly, since `decorateRef` falls back to
736
+ the whole string and the exporter writes `SAT_NAME` from the same segment.
737
+ - **`custom`** deliberately registers none. Its `SIG` is a program name the
738
+ *operator* typed, so there is no constant to match, and a hook claiming every
739
+ `SIG` would add a spurious reference to every other program's records.
740
+ "Claim the `SIG`s nobody else claimed" is the rule that works and it is not
741
+ expressible in a hook — these run independently and additively, so none can
742
+ see what the others took. It belongs to the importer.
743
+
744
+ ## ✅ `command` — call-field text shortcuts
745
+
746
+ Space in the logging panel's call field tries the typed text as a command
747
+ before falling back to "advance focus." The core does no matching itself —
748
+ it fans out to **every** registered `command` hook via `invokeHookAll` and
749
+ applies the first non-null result, in priority order.
750
+
751
+ ```ts
752
+ interface CommandHook {
753
+ interpret(
754
+ args: { input: string; operation?; qso?; execute? },
755
+ ctx,
756
+ ): Promise<CommandInterpretation | null>
757
+ catalog?(ctx): Promise<CommandCatalogEntry[]>
758
+ }
759
+ // CommandInterpretation: {expectsParams?, mixedCase?, error?, describe?, confirm?, commands?: CommandAction[]}
760
+ // CommandCatalogEntry: {command, describe, params?, category?, needsOperation?, expectsParams?, suggest?}
761
+ // CommandAction (closed set): {setVfo?, setPower?, updateQso?, addQso?,
762
+ // setKeyerSpeed?, showKeyer?, sendSpots?, syncAll?, updateSettings?, updateSetting?, openSettings?,
763
+ // updateOperation?, markSegment?, setCallField?, reloadDataFiles?, devNotice?, devStatus?,
764
+ // devProgress?, devHammer?, devSplash?, devOnboard?, devTester?, devPopup?,
765
+ // devTimeTravel?, toggleExperiment?}
766
+ ```
767
+
768
+ Each hook does its own matching internally and returns `null` for "not mine"
769
+ — the host never compiles extension-supplied regex source itself. A match
770
+ returns an interpretation the core previews (`describe`) and, on confirm,
771
+ applies: state changes go only through the closed `CommandAction` field set
772
+ (DESIGN.md §4's "whitelisted field-change operations"), never arbitrary
773
+ state. `expectsParams: true` is the "recognized prefix, keep typing" signal
774
+ described below.
775
+
776
+ A command that IS recognized but can't run in the current context returns
777
+ `{ error: '…' }` instead of matching silently or pretending to succeed —
778
+ e.g. the operation-scoped commands (SEED, NOTE, SPOT/SPOTLAST) typed into
779
+ the home screen's quick search, where no operation is open. The UI presents
780
+ the error message, never executes the interpretation, and leaves the typed
781
+ input in place for editing. Commands that work anywhere (DEVMODE, RELOAD,
782
+ band/frequency/mode…) never need it.
783
+
784
+ `interpret` is called on **every** keystroke pause (the call field's 150ms
785
+ typing-debounce, for the live `describe` preview), not just once when the
786
+ command actually runs — so a hook whose `commands` are expensive to build
787
+ must not compute them unconditionally. `execute: true` is the signal that
788
+ this particular call IS the one about to run the command (the host makes a
789
+ final `interpret` call with it set, right before dispatching `commands`,
790
+ discarding whatever a stale preview may have returned); it's false/undefined
791
+ on every describe-preview call. A hook with cheap `commands` can ignore this
792
+ entirely. `SEED` (below) is the one hook that needs it: skips its `ctx.getQsos`
793
+ read and per-generated-QSO lookups — real bridge/network-ish work — unless
794
+ `execute` is set, returning a cheap describe-only result otherwise.
795
+
796
+ A hook whose complete result needs a network fetch doesn't have to block
797
+ `interpret`'s own returned Promise on it — even on a describe-preview call.
798
+ Return quickly with a placeholder `describe` ("Fetching…"), kick the fetch
799
+ off in the background, and once it resolves call:
800
+
801
+ ```ts
802
+ host.updateInterpretation(input: string, interpretation: CommandInterpretation): void
803
+ ```
804
+
805
+ One-way, like `host.log` — no response ever comes back, nothing to await.
806
+ `input` must be the same `input` the triggering `interpret` call received;
807
+ the host discards a push whose `input` no longer matches the live call field
808
+ (the user typed past it), the same way it already discards a stale
809
+ describe-preview result. This is how `annotation-commands`' SOLAR/WEATHER
810
+ (below) show their fetched reading in the UI before the user ever presses
811
+ Enter, without either blocking every keystroke-debounce call on a live
812
+ fetch or requiring a second command invocation.
813
+
814
+ Note that only the logging panel consumes those pushes today — the ⌘K palette
815
+ and the Home search bar do not — so a hook that needs its describe on every
816
+ surface must resolve it inside `interpret` instead. `settings-commands` awaits
817
+ its host call for that reason.
818
+
819
+ A command that needs to reach the app's SETTINGS asks rather than carrying its
820
+ own list of them:
821
+
822
+ ```ts
823
+ host.searchSettings({ args: string, kind?: 'set' | 'show' | 'hide' }): Promise<SettingsSearchResult>
824
+ ```
825
+
826
+ `args` is everything the operator typed after the command word, whole: where
827
+ the setting's name ends and its value begins is decided app-side, because only
828
+ the settings catalog can tell `SET AVOID ZERO BEAT N` (a four-word name plus a
829
+ value) from `SET THEME DARK` (a one-word name plus a value). Matching is by
830
+ subsequence over each setting's key, aliases and label, and the catalog is
831
+ derived from the app's declarative settings registry — so it reaches every
832
+ setting the settings screen would currently show, and only those (`devMode`,
833
+ `environment` and `visibleWhen` all apply). The result carries the setting's
834
+ label, section, current value and the coerced new value, each already
835
+ localized; a `secret` field's value is never included. Write the result back
836
+ with the `updateSetting` action, or open the settings form with `openSettings`.
837
+
838
+ This call re-enters the extension runtime while the calling hook is still
839
+ awaiting it (reading every `settingsPanel` is part of building the catalog).
840
+ That is safe by construction — hook calls are keyed by id with independent
841
+ completers and no lock — but it is the only host call that does so, and any
842
+ new one that re-enters should say so here.
843
+
844
+ Implemented by `radio-commands` (band/frequency/mode shortcuts — "20m ",
845
+ "14285 ", "14.285 ", "cw "), ported from app-polo's `RadioCommands`
846
+ extension. Notably, this replaced an earlier hand-rolled Dart regex for
847
+ frequency parsing that rejected valid MHz-style input ("14.285") because it
848
+ required 3+ digits before any decimal point; the extension delegates to
849
+ `@ham2k/lib-format-tools`' published `parseFreq` instead, which already
850
+ handles this correctly. Also implemented by `dev-commands` (DEVMODE toggles
851
+ developer mode; DEVNOTICE/DEVSTATUS/DEVPROGRESS post sample items to the
852
+ status bar via the `devNotice`/`devStatus`/`devProgress` action fields, for
853
+ exercising that UI without a real extension driving it; DEVHAMMER
854
+ (`devHammer`) churns the footer the way a data-file sync does — items
855
+ appearing, ticking and clearing — which is what provokes the web engine into
856
+ blurring a focused text field, and which the real thing only does on a cold
857
+ profile; DEVSPLASH re-shows
858
+ the startup release-notes overlay (Issue #42) via the `devSplash` action
859
+ field, bypassing its normal once-per-version gate — for previewing that
860
+ overlay on demand instead of only once per release; RELOAD forces a
861
+ `dataFile` re-sync via the `reloadDataFiles` action field — "RELOAD POTA"/
862
+ "RELOAD SOTA" target one activity's data file(s) by category, "RELOAD ALL",
863
+ "RELOADALL", or bare "RELOAD!" target every data file, and it bypasses ETag caching
864
+ since a manual reload is usually about our own `csvToLookupEntry` mapping
865
+ changing, not the source data; SEED fills the current log with generated
866
+ QSOs via `addQso` actions — "SEED" adds 10, "SEED 20" adds 20, "SEED 15 3H"
867
+ spreads 15 over the 3 hours up to now, or over the 3 hours *ending at the
868
+ earliest existing QSO* when the log's own time span is under a third of
869
+ that spread. Calls come from the Callsign Notes data — call-notes exposes
870
+ its parsed index to other extensions through an in-runtime `callNotes`/
871
+ `allCalls` hook — and are mutated 80% of the time by swapping the digit or
872
+ a suffix letter; RSTs lean heavily toward 55/57/59; each generated QSO gets
873
+ the lookup service's offline `their.guess` decoration. The placement rule
874
+ needs the existing log: SEED reads it through `ctx.getQsos`, using the
875
+ `uuid` the logging panel folds into the `operation` map it passes to
876
+ `interpret`; EXP toggles an experimental feature via the `toggleExperiment`
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;
881
+ DEVTT/DEVTIMETRAVEL move the app clock via the `devTimeTravel` action field —
882
+ "DEVTT +3h", "DEVTT -1d", "DEVTT 2x", bare "DEVTT" to return to real time — so
883
+ anything that turns on the date or on elapsed time can be tested without
884
+ waiting for it. The hook only parses, and only while developer mode is on; the
885
+ app applies the offset, refuses it against a real sync account, and reports the
886
+ resulting time. Deliberately the one command here with NO `catalog`, so it can
887
+ never surface in the palette (docs/design/time.md)),
888
+ `annotation-commands` (ham2k/halo-dist#64, ported from
889
+ app-polo's AnnotationCommands: NOTE/TODO `<text>`, SOLAR, and WEATHER each
890
+ add an annotation row to the log — an event pseudo-QSO, `band: 'event'`,
891
+ via `addQso`, rendered as a yellow row by the QSO list. SOLAR fetches
892
+ hamqsl.com's solar indices and WEATHER fetches Open-Meteo conditions for
893
+ the operation's grid square — as soon as the command is recognized
894
+ (ham2k/halo-dist#189), not only at `execute`: `interpret` kicks the
895
+ fetch off in the background and returns a placeholder `describe`
896
+ immediately, then pushes the resolved reading via `host.updateInterpretation`
897
+ once it lands, so it shows in the UI before the user presses Enter. One
898
+ in-flight fetch is shared by every `interpret` call for the same command
899
+ until an `execute` call consumes it (reusing the same reading rather than
900
+ fetching twice) and clears it, so the next SOLAR/WEATHER starts fresh. The
901
+ raw readings land in the logged event's `data` and the emoji summary line
902
+ in its `description`. This superseded the original `note-command`
903
+ extension, whose NOTE wrote to the in-progress QSO's notes field instead of
904
+ the timeline), and `call-notes` (`//KEY`
905
+ expands a Callsign Notes entry whose note — once a leading heading OR emoji
906
+ marker (never both) is stripped — is a plain comma list of calls into the
907
+ call field via `setCallField` — issue #17; the call field explicitly excludes a field
908
+ STARTING with "//" from call stacking, or a note key that's itself a valid
909
+ callsign would also read as a stacked call, so the two syntaxes coexist. A key
910
+ stacked ALONGSIDE other calls reaches this hook too, but never as a whole-field
911
+ command: the call field asks again with just the segment under the cursor
912
+ ("//1AB") and splices the answer back into that segment, so the hook itself
913
+ needs to know nothing about stacks. See
914
+ [logging-fields.md](https://github.com/ham2k/halo/blob/main/docs/design/logging-fields.md) § "More than one call in the
915
+ field" for the order Enter resolves the two syntaxes in).
916
+ Future command hooks (spotting, operator change…) register in the same
917
+ category and compete on priority with these.
918
+
919
+ ### `catalog` — advertising commands to the ⌘K dialog
920
+
921
+ `interpret` answers *"what does this exact text mean?"* — one answer, for
922
+ text the user has already typed. The command dialog (⌘K, issue #55) needs the
923
+ other direction: *"what commands exist?"*, so it can list them before the
924
+ user knows the syntax. That's the optional `catalog(ctx)`, returning one
925
+ entry per command:
926
+
927
+ ```ts
928
+ { command: 'NOTE', params: '<text>', describe: 'Add a note to the log',
929
+ category: 'Log annotations', needsOperation: true, expectsParams: true }
930
+ ```
931
+
932
+ `command` is both what the palette matches against and what it types into the
933
+ field when the entry is chosen, so it has to be something `interpret`
934
+ recognizes. `params` is display-only. `describe` may use inline markdown
935
+ (`**bold**`, `*italic*`, `` `code` ``), the same as `interpret`'s own
936
+ `describe`/`confirm` — the dialog renders it as one ellipsized line rather
937
+ than as blocks, so keep it to a single sentence. `needsOperation` drops the row entirely when
938
+ no operation is open, instead of letting the user pick it and get
939
+ `interpret`'s `error` back. `expectsParams` leaves the field open for typing
940
+ rather than running the command immediately — the catalog counterpart of
941
+ `CommandInterpretation.expectsParams`, and the reason picking NOTE doesn't
942
+ log an empty note. `suggest` opts the entry into the empty-field "Try"
943
+ samples shown while the dialog is new; it's opt-in on purpose — the samples
944
+ go to users who don't yet know what a command does, so anything destructive,
945
+ heavyweight or developer-only must stay out. Absent means listed and
946
+ searchable, never volunteered.
947
+
948
+ Three things differ from `interpret`, all deliberate:
949
+
950
+ - **Called once and cached**, not per keystroke — dropped when the runtime is
951
+ replaced (an extension enabled/disabled) or the hook context is
952
+ invalidated (locale change, since `describe` is localized by the hook).
953
+ - **Matching is host-side**, plain case-insensitive substring against
954
+ `command` and `describe`. Same rule `interpret`'s own matching follows: the
955
+ host never compiles extension-supplied patterns.
956
+ - **List commands, not variants.** `radio-commands` advertises one BAND
957
+ entry, not fifteen; `call-notes` advertises `//`, not every key the user
958
+ has defined.
959
+
960
+ A hook without `catalog` contributes nothing to the palette's suggestions and
961
+ still works fine when its commands are typed in full — that's why it's
962
+ optional. `dev-commands` uses `ctx.developerMode` to keep DEVMODE, SEED and
963
+ friends out of the list unless developer mode is on (RELOAD, being genuinely
964
+ user-facing, is always listed); the commands themselves still run either way.
965
+
966
+ A command that recognizes its own prefix but needs more typed input before
967
+ it can run (e.g. bare "RELOAD", before its target word) returns
968
+ `{ expectsParams: true }` instead of `null` or a full result. That switches
969
+ the call field to free-text entry — spaces allowed, no callsign character
970
+ restriction — and space no longer advances focus to the next field while
971
+ it's set; the interpretation is never executed even if Enter is pressed
972
+ while it still just says `expectsParams`. The field stays uppercase-only
973
+ (all logging input is uppercase unless explicitly mixed-case) unless the
974
+ interpretation also says `mixedCase: true` — for commands whose parameter
975
+ is free prose (NOTE's text), which lifts the uppercase rule. Return
976
+ `mixedCase` on both the bare-prefix result and the complete result, so
977
+ continued typing after the command is already complete ("NOTE Hello…")
978
+ isn't snapped back to uppercase. `mixedCase` is also what keeps Space a
979
+ literal character once a command is *already complete* — `expectsParams`
980
+ can't, since it also blocks execution — which is why SEED returns it on
981
+ every result despite its parameters not being prose: bare "SEED" must be
982
+ executable (default count) yet still extendable to "SEED 15 3H".
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
+
1014
+ ## ✅ `scoring` — per-operation score batches
1015
+
1016
+ One hook call scores an entire operation's QSOs at once (DESIGN.md §4:
1017
+ "score 500 QSOs in one call, not 500 calls"). Day-sectioning (24h UTC
1018
+ buckets, following app-polo/web-lofi's convention) and accumulation both
1019
+ happen inside the runtime — the host just supplies the operation and its
1020
+ QSOs and gets back everything it needs to cache.
1021
+
1022
+ Authors don't implement this directly. Write a **`ContestScorer`** — three
1023
+ pure functions — and wrap it with `contestScorer()`, which supplies the loop,
1024
+ day sectioning, checkpointing and the live single-QSO path
1025
+ (docs/design/contests.md §5.2):
1026
+
1027
+ ```ts
1028
+ interface ContestScorer<S> {
1029
+ startScoresheet({operation, ref?}, ctx): S
1030
+ scoreQso({scoresheet, qso, operation, ref?, isNewDay}, ctx): {scoresheet: S, score}
1031
+ summarizeScore({scoresheet, operation, ref?, scope}, ctx): Record<string, ScoreTally>
1032
+ }
1033
+
1034
+ registerHook('scoring', {
1035
+ hook: contestScorer(MyScorer, { scope: { refTypes: ['cqww'] } }),
1036
+ key: 'cqww',
1037
+ })
1038
+ ```
1039
+
1040
+ `scope` decides which operations the scorer applies to, and the host reads it
1041
+ *before* invoking anything: `'always'` (core dupe/DXCC/bands) or a `refTypes`
1042
+ list, so an operation that doesn't carry the ref never crosses the bridge.
1043
+ Several scorers run on one operation — contests alongside core — and their
1044
+ results are namespaced per scorer. An `'always'` scorer is the **lowest priority
1045
+ on the worked-before axis**: for a QSO some other scorer has judged on that axis
1046
+ — a verdict carrying `dupe`, a `duplicate` alert, or a
1047
+ `newBand`/`newMode`/`newDay`/`newRef` notice — the host drops the `always`
1048
+ scorer's own `dupe`, `duplicate` and those notices, so core's call+band+mode
1049
+ rule never contradicts an activity's. What core alone reports — `newDXCC`,
1050
+ `newState`, `newProvince` — always survives, and a verdict that says nothing
1051
+ about a prior contact (`invalidBand`, `missingExchange`, a bare `{value}`)
1052
+ displaces nothing: core's dupe warning is then the only one there is.
1053
+
1054
+ So emit `dupe` whenever your dupe test actually ran, `dupe: false` included —
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
1061
+ verdict means "worked before, and this one counts anyway", say it in one of
1062
+ those notice keys (`newPark` is state-parks' spelling of `newRef`) rather than
1063
+ inventing a spelling of your own: an unrecognized key leaves core's `duplicate`
1064
+ standing beside your credit, on the one row that can show only one.
1065
+
1066
+ A scorer's internal *rules* stay private to its scoresheet: contests don't
1067
+ share a rule vocabulary, so only notices, alerts, points and summary tallies
1068
+ cross the boundary. `scoreQso` **may mutate** the scoresheet it's given and
1069
+ return it; copying accumulated state per QSO is quadratic, and the harness
1070
+ copies any checkpoint at its boundary.
1071
+
1072
+ ```ts
1073
+ // The wire shape the harness produces, for reference:
1074
+ interface ScoringHook {
1075
+ scoreQsos(args: ScoreQsosRequest, ctx): Promise<ScoreQsosResult>
1076
+ scoreQso?(args: ScoreQsoRequest, ctx): Promise<QsoScoreNotices>
1077
+ scoreCandidates?(args: ScoreCandidatesRequest, ctx): Promise<ScoreCandidatesResult>
1078
+ }
1079
+ // ScoreQsosRequest: {operation, qsos, ref?, resumeFrom?}
1080
+ // ScoreQsosResult: {
1081
+ // qsoScores: Record<uuid, QsoScoreVerdict>, // one per QSO claimed; see above
1082
+ // daySections: {day, count, scores: Record<tallyKey, ScoreTally>}[],
1083
+ // operationSummary: Record<tallyKey, ScoreTally>,
1084
+ // scoresheet, // checkpoint to resume from
1085
+ // }
1086
+ // ScoreCandidatesRequest: {operation, candidates: {key, qso}[], ref?, segments?, resumeFrom?}
1087
+ // ScoreCandidatesResult: Record<candidateKey, QsoScoreNotices>
1088
+ ```
1089
+
1090
+ All three arrive already implemented by `contestScorer()`; a scorer writes the
1091
+ three pure `ContestScorer` functions and gets the loop, the checkpointing and
1092
+ both live paths for free.
1093
+
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.
1099
+ `scoreCandidates` answers the same question for many at once, which is what the
1100
+ Spots Panel asks to mark the spots already worked: the log is folded once per
1101
+ scorer and every candidate scored against a COPY of the result, so no candidate
1102
+ can become another's duplicate. Candidates carry no uuid — a spot is not a
1103
+ record — so results come back under caller-chosen keys.
1104
+
1105
+ `Qsos.watchForOperation` already filters deleted rows and orders by
1106
+ `startAtMillis`, so unlike web-lofi's `analyzeAndSectionQSOs` there's no
1107
+ `deleted`/`event` row bookkeeping on this side of the bridge.
1108
+ `ScoringService` (`app/lib/services/scoring_service.dart`) writes
1109
+ `qsoScores` into each QSO's `score_cache` column (`QsosRepository.updateScoreCache`,
1110
+ one batched transaction) and `daySections`/`operationSummary` into the
1111
+ operation's `local_data` (`OperationsRepository.updateLocalData`) — never
1112
+ synced content, so list scroll and score displays both read from SQLite and
1113
+ never wait on the JS runtime. Triggered on operation open; per-edit debounced
1114
+ rescoring is not wired up yet (see [backlog.md](https://github.com/ham2k/halo/blob/main/docs/backlog.md)).
1115
+
1116
+ Implemented by: `scoring` (key `core`) — three rules so far: duplicate-contact
1117
+ detection, DXCC entities worked (via `@ham2k/lib-country-files`'
1118
+ `useBuiltinCountryFile`, no network needed), and bands/modes worked. US
1119
+ States and Canadian Provinces rules are deferred (see [backlog.md](https://github.com/ham2k/halo/blob/main/docs/backlog.md)).
1120
+
1121
+ ## ✅ `bench` — kernel diagnostics
1122
+
1123
+ Registered by the kernel itself (`bench/kernel`): `echo` (bridge round-trip
1124
+ measurement) and `spin` (JS compute). Used by the spike screen; not for
1125
+ extensions.
1126
+
1127
+ ---
1128
+
1129
+ ## ✅ `syncTransport` — sync backends
1130
+
1131
+ The transport seam for LoFi (and future) sync. The Dart core owns the
1132
+ loop, batching, and dirty flags (`packages/halo_core`'s `SyncEngine`); the
1133
+ hook only moves an already-selected payload over the network, so sync keeps
1134
+ working even if the JS runtime is wedged (DESIGN.md §4). Registered as a
1135
+ single object with `sync`/`getAccountData`/`linkClient`/
1136
+ `linkClientWithEmail` methods — `linkClient` (optional) requests a link
1137
+ permission (pending challenge code for an email, immediately-active for an
1138
+ already-permitted account, or a blank permission for QR-code linking) and
1139
+ returns server rejections as `{ error, error_status }` data.
1140
+
1141
+ The account-management methods are all optional and all follow that same
1142
+ "a rejection is data, not a throw" convention, because every one of them
1143
+ reports to an operator in the middle of an action: `setAccountEmail` /
1144
+ `resendAccountEmail` change the address that identifies the account (it
1145
+ stays pending until the operator opens a mailed confirmation link, so a
1146
+ typo can never hand the account to a stranger), and `findLinkRequest` /
1147
+ `findLinkRequestByClient` / `approveLinkRequest` are the CONFIRMING side of
1148
+ linking — where `linkClient` asks to join some other account, these let
1149
+ this device admit another one into its own, from a typed challenge code or
1150
+ from the `com.ham2k:///link_client?id=…&token=…` deep link a requesting
1151
+ device's QR code carries. `approveLinkRequest` must be given the
1152
+ `challenge_token` for a blank/QR permission: that token is what binds a
1153
+ permission created with no account behind it to this one.
1154
+
1155
+ `submitReport`
1156
+ (optional) files a report the host composed — the equipment editor's radio
1157
+ report — as a real CaBo card; the host writes the title, body and kind, the
1158
+ transport adds who/where/what-build and owns the account gate.
1159
+ Implemented by: `ham2k-lofi`.
1160
+
1161
+ ## ✅ `panel` — panes an extension contributes to a view
1162
+
1163
+ A panel is a PANE the operator can put in a customizable view's layout
1164
+ (docs/design/layouts.md), listed in Layout Setup alongside the app's own
1165
+ QSOs / Info / Spots / Map. One extension may declare several, each with its
1166
+ own identity, config form and refresh budget.
1167
+
1168
+ ```ts
1169
+ interface PanelHook {
1170
+ getPanels(args: {}, ctx): Promise<PanelDescriptor[]>
1171
+ render(args: PanelRenderArgs, ctx): Promise<PanelContent>
1172
+ }
1173
+ // PanelDescriptor: {key, title, description?, icon?, preview?, on?, form?}
1174
+ // PanelRenderArgs: {panelKey, operation, qso?, qsoCount, config, reason}
1175
+ // PanelContent: {kind: 'markdown' | 'svg' | 'html', content, title?, triggers?}
1176
+ ```
1177
+
1178
+ The host addresses a panel as `ext:<hookKey>:<key>`, and that id is stored
1179
+ inside saved layouts — renaming a `key` drops the panel out of every
1180
+ arrangement holding it.
1181
+
1182
+ **Content is a document, not widgets** — the same UI-catalog seam every
1183
+ other declarative hook sits behind. Prefer `markdown`: it renders with the
1184
+ app's own typography, theme, font scale and density, and costs no web view.
1185
+ `svg` is drawn as a vector image. `html` is the escape hatch and runs
1186
+ sandboxed: **no JavaScript, no network for subresources (inline what you
1187
+ need as a `data:` URI), and no navigation** — a link the operator taps opens
1188
+ in the OS browser; one the document triggers itself is blocked. The
1189
+ no-network half is enforced by content blockers on Apple platforms and
1190
+ `blockNetworkLoads` on Android; a platform whose web view honours neither
1191
+ still gets no scripts and no navigation, so treat "no network" as a rule
1192
+ your panel must follow rather than one it is held to everywhere. HTML
1193
+ panels do not render at all where there is no web view (Linux, and Windows
1194
+ without the WebView2 runtime) — the pane says so. Nor on the web build, where
1195
+ one exists but is an iframe whose sandbox grants top-level navigation and
1196
+ popups: the no-navigation rule above is the one part a panel cannot be trusted
1197
+ to keep on its own, so the pane refuses rather than render under it.
1198
+ `markdown` has none of these caveats, which is the other reason to prefer it.
1199
+
1200
+ **`on`** is a refresh budget, not a subscription: `'operation'`,
1201
+ `'qsoLogged'`, `'spots'`, `'lookup'`, `'qso'`, or `` `tick:<seconds>` ``
1202
+ (one second is the fastest; anything below it is not a recognized trigger and
1203
+ is dropped with a line in the console, so the panel gets no tick at all). The host
1204
+ keeps at least 750ms of idle between one render FINISHING and the next
1205
+ starting — a gap, not a period, so a slow panel simply runs less often
1206
+ instead of monopolising a single-threaded runtime. A tick that arrives while
1207
+ a render is already coming is DROPPED rather than queued: another is aimed at
1208
+ the next boundary, and the render that is coming reads fresh context anyway.
1209
+ Everything else waits instead of being dropped. Ticks are aimed at wall-clock
1210
+ boundaries, so panels sharing a period render in the same frame. The host
1211
+ renders nothing while the panel is behind another dock tab, or while the whole
1212
+ app is hidden — an unselected tab stays mounted, so without that a four-panel
1213
+ layout would pay for the three nobody is looking at. A trigger that arrives
1214
+ while the panel is away is remembered, and the panel renders once when it
1215
+ comes back. Do not treat the first render as something that happens at mount:
1216
+ a panel placed on a tab nobody has selected is not rendered until that tab is,
1217
+ so one-time setup belongs in the hook's own initialization, not in a render. A trigger name outside this list is dropped when
1218
+ the panel is read, with a line in the console: it would otherwise sit in the
1219
+ descriptor looking live and never fire. A descriptor with no `on` renders once when it
1220
+ appears. Omitting it is the right default: a trigger costs a bridge call
1221
+ every time it fires.
1222
+
1223
+ `'qso'` is the expensive one. `args.qso` is the whole QSO being composed —
1224
+ every field, in QSON, as it would be logged — and it changes as fast as the
1225
+ operator types. The host debounces it to one wake per pause and stops
1226
+ producing it entirely when no panel in the arrangement asked, but a panel
1227
+ that wants only the resolved callsign should declare `'lookup'` instead: it
1228
+ fires when a lookup is ANSWERED, not while a call is being typed, and the
1229
+ guess is inside `args.qso` either way — declaring `'lookup'` is enough to be
1230
+ sent the draft, so what it saves over `'qso'` is renders, not the QSO.
1231
+
1232
+ `'lookup'` fires when the panel is shown a callsign that HAS an answer —
1233
+ which includes opening a QSO whose stored record already carries one, not
1234
+ only a fresh lookup resolving. It does not fire when a lookup starts, fails,
1235
+ or times out. A panel keyed to it alone cannot tell "still looking"
1236
+ from "no answer coming", and will sit on whatever it last drew. A panel that
1237
+ needs to show either state wants `'qso'` too, and should read
1238
+ `qso.their.guess` to decide which it is looking at.
1239
+
1240
+ **`form`** declares the panel's own configuration in the same field schema
1241
+ a Tier 1 `settingsPanel` uses ([settings.md](./settings.md)), rendered by
1242
+ the core's `FormRenderer` and reached from the gear beside the panel in
1243
+ Layout Setup. Values are host-persisted per panel and handed back as
1244
+ `args.config`; there is no per-keystroke bridge call and nothing for the
1245
+ extension to store.
1246
+
1247
+ **`multiple`** lets an operator place more than one of your panel. Off by
1248
+ default, because most panels are one thing — two maps or two spot lists would
1249
+ be a mistake to offer. Set it where two is the point: a panel whose content
1250
+ is the operator's OWN, like `custom-text`'s notes.
1251
+
1252
+ Each placement gets its own id and therefore its own config, so `args.config`
1253
+ differs per pane with nothing to do on your side. What you DO owe an
1254
+ instanceable panel is a `title` on the render result — without one every pane
1255
+ reads the same descriptor title and the operator cannot tell them apart.
1256
+
1257
+ **`preview`** is a still image for that same list — an SVG document or a
1258
+ `data:` URI, never a URL: the list renders offline, and a remote preview
1259
+ would make opening it a fetch (and a beacon).
1260
+
1261
+ A render result may carry a **`title`**, which replaces the tab label for
1262
+ that pane. Blank is ignored rather than producing a nameless tab, and a
1263
+ result with no title keeps the descriptor's.
1264
+
1265
+ A render result may also carry **`triggers`** — the same names as `on`,
1266
+ added to the descriptor's for THAT PLACEMENT only. `on` is per panel and so
1267
+ is paid by every pane of it; this is per pane, and exists for panels whose
1268
+ content is written by the operator: `custom-text` derives its triggers from
1269
+ the template in each pane (`triggersForTemplate`), so a pane showing a band
1270
+ plan renders once while the pane beside it, naming the callsign being typed,
1271
+ gets woken per keystroke pause. Unknown names are dropped with a line in the
1272
+ console, exactly as in a descriptor.
1273
+
1274
+ The declaration necessarily lands AFTER the render that carried it, so a
1275
+ panel asking for `'qso'` this way is answered without one the first time and
1276
+ woken by the next change to the draft. Declare in `on` instead when the
1277
+ panel always needs the signal — a descriptor's triggers are in force before
1278
+ its first render.
1279
+
1280
+ The whole log is deliberately absent from `render`'s args. A panel that
1281
+ needs it calls `ctx.getQsos(operation.uuid)` — right for a `'qsoLogged'`
1282
+ render, wrong for a per-keystroke one.
1283
+
1284
+ ## ✅ `settingsPanel` — extension settings forms
1285
+
1286
+ Declarative settings panels rendered by the core's `FormRenderer` — full
1287
+ schema, kernel dispatch, `ExtensionService`, and `SettingsView` wiring; see
1288
+ [settings.md](./settings.md). Complementary to `account`, not a replacement.
1289
+ Implemented by: `settings` (the core General panel).
1290
+
1291
+ ## ✅ `account` — account connections
1292
+
1293
+ Account connections (QRZ, LoFi, SOTA): credential fields, `testCredentials`,
1294
+ and optional OAuth2, surfaced in `SettingsView`'s Accounts panel via
1295
+ `ExtensionService.getAccounts`/`testCredentials`. Credentials live in the
1296
+ platform keychain, keyed by the account's `kvKey`. Implemented by: `qrz`
1297
+ (username/password) and `sota` (OAuth2 via Keycloak — `sso.sota.org.uk`; the
1298
+ host runs the whole PKCE flow itself, see `_doOAuth` in
1299
+ `app/lib/views/settings/accounts_panel.dart`).
1300
+
1301
+ **`label` / `description` / `fields`** — each either a plain value, or a
1302
+ function `(args, ctx) => value` called the same way any other hook method is,
1303
+ so extensions can localize them from `ctx.locale` (via the SDK's
1304
+ `createTranslator`). The same `Localizable<T>` convention applies to a
1305
+ `dataFile` registration's `name`/`description`. QRZ localizes its account
1306
+ description and field labels this way.
1307
+
1308
+ **`oauth2`** (optional) — either a plain `OAuth2Config`
1309
+ (`{issuer, clientId, redirectUrl, scopes}`), or, for a client that varies by
1310
+ build edition, a function `(args, ctx) => OAuth2Config` called the same way
1311
+ any other hook method is (`ctx.edition` is `'dev' | 'next' | 'prod'` — see
1312
+ `HookContext.edition` below). SOTA is the one such hook: every edition
1313
+ authenticates as the SAME Keycloak client, and what varies is the redirect
1314
+ URL — `com.ham2k.logger.<edition>.auth://sota`, one per edition, each
1315
+ registered against that one client.
1316
+
1317
+ **`synchronizable`** (optional, default `false`) — on Apple platforms (macOS
1318
+ and iOS), when `true` the account's stored credentials and session sync across
1319
+ the user's devices via iCloud Keychain (`kSecAttrSynchronizable`); when `false`
1320
+ they stay device-local. No effect on other platforms. Set it `true` only for
1321
+ account-level logins that are genuinely the same on every device — QRZ.com sets
1322
+ it, since one QRZ login is shared across a user's installs. **Never** set it for
1323
+ per-device identifiers or bearer tokens: the LoFi sync **device key** is a
1324
+ per-install identifier and deliberately stays device-local (it isn't even an
1325
+ `account` hook — it lives in `SettingsService`, non-synchronizable, and must
1326
+ stay that way, or two devices would present as the same sync client).
1327
+
1328
+ The flag must be consistent for a `kvKey` across its whole lifetime: a
1329
+ synchronizable and a non-synchronizable keychain item are *distinct entries*,
1330
+ so the host resolves each account's flag once at runtime start
1331
+ (`ExtensionService._accountSynchronizable`) and applies it to every
1332
+ read/write/delete. Flipping the flag on an account that already has stored
1333
+ credentials makes the old entry invisible — the credentials must be re-entered
1334
+ once (there is no automatic migration between the two entries).
1335
+
1336
+ ## ✅ `dataFile` — offline reference datasets
1337
+
1338
+ Large offline datasets (park/summit lists) the core downloads, caches, and
1339
+ refreshes on a schedule; the extension supplies the URL and a
1340
+ row/entry→`LookupRow` transform. See `DataFileManager`
1341
+ (`app/lib/services/data_file_manager.dart`). Implemented by: `pota`
1342
+ (`pota-all-parks`), `sota` (`sota-all-summits`), and `call-notes`
1343
+ (`fetchType: 'raw'` — the parsed Hams of Note index is cached as JSON and
1344
+ replayed through `onLoadRawData` at startup, issue #17).
1345
+
1346
+ ### How CSV rows reach `csvToLookupEntry`
1347
+
1348
+ An extension writes a **per-row** mapper; the kernel synthesizes the batch
1349
+ method around it. What crosses the boundary is **not** what the mapper sees:
1350
+ the host sends rows **positionally** plus the header row **once per chunk**,
1351
+ and `registerHook` zips them back into the `Record<string, string>` the mapper
1352
+ is declared to take. Building those maps host-side instead meant every column
1353
+ name was repeated for every row — 61% of the bytes on SOTA's 181k-row file
1354
+ (APP-16).
1355
+
1356
+ Consequences worth knowing:
1357
+
1358
+ - A file with `hasHeaders: false` sends no headers, and the mapper receives the
1359
+ positional `string[]` — the other half of the declared
1360
+ `string[] | Record<string, string>` signature.
1361
+ - A short row leaves its trailing keys **absent**, not `undefined`-valued, so
1362
+ `!row.SummitCode` still works as the emptiness test.
1363
+ - The zipped row has a **null prototype**: column names come from a
1364
+ third-party file and must never reach `Object.prototype`.
1365
+ - An extension that defines `mapCsvBatch` **itself** bypasses all of the above
1366
+ and receives the raw `{ rows, headers? }` — it has to zip on its own. Nothing
1367
+ does this today, and `DataFileDefinition` doesn't declare the method, so the
1368
+ type checker will not remind you.
1369
+
1370
+ ---
1371
+
1372
+ ## 🔜 Planned categories
1373
+
1374
+ | Category | Role | Notes |
1375
+ |---|---|---|
1376
+ | `confirmation` | QSL/confirmation sources (spot-history cross-checking) | |
1377
+ | `opSetting` | per-operation settings contributions | |
1378
+
1379
+ When one of these lands, move it above the line with its real signature.