@ham2k/extension-sdk 0.9.1 → 0.10.1

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/AGENTS.md CHANGED
@@ -81,7 +81,7 @@ will actually call.
81
81
 
82
82
  | Doing this | Read |
83
83
  | --- | --- |
84
- | Anything, before the reference — a whole worked extension of that shape | `samples/`: `k2hrc-hamqth` (lookup + credentials), `k2hrc-llota` (award program), `k2hrc-cqww` (contest + scoring), `k2hrc-radio` (HTML panel), `k2hrc-svg-scenes` (experimental interactive SVG) |
84
+ | Anything, before the reference — a whole worked extension of that shape | `samples/`: `k2hrc-hamqth` (lookup + credentials), `k2hrc-llota` (award program), `k2hrc-cqww` (contest + scoring), `k2hrc-radio` (HTML panel), `k2hrc-panel-scenes` (experimental interactive panel scene) |
85
85
  | Any hook at all — what the category is, what it's handed, what it must return | `docs/hooks.md`, the section named for the category |
86
86
  | A pane in a view: `getPanels`, `render`, panel content kinds | `docs/hooks.md` §`panel` |
87
87
  | Callsign lookups, spot sources, exporters, reference types (POTA-style) | `docs/hooks.md` §`lookup`, §`spots`, §`export`, §`ref:<type>` |
package/dist/index.d.ts CHANGED
@@ -1,12 +1,17 @@
1
- // @ham2k/extension-sdk 0.9.1
2
- /** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
3
- export interface SvgScene {
1
+ // @ham2k/extension-sdk 0.10.1
2
+ /** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
3
+ export interface PanelScene {
4
4
  version: 1;
5
5
  width: number;
6
6
  height: number;
7
7
  values: Record<string, number>;
8
- layers: SvgSceneLayer[];
9
- controls?: SvgSceneControl[];
8
+ /** String state for text fields and choices. A key is in `values` or here, never both. At most 256, each up to 4096 characters. */
9
+ strings?: Record<string, string>;
10
+ /** Painted in order, each over the ones before. */
11
+ layers: PanelSceneLayer[];
12
+ controls?: PanelSceneControl[];
13
+ /** Where rect-less native controls and the artwork go. Omitted, the artwork fills the panel. */
14
+ layout?: PanelSceneLayoutNode;
10
15
  }
11
16
  /** A clamped, linear mapping. No expressions or extension code run per frame. */
12
17
  export interface SceneBinding {
@@ -26,7 +31,7 @@ export interface SceneBinding {
26
31
  /** Optional equally spaced output samples over input; replaces the output ramp. */
27
32
  samples?: number[];
28
33
  }
29
- export interface SvgSceneLayer {
34
+ export interface PanelSceneLayer {
30
35
  id: string;
31
36
  x: number;
32
37
  y: number;
@@ -34,7 +39,12 @@ export interface SvgSceneLayer {
34
39
  height: number;
35
40
  /** Supply exactly one of svg or text. SVG must be self-contained. */
36
41
  svg?: string;
37
- /** Exactly one of literal/value. Use text layers, not SVG <text>, for font support.
42
+ /**
43
+ * Take the clicks that land on this layer. Off by default: layers are click-through, so
44
+ * artwork painted over a control does not stop it working. On, it covers the controls under it.
45
+ */
46
+ blocksPointer?: boolean;
47
+ /** Exactly one of literal/value. A value naming a string shows it as-is. Use text layers, not SVG <text>, for font support.
38
48
  * Size is unscaled; the host applies OS text scaling once. */
39
49
  text?: {
40
50
  value?: string;
@@ -75,24 +85,77 @@ export interface SvgSceneLayer {
75
85
  flicker?: boolean;
76
86
  };
77
87
  }
78
- export interface SvgSceneControl {
88
+ /**
89
+ * `button`, `slider` and `knob` are invisible hit areas over the extension's own artwork.
90
+ * The `native…` kinds are Material widgets, themed by the app:
91
+ *
92
+ * - `nativeButton`: sends `activate`. `variant`, `icon`, and optionally `menu`.
93
+ * - `nativeSlider`: a number, between `min` and `max` in `step`s.
94
+ * - `nativeTextField`: a string. Commits on Enter, or on leaving the field after typing.
95
+ * - `nativeDropdown`, `nativeRadio`, `nativeSegmented`: a string, one of `options`.
96
+ * A `multi` segmented control holds its chosen values joined with commas, in option order.
97
+ * - `nativeSwitch`, `nativeCheckbox`: a number, 1 or 0. Shows `label` beside it.
98
+ * - `nativeChip`: with `value`, a toggle (1 or 0); without, an action that sends `activate`.
99
+ * - `nativeText`: shows `label`, or its bound value. Sends nothing.
100
+ *
101
+ * `label` is every control's accessible name. There are no tooltips.
102
+ */
103
+ export type PanelSceneControlKind = "button" | "slider" | "knob" | "nativeButton" | "nativeSlider" | "nativeTextField" | "nativeDropdown" | "nativeSwitch" | "nativeCheckbox" | "nativeRadio" | "nativeSegmented" | "nativeChip" | "nativeText";
104
+ export interface PanelSceneControl {
79
105
  id: string;
80
106
  label: string;
81
- kind: "button" | "slider" | "knob";
107
+ kind: PanelSceneControlKind;
82
108
  /** A button can open a native dropdown. Picking an item sends its event as the action. */
83
109
  menu?: {
84
110
  label: string;
85
111
  event: string;
86
112
  }[];
87
- x: number;
88
- y: number;
89
- width: number;
90
- height: number;
113
+ /**
114
+ * The control's rect over the artwork, in scene units. Required for the drawn kinds.
115
+ * A native control omits all four to be placed by `layout` instead.
116
+ */
117
+ x?: number;
118
+ y?: number;
119
+ width?: number;
120
+ height?: number;
121
+ /**
122
+ * With a rect: the id of the layer this control paints directly after, so later layers draw
123
+ * over it. Omitted, the control paints above every layer.
124
+ */
125
+ after?: string;
91
126
  /** Omit event for local-only interaction (for example a chart inspection cursor). */
92
127
  event?: string;
93
- /** Send coalesced change events during dragging, plus the final commit. */
128
+ /** `slider`, `knob`, `nativeSlider` and `nativeTextField` only: send coalesced change events while dragging or typing, plus the final commit. */
94
129
  continuous?: boolean;
130
+ /**
131
+ * The name of the value this control shows and sets: in `values`, or in `strings` for a string kind.
132
+ * A choice's string must be `""` or one of its `options` (each part, for `multi`). `nativeButton` takes none.
133
+ */
95
134
  value?: string;
135
+ /** Choices for `nativeDropdown`, `nativeRadio` and `nativeSegmented`. Values are unique. */
136
+ options?: {
137
+ label: string;
138
+ value: string;
139
+ }[];
140
+ /** `nativeSegmented` only: choose several. */
141
+ multi?: boolean;
142
+ /** Shown but inert: no clicks, drags, keys or screen-reader adjustments act on it. */
143
+ disabled?: boolean;
144
+ /**
145
+ * 0 to 1, fixed or bound like a layer's. At 0 the control is gone: it takes no clicks,
146
+ * keyboard focus or screen-reader node. Anything between is only drawn fainter.
147
+ */
148
+ opacity?: number | SceneBinding;
149
+ /** `nativeButton` only. Default `filled`. */
150
+ variant?: "filled" | "tonal" | "outlined" | "text";
151
+ /** `nativeButton` and `nativeChip`: a Material Design Icons name, such as `radio-tower`. */
152
+ icon?: string;
153
+ /** `nativeTextField` and `nativeDropdown`: shown while empty. */
154
+ placeholder?: string;
155
+ /** `nativeText` only: a typography role from the render environment. Default `body`. */
156
+ style?: "body" | "label" | "title" | "display" | "mono";
157
+ /** `nativeText` only. Default `start`. */
158
+ align?: "start" | "center" | "end";
96
159
  min?: number;
97
160
  max?: number;
98
161
  step?: number;
@@ -110,12 +173,66 @@ export interface PanelSceneEvent {
110
173
  action: string;
111
174
  phase: "activate" | "change" | "commit";
112
175
  sequence: number;
176
+ /** The control's number, for a control bound to `values`. */
113
177
  value?: number;
178
+ /** The control's string, for a control bound to `strings`. */
179
+ text?: string;
114
180
  }
115
- /** Patch only named scene values. The host ignores stale responses. */
181
+ /** Patch only named scene values and strings. The host ignores stale responses. */
116
182
  export interface PanelSceneEventResult {
117
183
  values: Record<string, number>;
184
+ strings?: Record<string, string>;
118
185
  }
186
+ /**
187
+ * Flutter's Row, Column and Wrap, with Flexible, Spacer and SizedBox as fields.
188
+ * Sizes are logical pixels, like the render environment's. At most 8 deep and 128 nodes.
189
+ *
190
+ * - `{row: [...]}` / `{column: [...]}` / `{wrap: [...]}` lay out their children.
191
+ * - `{control: "id"}` places a native control that has no rect. Each appears exactly once.
192
+ * - `{scene: true}` places the artwork, scaled to fit. At most once.
193
+ * - `{spacer: flex}` takes a share of the free space.
194
+ *
195
+ * A text field or dropdown given no width (a row child without `flex` or `width`) is 200 wide.
196
+ */
197
+ export type PanelSceneLayoutNode = ({
198
+ row: PanelSceneLayoutNode[];
199
+ } | {
200
+ column: PanelSceneLayoutNode[];
201
+ } | {
202
+ wrap: PanelSceneLayoutNode[];
203
+ } | {
204
+ control: string;
205
+ } | {
206
+ scene: true;
207
+ } | {
208
+ spacer: number;
209
+ }) & {
210
+ /** Only on a row or column's direct child: share the free space, like Expanded (`tight`, the default) or Flexible (`loose`). */
211
+ flex?: number;
212
+ fit?: "tight" | "loose";
213
+ width?: number;
214
+ height?: number;
215
+ /** All sides, or `[start, top, end, bottom]`. */
216
+ padding?: number | [
217
+ number,
218
+ number,
219
+ number,
220
+ number
221
+ ];
222
+ /** Gap between children; for a wrap, between items in a run. */
223
+ spacing?: number;
224
+ /** Wrap only: gap between runs. */
225
+ runSpacing?: number;
226
+ mainAxisAlignment?: "start" | "center" | "end" | "spaceBetween" | "spaceAround" | "spaceEvenly";
227
+ /** `stretch` on a row or column only. */
228
+ crossAxisAlignment?: "start" | "center" | "end" | "stretch";
229
+ };
230
+ /** @deprecated Renamed to `PanelScene`. */
231
+ export type SvgScene = PanelScene;
232
+ /** @deprecated Renamed to `PanelSceneLayer`. */
233
+ export type SvgSceneLayer = PanelSceneLayer;
234
+ /** @deprecated Renamed to `PanelSceneControl`. */
235
+ export type SvgSceneControl = PanelSceneControl;
119
236
  export declare const HOOK_CATEGORIES: readonly [
120
237
  "account",
121
238
  "activity",
@@ -413,6 +530,7 @@ export interface RefGeojson {
413
530
  url: string;
414
531
  maxAgeInDays?: number;
415
532
  missMaxAgeInDays?: number;
533
+ sourceName?: string;
416
534
  }
417
535
  export interface LoggingControlDescriptor {
418
536
  key: string;
@@ -507,6 +625,12 @@ export interface ActivitySuggestion extends Ref {
507
625
  relevance?: number;
508
626
  allowsMultiple?: boolean;
509
627
  }
628
+ export interface MapBounds {
629
+ south: number;
630
+ west: number;
631
+ north: number;
632
+ east: number;
633
+ }
510
634
  export interface SuggestArgs {
511
635
  operation: Record<string, JSONValue>;
512
636
  location?: {
@@ -515,6 +639,7 @@ export interface SuggestArgs {
515
639
  };
516
640
  callsign?: string;
517
641
  searchTerm?: string;
642
+ bounds?: MapBounds;
518
643
  scoped?: boolean;
519
644
  }
520
645
  export interface ActivityHook {
@@ -536,6 +661,7 @@ export interface AdifFieldsHook {
536
661
  fieldsForOneQSO(args: {
537
662
  qso: Record<string, JSONValue>;
538
663
  operation: Record<string, JSONValue>;
664
+ mainHandler?: boolean;
539
665
  }, ctx: HookContext): Promise<{
540
666
  name: string;
541
667
  value: string;
@@ -818,6 +944,7 @@ export interface CommandAction {
818
944
  qsoCount?: number;
819
945
  };
820
946
  devLogCat?: Record<string, never>;
947
+ devMapInfo?: Record<string, never>;
821
948
  toggleExperiment?: {
822
949
  token: string;
823
950
  };
@@ -1411,7 +1538,7 @@ export interface PanelRadioTuneResult {
1411
1538
  state: PanelRadioState | null;
1412
1539
  }
1413
1540
  export interface PanelRenderArgs {
1414
- /** Present on environment-capable hosts. Changes automatically request a throttled render of an `svgScene` panel. */
1541
+ /** Present on environment-capable hosts. Changes automatically request a throttled render of a `scene` panel. */
1415
1542
  environment?: PanelEnvironment;
1416
1543
  /** Display time follows developer time travel; real time is for network retry/cache budgets. */
1417
1544
  clock?: {
@@ -1428,8 +1555,18 @@ export interface PanelRenderArgs {
1428
1555
  reason: string;
1429
1556
  }
1430
1557
  export type PanelContent = {
1558
+ kind: "scene";
1559
+ scene: PanelScene;
1560
+ title?: string;
1561
+ triggers?: string[];
1562
+ }
1563
+ /**
1564
+ * @deprecated The same kind as `scene`, under its name through SDK 0.9. Apps before extension
1565
+ * API 5 know only `svgScene`; `scene` needs `"api": 5`, as do native controls, `strings` and `layout`.
1566
+ */
1567
+ | {
1431
1568
  kind: "svgScene";
1432
- scene: SvgScene;
1569
+ scene: PanelScene;
1433
1570
  title?: string;
1434
1571
  triggers?: string[];
1435
1572
  } | {
@@ -1733,6 +1870,21 @@ export interface ReferenceActivityHooks {
1733
1870
  adifImportHook: AdifImportHook;
1734
1871
  }
1735
1872
  export declare function referenceActivity(config: ReferenceActivityConfig): ReferenceActivityHooks;
1873
+ export interface NearbyLookupHost {
1874
+ dbLookupSelectByLocation(category: string, lat: number, lon: number, delta: number, activeOnly?: boolean): Promise<LookupRow[]>;
1875
+ dbLookupSelectInBounds(category: string, bounds: MapBounds, options?: {
1876
+ activeOnly?: boolean;
1877
+ limit?: number;
1878
+ }): Promise<LookupRow[]>;
1879
+ }
1880
+ export declare const VIEWPORT_LIMIT = 500;
1881
+ export declare function nearbyLookupRows(host: NearbyLookupHost, category: string, { location, bounds }: Pick<SuggestArgs, "location" | "bounds">, { delta, activeOnly }: {
1882
+ delta: number;
1883
+ activeOnly?: boolean;
1884
+ }): Promise<{
1885
+ rows: LookupRow[];
1886
+ inViewport: boolean;
1887
+ }>;
1736
1888
  export declare class TemplateError extends Error {
1737
1889
  readonly cause?: unknown;
1738
1890
  constructor(message: string, options?: {
@@ -1836,6 +1988,10 @@ export declare const host: {
1836
1988
  dbLookupSelectOne(category: string, key: string): Promise<LookupRow | null>;
1837
1989
  dbLookupSelectAll(category: string, query: string, subCategory?: string, activeOnly?: boolean): Promise<LookupRow[]>;
1838
1990
  dbLookupSelectByLocation(category: string, lat: number, lon: number, delta: number, activeOnly?: boolean): Promise<LookupRow[]>;
1991
+ dbLookupSelectInBounds(category: string, bounds: MapBounds, options?: {
1992
+ activeOnly?: boolean;
1993
+ limit?: number;
1994
+ }): Promise<LookupRow[]>;
1839
1995
  showForm(form: FormDefinition, state?: Record<string, any>): Promise<Record<string, any> | null>;
1840
1996
  statusBar: {
1841
1997
  setMessage(params: StatusBarMessageParams): Promise<void>;
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { base64ToBytes, bytesToBase64 } from "./base64.js";
2
2
  import { adoptExtensionTimers, clearInterval, clearTimeout, setInterval, setTimeout } from "./timers.js";
3
3
  export * from "./types.js";
4
4
  export * from "./hookCategories.js";
5
- export * from "./svgScene.js";
5
+ export * from "./panelScene.js";
6
6
  export * from "./i18n.js";
7
7
  export * from "./dxcc.js";
8
8
  export * from "./location.js";
@@ -16,6 +16,7 @@ export * from "./activityAdifImport.js";
16
16
  export * from "./exportNames.js";
17
17
  export * from "./exportSettings.js";
18
18
  export * from "./referenceActivity.js";
19
+ export * from "./nearby.js";
19
20
  export * from "./refTransforms.js";
20
21
  export * from "./templates.js";
21
22
  export * from "./templateContext.js";
@@ -304,6 +305,13 @@ const host = {
304
305
  async dbLookupSelectByLocation(category, lat, lon, delta, activeOnly) {
305
306
  return await hostCallFn()("dbLookupSelectByLocation", { category, lat, lon, delta, activeOnly });
306
307
  },
308
+ /// Every row inside `bounds`, nearest its centre first, at most `limit`
309
+ /// (default 500, never past 1000). `west` past `east` is a box across the
310
+ /// antimeridian. Only an app that sends `SuggestArgs.bounds` answers this
311
+ /// call — so call it only when `bounds` arrived, as `nearbyLookupRows` does.
312
+ async dbLookupSelectInBounds(category, bounds, options = {}) {
313
+ return await hostCallFn()("dbLookupSelectInBounds", { category, ...bounds, ...options });
314
+ },
307
315
  async showForm(form, state = {}) {
308
316
  return kernel().host.showForm(form, state, currentExtensionKey);
309
317
  },
package/dist/nearby.js ADDED
@@ -0,0 +1,10 @@
1
+ const VIEWPORT_LIMIT = 500;
2
+ async function nearbyLookupRows(host, category, { location, bounds }, { delta, activeOnly }) {
3
+ if (bounds) return { rows: await host.dbLookupSelectInBounds(category, bounds, { activeOnly, limit: VIEWPORT_LIMIT }), inViewport: true };
4
+ if (location) return { rows: await host.dbLookupSelectByLocation(category, location.lat, location.lon, delta, activeOnly), inViewport: false };
5
+ return { rows: [], inViewport: false };
6
+ }
7
+ export {
8
+ VIEWPORT_LIMIT,
9
+ nearbyLookupRows
10
+ };
@@ -1,3 +1,4 @@
1
+ import { nearbyLookupRows } from "./nearby.js";
1
2
  import { distanceOnEarth } from "@ham2k/lib-geo-tools";
2
3
  import { activityAdifImport } from "./activityAdifImport.js";
3
4
  async function hostApi() {
@@ -186,9 +187,9 @@ function referenceActivity(config) {
186
187
  ];
187
188
  }
188
189
  } : {},
189
- async suggest({ location, searchTerm, scoped }, ctx) {
190
+ async suggest({ location, bounds, searchTerm, scoped }, ctx) {
190
191
  const host = await hostApi();
191
- const rows = searchTerm ? await host.dbLookupSelectAll(category, searchTerm) : location ? await host.dbLookupSelectByLocation(category, location.lat, location.lon, NEARBY_DELTA) : [];
192
+ const { rows, inViewport } = searchTerm ? { rows: await host.dbLookupSelectAll(category, searchTerm), inViewport: false } : await nearbyLookupRows(host, category, { location, bounds }, { delta: NEARBY_DELTA });
192
193
  const withDistance = rows.map((row) => ({
193
194
  row,
194
195
  distance: location && row.lat != null && row.lon != null ? distanceOnEarth(location, { lat: row.lat, lon: row.lon }) ?? void 0 : void 0
@@ -196,7 +197,7 @@ function referenceActivity(config) {
196
197
  if (!searchTerm) {
197
198
  withDistance.sort((a, b) => (a.distance ?? Infinity) - (b.distance ?? Infinity));
198
199
  }
199
- const suggestions = withDistance.slice(0, MAX_SUGGESTIONS).map(({ row, distance }) => {
200
+ const suggestions = withDistance.slice(0, inViewport ? withDistance.length : MAX_SUGGESTIONS).map(({ row, distance }) => {
200
201
  const data = row.data ?? {};
201
202
  return {
202
203
  type: config.activationType,
@@ -51,7 +51,9 @@ reaches every app that can run it: 2 adds `host.webSocket`, and the packer
51
51
  refuses a manifest declaring `webSockets` under anything less; 3 adds timers
52
52
  and `onShow`/`onHide`, and from 3 the build rewrites a bare `setTimeout` into
53
53
  the SDK's (README's "Timers"); 4 lets `host.fetch` reach a plain-`http://` host
54
- the app fronts with its https proxy (README's `host.fetch`).
54
+ the app fronts with its https proxy (README's `host.fetch`); 5 adds panel
55
+ scenes' `scene` kind, native controls, `strings`, `layout`, `after` and control
56
+ `opacity` (hooks.md's `panel`).
55
57
 
56
58
  Three fields are **refused** in a distributed bundle, and it is worth
57
59
  understanding why:
@@ -260,7 +262,7 @@ It reports every problem at once rather than one per run:
260
262
  h2kext-pack: ./build is not ready to package:
261
263
  manifest.json: missing required field 'name'
262
264
  manifest.json: key 'my-clock' must start with your callsign — 'my' is not one (e.g. ki2d-my-clock)
263
- manifest.json: missing 'api' — declare the extension API this was built against (currently 4)
265
+ manifest.json: missing 'api' — declare the extension API this was built against (currently 5)
264
266
  ```
265
267
 
266
268
  `--force-name` waives the naming convention below. It exists for Ham2K's own
@@ -399,8 +401,9 @@ built-in's own state stays where it is.
399
401
  One rule bends for these, and only for them:
400
402
 
401
403
  - **Built-in collisions.** An installed bundle may not claim a key the app
402
- ships — judged against what the app is currently OFFERING, which under the
403
- catalog experiment is the core extensions and nothing else. `ham2k-lookup`
404
+ ships — judged against what the app is currently OFFERING, which once the
405
+ upgrade to catalog extensions is taken is the core extensions and nothing
406
+ else. `ham2k-lookup`
404
407
  is both a built-in and a pre-load, and could otherwise never install.
405
408
 
406
409
  **Build secrets are not among them, and used to be.** A pre-load holding a
@@ -412,14 +415,16 @@ sees no build secret, whatever key it claims and wherever it came from.
412
415
 
413
416
  ### The upgrade to catalog extensions
414
417
 
415
- An install that has been running the app's own extensions is offered the move
416
- at start, and again the moment the catalog experiment is switched on: that
417
- switch is the operator asking for catalog extensions, and being made to
418
- relaunch before anything is offered reads as nothing having happened. The
419
- switch withdraws nothing by itself; only a taken upgrade does, and the offer
420
- may be deferred either way. A catalog that cannot be reached says so in the
421
- footer when the operator asked, and says nothing at a launch nobody was
422
- watching.
418
+ A new install — one whose default profile has never had a callsign — starts
419
+ on the catalog at its first boot, on the bundles the app ships, with nothing
420
+ to offer and nothing to carry; the decision is recorded once
421
+ (`seedExtensionsMigratedDefault`). Onboarding still names its activities by
422
+ their built-in keys, which `offeredKeyFor` maps to the shipped twin that
423
+ actually runs. An install that has been running the app's own extensions is
424
+ offered the move at start, on every launch until it is taken. Only a taken upgrade withdraws
425
+ anything, and the offer may be deferred. A catalog that cannot be reached
426
+ raises no offer and says nothing: it is a launch nobody was watching, and the
427
+ offer comes back next time.
423
428
 
424
429
  Accepting installs the pre-loads, then fetches the catalog's twin of every
425
430
  other built-in the operator had switched on — `wca` becomes `ham2k-wca` —
@@ -521,15 +526,72 @@ Beyond what the packer already refused:
521
526
 
522
527
  Each has its own message. A bundle that cannot be installed says why.
523
528
 
524
- ## Native only, for now
525
-
526
- Installed bundles are files on disk. On web the app runs from its own assets
527
- and, for authors, the dev server — [development.md](https://github.com/ham2k/halo/blob/main/docs/extensions/development.md). So on
528
- web there is nothing to install into: no install from a file, no install or
529
- update from the catalog, and none of the pre-loaded extensions above.
530
-
531
- "For now" is a deferral, not a fact about the platform. Almost everything
532
- here is already platform-neutral — the zip walk, the manifest rules, the
533
- consent screen, the hash check — and what is left is a persistence seam of
534
- about six operations. `HALO-583` is the card that picks it up, and says what
535
- has to be decided first.
529
+ ## Where installed bundles live
530
+
531
+ Everything above is platform-neutral — the zip walk, the manifest rules, the
532
+ consent screen, the hash check. What differs is only where the bytes are kept
533
+ (`BundleStorage`):
534
+
535
+ - **natively**, one directory per key under the app's data directory, one
536
+ file per bundle entry;
537
+ - **on web**, one IndexedDB record per key (database `halo-extensions`),
538
+ holding every entry's bytes under its own name. IndexedDB rather than the
539
+ Origin Private File System, which would mirror the native layout: every
540
+ browser HaLo runs in supports it the same way, while OPFS writes from the
541
+ page are newer and patchier on Safari.
542
+
543
+ So the web build installs from a file, installs and updates from the catalog,
544
+ pre-loads the shipped bundles and takes the upgrade exactly as the native ones
545
+ do.
546
+
547
+ ### When the store is lost
548
+
549
+ The store can go while the preferences describing it survive: a browser
550
+ clearing IndexedDB alone, a data directory deleted by hand, a store that
551
+ failed mid-session. (When a browser clears a whole site, preferences go with
552
+ it, and the app starts as a new install.) So what the operator installed is
553
+ kept apart from the store that holds it, and put back from there.
554
+
555
+ `extensionsInstalled` records every extension the operator installed
556
+ (`{key: name}`). Every install writes it; only an uninstall — or Forget, below
557
+ — removes it. An install that predates it is seeded from its store once, and
558
+ after that it never follows the store: a store found short is exactly what it
559
+ must not shrink to. Whatever is recorded there and not in the store is
560
+ **missing**, however it went — judged by the keys the store holds, not by the
561
+ extensions the app offers, since a built-in or a dev server can stand in front
562
+ of an installed bundle. A store that cannot be read at all has lost nothing:
563
+ that launch offers nothing, rather than everything back into a failing store.
564
+ Where each came from is its provenance record's answer, and a key with none
565
+ came from a file.
566
+
567
+ The operator's on/off choice for a missing extension is kept too: the boot
568
+ cleanup that prunes settings for keys the app no longer knows treats every
569
+ recorded key as known.
570
+
571
+ Putting them back, after the app starts:
572
+
573
+ - **Shipped bundles** come back at boot from the app's own assets, unasked and
574
+ offline. The pre-load record says they were already offered, which is true
575
+ and beside the point; they are installed anyway, never as a first install,
576
+ so their settings and on/off choice stand. An install that has not taken the
577
+ upgrade gets back only what it had.
578
+ - **Everything else** is offered in a dialog once the boot has settled:
579
+ - extensions from the catalog in use are listed to install, at the release
580
+ it serves now. **Later** keeps every one for the next launch;
581
+ - extensions from a file are listed to install again from their files, and
582
+ ones from a different catalog as not available here. Neither is fetched —
583
+ the bytes would come from a publisher the operator never agreed to;
584
+ - every row has **Forget**, which drops it with everything it stored — the
585
+ only way out for one the catalog no longer serves, which would otherwise
586
+ be offered, and fail, every launch.
587
+ - **Install** installs each on its own: one that is not listed, will not
588
+ download or fails the check costs only itself. The runtime restarts with
589
+ everything that landed; a bundle it will not evaluate is set aside, as the
590
+ upgrade does, and the runtime restarts without it. The result names what
591
+ could not be installed (offered again next launch) and what installed but
592
+ would not run (kept, marked in the panel, where an update is the way back);
593
+ says so when the runtime would not start at all, rather than calling it a
594
+ success; and says the app is still busy — offering them again next launch
595
+ — when another catalog pass holds the store.
596
+ - The offer waits for the boot's catch-up to finish, or for an upgrade that
597
+ every catch-up stepped aside for to let go.
package/docs/hooks.md CHANGED
@@ -46,8 +46,8 @@ hook method receives `(args, ctx)` where `ctx: HookContext` is:
46
46
  effect must stay invisible until an experiment gating the surface it
47
47
  belongs to has shipped ungated — checked per-call, since gating at the
48
48
  whole-extension `manifest.experiments` grain would also hide any other
49
- hook the same extension registers (e.g. `radio-commands`' POWER command
50
- checks `gato` here; its BAND/MODE/frequency siblings don't).
49
+ hook the same extension registers (e.g. `radio-commands`' WPM command
50
+ checks `keyer` here; its BAND/MODE/frequency siblings don't).
51
51
  * `experiments?: { key, name, aliases }[]` — The experiment catalog itself,
52
52
  on or off, so a hook can resolve a typed key-or-alias to one of the keys
53
53
  `enabledExperiments` lists (the EXP preview says "Enable"/"Disable" rather
@@ -473,15 +473,18 @@ its own program name reads the wrong program aloud. Programs built on the SDK's
473
473
  from one `linkUrl` config line.
474
474
 
475
475
  `geojsonUrlForRef` says where the reference's own footprint can be fetched as
476
- GeoJSON — a SOTA summit's activation zone, and whatever equivalent another
477
- program publishes. Same contract as `linkForRef`: build the URL from the
476
+ GeoJSON — a SOTA summit's activation zone, a POTA park's boundary, and
477
+ whatever equivalent another program publishes. Areas (`Polygon`,
478
+ `MultiPolygon`) are drawn filled with an edge; lines (`LineString`,
479
+ `MultiLineString`) — a trail or a river that is the park — are drawn as an
480
+ edge alone; points are ignored. Same contract as `linkForRef`: build the URL from the
478
481
  reference, **never hit the network**, return `null` for a reference the program
479
482
  has no shape for. The core does the fetching and the caching (`GeoshapeService`),
480
483
  so an extension never decides how long a device keeps a file or how large one may
481
484
  be; it only names the two ages the core should use:
482
485
 
483
486
  ```ts
484
- // RefGeojson: {url, maxAgeInDays?, missMaxAgeInDays?}
487
+ // RefGeojson: {url, maxAgeInDays?, missMaxAgeInDays?, sourceName?}
485
488
  ```
486
489
 
487
490
  `maxAgeInDays` (default 30) is how long a fetched shape stays usable offline —
@@ -490,6 +493,14 @@ long the core remembers that a reference has NO shape, deliberately shorter so a
490
493
  newly surveyed zone appears within a day rather than a month. Both are ignored
491
494
  unless positive: a zero would re-fetch on every rebuild of the map.
492
495
 
496
+ `sourceName` is who drew the shapes, short enough for the credit line a map
497
+ shows under its outlines ("Source: OSM, sotl.as") — the host of `url` when it
498
+ is left out. Name the data's owner, not the server that relays it: POTA's
499
+ outlines are served by Ham2K and credited to OSM.
500
+
501
+ A document past 16MB is not kept: the core remembers it as a miss, on
502
+ `missMaxAgeInDays`, rather than downloading it again on every retry.
503
+
493
504
  The URL must be `https`, and the core does not follow redirects to reach it.
494
505
  Unlike a link, this one is fetched rather than handed to the platform, and a
495
506
  plaintext one — named outright, or arrived at through a `Location:` header —
@@ -497,10 +508,13 @@ would downgrade every activator's connection on a program's say-so.
497
508
 
498
509
  Shapes are asked for sparingly: the Map tab asks about the operation's own
499
510
  references, and the Activities pane's results map about those plus the one
500
- suggestion whose card is open. Neither fetches a shape per search hit — a search
501
- answers with dozens of references, and at the zoom that shows dozens of them a
502
- footprint is a few pixels across. Implemented by: `sota` (both types, via
503
- SOTLAS's `az.sotl.as`, used with the operators' permission).
511
+ suggestion whose card is open, drawn solid. The rest of the map's suggestions
512
+ are asked about only once it is zoomed in (from GL zoom 7, fading in by 8)
513
+ and drawn as a dashed edge under the solid ones — a search answers with dozens
514
+ of references, and further out a footprint is a few pixels across. Implemented by: `sota` (both types, via
515
+ SOTLAS's `az.sotl.as`, used with the operators' permission) and `pota` (both
516
+ types, via `services.ham2k.net/lookups/outlines/pota/`, which links parks to
517
+ OpenStreetMap shapes; a park not yet linked answers 404).
504
518
 
505
519
  `suggestOperationTitle` lets an activity build a natural-language operation
506
520
  title/subtitle from its refs — "at Yosemite NP" (POTA), "for Field Day"
@@ -537,7 +551,7 @@ interface ActivityHook {
537
551
  // extension only says what the short form is.
538
552
  // `skipFocus: true` takes a main logging field out of the Space and Tab cycle;
539
553
  // the arrow keys still reach it. Every shipped contest sets it on Our Serial.
540
- // SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped?}
554
+ // SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped?, bounds?: {south, west, north, east}}
541
555
  // ActivitySuggestion: Ref & {distance?, relevance?, allowsMultiple?}
542
556
 
543
557
  // The input catalog. A new kind is earned by a different WIDGET, never by
@@ -622,7 +636,17 @@ proposing a new `input.kind` is a core PR, deliberately (DESIGN.md §5's
622
636
 
623
637
  `suggest` powers the Activities view's "add activity" search: called with a
624
638
  `location`/`callsign` the core resolves from the operation if set, else the
625
- station defaults (extensions never need to know which). Called with no
639
+ station defaults (extensions never need to know which). When the operator pans
640
+ the Activities results map it asks again with the map's view as `bounds` (and
641
+ its centre as `location`, no `searchTerm`, the box's scope if it has one):
642
+ answer with EVERY reference inside the view, not the nearest few, or the view's
643
+ corners stay empty however far the operator pans. `nearbyLookupRows` reads
644
+ either case, through the `dbLookupSelectInBounds` host call for a view — only
645
+ an app that sends `bounds` answers that call, so an extension that calls it
646
+ only then runs on older apps unchanged, and one that predates `bounds` answers
647
+ with what is nearest the view's centre. Measure `distance` from the `location`
648
+ given either way; the core re-measures a panned answer from the operation's own
649
+ place before any card shows it. Called with no
626
650
  `searchTerm` for nearby suggestions, or with one for name/code search — an
627
651
  extension may honor either, both, or neither (returning `[]`). `scoped` is
628
652
  true when THIS extension alone was asked — the operator tapped its "Activity
@@ -717,12 +741,17 @@ knowing them. Called by the `adif` extension **inside the runtime** via
717
741
 
718
742
  ```ts
719
743
  interface AdifFieldsHook {
720
- fieldsForOneQSO(args: { qso, operation }, ctx): Promise<{name, value}[]>
744
+ fieldsForOneQSO(args: { qso, operation, mainHandler? }, ctx): Promise<{name, value}[]>
721
745
  // One field set PER ADIF RECORD this contact should produce.
722
- fieldCombinationsForOneQSO?(args: { qso, operation }, ctx): Promise<{name, value}[][]>
746
+ fieldCombinationsForOneQSO?(args: { qso, operation, mainHandler? }, ctx): Promise<{name, value}[][]>
723
747
  }
724
748
  ```
725
749
 
750
+ `mainHandler` is true when the file is this extension's own export — app-polo's
751
+ argument of the same name. It is how a program writes what only its own
752
+ submission asks for: SOTA's files carry both stations' gridsquares, which the
753
+ full export leaves to the contact's own privacy rules.
754
+
726
755
  POTA contributes `SIG/SIG_INFO/POTA_REF` (hunted refs on the QSO) and
727
756
  `MY_SIG/MY_SIG_INFO/MY_POTA_REF` (activation refs on the operation).
728
757
 
@@ -795,11 +824,18 @@ would claim no reference at all.
795
824
  **A field name is written once per record, and the first to carry a value
796
825
  wins.** ADIF gives no meaning to a repeated field, so where two hooks answer
797
826
  the same name — the full export asks every program on the contact, and a park
798
- and a summit both answer `MY_SIG` — the later one is dropped, and the contact's own fields
799
- outrank all of them. Hooks are asked main handler first, then each
800
- `includeFieldsFrom` key in the order given, so that order decides — whichever
801
- of the two methods each hook answered. app-polo resolves a collision the same
802
- way ("keep the first one defined"). The **full export**, which asks every
827
+ and a summit both answer `MY_SIG` — the later one is dropped. Hooks are asked
828
+ main handler first, then each `includeFieldsFrom` key in the order given, so
829
+ that order decides — whichever of the two methods each hook answered. The
830
+ contact's own fields come after every hook's: a program knows what its file
831
+ needs in a field better than the contact's generic answer does (the section a
832
+ Field Day station sent, a satellite's downlink band). The exception is what
833
+ identifies the contact — `CALL`, `QSO_DATE`, `TIME_ON`, `QSO_DATE_OFF`,
834
+ `TIME_OFF`, `BAND`, `FREQ`, `MODE`, `SUBMODE`, `STATION_CALLSIGN`,
835
+ `OPERATOR`: a hook answering one of those is dropped, since it would rewrite
836
+ every record it is asked about, the whole-log backup included. This is a
837
+ deliberate divergence from app-polo, whose contact fields come first and whose
838
+ main handler's are appended unchecked, so a name both answer is written twice. The **full export**, which asks every
803
839
  program on the contact, has no such order: hooks answering `fieldCombinationsForOneQSO` are asked as one
804
840
  group and hooks answering `fieldsForOneQSO` as another, so which of two programs
805
841
  keeps a shared `MY_SIG` there is not something either of them chose.
@@ -1389,17 +1425,25 @@ interface PanelHook {
1389
1425
  // PanelDescriptor: {key, title, description?, icon?, preview?, on?, form?}
1390
1426
  // PanelRenderArgs: {panelKey, operation, qso?, qsoCount, config, reason}
1391
1427
  // PanelContent: {kind: 'markdown' | 'svg' | 'html', content, title?, triggers?}
1428
+ // | {kind: 'scene', scene, title?, triggers?} (see Panel scene reference)
1392
1429
  ```
1393
1430
 
1394
1431
  The host addresses a panel as `ext:<hookKey>:<key>`, and that id is stored
1395
1432
  inside saved layouts — renaming a `key` drops the panel out of every
1396
1433
  arrangement holding it.
1397
1434
 
1398
- An experimental fourth kind, `svgScene`, adds native SVG layers, numeric bindings,
1399
- local controls, and host-run animations. Its payload is `{kind: 'svgScene', scene,
1400
- title?, triggers?}`; it does not have a `content` string. Scene-capable hosts pass
1435
+ An experimental fourth kind, the **panel scene**, adds SVG and text layers, numeric
1436
+ bindings, local controls, native Material controls, a layout, and host-run animations.
1437
+ Its payload is `{kind: 'scene', scene, title?, triggers?}`; it does not have a
1438
+ `content` string. `kind: 'svgScene'` is the same kind under its name through SDK 0.9,
1439
+ which the SDK keeps as a deprecated alias along with the `SvgScene*` type names.
1440
+ **`scene` and everything it adds are extension API 5**: native controls,
1441
+ `strings`, `layout`, `after` and control `opacity`. A bundle using any of them
1442
+ declares `"api": 5` ([distribution.md](distribution.md)), and an app speaking
1443
+ less refuses it at install rather than showing the panel as an unknown kind.
1444
+ A plain scene that must reach those apps keeps `svgScene` and its lower API. Scene-capable hosts pass
1401
1445
  `instanceId` on render/event calls. `onEvent` handles control events and returns
1402
- numeric value patches; local-only controls and animation frames make no bridge
1446
+ value patches (`values`, and `strings` for string controls); local-only controls and animation frames make no bridge
1403
1447
  calls. Controls with `continuous: true` send `change` events during dragging and a
1404
1448
  final `commit` on release; pending movements are coalesced to the newest value.
1405
1449
  Numeric text supports `scale`, `truncate`, `modulo`, and `minIntegerDigits`
@@ -1410,8 +1454,8 @@ may hold numbers or preformatted strings; control `valueLabels` supplies accessi
1410
1454
  value descriptions. Transform/opacity bindings may also supply numeric `samples`,
1411
1455
  equally spaced over their `input` range, instead of the `output` ramp. The host
1412
1456
  interpolates and clamps these locally; chart markers and callouts can follow
1413
- individual readings without bridge calls. See [the design notes and open milestones](https://github.com/ham2k/halo/blob/main/docs/design/svg-scenes.md)
1414
- and the `k2hrc-svg-scenes` sample. This API is experimental.
1457
+ individual readings without bridge calls. See [the design notes and open milestones](https://github.com/ham2k/halo/blob/main/docs/design/panel-scenes.md)
1458
+ and the `k2hrc-panel-scenes` sample. This API is experimental.
1415
1459
 
1416
1460
  Scene-capable hosts also supply `args.environment` on **render and event** calls:
1417
1461
 
@@ -1432,7 +1476,7 @@ Scene-capable hosts also supply `args.environment` on **render and event** calls
1432
1476
  describe the resolved presentation context. Font names refer to host fonts;
1433
1477
  they are not URLs or permission to load remote fonts.
1434
1478
 
1435
- For a panel showing an `svgScene`, environment changes automatically request a
1479
+ For a panel showing a scene, environment changes automatically request a
1436
1480
  render through the existing throttle, even with no triggers declared. Hidden
1437
1481
  panels catch up on reveal; repeated resize updates coalesce; a scene laid out for
1438
1482
  an old environment cannot overwrite a newer one. Markdown and HTML panels are
@@ -1450,6 +1494,202 @@ Button controls can supply `menu: [{label, event}]` to open a native dropdown.
1450
1494
  Selecting an item emits its `event` as the action with the original control ID;
1451
1495
  dismissing the menu emits nothing.
1452
1496
 
1497
+ **Native controls.** Besides the drawn `button`, `slider` and `knob` — invisible
1498
+ hit areas over the extension's own artwork — a scene can use Material widgets
1499
+ the app themes itself: `nativeButton`, `nativeSlider`, `nativeTextField`,
1500
+ `nativeDropdown`, `nativeSwitch`, `nativeCheckbox`, `nativeRadio`,
1501
+ `nativeSegmented`, `nativeChip` and `nativeText`. `PanelSceneControl` in the SDK
1502
+ lists what each kind takes. String-valued controls (text field, dropdown, radio,
1503
+ segmented) bind to the scene's `strings` map, which sits beside `values`; a key
1504
+ is in one or the other, never both; a choice's string is `""` or one of its
1505
+ `options` (each comma-separated part, for `multi`). Their events carry `text` instead of `value`,
1506
+ and an `onEvent` result may return `strings` as well as `values`. A text layer
1507
+ whose `value` names a string shows it as-is. A text field commits on Enter, or
1508
+ on leaving it after typing; a render that lands while the operator is typing
1509
+ does not overwrite the typing. Every control's `label` is its accessible name;
1510
+ the app has no tooltips. A text field or dropdown in a panel keeps keyboard
1511
+ focus; clicking any other native control hands focus back to the callsign
1512
+ field, as clicking a button anywhere else in the Operation view does.
1513
+
1514
+ **Layout.** A native control either has an `x`/`y`/`width`/`height` rect over the
1515
+ artwork, like the drawn kinds — it takes the rect's width and its own height, up
1516
+ to the rect's, so give a field a rect tall enough for it — or omits all four and is placed by
1517
+ `scene.layout` — a tree of Flutter's `row`, `column` and `wrap`, with `flex`/`fit`
1518
+ (Expanded and Flexible), `width`/`height`, `padding`, `spacing`, the two axis
1519
+ alignments, `{spacer: flex}`, `{control: id}`, and `{scene: true}` for the
1520
+ artwork, scaled to fit. Sizes are logical pixels, like the render environment's.
1521
+ Every rect-less control appears in the layout exactly once; a layout with no
1522
+ `scene` node shows no artwork, so it may declare no layers either. A text field
1523
+ or dropdown given no width (a row child without `flex` or `width`) is 200 wide,
1524
+ and a row's children that do not fit overflow as a Flutter `Row`'s do — use
1525
+ `flex` or `wrap`. A `flex` only shares space its row or column has: in a row
1526
+ that is itself an unflexed child of a row, there is none, and its children lay
1527
+ out at their own size. The layout is at most 8 deep and 128 nodes.
1528
+
1529
+ **Controls among the layers.** Layers paint in order, and a control with a rect
1530
+ paints above all of them unless its `after` names a layer: then it paints
1531
+ directly after that one, and later layers draw over it. Layers are
1532
+ click-through: art painted over a control does not stop it working. A layer with
1533
+ `blocksPointer: true` takes the clicks that land on it while it is visible,
1534
+ covering the controls under it (keyboard focus still reaches them; mark them
1535
+ `disabled` too to shut them off). Only a pending action disables action
1536
+ controls; a value being sent does not. Any control can take an `opacity`, fixed or bound like a layer's;
1537
+ at 0 it takes no clicks, focus or screen-reader node.
1538
+
1539
+ ### Panel scene reference
1540
+
1541
+ The schema in one place; `panelScene.ts` in the SDK carries the same as types.
1542
+ Every rule below is checked when the scene arrives: a scene that breaks one is
1543
+ refused whole, and the pane shows the error naming what is wrong, rather than
1544
+ drawing part of it.
1545
+
1546
+ **The scene document**
1547
+
1548
+ | Field | |
1549
+ |---|---|
1550
+ | `version` | `1` |
1551
+ | `width`, `height` | The artwork's viewBox, in scene units. Size it to `environment.width`/`height` and a scene unit is a logical pixel. |
1552
+ | `values` | `{name: number}`: what bindings, numeric text and numeric controls read. At most 256. |
1553
+ | `strings` | `{name: string}`: what text fields and choices hold. At most 256, each up to 4096 characters. A name is in `values` or `strings`, never both. |
1554
+ | `layers` | Painted in order, each over the ones before: `svg` artwork or `text`. At most 128, and 1 MiB of artwork and text in all. |
1555
+ | `controls` | At most 64. Drawn or native; see below. |
1556
+ | `layout` | Optional. Where rect-less native controls and the artwork go; omitted, the artwork fills the pane. |
1557
+
1558
+ **Controls.** Every control has an `id` and a `label`, which is its accessible
1559
+ name. An `event` names the action sent when it is used; without one the control
1560
+ is local-only: its value changes on screen, survives renders, and never reaches
1561
+ the extension. `value` names what it shows and sets.
1562
+
1563
+ | Kind | Binds to | Sends | Also takes |
1564
+ |---|---|---|---|
1565
+ | `button` | — | `activate` | `menu` |
1566
+ | `slider` | number | `commit` | `min`, `max`, `step`, `discrete`, `hover`, `valueLabels`, `continuous` |
1567
+ | `knob` | number | `commit` | `min`, `max`, `step`, `sensitivity`, `valueLabels`, `continuous` |
1568
+ | `nativeButton` | — | `activate` | `variant` (`filled`, `tonal`, `outlined`, `text`), `icon`, `menu`; needs `event` or `menu` |
1569
+ | `nativeSlider` | number | `commit` | `min`, `max`, `step`, `valueLabels`, `continuous` |
1570
+ | `nativeTextField` | string | `commit` on Enter, or on leaving it after typing | `placeholder`, `continuous` |
1571
+ | `nativeDropdown` | string | `commit` | `options`, `placeholder` |
1572
+ | `nativeRadio` | string | `commit` | `options` |
1573
+ | `nativeSegmented` | string | `commit` | `options`, `multi` |
1574
+ | `nativeSwitch`, `nativeCheckbox` | number, 1 or 0 | `commit` | — (shows `label` beside it) |
1575
+ | `nativeChip` | number, 1 or 0; or none | `commit`; `activate` without a value | `icon`; needs `event` without a value |
1576
+ | `nativeText` | number, string, or none | nothing | `style` (a typography role), `align` (`start`, `center`, `end`) |
1577
+
1578
+ The three drawn kinds are invisible hit areas over artwork the extension paints;
1579
+ the native kinds are Material widgets the app paints and themes. Every control
1580
+ may also take:
1581
+
1582
+ - `x`, `y`, `width`, `height`: its rect over the artwork, in scene units.
1583
+ Required for the drawn kinds; a native control omits all four to be placed by
1584
+ `layout` instead, and takes the rect's width and its own height when it has one.
1585
+ - `after`: with a rect, the layer it paints directly after.
1586
+ - `opacity`: 0 to 1, fixed or bound like a layer's. At 0 the control is gone.
1587
+ - `disabled`: shown, but no click, drag, key or screen-reader adjustment acts on it.
1588
+
1589
+ `options` is `[{label, value}]`, up to 64, with each `value` unique. A choice's
1590
+ string is `""` or one of them; a `multi` choice holds its values joined with
1591
+ commas in option order, so no option may contain one. `menu` is
1592
+ `[{label, event}]`, up to 32: picking an item sends its `event` as the action.
1593
+
1594
+ **Layout nodes.** Each is an object with exactly one of these keys:
1595
+
1596
+ | Node | |
1597
+ |---|---|
1598
+ | `{row: [...]}`, `{column: [...]}` | Flutter's `Row` and `Column`. `mainAxisAlignment` (`start`, `center`, `end`, `spaceBetween`, `spaceAround`, `spaceEvenly`), `crossAxisAlignment` (`start`, `center`, `end`, `stretch`), `spacing`. |
1599
+ | `{wrap: [...]}` | Flutter's `Wrap`: `spacing`, `runSpacing`, the same alignments but no `stretch`. |
1600
+ | `{control: "id"}` | A native control without a rect. Each appears exactly once. |
1601
+ | `{scene: true}` | The artwork, scaled to fit. At most once. |
1602
+ | `{spacer: n}` | `Spacer(flex: n)`. |
1603
+
1604
+ Any node may add `width`, `height` and `padding` (one number, or
1605
+ `[start, top, end, bottom]`). A row or column's direct child may add `flex`, with
1606
+ `fit: "tight"` (the default, Flutter's `Expanded`) or `"loose"` (`Flexible`). At
1607
+ most 8 deep and 128 nodes.
1608
+
1609
+ **Events and results.** `onEvent` receives, beside the render args:
1610
+
1611
+ ```ts
1612
+ event: {
1613
+ controlId: string // the control's id
1614
+ action: string // its `event`, or the menu item's
1615
+ phase: "activate" | "change" | "commit"
1616
+ sequence: number // increases with every event from this pane
1617
+ value?: number // for a control bound to `values`
1618
+ text?: string // for a control bound to `strings`
1619
+ }
1620
+ ```
1621
+
1622
+ It answers `{values, strings?}`: a patch of names already in the scene. An
1623
+ answer with neither is taken as a failure, and so is one that throws or runs out
1624
+ of time: the pane says the action failed, and the control goes back to what the
1625
+ scene last reported. The host sends one event at a time, keeps only the newest
1626
+ of a control's queued changes, ignores an answer to an event a newer one has
1627
+ superseded, and requests a render after each. Action controls are disabled while an
1628
+ action is waiting for its answer; a value being sent does not disable them. A text field's typing is sent before any action, and an
1629
+ action queued behind a value the extension refused is not sent.
1630
+
1631
+ **An example.** A spot form over a status strip:
1632
+
1633
+ ```ts
1634
+ const state = { call: "", band: "20m", status: "Nothing spotted yet" }
1635
+
1636
+ export const spotter: PanelHook = {
1637
+ async getPanels() {
1638
+ return [{ key: "spotter", title: "Spotter", icon: "bullhorn" }]
1639
+ },
1640
+ async render() {
1641
+ return {
1642
+ kind: "scene",
1643
+ scene: {
1644
+ version: 1,
1645
+ width: 400,
1646
+ height: 40,
1647
+ values: {},
1648
+ strings: state,
1649
+ layers: [
1650
+ {
1651
+ id: "strip", x: 0, y: 0, width: 400, height: 40,
1652
+ svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 40"><rect width="400" height="40" rx="8" fill="#142337"/></svg>',
1653
+ },
1654
+ { id: "status", x: 12, y: 8, width: 376, height: 24, text: { value: "status", size: 14, color: "#a9efcf" } },
1655
+ ],
1656
+ controls: [
1657
+ { id: "call", kind: "nativeTextField", label: "Callsign", value: "call", event: "set" },
1658
+ {
1659
+ id: "band", kind: "nativeDropdown", label: "Band", value: "band", event: "set",
1660
+ options: ["40m", "20m", "15m"].map((b) => ({ label: b, value: b })),
1661
+ },
1662
+ { id: "spot", kind: "nativeButton", label: "Spot", icon: "bullhorn", event: "spot" },
1663
+ ],
1664
+ layout: {
1665
+ column: [
1666
+ { row: [{ control: "call", flex: 1 }, { control: "band", width: 110 }, { control: "spot" }], spacing: 8 },
1667
+ { scene: true, flex: 1 },
1668
+ ],
1669
+ padding: 12,
1670
+ spacing: 8,
1671
+ crossAxisAlignment: "stretch",
1672
+ },
1673
+ },
1674
+ }
1675
+ },
1676
+ async onEvent({ event }) {
1677
+ if (event.controlId === "call" && event.text !== undefined) state.call = event.text.toUpperCase()
1678
+ if (event.controlId === "band" && event.text !== undefined) state.band = event.text
1679
+ if (event.action === "spot") {
1680
+ if (!state.call) throw new Error("No callsign to spot")
1681
+ state.status = `Spotted ${state.call} on ${state.band}`
1682
+ }
1683
+ return { values: {}, strings: state }
1684
+ },
1685
+ }
1686
+ ```
1687
+
1688
+ The callsign comes back upper-cased, and the field shows the extension's
1689
+ version once the typing is committed. A bundle carrying this declares
1690
+ `"api": 5`. The `k2hrc-panel-scenes` sample in the SDK has every kind, a
1691
+ control faded in over the artwork, and a click-through layer painted over it.
1692
+
1453
1693
  The `ki2d-weather-panel`, `ki2d-solar-panel` and `ki2d-radio-panel` reference
1454
1694
  panels live in
1455
1695
  [ham2k/extensions](https://github.com/ham2k/extensions/tree/main/extensions/dashboard).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",
@@ -71,12 +71,12 @@
71
71
  "build": "node build.mjs"
72
72
  },
73
73
  "//peerDependencies": [
74
- "The libraries the extension host carries. An extension does NOT need these",
74
+ "The libraries the extension host provides. An extension does NOT need these",
75
75
  "installed: the build preset rewrites each one the manifest declares into a",
76
76
  "lookup on the host's single instance, before esbuild ever resolves it, and",
77
- "the published .d.ts carries their types rolled in. They are here, optional,",
77
+ "the published .d.ts has their types rolled in. They are here, optional,",
78
78
  "for the one case that does reach the filesystem \u2014 `inline: [...]`, which",
79
- "ships your own copy because you need a version the host does not carry."
79
+ "ships your own copy because you need a version the host does not provide."
80
80
  ],
81
81
  "peerDependencies": {
82
82
  "@ham2k/lib-callsigns": "*",
package/samples/README.md CHANGED
@@ -10,7 +10,7 @@ building — the header comment in each `src/index.ts` says what it leaves out.
10
10
  | `k2hrc-llota` | An award program: references, an offline list, activation scoring |
11
11
  | `k2hrc-cqww` | A contest: an exchange to type, and a score to keep |
12
12
  | `k2hrc-radio` | An HTML panel, and why you should probably write markdown instead |
13
- | `k2hrc-svg-scenes` | Experimental SVG controls and animations, with simulated radio and weather panels |
13
+ | `k2hrc-panel-scenes` | Experimental panel scenes: SVG artwork, controls and animations, with simulated radio and weather panels |
14
14
 
15
15
  ## Running one
16
16
 
@@ -1,7 +1,7 @@
1
- # SVG Scene Prototypes
1
+ # Panel Scene Prototypes
2
2
 
3
- Experimental radio front face and weather inspection chart, using the public
4
- `svgScene` / `PanelHook.onEvent` contracts. Requires a host with SVG scene support.
3
+ Experimental radio front face, weather inspection chart and native controls form, using the public
4
+ `scene` / `PanelHook.onEvent` contracts. Requires a host with panel scene support.
5
5
  All data is simulated; this extension does not fetch weather or control a radio.
6
6
 
7
7
  The radio keeps requested frequency separate from reported frequency, simulates
@@ -16,9 +16,9 @@ In this repository:
16
16
 
17
17
  ```sh
18
18
  node build.mjs
19
- node ../../tools/h2kext-pack.mjs build -o /tmp/k2hrc-svg-scenes.h2kext
19
+ node ../../tools/h2kext-pack.mjs build -o /tmp/k2hrc-panel-scenes.h2kext
20
20
  ```
21
21
 
22
- For the contract and migration plan, see `docs/design/svg-scenes.md` in the
22
+ For the contract and migration plan, see `docs/design/panel-scenes.md` in the
23
23
  logger repository. This is an API experiment,
24
24
  not a replacement for the production weather/solar panels yet.
@@ -1,13 +1,13 @@
1
1
  {
2
- "key": "k2hrc-svg-scenes",
3
- "name": "SVG Scene Prototypes",
4
- "shortName": "SVG Scenes",
2
+ "key": "k2hrc-panel-scenes",
3
+ "name": "Panel Scene Prototypes",
4
+ "shortName": "Panel Scenes",
5
5
  "version": "0.1.0",
6
- "description": "Experimental radio front face and weather chart; simulated data only",
6
+ "description": "Experimental radio front face, weather chart and native controls; simulated data only",
7
7
  "category": "dashboard",
8
8
  "icon": "radio-tower",
9
9
  "accentColor": "#4C6EF5",
10
- "api": 1,
10
+ "api": 5,
11
11
  "keywords": ["svg", "panel", "sample", "weather", "radio"],
12
12
  "hooks": ["panel"],
13
13
  "sharedDependencies": {
@@ -1,13 +1,13 @@
1
1
  // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
2
  // SPDX-License-Identifier: MIT
3
- import type { PanelContent, PanelHook, SceneBinding, SvgSceneLayer } from "@ham2k/extension-sdk"
3
+ import type { PanelContent, PanelHook, PanelScene, SceneBinding, PanelSceneLayer } from "@ham2k/extension-sdk"
4
4
 
5
5
  const svg = (body: string, width = 640, height = 360) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
6
6
  const bind = (value: string, input: [number, number], output: [number, number]): SceneBinding => ({ value, input, output })
7
7
  const label = (x: number, y: number, text: string, size = 16, color = "#9aaec6") =>
8
8
  `<text x="${x}" y="${y}" fill="${color}" font-family="sans-serif" font-size="${size}">${text}</text>`
9
9
  const button = (x: number, text: string) => `<rect x="${x}" y="298" width="172" height="38" rx="8" fill="#283b52"/>${label(x + 12, 322, text, 14, "#e7edf4")}`
10
- const textLayer = (id: string, value: string, x: number, y: number, width: number, size: number, prefix = "", suffix = "", decimals = 0): SvgSceneLayer => ({
10
+ const textLayer = (id: string, value: string, x: number, y: number, width: number, size: number, prefix = "", suffix = "", decimals = 0): PanelSceneLayer => ({
11
11
  id,
12
12
  x,
13
13
  y,
@@ -17,9 +17,23 @@ const textLayer = (id: string, value: string, x: number, y: number, width: numbe
17
17
  })
18
18
 
19
19
  type RadioState = { requested: number; reported: number; connected: number; due: number; slow: number }
20
+ type FormState = { strings: Record<string, string>; values: Record<string, number> }
20
21
  /** Inject time for deterministic tests; no timers or per-frame extension work. */
21
22
  export function createScenePanels(now: () => number = () => Date.now()): PanelHook {
22
23
  const radios = new Map<string, RadioState>()
24
+ const forms = new Map<string, FormState>()
25
+ function form(id: string): FormState {
26
+ let f = forms.get(id)
27
+ if (!f) {
28
+ if (forms.size >= 64) forms.delete(forms.keys().next().value!)
29
+ f = {
30
+ strings: { call: "", band: "20m", mode: "CW", antenna: "vertical", status: "Nothing spotted yet" },
31
+ values: { power: 100, qrp: 0, cluster: 1, cwOnly: 0, spotted: 0 },
32
+ }
33
+ forms.set(id, f)
34
+ }
35
+ return f
36
+ }
23
37
  function state(id: string): RadioState {
24
38
  let s = radios.get(id)
25
39
  if (!s) {
@@ -39,14 +53,16 @@ export function createScenePanels(now: () => number = () => Date.now()): PanelHo
39
53
  return [
40
54
  { key: "radio", title: "Radio Front Face · Simulation", icon: "radio-tower", multiple: true, on: ["tick:1"] },
41
55
  { key: "weather", title: "Weather Chart · Simulation", icon: "weather-partly-cloudy", multiple: true },
56
+ { key: "controls", title: "Native Controls · Simulation", icon: "form-textbox", multiple: true },
42
57
  ]
43
58
  },
44
59
  async render(args): Promise<PanelContent> {
60
+ if (args.panelKey === "controls") return { kind: "scene", scene: controlsScene(form(args.instanceId ?? args.panelKey)) }
45
61
  if (args.panelKey === "weather") {
46
62
  const temperatures = [17, 18, 21, 24, 25, 23, 20, 18]
47
63
  const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(" ")
48
64
  return {
49
- kind: "svgScene",
65
+ kind: "scene",
50
66
  scene: {
51
67
  version: 1,
52
68
  width: 640,
@@ -91,7 +107,7 @@ export function createScenePanels(now: () => number = () => Date.now()): PanelHo
91
107
  }
92
108
  const s = state(args.instanceId ?? args.panelKey)
93
109
  return {
94
- kind: "svgScene",
110
+ kind: "scene",
95
111
  scene: {
96
112
  version: 1,
97
113
  width: 640,
@@ -213,8 +229,25 @@ export function createScenePanels(now: () => number = () => Date.now()): PanelHo
213
229
  }
214
230
  },
215
231
  async onEvent(args) {
216
- const s = state(args.instanceId ?? args.panelKey)
217
232
  const event = args.event
233
+ if (args.panelKey === "controls") {
234
+ const f = form(args.instanceId ?? args.panelKey)
235
+ if (event.text !== undefined) f.strings[event.controlId] = event.text
236
+ if (event.value !== undefined) f.values[event.controlId] = event.value
237
+ if (event.action === "spot") {
238
+ const call = f.strings.call.trim().toUpperCase()
239
+ if (!call) throw new Error("No callsign to spot")
240
+ f.strings.call = call
241
+ f.values.spotted = 1
242
+ f.strings.status = `Spotted ${call} on ${f.strings.band} ${f.strings.mode}, ${f.values.qrp ? 5 : f.values.power} W`
243
+ }
244
+ if (event.action === "clear") {
245
+ f.values.spotted = 0
246
+ f.strings.status = "Nothing spotted yet"
247
+ }
248
+ return { values: f.values, strings: f.strings }
249
+ }
250
+ const s = state(args.instanceId ?? args.panelKey)
218
251
  if (event.action === "connect") {
219
252
  s.connected = 1 - s.connected
220
253
  s.due = 0
@@ -241,3 +274,88 @@ export function createScenePanels(now: () => number = () => Date.now()): PanelHo
241
274
  },
242
275
  }
243
276
  }
277
+
278
+ /**
279
+ * Every native control kind, placed by a layout around a strip of artwork. Clear sits
280
+ * over the strip instead: ordered among its layers, under a click-through sheen, and
281
+ * faded out until there is something to clear.
282
+ */
283
+ function controlsScene(f: FormState): PanelScene {
284
+ return {
285
+ version: 1,
286
+ width: 640,
287
+ height: 60,
288
+ values: f.values,
289
+ strings: f.strings,
290
+ layers: [
291
+ { id: "strip", x: 0, y: 0, width: 640, height: 60, svg: svg('<rect width="640" height="60" rx="10" fill="#142337"/>', 640, 60) },
292
+ { id: "status", x: 16, y: 16, width: 490, height: 28, text: { value: "status", size: 16, color: "#a9efcf" } },
293
+ {
294
+ id: "sheen",
295
+ x: 0,
296
+ y: 0,
297
+ width: 640,
298
+ height: 30,
299
+ svg: svg('<rect width="640" height="30" rx="10" fill="#ffffff" fill-opacity="0.08"/>', 640, 30),
300
+ },
301
+ ],
302
+ controls: [
303
+ { id: "call", kind: "nativeTextField", label: "Callsign", placeholder: "W1AW", value: "call", event: "set" },
304
+ {
305
+ id: "band",
306
+ kind: "nativeDropdown",
307
+ label: "Band",
308
+ value: "band",
309
+ event: "set",
310
+ options: ["40m", "20m", "15m", "10m"].map((b) => ({ label: b, value: b })),
311
+ },
312
+ { id: "mode", kind: "nativeSegmented", label: "Mode", value: "mode", event: "set", options: ["CW", "SSB", "FT8"].map((m) => ({ label: m, value: m })) },
313
+ { id: "spot", kind: "nativeButton", label: "Spot", icon: "bullhorn", event: "spot" },
314
+ {
315
+ id: "clear",
316
+ kind: "nativeButton",
317
+ label: "Clear",
318
+ variant: "outlined",
319
+ event: "clear",
320
+ after: "status",
321
+ x: 520,
322
+ y: 10,
323
+ width: 110,
324
+ height: 40,
325
+ opacity: { value: "spotted", input: [0, 1], output: [0, 1] },
326
+ },
327
+ { id: "power", kind: "nativeSlider", label: "Power, watts", value: "power", event: "set", min: 5, max: 100, step: 5, disabled: f.values.qrp === 1 },
328
+ { id: "qrp", kind: "nativeSwitch", label: "QRP", value: "qrp", event: "set" },
329
+ { id: "cluster", kind: "nativeCheckbox", label: "Send to cluster", value: "cluster", event: "set" },
330
+ {
331
+ id: "antenna",
332
+ kind: "nativeRadio",
333
+ label: "Antenna",
334
+ value: "antenna",
335
+ event: "set",
336
+ options: [
337
+ { label: "Vertical", value: "vertical" },
338
+ { label: "Dipole", value: "dipole" },
339
+ ],
340
+ },
341
+ { id: "cw", kind: "nativeChip", label: "CW only", value: "cwOnly", icon: "filter" },
342
+ { id: "powerText", kind: "nativeText", label: "Power", value: "power", style: "mono", align: "end" },
343
+ ],
344
+ layout: {
345
+ column: [
346
+ { row: [{ control: "call", flex: 1 }, { control: "band", width: 110 }, { control: "mode" }], spacing: 8 },
347
+ { scene: true, flex: 1 },
348
+ {
349
+ wrap: [{ control: "qrp" }, { control: "cluster" }, { control: "antenna" }, { control: "cw" }],
350
+ spacing: 12,
351
+ runSpacing: 4,
352
+ crossAxisAlignment: "center",
353
+ },
354
+ { row: [{ control: "power", flex: 1 }, { control: "powerText", width: 48 }, { control: "spot" }], spacing: 8 },
355
+ ],
356
+ padding: 12,
357
+ spacing: 8,
358
+ crossAxisAlignment: "stretch",
359
+ },
360
+ }
361
+ }
File without changes