@devicechain/widget-sdk 0.21.1-rc.1 → 0.22.0-rc.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/dist/index.js CHANGED
@@ -1,15 +1,45 @@
1
1
  import {
2
+ ALARM_LEVELS,
3
+ FIT_MIN_FONT_PX,
4
+ SEVERITY_LEVELS,
5
+ SEVERITY_TOKENS,
6
+ WidgetBodySizeProvider,
2
7
  WidgetCandidatesProvider,
8
+ WidgetDensityProvider,
9
+ WidgetFrameStyleProvider,
10
+ WidgetFreshnessProvider,
3
11
  WidgetSelectProvider,
4
12
  WidgetSubjectProvider,
5
13
  WidgetThemeRootProvider,
14
+ WidgetTimeRangeProvider,
6
15
  WidgetTimeZoneProvider,
16
+ bandAt,
17
+ fitValueFont,
18
+ fitValueFontCss,
19
+ formatDateTime,
20
+ formatTimestamp,
21
+ formatValue,
22
+ isAlarmLevel,
23
+ isSeverityLevel,
24
+ levelColor,
25
+ optThresholds,
26
+ parseThresholds,
27
+ severityTokenCss,
28
+ severityVar,
29
+ stickyChars,
30
+ thresholdBands,
31
+ useSeverityColors,
32
+ useWidgetBodySize,
7
33
  useWidgetCandidates,
34
+ useWidgetDensityScale,
35
+ useWidgetFrameStyle,
36
+ useWidgetFreshness,
8
37
  useWidgetSelect,
9
38
  useWidgetSubject,
10
39
  useWidgetThemeRoot,
40
+ useWidgetTimeRange,
11
41
  useWidgetTimeZone
12
- } from "./chunks/chunk-CC2V7LVI.js";
42
+ } from "./chunks/chunk-A5BJ2ALW.js";
13
43
 
14
44
  // src/define-widget.ts
15
45
  function defineWidget(definition) {
@@ -77,140 +107,12 @@ function createWidgetRegistry(definitions) {
77
107
  });
78
108
  }
79
109
 
80
- // src/theme.ts
81
- function css(name) {
82
- return `hsl(var(--${name}))`;
83
- }
84
-
85
110
  // src/frame.tsx
86
- import { jsx, jsxs } from "react/jsx-runtime";
87
- function WidgetFrame({ title, subtitle, children, bodyStyle }) {
88
- const subject = useWidgetSubject();
89
- const sub = subtitle ?? subject;
90
- return /* @__PURE__ */ jsxs(
91
- "div",
92
- {
93
- style: {
94
- display: "flex",
95
- flexDirection: "column",
96
- width: "100%",
97
- height: "100%",
98
- boxSizing: "border-box",
99
- background: css("card"),
100
- color: css("card-foreground"),
101
- border: `1px solid ${css("border")}`,
102
- borderRadius: "var(--radius, 8px)",
103
- overflow: "hidden"
104
- },
105
- children: [
106
- title || sub ? /* @__PURE__ */ jsxs(
107
- "div",
108
- {
109
- style: {
110
- padding: "8px 12px",
111
- borderBottom: `1px solid ${css("border")}`
112
- },
113
- children: [
114
- title ? /* @__PURE__ */ jsx("div", { style: { fontSize: 12, fontWeight: 600, color: css("muted-foreground") }, children: title }) : null,
115
- sub ? /* @__PURE__ */ jsx(
116
- "div",
117
- {
118
- title: sub,
119
- style: {
120
- fontSize: 11,
121
- fontWeight: 400,
122
- color: css("muted-foreground"),
123
- opacity: 0.75,
124
- marginTop: title ? 2 : 0,
125
- whiteSpace: "nowrap",
126
- overflow: "hidden",
127
- textOverflow: "ellipsis"
128
- },
129
- children: sub
130
- }
131
- ) : null
132
- ]
133
- }
134
- ) : null,
135
- /* @__PURE__ */ jsx("div", { style: { flex: 1, minHeight: 0, ...bodyStyle }, children })
136
- ]
137
- }
138
- );
139
- }
111
+ import { resolveFrame, sameLabel } from "@devicechain/dashboards";
112
+ import { useMemo as useMemo3 } from "react";
140
113
 
141
- // src/format.ts
142
- function formatTimestamp(iso, timeZone) {
143
- if (!iso) return "";
144
- const date = new Date(iso);
145
- if (Number.isNaN(date.getTime())) return iso;
146
- return timeZone ? date.toLocaleTimeString(void 0, { timeZone, timeZoneName: "short" }) : date.toLocaleTimeString();
147
- }
148
- function formatDateTime(iso, timeZone) {
149
- if (!iso) return "";
150
- const date = new Date(iso);
151
- if (Number.isNaN(date.getTime())) return iso;
152
- return timeZone ? date.toLocaleString(void 0, { timeZone, timeZoneName: "short" }) : date.toLocaleString();
153
- }
154
- function formatValue(value, precision) {
155
- if (value == null) return "\u2014";
156
- if (precision == null) return String(value);
157
- const places = Math.min(100, Math.max(0, Math.trunc(precision)));
158
- return value.toFixed(places);
159
- }
160
-
161
- // src/flash.tsx
162
- import { useEffect, useRef, useState } from "react";
163
- import { jsx as jsx2 } from "react/jsx-runtime";
164
- var UP = {
165
- fg: "var(--flash-up, hsl(142 72% 40%))",
166
- bg: "var(--flash-up-bg, hsl(142 72% 45% / 0.16))"
167
- };
168
- var DOWN = {
169
- fg: "var(--flash-down, hsl(0 72% 51%))",
170
- bg: "var(--flash-down-bg, hsl(0 72% 55% / 0.16))"
171
- };
172
- var HOLD_MS = 400;
173
- var FADE_MS = 600;
174
- function useFlashOnChange(value, enabled, identity) {
175
- const prev = useRef(value);
176
- const prevIdentity = useRef(identity);
177
- const [direction, setDirection] = useState(null);
178
- useEffect(() => {
179
- const before = prev.current;
180
- const sameEntity = prevIdentity.current === identity;
181
- prev.current = value;
182
- prevIdentity.current = identity;
183
- if (!enabled || !sameEntity || typeof value !== "number" || typeof before !== "number" || !Number.isFinite(value) || !Number.isFinite(before) || value === before) {
184
- setDirection(null);
185
- return;
186
- }
187
- setDirection(value > before ? "up" : "down");
188
- const timer = setTimeout(() => setDirection(null), HOLD_MS);
189
- return () => clearTimeout(timer);
190
- }, [value, enabled, identity]);
191
- return enabled ? direction : null;
192
- }
193
- function flashTextStyle(direction, fadeMs = FADE_MS) {
194
- if (direction) return { color: direction === "up" ? UP.fg : DOWN.fg, transition: "none" };
195
- return { transition: `color ${fadeMs}ms ease-out` };
196
- }
197
- function flashBackgroundStyle(direction, fadeMs = FADE_MS) {
198
- if (direction) {
199
- return { backgroundColor: direction === "up" ? UP.bg : DOWN.bg, transition: "none" };
200
- }
201
- return { transition: `background-color ${fadeMs}ms ease-out` };
202
- }
203
- function FlashValue({
204
- value,
205
- precision,
206
- enabled,
207
- identity,
208
- baseColor = "inherit",
209
- style
210
- }) {
211
- const direction = useFlashOnChange(value, enabled, identity);
212
- return /* @__PURE__ */ jsx2("span", { style: { color: baseColor, ...style, ...flashTextStyle(direction) }, children: formatValue(value, precision) });
213
- }
114
+ // src/freshness-readout.tsx
115
+ import { useEffect, useState } from "react";
214
116
 
215
117
  // src/widget-options.ts
216
118
  function pickSample(latest, name) {
@@ -235,6 +137,208 @@ function primaryMeasurementName(widget) {
235
137
  return void 0;
236
138
  }
237
139
 
140
+ // src/freshness.ts
141
+ function resolveStaleAfterSeconds(options) {
142
+ const configured = optNumber(options, "staleAfterSeconds");
143
+ return configured !== void 0 && configured > 0 ? configured : void 0;
144
+ }
145
+ function newestSampleTime(latest, names) {
146
+ let newest;
147
+ for (const [name, sample] of Object.entries(latest)) {
148
+ if (names && names.length > 0 && !names.includes(name)) continue;
149
+ if (!sample.occurredTime) continue;
150
+ const at = Date.parse(sample.occurredTime);
151
+ if (Number.isNaN(at)) continue;
152
+ if (newest === void 0 || at > newest) newest = at;
153
+ }
154
+ return newest;
155
+ }
156
+ function ageSeconds(at, now) {
157
+ return Math.max(0, Math.floor((now - at) / 1e3));
158
+ }
159
+ function isStale(age, staleAfter) {
160
+ return staleAfter !== void 0 && age > staleAfter;
161
+ }
162
+ function viewerLocale() {
163
+ if (typeof document !== "undefined" && document.documentElement.lang) return document.documentElement.lang;
164
+ if (typeof navigator !== "undefined" && navigator.language) return navigator.language;
165
+ return void 0;
166
+ }
167
+ var formatters = /* @__PURE__ */ new Map();
168
+ function relativeTimeFormat(locale) {
169
+ const key = locale ?? "";
170
+ let formatter = formatters.get(key);
171
+ if (!formatter) {
172
+ try {
173
+ formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "short" });
174
+ } catch {
175
+ formatter = new Intl.RelativeTimeFormat(void 0, { numeric: "auto", style: "short" });
176
+ }
177
+ formatters.set(key, formatter);
178
+ }
179
+ return formatter;
180
+ }
181
+ function formatAge(age, locale = viewerLocale()) {
182
+ const formatter = relativeTimeFormat(locale);
183
+ if (age < 60) return formatter.format(-age, "second");
184
+ if (age < 3600) return formatter.format(-Math.floor(age / 60), "minute");
185
+ if (age < 86400) return formatter.format(-Math.floor(age / 3600), "hour");
186
+ return formatter.format(-Math.floor(age / 86400), "day");
187
+ }
188
+ var FRESHNESS_STRINGS = {
189
+ en: { stale: "stale", ageLabel: "Last sample" },
190
+ es: { stale: "obsoleto", ageLabel: "\xDAltima muestra" }
191
+ };
192
+ function freshnessStrings(locale = viewerLocale()) {
193
+ const base = locale?.toLowerCase().split("-")[0];
194
+ return base === "es" ? FRESHNESS_STRINGS.es : FRESHNESS_STRINGS.en;
195
+ }
196
+
197
+ // src/theme.ts
198
+ function css(name) {
199
+ return `hsl(var(--${name}))`;
200
+ }
201
+
202
+ // src/tokens.tsx
203
+ import { DENSITY_SCALE, SPACE_TOKEN_PX } from "@devicechain/dashboards";
204
+ import { jsx } from "react/jsx-runtime";
205
+ var FONT_TOKENS = {
206
+ xs: 11,
207
+ sm: 12,
208
+ md: 13,
209
+ lg: 16,
210
+ xl: 20,
211
+ display: 28
212
+ };
213
+ var SPACE_TOKENS = {
214
+ 1: SPACE_TOKEN_PX["space-1"],
215
+ 2: SPACE_TOKEN_PX["space-2"],
216
+ 3: SPACE_TOKEN_PX["space-3"],
217
+ 4: SPACE_TOKEN_PX["space-4"]
218
+ };
219
+ function densityScale(density) {
220
+ return DENSITY_SCALE[density ?? "comfortable"];
221
+ }
222
+ function fontVar(token) {
223
+ return `var(--dc-font-${token}, ${FONT_TOKENS[token]}px)`;
224
+ }
225
+ function spaceVar(token) {
226
+ return `var(--dc-space-${token}, ${SPACE_TOKENS[token]}px)`;
227
+ }
228
+ function tokenPx(token, scale = 1) {
229
+ const [kind, name] = token.split("-");
230
+ const base = kind === "font" ? FONT_TOKENS[name] : SPACE_TOKENS[Number(name)];
231
+ return Math.round(base * scale * 100) / 100;
232
+ }
233
+ function tokenStyle(scale) {
234
+ const vars = { "--dc-scale": String(scale) };
235
+ for (const [name, px] of Object.entries(FONT_TOKENS)) vars[`--dc-font-${name}`] = `calc(${px}px * var(--dc-scale))`;
236
+ for (const [name, px] of Object.entries(SPACE_TOKENS)) vars[`--dc-space-${name}`] = `calc(${px}px * var(--dc-scale))`;
237
+ return vars;
238
+ }
239
+ function WidgetTokenStyles() {
240
+ return /* @__PURE__ */ jsx("style", { "data-dc-tokens": "", children: severityTokenCss({ hostWins: true }) });
241
+ }
242
+
243
+ // src/freshness-readout.tsx
244
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
245
+ var tickers = /* @__PURE__ */ new Map();
246
+ function subscribeTick(everyMs, listener) {
247
+ let ticker = tickers.get(everyMs);
248
+ if (!ticker) {
249
+ const listeners = /* @__PURE__ */ new Set();
250
+ const timer = setInterval(() => {
251
+ const now = Date.now();
252
+ for (const l of [...listeners]) l(now);
253
+ }, everyMs);
254
+ ticker = { timer, listeners };
255
+ tickers.set(everyMs, ticker);
256
+ }
257
+ ticker.listeners.add(listener);
258
+ const owned = ticker;
259
+ return () => {
260
+ owned.listeners.delete(listener);
261
+ if (owned.listeners.size === 0 && tickers.get(everyMs) === owned) {
262
+ clearInterval(owned.timer);
263
+ tickers.delete(everyMs);
264
+ }
265
+ };
266
+ }
267
+ function useNow(everyMs = 1e3, enabled = true) {
268
+ const [now, setNow] = useState(() => Date.now());
269
+ useEffect(() => {
270
+ if (!enabled) return;
271
+ setNow(Date.now());
272
+ return subscribeTick(everyMs, setNow);
273
+ }, [everyMs, enabled]);
274
+ return now;
275
+ }
276
+ function useFreshness(at, staleAfterSeconds) {
277
+ const now = useNow(1e3, at !== void 0);
278
+ const [seen, setSeen] = useState(void 0);
279
+ let current = seen;
280
+ if (at !== void 0 && seen?.at !== at) {
281
+ current = { at, seenAt: Math.max(now, Date.now()) };
282
+ setSeen(current);
283
+ }
284
+ if (at === void 0 || current === void 0) return void 0;
285
+ const age = ageSeconds(Math.min(at, current.seenAt), now);
286
+ return { age, stale: isStale(age, staleAfterSeconds) };
287
+ }
288
+ function StaleBadge({ label }) {
289
+ return /* @__PURE__ */ jsx2(
290
+ "span",
291
+ {
292
+ "data-stale-badge": "",
293
+ style: {
294
+ fontSize: fontVar("xs"),
295
+ fontWeight: 600,
296
+ textTransform: "uppercase",
297
+ letterSpacing: 0.4,
298
+ padding: "0 5px",
299
+ borderRadius: 4,
300
+ border: `1px dashed ${css("muted-foreground")}`,
301
+ color: css("foreground"),
302
+ background: css("muted"),
303
+ whiteSpace: "nowrap"
304
+ },
305
+ children: label ?? freshnessStrings().stale
306
+ }
307
+ );
308
+ }
309
+ function Freshness({ at, staleAfterSeconds, staleLabel, style }) {
310
+ const reading = useFreshness(at, staleAfterSeconds);
311
+ if (!reading) return null;
312
+ return /* @__PURE__ */ jsx2(FreshnessReadout, { reading, staleLabel, style });
313
+ }
314
+ function FreshnessReadout({
315
+ reading,
316
+ staleLabel,
317
+ style
318
+ }) {
319
+ return /* @__PURE__ */ jsxs(
320
+ "span",
321
+ {
322
+ "data-freshness": "",
323
+ "data-stale": reading.stale ? "true" : void 0,
324
+ title: freshnessStrings().ageLabel,
325
+ style: {
326
+ display: "inline-flex",
327
+ alignItems: "center",
328
+ gap: 6,
329
+ fontSize: fontVar("xs"),
330
+ color: css("muted-foreground"),
331
+ fontVariantNumeric: "tabular-nums",
332
+ ...style
333
+ },
334
+ children: [
335
+ /* @__PURE__ */ jsx2("time", { children: formatAge(reading.age) }),
336
+ reading.stale ? /* @__PURE__ */ jsx2(StaleBadge, { label: staleLabel }) : null
337
+ ]
338
+ }
339
+ );
340
+ }
341
+
238
342
  // src/hooks.ts
239
343
  import {
240
344
  bindingsWithoutScopedSlots,
@@ -243,7 +347,7 @@ import {
243
347
  resolveContextBindings,
244
348
  resolveSlotCandidates
245
349
  } from "@devicechain/dashboards";
246
- import { useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState2 } from "react";
350
+ import { useEffect as useEffect2, useMemo, useRef, useState as useState2, useSyncExternalStore } from "react";
247
351
  function useDashboardHub(resolver, authorities) {
248
352
  const authoritiesKey = JSON.stringify(authorities ?? []);
249
353
  const hub = useMemo(
@@ -259,7 +363,7 @@ function useResolvedBindings(definition, base, selection, resolver) {
259
363
  const scoped = useMemo(() => hasScopedSlots(definition), [definition]);
260
364
  const overlaid = useMemo(() => ({ ...base, ...selection }), [base, selection]);
261
365
  const [resolved, setResolved] = useState2(() => bindingsWithoutScopedSlots(definition, overlaid));
262
- const genRef = useRef2(0);
366
+ const genRef = useRef(0);
263
367
  useEffect2(() => {
264
368
  if (!scoped) {
265
369
  setResolved((prev) => sameBindings(prev, overlaid) ? prev : overlaid);
@@ -308,7 +412,7 @@ function useCandidates(slot) {
308
412
  loading: Boolean(provider && slot),
309
413
  error: null
310
414
  }));
311
- const genRef = useRef2(0);
415
+ const genRef = useRef(0);
312
416
  useEffect2(() => {
313
417
  if (!provider || !slot) {
314
418
  setState({ candidates: [], loading: false, error: null });
@@ -328,16 +432,49 @@ function useCandidates(slot) {
328
432
  }, [provider, slot]);
329
433
  return { ...state, wired };
330
434
  }
435
+ var MAX_SPAN_SAMPLES = 2e3;
331
436
  var EMPTY = { latest: {}, samples: [], error: null };
332
437
  function latestOf(samples) {
333
438
  const latest = {};
334
439
  for (const s of samples) latest[s.name] = s;
335
440
  return latest;
336
441
  }
442
+ function evictOldest(samples, max, evicted) {
443
+ if (samples.length <= max) return samples;
444
+ const cut = samples.length - max;
445
+ for (let i = 0; i < cut; i++) {
446
+ const at = Date.parse(samples[i].occurredTime ?? "");
447
+ if (at > evicted.current) evicted.current = at;
448
+ }
449
+ return samples.slice(cut);
450
+ }
451
+ function withinSpan(samples, spanSeconds, end) {
452
+ const from = end - spanSeconds * 1e3;
453
+ return samples.filter((s) => {
454
+ if (!s.occurredTime) return true;
455
+ const at = Date.parse(s.occurredTime);
456
+ return Number.isNaN(at) || at >= from;
457
+ });
458
+ }
337
459
  function useMeasurementStream(hub, datasource, options = {}) {
338
- const windowSize = options.window ?? 300;
460
+ const { windowSeconds, pausedAt } = options;
461
+ const windowSize = options.window ?? (windowSeconds !== void 0 ? MAX_SPAN_SAMPLES : 300);
339
462
  const initialSamples = options.initialSamples;
463
+ const paused = pausedAt !== void 0;
340
464
  const key = datasource ? JSON.stringify(datasource) : null;
465
+ const spanRef = useRef(windowSeconds);
466
+ const clockRef = useRef(options.now ?? Date.now);
467
+ const pausedRef = useRef(paused);
468
+ const queueRef = useRef([]);
469
+ const queueLatestRef = useRef({});
470
+ const evictedRef = useRef(-Infinity);
471
+ const flushRef = useRef(() => {
472
+ });
473
+ const clock = options.now ?? Date.now;
474
+ useEffect2(() => {
475
+ spanRef.current = windowSeconds;
476
+ clockRef.current = clock;
477
+ }, [windowSeconds, clock]);
341
478
  const [tagged, setTagged] = useState2({
342
479
  key,
343
480
  state: EMPTY
@@ -345,40 +482,79 @@ function useMeasurementStream(hub, datasource, options = {}) {
345
482
  const live = tagged.key === key ? tagged.state : EMPTY;
346
483
  useEffect2(() => {
347
484
  setTagged({ key, state: EMPTY });
485
+ queueRef.current = [];
486
+ queueLatestRef.current = {};
487
+ evictedRef.current = -Infinity;
488
+ flushRef.current = () => {
489
+ };
348
490
  if (!datasource) return;
349
491
  const update = (fn) => setTagged((prev) => prev.key === key ? { key, state: fn(prev.state) } : prev);
492
+ const fold = (prev, incoming, newest = latestOf(incoming)) => {
493
+ let samples = prev.samples.concat(incoming);
494
+ const span = spanRef.current;
495
+ if (span !== void 0) samples = withinSpan(samples, span, clockRef.current());
496
+ samples = evictOldest(samples, windowSize, evictedRef);
497
+ return { latest: { ...prev.latest, ...newest }, samples, error: null };
498
+ };
499
+ flushRef.current = () => {
500
+ const queued = queueRef.current;
501
+ if (queued.length === 0) return;
502
+ const newest = queueLatestRef.current;
503
+ queueRef.current = [];
504
+ queueLatestRef.current = {};
505
+ update((prev) => fold(prev, queued, newest));
506
+ };
350
507
  return hub.subscribeWidget(datasource, {
351
- next: (sample) => update((prev) => {
352
- const samples = prev.samples.concat(sample);
353
- if (samples.length > windowSize) samples.splice(0, samples.length - windowSize);
354
- return { latest: { ...prev.latest, [sample.name]: sample }, samples, error: null };
355
- }),
508
+ next: (sample) => {
509
+ if (pausedRef.current) {
510
+ queueRef.current = evictOldest([...queueRef.current, sample], windowSize, evictedRef);
511
+ queueLatestRef.current[sample.name] = sample;
512
+ return;
513
+ }
514
+ update((prev) => fold(prev, [sample]));
515
+ },
356
516
  error: (err) => update((prev) => ({ ...prev, error: err }))
357
517
  });
358
518
  }, [hub, key, windowSize]);
519
+ useEffect2(() => {
520
+ pausedRef.current = paused;
521
+ if (!paused) flushRef.current();
522
+ }, [paused]);
359
523
  return useMemo(() => {
360
- if (!initialSamples || initialSamples.length === 0) return live;
361
- const merged = initialSamples.concat(live.samples);
524
+ const end = pausedAt ?? clock();
525
+ if (windowSeconds === void 0) {
526
+ if (!initialSamples || initialSamples.length === 0) return live;
527
+ const merged2 = initialSamples.concat(live.samples);
528
+ return {
529
+ latest: { ...latestOf(initialSamples), ...live.latest },
530
+ samples: merged2.length > windowSize ? merged2.slice(merged2.length - windowSize) : merged2,
531
+ error: live.error
532
+ };
533
+ }
534
+ const from = end - windowSeconds * 1e3;
535
+ const merged = withinSpan(initialSamples ? initialSamples.concat(live.samples) : live.samples, windowSeconds, end);
362
536
  const samples = merged.length > windowSize ? merged.slice(merged.length - windowSize) : merged;
363
- return {
364
- latest: { ...latestOf(initialSamples), ...live.latest },
365
- samples,
366
- error: live.error
367
- };
368
- }, [initialSamples, live, windowSize]);
537
+ const cut = samples.length < merged.length || evictedRef.current >= from;
538
+ const truncatedBefore = cut ? samples[0]?.occurredTime ?? void 0 : void 0;
539
+ const latest = initialSamples ? { ...latestOf(initialSamples), ...live.latest } : live.latest;
540
+ if (!truncatedBefore && latest === live.latest && samples.length === live.samples.length) return live;
541
+ const state = { latest, samples, error: live.error };
542
+ if (truncatedBefore) state.truncatedBefore = truncatedBefore;
543
+ return state;
544
+ }, [initialSamples, live, windowSize, windowSeconds, pausedAt, clock]);
369
545
  }
370
546
  var EMPTY_ALARMS = { alarms: [], total: 0, loading: true, error: null };
371
547
  function useAlarmStream(hub, subscription) {
372
- const [state, setState] = useState2(EMPTY_ALARMS);
373
548
  const key = JSON.stringify(subscription);
549
+ const [[at, state], setState] = useState2([key, EMPTY_ALARMS]);
374
550
  useEffect2(() => {
375
- setState(EMPTY_ALARMS);
551
+ setState([key, EMPTY_ALARMS]);
376
552
  return hub.subscribeAlarms(subscription, {
377
- next: (snapshot) => setState({ alarms: snapshot.alarms, total: snapshot.total, loading: false, error: null }),
378
- error: (err) => setState((prev) => ({ ...prev, loading: false, error: err }))
553
+ next: (snapshot) => setState([key, { alarms: snapshot.alarms, total: snapshot.total, loading: false, error: null }]),
554
+ error: (err) => setState(([, prev]) => [key, { ...prev, loading: false, error: err }])
379
555
  });
380
556
  }, [hub, key]);
381
- return state;
557
+ return at === key ? state : EMPTY_ALARMS;
382
558
  }
383
559
  var EMPTY_COMMANDS = {
384
560
  deviceToken: null,
@@ -413,12 +589,13 @@ var EMPTY_LOCATIONS = {
413
589
  error: null
414
590
  };
415
591
  function useLocationStream(hub, subscription) {
416
- const [state, setState] = useState2(EMPTY_LOCATIONS);
417
592
  const key = JSON.stringify(subscription);
593
+ const [[at, state], setState] = useState2([key, EMPTY_LOCATIONS]);
418
594
  useEffect2(() => {
419
- setState(EMPTY_LOCATIONS);
595
+ setState([key, EMPTY_LOCATIONS]);
420
596
  return hub.subscribeLocations(subscription, {
421
- next: (snapshot) => setState(
597
+ next: (snapshot) => setState([
598
+ key,
422
599
  snapshot.kind === "forbidden" ? { locations: [], deviceTokens: [], forbidden: true, loading: false, error: null } : {
423
600
  locations: snapshot.locations,
424
601
  deviceTokens: snapshot.deviceTokens,
@@ -426,11 +603,11 @@ function useLocationStream(hub, subscription) {
426
603
  loading: false,
427
604
  error: null
428
605
  }
429
- ),
430
- error: (err) => setState((prev) => ({ ...prev, loading: false, error: err }))
606
+ ]),
607
+ error: (err) => setState(([, prev]) => [key, { ...prev, loading: false, error: err }])
431
608
  });
432
609
  }, [hub, key]);
433
- return state;
610
+ return at === key ? state : EMPTY_LOCATIONS;
434
611
  }
435
612
  var AVAILABILITY_REVALIDATE_MS = 6e4;
436
613
  function useDatasourceAvailability(hub, datasource) {
@@ -470,8 +647,8 @@ function useDatasourceAvailability(hub, datasource) {
470
647
  }, [hub, key]);
471
648
  return state;
472
649
  }
473
- function useElementSize() {
474
- const ref = useRef2(null);
650
+ function useElementSize(step = 1) {
651
+ const ref = useRef(null);
475
652
  const [size, setSize] = useState2({ width: 0, height: 0 });
476
653
  useEffect2(() => {
477
654
  const el = ref.current;
@@ -479,15 +656,314 @@ function useElementSize() {
479
656
  const observer = new ResizeObserver((entries) => {
480
657
  const rect = entries[0]?.contentRect;
481
658
  if (!rect) return;
482
- setSize(
483
- (prev) => prev.width === rect.width && prev.height === rect.height ? prev : { width: rect.width, height: rect.height }
484
- );
659
+ const width = step > 1 ? Math.floor(rect.width / step) * step : rect.width;
660
+ const height = step > 1 ? Math.floor(rect.height / step) * step : rect.height;
661
+ setSize((prev) => prev.width === width && prev.height === height ? prev : { width, height });
485
662
  });
486
663
  observer.observe(el);
487
664
  return () => observer.disconnect();
488
- }, []);
665
+ }, [step]);
489
666
  return [ref, size];
490
667
  }
668
+ var NO_CONNECTION_SOURCE = {
669
+ connectionState: "live",
670
+ onConnectionState: () => () => {
671
+ }
672
+ };
673
+ function useHubConnectionState(hub) {
674
+ const source = hub && typeof hub.onConnectionState === "function" ? hub : NO_CONNECTION_SOURCE;
675
+ return useSyncExternalStore(
676
+ (listener) => source.onConnectionState(listener),
677
+ () => source.connectionState
678
+ );
679
+ }
680
+
681
+ // src/scale.ts
682
+ import { useEffect as useEffect3, useMemo as useMemo2, useReducer, useRef as useRef2 } from "react";
683
+ var SIZE_CLASS_BREAKS = {
684
+ compactWidth: 120,
685
+ compactHeight: 72,
686
+ largeWidth: 420,
687
+ largeHeight: 220
688
+ };
689
+ function sizeClassOf(size, scale = 1) {
690
+ if (!(size.width > 0) || !(size.height > 0)) return "regular";
691
+ const b = SIZE_CLASS_BREAKS;
692
+ if (size.width < b.compactWidth * scale || size.height < b.compactHeight * scale) return "compact";
693
+ if (size.width >= b.largeWidth * scale && size.height >= b.largeHeight * scale) return "large";
694
+ return "regular";
695
+ }
696
+ function makeWidgetScale(size, scale) {
697
+ return { sizeClass: sizeClassOf(size, scale), scale, size, px: (token) => tokenPx(token, scale) };
698
+ }
699
+ function useWidgetScale() {
700
+ const size = useWidgetBodySize();
701
+ const scale = useWidgetDensityScale();
702
+ return useMemo2(() => makeWidgetScale(size, scale), [size, scale]);
703
+ }
704
+ var STICKY_HOLD_MS = 6e4;
705
+ function useStickyChars(chars, holdMs = STICKY_HOLD_MS) {
706
+ const held = useRef2(void 0);
707
+ const [tick, wake] = useReducer((n) => n + 1, 0);
708
+ held.current = stickyChars(held.current, chars, Date.now(), holdMs);
709
+ const { value, since } = held.current;
710
+ useEffect3(() => {
711
+ if (value <= chars) return;
712
+ const timer = setTimeout(wake, Math.max(0, since + holdMs - Date.now()));
713
+ return () => clearTimeout(timer);
714
+ }, [value, since, chars, holdMs, tick]);
715
+ return value;
716
+ }
717
+
718
+ // src/frame.tsx
719
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
720
+ var FRESHNESS_MIN_WIDTH = 150;
721
+ var FRESHNESS_MIN_HEIGHT = 110;
722
+ var FRAME_SUBTITLE_MIN_HEIGHT = 120;
723
+ var FRAME_TITLE_MIN_HEIGHT = 76;
724
+ var BODY_SIZE_STEP = 4;
725
+ var DEFAULT_FRAME = resolveFrame(void 0, void 0);
726
+ var RADIUS = {
727
+ none: "0px",
728
+ sm: "calc(var(--radius, 8px) / 2)",
729
+ md: "var(--radius, 8px)",
730
+ lg: "calc(var(--radius, 8px) * 1.5)"
731
+ };
732
+ function spaceTokenVar(token) {
733
+ return token === "space-0" ? "0px" : spaceVar(Number(token.slice("space-".length)));
734
+ }
735
+ function parseSampleTime(time) {
736
+ if (!time) return void 0;
737
+ const at = Date.parse(time);
738
+ return Number.isNaN(at) ? void 0 : at;
739
+ }
740
+ function WidgetFrame({ title, subtitle, children, bodyStyle, flush, sampleTime }) {
741
+ const subject = useWidgetSubject();
742
+ const rawSub = subtitle ?? subject;
743
+ const dupSub = rawSub && sameLabel(rawSub, title) ? void 0 : rawSub;
744
+ const freshness = useWidgetFreshness();
745
+ const at = freshness === void 0 ? void 0 : sampleTime === void 0 ? freshness.at : parseSampleTime(sampleTime);
746
+ const reading = useFreshness(at, freshness?.staleAfterSeconds);
747
+ const [rootRef, size] = useElementSize();
748
+ const [columnRef, columnSize] = useElementSize(BODY_SIZE_STEP);
749
+ const scale = useWidgetDensityScale();
750
+ const frame = useWidgetFrameStyle() ?? DEFAULT_FRAME;
751
+ const measured = size.height > 0;
752
+ const header = frame.header;
753
+ const showSub = header === "show" || header === "auto" && (!measured || size.height >= FRAME_SUBTITLE_MIN_HEIGHT * scale);
754
+ const showTitle = header === "show" || header === "auto" && (!measured || size.height >= FRAME_TITLE_MIN_HEIGHT * scale);
755
+ const rule = frame.border.width > 0 ? `${frame.border.width}px solid ${css(frame.border.color)}` : "none";
756
+ const sub = showSub ? dupSub : void 0;
757
+ const shownTitle = showTitle ? title : void 0;
758
+ const widgetScale = useMemo3(() => makeWidgetScale(columnSize, scale), [columnSize, scale]);
759
+ const compact = size.width > 0 && (size.width < FRESHNESS_MIN_WIDTH || size.height < FRESHNESS_MIN_HEIGHT);
760
+ const stale = reading?.stale === true;
761
+ return /* @__PURE__ */ jsxs2(
762
+ "div",
763
+ {
764
+ ref: rootRef,
765
+ "data-stale": stale ? "true" : void 0,
766
+ role: title && !showTitle ? "group" : void 0,
767
+ "aria-label": title && !showTitle ? title : void 0,
768
+ style: {
769
+ position: "relative",
770
+ display: "flex",
771
+ flexDirection: "column",
772
+ width: "100%",
773
+ height: "100%",
774
+ boxSizing: "border-box",
775
+ background: frame.background === "transparent" ? "transparent" : css(frame.background),
776
+ color: css("card-foreground"),
777
+ border: rule,
778
+ borderRadius: RADIUS[frame.border.radius],
779
+ overflow: "hidden"
780
+ },
781
+ children: [
782
+ shownTitle || sub ? /* @__PURE__ */ jsxs2(
783
+ "div",
784
+ {
785
+ style: {
786
+ padding: `${spaceVar(2)} ${spaceVar(3)}`,
787
+ borderBottom: rule
788
+ },
789
+ children: [
790
+ shownTitle ? /* @__PURE__ */ jsx3(
791
+ "div",
792
+ {
793
+ title: shownTitle,
794
+ style: {
795
+ fontSize: fontVar("sm"),
796
+ fontWeight: 600,
797
+ color: css("muted-foreground"),
798
+ whiteSpace: "nowrap",
799
+ overflow: "hidden",
800
+ textOverflow: "ellipsis"
801
+ },
802
+ children: shownTitle
803
+ }
804
+ ) : null,
805
+ sub ? /* @__PURE__ */ jsx3(
806
+ "div",
807
+ {
808
+ title: sub,
809
+ style: {
810
+ fontSize: fontVar("xs"),
811
+ fontWeight: 400,
812
+ color: css("muted-foreground"),
813
+ opacity: 0.75,
814
+ marginTop: shownTitle ? 2 : 0,
815
+ whiteSpace: "nowrap",
816
+ overflow: "hidden",
817
+ textOverflow: "ellipsis"
818
+ },
819
+ children: sub
820
+ }
821
+ ) : null
822
+ ]
823
+ }
824
+ ) : null,
825
+ /* @__PURE__ */ jsx3(
826
+ "div",
827
+ {
828
+ "data-widget-body": "",
829
+ style: {
830
+ position: "relative",
831
+ flex: 1,
832
+ minHeight: 0,
833
+ minWidth: 0,
834
+ overflow: "hidden",
835
+ ...bodyStyle,
836
+ ...stale ? { opacity: 0.45, filter: "grayscale(1)" } : null
837
+ },
838
+ children: /* @__PURE__ */ jsx3(
839
+ "div",
840
+ {
841
+ ref: columnRef,
842
+ "data-widget-content": "",
843
+ style: {
844
+ position: "absolute",
845
+ inset: 0,
846
+ boxSizing: "border-box",
847
+ containerType: "size",
848
+ overflow: "hidden",
849
+ padding: flush ? 0 : spaceTokenVar(frame.padding)
850
+ },
851
+ children: /* @__PURE__ */ jsx3(WidgetBodySizeProvider, { size: columnSize, children: typeof children === "function" ? children(widgetScale) : children })
852
+ }
853
+ )
854
+ }
855
+ ),
856
+ reading && !compact ? /* @__PURE__ */ jsx3(
857
+ "div",
858
+ {
859
+ "data-widget-freshness": "",
860
+ style: { padding: `${spaceVar(1)} ${spaceVar(3)}`, borderTop: rule, flex: "0 0 auto" },
861
+ children: /* @__PURE__ */ jsx3(FreshnessReadout, { reading })
862
+ }
863
+ ) : null,
864
+ reading && compact && stale ? /* @__PURE__ */ jsx3("div", { style: { position: "absolute", top: spaceVar(1), right: spaceVar(1) }, children: /* @__PURE__ */ jsx3(StaleBadge, {}) }) : null
865
+ ]
866
+ }
867
+ );
868
+ }
869
+
870
+ // src/connection-badge.tsx
871
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
872
+ var DOT = {
873
+ live: "primary",
874
+ reconnecting: "muted-foreground",
875
+ offline: "destructive"
876
+ };
877
+ function HubConnectionBadge({ state, labels }) {
878
+ return /* @__PURE__ */ jsxs3(
879
+ "span",
880
+ {
881
+ role: "status",
882
+ "data-connection-state": state,
883
+ style: {
884
+ display: "inline-flex",
885
+ alignItems: "center",
886
+ gap: 6,
887
+ fontSize: fontVar("sm"),
888
+ padding: "2px 8px",
889
+ borderRadius: 999,
890
+ border: `1px solid ${css("border")}`,
891
+ color: state === "offline" ? css("destructive") : css("muted-foreground"),
892
+ whiteSpace: "nowrap"
893
+ },
894
+ children: [
895
+ /* @__PURE__ */ jsx4(
896
+ "span",
897
+ {
898
+ "aria-hidden": true,
899
+ style: {
900
+ width: 8,
901
+ height: 8,
902
+ borderRadius: "50%",
903
+ background: css(DOT[state]),
904
+ opacity: state === "reconnecting" ? 0.6 : 1
905
+ }
906
+ }
907
+ ),
908
+ labels[state]
909
+ ]
910
+ }
911
+ );
912
+ }
913
+
914
+ // src/flash.tsx
915
+ import { useEffect as useEffect4, useRef as useRef3, useState as useState3 } from "react";
916
+ import { jsx as jsx5 } from "react/jsx-runtime";
917
+ var UP = {
918
+ fg: "var(--flash-up, hsl(142 72% 40%))",
919
+ bg: "var(--flash-up-bg, hsl(142 72% 45% / 0.16))"
920
+ };
921
+ var DOWN = {
922
+ fg: "var(--flash-down, hsl(0 72% 51%))",
923
+ bg: "var(--flash-down-bg, hsl(0 72% 55% / 0.16))"
924
+ };
925
+ var HOLD_MS = 400;
926
+ var FADE_MS = 600;
927
+ function useFlashOnChange(value, enabled, identity) {
928
+ const prev = useRef3(value);
929
+ const prevIdentity = useRef3(identity);
930
+ const [direction, setDirection] = useState3(null);
931
+ useEffect4(() => {
932
+ const before = prev.current;
933
+ const sameEntity = prevIdentity.current === identity;
934
+ prev.current = value;
935
+ prevIdentity.current = identity;
936
+ if (!enabled || !sameEntity || typeof value !== "number" || typeof before !== "number" || !Number.isFinite(value) || !Number.isFinite(before) || value === before) {
937
+ setDirection(null);
938
+ return;
939
+ }
940
+ setDirection(value > before ? "up" : "down");
941
+ const timer = setTimeout(() => setDirection(null), HOLD_MS);
942
+ return () => clearTimeout(timer);
943
+ }, [value, enabled, identity]);
944
+ return enabled ? direction : null;
945
+ }
946
+ function flashTextStyle(direction, fadeMs = FADE_MS) {
947
+ if (direction) return { color: direction === "up" ? UP.fg : DOWN.fg, transition: "none" };
948
+ return { transition: `color ${fadeMs}ms ease-out` };
949
+ }
950
+ function flashBackgroundStyle(direction, fadeMs = FADE_MS) {
951
+ if (direction) {
952
+ return { backgroundColor: direction === "up" ? UP.bg : DOWN.bg, transition: "none" };
953
+ }
954
+ return { transition: `background-color ${fadeMs}ms ease-out` };
955
+ }
956
+ function FlashValue({
957
+ value,
958
+ precision,
959
+ enabled,
960
+ identity,
961
+ baseColor = "inherit",
962
+ style
963
+ }) {
964
+ const direction = useFlashOnChange(value, enabled, identity);
965
+ return /* @__PURE__ */ jsx5("span", { style: { color: baseColor, ...style, ...flashTextStyle(direction) }, children: formatValue(value, precision) });
966
+ }
491
967
 
492
968
  // src/index.ts
493
969
  import {
@@ -499,32 +975,71 @@ import {
499
975
  validateWidgetDescriptor
500
976
  } from "@devicechain/dashboards";
501
977
  export {
978
+ ALARM_LEVELS,
979
+ FIT_MIN_FONT_PX,
980
+ FONT_TOKENS,
502
981
  FlashValue,
982
+ Freshness,
983
+ FreshnessReadout,
984
+ HubConnectionBadge,
985
+ MAX_SPAN_SAMPLES,
503
986
  SDK_API_VERSION,
987
+ SEVERITY_LEVELS,
988
+ SEVERITY_TOKENS,
989
+ SIZE_CLASS_BREAKS,
990
+ SPACE_TOKENS,
991
+ StaleBadge,
504
992
  WidgetCandidatesProvider,
505
993
  WidgetCatalogError2 as WidgetCatalogError,
994
+ WidgetDensityProvider,
506
995
  WidgetFrame,
996
+ WidgetFrameStyleProvider,
997
+ WidgetFreshnessProvider,
507
998
  WidgetRegistryError,
508
999
  WidgetSelectProvider,
509
1000
  WidgetSubjectProvider,
510
1001
  WidgetThemeRootProvider,
1002
+ WidgetTimeRangeProvider,
511
1003
  WidgetTimeZoneProvider,
1004
+ WidgetTokenStyles,
1005
+ ageSeconds,
1006
+ bandAt,
512
1007
  createWidgetRegistry,
513
1008
  css,
514
1009
  defineWidget,
515
1010
  defineWidgetDescriptor,
1011
+ densityScale,
1012
+ fitValueFont,
1013
+ fitValueFontCss,
516
1014
  flashBackgroundStyle,
517
1015
  flashTextStyle,
1016
+ fontVar,
1017
+ formatAge,
518
1018
  formatDateTime,
519
1019
  formatTimestamp,
520
1020
  formatValue,
1021
+ freshnessStrings,
1022
+ isAlarmLevel,
521
1023
  isBuiltinWidgetId,
522
1024
  isNamespacedWidgetId,
1025
+ isSeverityLevel,
1026
+ isStale,
1027
+ levelColor,
1028
+ newestSampleTime,
523
1029
  optBoolean,
524
1030
  optNumber,
525
1031
  optString,
1032
+ optThresholds,
1033
+ parseThresholds,
526
1034
  pickSample,
527
1035
  primaryMeasurementName,
1036
+ resolveStaleAfterSeconds,
1037
+ severityTokenCss,
1038
+ severityVar,
1039
+ spaceVar,
1040
+ thresholdBands,
1041
+ tokenPx,
1042
+ tokenStyle,
528
1043
  useAlarmStream,
529
1044
  useCandidates,
530
1045
  useCommandStream,
@@ -532,15 +1047,26 @@ export {
532
1047
  useDatasourceAvailability,
533
1048
  useElementSize,
534
1049
  useFlashOnChange,
1050
+ useFreshness,
1051
+ useHubConnectionState,
535
1052
  useLocationStream,
536
1053
  useMeasurementStream,
1054
+ useNow,
537
1055
  useResolvedBindings,
1056
+ useSeverityColors,
538
1057
  useSlotCandidates,
1058
+ useStickyChars,
539
1059
  useWidgetCandidates,
1060
+ useWidgetFrameStyle,
1061
+ useWidgetFreshness,
1062
+ useWidgetScale,
540
1063
  useWidgetSelect,
541
1064
  useWidgetSubject,
542
1065
  useWidgetThemeRoot,
1066
+ useWidgetTimeRange,
543
1067
  useWidgetTimeZone,
544
- validateWidgetDescriptor
1068
+ validateWidgetDescriptor,
1069
+ viewerLocale,
1070
+ withinSpan
545
1071
  };
546
1072
  //# sourceMappingURL=index.js.map