@camstack/ui-library 1.2.189 → 1.2.191

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.cjs CHANGED
@@ -1191,6 +1191,30 @@ var CircleX = createLucideIcon("circle-x", [
1191
1191
  * This source code is licensed under the ISC license.
1192
1192
  * See the LICENSE file in the root directory of this source tree.
1193
1193
  */
1194
+ var Clapperboard = createLucideIcon("clapperboard", [
1195
+ ["path", {
1196
+ d: "m12.296 3.464 3.02 3.956",
1197
+ key: "qash78"
1198
+ }],
1199
+ ["path", {
1200
+ d: "M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3z",
1201
+ key: "1h7j8b"
1202
+ }],
1203
+ ["path", {
1204
+ d: "M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
1205
+ key: "4lm6w1"
1206
+ }],
1207
+ ["path", {
1208
+ d: "m6.18 5.276 3.1 3.899",
1209
+ key: "zjj9t3"
1210
+ }]
1211
+ ]);
1212
+ /**
1213
+ * @license lucide-react v0.576.0 - ISC
1214
+ *
1215
+ * This source code is licensed under the ISC license.
1216
+ * See the LICENSE file in the root directory of this source tree.
1217
+ */
1194
1218
  var Clock = createLucideIcon("clock", [["circle", {
1195
1219
  cx: "12",
1196
1220
  cy: "12",
@@ -7183,6 +7207,8 @@ var useValveGetStatus = trpc.valve.getStatus.useQuery;
7183
7207
  var useVibrationGetStatus = trpc.vibration.getStatus.useQuery;
7184
7208
  /** Generated alias around `trpc.videoclips.listClips.useQuery`. */
7185
7209
  var useVideoclipsListClips = trpc.videoclips.listClips.useQuery;
7210
+ /** Generated alias around `trpc.videoclips.listSources.useQuery`. */
7211
+ var useVideoclipsListSources = trpc.videoclips.listSources.useQuery;
7186
7212
  /** Generated alias around `trpc.videoclips.getClipPlayback.useQuery`. */
7187
7213
  var useVideoclipsGetClipPlayback = trpc.videoclips.getClipPlayback.useQuery;
7188
7214
  /** Generated alias around `trpc.waterHeater.setTargetTemp.useMutation`. */
@@ -17573,396 +17599,2098 @@ function ConsumablesPanel({ deviceId }) {
17573
17599
  });
17574
17600
  }
17575
17601
  //#endregion
17576
- //#region src/hooks/use-navigation.ts
17602
+ //#region src/hooks/use-capability-presence.ts
17577
17603
  /**
17578
- * useNavigation — drive a `navigation` robot camera from the UI.
17604
+ * Classify a settled registry answer.
17579
17605
  *
17580
- * The cap's `move` is MOMENTARY: fluid driving is the UI sending a nudge at
17581
- * 1 Hz while a direction is held and ONE `stop` on release, exactly like the
17582
- * robot app's joystick. This hook owns that cadence (`startDrive` /
17583
- * `stopDrive`); the provider forwards each nudge to a single drive write.
17606
+ * `null` is "no such capability". A row with an empty `providers` list is a
17607
+ * capability that is DECLARED but that no addon supplies — indistinguishable
17608
+ * to an operator from not having it, and the panel behind it would have
17609
+ * nothing to call. Both are `absent`.
17584
17610
  *
17585
- * Everything else is a plain call plus the data the panel renders from:
17586
- * `features` (which primitives are on), `actions` (the enabled dictionary)
17587
- * and the live `status` slice off the device proxy.
17611
+ * Exported for the unit test: the mapping is the whole decision, and it is
17612
+ * worth pinning without a React tree around it.
17588
17613
  */
17589
- /** The cadence the robot app drives at while a direction is held. */
17590
- var NAVIGATION_DRIVE_INTERVAL_MS = 1e3;
17591
- var DIRECTION_VECTORS$1 = {
17592
- forward: {
17593
- pan: 0,
17594
- tilt: 1
17595
- },
17596
- left: {
17597
- pan: -1,
17598
- tilt: 0
17599
- },
17600
- right: {
17601
- pan: 1,
17602
- tilt: 0
17603
- },
17604
- spin: {
17605
- pan: 0,
17606
- tilt: -1
17607
- }
17608
- };
17609
- function isAbsentProvider$1(err) {
17610
- const message = err instanceof Error ? err.message : String(err);
17611
- return /not available|no provider|NOT_FOUND|PRECONDITION/i.test(message);
17614
+ function classifyCapability(row) {
17615
+ if (row === null) return "absent";
17616
+ return row.providers.length > 0 ? "present" : "absent";
17612
17617
  }
17613
- function useNavigation(trpc, deviceId, hookOptions) {
17614
- const enabled = hookOptions?.enabled ?? true;
17615
- const nav = useDeviceProxy(trpc, enabled ? deviceId : null)?.navigation;
17616
- const status = useDeviceState(trpc, enabled ? deviceId : null, (s) => s.navigation);
17617
- const [features, setFeatures] = (0, react$1.useState)(null);
17618
- const [actions, setActions] = (0, react$1.useState)([]);
17619
- const [busy, setBusy] = (0, react$1.useState)(false);
17620
- const [error, setError] = (0, react$1.useState)(null);
17621
- const [driving, setDriving] = (0, react$1.useState)(null);
17622
- const driveTimer = (0, react$1.useRef)(null);
17623
- (0, react$1.useEffect)(() => {
17624
- if (!enabled || !nav) return;
17625
- let cancelled = false;
17626
- (async () => {
17627
- try {
17628
- const [nextFeatures, nextActions] = await Promise.all([nav.getFeatures({}), nav.listActions({})]);
17629
- if (cancelled) return;
17630
- setFeatures(nextFeatures);
17631
- setActions(nextActions);
17632
- } catch (err) {
17633
- if (cancelled || isAbsentProvider$1(err)) return;
17634
- setError(err instanceof Error ? err.message : String(err));
17635
- }
17636
- })();
17637
- return () => {
17638
- cancelled = true;
17639
- };
17640
- }, [nav, enabled]);
17641
- const wrap = (0, react$1.useCallback)(async (fn) => {
17642
- if (!enabled || !nav) return;
17643
- setError(null);
17644
- setBusy(true);
17645
- try {
17646
- await fn();
17647
- } catch (err) {
17648
- setError(err instanceof Error ? err.message : String(err));
17649
- } finally {
17650
- setBusy(false);
17651
- }
17652
- }, [enabled, nav]);
17653
- const clearTimer = (0, react$1.useCallback)(() => {
17654
- if (driveTimer.current !== null) {
17655
- clearInterval(driveTimer.current);
17656
- driveTimer.current = null;
17657
- }
17658
- }, []);
17659
- const stopDrive = (0, react$1.useCallback)(async () => {
17660
- clearTimer();
17661
- setDriving(null);
17662
- if (!nav) return;
17663
- await wrap(() => nav.stop({}));
17664
- }, [
17665
- clearTimer,
17666
- nav,
17667
- wrap
17668
- ]);
17669
- const startDrive = (0, react$1.useCallback)((direction) => {
17670
- if (!nav) return;
17671
- clearTimer();
17672
- setDriving(direction);
17673
- const vector = DIRECTION_VECTORS$1[direction];
17674
- const nudge = () => {
17675
- nav.move({
17676
- pan: vector.pan,
17677
- tilt: vector.tilt
17678
- }).catch((err) => {
17679
- setError(err instanceof Error ? err.message : String(err));
17680
- });
17681
- };
17682
- nudge();
17683
- driveTimer.current = setInterval(nudge, NAVIGATION_DRIVE_INTERVAL_MS);
17684
- }, [clearTimer, nav]);
17685
- (0, react$1.useEffect)(() => {
17686
- return () => {
17687
- if (driveTimer.current !== null) {
17688
- clearInterval(driveTimer.current);
17689
- driveTimer.current = null;
17690
- nav?.stop({}).catch(() => void 0);
17691
- }
17692
- };
17693
- }, [nav]);
17694
- const runAction = (0, react$1.useCallback)((actionId) => wrap(async () => nav?.runAction({ actionId })), [nav, wrap]);
17695
- const playSound = (0, react$1.useCallback)((soundId) => wrap(async () => nav?.playSound({ soundId })), [nav, wrap]);
17696
- const setLightOn = (0, react$1.useCallback)((on) => wrap(async () => nav?.setLightOn({ on })), [nav, wrap]);
17697
- const setLightMode = (0, react$1.useCallback)((mode, level) => wrap(async () => nav?.setLightMode(level === void 0 ? { mode } : {
17698
- mode,
17699
- level
17700
- })), [nav, wrap]);
17701
- const setLightLevel = (0, react$1.useCallback)((level) => wrap(async () => nav?.setLightLevel({ level })), [nav, wrap]);
17702
- return {
17703
- features,
17704
- actions,
17705
- status: status ?? null,
17706
- startDrive,
17707
- stopDrive,
17708
- runAction,
17709
- playSound,
17710
- setLightOn,
17711
- setLightMode,
17712
- setLightLevel,
17713
- busy,
17714
- error,
17715
- driving
17716
- };
17618
+ /**
17619
+ * Ask the hub's capability registry whether `capabilityName` has a provider.
17620
+ *
17621
+ * The four states map straight off React Query's `status`, so there is no
17622
+ * place for a nullish fallback to turn "no answer" into "no providers" — the
17623
+ * exact collapse this hook exists to make unrepresentable.
17624
+ */
17625
+ function useCapabilityPresence(capabilityName) {
17626
+ const query = trpc.capabilities.getCapability.useQuery({ name: capabilityName });
17627
+ if (query.status === "pending") return "pending";
17628
+ if (query.status === "error") return "unknown";
17629
+ return classifyCapability(query.data);
17717
17630
  }
17718
17631
  //#endregion
17719
- //#region src/composites/cap-settings/NavigationPanel.tsx
17720
- /** The cap's icon HINTS are lucide names; unknown hints fall back to a spark. */
17721
- var ICONS = {
17722
- play: Play,
17723
- pause: Pause,
17724
- hand: Hand,
17725
- home: House,
17726
- bell: Bell,
17727
- crosshair: Crosshair,
17728
- "paw-print": PawPrint,
17729
- footprints: Footprints,
17730
- droplets: Droplets,
17731
- "trash-2": Trash2,
17732
- flashlight: Flashlight,
17733
- "flashlight-off": FlashlightOff,
17734
- cat: Cat,
17735
- dog: Dog,
17736
- clock: Clock
17632
+ //#region ../../node_modules/class-variance-authority/dist/index.mjs
17633
+ /**
17634
+ * Copyright 2022 Joe Bell. All rights reserved.
17635
+ *
17636
+ * This file is licensed to you under the Apache License, Version 2.0
17637
+ * (the "License"); you may not use this file except in compliance with the
17638
+ * License. You may obtain a copy of the License at
17639
+ *
17640
+ * http://www.apache.org/licenses/LICENSE-2.0
17641
+ *
17642
+ * Unless required by applicable law or agreed to in writing, software
17643
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
17644
+ * WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the
17645
+ * License for the specific language governing permissions and limitations under
17646
+ * the License.
17647
+ */ var falsyToString = (value) => typeof value === "boolean" ? `${value}` : value === 0 ? "0" : value;
17648
+ var cx = clsx;
17649
+ var cva = (base, config) => (props) => {
17650
+ var _config_compoundVariants;
17651
+ if ((config === null || config === void 0 ? void 0 : config.variants) == null) return cx(base, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);
17652
+ const { variants, defaultVariants } = config;
17653
+ const getVariantClassNames = Object.keys(variants).map((variant) => {
17654
+ const variantProp = props === null || props === void 0 ? void 0 : props[variant];
17655
+ const defaultVariantProp = defaultVariants === null || defaultVariants === void 0 ? void 0 : defaultVariants[variant];
17656
+ if (variantProp === null) return null;
17657
+ const variantKey = falsyToString(variantProp) || falsyToString(defaultVariantProp);
17658
+ return variants[variant][variantKey];
17659
+ });
17660
+ const propsWithoutUndefined = props && Object.entries(props).reduce((acc, param) => {
17661
+ let [key, value] = param;
17662
+ if (value === void 0) return acc;
17663
+ acc[key] = value;
17664
+ return acc;
17665
+ }, {});
17666
+ return cx(base, getVariantClassNames, config === null || config === void 0 ? void 0 : (_config_compoundVariants = config.compoundVariants) === null || _config_compoundVariants === void 0 ? void 0 : _config_compoundVariants.reduce((acc, param) => {
17667
+ let { class: cvClass, className: cvClassName, ...compoundVariantOptions } = param;
17668
+ return Object.entries(compoundVariantOptions).every((param) => {
17669
+ let [key, value] = param;
17670
+ return Array.isArray(value) ? value.includes({
17671
+ ...defaultVariants,
17672
+ ...propsWithoutUndefined
17673
+ }[key]) : {
17674
+ ...defaultVariants,
17675
+ ...propsWithoutUndefined
17676
+ }[key] === value;
17677
+ }) ? [
17678
+ ...acc,
17679
+ cvClass,
17680
+ cvClassName
17681
+ ] : acc;
17682
+ }, []), props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);
17737
17683
  };
17738
- function iconFor(hint) {
17739
- return ICONS[hint] ?? Sparkles;
17740
- }
17741
- var DRIVE_BUTTONS = [
17742
- {
17743
- direction: "left",
17744
- label: "Turn left",
17745
- Icon: RotateCcw
17746
- },
17747
- {
17748
- direction: "forward",
17749
- label: "Forward",
17750
- Icon: ArrowUp
17751
- },
17752
- {
17753
- direction: "right",
17754
- label: "Turn right",
17755
- Icon: RotateCw
17684
+ //#endregion
17685
+ //#region src/primitives/badge.tsx
17686
+ var badgeVariants = cva("inline-flex items-center rounded-full text-xs font-medium px-2 py-0.5", {
17687
+ variants: {
17688
+ variant: {
17689
+ default: "bg-surface-hover text-foreground",
17690
+ success: "bg-success/15 text-success",
17691
+ warning: "bg-warning/15 text-warning",
17692
+ danger: "bg-danger/15 text-danger",
17693
+ info: "bg-info/15 text-info"
17694
+ },
17695
+ styleVariant: {
17696
+ solid: "",
17697
+ outline: "border bg-transparent"
17698
+ }
17756
17699
  },
17757
- {
17758
- direction: "spin",
17759
- label: "Turn around",
17760
- Icon: Undo2
17700
+ defaultVariants: {
17701
+ variant: "default",
17702
+ styleVariant: "solid"
17761
17703
  }
17762
- ];
17763
- var BUTTON = "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[10px] font-medium transition-colors bg-surface-elevated/40 text-foreground-subtle hover:bg-surface-hover hover:text-foreground disabled:opacity-50";
17764
- var BUTTON_ON = "bg-success/15 text-success hover:bg-success/25";
17765
- function EntryButton({ entry, disabled, onRun }) {
17766
- const Glyph = iconFor(entry.icon);
17767
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17768
- type: "button",
17769
- className: BUTTON,
17770
- disabled,
17771
- onClick: () => onRun(entry),
17772
- title: entry.label,
17773
- "aria-label": entry.label,
17774
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Glyph, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: entry.label })]
17775
- });
17776
- }
17777
- function NavigationPanel({ deviceId }) {
17778
- const system = useSystem();
17779
- const ready = Number.isFinite(deviceId);
17780
- const nav = useNavigation(system.trpcClient, ready ? deviceId : 0, { enabled: ready });
17781
- if (!ready) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17782
- className: "flex items-center justify-center h-full text-[11px] text-foreground-subtle italic",
17783
- children: [
17784
- "Device #",
17785
- deviceId,
17786
- " not loaded."
17787
- ]
17704
+ });
17705
+ var Badge = (0, react$1.forwardRef)(({ className, variant, style, ...props }, ref) => {
17706
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17707
+ ref,
17708
+ className: cn(badgeVariants({
17709
+ variant,
17710
+ styleVariant: style
17711
+ }), className),
17712
+ ...props
17788
17713
  });
17789
- const features = nav.features;
17790
- const actions = nav.actions.filter((entry) => entry.enabled && entry.kind === "action");
17791
- const sounds = nav.actions.filter((entry) => entry.enabled && entry.kind === "sound");
17792
- const status = nav.status;
17793
- const manual = status?.lightMode === "manual";
17794
- const run = (entry) => {
17795
- if (entry.kind === "sound") {
17796
- if (entry.soundId !== void 0) nav.playSound(entry.soundId);
17797
- return;
17714
+ });
17715
+ Badge.displayName = "Badge";
17716
+ //#endregion
17717
+ //#region src/primitives/button.tsx
17718
+ var buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium transition-colors cursor-pointer disabled:opacity-50 disabled:pointer-events-none", {
17719
+ variants: {
17720
+ variant: {
17721
+ primary: "bg-primary text-primary-foreground hover:bg-primary/90",
17722
+ secondary: "bg-surface text-foreground hover:bg-surface-hover",
17723
+ ghost: "hover:bg-surface-hover text-foreground-muted",
17724
+ danger: "bg-danger text-white hover:bg-danger/90",
17725
+ outline: "border border-border bg-transparent text-foreground hover:bg-surface-hover"
17726
+ },
17727
+ size: {
17728
+ sm: "h-7 px-2.5 text-xs gap-1.5",
17729
+ md: "h-8 px-3 text-sm gap-2",
17730
+ lg: "h-9 px-4 text-sm gap-2"
17798
17731
  }
17799
- nav.runAction(entry.id);
17800
- };
17801
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(WidgetPanel, {
17802
- title: "Navigation",
17803
- icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Navigation, { className: "h-3.5 w-3.5 text-foreground-subtle" }),
17804
- bodyClassName: "p-3 flex flex-col gap-3 overflow-y-auto",
17805
- children: [
17806
- status && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17807
- className: "flex flex-wrap items-center gap-1.5 text-[10px]",
17808
- children: [
17809
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17810
- className: "rounded-full bg-surface-elevated/60 px-2 py-0.5 text-foreground",
17811
- children: status.mode
17812
- }),
17813
- status.following && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17814
- className: "rounded-full bg-info/15 px-2 py-0.5 text-info",
17815
- children: "following"
17816
- }),
17817
- status.flash && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17818
- className: "rounded-full bg-warning/15 px-2 py-0.5 text-warning",
17819
- children: "light on"
17820
- })
17821
- ]
17822
- }),
17823
- features?.move && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
17824
- className: "flex flex-col gap-1.5",
17825
- children: [
17826
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17827
- className: "text-[10.5px] font-semibold text-foreground",
17828
- children: "Drive (hold)"
17829
- }),
17830
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17831
- className: "grid grid-cols-4 gap-1.5",
17832
- children: DRIVE_BUTTONS.map(({ direction, label, Icon }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17833
- type: "button",
17834
- className: cn(BUTTON, "justify-center select-none", nav.driving === direction && BUTTON_ON),
17835
- "aria-label": label,
17836
- title: label,
17837
- onPointerDown: () => nav.startDrive(direction),
17838
- onPointerUp: () => void nav.stopDrive(),
17839
- onPointerLeave: () => {
17840
- if (nav.driving === direction) nav.stopDrive();
17841
- },
17842
- onPointerCancel: () => void nav.stopDrive(),
17843
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { className: "h-4 w-4" })
17844
- }, direction))
17845
- }),
17846
- features.stop && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17847
- type: "button",
17848
- className: cn(BUTTON, "justify-center"),
17849
- onClick: () => void nav.stopDrive(),
17850
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Hand, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "Stop" })]
17851
- })
17852
- ]
17853
- }),
17854
- features?.runAction && actions.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
17855
- className: "flex flex-col gap-1.5",
17856
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17857
- className: "text-[10.5px] font-semibold text-foreground",
17858
- children: "Actions"
17859
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17860
- className: "flex flex-wrap gap-1.5",
17861
- children: actions.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EntryButton, {
17862
- entry,
17863
- disabled: nav.busy,
17864
- onRun: run
17865
- }, entry.id))
17866
- })]
17867
- }),
17868
- features?.playSound && sounds.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
17869
- className: "flex flex-col gap-1.5",
17870
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17871
- className: "text-[10.5px] font-semibold text-foreground",
17872
- children: "Sounds"
17873
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17874
- className: "flex flex-wrap gap-1.5",
17875
- children: sounds.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EntryButton, {
17876
- entry,
17877
- disabled: nav.busy,
17878
- onRun: run
17879
- }, entry.id))
17880
- })]
17881
- }),
17882
- features?.light && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
17883
- className: "flex flex-col gap-1.5 border-t border-border/40 pt-2",
17884
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17885
- className: "flex items-center justify-between",
17886
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17887
- className: "text-[10.5px] font-semibold text-foreground",
17888
- children: "Fill light"
17889
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17890
- type: "button",
17891
- className: cn(BUTTON, status?.flash && BUTTON_ON),
17892
- "aria-pressed": status?.flash === true,
17893
- disabled: nav.busy,
17894
- onClick: () => void nav.setLightOn(!(status?.flash === true)),
17895
- children: [status?.flash ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Flashlight, { className: "h-3.5 w-3.5" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FlashlightOff, { className: "h-3.5 w-3.5" }), status?.flash ? "On" : "Off"]
17896
- })]
17897
- }), features.lightMode && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17898
- className: "flex items-center gap-2",
17899
- children: [
17900
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
17901
- "aria-label": "Light mode",
17902
- className: "rounded-md bg-surface-elevated/40 px-2 py-1 text-[10px] text-foreground",
17903
- value: status?.lightMode ?? "auto",
17904
- disabled: nav.busy,
17905
- onChange: (event) => {
17906
- const mode = event.target.value === "manual" ? "manual" : "auto";
17907
- nav.setLightMode(mode, mode === "manual" ? status?.lightLevel : void 0);
17908
- },
17909
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
17910
- value: "auto",
17911
- children: "Auto"
17912
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
17913
- value: "manual",
17914
- children: "Manual"
17915
- })]
17916
- }),
17917
- manual && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
17918
- type: "range",
17919
- "aria-label": "Light level",
17920
- className: "flex-1",
17921
- min: _camstack_types.NAVIGATION_LIGHT_LEVEL_MIN,
17922
- max: _camstack_types.NAVIGATION_LIGHT_LEVEL_MAX,
17923
- step: 1,
17924
- value: status?.lightLevel ?? _camstack_types.NAVIGATION_LIGHT_LEVEL_MAX,
17925
- disabled: nav.busy,
17926
- onChange: (event) => void nav.setLightLevel(Number(event.target.value))
17927
- }),
17928
- manual && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17929
- className: "text-[10px] text-foreground-subtle w-8 text-right",
17930
- children: status?.lightLevel ?? _camstack_types.NAVIGATION_LIGHT_LEVEL_MAX
17931
- })
17932
- ]
17933
- })]
17934
- }),
17935
- nav.error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17936
- className: "text-[10px] text-danger",
17937
- children: nav.error
17938
- })
17939
- ]
17732
+ },
17733
+ defaultVariants: {
17734
+ variant: "primary",
17735
+ size: "sm"
17736
+ }
17737
+ });
17738
+ var Button = (0, react$1.forwardRef)(({ className, variant, size, ...props }, ref) => {
17739
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
17740
+ ref,
17741
+ className: cn(buttonVariants({
17742
+ variant,
17743
+ size
17744
+ }), className),
17745
+ ...props
17940
17746
  });
17941
- }
17747
+ });
17748
+ Button.displayName = "Button";
17942
17749
  //#endregion
17943
- //#region src/lib/cap-error.ts
17750
+ //#region src/primitives/select.tsx
17944
17751
  /**
17945
- * Shared capability-error utilities for the admin UI.
17946
- *
17947
- * The primary export `isAbsentProvider` lets UI components distinguish
17948
- * "this device doesn't have this cap" (expected — hide the widget) from
17949
- * "something went wrong while calling a present cap" (unexpected — surface
17950
- * the error to the operator).
17951
- *
17952
- * Detection strategy (two-tier):
17752
+ * A native `<select>` styled to match the rest of the field set.
17953
17753
  *
17954
- * 1. **Typed reason** (preferred): tRPC's errorFormatter augments the
17955
- * error shape with `data.capRouteReason` when the server throws a
17956
- * CapRouteError. Read it from `err.data?.capRouteReason` to avoid
17957
- * brittle string matching.
17754
+ * `appearance-none` plus an explicit `bg-surface` / `text-foreground` is what
17755
+ * makes it match — and it is also what USED to make a disabled one a lie. A
17756
+ * browser greys a disabled control by rendering its own background and text
17757
+ * colour; all three of those are overridden here, so a `disabled` dropdown
17758
+ * looked exactly like a live one and simply ignored the click. That is the
17759
+ * whole of the "the language selector does not open" report of 2026-08-15:
17760
+ * the AI section's switch was off, every control under it was correctly inert,
17761
+ * and nothing on screen said so.
17958
17762
  *
17959
- * 2. **Substring fallback** (transitional safety): older error paths
17960
- * that don't carry a typed reason still emit the human-readable
17961
- * message "provider not available". The fallback is ONLY engaged
17962
- * when `capRouteReason` is absent — future clean-up can drop it
17963
- * once every throw site has been migrated.
17964
- */
17965
- /**
17763
+ * `disabled:` classes belong HERE and not on each call site: there are dozens
17764
+ * of `<Select disabled>` in this app and the one that gets forgotten is the one
17765
+ * that generates the ticket. Guarded by `disabled-affordance.test.tsx`.
17766
+ */
17767
+ var Select = (0, react$1.forwardRef)(({ className, options, disabled, ...props }, ref) => {
17768
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17769
+ className: "relative w-full",
17770
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
17771
+ ref,
17772
+ disabled,
17773
+ className: cn("h-7 w-full px-2.5 text-xs bg-surface border border-border rounded-md text-foreground appearance-none pr-7 focus:outline-none focus:ring-1 focus:ring-primary", "disabled:opacity-50 disabled:cursor-not-allowed", className),
17774
+ ...props,
17775
+ children: options.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
17776
+ value: option.value,
17777
+ children: option.label
17778
+ }, option.value))
17779
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17780
+ className: cn("absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-foreground-muted", disabled === true ? "opacity-50" : ""),
17781
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3.5 w-3.5" })
17782
+ })]
17783
+ });
17784
+ });
17785
+ Select.displayName = "Select";
17786
+ //#endregion
17787
+ //#region src/primitives/skeleton.tsx
17788
+ var Skeleton = (0, react$1.forwardRef)(({ className, ...props }, ref) => {
17789
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
17790
+ ref,
17791
+ className: cn("animate-pulse bg-surface-hover rounded-md h-4 w-full", className),
17792
+ ...props
17793
+ });
17794
+ });
17795
+ Skeleton.displayName = "Skeleton";
17796
+ //#endregion
17797
+ //#region src/hooks/use-is-mobile.ts
17798
+ var MOBILE_QUERY = "(max-width: 767px)";
17799
+ /**
17800
+ * Mid breakpoint — below this width the sidebar collapses to an
17801
+ * icons-only rail to reclaim canvas space. Above it (and outside
17802
+ * mobile) the full sidebar renders. Threshold matches Tailwind `lg`
17803
+ * so sidebar fans out at the same point grids switch to multi-column.
17804
+ */
17805
+ var MID_QUERY = "(min-width: 768px) and (max-width: 1023px)";
17806
+ /**
17807
+ * `matchMedia` is universal in browsers but absent in bare DOM environments.
17808
+ * These hooks are called from shared primitives (`DataTable` picks its narrow
17809
+ * layout with one), so throwing here takes down the whole page that merely
17810
+ * rendered a table. Where the capability is missing, report the desktop
17811
+ * layout — a widescreen rendering is wrong-looking; a crashed page is gone.
17812
+ */
17813
+ function matchQuery(query) {
17814
+ if (typeof window.matchMedia !== "function") return null;
17815
+ return window.matchMedia(query);
17816
+ }
17817
+ function subscribeQuery(query) {
17818
+ return (callback) => {
17819
+ const mql = matchQuery(query);
17820
+ if (mql === null) return () => {};
17821
+ mql.addEventListener("change", callback);
17822
+ return () => mql.removeEventListener("change", callback);
17823
+ };
17824
+ }
17825
+ function getSnapshot(query) {
17826
+ return () => matchQuery(query)?.matches ?? false;
17827
+ }
17828
+ function getServerSnapshot() {
17829
+ return false;
17830
+ }
17831
+ function useIsMobile() {
17832
+ return (0, react$1.useSyncExternalStore)(subscribeQuery(MOBILE_QUERY), getSnapshot(MOBILE_QUERY), getServerSnapshot);
17833
+ }
17834
+ /**
17835
+ * True when the viewport is in the medium (`md`) Tailwind range —
17836
+ * 768px ≤ width < 1024px. Use to drive layouts that should pack denser
17837
+ * than full-desktop but more legibly than mobile (e.g. icons-only
17838
+ * sidebar rail, narrower stat tiles).
17839
+ */
17840
+ function useIsMidWidth() {
17841
+ return (0, react$1.useSyncExternalStore)(subscribeQuery(MID_QUERY), getSnapshot(MID_QUERY), getServerSnapshot);
17842
+ }
17843
+ //#endregion
17844
+ //#region src/composites/data-table-layout.ts
17845
+ /**
17846
+ * At this many columns and above, `auto` switches a narrow viewport to cards.
17847
+ * Three columns still fit a phone at a readable size; four do not.
17848
+ */
17849
+ var CARD_MODE_MIN_COLUMNS = 4;
17850
+ function resolveTableLayout({ mode, columnCount, isNarrow }) {
17851
+ if (!isNarrow) return "table";
17852
+ if (mode === "scroll") return "table";
17853
+ if (mode === "cards") return "cards";
17854
+ if (columnCount === 0) return "table";
17855
+ return columnCount >= 4 ? "cards" : "table";
17856
+ }
17857
+ //#endregion
17858
+ //#region src/lib/responsive.ts
17859
+ /**
17860
+ * Responsive design tokens — three-tier system.
17861
+ *
17862
+ * The admin-ui targets three width tiers; every component should pick
17863
+ * the right token from this file instead of hand-coding breakpoints.
17864
+ * Adopting the tokens uniformly is what makes the app feel consistent
17865
+ * across operator devices (mobile / tablet / desktop) and what unlocks
17866
+ * targeted tuning (e.g. shrink every label by 1px in one place).
17867
+ *
17868
+ * Tier definitions (Tailwind breakpoints):
17869
+ *
17870
+ * • **L1 — Compact** (`< 640px`, default = mobile portrait)
17871
+ * Single column, tight padding, smallest text, icon-only buttons
17872
+ * where labels can be inferred. The operator can still see every
17873
+ * stat / action — no horizontal scroll, no clipped text. The user
17874
+ * directive: "molta informazione in poco spazio quando la width è
17875
+ * bassa."
17876
+ *
17877
+ * • **L2 — Standard** (`sm` — `640–1023px`, tablet + small laptop)
17878
+ * 2-column grids, side panels become visible, button labels return.
17879
+ * Targets ~768px–1024px viewports — the most common operator sizes.
17880
+ *
17881
+ * • **L3 — Spacious** (`lg+` — `≥ 1024px`, desktop)
17882
+ * 4-column grids, generous padding, full-width header text,
17883
+ * side-by-side composite layouts. The operator has elbow room.
17884
+ *
17885
+ * Conventions:
17886
+ * - **Always start from L1** — the bare class is always the compact
17887
+ * variant, then `sm:` adds room at 640px, `lg:` at 1024px. No
17888
+ * desktop-first breakpoints; no max-width queries.
17889
+ * - **Token strings are static** — kept as `as const` so callers can
17890
+ * compose them via `cn()` / template literals without running a
17891
+ * class generator at runtime.
17892
+ * - **Pair the token with a comment** — the comment in the file
17893
+ * names every breakpoint it crosses, so a reader can predict the
17894
+ * layout without going to Tailwind docs.
17895
+ */
17896
+ /** Container padding for a card-style section (header + body). */
17897
+ var SECTION_CARD = "rounded-lg border border-border bg-surface overflow-hidden";
17898
+ /** Header strip inside a card (between border-b lines). */
17899
+ var SECTION_HEADER = "border-b border-border px-3 sm:px-4 py-1.5 sm:py-2";
17900
+ /** Body padding for a card-style section. */
17901
+ var SECTION_BODY = "p-2 sm:p-3 lg:p-4";
17902
+ /** Gap between cards in a vertical stack. */
17903
+ var STACK_GAP = "space-y-3 sm:space-y-4";
17904
+ /** Gap between cards in a grid (4 cells / row at lg). */
17905
+ var GRID_GAP = "gap-2 sm:gap-3";
17906
+ /** Row padding inside a divided list (e.g. InfoRow). */
17907
+ var LIST_ROW = "px-3 sm:px-4 py-1.5 sm:py-2";
17908
+ /** 1-up at L1, 2-up at L2, 4-up at L3 — quick-stat row. */
17909
+ var GRID_QUICK_STATS = "grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4";
17910
+ /** 1-up at L1, 2-up at L2+ — paired panels (motion + detection rules). */
17911
+ var GRID_PAIRED = "grid grid-cols-1 lg:grid-cols-2";
17912
+ /** Side-by-side layout that stacks below md (canvas + side panel). */
17913
+ var SPLIT_PANEL_OUTER = "flex flex-col-reverse md:flex-row";
17914
+ /** Sidebar narrow lane in a SPLIT_PANEL_OUTER layout. */
17915
+ var SPLIT_PANEL_SIDE = "w-full md:w-44 lg:w-52 md:flex-shrink-0 border-b md:border-b-0 md:border-l border-border";
17916
+ /**
17917
+ * The tier boundary at which a settings label/value row stops stacking.
17918
+ *
17919
+ * Below it (L1 — compact) the row is ONE column: label on its own line, value
17920
+ * on the next at full width. Above it the row is label-left / value-right as
17921
+ * before. The two-column form needs `SETTING_ROW_LABEL`'s lane plus a readable
17922
+ * value beside it; a phone has neither, and the failure mode is not "cramped"
17923
+ * but "the value is the part that gets cut" — the one thing the operator
17924
+ * opened the page to read.
17925
+ */
17926
+ var SETTING_ROW_STACK_BREAKPOINT = "sm";
17927
+ /** Outer row: single column at L1, label/value columns from `sm` up. */
17928
+ var SETTING_ROW = "flex min-w-0 flex-col gap-0.5 py-1.5 sm:flex-row sm:items-center";
17929
+ /** Label lane. Full width (and free to wrap) at L1; a fixed lane from `sm` up. */
17930
+ var SETTING_ROW_LABEL = "min-w-0 text-[11px] leading-tight text-foreground-subtle sm:w-32 sm:shrink-0 sm:pr-2";
17931
+ /**
17932
+ * Value lane — the value text plus its trailing affordances (copy / reveal).
17933
+ * `w-full` at L1 so the value owns the whole row once the label is above it;
17934
+ * `sm:flex-1` makes it share the row again from `sm` up.
17935
+ */
17936
+ var SETTING_ROW_VALUE = "flex w-full min-w-0 items-center gap-1 sm:flex-1";
17937
+ /**
17938
+ * The value text itself. `break-all` lets an opaque token (a key, a URL) wrap
17939
+ * at L1 instead of being cut mid-word; `sm:truncate` restores the single-line
17940
+ * desktop form, where the row is wide enough for truncation to be a choice
17941
+ * rather than data loss.
17942
+ */
17943
+ var SETTING_ROW_VALUE_TEXT = "min-w-0 break-all text-xs text-foreground sm:truncate";
17944
+ /** Section header label (uppercase tracking-wider). */
17945
+ var TEXT_SECTION_LABEL = "text-[10px] sm:text-[11px] font-semibold text-foreground uppercase tracking-wider";
17946
+ /** Field label inside a row. */
17947
+ var TEXT_FIELD_LABEL = "text-[11px] sm:text-xs text-foreground";
17948
+ /** Sub-label / hint text. */
17949
+ var TEXT_HINT = "text-[10px] text-foreground-subtle";
17950
+ /** Primary value (a metric, badge, …). */
17951
+ var TEXT_VALUE = "text-[11px] sm:text-xs font-medium text-foreground";
17952
+ /** Big metric value (StatCard). */
17953
+ var TEXT_METRIC = "text-base sm:text-lg font-semibold leading-tight text-foreground";
17954
+ /** Default compact button. */
17955
+ var BTN_COMPACT = "inline-flex items-center justify-center gap-1 rounded border border-border bg-surface px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-foreground hover:bg-surface-hover transition-colors disabled:opacity-40";
17956
+ /** Primary compact button. */
17957
+ var BTN_COMPACT_PRIMARY = "inline-flex items-center justify-center gap-1 rounded border border-primary/40 bg-primary/15 px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-primary hover:bg-primary/25 transition-colors disabled:opacity-40";
17958
+ /** Danger compact button. */
17959
+ var BTN_COMPACT_DANGER = "inline-flex items-center justify-center gap-1 rounded border border-danger/40 bg-danger/10 px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-danger hover:bg-danger/20 transition-colors disabled:opacity-40";
17960
+ /** Warning compact button (e.g. cancel-drawing). */
17961
+ var BTN_COMPACT_WARNING = "inline-flex items-center justify-center gap-1 rounded border border-warning/40 bg-warning/10 px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-warning hover:bg-warning/20 transition-colors disabled:opacity-40";
17962
+ /** Compact text/select/number input. */
17963
+ var INPUT_COMPACT = "w-full rounded border border-border bg-background px-1.5 py-0.5 sm:px-2 sm:py-1 text-[11px] sm:text-xs text-foreground focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none";
17964
+ /** Chip-style toggle (zone / class selectors in ZoneRulesEditor). */
17965
+ var CHIP_BASE = "inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors";
17966
+ var CHIP_ACTIVE = "border-primary/50 bg-primary/15 text-primary";
17967
+ var CHIP_INACTIVE = "border-border bg-surface text-foreground-subtle hover:bg-surface-hover";
17968
+ //#endregion
17969
+ //#region src/composites/setting-row.tsx
17970
+ function SettingRow({ label, children, actions, elements = "plain", className, valueClassName }) {
17971
+ const LabelTag = elements === "description" ? "dt" : "span";
17972
+ const ValueTag = elements === "description" ? "dd" : "div";
17973
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17974
+ "data-setting-row": "",
17975
+ className: cn(SETTING_ROW, className),
17976
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelTag, {
17977
+ "data-setting-row-label": "",
17978
+ className: SETTING_ROW_LABEL,
17979
+ children: label
17980
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(ValueTag, {
17981
+ "data-setting-row-value": "",
17982
+ className: SETTING_ROW_VALUE,
17983
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17984
+ "data-setting-row-value-text": "",
17985
+ className: cn(SETTING_ROW_VALUE_TEXT, valueClassName),
17986
+ children
17987
+ }), actions !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17988
+ className: "flex shrink-0 items-center gap-0.5",
17989
+ children: actions
17990
+ })]
17991
+ })]
17992
+ });
17993
+ }
17994
+ //#endregion
17995
+ //#region src/composites/sort-header-button.tsx
17996
+ function SortHeaderButton({ label, active, dir, onSort }) {
17997
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17998
+ type: "button",
17999
+ onClick: onSort,
18000
+ className: "group/sort inline-flex items-center gap-0.5 hover:text-foreground transition-colors",
18001
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }), active ? dir === "asc" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronUp, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronUp, { className: cn("h-3 w-3 flex-shrink-0 opacity-0 transition-opacity", "group-hover/sort:opacity-30") })]
18002
+ });
18003
+ }
18004
+ //#endregion
18005
+ //#region src/composites/table-sort.ts
18006
+ /**
18007
+ * The toggle rule, and the only copy of it.
18008
+ *
18009
+ * none → asc → desc → none, and a click on a DIFFERENT column starts that
18010
+ * column fresh at `asc`. `sameColumn` says whether the clicked column is the
18011
+ * one currently sorted; `currentDir` is its direction (`null` when nothing is
18012
+ * sorted). Returns the next direction, or `null` for "unsorted".
18013
+ *
18014
+ * Split out from {@link nextTableSort} so the device list — whose sort state
18015
+ * keys columns by `columnId`, not `columnKey` — can reuse the rule without a
18016
+ * cast and without a second copy of it.
18017
+ */
18018
+ function nextSortDirection(sameColumn, currentDir) {
18019
+ if (!sameColumn || currentDir === null) return "asc";
18020
+ return currentDir === "asc" ? "desc" : null;
18021
+ }
18022
+ /**
18023
+ * Toggle a column's sort on header click: none → asc → desc → none.
18024
+ * Clicking a DIFFERENT column starts it fresh at `asc`.
18025
+ */
18026
+ function nextTableSort(current, columnKey) {
18027
+ const dir = nextSortDirection(current !== null && current.columnKey === columnKey, current === null ? null : current.dir);
18028
+ return dir === null ? null : {
18029
+ columnKey,
18030
+ dir
18031
+ };
18032
+ }
18033
+ /**
18034
+ * Ascending comparison of two cell values.
18035
+ *
18036
+ * Numbers compare numerically and strings compare with `localeCompare` — a
18037
+ * numeric column must never be ordered as text ("100" before "9"), and a text
18038
+ * column must respect the locale's collation. A mixed column (one row a
18039
+ * number, another a string) falls back to string comparison of both, which is
18040
+ * the only total order available; it is not a shape any caller should build on
18041
+ * purpose.
18042
+ *
18043
+ * `null` is NOT handled here — {@link sortRowsByColumn} pulls it out first,
18044
+ * because its placement is direction-independent and a comparator that is
18045
+ * multiplied by the direction factor cannot express that.
18046
+ */
18047
+ function compareValues(a, b) {
18048
+ if (typeof a === "number" && typeof b === "number") return a - b;
18049
+ return String(a).localeCompare(String(b));
18050
+ }
18051
+ /**
18052
+ * Return a NEW array of `rows` ordered by `sort`.
18053
+ *
18054
+ * `valueOf` is the column's `sortValue` — it is called once per row per
18055
+ * comparison, so it must be cheap (read a field, do not format a string).
18056
+ * `sort === null` ⇒ a fresh shallow copy in the incoming order.
18057
+ */
18058
+ function sortRowsByColumn(rows, sort, valueOf) {
18059
+ if (sort === null) return [...rows];
18060
+ const factor = sort.dir === "asc" ? 1 : -1;
18061
+ return rows.toSorted((a, b) => {
18062
+ const av = valueOf(a);
18063
+ const bv = valueOf(b);
18064
+ if (av === null || bv === null) {
18065
+ if (av === bv) return 0;
18066
+ return av === null ? 1 : -1;
18067
+ }
18068
+ return factor * compareValues(av, bv);
18069
+ });
18070
+ }
18071
+ /** Derive the `<th>` `aria-sort` for `columnKey` from the table's sort state. */
18072
+ function ariaSortForColumn(sort, columnKey) {
18073
+ if (sort === null || sort.columnKey !== columnKey) return "none";
18074
+ return sort.dir === "asc" ? "ascending" : "descending";
18075
+ }
18076
+ //#endregion
18077
+ //#region src/composites/data-table.tsx
18078
+ /**
18079
+ * DataTable — shared responsive table for admin pages.
18080
+ *
18081
+ * Recipes baked in:
18082
+ * - `overflow-x-auto` wrapper so narrow viewports don't clip the
18083
+ * right edge (the rounded border survives the scroll).
18084
+ * - `min-w-[Npx]` on the inner table so columns don't squash below
18085
+ * readable width before the scroll kicks in.
18086
+ * - `whitespace-nowrap` on headers — column titles never wrap to
18087
+ * two lines (`AUTO-KILL` was the regression that triggered this).
18088
+ * - Themed `bg-surface` + `border-border` so contrast works in both
18089
+ * light and dark themes.
18090
+ *
18091
+ * Column model: caller supplies `columns: DataColumn<T>[]` and
18092
+ * `rows: T[]`. Each column declares `header`, optional `align`,
18093
+ * optional `nowrap`, and a `render(row) => ReactNode` that produces
18094
+ * the cell content.
18095
+ *
18096
+ * <DataTable
18097
+ * columns={[
18098
+ * { key: 'username', header: 'Username', render: (u) => u.username },
18099
+ * { key: 'role', header: 'Role', render: (u) => <RoleBadge .../> },
18100
+ * { key: 'actions', header: '', align: 'right', render: (u) => <Actions .../> },
18101
+ * ]}
18102
+ * rows={users}
18103
+ * minWidthPx={560}
18104
+ * />
18105
+ *
18106
+ * If finer header markup is needed, pass `headerRender` instead of
18107
+ * `header` (e.g. for a sortable click affordance).
18108
+ *
18109
+ * ── Narrow viewports ──────────────────────────────────────────────────────
18110
+ * The table stays a table. The wrapper is `overflow-x-auto`, so a phone
18111
+ * scrolls the columns sideways INSIDE the table — the page body never moves
18112
+ * sideways, and the cells stay one line (`whitespace-nowrap`). That is the
18113
+ * default (`mobileMode="scroll"`). Cards are opt-in via `mobileMode="cards"`;
18114
+ * `auto` still applies the old column-count rule for a surface that wants it.
18115
+ * See `resolveTableLayout`.
18116
+ *
18117
+ * ── Scrolling the BODY, not the page ──────────────────────────────────────
18118
+ * `maxBodyHeight` caps the table and scrolls its rows INSIDE the container
18119
+ * with the column header pinned. It is ONE prop, not two, because "this list
18120
+ * scrolls internally" and "its header stays visible" are the same decision: a
18121
+ * capped table whose header scrolls away is a list of unlabelled cells.
18122
+ *
18123
+ * Use it wherever the row count is open-ended (rules, targets, delivery
18124
+ * history) so the page keeps its own header, filters and actions in view while
18125
+ * the operator moves through the rows.
18126
+ */
18127
+ /**
18128
+ * The default cap for an internally-scrolling table.
18129
+ *
18130
+ * `60vh` leaves the page header, its filters and its actions on screen at
18131
+ * every viewport while still showing ~10 rows — and it is a fraction, so it
18132
+ * does not need a breakpoint of its own.
18133
+ */
18134
+ var TABLE_SCROLL_MAX_H = "max-h-[60vh]";
18135
+ var ALIGN_CLASS = {
18136
+ left: "text-left",
18137
+ right: "text-right",
18138
+ center: "text-center"
18139
+ };
18140
+ function DataTable({ columns, rows, rowKey, onRowClick, minWidthPx = 480, emptyMessage, className, bordered = true, rowClassName, mobileMode = "scroll", maxBodyHeight, defaultSort }) {
18141
+ const isNarrow = useIsMobile();
18142
+ const [sort, setSort] = (0, react$1.useState)(defaultSort ?? null);
18143
+ const layout = resolveTableLayout({
18144
+ mode: mobileMode,
18145
+ columnCount: columns.length,
18146
+ isNarrow
18147
+ });
18148
+ const scrolls = maxBodyHeight !== void 0;
18149
+ const sortedColumn = (0, react$1.useMemo)(() => sort === null ? void 0 : columns.find((col) => col.key === sort.columnKey && col.sortValue !== void 0), [columns, sort]);
18150
+ const sortValue = sortedColumn?.sortValue;
18151
+ const displayRows = (0, react$1.useMemo)(() => sortValue === void 0 ? rows : sortRowsByColumn(rows, sort, sortValue), [
18152
+ rows,
18153
+ sort,
18154
+ sortValue
18155
+ ]);
18156
+ const activeSort = sortedColumn === void 0 ? null : sort;
18157
+ if (rows.length === 0 && emptyMessage) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18158
+ className: `rounded-lg ${bordered ? "border border-border" : ""} bg-surface px-3 py-4 text-xs text-foreground-subtle text-center ${className ?? ""}`,
18159
+ children: emptyMessage
18160
+ });
18161
+ if (rows.length === 0) return null;
18162
+ if (layout === "cards") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18163
+ className: `space-y-2 ${scrolls ? `${maxBodyHeight} overflow-y-auto` : ""} ${className ?? ""}`,
18164
+ children: displayRows.map((row, rowIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataTableCard, {
18165
+ row,
18166
+ rowIndex,
18167
+ columns,
18168
+ bordered,
18169
+ onRowClick,
18170
+ rowClassName
18171
+ }, rowKey ? rowKey(row, rowIndex) : rowIndex))
18172
+ });
18173
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18174
+ className: [
18175
+ "rounded-lg",
18176
+ bordered ? "border border-border" : "",
18177
+ "bg-surface overflow-x-auto",
18178
+ scrolls ? `${maxBodyHeight} overflow-y-auto` : "",
18179
+ className ?? ""
18180
+ ].filter(Boolean).join(" "),
18181
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
18182
+ className: `w-full text-xs ${scrolls ? "border-separate border-spacing-0" : ""}`,
18183
+ style: { minWidth: `${minWidthPx}px` },
18184
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", {
18185
+ className: "bg-surface",
18186
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", { children: columns.map((col) => {
18187
+ const align = col.align ?? "left";
18188
+ const nowrap = col.nowrap ?? true;
18189
+ const sortable = col.sortValue !== void 0;
18190
+ const isSorted = activeSort !== null && activeSort.columnKey === col.key;
18191
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
18192
+ "aria-sort": sortable ? ariaSortForColumn(activeSort, col.key) : void 0,
18193
+ title: sortable ? void 0 : col.notSortableReason,
18194
+ className: [
18195
+ "px-3 py-2 text-foreground-subtle font-medium bg-surface border-b border-border",
18196
+ scrolls ? "sticky top-0 z-10" : "",
18197
+ ALIGN_CLASS[align],
18198
+ nowrap ? "whitespace-nowrap" : "",
18199
+ col.width ?? ""
18200
+ ].filter(Boolean).join(" "),
18201
+ children: sortable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SortHeaderButton, {
18202
+ label: col.headerRender ? col.headerRender() : col.header,
18203
+ active: isSorted,
18204
+ dir: isSorted && activeSort !== null ? activeSort.dir : "asc",
18205
+ onSort: () => setSort((prev) => nextTableSort(prev, col.key))
18206
+ }) : col.headerRender ? col.headerRender() : col.header
18207
+ }, col.key);
18208
+ }) })
18209
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: displayRows.map((row, rowIndex) => {
18210
+ const key = rowKey ? rowKey(row, rowIndex) : rowIndex;
18211
+ const extra = rowClassName?.(row, rowIndex) ?? "";
18212
+ const interactive = onRowClick !== void 0;
18213
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", {
18214
+ onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
18215
+ className: [interactive ? "cursor-pointer hover:bg-primary/5" : "hover:bg-primary/5", extra].filter(Boolean).join(" "),
18216
+ children: columns.map((col) => {
18217
+ const align = col.align ?? "left";
18218
+ const nowrap = col.nowrap ?? true;
18219
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
18220
+ className: [
18221
+ "px-3 py-2 text-foreground border-b border-border",
18222
+ ALIGN_CLASS[align],
18223
+ nowrap ? "whitespace-nowrap" : ""
18224
+ ].filter(Boolean).join(" "),
18225
+ children: col.render(row, rowIndex)
18226
+ }, col.key);
18227
+ })
18228
+ }, key);
18229
+ }) })]
18230
+ })
18231
+ });
18232
+ }
18233
+ /**
18234
+ * One row as a card. Columns that carry a plain-text `header` become labelled
18235
+ * `SettingRow`s — the label is the column title and the value gets the full
18236
+ * card width. Columns with no text header (an actions column, a
18237
+ * `headerRender`-only column) have no label to show, so they render as a
18238
+ * full-width strip at the foot of the card.
18239
+ */
18240
+ function DataTableCard({ row, rowIndex, columns, bordered, onRowClick, rowClassName }) {
18241
+ const labelled = columns.filter((col) => col.header !== void 0 && col.header !== "");
18242
+ const unlabelled = columns.filter((col) => col.header === void 0 || col.header === "");
18243
+ const interactive = onRowClick !== void 0;
18244
+ const extra = rowClassName?.(row, rowIndex) ?? "";
18245
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18246
+ "data-data-table-card": "",
18247
+ onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
18248
+ className: [
18249
+ "rounded-lg bg-surface px-3 py-1.5 divide-y divide-border-subtle",
18250
+ bordered ? "border border-border" : "",
18251
+ interactive ? "cursor-pointer hover:bg-primary/5" : "",
18252
+ extra
18253
+ ].filter(Boolean).join(" "),
18254
+ children: [labelled.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingRow, {
18255
+ label: col.header,
18256
+ children: col.render(row, rowIndex)
18257
+ }, col.key)), unlabelled.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18258
+ className: "flex flex-wrap items-center justify-end gap-2 py-1.5",
18259
+ children: unlabelled.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: col.render(row, rowIndex) }, col.key))
18260
+ })]
18261
+ });
18262
+ }
18263
+ //#endregion
18264
+ //#region src/composites/device-list/pagination.tsx
18265
+ var NEIGHBORS = 2;
18266
+ function computePageButtons(currentPage, totalPages) {
18267
+ if (totalPages <= 1) return [{
18268
+ kind: "page",
18269
+ n: 1
18270
+ }];
18271
+ if (totalPages <= 7) {
18272
+ const all = [];
18273
+ for (let i = 1; i <= totalPages; i++) all.push({
18274
+ kind: "page",
18275
+ n: i
18276
+ });
18277
+ return all;
18278
+ }
18279
+ const result = [];
18280
+ const leftBound = Math.max(2, currentPage - NEIGHBORS);
18281
+ const rightBound = Math.min(totalPages - 1, currentPage + NEIGHBORS);
18282
+ result.push({
18283
+ kind: "page",
18284
+ n: 1
18285
+ });
18286
+ if (leftBound > 2) result.push({
18287
+ kind: "ellipsis",
18288
+ side: "left"
18289
+ });
18290
+ for (let i = leftBound; i <= rightBound; i++) result.push({
18291
+ kind: "page",
18292
+ n: i
18293
+ });
18294
+ if (rightBound < totalPages - 1) result.push({
18295
+ kind: "ellipsis",
18296
+ side: "right"
18297
+ });
18298
+ result.push({
18299
+ kind: "page",
18300
+ n: totalPages
18301
+ });
18302
+ return result;
18303
+ }
18304
+ function Pagination({ currentPage, totalItems, pageSize, onPageChange, itemNoun = "devices" }) {
18305
+ if (totalItems <= pageSize) return null;
18306
+ const totalPages = Math.ceil(totalItems / pageSize);
18307
+ const startIdx = (currentPage - 1) * pageSize + 1;
18308
+ const endIdx = Math.min(currentPage * pageSize, totalItems);
18309
+ const buttons = computePageButtons(currentPage, totalPages);
18310
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18311
+ className: "flex items-center justify-between gap-4 flex-wrap pt-2 text-[11px] text-foreground-subtle",
18312
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
18313
+ "Showing ",
18314
+ startIdx,
18315
+ "–",
18316
+ endIdx,
18317
+ " of ",
18318
+ totalItems,
18319
+ " ",
18320
+ itemNoun
18321
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
18322
+ className: "inline-flex items-center gap-1",
18323
+ children: [
18324
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18325
+ type: "button",
18326
+ "aria-label": "Previous page",
18327
+ disabled: currentPage === 1,
18328
+ onClick: () => onPageChange(currentPage - 1),
18329
+ className: cn("inline-flex items-center justify-center h-7 w-7 rounded border border-border bg-surface text-foreground-subtle", "hover:text-foreground hover:bg-surface-hover transition-colors", "disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-surface"),
18330
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronLeft, { className: "h-3 w-3" })
18331
+ }),
18332
+ buttons.map((b, i) => {
18333
+ if (b.kind === "ellipsis") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18334
+ className: "px-1 text-foreground-subtle/60 select-none",
18335
+ children: "…"
18336
+ }, `e-${i}`);
18337
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18338
+ type: "button",
18339
+ onClick: () => onPageChange(b.n),
18340
+ className: cn("inline-flex items-center justify-center h-7 min-w-7 px-1.5 rounded border text-[10.5px] font-medium transition-colors", b.n === currentPage ? "border-primary/40 bg-primary/10 text-primary" : "border-border bg-surface text-foreground-subtle hover:text-foreground hover:bg-surface-hover"),
18341
+ children: b.n
18342
+ }, `p-${b.n}`);
18343
+ }),
18344
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18345
+ type: "button",
18346
+ "aria-label": "Next page",
18347
+ disabled: currentPage === totalPages,
18348
+ onClick: () => onPageChange(currentPage + 1),
18349
+ className: cn("inline-flex items-center justify-center h-7 w-7 rounded border border-border bg-surface text-foreground-subtle", "hover:text-foreground hover:bg-surface-hover transition-colors", "disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-surface"),
18350
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronRight, { className: "h-3 w-3" })
18351
+ })
18352
+ ]
18353
+ })]
18354
+ });
18355
+ }
18356
+ //#endregion
18357
+ //#region src/composites/empty-state.tsx
18358
+ function EmptyState({ icon: Icon, title, description, action, className }) {
18359
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18360
+ className: cn("flex flex-col items-center justify-center gap-3 py-12", className),
18361
+ children: [
18362
+ Icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
18363
+ className: "h-12 w-12 text-foreground-subtle",
18364
+ "aria-hidden": "true"
18365
+ }),
18366
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18367
+ className: "flex flex-col items-center gap-1 text-center",
18368
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
18369
+ className: "text-foreground-muted text-sm font-medium",
18370
+ children: title
18371
+ }), description && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
18372
+ className: "text-foreground-subtle text-xs max-w-xs",
18373
+ children: description
18374
+ })]
18375
+ }),
18376
+ action && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18377
+ className: "mt-1",
18378
+ children: action
18379
+ })
18380
+ ]
18381
+ });
18382
+ }
18383
+ //#endregion
18384
+ //#region src/composites/error-box.tsx
18385
+ var VARIANT_STYLES$1 = {
18386
+ danger: {
18387
+ box: "border-danger/40 bg-danger/10",
18388
+ icon: "text-danger",
18389
+ title: "text-danger",
18390
+ message: "text-danger"
18391
+ },
18392
+ warning: {
18393
+ box: "border-amber-500/40 bg-amber-500/10",
18394
+ icon: "text-amber-600 dark:text-amber-400",
18395
+ title: "text-amber-700 dark:text-amber-300",
18396
+ message: "text-amber-700 dark:text-amber-300"
18397
+ }
18398
+ };
18399
+ function ErrorBox({ message, title, onRetry, action, variant = "danger", className }) {
18400
+ const styles = VARIANT_STYLES$1[variant];
18401
+ const Icon = variant === "warning" ? TriangleAlert : CircleAlert;
18402
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18403
+ role: "alert",
18404
+ className: cn("flex items-start gap-2.5 rounded border px-3 py-2.5", styles.box, className),
18405
+ children: [
18406
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
18407
+ className: cn("h-4 w-4 mt-0.5 shrink-0", styles.icon),
18408
+ "aria-hidden": "true"
18409
+ }),
18410
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18411
+ className: "flex-1 min-w-0",
18412
+ children: [title && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
18413
+ className: cn("text-xs font-semibold", styles.title),
18414
+ children: title
18415
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18416
+ className: cn("text-xs break-words", styles.message, title ? "mt-0.5" : ""),
18417
+ children: message
18418
+ })]
18419
+ }),
18420
+ action ?? (onRetry && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
18421
+ type: "button",
18422
+ onClick: onRetry,
18423
+ className: cn("shrink-0 inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] font-medium", "border border-current/30 hover:bg-current/10 transition-colors", styles.message),
18424
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RefreshCw, {
18425
+ className: "h-3 w-3",
18426
+ "aria-hidden": "true"
18427
+ }), "Retry"]
18428
+ }))
18429
+ ]
18430
+ });
18431
+ }
18432
+ //#endregion
18433
+ //#region src/contexts/vod-playback.tsx
18434
+ /**
18435
+ * VodPlayback — shared context coordinating recorded (VOD) playback between a
18436
+ * widget/section (the Recording panel) and the hero StreamPanel.
18437
+ *
18438
+ * A CONTROLLER (the Recording widget) calls `play({ url, label })`; the hero
18439
+ * StreamPanel CONSUMES `request` and switches into VOD mode (HLS playback
18440
+ * through the hub data-plane) instead of the live WebRTC stream. `clear()`
18441
+ * returns the hero to live.
18442
+ *
18443
+ * Built on `createSharedContext` (NOT a plain `createContext`) so it crosses
18444
+ * the Module-Federation boundary: an addon-shipped widget consuming
18445
+ * `useVodPlayback()` resolves the SAME provider instance the host mounted —
18446
+ * the same mechanism `player-overlays` / `zone-editing` use to let federated
18447
+ * widgets (zone-editor, privacy-mask) drive the live player. Consumers used
18448
+ * outside a provider get the inert default (no-op), so a StreamPanel mounted
18449
+ * on a page without the Recording widget stays live-only without crashing.
18450
+ */
18451
+ var VodPlaybackContext = createSharedContext("camstack:vod-playback", {
18452
+ request: null,
18453
+ play: () => {},
18454
+ clear: () => {}
18455
+ });
18456
+ function VodPlaybackProvider({ children }) {
18457
+ const [request, setRequest] = (0, react$1.useState)(null);
18458
+ const value = (0, react$1.useMemo)(() => ({
18459
+ request,
18460
+ play: (req) => setRequest(req),
18461
+ clear: () => setRequest(null)
18462
+ }), [request]);
18463
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VodPlaybackContext.Provider, {
18464
+ value,
18465
+ children
18466
+ });
18467
+ }
18468
+ /** @returns the VOD playback controller (inert no-op outside a provider). */
18469
+ function useVodPlayback() {
18470
+ return (0, react$1.useContext)(VodPlaybackContext);
18471
+ }
18472
+ //#endregion
18473
+ //#region src/primitives/card.tsx
18474
+ var cardVariants = cva("rounded-lg p-3", {
18475
+ variants: { variant: {
18476
+ flat: "bg-surface",
18477
+ bordered: "bg-surface border border-border"
18478
+ } },
18479
+ defaultVariants: { variant: "bordered" }
18480
+ });
18481
+ var Card = (0, react$1.forwardRef)(({ className, variant, ...props }, ref) => {
18482
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18483
+ ref,
18484
+ className: cn(cardVariants({ variant }), className),
18485
+ ...props
18486
+ });
18487
+ });
18488
+ Card.displayName = "Card";
18489
+ //#endregion
18490
+ //#region src/composites/clips/ClipPanel.tsx
18491
+ /**
18492
+ * The selected clip — what it is, how it plays, and how it is saved.
18493
+ *
18494
+ * **No second player.** `getClipPlayback` answers `{ playbackUrl, format,
18495
+ * served }` and the panel hands it to the SAME hero the recordings timeline
18496
+ * drives: `vod.play({ url, format })` → `StreamPanel playbackUrl/playbackFormat`
18497
+ * → `HlsVideo`'s `mp4` branch (`video.src`, no hls.js). Building a clip player
18498
+ * here is the thing D549 decision 22 exists to refuse.
18499
+ *
18500
+ * **`format` travels.** An MP4 handed to hls.js is parsed as a manifest and
18501
+ * fails, so the format the provider named is forwarded verbatim and never
18502
+ * defaulted here.
18503
+ *
18504
+ * **`served` is shown.** A clip that holds only one twin answers with the one
18505
+ * it has, and the surface SAYS so — a missing main twin must never be served
18506
+ * silently as if it were the asked-for quality (D549 decision 15). Both ids
18507
+ * are already on the row, so changing quality re-asks the hub and never
18508
+ * re-searches the camera.
18509
+ *
18510
+ * **Nothing is appended to the URL.** The route is `access:'authenticated'`
18511
+ * and the session cookie authenticates it (D549 decision 22); the same URL is
18512
+ * the download.
18513
+ */
18514
+ var PROFILES = [
18515
+ "low",
18516
+ "mid",
18517
+ "high"
18518
+ ];
18519
+ function ClipPanel({ deviceId, clip, profile, onProfileChange }) {
18520
+ const vod = useVodPlayback();
18521
+ const playback = useVideoclipsGetClipPlayback({
18522
+ deviceId,
18523
+ clipId: clip.id,
18524
+ profile
18525
+ });
18526
+ const data = playback.data;
18527
+ const label = `${new Date(clip.timeRange.startMs).toLocaleString()} · ${clip.source}`;
18528
+ (0, react$1.useEffect)(() => {
18529
+ if (data === void 0) return;
18530
+ vod.play({
18531
+ url: hubPath(data.playbackUrl),
18532
+ ...data.format === void 0 ? {} : { format: data.format },
18533
+ label
18534
+ });
18535
+ }, [data, label]);
18536
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Card, {
18537
+ className: "space-y-2 text-sm",
18538
+ children: [
18539
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18540
+ className: "font-medium text-foreground",
18541
+ children: label
18542
+ }),
18543
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18544
+ className: "flex items-center gap-1",
18545
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18546
+ className: "mr-1 text-[10px] uppercase tracking-wide text-foreground-subtle",
18547
+ children: "Quality"
18548
+ }), PROFILES.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
18549
+ variant: option === profile ? "primary" : "secondary",
18550
+ "aria-pressed": option === profile,
18551
+ onClick: () => onProfileChange(option),
18552
+ children: option
18553
+ }, option))]
18554
+ }),
18555
+ playback.status === "pending" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18556
+ className: "space-y-1",
18557
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18558
+ role: "status",
18559
+ className: "text-xs text-foreground-subtle",
18560
+ children: "Preparing the clip — the file is fetched and muxed once…"
18561
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-4 w-48" })]
18562
+ }) : playback.status === "error" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
18563
+ title: "This clip could not be prepared",
18564
+ message: playback.error?.message ?? "no reason given"
18565
+ }) : data === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18566
+ className: "space-y-2",
18567
+ children: [data.served === void 0 || data.served === profile ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
18568
+ variant: "warning",
18569
+ message: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
18570
+ "Served the ",
18571
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: data.served }),
18572
+ " stream — this clip has no ",
18573
+ profile,
18574
+ " ",
18575
+ "twin."
18576
+ ] })
18577
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
18578
+ href: hubPath(data.playbackUrl),
18579
+ download: true,
18580
+ className: "inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:underline",
18581
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Download, {
18582
+ className: "h-3.5 w-3.5",
18583
+ "aria-hidden": "true"
18584
+ }), "Download this clip"]
18585
+ })]
18586
+ })
18587
+ ]
18588
+ });
18589
+ }
18590
+ //#endregion
18591
+ //#region src/composites/clips/clip-tile-media.ts
18592
+ /**
18593
+ * Why no still exists, in words. Keyed by the provider's own vocabulary so a
18594
+ * reason this table does not know still reaches the operator verbatim.
18595
+ */
18596
+ var REFUSAL_TEXT = {
18597
+ sleeping: "The camera is asleep — a still would cost a wake",
18598
+ "camera-refused": "The camera refused the still request",
18599
+ "no-keyframe": "No decodable frame inside this window",
18600
+ unsupported: "This source cannot produce stills",
18601
+ "queue-full": "The camera is busy — its still queue is full",
18602
+ "no-catalog-row": "This clip is no longer in the catalog"
18603
+ };
18604
+ function refusal(reason) {
18605
+ return {
18606
+ kind: "unavailable",
18607
+ reason,
18608
+ text: REFUSAL_TEXT[reason] ?? `Refused: ${reason}`
18609
+ };
18610
+ }
18611
+ /** What to draw for one clip's tile. */
18612
+ function resolveTileMedia(clip) {
18613
+ if (clip.thumbnail !== void 0) return {
18614
+ kind: "vouched",
18615
+ url: clip.thumbnail
18616
+ };
18617
+ if (clip.thumbnailUnavailable !== void 0) return refusal(clip.thumbnailUnavailable.reason);
18618
+ if (clip.thumbnailMint !== void 0) return {
18619
+ kind: "mint",
18620
+ url: clip.thumbnailMint
18621
+ };
18622
+ return {
18623
+ kind: "none",
18624
+ text: "This clip carries no still and offered none"
18625
+ };
18626
+ }
18627
+ /**
18628
+ * A mint that answered 204 — latch its `x-camstack-reason` to this instant.
18629
+ *
18630
+ * The refusal is about NOW (a full queue, a sleeping camera), so it replaces
18631
+ * the offer for as long as the tile is mounted and is not retried behind the
18632
+ * operator's back. An unknown reason is shown raw rather than dropped: a
18633
+ * firmware inventing a refusal must not become a blank tile.
18634
+ */
18635
+ function latchMintRefusal(reason) {
18636
+ return refusal(reason);
18637
+ }
18638
+ //#endregion
18639
+ //#region src/composites/clips/clip-thumbnail-fetch.ts
18640
+ /**
18641
+ * Asking a `thumbnailMint` route for a still — and hearing its refusal.
18642
+ *
18643
+ * The route answers **200** with a JPEG or **204** with the reason in
18644
+ * `x-camstack-reason` (`queue-full`, `sleeping`, `camera-refused`,
18645
+ * `no-keyframe`, `no-catalog-row`). An `<img src>` cannot read that header:
18646
+ * a 204 fires `onerror` with nothing to say, turning a named refusal into an
18647
+ * anonymous broken tile. So a mint goes through `fetch`.
18648
+ *
18649
+ * **Nothing is appended to the URL.** Both data-plane routes are registered
18650
+ * `access: 'authenticated'` and the hub's `/addon/<id>/<prefix>` proxy takes
18651
+ * the session cookie the admin UI sets at login, so the URL is never a
18652
+ * credential (D549 decision 22) — it may sit in history or a `Referer` and
18653
+ * open nothing.
18654
+ *
18655
+ * The refusal is about NOW and is LATCHED by the caller: a camera that just
18656
+ * said "busy" is not asked again behind the operator's back.
18657
+ */
18658
+ /**
18659
+ * Ask a mint route for one still.
18660
+ *
18661
+ * Never rejects: a thrown fetch is a refusal like any other, because a tile
18662
+ * that throws is a tile that renders nothing and says nothing — the exact
18663
+ * failure this module exists to prevent.
18664
+ */
18665
+ async function fetchMintedThumbnail(url, fetcher) {
18666
+ let response;
18667
+ try {
18668
+ response = await fetcher(url, { credentials: "same-origin" });
18669
+ } catch (err) {
18670
+ return latchMintRefusal(err instanceof Error ? err.message : String(err));
18671
+ }
18672
+ if (response.status === 204) return latchMintRefusal(response.headers.get("x-camstack-reason") ?? "no reason given");
18673
+ if (!response.ok) return latchMintRefusal(`HTTP ${String(response.status)}`);
18674
+ try {
18675
+ return {
18676
+ kind: "image",
18677
+ blob: await response.blob()
18678
+ };
18679
+ } catch (err) {
18680
+ return latchMintRefusal(err instanceof Error ? err.message : String(err));
18681
+ }
18682
+ }
18683
+ //#endregion
18684
+ //#region src/composites/clips/ClipTile.tsx
18685
+ /**
18686
+ * One clip, as a tile.
18687
+ *
18688
+ * The picture is resolved by `resolveTileMedia` — vouched JPEG, an offer, or
18689
+ * the reason there is none (D549 decision 5). An offer is fetched rather than
18690
+ * dropped into an `<img src>`, because the route's **204 + x-camstack-reason**
18691
+ * is invisible to an `<img>`: it fires `onerror` with nothing to say. The
18692
+ * refusal is LATCHED for the life of the tile and never retried behind the
18693
+ * operator's back.
18694
+ *
18695
+ * The row also carries what the camera itself said: its native types, the
18696
+ * behaviours beside the class, which storages hold it, and — for a row that
18697
+ * is an EVENT sitting inside no file — `playable: false` with its reason
18698
+ * (measured: 1 of 29 hub events on 3628). Such a row is SHOWN, never dropped
18699
+ * and never offered as playable-then-failing (D549 decision 19).
18700
+ *
18701
+ * The CHROME is the library's: a `Card` for the tile, `Badge` for every label
18702
+ * the camera gave it, `Skeleton` while a mint is in flight. Only the ladder
18703
+ * above is this file's own, because only it is a decision.
18704
+ */
18705
+ function clockOf(ms) {
18706
+ return new Date(ms).toLocaleTimeString(void 0, {
18707
+ hour: "2-digit",
18708
+ minute: "2-digit",
18709
+ second: "2-digit"
18710
+ });
18711
+ }
18712
+ /**
18713
+ * Ask a mint route once, and LATCH whatever it says.
18714
+ *
18715
+ * The offer is memoised on the three fields it is derived from, not rebuilt
18716
+ * per render: `resolveTileMedia` returns a fresh object every call, and an
18717
+ * effect keyed on that object re-runs on every commit — which, because the
18718
+ * effect sets state, is an infinite render loop. It was one here, and it hung
18719
+ * the suite rather than failing it.
18720
+ *
18721
+ * The refusal is latched for the life of the tile: a camera that just said
18722
+ * "busy" is not asked again behind the operator's back (D549 decision 5).
18723
+ */
18724
+ function useMintedStill(clip) {
18725
+ const offered = (0, react$1.useMemo)(() => resolveTileMedia(clip), [
18726
+ clip.thumbnail,
18727
+ clip.thumbnailMint,
18728
+ clip.thumbnailUnavailable?.reason
18729
+ ]);
18730
+ const [latched, setLatched] = (0, react$1.useState)(null);
18731
+ const mintUrl = offered.kind === "mint" ? offered.url : null;
18732
+ (0, react$1.useEffect)(() => {
18733
+ setLatched(null);
18734
+ if (mintUrl === null || typeof fetch !== "function") return void 0;
18735
+ let objectUrl = null;
18736
+ let cancelled = false;
18737
+ (async () => {
18738
+ const answer = await fetchMintedThumbnail(hubPath(mintUrl), fetch);
18739
+ if (cancelled) return;
18740
+ if (answer.kind === "image") {
18741
+ objectUrl = URL.createObjectURL(answer.blob);
18742
+ setLatched({
18743
+ kind: "vouched",
18744
+ url: objectUrl
18745
+ });
18746
+ return;
18747
+ }
18748
+ setLatched(answer);
18749
+ })();
18750
+ return () => {
18751
+ cancelled = true;
18752
+ if (objectUrl !== null) URL.revokeObjectURL(objectUrl);
18753
+ };
18754
+ }, [mintUrl]);
18755
+ return latched ?? offered;
18756
+ }
18757
+ function ClipTile({ clip, selected, onSelect }) {
18758
+ const media = useMintedStill(clip);
18759
+ const playable = clip.playable !== false;
18760
+ const labels = [...clip.labels ?? [], ...clip.behaviours ?? []];
18761
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18762
+ type: "button",
18763
+ onClick: () => onSelect(clip),
18764
+ disabled: !playable,
18765
+ "aria-current": selected ? "true" : void 0,
18766
+ className: "block w-full text-left disabled:cursor-not-allowed",
18767
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Card, {
18768
+ className: `flex gap-3 transition-colors ${selected ? "border-primary" : ""} ${playable ? "hover:bg-surface-hover" : "opacity-70"}`,
18769
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18770
+ className: "flex h-16 w-28 shrink-0 items-center justify-center overflow-hidden rounded bg-surface-hover text-[10px] text-foreground-subtle",
18771
+ children: media.kind === "vouched" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
18772
+ src: media.url,
18773
+ alt: "",
18774
+ className: "h-full w-full object-cover"
18775
+ }) : media.kind === "mint" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-full w-full rounded-none" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18776
+ className: "px-1 text-center leading-tight",
18777
+ children: media.text
18778
+ })
18779
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18780
+ className: "min-w-0 flex-1 space-y-1",
18781
+ children: [
18782
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18783
+ className: "text-sm font-medium text-foreground",
18784
+ children: clockOf(clip.timeRange.startMs)
18785
+ }),
18786
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18787
+ className: "text-[10px] text-foreground-subtle",
18788
+ children: clip.source
18789
+ }),
18790
+ labels.length === 0 && clip.nativeTypes === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18791
+ className: "flex flex-wrap gap-1",
18792
+ children: [labels.map((label) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Badge, {
18793
+ variant: "info",
18794
+ children: label
18795
+ }, `label:${label}`)), (clip.nativeTypes ?? []).map((native) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Badge, {
18796
+ style: "outline",
18797
+ children: native
18798
+ }, `native:${native}`))]
18799
+ }),
18800
+ playable ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18801
+ className: "text-[11px] text-warning",
18802
+ children: ["Not playable — ", clip.unplayableReason ?? "the source gave no reason"]
18803
+ })
18804
+ ]
18805
+ })]
18806
+ })
18807
+ }) });
18808
+ }
18809
+ //#endregion
18810
+ //#region src/composites/clips/clip-day-window.ts
18811
+ function pad2(value) {
18812
+ return value < 10 ? `0${String(value)}` : String(value);
18813
+ }
18814
+ /** Parse `YYYY-MM-DD` into its local-midnight `Date`. */
18815
+ function startOfDay(dayId) {
18816
+ const [year, month, day] = dayId.split("-").map((part) => Number(part));
18817
+ return new Date(year ?? 1970, (month ?? 1) - 1, day ?? 1, 0, 0, 0, 0);
18818
+ }
18819
+ /** The LOCAL calendar day an instant falls in, as `YYYY-MM-DD`. */
18820
+ function toDayId(instant) {
18821
+ return `${String(instant.getFullYear())}-${pad2(instant.getMonth() + 1)}-${pad2(instant.getDate())}`;
18822
+ }
18823
+ /** The half-open local-wall-clock window of one day. */
18824
+ function dayWindow$1(dayId) {
18825
+ const start = startOfDay(dayId);
18826
+ const end = startOfDay(dayId);
18827
+ end.setDate(end.getDate() + 1);
18828
+ return {
18829
+ since: start.getTime(),
18830
+ until: end.getTime()
18831
+ };
18832
+ }
18833
+ /** Walk `days` calendar days from `dayId` — DST-safe, because `Date` does it. */
18834
+ function shiftDay(dayId, days) {
18835
+ const moved = startOfDay(dayId);
18836
+ moved.setDate(moved.getDate() + days);
18837
+ return toDayId(moved);
18838
+ }
18839
+ /**
18840
+ * The day, spelled out for the header.
18841
+ *
18842
+ * An empty list has to say WHICH day it is empty for — "no clips" with no date
18843
+ * beside it is the ambiguity this surface exists to remove — so this label is
18844
+ * rendered next to every state, not only the populated one.
18845
+ */
18846
+ function formatDayLabel(dayId) {
18847
+ return startOfDay(dayId).toLocaleDateString(void 0, {
18848
+ weekday: "short",
18849
+ year: "numeric",
18850
+ month: "short",
18851
+ day: "numeric"
18852
+ });
18853
+ }
18854
+ //#endregion
18855
+ //#region src/composites/clips/clip-source-state.ts
18856
+ var STATE_COPY = {
18857
+ ok: {
18858
+ headline: "Answering",
18859
+ tone: "ok",
18860
+ answered: true
18861
+ },
18862
+ sleeping: {
18863
+ headline: "Asleep — showing the catalog read while it was awake",
18864
+ tone: "warn",
18865
+ answered: true
18866
+ },
18867
+ unreachable: {
18868
+ headline: "Could not be reached — its clips are missing from this list",
18869
+ tone: "error",
18870
+ answered: false
18871
+ },
18872
+ "no-storage": {
18873
+ headline: "No storage card — this camera keeps nothing of its own",
18874
+ tone: "warn",
18875
+ answered: false
18876
+ },
18877
+ "index-empty": {
18878
+ headline: "The camera's calendar marks these days, and its own index lists nothing",
18879
+ tone: "warn",
18880
+ answered: false
18881
+ }
18882
+ };
18883
+ /** Turn one source row into the line the picker and the list show. */
18884
+ function describeSource(source) {
18885
+ const copy = STATE_COPY[source.availability.state];
18886
+ return {
18887
+ source: source.source,
18888
+ label: source.label,
18889
+ headline: copy.headline,
18890
+ detail: source.availability.reason ?? null,
18891
+ tone: copy.tone,
18892
+ answered: copy.answered
18893
+ };
18894
+ }
18895
+ var MINUTE_MS$1 = 6e4;
18896
+ var HOUR_MS$1 = 36e5;
18897
+ var DAY_MS$2 = 864e5;
18898
+ /**
18899
+ * How old a source's catalog is, or null when it did not say.
18900
+ *
18901
+ * Null is NOT "now". A source that omits `catalogAsOf` was read live and has
18902
+ * no age to show; inventing "just now" for a source that never told us would
18903
+ * be the same lie as drawing a cached list as current (D549 decision 13).
18904
+ */
18905
+ function formatCatalogAge(catalogAsOf, now) {
18906
+ if (catalogAsOf === void 0) return null;
18907
+ const age = Math.max(0, now - catalogAsOf);
18908
+ if (age < MINUTE_MS$1) return "under a minute ago";
18909
+ if (age < HOUR_MS$1) return `${String(Math.floor(age / MINUTE_MS$1))} min ago`;
18910
+ if (age < DAY_MS$2) return `${String(Math.floor(age / HOUR_MS$1))} h ago`;
18911
+ return `${String(Math.floor(age / DAY_MS$2))} d ago`;
18912
+ }
18913
+ //#endregion
18914
+ //#region src/composites/clips/clip-view-state.ts
18915
+ function resolveClipsView(input) {
18916
+ if (input.clipsStatus === "error") return {
18917
+ kind: "failed",
18918
+ message: input.clipsError ?? "The clip list could not be read"
18919
+ };
18920
+ if (input.sourcesStatus === "error" && input.clips.length === 0) return {
18921
+ kind: "failed",
18922
+ message: input.sourcesError ?? "The source list could not be read"
18923
+ };
18924
+ if (input.clipsStatus === "pending" || input.sourcesStatus === "pending") return { kind: "loading" };
18925
+ const notices = input.sources.map(describeSource);
18926
+ const answered = notices.filter((notice) => notice.answered);
18927
+ const silent = notices.filter((notice) => !notice.answered);
18928
+ const clips = input.clips;
18929
+ if (clips.length === 0) return {
18930
+ kind: "empty",
18931
+ dayId: input.dayId,
18932
+ answered,
18933
+ silent,
18934
+ nobodyAnswered: answered.length === 0
18935
+ };
18936
+ return {
18937
+ kind: "listed",
18938
+ clips,
18939
+ answered,
18940
+ silent
18941
+ };
18942
+ }
18943
+ //#endregion
18944
+ //#region src/composites/clips/ClipsSection.tsx
18945
+ /**
18946
+ * `host/clips-browser` — a camera's clips, from EVERY source it has, as the
18947
+ * LAST SECTION of the Recording tab.
18948
+ *
18949
+ * Mounted by the framework, not by hand: `videoclips.cap.ts` declares
18950
+ * `deviceConfig.ui = { kind:'widget', widgetId:'host/clips-browser',
18951
+ * tab:'recording', topTab:true, order:100 }` and
18952
+ * `deriveDeviceConfigContribution` emits the section (D14). `DeviceDetail.tsx`
18953
+ * is not edited — placement is data. Because the admin Recordings page renders
18954
+ * every `location:'top-tab'` + `tab:'recording'` section behind its camera
18955
+ * picker (`CameraRecordingSettingsSection`), the same declaration puts this on
18956
+ * both surfaces with no second wiring.
18957
+ *
18958
+ * ## Built from the library, not from scratch
18959
+ *
18960
+ * It shipped as its own top tab made of five bespoke components, and the
18961
+ * operator rejected both: *"La UI è orribile, non segue la nostra UI affatto …
18962
+ * Io in realtà utilizzerei la stessa tab recordings, lì abbiamo già tutto il
18963
+ * necessario, una nuova sezione alla fine per le clips dove possiamo
18964
+ * selezionare le source con un multiselect common, non componenti nuovi
18965
+ * anonimi."* So every piece of chrome here is a component this repo already
18966
+ * has: `Select`, `DataTable`, `EmptyState`, `ErrorBox`, `Pagination`, `Card`,
18967
+ * `Badge`, `Button`, `Skeleton`. What the chrome CARRIES is unchanged, and it
18968
+ * is the feature:
18969
+ *
18970
+ * - **The source list is complete, always.** Every source the camera has,
18971
+ * with its availability and the age of its catalog, whichever provider is
18972
+ * being asked. A source never leaves the table.
18973
+ * - **Nothing is deduped.** A clip held in two storages appears twice, once
18974
+ * per source, and every tile says which (D549).
18975
+ * - **Four states are tellable apart**: loading, empty-with-a-cause,
18976
+ * degraded-per-source, and each silent source in its own words. Every one
18977
+ * of them used to render as "no clips", which is the defect this whole line
18978
+ * of work is about — a camera with no SD card (640) is not quiet, and a
18979
+ * camera whose own index disagrees with its own calendar (618, 6–20
18980
+ * September) is not quiet either.
18981
+ *
18982
+ * ## The picker WRITES nothing, and there is nothing for it to write
18983
+ *
18984
+ * A clip source is not enabled or disabled — it EXISTS, because of a
18985
+ * configuration that lives somewhere else. The operator put it exactly:
18986
+ * *"non è affatto necessario lo stato di abilitato o meno delle sources. Loro
18987
+ * vivono lì, ad esempio hksv c'è se la camera è esportata su homekit, reolink
18988
+ * la dà di suo così come il camstack."* HKSV is there if the camera is
18989
+ * exported to HomeKit; the Reolink source is there because Reolink is that
18990
+ * camera's provider; CamStack's is always there. None of that is a decision to
18991
+ * make in a clips list, so this surface offers no switch and writes no
18992
+ * authority (D557, amended). A directory-wide guard in
18993
+ * `clips-section-sources.test.tsx` keeps it that way.
18994
+ *
18995
+ * ## Why the picker is a SELECT and not a multiselect
18996
+ *
18997
+ * The operator asked for *"un multiselect common"* while `listClips` still
18998
+ * took `sources?: string[]`. It now takes ONE `provider` — *"il get videoclips
18999
+ * prendere un provider opzionale, di default va su camstack … l'utilizzatore è
19000
+ * uno solo"* — and a multi-pick control over a one-at-a-time argument offers a
19001
+ * sum the hub cannot answer. So the control is the shared single-choice one,
19002
+ * `Select`: the same component the Recordings page's own camera picker uses,
19003
+ * which is what *"segue la nostra UI"* means here. Absent means the camera's
19004
+ * BOUND provider, and the option that carries it is labelled with that
19005
+ * provider's own sources rather than the word "default".
19006
+ *
19007
+ * ## One provider is a fleet STATE
19008
+ *
19009
+ * `listSources` derives from `deviceManager.getBindings`, and today nothing
19010
+ * binds `provider-reolink` for `videoclips`, so a real camera answers with one
19011
+ * provider. The selector is then inert and SAYS why. An empty or silently
19012
+ * single-entry control would read as a defect; the binding store learning to
19013
+ * hold several providers for a collection cap is what changes this, and
19014
+ * nothing here needs to change with it.
19015
+ */
19016
+ /** Newest-first, and bounded: a busy day on 592 lists 546 clips. */
19017
+ var CLIP_PAGE_LIMIT = 500;
19018
+ /** Tiles per page. The rest are a `Pagination` click away, never dropped. */
19019
+ var TILES_PER_PAGE = 60;
19020
+ var TONE_BADGE = {
19021
+ ok: "success",
19022
+ warn: "warning",
19023
+ error: "danger"
19024
+ };
19025
+ var SOURCE_COLUMNS = [
19026
+ {
19027
+ key: "source",
19028
+ header: "Source",
19029
+ render: (row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19030
+ className: "min-w-0",
19031
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19032
+ className: "font-medium text-foreground",
19033
+ children: row.notice.label
19034
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19035
+ className: "text-[10px] text-foreground-subtle",
19036
+ children: row.notice.source
19037
+ })]
19038
+ })
19039
+ },
19040
+ {
19041
+ key: "state",
19042
+ header: "State",
19043
+ nowrap: false,
19044
+ render: (row) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Badge, {
19045
+ variant: TONE_BADGE[row.notice.tone],
19046
+ children: row.notice.headline
19047
+ })
19048
+ },
19049
+ {
19050
+ key: "reason",
19051
+ header: "Reason",
19052
+ nowrap: false,
19053
+ render: (row) => row.notice.detail === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19054
+ className: "text-foreground-subtle",
19055
+ children: "—"
19056
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19057
+ className: "break-words",
19058
+ children: row.notice.detail
19059
+ })
19060
+ },
19061
+ {
19062
+ key: "read",
19063
+ header: "Catalog read",
19064
+ render: (row) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19065
+ className: "text-foreground-subtle",
19066
+ children: row.catalogAge ?? "live"
19067
+ })
19068
+ }
19069
+ ];
19070
+ /** The sources that contributed NOTHING to what is on screen, and why. */
19071
+ function SilentSources({ silent }) {
19072
+ if (silent.length === 0) return null;
19073
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
19074
+ variant: "warning",
19075
+ title: silent.length === 1 ? "One source contributed nothing to this list" : `${String(silent.length)} sources contributed nothing to this list`,
19076
+ message: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19077
+ className: "space-y-1",
19078
+ children: silent.map((notice) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
19079
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19080
+ className: "font-medium",
19081
+ children: notice.label
19082
+ }),
19083
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
19084
+ className: "opacity-70",
19085
+ children: [
19086
+ " (",
19087
+ notice.source,
19088
+ ")"
19089
+ ]
19090
+ }),
19091
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: notice.headline }),
19092
+ notice.detail === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19093
+ className: "opacity-80 break-words",
19094
+ children: notice.detail
19095
+ })
19096
+ ] }, notice.source))
19097
+ })
19098
+ });
19099
+ }
19100
+ function ClipsSection({ deviceId }) {
19101
+ const [dayId, setDayId] = (0, react$1.useState)(() => toDayId(/* @__PURE__ */ new Date()));
19102
+ const [provider, setProvider] = (0, react$1.useState)("");
19103
+ const [page, setPage] = (0, react$1.useState)(1);
19104
+ const [selectedClip, setSelectedClip] = (0, react$1.useState)(null);
19105
+ const [profile, setProfile] = (0, react$1.useState)("low");
19106
+ const presence = useCapabilityPresence("videoclips");
19107
+ const window = (0, react$1.useMemo)(() => dayWindow$1(dayId), [dayId]);
19108
+ const sources = useVideoclipsListSources({ deviceId });
19109
+ const clips = useVideoclipsListClips({
19110
+ deviceId,
19111
+ since: window.since,
19112
+ until: window.until,
19113
+ limit: CLIP_PAGE_LIMIT,
19114
+ ...provider === "" ? {} : { provider }
19115
+ });
19116
+ const sourceRows = sources.data ?? [];
19117
+ const view = resolveClipsView({
19118
+ dayId,
19119
+ clipsStatus: clips.status,
19120
+ clips: clips.data ?? [],
19121
+ sourcesStatus: sources.status,
19122
+ sources: sourceRows,
19123
+ ...clips.error ? { clipsError: clips.error.message } : {},
19124
+ ...sources.error ? { sourcesError: sources.error.message } : {}
19125
+ });
19126
+ const now = Date.now();
19127
+ const options = (0, react$1.useMemo)(() => {
19128
+ const byAddon = /* @__PURE__ */ new Map();
19129
+ for (const row of sourceRows) {
19130
+ const addonId = row.addonId;
19131
+ if (addonId === void 0) continue;
19132
+ const labels = byAddon.get(addonId);
19133
+ if (labels === void 0) byAddon.set(addonId, [row.label]);
19134
+ else labels.push(row.label);
19135
+ }
19136
+ const rest = [...byAddon].map(([value, labels]) => ({
19137
+ value,
19138
+ label: labels.join(" · ")
19139
+ }));
19140
+ const first = rest[0];
19141
+ return [{
19142
+ value: "",
19143
+ label: first === void 0 ? "The camera’s bound provider" : first.label
19144
+ }, ...rest.slice(1)];
19145
+ }, [sourceRows]);
19146
+ const tableRows = (0, react$1.useMemo)(() => sourceRows.map((row) => ({
19147
+ notice: describeSource(row),
19148
+ catalogAge: formatCatalogAge(row.availability.catalogAsOf, now)
19149
+ })), [sourceRows]);
19150
+ const goToDay = (next) => {
19151
+ setDayId(next);
19152
+ setPage(1);
19153
+ };
19154
+ const pick = (next) => {
19155
+ setProvider(next);
19156
+ setPage(1);
19157
+ };
19158
+ if (presence === "pending") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19159
+ className: "space-y-2",
19160
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19161
+ role: "status",
19162
+ className: "text-xs text-foreground-subtle",
19163
+ children: "Asking the registry whether this cluster serves clips…"
19164
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-24 w-full" })]
19165
+ });
19166
+ if (presence === "unknown") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
19167
+ variant: "warning",
19168
+ title: "Clip sources unknown",
19169
+ message: "The capability registry could not be reached, so whether this camera has clip sources is unknown — not absent."
19170
+ });
19171
+ if (presence === "absent") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19172
+ role: "status",
19173
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, {
19174
+ icon: Clapperboard,
19175
+ title: "No provider of videoclips in this cluster",
19176
+ description: "Nothing is installed that could list a camera's stored clips."
19177
+ })
19178
+ });
19179
+ const visible = view.kind === "listed" ? view.clips : [];
19180
+ const pageClips = visible.slice((page - 1) * TILES_PER_PAGE, page * TILES_PER_PAGE);
19181
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19182
+ className: "space-y-3",
19183
+ children: [
19184
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
19185
+ className: "text-sm font-semibold text-foreground",
19186
+ children: "Clips"
19187
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
19188
+ className: "mt-1 text-[11px] text-foreground-subtle",
19189
+ children: "Every source this camera has, side by side — CamStack's own recordings and whatever the camera or an export keeps of its own. A source is listed because it exists, not because it was switched on here; pick sources to narrow the view."
19190
+ })] }),
19191
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19192
+ className: "flex flex-wrap items-center gap-2",
19193
+ children: [
19194
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
19195
+ htmlFor: "clips-provider",
19196
+ className: "text-[10px] uppercase tracking-wide text-foreground-subtle",
19197
+ children: "Source"
19198
+ }),
19199
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19200
+ className: "w-60",
19201
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Select, {
19202
+ id: "clips-provider",
19203
+ options,
19204
+ value: provider,
19205
+ disabled: options.length < 2,
19206
+ onChange: (e) => pick(e.target.value)
19207
+ })
19208
+ }),
19209
+ options.length < 2 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19210
+ className: "text-[11px] text-foreground-subtle",
19211
+ children: "This camera is bound to one clip provider today."
19212
+ }) : null,
19213
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19214
+ className: "ml-auto flex items-center gap-1",
19215
+ children: [
19216
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
19217
+ variant: "ghost",
19218
+ "aria-label": "Previous day",
19219
+ onClick: () => goToDay(shiftDay(dayId, -1)),
19220
+ children: "‹"
19221
+ }),
19222
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19223
+ className: "px-1 text-xs font-medium text-foreground",
19224
+ children: formatDayLabel(dayId)
19225
+ }),
19226
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
19227
+ variant: "ghost",
19228
+ "aria-label": "Next day",
19229
+ onClick: () => goToDay(shiftDay(dayId, 1)),
19230
+ children: "›"
19231
+ }),
19232
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
19233
+ variant: "outline",
19234
+ onClick: () => goToDay(toDayId(/* @__PURE__ */ new Date())),
19235
+ children: "Today"
19236
+ })
19237
+ ]
19238
+ })
19239
+ ]
19240
+ }),
19241
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
19242
+ "aria-label": "Clip sources",
19243
+ className: "space-y-1",
19244
+ children: sources.status === "pending" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-16 w-full" }) : tableRows.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, {
19245
+ icon: Film,
19246
+ title: "No provider answered for this camera",
19247
+ description: "Nothing claims to hold clips for it."
19248
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataTable, {
19249
+ columns: SOURCE_COLUMNS,
19250
+ rows: [...tableRows],
19251
+ minWidthPx: 520
19252
+ })
19253
+ }),
19254
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
19255
+ "aria-label": "Clips",
19256
+ className: "space-y-2",
19257
+ children: view.kind === "loading" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19258
+ role: "status",
19259
+ className: "text-xs text-foreground-subtle",
19260
+ children: [
19261
+ "Loading clips for ",
19262
+ formatDayLabel(dayId),
19263
+ "…"
19264
+ ]
19265
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Skeleton, { className: "h-24 w-full" })] }) : view.kind === "failed" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorBox, {
19266
+ title: "The clip list could not be read",
19267
+ message: view.message
19268
+ }) : view.kind === "empty" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19269
+ role: "status",
19270
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EmptyState, {
19271
+ icon: Film,
19272
+ title: view.nobodyAnswered ? `No source could answer for ${formatDayLabel(dayId)}` : `No clips on ${formatDayLabel(dayId)}`,
19273
+ ...view.nobodyAnswered ? {} : { description: `Asked ${view.answered.map((notice) => notice.label).join(", ")}.` }
19274
+ })
19275
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SilentSources, { silent: view.silent })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
19276
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SilentSources, { silent: view.silent }),
19277
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
19278
+ className: "grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3",
19279
+ children: pageClips.map((clip) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipTile, {
19280
+ clip,
19281
+ selected: selectedClip?.id === clip.id,
19282
+ onSelect: setSelectedClip
19283
+ }, `${clip.source}:${clip.id}`))
19284
+ }),
19285
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pagination, {
19286
+ currentPage: page,
19287
+ totalItems: visible.length,
19288
+ pageSize: TILES_PER_PAGE,
19289
+ onPageChange: setPage,
19290
+ itemNoun: "clips"
19291
+ })
19292
+ ] })
19293
+ }),
19294
+ selectedClip === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ClipPanel, {
19295
+ deviceId,
19296
+ clip: selectedClip,
19297
+ profile,
19298
+ onProfileChange: setProfile
19299
+ })
19300
+ ]
19301
+ });
19302
+ }
19303
+ //#endregion
19304
+ //#region src/hooks/use-navigation.ts
19305
+ /**
19306
+ * useNavigation — drive a `navigation` robot camera from the UI.
19307
+ *
19308
+ * The cap's `move` is MOMENTARY: fluid driving is the UI sending a nudge at
19309
+ * 1 Hz while a direction is held and ONE `stop` on release, exactly like the
19310
+ * robot app's joystick. This hook owns that cadence (`startDrive` /
19311
+ * `stopDrive`); the provider forwards each nudge to a single drive write.
19312
+ *
19313
+ * Everything else is a plain call plus the data the panel renders from:
19314
+ * `features` (which primitives are on), `actions` (the enabled dictionary)
19315
+ * and the live `status` slice off the device proxy.
19316
+ */
19317
+ /** The cadence the robot app drives at while a direction is held. */
19318
+ var NAVIGATION_DRIVE_INTERVAL_MS = 1e3;
19319
+ var DIRECTION_VECTORS$1 = {
19320
+ forward: {
19321
+ pan: 0,
19322
+ tilt: 1
19323
+ },
19324
+ left: {
19325
+ pan: -1,
19326
+ tilt: 0
19327
+ },
19328
+ right: {
19329
+ pan: 1,
19330
+ tilt: 0
19331
+ },
19332
+ spin: {
19333
+ pan: 0,
19334
+ tilt: -1
19335
+ }
19336
+ };
19337
+ function isAbsentProvider$1(err) {
19338
+ const message = err instanceof Error ? err.message : String(err);
19339
+ return /not available|no provider|NOT_FOUND|PRECONDITION/i.test(message);
19340
+ }
19341
+ function useNavigation(trpc, deviceId, hookOptions) {
19342
+ const enabled = hookOptions?.enabled ?? true;
19343
+ const nav = useDeviceProxy(trpc, enabled ? deviceId : null)?.navigation;
19344
+ const status = useDeviceState(trpc, enabled ? deviceId : null, (s) => s.navigation);
19345
+ const [features, setFeatures] = (0, react$1.useState)(null);
19346
+ const [actions, setActions] = (0, react$1.useState)([]);
19347
+ const [busy, setBusy] = (0, react$1.useState)(false);
19348
+ const [error, setError] = (0, react$1.useState)(null);
19349
+ const [driving, setDriving] = (0, react$1.useState)(null);
19350
+ const driveTimer = (0, react$1.useRef)(null);
19351
+ (0, react$1.useEffect)(() => {
19352
+ if (!enabled || !nav) return;
19353
+ let cancelled = false;
19354
+ (async () => {
19355
+ try {
19356
+ const [nextFeatures, nextActions] = await Promise.all([nav.getFeatures({}), nav.listActions({})]);
19357
+ if (cancelled) return;
19358
+ setFeatures(nextFeatures);
19359
+ setActions(nextActions);
19360
+ } catch (err) {
19361
+ if (cancelled || isAbsentProvider$1(err)) return;
19362
+ setError(err instanceof Error ? err.message : String(err));
19363
+ }
19364
+ })();
19365
+ return () => {
19366
+ cancelled = true;
19367
+ };
19368
+ }, [nav, enabled]);
19369
+ const wrap = (0, react$1.useCallback)(async (fn) => {
19370
+ if (!enabled || !nav) return;
19371
+ setError(null);
19372
+ setBusy(true);
19373
+ try {
19374
+ await fn();
19375
+ } catch (err) {
19376
+ setError(err instanceof Error ? err.message : String(err));
19377
+ } finally {
19378
+ setBusy(false);
19379
+ }
19380
+ }, [enabled, nav]);
19381
+ const clearTimer = (0, react$1.useCallback)(() => {
19382
+ if (driveTimer.current !== null) {
19383
+ clearInterval(driveTimer.current);
19384
+ driveTimer.current = null;
19385
+ }
19386
+ }, []);
19387
+ const stopDrive = (0, react$1.useCallback)(async () => {
19388
+ clearTimer();
19389
+ setDriving(null);
19390
+ if (!nav) return;
19391
+ await wrap(() => nav.stop({}));
19392
+ }, [
19393
+ clearTimer,
19394
+ nav,
19395
+ wrap
19396
+ ]);
19397
+ const startDrive = (0, react$1.useCallback)((direction) => {
19398
+ if (!nav) return;
19399
+ clearTimer();
19400
+ setDriving(direction);
19401
+ const vector = DIRECTION_VECTORS$1[direction];
19402
+ const nudge = () => {
19403
+ nav.move({
19404
+ pan: vector.pan,
19405
+ tilt: vector.tilt
19406
+ }).catch((err) => {
19407
+ setError(err instanceof Error ? err.message : String(err));
19408
+ });
19409
+ };
19410
+ nudge();
19411
+ driveTimer.current = setInterval(nudge, NAVIGATION_DRIVE_INTERVAL_MS);
19412
+ }, [clearTimer, nav]);
19413
+ (0, react$1.useEffect)(() => {
19414
+ return () => {
19415
+ if (driveTimer.current !== null) {
19416
+ clearInterval(driveTimer.current);
19417
+ driveTimer.current = null;
19418
+ nav?.stop({}).catch(() => void 0);
19419
+ }
19420
+ };
19421
+ }, [nav]);
19422
+ const runAction = (0, react$1.useCallback)((actionId) => wrap(async () => nav?.runAction({ actionId })), [nav, wrap]);
19423
+ const playSound = (0, react$1.useCallback)((soundId) => wrap(async () => nav?.playSound({ soundId })), [nav, wrap]);
19424
+ const setLightOn = (0, react$1.useCallback)((on) => wrap(async () => nav?.setLightOn({ on })), [nav, wrap]);
19425
+ const setLightMode = (0, react$1.useCallback)((mode, level) => wrap(async () => nav?.setLightMode(level === void 0 ? { mode } : {
19426
+ mode,
19427
+ level
19428
+ })), [nav, wrap]);
19429
+ const setLightLevel = (0, react$1.useCallback)((level) => wrap(async () => nav?.setLightLevel({ level })), [nav, wrap]);
19430
+ return {
19431
+ features,
19432
+ actions,
19433
+ status: status ?? null,
19434
+ startDrive,
19435
+ stopDrive,
19436
+ runAction,
19437
+ playSound,
19438
+ setLightOn,
19439
+ setLightMode,
19440
+ setLightLevel,
19441
+ busy,
19442
+ error,
19443
+ driving
19444
+ };
19445
+ }
19446
+ //#endregion
19447
+ //#region src/composites/cap-settings/NavigationPanel.tsx
19448
+ /** The cap's icon HINTS are lucide names; unknown hints fall back to a spark. */
19449
+ var ICONS = {
19450
+ play: Play,
19451
+ pause: Pause,
19452
+ hand: Hand,
19453
+ home: House,
19454
+ bell: Bell,
19455
+ crosshair: Crosshair,
19456
+ "paw-print": PawPrint,
19457
+ footprints: Footprints,
19458
+ droplets: Droplets,
19459
+ "trash-2": Trash2,
19460
+ flashlight: Flashlight,
19461
+ "flashlight-off": FlashlightOff,
19462
+ cat: Cat,
19463
+ dog: Dog,
19464
+ clock: Clock
19465
+ };
19466
+ function iconFor(hint) {
19467
+ return ICONS[hint] ?? Sparkles;
19468
+ }
19469
+ var DRIVE_BUTTONS = [
19470
+ {
19471
+ direction: "left",
19472
+ label: "Turn left",
19473
+ Icon: RotateCcw
19474
+ },
19475
+ {
19476
+ direction: "forward",
19477
+ label: "Forward",
19478
+ Icon: ArrowUp
19479
+ },
19480
+ {
19481
+ direction: "right",
19482
+ label: "Turn right",
19483
+ Icon: RotateCw
19484
+ },
19485
+ {
19486
+ direction: "spin",
19487
+ label: "Turn around",
19488
+ Icon: Undo2
19489
+ }
19490
+ ];
19491
+ var BUTTON = "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[10px] font-medium transition-colors bg-surface-elevated/40 text-foreground-subtle hover:bg-surface-hover hover:text-foreground disabled:opacity-50";
19492
+ var BUTTON_ON = "bg-success/15 text-success hover:bg-success/25";
19493
+ function EntryButton({ entry, disabled, onRun }) {
19494
+ const Glyph = iconFor(entry.icon);
19495
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
19496
+ type: "button",
19497
+ className: BUTTON,
19498
+ disabled,
19499
+ onClick: () => onRun(entry),
19500
+ title: entry.label,
19501
+ "aria-label": entry.label,
19502
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Glyph, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: entry.label })]
19503
+ });
19504
+ }
19505
+ function NavigationPanel({ deviceId }) {
19506
+ const system = useSystem();
19507
+ const ready = Number.isFinite(deviceId);
19508
+ const nav = useNavigation(system.trpcClient, ready ? deviceId : 0, { enabled: ready });
19509
+ if (!ready) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19510
+ className: "flex items-center justify-center h-full text-[11px] text-foreground-subtle italic",
19511
+ children: [
19512
+ "Device #",
19513
+ deviceId,
19514
+ " not loaded."
19515
+ ]
19516
+ });
19517
+ const features = nav.features;
19518
+ const actions = nav.actions.filter((entry) => entry.enabled && entry.kind === "action");
19519
+ const sounds = nav.actions.filter((entry) => entry.enabled && entry.kind === "sound");
19520
+ const status = nav.status;
19521
+ const manual = status?.lightMode === "manual";
19522
+ const run = (entry) => {
19523
+ if (entry.kind === "sound") {
19524
+ if (entry.soundId !== void 0) nav.playSound(entry.soundId);
19525
+ return;
19526
+ }
19527
+ nav.runAction(entry.id);
19528
+ };
19529
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(WidgetPanel, {
19530
+ title: "Navigation",
19531
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Navigation, { className: "h-3.5 w-3.5 text-foreground-subtle" }),
19532
+ bodyClassName: "p-3 flex flex-col gap-3 overflow-y-auto",
19533
+ children: [
19534
+ status && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19535
+ className: "flex flex-wrap items-center gap-1.5 text-[10px]",
19536
+ children: [
19537
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19538
+ className: "rounded-full bg-surface-elevated/60 px-2 py-0.5 text-foreground",
19539
+ children: status.mode
19540
+ }),
19541
+ status.following && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19542
+ className: "rounded-full bg-info/15 px-2 py-0.5 text-info",
19543
+ children: "following"
19544
+ }),
19545
+ status.flash && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19546
+ className: "rounded-full bg-warning/15 px-2 py-0.5 text-warning",
19547
+ children: "light on"
19548
+ })
19549
+ ]
19550
+ }),
19551
+ features?.move && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
19552
+ className: "flex flex-col gap-1.5",
19553
+ children: [
19554
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19555
+ className: "text-[10.5px] font-semibold text-foreground",
19556
+ children: "Drive (hold)"
19557
+ }),
19558
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19559
+ className: "grid grid-cols-4 gap-1.5",
19560
+ children: DRIVE_BUTTONS.map(({ direction, label, Icon }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
19561
+ type: "button",
19562
+ className: cn(BUTTON, "justify-center select-none", nav.driving === direction && BUTTON_ON),
19563
+ "aria-label": label,
19564
+ title: label,
19565
+ onPointerDown: () => nav.startDrive(direction),
19566
+ onPointerUp: () => void nav.stopDrive(),
19567
+ onPointerLeave: () => {
19568
+ if (nav.driving === direction) nav.stopDrive();
19569
+ },
19570
+ onPointerCancel: () => void nav.stopDrive(),
19571
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { className: "h-4 w-4" })
19572
+ }, direction))
19573
+ }),
19574
+ features.stop && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
19575
+ type: "button",
19576
+ className: cn(BUTTON, "justify-center"),
19577
+ onClick: () => void nav.stopDrive(),
19578
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Hand, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "Stop" })]
19579
+ })
19580
+ ]
19581
+ }),
19582
+ features?.runAction && actions.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
19583
+ className: "flex flex-col gap-1.5",
19584
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19585
+ className: "text-[10.5px] font-semibold text-foreground",
19586
+ children: "Actions"
19587
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19588
+ className: "flex flex-wrap gap-1.5",
19589
+ children: actions.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EntryButton, {
19590
+ entry,
19591
+ disabled: nav.busy,
19592
+ onRun: run
19593
+ }, entry.id))
19594
+ })]
19595
+ }),
19596
+ features?.playSound && sounds.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
19597
+ className: "flex flex-col gap-1.5",
19598
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19599
+ className: "text-[10.5px] font-semibold text-foreground",
19600
+ children: "Sounds"
19601
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19602
+ className: "flex flex-wrap gap-1.5",
19603
+ children: sounds.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EntryButton, {
19604
+ entry,
19605
+ disabled: nav.busy,
19606
+ onRun: run
19607
+ }, entry.id))
19608
+ })]
19609
+ }),
19610
+ features?.light && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
19611
+ className: "flex flex-col gap-1.5 border-t border-border/40 pt-2",
19612
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19613
+ className: "flex items-center justify-between",
19614
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19615
+ className: "text-[10.5px] font-semibold text-foreground",
19616
+ children: "Fill light"
19617
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
19618
+ type: "button",
19619
+ className: cn(BUTTON, status?.flash && BUTTON_ON),
19620
+ "aria-pressed": status?.flash === true,
19621
+ disabled: nav.busy,
19622
+ onClick: () => void nav.setLightOn(!(status?.flash === true)),
19623
+ children: [status?.flash ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Flashlight, { className: "h-3.5 w-3.5" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FlashlightOff, { className: "h-3.5 w-3.5" }), status?.flash ? "On" : "Off"]
19624
+ })]
19625
+ }), features.lightMode && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19626
+ className: "flex items-center gap-2",
19627
+ children: [
19628
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
19629
+ "aria-label": "Light mode",
19630
+ className: "rounded-md bg-surface-elevated/40 px-2 py-1 text-[10px] text-foreground",
19631
+ value: status?.lightMode ?? "auto",
19632
+ disabled: nav.busy,
19633
+ onChange: (event) => {
19634
+ const mode = event.target.value === "manual" ? "manual" : "auto";
19635
+ nav.setLightMode(mode, mode === "manual" ? status?.lightLevel : void 0);
19636
+ },
19637
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
19638
+ value: "auto",
19639
+ children: "Auto"
19640
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
19641
+ value: "manual",
19642
+ children: "Manual"
19643
+ })]
19644
+ }),
19645
+ manual && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
19646
+ type: "range",
19647
+ "aria-label": "Light level",
19648
+ className: "flex-1",
19649
+ min: _camstack_types.NAVIGATION_LIGHT_LEVEL_MIN,
19650
+ max: _camstack_types.NAVIGATION_LIGHT_LEVEL_MAX,
19651
+ step: 1,
19652
+ value: status?.lightLevel ?? _camstack_types.NAVIGATION_LIGHT_LEVEL_MAX,
19653
+ disabled: nav.busy,
19654
+ onChange: (event) => void nav.setLightLevel(Number(event.target.value))
19655
+ }),
19656
+ manual && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
19657
+ className: "text-[10px] text-foreground-subtle w-8 text-right",
19658
+ children: status?.lightLevel ?? _camstack_types.NAVIGATION_LIGHT_LEVEL_MAX
19659
+ })
19660
+ ]
19661
+ })]
19662
+ }),
19663
+ nav.error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19664
+ className: "text-[10px] text-danger",
19665
+ children: nav.error
19666
+ })
19667
+ ]
19668
+ });
19669
+ }
19670
+ //#endregion
19671
+ //#region src/lib/cap-error.ts
19672
+ /**
19673
+ * Shared capability-error utilities for the admin UI.
19674
+ *
19675
+ * The primary export `isAbsentProvider` lets UI components distinguish
19676
+ * "this device doesn't have this cap" (expected — hide the widget) from
19677
+ * "something went wrong while calling a present cap" (unexpected — surface
19678
+ * the error to the operator).
19679
+ *
19680
+ * Detection strategy (two-tier):
19681
+ *
19682
+ * 1. **Typed reason** (preferred): tRPC's errorFormatter augments the
19683
+ * error shape with `data.capRouteReason` when the server throws a
19684
+ * CapRouteError. Read it from `err.data?.capRouteReason` to avoid
19685
+ * brittle string matching.
19686
+ *
19687
+ * 2. **Substring fallback** (transitional safety): older error paths
19688
+ * that don't carry a typed reason still emit the human-readable
19689
+ * message "provider not available". The fallback is ONLY engaged
19690
+ * when `capRouteReason` is absent — future clean-up can drop it
19691
+ * once every throw site has been migrated.
19692
+ */
19693
+ /**
17966
19694
  * Extract the typed `capRouteReason` field from a serialized tRPC error.
17967
19695
  *
17968
19696
  * tRPC client errors typically look like:
@@ -19356,46 +21084,6 @@ function RecordingSettings({ initial, availableProfiles, saving, onSave, sensorC
19356
21084
  });
19357
21085
  }
19358
21086
  //#endregion
19359
- //#region src/contexts/vod-playback.tsx
19360
- /**
19361
- * VodPlayback — shared context coordinating recorded (VOD) playback between a
19362
- * widget/section (the Recording panel) and the hero StreamPanel.
19363
- *
19364
- * A CONTROLLER (the Recording widget) calls `play({ url, label })`; the hero
19365
- * StreamPanel CONSUMES `request` and switches into VOD mode (HLS playback
19366
- * through the hub data-plane) instead of the live WebRTC stream. `clear()`
19367
- * returns the hero to live.
19368
- *
19369
- * Built on `createSharedContext` (NOT a plain `createContext`) so it crosses
19370
- * the Module-Federation boundary: an addon-shipped widget consuming
19371
- * `useVodPlayback()` resolves the SAME provider instance the host mounted —
19372
- * the same mechanism `player-overlays` / `zone-editing` use to let federated
19373
- * widgets (zone-editor, privacy-mask) drive the live player. Consumers used
19374
- * outside a provider get the inert default (no-op), so a StreamPanel mounted
19375
- * on a page without the Recording widget stays live-only without crashing.
19376
- */
19377
- var VodPlaybackContext = createSharedContext("camstack:vod-playback", {
19378
- request: null,
19379
- play: () => {},
19380
- clear: () => {}
19381
- });
19382
- function VodPlaybackProvider({ children }) {
19383
- const [request, setRequest] = (0, react$1.useState)(null);
19384
- const value = (0, react$1.useMemo)(() => ({
19385
- request,
19386
- play: (req) => setRequest(req),
19387
- clear: () => setRequest(null)
19388
- }), [request]);
19389
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VodPlaybackContext.Provider, {
19390
- value,
19391
- children
19392
- });
19393
- }
19394
- /** @returns the VOD playback controller (inert no-op outside a provider). */
19395
- function useVodPlayback() {
19396
- return (0, react$1.useContext)(VodPlaybackContext);
19397
- }
19398
- //#endregion
19399
21087
  //#region src/lib/recorded-control-protocol.ts
19400
21088
  /** Serialize a command for `RTCDataChannel.send` (matches the server schema). */
19401
21089
  function serializeRecordedCommand(cmd) {
@@ -19732,82 +21420,6 @@ function useScrubController(sink, isPlayerLive = () => false) {
19732
21420
  };
19733
21421
  }
19734
21422
  //#endregion
19735
- //#region src/composites/empty-state.tsx
19736
- function EmptyState({ icon: Icon, title, description, action, className }) {
19737
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19738
- className: cn("flex flex-col items-center justify-center gap-3 py-12", className),
19739
- children: [
19740
- Icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
19741
- className: "h-12 w-12 text-foreground-subtle",
19742
- "aria-hidden": "true"
19743
- }),
19744
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19745
- className: "flex flex-col items-center gap-1 text-center",
19746
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
19747
- className: "text-foreground-muted text-sm font-medium",
19748
- children: title
19749
- }), description && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
19750
- className: "text-foreground-subtle text-xs max-w-xs",
19751
- children: description
19752
- })]
19753
- }),
19754
- action && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19755
- className: "mt-1",
19756
- children: action
19757
- })
19758
- ]
19759
- });
19760
- }
19761
- //#endregion
19762
- //#region src/composites/error-box.tsx
19763
- var VARIANT_STYLES$1 = {
19764
- danger: {
19765
- box: "border-danger/40 bg-danger/10",
19766
- icon: "text-danger",
19767
- title: "text-danger",
19768
- message: "text-danger"
19769
- },
19770
- warning: {
19771
- box: "border-amber-500/40 bg-amber-500/10",
19772
- icon: "text-amber-600 dark:text-amber-400",
19773
- title: "text-amber-700 dark:text-amber-300",
19774
- message: "text-amber-700 dark:text-amber-300"
19775
- }
19776
- };
19777
- function ErrorBox({ message, title, onRetry, action, variant = "danger", className }) {
19778
- const styles = VARIANT_STYLES$1[variant];
19779
- const Icon = variant === "warning" ? TriangleAlert : CircleAlert;
19780
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19781
- role: "alert",
19782
- className: cn("flex items-start gap-2.5 rounded border px-3 py-2.5", styles.box, className),
19783
- children: [
19784
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
19785
- className: cn("h-4 w-4 mt-0.5 shrink-0", styles.icon),
19786
- "aria-hidden": "true"
19787
- }),
19788
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
19789
- className: "flex-1 min-w-0",
19790
- children: [title && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
19791
- className: cn("text-xs font-semibold", styles.title),
19792
- children: title
19793
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
19794
- className: cn("text-xs break-words", styles.message, title ? "mt-0.5" : ""),
19795
- children: message
19796
- })]
19797
- }),
19798
- action ?? (onRetry && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
19799
- type: "button",
19800
- onClick: onRetry,
19801
- className: cn("shrink-0 inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] font-medium", "border border-current/30 hover:bg-current/10 transition-colors", styles.message),
19802
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RefreshCw, {
19803
- className: "h-3 w-3",
19804
- "aria-hidden": "true"
19805
- }), "Retry"]
19806
- }))
19807
- ]
19808
- });
19809
- }
19810
- //#endregion
19811
21423
  //#region src/composites/cap-settings/recording-spans.ts
19812
21424
  /**
19813
21425
  * Merge availability ranges (across every profile) into the union of distinct
@@ -20990,7 +22602,8 @@ var HOST_WIDGETS = {
20990
22602
  "host/consumables-panel": ConsumablesPanel,
20991
22603
  "host/ptz-autotrack": AutotrackSection,
20992
22604
  "host/recording-panel": RecordingPanel,
20993
- "host/camera-grid-layout": CameraGridLayoutEditor
22605
+ "host/camera-grid-layout": CameraGridLayoutEditor,
22606
+ "host/clips-browser": ClipsSection
20994
22607
  };
20995
22608
  //#endregion
20996
22609
  //#region src/composites/widget-slot.tsx
@@ -25297,92 +26910,6 @@ function AgentStepEditor(props) {
25297
26910
  });
25298
26911
  }
25299
26912
  //#endregion
25300
- //#region ../../node_modules/class-variance-authority/dist/index.mjs
25301
- /**
25302
- * Copyright 2022 Joe Bell. All rights reserved.
25303
- *
25304
- * This file is licensed to you under the Apache License, Version 2.0
25305
- * (the "License"); you may not use this file except in compliance with the
25306
- * License. You may obtain a copy of the License at
25307
- *
25308
- * http://www.apache.org/licenses/LICENSE-2.0
25309
- *
25310
- * Unless required by applicable law or agreed to in writing, software
25311
- * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
25312
- * WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the
25313
- * License for the specific language governing permissions and limitations under
25314
- * the License.
25315
- */ var falsyToString = (value) => typeof value === "boolean" ? `${value}` : value === 0 ? "0" : value;
25316
- var cx = clsx;
25317
- var cva = (base, config) => (props) => {
25318
- var _config_compoundVariants;
25319
- if ((config === null || config === void 0 ? void 0 : config.variants) == null) return cx(base, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);
25320
- const { variants, defaultVariants } = config;
25321
- const getVariantClassNames = Object.keys(variants).map((variant) => {
25322
- const variantProp = props === null || props === void 0 ? void 0 : props[variant];
25323
- const defaultVariantProp = defaultVariants === null || defaultVariants === void 0 ? void 0 : defaultVariants[variant];
25324
- if (variantProp === null) return null;
25325
- const variantKey = falsyToString(variantProp) || falsyToString(defaultVariantProp);
25326
- return variants[variant][variantKey];
25327
- });
25328
- const propsWithoutUndefined = props && Object.entries(props).reduce((acc, param) => {
25329
- let [key, value] = param;
25330
- if (value === void 0) return acc;
25331
- acc[key] = value;
25332
- return acc;
25333
- }, {});
25334
- return cx(base, getVariantClassNames, config === null || config === void 0 ? void 0 : (_config_compoundVariants = config.compoundVariants) === null || _config_compoundVariants === void 0 ? void 0 : _config_compoundVariants.reduce((acc, param) => {
25335
- let { class: cvClass, className: cvClassName, ...compoundVariantOptions } = param;
25336
- return Object.entries(compoundVariantOptions).every((param) => {
25337
- let [key, value] = param;
25338
- return Array.isArray(value) ? value.includes({
25339
- ...defaultVariants,
25340
- ...propsWithoutUndefined
25341
- }[key]) : {
25342
- ...defaultVariants,
25343
- ...propsWithoutUndefined
25344
- }[key] === value;
25345
- }) ? [
25346
- ...acc,
25347
- cvClass,
25348
- cvClassName
25349
- ] : acc;
25350
- }, []), props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);
25351
- };
25352
- //#endregion
25353
- //#region src/primitives/button.tsx
25354
- var buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium transition-colors cursor-pointer disabled:opacity-50 disabled:pointer-events-none", {
25355
- variants: {
25356
- variant: {
25357
- primary: "bg-primary text-primary-foreground hover:bg-primary/90",
25358
- secondary: "bg-surface text-foreground hover:bg-surface-hover",
25359
- ghost: "hover:bg-surface-hover text-foreground-muted",
25360
- danger: "bg-danger text-white hover:bg-danger/90",
25361
- outline: "border border-border bg-transparent text-foreground hover:bg-surface-hover"
25362
- },
25363
- size: {
25364
- sm: "h-7 px-2.5 text-xs gap-1.5",
25365
- md: "h-8 px-3 text-sm gap-2",
25366
- lg: "h-9 px-4 text-sm gap-2"
25367
- }
25368
- },
25369
- defaultVariants: {
25370
- variant: "primary",
25371
- size: "sm"
25372
- }
25373
- });
25374
- var Button = (0, react$1.forwardRef)(({ className, variant, size, ...props }, ref) => {
25375
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
25376
- ref,
25377
- className: cn(buttonVariants({
25378
- variant,
25379
- size
25380
- }), className),
25381
- ...props
25382
- });
25383
- });
25384
- Button.displayName = "Button";
25385
- //#endregion
25386
26913
  //#region src/primitives/icon-button.tsx
25387
26914
  var iconButtonVariants = cva("inline-flex items-center justify-center rounded-md font-medium transition-colors cursor-pointer disabled:opacity-50 disabled:pointer-events-none", {
25388
26915
  variants: {
@@ -25417,55 +26944,6 @@ var IconButton = (0, react$1.forwardRef)(({ className, variant, size, icon: Icon
25417
26944
  });
25418
26945
  IconButton.displayName = "IconButton";
25419
26946
  //#endregion
25420
- //#region src/primitives/badge.tsx
25421
- var badgeVariants = cva("inline-flex items-center rounded-full text-xs font-medium px-2 py-0.5", {
25422
- variants: {
25423
- variant: {
25424
- default: "bg-surface-hover text-foreground",
25425
- success: "bg-success/15 text-success",
25426
- warning: "bg-warning/15 text-warning",
25427
- danger: "bg-danger/15 text-danger",
25428
- info: "bg-info/15 text-info"
25429
- },
25430
- styleVariant: {
25431
- solid: "",
25432
- outline: "border bg-transparent"
25433
- }
25434
- },
25435
- defaultVariants: {
25436
- variant: "default",
25437
- styleVariant: "solid"
25438
- }
25439
- });
25440
- var Badge = (0, react$1.forwardRef)(({ className, variant, style, ...props }, ref) => {
25441
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
25442
- ref,
25443
- className: cn(badgeVariants({
25444
- variant,
25445
- styleVariant: style
25446
- }), className),
25447
- ...props
25448
- });
25449
- });
25450
- Badge.displayName = "Badge";
25451
- //#endregion
25452
- //#region src/primitives/card.tsx
25453
- var cardVariants = cva("rounded-lg p-3", {
25454
- variants: { variant: {
25455
- flat: "bg-surface",
25456
- bordered: "bg-surface border border-border"
25457
- } },
25458
- defaultVariants: { variant: "bordered" }
25459
- });
25460
- var Card = (0, react$1.forwardRef)(({ className, variant, ...props }, ref) => {
25461
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25462
- ref,
25463
- className: cn(cardVariants({ variant }), className),
25464
- ...props
25465
- });
25466
- });
25467
- Card.displayName = "Card";
25468
- //#endregion
25469
26947
  //#region src/primitives/label.tsx
25470
26948
  var Label = (0, react$1.forwardRef)(({ className, ...props }, ref) => {
25471
26949
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
@@ -25494,16 +26972,6 @@ var Separator = (0, react$1.forwardRef)(({ className, orientation, ...props }, r
25494
26972
  });
25495
26973
  Separator.displayName = "Separator";
25496
26974
  //#endregion
25497
- //#region src/primitives/skeleton.tsx
25498
- var Skeleton = (0, react$1.forwardRef)(({ className, ...props }, ref) => {
25499
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25500
- ref,
25501
- className: cn("animate-pulse bg-surface-hover rounded-md h-4 w-full", className),
25502
- ...props
25503
- });
25504
- });
25505
- Skeleton.displayName = "Skeleton";
25506
- //#endregion
25507
26975
  //#region src/primitives/input.tsx
25508
26976
  /**
25509
26977
  * `disabled:opacity-50 disabled:cursor-not-allowed` for the same reason as
@@ -25545,43 +27013,6 @@ var Input = (0, react$1.forwardRef)(({ className, state, leftSlot, rightSlot, ..
25545
27013
  });
25546
27014
  Input.displayName = "Input";
25547
27015
  //#endregion
25548
- //#region src/primitives/select.tsx
25549
- /**
25550
- * A native `<select>` styled to match the rest of the field set.
25551
- *
25552
- * `appearance-none` plus an explicit `bg-surface` / `text-foreground` is what
25553
- * makes it match — and it is also what USED to make a disabled one a lie. A
25554
- * browser greys a disabled control by rendering its own background and text
25555
- * colour; all three of those are overridden here, so a `disabled` dropdown
25556
- * looked exactly like a live one and simply ignored the click. That is the
25557
- * whole of the "the language selector does not open" report of 2026-08-15:
25558
- * the AI section's switch was off, every control under it was correctly inert,
25559
- * and nothing on screen said so.
25560
- *
25561
- * `disabled:` classes belong HERE and not on each call site: there are dozens
25562
- * of `<Select disabled>` in this app and the one that gets forgotten is the one
25563
- * that generates the ticket. Guarded by `disabled-affordance.test.tsx`.
25564
- */
25565
- var Select = (0, react$1.forwardRef)(({ className, options, disabled, ...props }, ref) => {
25566
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
25567
- className: "relative w-full",
25568
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
25569
- ref,
25570
- disabled,
25571
- className: cn("h-7 w-full px-2.5 text-xs bg-surface border border-border rounded-md text-foreground appearance-none pr-7 focus:outline-none focus:ring-1 focus:ring-primary", "disabled:opacity-50 disabled:cursor-not-allowed", className),
25572
- ...props,
25573
- children: options.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
25574
- value: option.value,
25575
- children: option.label
25576
- }, option.value))
25577
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
25578
- className: cn("absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-foreground-muted", disabled === true ? "opacity-50" : ""),
25579
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3.5 w-3.5" })
25580
- })]
25581
- });
25582
- });
25583
- Select.displayName = "Select";
25584
- //#endregion
25585
27016
  //#region src/primitives/checkbox.tsx
25586
27017
  var Checkbox = (0, react$1.forwardRef)(({ className, ...props }, ref) => {
25587
27018
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
@@ -25870,53 +27301,6 @@ function TooltipContent({ className, children, ...props }) {
25870
27301
  });
25871
27302
  }
25872
27303
  //#endregion
25873
- //#region src/hooks/use-is-mobile.ts
25874
- var MOBILE_QUERY = "(max-width: 767px)";
25875
- /**
25876
- * Mid breakpoint — below this width the sidebar collapses to an
25877
- * icons-only rail to reclaim canvas space. Above it (and outside
25878
- * mobile) the full sidebar renders. Threshold matches Tailwind `lg`
25879
- * so sidebar fans out at the same point grids switch to multi-column.
25880
- */
25881
- var MID_QUERY = "(min-width: 768px) and (max-width: 1023px)";
25882
- /**
25883
- * `matchMedia` is universal in browsers but absent in bare DOM environments.
25884
- * These hooks are called from shared primitives (`DataTable` picks its narrow
25885
- * layout with one), so throwing here takes down the whole page that merely
25886
- * rendered a table. Where the capability is missing, report the desktop
25887
- * layout — a widescreen rendering is wrong-looking; a crashed page is gone.
25888
- */
25889
- function matchQuery(query) {
25890
- if (typeof window.matchMedia !== "function") return null;
25891
- return window.matchMedia(query);
25892
- }
25893
- function subscribeQuery(query) {
25894
- return (callback) => {
25895
- const mql = matchQuery(query);
25896
- if (mql === null) return () => {};
25897
- mql.addEventListener("change", callback);
25898
- return () => mql.removeEventListener("change", callback);
25899
- };
25900
- }
25901
- function getSnapshot(query) {
25902
- return () => matchQuery(query)?.matches ?? false;
25903
- }
25904
- function getServerSnapshot() {
25905
- return false;
25906
- }
25907
- function useIsMobile() {
25908
- return (0, react$1.useSyncExternalStore)(subscribeQuery(MOBILE_QUERY), getSnapshot(MOBILE_QUERY), getServerSnapshot);
25909
- }
25910
- /**
25911
- * True when the viewport is in the medium (`md`) Tailwind range —
25912
- * 768px ≤ width < 1024px. Use to drive layouts that should pack denser
25913
- * than full-desktop but more legibly than mobile (e.g. icons-only
25914
- * sidebar rail, narrower stat tiles).
25915
- */
25916
- function useIsMidWidth() {
25917
- return (0, react$1.useSyncExternalStore)(subscribeQuery(MID_QUERY), getSnapshot(MID_QUERY), getServerSnapshot);
25918
- }
25919
- //#endregion
25920
27304
  //#region src/primitives/bottom-sheet.tsx
25921
27305
  function BottomSheet({ open, onClose, title, children, className }) {
25922
27306
  (0, react$1.useEffect)(() => {
@@ -29130,644 +30514,224 @@ function coverageLine(monitor) {
29130
30514
  return monitor.onUncoveredCondition === "judge-anyway" ? `${parts.join(" · ")} — ${names} judged against the references you do have.` : `${parts.join(" · ")} — optional; checks in ${names} are skipped.`;
29131
30515
  }
29132
30516
  /**
29133
- * The calm sentence for a scene that is sitting the current light out — `null`
29134
- * whenever it is checking normally.
29135
- *
29136
- * Separate from {@link statusLine} because it is not a verdict and must not
29137
- * read like one. A paused scene has not failed, has not lost its flag, and
29138
- * needs nothing from the operator; the only thing worth saying is which light
29139
- * it is waiting out and that it will pick itself back up.
29140
- */
29141
- function suspensionLine(monitor) {
29142
- if (monitor.suspendedCondition === null) return null;
29143
- const light = variantLabel(monitor.suspendedCondition).toLowerCase();
29144
- return `Paused — nothing was captured in ${light}, so checks are on hold until the light changes back. Capture ${light} if you want it watched around the clock.`;
29145
- }
29146
- var UNAVAILABLE_SENTENCE = {
29147
- "no-reference-for-condition": "can’t tell — nothing has been captured for this scene yet. Press “Capture daylight” to photograph what normal looks like.",
29148
- "view-shifted": "can’t tell — the camera view has moved since the reference was captured. Re-capture it.",
29149
- "no-vision-profile": "can’t tell — the vision model is unreachable, so the change is on hold.",
29150
- "encoder-model-changed": "can’t tell — the embedding model changed, so the stored references are unreadable. Re-capture them.",
29151
- "no-snapshot": "can’t tell — this camera did not return a frame."
29152
- };
29153
- /**
29154
- * The one line under a scene's name. It answers, in order: can it judge, what
29155
- * does it say, and how long has it said it.
29156
- */
29157
- function statusLine(monitor, now) {
29158
- if (monitor.states.length === 0) return "Not armed yet — capture the initial state to start watching.";
29159
- if (monitor.verdict === "unknown") return monitor.unavailable !== null ? UNAVAILABLE_SENTENCE[monitor.unavailable] : "can’t tell right now.";
29160
- const since = monitor.verdict === "diverged" ? monitor.divergedAt : monitor.armedAt;
29161
- const held = since !== null ? ` for ${humanDuration(Math.max(0, now - since))}` : "";
29162
- if (monitor.latched) return monitor.verdict === "diverged" ? `CHANGED${held} — press Reset when you have dealt with it.` : `Back to normal, but still flagged as changed${held}. Press Reset to clear it.`;
29163
- const paused = suspensionLine(monitor);
29164
- if (paused !== null) return paused;
29165
- return monitor.verdict === "diverged" ? `CHANGED${held}` : `Normal${held}`;
29166
- }
29167
- /** The badge word. `latched` wins over `verdict`, because the latch is the
29168
- * thing the operator asked to be told and it outlives the live state.
29169
- *
29170
- * A scene sitting out an uncovered light reads `Paused`, in the NEUTRAL tone —
29171
- * never `alarm`, and never the `danger` variant the page maps that to. A
29172
- * missing IR reference must not be able to paint a red badge on a scene that
29173
- * is doing exactly what it was configured to do. */
29174
- function badgeFor(monitor) {
29175
- if (monitor.states.length === 0) return {
29176
- text: "Not armed",
29177
- tone: "unknown"
29178
- };
29179
- if (monitor.latched) return {
29180
- text: "Changed",
29181
- tone: "alarm"
29182
- };
29183
- if (monitor.suspendedCondition !== null) return {
29184
- text: "Paused",
29185
- tone: "unknown"
29186
- };
29187
- if (monitor.verdict === "unknown") return {
29188
- text: "Can’t tell",
29189
- tone: "unknown"
29190
- };
29191
- return monitor.verdict === "diverged" ? {
29192
- text: "Changed",
29193
- tone: "alarm"
29194
- } : {
29195
- text: "Normal",
29196
- tone: "ok"
29197
- };
29198
- }
29199
- /** A rect covering most of the frame is dominated by weather, sky and parked
29200
- * cars: the bin is 2 % of the pixels and CLIP will not notice it leaving. The
29201
- * failure is silent INSENSITIVITY, which is the worst kind — hence a warning
29202
- * rather than a refusal. */
29203
- var ROI_AREA_WARN_FRACTION = .4;
29204
- function roiTooLarge(roi) {
29205
- return roi.width * roi.height > ROI_AREA_WARN_FRACTION;
29206
- }
29207
- function humanDuration(ms) {
29208
- const s = Math.floor(ms / 1e3);
29209
- if (s < 60) return `${s}s`;
29210
- const m = Math.floor(s / 60);
29211
- if (m < 60) return `${m}m`;
29212
- const h = Math.floor(m / 60);
29213
- if (h < 48) return `${h}h`;
29214
- return `${Math.floor(h / 24)}d`;
29215
- }
29216
- //#endregion
29217
- //#region src/composites/code-block.tsx
29218
- function CodeBlock({ children, maxHeight = 300, className }) {
29219
- const [copied, setCopied] = (0, react$1.useState)(false);
29220
- const handleCopy = (0, react$1.useCallback)(() => {
29221
- navigator.clipboard.writeText(children).then(() => {
29222
- setCopied(true);
29223
- setTimeout(() => setCopied(false), 2e3);
29224
- });
29225
- }, [children]);
29226
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
29227
- className: cn("relative group", className),
29228
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScrollArea, {
29229
- style: { maxHeight },
29230
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
29231
- className: "font-mono text-xs bg-surface p-3 rounded-md border border-border-subtle",
29232
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children })
29233
- })
29234
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
29235
- className: "absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity",
29236
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconButton, {
29237
- icon: copied ? Check : Copy,
29238
- "aria-label": "Copy code",
29239
- variant: "ghost",
29240
- size: "sm",
29241
- onClick: handleCopy
29242
- })
29243
- })]
29244
- });
29245
- }
29246
- //#endregion
29247
- //#region src/composites/confirm-action-button.tsx
29248
- /**
29249
- * ConfirmActionButton — destructive-action button with a modal confirm.
29250
- *
29251
- * Two-step UX for any operation operators shouldn't trigger by accident:
29252
- * reboot device, restart addon, regenerate token, etc. The trigger is a
29253
- * normal Button that opens a Dialog; confirming runs the async action,
29254
- * the trigger spinner-disables itself for the duration, and the dialog
29255
- * closes on success. Errors surface inline at the dialog's bottom.
29256
- *
29257
- * Generic on `TResult` so callers don't have to discard the return
29258
- * value. Pass an `icon` to render it inside the trigger (e.g. RotateCw
29259
- * for reboot, RefreshCw for restart).
29260
- */
29261
- function ConfirmActionButton({ label, icon: Icon, title = "Confirm action", description, confirmLabel, triggerVariant = "outline", confirmVariant = "danger", size = "sm", disabled, className, action, onSuccess }) {
29262
- const [open, setOpen] = (0, react$1.useState)(false);
29263
- const [running, setRunning] = (0, react$1.useState)(false);
29264
- const [error, setError] = (0, react$1.useState)(null);
29265
- const handleConfirm = async () => {
29266
- setError(null);
29267
- setRunning(true);
29268
- try {
29269
- const result = await action();
29270
- onSuccess?.(result);
29271
- setOpen(false);
29272
- } catch (err) {
29273
- setError(err instanceof Error ? err.message : String(err));
29274
- } finally {
29275
- setRunning(false);
29276
- }
29277
- };
29278
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
29279
- type: "button",
29280
- variant: triggerVariant,
29281
- size,
29282
- disabled,
29283
- onClick: () => {
29284
- setError(null);
29285
- setOpen(true);
29286
- },
29287
- className,
29288
- children: [Icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { className: "h-3.5 w-3.5" }), label]
29289
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Dialog, {
29290
- open,
29291
- onOpenChange: (next) => !running && setOpen(next),
29292
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogContent, { children: [
29293
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogHeader, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogTitle, {
29294
- className: "flex items-center gap-2",
29295
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TriangleAlert, { className: "h-4 w-4 text-amber-400" }), title]
29296
- }), typeof description === "string" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DialogDescription, { children: description }) : description] }),
29297
- error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
29298
- className: cn("mt-2 px-3 py-2 rounded border border-danger/40 bg-danger/10", "text-xs text-danger"),
29299
- children: error
29300
- }),
29301
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogFooter, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
29302
- type: "button",
29303
- variant: "ghost",
29304
- size,
29305
- disabled: running,
29306
- onClick: () => setOpen(false),
29307
- children: "Cancel"
29308
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
29309
- type: "button",
29310
- variant: confirmVariant,
29311
- size,
29312
- disabled: running,
29313
- onClick: () => {
29314
- handleConfirm();
29315
- },
29316
- children: [running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), confirmLabel ?? label]
29317
- })] })
29318
- ] })
29319
- })] });
29320
- }
29321
- //#endregion
29322
- //#region src/composites/copy-button.tsx
29323
- /**
29324
- * `CopyButton` — a compact button that copies a string `value` to the
29325
- * clipboard via `navigator.clipboard.writeText`, showing a brief
29326
- * "copied" check state. Addon-agnostic; used wherever a secret / URL
29327
- * needs a one-click copy affordance (export setup panels, etc.).
29328
- */
29329
- var COPIED_RESET_MS = 2e3;
29330
- function CopyButton({ value, label, srLabel, className, disabled }) {
29331
- const [copied, setCopied] = (0, react$1.useState)(false);
29332
- const handleCopy = (0, react$1.useCallback)(() => {
29333
- if (!value) return;
29334
- navigator.clipboard.writeText(value).then(() => {
29335
- setCopied(true);
29336
- setTimeout(() => setCopied(false), COPIED_RESET_MS);
29337
- });
29338
- }, [value]);
29339
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
29340
- size: "sm",
29341
- variant: "ghost",
29342
- type: "button",
29343
- disabled: disabled || value.length === 0,
29344
- onClick: handleCopy,
29345
- className: cn(className),
29346
- "aria-label": copied ? "Copied" : `Copy ${srLabel ?? label ?? "value"}`,
29347
- children: [copied ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Check, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Copy, { className: "h-3.5 w-3.5" }), label ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
29348
- className: "ml-1",
29349
- children: copied ? "Copied" : label
29350
- }) : null]
29351
- });
29352
- }
29353
- //#endregion
29354
- //#region src/composites/data-table-layout.ts
29355
- /**
29356
- * At this many columns and above, `auto` switches a narrow viewport to cards.
29357
- * Three columns still fit a phone at a readable size; four do not.
29358
- */
29359
- var CARD_MODE_MIN_COLUMNS = 4;
29360
- function resolveTableLayout({ mode, columnCount, isNarrow }) {
29361
- if (!isNarrow) return "table";
29362
- if (mode === "scroll") return "table";
29363
- if (mode === "cards") return "cards";
29364
- if (columnCount === 0) return "table";
29365
- return columnCount >= 4 ? "cards" : "table";
29366
- }
29367
- //#endregion
29368
- //#region src/lib/responsive.ts
29369
- /**
29370
- * Responsive design tokens — three-tier system.
29371
- *
29372
- * The admin-ui targets three width tiers; every component should pick
29373
- * the right token from this file instead of hand-coding breakpoints.
29374
- * Adopting the tokens uniformly is what makes the app feel consistent
29375
- * across operator devices (mobile / tablet / desktop) and what unlocks
29376
- * targeted tuning (e.g. shrink every label by 1px in one place).
29377
- *
29378
- * Tier definitions (Tailwind breakpoints):
29379
- *
29380
- * • **L1 — Compact** (`< 640px`, default = mobile portrait)
29381
- * Single column, tight padding, smallest text, icon-only buttons
29382
- * where labels can be inferred. The operator can still see every
29383
- * stat / action — no horizontal scroll, no clipped text. The user
29384
- * directive: "molta informazione in poco spazio quando la width è
29385
- * bassa."
29386
- *
29387
- * • **L2 — Standard** (`sm` — `640–1023px`, tablet + small laptop)
29388
- * 2-column grids, side panels become visible, button labels return.
29389
- * Targets ~768px–1024px viewports — the most common operator sizes.
29390
- *
29391
- * • **L3 — Spacious** (`lg+` — `≥ 1024px`, desktop)
29392
- * 4-column grids, generous padding, full-width header text,
29393
- * side-by-side composite layouts. The operator has elbow room.
29394
- *
29395
- * Conventions:
29396
- * - **Always start from L1** — the bare class is always the compact
29397
- * variant, then `sm:` adds room at 640px, `lg:` at 1024px. No
29398
- * desktop-first breakpoints; no max-width queries.
29399
- * - **Token strings are static** — kept as `as const` so callers can
29400
- * compose them via `cn()` / template literals without running a
29401
- * class generator at runtime.
29402
- * - **Pair the token with a comment** — the comment in the file
29403
- * names every breakpoint it crosses, so a reader can predict the
29404
- * layout without going to Tailwind docs.
29405
- */
29406
- /** Container padding for a card-style section (header + body). */
29407
- var SECTION_CARD = "rounded-lg border border-border bg-surface overflow-hidden";
29408
- /** Header strip inside a card (between border-b lines). */
29409
- var SECTION_HEADER = "border-b border-border px-3 sm:px-4 py-1.5 sm:py-2";
29410
- /** Body padding for a card-style section. */
29411
- var SECTION_BODY = "p-2 sm:p-3 lg:p-4";
29412
- /** Gap between cards in a vertical stack. */
29413
- var STACK_GAP = "space-y-3 sm:space-y-4";
29414
- /** Gap between cards in a grid (4 cells / row at lg). */
29415
- var GRID_GAP = "gap-2 sm:gap-3";
29416
- /** Row padding inside a divided list (e.g. InfoRow). */
29417
- var LIST_ROW = "px-3 sm:px-4 py-1.5 sm:py-2";
29418
- /** 1-up at L1, 2-up at L2, 4-up at L3 — quick-stat row. */
29419
- var GRID_QUICK_STATS = "grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4";
29420
- /** 1-up at L1, 2-up at L2+ — paired panels (motion + detection rules). */
29421
- var GRID_PAIRED = "grid grid-cols-1 lg:grid-cols-2";
29422
- /** Side-by-side layout that stacks below md (canvas + side panel). */
29423
- var SPLIT_PANEL_OUTER = "flex flex-col-reverse md:flex-row";
29424
- /** Sidebar narrow lane in a SPLIT_PANEL_OUTER layout. */
29425
- var SPLIT_PANEL_SIDE = "w-full md:w-44 lg:w-52 md:flex-shrink-0 border-b md:border-b-0 md:border-l border-border";
29426
- /**
29427
- * The tier boundary at which a settings label/value row stops stacking.
29428
- *
29429
- * Below it (L1 — compact) the row is ONE column: label on its own line, value
29430
- * on the next at full width. Above it the row is label-left / value-right as
29431
- * before. The two-column form needs `SETTING_ROW_LABEL`'s lane plus a readable
29432
- * value beside it; a phone has neither, and the failure mode is not "cramped"
29433
- * but "the value is the part that gets cut" — the one thing the operator
29434
- * opened the page to read.
29435
- */
29436
- var SETTING_ROW_STACK_BREAKPOINT = "sm";
29437
- /** Outer row: single column at L1, label/value columns from `sm` up. */
29438
- var SETTING_ROW = "flex min-w-0 flex-col gap-0.5 py-1.5 sm:flex-row sm:items-center";
29439
- /** Label lane. Full width (and free to wrap) at L1; a fixed lane from `sm` up. */
29440
- var SETTING_ROW_LABEL = "min-w-0 text-[11px] leading-tight text-foreground-subtle sm:w-32 sm:shrink-0 sm:pr-2";
29441
- /**
29442
- * Value lane — the value text plus its trailing affordances (copy / reveal).
29443
- * `w-full` at L1 so the value owns the whole row once the label is above it;
29444
- * `sm:flex-1` makes it share the row again from `sm` up.
29445
- */
29446
- var SETTING_ROW_VALUE = "flex w-full min-w-0 items-center gap-1 sm:flex-1";
29447
- /**
29448
- * The value text itself. `break-all` lets an opaque token (a key, a URL) wrap
29449
- * at L1 instead of being cut mid-word; `sm:truncate` restores the single-line
29450
- * desktop form, where the row is wide enough for truncation to be a choice
29451
- * rather than data loss.
29452
- */
29453
- var SETTING_ROW_VALUE_TEXT = "min-w-0 break-all text-xs text-foreground sm:truncate";
29454
- /** Section header label (uppercase tracking-wider). */
29455
- var TEXT_SECTION_LABEL = "text-[10px] sm:text-[11px] font-semibold text-foreground uppercase tracking-wider";
29456
- /** Field label inside a row. */
29457
- var TEXT_FIELD_LABEL = "text-[11px] sm:text-xs text-foreground";
29458
- /** Sub-label / hint text. */
29459
- var TEXT_HINT = "text-[10px] text-foreground-subtle";
29460
- /** Primary value (a metric, badge, …). */
29461
- var TEXT_VALUE = "text-[11px] sm:text-xs font-medium text-foreground";
29462
- /** Big metric value (StatCard). */
29463
- var TEXT_METRIC = "text-base sm:text-lg font-semibold leading-tight text-foreground";
29464
- /** Default compact button. */
29465
- var BTN_COMPACT = "inline-flex items-center justify-center gap-1 rounded border border-border bg-surface px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-foreground hover:bg-surface-hover transition-colors disabled:opacity-40";
29466
- /** Primary compact button. */
29467
- var BTN_COMPACT_PRIMARY = "inline-flex items-center justify-center gap-1 rounded border border-primary/40 bg-primary/15 px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-primary hover:bg-primary/25 transition-colors disabled:opacity-40";
29468
- /** Danger compact button. */
29469
- var BTN_COMPACT_DANGER = "inline-flex items-center justify-center gap-1 rounded border border-danger/40 bg-danger/10 px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-danger hover:bg-danger/20 transition-colors disabled:opacity-40";
29470
- /** Warning compact button (e.g. cancel-drawing). */
29471
- var BTN_COMPACT_WARNING = "inline-flex items-center justify-center gap-1 rounded border border-warning/40 bg-warning/10 px-1.5 py-0.5 sm:px-2 sm:py-1 text-[10px] sm:text-[11px] font-medium text-warning hover:bg-warning/20 transition-colors disabled:opacity-40";
29472
- /** Compact text/select/number input. */
29473
- var INPUT_COMPACT = "w-full rounded border border-border bg-background px-1.5 py-0.5 sm:px-2 sm:py-1 text-[11px] sm:text-xs text-foreground focus:border-primary focus:ring-1 focus:ring-primary/30 outline-none";
29474
- /** Chip-style toggle (zone / class selectors in ZoneRulesEditor). */
29475
- var CHIP_BASE = "inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors";
29476
- var CHIP_ACTIVE = "border-primary/50 bg-primary/15 text-primary";
29477
- var CHIP_INACTIVE = "border-border bg-surface text-foreground-subtle hover:bg-surface-hover";
29478
- //#endregion
29479
- //#region src/composites/setting-row.tsx
29480
- function SettingRow({ label, children, actions, elements = "plain", className, valueClassName }) {
29481
- const LabelTag = elements === "description" ? "dt" : "span";
29482
- const ValueTag = elements === "description" ? "dd" : "div";
29483
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
29484
- "data-setting-row": "",
29485
- className: cn(SETTING_ROW, className),
29486
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LabelTag, {
29487
- "data-setting-row-label": "",
29488
- className: SETTING_ROW_LABEL,
29489
- children: label
29490
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(ValueTag, {
29491
- "data-setting-row-value": "",
29492
- className: SETTING_ROW_VALUE,
29493
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
29494
- "data-setting-row-value-text": "",
29495
- className: cn(SETTING_ROW_VALUE_TEXT, valueClassName),
29496
- children
29497
- }), actions !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
29498
- className: "flex shrink-0 items-center gap-0.5",
29499
- children: actions
29500
- })]
29501
- })]
29502
- });
29503
- }
29504
- //#endregion
29505
- //#region src/composites/sort-header-button.tsx
29506
- function SortHeaderButton({ label, active, dir, onSort }) {
29507
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
29508
- type: "button",
29509
- onClick: onSort,
29510
- className: "group/sort inline-flex items-center gap-0.5 hover:text-foreground transition-colors",
29511
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }), active ? dir === "asc" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronUp, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3 w-3 flex-shrink-0" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronUp, { className: cn("h-3 w-3 flex-shrink-0 opacity-0 transition-opacity", "group-hover/sort:opacity-30") })]
29512
- });
29513
- }
29514
- //#endregion
29515
- //#region src/composites/table-sort.ts
29516
- /**
29517
- * The toggle rule, and the only copy of it.
29518
- *
29519
- * none → asc → desc → none, and a click on a DIFFERENT column starts that
29520
- * column fresh at `asc`. `sameColumn` says whether the clicked column is the
29521
- * one currently sorted; `currentDir` is its direction (`null` when nothing is
29522
- * sorted). Returns the next direction, or `null` for "unsorted".
29523
- *
29524
- * Split out from {@link nextTableSort} so the device list — whose sort state
29525
- * keys columns by `columnId`, not `columnKey` — can reuse the rule without a
29526
- * cast and without a second copy of it.
29527
- */
29528
- function nextSortDirection(sameColumn, currentDir) {
29529
- if (!sameColumn || currentDir === null) return "asc";
29530
- return currentDir === "asc" ? "desc" : null;
29531
- }
29532
- /**
29533
- * Toggle a column's sort on header click: none → asc → desc → none.
29534
- * Clicking a DIFFERENT column starts it fresh at `asc`.
29535
- */
29536
- function nextTableSort(current, columnKey) {
29537
- const dir = nextSortDirection(current !== null && current.columnKey === columnKey, current === null ? null : current.dir);
29538
- return dir === null ? null : {
29539
- columnKey,
29540
- dir
29541
- };
29542
- }
29543
- /**
29544
- * Ascending comparison of two cell values.
29545
- *
29546
- * Numbers compare numerically and strings compare with `localeCompare` — a
29547
- * numeric column must never be ordered as text ("100" before "9"), and a text
29548
- * column must respect the locale's collation. A mixed column (one row a
29549
- * number, another a string) falls back to string comparison of both, which is
29550
- * the only total order available; it is not a shape any caller should build on
29551
- * purpose.
30517
+ * The calm sentence for a scene that is sitting the current light out — `null`
30518
+ * whenever it is checking normally.
29552
30519
  *
29553
- * `null` is NOT handled here — {@link sortRowsByColumn} pulls it out first,
29554
- * because its placement is direction-independent and a comparator that is
29555
- * multiplied by the direction factor cannot express that.
30520
+ * Separate from {@link statusLine} because it is not a verdict and must not
30521
+ * read like one. A paused scene has not failed, has not lost its flag, and
30522
+ * needs nothing from the operator; the only thing worth saying is which light
30523
+ * it is waiting out and that it will pick itself back up.
29556
30524
  */
29557
- function compareValues(a, b) {
29558
- if (typeof a === "number" && typeof b === "number") return a - b;
29559
- return String(a).localeCompare(String(b));
30525
+ function suspensionLine(monitor) {
30526
+ if (monitor.suspendedCondition === null) return null;
30527
+ const light = variantLabel(monitor.suspendedCondition).toLowerCase();
30528
+ return `Paused — nothing was captured in ${light}, so checks are on hold until the light changes back. Capture ${light} if you want it watched around the clock.`;
29560
30529
  }
30530
+ var UNAVAILABLE_SENTENCE = {
30531
+ "no-reference-for-condition": "can’t tell — nothing has been captured for this scene yet. Press “Capture daylight” to photograph what normal looks like.",
30532
+ "view-shifted": "can’t tell — the camera view has moved since the reference was captured. Re-capture it.",
30533
+ "no-vision-profile": "can’t tell — the vision model is unreachable, so the change is on hold.",
30534
+ "encoder-model-changed": "can’t tell — the embedding model changed, so the stored references are unreadable. Re-capture them.",
30535
+ "no-snapshot": "can’t tell — this camera did not return a frame."
30536
+ };
29561
30537
  /**
29562
- * Return a NEW array of `rows` ordered by `sort`.
29563
- *
29564
- * `valueOf` is the column's `sortValue` — it is called once per row per
29565
- * comparison, so it must be cheap (read a field, do not format a string).
29566
- * `sort === null` ⇒ a fresh shallow copy in the incoming order.
30538
+ * The one line under a scene's name. It answers, in order: can it judge, what
30539
+ * does it say, and how long has it said it.
29567
30540
  */
29568
- function sortRowsByColumn(rows, sort, valueOf) {
29569
- if (sort === null) return [...rows];
29570
- const factor = sort.dir === "asc" ? 1 : -1;
29571
- return rows.toSorted((a, b) => {
29572
- const av = valueOf(a);
29573
- const bv = valueOf(b);
29574
- if (av === null || bv === null) {
29575
- if (av === bv) return 0;
29576
- return av === null ? 1 : -1;
29577
- }
29578
- return factor * compareValues(av, bv);
29579
- });
30541
+ function statusLine(monitor, now) {
30542
+ if (monitor.states.length === 0) return "Not armed yet — capture the initial state to start watching.";
30543
+ if (monitor.verdict === "unknown") return monitor.unavailable !== null ? UNAVAILABLE_SENTENCE[monitor.unavailable] : "can’t tell right now.";
30544
+ const since = monitor.verdict === "diverged" ? monitor.divergedAt : monitor.armedAt;
30545
+ const held = since !== null ? ` for ${humanDuration(Math.max(0, now - since))}` : "";
30546
+ if (monitor.latched) return monitor.verdict === "diverged" ? `CHANGED${held} — press Reset when you have dealt with it.` : `Back to normal, but still flagged as changed${held}. Press Reset to clear it.`;
30547
+ const paused = suspensionLine(monitor);
30548
+ if (paused !== null) return paused;
30549
+ return monitor.verdict === "diverged" ? `CHANGED${held}` : `Normal${held}`;
29580
30550
  }
29581
- /** Derive the `<th>` `aria-sort` for `columnKey` from the table's sort state. */
29582
- function ariaSortForColumn(sort, columnKey) {
29583
- if (sort === null || sort.columnKey !== columnKey) return "none";
29584
- return sort.dir === "asc" ? "ascending" : "descending";
30551
+ /** The badge word. `latched` wins over `verdict`, because the latch is the
30552
+ * thing the operator asked to be told and it outlives the live state.
30553
+ *
30554
+ * A scene sitting out an uncovered light reads `Paused`, in the NEUTRAL tone —
30555
+ * never `alarm`, and never the `danger` variant the page maps that to. A
30556
+ * missing IR reference must not be able to paint a red badge on a scene that
30557
+ * is doing exactly what it was configured to do. */
30558
+ function badgeFor(monitor) {
30559
+ if (monitor.states.length === 0) return {
30560
+ text: "Not armed",
30561
+ tone: "unknown"
30562
+ };
30563
+ if (monitor.latched) return {
30564
+ text: "Changed",
30565
+ tone: "alarm"
30566
+ };
30567
+ if (monitor.suspendedCondition !== null) return {
30568
+ text: "Paused",
30569
+ tone: "unknown"
30570
+ };
30571
+ if (monitor.verdict === "unknown") return {
30572
+ text: "Can’t tell",
30573
+ tone: "unknown"
30574
+ };
30575
+ return monitor.verdict === "diverged" ? {
30576
+ text: "Changed",
30577
+ tone: "alarm"
30578
+ } : {
30579
+ text: "Normal",
30580
+ tone: "ok"
30581
+ };
30582
+ }
30583
+ /** A rect covering most of the frame is dominated by weather, sky and parked
30584
+ * cars: the bin is 2 % of the pixels and CLIP will not notice it leaving. The
30585
+ * failure is silent INSENSITIVITY, which is the worst kind — hence a warning
30586
+ * rather than a refusal. */
30587
+ var ROI_AREA_WARN_FRACTION = .4;
30588
+ function roiTooLarge(roi) {
30589
+ return roi.width * roi.height > ROI_AREA_WARN_FRACTION;
30590
+ }
30591
+ function humanDuration(ms) {
30592
+ const s = Math.floor(ms / 1e3);
30593
+ if (s < 60) return `${s}s`;
30594
+ const m = Math.floor(s / 60);
30595
+ if (m < 60) return `${m}m`;
30596
+ const h = Math.floor(m / 60);
30597
+ if (h < 48) return `${h}h`;
30598
+ return `${Math.floor(h / 24)}d`;
29585
30599
  }
29586
30600
  //#endregion
29587
- //#region src/composites/data-table.tsx
30601
+ //#region src/composites/code-block.tsx
30602
+ function CodeBlock({ children, maxHeight = 300, className }) {
30603
+ const [copied, setCopied] = (0, react$1.useState)(false);
30604
+ const handleCopy = (0, react$1.useCallback)(() => {
30605
+ navigator.clipboard.writeText(children).then(() => {
30606
+ setCopied(true);
30607
+ setTimeout(() => setCopied(false), 2e3);
30608
+ });
30609
+ }, [children]);
30610
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30611
+ className: cn("relative group", className),
30612
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScrollArea, {
30613
+ style: { maxHeight },
30614
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
30615
+ className: "font-mono text-xs bg-surface p-3 rounded-md border border-border-subtle",
30616
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", { children })
30617
+ })
30618
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30619
+ className: "absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity",
30620
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconButton, {
30621
+ icon: copied ? Check : Copy,
30622
+ "aria-label": "Copy code",
30623
+ variant: "ghost",
30624
+ size: "sm",
30625
+ onClick: handleCopy
30626
+ })
30627
+ })]
30628
+ });
30629
+ }
30630
+ //#endregion
30631
+ //#region src/composites/confirm-action-button.tsx
29588
30632
  /**
29589
- * DataTable — shared responsive table for admin pages.
29590
- *
29591
- * Recipes baked in:
29592
- * - `overflow-x-auto` wrapper so narrow viewports don't clip the
29593
- * right edge (the rounded border survives the scroll).
29594
- * - `min-w-[Npx]` on the inner table so columns don't squash below
29595
- * readable width before the scroll kicks in.
29596
- * - `whitespace-nowrap` on headers — column titles never wrap to
29597
- * two lines (`AUTO-KILL` was the regression that triggered this).
29598
- * - Themed `bg-surface` + `border-border` so contrast works in both
29599
- * light and dark themes.
29600
- *
29601
- * Column model: caller supplies `columns: DataColumn<T>[]` and
29602
- * `rows: T[]`. Each column declares `header`, optional `align`,
29603
- * optional `nowrap`, and a `render(row) => ReactNode` that produces
29604
- * the cell content.
29605
- *
29606
- * <DataTable
29607
- * columns={[
29608
- * { key: 'username', header: 'Username', render: (u) => u.username },
29609
- * { key: 'role', header: 'Role', render: (u) => <RoleBadge .../> },
29610
- * { key: 'actions', header: '', align: 'right', render: (u) => <Actions .../> },
29611
- * ]}
29612
- * rows={users}
29613
- * minWidthPx={560}
29614
- * />
29615
- *
29616
- * If finer header markup is needed, pass `headerRender` instead of
29617
- * `header` (e.g. for a sortable click affordance).
29618
- *
29619
- * ── Narrow viewports ──────────────────────────────────────────────────────
29620
- * The table stays a table. The wrapper is `overflow-x-auto`, so a phone
29621
- * scrolls the columns sideways INSIDE the table — the page body never moves
29622
- * sideways, and the cells stay one line (`whitespace-nowrap`). That is the
29623
- * default (`mobileMode="scroll"`). Cards are opt-in via `mobileMode="cards"`;
29624
- * `auto` still applies the old column-count rule for a surface that wants it.
29625
- * See `resolveTableLayout`.
29626
- *
29627
- * ── Scrolling the BODY, not the page ──────────────────────────────────────
29628
- * `maxBodyHeight` caps the table and scrolls its rows INSIDE the container
29629
- * with the column header pinned. It is ONE prop, not two, because "this list
29630
- * scrolls internally" and "its header stays visible" are the same decision: a
29631
- * capped table whose header scrolls away is a list of unlabelled cells.
30633
+ * ConfirmActionButton — destructive-action button with a modal confirm.
29632
30634
  *
29633
- * Use it wherever the row count is open-ended (rules, targets, delivery
29634
- * history) so the page keeps its own header, filters and actions in view while
29635
- * the operator moves through the rows.
29636
- */
29637
- /**
29638
- * The default cap for an internally-scrolling table.
30635
+ * Two-step UX for any operation operators shouldn't trigger by accident:
30636
+ * reboot device, restart addon, regenerate token, etc. The trigger is a
30637
+ * normal Button that opens a Dialog; confirming runs the async action,
30638
+ * the trigger spinner-disables itself for the duration, and the dialog
30639
+ * closes on success. Errors surface inline at the dialog's bottom.
29639
30640
  *
29640
- * `60vh` leaves the page header, its filters and its actions on screen at
29641
- * every viewport while still showing ~10 rows — and it is a fraction, so it
29642
- * does not need a breakpoint of its own.
30641
+ * Generic on `TResult` so callers don't have to discard the return
30642
+ * value. Pass an `icon` to render it inside the trigger (e.g. RotateCw
30643
+ * for reboot, RefreshCw for restart).
29643
30644
  */
29644
- var TABLE_SCROLL_MAX_H = "max-h-[60vh]";
29645
- var ALIGN_CLASS = {
29646
- left: "text-left",
29647
- right: "text-right",
29648
- center: "text-center"
29649
- };
29650
- function DataTable({ columns, rows, rowKey, onRowClick, minWidthPx = 480, emptyMessage, className, bordered = true, rowClassName, mobileMode = "scroll", maxBodyHeight, defaultSort }) {
29651
- const isNarrow = useIsMobile();
29652
- const [sort, setSort] = (0, react$1.useState)(defaultSort ?? null);
29653
- const layout = resolveTableLayout({
29654
- mode: mobileMode,
29655
- columnCount: columns.length,
29656
- isNarrow
29657
- });
29658
- const scrolls = maxBodyHeight !== void 0;
29659
- const sortedColumn = (0, react$1.useMemo)(() => sort === null ? void 0 : columns.find((col) => col.key === sort.columnKey && col.sortValue !== void 0), [columns, sort]);
29660
- const sortValue = sortedColumn?.sortValue;
29661
- const displayRows = (0, react$1.useMemo)(() => sortValue === void 0 ? rows : sortRowsByColumn(rows, sort, sortValue), [
29662
- rows,
29663
- sort,
29664
- sortValue
29665
- ]);
29666
- const activeSort = sortedColumn === void 0 ? null : sort;
29667
- if (rows.length === 0 && emptyMessage) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
29668
- className: `rounded-lg ${bordered ? "border border-border" : ""} bg-surface px-3 py-4 text-xs text-foreground-subtle text-center ${className ?? ""}`,
29669
- children: emptyMessage
29670
- });
29671
- if (rows.length === 0) return null;
29672
- if (layout === "cards") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
29673
- className: `space-y-2 ${scrolls ? `${maxBodyHeight} overflow-y-auto` : ""} ${className ?? ""}`,
29674
- children: displayRows.map((row, rowIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DataTableCard, {
29675
- row,
29676
- rowIndex,
29677
- columns,
29678
- bordered,
29679
- onRowClick,
29680
- rowClassName
29681
- }, rowKey ? rowKey(row, rowIndex) : rowIndex))
29682
- });
29683
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
29684
- className: [
29685
- "rounded-lg",
29686
- bordered ? "border border-border" : "",
29687
- "bg-surface overflow-x-auto",
29688
- scrolls ? `${maxBodyHeight} overflow-y-auto` : "",
29689
- className ?? ""
29690
- ].filter(Boolean).join(" "),
29691
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
29692
- className: `w-full text-xs ${scrolls ? "border-separate border-spacing-0" : ""}`,
29693
- style: { minWidth: `${minWidthPx}px` },
29694
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", {
29695
- className: "bg-surface",
29696
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", { children: columns.map((col) => {
29697
- const align = col.align ?? "left";
29698
- const nowrap = col.nowrap ?? true;
29699
- const sortable = col.sortValue !== void 0;
29700
- const isSorted = activeSort !== null && activeSort.columnKey === col.key;
29701
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
29702
- "aria-sort": sortable ? ariaSortForColumn(activeSort, col.key) : void 0,
29703
- title: sortable ? void 0 : col.notSortableReason,
29704
- className: [
29705
- "px-3 py-2 text-foreground-subtle font-medium bg-surface border-b border-border",
29706
- scrolls ? "sticky top-0 z-10" : "",
29707
- ALIGN_CLASS[align],
29708
- nowrap ? "whitespace-nowrap" : "",
29709
- col.width ?? ""
29710
- ].filter(Boolean).join(" "),
29711
- children: sortable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SortHeaderButton, {
29712
- label: col.headerRender ? col.headerRender() : col.header,
29713
- active: isSorted,
29714
- dir: isSorted && activeSort !== null ? activeSort.dir : "asc",
29715
- onSort: () => setSort((prev) => nextTableSort(prev, col.key))
29716
- }) : col.headerRender ? col.headerRender() : col.header
29717
- }, col.key);
29718
- }) })
29719
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: displayRows.map((row, rowIndex) => {
29720
- const key = rowKey ? rowKey(row, rowIndex) : rowIndex;
29721
- const extra = rowClassName?.(row, rowIndex) ?? "";
29722
- const interactive = onRowClick !== void 0;
29723
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", {
29724
- onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
29725
- className: [interactive ? "cursor-pointer hover:bg-primary/5" : "hover:bg-primary/5", extra].filter(Boolean).join(" "),
29726
- children: columns.map((col) => {
29727
- const align = col.align ?? "left";
29728
- const nowrap = col.nowrap ?? true;
29729
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
29730
- className: [
29731
- "px-3 py-2 text-foreground border-b border-border",
29732
- ALIGN_CLASS[align],
29733
- nowrap ? "whitespace-nowrap" : ""
29734
- ].filter(Boolean).join(" "),
29735
- children: col.render(row, rowIndex)
29736
- }, col.key);
29737
- })
29738
- }, key);
29739
- }) })]
29740
- })
29741
- });
30645
+ function ConfirmActionButton({ label, icon: Icon, title = "Confirm action", description, confirmLabel, triggerVariant = "outline", confirmVariant = "danger", size = "sm", disabled, className, action, onSuccess }) {
30646
+ const [open, setOpen] = (0, react$1.useState)(false);
30647
+ const [running, setRunning] = (0, react$1.useState)(false);
30648
+ const [error, setError] = (0, react$1.useState)(null);
30649
+ const handleConfirm = async () => {
30650
+ setError(null);
30651
+ setRunning(true);
30652
+ try {
30653
+ const result = await action();
30654
+ onSuccess?.(result);
30655
+ setOpen(false);
30656
+ } catch (err) {
30657
+ setError(err instanceof Error ? err.message : String(err));
30658
+ } finally {
30659
+ setRunning(false);
30660
+ }
30661
+ };
30662
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
30663
+ type: "button",
30664
+ variant: triggerVariant,
30665
+ size,
30666
+ disabled,
30667
+ onClick: () => {
30668
+ setError(null);
30669
+ setOpen(true);
30670
+ },
30671
+ className,
30672
+ children: [Icon && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, { className: "h-3.5 w-3.5" }), label]
30673
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Dialog, {
30674
+ open,
30675
+ onOpenChange: (next) => !running && setOpen(next),
30676
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogContent, { children: [
30677
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogHeader, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogTitle, {
30678
+ className: "flex items-center gap-2",
30679
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TriangleAlert, { className: "h-4 w-4 text-amber-400" }), title]
30680
+ }), typeof description === "string" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DialogDescription, { children: description }) : description] }),
30681
+ error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30682
+ className: cn("mt-2 px-3 py-2 rounded border border-danger/40 bg-danger/10", "text-xs text-danger"),
30683
+ children: error
30684
+ }),
30685
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(DialogFooter, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
30686
+ type: "button",
30687
+ variant: "ghost",
30688
+ size,
30689
+ disabled: running,
30690
+ onClick: () => setOpen(false),
30691
+ children: "Cancel"
30692
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
30693
+ type: "button",
30694
+ variant: confirmVariant,
30695
+ size,
30696
+ disabled: running,
30697
+ onClick: () => {
30698
+ handleConfirm();
30699
+ },
30700
+ children: [running && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LoaderCircle, { className: "h-3.5 w-3.5 animate-spin" }), confirmLabel ?? label]
30701
+ })] })
30702
+ ] })
30703
+ })] });
29742
30704
  }
30705
+ //#endregion
30706
+ //#region src/composites/copy-button.tsx
29743
30707
  /**
29744
- * One row as a card. Columns that carry a plain-text `header` become labelled
29745
- * `SettingRow`s — the label is the column title and the value gets the full
29746
- * card width. Columns with no text header (an actions column, a
29747
- * `headerRender`-only column) have no label to show, so they render as a
29748
- * full-width strip at the foot of the card.
30708
+ * `CopyButton` — a compact button that copies a string `value` to the
30709
+ * clipboard via `navigator.clipboard.writeText`, showing a brief
30710
+ * "copied" check state. Addon-agnostic; used wherever a secret / URL
30711
+ * needs a one-click copy affordance (export setup panels, etc.).
29749
30712
  */
29750
- function DataTableCard({ row, rowIndex, columns, bordered, onRowClick, rowClassName }) {
29751
- const labelled = columns.filter((col) => col.header !== void 0 && col.header !== "");
29752
- const unlabelled = columns.filter((col) => col.header === void 0 || col.header === "");
29753
- const interactive = onRowClick !== void 0;
29754
- const extra = rowClassName?.(row, rowIndex) ?? "";
29755
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
29756
- "data-data-table-card": "",
29757
- onClick: interactive ? () => onRowClick(row, rowIndex) : void 0,
29758
- className: [
29759
- "rounded-lg bg-surface px-3 py-1.5 divide-y divide-border-subtle",
29760
- bordered ? "border border-border" : "",
29761
- interactive ? "cursor-pointer hover:bg-primary/5" : "",
29762
- extra
29763
- ].filter(Boolean).join(" "),
29764
- children: [labelled.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingRow, {
29765
- label: col.header,
29766
- children: col.render(row, rowIndex)
29767
- }, col.key)), unlabelled.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
29768
- className: "flex flex-wrap items-center justify-end gap-2 py-1.5",
29769
- children: unlabelled.map((col) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: col.render(row, rowIndex) }, col.key))
29770
- })]
30713
+ var COPIED_RESET_MS = 2e3;
30714
+ function CopyButton({ value, label, srLabel, className, disabled }) {
30715
+ const [copied, setCopied] = (0, react$1.useState)(false);
30716
+ const handleCopy = (0, react$1.useCallback)(() => {
30717
+ if (!value) return;
30718
+ navigator.clipboard.writeText(value).then(() => {
30719
+ setCopied(true);
30720
+ setTimeout(() => setCopied(false), COPIED_RESET_MS);
30721
+ });
30722
+ }, [value]);
30723
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
30724
+ size: "sm",
30725
+ variant: "ghost",
30726
+ type: "button",
30727
+ disabled: disabled || value.length === 0,
30728
+ onClick: handleCopy,
30729
+ className: cn(className),
30730
+ "aria-label": copied ? "Copied" : `Copy ${srLabel ?? label ?? "value"}`,
30731
+ children: [copied ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Check, { className: "h-3.5 w-3.5 text-success" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Copy, { className: "h-3.5 w-3.5" }), label ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
30732
+ className: "ml-1",
30733
+ children: copied ? "Copied" : label
30734
+ }) : null]
29771
30735
  });
29772
30736
  }
29773
30737
  //#endregion
@@ -45825,99 +46789,6 @@ function fuzzyMatch(query, target) {
45825
46789
  return normalizeForSearch(target).includes(nq);
45826
46790
  }
45827
46791
  //#endregion
45828
- //#region src/composites/device-list/pagination.tsx
45829
- var NEIGHBORS = 2;
45830
- function computePageButtons(currentPage, totalPages) {
45831
- if (totalPages <= 1) return [{
45832
- kind: "page",
45833
- n: 1
45834
- }];
45835
- if (totalPages <= 7) {
45836
- const all = [];
45837
- for (let i = 1; i <= totalPages; i++) all.push({
45838
- kind: "page",
45839
- n: i
45840
- });
45841
- return all;
45842
- }
45843
- const result = [];
45844
- const leftBound = Math.max(2, currentPage - NEIGHBORS);
45845
- const rightBound = Math.min(totalPages - 1, currentPage + NEIGHBORS);
45846
- result.push({
45847
- kind: "page",
45848
- n: 1
45849
- });
45850
- if (leftBound > 2) result.push({
45851
- kind: "ellipsis",
45852
- side: "left"
45853
- });
45854
- for (let i = leftBound; i <= rightBound; i++) result.push({
45855
- kind: "page",
45856
- n: i
45857
- });
45858
- if (rightBound < totalPages - 1) result.push({
45859
- kind: "ellipsis",
45860
- side: "right"
45861
- });
45862
- result.push({
45863
- kind: "page",
45864
- n: totalPages
45865
- });
45866
- return result;
45867
- }
45868
- function Pagination({ currentPage, totalItems, pageSize, onPageChange, itemNoun = "devices" }) {
45869
- if (totalItems <= pageSize) return null;
45870
- const totalPages = Math.ceil(totalItems / pageSize);
45871
- const startIdx = (currentPage - 1) * pageSize + 1;
45872
- const endIdx = Math.min(currentPage * pageSize, totalItems);
45873
- const buttons = computePageButtons(currentPage, totalPages);
45874
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
45875
- className: "flex items-center justify-between gap-4 flex-wrap pt-2 text-[11px] text-foreground-subtle",
45876
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
45877
- "Showing ",
45878
- startIdx,
45879
- "–",
45880
- endIdx,
45881
- " of ",
45882
- totalItems,
45883
- " ",
45884
- itemNoun
45885
- ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
45886
- className: "inline-flex items-center gap-1",
45887
- children: [
45888
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
45889
- type: "button",
45890
- "aria-label": "Previous page",
45891
- disabled: currentPage === 1,
45892
- onClick: () => onPageChange(currentPage - 1),
45893
- className: cn("inline-flex items-center justify-center h-7 w-7 rounded border border-border bg-surface text-foreground-subtle", "hover:text-foreground hover:bg-surface-hover transition-colors", "disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-surface"),
45894
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronLeft, { className: "h-3 w-3" })
45895
- }),
45896
- buttons.map((b, i) => {
45897
- if (b.kind === "ellipsis") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
45898
- className: "px-1 text-foreground-subtle/60 select-none",
45899
- children: "…"
45900
- }, `e-${i}`);
45901
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
45902
- type: "button",
45903
- onClick: () => onPageChange(b.n),
45904
- className: cn("inline-flex items-center justify-center h-7 min-w-7 px-1.5 rounded border text-[10.5px] font-medium transition-colors", b.n === currentPage ? "border-primary/40 bg-primary/10 text-primary" : "border-border bg-surface text-foreground-subtle hover:text-foreground hover:bg-surface-hover"),
45905
- children: b.n
45906
- }, `p-${b.n}`);
45907
- }),
45908
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
45909
- type: "button",
45910
- "aria-label": "Next page",
45911
- disabled: currentPage === totalPages,
45912
- onClick: () => onPageChange(currentPage + 1),
45913
- className: cn("inline-flex items-center justify-center h-7 w-7 rounded border border-border bg-surface text-foreground-subtle", "hover:text-foreground hover:bg-surface-hover transition-colors", "disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-surface"),
45914
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronRight, { className: "h-3 w-3" })
45915
- })
45916
- ]
45917
- })]
45918
- });
45919
- }
45920
- //#endregion
45921
46792
  //#region src/composites/device-list/multi-select.tsx
45922
46793
  /**
45923
46794
  * Compact multi-select dropdown — a single chip-sized trigger ("Label · N")
@@ -48901,7 +49772,7 @@ function hlsLevelToInfo(level) {
48901
49772
  name: level.name.length > 0 ? level.name : void 0
48902
49773
  };
48903
49774
  }
48904
- function HlsVideo({ url, authToken, posterUrl, autoPlay = true, className, onQualityOptions, selectedLevel = -1 }) {
49775
+ function HlsVideo({ url, format = "hls", authToken, posterUrl, autoPlay = true, className, onQualityOptions, selectedLevel = -1 }) {
48905
49776
  const videoRef = (0, react$1.useRef)(null);
48906
49777
  const hlsRef = (0, react$1.useRef)(null);
48907
49778
  const [error, setError] = (0, react$1.useState)(null);
@@ -48914,6 +49785,23 @@ function HlsVideo({ url, authToken, posterUrl, autoPlay = true, className, onQua
48914
49785
  onQualityOptions?.([]);
48915
49786
  const onPlaying = () => setLoading(false);
48916
49787
  video.addEventListener("playing", onPlaying);
49788
+ if (format === "mp4") {
49789
+ video.src = url;
49790
+ const onLoaded = () => {
49791
+ if (autoPlay) video.play().catch(() => {});
49792
+ };
49793
+ const onFailed = () => setError("Could not load this clip.");
49794
+ video.addEventListener("loadedmetadata", onLoaded);
49795
+ video.addEventListener("error", onFailed);
49796
+ onQualityOptions?.([]);
49797
+ return () => {
49798
+ video.removeEventListener("loadedmetadata", onLoaded);
49799
+ video.removeEventListener("error", onFailed);
49800
+ video.removeEventListener("playing", onPlaying);
49801
+ video.removeAttribute("src");
49802
+ video.load();
49803
+ };
49804
+ }
48917
49805
  if (!("MediaSource" in window) && video.canPlayType("application/vnd.apple.mpegurl")) {
48918
49806
  video.src = url;
48919
49807
  const onLoaded = () => {
@@ -48976,6 +49864,7 @@ function HlsVideo({ url, authToken, posterUrl, autoPlay = true, className, onQua
48976
49864
  };
48977
49865
  }, [
48978
49866
  url,
49867
+ format,
48979
49868
  authToken,
48980
49869
  autoPlay,
48981
49870
  onQualityOptions
@@ -51119,7 +52008,7 @@ function ImageOffIcon({ className }) {
51119
52008
  ]
51120
52009
  });
51121
52010
  }
51122
- function StreamPanel({ serverUrl, createSession, sendAnswer, handleOffer, getIceServers, addIceCandidate, getIceCandidates, closeSession, getSessionState, getSessionDebug, playbackUrl, playbackAuthToken, onExitPlayback, posterUrl, streams, activeStreamId: controlledStreamId, onStreamChange, deviceName, phase, pipelineMetrics, detections, stationaryObjects, defaultShowDetections = true, defaultShowMotion = false, streamStats, autoPlay = false, showPlayStop = false, snapshotSrc, snapshotLoading = false, onRefreshSnapshot, showStreamStats = false, children, extraOverlay, ptzAvailable = false, ptzShown = false, ptzOverlay, onPtzToggle, intercomAvailable = false, intercomShown = false, onIntercomToggle, chromeless = false, className }) {
52011
+ function StreamPanel({ serverUrl, createSession, sendAnswer, handleOffer, getIceServers, addIceCandidate, getIceCandidates, closeSession, getSessionState, getSessionDebug, playbackUrl, playbackFormat, playbackAuthToken, onExitPlayback, posterUrl, streams, activeStreamId: controlledStreamId, onStreamChange, deviceName, phase, pipelineMetrics, detections, stationaryObjects, defaultShowDetections = true, defaultShowMotion = false, streamStats, autoPlay = false, showPlayStop = false, snapshotSrc, snapshotLoading = false, onRefreshSnapshot, showStreamStats = false, children, extraOverlay, ptzAvailable = false, ptzShown = false, ptzOverlay, onPtzToggle, intercomAvailable = false, intercomShown = false, onIntercomToggle, chromeless = false, className }) {
51123
52012
  const registeredButtons = usePlayerToolbarButtons();
51124
52013
  const recordedPlayback = useRecordedPlayback();
51125
52014
  const [isPlaying, setIsPlaying] = (0, react$1.useState)(autoPlay);
@@ -51420,6 +52309,7 @@ function StreamPanel({ serverUrl, createSession, sendAnswer, handleOffer, getIce
51420
52309
  className: "aspect-video relative",
51421
52310
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(HlsVideo, {
51422
52311
  url: playbackUrl,
52312
+ ...playbackFormat ? { format: playbackFormat } : {},
51423
52313
  authToken: playbackAuthToken ?? null,
51424
52314
  onQualityOptions: handleVodQualityOptions,
51425
52315
  selectedLevel: vodLevel,
@@ -52796,6 +53686,7 @@ exports.badgeFor$1 = badgeFor;
52796
53686
  exports.buildStepTreeFromSchema = buildStepTreeFromSchema;
52797
53687
  exports.childEntityId = childEntityId;
52798
53688
  exports.childListName = childListName;
53689
+ exports.classifyCapability = classifyCapability;
52799
53690
  exports.cn = cn;
52800
53691
  exports.columnsForContext = columnsForContext;
52801
53692
  exports.containerChildToRef = containerChildToRef;
@@ -53034,6 +53925,7 @@ exports.useCameraStreamsGetCameraStreams = useCameraStreamsGetCameraStreams;
53034
53925
  exports.useCameraStreamsGetProfileRtspEntries = useCameraStreamsGetProfileRtspEntries;
53035
53926
  exports.useCameraStreamsGetRtspEntries = useCameraStreamsGetRtspEntries;
53036
53927
  exports.useCameraStreamsPickStream = useCameraStreamsPickStream;
53928
+ exports.useCapabilityPresence = useCapabilityPresence;
53037
53929
  exports.useCarbonMonoxideGetStatus = useCarbonMonoxideGetStatus;
53038
53930
  exports.useClimateControlGetStatus = useClimateControlGetStatus;
53039
53931
  exports.useClimateControlSetFanMode = useClimateControlSetFanMode;
@@ -53987,6 +54879,7 @@ exports.useValveStop = useValveStop;
53987
54879
  exports.useVibrationGetStatus = useVibrationGetStatus;
53988
54880
  exports.useVideoclipsGetClipPlayback = useVideoclipsGetClipPlayback;
53989
54881
  exports.useVideoclipsListClips = useVideoclipsListClips;
54882
+ exports.useVideoclipsListSources = useVideoclipsListSources;
53990
54883
  exports.useVodPlayback = useVodPlayback;
53991
54884
  exports.useWaterHeaterGetStatus = useWaterHeaterGetStatus;
53992
54885
  exports.useWaterHeaterSetAway = useWaterHeaterSetAway;