@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.
@@ -0,0 +1,524 @@
1
+ # Settings panel schema
2
+
3
+ Extensions can contribute panels to the app's settings screen by registering
4
+ a hook in the `settingsPanel` category. Panels are rendered with the same
5
+ `FormRenderer` used for [ad hoc forms](./forms.md) — a settings panel is a
6
+ `FormDefinition` whose fields' values happen to be persisted, not just
7
+ submitted once.
8
+
9
+ This is a separate, complementary mechanism to the `account` hook category
10
+ (see [Relationship to `AccountHook`](#relationship-to-accounthook) below) —
11
+ `settingsPanel` never defines, stores, or edits account credentials itself.
12
+
13
+ > **Status: ✅ implemented.** Schema, kernel-side dispatch (key-namespacing
14
+ > enforcement at `registerHook` time — including on panel keys a Tier 2
15
+ > hook's `getPanels()` returns at call time, not just the registration key
16
+ > — Tier 1's static `getDefinition`, `getPanels` discovery/flattening),
17
+ > `ExtensionService`, `FormRenderer` support for every field type, and a
18
+ > data-driven panel list in `SettingsView` are all done and reachable from
19
+ > the app. One known gap: `fieldType: 'account'`'s `subkey` (multiple
20
+ > accounts under one extension key) has no real implementation yet —
21
+ > `AccountHook` registrations are one-kvKey-per-registration today, so
22
+ > there's nothing to disambiguate against; `SettingsView` logs a warning
23
+ > rather than silently mismatching if a field ever sets it.
24
+
25
+ There are two tiers, discriminated by a required `kind` tag.
26
+
27
+ ---
28
+
29
+ ## Key namespacing
30
+
31
+ Wherever an extension can define more than one of something under one
32
+ category — multiple settings panels, multiple accounts — the key that
33
+ identifies each one defaults to the extension's own key, but if overridden
34
+ it must equal the extension key or be prefixed `<extensionKey>_`. For
35
+ example, extension `pota` may register panels keyed `pota` (the default) and
36
+ `pota_spots`, but never bare `spots` or camelCase `potaSpots`.
37
+
38
+ This applies to `SimpleSettingsPanel.key`, `DynamicSettingsPanel.key`,
39
+ `SettingsPanelDescriptor.key`, and `AccountHook.kvKey`. It keeps every
40
+ panel/account keyed under its owning extension's namespace, which is how
41
+ storage collisions between extensions are prevented, and how KV access
42
+ control will be scoped once that lands — a write can be checked against the
43
+ calling extension's own key prefix without a separate ownership table.
44
+
45
+ ```ts
46
+ // Extension key: 'pota'. Two panels — the default and one explicit override.
47
+ async getPanels() {
48
+ return [
49
+ { key: 'pota', title: 'POTA' }, // default, no override needed
50
+ { key: 'pota_spots', title: 'POTA Spots' }, // override — extension-key-prefixed
51
+ ];
52
+ }
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Tier 1: `SimpleSettingsPanel` — declarative, engine-owned storage
58
+
59
+ The common case: a fixed list of fields, each independently persisted, no
60
+ custom logic. Register the panel once at activation; the engine handles
61
+ reading current values, writing on change, and simple validation. No bridge
62
+ call happens per keystroke.
63
+
64
+ ```ts
65
+ export interface SettingsField extends Omit<FormField, 'validate' | 'transform' | 'itemFields'> {
66
+ pattern?: string; // regex; declarative validation instead of a validate() closure
67
+ patternError?: string;
68
+ itemFields?: SettingsField[]; // re-typed, not the inherited FormField[] — see "list" below
69
+ }
70
+
71
+ export interface SimpleSettingsPanel {
72
+ kind: 'simple';
73
+ key?: string; // defaults to the extension's key; see "Key namespacing"
74
+ title: string;
75
+ icon?: string;
76
+ order?: number;
77
+ // Defaults true. Set false to keep this panel out of the app's top-level
78
+ // settings navigation (the sidebar list). Independent of dataFilesSection
79
+ // below — this only controls the top-level nav.
80
+ sidebar?: boolean;
81
+ // Defaults false. Set true to also show this panel as a row in the Data
82
+ // Files screen's own "Extensions" section — the ONLY host-recognized
83
+ // alternate surface today, so leave this false for anything not actually
84
+ // about managing data files. Independent of `sidebar`; a panel usually
85
+ // wants both false-then-true, but the two aren't coupled at the type
86
+ // level — inferring this from `sidebar: false` alone would sweep any
87
+ // sidebar-opt-out panel in here.
88
+ dataFilesSection?: boolean;
89
+ fields: (SettingsField | FormActionElement)[];
90
+ }
91
+ ```
92
+
93
+ Persistence: each field is read/written under a KV path built from the
94
+ panel's key and the field's key — `<panelKey>.<fieldKey>`, where `panelKey`
95
+ is the extension's key unless `key` overrides it (so the common single-panel
96
+ case reads/writes e.g. `myext.apiKey`, never a doubled `myext.myext.apiKey`;
97
+ a second panel keyed `myext_spots` reads/writes `myext_spots.someField`).
98
+ Fields with `fieldType: 'secret'` are read/written through secure storage
99
+ instead of the plain KV store — extensions never see the raw stored value
100
+ beyond what `getDefinition`-equivalent state exposes back to them.
101
+
102
+ ```ts
103
+ api.registerHook('settingsPanel', {
104
+ hook: {
105
+ kind: 'simple',
106
+ title: "My Service",
107
+ icon: "cloud",
108
+ fields: [
109
+ { type: "field", fieldType: "text", key: "endpoint", label: "Webhook URL" },
110
+ { type: "action", key: "clear", label: "Clear Cache", method: "clearCache" }
111
+ ],
112
+ // Not part of the SimpleSettingsPanel type — an extra property the
113
+ // 'clear' action's `method` resolves against at dispatch time. See
114
+ // "The action element" below.
115
+ async clearCache(state, ctx) {
116
+ await purgeLocalCache();
117
+ return "Cache cleared";
118
+ }
119
+ }
120
+ });
121
+ ```
122
+
123
+ ## Tier 2: `DynamicSettingsPanel` — computed fields, custom logic
124
+
125
+ For panels whose fields, values, or validation depend on runtime state the
126
+ engine can't compute on its own (e.g. options fetched from an API, fields
127
+ that appear/disappear based on another field's value). Mirrors
128
+ [`FormHook`](./forms.md), plus panel discovery metadata and a
129
+ persistence-on-commit hook.
130
+
131
+ ```ts
132
+ export interface SettingsPanelDescriptor {
133
+ key: string; // extension key, or <extensionKey>_-prefixed; see "Key namespacing"
134
+ title: string;
135
+ icon?: string;
136
+ order?: number;
137
+ sidebar?: boolean; // see SimpleSettingsPanel.sidebar above; defaults true
138
+ dataFilesSection?: boolean; // see SimpleSettingsPanel.dataFilesSection above; defaults false
139
+ }
140
+
141
+ export interface DynamicSettingsPanel {
142
+ kind: 'dynamic';
143
+ key?: string; // same rule; only used as the panel key when getPanels is omitted
144
+ getPanels?(args: Record<string, never>, ctx: HookContext): Promise<SettingsPanelDescriptor[]>;
145
+ getDefinition(args: { panelKey: string }, ctx: HookContext): Promise<FormDefinition>;
146
+ validateField?(args: { panelKey: string; fieldKey: string; value: any; state: Record<string, any> }, ctx: HookContext): Promise<string | null>;
147
+ transformField?(args: { panelKey: string; fieldKey: string; value: any; state: Record<string, any> }, ctx: HookContext): Promise<any>;
148
+ // Fired on every accepted change (post-validate/transform). Unlike
149
+ // FormHook.onChangeField, this isn't patching sibling form state — it's
150
+ // "the field committed, go persist it." The extension is responsible for
151
+ // its own persistence here (e.g. host.setSettings — NOT host.kvSet, which
152
+ // is in-memory only and does not survive a runtime restart), since the
153
+ // engine can't infer storage for computed fields the way it can for
154
+ // Tier 1.
155
+ onChangeField(args: { panelKey: string; fieldKey: string; value: any; state: Record<string, any> }, ctx: HookContext): Promise<void>;
156
+ }
157
+ ```
158
+
159
+ `state` is always that panel's own values, bare-keyed (never the app's
160
+ `<panelKey>.`-namespaced form), and it holds the value being written — the
161
+ edited field already reads as `value`, not as what it was before. That is true
162
+ whether the edit came from the settings screen or from a `SET` command, so a
163
+ hook may validate across fields without caring which surface it was reached
164
+ from.
165
+
166
+ Core discriminates between the two tiers by the required `kind` tag
167
+ (`'simple' | 'dynamic'`), not by shape — a panel can't accidentally match
168
+ both, and a typo in `kind` is a compile error rather than silent
169
+ misclassification.
170
+
171
+ ---
172
+
173
+ ## The `list` field type and nested items
174
+
175
+ A settings field can itself be a repeatable list of objects — e.g. a list of
176
+ saved exchange presets. This reuses `FormField`/`SettingsField` recursively
177
+ rather than introducing a separate schema:
178
+
179
+ ```ts
180
+ {
181
+ type: "field",
182
+ fieldType: "list",
183
+ key: "presets",
184
+ label: "Saved Presets",
185
+ itemTitle: "${label} — ${exchange}",
186
+ itemFields: [
187
+ { type: "field", fieldType: "text", key: "label", label: "Name" },
188
+ { type: "field", fieldType: "text", key: "exchange", label: "Exchange" }
189
+ ]
190
+ }
191
+ ```
192
+
193
+ Each item's shape is described by `itemFields`. In `SettingsField`,
194
+ `itemFields` is typed as `SettingsField[]`, not the plain `FormField[]` a
195
+ Tier 2 form's `itemFields` would allow — so a Tier 1 list's items are
196
+ declarative-only too, same as every other Tier 1 field; a `validate`/
197
+ `transform` closure inside a Tier 1 list item is a type error, not a
198
+ silently-ignored one.
199
+
200
+ The list renders as a single row summarizing the current items as a
201
+ comma-separated list of `itemTitle`s (templated against each item's
202
+ values). Tapping it opens an editor dialog with a row per item plus an
203
+ "Add" row; every mutation commits immediately and "Done" closes the
204
+ dialog. Editing or adding an item opens the same form renderer
205
+ recursively, scoped to `itemFields`, in a nested dialog — there is no
206
+ separate "list item editor" widget, just `FormRenderer` given a smaller
207
+ `FormDefinition`.
208
+
209
+ Set `orderable: true` to allow drag-to-reorder in the editor; omit it for
210
+ lists where order doesn't matter.
211
+
212
+ ## The `multiselect` field type
213
+
214
+ Picking several values from a fixed vocabulary — the user cannot enter new
215
+ ones (that's what `list` is for). `value` is a plain string list; `options`
216
+ is the vocabulary, each option optionally flagged `common: true`:
217
+
218
+ ```ts
219
+ {
220
+ type: "field",
221
+ fieldType: "multiselect",
222
+ key: "bands",
223
+ label: "Bands",
224
+ icon: "radio",
225
+ value: ["80m", "40m", "20m"],
226
+ options: "$core.bands" // or inline: [{ label: "80m", value: "80m", common: true }, ...]
227
+ }
228
+ ```
229
+
230
+ Like `list`, it renders as a single row summarizing the selection
231
+ comma-separated; tapping opens an editor dialog with a switch per option.
232
+ Options disclose in up to three tiers: `common: true` ones show upfront,
233
+ unflagged ones behind the first disclosure step, and `uncommon: true`
234
+ ones behind the second — the action-row toggle cycles Show more → Show
235
+ all → Show fewer (or just Show all / Show fewer when no option is flagged
236
+ uncommon). A hidden-tier value that has been selected stays visible for
237
+ the dialog's lifetime. Declare `defaultValue` (a string list) to offer a
238
+ Defaults button restoring that selection; omit it and none is shown. The
239
+ selection always keeps the canonical order of `options` — `orderable` is
240
+ reserved for a future manual-ordering variant and currently ignored.
241
+
242
+ ## Reading and writing settings from extensions
243
+
244
+ Extensions access settings through two host calls:
245
+
246
+ - `host.getSettings()` — the full read view: resolved core values (theme,
247
+ locale, font scale, distance units, logging toggles, bands/modes,
248
+ privacy consents, the active profile's `operatorCall`, `operatorName`,
249
+ `operatorEmail`, `operatorClub` and `operatorSection`, `syncEnabled`,
250
+ `developerMode`, `appName`, `enabledExperiments`) plus every extension's own settings group
251
+ under `extensions` (keyed `extension_<key>`). Reading is unrestricted —
252
+ settings are not secrets. Credentials and accounts are deliberately
253
+ absent: they live in the platform keychain via the account hook system,
254
+ never in settings. The `operator*` profile fields beyond the callsign are
255
+ all optional and free-form — an extension must cope with `''`, and must
256
+ not treat `operatorSection` as validated against any section table.
257
+ - `host.setSettings({ key: value, ... })` — writes into the calling
258
+ extension's OWN group only (`extension_<key>`; the namespace is injected
259
+ by the SDK). Core settings and other extensions' groups are read-only.
260
+
261
+ ## System lists
262
+
263
+ A field's `options` can name a **system list** instead of inlining the
264
+ choices: a `$`-prefixed identifier, `$<namespace>.<list>`, resolved by the
265
+ app before the definition reaches the renderer. This keeps vocabularies
266
+ owned elsewhere (halo_core's band table, runtime-contributed controls) out
267
+ of settings declarations — both core settings groups and extension
268
+ settingsPanels can reference them.
269
+
270
+ Currently defined (see `app/lib/settings/system_lists.dart`):
271
+
272
+ - `$core.bands` — the extended band vocabulary (halo_core's snapshot of
273
+ lib-operation-data's `EXTENDED_BANDS`); `POPULAR_BANDS` flagged `common`.
274
+ - `$core.modes` — every ADIF mode and submode (snapshot of
275
+ `ADIF_MODES_AND_SUBMODES`); `MAIN_MODES` flagged `common`.
276
+
277
+ The `$extensions.*` namespace is reserved for runtime-derived lists (e.g. a
278
+ future `$extensions.secondaryLoggingControls`). An unknown name resolves to
279
+ no options — logged, and the field renders disabled rather than failing
280
+ the panel.
281
+
282
+ ## The `secret` field type
283
+
284
+ Renders as an obscured text field (`fieldType: 'secret'`). For Tier 1
285
+ panels, values are persisted via secure storage rather than the plain KV
286
+ store — see "Persistence" above. Tier 2 panels are responsible for their own
287
+ storage in `onChangeField` and should apply the same care. Not for account
288
+ credentials — those belong on `AccountHook.fields`, not here (see below).
289
+
290
+ ## The `account` field type
291
+
292
+ A read-only reference into the separate `account` hook system — renders a
293
+ connection-status row (e.g. "Connected as KI2D" / "Not Connected") with a
294
+ Manage/Connect button that opens that account's own standalone dialog. It
295
+ never defines or stores account data itself:
296
+
297
+ ```ts
298
+ {
299
+ type: "field",
300
+ fieldType: "account",
301
+ key: "qrzStatus",
302
+ label: "QRZ.com Account",
303
+ account: { key: "qrz" } // matches the account's AccountHook.kvKey
304
+ }
305
+ ```
306
+
307
+ `subkey` is an opaque, extension-defined discriminator, only needed when one
308
+ extension exposes more than one account-like entity under the same `key`
309
+ (e.g. multiple stored profiles) — most accounts don't need it.
310
+
311
+ > **Not implemented yet:** `AccountHook` registrations are one-`kvKey`-per-
312
+ > registration today, so there's no real scenario where two accounts share a
313
+ > `key` to disambiguate between — `SettingsView`'s Manage/Connect handler
314
+ > accepts `subkey` but only matches on `key`, logging a warning if `subkey`
315
+ > is set rather than silently picking the wrong account. Wiring this up for
316
+ > real needs `AccountHook` to grow its own multi-account concept first.
317
+
318
+ ## The `action` element
319
+
320
+ A button that invokes a named hook method against the current panel state
321
+ and shows the returned string as a result banner:
322
+
323
+ ```ts
324
+ { type: "action", key: "clear", label: "Clear Cache", method: "clearCache" }
325
+ ```
326
+
327
+ `method` is resolved dynamically against an extra, undeclared property on
328
+ the registered hook object (for Tier 1) or the hook object backing the panel
329
+ (for Tier 2) — not statically part of `SimpleSettingsPanel`/
330
+ `DynamicSettingsPanel`. The SDK's `Hook` union includes
331
+ `Record<string, unknown>`, so TypeScript permits the extra property on the
332
+ object literal without a cast. This is a general mechanism, not specific to
333
+ credentials — account connection testing lives on `AccountHook.testCredentials`
334
+ instead (see below).
335
+
336
+ ## `devMode` fields
337
+
338
+ Any field (or header/divider/action, anything in `fields`/`elements`) can be
339
+ marked `devMode: true` to hide it unless the app's developer mode is on — see
340
+ [forms.md's `devMode` elements](./forms.md#devmode-elements) for the full
341
+ behavior (orange accent when shown, host-driven visibility). Useful for a
342
+ Tier 1 panel that wants to expose a debug field (e.g. a raw API endpoint
343
+ override) without it cluttering the panel for regular users:
344
+
345
+ ```ts
346
+ { type: "field", fieldType: "text", key: "apiEndpointOverride", label: "API Endpoint Override", devMode: true }
347
+ ```
348
+
349
+ ## `aliases`: short names for the SET command
350
+
351
+ The `SET`, `SHOW` and `HIDE` commands search every declared setting by key,
352
+ label and description (see `app/lib/settings/settings_catalog.dart`). Matching is by
353
+ subsequence and the ranking is usually enough on its own, but not for a
354
+ setting whose real name buries the word people reach for: `REPORT` matches
355
+ `defaultReport`, `defaultReportCW` and `defaultReportFT8` alike, and none of
356
+ them is obviously the one meant.
357
+
358
+ `aliases` fixes that by giving a field extra names, matched at the same top
359
+ tier as its key:
360
+
361
+ ```ts
362
+ { type: "field", fieldType: "text", key: "defaultReport", label: "Default Report",
363
+ aliases: ["report", "rst"] }
364
+ ```
365
+
366
+ A single string is accepted as well as a list. Aliases are matched
367
+ case-insensitively and ignoring punctuation, like every other term (`zeroBeat`,
368
+ `Zero Beat` and `ZEROBEAT` are the same term), so there is no point declaring
369
+ case or spacing variants of a name that already matches.
370
+
371
+ Aliases are for names an operator would plausibly *reach for* — not a thesaurus.
372
+ Every alias is one more thing that can collide with another setting's real name,
373
+ and a collision costs a disambiguation prompt on a query that used to be exact.
374
+
375
+ ## Common Settings and environment gating
376
+
377
+ The app has one settings screen, **Settings**: a collapsible section per
378
+ declared group and panel, led by **Common Settings**, a short list of the
379
+ handful of settings most users ever touch. Which fields land on Common
380
+ Settings —
381
+ and which platforms show a field at all — is declared per-element, not
382
+ maintained as a separate list somewhere else in the app. Any field, link, or
383
+ action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
384
+
385
+ - **`common: true`** — also show this element in the Common Settings
386
+ section, on top of its own group's. Off by default. Not the same thing as
387
+ `FormFieldOption.common`/`uncommon` above — that's a `multiselect`
388
+ option's own disclosure tier (upfront vs. behind "Show more"), a
389
+ different scope (an option inside one field, not the field itself).
390
+
391
+ ```ts
392
+ { type: "field", fieldType: "select", key: "units", label: "Units", value: "metric", options: [...], common: true }
393
+ ```
394
+
395
+ - **`environment: 'ios,android'`** (or `'-web'`, or an array either way) —
396
+ restrict which platform(s) show this element at all. This is a platform
397
+ gate, not a common-vs-everything one: an excluded field disappears from every
398
+ settings surface on that platform, not just Common Settings. Every
399
+ token unprefixed is a whitelist (show ONLY there); every token
400
+ `-`-prefixed is a blacklist (show everywhere EXCEPT there); mixing the two
401
+ forms in one attribute is invalid and treated as if `environment` were
402
+ absent. Tokens: `ios`, `android`, `macos`, `windows`, `linux`, `web` — the
403
+ same vocabulary server notices filter on. Omit `environment` for "every
404
+ platform".
405
+
406
+ ```ts
407
+ // A field that only makes sense with an on-screen keyboard.
408
+ { type: "field", fieldType: "checkbox", key: "showNumbersRow", label: "Numbers Row", value: false,
409
+ common: true, environment: "ios,android" }
410
+ ```
411
+
412
+ Both attributes are evaluated fresh every time the panel reloads (same as
413
+ `devMode`), so they can vary with `getDefinition`'s own logic if a Tier 2
414
+ panel wants to compute them — though for most panels a static value is all
415
+ that's needed. `common` only applies to field/link/action elements — a
416
+ header/divider/markdown block can't be individually whitelisted onto Common
417
+ Preferences, since that screen builds its own section headers from each
418
+ group/panel's title rather than reusing the panel's own `FormHeader`
419
+ elements. `environment`, by contrast, applies to every element type,
420
+ headers included — a Tier 2 panel with a platform-specific sub-section can
421
+ gate its own header the same way it gates the fields under it.
422
+
423
+ ## Settings targets
424
+
425
+ A third element attribute, **`target`**, opts a field into a target-scoped
426
+ settings modal somewhere else in the app — a curated view built around one
427
+ concern rather than one owning group/panel. The logging view's tune icon is
428
+ the first one: it collects every declared field (core- or
429
+ extension-declared) tagged `target: 'logging'`, wherever it's declared, into
430
+ one dialog.
431
+
432
+ ```ts
433
+ { type: "field", fieldType: "checkbox", key: "showBearing", label: "Show Bearing",
434
+ value: false, target: "logging" }
435
+ ```
436
+
437
+ `target` is a single target key, or an array when the same field should
438
+ surface under more than one. A `'<target>-extended'` target (e.g.
439
+ `'logging-extended'`) is the same idea, but starts hidden behind that
440
+ dialog's own "show more settings" disclosure — for a field related to the
441
+ target but niche enough it shouldn't clutter the default view:
442
+
443
+ ```ts
444
+ { type: "field", fieldType: "checkbox", key: "leftieMode", label: "Leftie Mode",
445
+ value: false, target: "logging-extended" }
446
+ ```
447
+
448
+ Omitting `target` doesn't hide a field from anywhere it already appears —
449
+ The Settings panel is unaffected either way. It's purely
450
+ additive: an extra place a field can be reached from, on top of wherever
451
+ `common`/`environment` already put it.
452
+
453
+ `type: "field"` (including `fieldType: "account"`) and `type: "action"`
454
+ elements can both set `target` and both appear in a target-scoped modal —
455
+ an action's method dispatches through the same extension host call the
456
+ full settings screens use, and an account field's Manage/Connect button
457
+ opens that account's own standalone dialog, exactly as it would from the
458
+ Settings panel.
459
+
460
+ ## Restarting the runtime after a Tier 2 field commits
461
+
462
+ A `kind: 'dynamic'` field can set `restartOnChange: true` when its value is
463
+ only ever consulted at `onActivation` — the common case being a list of URLs
464
+ the extension turns into `dataFile` hook registrations at startup, the way
465
+ `getPanels`/`getDefinition` can be re-queried on demand but `onActivation`
466
+ cannot. Once the field commits, the app calls the same `restartRuntime()`
467
+ already used when the user toggles an extension on/off, so `onActivation`
468
+ runs again and picks up the new value via `host.getSettings()`.
469
+
470
+ Don't reach for this by default — most Tier 2 fields are read fresh by a
471
+ hook that runs on every relevant call (`getDefinition`, `lookup`, etc.), so
472
+ they see a change immediately with no restart needed. Reserve
473
+ `restartOnChange` for the fields that genuinely can't be re-read any other
474
+ way, since a restart is a real interruption, not a free way to "make sure
475
+ everything's fresh."
476
+
477
+ `onActivation` re-running is only half the story — it also needs to read
478
+ that new value, which means `await host.getSettings()` before any
479
+ `registerHook` calls that depend on it. Nothing in the kernel awaits
480
+ `onActivation` itself (it's dispatched fire-and-forget), so those
481
+ `registerHook` calls land whenever that promise happens to resolve, not
482
+ before boot "finishes." In practice this is safe because the app's own
483
+ boot path always makes an unrelated host round trip of its own before
484
+ returning control to any caller — which reliably gives a pending
485
+ `getSettings()` continuation enough time to land first — but there's no
486
+ hard guarantee of that ordering, only an incidental one. Register anything
487
+ that DOESN'T depend on settings (a static builtin list, say) synchronously,
488
+ before the `await`, so at least that part is never subject to this.
489
+
490
+ ---
491
+
492
+ ## Relationship to `AccountHook`
493
+
494
+ `account` and `settingsPanel` are separate, complementary hook categories —
495
+ `settingsPanel` does not supersede `account`. Extensions still define
496
+ account credentials by registering an `account` hook exactly as before:
497
+
498
+ ```ts
499
+ export interface AccountField {
500
+ key: string;
501
+ label: string;
502
+ type: 'text' | 'email' | 'secret';
503
+ preface?: string;
504
+ postface?: string;
505
+ }
506
+
507
+ export interface AccountHook {
508
+ label: string;
509
+ kvKey?: string; // defaults to the extension key; see "Key namespacing"
510
+ fields?: AccountField[];
511
+ oauth2?: OAuth2Config;
512
+ testCredentials(credentials: Record<string, string>, ctx: HookContext): Promise<string>;
513
+ }
514
+ ```
515
+
516
+ Core aggregates every extension's `account` hook (one hook per account) into
517
+ the account list shown in Settings; each account is stored separately
518
+ (credentials in secure storage, keyed by `kvKey`) and rendered in its own
519
+ standalone dialog — not inline in a `settingsPanel`'s `FormRenderer`.
520
+
521
+ A `settingsPanel` field can reference an account for display/navigation via
522
+ `fieldType: 'account'` (see above), but the account itself is still fully
523
+ owned by its `AccountHook` registration. `extensions/lookups/qrz` continues
524
+ to register under `account` — there is no migration to do.