@devicechain/dashboards 0.19.0 → 0.20.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.
package/dist/index.js CHANGED
@@ -61,6 +61,85 @@ var WIDGET_TYPES = [
61
61
  "entity-selector",
62
62
  "map"
63
63
  ];
64
+ var UNKNOWN_WIDGET_TYPE = "unknown-widget";
65
+
66
+ // src/widget-compat.ts
67
+ var BUILTIN_TYPES = new Set(WIDGET_TYPES);
68
+ function isBuiltinWidget(widget) {
69
+ return BUILTIN_TYPES.has(widget.type);
70
+ }
71
+ function isResolvedWidget(widget) {
72
+ return widget.type !== UNKNOWN_WIDGET_TYPE;
73
+ }
74
+ var SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
75
+ function isRecord(v) {
76
+ return typeof v === "object" && v !== null && !Array.isArray(v);
77
+ }
78
+ function parseWidgetRef(raw) {
79
+ if (raw === void 0) return void 0;
80
+ if (!isRecord(raw)) return null;
81
+ const { version, optionsVersion, sdkApiVersion } = raw;
82
+ if (typeof version !== "string" || !SEMVER.test(version)) return null;
83
+ if (typeof optionsVersion !== "number" || !Number.isInteger(optionsVersion) || optionsVersion < 1) return null;
84
+ const ref = { version, optionsVersion };
85
+ if (sdkApiVersion !== void 0) {
86
+ if (typeof sdkApiVersion !== "number" || !Number.isInteger(sdkApiVersion) || sdkApiVersion < 1) return null;
87
+ ref.sdkApiVersion = sdkApiVersion;
88
+ }
89
+ return ref;
90
+ }
91
+ function checkWidgetCompatibility(id, ref, descriptor) {
92
+ if (ref.optionsVersion !== descriptor.optionsVersion) {
93
+ return {
94
+ code: "options-version",
95
+ message: `"${id}" was saved with settings version ${ref.optionsVersion}; the installed widget uses settings version ${descriptor.optionsVersion}`
96
+ };
97
+ }
98
+ if (ref.sdkApiVersion !== void 0 && ref.sdkApiVersion !== descriptor.sdkApiVersion) {
99
+ return {
100
+ code: "sdk-incompatible",
101
+ message: `"${id}" was authored for widget SDK API ${ref.sdkApiVersion}; the installed widget uses API ${descriptor.sdkApiVersion}`
102
+ };
103
+ }
104
+ return void 0;
105
+ }
106
+ function widgetVersionNotice(id, ref, descriptor) {
107
+ if (!ref || ref.version === descriptor.version) return void 0;
108
+ return `"${id}" was authored with version ${ref.version}; rendering with version ${descriptor.version}`;
109
+ }
110
+ function unresolvedReason(type, rawRef, catalog, builtin) {
111
+ const ref = parseWidgetRef(rawRef);
112
+ if (ref === null) {
113
+ return { code: "invalid-metadata", message: `"${type}" carries widget version metadata that is not valid` };
114
+ }
115
+ if (!catalog) {
116
+ return builtin ? void 0 : { code: "unregistered", message: `"${type}" is not available: no widget catalog was supplied` };
117
+ }
118
+ const descriptor = catalog.get(type);
119
+ if (!descriptor) {
120
+ return {
121
+ code: "unregistered",
122
+ message: builtin ? `"${type}" is not registered in this host` : `"${type}" is not installed in this host; add its package to the host's widget registry`
123
+ };
124
+ }
125
+ if (ref === void 0) {
126
+ return builtin ? void 0 : { code: "unversioned", message: `"${type}" is stored without version metadata, so the installed widget cannot be matched to it` };
127
+ }
128
+ return checkWidgetCompatibility(type, ref, descriptor);
129
+ }
130
+ function unresolvedWidgets(definition) {
131
+ const out = [];
132
+ for (const w of definition.widgets) {
133
+ if (w.type !== UNKNOWN_WIDGET_TYPE) continue;
134
+ const storedType = typeof w.raw.type === "string" ? w.raw.type : "";
135
+ out.push({
136
+ widgetId: w.id,
137
+ type: storedType,
138
+ reason: w.reason ?? { code: "unregistered", message: `"${storedType}" is not available in this host` }
139
+ });
140
+ }
141
+ return out;
142
+ }
64
143
 
65
144
  // src/definition.ts
66
145
  var BASE_BREAKPOINT = "base";
@@ -73,33 +152,33 @@ var DashboardDefinitionError = class extends Error {
73
152
  this.name = "DashboardDefinitionError";
74
153
  }
75
154
  };
76
- function isRecord(v) {
155
+ function isRecord2(v) {
77
156
  return typeof v === "object" && v !== null && !Array.isArray(v);
78
157
  }
79
158
  function numberAt(rec, key, fallback) {
80
159
  const v = rec[key];
81
160
  return typeof v === "number" && Number.isFinite(v) ? v : fallback;
82
161
  }
83
- function parseDashboardDefinition(raw) {
84
- if (!isRecord(raw)) throw new DashboardDefinitionError("not a JSON object");
162
+ function parseDashboardDefinition(raw, options = {}) {
163
+ if (!isRecord2(raw)) throw new DashboardDefinitionError("not a JSON object");
85
164
  const widgetsRaw = raw.widgets;
86
165
  if (!Array.isArray(widgetsRaw)) throw new DashboardDefinitionError("widgets must be an array");
87
166
  const def = {
88
167
  schemaVersion: numberAt(raw, "schemaVersion", 1),
89
168
  title: typeof raw.title === "string" ? raw.title : "",
90
169
  canvas: parseCanvas(raw.canvas),
91
- widgets: widgetsRaw.map((w, i) => parseWidget(w, i))
170
+ widgets: widgetsRaw.map((w, i) => parseWidget(w, i, options.catalog))
92
171
  };
93
172
  const slots = parseSlots(raw.slots);
94
173
  if (slots) def.slots = slots;
95
174
  return def;
96
175
  }
97
176
  function parseSlots(raw) {
98
- if (!isRecord(raw)) return void 0;
177
+ if (!isRecord2(raw)) return void 0;
99
178
  const slots = {};
100
179
  for (const [name, spec] of Object.entries(raw)) {
101
180
  if (name === "__proto__") continue;
102
- if (!isRecord(spec)) continue;
181
+ if (!isRecord2(spec)) continue;
103
182
  const type = spec.type === "anchor" ? "anchor" : "device";
104
183
  const slot = { type };
105
184
  if (typeof spec.label === "string") slot.label = spec.label;
@@ -114,7 +193,7 @@ function parseSlots(raw) {
114
193
  return slots;
115
194
  }
116
195
  function parseScope(raw) {
117
- if (!isRecord(raw)) return void 0;
196
+ if (!isRecord2(raw)) return void 0;
118
197
  const parent = typeof raw.parent === "string" ? raw.parent : "";
119
198
  if (!parent) return void 0;
120
199
  return { parent, strategy: raw.strategy === "manual" ? "manual" : "first" };
@@ -158,13 +237,13 @@ function canScopeSlot(slots, child, parentName) {
158
237
  return true;
159
238
  }
160
239
  function parseSlotBinding(raw) {
161
- if (!isRecord(raw)) return void 0;
240
+ if (!isRecord2(raw)) return void 0;
162
241
  if (raw.kind === "device") {
163
242
  const token = stringAt(raw, "deviceToken");
164
243
  return token ? { kind: "device", deviceToken: token } : void 0;
165
244
  }
166
245
  if (raw.kind === "anchor") {
167
- const anchorRec = isRecord(raw.anchor) ? raw.anchor : {};
246
+ const anchorRec = isRecord2(raw.anchor) ? raw.anchor : {};
168
247
  const targetToken = stringAt(anchorRec, "targetToken");
169
248
  if (!targetToken) return void 0;
170
249
  return {
@@ -179,13 +258,13 @@ function parseSlotBinding(raw) {
179
258
  return void 0;
180
259
  }
181
260
  function parseGrid(raw) {
182
- const rec = isRecord(raw) ? raw : {};
261
+ const rec = isRecord2(raw) ? raw : {};
183
262
  const columns = Math.max(1, Math.round(numberAt(rec, "columns", DEFAULT_GRID.columns)));
184
263
  const rowHeight = Math.max(1, numberAt(rec, "rowHeight", DEFAULT_GRID.rowHeight));
185
264
  let gap = DEFAULT_GRID.gap;
186
265
  if (typeof rec.gap === "number" && Number.isFinite(rec.gap)) {
187
266
  gap = Math.max(0, rec.gap);
188
- } else if (isRecord(rec.gap)) {
267
+ } else if (isRecord2(rec.gap)) {
189
268
  gap = {
190
269
  row: Math.max(0, numberAt(rec.gap, "row", DEFAULT_GRID.gap)),
191
270
  col: Math.max(0, numberAt(rec.gap, "col", DEFAULT_GRID.gap))
@@ -194,7 +273,7 @@ function parseGrid(raw) {
194
273
  return { columns, gap, rowHeight };
195
274
  }
196
275
  function parseSizing(raw) {
197
- if (isRecord(raw)) {
276
+ if (isRecord2(raw)) {
198
277
  if (typeof raw.width === "number" && Number.isFinite(raw.width)) {
199
278
  return { width: Math.max(1, raw.width) };
200
279
  }
@@ -205,17 +284,17 @@ function parseSizing(raw) {
205
284
  return DEFAULT_SIZING;
206
285
  }
207
286
  function parseCanvas(raw) {
208
- const rec = isRecord(raw) ? raw : {};
287
+ const rec = isRecord2(raw) ? raw : {};
209
288
  const grid = parseGrid(rec.grid);
210
289
  const sizing = parseSizing(rec.sizing);
211
- const bpRec = isRecord(rec.breakpoints) ? rec.breakpoints : {};
290
+ const bpRec = isRecord2(rec.breakpoints) ? rec.breakpoints : {};
212
291
  const breakpoints = {};
213
292
  for (const [name, width] of Object.entries(bpRec)) {
214
293
  if (typeof width === "number" && Number.isFinite(width)) breakpoints[name] = width;
215
294
  }
216
295
  if (!(BASE_BREAKPOINT in breakpoints)) breakpoints[BASE_BREAKPOINT] = 0;
217
296
  const canvas = { grid, sizing, breakpoints };
218
- if (isRecord(rec.background)) {
297
+ if (isRecord2(rec.background)) {
219
298
  const { color, imageUrl } = rec.background;
220
299
  canvas.background = {
221
300
  color: typeof color === "string" ? color : null,
@@ -224,22 +303,81 @@ function parseCanvas(raw) {
224
303
  }
225
304
  return canvas;
226
305
  }
227
- function parseWidget(raw, index) {
228
- if (!isRecord(raw)) throw new DashboardDefinitionError(`widgets[${index}] is not an object`);
306
+ function parseWidget(raw, index, catalog) {
307
+ if (!isRecord2(raw)) throw new DashboardDefinitionError(`widgets[${index}] is not an object`);
229
308
  const type = raw.type;
230
- if (typeof type !== "string" || !WIDGET_TYPE_SET.has(type)) {
309
+ if (typeof type !== "string" || type.length === 0) {
231
310
  throw new DashboardDefinitionError(`widgets[${index}] has unknown type ${JSON.stringify(type)}`);
232
311
  }
233
- const widget = {
234
- id: typeof raw.id === "string" && raw.id.length > 0 ? raw.id : generateWidgetId(),
312
+ if (type === UNKNOWN_WIDGET_TYPE && isRecord2(raw.raw)) return reparseUnknownWidget(raw, index, catalog);
313
+ const builtin = WIDGET_TYPE_SET.has(type);
314
+ const reason = unresolvedReason(type, raw.widget, catalog, builtin) ?? (builtin ? void 0 : reservedFieldReason(type, raw));
315
+ if (reason) return parseUnknownWidget(raw, index, reason);
316
+ const ref = parseWidgetRef(raw.widget) ?? void 0;
317
+ const widget = builtin ? {
318
+ id: storedWidgetId(raw) ?? generateWidgetId(),
235
319
  type,
320
+ ...ref ? { widget: ref } : {},
321
+ layout: parseLayout(raw.layout, index)
322
+ } : {
323
+ id: storedWidgetId(raw) ?? generateWidgetId(),
324
+ type,
325
+ widget: ref,
326
+ // unresolvedReason guarantees a valid ref here
236
327
  layout: parseLayout(raw.layout, index)
237
328
  };
238
- const ds = parseDatasource(raw.datasource);
329
+ const ds = parseDatasource(raw.datasource, index);
239
330
  if (ds) widget.datasource = ds;
240
- if (isRecord(raw.options)) widget.options = raw.options;
331
+ if (isRecord2(raw.options)) widget.options = raw.options;
332
+ if (!builtin) return withStoredFields(raw, widget);
241
333
  return widget;
242
334
  }
335
+ var OWNED_WIDGET_KEYS = /* @__PURE__ */ new Set(["id", "type", "widget", "layout", "datasource", "options"]);
336
+ function withStoredFields(raw, parsed) {
337
+ const owned = parsed;
338
+ const out = {};
339
+ const put = (key, value) => Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
340
+ for (const key of Object.keys(raw)) {
341
+ if (!OWNED_WIDGET_KEYS.has(key)) put(key, JSON.parse(JSON.stringify(raw[key])));
342
+ else if (key === "widget") put(key, { ...raw.widget, ...parsed.widget });
343
+ else if (owned[key] !== void 0) put(key, owned[key]);
344
+ }
345
+ for (const key of OWNED_WIDGET_KEYS) {
346
+ if (!Object.prototype.hasOwnProperty.call(out, key) && owned[key] !== void 0) put(key, owned[key]);
347
+ }
348
+ return out;
349
+ }
350
+ function reservedFieldReason(type, raw) {
351
+ const clash = ["raw", "reason"].find((k) => Object.prototype.hasOwnProperty.call(raw, k));
352
+ return clash === void 0 ? void 0 : { code: "invalid-metadata", message: `"${type}" carries a top-level "${clash}" field, which the dashboard format reserves` };
353
+ }
354
+ function storedWidgetId(raw) {
355
+ return typeof raw.id === "string" && raw.id.length > 0 ? raw.id : void 0;
356
+ }
357
+ function parseUnknownWidget(raw, index, reason) {
358
+ return {
359
+ id: storedWidgetId(raw) ?? generateWidgetId(),
360
+ type: UNKNOWN_WIDGET_TYPE,
361
+ layout: parseLayout(raw.layout, index),
362
+ raw: JSON.parse(JSON.stringify(raw)),
363
+ reason
364
+ };
365
+ }
366
+ function reparseUnknownWidget(raw, index, catalog) {
367
+ const stored = JSON.parse(JSON.stringify(raw.raw));
368
+ const storedType = typeof stored.type === "string" ? stored.type : "";
369
+ const reason = unresolvedReason(storedType, stored.widget, catalog, WIDGET_TYPE_SET.has(storedType)) ?? {
370
+ code: "unregistered",
371
+ message: `"${storedType}" is not available in this host`
372
+ };
373
+ return {
374
+ id: storedWidgetId(raw) ?? generateWidgetId(),
375
+ type: UNKNOWN_WIDGET_TYPE,
376
+ layout: parseLayout(raw.layout, index),
377
+ raw: stored,
378
+ reason
379
+ };
380
+ }
243
381
  function stringAt(rec, key) {
244
382
  const v = rec[key];
245
383
  return typeof v === "string" ? v : "";
@@ -249,17 +387,22 @@ function stringArrayAt(rec, key) {
249
387
  return Array.isArray(v) ? v.filter((m) => typeof m === "string") : [];
250
388
  }
251
389
  function parseLocationSelection(raw) {
252
- if (!isRecord(raw)) return void 0;
390
+ if (!isRecord2(raw)) return void 0;
253
391
  return raw.series === "latest" ? { series: "latest" } : void 0;
254
392
  }
255
393
  function withLocation(selector, raw) {
256
394
  const location = parseLocationSelection(raw.location);
257
395
  return location ? { ...selector, location } : selector;
258
396
  }
259
- function parseDatasource(raw) {
260
- if (!isRecord(raw)) return void 0;
397
+ function parseDatasource(raw, index) {
398
+ if (!isRecord2(raw)) return void 0;
261
399
  const kind = raw.kind;
262
400
  if (typeof kind !== "string" || kind.length === 0) return void 0;
401
+ if (kind === "unbound") {
402
+ throw new DashboardDefinitionError(
403
+ `widgets[${index}].datasource has kind 'unbound', which is never stored; bind the widget through a slot`
404
+ );
405
+ }
263
406
  if (kind === "device") {
264
407
  return withLocation(
265
408
  { kind: "device", deviceToken: stringAt(raw, "deviceToken"), measurements: stringArrayAt(raw, "measurements") },
@@ -267,7 +410,7 @@ function parseDatasource(raw) {
267
410
  );
268
411
  }
269
412
  if (kind === "anchor") {
270
- const anchorRec = isRecord(raw.anchor) ? raw.anchor : {};
413
+ const anchorRec = isRecord2(raw.anchor) ? raw.anchor : {};
271
414
  const selector = withLocation(
272
415
  {
273
416
  kind: "anchor",
@@ -282,7 +425,7 @@ function parseDatasource(raw) {
282
425
  },
283
426
  raw
284
427
  );
285
- if (isRecord(raw.aggregation)) {
428
+ if (isRecord2(raw.aggregation)) {
286
429
  selector.aggregation = raw.aggregation;
287
430
  }
288
431
  return selector;
@@ -296,10 +439,10 @@ function parseDatasource(raw) {
296
439
  return raw;
297
440
  }
298
441
  function parseLayout(raw, index) {
299
- if (!isRecord(raw)) throw new DashboardDefinitionError(`widgets[${index}].layout is missing`);
442
+ if (!isRecord2(raw)) throw new DashboardDefinitionError(`widgets[${index}].layout is missing`);
300
443
  const layout = {};
301
444
  for (const [bp, box] of Object.entries(raw)) {
302
- if (isRecord(box)) layout[bp] = parseBox(box);
445
+ if (isRecord2(box)) layout[bp] = parseBox(box);
303
446
  }
304
447
  if (!(BASE_BREAKPOINT in layout)) {
305
448
  throw new DashboardDefinitionError(`widgets[${index}].layout has no '${BASE_BREAKPOINT}' box`);
@@ -316,13 +459,64 @@ function parseBox(rec) {
316
459
  // so keep it an integer like every other box field.
317
460
  z: Math.round(numberAt(rec, "z", 0))
318
461
  };
319
- if (isRecord(rec.offset)) {
462
+ if (isRecord2(rec.offset)) {
320
463
  box.offset = { x: numberAt(rec.offset, "x", 0), y: numberAt(rec.offset, "y", 0) };
321
464
  }
322
465
  return box;
323
466
  }
467
+ function storedDefinition(def) {
468
+ if (!def.widgets.some((w) => w.type === UNKNOWN_WIDGET_TYPE)) return def;
469
+ return {
470
+ ...def,
471
+ widgets: def.widgets.map((w) => w.type === UNKNOWN_WIDGET_TYPE ? storedUnknownWidget(w) : w)
472
+ };
473
+ }
474
+ function storedUnknownWidget(w) {
475
+ let out = w.raw;
476
+ const rawId = storedWidgetId(w.raw);
477
+ if (rawId !== void 0 && rawId !== w.id) out = { ...out, id: w.id };
478
+ const layout = storedUnknownLayout(w.layout, w.raw.layout);
479
+ if (layout !== w.raw.layout) out = { ...out, layout };
480
+ return out;
481
+ }
482
+ function storedUnknownLayout(layout, rawLayout) {
483
+ const stored = isRecord2(rawLayout) ? rawLayout : {};
484
+ const out = {};
485
+ let changed = !isRecord2(rawLayout);
486
+ for (const [bp, box] of Object.entries(stored)) {
487
+ if (!isRecord2(box)) {
488
+ out[bp] = box;
489
+ continue;
490
+ }
491
+ const current = Object.prototype.hasOwnProperty.call(layout, bp) ? layout[bp] : void 0;
492
+ if (!current) {
493
+ changed = true;
494
+ continue;
495
+ }
496
+ if (sameBox(parseBox(box), current)) {
497
+ out[bp] = box;
498
+ } else {
499
+ out[bp] = movedBox(box, current);
500
+ changed = true;
501
+ }
502
+ }
503
+ for (const [bp, box] of Object.entries(layout)) {
504
+ if (Object.prototype.hasOwnProperty.call(stored, bp) && isRecord2(stored[bp])) continue;
505
+ out[bp] = box;
506
+ changed = true;
507
+ }
508
+ return changed ? out : rawLayout;
509
+ }
510
+ function movedBox(stored, current) {
511
+ const out = { ...stored, ...current };
512
+ if (current.offset === void 0) delete out.offset;
513
+ return out;
514
+ }
515
+ function sameBox(a, b) {
516
+ return a.col === b.col && a.colSpan === b.colSpan && a.row === b.row && a.rowSpan === b.rowSpan && a.z === b.z && a.offset?.x === b.offset?.x && a.offset?.y === b.offset?.y;
517
+ }
324
518
  function serializeDefinition(def) {
325
- return JSON.stringify(def);
519
+ return JSON.stringify(storedDefinition(def));
326
520
  }
327
521
  function isDirty(a, b) {
328
522
  return serializeDefinition(a) !== serializeDefinition(b);
@@ -386,6 +580,14 @@ function stripDefaultBindings(def) {
386
580
  }
387
581
  return { ...def, slots };
388
582
  }
583
+ function resolveWidgetDatasource(datasource, bindings) {
584
+ if (!datasource || datasource.kind !== "slot") return datasource;
585
+ const binding = bindings && Object.prototype.hasOwnProperty.call(bindings, datasource.slot) ? bindings[datasource.slot] : void 0;
586
+ const series = datasource.location ? { measurements: datasource.measurements, location: datasource.location } : { measurements: datasource.measurements };
587
+ if (binding?.kind === "device") return { kind: "device", deviceToken: binding.deviceToken, ...series };
588
+ if (binding?.kind === "anchor") return { kind: "anchor", anchor: binding.anchor, ...series };
589
+ return { kind: "unbound", ...series };
590
+ }
389
591
 
390
592
  // src/context.ts
391
593
  function ownGet(map, key) {
@@ -527,7 +729,11 @@ function bindWidgetSlot(def, widgetId, binding, measurements, location) {
527
729
  const currentDef = currentSlot ? slots[currentSlot] : void 0;
528
730
  const slot = currentSlot && currentDef && (currentDef.scope || sameBinding(currentDef.defaultBinding, binding)) ? currentSlot : findOrAddSlot(slots, binding);
529
731
  const widgets = def.widgets.map(
530
- (w) => w.id === widgetId ? { ...w, datasource: slotSelector(slot, measurements, location) } : w
732
+ (w) => (
733
+ // A placeholder for an unknown widget type binds nothing this build can see, so it is
734
+ // never given a datasource here: its bindings live in its raw object.
735
+ w.id === widgetId && w.type !== UNKNOWN_WIDGET_TYPE ? { ...w, datasource: slotSelector(slot, measurements, location) } : w
736
+ )
531
737
  );
532
738
  return { ...def, widgets, slots };
533
739
  }
@@ -539,6 +745,18 @@ function clearWidgetDatasource(def, widgetId) {
539
745
  });
540
746
  return { ...def, widgets };
541
747
  }
748
+ var RAW_SLOT_SCAN_DEPTH = 32;
749
+ function collectRawSlotReferences(value, used, depth) {
750
+ if (depth > RAW_SLOT_SCAN_DEPTH || typeof value !== "object" || value === null) return;
751
+ if (Array.isArray(value)) {
752
+ for (const item of value) collectRawSlotReferences(item, used, depth + 1);
753
+ return;
754
+ }
755
+ const rec = value;
756
+ if (rec.kind === "slot" && typeof rec.slot === "string" && rec.slot.length > 0) used.add(rec.slot);
757
+ if (typeof rec.selectionTarget === "string" && rec.selectionTarget.length > 0) used.add(rec.selectionTarget);
758
+ for (const child of Object.values(rec)) collectRawSlotReferences(child, used, depth + 1);
759
+ }
542
760
  function pruneSlots(def) {
543
761
  if (!def.slots) return def;
544
762
  const defSlots = def.slots;
@@ -547,6 +765,7 @@ function pruneSlots(def) {
547
765
  if (w.datasource?.kind === "slot") used.add(w.datasource.slot);
548
766
  const target = w.options?.selectionTarget;
549
767
  if (typeof target === "string" && target.length > 0) used.add(target);
768
+ if (w.raw) collectRawSlotReferences(w.raw, used, 0);
550
769
  }
551
770
  for (const start of [...used]) {
552
771
  let cur = start;
@@ -594,10 +813,15 @@ function widgetSlotName(widget) {
594
813
  }
595
814
  function resolveConcrete(def, widget) {
596
815
  const binding = widgetBinding(def, widget);
597
- if (!binding) return void 0;
598
816
  const measurements = widget.datasource?.measurements ?? [];
599
817
  const location = widget.datasource?.location;
600
818
  const base = location ? { measurements, location } : { measurements };
819
+ if (!binding) {
820
+ const ds = widget.datasource;
821
+ if (ds?.kind === "device") return { kind: "device", deviceToken: ds.deviceToken, ...base };
822
+ if (ds?.kind === "anchor") return { kind: "anchor", anchor: ds.anchor, ...base };
823
+ return void 0;
824
+ }
601
825
  return binding.kind === "device" ? { kind: "device", deviceToken: binding.deviceToken, ...base } : { kind: "anchor", anchor: binding.anchor, ...base };
602
826
  }
603
827
 
@@ -938,15 +1162,8 @@ var DashboardHub = class {
938
1162
  // device stream), so disposeAll() can tear down every map widget's poll.
939
1163
  this.locationDisposers = /* @__PURE__ */ new Set();
940
1164
  this.resolver = config.resolver;
941
- this.bindings = config.bindings ?? {};
942
1165
  this.authorities = new Set(config.authorities ?? []);
943
1166
  }
944
- // setBindings replaces the slot manifest. New subscriptions resolve through it;
945
- // callers that need already-open slot streams to re-resolve should re-subscribe
946
- // (the console keys the renderer on the manifest to do exactly that).
947
- setBindings(bindings) {
948
- this.bindings = bindings;
949
- }
950
1167
  // subscribeWidget binds a widget's datasource to a sink and returns a disposer.
951
1168
  // Selector resolution is async (anchor→devices); the disposer is returned
952
1169
  // synchronously and cancels a still-pending resolution, so tearing a widget down
@@ -1028,7 +1245,10 @@ var DashboardHub = class {
1028
1245
  }
1029
1246
  // resolveAlarmScope turns an alarm widget's scope selector into the originator device
1030
1247
  // tokens to filter on, or tenant-wide when it carries no datasource. Reuses the same
1031
- // device/anchor/slot resolution the measurement channel does.
1248
+ // device/anchor/unbound resolution the measurement channel does.
1249
+ //
1250
+ // 🔴 Only an ABSENT datasource is tenant-wide. `{kind:'unbound'}` is a datasource, and
1251
+ // it resolves to zero devices like any other empty scope.
1032
1252
  async resolveAlarmScope(datasource) {
1033
1253
  if (!datasource) return { tenantWide: true, tokens: [] };
1034
1254
  const groups = await this.resolveDevices(datasource);
@@ -1276,9 +1496,10 @@ var DashboardHub = class {
1276
1496
  return this.streams.size;
1277
1497
  }
1278
1498
  // isDatasourceAvailable reports whether a widget's bound device still exists. Only a
1279
- // device selector (or a slot bound to a device) is validated — an anchor, an unbound
1280
- // slot, or no datasource has a legitimate empty state and is always "available". Fails
1281
- // open: an existence-check outage returns true (never falsely mark a live device gone).
1499
+ // device selector is validated (a slot bound to a device arrives as one) — an anchor, an
1500
+ // unbound slot, or no datasource has a legitimate empty state and is always "available".
1501
+ // Fails open: an existence-check outage returns true (never falsely mark a live device
1502
+ // gone).
1282
1503
  async isDatasourceAvailable(datasource) {
1283
1504
  const deviceToken = this.availabilityToken(datasource);
1284
1505
  if (deviceToken === void 0) return true;
@@ -1291,14 +1512,10 @@ var DashboardHub = class {
1291
1512
  // availabilityToken returns the single device token whose existence gates a widget's
1292
1513
  // availability, or undefined when there is nothing device-specific to validate (an
1293
1514
  // anchor's membership is self-validating; an unbound slot is a placeholder; a reserved
1294
- // kind isn't resolved yet).
1515
+ // kind isn't resolved yet; an unresolved slot fails on its data channel instead).
1295
1516
  availabilityToken(datasource) {
1296
1517
  if (!datasource) return void 0;
1297
1518
  if (datasource.kind === "device") return datasource.deviceToken || void 0;
1298
- if (datasource.kind === "slot") {
1299
- const binding = Object.prototype.hasOwnProperty.call(this.bindings, datasource.slot) ? this.bindings[datasource.slot] : void 0;
1300
- return binding && binding.kind === "device" ? binding.deviceToken || void 0 : void 0;
1301
- }
1302
1519
  return void 0;
1303
1520
  }
1304
1521
  // resolveDevices turns a selector into the devices to stream, each with the
@@ -1316,11 +1533,12 @@ var DashboardHub = class {
1316
1533
  { kind: "anchor", anchor: datasource.anchor },
1317
1534
  new Set(datasource.measurements)
1318
1535
  );
1319
- case "slot": {
1320
- const binding = Object.prototype.hasOwnProperty.call(this.bindings, datasource.slot) ? this.bindings[datasource.slot] : void 0;
1321
- if (!binding) return [];
1322
- return this.resolveBinding(binding, new Set(datasource.measurements));
1323
- }
1536
+ case "unbound":
1537
+ return [];
1538
+ case "slot":
1539
+ throw new Error(
1540
+ `dashboard slot '${datasource.slot}' reached the data source unresolved; resolve it through the bindings first (resolveWidgetDatasource)`
1541
+ );
1324
1542
  default:
1325
1543
  throw new Error(
1326
1544
  `dashboard selector kind '${datasource.kind}' is not supported yet`
@@ -1640,6 +1858,853 @@ var SyntheticDataSource = class {
1640
1858
  }
1641
1859
  };
1642
1860
 
1861
+ // src/recording.ts
1862
+ var RECORDING_FORMAT_VERSION = 1;
1863
+ var RECORDING_KIND = "sitepulse-board-recording";
1864
+ var ALARM_STATES = ["ACTIVE", "CLEARED"];
1865
+ var ALARM_SEVERITIES = ["CRITICAL", "MAJOR", "MINOR", "WARNING"];
1866
+ var RecordingFormatError = class extends Error {
1867
+ constructor(path, reason) {
1868
+ super(`invalid recording at ${path}: ${reason}`);
1869
+ this.path = path;
1870
+ this.name = "RecordingFormatError";
1871
+ }
1872
+ };
1873
+ function recordingBounds(rec) {
1874
+ return rec.excerpt ? { ...rec.excerpt } : { fromMs: 0, toMs: rec.durationMs };
1875
+ }
1876
+ function recordedWallTimeMs(rec, tMs) {
1877
+ return Date.parse(rec.startedAtUtc) + tMs;
1878
+ }
1879
+ function simulationScaleAt(rec, tMs) {
1880
+ let hit = null;
1881
+ for (const seg of rec.clock) {
1882
+ if (seg.fromMs <= tMs) hit = seg;
1883
+ }
1884
+ return hit ? { mode: hit.mode, scale: hit.scale } : null;
1885
+ }
1886
+ function isObj(v) {
1887
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1888
+ }
1889
+ function strict(v, path, required, optional = []) {
1890
+ if (!isObj(v)) throw new RecordingFormatError(path, "expected an object");
1891
+ const allowed = /* @__PURE__ */ new Set([...required, ...optional]);
1892
+ for (const key of Object.keys(v)) {
1893
+ if (!allowed.has(key)) throw new RecordingFormatError(`${path}.${key}`, "unknown key");
1894
+ }
1895
+ for (const key of required) {
1896
+ if (!Object.prototype.hasOwnProperty.call(v, key)) {
1897
+ throw new RecordingFormatError(`${path}.${key}`, "missing required key");
1898
+ }
1899
+ }
1900
+ return v;
1901
+ }
1902
+ function str(v, path) {
1903
+ if (typeof v !== "string" || v === "") throw new RecordingFormatError(path, "expected a non-empty string");
1904
+ return v;
1905
+ }
1906
+ function bool(v, path) {
1907
+ if (typeof v !== "boolean") throw new RecordingFormatError(path, "expected a boolean");
1908
+ return v;
1909
+ }
1910
+ function num(v, path) {
1911
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new RecordingFormatError(path, "expected a finite number");
1912
+ return v;
1913
+ }
1914
+ function arr(v, path) {
1915
+ if (!Array.isArray(v)) throw new RecordingFormatError(path, "expected an array");
1916
+ return v;
1917
+ }
1918
+ function oneOf(v, path, allowed) {
1919
+ if (typeof v !== "string" || !allowed.includes(v)) {
1920
+ throw new RecordingFormatError(path, `expected one of ${allowed.join(", ")}`);
1921
+ }
1922
+ return v;
1923
+ }
1924
+ function matching(v, path, re, what) {
1925
+ if (typeof v !== "string" || !re.test(v)) throw new RecordingFormatError(path, `expected ${what}`);
1926
+ return v;
1927
+ }
1928
+ var HEX64 = /^[0-9a-f]{64}$/;
1929
+ var HEX40 = /^[0-9a-f]{40}$/;
1930
+ var HEX_SHORT = /^[0-9a-f]{7,40}$/;
1931
+ var INSTANT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?Z$/;
1932
+ function instant(v, path) {
1933
+ const s = str(v, path);
1934
+ const m = INSTANT.exec(s);
1935
+ if (m) {
1936
+ const [y, mo, d, h, mi, sec] = m.slice(1, 7).map(Number);
1937
+ const t = new Date(Date.UTC(y, mo - 1, d, h, mi, sec));
1938
+ if (t.getUTCFullYear() === y && t.getUTCMonth() === mo - 1 && t.getUTCDate() === d && t.getUTCHours() === h && t.getUTCMinutes() === mi && t.getUTCSeconds() === sec) {
1939
+ return s;
1940
+ }
1941
+ }
1942
+ throw new RecordingFormatError(path, "expected an RFC 3339 UTC instant ending in Z that exists on the calendar");
1943
+ }
1944
+ function compareInstants(a, b) {
1945
+ const pa = INSTANT.exec(a);
1946
+ const pb = INSTANT.exec(b);
1947
+ if (!pa || !pb) throw new RangeError(`compareInstants: not an instant: ${pa ? b : a}`);
1948
+ const whole = (m) => Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`);
1949
+ const wa = whole(pa);
1950
+ const wb = whole(pb);
1951
+ if (wa !== wb) return wa < wb ? -1 : 1;
1952
+ const fa = (pa[7] ?? "").padEnd(12, "0");
1953
+ const fb = (pb[7] ?? "").padEnd(12, "0");
1954
+ return fa < fb ? -1 : fa > fb ? 1 : 0;
1955
+ }
1956
+ function column(v, path, item, length) {
1957
+ const a = arr(v, path);
1958
+ if (length !== void 0 && a.length !== length) {
1959
+ throw new RecordingFormatError(path, `length ${a.length} does not match the time column (${length})`);
1960
+ }
1961
+ return a.map((x, i) => item(x, `${path}[${i}]`));
1962
+ }
1963
+ function nonNegative(v, path) {
1964
+ const n = num(v, path);
1965
+ if (n < 0) throw new RecordingFormatError(path, "must not be negative");
1966
+ return n;
1967
+ }
1968
+ function parseBoardRecording(input) {
1969
+ const root = strict(
1970
+ input,
1971
+ "$",
1972
+ [
1973
+ "kind",
1974
+ "formatVersion",
1975
+ "runId",
1976
+ "startedAtUtc",
1977
+ "durationMs",
1978
+ "platformVersion",
1979
+ "instance",
1980
+ "tenant",
1981
+ "build",
1982
+ "clock",
1983
+ "devices",
1984
+ "channels",
1985
+ "board",
1986
+ "sourceHashes",
1987
+ "converter",
1988
+ "chapters",
1989
+ "measurements",
1990
+ "alarms",
1991
+ "locations"
1992
+ ],
1993
+ ["excerpt"]
1994
+ );
1995
+ if (root.kind !== RECORDING_KIND) {
1996
+ throw new RecordingFormatError("$.kind", `expected ${JSON.stringify(RECORDING_KIND)}`);
1997
+ }
1998
+ if (root.formatVersion !== RECORDING_FORMAT_VERSION) {
1999
+ throw new RecordingFormatError(
2000
+ "$.formatVersion",
2001
+ `unsupported version ${JSON.stringify(root.formatVersion)}; this reader understands ${RECORDING_FORMAT_VERSION}`
2002
+ );
2003
+ }
2004
+ const durationMs = num(root.durationMs, "$.durationMs");
2005
+ if (durationMs <= 0) throw new RecordingFormatError("$.durationMs", "must be positive");
2006
+ const b = strict(root.build, "$.build", ["gitSha", "sdkCommit"]);
2007
+ const build = {
2008
+ gitSha: matching(b.gitSha, "$.build.gitSha", HEX_SHORT, "a lowercase hex commit id"),
2009
+ sdkCommit: matching(b.sdkCommit, "$.build.sdkCommit", HEX_SHORT, "a lowercase hex commit id")
2010
+ };
2011
+ const clock = column(root.clock, "$.clock", (x, p) => {
2012
+ const o = strict(x, p, ["fromMs", "mode", "scale"]);
2013
+ const scale = num(o.scale, `${p}.scale`);
2014
+ if (scale <= 0) throw new RecordingFormatError(`${p}.scale`, "must be positive");
2015
+ return { fromMs: nonNegative(o.fromMs, `${p}.fromMs`), mode: str(o.mode, `${p}.mode`), scale };
2016
+ });
2017
+ clock.forEach((seg, i) => {
2018
+ if (i > 0 && seg.fromMs <= clock[i - 1].fromMs) throw new RecordingFormatError(`$.clock[${i}].fromMs`, "must increase");
2019
+ });
2020
+ const devices = column(root.devices, "$.devices", (x, p) => {
2021
+ const o = strict(x, p, ["id", "token", "kind"]);
2022
+ return { id: str(o.id, `${p}.id`), token: str(o.token, `${p}.token`), kind: str(o.kind, `${p}.kind`) };
2023
+ });
2024
+ const tokens = new Set(devices.map((d) => d.token));
2025
+ if (tokens.size !== devices.length || new Set(devices.map((d) => d.id)).size !== devices.length) {
2026
+ throw new RecordingFormatError("$.devices", "duplicate device id or token");
2027
+ }
2028
+ const ch = strict(root.channels, "$.channels", ["measurements", "alarms", "locations", "commands"]);
2029
+ if (ch.locations !== false) {
2030
+ throw new RecordingFormatError("$.channels.locations", "this version records no positions; must be false");
2031
+ }
2032
+ if (ch.commands !== false) {
2033
+ throw new RecordingFormatError("$.channels.commands", "this version records no command data; must be false");
2034
+ }
2035
+ const channels = {
2036
+ measurements: column(ch.measurements, "$.channels.measurements", str),
2037
+ alarms: bool(ch.alarms, "$.channels.alarms"),
2038
+ locations: false,
2039
+ commands: false
2040
+ };
2041
+ if (new Set(channels.measurements).size !== channels.measurements.length) {
2042
+ throw new RecordingFormatError("$.channels.measurements", "duplicate measurement name");
2043
+ }
2044
+ const bd = strict(root.board, "$.board", ["path", "sourceCommit", "sha256"]);
2045
+ const board = {
2046
+ path: str(bd.path, "$.board.path"),
2047
+ sourceCommit: matching(bd.sourceCommit, "$.board.sourceCommit", HEX40, "a full lowercase hex commit id"),
2048
+ sha256: matching(bd.sha256, "$.board.sha256", HEX64, "a lowercase hex SHA-256")
2049
+ };
2050
+ const sh = root.sourceHashes;
2051
+ if (!isObj(sh)) throw new RecordingFormatError("$.sourceHashes", "expected an object");
2052
+ const sourceHashes = {};
2053
+ for (const key of Object.keys(sh)) {
2054
+ if (key === "__proto__") throw new RecordingFormatError(`$.sourceHashes.${key}`, "reserved key");
2055
+ sourceHashes[key] = matching(sh[key], `$.sourceHashes.${key}`, HEX64, "a lowercase hex SHA-256");
2056
+ }
2057
+ const cv = strict(root.converter, "$.converter", ["version"]);
2058
+ const converter = { version: num(cv.version, "$.converter.version") };
2059
+ let excerpt;
2060
+ if (root.excerpt !== void 0) {
2061
+ const e = strict(root.excerpt, "$.excerpt", ["fromMs", "toMs"]);
2062
+ excerpt = { fromMs: nonNegative(e.fromMs, "$.excerpt.fromMs"), toMs: nonNegative(e.toMs, "$.excerpt.toMs") };
2063
+ if (excerpt.fromMs >= excerpt.toMs || excerpt.toMs > durationMs) {
2064
+ throw new RecordingFormatError("$.excerpt", "must be a non-empty window inside the run");
2065
+ }
2066
+ }
2067
+ const lo = excerpt ? excerpt.fromMs : 0;
2068
+ const hi = excerpt ? excerpt.toMs : durationMs;
2069
+ const chapters = column(root.chapters, "$.chapters", (x, p) => {
2070
+ const o = strict(x, p, ["tMs", "name", "note"]);
2071
+ const tMs = num(o.tMs, `${p}.tMs`);
2072
+ if (tMs < 0 || tMs > durationMs) throw new RecordingFormatError(`${p}.tMs`, "is outside the run");
2073
+ if (excerpt && (tMs < lo || tMs > hi)) throw new RecordingFormatError(`${p}.tMs`, "is outside the excerpt window");
2074
+ return { tMs, name: str(o.name, `${p}.name`), note: str(o.note, `${p}.note`) };
2075
+ });
2076
+ chapters.forEach((c, i) => {
2077
+ if (i > 0 && c.tMs < chapters[i - 1].tMs) throw new RecordingFormatError(`$.chapters[${i}].tMs`, "chapters must not decrease in time");
2078
+ });
2079
+ const m = strict(root.measurements, "$.measurements", ["d", "n", "t", "v", "s"]);
2080
+ const t = column(m.t, "$.measurements.t", num);
2081
+ let prev = lo;
2082
+ t.forEach((x, i) => {
2083
+ if (x < lo || x > hi) throw new RecordingFormatError(`$.measurements.t[${i}]`, `${x} is outside [${lo}, ${hi}]`);
2084
+ if (x < prev) throw new RecordingFormatError(`$.measurements.t[${i}]`, "time column must not decrease");
2085
+ prev = x;
2086
+ });
2087
+ const index = (name, limit) => (x, p) => {
2088
+ const n = num(x, p);
2089
+ if (!Number.isInteger(n) || n < 0 || n >= limit) throw new RecordingFormatError(p, `${name} index out of range`);
2090
+ return n;
2091
+ };
2092
+ const measurements = {
2093
+ d: column(m.d, "$.measurements.d", index("device", devices.length), t.length),
2094
+ n: column(m.n, "$.measurements.n", index("measurement", channels.measurements.length), t.length),
2095
+ t,
2096
+ v: column(m.v, "$.measurements.v", num, t.length),
2097
+ s: column(
2098
+ m.s,
2099
+ "$.measurements.s",
2100
+ (x, p) => {
2101
+ if (x !== 0 && x !== 1) throw new RecordingFormatError(p, "expected 0 or 1");
2102
+ return x;
2103
+ },
2104
+ t.length
2105
+ )
2106
+ };
2107
+ const al = strict(root.alarms, "$.alarms", ["snapshots", "events"]);
2108
+ const alarm = (o, p) => {
2109
+ const id = matching(o.id, `${p}.id`, /^alarm-\d+$/, "a re-minted id (alarm-N)");
2110
+ const dev = str(o.dev, `${p}.dev`);
2111
+ if (!tokens.has(dev)) throw new RecordingFormatError(`${p}.dev`, `device '${dev}' is not in $.devices`);
2112
+ if (o.ack !== void 0 && o.ack !== true) throw new RecordingFormatError(`${p}.ack`, "present only when true");
2113
+ const out = {
2114
+ id,
2115
+ dev,
2116
+ key: str(o.key, `${p}.key`),
2117
+ metric: str(o.metric, `${p}.metric`),
2118
+ state: oneOf(o.state, `${p}.state`, ALARM_STATES),
2119
+ sev: oneOf(o.sev, `${p}.sev`, ALARM_SEVERITIES),
2120
+ occ: instant(o.occ, `${p}.occ`)
2121
+ };
2122
+ if (o.ack === true) out.ack = true;
2123
+ return out;
2124
+ };
2125
+ const ALARM_KEYS = ["id", "dev", "key", "metric", "state", "sev", "occ"];
2126
+ const checkTime = (tMs, p) => {
2127
+ if (tMs < 0 || tMs > hi) throw new RecordingFormatError(p, `${tMs} is outside [0, ${hi}]`);
2128
+ return tMs;
2129
+ };
2130
+ const snapshots = column(al.snapshots, "$.alarms.snapshots", (x, p) => {
2131
+ const o = strict(x, p, ["tMs", "requestedAtMs", "total", "alarms"]);
2132
+ const alarms = column(o.alarms, `${p}.alarms`, (a, ap) => alarm(strict(a, ap, ALARM_KEYS, ["ack"]), ap));
2133
+ const ids = /* @__PURE__ */ new Set();
2134
+ alarms.forEach((a, i) => {
2135
+ if (ids.has(a.id)) throw new RecordingFormatError(`${p}.alarms[${i}].id`, `duplicate alarm ${a.id} in one snapshot`);
2136
+ ids.add(a.id);
2137
+ });
2138
+ const total = nonNegative(o.total, `${p}.total`);
2139
+ if (total !== alarms.length) {
2140
+ throw new RecordingFormatError(`${p}.total`, `is ${total} but ${alarms.length} alarms are listed (truncated snapshot)`);
2141
+ }
2142
+ const tMs = checkTime(num(o.tMs, `${p}.tMs`), `${p}.tMs`);
2143
+ const requestedAtMs = nonNegative(o.requestedAtMs, `${p}.requestedAtMs`);
2144
+ if (!Number.isInteger(requestedAtMs)) throw new RecordingFormatError(`${p}.requestedAtMs`, "expected an integer");
2145
+ if (requestedAtMs > tMs) throw new RecordingFormatError(`${p}.requestedAtMs`, "a query cannot be sent after its answer");
2146
+ return { tMs, requestedAtMs, total, alarms };
2147
+ });
2148
+ const events = column(al.events, "$.alarms.events", (x, p) => {
2149
+ const o = strict(x, p, [...ALARM_KEYS, "tMs"], ["ack"]);
2150
+ return { ...alarm(o, p), tMs: checkTime(num(o.tMs, `${p}.tMs`), `${p}.tMs`) };
2151
+ });
2152
+ for (const [name, list] of [["snapshots", snapshots], ["events", events]]) {
2153
+ list.forEach((e, i) => {
2154
+ if (i > 0 && e.tMs < list[i - 1].tMs) throw new RecordingFormatError(`$.alarms.${name}[${i}].tMs`, "must not decrease");
2155
+ });
2156
+ }
2157
+ const deviceOf = /* @__PURE__ */ new Map();
2158
+ const everyAlarm = [
2159
+ ...snapshots.flatMap((s, i) => s.alarms.map((a, j) => [`$.alarms.snapshots[${i}].alarms[${j}]`, a])),
2160
+ ...events.map((e, i) => [`$.alarms.events[${i}]`, e])
2161
+ ];
2162
+ for (const [path, a] of everyAlarm) {
2163
+ const seen = deviceOf.get(a.id);
2164
+ if (seen !== void 0 && seen !== a.dev) {
2165
+ throw new RecordingFormatError(`${path}.dev`, `alarm ${a.id} was on '${seen}' and cannot move to '${a.dev}'`);
2166
+ }
2167
+ deviceOf.set(a.id, a.dev);
2168
+ }
2169
+ if (!channels.alarms && (snapshots.length > 0 || events.length > 0)) {
2170
+ throw new RecordingFormatError("$.alarms", "history present but $.channels.alarms is false");
2171
+ }
2172
+ if (!Array.isArray(root.locations) || root.locations.length !== 0) {
2173
+ throw new RecordingFormatError("$.locations", "this version carries no positions; must be an empty array");
2174
+ }
2175
+ const rec = {
2176
+ kind: RECORDING_KIND,
2177
+ formatVersion: RECORDING_FORMAT_VERSION,
2178
+ runId: str(root.runId, "$.runId"),
2179
+ startedAtUtc: instant(root.startedAtUtc, "$.startedAtUtc"),
2180
+ durationMs,
2181
+ platformVersion: str(root.platformVersion, "$.platformVersion"),
2182
+ instance: str(root.instance, "$.instance"),
2183
+ tenant: str(root.tenant, "$.tenant"),
2184
+ build,
2185
+ clock,
2186
+ devices,
2187
+ channels,
2188
+ board,
2189
+ sourceHashes,
2190
+ converter,
2191
+ chapters,
2192
+ measurements,
2193
+ alarms: { snapshots, events },
2194
+ locations: []
2195
+ };
2196
+ if (excerpt) rec.excerpt = excerpt;
2197
+ return rec;
2198
+ }
2199
+
2200
+ // src/recorded.ts
2201
+ var RECORDED_HISTORY_WINDOW_MS = 60 * 60 * 1e3;
2202
+ var NotInRecordingError = class extends Error {
2203
+ constructor(channel, detail = "") {
2204
+ super(`Not in this recording${detail ? `: ${detail}` : ""}`);
2205
+ this.name = "NotInRecordingError";
2206
+ this.channel = channel;
2207
+ this.detail = detail;
2208
+ }
2209
+ // The widget error frame prints String(error). The default would read
2210
+ // "NotInRecordingError: Not in this recording", which says the name twice; the message
2211
+ // alone is what a viewer should see.
2212
+ toString() {
2213
+ return this.message;
2214
+ }
2215
+ };
2216
+ var PLAYBACK_RATES = [1, 4, 16];
2217
+ function defaultTicker() {
2218
+ return {
2219
+ start(onFrame) {
2220
+ const now = () => typeof performance !== "undefined" ? performance.now() : Date.now();
2221
+ let last = now();
2222
+ const step = () => {
2223
+ const t = now();
2224
+ const dt = t - last;
2225
+ last = t;
2226
+ onFrame(dt);
2227
+ };
2228
+ if (typeof requestAnimationFrame === "function") {
2229
+ let handle = 0;
2230
+ let stopped = false;
2231
+ const loop = () => {
2232
+ if (stopped) return;
2233
+ step();
2234
+ handle = requestAnimationFrame(loop);
2235
+ };
2236
+ handle = requestAnimationFrame(loop);
2237
+ return () => {
2238
+ stopped = true;
2239
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(handle);
2240
+ };
2241
+ }
2242
+ const timer = setInterval(step, 100);
2243
+ return () => clearInterval(timer);
2244
+ }
2245
+ };
2246
+ }
2247
+ var RecordedClock = class {
2248
+ constructor(durationMs, options = {}) {
2249
+ this.isPlaying = false;
2250
+ this.playbackRate = 1;
2251
+ this.stopTicker = null;
2252
+ this.listeners = /* @__PURE__ */ new Set();
2253
+ if (!Number.isFinite(durationMs) || durationMs <= 0) {
2254
+ throw new RangeError(`RecordedClock: durationMs must be positive and finite, got ${durationMs}`);
2255
+ }
2256
+ this.durationMs = durationMs;
2257
+ this.minMs = options.minMs ?? 0;
2258
+ if (!Number.isFinite(this.minMs) || this.minMs < 0 || this.minMs >= durationMs) {
2259
+ throw new RangeError(`RecordedClock: minMs must be in [0, ${durationMs}), got ${this.minMs}`);
2260
+ }
2261
+ this.ticker = options.ticker ?? defaultTicker();
2262
+ this.time = this.clampTime(options.startMs ?? this.minMs);
2263
+ }
2264
+ get timeMs() {
2265
+ return this.time;
2266
+ }
2267
+ get playing() {
2268
+ return this.isPlaying;
2269
+ }
2270
+ get rate() {
2271
+ return this.playbackRate;
2272
+ }
2273
+ get ended() {
2274
+ return this.time >= this.durationMs;
2275
+ }
2276
+ // play starts the cursor moving. At the end of the run it does nothing: playback stops
2277
+ // there (no loop seam) and a host that wants to start over seeks to 0 first.
2278
+ play() {
2279
+ if (this.isPlaying || this.ended) return;
2280
+ this.isPlaying = true;
2281
+ this.stopTicker = this.ticker.start((dt) => this.advance(dt));
2282
+ this.emit("state");
2283
+ }
2284
+ pause() {
2285
+ if (!this.isPlaying) return;
2286
+ this.halt();
2287
+ this.emit("state");
2288
+ }
2289
+ // seek moves the cursor, clamped to [minMs, durationMs]. Playback state is unchanged, except
2290
+ // that seeking to the very end stops it (there is nothing left to play).
2291
+ seek(tMs) {
2292
+ if (!Number.isFinite(tMs)) throw new RangeError(`RecordedClock.seek: not a finite time: ${tMs}`);
2293
+ this.time = this.clampTime(tMs);
2294
+ if (this.isPlaying && this.ended) this.halt();
2295
+ this.emit("seek");
2296
+ }
2297
+ setRate(rate) {
2298
+ if (!PLAYBACK_RATES.includes(rate)) {
2299
+ throw new RangeError(`RecordedClock.setRate: ${rate} is not one of ${PLAYBACK_RATES.join(", ")}`);
2300
+ }
2301
+ this.playbackRate = rate;
2302
+ this.emit("state");
2303
+ }
2304
+ // advance moves the cursor by wallDeltaMs of real time (scaled by the playback rate).
2305
+ // The ticker calls this each frame; a test calls it directly. It does nothing while
2306
+ // paused, and stops at the end of the run.
2307
+ advance(wallDeltaMs) {
2308
+ if (!this.isPlaying) return;
2309
+ if (!Number.isFinite(wallDeltaMs) || wallDeltaMs < 0) {
2310
+ throw new RangeError(`RecordedClock.advance: expected a non-negative finite delta, got ${wallDeltaMs}`);
2311
+ }
2312
+ this.time = this.clampTime(this.time + wallDeltaMs * this.playbackRate);
2313
+ if (this.ended) this.halt();
2314
+ this.emit("tick");
2315
+ }
2316
+ subscribe(listener) {
2317
+ this.listeners.add(listener);
2318
+ return () => {
2319
+ this.listeners.delete(listener);
2320
+ };
2321
+ }
2322
+ halt() {
2323
+ this.isPlaying = false;
2324
+ this.stopTicker?.();
2325
+ this.stopTicker = null;
2326
+ }
2327
+ clampTime(t) {
2328
+ return t < this.minMs ? this.minMs : t > this.durationMs ? this.durationMs : t;
2329
+ }
2330
+ emit(reason) {
2331
+ for (const listener of [...this.listeners]) listener({ timeMs: this.time, reason });
2332
+ }
2333
+ };
2334
+ function lowerBound(a, x) {
2335
+ let lo = 0;
2336
+ let hi = a.length;
2337
+ while (lo < hi) {
2338
+ const mid = lo + hi >>> 1;
2339
+ if (a[mid] < x) lo = mid + 1;
2340
+ else hi = mid;
2341
+ }
2342
+ return lo;
2343
+ }
2344
+ function upperBound(a, x) {
2345
+ let lo = 0;
2346
+ let hi = a.length;
2347
+ while (lo < hi) {
2348
+ const mid = lo + hi >>> 1;
2349
+ if (a[mid] <= x) lo = mid + 1;
2350
+ else hi = mid;
2351
+ }
2352
+ return lo;
2353
+ }
2354
+ function createRecordedClock(rec, options = {}) {
2355
+ const { fromMs, toMs } = recordingBounds(rec);
2356
+ return new RecordedClock(toMs, { minMs: fromMs, ticker: options.ticker });
2357
+ }
2358
+ function sameAnchor(a, b) {
2359
+ return a.relationship === b.relationship && a.targetType === b.targetType && a.targetToken === b.targetToken;
2360
+ }
2361
+ function declaredSite(options) {
2362
+ const anchors = options.siteAnchors;
2363
+ if (anchors === void 0) return null;
2364
+ if (anchors.length !== 1) {
2365
+ throw new Error(`RecordedSiteOptions.siteAnchors must declare exactly one anchor, got ${anchors.length}`);
2366
+ }
2367
+ if (anchors[0].targetType !== "customer") {
2368
+ throw new Error(
2369
+ `RecordedSiteOptions.siteAnchors must be a customer anchor, got '${anchors[0].targetType}': a recording cannot say that every device belongs to an area or an asset`
2370
+ );
2371
+ }
2372
+ return anchors[0];
2373
+ }
2374
+ function anchorMembers(rec, site, anchor) {
2375
+ if (!site || !sameAnchor(site, anchor)) {
2376
+ throw new Error(
2377
+ `anchor ${anchor.targetType} '${anchor.targetToken}' (${anchor.relationship}) is not in this recording`
2378
+ );
2379
+ }
2380
+ return rec.devices.map((d) => d.token);
2381
+ }
2382
+ function createRecordedResolver(rec, options = {}) {
2383
+ const devices = new Set(rec.devices.map((d) => d.token));
2384
+ const site = declaredSite(options);
2385
+ return {
2386
+ devicesForAnchor: async (anchor) => anchorMembers(rec, site, anchor),
2387
+ deviceExists: async (token) => devices.has(token)
2388
+ };
2389
+ }
2390
+ function createRecordedLister(rec, options = {}) {
2391
+ const site = declaredSite(options);
2392
+ return async (kind) => {
2393
+ if (kind === "device") return rec.devices.map((d) => ({ token: d.token, name: null }));
2394
+ return site && site.targetType === kind ? [{ token: site.targetToken, name: null }] : [];
2395
+ };
2396
+ }
2397
+ var indexCache = /* @__PURE__ */ new WeakMap();
2398
+ function indexOf(rec) {
2399
+ const cached = indexCache.get(rec);
2400
+ if (cached) return cached;
2401
+ const bySeries = /* @__PURE__ */ new Map();
2402
+ const byDevice = /* @__PURE__ */ new Map();
2403
+ const { d, n, t } = rec.measurements;
2404
+ for (let i = 0; i < t.length; i++) {
2405
+ const key = d[i] * rec.channels.measurements.length + n[i];
2406
+ let s = bySeries.get(String(key));
2407
+ if (!s) {
2408
+ s = { device: rec.devices[d[i]].token, name: rec.channels.measurements[n[i]], rows: [], times: [] };
2409
+ bySeries.set(String(key), s);
2410
+ const list = byDevice.get(s.device);
2411
+ if (list) list.push(s);
2412
+ else byDevice.set(s.device, [s]);
2413
+ }
2414
+ s.rows.push(i);
2415
+ s.times.push(t[i]);
2416
+ }
2417
+ const timeline = [
2418
+ ...rec.alarms.snapshots.map((snapshot) => ({ tMs: snapshot.tMs, snapshot })),
2419
+ ...rec.alarms.events.map((event) => ({ tMs: event.tMs, event }))
2420
+ ];
2421
+ timeline.sort((a, b) => a.tMs - b.tMs);
2422
+ const indexed = { byDevice, timeline, timelineTimes: timeline.map((e) => e.tMs) };
2423
+ indexCache.set(rec, indexed);
2424
+ return indexed;
2425
+ }
2426
+ var RecordedDataSource = class {
2427
+ // `clock` is the shared replay clock; the view starts at its current time. `options`
2428
+ // names the site anchors this recording's devices belong to (see RecordedSiteOptions).
2429
+ constructor(rec, clock, options = {}) {
2430
+ this.subs = /* @__PURE__ */ new Set();
2431
+ this.stale = false;
2432
+ this.rec = rec;
2433
+ this.site = declaredSite(options);
2434
+ this.index = indexOf(rec);
2435
+ this.cursor = clock.timeMs;
2436
+ this.startMs = Date.parse(rec.startedAtUtc);
2437
+ this.devices = new Set(rec.devices.map((d) => d.token));
2438
+ this.unlisten = clock.subscribe((ev) => this.onClock(ev));
2439
+ }
2440
+ // True once the clock has been seeked since this view was built. A stale view emits
2441
+ // nothing further; the host builds a new view at the new cursor.
2442
+ get isStale() {
2443
+ return this.stale;
2444
+ }
2445
+ onClock(ev) {
2446
+ if (this.stale) return;
2447
+ if (ev.reason === "seek") {
2448
+ this.stale = true;
2449
+ this.unlisten();
2450
+ return;
2451
+ }
2452
+ if (ev.reason !== "tick") return;
2453
+ this.cursor = ev.timeMs;
2454
+ for (const sub of [...this.subs]) sub.advance(this.cursor);
2455
+ }
2456
+ iso(offsetMs) {
2457
+ return new Date(this.startMs + offsetMs).toISOString();
2458
+ }
2459
+ // open tracks a subscription and returns its disposer. `start` runs once, in a
2460
+ // microtask, so the disposer is in the caller's hands before anything is emitted (the
2461
+ // contract every source keeps: a synchronous disposer, and nothing emitted after it).
2462
+ // `advance` runs on each forward tick after that.
2463
+ open(handlers) {
2464
+ let disposed = false;
2465
+ let started = false;
2466
+ const isDisposed = () => disposed;
2467
+ const sub = {
2468
+ advance: (cursor) => {
2469
+ if (started && !disposed) handlers.advance(cursor, isDisposed);
2470
+ },
2471
+ dispose: () => {
2472
+ disposed = true;
2473
+ this.subs.delete(sub);
2474
+ }
2475
+ };
2476
+ this.subs.add(sub);
2477
+ queueMicrotask(() => {
2478
+ if (disposed || this.stale) return;
2479
+ started = true;
2480
+ handlers.start(this.cursor, isDisposed);
2481
+ });
2482
+ return sub.dispose;
2483
+ }
2484
+ // Resolve a selector to the devices it names, synchronously and from the recording.
2485
+ // Mirrors the hub: unbound is zero devices, an unresolved slot is a loud error.
2486
+ resolve(ds) {
2487
+ switch (ds.kind) {
2488
+ case "device":
2489
+ return [{ deviceToken: ds.deviceToken, names: new Set(ds.measurements) }];
2490
+ case "anchor":
2491
+ return anchorMembers(this.rec, this.site, ds.anchor).map((deviceToken) => ({
2492
+ deviceToken,
2493
+ names: new Set(ds.measurements)
2494
+ }));
2495
+ case "unbound":
2496
+ return [];
2497
+ case "slot":
2498
+ throw new Error(
2499
+ `dashboard slot '${ds.slot}' reached the data source unresolved; resolve it through the bindings first (resolveWidgetDatasource)`
2500
+ );
2501
+ default:
2502
+ throw new Error(`dashboard selector kind '${ds.kind}' is not supported yet`);
2503
+ }
2504
+ }
2505
+ // Report a failure on a sink, asynchronously and only if the subscription is still live.
2506
+ failLater(err, sink) {
2507
+ let disposed = false;
2508
+ queueMicrotask(() => {
2509
+ if (!disposed) sink.error?.(err);
2510
+ });
2511
+ return () => {
2512
+ disposed = true;
2513
+ };
2514
+ }
2515
+ // ── measurements ──────────────────────────────────────────────────────────
2516
+ subscribeWidget(datasource, sink) {
2517
+ let groups;
2518
+ try {
2519
+ groups = this.resolve(datasource);
2520
+ for (const g of groups) {
2521
+ for (const name of g.names) {
2522
+ if (!this.rec.channels.measurements.includes(name)) {
2523
+ throw new NotInRecordingError("measurements", `no '${name}' series was recorded`);
2524
+ }
2525
+ }
2526
+ }
2527
+ } catch (err) {
2528
+ return this.failLater(err, sink);
2529
+ }
2530
+ const picked = [];
2531
+ for (const g of groups) {
2532
+ for (const s of this.index.byDevice.get(g.deviceToken) ?? []) {
2533
+ if (g.names.size === 0 || g.names.has(s.name)) picked.push({ s, next: 0 });
2534
+ }
2535
+ }
2536
+ const { t, v } = this.rec.measurements;
2537
+ const flush = (cursor, isDisposed) => {
2538
+ const due = [];
2539
+ for (const p of picked) {
2540
+ const { s } = p;
2541
+ while (p.next < s.times.length && s.times[p.next] <= cursor) {
2542
+ const row = s.rows[p.next++];
2543
+ due.push({
2544
+ t: t[row],
2545
+ sample: {
2546
+ id: `${s.device}|${s.name}|${row}`,
2547
+ deviceToken: s.device,
2548
+ eventType: 0,
2549
+ // The time the viewer applied the row: the recording holds no separate
2550
+ // occurred time for measurements.
2551
+ occurredTime: this.iso(t[row]),
2552
+ name: s.name,
2553
+ value: v[row],
2554
+ classifier: null
2555
+ }
2556
+ });
2557
+ }
2558
+ }
2559
+ due.sort((a, b) => a.t - b.t);
2560
+ for (const d of due) {
2561
+ if (isDisposed()) return;
2562
+ sink.next(d.sample);
2563
+ }
2564
+ };
2565
+ return this.open({
2566
+ // Back-fill: rows from the preceding window up to the cursor, then play on.
2567
+ start: (cursor, isDisposed) => {
2568
+ for (const p of picked) p.next = lowerBound(p.s.times, cursor - RECORDED_HISTORY_WINDOW_MS);
2569
+ flush(cursor, isDisposed);
2570
+ },
2571
+ advance: flush
2572
+ });
2573
+ }
2574
+ // ── alarms ────────────────────────────────────────────────────────────────
2575
+ subscribeAlarms(subscription, sink) {
2576
+ let scope = null;
2577
+ try {
2578
+ if (!this.rec.channels.alarms) throw new NotInRecordingError("alarms");
2579
+ if (subscription.datasource) {
2580
+ scope = new Set(this.resolve(subscription.datasource).map((g) => g.deviceToken));
2581
+ }
2582
+ } catch (err) {
2583
+ return this.failLater(err, sink);
2584
+ }
2585
+ const { timeline, timelineTimes } = this.index;
2586
+ const alarms = /* @__PURE__ */ new Map();
2587
+ let applied = 0;
2588
+ let lastSignature = null;
2589
+ const apply = (a, asOfMs) => {
2590
+ const prior = alarms.get(a.id);
2591
+ alarms.set(a.id, {
2592
+ id: a.id,
2593
+ dev: a.dev,
2594
+ key: a.key,
2595
+ metric: a.metric,
2596
+ state: a.state,
2597
+ sev: a.sev,
2598
+ // `occ` is the time of THIS state: the raise time for an ACTIVE row, the clear time
2599
+ // for a CLEARED one. A cleared alarm keeps the raise time its ACTIVE row gave it
2600
+ // (null if the history never showed it being raised).
2601
+ raised: a.state === "ACTIVE" ? a.occ : prior?.raised ?? null,
2602
+ cleared: a.state === "CLEARED" ? a.occ : null,
2603
+ // Each record's own `ack` decides; acknowledgement is not carried over from an
2604
+ // earlier record of the same alarm.
2605
+ ack: a.ack === true,
2606
+ asOfMs
2607
+ });
2608
+ };
2609
+ const emit = (cursor, isDisposed) => {
2610
+ const upTo = upperBound(timelineTimes, cursor);
2611
+ for (; applied < upTo; applied++) {
2612
+ const entry = timeline[applied];
2613
+ if ("snapshot" in entry) {
2614
+ const { requestedAtMs } = entry.snapshot;
2615
+ const newer = [...alarms.values()].filter((a) => a.asOfMs > requestedAtMs);
2616
+ alarms.clear();
2617
+ for (const a of entry.snapshot.alarms) apply(a, requestedAtMs);
2618
+ for (const a of newer) alarms.set(a.id, a);
2619
+ } else {
2620
+ apply(entry.event, entry.event.tMs);
2621
+ }
2622
+ }
2623
+ const matches = [...alarms.values()].filter(
2624
+ (a) => (scope === null || scope.has(a.dev)) && (!subscription.state || a.state === subscription.state) && (!subscription.severity || a.sev === subscription.severity) && (subscription.acknowledged == null || a.ack === subscription.acknowledged)
2625
+ );
2626
+ const when = (a) => a.raised ?? a.cleared;
2627
+ const byTime = (a, b) => {
2628
+ const wa = when(a);
2629
+ const wb = when(b);
2630
+ if (wa === null || wb === null) return wa === wb ? 0 : wa === null ? 1 : -1;
2631
+ return compareInstants(wb, wa);
2632
+ };
2633
+ matches.sort((a, b) => byTime(a, b) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
2634
+ const page = matches.slice(0, subscription.pageSize);
2635
+ const signature = JSON.stringify([matches.length, page]);
2636
+ if (signature === lastSignature || isDisposed()) return;
2637
+ lastSignature = signature;
2638
+ const snapshot = { alarms: page.map(toAlarmRow), total: matches.length };
2639
+ sink.next(snapshot);
2640
+ };
2641
+ return this.open({ start: emit, advance: emit });
2642
+ }
2643
+ // ── commands ──────────────────────────────────────────────────────────────
2644
+ // The recording holds no command data, so this channel is never available.
2645
+ subscribeCommands(_subscription, sink) {
2646
+ return this.failLater(new NotInRecordingError("commands"), sink);
2647
+ }
2648
+ // ── locations ─────────────────────────────────────────────────────────────
2649
+ // This format version records no positions. A selector that names no location series
2650
+ // asks for nothing, and one that resolves to no device has nothing to place: both get the
2651
+ // empty snapshot, as from the hub. Naming a location series for devices that exist is a
2652
+ // request for data the recording does not have, which is an error, never an empty map.
2653
+ subscribeLocations(subscription, sink) {
2654
+ const ds = subscription.datasource;
2655
+ let tokens = [];
2656
+ if (ds?.location) {
2657
+ try {
2658
+ tokens = this.resolve(ds).map((g) => g.deviceToken);
2659
+ } catch (err) {
2660
+ return this.failLater(err, sink);
2661
+ }
2662
+ if (tokens.length > 0) {
2663
+ return this.failLater(new NotInRecordingError("locations", "no positions were recorded"), sink);
2664
+ }
2665
+ }
2666
+ return this.open({
2667
+ start: (_cursor, isDisposed) => {
2668
+ if (!isDisposed()) sink.next({ kind: "positions", deviceTokens: [], locations: [] });
2669
+ },
2670
+ advance: () => {
2671
+ }
2672
+ });
2673
+ }
2674
+ // ── availability ──────────────────────────────────────────────────────────
2675
+ // A device selector is available iff the run has that device. Anchors, unbound slots and
2676
+ // no datasource have a legitimate empty state and are available, as on the hub.
2677
+ async isDatasourceAvailable(datasource) {
2678
+ if (datasource?.kind === "device" && datasource.deviceToken) return this.devices.has(datasource.deviceToken);
2679
+ return true;
2680
+ }
2681
+ // Stop every subscription and leave the clock. Call when the view is replaced.
2682
+ disposeAll() {
2683
+ this.unlisten();
2684
+ for (const sub of [...this.subs]) sub.dispose();
2685
+ }
2686
+ };
2687
+ function toAlarmRow(a) {
2688
+ return {
2689
+ token: a.id,
2690
+ originatorType: "device",
2691
+ originatorToken: a.dev,
2692
+ alarmKey: a.key,
2693
+ metricKey: a.metric,
2694
+ state: a.state,
2695
+ acknowledged: a.ack,
2696
+ severity: a.sev,
2697
+ raisedTime: a.raised,
2698
+ clearedTime: a.cleared,
2699
+ // The recording notes THAT an alarm was acknowledged, not when or by whom.
2700
+ acknowledgedTime: null,
2701
+ acknowledgedBy: null,
2702
+ // The stored alarm's last value is not recorded, and a nearby measurement would
2703
+ // invent the integrator's state. Null renders as an em dash.
2704
+ lastValue: null
2705
+ };
2706
+ }
2707
+
1643
2708
  // src/editor-model.ts
1644
2709
  function baseBox(widget) {
1645
2710
  return widget.layout[BASE_BREAKPOINT];
@@ -1687,27 +2752,21 @@ function setCanvasSizing(def, sizing) {
1687
2752
  function updateWidget(def, id, next) {
1688
2753
  return { ...def, widgets: def.widgets.map((w) => w.id === id ? next : w) };
1689
2754
  }
1690
- function humanizeType(type) {
1691
- const words = type.replace(/-/g, " ");
1692
- return words.charAt(0).toUpperCase() + words.slice(1);
1693
- }
1694
- function defaultOptions(type) {
1695
- if (type === "label") return { text: "New label" };
1696
- if (type === "alarm-table" || type === "alarm-count") {
1697
- return { title: humanizeType(type), state: "ACTIVE" };
1698
- }
1699
- return { title: humanizeType(type) };
1700
- }
1701
- function addWidget(def, type) {
2755
+ function addWidget(def, descriptor) {
1702
2756
  const maxZ = def.widgets.reduce((m, w) => Math.max(m, baseBox(w).z), 0);
1703
2757
  const id = generateWidgetId();
1704
2758
  const box = { col: 0, colSpan: 8, row: 0, rowSpan: 4, z: maxZ + 1 };
1705
- const widget = {
2759
+ const common = {
1706
2760
  id,
1707
- type,
2761
+ widget: {
2762
+ version: descriptor.version,
2763
+ optionsVersion: descriptor.optionsVersion,
2764
+ sdkApiVersion: descriptor.sdkApiVersion
2765
+ },
1708
2766
  layout: { [BASE_BREAKPOINT]: box },
1709
- options: defaultOptions(type)
2767
+ options: { ...descriptor.defaults }
1710
2768
  };
2769
+ const widget = { ...common, type: descriptor.id };
1711
2770
  return { definition: { ...def, widgets: [...def.widgets, widget] }, id };
1712
2771
  }
1713
2772
  function gridBoxToPx(box, geom) {
@@ -1810,8 +2869,8 @@ function defaultHistoryWindow() {
1810
2869
  intervalSeconds: 60
1811
2870
  };
1812
2871
  }
1813
- async function fetchWidgetHistory(widget, window, bindings) {
1814
- const ds = resolveHistorySelector(widget.datasource, bindings);
2872
+ async function fetchWidgetHistory(widget, window) {
2873
+ const ds = widget.datasource;
1815
2874
  if (!ds || ds.kind !== "device") return [];
1816
2875
  try {
1817
2876
  const deviceToken = ds.deviceToken;
@@ -1842,57 +2901,613 @@ async function fetchWidgetHistory(widget, window, bindings) {
1842
2901
  return [];
1843
2902
  }
1844
2903
  }
1845
- function resolveHistorySelector(ds, bindings) {
1846
- if (!ds || ds.kind !== "slot") return ds;
1847
- const binding = bindings && Object.prototype.hasOwnProperty.call(bindings, ds.slot) ? bindings[ds.slot] : void 0;
1848
- if (binding?.kind === "device") {
1849
- return { kind: "device", deviceToken: binding.deviceToken, measurements: ds.measurements };
2904
+
2905
+ // src/option-schema.ts
2906
+ var HOST_OPTION_CONTROLS = ["command-vocabulary", "slot-target", "basemap"];
2907
+ function hasOwn(obj, key) {
2908
+ return Object.prototype.hasOwnProperty.call(obj, key);
2909
+ }
2910
+ function validateOptions(specs, options, context) {
2911
+ const { label: widgetId } = context;
2912
+ const issues = [];
2913
+ const bag = options ?? {};
2914
+ for (const [key, spec] of Object.entries(specs)) {
2915
+ const value = bag[key];
2916
+ if (value === void 0 || spec.required && value === "") {
2917
+ if (spec.required) {
2918
+ issues.push({ widgetId, key, code: "missing", message: `${key} is required but not set` });
2919
+ }
2920
+ continue;
2921
+ }
2922
+ issues.push(...checkValue(widgetId, key, spec, value));
2923
+ }
2924
+ for (const key of Object.keys(bag)) {
2925
+ if (hasOwn(specs, key)) continue;
2926
+ issues.push({
2927
+ widgetId,
2928
+ key,
2929
+ code: "unknown",
2930
+ message: `${key} is not read by a ${widgetId} widget`
2931
+ });
1850
2932
  }
1851
- if (binding?.kind === "anchor") {
1852
- return { kind: "anchor", anchor: binding.anchor, measurements: ds.measurements };
2933
+ for (const constraint of context.constraints ?? []) {
2934
+ const issue = checkConstraint(constraint, bag);
2935
+ if (issue) issues.push({ widgetId, ...issue });
2936
+ }
2937
+ return issues;
2938
+ }
2939
+ function checkConstraint(constraint, bag) {
2940
+ switch (constraint.kind) {
2941
+ case "greater-than": {
2942
+ const value = bag[constraint.key];
2943
+ const than = bag[constraint.than];
2944
+ if (typeof value !== "number" || typeof than !== "number") return void 0;
2945
+ if (!Number.isFinite(value) || !Number.isFinite(than)) return void 0;
2946
+ if (value > than) return void 0;
2947
+ return {
2948
+ key: constraint.key,
2949
+ code: "invariant",
2950
+ message: `${constraint.key} (${value}) must be greater than ${constraint.than} (${than})`
2951
+ };
2952
+ }
1853
2953
  }
1854
- return void 0;
2954
+ }
2955
+ function checkValue(widgetId, key, spec, value) {
2956
+ const issue = (code, message) => [{ widgetId, key, code, message }];
2957
+ switch (spec.kind) {
2958
+ case "string":
2959
+ if (typeof value !== "string") {
2960
+ return issue("type", `${key} must be a string, got ${describe(value)}`);
2961
+ }
2962
+ if (spec.format === "json" && !parsesAsJson(value)) {
2963
+ return issue("json", `${key} must be valid JSON`);
2964
+ }
2965
+ return [];
2966
+ case "number":
2967
+ if (typeof value !== "number" || !Number.isFinite(value)) {
2968
+ return issue("type", `${key} must be a finite number, got ${describe(value)}`);
2969
+ }
2970
+ if (spec.integer && !Number.isInteger(value)) {
2971
+ return issue("range", `${key} must be a whole number, got ${value}`);
2972
+ }
2973
+ if (spec.min !== void 0 && value < spec.min) {
2974
+ return issue("range", `${key} must be at least ${spec.min}, got ${value}`);
2975
+ }
2976
+ if (spec.max !== void 0 && value > spec.max) {
2977
+ return issue("range", `${key} must be at most ${spec.max}, got ${value}`);
2978
+ }
2979
+ return [];
2980
+ case "boolean":
2981
+ if (typeof value !== "boolean") {
2982
+ return issue("type", `${key} must be a boolean, got ${describe(value)}`);
2983
+ }
2984
+ return [];
2985
+ case "enum":
2986
+ if (typeof value !== "string") {
2987
+ return issue("type", `${key} must be a string, got ${describe(value)}`);
2988
+ }
2989
+ if (!spec.values.includes(value)) {
2990
+ return issue("enum", `${key} must be one of ${spec.values.join(", ")}, got ${JSON.stringify(value)}`);
2991
+ }
2992
+ return [];
2993
+ }
2994
+ }
2995
+ function parsesAsJson(value) {
2996
+ try {
2997
+ JSON.parse(value);
2998
+ return true;
2999
+ } catch {
3000
+ return false;
3001
+ }
3002
+ }
3003
+ function describe(value) {
3004
+ if (value === null) return "null";
3005
+ if (typeof value === "string") return `string ${JSON.stringify(value)}`;
3006
+ if (Array.isArray(value)) return "an array";
3007
+ return `${typeof value} ${JSON.stringify(value)}`;
3008
+ }
3009
+ function numberSpec(specs, key) {
3010
+ if (!hasOwn(specs, key)) return void 0;
3011
+ const spec = specs[key];
3012
+ return spec.kind === "number" ? spec : void 0;
3013
+ }
3014
+ function clampNumberOption(spec, value) {
3015
+ if (!Number.isFinite(value)) return void 0;
3016
+ if (!spec) return value;
3017
+ let next = spec.integer ? Math.trunc(value) : value;
3018
+ if (spec.min !== void 0) next = Math.max(spec.min, next);
3019
+ if (spec.max !== void 0) next = Math.min(spec.max, next);
3020
+ return next;
3021
+ }
3022
+ function enumValues(specs, key) {
3023
+ if (!hasOwn(specs, key)) return void 0;
3024
+ const spec = specs[key];
3025
+ return spec.kind === "enum" ? spec.values : void 0;
3026
+ }
3027
+ function applyOptionPatch(specs, options, patch, label = "widget") {
3028
+ const next = { ...options ?? {} };
3029
+ for (const [key, value] of Object.entries(patch)) {
3030
+ if (!hasOwn(specs, key)) throw new Error(`${label}: "${key}" is not a declared option`);
3031
+ const spec = specs[key];
3032
+ if (value === void 0 || value === "" || spec.kind === "boolean" && value === false) {
3033
+ delete next[key];
3034
+ continue;
3035
+ }
3036
+ const refuse = (expected) => {
3037
+ throw new TypeError(`${label}: option "${key}" takes ${expected}, got ${JSON.stringify(value)}`);
3038
+ };
3039
+ switch (spec.kind) {
3040
+ case "string":
3041
+ if (typeof value !== "string") refuse("a string");
3042
+ if (spec.format === "json" && !parsesAsJson(value)) refuse("a JSON string");
3043
+ next[key] = value;
3044
+ break;
3045
+ case "boolean":
3046
+ if (typeof value !== "boolean") refuse("a boolean");
3047
+ next[key] = value;
3048
+ break;
3049
+ case "number": {
3050
+ if (typeof value !== "number") refuse("a finite number");
3051
+ const clamped = clampNumberOption(spec, value);
3052
+ if (clamped === void 0) refuse("a finite number");
3053
+ next[key] = clamped;
3054
+ break;
3055
+ }
3056
+ case "enum":
3057
+ if (typeof value !== "string" || !spec.values.includes(value)) {
3058
+ refuse(`one of ${spec.values.map((v) => JSON.stringify(v)).join(", ")}`);
3059
+ }
3060
+ next[key] = value;
3061
+ break;
3062
+ }
3063
+ }
3064
+ return next;
3065
+ }
3066
+
3067
+ // src/widget-descriptor.ts
3068
+ var BUILTIN_IDS = new Set(WIDGET_TYPES);
3069
+ function isBuiltinWidgetId(id) {
3070
+ return typeof id === "string" && BUILTIN_IDS.has(id);
3071
+ }
3072
+ var NAMESPACED_ID = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
3073
+ function isNamespacedWidgetId(id) {
3074
+ return typeof id === "string" && NAMESPACED_ID.test(id);
3075
+ }
3076
+ var SDK_API_VERSION = 1;
3077
+ function defineWidgetDescriptor(descriptor) {
3078
+ return descriptor;
3079
+ }
3080
+
3081
+ // src/widget-catalog.ts
3082
+ var WidgetCatalogError = class extends Error {
3083
+ constructor(issues, subject = "widget catalog") {
3084
+ super(describeIssues(subject, issues));
3085
+ this.name = "WidgetCatalogError";
3086
+ this.issues = Object.freeze([...issues]);
3087
+ }
3088
+ };
3089
+ function describeIssues(subject, issues) {
3090
+ const lines = issues.map((i) => ` - ${i.id}${i.path ? `.${i.path}` : ""}: ${i.message}`);
3091
+ return `invalid ${subject} (${issues.length} problem${issues.length === 1 ? "" : "s"}):
3092
+ ${lines.join("\n")}`;
3093
+ }
3094
+ var CHANNELS = ["measurement", "alarm", "control", "selection", "location", "none"];
3095
+ var SEMVER2 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
3096
+ var SELECTOR_KINDS = ["device", "anchor", "slot"];
3097
+ var BINDING_KEYS = {
3098
+ measurement: ["window"],
3099
+ alarm: ["pageSize", "state", "severity", "acknowledged"],
3100
+ control: ["pageSize"],
3101
+ location: [],
3102
+ selection: [],
3103
+ none: []
3104
+ };
3105
+ function isRecord3(value) {
3106
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3107
+ }
3108
+ function isPlainObject(value) {
3109
+ if (!isRecord3(value)) return false;
3110
+ const proto = Object.getPrototypeOf(value);
3111
+ return proto === Object.prototype || proto === null;
3112
+ }
3113
+ var join = (base, key) => base ? `${base}.${key}` : key;
3114
+ function validateWidgetDescriptor(descriptor) {
3115
+ const rawId = isRecord3(descriptor) && typeof descriptor.id === "string" ? descriptor.id : "";
3116
+ return collectDescriptorIssues(descriptor, rawId || "#?");
3117
+ }
3118
+ function collectDescriptorIssues(d, id) {
3119
+ const issues = [];
3120
+ const add = (path, message) => {
3121
+ issues.push({ id, path, message });
3122
+ };
3123
+ if (!isPlainObject(d)) {
3124
+ add("", "a descriptor must be a plain object");
3125
+ return issues;
3126
+ }
3127
+ jsonOnly(d, "", add, /* @__PURE__ */ new Set());
3128
+ if (typeof d.id !== "string" || d.id === "") {
3129
+ add("id", "id must be a non-empty string");
3130
+ } else if (!isBuiltinWidgetId(d.id) && !isNamespacedWidgetId(d.id)) {
3131
+ add("id", `id ${JSON.stringify(d.id)} must be a reserved built-in id or a namespaced "scope/name" id`);
3132
+ }
3133
+ if (typeof d.version !== "string" || !SEMVER2.test(d.version)) {
3134
+ add("version", `version must be MAJOR.MINOR.PATCH, got ${JSON.stringify(d.version)}`);
3135
+ }
3136
+ if (d.sdkApiVersion !== SDK_API_VERSION) {
3137
+ add("sdkApiVersion", `unsupported sdkApiVersion ${JSON.stringify(d.sdkApiVersion)}; this build supports ${SDK_API_VERSION}`);
3138
+ }
3139
+ if (typeof d.optionsVersion !== "number" || !Number.isInteger(d.optionsVersion) || d.optionsVersion < 1) {
3140
+ add("optionsVersion", `optionsVersion must be a positive integer, got ${JSON.stringify(d.optionsVersion)}`);
3141
+ }
3142
+ if (!isRecord3(d.display)) {
3143
+ add("display", "display must be an object");
3144
+ } else {
3145
+ if (typeof d.display.title !== "string" || d.display.title === "") {
3146
+ add("display.title", "display.title must be a non-empty string");
3147
+ }
3148
+ for (const text of ["description", "category", "titleId", "categoryId"]) {
3149
+ const value = d.display[text];
3150
+ if (value !== void 0 && typeof value !== "string") add(`display.${text}`, `display.${text} must be a string`);
3151
+ }
3152
+ }
3153
+ const channel = CHANNELS.find((c) => c === d.channel);
3154
+ if (!channel) add("channel", `channel must be one of ${CHANNELS.join(", ")}, got ${JSON.stringify(d.channel)}`);
3155
+ const specs = checkOptions(d.options, add);
3156
+ const constraints = checkConstraints(d.constraints, specs, add);
3157
+ if (specs) checkDefaults(d.defaults, specs, constraints, typeof d.id === "string" ? d.id : id, add);
3158
+ else if (!isRecord3(d.defaults)) add("defaults", "defaults must be an object");
3159
+ checkDatasource(d.datasource, channel, add);
3160
+ if (channel) checkBinding(d.binding, channel, specs, add);
3161
+ checkInteractions(d.interactions, channel, add);
3162
+ return issues;
3163
+ }
3164
+ function jsonOnly(value, path, add, ancestors) {
3165
+ const here = path || "(descriptor)";
3166
+ switch (typeof value) {
3167
+ case "string":
3168
+ case "boolean":
3169
+ return;
3170
+ case "number":
3171
+ if (!Number.isFinite(value)) add(path, `${here} is ${value}; descriptors hold JSON values only`);
3172
+ return;
3173
+ case "undefined":
3174
+ add(path, `${here} is undefined; omit the key instead (descriptors hold JSON values only)`);
3175
+ return;
3176
+ case "function":
3177
+ case "symbol":
3178
+ case "bigint":
3179
+ add(path, `${here} is a ${typeof value}; descriptors hold JSON values only`);
3180
+ return;
3181
+ }
3182
+ if (value === null) return;
3183
+ const obj = value;
3184
+ if (ancestors.has(obj)) {
3185
+ add(path, `${here} is a circular reference; descriptors hold JSON values only`);
3186
+ return;
3187
+ }
3188
+ ancestors.add(obj);
3189
+ if (Array.isArray(obj)) {
3190
+ obj.forEach((item, i) => jsonOnly(item, `${path}[${i}]`, add, ancestors));
3191
+ } else if (!isPlainObject(obj)) {
3192
+ add(path, `${here} is not a plain object; descriptors hold JSON values only`);
3193
+ } else {
3194
+ if (Object.getOwnPropertySymbols(obj).length > 0) add(path, `${here} has a symbol key; descriptors hold JSON values only`);
3195
+ for (const [key, item] of Object.entries(obj)) jsonOnly(item, join(path, key), add, ancestors);
3196
+ }
3197
+ ancestors.delete(obj);
3198
+ }
3199
+ function checkOptions(options, add) {
3200
+ if (!isRecord3(options)) {
3201
+ add("options", "options must be an object mapping option keys to specs");
3202
+ return void 0;
3203
+ }
3204
+ let sound = true;
3205
+ for (const [key, spec] of Object.entries(options)) {
3206
+ const at = `options.${key}`;
3207
+ if (!isRecord3(spec)) {
3208
+ add(at, "an option spec must be an object");
3209
+ sound = false;
3210
+ continue;
3211
+ }
3212
+ if (typeof spec.doc !== "string") {
3213
+ add(`${at}.doc`, "doc must be a string");
3214
+ }
3215
+ for (const text of ["label", "help", "labelId", "helpId", "placeholder", "placeholderId"]) {
3216
+ if (spec[text] !== void 0 && typeof spec[text] !== "string") add(`${at}.${text}`, `${text} must be a string`);
3217
+ }
3218
+ if (spec.control !== void 0 && (typeof spec.control !== "string" || spec.control === "")) {
3219
+ add(`${at}.control`, `control must be a non-empty string, got ${JSON.stringify(spec.control)}`);
3220
+ }
3221
+ switch (spec.kind) {
3222
+ case "string":
3223
+ case "boolean":
3224
+ break;
3225
+ case "number":
3226
+ for (const bound of ["min", "max"]) {
3227
+ if (spec[bound] !== void 0 && typeof spec[bound] !== "number") add(`${at}.${bound}`, `${bound} must be a number`);
3228
+ }
3229
+ break;
3230
+ case "enum":
3231
+ if (!Array.isArray(spec.values) || spec.values.length === 0 || !spec.values.every((v) => typeof v === "string")) {
3232
+ add(`${at}.values`, "an enum option needs a non-empty list of string values");
3233
+ sound = false;
3234
+ }
3235
+ for (const table of ["valueLabels", "valueLabelIds"]) {
3236
+ const labels = spec[table];
3237
+ if (labels === void 0) continue;
3238
+ if (!isRecord3(labels)) {
3239
+ add(`${at}.${table}`, `${table} must be an object mapping enum values to strings`);
3240
+ continue;
3241
+ }
3242
+ for (const [value, label] of Object.entries(labels)) {
3243
+ if (typeof label !== "string") add(`${at}.${table}.${value}`, `${table} entries must be strings`);
3244
+ else if (Array.isArray(spec.values) && !spec.values.includes(value)) {
3245
+ add(`${at}.${table}.${value}`, `${JSON.stringify(value)} is not one of this option's values`);
3246
+ }
3247
+ }
3248
+ }
3249
+ break;
3250
+ default:
3251
+ add(`${at}.kind`, `kind must be string, number, boolean or enum, got ${JSON.stringify(spec.kind)}`);
3252
+ sound = false;
3253
+ }
3254
+ }
3255
+ return sound ? options : void 0;
3256
+ }
3257
+ function checkConstraints(constraints, specs, add) {
3258
+ if (constraints === void 0) return [];
3259
+ if (!Array.isArray(constraints)) {
3260
+ add("constraints", "constraints must be a list");
3261
+ return [];
3262
+ }
3263
+ const valid = [];
3264
+ constraints.forEach((c, i) => {
3265
+ const at = `constraints[${i}]`;
3266
+ if (!isRecord3(c) || c.kind !== "greater-than") {
3267
+ add(`${at}.kind`, `constraint kind must be "greater-than", got ${JSON.stringify(isRecord3(c) ? c.kind : c)}`);
3268
+ return;
3269
+ }
3270
+ let ok = true;
3271
+ for (const field of ["key", "than"]) {
3272
+ const name = c[field];
3273
+ if (typeof name !== "string") {
3274
+ add(`${at}.${field}`, `${field} must name a declared option`);
3275
+ ok = false;
3276
+ } else if (specs && (!hasOwn(specs, name) || specs[name].kind !== "number")) {
3277
+ add(`${at}.${field}`, `${field} ${JSON.stringify(name)} must name a declared number option`);
3278
+ ok = false;
3279
+ }
3280
+ }
3281
+ if (ok) valid.push({ kind: "greater-than", key: c.key, than: c.than });
3282
+ });
3283
+ return valid;
3284
+ }
3285
+ function checkDefaults(defaults, specs, constraints, id, add) {
3286
+ if (!isRecord3(defaults)) {
3287
+ add("defaults", "defaults must be an object");
3288
+ return;
3289
+ }
3290
+ const found = validateOptions(specs, defaults, { constraints, label: id }).filter(
3291
+ (issue) => issue.code !== "missing"
3292
+ );
3293
+ for (const issue of found) add(`defaults.${issue.key}`, issue.message);
3294
+ }
3295
+ function checkDatasource(datasource, channel, add) {
3296
+ if (!isRecord3(datasource)) {
3297
+ add("datasource", "datasource must be an object");
3298
+ return;
3299
+ }
3300
+ const mode = datasource.mode;
3301
+ if (mode !== "none" && mode !== "optional" && mode !== "required") {
3302
+ add("datasource.mode", `mode must be none, optional or required, got ${JSON.stringify(mode)}`);
3303
+ return;
3304
+ }
3305
+ if ((channel === "none" || channel === "selection") && mode !== "none") {
3306
+ add("datasource.mode", `a ${channel}-channel widget opens no data channel, so its datasource mode must be "none"`);
3307
+ }
3308
+ if (mode === "none") {
3309
+ if (datasource.selectors !== void 0) add("datasource.selectors", 'a datasource mode of "none" takes no selectors');
3310
+ return;
3311
+ }
3312
+ const selectors = datasource.selectors;
3313
+ if (!Array.isArray(selectors) || selectors.length === 0) {
3314
+ add("datasource.selectors", `a datasource mode of "${mode}" needs a non-empty list of selector kinds`);
3315
+ return;
3316
+ }
3317
+ selectors.forEach((s, i) => {
3318
+ if (typeof s !== "string" || !SELECTOR_KINDS.includes(s)) {
3319
+ add(`datasource.selectors[${i}]`, `selector must be one of ${SELECTOR_KINDS.join(", ")}, got ${JSON.stringify(s)}`);
3320
+ } else if (channel === "control" && s !== "device" && s !== "slot") {
3321
+ add(`datasource.selectors[${i}]`, "a command target must be a device, so a control widget accepts only device and slot selectors");
3322
+ }
3323
+ });
3324
+ }
3325
+ function checkBinding(binding, channel, specs, add) {
3326
+ if (!isRecord3(binding)) {
3327
+ add("binding", "binding must be an object");
3328
+ return;
3329
+ }
3330
+ const allowed = BINDING_KEYS[channel];
3331
+ for (const key of Object.keys(binding)) {
3332
+ if (!allowed.includes(key)) add(`binding.${key}`, `a ${channel}-channel widget has no binding policy "${key}"`);
3333
+ }
3334
+ if (allowed.includes("pageSize") && binding.pageSize === void 0) {
3335
+ add("binding.pageSize", `a ${channel}-channel widget must declare binding.pageSize`);
3336
+ }
3337
+ const ref = (key, accept, expect, allowConst) => {
3338
+ const policy = binding[key];
3339
+ if (policy === void 0) return;
3340
+ const at = `binding.${key}`;
3341
+ if (!isRecord3(policy)) {
3342
+ add(at, `${key} must be an object`);
3343
+ return;
3344
+ }
3345
+ const hasConst = policy.const !== void 0;
3346
+ const hasOption = policy.option !== void 0;
3347
+ if (hasConst) {
3348
+ if (!allowConst) add(`${at}.const`, `${key} takes an option reference, not a constant`);
3349
+ else if (typeof policy.const !== "number" || !Number.isInteger(policy.const) || policy.const < 1) {
3350
+ add(`${at}.const`, `const must be a positive integer, got ${JSON.stringify(policy.const)}`);
3351
+ }
3352
+ if (hasOption) add(at, `${key} must be a constant or an option reference, not both`);
3353
+ return;
3354
+ }
3355
+ if (!hasOption) {
3356
+ add(at, `${key} must be a constant or an option reference`);
3357
+ return;
3358
+ }
3359
+ const name = policy.option;
3360
+ if (typeof name !== "string") {
3361
+ add(`${at}.option`, "option must name a declared option key");
3362
+ } else if (specs) {
3363
+ if (!hasOwn(specs, name)) add(`${at}.option`, `option ${JSON.stringify(name)} is not a declared option key`);
3364
+ else if (!accept(specs[name])) add(`${at}.option`, `option ${JSON.stringify(name)} must be ${expect}`);
3365
+ }
3366
+ if (policy.min !== void 0 && typeof policy.min !== "number") add(`${at}.min`, "min must be a number");
3367
+ if (policy.default !== void 0) {
3368
+ const wantsNumber = key === "window" || key === "pageSize";
3369
+ if (typeof policy.default !== (wantsNumber ? "number" : "string")) {
3370
+ add(`${at}.default`, `default must be a ${wantsNumber ? "number" : "string"}`);
3371
+ }
3372
+ }
3373
+ };
3374
+ const isNumber = (s) => s.kind === "number";
3375
+ const isText = (s) => s.kind === "string" || s.kind === "enum";
3376
+ const isBoolEnum = (s) => s.kind === "enum" && s.values.every((v) => v === "true" || v === "false");
3377
+ ref("window", isNumber, "a number option", false);
3378
+ ref("pageSize", isNumber, "a number option", true);
3379
+ ref("state", isText, "a string or enum option", false);
3380
+ ref("severity", isText, "a string or enum option", false);
3381
+ ref("acknowledged", isBoolEnum, 'an enum option whose values are only "true" and "false"', false);
3382
+ }
3383
+ function checkInteractions(interactions, channel, add) {
3384
+ if (!isRecord3(interactions)) {
3385
+ add("interactions", "interactions must be an object");
3386
+ return;
3387
+ }
3388
+ const only = { alarmActions: "alarm", commandSend: "control" };
3389
+ for (const [key, value] of Object.entries(interactions)) {
3390
+ const at = `interactions.${key}`;
3391
+ if (key !== "alarmActions" && key !== "commandSend" && key !== "selection" && key !== "candidates") {
3392
+ add(at, `unknown interaction "${key}"`);
3393
+ } else if (value !== true) {
3394
+ add(at, `${key} must be true when declared (omit it otherwise)`);
3395
+ } else if (channel && only[key] && only[key] !== channel) {
3396
+ add(at, `${key} is only available to ${only[key]}-channel widgets, not ${channel}`);
3397
+ }
3398
+ }
3399
+ }
3400
+ function deepFreeze(value) {
3401
+ if (typeof value === "object" && value !== null) {
3402
+ Object.freeze(value);
3403
+ for (const child of Object.values(value)) deepFreeze(child);
3404
+ }
3405
+ return value;
3406
+ }
3407
+ function createWidgetCatalog(descriptors) {
3408
+ const issues = collectCatalogIssues(descriptors);
3409
+ if (issues.length > 0) throw new WidgetCatalogError(issues);
3410
+ return sealCatalog(descriptors);
3411
+ }
3412
+ function collectCatalogIssues(descriptors) {
3413
+ if (!Array.isArray(descriptors)) {
3414
+ return [{ id: "#?", path: "", message: "descriptors must be an array" }];
3415
+ }
3416
+ const issues = [];
3417
+ const seen = /* @__PURE__ */ new Map();
3418
+ descriptors.forEach((d, index) => {
3419
+ const id = isRecord3(d) && typeof d.id === "string" && d.id !== "" ? d.id : `#${index}`;
3420
+ issues.push(...collectDescriptorIssues(d, id));
3421
+ if (id.startsWith("#")) return;
3422
+ const first = seen.get(id);
3423
+ if (first === void 0) seen.set(id, index);
3424
+ else issues.push({ id, path: "id", message: `duplicate widget id (first registered at position ${first}, again at ${index})` });
3425
+ });
3426
+ return issues;
3427
+ }
3428
+ function sealCatalog(descriptors) {
3429
+ const byId = /* @__PURE__ */ new Map();
3430
+ for (const d of descriptors) byId.set(d.id, deepFreeze(d));
3431
+ const ordered = Object.freeze([...byId.values()]);
3432
+ return Object.freeze({
3433
+ has: (id) => byId.has(id),
3434
+ get: (id) => byId.get(id),
3435
+ list: () => ordered
3436
+ });
1855
3437
  }
1856
3438
  export {
1857
3439
  BASE_BREAKPOINT,
1858
3440
  COMMAND_STATUSES,
1859
3441
  DashboardDefinitionError,
1860
3442
  DashboardHub,
3443
+ HOST_OPTION_CONTROLS,
3444
+ NotInRecordingError,
3445
+ PLAYBACK_RATES,
3446
+ RECORDED_HISTORY_WINDOW_MS,
3447
+ RECORDING_FORMAT_VERSION,
3448
+ RECORDING_KIND,
3449
+ RecordedClock,
3450
+ RecordedDataSource,
3451
+ RecordingFormatError,
3452
+ SDK_API_VERSION,
1861
3453
  SYNTHETIC_GENERATORS,
1862
3454
  SyntheticDataSource,
3455
+ UNKNOWN_WIDGET_TYPE,
1863
3456
  WIDGET_TYPES,
3457
+ WidgetCatalogError,
1864
3458
  activeBreakpoint,
1865
3459
  addWidget,
1866
3460
  anchorSlotNames,
3461
+ applyOptionPatch,
1867
3462
  applySelection,
1868
3463
  baseBox,
1869
3464
  bindWidgetSlot,
1870
3465
  bindingsWithoutScopedSlots,
1871
3466
  bringToFront,
1872
3467
  canScopeSlot,
3468
+ checkWidgetCompatibility,
3469
+ clampNumberOption,
1873
3470
  clearWidgetDatasource,
3471
+ collectCatalogIssues,
1874
3472
  createDeviceResolver,
1875
3473
  createEntityLister,
3474
+ createRecordedClock,
3475
+ createRecordedLister,
3476
+ createRecordedResolver,
3477
+ createWidgetCatalog,
1876
3478
  defaultHistoryWindow,
3479
+ defineWidgetDescriptor,
1877
3480
  deleteWidget,
3481
+ describeIssues,
1878
3482
  effectiveBindings,
3483
+ enumValues,
1879
3484
  fetchWidgetHistory,
1880
3485
  generateWidgetId,
1881
3486
  gridBoxToPx,
1882
3487
  hasScopedSlots,
3488
+ isBuiltinWidget,
3489
+ isBuiltinWidgetId,
1883
3490
  isCancellableCommandStatus,
1884
3491
  isDirty,
3492
+ isNamespacedWidgetId,
3493
+ isResolvedWidget,
1885
3494
  isTerminalCommandStatus,
1886
3495
  migrateToSlots,
3496
+ numberSpec,
1887
3497
  parseBindingManifest,
3498
+ parseBoardRecording,
1888
3499
  parseDashboardDefinition,
1889
3500
  parseSlotBinding,
3501
+ parseWidgetRef,
1890
3502
  pruneSlots,
1891
3503
  pxToGridBox,
3504
+ recordedWallTimeMs,
3505
+ recordingBounds,
1892
3506
  resolveConcrete,
1893
3507
  resolveContextBindings,
1894
3508
  resolveSlotCandidates,
1895
3509
  resolveWidgetBox,
3510
+ resolveWidgetDatasource,
1896
3511
  sameBinding,
1897
3512
  serializeDefinition,
1898
3513
  setCanvasGrid,
@@ -1900,9 +3515,15 @@ export {
1900
3515
  setSlotScope,
1901
3516
  setTitle,
1902
3517
  setWidgetBox,
3518
+ simulationScaleAt,
3519
+ storedDefinition,
1903
3520
  stripDefaultBindings,
3521
+ unresolvedWidgets,
1904
3522
  updateWidget,
3523
+ validateOptions,
3524
+ validateWidgetDescriptor,
1905
3525
  widgetBinding,
1906
- widgetSlotName
3526
+ widgetSlotName,
3527
+ widgetVersionNotice
1907
3528
  };
1908
3529
  //# sourceMappingURL=index.js.map