@ham2k/extension-sdk 0.5.5 → 0.5.7

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
@@ -34,24 +34,24 @@ build/ generated: index.js + manifest.json
34
34
  ## The smallest whole extension
35
35
 
36
36
  ```ts
37
- import { defineExtension } from '@ham2k/extension-sdk'
38
- import type { PanelHook } from '@ham2k/extension-sdk'
37
+ import { defineExtension } from "@ham2k/extension-sdk"
38
+ import type { PanelHook } from "@ham2k/extension-sdk"
39
39
 
40
- import manifest from '../manifest.json'
40
+ import manifest from "../manifest.json"
41
41
 
42
42
  const HelloPanel: PanelHook = {
43
43
  async getPanels() {
44
- return [{ key: 'hello', title: 'Hello World', icon: 'hand-wave-outline' }]
44
+ return [{ key: "hello", title: "Hello World", icon: "hand-wave-outline" }]
45
45
  },
46
46
  async render() {
47
- return { kind: 'markdown', content: '# Hello, World!' }
47
+ return { kind: "markdown", content: "# Hello, World!" }
48
48
  },
49
49
  }
50
50
 
51
51
  defineExtension({
52
52
  ...manifest,
53
53
  onActivation({ registerHook }) {
54
- registerHook('panel', { key: manifest.key, hook: HelloPanel })
54
+ registerHook("panel", { key: manifest.key, hook: HelloPanel })
55
55
  },
56
56
  })
57
57
  ```
@@ -100,7 +100,7 @@ No Node, no DOM, no `fetch`, no `window`, no filesystem. One ES2020 engine
100
100
  evaluating one script. Reach the outside world through `host`:
101
101
 
102
102
  ```ts
103
- import { host } from '@ham2k/extension-sdk'
103
+ import { host } from "@ham2k/extension-sdk"
104
104
 
105
105
  await host.fetch(url) // https only, and only domains your manifest lists
106
106
  await host.kvGet('key') // storage, namespaced to your extension
@@ -114,7 +114,7 @@ host.log('…') // the app's console
114
114
 
115
115
  ## Traps worth knowing before you write anything
116
116
 
117
- - **A panel returns content, not widgets.** `{ kind: 'markdown', content }` and
117
+ - **A panel returns content, not widgets.** `{ kind: "markdown", content }` and
118
118
  its siblings. There is no way to reach the app's UI toolkit, by design.
119
119
  - **`host.fetch` only reaches domains your manifest's `domains` lists**, over
120
120
  https. An undeclared host is refused at the call, not at build.
@@ -122,7 +122,7 @@ host.log('…') // the app's console
122
122
  carries `@ham2k/lib-*`, `i18next` and `liquidjs`; naming one in
123
123
  `sharedDependencies` rewrites your imports into lookups on the app's single
124
124
  copy, which is worth roughly thirty times the bundle size. Don't install them.
125
- `inline: ['liquidjs']` in your build ships your own copy, for when you need a
125
+ `inline: ["liquidjs"]` in your build ships your own copy, for when you need a
126
126
  version the app does not carry. A scaffolded manifest already declares the
127
127
  eight this SDK's own modules reach — importing anything from it pulls them
128
128
  in — so deleting one from the manifest breaks a build that was working.
package/README.md CHANGED
@@ -9,24 +9,24 @@ npm install --save-dev @ham2k/extension-tools esbuild
9
9
  ```
10
10
 
11
11
  ```ts
12
- import { defineExtension } from '@ham2k/extension-sdk'
13
- import type { PanelHook } from '@ham2k/extension-sdk'
12
+ import { defineExtension } from "@ham2k/extension-sdk"
13
+ import type { PanelHook } from "@ham2k/extension-sdk"
14
14
 
15
- import manifest from '../manifest.json'
15
+ import manifest from "../manifest.json"
16
16
 
17
17
  const HelloPanel: PanelHook = {
18
18
  async getPanels() {
19
- return [{ key: 'hello', title: 'Hello World', icon: 'hand-wave-outline' }]
19
+ return [{ key: "hello", title: "Hello World", icon: "hand-wave-outline" }]
20
20
  },
21
21
  async render() {
22
- return { kind: 'markdown', content: '# Hello, World!' }
22
+ return { kind: "markdown", content: "# Hello, World!" }
23
23
  },
24
24
  }
25
25
 
26
26
  defineExtension({
27
27
  ...manifest,
28
28
  onActivation({ registerHook }) {
29
- registerHook('panel', { key: manifest.key, hook: HelloPanel })
29
+ registerHook("panel", { key: manifest.key, hook: HelloPanel })
30
30
  },
31
31
  })
32
32
  ```
@@ -23,14 +23,16 @@ function activityExportHook(rules) {
23
23
  const prefix = `${rules.key}-adif`;
24
24
  return {
25
25
  async getExportTypes() {
26
- return [{
27
- ...exportTypeDefinition(rules.activationType, "adif", rules.label),
28
- templateCategory: "reference",
29
- templateSample: rules.templateSample ?? {
30
- log: { ref: "REF-1234", refName: "Example Reference" },
31
- operation: { refs: [{ type: rules.activationType, ref: "REF-1234" }] }
26
+ return [
27
+ {
28
+ ...exportTypeDefinition(rules.activationType, "adif", rules.label),
29
+ templateCategory: "reference",
30
+ templateSample: rules.templateSample ?? {
31
+ log: { ref: "REF-1234", refName: "Example Reference" },
32
+ operation: { refs: [{ type: rules.activationType, ref: "REF-1234" }] }
33
+ }
32
34
  }
33
- }];
35
+ ];
34
36
  },
35
37
  async suggestExportOptions({ operation, qsos, compactFilenames }, ctx) {
36
38
  const refs = refsOfType(operation, rules.activationType);
@@ -113,21 +115,23 @@ function huntingExportHook(rules) {
113
115
  if (refsOfType(operation, rules.activationType).length > 0) return [];
114
116
  if (huntedQsos(qsos ?? []).length === 0) return [];
115
117
  const t = tFor(ctx);
116
- return [{
117
- exportType,
118
- qsoCount: huntedQsos(qsos ?? []).length,
119
- templateData: { activity: rules.label },
120
- format: "adif",
121
- label: t("adifForHunter", { program: rules.label }),
122
- filename: filenameForHunt(operation, qsos ?? [], rules.label, compactFilenames),
123
- icon: rules.icon,
124
- color: rules.color,
125
- selectedByDefault: true
126
- // No `refType`: hunted refs live on QSOs, not on operation segments,
127
- // so this isn't the segment-scoped slice `activityExportHook` uses —
128
- // it spans the whole log. (See `HuntingExportRules.color` for why the
129
- // accent has to be threaded explicitly instead.)
130
- }];
118
+ return [
119
+ {
120
+ exportType,
121
+ qsoCount: huntedQsos(qsos ?? []).length,
122
+ templateData: { activity: rules.label },
123
+ format: "adif",
124
+ label: t("adifForHunter", { program: rules.label }),
125
+ filename: filenameForHunt(operation, qsos ?? [], rules.label, compactFilenames),
126
+ icon: rules.icon,
127
+ color: rules.color,
128
+ selectedByDefault: true
129
+ // No `refType`: hunted refs live on QSOs, not on operation segments,
130
+ // so this isn't the segment-scoped slice `activityExportHook` uses —
131
+ // it spans the whole log. (See `HuntingExportRules.color` for why the
132
+ // accent has to be threaded explicitly instead.)
133
+ }
134
+ ];
131
135
  },
132
136
  async generateExport(args, ctx) {
133
137
  const requested = String(args.exportType ?? "");
@@ -163,7 +167,18 @@ function huntingExportHook(rules) {
163
167
  }
164
168
  };
165
169
  }
166
- async function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }) {
170
+ async function adifForExport({
171
+ operation,
172
+ qsos,
173
+ segments,
174
+ includePrivateData,
175
+ includeLookupData,
176
+ exportSettings,
177
+ exportData,
178
+ exportTitle,
179
+ mainHandler,
180
+ includeFieldsFrom
181
+ }) {
167
182
  const { hooks } = await import("@ham2k/extension-sdk");
168
183
  const entries = await hooks.invokeOne("export", "adif", "generateExport", {
169
184
  operation,
@@ -62,9 +62,7 @@ function activityScorer(rules) {
62
62
  if (candidates.length === 0) notice ?? (notice = "mode");
63
63
  }
64
64
  if (unique.has("ref")) {
65
- candidates = candidates.filter(
66
- (c) => c.refs.length === 0 && now.refs.length === 0 || c.refs.some((r) => now.refs.includes(r))
67
- );
65
+ candidates = candidates.filter((c) => c.refs.length === 0 && now.refs.length === 0 || c.refs.some((r) => now.refs.includes(r)));
68
66
  if (candidates.length === 0 && now.refs.length > 0) notice ?? (notice = "ref");
69
67
  }
70
68
  if (notice) return notice;
@@ -189,14 +187,10 @@ function summarizeActivation(activatedRefs, qsos, duplicates, huntedQsos, scope,
189
187
  const detail = refKeys.map(
190
188
  (key) => activatedRefs[key] >= qsosToActivate ? `\u2705 **${key}: ${fmtInteger(activatedRefs[key])}**` : `\u274C ${key}: ${fmtInteger(activatedRefs[key])}/${fmtInteger(qsosToActivate)}`
191
189
  );
192
- const parts = [
193
- qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })
194
- ];
190
+ const parts = [qsos === 1 ? t("activationQsoOne") : t("activationQsoMany", { formatted: fmtInteger(qsos) })];
195
191
  if (huntedQsos > 0) {
196
192
  const p2p = resolveRuleText(rules.p2pLabel, ctx, t("ref2ref"));
197
- parts.push(
198
- huntedQsos === 1 ? t("p2pQsoOne", { label: p2p }) : t("p2pQsoMany", { label: p2p, formatted: fmtInteger(huntedQsos) })
199
- );
193
+ parts.push(huntedQsos === 1 ? t("p2pQsoOne", { label: p2p }) : t("p2pQsoMany", { label: p2p, formatted: fmtInteger(huntedQsos) }));
200
194
  }
201
195
  return {
202
196
  key: "activation",
@@ -26,22 +26,23 @@ function exportFilename(parts) {
26
26
  const millis = parts.startAtMillis && parts.startAtMillis > 0 ? parts.startAtMillis : Date.now();
27
27
  const extension = parts.extension.replace(/^\./, "");
28
28
  const shape = `${parts.compact ? "Compact" : "Normal"}${parts.ref ? "Ref" : parts.activity ? "Activity" : ""}Name`;
29
- const modifier = [
30
- (parts.modifier ?? "").trim(),
31
- isTestOperation(parts.stationCall) ? "Testing" : ""
32
- ].filter((part) => part !== "").join(" ");
29
+ const modifier = [(parts.modifier ?? "").trim(), isTestOperation(parts.stationCall) ? "Testing" : ""].filter((part) => part !== "").join(" ");
33
30
  const stem = oneLine(
34
- renderTemplate(NAME_TEMPLATES[shape], {
35
- op: dateValues(millis),
36
- log: logValues({
37
- station: String(parts.stationCall ?? "").trim(),
38
- ref: parts.ref,
39
- activity: parts.activity,
40
- modifier,
41
- extension,
42
- compact: parts.compact
43
- })
44
- }, shape)
31
+ renderTemplate(
32
+ NAME_TEMPLATES[shape],
33
+ {
34
+ op: dateValues(millis),
35
+ log: logValues({
36
+ station: String(parts.stationCall ?? "").trim(),
37
+ ref: parts.ref,
38
+ activity: parts.activity,
39
+ modifier,
40
+ extension,
41
+ compact: parts.compact
42
+ })
43
+ },
44
+ shape
45
+ )
45
46
  );
46
47
  return `${sanitize(stem) || "export"}.${extension}`;
47
48
  }
@@ -118,7 +118,17 @@ function prepareExportOption(option, operation, qsos, compact) {
118
118
  const rendered = oneLine(renderTemplate(template ?? "", context, "Export filename"));
119
119
  const stem = rendered.replace(/[/\\:*?"<>|\x00-\x1f]+/g, "-").replace(/[. ]+$/g, "").trim() || "export";
120
120
  const filename = stem.toLowerCase().endsWith(`.${extension}`) ? stem : `${stem}.${extension}`;
121
- const titleContext = settings.includePrivateData === false ? { ...context, op: { ...context.op, title: "", userTitle: "", userNotes: "", grid: String(context.op.grid ?? "").slice(0, 6) }, log: { ...context.log, modifier: "" } } : context;
121
+ const titleContext = settings.includePrivateData === false ? {
122
+ ...context,
123
+ op: {
124
+ ...context.op,
125
+ title: "",
126
+ userTitle: "",
127
+ userNotes: "",
128
+ grid: String(context.op.grid ?? "").slice(0, 6)
129
+ },
130
+ log: { ...context.log, modifier: "" }
131
+ } : context;
122
132
  const title = oneLine(renderTemplate(settings.titleTemplate ?? "", titleContext, "Export title"));
123
133
  return { ...option, filename, exportTitle: title, exportData: settings.includePrivateData === false ? { ...data, modifier: "" } : data };
124
134
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 0.5.5
1
+ // @ham2k/extension-sdk 0.5.7
2
2
  /** Experimental v1 scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface SvgScene {
4
4
  version: 1;
@@ -102,7 +102,7 @@ export interface SvgSceneControl {
102
102
  hover?: boolean;
103
103
  /** Accessible descriptions indexed by (value - min) / step. */
104
104
  valueLabels?: string[];
105
- /** Knobs use relative horizontal/upward dragging. Default: step per scene unit. */
105
+ /** Knobs use relative horizontal/upward dragging. Value per scene unit; default 1. */
106
106
  sensitivity?: number;
107
107
  }
108
108
  export interface PanelSceneEvent {
@@ -393,6 +393,7 @@ export interface RefGeojson {
393
393
  export interface LoggingControlDescriptor {
394
394
  key: string;
395
395
  label: string;
396
+ shortLabel?: string;
396
397
  icon?: string;
397
398
  color?: string;
398
399
  order?: number;
@@ -632,12 +633,14 @@ export interface ScoreQsosRequest {
632
633
  segments?: OperationSegmentPayload[];
633
634
  ref?: Record<string, JSONValue>;
634
635
  resumeFrom?: JSONValue;
636
+ resumeDay?: number;
635
637
  }
636
638
  export interface ScoreQsosResult {
637
639
  qsoScores: Record<string, QsoScoreVerdict>;
638
640
  daySections: DayScoreSummary[];
639
641
  operationSummary: Record<string, ScoreTally>;
640
642
  scoresheet?: JSONValue;
643
+ scoresheetDay?: number;
641
644
  }
642
645
  export interface ScoreQsoRequest {
643
646
  operation: Record<string, JSONValue>;
@@ -646,6 +649,8 @@ export interface ScoreQsoRequest {
646
649
  ref?: Record<string, JSONValue>;
647
650
  segments?: OperationSegmentPayload[];
648
651
  resumeFrom?: JSONValue;
652
+ resumeDay?: number;
653
+ qsos?: Record<string, JSONValue>[];
649
654
  }
650
655
  export interface QsoScoreNotices {
651
656
  notices: string[];
@@ -661,6 +666,8 @@ export interface ScoreCandidatesRequest {
661
666
  ref?: Record<string, JSONValue>;
662
667
  segments?: OperationSegmentPayload[];
663
668
  resumeFrom?: JSONValue;
669
+ resumeDay?: number;
670
+ qsos?: Record<string, JSONValue>[];
664
671
  }
665
672
  export type ScoreCandidatesResult = Record<string, QsoScoreNotices>;
666
673
  export interface ScoringHook {
@@ -1520,7 +1527,7 @@ export declare function huntingExportHook(rules: HuntingExportRules): {
1520
1527
  suggestExportOptions({ operation, qsos, compactFilenames }: ExportOptionsRequest, ctx: HookContext): Promise<ExportOption[]>;
1521
1528
  generateExport(args: ExportRequest, ctx: HookContext): Promise<ExportResult>;
1522
1529
  };
1523
- export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom }: {
1530
+ export declare function adifForExport({ operation, qsos, segments, includePrivateData, includeLookupData, exportSettings, exportData, exportTitle, mainHandler, includeFieldsFrom, }: {
1524
1531
  operation: Record<string, JSONValue>;
1525
1532
  qsos: Record<string, JSONValue>[];
1526
1533
  segments?: OperationSegmentPayload[];
package/dist/scoring.js CHANGED
@@ -1,7 +1,10 @@
1
1
  const DAY_IN_MILLIS = 1e3 * 60 * 60 * 24;
2
- function copyCheckpoint(sheet) {
2
+ function copySheet(sheet) {
3
3
  return JSON.parse(JSON.stringify(sheet));
4
4
  }
5
+ function openCheckpoint(resumeFrom, resumeDay) {
6
+ return { sheet: copySheet(resumeFrom), day: typeof resumeDay === "number" ? resumeDay : void 0 };
7
+ }
5
8
  function dayOf(qso) {
6
9
  const millis = Number(qso.startAtMillis ?? 0);
7
10
  return millis - millis % DAY_IN_MILLIS;
@@ -27,9 +30,9 @@ function contestScorer(scorer, options = {}) {
27
30
  const refs = operation.refs ?? [];
28
31
  return refs.find((r) => typeof r?.type === "string" && scopedRefTypes.includes(r.type));
29
32
  }
30
- function fold(scoresheet, qsos, args, ctx, onQso, onDayEnd, segments) {
33
+ function fold(scoresheet, qsos, args, ctx, onQso, onDayEnd, segments, startDay) {
31
34
  let sheet = scoresheet;
32
- let currentDay;
35
+ let currentDay = startDay;
33
36
  let dayCount = 0;
34
37
  const timeline = segments && segments.length > 1 ? segments : void 0;
35
38
  let segmentIndex = 0;
@@ -60,35 +63,29 @@ function contestScorer(scorer, options = {}) {
60
63
  onQso?.(typeof qso.uuid === "string" ? qso.uuid : void 0, result.score);
61
64
  }
62
65
  if (currentDay !== void 0) onDayEnd?.(currentDay, dayCount, sheet);
63
- return sheet;
66
+ return { sheet, day: currentDay };
64
67
  }
65
68
  async function liveScoresheet(args, ctx, excludeUuid) {
66
69
  if (args.resumeFrom !== void 0) {
67
- return copyCheckpoint(args.resumeFrom);
70
+ const { sheet, day } = openCheckpoint(args.resumeFrom, args.resumeDay);
71
+ const tail = chronological(args.qsos ?? []).filter((q) => !excludeUuid || q.uuid !== excludeUuid);
72
+ if (tail.length === 0) return { sheet, day };
73
+ return fold(sheet, tail, args, ctx, void 0, void 0, args.segments, day);
68
74
  }
69
75
  const uuid = typeof args.operation.uuid === "string" ? args.operation.uuid : void 0;
70
76
  const log = uuid ? await ctx.getQsos?.(uuid) ?? [] : [];
71
- const rest = chronological(log).filter(
72
- (q) => !excludeUuid || q.uuid !== excludeUuid
73
- );
74
- return fold(
75
- scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx),
76
- rest,
77
- args,
78
- ctx,
79
- void 0,
80
- void 0,
81
- args.segments
82
- );
77
+ const rest = chronological(log).filter((q) => !excludeUuid || q.uuid !== excludeUuid);
78
+ return fold(scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx), rest, args, ctx, void 0, void 0, args.segments);
83
79
  }
84
80
  return {
85
81
  scope,
86
82
  async scoreQsos(args, ctx) {
87
83
  const qsos = chronological(args.qsos);
88
- const start = args.resumeFrom !== void 0 ? copyCheckpoint(args.resumeFrom) : scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx);
84
+ const resumed = args.resumeFrom !== void 0 ? openCheckpoint(args.resumeFrom, args.resumeDay) : void 0;
85
+ const start = resumed?.sheet ?? scorer.startScoresheet({ operation: args.operation, ref: args.ref }, ctx);
89
86
  const qsoScores = {};
90
87
  const daySections = [];
91
- const end = fold(
88
+ const { sheet: end, day: endDay } = fold(
92
89
  start,
93
90
  qsos,
94
91
  args,
@@ -103,27 +100,23 @@ function contestScorer(scorer, options = {}) {
103
100
  scores: scorer.summarizeScore({ scoresheet: sheet, operation: args.operation, ref: args.ref, scope: "day" }, ctx)
104
101
  });
105
102
  },
106
- args.segments
103
+ args.segments,
104
+ resumed?.day
107
105
  );
108
106
  return {
109
107
  qsoScores,
110
108
  daySections,
111
- operationSummary: scorer.summarizeScore(
112
- { scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" },
113
- ctx
114
- ),
115
- scoresheet: end
109
+ operationSummary: scorer.summarizeScore({ scoresheet: end, operation: args.operation, ref: args.ref, scope: "operation" }, ctx),
110
+ scoresheet: end,
111
+ ...endDay !== void 0 && { scoresheetDay: endDay }
116
112
  };
117
113
  },
118
114
  /// Live path: score the candidate against the log so far and report only
119
115
  /// its notices/alerts. Nothing is recorded — the resulting scoresheet is
120
116
  /// discarded, which is exactly why one `scoreQso` can serve both paths.
121
117
  async scoreQso(args, ctx) {
122
- const sheet = await liveScoresheet(args, ctx, args.excludeUuid);
123
- const { score } = scorer.scoreQso(
124
- { scoresheet: sheet, qso: candidateAsOfNow(args.qso), operation: args.operation, ref: args.ref, isNewDay: false },
125
- ctx
126
- );
118
+ const { sheet } = await liveScoresheet(args, ctx, args.excludeUuid);
119
+ const { score } = scorer.scoreQso({ scoresheet: sheet, qso: candidateAsOfNow(args.qso), operation: args.operation, ref: args.ref, isNewDay: false }, ctx);
127
120
  return { notices: score.notices ?? [], alerts: score.alerts ?? [], ...score.dupe !== void 0 && { dupe: score.dupe } };
128
121
  },
129
122
  /// The same live judgement as `scoreQso`, for many candidates at once —
@@ -138,12 +131,12 @@ function contestScorer(scorer, options = {}) {
138
131
  /// contract that makes the batch fold linear. Without it the first
139
132
  /// candidate's call would count as worked for every candidate after it.
140
133
  async scoreCandidates(args, ctx) {
141
- const base = await liveScoresheet(args, ctx);
134
+ const { sheet: base } = await liveScoresheet(args, ctx);
142
135
  const result = {};
143
136
  for (const candidate of args.candidates) {
144
137
  const { score } = scorer.scoreQso(
145
138
  {
146
- scoresheet: copyCheckpoint(base),
139
+ scoresheet: copySheet(base),
147
140
  qso: candidateAsOfNow(candidate.qso),
148
141
  operation: args.operation,
149
142
  ref: args.ref,
@@ -198,8 +198,8 @@ below — manifest, source, build script — so the rest of this section is what
198
198
  change rather than what to type.
199
199
 
200
200
  ```js
201
- import { build } from 'esbuild'
202
- import { buildExtension } from '@ham2k/extension-tools'
201
+ import { build } from "esbuild"
202
+ import { buildExtension } from "@ham2k/extension-tools"
203
203
 
204
204
  await buildExtension(build, { dir: import.meta.dirname })
205
205
  ```
@@ -224,7 +224,7 @@ own module state — and nothing anywhere that says so. If the second copy is
224
224
  what you want, which is how you use a version the host does not carry, say so:
225
225
 
226
226
  ```js
227
- await buildExtension(build, { dir: import.meta.dirname, inline: ['liquidjs'] })
227
+ await buildExtension(build, { dir: import.meta.dirname, inline: ["liquidjs"] })
228
228
  ```
229
229
 
230
230
  `extensions/samples/hello-world` is the shortest complete example. Declaring
package/docs/hooks.md CHANGED
@@ -499,8 +499,11 @@ interface ActivityHook {
499
499
  suggest?(args: SuggestArgs, ctx): Promise<ActivitySuggestion[]>
500
500
  processQsoBeforeSave?(args: { qso, operation }, ctx): Promise<Patch | null>
501
501
  }
502
- // LoggingControlDescriptor: {key, label, icon?, color?, order?, optionType?,
503
- // allowsMultiple?, editable?, input}
502
+ // LoggingControlDescriptor: {key, label, shortLabel?, icon?, color?, order?,
503
+ // optionType?, allowsMultiple?, editable?, input}
504
+ // `shortLabel` is the same name in fewer characters, for a field the logging
505
+ // row has squeezed below its label's width. The core measures and picks; the
506
+ // extension only says what the short form is.
504
507
  // SuggestArgs: {operation, location?: {lat, lon}, callsign?, searchTerm?, scoped?}
505
508
  // ActivitySuggestion: Ref & {distance?, relevance?, allowsMultiple?}
506
509
 
@@ -1168,14 +1171,16 @@ interface ScoringHook {
1168
1171
  scoreQso?(args: ScoreQsoRequest, ctx): Promise<QsoScoreNotices>
1169
1172
  scoreCandidates?(args: ScoreCandidatesRequest, ctx): Promise<ScoreCandidatesResult>
1170
1173
  }
1171
- // ScoreQsosRequest: {operation, qsos, ref?, resumeFrom?}
1174
+ // ScoreQsosRequest: {operation, qsos, ref?, segments?, resumeFrom?, resumeDay?}
1172
1175
  // ScoreQsosResult: {
1173
1176
  // qsoScores: Record<uuid, QsoScoreVerdict>, // one per QSO claimed; see above
1174
1177
  // daySections: {day, count, scores: Record<tallyKey, ScoreTally>}[],
1175
1178
  // operationSummary: Record<tallyKey, ScoreTally>,
1176
1179
  // scoresheet, // checkpoint to resume from
1180
+ // scoresheetDay?, // the day it ends on, handed back as resumeDay
1177
1181
  // }
1178
- // ScoreCandidatesRequest: {operation, candidates: {key, qso}[], ref?, segments?, resumeFrom?}
1182
+ // ScoreQsoRequest: {operation, qso, excludeUuid?, ref?, segments?, resumeFrom?, resumeDay?, qsos?}
1183
+ // ScoreCandidatesRequest: {operation, candidates: {key, qso}[], ref?, segments?, resumeFrom?, resumeDay?, qsos?}
1179
1184
  // ScoreCandidatesResult: Record<candidateKey, QsoScoreNotices>
1180
1185
  ```
1181
1186
 
@@ -1187,7 +1192,16 @@ both live paths for free.
1187
1192
  A candidate that carries no `startAtMillis` (the draft's time is still
1188
1193
  automatic) reaches the scorer stamped with the current time on both live
1189
1194
  paths, so a day-bucketed rule judges it as of now rather than at the epoch; a
1190
- candidate that states a time keeps it.
1195
+ candidate that states a time keeps it. On both live paths a `resumeFrom`
1196
+ checkpoint, with the `qsos` logged since it, stands in for the whole-log read:
1197
+ the harness copies the checkpoint, replays that tail onto it (continuing the
1198
+ day `resumeDay` names, so no scorer's per-day tally restarts mid-day) and
1199
+ scores the candidate against the result. The host sends a checkpoint only when
1200
+ it can prove the log is what the checkpoint folded plus appended contacts;
1201
+ otherwise the harness reads the log through `ctx.getQsos` and folds it cold.
1202
+ The batched `scoreQsos` resumes the same way: after the first pass of a
1203
+ session, an append arrives as `resumeFrom` plus the appended contacts, and only
1204
+ their verdicts come back.
1191
1205
  `scoreCandidates` answers the same question for many at once, which is what the
1192
1206
  Spots Panel asks to mark the spots already worked: the log is folded once per
1193
1207
  scorer and every candidate scored against a COPY of the result, so no candidate
@@ -1288,13 +1302,14 @@ numeric value patches; local-only controls and animation frames make no bridge
1288
1302
  calls. Controls with `continuous: true` send `change` events during dragging and a
1289
1303
  final `commit` on release; pending movements are coalesced to the newest value.
1290
1304
  Numeric text supports `scale`, `truncate`, `modulo`, and `minIntegerDigits`
1291
- for local numeric formatting. KI2D’s Radio Panel uses these for its Modern
1305
+ for local numeric formatting; bindings accept `scale`, `truncate` and `modulo`
1306
+ too, applied to the value before the ramp. KI2D’s Radio Panel uses these for its Modern
1292
1307
  frequency groups; its settings offer Modern/LCD styles and radio selection. Local sliders may use `hover` and `discrete` hourly buckets. Text `samples`
1293
1308
  may hold numbers or preformatted strings; control `valueLabels` supplies accessible
1294
1309
  value descriptions. Transform/opacity bindings may also supply numeric `samples`,
1295
1310
  equally spaced over their `input` range, instead of the `output` ramp. The host
1296
1311
  interpolates and clamps these locally; chart markers and callouts can follow
1297
- individual readings without bridge calls. See [the prototype contract and migration plan](https://github.com/ham2k/halo/blob/main/docs/design/svg-scenes.md)
1312
+ individual readings without bridge calls. See [the design notes and open milestones](https://github.com/ham2k/halo/blob/main/docs/design/svg-scenes.md)
1298
1313
  and the `k2hrc-svg-scenes` sample. This API is experimental.
1299
1314
 
1300
1315
  Scene-capable hosts also supply `args.environment` on **render and event** calls:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",
@@ -1,6 +1,6 @@
1
- import { build } from 'esbuild'
2
- import { buildExtension } from '@ham2k/extension-tools'
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
3
 
4
4
  await buildExtension(build, { dir: import.meta.dirname })
5
5
 
6
- console.log('built build/index.js')
6
+ console.log("built build/index.js")
@@ -21,14 +21,7 @@
21
21
  // for the whole thing.
22
22
 
23
23
  import { annotateCallAgainstCountryFile, contestScorer, defineExtension } from "@ham2k/extension-sdk"
24
- import type {
25
- ContestScorer,
26
- HookContext,
27
- JSONValue,
28
- LoggingControlDescriptor,
29
- Ref,
30
- ScoreTally,
31
- } from "@ham2k/extension-sdk"
24
+ import type { ContestScorer, HookContext, JSONValue, LoggingControlDescriptor, Ref, ScoreTally } from "@ham2k/extension-sdk"
32
25
  import { fmtInteger } from "@ham2k/lib-format-tools"
33
26
 
34
27
  import manifest from "../manifest.json" with { type: "json" }
@@ -53,7 +46,9 @@ function refOfType(container: Record<string, JSONValue>, type: string): Record<s
53
46
 
54
47
  /// "05" and "5" are the same multiplier and must not count twice.
55
48
  function normalizeZone(value: JSONValue | undefined): string {
56
- const digits = String(value ?? "").trim().replace(/^0+(?=\d)/, "")
49
+ const digits = String(value ?? "")
50
+ .trim()
51
+ .replace(/^0+(?=\d)/, "")
57
52
  return /^([1-9]|[1-3][0-9]|40)$/.test(digits) ? digits : ""
58
53
  }
59
54
 
@@ -154,10 +149,7 @@ const CQWWScorer: ContestScorer<Scoresheet> = {
154
149
 
155
150
  const ActivityHook = {
156
151
  /// What the operator fills in when adding this contest to an operation.
157
- async operationControls(
158
- _args: { operation: Record<string, JSONValue> },
159
- _ctx: HookContext,
160
- ): Promise<LoggingControlDescriptor[]> {
152
+ async operationControls(_args: { operation: Record<string, JSONValue> }, _ctx: HookContext): Promise<LoggingControlDescriptor[]> {
161
153
  return [
162
154
  {
163
155
  key: "k2hrc-cqww/setup",
@@ -209,8 +201,7 @@ const ActivityHook = {
209
201
  const their = (args.qso?.their as Record<string, JSONValue>) ?? {}
210
202
  const guess = (their.guess as Record<string, JSONValue>) ?? {}
211
203
  const call = str(their.call)
212
- const suggested =
213
- normalizeZone(guess.cqZone) || (call ? normalizeZone(annotateCallAgainstCountryFile(call).cqZone) : "")
204
+ const suggested = normalizeZone(guess.cqZone) || (call ? normalizeZone(annotateCallAgainstCountryFile(call).cqZone) : "")
214
205
 
215
206
  return [
216
207
  {
@@ -1,6 +1,6 @@
1
- import { build } from 'esbuild'
2
- import { buildExtension } from '@ham2k/extension-tools'
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
3
 
4
4
  await buildExtension(build, { dir: import.meta.dirname })
5
5
 
6
- console.log('built build/index.js')
6
+ console.log("built build/index.js")
@@ -46,8 +46,7 @@ async function login(ctx: HookContext): Promise<string> {
46
46
  const password = ctx.account?.credentials?.password
47
47
  if (!username || !password) throw new Error("No HamQTH account configured")
48
48
 
49
- const response = await host.fetch(
50
- `${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(password)}`)
49
+ const response = await host.fetch(`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(password)}`)
51
50
  const sessionId = tag(response.body, "session_id")
52
51
  if (!sessionId) throw new Error(tag(response.body, "error") ?? `HTTP ${response.status}`)
53
52
 
@@ -62,8 +61,7 @@ async function login(ctx: HookContext): Promise<string> {
62
61
  /// Note that it re-logs-in only ONCE — a second failure is a real one, and
63
62
  /// looping on it would hammer the API behind the operator's back.
64
63
  async function fetchCall(call: string, ctx: HookContext): Promise<string> {
65
- const search = (id: string) =>
66
- host.fetch(`${API}?id=${id}&callsign=${encodeURIComponent(call)}&prg=${PROGRAM}`)
64
+ const search = (id: string) => host.fetch(`${API}?id=${id}&callsign=${encodeURIComponent(call)}&prg=${PROGRAM}`)
67
65
 
68
66
  const sessionId = ctx.account?.session?.sessionId ?? (await login(ctx))
69
67
  const response = await search(sessionId)
@@ -167,13 +165,11 @@ const HamQTHAccount = {
167
165
  if (!username || !credentials.password) return "Enter your HamQTH username and password"
168
166
 
169
167
  try {
170
- const response = await host.fetch(
171
- `${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(credentials.password)}`)
168
+ const response = await host.fetch(`${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(credentials.password)}`)
172
169
  const sessionId = tag(response.body, "session_id")
173
170
  if (!sessionId) return tag(response.body, "error") ?? `HamQTH returned HTTP ${response.status}`
174
171
 
175
- const check = await host.fetch(
176
- `${API}?id=${sessionId}&callsign=${encodeURIComponent(username)}&prg=${PROGRAM}`)
172
+ const check = await host.fetch(`${API}?id=${sessionId}&callsign=${encodeURIComponent(username)}&prg=${PROGRAM}`)
177
173
  const name = tag(check.body, "adr_name")
178
174
  return `✅ ${username.toUpperCase()}${name ? `: ${name}` : ""}`
179
175
  } catch (e) {
@@ -1,6 +1,6 @@
1
- import { build } from 'esbuild'
2
- import { buildExtension } from '@ham2k/extension-tools'
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
3
 
4
4
  await buildExtension(build, { dir: import.meta.dirname })
5
5
 
6
- console.log('built build/index.js')
6
+ console.log("built build/index.js")
@@ -19,12 +19,7 @@
19
19
  // API key the program issues, and the export hooks that submit a log. See
20
20
  // `docs/hooks.md` §`spots` and §`export`.
21
21
 
22
- import {
23
- activityScorer,
24
- contestScorer,
25
- defineExtension,
26
- referenceActivity,
27
- } from "@ham2k/extension-sdk"
22
+ import { activityScorer, contestScorer, defineExtension, referenceActivity } from "@ham2k/extension-sdk"
28
23
  import type { DataFileDefinition, HookContext } from "@ham2k/extension-sdk"
29
24
  import { locationToGrid6 } from "@ham2k/lib-geo-tools"
30
25
 
@@ -116,7 +111,9 @@ const referenceList: DataFileDefinition = {
116
111
  category: "k2hrc-llota",
117
112
 
118
113
  jsonToLookupEntry: (entry: Record<string, any>) => {
119
- const ref = String(entry?.reference_code ?? "").trim().toUpperCase()
114
+ const ref = String(entry?.reference_code ?? "")
115
+ .trim()
116
+ .toUpperCase()
120
117
  // Returning null drops the entry. A list of this size will contain a few
121
118
  // rows that are not references, and throwing would abandon the rest.
122
119
  if (!ref) return null
@@ -1,6 +1,6 @@
1
- import { build } from 'esbuild'
2
- import { buildExtension } from '@ham2k/extension-tools'
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
3
 
4
4
  await buildExtension(build, { dir: import.meta.dirname })
5
5
 
6
- console.log('built build/index.js')
6
+ console.log("built build/index.js")
@@ -49,11 +49,7 @@ const PREVIEW = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
49
49
  /// document that does not parse, and the sandbox is a mitigation, not a reason
50
50
  /// to skip this.
51
51
  function escapeHtml(value: string): string {
52
- return value
53
- .replace(/&/g, "&amp;")
54
- .replace(/</g, "&lt;")
55
- .replace(/>/g, "&gt;")
56
- .replace(/"/g, "&quot;")
52
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
57
53
  }
58
54
 
59
55
  /// 14074000 → "14.074.0" — the grouping a radio's own display uses, which is
@@ -1,6 +1,6 @@
1
- import { build } from 'esbuild'
2
- import { buildExtension } from '@ham2k/extension-tools'
1
+ import { build } from "esbuild"
2
+ import { buildExtension } from "@ham2k/extension-tools"
3
3
 
4
4
  await buildExtension(build, { dir: import.meta.dirname })
5
5
 
6
- console.log('built build/index.js')
6
+ console.log("built build/index.js")
@@ -8,16 +8,8 @@
8
8
  "icon": "radio-tower",
9
9
  "accentColor": "#4C6EF5",
10
10
  "api": 1,
11
- "keywords": [
12
- "svg",
13
- "panel",
14
- "sample",
15
- "weather",
16
- "radio"
17
- ],
18
- "hooks": [
19
- "panel"
20
- ],
11
+ "keywords": ["svg", "panel", "sample", "weather", "radio"],
12
+ "hooks": ["panel"],
21
13
  "sharedDependencies": {
22
14
  "@ham2k/lib-callsigns": "^1.0.0",
23
15
  "@ham2k/lib-country-files": "^1.0.0",
@@ -1,12 +1,12 @@
1
1
  // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
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'
3
+ import { defineExtension } from "@ham2k/extension-sdk"
4
+ import manifest from "../manifest.json" with { type: "json" }
5
+ import { createScenePanels } from "./scenes.ts"
6
6
 
7
7
  defineExtension({
8
8
  ...manifest,
9
9
  onActivation({ registerHook }) {
10
- registerHook('panel', { key: manifest.key, hook: createScenePanels() })
10
+ registerHook("panel", { key: manifest.key, hook: createScenePanels() })
11
11
  },
12
12
  })
@@ -1,16 +1,20 @@
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, SceneBinding, SvgSceneLayer } from "@ham2k/extension-sdk"
4
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>`
5
+ const svg = (body: string, width = 640, height = 360) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${body}</svg>`
7
6
  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') =>
7
+ const label = (x: number, y: number, text: string, size = 16, color = "#9aaec6") =>
9
8
  `<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' } })
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 => ({
11
+ id,
12
+ x,
13
+ y,
14
+ width,
15
+ height: size + 12,
16
+ text: { value, prefix, suffix, decimals, size, color: "#a9efcf" },
17
+ })
14
18
 
15
19
  type RadioState = { requested: number; reported: number; connected: number; due: number; slow: number }
16
20
  /** Inject time for deterministic tests; no timers or per-frame extension work. */
@@ -24,103 +28,214 @@ export function createScenePanels(now: () => number = () => Date.now()): PanelHo
24
28
  s = { requested: 14074, reported: 14074, connected: 1, due: 0, slow: 1 }
25
29
  radios.set(id, s)
26
30
  }
27
- if (s.connected && s.due && now() >= s.due) { s.reported = s.requested; s.due = 0 }
31
+ if (s.connected && s.due && now() >= s.due) {
32
+ s.reported = s.requested
33
+ s.due = 0
34
+ }
28
35
  return s
29
36
  }
30
37
  return {
31
38
  async getPanels() {
32
39
  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 },
40
+ { key: "radio", title: "Radio Front Face · Simulation", icon: "radio-tower", multiple: true, on: ["tick:1"] },
41
+ { key: "weather", title: "Weather Chart · Simulation", icon: "weather-partly-cloudy", multiple: true },
35
42
  ]
36
43
  },
37
44
  async render(args): Promise<PanelContent> {
38
- if (args.panelKey === 'weather') {
45
+ if (args.panelKey === "weather") {
39
46
  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(`
47
+ const points = temperatures.map((t, i) => `${60 + i * 74},${265 - (t - 15) * 14}`).join(" ")
48
+ return {
49
+ kind: "svgScene",
50
+ scene: {
51
+ version: 1,
52
+ width: 640,
53
+ height: 360,
54
+ values: { hour: 0 },
55
+ layers: [
56
+ {
57
+ id: "chart",
58
+ x: 0,
59
+ y: 0,
60
+ width: 640,
61
+ height: 360,
62
+ svg: svg(`
45
63
  <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('')}
64
+ ${label(30, 38, "FORECAST / SIMULATED DATA", 18, "#e7edf4")}
65
+ ${[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
66
  <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
- } }
67
+ ${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("")}
68
+ ${label(30, 334, "Drag the chart to inspect · Arrow keys to step", 14)}
69
+ `),
70
+ },
71
+ {
72
+ id: "cursor",
73
+ x: 59,
74
+ y: 100,
75
+ width: 2,
76
+ height: 172,
77
+ svg: svg('<rect width="2" height="172" fill="#f7c576"/>', 2, 172),
78
+ translateX: bind("hour", [0, 7], [0, 518]),
79
+ },
80
+ textLayer("hour", "hour", 30, 52, 250, 24, "In ", " hours"),
81
+ {
82
+ ...textLayer("temperature", "hour", 370, 52, 240, 24, "", " °C"),
83
+ text: { value: "hour", samples: temperatures, suffix: " °C", size: 24, color: "#a9efcf" },
84
+ },
85
+ ],
86
+ controls: [
87
+ { id: "inspect", kind: "slider", label: "Forecast hour", x: 60, y: 100, width: 518, height: 185, value: "hour", min: 0, max: 7, step: 1 },
88
+ ],
89
+ },
90
+ }
62
91
  }
63
92
  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(`
93
+ return {
94
+ kind: "svgScene",
95
+ scene: {
96
+ version: 1,
97
+ width: 640,
98
+ height: 360,
99
+ values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow },
100
+ layers: [
101
+ {
102
+ id: "face",
103
+ x: 0,
104
+ y: 0,
105
+ width: 640,
106
+ height: 360,
107
+ svg: svg(`
69
108
  <rect width="640" height="360" rx="18" fill="#142337"/>
70
- ${label(28, 35, 'RADIO FRONT FACE / SIMULATED CAT', 18, '#e7edf4')}
109
+ ${label(28, 35, "RADIO FRONT FACE / SIMULATED CAT", 18, "#e7edf4")}
71
110
  <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)}
111
+ ${label(42, 78, "REPORTED VFO", 12)}${label(42, 143, "REQUESTED", 12)}
73
112
  <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)}
113
+ ${label(423, 170, "14.0", 12)}${label(565, 170, "14.35", 12)}
114
+ ${label(424, 197, "VFO POSITION", 12)}
76
115
  <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
- } }
116
+ ${label(42, 268, "Band position / kHz", 14)}
117
+ ${label(548, 256, "LINK", 12)}
118
+ ${button(28, "Connect / disconnect")}${button(234, "External tune +5 kHz")}${button(440, "Toggle 2 s / 0 s delay")}
119
+ `),
120
+ },
121
+ textLayer("reported", "reported", 42, 78, 320, 34, "", " kHz", 1),
122
+ textLayer("requested", "requested", 168, 121, 210, 22, "", "", 1),
123
+ {
124
+ id: "needle",
125
+ x: 503,
126
+ y: 72,
127
+ width: 8,
128
+ height: 80,
129
+ pivot: [0.5, 1],
130
+ transitionMs: 450,
131
+ svg: svg('<path d="M4 0 L7 80 H1 Z" fill="#f6c673"/>', 8, 80),
132
+ rotation: bind("reported", [14000, 14350], [-80, 80]),
133
+ },
134
+ {
135
+ id: "knob",
136
+ x: 400,
137
+ y: 204,
138
+ width: 72,
139
+ height: 72,
140
+ svg: svg(
141
+ '<circle cx="36" cy="36" r="34" fill="#31455d" stroke="#7187a0" stroke-width="2"/><path d="M36 8V22" stroke="#f6c673" stroke-width="4"/>',
142
+ 72,
143
+ 72,
144
+ ),
145
+ rotation: bind("requested", [14000, 14350], [-150, 150]),
146
+ },
147
+ {
148
+ id: "handle",
149
+ x: 36,
150
+ y: 224,
151
+ width: 12,
152
+ height: 24,
153
+ svg: svg('<rect width="12" height="24" rx="4" fill="#a9efcf"/>', 12, 24),
154
+ translateX: bind("requested", [14000, 14350], [0, 290]),
155
+ },
156
+ {
157
+ id: "led",
158
+ x: 548,
159
+ y: 208,
160
+ width: 40,
161
+ height: 40,
162
+ svg: svg(
163
+ '<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)"/>',
164
+ 40,
165
+ 40,
166
+ ),
167
+ opacity: bind("connected", [0, 1], [0.08, 1]),
168
+ pulse: { periodMs: 1700, minOpacity: 0.55, flicker: true },
169
+ },
170
+ ],
171
+ controls: [
172
+ {
173
+ id: "tune",
174
+ label: "Tuning knob, kHz",
175
+ kind: "knob",
176
+ x: 398,
177
+ y: 202,
178
+ width: 76,
179
+ height: 76,
180
+ value: "requested",
181
+ min: 14000,
182
+ max: 14350,
183
+ step: 0.1,
184
+ sensitivity: 0.1,
185
+ event: "tune",
186
+ },
187
+ {
188
+ id: "band",
189
+ label: "Band position, kHz",
190
+ kind: "slider",
191
+ x: 42,
192
+ y: 215,
193
+ width: 290,
194
+ height: 42,
195
+ value: "requested",
196
+ min: 14000,
197
+ max: 14350,
198
+ step: 0.1,
199
+ event: "tune",
200
+ },
201
+ ...(["connect", "external", "delay"] as const).map((event, i) => ({
202
+ id: event,
203
+ label: ["Toggle connection", "Simulate external tuning", "Toggle CAT delay"][i],
204
+ kind: "button" as const,
205
+ x: 28 + i * 206,
206
+ y: 298,
207
+ width: 172,
208
+ height: 38,
209
+ event,
210
+ })),
211
+ ],
212
+ },
213
+ }
107
214
  },
108
215
  async onEvent(args) {
109
216
  const s = state(args.instanceId ?? args.panelKey)
110
217
  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')
218
+ if (event.action === "connect") {
219
+ s.connected = 1 - s.connected
220
+ s.due = 0
221
+ s.requested = s.reported
222
+ }
223
+ if (event.action === "delay") s.slow = 1 - s.slow
224
+ if (event.action === "external") {
225
+ if (!s.connected) throw new Error("Radio disconnected")
115
226
  s.reported = s.reported + 5 > 14350 ? 14000 : s.reported + 5
116
- s.requested = s.reported; s.due = 0
227
+ s.requested = s.reported
228
+ s.due = 0
117
229
  }
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')
230
+ if (event.action === "tune") {
231
+ if (!s.connected) throw new Error("Radio disconnected")
232
+ if (typeof event.value !== "number" || !Number.isFinite(event.value)) throw new Error("Invalid frequency")
121
233
  s.requested = Math.max(14000, Math.min(14350, event.value))
122
234
  s.due = now() + (s.slow ? 2000 : 0)
123
- if (!s.slow) { s.reported = s.requested; s.due = 0 }
235
+ if (!s.slow) {
236
+ s.reported = s.requested
237
+ s.due = 0
238
+ }
124
239
  }
125
240
  return { values: { requested: s.requested, reported: s.reported, connected: s.connected, slow: s.slow } }
126
241
  },