@ham2k/extension-sdk 0.12.15 → 1.12.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/README.md CHANGED
@@ -53,6 +53,13 @@ offline and describe the version installed here rather than whatever the
53
53
  repository says today. If an agent is writing your extension, point it at
54
54
  `node_modules/@ham2k/extension-sdk/AGENTS.md` — `h2kext-init` does that for you.
55
55
 
56
+ ## Versions
57
+
58
+ The minor version is the extension API: SDK 1.12 describes API 12, and
59
+ exports it as `API_VERSION`. `@ham2k/extension-tools` follows the same rule,
60
+ so a `^1.12.0` range says the extension needs API 12 and admits every later
61
+ one. A major version is a change that breaks an extension already written.
62
+
56
63
  ## Export types and settings
57
64
 
58
65
  Export hooks can register shared types with `getExportTypes()`. Use
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ham2k/extension-sdk 0.12.15
1
+ // @ham2k/extension-sdk 1.12.1
2
2
  /** Experimental v1 panel scene contract. All coordinates are in the scene's viewBox. */
3
3
  export interface PanelScene {
4
4
  version: 1;
@@ -1360,6 +1360,9 @@ export interface CommandAction {
1360
1360
  findInLog?: {
1361
1361
  query?: string;
1362
1362
  };
1363
+ navigate?: {
1364
+ to: string;
1365
+ };
1363
1366
  setCallField?: {
1364
1367
  text: string;
1365
1368
  };
@@ -1456,6 +1459,8 @@ export interface CommandCatalogEntry {
1456
1459
  params?: string;
1457
1460
  category?: string;
1458
1461
  needsOperation?: boolean;
1462
+ needsTestData?: boolean;
1463
+ keywords?: string[];
1459
1464
  expectsParams?: boolean;
1460
1465
  suggest?: boolean;
1461
1466
  }
@@ -1590,6 +1595,12 @@ export interface ExtensionRelevance {
1590
1595
  dates?: string[];
1591
1596
  interests?: string[];
1592
1597
  }
1598
+ export type SettingsSection = "activities" | "hunting" | "contests" | "lookups" | "spotting" | "dashboard" | "logging" | "radio" | "map" | "exports";
1599
+ export interface ExtensionSettingsPlacement {
1600
+ section?: SettingsSection | (string & {});
1601
+ sidebar?: boolean;
1602
+ common?: boolean;
1603
+ }
1593
1604
  export interface ExtensionManifest {
1594
1605
  key: string;
1595
1606
  name: string;
@@ -1615,6 +1626,7 @@ export interface ExtensionManifest {
1615
1626
  requiresClusterCommand?: boolean;
1616
1627
  experiments?: string[];
1617
1628
  relevance?: ExtensionRelevance;
1629
+ settings?: ExtensionSettingsPlacement;
1618
1630
  translations?: Record<string, {
1619
1631
  name?: string;
1620
1632
  description?: string;
@@ -2614,6 +2626,14 @@ export interface TemplateContextArgs {
2614
2626
  }
2615
2627
  export declare function templateContext(args: TemplateContextArgs): Record<string, unknown>;
2616
2628
  export declare function isTestOperation(stationCall: JSONValue | undefined): boolean;
2629
+ /**
2630
+ * The extension API this SDK describes: the highest `api` a manifest written
2631
+ * against it can declare, and the one its types and host calls are for. The
2632
+ * SDK's and `@ham2k/extension-tools`' minor version is this number, so a
2633
+ * `^1.<api>.0` range names the oldest API an extension needs and admits every
2634
+ * later one.
2635
+ */
2636
+ export declare const API_VERSION = 12;
2617
2637
  export declare function defineExtension(def: ExtensionDefinition): void;
2618
2638
  export declare const hooks: {
2619
2639
  invokeAll(category: HookCategoryName, method: string, args: unknown, online?: boolean): Promise<{
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { base64ToBytes, bytesToBase64 } from "./base64.js";
2
2
  import { adoptExtensionTimers, clearInterval, clearTimeout, setInterval, setTimeout } from "./timers.js";
3
+ const API_VERSION = 12;
3
4
  export * from "./types.js";
4
5
  export * from "./hookCategories.js";
5
6
  export * from "./panelScene.js";
@@ -426,6 +427,7 @@ const host = {
426
427
  }
427
428
  };
428
429
  export {
430
+ API_VERSION,
429
431
  defineExtension,
430
432
  hooks,
431
433
  host
@@ -144,6 +144,28 @@ The packer refuses:
144
144
  The matcher reads a key it cannot parse as "does not gate", so each of the
145
145
  shape errors would otherwise ship the bundle as worldwide.
146
146
 
147
+ ### Settings
148
+
149
+ An extension with a `settingsPanel` says where its panel goes:
150
+
151
+ ```json
152
+ "settings": { "section": "activities" }
153
+ ```
154
+
155
+ `section` is one of the app's settings sections, picked by the extension's
156
+ main purpose; `sidebar: true` asks for a sidebar entry for a panel that is a
157
+ category of its own, and `common: true` for Common Preferences rows. The section list, and what the app does with a
158
+ placement the manifest does not declare, are in
159
+ [settings.md](./settings.md#where-an-extensions-settings-go).
160
+
161
+ The packer refuses a `settings` that is not an object, a key inside it other
162
+ than those three, a `section` that is not a section key, and a `sidebar` or
163
+ `common` that is not `true` or `false`. It does not refuse a placement beyond
164
+ the usual — a section outside the list, no section for a `settingsPanel`, a
165
+ sidebar entry, Common Preferences — but prints `held for review on the
166
+ catalog` for each, because the catalog holds such an upload for an admin
167
+ even from a trusted publisher.
168
+
147
169
  ## Using the host's libraries
148
170
 
149
171
  The host carries a set of libraries and hands every extension the same
package/docs/hooks.md CHANGED
@@ -1170,10 +1170,10 @@ interface CommandHook {
1170
1170
  catalog?(ctx): Promise<CommandCatalogEntry[]>
1171
1171
  }
1172
1172
  // CommandInterpretation: {expectsParams?, mixedCase?, error?, describe?, confirm?, commands?: CommandAction[]}
1173
- // CommandCatalogEntry: {command, describe, params?, category?, needsOperation?, expectsParams?, suggest?}
1173
+ // CommandCatalogEntry: {command, describe, params?, category?, needsOperation?, needsTestData?, keywords?, expectsParams?, suggest?}
1174
1174
  // CommandAction (closed set): {setVfo?, setPower?, updateQso?, addQso?,
1175
1175
  // setKeyerSpeed?, showKeyer?, setEnterSends?, sendSpots?, autoRespot?, syncAll?, updateSettings?, updateSetting?, openSettings?,
1176
- // updateOperation?, markSegment?, findInLog?, setCallField?, setTime?, reloadDataFiles?, devNotice?, devStatus?,
1176
+ // updateOperation?, markSegment?, findInLog?, navigate?, setCallField?, setTime?, reloadDataFiles?, devNotice?, devStatus?,
1177
1177
  // devProgress?, devHammer?, devSplash?, devOnboard?, devTester?, devPopup?,
1178
1178
  // devTimeTravel?, toggleExperiment?}
1179
1179
  ```
@@ -1366,9 +1366,12 @@ Three things differ from `interpret`, all deliberate:
1366
1366
  - **Called once and cached**, not per keystroke — dropped when the runtime is
1367
1367
  replaced (an extension enabled/disabled) or the hook context is
1368
1368
  invalidated (locale change, since `describe` is localized by the hook).
1369
- - **Matching is host-side**, plain case-insensitive substring against
1370
- `command` and `describe`. Same rule `interpret`'s own matching follows: the
1371
- host never compiles extension-supplied patterns.
1369
+ - **Matching is host-side**, case-insensitive, at the start of a word in
1370
+ `command` or in `keywords` — never mid-word, and `describe` is never
1371
+ searched. Every word that should find a command belongs in its
1372
+ `keywords`: the aliases it accepts when typed, and plain words for what it
1373
+ does. Same rule `interpret`'s own matching follows: the host never
1374
+ compiles extension-supplied patterns.
1372
1375
  - **List commands, not variants.** `radio-commands` advertises one BAND
1373
1376
  entry, not fifteen; `call-notes` advertises `//`, not every key the user
1374
1377
  has defined.
@@ -1958,7 +1961,8 @@ stay as drawn and only spread apart or close in; a layer with `stretch: true`
1958
1961
  a line the length of the scene — scales along the scroll instead. That preview holds until
1959
1962
  the render that answers the `commit` arrives, so a scene meant to zoom draws its pieces as
1960
1963
  layers of their own rather than as one picture. A tap on the strip still reaches the button there, and a drag that starts off it
1961
- still scrolls.
1964
+ still scrolls. A trackpad's two-finger swipe scrolls on the strip as anywhere else; a trackpad
1965
+ pinch begun on it zooms by the pinch's own scale, clamped the same way, and commits as a drag does.
1962
1966
 
1963
1967
  A scroll steps a slider or knob by its `step`, down and left lowering it; a one-axis
1964
1968
  control takes a sideways scroll by its own direction. A slider or knob with
@@ -2474,6 +2478,12 @@ schema, kernel dispatch, `ExtensionService`, and `SettingsView` wiring; see
2474
2478
  [settings.md](./settings.md). Complementary to `account`, not a replacement.
2475
2479
  Implemented by: `settings` (the core General panel).
2476
2480
 
2481
+ An extension puts all its settings in one panel, placed in the section that
2482
+ fits its main purpose and declared in the manifest's `settings` — see
2483
+ [Where an extension's settings go](./settings.md#where-an-extensions-settings-go).
2484
+ A category of its own, a sidebar entry or Common Preferences rows are reviewed
2485
+ on the catalog.
2486
+
2477
2487
  ## ✅ `account` — account connections
2478
2488
 
2479
2489
  Account connections (QRZ, LoFi, SOTA): credential fields, `testCredentials`,
package/docs/settings.md CHANGED
@@ -26,6 +26,122 @@ There are two tiers, discriminated by a required `kind` tag.
26
26
 
27
27
  ---
28
28
 
29
+ ## Where an extension's settings go
30
+
31
+ **Put all of an extension's settings in one panel, and that panel in the
32
+ section that fits the extension's main purpose.** The panel shows there as a
33
+ single row that opens it in a dialog. An extension does not add a category of
34
+ its own to Application Preferences, an entry to the Settings sidebar, or
35
+ fields to Common Preferences — not without an admin's review on the catalog.
36
+
37
+ These sections take an extension's panel without review:
38
+
39
+ | `section` | Shown as | For |
40
+ |---|---|---|
41
+ | `activities` | Activities | an activity program — POTA, SOTA, WWFF |
42
+ | `hunting` | Hunting | chasing and awards: what an operator works rather than activates |
43
+ | `contests` | Contests | a contest or event |
44
+ | `lookups` | Lookups | callsign lookups, notes and history |
45
+ | `spotting` | Spotting | a spot source or destination |
46
+ | `dashboard` | Dashboard | a dashboard panel's app-wide preferences |
47
+ | `logging` | Logging | something that changes how QSOs are entered |
48
+ | `radio` | CAT Control | radios, rigs and keyers |
49
+ | `map` | Maps | the map views |
50
+ | `exports` | Exports | export files and formats |
51
+
52
+ Prefer the first six — they say what the extension is. The last four suit an
53
+ extension that tunes something the app already has a section for. A section
54
+ holding no panel is not shown.
55
+
56
+ The section is declared twice: in the **manifest**, which is what the catalog
57
+ reviews and what the app places the panel by, and on the **panel**, for an app
58
+ older than the manifest field. In `manifest.json`:
59
+
60
+ ```json
61
+ {
62
+ "key": "w7abc-park-notes",
63
+ "category": "activity",
64
+ "hooks": ["settingsPanel"],
65
+ "settings": { "section": "activities" }
66
+ }
67
+ ```
68
+
69
+ And where the panel is registered:
70
+
71
+ ```ts
72
+ api.registerHook('settingsPanel', {
73
+ hook: {
74
+ kind: 'simple',
75
+ title: 'Park Notes',
76
+ section: 'activities',
77
+ sidebar: false,
78
+ fields: [ /* … */ ],
79
+ },
80
+ });
81
+ ```
82
+
83
+ `settings` has three keys, all optional:
84
+
85
+ - **`section`** — the key of the section, from the table above.
86
+ - **`sidebar: true`** — a panel that is a category of its own (no `section`,
87
+ or one the app does not have) may also have an entry in the Settings
88
+ sidebar. A panel placed in a section is a row there and never has one.
89
+ Reviewed.
90
+ - **`common: true`** — the panel's fields flagged `common: true` may also show
91
+ in Common Preferences. Reviewed.
92
+
93
+ ### What is reviewed
94
+
95
+ The catalog reads a manifest and never runs a bundle, so the manifest is what
96
+ it reviews. An upload is held for an admin — even one from a trusted
97
+ publisher, whose uploads otherwise skip the queue — when its manifest:
98
+
99
+ - lists `settingsPanel` in `hooks` and names no `settings.section` — the panel
100
+ would be a category of its own;
101
+ - names a `settings.section` outside the table above — a new category, which
102
+ the app also has to know (a section the app does not know is shown as a
103
+ category of its own);
104
+ - sets `settings.sidebar` or `settings.common`.
105
+
106
+ A placement an admin has approved stays approved: a later version whose
107
+ `settings` are unchanged is not held for it again. To ask for a new category
108
+ or a sidebar entry, declare it and say why in the release notes; the reviewer
109
+ reads both. `npm run pack` (`h2kext-pack`) prints `held for review on the
110
+ catalog` for each of these, so it is no surprise.
111
+
112
+ ### What the app enforces
113
+
114
+ The manifest is a promise the code could break, so the app holds every
115
+ extension it did not ship to its manifest, whatever the panel asks for:
116
+
117
+ - the panel goes in the manifest's `settings.section`; with none declared, in
118
+ the panel's own `section` if it is one of the table's; otherwise it is a
119
+ category of its own;
120
+ - it has a sidebar entry only when it is a category of its own, the panel's
121
+ `sidebar` is not false AND the manifest declares `sidebar: true` — so with
122
+ no `settings`, `sidebar` reads as false;
123
+ - its `common: true` fields reach Common Preferences only when the manifest
124
+ declares `common: true`; otherwise the flag is ignored and the fields stay
125
+ in the panel.
126
+
127
+ A panel from an extension whose manifest `hooks` does not list
128
+ `settingsPanel` is not shown at all: the catalog decides what to review by
129
+ that list, so an undeclared panel is one nobody was asked about.
130
+ `buildExtension` fails the build when the bundle registers a panel its
131
+ manifest does not declare, unless it cannot activate the bundle to find out,
132
+ in which case it warns.
133
+
134
+ An extension installed from a `.h2kext` file never passed through the
135
+ catalog, so nobody reviewed its manifest: the app takes its `settings` as
136
+ written, `sidebar` and `common` included. Installing a file is the
137
+ operator's own choice to trust it.
138
+
139
+ Everything else about a panel is untouched: `dataFilesSection` still adds its
140
+ row to the Data Files screen, and `target` still reaches the Logging and
141
+ Developer dialogs (see [Settings targets](#settings-targets)).
142
+
143
+ ---
144
+
29
145
  ## Key namespacing
30
146
 
31
147
  Wherever an extension can define more than one of something under one
@@ -76,7 +192,9 @@ export interface SimpleSettingsPanel {
76
192
  order?: number;
77
193
  // Defaults true. Set false to keep this panel out of the app's top-level
78
194
  // settings navigation (the sidebar list). Independent of dataFilesSection
79
- // below — this only controls the top-level nav.
195
+ // below — this only controls the top-level nav. An extension the app did
196
+ // not ship also needs the manifest's `settings.sidebar: true`, which is
197
+ // reviewed; see "Where an extension's settings go".
80
198
  sidebar?: boolean;
81
199
  // Defaults false. Set true to also show this panel as a row in the Data
82
200
  // Files screen's own "Extensions" section — leave this false for anything
@@ -85,10 +203,12 @@ export interface SimpleSettingsPanel {
85
203
  // the type level — inferring this from `sidebar: false` alone would sweep
86
204
  // any sidebar-opt-out panel in here.
87
205
  dataFilesSection?: boolean;
88
- // The key of one of the app's own settings sections ('spotting', the
89
- // Spotting section) to show this panel inside, as one row that opens it
90
- // in a dialog — instead of a section of its own in Settings and a sidebar
91
- // entry, whatever `sidebar` says. An app without that section (an older
206
+ // The key of one of the app's own settings sections ('activities',
207
+ // 'spotting', … — see "Where an extension's settings go") to show this
208
+ // panel inside, as one row that opens it in a dialog — instead of a
209
+ // section of its own in Settings and a sidebar entry, whatever `sidebar`
210
+ // says. Declare the same key as the manifest's `settings.section`, which is
211
+ // what the app places the panel by. An app without that section (an older
92
212
  // one, or a key it doesn't know) shows the panel as if this weren't set,
93
213
  // so it needs no newer API level.
94
214
  section?: string;
@@ -363,8 +483,11 @@ override) without it cluttering the panel for regular users:
363
483
  A panel with nothing to show at the current developer-mode setting is left
364
484
  out of the Settings screen's list of categories rather than shown as an empty
365
485
  heading, so a panel whose every field is `devMode` appears there only while
366
- developer mode is on. Its sidebar entry is not: give such a panel
367
- `sidebar: false`, or the entry opens on an empty page.
486
+ developer mode is on. A panel placed in a `section` is treated the same way:
487
+ its row there is a developer-mode row when every field it has is `devMode`,
488
+ and it has no row at all when it has no fields on this platform. Its sidebar
489
+ entry is not: give such a panel `sidebar: false`, or the entry opens on an
490
+ empty page.
368
491
 
369
492
  ## `aliases`: short names for the SET command
370
493
 
@@ -404,7 +527,10 @@ maintained as a separate list somewhere else in the app. Any field, link, or
404
527
  action (core-declared or extension-declared, Tier 1 or Tier 2 alike) can set:
405
528
 
406
529
  - **`common: true`** — also show this element in the Common Preferences
407
- section, on top of its own group's. Off by default. Not the same thing as
530
+ section, on top of its own group's. Off by default. An extension's
531
+ `common` fields reach Common Preferences only when its manifest declares
532
+ `settings.common: true`, which is reviewed — see
533
+ [Where an extension's settings go](#where-an-extensions-settings-go). Not the same thing as
408
534
  `FormFieldOption.common`/`uncommon` above — that's a `multiselect`
409
535
  option's own disclosure tier (upfront vs. behind "Show more"), a
410
536
  different scope (an option inside one field, not the field itself).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ham2k/extension-sdk",
3
- "version": "0.12.15",
3
+ "version": "1.12.1",
4
4
  "description": "Write extensions for the Ham2K Logger: typed hook contracts and the host API",
5
5
  "keywords": [
6
6
  "ham2k",
@@ -4,9 +4,17 @@ The radio's panadapter and waterfall in a scene panel, with its span, reference
4
4
  level and sweep speed, a local contrast control, and click-to-tune. The host
5
5
  draws the sweeps in the `spectrum` control; they never reach the extension.
6
6
 
7
- Needs a radio whose CAT protocol carries its scope: an Icom IC-705/7300/7610 (at
8
- 115200 bps on a serial line), or a FlexRadio, which is mirrored through a DAX IQ
9
- channel.
7
+ Needs a supported radio:
8
+
9
+ - Icom IC-705/7300/7610, at 115200 bps when using a serial line.
10
+ - FlexRadio, mirrored through a DAX IQ channel.
11
+ - Elecraft K4 over Ethernet, with a separate local TCP spectrum connection on
12
+ port 9201 alongside CAT. It displays the main panadapter with local zoom,
13
+ reference and speed controls. Levels are displayed as received, without an
14
+ RF calibration claim.
15
+
16
+ The panel shows only the controls each radio supports; contrast, averaging,
17
+ frame rate and waterfall rows remain local.
10
18
 
11
19
  In this repository:
12
20
 
@@ -14,3 +22,8 @@ In this repository:
14
22
  node build.mjs
15
23
  node ../../tools/h2kext-pack.mjs build -o /tmp/k2hrc-panafall.h2kext
16
24
  ```
25
+
26
+ Samples are not included in the app's regular extension build. Install the
27
+ resulting `.h2kext` through **Settings → Extensions & Features → Install from
28
+ file…**, then add its **Panadapter** panel to an operation. The host must have
29
+ the matching radio support.
@@ -42,15 +42,25 @@ const spanLabel = (hz: number) => `±${hz >= 1000 ? `${hz / 1000} kHz` : `${hz}
42
42
  export function scene(radio: PanelRadioState | null, display: Display, step = "100"): PanelScene {
43
43
  const spectrum: PanelRadioSpectrum | null | undefined = radio?.spectrum
44
44
  const knobs = spectrum && !spectrum.problem
45
+ const hasSpan = knobs && spectrum.spansHz.length > 0
46
+ const hasSpeed = knobs && spectrum.speeds.length > 0
47
+ const hasRef =
48
+ knobs &&
49
+ Number.isFinite(spectrum.refMinDb) &&
50
+ Number.isFinite(spectrum.refMaxDb) &&
51
+ Number.isFinite(spectrum.refStepDb) &&
52
+ spectrum.refMinDb < spectrum.refMaxDb &&
53
+ spectrum.refStepDb > 0
54
+ const ref = hasRef ? Math.max(spectrum.refMinDb, Math.min(spectrum.refMaxDb, spectrum.refLevelDb ?? spectrum.refMinDb)) : 0
45
55
  const band = passband(radio?.mode)
46
56
  return {
47
57
  version: 1,
48
58
  width: 600,
49
59
  height: 24,
50
- values: { floor: 0, average: 40, fps: 15, rows: 10, hoverHz: 0, hoverDb: 0, ref: spectrum?.refLevelDb ?? 0 },
60
+ values: { floor: 0, average: 40, fps: 15, rows: 10, hoverHz: 0, hoverDb: 0, ref },
51
61
  strings: {
52
- span: spectrum?.spanHz != null ? String(spectrum.spanHz) : "",
53
- speed: spectrum?.speed ?? "",
62
+ span: hasSpan && spectrum.spanHz != null && spectrum.spansHz.includes(spectrum.spanHz) ? String(spectrum.spanHz) : "",
63
+ speed: hasSpeed && spectrum.speed != null && spectrum.speeds.includes(spectrum.speed) ? spectrum.speed : "",
54
64
  display,
55
65
  step,
56
66
  },
@@ -65,7 +75,7 @@ export function scene(radio: PanelRadioState | null, display: Display, step = "1
65
75
  },
66
76
  ],
67
77
  controls: [
68
- ...(knobs
78
+ ...(hasSpan
69
79
  ? [
70
80
  {
71
81
  id: "span",
@@ -75,6 +85,10 @@ export function scene(radio: PanelRadioState | null, display: Display, step = "1
75
85
  event: "span",
76
86
  options: spectrum.spansHz.map((hz) => ({ label: spanLabel(hz), value: String(hz) })),
77
87
  },
88
+ ]
89
+ : []),
90
+ ...(hasRef
91
+ ? [
78
92
  {
79
93
  id: "ref",
80
94
  label: "Reference level",
@@ -85,6 +99,10 @@ export function scene(radio: PanelRadioState | null, display: Display, step = "1
85
99
  max: spectrum.refMaxDb,
86
100
  step: spectrum.refStepDb,
87
101
  },
102
+ ]
103
+ : []),
104
+ ...(hasSpeed
105
+ ? [
88
106
  {
89
107
  id: "speed",
90
108
  label: "Speed",
@@ -100,7 +118,7 @@ export function scene(radio: PanelRadioState | null, display: Display, step = "1
100
118
  { id: "fps", label: "Panadapter FPS", kind: "nativeSlider" as const, value: "fps", min: 1, max: 30, step: 1 },
101
119
  { id: "rows", label: "Waterfall rows per second", kind: "nativeSlider" as const, value: "rows", min: 1, max: 30, step: 1 },
102
120
  // A control the layout does not place fails the scene: Ref's label goes with its slider.
103
- ...[...(knobs ? ["Ref"] : []), "Contrast", "Avg", "FPS", "Rows"].map((text) => ({
121
+ ...[...(hasRef ? ["Ref"] : []), "Contrast", "Avg", "FPS", "Rows"].map((text) => ({
104
122
  id: `label-${text}`,
105
123
  label: text,
106
124
  kind: "nativeText" as const,
@@ -179,7 +197,9 @@ export function scene(radio: PanelRadioState | null, display: Display, step = "1
179
197
  column: [
180
198
  {
181
199
  wrap: [
182
- ...(knobs ? [{ control: "span" }, labeled("Ref", "ref"), { control: "speed" }] : []),
200
+ ...(hasSpan ? [{ control: "span" }] : []),
201
+ ...(hasRef ? [labeled("Ref", "ref")] : []),
202
+ ...(hasSpeed ? [{ control: "speed" }] : []),
183
203
  labeled("Contrast", "floor"),
184
204
  labeled("Avg", "average"),
185
205
  labeled("FPS", "fps"),