@ham2k/extension-sdk 0.4.0 → 0.5.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) |
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) |
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,4 +1,121 @@
1
- // @ham2k/extension-sdk 0.4.0
1
+ // @ham2k/extension-sdk 0.5.1
2
+ /** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
3
+ export interface SvgScene {
4
+ version: 1;
5
+ width: number;
6
+ height: number;
7
+ values: Record<string, number>;
8
+ layers: SvgSceneLayer[];
9
+ controls?: SvgSceneControl[];
10
+ }
11
+ /** A clamped, linear mapping. No expressions or extension code run per frame. */
12
+ export interface SceneBinding {
13
+ value: string;
14
+ /** Transform the source before mapping (for example selecting a display digit). */
15
+ scale?: number;
16
+ truncate?: boolean;
17
+ modulo?: number;
18
+ input: [
19
+ number,
20
+ number
21
+ ];
22
+ output: [
23
+ number,
24
+ number
25
+ ];
26
+ /** Optional equally spaced output samples over input; replaces the output ramp. */
27
+ samples?: number[];
28
+ }
29
+ export interface SvgSceneLayer {
30
+ id: string;
31
+ x: number;
32
+ y: number;
33
+ width: number;
34
+ height: number;
35
+ /** Supply exactly one of svg or text. SVG must be self-contained. */
36
+ svg?: string;
37
+ /** Exactly one of literal/value. Use text layers, not SVG <text>, for font support.
38
+ * Size is unscaled; the host applies OS text scaling once. */
39
+ text?: {
40
+ value?: string;
41
+ literal?: string;
42
+ prefix?: string;
43
+ suffix?: string;
44
+ decimals?: number;
45
+ size?: number;
46
+ color?: string;
47
+ samples?: (number | string)[];
48
+ /** Multiply numeric text values before formatting. Defaults to 1. */
49
+ scale?: number;
50
+ /** Numeric formatting after scaling: truncate, then modulo, then zero padding. */
51
+ truncate?: boolean;
52
+ modulo?: number;
53
+ minIntegerDigits?: number;
54
+ fontFamily?: string;
55
+ fontWeight?: number;
56
+ lineHeight?: number;
57
+ letterSpacing?: number;
58
+ /** Horizontal alignment within the layer. Defaults to start. */
59
+ align?: "start" | "center" | "end";
60
+ };
61
+ rotation?: SceneBinding;
62
+ translateX?: SceneBinding;
63
+ translateY?: SceneBinding;
64
+ opacity?: SceneBinding;
65
+ /** Rotation origin as fractions of this layer's dimensions; default [0.5, 0.5]. */
66
+ pivot?: [
67
+ number,
68
+ number
69
+ ];
70
+ transitionMs?: number;
71
+ /** Local deterministic flicker/pulse; pauses when hidden, disabled for reduced motion. */
72
+ pulse?: {
73
+ periodMs: number;
74
+ minOpacity: number;
75
+ flicker?: boolean;
76
+ };
77
+ }
78
+ export interface SvgSceneControl {
79
+ id: string;
80
+ label: string;
81
+ kind: "button" | "slider" | "knob";
82
+ /** A button can open a native dropdown. Picking an item sends its event as the action. */
83
+ menu?: {
84
+ label: string;
85
+ event: string;
86
+ }[];
87
+ x: number;
88
+ y: number;
89
+ width: number;
90
+ height: number;
91
+ /** Omit event for local-only interaction (for example a chart inspection cursor). */
92
+ event?: string;
93
+ /** Send coalesced change events during dragging, plus the final commit. */
94
+ continuous?: boolean;
95
+ value?: string;
96
+ min?: number;
97
+ max?: number;
98
+ step?: number;
99
+ /** Equal-width buckets, with one bucket per step (useful for hourly charts). */
100
+ discrete?: boolean;
101
+ /** Inspect on hover. Only allowed on local-only sliders; touch/keyboard also work. */
102
+ hover?: boolean;
103
+ /** Accessible descriptions indexed by (value - min) / step. */
104
+ valueLabels?: string[];
105
+ /** Knobs use relative horizontal/upward dragging. Default: step per scene unit. */
106
+ sensitivity?: number;
107
+ }
108
+ export interface PanelSceneEvent {
109
+ controlId: string;
110
+ action: string;
111
+ phase: "activate" | "change" | "commit";
112
+ sequence: number;
113
+ value?: number;
114
+ }
115
+ /** Patch only named scene values. The host ignores stale responses. */
116
+ export interface PanelSceneEventResult {
117
+ values: Record<string, number>;
118
+ }
2
119
  export type CallInfo = {
3
120
  call: string;
4
121
  baseCall?: string;
@@ -807,6 +924,8 @@ export interface ExtensionManifest {
807
924
  domains?: string[];
808
925
  allowUserAgentOverride?: boolean;
809
926
  requiresLocation?: boolean;
927
+ requiresRadioRead?: boolean;
928
+ requiresRadioWrite?: boolean;
810
929
  experiments?: string[];
811
930
  relevance?: ExtensionRelevance;
812
931
  translations?: Record<string, {
@@ -1122,7 +1241,75 @@ export interface PanelDescriptor {
1122
1241
  multiple?: boolean;
1123
1242
  form?: SettingsField[];
1124
1243
  }
1244
+ /** Resolved host typography; sizes are logical pixels, not the app Font Scale. */
1245
+ export interface PanelTypography {
1246
+ fontFamily: string | null;
1247
+ fontFamilyFallback: string[];
1248
+ fontSize: number;
1249
+ /** OS text scaling at this exact role size (the scaler may be nonlinear). */
1250
+ scaledFontSize: number;
1251
+ fontWeight: number;
1252
+ lineHeight: number;
1253
+ letterSpacing: number;
1254
+ }
1255
+ /** Current placement environment. Never multiply coordinates by devicePixelRatio. */
1256
+ export interface PanelEnvironment {
1257
+ version: 1;
1258
+ width: number;
1259
+ height: number;
1260
+ safeInsets: {
1261
+ left: number;
1262
+ top: number;
1263
+ right: number;
1264
+ bottom: number;
1265
+ };
1266
+ brightness: "light" | "dark";
1267
+ colors: Record<"surface" | "surfaceContainer" | "onSurface" | "onSurfaceVariant" | "accent" | "primary" | "onPrimary" | "secondary" | "outline" | "outlineVariant" | "error" | "onError", string>;
1268
+ typography: Record<"body" | "label" | "title" | "display" | "mono", PanelTypography>;
1269
+ locale: string;
1270
+ textDirection: "ltr" | "rtl";
1271
+ devicePixelRatio: number;
1272
+ reducedMotion: boolean;
1273
+ highContrast: boolean;
1274
+ }
1275
+ /** A local CAT radio. Values are reported readings, never manual VFO fallbacks. */
1276
+ export interface PanelRadioState {
1277
+ id: string | null;
1278
+ name: string | null;
1279
+ status: "disconnected" | "connecting" | "connected" | "disconnecting" | "error";
1280
+ stale: boolean;
1281
+ problem: string | null;
1282
+ frequencyHz: number | null;
1283
+ mode: string | null;
1284
+ powerWatts: number | null;
1285
+ transmitting: boolean | null;
1286
+ meters: Partial<Record<"signalDbm" | "powerOut" | "swr" | "alc" | "supplyVolts", number>>;
1287
+ canTune: boolean;
1288
+ }
1289
+ export interface PanelRadioTune {
1290
+ /** Reject if this identity no longer matches the selected radio. */
1291
+ id: string;
1292
+ /** Pin a local radio by ID; omitted means follow the designated radio. */
1293
+ selection?: string;
1294
+ frequencyHz?: number;
1295
+ mode?: "LSB" | "USB" | "CW" | "AM" | "FM";
1296
+ }
1297
+ export interface PanelRadioTuneResult {
1298
+ /** Accepted by the host; confirmation still comes from reported state. */
1299
+ accepted: boolean;
1300
+ reason?: "changed" | "disconnected" | "transmitting" | "invalid";
1301
+ state: PanelRadioState | null;
1302
+ }
1125
1303
  export interface PanelRenderArgs {
1304
+ /** Present on environment-capable hosts. Changes automatically request a throttled render of an `svgScene` panel. */
1305
+ environment?: PanelEnvironment;
1306
+ /** Display time follows developer time travel; real time is for network retry/cache budgets. */
1307
+ clock?: {
1308
+ nowMillis: number;
1309
+ realNowMillis: number;
1310
+ };
1311
+ /** Stable placement identity, supplied by scene-capable hosts. */
1312
+ instanceId?: string;
1126
1313
  panelKey: string;
1127
1314
  operation: Record<string, JSONValue>;
1128
1315
  qso?: Record<string, JSONValue>;
@@ -1131,6 +1318,11 @@ export interface PanelRenderArgs {
1131
1318
  reason: string;
1132
1319
  }
1133
1320
  export type PanelContent = {
1321
+ kind: "svgScene";
1322
+ scene: SvgScene;
1323
+ title?: string;
1324
+ triggers?: string[];
1325
+ } | {
1134
1326
  kind: "markdown";
1135
1327
  content: string;
1136
1328
  title?: string;
@@ -1149,6 +1341,9 @@ export type PanelContent = {
1149
1341
  export interface PanelHook {
1150
1342
  getPanels(args: Record<string, never>, ctx: HookContext): Promise<PanelDescriptor[]>;
1151
1343
  render(args: PanelRenderArgs, ctx: HookContext): Promise<PanelContent>;
1344
+ onEvent?(args: PanelRenderArgs & {
1345
+ event: PanelSceneEvent;
1346
+ }, ctx: HookContext): Promise<PanelSceneEventResult>;
1152
1347
  }
1153
1348
  /**
1154
1349
  * The standard per-extension translator: takes the extension's i18next
@@ -1458,6 +1653,14 @@ export declare const hooks: {
1458
1653
  }[]>;
1459
1654
  };
1460
1655
  export declare const host: {
1656
+ listRadios(): Promise<PanelRadioState[]>;
1657
+ readRadio(id?: string): Promise<PanelRadioState | null>;
1658
+ setRadioConnection(args: {
1659
+ id: string;
1660
+ selection?: string;
1661
+ connected: boolean;
1662
+ }): Promise<PanelRadioTuneResult>;
1663
+ tuneRadio(args: PanelRadioTune): Promise<PanelRadioTuneResult>;
1461
1664
  fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
1462
1665
  kvGet(key: string): Promise<JSONValue | null>;
1463
1666
  getLocation(): Promise<DeviceLocation | null>;
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./types.js";
2
+ export * from "./svgScene.js";
2
3
  export * from "./i18n.js";
3
4
  export * from "./dxcc.js";
4
5
  export * from "./location.js";
@@ -60,6 +61,18 @@ const hooks = {
60
61
  }
61
62
  };
62
63
  const host = {
64
+ async listRadios() {
65
+ return await hostCallFn()("listRadios", {});
66
+ },
67
+ async readRadio(id) {
68
+ return await hostCallFn()("readRadio", { ...id ? { id } : {} });
69
+ },
70
+ async setRadioConnection(args) {
71
+ return await hostCallFn()("setRadioConnection", { ...args });
72
+ },
73
+ async tuneRadio(args) {
74
+ return await hostCallFn()("tuneRadio", { ...args });
75
+ },
63
76
  async fetch(url, options) {
64
77
  return await hostCallFn()("fetch", { url, ...options });
65
78
  },
File without changes
@@ -60,6 +60,7 @@ function qsoValues(qso) {
60
60
  const their = qso.their ?? {};
61
61
  const our = qso.our ?? {};
62
62
  const startMillis = Number(qso.startAtMillis ?? 0);
63
+ const serialSent = refField(qso.refs, "ourSerial");
63
64
  return {
64
65
  call: their.call ?? "",
65
66
  their,
@@ -71,12 +72,31 @@ function qsoValues(qso) {
71
72
  // sides — `qso.our.sent` is what WE sent, i.e. the report they received.
72
73
  rstSent: our.sent ?? "",
73
74
  rstRcvd: their.sent ?? "",
75
+ // Unpadded, as stored: `001` or `1` is the operator's call, not the
76
+ // contest's, so it is left to the `pad` filter.
77
+ serialSent,
78
+ serialRcvd: refField(qso.refs, "theirSerial"),
79
+ // The short forms are the SENT side: a CW message is what we send, and
80
+ // that is where these get typed.
81
+ rst: our.sent ?? "",
82
+ serial: serialSent,
74
83
  notes: qso.notes ?? "",
75
84
  refs: qso.refs ?? [],
76
85
  ...startMillis > 0 ? dateValues(startMillis) : { date: "", dateCompact: "", time: "", at: "" },
77
86
  startAtMillis: startMillis
78
87
  };
79
88
  }
89
+ function refField(refs, field) {
90
+ if (!Array.isArray(refs)) return "";
91
+ for (const ref of refs) {
92
+ if (ref === null || typeof ref !== "object" || Array.isArray(ref)) continue;
93
+ const value = ref[field];
94
+ if (typeof value !== "string" && typeof value !== "number") continue;
95
+ const text = String(value).trim();
96
+ if (text !== "") return text;
97
+ }
98
+ return "";
99
+ }
80
100
  function logValues(values) {
81
101
  return {
82
102
  station: values.station ?? "",
package/dist/templates.js CHANGED
@@ -30,7 +30,16 @@ function registerFilters(liquid2) {
30
30
  (value) => String(value ?? "").replace(/[^A-Za-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")
31
31
  );
32
32
  liquid2.registerFilter("alnum", (value) => String(value ?? "").replace(/[^A-Za-z0-9]/g, ""));
33
+ liquid2.registerFilter("pad", (value, width = 3) => {
34
+ const text = String(value ?? "").trim();
35
+ return text === "" ? "" : text.padStart(Number(width) || 0, "0");
36
+ });
37
+ liquid2.registerFilter("cut", (value, digits = "09") => {
38
+ const chosen = String(digits ?? "");
39
+ return String(value ?? "").replace(/[0159]/g, (digit) => chosen.includes(digit) ? CUT_NUMBERS[digit] : digit);
40
+ });
33
41
  }
42
+ const CUT_NUMBERS = { "0": "T", "1": "A", "5": "E", "9": "N" };
34
43
  class TemplateError extends Error {
35
44
  constructor(message, options) {
36
45
  super(message);
package/docs/hooks.md CHANGED
@@ -1237,6 +1237,7 @@ own identity, config form and refresh budget.
1237
1237
  interface PanelHook {
1238
1238
  getPanels(args: {}, ctx): Promise<PanelDescriptor[]>
1239
1239
  render(args: PanelRenderArgs, ctx): Promise<PanelContent>
1240
+ onEvent?(args: PanelRenderArgs & {event: PanelSceneEvent}, ctx): Promise<PanelSceneEventResult>
1240
1241
  }
1241
1242
  // PanelDescriptor: {key, title, description?, icon?, preview?, on?, form?}
1242
1243
  // PanelRenderArgs: {panelKey, operation, qso?, qsoCount, config, reason}
@@ -1247,6 +1248,101 @@ The host addresses a panel as `ext:<hookKey>:<key>`, and that id is stored
1247
1248
  inside saved layouts — renaming a `key` drops the panel out of every
1248
1249
  arrangement holding it.
1249
1250
 
1251
+ An experimental fourth kind, `svgScene`, adds native SVG layers, numeric bindings,
1252
+ local controls, and host-run animations. Its payload is `{kind: 'svgScene', scene,
1253
+ title?, triggers?}`; it does not have a `content` string. Scene-capable hosts pass
1254
+ `instanceId` on render/event calls. `onEvent` handles control events and returns
1255
+ numeric value patches; local-only controls and animation frames make no bridge
1256
+ calls. Controls with `continuous: true` send `change` events during dragging and a
1257
+ final `commit` on release; pending movements are coalesced to the newest value.
1258
+ Numeric text supports `scale`, `truncate`, `modulo`, and `minIntegerDigits`
1259
+ for local numeric formatting. The Radio Panel Example uses these for its Modern
1260
+ frequency groups; its settings offer Modern/LCD styles and radio selection. Local sliders may use `hover` and `discrete` hourly buckets. Text `samples`
1261
+ may hold numbers or preformatted strings; control `valueLabels` supplies accessible
1262
+ value descriptions. Transform/opacity bindings may also supply numeric `samples`,
1263
+ equally spaced over their `input` range, instead of the `output` ramp. The host
1264
+ interpolates and clamps these locally; chart markers and callouts can follow
1265
+ individual readings without bridge calls. See [the prototype contract and migration plan](https://github.com/ham2k/halo/blob/main/docs/design/svg-scenes.md)
1266
+ and the `k2hrc-svg-scenes` sample. This API is experimental.
1267
+
1268
+ Scene-capable hosts also supply `args.environment` on **render and event** calls:
1269
+
1270
+ - `width`, `height`, and `safeInsets` describe the current panel in logical pixels.
1271
+ The app's Font Scale is already applied to this coordinate space. Do not multiply
1272
+ coordinates by a font-scale preference or `devicePixelRatio`.
1273
+ - `brightness` is resolved to `light` or `dark`, including system theme changes.
1274
+ `colors` contains `surface`, `surfaceContainer`, `onSurface`, `onSurfaceVariant`,
1275
+ `accent`, `primary`, `onPrimary`, `secondary`, `outline`, `outlineVariant`,
1276
+ `error`, and `onError`, all `#RRGGBB` strings.
1277
+ - `typography` contains `body`, `label`, `title`, `display`, and `mono` roles, each
1278
+ with `fontFamily`, fallbacks, `fontSize`, `scaledFontSize`, `fontWeight`,
1279
+ `lineHeight`, and `letterSpacing`. Family can be null for a platform default.
1280
+ `scaledFontSize` describes OS accessibility scaling **at that role's size**;
1281
+ do not assume one linear factor for all sizes. Use it when reserving label space.
1282
+ Supply the unscaled `fontSize` as scene text `size`: the host scales text once.
1283
+ - `locale`, `textDirection`, `devicePixelRatio`, `reducedMotion`, and `highContrast`
1284
+ describe the resolved presentation context. Font names refer to host fonts;
1285
+ they are not URLs or permission to load remote fonts.
1286
+
1287
+ For a panel showing an `svgScene`, environment changes automatically request a
1288
+ render through the existing throttle, even with no triggers declared. Hidden
1289
+ panels catch up on reveal; repeated resize updates coalesce; a scene laid out for
1290
+ an old environment cannot overwrite a newer one. Markdown and HTML panels are
1291
+ themed and laid out by the host, so an environment change does not re-render
1292
+ them; their next render carries the current environment.
1293
+ `args.clock.nowMillis` follows the app clock; `realNowMillis` is for network retry
1294
+ and cache budgets, so developer time travel does not hammer an external API.
1295
+ Both `environment` and `clock` are optional in the SDK for older-host detection.
1296
+
1297
+ Use scene **text layers** for typography; SVG `<text>` is not a portable text path
1298
+ through the renderer. Text supports either `literal` or bound `value`, plus
1299
+ `fontFamily`, `fontWeight`, `lineHeight`, and `letterSpacing`. Text participates
1300
+ in native directionality, accessibility scaling and semantics.
1301
+ Button controls can supply `menu: [{label, event}]` to open a native dropdown.
1302
+ Selecting an item emits its `event` as the action with the original control ID;
1303
+ dismissing the menu emits nothing.
1304
+
1305
+ The `svg-weather`, `svg-solar`, and `svg-radio` reference panels live in
1306
+ [ham2k/extensions](https://github.com/ham2k/extensions/tree/main/extensions/dashboard).
1307
+ They use public host APIs and ship as `.h2kext` packages, not app assets.
1308
+ Install the packages, then add their panels through Edit Layout. Their original
1309
+ extension and panel keys are retained so existing placements and settings survive.
1310
+ See that repository’s dashboard README for building against the unreleased SDK.
1311
+
1312
+ The radio host calls are manifest capabilities, refused unless declared — the
1313
+ same shape as `requiresLocation`, and shown to the operator at install:
1314
+ `"requiresRadioRead": true` allows `host.listRadios()` and `host.readRadio()`;
1315
+ `"requiresRadioWrite": true` allows `host.tuneRadio()` and
1316
+ `host.setRadioConnection()`, and implies read, since a command answers with the
1317
+ radio's state. An undeclared call rejects rather than answering null.
1318
+
1319
+ **Radio Panel Example** is the CAT reference extension. `host.listRadios()` lists configured
1320
+ local transceivers. `host.readRadio(id?)` returns
1321
+ a **local** radio's reported frequency (Hz), mode, connection state,
1322
+ power, TX state and fresh telemetry, or null on hosts without this API.
1323
+ `meters.signalDbm` is receive power in whole dBm when supported, refreshed a few
1324
+ times a second; it expires after two seconds without a reading. The Flex driver receives the selected slice’s LEVEL
1325
+ meter through its native UDP channel. The reference panel draws a segmented
1326
+ receive meter and leaves it unlit when unavailable, stale, or transmitting. It does
1327
+ not substitute the app's manually entered VFO for a radio reading.
1328
+ `host.tuneRadio({id, frequencyHz?, mode?})` targets that same radio through the
1329
+ existing VFO/CAT service. Supported mode requests are LSB, USB, CW, AM and FM.
1330
+ The host rejects a changed designation, disconnected/stale radio, transmission
1331
+ in progress or malformed command, and spaces accepted tuning requests by at
1332
+ least 250 ms. Omitting a selection follows the designated logging radio; an explicit `selection`
1333
+ pins tuning to that local radio without changing the logging designation.
1334
+ An accepted result means requested, not confirmed; only subsequent
1335
+ reported state confirms it. Drivers retain their own protocol/echo handling.
1336
+ `host.setRadioConnection({id, selection?, connected})` starts or ends a local CAT
1337
+ connection using the same selection and identity checks. Acceptance starts the
1338
+ operation; follow reported connection state for completion. It does not switch
1339
+ the hardware's mains power or initiate transmission. The reference panel samples
1340
+ state once per second while visible; knob motion and LCD digit updates run locally,
1341
+ and tuning is sent continuously during dragging, paced by the CAT adapter. The
1342
+ main readout follows the requested value immediately; confirmation status distinguishes pending requests from the radio
1343
+ reporting the requested frequency. Radio-specific hardware verification remains
1344
+ necessary before considering the experimental control API stable.
1345
+
1250
1346
  **Content is a document, not widgets** — the same UI-catalog seam every
1251
1347
  other declarative hook sits behind. Prefer `markdown`: it renders with the
1252
1348
  app's own typography, theme, font scale and density, and costs no web view.
package/docs/templates.md CHANGED
@@ -74,12 +74,22 @@ that contact's length.
74
74
 
75
75
  ### `qso` — one contact
76
76
  `call`, `their`, `our` (both whole, so `qso.their.guess.name` reaches the
77
- lookup's answer), `band`, `mode`, `freq`, `rstSent`, `rstRcvd`, `notes`,
78
- `date`, `dateCompact`, `time`, `at`, `startAtMillis`, `refs`.
77
+ lookup's answer), `band`, `mode`, `freq`, `rstSent`, `rstRcvd`, `serialSent`,
78
+ `serialRcvd`, `notes`, `date`, `dateCompact`, `time`, `at`, `startAtMillis`,
79
+ `refs` — and two short forms for the sent side, `rst` (= `rstSent`) and
80
+ `serial` (= `serialSent`), since a CW message is what we send and that is
81
+ where these get typed: `{{ qso.call }} {{ qso.rst | cut }} {{ qso.serial | pad: 3 | cut }}`.
79
82
 
80
83
  `rstSent` is what WE sent — QSON stores each side's report under its own
81
84
  `sent`, and these are named for the operator's view of the contact.
82
85
 
86
+ `serialSent` / `serialRcvd` are a contest's serial numbers, blank outside
87
+ one. A contest keeps them on its own ref, so they are found by field name —
88
+ the first ref carrying `ourSerial` / `theirSerial` — which is what lets one
89
+ message serve every serial contest (docs/design/contests.md §5.8). In a CW
90
+ message `serialSent` is the number the serial field is SHOWING, the one the
91
+ QSO will be logged with. It is unpadded (`7`, not `007`): use `pad`.
92
+
83
93
  ### `config` — a panel's own form values
84
94
  Whatever that panel's `form` declared, under the keys it used.
85
95
 
@@ -112,12 +122,19 @@ with nothing to say it was invented.
112
122
 
113
123
  All of Liquid's own (`date`, `downcase`, `upcase`, `strip`, `default`,
114
124
  `join`, `size`, `first`, `last`, `round`, `truncate`, `replace`, `map`,
115
- `where`, …), plus two:
125
+ `where`, …), plus four:
116
126
 
117
127
  | filter | does | example |
118
128
  |---|---|---|
119
129
  | `dash` | non-alphanumerics → `-`, collapsed and trimmed, **case preserved** | `N0CALL/P` → `N0CALL-P` |
120
130
  | `alnum` | strip to alphanumerics | `2026-07-27` → `20260727` |
131
+ | `pad: n` | zero-pad on the left to `n` characters (default 3); **blank stays blank** | `7` → `007` |
132
+ | `cut: "digits"` | CW cut numbers for the listed digits, of `0`→`T` `1`→`A` `5`→`E` `9`→`N`; default `"09"` | `599` → `5NN`, `{{ 9 \| pad: 3 \| cut }}` → `TTN` |
133
+
134
+ `pad` leaves a blank alone because a CW message can be keyed before the
135
+ serial field has a number, and `000` sent in that gap is a serial the log
136
+ will never agree with. Order matters with `cut`: pad first, or the padding
137
+ zeros go out uncut.
121
138
 
122
139
  `alnum` is app-polo's `compact` helper under a different name: Liquid already
123
140
  has a `compact` (it drops nils from an **array**, and `op.refs`/`qso.refs` are
@@ -140,6 +157,10 @@ Each of these was measured against the shipped runtime, not assumed.
140
157
  number as SECONDS, so epoch millis render in the year 58567. Use the `…At`
141
158
  strings.
142
159
 
160
+ **`cut`'s digit list must be quoted.** Liquid reads a bare `0159` as the
161
+ number 159 before the filter sees it, so `{{ n | cut: 09 }}` cuts only the
162
+ nines — the zero is gone with no error. Write `cut: "09"`.
163
+
143
164
  **Unknown names are silent.** An unknown variable renders empty and an
144
165
  unknown filter passes its value through. That is deliberate — an operator's
145
166
  typo costs one line, not the document — but it means a misspelled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",
package/samples/README.md CHANGED
@@ -10,6 +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
14
 
14
15
  ## Running one
15
16
 
@@ -0,0 +1,24 @@
1
+ # SVG Scene Prototypes
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.
5
+ All data is simulated; this extension does not fetch weather or control a radio.
6
+
7
+ The radio keeps requested frequency separate from reported frequency, simulates
8
+ 2-second CAT acknowledgments, and has buttons for external tuning, disconnection,
9
+ and changing acknowledgment delay. Drag the knob horizontally/upward, use the band
10
+ slider, or focus either control and use arrows (Shift for larger steps) / the wheel.
11
+
12
+ The weather chart demonstrates local-only cursor and numeric sample inspection.
13
+ Drag, click, or use arrows; no interaction events cross the extension bridge.
14
+
15
+ In this repository:
16
+
17
+ ```sh
18
+ node build.mjs
19
+ node ../../tools/h2kext-pack.mjs build -o /tmp/k2hrc-svg-scenes.h2kext
20
+ ```
21
+
22
+ For the standalone Flutter preview and migration plan, see
23
+ `docs/design/svg-scenes.md` in the logger repository. This is an API experiment,
24
+ not a replacement for the production weather/solar panels yet.
@@ -0,0 +1,6 @@
1
+ import { build } from 'esbuild'
2
+ import { buildExtension } from '@ham2k/extension-tools'
3
+
4
+ await buildExtension(build, { dir: import.meta.dirname })
5
+
6
+ console.log('built build/index.js')
@@ -0,0 +1,31 @@
1
+ {
2
+ "key": "k2hrc-svg-scenes",
3
+ "name": "SVG Scene Prototypes",
4
+ "shortName": "SVG Scenes",
5
+ "version": "0.1.0",
6
+ "description": "Experimental radio front face and weather chart; simulated data only",
7
+ "category": "dashboard",
8
+ "icon": "radio-tower",
9
+ "accentColor": "#4C6EF5",
10
+ "api": 1,
11
+ "keywords": [
12
+ "svg",
13
+ "panel",
14
+ "sample",
15
+ "weather",
16
+ "radio"
17
+ ],
18
+ "hooks": [
19
+ "panel"
20
+ ],
21
+ "sharedDependencies": {
22
+ "@ham2k/lib-callsigns": "^1.0.0",
23
+ "@ham2k/lib-country-files": "^1.0.0",
24
+ "@ham2k/lib-dxcc-data": "^1.0.0",
25
+ "@ham2k/lib-format-tools": "^1.0.0",
26
+ "@ham2k/lib-geo-tools": "^1.0.0",
27
+ "@ham2k/lib-operation-data": "^1.0.0",
28
+ "i18next": "^23.0.0",
29
+ "liquidjs": "^10.0.0"
30
+ }
31
+ }
@@ -0,0 +1,12 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MIT
3
+ import { defineExtension } from '@ham2k/extension-sdk'
4
+ import manifest from '../manifest.json' with { type: 'json' }
5
+ import { createScenePanels } from './scenes.ts'
6
+
7
+ defineExtension({
8
+ ...manifest,
9
+ onActivation({ registerHook }) {
10
+ registerHook('panel', { key: manifest.key, hook: createScenePanels() })
11
+ },
12
+ })
@@ -0,0 +1,128 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MIT
3
+ import type { PanelContent, PanelHook, SceneBinding, SvgSceneLayer } from '@ham2k/extension-sdk'
4
+
5
+ const svg = (body: string, width = 640, height = 360) =>
6
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
7
+ const bind = (value: string, input: [number, number], output: [number, number]): SceneBinding => ({ value, input, output })
8
+ const label = (x: number, y: number, text: string, size = 16, color = '#9aaec6') =>
9
+ `<text x="${x}" y="${y}" fill="${color}" font-family="sans-serif" font-size="${size}">${text}</text>`
10
+ 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')}`
11
+ const textLayer = (id: string, value: string, x: number, y: number, width: number,
12
+ size: number, prefix = '', suffix = '', decimals = 0): SvgSceneLayer =>
13
+ ({ id, x, y, width, height: size + 12, text: { value, prefix, suffix, decimals, size, color: '#a9efcf' } })
14
+
15
+ type RadioState = { requested: number; reported: number; connected: number; due: number; slow: number }
16
+ /** Inject time for deterministic tests; no timers or per-frame extension work. */
17
+ export function createScenePanels(now: () => number = () => Date.now()): PanelHook {
18
+ const radios = new Map<string, RadioState>()
19
+ function state(id: string): RadioState {
20
+ let s = radios.get(id)
21
+ if (!s) {
22
+ // Bounded session-only simulator state; no storage or CAT commands.
23
+ if (radios.size >= 64) radios.delete(radios.keys().next().value!)
24
+ s = { requested: 14074, reported: 14074, connected: 1, due: 0, slow: 1 }
25
+ radios.set(id, s)
26
+ }
27
+ if (s.connected && s.due && now() >= s.due) { s.reported = s.requested; s.due = 0 }
28
+ return s
29
+ }
30
+ return {
31
+ async getPanels() {
32
+ return [
33
+ { key: 'radio', title: 'Radio Front Face · Simulation', icon: 'radio-tower', multiple: true, on: ['tick:1'] },
34
+ { key: 'weather', title: 'Weather Chart · Simulation', icon: 'weather-partly-cloudy', multiple: true },
35
+ ]
36
+ },
37
+ async render(args): Promise<PanelContent> {
38
+ if (args.panelKey === 'weather') {
39
+ const temperatures = [17, 18, 21, 24, 25, 23, 20, 18]
40
+ const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(' ')
41
+ return { kind: 'svgScene', scene: {
42
+ version: 1, width: 640, height: 360, values: { hour: 0 },
43
+ layers: [
44
+ { id: 'chart', x: 0, y: 0, width: 640, height: 360, svg: svg(`
45
+ <rect width="640" height="360" rx="18" fill="#142337"/>
46
+ ${label(30, 38, 'FORECAST / SIMULATED DATA', 18, '#e7edf4')}
47
+ ${[15, 20, 25].map(t => `<path d="M60 ${265 - (t - 15) * 14}H580" stroke="#31445b"/>${label(20, 270 - (t - 15) * 14, String(t), 12)}`).join('')}
48
+ <polyline points="${points}" fill="none" stroke="#8bdccc" stroke-width="4"/>
49
+ ${temperatures.map((t, i) => `<circle cx="${60 + i * 74}" cy="${265 - (t - 15) * 14}" r="5" fill="#e7edf4"/>${label(52 + i * 74, 288, '+' + i + 'h', 12)}`).join('')}
50
+ ${label(30, 334, 'Drag the chart to inspect · Arrow keys to step', 14)}
51
+ `) },
52
+ { id: 'cursor', x: 59, y: 100, width: 2, height: 172,
53
+ svg: svg('<rect width="2" height="172" fill="#f7c576"/>', 2, 172),
54
+ translateX: bind('hour', [0, 7], [0, 518]) },
55
+ textLayer('hour', 'hour', 30, 52, 250, 24, 'In ', ' hours'),
56
+ { ...textLayer('temperature', 'hour', 370, 52, 240, 24, '', ' °C'),
57
+ text: { value: 'hour', samples: temperatures, suffix: ' °C', size: 24, color: '#a9efcf' } },
58
+ ],
59
+ controls: [{ id: 'inspect', kind: 'slider', label: 'Forecast hour', x: 60, y: 100, width: 518, height: 185,
60
+ value: 'hour', min: 0, max: 7, step: 1 }],
61
+ } }
62
+ }
63
+ const s = state(args.instanceId ?? args.panelKey)
64
+ return { kind: 'svgScene', scene: {
65
+ version: 1, width: 640, height: 360,
66
+ values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow },
67
+ layers: [
68
+ { id: 'face', x: 0, y: 0, width: 640, height: 360, svg: svg(`
69
+ <rect width="640" height="360" rx="18" fill="#142337"/>
70
+ ${label(28, 35, 'RADIO FRONT FACE / SIMULATED CAT', 18, '#e7edf4')}
71
+ <rect x="28" y="58" width="350" height="114" rx="12" fill="#0a1622"/>
72
+ ${label(42, 78, 'REPORTED VFO', 12)}${label(42, 143, 'REQUESTED', 12)}
73
+ <path d="M417 150 A90 90 0 0 1 597 150" fill="none" stroke="#52657d" stroke-width="5"/>
74
+ ${label(423, 170, '14.0', 12)}${label(565, 170, '14.35', 12)}
75
+ ${label(424, 197, 'VFO POSITION', 12)}
76
+ <path d="M42 236 H332" stroke="#52657d" stroke-width="6"/>
77
+ ${label(42, 268, 'Band position / kHz', 14)}
78
+ ${label(548, 256, 'LINK', 12)}
79
+ ${button(28, 'Connect / disconnect')}${button(234, 'External tune +5 kHz')}${button(440, 'Toggle 2 s / 0 s delay')}
80
+ `) },
81
+ textLayer('reported', 'reported', 42, 78, 320, 34, '', ' kHz', 1),
82
+ textLayer('requested', 'requested', 168, 121, 210, 22, '', '', 1),
83
+ { id: 'needle', x: 503, y: 72, width: 8, height: 80, pivot: [0.5, 1], transitionMs: 450,
84
+ svg: svg('<path d="M4 0 L7 80 H1 Z" fill="#f6c673"/>', 8, 80),
85
+ rotation: bind('reported', [14000, 14350], [-80, 80]) },
86
+ { id: 'knob', x: 400, y: 204, width: 72, height: 72,
87
+ svg: svg('<circle cx="36" cy="36" r="34" fill="#31455d" stroke="#7187a0" stroke-width="2"/><path d="M36 8V22" stroke="#f6c673" stroke-width="4"/>', 72, 72),
88
+ rotation: bind('requested', [14000, 14350], [-150, 150]) },
89
+ { id: 'handle', x: 36, y: 224, width: 12, height: 24,
90
+ svg: svg('<rect width="12" height="24" rx="4" fill="#a9efcf"/>', 12, 24),
91
+ translateX: bind('requested', [14000, 14350], [0, 290]) },
92
+ { id: 'led', x: 548, y: 208, width: 40, height: 40,
93
+ svg: svg('<defs><radialGradient id="glow"><stop stop-color="#a9ffbd"/><stop offset="0.3" stop-color="#58e8a2"/><stop offset="1" stop-color="#58e8a2" stop-opacity="0"/></radialGradient></defs><circle cx="20" cy="20" r="20" fill="url(#glow)"/>', 40, 40),
94
+ opacity: bind('connected', [0, 1], [0.08, 1]), pulse: { periodMs: 1700, minOpacity: 0.55, flicker: true } },
95
+ ],
96
+ controls: [
97
+ { id: 'tune', label: 'Tuning knob, kHz', kind: 'knob', x: 398, y: 202, width: 76, height: 76,
98
+ value: 'requested', min: 14000, max: 14350, step: 0.1, sensitivity: 0.1, event: 'tune' },
99
+ { id: 'band', label: 'Band position, kHz', kind: 'slider', x: 42, y: 215, width: 290, height: 42,
100
+ value: 'requested', min: 14000, max: 14350, step: 0.1, event: 'tune' },
101
+ ...(['connect', 'external', 'delay'] as const).map((event, i) => ({
102
+ id: event, label: ['Toggle connection', 'Simulate external tuning', 'Toggle CAT delay'][i],
103
+ kind: 'button' as const, x: 28 + i * 206, y: 298, width: 172, height: 38, event,
104
+ })),
105
+ ],
106
+ } }
107
+ },
108
+ async onEvent(args) {
109
+ const s = state(args.instanceId ?? args.panelKey)
110
+ const event = args.event
111
+ if (event.action === 'connect') { s.connected = 1 - s.connected; s.due = 0; s.requested = s.reported }
112
+ if (event.action === 'delay') s.slow = 1 - s.slow
113
+ if (event.action === 'external') {
114
+ if (!s.connected) throw new Error('Radio disconnected')
115
+ s.reported = s.reported + 5 > 14350 ? 14000 : s.reported + 5
116
+ s.requested = s.reported; s.due = 0
117
+ }
118
+ if (event.action === 'tune') {
119
+ if (!s.connected) throw new Error('Radio disconnected')
120
+ if (typeof event.value !== 'number' || !Number.isFinite(event.value)) throw new Error('Invalid frequency')
121
+ s.requested = Math.max(14000, Math.min(14350, event.value))
122
+ s.due = now() + (s.slow ? 2000 : 0)
123
+ if (!s.slow) { s.reported = s.requested; s.due = 0 }
124
+ }
125
+ return { values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow } }
126
+ },
127
+ }
128
+ }