@ham2k/extension-sdk 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,202 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // SAMPLE — a callsign lookup source, with the credentials one needs.
5
+ //
6
+ // HamQTH's XML API is free and its shape is the common one: log in once with a
7
+ // username and password, get a session id back, and spend that id on lookups
8
+ // until it expires. So this sample is really two things an extension author
9
+ // needs together — the `lookup` hook that answers about a callsign, and the
10
+ // `account` hook that is the ONLY way to ask an operator for a secret.
11
+ //
12
+ // Never put a password in a settings field of your own: `account` stores
13
+ // credentials in the platform keychain, keeps the session out of your hands,
14
+ // and gives the operator one place to see what they have connected. A `secret`
15
+ // field in a settings panel is for a bare API key with no login behind it.
16
+ //
17
+ // What this sample leaves out: the country-file cross-checks, the
18
+ // already-know-enough short circuits, and the per-call caching a production
19
+ // source wants. Read `docs/hooks.md` §`lookup` before shipping one.
20
+
21
+ import { defineExtension, host } from "@ham2k/extension-sdk"
22
+ import type { AnnotatedCallInfo, CallInfoLookup, HookContext, JSONValue, LookupResult } from "@ham2k/extension-sdk"
23
+ import { cleanLocationParams } from "@ham2k/lib-geo-tools"
24
+
25
+ import manifest from "../manifest.json" with { type: "json" }
26
+
27
+ const API = "https://www.hamqth.com/xml.php"
28
+
29
+ /// HamQTH asks every caller to identify itself, and rejects an empty `prg`.
30
+ const PROGRAM = "ham2k-sample"
31
+
32
+ /// HamQTH's XML is flat and carries no attributes worth reading, so a tag
33
+ /// extractor beats a parser the sandbox would have to ship.
34
+ function tag(xml: string, name: string): string | undefined {
35
+ return xml.match(new RegExp(`<${name}[^>]*>([^<]*)</${name}>`, "i"))?.[1]?.trim() || undefined
36
+ }
37
+
38
+ /// Trades the operator's credentials for a session id, and hands it to the
39
+ /// host to hold.
40
+ ///
41
+ /// `host.setAccountSession` rather than a module variable or `host.kvSet`: the
42
+ /// session outlives a reload of this extension, and it is a credential — the
43
+ /// host keeps it beside the password rather than in your storage.
44
+ async function login(ctx: HookContext): Promise<string> {
45
+ const username = ctx.account?.credentials?.username
46
+ const password = ctx.account?.credentials?.password
47
+ if (!username || !password) throw new Error("No HamQTH account configured")
48
+
49
+ const response = await host.fetch(
50
+ `${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(password)}`)
51
+ const sessionId = tag(response.body, "session_id")
52
+ if (!sessionId) throw new Error(tag(response.body, "error") ?? `HTTP ${response.status}`)
53
+
54
+ await host.setAccountSession({ sessionId })
55
+ return sessionId
56
+ }
57
+
58
+ /// One lookup, renewing the session if this one has aged out.
59
+ ///
60
+ /// HamQTH sessions expire on their own schedule, so an expired one is a normal
61
+ /// Tuesday rather than an error: the retry is the happy path, not recovery.
62
+ /// Note that it re-logs-in only ONCE — a second failure is a real one, and
63
+ /// looping on it would hammer the API behind the operator's back.
64
+ 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}`)
67
+
68
+ const sessionId = ctx.account?.session?.sessionId ?? (await login(ctx))
69
+ const response = await search(sessionId)
70
+
71
+ if (/session does not exist|expired/i.test(tag(response.body, "error") ?? "")) {
72
+ await host.setAccountSession({})
73
+ return (await search(await login(ctx))).body
74
+ }
75
+ return response.body
76
+ }
77
+
78
+ async function lookupCall(
79
+ { callInfo }: { callInfo: AnnotatedCallInfo; qso: Record<string, JSONValue>; operation: Record<string, JSONValue> },
80
+ ctx: HookContext,
81
+ ): Promise<LookupResult> {
82
+ const call = (callInfo.call || "").trim().toUpperCase()
83
+ // Offline, or too short to be a callsign: answer with nothing rather than
84
+ // spend a round-trip. `ctx.online` is the host's own answer, and a lookup
85
+ // hook runs on the typing path.
86
+ if (call.length < 3 || !ctx.online) return []
87
+
88
+ try {
89
+ const xml = await fetchCall(call, ctx)
90
+
91
+ const error = tag(xml, "error")
92
+ if (error) {
93
+ // "Callsign not found" is an ANSWER, not a fault — most operators work
94
+ // stations HamQTH has never heard of, and logging each one would bury
95
+ // the errors that matter (a bad password, a subscription problem).
96
+ if (!/not found/i.test(error)) host.log(`HamQTH said: ${error}`)
97
+ return []
98
+ }
99
+
100
+ // `<nick>` is a SHORTER FORM of the same name rather than a handle the
101
+ // operator also goes by: "Martin" against an `<adr_name>` of "Martin
102
+ // Kratoska", "Sebastian Delmont" against "Sebastian D Delmont", and for
103
+ // W1AW the identical string twice. Appending it reads as information and
104
+ // is not, so the full name wins and the nickname is only a fallback.
105
+ //
106
+ // QRZ's `nickname` deliberately goes the other way — there it IS what the
107
+ // operator goes by on the air, and its extension shows both. Same-looking
108
+ // field, opposite verdict; don't make the two consistent.
109
+ const name = tag(xml, "adr_name") ?? tag(xml, "nick")
110
+
111
+ const city = tag(xml, "adr_city")
112
+ const state = tag(xml, "us_state")
113
+
114
+ // The coordinate AND the grid. HamQTH reports both, and where a pair is
115
+ // present it outranks the locator — a lookup that kept only the grid would
116
+ // throw away the more precise of the two answers it was given. Parsed
117
+ // through `cleanLocationParams` rather than `Number`, because a service
118
+ // that has no fix for a record says so with an empty string, a "0", or a
119
+ // pair of zeroes, and none of those is a place.
120
+ const [lat, lon] = cleanLocationParams(tag(xml, "latitude"), tag(xml, "longitude"))
121
+
122
+ const result: CallInfoLookup = {
123
+ // HamQTH answers in lower case, whatever was asked.
124
+ call: tag(xml, "callsign")?.toUpperCase() ?? call,
125
+ // Named so the app can say where this came from, and so the merge can
126
+ // tell your answer from another source's.
127
+ source: "hamqth.com",
128
+ scope: "general",
129
+ name,
130
+ location: [city, state].filter((x) => x).join(", "),
131
+ city,
132
+ state,
133
+ country: tag(xml, "country"),
134
+ grid: tag(xml, "grid"),
135
+ lat,
136
+ lon,
137
+ }
138
+ return [result]
139
+ } catch (e) {
140
+ // A lookup that throws takes down the call-info panel for every source,
141
+ // not just this one.
142
+ host.log(`HamQTH lookup failed for ${call}: ${(e as Error).message ?? e}`)
143
+ return []
144
+ }
145
+ }
146
+
147
+ /// What the operator fills in under Settings → Accounts.
148
+ ///
149
+ /// `testCredentials` returns a STRING to show them — it is the only feedback
150
+ /// they get that a password is right, so make it name what was found rather
151
+ /// than saying "OK".
152
+ const HamQTHAccount = {
153
+ label: "HamQTH.com",
154
+ description: "A free account at hamqth.com, for callsign lookups",
155
+ kvKey: "k2hrc-hamqth",
156
+ // The same login on every device the operator owns, so it rides iCloud
157
+ // Keychain. A per-install credential (a device key, a sync token) must NOT
158
+ // set this.
159
+ synchronizable: true,
160
+ fields: [
161
+ { key: "username", label: "Username", type: "text" as const },
162
+ { key: "password", label: "Password", type: "secret" as const },
163
+ ],
164
+
165
+ async testCredentials(credentials: Record<string, string>, _ctx: HookContext): Promise<string> {
166
+ const username = (credentials.username || "").trim()
167
+ if (!username || !credentials.password) return "Enter your HamQTH username and password"
168
+
169
+ try {
170
+ const response = await host.fetch(
171
+ `${API}?u=${encodeURIComponent(username)}&p=${encodeURIComponent(credentials.password)}`)
172
+ const sessionId = tag(response.body, "session_id")
173
+ if (!sessionId) return tag(response.body, "error") ?? `HamQTH returned HTTP ${response.status}`
174
+
175
+ const check = await host.fetch(
176
+ `${API}?id=${sessionId}&callsign=${encodeURIComponent(username)}&prg=${PROGRAM}`)
177
+ const name = tag(check.body, "adr_name")
178
+ return `✅ ${username.toUpperCase()}${name ? `: ${name}` : ""}`
179
+ } catch (e) {
180
+ return `Could not reach HamQTH: ${(e as Error).message ?? e}`
181
+ }
182
+ },
183
+ }
184
+
185
+ defineExtension({
186
+ ...manifest,
187
+ onActivation({ registerHook }) {
188
+ // No `priority`, which means 0 — and 0 is where a third-party callbook
189
+ // belongs. The ladder it lands in: the operator's own call notes at 100,
190
+ // QRZ at 99, the Ham2K lookup service at 10, and Call History last at -1,
191
+ // deliberately below the 0 an undeclared hook defaults to, because the log
192
+ // describes a station whenever it was last worked while a live source
193
+ // describes it today.
194
+ //
195
+ // So the default already reads "a real source, but not one that outranks
196
+ // what the operator typed or what the core provides". Set a priority only
197
+ // when you mean to move against that, and know which source you are
198
+ // stepping over.
199
+ registerHook("lookup", { hook: { lookupCall }, key: manifest.key })
200
+ registerHook("account", { hook: HamQTHAccount, key: manifest.key })
201
+ },
202
+ })
@@ -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,25 @@
1
+ {
2
+ "key": "k2hrc-llota",
3
+ "name": "Lakes on the Air (sample)",
4
+ "shortName": "LLOTA*",
5
+ "version": "1.0.0",
6
+ "description": "Lake references and activation scoring, as a worked example",
7
+ "category": "activity",
8
+ "icon": "waves",
9
+ "accentColor": "#2F7FA6",
10
+ "api": 1,
11
+ "keywords": ["lakes", "activation", "hunting", "sample"],
12
+ "hooks": ["activity", "adifFields", "adifImport", "dataFile", "ref:k2hrcLlota", "ref:k2hrcLlotaActivation", "scoring"],
13
+ "domains": ["llota.app"],
14
+ "relevance": { "continents": ["SA"] },
15
+ "sharedDependencies": {
16
+ "@ham2k/lib-callsigns": "^1.0.0",
17
+ "@ham2k/lib-country-files": "^1.0.0",
18
+ "@ham2k/lib-dxcc-data": "^1.0.0",
19
+ "@ham2k/lib-format-tools": "^1.0.0",
20
+ "@ham2k/lib-geo-tools": "^1.0.0",
21
+ "@ham2k/lib-operation-data": "^1.0.0",
22
+ "i18next": "^23.0.0",
23
+ "liquidjs": "^10.0.0"
24
+ }
25
+ }
@@ -0,0 +1,172 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // SAMPLE — an award program: references, an offline list, and activation
5
+ // scoring.
6
+ //
7
+ // This is the shape of POTA, SOTA, WWFF and a dozen others, and almost none of
8
+ // it is written by hand. `referenceActivity()` builds the reference handler,
9
+ // the logging controls and the ADIF fields from one description; the manifest
10
+ // says which `ref:` types they answer for. What is left for you is what the
11
+ // program actually is: its reference format, where the list comes from, and
12
+ // what it takes to activate.
13
+ //
14
+ // Modelled on LLOTA (Lakes and Lagoons on the Air) and using its public list.
15
+ // The reference TYPES are namespaced to this sample (`k2hrcLlota`), so
16
+ // installing it cannot collide with the real LLOTA extension's records.
17
+ //
18
+ // What this sample leaves out: the spot feed and spot posting, which need an
19
+ // API key the program issues, and the export hooks that submit a log. See
20
+ // `docs/hooks.md` §`spots` and §`export`.
21
+
22
+ import {
23
+ activityScorer,
24
+ contestScorer,
25
+ defineExtension,
26
+ referenceActivity,
27
+ } from "@ham2k/extension-sdk"
28
+ import type { DataFileDefinition, HookContext } from "@ham2k/extension-sdk"
29
+ import { locationToGrid6 } from "@ham2k/lib-geo-tools"
30
+
31
+ import manifest from "../manifest.json" with { type: "json" }
32
+
33
+ /// The type a QSO carries for a lake the OTHER station was at, and the type an
34
+ /// operation carries while activating one. Two types, because they are two
35
+ /// different claims about the same reference.
36
+ const HUNTING_TYPE = "k2hrcLlota"
37
+ const ACTIVATION_TYPE = "k2hrcLlotaActivation"
38
+
39
+ const REFERENCE_REGEX = /^LL[A-Z0-9]+-(?:[0-9]{4,5}|TEST)$/i
40
+
41
+ /// Every user-visible string reaches the SDK through a translator, so that one
42
+ /// written against several locales needs no different wiring. A real extension
43
+ /// builds this with `createCachedTranslator` over its own JSON files — see
44
+ /// `activities/*/src/i18n.ts` in the app's repository. This is the smallest
45
+ /// thing with the right shape.
46
+ ///
47
+ /// `invalidReference` and `unknownReference` are the vocabulary
48
+ /// `referenceActivity` looks up; `activationControl` and `huntingControl` name
49
+ /// the two logging controls it builds.
50
+ const STRINGS: Record<string, string> = {
51
+ invalidReference: "Not a lake reference",
52
+ unknownReference: "Unknown lake",
53
+ activationControl: "Lake",
54
+ huntingControl: "Lake",
55
+ }
56
+ const tFor = (_ctx: { locale?: string }) => (key: string) => STRINGS[key] ?? key
57
+
58
+ /// What it takes to activate: ten contacts in a UTC day.
59
+ ///
60
+ /// `uniquePer` is the rule that decides when a repeat contact counts AGAIN —
61
+ /// here, a station already worked is worth another contact on a new band, a
62
+ /// new mode, a new day, or at a different lake. Get this wrong and the score
63
+ /// is wrong in a way no test of yours will notice, because it only shows up on
64
+ /// the second QSO with the same station.
65
+ const SCORING = {
66
+ label: "LLOTA",
67
+ icon: manifest.icon,
68
+ activationType: ACTIVATION_TYPE,
69
+ huntingType: HUNTING_TYPE,
70
+ qsosToActivate: 10,
71
+ // Several lakes activated at once, and one contact crediting several hunted
72
+ // ones. Programs genuinely differ here; most of the simpler awards do not
73
+ // allow it, and the default is false.
74
+ allowsMultipleReferences: true,
75
+ uniquePer: ["band", "mode", "day", "ref"] as const,
76
+ activates: "daily" as const,
77
+ }
78
+
79
+ const { refHandler, activityHook, adifFieldsHook, adifImportHook } = referenceActivity({
80
+ key: "k2hrc-llota",
81
+ label: "LLOTA",
82
+ activationType: ACTIVATION_TYPE,
83
+ huntingType: HUNTING_TYPE,
84
+ referenceRegex: REFERENCE_REGEX,
85
+ icon: manifest.icon,
86
+ color: manifest.accentColor,
87
+ placeholder: "LLUS-0001",
88
+ tFor,
89
+ // Where the `lookups` rows below are written and read. It must match the
90
+ // data file's `category`, or the handler will look up references nothing
91
+ // ever stored.
92
+ category: "k2hrc-llota",
93
+ linkUrl: (reference: string) => `https://llota.app/list/ref/${encodeURIComponent(reference)}`,
94
+ // One ADIF record per hunted lake rather than one naming them all. The
95
+ // programs disagree, and it decides how many contacts the award sees.
96
+ splitRecordsPerHuntedRef: true,
97
+ adifRefField: "LLOTA",
98
+ // Read off the scoring rule rather than restated, so the control and the
99
+ // scorer cannot disagree about whether this award allows n-fers.
100
+ allowsMultiple: SCORING.allowsMultipleReferences,
101
+ })
102
+
103
+ /// The reference list, downloaded and kept offline.
104
+ ///
105
+ /// This is what makes a reference resolve to a lake's name in the field, with
106
+ /// no signal and no per-reference API. The host does the fetching, the
107
+ /// scheduling and the storage; the extension's whole job is turning one
108
+ /// entry of somebody else's JSON into one row.
109
+ const referenceList: DataFileDefinition = {
110
+ key: "k2hrc-llota-references",
111
+ name: (_args: Record<string, never>, _ctx: HookContext) => "LLOTA Lakes",
112
+ description: (_args: Record<string, never>, _ctx: HookContext) => "Every lake reference, for offline lookup",
113
+ url: "https://llota.app/api/public/references?version=lite",
114
+ maxAgeInDays: 30,
115
+ fetchType: "json",
116
+ category: "k2hrc-llota",
117
+
118
+ jsonToLookupEntry: (entry: Record<string, any>) => {
119
+ const ref = String(entry?.reference_code ?? "").trim().toUpperCase()
120
+ // Returning null drops the entry. A list of this size will contain a few
121
+ // rows that are not references, and throwing would abandon the rest.
122
+ if (!ref) return null
123
+
124
+ const lat = typeof entry?.latitude === "number" ? entry.latitude : undefined
125
+ const lon = typeof entry?.longitude === "number" ? entry.longitude : undefined
126
+
127
+ return {
128
+ // The country part of the code (`LLUS` of `LLUS-0001`), which is how an
129
+ // operator narrows a search to their own country.
130
+ subCategory: ref.split("-")[0],
131
+ key: ref,
132
+ name: entry?.name,
133
+ lat,
134
+ lon,
135
+ flags: 1,
136
+ data: {
137
+ ref,
138
+ name: entry?.name,
139
+ location: ref.split("-")[0],
140
+ // The grid is DERIVED from the coordinate, never stored instead of it:
141
+ // where both are present the pair outranks the grid, and a grid with
142
+ // no coordinate behind it would quietly lose precision the list had.
143
+ grid: lat != null && lon != null ? locationToGrid6(lat, lon) : undefined,
144
+ lat,
145
+ lon,
146
+ },
147
+ }
148
+ },
149
+ }
150
+
151
+ defineExtension({
152
+ ...manifest,
153
+ onActivation({ registerHook }) {
154
+ // One handler, both types: an activation and a hunted reference are the
155
+ // same lake, validated and decorated the same way.
156
+ registerHook(`ref:${HUNTING_TYPE}`, { hook: refHandler, key: manifest.key })
157
+ registerHook(`ref:${ACTIVATION_TYPE}`, { hook: refHandler, key: manifest.key })
158
+ registerHook("activity", { hook: activityHook, key: manifest.key })
159
+ registerHook("adifFields", { hook: adifFieldsHook, key: manifest.key })
160
+ registerHook("adifImport", { hook: adifImportHook, key: manifest.key })
161
+ registerHook("dataFile", { hook: referenceList, key: "k2hrc-llota-references" })
162
+ // `contestScorer` wraps the activity rules in the scoring hook's own
163
+ // interface, and `scope` is what keeps this scorer out of operations that
164
+ // have nothing to do with the program.
165
+ registerHook("scoring", {
166
+ hook: contestScorer(activityScorer(SCORING), {
167
+ scope: { refTypes: [ACTIVATION_TYPE, HUNTING_TYPE], huntingRefTypes: [HUNTING_TYPE] },
168
+ }),
169
+ key: manifest.key,
170
+ })
171
+ },
172
+ })
@@ -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,23 @@
1
+ {
2
+ "key": "k2hrc-radio",
3
+ "name": "Radio Readout",
4
+ "shortName": "Radio",
5
+ "version": "1.0.0",
6
+ "description": "A big band, mode and frequency readout across the room",
7
+ "category": "dashboard",
8
+ "icon": "radio-tower",
9
+ "accentColor": "#4C6EF5",
10
+ "api": 1,
11
+ "keywords": ["radio", "frequency", "band", "panel", "sample"],
12
+ "hooks": ["panel"],
13
+ "sharedDependencies": {
14
+ "@ham2k/lib-callsigns": "^1.0.0",
15
+ "@ham2k/lib-country-files": "^1.0.0",
16
+ "@ham2k/lib-dxcc-data": "^1.0.0",
17
+ "@ham2k/lib-format-tools": "^1.0.0",
18
+ "@ham2k/lib-geo-tools": "^1.0.0",
19
+ "@ham2k/lib-operation-data": "^1.0.0",
20
+ "i18next": "^23.0.0",
21
+ "liquidjs": "^10.0.0"
22
+ }
23
+ }
@@ -0,0 +1,145 @@
1
+ // Copyright ©️ 2026 Sebastian Delmont <sd@ham2k.com>
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // SAMPLE — an HTML panel: what the radio is doing, big enough to read from
5
+ // across the room.
6
+ //
7
+ // A panel returns a DOCUMENT, not widgets, and `html` is the escape hatch for
8
+ // when `markdown` cannot express the layout. It buys you exactly one thing —
9
+ // arbitrary CSS — and costs several:
10
+ //
11
+ // * No JavaScript. None. The document is static from the moment it renders,
12
+ // so anything that changes has to come from another render.
13
+ // * No network for subresources. Fonts, images and stylesheets must be
14
+ // inlined, as text or as `data:` URIs.
15
+ // * No navigation. A link the operator taps opens in their browser; one the
16
+ // document triggers itself is blocked.
17
+ // * It does not render at all where there is no web view — Linux, Windows
18
+ // without WebView2, and the web build. The pane says so, which is a worse
19
+ // experience than markdown that simply works.
20
+ //
21
+ // So reach for `markdown` first: it renders with the app's own typography,
22
+ // theme, font scale and density, and costs no web view. This panel earns the
23
+ // escape hatch because a readout is about type size and nothing else.
24
+ //
25
+ // See `docs/hooks.md` §`panel` for the whole contract, including `form`,
26
+ // `multiple` and every trigger name.
27
+
28
+ import { defineExtension } from "@ham2k/extension-sdk"
29
+ import type { HookContext, PanelContent, PanelDescriptor, PanelHook, PanelRenderArgs } from "@ham2k/extension-sdk"
30
+
31
+ import manifest from "../manifest.json" with { type: "json" }
32
+
33
+ /// A still image for the panel picker. It carries its own pixels — never a URL,
34
+ /// because the list renders offline and a remote preview would make opening it
35
+ /// a network fetch (and a beacon).
36
+ const PREVIEW = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
37
+ <rect x="2" y="5" width="20" height="14" rx="2" fill="#4C6EF5"/>
38
+ <rect x="4" y="8" width="16" height="5" rx="1" fill="#1B2559"/>
39
+ <rect x="5.5" y="9.5" width="9" height="2" rx="1" fill="#8FA4FF"/>
40
+ <circle cx="7" cy="16" r="1.2" fill="#1B2559"/>
41
+ <circle cx="11" cy="16" r="1.2" fill="#1B2559"/>
42
+ </svg>`
43
+
44
+ /// Everything that reaches the document goes through here.
45
+ ///
46
+ /// The values below are the operator's own — a callsign they typed, a mode
47
+ /// their radio reported — and none of it is trusted markup. A panel that
48
+ /// interpolates raw strings into HTML is one odd character away from a
49
+ /// document that does not parse, and the sandbox is a mitigation, not a reason
50
+ /// to skip this.
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;")
57
+ }
58
+
59
+ /// 14074000 → "14.074.0" — the grouping a radio's own display uses, which is
60
+ /// what makes a frequency readable at a glance rather than counted digit by
61
+ /// digit.
62
+ function formatFrequency(hz: number): string {
63
+ const khz = hz / 1000
64
+ const whole = Math.floor(khz)
65
+ const fraction = Math.round((khz - whole) * 10)
66
+ return `${whole.toLocaleString("en-US").replace(/,/g, ".")}.${fraction}`
67
+ }
68
+
69
+ const RadioPanel: PanelHook = {
70
+ async getPanels(_args: Record<string, never>, _ctx: HookContext): Promise<PanelDescriptor[]> {
71
+ return [
72
+ {
73
+ key: "readout",
74
+ title: "Radio Readout",
75
+ description: "Band, mode and frequency, in large type",
76
+ icon: manifest.icon,
77
+ preview: PREVIEW,
78
+ // `on` is a refresh BUDGET, not a subscription: every trigger named
79
+ // here is paid by every placement of this panel, every time it fires.
80
+ //
81
+ // 'qso' is the expensive one — it is the whole QSO being composed, and
82
+ // it changes as fast as the operator types (the host debounces it to
83
+ // one wake per pause). It is the right trigger here anyway, because
84
+ // the band and mode on that draft ARE the radio's, and they are what
85
+ // this panel exists to show. A panel wanting only a resolved callsign
86
+ // should declare 'lookup' instead and get far fewer renders.
87
+ on: ["qso", "operation"],
88
+ },
89
+ ]
90
+ },
91
+
92
+ async render(args: PanelRenderArgs, _ctx: HookContext): Promise<PanelContent> {
93
+ const qso = (args.qso ?? {}) as Record<string, any>
94
+
95
+ // `args.qso` is absent unless some panel in the arrangement asked for it —
96
+ // which this one does. It is still absent when there is no logging panel
97
+ // in the view at all, so the empty state is a real state, not a bug.
98
+ const band = typeof qso.band === "string" ? qso.band : ""
99
+ const mode = typeof qso.mode === "string" ? qso.mode : ""
100
+ const freq = typeof qso.freq === "number" ? qso.freq : undefined
101
+
102
+ const primary = freq ? formatFrequency(freq) : band || "—"
103
+ const unit = freq ? "kHz" : ""
104
+
105
+ // One inline stylesheet, no external anything, and `prefers-color-scheme`
106
+ // rather than a fixed palette: the panel sits inside the app's own theme,
107
+ // and a readout that stays white at night is a readout nobody uses at
108
+ // night. `vw` units so the type grows with the pane rather than the pane
109
+ // scrolling.
110
+ const content = `<style>
111
+ :root { color-scheme: light dark; }
112
+ body {
113
+ margin: 0; height: 100vh;
114
+ display: flex; flex-direction: column;
115
+ align-items: center; justify-content: center;
116
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
117
+ background: #fff; color: #1B2559;
118
+ }
119
+ .frequency { font-size: min(18vw, 22vh); font-weight: 600; line-height: 1; letter-spacing: -0.02em; }
120
+ .unit { font-size: min(4vw, 5vh); opacity: 0.5; margin-left: 0.3em; }
121
+ .details { margin-top: 0.6em; font-size: min(6vw, 7vh); letter-spacing: 0.08em; opacity: 0.75; }
122
+ .details span + span::before { content: "·"; margin: 0 0.5em; opacity: 0.4; }
123
+ .idle { opacity: 0.4; font-size: min(5vw, 6vh); }
124
+ @media (prefers-color-scheme: dark) {
125
+ body { background: #10121A; color: #E8ECFF; }
126
+ }
127
+ </style>
128
+ <div class="frequency">${escapeHtml(primary)}<span class="unit">${unit}</span></div>
129
+ <div class="details">
130
+ ${band ? `<span>${escapeHtml(band)}</span>` : ""}
131
+ ${mode ? `<span>${escapeHtml(mode)}</span>` : ""}
132
+ <span>${args.qsoCount} Q</span>
133
+ </div>
134
+ ${freq || band ? "" : '<div class="idle">No radio connected</div>'}`
135
+
136
+ return { kind: "html", content }
137
+ },
138
+ }
139
+
140
+ defineExtension({
141
+ ...manifest,
142
+ onActivation({ registerHook }) {
143
+ registerHook("panel", { key: manifest.key, hook: RadioPanel })
144
+ },
145
+ })