@camstack/ui-library 1.2.368 → 1.2.370

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
@@ -4284,6 +4284,34 @@ var Thermometer = require_createLucideIcon.createLucideIcon("thermometer", [["pa
4284
4284
  * This source code is licensed under the ISC license.
4285
4285
  * See the LICENSE file in the root directory of this source tree.
4286
4286
  */
4287
+ var TimerOff = require_createLucideIcon.createLucideIcon("timer-off", [
4288
+ ["path", {
4289
+ d: "M10 2h4",
4290
+ key: "n1abiw"
4291
+ }],
4292
+ ["path", {
4293
+ d: "M4.6 11a8 8 0 0 0 1.7 8.7 8 8 0 0 0 8.7 1.7",
4294
+ key: "10he05"
4295
+ }],
4296
+ ["path", {
4297
+ d: "M7.4 7.4a8 8 0 0 1 10.3 1 8 8 0 0 1 .9 10.2",
4298
+ key: "15f7sh"
4299
+ }],
4300
+ ["path", {
4301
+ d: "m2 2 20 20",
4302
+ key: "1ooewy"
4303
+ }],
4304
+ ["path", {
4305
+ d: "M12 12v-2",
4306
+ key: "fwoke6"
4307
+ }]
4308
+ ]);
4309
+ /**
4310
+ * @license lucide-react v0.576.0 - ISC
4311
+ *
4312
+ * This source code is licensed under the ISC license.
4313
+ * See the LICENSE file in the root directory of this source tree.
4314
+ */
4287
4315
  var Timer = require_createLucideIcon.createLucideIcon("timer", [
4288
4316
  ["line", {
4289
4317
  x1: "10",
@@ -5456,6 +5484,11 @@ var DEVICE_TYPE_META = {
5456
5484
  labelKey: "uiLibrary.deviceMeta.type.petFountain",
5457
5485
  labelFallback: "Pet fountain"
5458
5486
  },
5487
+ [_camstack_types.DeviceType.Heater]: {
5488
+ icon: Flame,
5489
+ labelKey: "uiLibrary.deviceMeta.type.heater",
5490
+ labelFallback: "Heater"
5491
+ },
5459
5492
  [_camstack_types.DeviceType.Dishwasher]: {
5460
5493
  icon: Waves,
5461
5494
  labelKey: "uiLibrary.deviceMeta.type.dishwasher",
@@ -5961,7 +5994,7 @@ function lucideExportName(key) {
5961
5994
  function loadIconGlyphs() {
5962
5995
  if (fullTable !== void 0) return Promise.resolve();
5963
5996
  if (failure$1 !== void 0) return Promise.reject(failure$1);
5964
- pending ??= Promise.resolve().then(() => require("./icons-jbAtRNh8.cjs")).then((module) => {
5997
+ pending ??= Promise.resolve().then(() => require("./icons-DXwPEKdJ.cjs")).then((module) => {
5965
5998
  fullTable = module;
5966
5999
  publish();
5967
6000
  }, (error) => {
@@ -6043,6 +6076,7 @@ function matchesCombo(event, combo, platform) {
6043
6076
  var PAGE_SCOPE = "page";
6044
6077
  /** Asked last, after the page: a global handler that yields to whatever the page registers. */
6045
6078
  var FALLBACK_SCOPE = "fallback";
6079
+ var NO_HOTKEYS = [];
6046
6080
  var RegistryContext = (0, react$1.createContext)(null);
6047
6081
  var ScopeContext = (0, react$1.createContext)(PAGE_SCOPE);
6048
6082
  function isTypingTarget(target) {
@@ -6081,32 +6115,49 @@ function HotkeyProvider({ children, platform }) {
6081
6115
  scopes: []
6082
6116
  });
6083
6117
  const effectivePlatform = platform ?? detectPlatform();
6084
- const registry = (0, react$1.useMemo)(() => ({
6085
- addBinding: (binding) => {
6086
- stateRef.current = {
6087
- ...stateRef.current,
6088
- bindings: [...stateRef.current.bindings, binding]
6089
- };
6090
- return () => {
6118
+ const listenersRef = (0, react$1.useRef)(/* @__PURE__ */ new Set());
6119
+ const listRef = (0, react$1.useRef)(NO_HOTKEYS);
6120
+ const registry = (0, react$1.useMemo)(() => {
6121
+ const publish = () => {
6122
+ listRef.current = distinctHotkeys(stateRef.current.bindings);
6123
+ for (const listener of listenersRef.current) listener();
6124
+ };
6125
+ return {
6126
+ subscribe: (listener) => {
6127
+ listenersRef.current = new Set([...listenersRef.current, listener]);
6128
+ return () => {
6129
+ listenersRef.current = new Set([...listenersRef.current].filter((l) => l !== listener));
6130
+ };
6131
+ },
6132
+ snapshot: () => listRef.current,
6133
+ addBinding: (binding) => {
6091
6134
  stateRef.current = {
6092
6135
  ...stateRef.current,
6093
- bindings: stateRef.current.bindings.filter((b) => b.id !== binding.id)
6136
+ bindings: [...stateRef.current.bindings, binding]
6094
6137
  };
6095
- };
6096
- },
6097
- addScope: (scope) => {
6098
- stateRef.current = {
6099
- ...stateRef.current,
6100
- scopes: [...stateRef.current.scopes, scope]
6101
- };
6102
- return () => {
6138
+ publish();
6139
+ return () => {
6140
+ stateRef.current = {
6141
+ ...stateRef.current,
6142
+ bindings: stateRef.current.bindings.filter((b) => b.id !== binding.id)
6143
+ };
6144
+ publish();
6145
+ };
6146
+ },
6147
+ addScope: (scope) => {
6103
6148
  stateRef.current = {
6104
6149
  ...stateRef.current,
6105
- scopes: stateRef.current.scopes.filter((s) => s !== scope)
6150
+ scopes: [...stateRef.current.scopes, scope]
6106
6151
  };
6107
- };
6108
- }
6109
- }), []);
6152
+ return () => {
6153
+ stateRef.current = {
6154
+ ...stateRef.current,
6155
+ scopes: stateRef.current.scopes.filter((s) => s !== scope)
6156
+ };
6157
+ };
6158
+ }
6159
+ };
6160
+ }, []);
6110
6161
  (0, react$1.useEffect)(() => {
6111
6162
  const onKeyDown = (event) => dispatch(stateRef.current, event, effectivePlatform);
6112
6163
  window.addEventListener("keydown", onKeyDown, true);
@@ -6118,6 +6169,29 @@ function HotkeyProvider({ children, platform }) {
6118
6169
  });
6119
6170
  }
6120
6171
  var nextBindingId = 0;
6172
+ function distinctHotkeys(bindings) {
6173
+ const seen = /* @__PURE__ */ new Map();
6174
+ for (const b of bindings) {
6175
+ const key = `${b.scope}|${b.combo}`;
6176
+ const prev = seen.get(key);
6177
+ if (!prev || prev.label === null && b.label !== null) seen.set(key, {
6178
+ combo: b.combo,
6179
+ scope: b.scope,
6180
+ label: b.label
6181
+ });
6182
+ }
6183
+ return [...seen.values()];
6184
+ }
6185
+ /**
6186
+ * The bindings registered below the `HotkeyProvider` right now (read-only; there
6187
+ * is no rebinding). Empty without a provider.
6188
+ */
6189
+ function useRegisteredHotkeys() {
6190
+ const registry = (0, react$1.useContext)(RegistryContext);
6191
+ return (0, react$1.useSyncExternalStore)(registry ? registry.subscribe : noSubscribe, registry ? registry.snapshot : emptySnapshot);
6192
+ }
6193
+ var noSubscribe = () => () => void 0;
6194
+ var emptySnapshot = () => NO_HOTKEYS;
6121
6195
  /**
6122
6196
  * Bind a combo for the life of the component. Without a `HotkeyProvider` above
6123
6197
  * it does nothing, so the browser keeps its own shortcut.
@@ -6137,6 +6211,7 @@ function useHotkey(combo, handler, options = {}) {
6137
6211
  return registry.addBinding({
6138
6212
  id: nextBindingId,
6139
6213
  combo,
6214
+ label: options.label ?? null,
6140
6215
  scope,
6141
6216
  handlerRef,
6142
6217
  preventDefault,
@@ -6148,7 +6223,8 @@ function useHotkey(combo, handler, options = {}) {
6148
6223
  combo,
6149
6224
  scope,
6150
6225
  preventDefault,
6151
- allowInInput
6226
+ allowInInput,
6227
+ options.label
6152
6228
  ]);
6153
6229
  }
6154
6230
  /** Bindings made below this component belong to `id`; while enabled it is the topmost scope. */
@@ -6193,11 +6269,13 @@ function useSearchFieldHotkey(ref, options = {}) {
6193
6269
  };
6194
6270
  useHotkey("mod+f", focusField, {
6195
6271
  enabled,
6196
- scope
6272
+ scope,
6273
+ label: "Focus the search field"
6197
6274
  });
6198
6275
  useHotkey("mod+k", focusField, {
6199
6276
  enabled,
6200
- scope
6277
+ scope,
6278
+ label: "Focus the search field"
6201
6279
  });
6202
6280
  }
6203
6281
  //#endregion
@@ -11478,6 +11556,10 @@ var useDisplayPaletteGetPalette = /*#__PURE__*/ alias(() => trpc.displayPalette.
11478
11556
  var useDisplayPaletteListPresets = /*#__PURE__*/ alias(() => trpc.displayPalette.listPresets.useQuery);
11479
11557
  /** Generated alias around `trpc.displayPalette.setPalette.useMutation`. */
11480
11558
  var useDisplayPaletteSetPalette = /*#__PURE__*/ alias(() => trpc.displayPalette.setPalette.useMutation);
11559
+ /** Generated alias around `trpc.doNotDisturb.setEnabled.useMutation`. */
11560
+ var useDoNotDisturbSetEnabled = /*#__PURE__*/ alias(() => trpc.doNotDisturb.setEnabled.useMutation);
11561
+ /** Generated alias around `trpc.doNotDisturb.getStatus.useQuery`. */
11562
+ var useDoNotDisturbGetStatus = /*#__PURE__*/ alias(() => trpc.doNotDisturb.getStatus.useQuery);
11481
11563
  /** Generated alias around `trpc.doorbell.getStatus.useQuery`. */
11482
11564
  var useDoorbellGetStatus = /*#__PURE__*/ alias(() => trpc.doorbell.getStatus.useQuery);
11483
11565
  /** Generated alias around `trpc.enumSensor.getStatus.useQuery`. */
@@ -11526,6 +11608,14 @@ var useFanControlSetDirection = /*#__PURE__*/ alias(() => trpc.fanControl.setDir
11526
11608
  var useFanControlSetOscillating = /*#__PURE__*/ alias(() => trpc.fanControl.setOscillating.useMutation);
11527
11609
  /** Generated alias around `trpc.fanControl.getStatus.useQuery`. */
11528
11610
  var useFanControlGetStatus = /*#__PURE__*/ alias(() => trpc.fanControl.getStatus.useQuery);
11611
+ /** Generated alias around `trpc.fanPresets.getPresets.useQuery`. */
11612
+ var useFanPresetsGetPresets = /*#__PURE__*/ alias(() => trpc.fanPresets.getPresets.useQuery);
11613
+ /** Generated alias around `trpc.fanPresets.setPresets.useMutation`. */
11614
+ var useFanPresetsSetPresets = /*#__PURE__*/ alias(() => trpc.fanPresets.setPresets.useMutation);
11615
+ /** Generated alias around `trpc.fanPresets.applyPreset.useMutation`. */
11616
+ var useFanPresetsApplyPreset = /*#__PURE__*/ alias(() => trpc.fanPresets.applyPreset.useMutation);
11617
+ /** Generated alias around `trpc.fanPresets.capturePreset.useQuery`. */
11618
+ var useFanPresetsCapturePreset = /*#__PURE__*/ alias(() => trpc.fanPresets.capturePreset.useQuery);
11529
11619
  /** Generated alias around `trpc.featureProbe.getStatus.useQuery`. */
11530
11620
  var useFeatureProbeGetStatus = /*#__PURE__*/ alias(() => trpc.featureProbe.getStatus.useQuery);
11531
11621
  /** Generated alias around `trpc.flood.getStatus.useQuery`. */
@@ -13056,6 +13146,12 @@ var useTerminalSessionPullOutput = /*#__PURE__*/ alias(() => trpc.terminalSessio
13056
13146
  var useTerminalSessionWriteInput = /*#__PURE__*/ alias(() => trpc.terminalSession.writeInput.useMutation);
13057
13147
  /** Generated alias around `trpc.terminalSession.close.useMutation`. */
13058
13148
  var useTerminalSessionClose = /*#__PURE__*/ alias(() => trpc.terminalSession.close.useMutation);
13149
+ /** Generated alias around `trpc.timedRun.runFor.useMutation`. */
13150
+ var useTimedRunRunFor = /*#__PURE__*/ alias(() => trpc.timedRun.runFor.useMutation);
13151
+ /** Generated alias around `trpc.timedRun.cancel.useMutation`. */
13152
+ var useTimedRunCancel = /*#__PURE__*/ alias(() => trpc.timedRun.cancel.useMutation);
13153
+ /** Generated alias around `trpc.timedRun.getStatus.useQuery`. */
13154
+ var useTimedRunGetStatus = /*#__PURE__*/ alias(() => trpc.timedRun.getStatus.useQuery);
13059
13155
  /** Generated alias around `trpc.toast.onToast.useSubscription`. */
13060
13156
  var useToastOnToast = /*#__PURE__*/ alias(() => trpc.toast.onToast.useSubscription);
13061
13157
  /** Generated alias around `trpc.training.listBaseModels.useQuery`. */
@@ -54144,6 +54240,137 @@ function PetFeederHeroCard({ trpc, deviceId, features, optimistic }) {
54144
54240
  });
54145
54241
  }
54146
54242
  //#endregion
54243
+ //#region src/composites/device-controls/timed-run-control.tsx
54244
+ /**
54245
+ * "Run for N" - the inline control of the `timed-run` cap (D993).
54246
+ *
54247
+ * A number, the unit it is in, and a Run button: the hub turns the device on and
54248
+ * switches it off again after that long. While a run is armed the control shows
54249
+ * the time left (counted down from the slice's `activeUntil`) and a Cancel. The
54250
+ * maximum is the device type's own bound (`TIMED_RUN_LIMITS`); a value above it
54251
+ * is refused by the hub with a typed reason, which this control says in words.
54252
+ *
54253
+ * Rendered only when the device binds `timed-run`. An unknown slice is "nothing
54254
+ * armed", never a running timer.
54255
+ */
54256
+ var TICK_MS = 1e3;
54257
+ var SECONDS_PER_MINUTE$1 = 60;
54258
+ var MS_PER_SECOND = 1e3;
54259
+ var DEFAULT_MINUTES = 30;
54260
+ var DEFAULT_SECONDS = 60;
54261
+ /** `m:ss` for a countdown; a started run never shows a negative time. */
54262
+ function formatCountdown(remainingMs) {
54263
+ const total = Math.max(0, Math.ceil(remainingMs / MS_PER_SECOND));
54264
+ const minutes = Math.floor(total / SECONDS_PER_MINUTE$1);
54265
+ const seconds = total % SECONDS_PER_MINUTE$1;
54266
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
54267
+ }
54268
+ /** The largest value the field accepts, in the limit's unit. */
54269
+ function maxInUnit(limit) {
54270
+ return limit.unit === "min" ? limit.maxSeconds / SECONDS_PER_MINUTE$1 : limit.maxSeconds;
54271
+ }
54272
+ function useNow(active) {
54273
+ const [now, setNow] = (0, react$1.useState)(() => Date.now());
54274
+ (0, react$1.useEffect)(() => {
54275
+ if (!active) return void 0;
54276
+ setNow(Date.now());
54277
+ const handle = setInterval(() => setNow(Date.now()), TICK_MS);
54278
+ return () => clearInterval(handle);
54279
+ }, [active]);
54280
+ return now;
54281
+ }
54282
+ function TimedRunControl({ trpc, deviceId, limit }) {
54283
+ const t = useUiText();
54284
+ const { cap, slice, online } = useDeviceCapability({
54285
+ trpc,
54286
+ deviceId,
54287
+ cap: (d) => d.timedRun,
54288
+ slice: (d) => d.state.timedRun
54289
+ });
54290
+ const max = maxInUnit(limit);
54291
+ const [draft, setDraft] = (0, react$1.useState)(Math.min(max, limit.unit === "min" ? DEFAULT_MINUTES : DEFAULT_SECONDS));
54292
+ const [refusal, setRefusal] = (0, react$1.useState)(null);
54293
+ const activeUntil = slice?.activeUntil ?? null;
54294
+ const now = useNow(activeUntil !== null);
54295
+ if (cap === void 0) return null;
54296
+ const unitLabel = limit.unit === "min" ? t("uiLibrary.timedRun.minutes", "min") : t("uiLibrary.timedRun.seconds", "s");
54297
+ const valid = Number.isFinite(draft) && draft > 0 && draft <= max;
54298
+ const armed = activeUntil !== null && activeUntil > now;
54299
+ const run = () => {
54300
+ if (!valid) return;
54301
+ setRefusal(null);
54302
+ cap.runFor({
54303
+ duration: draft,
54304
+ unit: limit.unit
54305
+ }).then((result) => {
54306
+ if (!result.ok) setRefusal(result.reason);
54307
+ });
54308
+ };
54309
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54310
+ className: "flex flex-col gap-1.5",
54311
+ "data-testid": "timed-run-control",
54312
+ children: [
54313
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54314
+ className: "flex items-center gap-2",
54315
+ children: [
54316
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
54317
+ type: "number",
54318
+ inputMode: "numeric",
54319
+ min: 1,
54320
+ max,
54321
+ step: 1,
54322
+ value: Number.isFinite(draft) ? draft : "",
54323
+ disabled: !online,
54324
+ "aria-label": t("uiLibrary.timedRun.duration", "Run for"),
54325
+ className: "w-20 tabular-nums",
54326
+ onChange: (event) => setDraft(event.target.valueAsNumber)
54327
+ }),
54328
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54329
+ className: "text-xs text-foreground-muted",
54330
+ children: unitLabel
54331
+ }),
54332
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
54333
+ size: "sm",
54334
+ disabled: !online || !valid,
54335
+ onClick: run,
54336
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Timer, {
54337
+ className: "h-4 w-4",
54338
+ "aria-hidden": true
54339
+ }), t("uiLibrary.timedRun.run", "Run")]
54340
+ }),
54341
+ armed && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54342
+ className: "text-xs font-medium tabular-nums text-foreground",
54343
+ "data-testid": "timed-run-countdown",
54344
+ children: t("uiLibrary.timedRun.remaining", "{{time}} left", { time: formatCountdown(activeUntil - now) })
54345
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
54346
+ size: "sm",
54347
+ variant: "outline",
54348
+ disabled: !online,
54349
+ "aria-label": t("uiLibrary.timedRun.cancel", "Cancel the timer"),
54350
+ onClick: () => void cap.cancel({}),
54351
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TimerOff, {
54352
+ className: "h-4 w-4",
54353
+ "aria-hidden": true
54354
+ })
54355
+ })] })
54356
+ ]
54357
+ }),
54358
+ !valid && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54359
+ className: "text-xs text-danger",
54360
+ children: t("uiLibrary.timedRun.range", "Enter 1 to {{max}} {{unit}}", {
54361
+ max,
54362
+ unit: unitLabel
54363
+ })
54364
+ }),
54365
+ refusal !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54366
+ className: "text-xs text-danger",
54367
+ role: "alert",
54368
+ children: refusal === "over-max" ? t("uiLibrary.timedRun.overMax", "That is longer than this device may run.") : refusal === "command-failed" ? t("uiLibrary.timedRun.commandFailed", "The device did not turn on.") : t("uiLibrary.timedRun.unsupported", "This device cannot run on a timer.")
54369
+ })
54370
+ ]
54371
+ });
54372
+ }
54373
+ //#endregion
54147
54374
  //#region src/composites/device-controls/pet-fountain-panel.tsx
54148
54375
  /**
54149
54376
  * Pet-fountain controls for a `DeviceType.PetFountain` device.
@@ -54172,6 +54399,7 @@ var KEYFRAMES$2 = `
54172
54399
  .pfn-ripple, .pfn-bubble { animation: none !important; opacity: 0; }
54173
54400
  }
54174
54401
  `;
54402
+ var PUMP_LIMIT = _camstack_types.TIMED_RUN_LIMITS[_camstack_types.DeviceType.PetFountain];
54175
54403
  var LITRES_TO_ML = 1e3;
54176
54404
  var SECONDS_PER_MINUTE = 60;
54177
54405
  var MAX_PUMP_MINUTES = 240;
@@ -54336,6 +54564,11 @@ function PetFountainPanel({ trpc, deviceId, layout, optimistic }) {
54336
54564
  ]
54337
54565
  })
54338
54566
  }),
54567
+ PUMP_LIMIT !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TimedRunControl, {
54568
+ trpc,
54569
+ deviceId,
54570
+ limit: PUMP_LIMIT
54571
+ }),
54339
54572
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
54340
54573
  variant: "outline",
54341
54574
  size: "sm",
@@ -54560,1277 +54793,1430 @@ function PetFountainHeroCard({ trpc, deviceId, features, optimistic }) {
54560
54793
  });
54561
54794
  }
54562
54795
  //#endregion
54563
- //#region src/composites/device-controls/dishwasher-control.tsx
54564
- /**
54565
- * Dishwasher controls for a `DeviceType.Dishwasher` device.
54566
- *
54567
- * Reads the `dishwasher` cap status slice and renders inline + hero surfaces
54568
- * mirroring vacuum-control / pet-feeder patterns.
54569
- *
54570
- * Salt and rinse aid are not here: they are items of the device's `consumables`
54571
- * cap, shown by the page's Consumables section like every device's (D915).
54572
- *
54573
- * UNKNOWN IS NOT OFF. The slice carries `null` for everything the appliance has not
54574
- * reported, and this view renders that as "—" / a disabled control, never as an unchecked
54575
- * box or a zero. It also does not echo a command before the appliance confirms it: the
54576
- * slice moves when the appliance says so.
54577
- */
54578
- /** What the operation state is called to a person, and how it is toned. */
54579
- function dishwasherOperationMeta(operationState, t = defaultUiText) {
54580
- switch (operationState) {
54581
- case "Run": return {
54582
- label: t("uiLibrary.dishwasher.op.run", "Running"),
54583
- tone: "text-primary"
54584
- };
54585
- case "Pause": return {
54586
- label: t("uiLibrary.dishwasher.op.pause", "Paused"),
54587
- tone: "text-warning"
54588
- };
54589
- case "Finished": return {
54590
- label: t("uiLibrary.dishwasher.op.finished", "Finished"),
54591
- tone: "text-success"
54592
- };
54593
- case "Error": return {
54594
- label: t("uiLibrary.dishwasher.op.error", "Error"),
54595
- tone: "text-danger"
54596
- };
54597
- case "Aborting": return {
54598
- label: t("uiLibrary.dishwasher.op.aborting", "Stopping"),
54599
- tone: "text-warning"
54600
- };
54601
- case "ActionRequired": return {
54602
- label: t("uiLibrary.dishwasher.op.actionRequired", "Action required"),
54603
- tone: "text-warning"
54604
- };
54605
- case "DelayedStart": return {
54606
- label: t("uiLibrary.dishwasher.op.delayedStart", "Delayed start"),
54607
- tone: "text-primary"
54608
- };
54609
- case "Ready": return {
54610
- label: t("uiLibrary.dishwasher.op.ready", "Ready"),
54611
- tone: "text-foreground"
54612
- };
54613
- case "Inactive": return {
54614
- label: t("uiLibrary.dishwasher.op.inactive", "Inactive"),
54615
- tone: "text-foreground-subtle"
54616
- };
54617
- case null: return {
54618
- label: t("uiLibrary.dishwasher.unknown", "Unknown"),
54619
- tone: "text-foreground-subtle"
54620
- };
54621
- }
54796
+ //#region src/composites/device-controls/ac-panel.tsx
54797
+ /** Modes that mean the device can cool, dry, or move air — an air conditioner. */
54798
+ var COOLING_MODES = new Set([
54799
+ "cool",
54800
+ "heat_cool",
54801
+ "dry",
54802
+ "fan_only"
54803
+ ]);
54804
+ /** A heater offers heat (and maybe auto/off) and none of the cooling modes. */
54805
+ function climateOffersCooling(modes) {
54806
+ return modes.some((mode) => COOLING_MODES.has(mode));
54622
54807
  }
54623
- /** The headline for the whole slice: the appliance's state, or why there is none. */
54624
- function dishwasherHeadline(status, t = defaultUiText) {
54625
- switch (status.availability) {
54626
- case "offline": return {
54627
- label: t("uiLibrary.dishwasher.offline", "Offline"),
54628
- tone: "text-foreground-subtle"
54629
- };
54630
- case "description-missing": return {
54631
- label: t("uiLibrary.dishwasher.descriptionMissing", "Waiting for the appliance profile"),
54632
- tone: "text-warning"
54633
- };
54634
- case "available": return dishwasherOperationMeta(status.operationState, t);
54808
+ /** Setpoint track bounds. Absent when the integration did not report both ends. */
54809
+ function climateSetpointBounds(status) {
54810
+ const min = status.minTemp;
54811
+ const max = status.maxTemp;
54812
+ if (typeof min !== "number" || typeof max !== "number") return null;
54813
+ if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return null;
54814
+ return {
54815
+ min,
54816
+ max
54817
+ };
54818
+ }
54819
+ /** Map a 0–1 drag fraction onto the setpoint, snapped to `tempStep`. */
54820
+ function climateTargetFromFraction(fraction, bounds, tempStep) {
54821
+ const step = resolveClimateTempStep(tempStep);
54822
+ const clamped = Math.min(1, Math.max(0, fraction));
54823
+ const raw = bounds.min + clamped * (bounds.max - bounds.min);
54824
+ const steps = Math.round((raw - bounds.min) / step);
54825
+ const snapped = bounds.min + steps * step;
54826
+ const rounded = Math.round(snapped * 1e3) / 1e3;
54827
+ return Math.min(bounds.max, Math.max(bounds.min, rounded));
54828
+ }
54829
+ /** AC-appropriate short mode labels. Exhaustive over HvacMode. */
54830
+ function modeLabel(t, mode) {
54831
+ switch (mode) {
54832
+ case "cool": return t("uiLibrary.acPanel.modeCool", "Cool");
54833
+ case "heat": return t("uiLibrary.acPanel.modeHeat", "Heat");
54834
+ case "auto": return t("uiLibrary.acPanel.modeAuto", "Auto");
54835
+ case "heat_cool": return t("uiLibrary.acPanel.modeHeatCool", "Heat·Cool");
54836
+ case "fan_only": return t("uiLibrary.acPanel.modeFan", "Fan");
54837
+ case "dry": return t("uiLibrary.acPanel.modeDry", "Dry");
54838
+ case "off": return t("uiLibrary.acPanel.modeOff", "Off");
54635
54839
  }
54636
54840
  }
54637
- /** Last segment of a feature key, spaced out: `Dishcare.Dishwasher.Program.Eco50` -> `Eco50`. */
54638
- function featureLabel(key) {
54639
- return (key.split(".").pop() ?? key).replace(/([a-z0-9])([A-Z])/g, "$1 $2");
54841
+ /** Per-mode icon (lucide). Exhaustive over HvacMode. */
54842
+ var MODE_ICON = {
54843
+ cool: Snowflake,
54844
+ heat: Flame,
54845
+ auto: Sparkles,
54846
+ heat_cool: Thermometer,
54847
+ fan_only: Fan,
54848
+ dry: Droplets,
54849
+ off: Power
54850
+ };
54851
+ /** Mode accent hex — drives the airflow particles, the glow, and the target
54852
+ * value colour. Mirrors the thermostat gauge palette. Exhaustive. */
54853
+ var MODE_ACCENT = {
54854
+ heat: "#f59e0b",
54855
+ cool: "#60a5fa",
54856
+ auto: "#c084fc",
54857
+ heat_cool: "#e879f9",
54858
+ fan_only: "#94a3b8",
54859
+ dry: "#22d3ee",
54860
+ off: "#a1a1aa"
54861
+ };
54862
+ /** Title-case a snake/lower token for a selector option ('medium_low' → 'Medium
54863
+ * low', 'turbo' → 'Turbo'). Shared by the fan + preset selectors. */
54864
+ function fanLabel(token) {
54865
+ const spaced = token.replace(/_/g, " ");
54866
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
54867
+ }
54868
+ /** Preset → icon. Unknown presets fall back to a neutral sparkle. */
54869
+ function presetIcon(preset) {
54870
+ switch (preset) {
54871
+ case "turbo": return Zap;
54872
+ case "quiet": return Feather;
54873
+ case "sleep": return Moon;
54874
+ case "eco": return Leaf;
54875
+ case "8c_heat": return Snowflake;
54876
+ case "none": return Ban;
54877
+ default: return Sparkles;
54878
+ }
54640
54879
  }
54641
54880
  /**
54642
- * Salt and rinse aid are consumable items (D915). Their events must not
54643
- * reappear on the hero — the Consumables section is the only surface.
54881
+ * Fan level → airflow animation period. Higher speed ⇒ shorter period. Unknown
54882
+ * strings fall back to a medium pace. Turbo (a preset) shortens it further.
54644
54883
  */
54645
- function isConsumableDishwasherEvent(event) {
54646
- const short = (event.split(".").pop() ?? event).toLowerCase();
54647
- return short.includes("salt") || short.includes("rinse");
54648
- }
54649
- /** Raised events the hero may name. Consumable events are dropped. */
54650
- function visibleDishwasherEvents(events) {
54651
- return events.filter((event) => !isConsumableDishwasherEvent(event));
54884
+ function airflowPeriodSec(fanMode) {
54885
+ switch (fanMode) {
54886
+ case "high": return 1;
54887
+ case "medium_high": return 1.3;
54888
+ case "medium": return 1.6;
54889
+ case "medium_low": return 1.9;
54890
+ case "low": return 2.2;
54891
+ default: return 1.8;
54892
+ }
54652
54893
  }
54653
- /** Whole seconds as a short duration: `1 h 24 min`, `42 min`, `30 s`. */
54654
- function formatDuration(seconds, t = defaultUiText) {
54655
- if (seconds < 60) return t("uiLibrary.dishwasher.durationSeconds", "{{n}} s", { n: seconds });
54656
- const minutes = Math.round(seconds / 60);
54657
- if (minutes < 60) return t("uiLibrary.dishwasher.durationMinutes", "{{n}} min", { n: minutes });
54658
- const hours = Math.floor(minutes / 60);
54659
- const rest = minutes % 60;
54660
- return rest === 0 ? t("uiLibrary.dishwasher.durationHours", "{{n}} h", { n: hours }) : t("uiLibrary.dishwasher.durationHoursMinutes", "{{h}} h {{m}} min", {
54661
- h: hours,
54662
- m: rest
54663
- });
54894
+ var KEYFRAMES$1 = `
54895
+ @keyframes acv-fall { 0%{transform:translateY(-1.75rem);opacity:0} 18%{opacity:.7} 82%{opacity:.7} 100%{transform:translateY(7.5rem);opacity:0} }
54896
+ @keyframes acv-rise { 0%{transform:translateY(7.5rem);opacity:0} 18%{opacity:.7} 82%{opacity:.7} 100%{transform:translateY(-1.75rem);opacity:0} }
54897
+ @keyframes acv-sway { 0%,100%{transform:translateX(-9%)} 50%{transform:translateX(9%)} }
54898
+ @keyframes acv-swing-h { 0%,100%{transform:translateX(-22%)} 50%{transform:translateX(22%)} }
54899
+ @keyframes acv-swing-v { 0%,100%{transform:translateY(-22%)} 50%{transform:translateY(22%)} }
54900
+ @keyframes acv-spin { to { transform:rotate(360deg) } }
54901
+ @keyframes acv-glow { 0%,100%{opacity:.35;transform:scale(1)} 50%{opacity:.6;transform:scale(1.06)} }
54902
+ @media (prefers-reduced-motion: reduce) {
54903
+ .acv-streak, .acv-fan, .acv-sway-wrap, .acv-glow, .acv-swing { animation: none !important; }
54664
54904
  }
54665
- function useDishwasher(trpc, deviceId) {
54666
- return useDeviceCapability({
54905
+ `;
54906
+ function AcPanel({ trpc, deviceId, layout, optimistic }) {
54907
+ const t = useUiText();
54908
+ const { cap, slice, available } = useDeviceCapability({
54667
54909
  trpc,
54668
54910
  deviceId,
54669
- cap: (d) => d.dishwasher,
54670
- slice: (d) => d.state.dishwasher
54671
- });
54672
- }
54673
- function DishwasherIconButton({ label, busy, active, onClick, children }) {
54674
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
54675
- type: "button",
54676
- "aria-label": label,
54677
- title: label,
54678
- disabled: busy,
54679
- onClick: (e) => {
54680
- e.stopPropagation();
54681
- onClick();
54682
- },
54683
- className: cn("inline-flex h-5 w-5 items-center justify-center rounded text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors", active && "bg-primary text-primary-foreground", busy && "opacity-40 cursor-not-allowed"),
54684
- children
54685
- });
54686
- }
54687
- function DishwasherGraphic({ running, progressPercent }) {
54688
- const pct = progressPercent != null ? Math.max(0, Math.min(100, progressPercent)) : running ? 40 : 0;
54689
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54690
- "aria-hidden": "true",
54691
- className: "relative w-28 h-36 rounded border-2 border-border bg-gradient-to-b from-surface-hover to-surface overflow-hidden flex-shrink-0",
54692
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
54693
- className: cn("absolute inset-x-0 bottom-0 bg-primary/25 transition-[height] duration-500", running && "animate-pulse"),
54694
- style: { height: `${pct}%` }
54695
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
54696
- className: "absolute inset-0 flex items-center justify-center text-foreground-subtle",
54697
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Waves, { className: "h-10 w-10 opacity-70" })
54698
- })]
54911
+ cap: (d) => d.climateControl,
54912
+ slice: (d) => d.state.climateControl
54699
54913
  });
54700
- }
54701
- function DishwasherPanel({ trpc, deviceId, layout }) {
54702
- const t = useUiText();
54703
- const { cap, slice, available } = useDishwasher(trpc, deviceId);
54914
+ const { display, applyOptimistic } = useOptimisticSlice(slice, { enabled: optimistic ?? true });
54704
54915
  if (cap === void 0) return null;
54705
- if (slice === void 0) return null;
54706
- const display = slice;
54707
- const link = display.availability === "available";
54708
- const busy = !available || !link;
54709
- const optionTogglesLocked = display.childLock === true;
54710
- const meta = dishwasherHeadline(display, t);
54711
- const running = display.operationState === "Run";
54712
- const paused = display.operationState === "Pause";
54713
- const startable = display.remoteStartAllowed === true && display.selectedProgram !== null;
54714
- const press = (fn) => {
54715
- if (busy) return;
54716
- fn();
54717
- };
54916
+ if (display === void 0) return null;
54917
+ const busy = !available;
54718
54918
  if (layout === "compact") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54719
- className: "inline-flex items-center gap-1.5 max-w-full",
54919
+ "data-mode": display.mode,
54920
+ className: "inline-flex items-center gap-1",
54720
54921
  children: [
54721
- display.doorOpen === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54722
- className: "inline-flex text-warning shrink-0",
54723
- title: t("uiLibrary.dishwasher.doorOpen", "Door open"),
54724
- "aria-label": t("uiLibrary.dishwasher.doorOpen", "Door open"),
54725
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DoorOpen, { className: "h-3.5 w-3.5" })
54726
- }),
54727
54922
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54728
- className: cn("text-xs font-medium tabular-nums shrink-0", meta.tone),
54729
- children: meta.label
54730
- }),
54731
- display.program != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54732
- className: "text-xs text-foreground-subtle truncate max-w-[7rem]",
54733
- title: display.program,
54734
- children: featureLabel(display.program)
54735
- }),
54736
- display.remaining != null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
54737
- className: "inline-flex items-center gap-0.5 text-xs text-foreground-subtle tabular-nums shrink-0",
54738
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Timer, { className: "h-3 w-3" }), formatDuration(display.remaining, t)]
54739
- }),
54740
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
54741
- label: t("uiLibrary.dishwasher.start", "Start"),
54742
- busy: busy || !startable,
54743
- active: running,
54744
- onClick: () => press(() => cap.start({})),
54745
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Play, { className: "h-3 w-3" })
54923
+ className: cn("text-xs tabular-nums font-medium", MODE_TEXT_COLOR[display.mode]),
54924
+ children: display.target !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
54925
+ display.target,
54926
+ "°",
54927
+ display.currentTemp !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
54928
+ className: "text-foreground-subtle font-normal",
54929
+ children: [
54930
+ " ",
54931
+ "(",
54932
+ display.currentTemp,
54933
+ "°)"
54934
+ ]
54935
+ })
54936
+ ] }) : display.currentTemp !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
54937
+ className: "text-foreground-subtle",
54938
+ children: [display.currentTemp, "°"]
54939
+ }) : null
54746
54940
  }),
54747
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
54748
- label: paused ? t("uiLibrary.dishwasher.resume", "Resume") : t("uiLibrary.dishwasher.pause", "Pause"),
54749
- busy: busy || !(running || paused),
54750
- active: paused,
54751
- onClick: () => press(() => paused ? cap.resume({}) : cap.pause({})),
54752
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pause, { className: "h-3 w-3" })
54941
+ display.target !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
54942
+ type: "button",
54943
+ "aria-label": t("uiLibrary.acPanel.decrease", "Decrease target"),
54944
+ disabled: busy,
54945
+ onClick: (e) => {
54946
+ e.stopPropagation();
54947
+ if (!busy && display.target !== null) {
54948
+ const next = nextClimateTarget(display.target, -1, display);
54949
+ applyOptimistic({ target: next });
54950
+ cap.setTarget({ target: next });
54951
+ }
54952
+ },
54953
+ className: cn("inline-flex h-5 w-5 items-center justify-center rounded text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors text-xs", busy && "opacity-40 cursor-not-allowed"),
54954
+ children: "−"
54955
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
54956
+ type: "button",
54957
+ "aria-label": t("uiLibrary.acPanel.increase", "Increase target"),
54958
+ disabled: busy,
54959
+ onClick: (e) => {
54960
+ e.stopPropagation();
54961
+ if (!busy && display.target !== null) {
54962
+ const next = nextClimateTarget(display.target, 1, display);
54963
+ applyOptimistic({ target: next });
54964
+ cap.setTarget({ target: next });
54965
+ }
54966
+ },
54967
+ className: cn("inline-flex h-5 w-5 items-center justify-center rounded text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors text-xs", busy && "opacity-40 cursor-not-allowed"),
54968
+ children: "+"
54969
+ })] }),
54970
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
54971
+ "aria-label": t("uiLibrary.acPanel.mode", "Mode"),
54972
+ value: display.mode,
54973
+ disabled: busy,
54974
+ onChange: (e) => {
54975
+ e.stopPropagation();
54976
+ const mode = display.availableModes.find((m) => m === e.target.value);
54977
+ if (!busy && mode) {
54978
+ applyOptimistic({ mode });
54979
+ cap.setMode({ mode });
54980
+ }
54981
+ },
54982
+ className: cn("bg-transparent px-0.5 text-xs text-foreground-subtle hover:text-foreground cursor-pointer", busy && "opacity-40 cursor-not-allowed"),
54983
+ children: display.availableModes.map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
54984
+ value: m,
54985
+ children: modeLabel(t, m)
54986
+ }, m))
54753
54987
  })
54754
54988
  ]
54755
54989
  });
54756
- const programs = display.availablePrograms;
54990
+ const accent = MODE_ACCENT[display.mode];
54991
+ const on = display.mode !== "off";
54992
+ const heatingOnly = !climateOffersCooling(display.availableModes);
54993
+ const setpointBounds = climateSetpointBounds(display);
54994
+ const CurrentPresetIcon = presetIcon(display.preset);
54995
+ const boost = display.preset === "turbo";
54996
+ const period = Math.max(.55, airflowPeriodSec(display.fanMode) * (boost ? .6 : 1));
54997
+ const showVSwing = display.swingVertical !== null;
54998
+ const showHSwing = display.swingHorizontal !== null;
54999
+ const swaying = on && (display.swingVertical === true || display.swingHorizontal === true);
54757
55000
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54758
- className: "flex flex-col gap-3 rounded-lg border border-border bg-surface p-4",
55001
+ "data-mode": display.mode,
55002
+ className: cn("relative flex flex-col items-stretch gap-2.5 overflow-hidden rounded-lg border p-3 transition-colors", MODE_COLOR[display.mode]),
54759
55003
  children: [
55004
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: KEYFRAMES$1 }),
54760
55005
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54761
- className: "flex items-start gap-4",
54762
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherGraphic, {
54763
- running,
54764
- progressPercent: display.progress
54765
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54766
- className: "flex min-w-0 flex-1 flex-col gap-2",
54767
- children: [
54768
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54769
- className: "flex flex-wrap items-center gap-2",
54770
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54771
- className: cn("text-3xl font-semibold", meta.tone),
54772
- children: meta.label
54773
- }), display.powerOn === false && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54774
- className: "text-xs font-medium text-foreground-subtle",
54775
- children: t("uiLibrary.dishwasher.powerOff", "Power off")
54776
- })]
54777
- }),
54778
- display.program != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54779
- className: "text-sm text-foreground-subtle",
54780
- title: display.program,
54781
- children: featureLabel(display.program)
54782
- }),
54783
- display.remaining != null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
54784
- className: "inline-flex items-center gap-1 text-sm text-foreground-subtle tabular-nums",
54785
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Timer, { className: "h-3.5 w-3.5" }), t("uiLibrary.dishwasher.remaining", "{{time}} left", { time: formatDuration(display.remaining, t) })]
54786
- }),
54787
- display.delayedStart != null && display.delayedStart > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54788
- className: "text-xs text-foreground-subtle tabular-nums",
54789
- children: t("uiLibrary.dishwasher.startsIn", "Starts in {{time}}", { time: formatDuration(display.delayedStart, t) })
54790
- }),
54791
- display.progress != null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54792
- className: "flex items-center gap-2",
54793
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
54794
- className: "h-1.5 flex-1 rounded bg-surface-hover overflow-hidden",
54795
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
54796
- className: "h-full bg-primary transition-[width] duration-500",
54797
- style: { width: `${Math.max(0, Math.min(100, display.progress))}%` }
54798
- })
54799
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
54800
- className: "tabular-nums text-xs text-foreground-subtle",
54801
- children: [Math.round(display.progress), "%"]
54802
- })]
54803
- }),
54804
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54805
- className: "flex flex-wrap items-center gap-x-3 gap-y-1.5",
54806
- children: [display.doorOpen === true && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
54807
- className: "inline-flex items-center gap-1 text-xs text-warning",
54808
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DoorOpen, { className: "h-3.5 w-3.5" }), t("uiLibrary.dishwasher.doorOpen", "Door open")]
54809
- }), visibleDishwasherEvents(display.activeEvents).map((event) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54810
- className: "text-xs text-warning",
54811
- title: event,
54812
- children: featureLabel(event)
54813
- }, event))]
54814
- }),
54815
- display.operationState === "Error" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54816
- className: "inline-flex items-center gap-1 text-xs text-danger",
54817
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TriangleAlert, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: display.errorLabel != null ? featureLabel(display.errorLabel) : display.errorCode != null ? t("uiLibrary.dishwasher.errorCode", "Error {{code}}", { code: display.errorCode }) : t("uiLibrary.dishwasher.op.error", "Error") })]
54818
- }),
54819
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54820
- className: "flex flex-wrap items-center gap-2",
54821
- children: [
54822
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
54823
- label: t("uiLibrary.dishwasher.start", "Start"),
54824
- busy: busy || !startable,
54825
- active: running,
54826
- onClick: () => press(() => cap.start({})),
54827
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Play, { className: "h-4 w-4" })
54828
- }),
54829
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
54830
- label: paused ? t("uiLibrary.dishwasher.resume", "Resume") : t("uiLibrary.dishwasher.pause", "Pause"),
54831
- busy: busy || !(running || paused),
54832
- active: paused,
54833
- onClick: () => press(() => paused ? cap.resume({}) : cap.pause({})),
54834
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pause, { className: "h-4 w-4" })
54835
- }),
54836
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
54837
- label: t("uiLibrary.dishwasher.stop", "Stop"),
54838
- busy: busy || !(running || paused),
54839
- active: false,
54840
- onClick: () => press(() => cap.stop({})),
54841
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Square, { className: "h-3.5 w-3.5" })
54842
- }),
54843
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
54844
- label: t("uiLibrary.dishwasher.power", "Power"),
54845
- busy: busy || display.powerOn === null,
54846
- active: display.powerOn === true,
54847
- onClick: () => {
54848
- const next = display.powerOn !== true;
54849
- press(() => cap.setPower({ on: next }));
54850
- },
54851
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Power, { className: "h-4 w-4" })
54852
- })
54853
- ]
54854
- })
54855
- ]
54856
- })]
54857
- }),
54858
- programs.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
54859
- className: "flex items-center gap-2 text-xs text-foreground-subtle",
54860
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54861
- className: "w-16 shrink-0",
54862
- children: t("uiLibrary.dishwasher.program", "Program")
54863
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
54864
- "aria-label": t("uiLibrary.dishwasher.program", "Program"),
54865
- value: display.selectedProgram ?? "",
54866
- disabled: busy,
54867
- onChange: (e) => {
54868
- const program = e.target.value;
54869
- if (!program) return;
54870
- cap.setProgram({ program });
54871
- },
54872
- onClick: (e) => {
54873
- e.stopPropagation();
54874
- },
54875
- className: "flex-1 rounded border border-border bg-surface px-2 py-1 text-foreground disabled:opacity-50",
54876
- children: [display.selectedProgram == null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
54877
- value: "",
54878
- children: "—"
54879
- }), programs.map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
54880
- value: p,
54881
- children: featureLabel(p)
54882
- }, p))]
54883
- })]
54884
- }),
54885
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54886
- className: "flex flex-wrap gap-4 text-xs",
55006
+ className: "relative z-10 flex flex-col items-center justify-center gap-1 overflow-hidden rounded-lg border border-border/50 bg-surface/30 px-3 py-3",
54887
55007
  children: [
54888
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
54889
- label: t("uiLibrary.dishwasher.halfLoad", "Half load"),
54890
- checked: display.halfLoad,
54891
- shown: link,
54892
- busy: busy || optionTogglesLocked,
54893
- onChange: (on) => void cap.setHalfLoad({ on })
55008
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55009
+ className: "pointer-events-none absolute inset-0 opacity-60",
55010
+ children: heatingOnly && !on ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RadiatorFins, {}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AirflowViz, {
55011
+ mode: display.mode,
55012
+ accent,
55013
+ periodSec: period,
55014
+ swaying,
55015
+ forceRising: heatingOnly
55016
+ })
54894
55017
  }),
54895
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
54896
- label: t("uiLibrary.dishwasher.hygienePlus", "Hygiene plus"),
54897
- checked: display.hygienePlus,
54898
- shown: link,
54899
- busy: busy || optionTogglesLocked,
54900
- onChange: (on) => void cap.setHygienePlus({ on })
55018
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "pointer-events-none absolute inset-0 bg-gradient-to-b from-surface/50 via-transparent to-surface/50" }),
55019
+ on && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55020
+ "aria-hidden": true,
55021
+ className: "acv-glow pointer-events-none absolute left-1/2 top-1/2 h-16 w-16 -translate-x-1/2 -translate-y-1/2 rounded-full blur-2xl",
55022
+ style: {
55023
+ background: accent,
55024
+ animation: "acv-glow 3.2s ease-in-out infinite"
55025
+ }
54901
55026
  }),
54902
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
54903
- label: t("uiLibrary.dishwasher.extraDry", "Extra dry"),
54904
- checked: display.extraDry,
54905
- shown: link,
54906
- busy: busy || optionTogglesLocked,
54907
- onChange: (on) => void cap.setExtraDry({ on })
55027
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55028
+ className: "relative z-10 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide",
55029
+ style: { color: accent },
55030
+ children: [
55031
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModeIcon, {
55032
+ mode: display.mode,
55033
+ heatingOnly,
55034
+ className: "h-3.5 w-3.5"
55035
+ }),
55036
+ modeLabel(t, display.mode),
55037
+ display.currentTemp !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55038
+ className: "normal-case text-foreground-subtle",
55039
+ children: t("uiLibrary.acPanel.now", "· {{temp}}° now", { temp: display.currentTemp })
55040
+ }),
55041
+ display.currentHumidity !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55042
+ className: "inline-flex items-center gap-0.5 normal-case text-foreground-subtle",
55043
+ children: [
55044
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Droplets, { className: "h-3 w-3" }),
55045
+ display.currentHumidity,
55046
+ "%"
55047
+ ]
55048
+ })
55049
+ ]
54908
55050
  }),
54909
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
54910
- label: t("uiLibrary.dishwasher.intensiveZone", "Intensive zone"),
54911
- checked: display.intensiveZone,
54912
- shown: link,
54913
- busy: busy || optionTogglesLocked,
54914
- onChange: (on) => void cap.setIntensiveZone({ on })
55051
+ display.target !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55052
+ className: "relative z-10 flex items-center gap-3",
55053
+ children: [
55054
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RoundStep, {
55055
+ label: t("uiLibrary.acPanel.decrease", "Decrease target"),
55056
+ symbol: "−",
55057
+ disabled: busy,
55058
+ onClick: () => {
55059
+ if (busy || display.target === null) return;
55060
+ const next = nextClimateTarget(display.target, -1, display);
55061
+ applyOptimistic({ target: next });
55062
+ cap.setTarget({ target: next });
55063
+ }
55064
+ }),
55065
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55066
+ className: "flex items-baseline gap-0.5",
55067
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55068
+ className: cn("text-4xl font-bold leading-none tabular-nums", climateTargetWidthClass(display)),
55069
+ style: { color: accent },
55070
+ children: display.target
55071
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55072
+ className: "text-lg text-foreground-subtle",
55073
+ children: "°"
55074
+ })]
55075
+ }),
55076
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RoundStep, {
55077
+ label: t("uiLibrary.acPanel.increase", "Increase target"),
55078
+ symbol: "+",
55079
+ disabled: busy,
55080
+ onClick: () => {
55081
+ if (busy || display.target === null) return;
55082
+ const next = nextClimateTarget(display.target, 1, display);
55083
+ applyOptimistic({ target: next });
55084
+ cap.setTarget({ target: next });
55085
+ }
55086
+ })
55087
+ ]
55088
+ }), setpointBounds !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55089
+ className: "relative z-10 w-full px-1",
55090
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RangeTrack, {
55091
+ value: display.target,
55092
+ min: setpointBounds.min,
55093
+ max: setpointBounds.max,
55094
+ step: display.tempStep,
55095
+ accent,
55096
+ disabled: busy,
55097
+ label: t("uiLibrary.acPanel.setpoint", "Setpoint"),
55098
+ onPreview: (next) => {
55099
+ applyOptimistic({ target: next });
55100
+ },
55101
+ onCommit: (next) => {
55102
+ applyOptimistic({ target: next });
55103
+ cap.setTarget({ target: next });
55104
+ }
55105
+ })
55106
+ })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55107
+ className: "relative z-10 flex items-baseline gap-0.5",
55108
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55109
+ className: cn("text-4xl font-bold leading-none tabular-nums text-foreground", climateTargetWidthClass(display)),
55110
+ children: display.currentTemp ?? "—"
55111
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55112
+ className: "text-lg text-foreground-subtle",
55113
+ children: "°"
55114
+ })]
55115
+ })
55116
+ ]
55117
+ }),
55118
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55119
+ className: "relative z-10 grid grid-cols-3 gap-2",
55120
+ children: [
55121
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompactSelect, {
55122
+ label: t("uiLibrary.acPanel.mode", "Mode"),
55123
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55124
+ style: { color: accent },
55125
+ className: "flex",
55126
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModeIcon, {
55127
+ mode: display.mode,
55128
+ heatingOnly,
55129
+ className: "h-4 w-4"
55130
+ })
55131
+ }),
55132
+ value: display.mode,
55133
+ options: display.availableModes.map((m) => ({
55134
+ value: m,
55135
+ label: modeLabel(t, m)
55136
+ })),
55137
+ disabled: busy,
55138
+ onChange: (v) => {
55139
+ const mode = display.availableModes.find((m) => m === v);
55140
+ if (mode) {
55141
+ applyOptimistic({ mode });
55142
+ cap.setMode({ mode });
55143
+ }
55144
+ }
54915
55145
  }),
54916
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
54917
- label: t("uiLibrary.dishwasher.varioSpeedPlus", "VarioSpeed+"),
54918
- checked: display.varioSpeedPlus,
54919
- shown: link,
54920
- busy: busy || optionTogglesLocked,
54921
- onChange: (on) => void cap.setVarioSpeedPlus({ on })
55146
+ display.availableFanModes.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompactSelect, {
55147
+ label: t("uiLibrary.acPanel.fan", "Fan"),
55148
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Wind, { className: "h-4 w-4 text-sky-500" }),
55149
+ value: display.fanMode,
55150
+ options: display.availableFanModes.map((f) => ({
55151
+ value: f,
55152
+ label: fanLabel(f)
55153
+ })),
55154
+ disabled: busy,
55155
+ onChange: (v) => {
55156
+ applyOptimistic({ fanMode: v });
55157
+ cap.setFanMode({ fanMode: v });
55158
+ }
54922
55159
  }),
54923
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
54924
- label: t("uiLibrary.dishwasher.childLock", "Child lock"),
54925
- checked: display.childLock,
54926
- shown: link,
54927
- busy,
54928
- onChange: (on) => void cap.setChildLock({ on })
55160
+ display.availablePresets.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompactSelect, {
55161
+ label: t("uiLibrary.acPanel.preset", "Preset"),
55162
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CurrentPresetIcon, { className: "h-4 w-4 text-emerald-500" }),
55163
+ value: display.preset,
55164
+ options: display.availablePresets.map((p) => ({
55165
+ value: p,
55166
+ label: fanLabel(p)
55167
+ })),
55168
+ disabled: busy,
55169
+ onChange: (v) => {
55170
+ applyOptimistic({ preset: v });
55171
+ cap.setPreset({ preset: v });
55172
+ }
54929
55173
  })
54930
55174
  ]
54931
- })
54932
- ]
54933
- });
54934
- }
54935
- /**
54936
- * One boolean setting. `checked === null` is "the appliance has not said": on a reachable
54937
- * appliance that means it does not offer the setting (hidden); on an unreachable one the
54938
- * setting is shown but disabled and marked unknown — never as an unchecked box.
54939
- */
54940
- function ToggleRow({ label, checked, shown, busy, onChange }) {
54941
- const t = useUiText();
54942
- if (checked === null && shown) return null;
54943
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
54944
- className: "inline-flex items-center gap-2 cursor-pointer",
54945
- children: [
54946
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
54947
- type: "checkbox",
54948
- checked: checked === true,
54949
- "aria-checked": checked === null ? "mixed" : checked,
54950
- disabled: busy || checked === null,
54951
- onChange: (e) => {
54952
- onChange(e.target.checked);
54953
- },
54954
- onClick: (e) => {
54955
- e.stopPropagation();
54956
- },
54957
- className: "rounded border-border"
54958
55175
  }),
54959
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }),
54960
- checked === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54961
- className: "text-foreground-subtle",
54962
- children: t("uiLibrary.dishwasher.unknownValue", "—")
55176
+ (showVSwing || showHSwing) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
55177
+ label: t("uiLibrary.acPanel.swing", "Swing"),
55178
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55179
+ className: "grid grid-cols-2 gap-1",
55180
+ children: [showVSwing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SwingToggle, {
55181
+ axis: "vertical",
55182
+ on: display.swingVertical ?? false,
55183
+ busy,
55184
+ onToggle: (next) => {
55185
+ applyOptimistic({ swingVertical: next });
55186
+ cap.setSwingVertical({ on: next });
55187
+ }
55188
+ }), showHSwing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SwingToggle, {
55189
+ axis: "horizontal",
55190
+ on: display.swingHorizontal ?? false,
55191
+ busy,
55192
+ onToggle: (next) => {
55193
+ applyOptimistic({ swingHorizontal: next });
55194
+ cap.setSwingHorizontal({ on: next });
55195
+ }
55196
+ })]
55197
+ })
54963
55198
  })
54964
55199
  ]
54965
55200
  });
54966
55201
  }
54967
- function DishwasherInlineControl(props) {
54968
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherPanel, {
54969
- ...props,
54970
- layout: "compact"
55202
+ /** Labelled control group — a small uppercase header above its control. */
55203
+ function Field({ label, children }) {
55204
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55205
+ className: "relative z-10 flex flex-col gap-1",
55206
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55207
+ className: "text-[10px] font-semibold uppercase tracking-wider text-foreground-subtle",
55208
+ children: label
55209
+ }), children]
54971
55210
  });
54972
55211
  }
54973
- function DishwasherHeroCard(props) {
54974
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherPanel, {
54975
- ...props,
54976
- layout: "full"
55212
+ function ModeIcon({ mode, heatingOnly = false, className }) {
55213
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(heatingOnly && mode === "heat" ? Heater : MODE_ICON[mode], {
55214
+ className,
55215
+ "aria-hidden": true
54977
55216
  });
54978
55217
  }
54979
- //#endregion
54980
- //#region src/composites/device-controls/appliance-control.tsx
54981
- /**
54982
- * Appliance card for a `DeviceType.Washer` / `DeviceType.Dryer` device.
54983
- *
54984
- * These appliances have no program surface: what is worth showing is whether
54985
- * the cycle is running (the `running-state` cap), for how long, and what the
54986
- * appliance draws right now (the `power-meter` cap). Both slices are read
54987
- * straight from the device; neither is required.
54988
- * - ApplianceInlineControl (device-list row): the running badge round the
54989
- * drum icon, plus the live watts when there are any.
54990
- * - ApplianceHeroCard (device-detail hero): the drum (turning only while a
54991
- * cycle runs), the state, the phase, the dwell and the power.
54992
- *
54993
- * UNKNOWN IS NOT OFF (D393). `running: null` is drawn as "Unknown" and the
54994
- * drum stays still and unbadged; a missing watt reading is a dash, never
54995
- * "0 W". A slice that has not landed hides its row instead of faking a value.
54996
- */
54997
- var KEYFRAMES$1 = `
54998
- @keyframes apl-tumble { to { transform: rotate(360deg) } }
54999
- .apl-drum-run { animation: apl-tumble 3s linear infinite; }
55000
- @media (prefers-reduced-motion: reduce) {
55001
- .apl-drum-run { animation: none !important; }
55218
+ /** Compact labelled dropdown — a leading icon + native `<select>` + chevron,
55219
+ * used for the Mode / Fan / Preset selectors so all three fit in one row. */
55220
+ function CompactSelect({ label, icon, value, options, disabled, onChange }) {
55221
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55222
+ className: "flex min-w-0 flex-col gap-1",
55223
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55224
+ className: "text-[10px] font-semibold uppercase tracking-wider text-foreground-subtle",
55225
+ children: label
55226
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55227
+ className: cn("flex items-center gap-1 overflow-hidden rounded-lg border border-border/60 bg-surface-subtle px-1.5 py-1.5 has-[:focus-visible]:border-primary", disabled && "opacity-40"),
55228
+ children: [
55229
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55230
+ className: "flex shrink-0",
55231
+ children: icon
55232
+ }),
55233
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
55234
+ "aria-label": label,
55235
+ value,
55236
+ disabled,
55237
+ onChange: (e) => {
55238
+ e.stopPropagation();
55239
+ if (!disabled) onChange(e.target.value);
55240
+ },
55241
+ className: "min-w-0 flex-1 cursor-pointer appearance-none truncate bg-transparent text-xs font-medium text-foreground outline-none",
55242
+ children: options.map((o) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
55243
+ value: o.value,
55244
+ children: o.label
55245
+ }, o.value))
55246
+ }),
55247
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3 w-3 shrink-0 text-foreground-subtle" })
55248
+ ]
55249
+ })]
55250
+ });
55002
55251
  }
55003
- `;
55004
- /** Whole watts, or a dash when the appliance has not reported a reading. */
55005
- function formatWatts(power) {
55006
- return power === void 0 || !Number.isFinite(power) ? "—" : `${Math.round(power)} W`;
55252
+ /** Circular ± button for the target. */
55253
+ function RoundStep({ label, symbol, disabled, onClick }) {
55254
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
55255
+ type: "button",
55256
+ "aria-label": label,
55257
+ disabled,
55258
+ onClick,
55259
+ className: cn("flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border bg-surface text-lg leading-none text-foreground-subtle transition-colors hover:bg-surface-hover hover:text-foreground active:scale-95", disabled && "opacity-40 cursor-not-allowed"),
55260
+ children: symbol
55261
+ });
55007
55262
  }
55008
- /** The state to a person: running / idle, or why there is none (unknown). */
55009
- function applianceStateLabel(running, t) {
55010
- if (running === true) return {
55011
- label: t("uiLibrary.appliance.running", "Running"),
55012
- tone: "text-primary"
55263
+ /** Setpoint track. Drag previews; the command is sent when the pointer is released. */
55264
+ function RangeTrack({ value, min, max, step, accent, disabled, label, onPreview, onCommit }) {
55265
+ const trackRef = (0, react$1.useRef)(null);
55266
+ const draggingRef = (0, react$1.useRef)(false);
55267
+ const [draft, setDraft] = (0, react$1.useState)(null);
55268
+ const shown = draft ?? value;
55269
+ const pct = `${Math.min(1, Math.max(0, (shown - min) / (max - min))) * 100}%`;
55270
+ const tempAt = (clientX) => {
55271
+ const el = trackRef.current;
55272
+ if (el === null) return value;
55273
+ const rect = el.getBoundingClientRect();
55274
+ if (rect.width <= 0) return value;
55275
+ return climateTargetFromFraction((clientX - rect.left) / rect.width, {
55276
+ min,
55277
+ max
55278
+ }, step);
55013
55279
  };
55014
- if (running === false) return {
55015
- label: t("uiLibrary.appliance.idle", "Idle"),
55016
- tone: "text-foreground"
55280
+ const onPointerDown = (event) => {
55281
+ if (disabled) return;
55282
+ event.stopPropagation();
55283
+ event.preventDefault();
55284
+ draggingRef.current = true;
55285
+ if (typeof event.currentTarget.setPointerCapture === "function") event.currentTarget.setPointerCapture(event.pointerId);
55286
+ const next = tempAt(event.clientX);
55287
+ setDraft(next);
55288
+ onPreview(next);
55017
55289
  };
55018
- return {
55019
- label: t("uiLibrary.appliance.unknown", "Unknown"),
55020
- tone: "text-foreground-subtle"
55290
+ const onPointerMove = (event) => {
55291
+ if (disabled || !draggingRef.current) return;
55292
+ event.stopPropagation();
55293
+ const next = tempAt(event.clientX);
55294
+ setDraft(next);
55295
+ onPreview(next);
55021
55296
  };
55022
- }
55023
- function dwellText(running, since, now, t) {
55024
- if (running !== true) return null;
55025
- const { duration } = runningStatusParts(running, since, null, now);
55026
- if (duration === void 0) return null;
55027
- return "minutes" in duration ? t("uiLibrary.appliance.forMinutes", "for {{count}} min", { count: duration.minutes }) : t("uiLibrary.appliance.forHours", "for {{count}} h", { count: duration.hours });
55028
- }
55029
- function useApplianceState(props) {
55030
- return {
55031
- running: useDeviceState(props.trpc, props.deviceId, (s) => s.runningState),
55032
- power: useDeviceState(props.trpc, props.deviceId, (s) => s.powerMeter)
55297
+ const endDrag = (event) => {
55298
+ if (!draggingRef.current) return;
55299
+ event.stopPropagation();
55300
+ draggingRef.current = false;
55301
+ const next = tempAt(event.clientX);
55302
+ setDraft(null);
55303
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId) === true) event.currentTarget.releasePointerCapture(event.pointerId);
55304
+ onCommit(next);
55033
55305
  };
55306
+ const onKeyDown = (event) => {
55307
+ if (disabled) return;
55308
+ const current = draft ?? value;
55309
+ if (event.key === "ArrowRight" || event.key === "ArrowUp") {
55310
+ event.preventDefault();
55311
+ onCommit(nextClimateTarget(current, 1, {
55312
+ tempStep: step,
55313
+ minTemp: min,
55314
+ maxTemp: max
55315
+ }));
55316
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
55317
+ event.preventDefault();
55318
+ onCommit(nextClimateTarget(current, -1, {
55319
+ tempStep: step,
55320
+ minTemp: min,
55321
+ maxTemp: max
55322
+ }));
55323
+ } else if (event.key === "Home") {
55324
+ event.preventDefault();
55325
+ onCommit(min);
55326
+ } else if (event.key === "End") {
55327
+ event.preventDefault();
55328
+ onCommit(max);
55329
+ }
55330
+ };
55331
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55332
+ ref: trackRef,
55333
+ role: "slider",
55334
+ "aria-label": label,
55335
+ "aria-valuemin": min,
55336
+ "aria-valuemax": max,
55337
+ "aria-valuenow": shown,
55338
+ "aria-disabled": disabled,
55339
+ tabIndex: disabled ? -1 : 0,
55340
+ onPointerDown,
55341
+ onPointerMove,
55342
+ onPointerUp: endDrag,
55343
+ onPointerCancel: endDrag,
55344
+ onKeyDown,
55345
+ className: cn("mt-1 w-full touch-none select-none py-2", disabled ? "cursor-not-allowed opacity-40" : "cursor-ew-resize"),
55346
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55347
+ className: "relative h-1.5 w-full rounded-full bg-surface-subtle",
55348
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55349
+ className: "absolute inset-y-0 left-0 rounded-full",
55350
+ style: {
55351
+ width: pct,
55352
+ background: accent
55353
+ }
55354
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55355
+ className: "absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-surface shadow",
55356
+ style: {
55357
+ left: pct,
55358
+ background: accent
55359
+ }
55360
+ })]
55361
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55362
+ className: "mt-0.5 flex justify-between text-[9px] tabular-nums text-foreground-subtle",
55363
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [min, "°"] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [max, "°"] })]
55364
+ })]
55365
+ });
55034
55366
  }
55035
- /** The drum: a ring with a few items in it, turning only while a cycle runs. */
55036
- function Drum({ running }) {
55367
+ /**
55368
+ * Animated centerpiece. `fan_only` renders a spinning fan; `off` a dim static
55369
+ * vent; every active mode renders drifting particles that FALL (cool/dry/auto)
55370
+ * or RISE (heat), with a horizontal sweep overlaid when a swing axis is active.
55371
+ */
55372
+ function AirflowViz({ mode, accent, periodSec, swaying, forceRising = false }) {
55373
+ const columns = [
55374
+ 0,
55375
+ 1,
55376
+ 2,
55377
+ 3,
55378
+ 4,
55379
+ 5
55380
+ ];
55381
+ const rising = forceRising || mode === "heat";
55382
+ const isDroplet = mode === "dry";
55037
55383
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55038
- "aria-hidden": "true",
55039
- "data-testid": "appliance-drum",
55040
- "data-running": running === true ? "running" : running === false ? "idle" : "unknown",
55041
- className: "relative flex h-28 w-28 shrink-0 items-center justify-center rounded-full border-2 border-border bg-surface-hover",
55042
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55043
- className: cn("relative h-20 w-20 rounded-full border-2", running === true ? "apl-drum-run border-primary" : "border-border"),
55044
- "data-testid": "appliance-drum-ring",
55045
- children: [
55046
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "absolute left-2 top-4 h-3 w-3 rounded-full bg-primary/40" }),
55047
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "absolute bottom-3 right-3 h-4 w-4 rounded-full bg-info/40" }),
55048
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "absolute bottom-4 left-4 h-2.5 w-2.5 rounded-full bg-foreground-subtle/40" })
55049
- ]
55384
+ "aria-hidden": true,
55385
+ className: "absolute inset-0 overflow-hidden",
55386
+ children: mode === "off" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55387
+ className: "flex h-full items-center justify-center gap-2 opacity-30",
55388
+ children: columns.map((i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "h-8 w-1 rounded-full bg-foreground-subtle opacity-40" }, i))
55389
+ }) : mode === "fan_only" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55390
+ className: "flex h-full items-center justify-center",
55391
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FanBlades, {
55392
+ color: accent,
55393
+ periodSec: Math.max(.5, periodSec * .7),
55394
+ size: 56
55395
+ })
55396
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55397
+ "data-testid": forceRising ? "climate-heat-rise" : void 0,
55398
+ className: cn("acv-sway-wrap absolute inset-0", swaying && "acv-sway"),
55399
+ style: swaying ? { animation: "acv-sway 2.6s ease-in-out infinite" } : void 0,
55400
+ children: columns.map((i) => {
55401
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55402
+ className: "acv-streak absolute rounded-full",
55403
+ style: {
55404
+ left: `${8 + i * 15}%`,
55405
+ width: isDroplet ? "0.4rem" : "0.3rem",
55406
+ height: isDroplet ? "0.4rem" : "1.9rem",
55407
+ background: accent,
55408
+ boxShadow: `0 0 6px ${accent}`,
55409
+ animationName: rising ? "acv-rise" : "acv-fall",
55410
+ animationTimingFunction: "linear",
55411
+ animationIterationCount: "infinite",
55412
+ animationDuration: `${periodSec}s`,
55413
+ animationDelay: `${i % 3 * (periodSec / 3) + i * .05}s`
55414
+ }
55415
+ }, i);
55416
+ })
55050
55417
  })
55051
55418
  });
55052
55419
  }
55053
- function ApplianceHeroCard(props) {
55054
- const t = useUiText();
55055
- const { running, power } = useApplianceState(props);
55056
- const state = running?.running ?? null;
55057
- const meta = applianceStateLabel(state, t);
55058
- const phase = running?.phase?.trim() ?? "";
55059
- const dwell = running === void 0 ? null : dwellText(state, running.since, Date.now(), t);
55060
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55061
- className: "flex items-center gap-4 rounded-lg border border-border bg-surface p-4",
55420
+ /** Still radiator fins — the off state of a heating-only climate. */
55421
+ function RadiatorFins() {
55422
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55423
+ "data-testid": "climate-radiator",
55424
+ "aria-hidden": true,
55425
+ className: "flex h-full items-end justify-center gap-1.5 px-8 pb-3 opacity-40",
55062
55426
  children: [
55063
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: KEYFRAMES$1 }),
55064
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Drum, { running: state }),
55065
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55066
- className: "flex min-w-0 flex-1 flex-col gap-2",
55067
- children: [
55068
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55069
- className: "flex flex-wrap items-baseline gap-x-2",
55070
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55071
- "data-testid": "appliance-state",
55072
- className: cn("text-3xl font-semibold", meta.tone),
55073
- children: meta.label
55074
- }), dwell !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55075
- className: "text-sm tabular-nums text-foreground-subtle",
55076
- children: dwell
55077
- })]
55078
- }),
55079
- phase !== "" && state !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55080
- className: "text-sm text-foreground-subtle",
55081
- children: phase
55082
- }),
55083
- power !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55084
- "data-testid": "appliance-power",
55085
- className: "inline-flex items-center gap-1 text-sm tabular-nums text-foreground-muted",
55086
- title: power.power === void 0 ? t("uiLibrary.appliance.powerUnknown", "Power unknown") : void 0,
55087
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Zap, {
55088
- className: "h-3.5 w-3.5",
55089
- "aria-hidden": true
55090
- }), formatWatts(power.power)]
55091
- })
55092
- ]
55093
- })
55094
- ]
55427
+ 0,
55428
+ 1,
55429
+ 2,
55430
+ 3,
55431
+ 4,
55432
+ 5,
55433
+ 6
55434
+ ].map((i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "h-10 w-1.5 rounded-t-full bg-foreground-subtle" }, i))
55095
55435
  });
55096
55436
  }
55097
- function ApplianceInlineControl(props) {
55098
- const { running, power } = useApplianceState(props);
55099
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55100
- className: "inline-flex items-center gap-1.5 max-w-full",
55101
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RunningBadge, {
55102
- running: running?.running ?? null,
55103
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WashingMachine, {
55104
- className: "h-4 w-4 text-foreground-muted",
55105
- "aria-hidden": true
55106
- })
55107
- }), power !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55108
- className: "text-xs tabular-nums text-foreground-subtle",
55109
- children: formatWatts(power.power)
55437
+ /** Big spinning fan for `fan_only` mode. */
55438
+ function FanBlades({ color, periodSec, size }) {
55439
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
55440
+ width: size,
55441
+ height: size,
55442
+ viewBox: "0 0 100 100",
55443
+ className: "acv-fan",
55444
+ style: {
55445
+ animation: `acv-spin ${periodSec}s linear infinite`,
55446
+ transformOrigin: "50% 50%"
55447
+ },
55448
+ "aria-hidden": true,
55449
+ children: [[
55450
+ 0,
55451
+ 120,
55452
+ 240
55453
+ ].map((deg) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
55454
+ d: "M50 50 C 58 30, 78 26, 84 12 C 66 20, 54 34, 50 50 Z",
55455
+ fill: color,
55456
+ opacity: .85,
55457
+ transform: `rotate(${deg} 50 50)`
55458
+ }, deg)), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
55459
+ cx: "50",
55460
+ cy: "50",
55461
+ r: "7",
55462
+ fill: color
55110
55463
  })]
55111
55464
  });
55112
55465
  }
55113
- //#endregion
55114
- //#region src/composites/device-controls/heating-valve-readout.tsx
55115
- function formatHeatingValvePosition(position) {
55116
- return position !== null ? `${position}%` : "—";
55117
- }
55118
- function HeatingValveReadout({ trpc, deviceId, layout }) {
55466
+ /** Swing axis toggle — a card with a MoveVertical/Horizontal glyph that
55467
+ * oscillates along its axis when on. On/off is non-destructive → plain tap. */
55468
+ function SwingToggle({ axis, on, busy, onToggle }) {
55119
55469
  const t = useUiText();
55120
- const { cap, slice } = useDeviceCapability({
55470
+ const label = axis === "vertical" ? t("uiLibrary.acPanel.vertical", "Vertical") : t("uiLibrary.acPanel.horizontal", "Horizontal");
55471
+ const Icon = axis === "vertical" ? MoveVertical : MoveHorizontal;
55472
+ const anim = axis === "vertical" ? "acv-swing-v" : "acv-swing-h";
55473
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
55474
+ type: "button",
55475
+ "aria-label": t("uiLibrary.acPanel.swingAxis", "{{axis}} swing", { axis: label }),
55476
+ "aria-pressed": on,
55477
+ disabled: busy,
55478
+ onClick: () => {
55479
+ if (!busy) onToggle(!on);
55480
+ },
55481
+ className: cn("flex items-center justify-center gap-2 rounded-lg border px-2 py-1.5 text-xs font-medium transition-colors", on ? "border-transparent bg-indigo-400 text-indigo-950 shadow-sm" : "border-border/60 bg-surface-subtle text-foreground-subtle hover:bg-surface-hover hover:text-foreground", busy && "opacity-40 cursor-not-allowed"),
55482
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
55483
+ className: "acv-swing h-4 w-4",
55484
+ "aria-hidden": true,
55485
+ style: on ? { animation: `${anim} 1.4s ease-in-out infinite` } : void 0
55486
+ }), label]
55487
+ });
55488
+ }
55489
+ //#endregion
55490
+ //#region src/composites/device-controls/heater-control.tsx
55491
+ /**
55492
+ * Heater controls for a `DeviceType.Heater` device (a dumb plug-in stove).
55493
+ *
55494
+ * A heater is a switch that heats while it is on, and the hub can switch it off
55495
+ * again after a chosen time (`timed-run`, D993):
55496
+ * - HeaterInlineControl (device-list row): the on/off switch and a one-tap
55497
+ * "Run N min".
55498
+ * - HeaterHeroCard (device-detail hero): the glowing heat backdrop (the same
55499
+ * rising streaks a heating air conditioner draws) while it is on, the
55500
+ * on/off switch, the power draw when the plug measures one, and the timed
55501
+ * run (number, unit, Run, countdown, Cancel).
55502
+ *
55503
+ * Every reading may be unknown (`null`) and is drawn as a dash, never as zero.
55504
+ */
55505
+ var HEATER_LIMIT = _camstack_types.TIMED_RUN_LIMITS[_camstack_types.DeviceType.Heater];
55506
+ var QUICK_RUN_MINUTES = 30;
55507
+ var HEAT_ACCENT = "var(--color-warning)";
55508
+ var HEAT_PERIOD_SEC = 1.8;
55509
+ function useHeater({ trpc, deviceId, optimistic }) {
55510
+ const { cap, slice, available } = useDeviceCapability({
55121
55511
  trpc,
55122
55512
  deviceId,
55123
- cap: (d) => d.heatingValve,
55124
- slice: (d) => d.state.heatingValve
55513
+ cap: (d) => d.switch,
55514
+ slice: (d) => d.state.switch
55125
55515
  });
55126
- if (cap === void 0 || slice === void 0) return null;
55127
- const label = t("uiLibrary.heatingValveReadout.valve", "Valve");
55128
- const value = formatHeatingValvePosition(slice.position);
55129
- if (layout === "compact") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55130
- className: "inline-flex items-center text-xs tabular-nums text-foreground-subtle",
55131
- "aria-label": `${label} ${value}`,
55132
- children: value
55516
+ const running = useDeviceCapability({
55517
+ trpc,
55518
+ deviceId,
55519
+ cap: (d) => d.runningState,
55520
+ slice: (d) => d.state.runningState
55133
55521
  });
55134
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55135
- className: "flex items-center gap-2 text-xs tabular-nums text-foreground-subtle",
55136
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55137
- className: "font-medium text-foreground",
55138
- children: value
55139
- })]
55522
+ const power = useDeviceCapability({
55523
+ trpc,
55524
+ deviceId,
55525
+ cap: (d) => d.powerMeter,
55526
+ slice: (d) => d.state.powerMeter
55140
55527
  });
55141
- }
55142
- //#endregion
55143
- //#region src/composites/device-controls/ac-panel.tsx
55144
- /** Modes that mean the device can cool, dry, or move air — an air conditioner. */
55145
- var COOLING_MODES = new Set([
55146
- "cool",
55147
- "heat_cool",
55148
- "dry",
55149
- "fan_only"
55150
- ]);
55151
- /** A heater offers heat (and maybe auto/off) and none of the cooling modes. */
55152
- function climateOffersCooling(modes) {
55153
- return modes.some((mode) => COOLING_MODES.has(mode));
55154
- }
55155
- /** Setpoint track bounds. Absent when the integration did not report both ends. */
55156
- function climateSetpointBounds(status) {
55157
- const min = status.minTemp;
55158
- const max = status.maxTemp;
55159
- if (typeof min !== "number" || typeof max !== "number") return null;
55160
- if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return null;
55528
+ const timer = useDeviceCapability({
55529
+ trpc,
55530
+ deviceId,
55531
+ cap: (d) => d.timedRun,
55532
+ slice: (d) => d.state.timedRun
55533
+ });
55534
+ const { display, applyOptimistic } = useOptimisticSlice(slice, { enabled: optimistic ?? true });
55161
55535
  return {
55162
- min,
55163
- max
55536
+ cap,
55537
+ display,
55538
+ available,
55539
+ applyOptimistic,
55540
+ running: running.slice,
55541
+ power: power.slice,
55542
+ timer
55164
55543
  };
55165
55544
  }
55166
- /** Map a 0–1 drag fraction onto the setpoint, snapped to `tempStep`. */
55167
- function climateTargetFromFraction(fraction, bounds, tempStep) {
55168
- const step = resolveClimateTempStep(tempStep);
55169
- const clamped = Math.min(1, Math.max(0, fraction));
55170
- const raw = bounds.min + clamped * (bounds.max - bounds.min);
55171
- const steps = Math.round((raw - bounds.min) / step);
55172
- const snapped = bounds.min + steps * step;
55173
- const rounded = Math.round(snapped * 1e3) / 1e3;
55174
- return Math.min(bounds.max, Math.max(bounds.min, rounded));
55175
- }
55176
- /** AC-appropriate short mode labels. Exhaustive over HvacMode. */
55177
- function modeLabel(t, mode) {
55178
- switch (mode) {
55179
- case "cool": return t("uiLibrary.acPanel.modeCool", "Cool");
55180
- case "heat": return t("uiLibrary.acPanel.modeHeat", "Heat");
55181
- case "auto": return t("uiLibrary.acPanel.modeAuto", "Auto");
55182
- case "heat_cool": return t("uiLibrary.acPanel.modeHeatCool", "Heat·Cool");
55183
- case "fan_only": return t("uiLibrary.acPanel.modeFan", "Fan");
55184
- case "dry": return t("uiLibrary.acPanel.modeDry", "Dry");
55185
- case "off": return t("uiLibrary.acPanel.modeOff", "Off");
55186
- }
55545
+ function HeaterInlineControl(props) {
55546
+ const t = useUiText();
55547
+ const { cap, display, available, applyOptimistic, timer } = useHeater(props);
55548
+ if (cap === void 0 || display === void 0) return null;
55549
+ const on = display.on === true;
55550
+ const quickMinutes = HEATER_LIMIT === void 0 ? QUICK_RUN_MINUTES : Math.min(QUICK_RUN_MINUTES, maxInUnit(HEATER_LIMIT));
55551
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55552
+ className: "inline-flex items-center gap-2",
55553
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55554
+ onClick: (e) => e.stopPropagation(),
55555
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Switch, {
55556
+ checked: on,
55557
+ disabled: !available,
55558
+ "aria-label": t("uiLibrary.heater.power", "Heater"),
55559
+ onCheckedChange: (next) => {
55560
+ applyOptimistic({ on: next });
55561
+ cap.setState({ on: next });
55562
+ }
55563
+ })
55564
+ }), timer.cap !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55565
+ onClick: (e) => e.stopPropagation(),
55566
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
55567
+ size: "sm",
55568
+ variant: "outline",
55569
+ disabled: !timer.online,
55570
+ onClick: () => void timer.cap?.runFor({
55571
+ duration: quickMinutes,
55572
+ unit: "min"
55573
+ }),
55574
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Timer, {
55575
+ className: "h-3.5 w-3.5",
55576
+ "aria-hidden": true
55577
+ }), t("uiLibrary.heater.quickRun", "Run {{minutes}} min", { minutes: quickMinutes })]
55578
+ })
55579
+ })]
55580
+ });
55187
55581
  }
55188
- /** Per-mode icon (lucide). Exhaustive over HvacMode. */
55189
- var MODE_ICON = {
55190
- cool: Snowflake,
55191
- heat: Flame,
55192
- auto: Sparkles,
55193
- heat_cool: Thermometer,
55194
- fan_only: Fan,
55195
- dry: Droplets,
55196
- off: Power
55197
- };
55198
- /** Mode accent hex — drives the airflow particles, the glow, and the target
55199
- * value colour. Mirrors the thermostat gauge palette. Exhaustive. */
55200
- var MODE_ACCENT = {
55201
- heat: "#f59e0b",
55202
- cool: "#60a5fa",
55203
- auto: "#c084fc",
55204
- heat_cool: "#e879f9",
55205
- fan_only: "#94a3b8",
55206
- dry: "#22d3ee",
55207
- off: "#a1a1aa"
55208
- };
55209
- /** Title-case a snake/lower token for a selector option ('medium_low' → 'Medium
55210
- * low', 'turbo' → 'Turbo'). Shared by the fan + preset selectors. */
55211
- function fanLabel(token) {
55212
- const spaced = token.replace(/_/g, " ");
55213
- return spaced.charAt(0).toUpperCase() + spaced.slice(1);
55582
+ function HeaterHeroCard(props) {
55583
+ const t = useUiText();
55584
+ const { cap, display, available, applyOptimistic, running, power, timer } = useHeater(props);
55585
+ if (cap === void 0 || display === void 0) return null;
55586
+ const on = display.on === true;
55587
+ const heating = running?.running ?? on;
55588
+ const watts = power?.power ?? null;
55589
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55590
+ className: "relative flex flex-col items-stretch gap-3 overflow-hidden rounded-lg border border-border bg-surface p-4",
55591
+ children: [
55592
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: KEYFRAMES$1 }),
55593
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55594
+ className: "relative flex h-32 items-center justify-center overflow-hidden rounded-lg border border-border-subtle bg-background-elevated",
55595
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55596
+ className: cn("pointer-events-none absolute inset-0", heating ? "opacity-60" : "opacity-20"),
55597
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AirflowViz, {
55598
+ mode: heating ? "heat" : "off",
55599
+ accent: HEAT_ACCENT,
55600
+ periodSec: HEAT_PERIOD_SEC,
55601
+ swaying: false,
55602
+ forceRising: true
55603
+ })
55604
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55605
+ className: "relative z-10 flex flex-col items-center gap-1",
55606
+ children: [
55607
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Flame, {
55608
+ className: cn("h-8 w-8", heating ? "text-warning" : "text-foreground-subtle"),
55609
+ "aria-hidden": true
55610
+ }),
55611
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55612
+ className: "text-sm font-medium text-foreground",
55613
+ children: heating ? t("uiLibrary.heater.heating", "Heating") : t("uiLibrary.heater.off", "Off")
55614
+ }),
55615
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55616
+ className: "text-xs tabular-nums text-foreground-muted",
55617
+ "data-testid": "heater-watts",
55618
+ children: watts === null ? "—" : `${Math.round(watts)} W`
55619
+ })
55620
+ ]
55621
+ })]
55622
+ }),
55623
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
55624
+ className: "flex items-center justify-between gap-3 text-sm text-foreground",
55625
+ children: [t("uiLibrary.heater.power", "Heater"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Switch, {
55626
+ checked: on,
55627
+ disabled: !available,
55628
+ onCheckedChange: (next) => {
55629
+ applyOptimistic({ on: next });
55630
+ cap.setState({ on: next });
55631
+ }
55632
+ })]
55633
+ }),
55634
+ timer.cap !== void 0 && HEATER_LIMIT !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TimedRunControl, {
55635
+ trpc: props.trpc,
55636
+ deviceId: props.deviceId,
55637
+ limit: HEATER_LIMIT
55638
+ })
55639
+ ]
55640
+ });
55214
55641
  }
55215
- /** Preset → icon. Unknown presets fall back to a neutral sparkle. */
55216
- function presetIcon(preset) {
55217
- switch (preset) {
55218
- case "turbo": return Zap;
55219
- case "quiet": return Feather;
55220
- case "sleep": return Moon;
55221
- case "eco": return Leaf;
55222
- case "8c_heat": return Snowflake;
55223
- case "none": return Ban;
55224
- default: return Sparkles;
55642
+ //#endregion
55643
+ //#region src/composites/device-controls/dishwasher-control.tsx
55644
+ /**
55645
+ * Dishwasher controls for a `DeviceType.Dishwasher` device.
55646
+ *
55647
+ * Reads the `dishwasher` cap status slice and renders inline + hero surfaces
55648
+ * mirroring vacuum-control / pet-feeder patterns.
55649
+ *
55650
+ * Salt and rinse aid are not here: they are items of the device's `consumables`
55651
+ * cap, shown by the page's Consumables section like every device's (D915).
55652
+ *
55653
+ * UNKNOWN IS NOT OFF. The slice carries `null` for everything the appliance has not
55654
+ * reported, and this view renders that as "—" / a disabled control, never as an unchecked
55655
+ * box or a zero. It also does not echo a command before the appliance confirms it: the
55656
+ * slice moves when the appliance says so.
55657
+ */
55658
+ /** What the operation state is called to a person, and how it is toned. */
55659
+ function dishwasherOperationMeta(operationState, t = defaultUiText) {
55660
+ switch (operationState) {
55661
+ case "Run": return {
55662
+ label: t("uiLibrary.dishwasher.op.run", "Running"),
55663
+ tone: "text-primary"
55664
+ };
55665
+ case "Pause": return {
55666
+ label: t("uiLibrary.dishwasher.op.pause", "Paused"),
55667
+ tone: "text-warning"
55668
+ };
55669
+ case "Finished": return {
55670
+ label: t("uiLibrary.dishwasher.op.finished", "Finished"),
55671
+ tone: "text-success"
55672
+ };
55673
+ case "Error": return {
55674
+ label: t("uiLibrary.dishwasher.op.error", "Error"),
55675
+ tone: "text-danger"
55676
+ };
55677
+ case "Aborting": return {
55678
+ label: t("uiLibrary.dishwasher.op.aborting", "Stopping"),
55679
+ tone: "text-warning"
55680
+ };
55681
+ case "ActionRequired": return {
55682
+ label: t("uiLibrary.dishwasher.op.actionRequired", "Action required"),
55683
+ tone: "text-warning"
55684
+ };
55685
+ case "DelayedStart": return {
55686
+ label: t("uiLibrary.dishwasher.op.delayedStart", "Delayed start"),
55687
+ tone: "text-primary"
55688
+ };
55689
+ case "Ready": return {
55690
+ label: t("uiLibrary.dishwasher.op.ready", "Ready"),
55691
+ tone: "text-foreground"
55692
+ };
55693
+ case "Inactive": return {
55694
+ label: t("uiLibrary.dishwasher.op.inactive", "Inactive"),
55695
+ tone: "text-foreground-subtle"
55696
+ };
55697
+ case null: return {
55698
+ label: t("uiLibrary.dishwasher.unknown", "Unknown"),
55699
+ tone: "text-foreground-subtle"
55700
+ };
55701
+ }
55702
+ }
55703
+ /** The headline for the whole slice: the appliance's state, or why there is none. */
55704
+ function dishwasherHeadline(status, t = defaultUiText) {
55705
+ switch (status.availability) {
55706
+ case "offline": return {
55707
+ label: t("uiLibrary.dishwasher.offline", "Offline"),
55708
+ tone: "text-foreground-subtle"
55709
+ };
55710
+ case "description-missing": return {
55711
+ label: t("uiLibrary.dishwasher.descriptionMissing", "Waiting for the appliance profile"),
55712
+ tone: "text-warning"
55713
+ };
55714
+ case "available": return dishwasherOperationMeta(status.operationState, t);
55225
55715
  }
55226
55716
  }
55717
+ /** Last segment of a feature key, spaced out: `Dishcare.Dishwasher.Program.Eco50` -> `Eco50`. */
55718
+ function featureLabel(key) {
55719
+ return (key.split(".").pop() ?? key).replace(/([a-z0-9])([A-Z])/g, "$1 $2");
55720
+ }
55227
55721
  /**
55228
- * Fan level → airflow animation period. Higher speed ⇒ shorter period. Unknown
55229
- * strings fall back to a medium pace. Turbo (a preset) shortens it further.
55722
+ * Salt and rinse aid are consumable items (D915). Their events must not
55723
+ * reappear on the hero — the Consumables section is the only surface.
55230
55724
  */
55231
- function airflowPeriodSec(fanMode) {
55232
- switch (fanMode) {
55233
- case "high": return 1;
55234
- case "medium_high": return 1.3;
55235
- case "medium": return 1.6;
55236
- case "medium_low": return 1.9;
55237
- case "low": return 2.2;
55238
- default: return 1.8;
55239
- }
55725
+ function isConsumableDishwasherEvent(event) {
55726
+ const short = (event.split(".").pop() ?? event).toLowerCase();
55727
+ return short.includes("salt") || short.includes("rinse");
55240
55728
  }
55241
- var KEYFRAMES = `
55242
- @keyframes acv-fall { 0%{transform:translateY(-1.75rem);opacity:0} 18%{opacity:.7} 82%{opacity:.7} 100%{transform:translateY(7.5rem);opacity:0} }
55243
- @keyframes acv-rise { 0%{transform:translateY(7.5rem);opacity:0} 18%{opacity:.7} 82%{opacity:.7} 100%{transform:translateY(-1.75rem);opacity:0} }
55244
- @keyframes acv-sway { 0%,100%{transform:translateX(-9%)} 50%{transform:translateX(9%)} }
55245
- @keyframes acv-swing-h { 0%,100%{transform:translateX(-22%)} 50%{transform:translateX(22%)} }
55246
- @keyframes acv-swing-v { 0%,100%{transform:translateY(-22%)} 50%{transform:translateY(22%)} }
55247
- @keyframes acv-spin { to { transform:rotate(360deg) } }
55248
- @keyframes acv-glow { 0%,100%{opacity:.35;transform:scale(1)} 50%{opacity:.6;transform:scale(1.06)} }
55249
- @media (prefers-reduced-motion: reduce) {
55250
- .acv-streak, .acv-fan, .acv-sway-wrap, .acv-glow, .acv-swing { animation: none !important; }
55729
+ /** Raised events the hero may name. Consumable events are dropped. */
55730
+ function visibleDishwasherEvents(events) {
55731
+ return events.filter((event) => !isConsumableDishwasherEvent(event));
55251
55732
  }
55252
- `;
55253
- function AcPanel({ trpc, deviceId, layout, optimistic }) {
55254
- const t = useUiText();
55255
- const { cap, slice, available } = useDeviceCapability({
55733
+ /** Whole seconds as a short duration: `1 h 24 min`, `42 min`, `30 s`. */
55734
+ function formatDuration(seconds, t = defaultUiText) {
55735
+ if (seconds < 60) return t("uiLibrary.dishwasher.durationSeconds", "{{n}} s", { n: seconds });
55736
+ const minutes = Math.round(seconds / 60);
55737
+ if (minutes < 60) return t("uiLibrary.dishwasher.durationMinutes", "{{n}} min", { n: minutes });
55738
+ const hours = Math.floor(minutes / 60);
55739
+ const rest = minutes % 60;
55740
+ return rest === 0 ? t("uiLibrary.dishwasher.durationHours", "{{n}} h", { n: hours }) : t("uiLibrary.dishwasher.durationHoursMinutes", "{{h}} h {{m}} min", {
55741
+ h: hours,
55742
+ m: rest
55743
+ });
55744
+ }
55745
+ function useDishwasher(trpc, deviceId) {
55746
+ return useDeviceCapability({
55256
55747
  trpc,
55257
55748
  deviceId,
55258
- cap: (d) => d.climateControl,
55259
- slice: (d) => d.state.climateControl
55749
+ cap: (d) => d.dishwasher,
55750
+ slice: (d) => d.state.dishwasher
55260
55751
  });
55261
- const { display, applyOptimistic } = useOptimisticSlice(slice, { enabled: optimistic ?? true });
55752
+ }
55753
+ function DishwasherIconButton({ label, busy, active, onClick, children }) {
55754
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
55755
+ type: "button",
55756
+ "aria-label": label,
55757
+ title: label,
55758
+ disabled: busy,
55759
+ onClick: (e) => {
55760
+ e.stopPropagation();
55761
+ onClick();
55762
+ },
55763
+ className: cn("inline-flex h-5 w-5 items-center justify-center rounded text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors", active && "bg-primary text-primary-foreground", busy && "opacity-40 cursor-not-allowed"),
55764
+ children
55765
+ });
55766
+ }
55767
+ function DishwasherGraphic({ running, progressPercent }) {
55768
+ const pct = progressPercent != null ? Math.max(0, Math.min(100, progressPercent)) : running ? 40 : 0;
55769
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55770
+ "aria-hidden": "true",
55771
+ className: "relative w-28 h-36 rounded border-2 border-border bg-gradient-to-b from-surface-hover to-surface overflow-hidden flex-shrink-0",
55772
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55773
+ className: cn("absolute inset-x-0 bottom-0 bg-primary/25 transition-[height] duration-500", running && "animate-pulse"),
55774
+ style: { height: `${pct}%` }
55775
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55776
+ className: "absolute inset-0 flex items-center justify-center text-foreground-subtle",
55777
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Waves, { className: "h-10 w-10 opacity-70" })
55778
+ })]
55779
+ });
55780
+ }
55781
+ function DishwasherPanel({ trpc, deviceId, layout }) {
55782
+ const t = useUiText();
55783
+ const { cap, slice, available } = useDishwasher(trpc, deviceId);
55262
55784
  if (cap === void 0) return null;
55263
- if (display === void 0) return null;
55264
- const busy = !available;
55785
+ if (slice === void 0) return null;
55786
+ const display = slice;
55787
+ const link = display.availability === "available";
55788
+ const busy = !available || !link;
55789
+ const optionTogglesLocked = display.childLock === true;
55790
+ const meta = dishwasherHeadline(display, t);
55791
+ const running = display.operationState === "Run";
55792
+ const paused = display.operationState === "Pause";
55793
+ const startable = display.remoteStartAllowed === true && display.selectedProgram !== null;
55794
+ const press = (fn) => {
55795
+ if (busy) return;
55796
+ fn();
55797
+ };
55265
55798
  if (layout === "compact") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55266
- "data-mode": display.mode,
55267
- className: "inline-flex items-center gap-1",
55799
+ className: "inline-flex items-center gap-1.5 max-w-full",
55268
55800
  children: [
55801
+ display.doorOpen === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55802
+ className: "inline-flex text-warning shrink-0",
55803
+ title: t("uiLibrary.dishwasher.doorOpen", "Door open"),
55804
+ "aria-label": t("uiLibrary.dishwasher.doorOpen", "Door open"),
55805
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DoorOpen, { className: "h-3.5 w-3.5" })
55806
+ }),
55269
55807
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55270
- className: cn("text-xs tabular-nums font-medium", MODE_TEXT_COLOR[display.mode]),
55271
- children: display.target !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
55272
- display.target,
55273
- "°",
55274
- display.currentTemp !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55275
- className: "text-foreground-subtle font-normal",
55276
- children: [
55277
- " ",
55278
- "(",
55279
- display.currentTemp,
55280
- "°)"
55281
- ]
55282
- })
55283
- ] }) : display.currentTemp !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55284
- className: "text-foreground-subtle",
55285
- children: [display.currentTemp, "°"]
55286
- }) : null
55808
+ className: cn("text-xs font-medium tabular-nums shrink-0", meta.tone),
55809
+ children: meta.label
55287
55810
  }),
55288
- display.target !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
55289
- type: "button",
55290
- "aria-label": t("uiLibrary.acPanel.decrease", "Decrease target"),
55291
- disabled: busy,
55292
- onClick: (e) => {
55293
- e.stopPropagation();
55294
- if (!busy && display.target !== null) {
55295
- const next = nextClimateTarget(display.target, -1, display);
55296
- applyOptimistic({ target: next });
55297
- cap.setTarget({ target: next });
55298
- }
55299
- },
55300
- className: cn("inline-flex h-5 w-5 items-center justify-center rounded text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors text-xs", busy && "opacity-40 cursor-not-allowed"),
55301
- children: "−"
55302
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
55303
- type: "button",
55304
- "aria-label": t("uiLibrary.acPanel.increase", "Increase target"),
55305
- disabled: busy,
55306
- onClick: (e) => {
55307
- e.stopPropagation();
55308
- if (!busy && display.target !== null) {
55309
- const next = nextClimateTarget(display.target, 1, display);
55310
- applyOptimistic({ target: next });
55311
- cap.setTarget({ target: next });
55312
- }
55313
- },
55314
- className: cn("inline-flex h-5 w-5 items-center justify-center rounded text-foreground-subtle hover:text-foreground hover:bg-surface-hover transition-colors text-xs", busy && "opacity-40 cursor-not-allowed"),
55315
- children: "+"
55316
- })] }),
55317
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
55318
- "aria-label": t("uiLibrary.acPanel.mode", "Mode"),
55319
- value: display.mode,
55320
- disabled: busy,
55321
- onChange: (e) => {
55322
- e.stopPropagation();
55323
- const mode = display.availableModes.find((m) => m === e.target.value);
55324
- if (!busy && mode) {
55325
- applyOptimistic({ mode });
55326
- cap.setMode({ mode });
55327
- }
55328
- },
55329
- className: cn("bg-transparent px-0.5 text-xs text-foreground-subtle hover:text-foreground cursor-pointer", busy && "opacity-40 cursor-not-allowed"),
55330
- children: display.availableModes.map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
55331
- value: m,
55332
- children: modeLabel(t, m)
55333
- }, m))
55811
+ display.program != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55812
+ className: "text-xs text-foreground-subtle truncate max-w-[7rem]",
55813
+ title: display.program,
55814
+ children: featureLabel(display.program)
55815
+ }),
55816
+ display.remaining != null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55817
+ className: "inline-flex items-center gap-0.5 text-xs text-foreground-subtle tabular-nums shrink-0",
55818
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Timer, { className: "h-3 w-3" }), formatDuration(display.remaining, t)]
55819
+ }),
55820
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
55821
+ label: t("uiLibrary.dishwasher.start", "Start"),
55822
+ busy: busy || !startable,
55823
+ active: running,
55824
+ onClick: () => press(() => cap.start({})),
55825
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Play, { className: "h-3 w-3" })
55826
+ }),
55827
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
55828
+ label: paused ? t("uiLibrary.dishwasher.resume", "Resume") : t("uiLibrary.dishwasher.pause", "Pause"),
55829
+ busy: busy || !(running || paused),
55830
+ active: paused,
55831
+ onClick: () => press(() => paused ? cap.resume({}) : cap.pause({})),
55832
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pause, { className: "h-3 w-3" })
55334
55833
  })
55335
55834
  ]
55336
55835
  });
55337
- const accent = MODE_ACCENT[display.mode];
55338
- const on = display.mode !== "off";
55339
- const heatingOnly = !climateOffersCooling(display.availableModes);
55340
- const setpointBounds = climateSetpointBounds(display);
55341
- const CurrentPresetIcon = presetIcon(display.preset);
55342
- const boost = display.preset === "turbo";
55343
- const period = Math.max(.55, airflowPeriodSec(display.fanMode) * (boost ? .6 : 1));
55344
- const showVSwing = display.swingVertical !== null;
55345
- const showHSwing = display.swingHorizontal !== null;
55346
- const swaying = on && (display.swingVertical === true || display.swingHorizontal === true);
55836
+ const programs = display.availablePrograms;
55347
55837
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55348
- "data-mode": display.mode,
55349
- className: cn("relative flex flex-col items-stretch gap-2.5 overflow-hidden rounded-lg border p-3 transition-colors", MODE_COLOR[display.mode]),
55838
+ className: "flex flex-col gap-3 rounded-lg border border-border bg-surface p-4",
55350
55839
  children: [
55351
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: KEYFRAMES }),
55352
55840
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55353
- className: "relative z-10 flex flex-col items-center justify-center gap-1 overflow-hidden rounded-lg border border-border/50 bg-surface/30 px-3 py-3",
55354
- children: [
55355
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55356
- className: "pointer-events-none absolute inset-0 opacity-60",
55357
- children: heatingOnly && !on ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RadiatorFins, {}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AirflowViz, {
55358
- mode: display.mode,
55359
- accent,
55360
- periodSec: period,
55361
- swaying,
55362
- forceRising: heatingOnly
55363
- })
55364
- }),
55365
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "pointer-events-none absolute inset-0 bg-gradient-to-b from-surface/50 via-transparent to-surface/50" }),
55366
- on && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55367
- "aria-hidden": true,
55368
- className: "acv-glow pointer-events-none absolute left-1/2 top-1/2 h-16 w-16 -translate-x-1/2 -translate-y-1/2 rounded-full blur-2xl",
55369
- style: {
55370
- background: accent,
55371
- animation: "acv-glow 3.2s ease-in-out infinite"
55372
- }
55373
- }),
55374
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55375
- className: "relative z-10 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide",
55376
- style: { color: accent },
55377
- children: [
55378
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModeIcon, {
55379
- mode: display.mode,
55380
- heatingOnly,
55381
- className: "h-3.5 w-3.5"
55382
- }),
55383
- modeLabel(t, display.mode),
55384
- display.currentTemp !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55385
- className: "normal-case text-foreground-subtle",
55386
- children: t("uiLibrary.acPanel.now", "· {{temp}}° now", { temp: display.currentTemp })
55387
- }),
55388
- display.currentHumidity !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55389
- className: "inline-flex items-center gap-0.5 normal-case text-foreground-subtle",
55390
- children: [
55391
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Droplets, { className: "h-3 w-3" }),
55392
- display.currentHumidity,
55393
- "%"
55394
- ]
55395
- })
55396
- ]
55397
- }),
55398
- display.target !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55399
- className: "relative z-10 flex items-center gap-3",
55400
- children: [
55401
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RoundStep, {
55402
- label: t("uiLibrary.acPanel.decrease", "Decrease target"),
55403
- symbol: "−",
55404
- disabled: busy,
55405
- onClick: () => {
55406
- if (busy || display.target === null) return;
55407
- const next = nextClimateTarget(display.target, -1, display);
55408
- applyOptimistic({ target: next });
55409
- cap.setTarget({ target: next });
55410
- }
55411
- }),
55412
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55413
- className: "flex items-baseline gap-0.5",
55414
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55415
- className: cn("text-4xl font-bold leading-none tabular-nums", climateTargetWidthClass(display)),
55416
- style: { color: accent },
55417
- children: display.target
55418
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55419
- className: "text-lg text-foreground-subtle",
55420
- children: "°"
55421
- })]
55422
- }),
55423
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RoundStep, {
55424
- label: t("uiLibrary.acPanel.increase", "Increase target"),
55425
- symbol: "+",
55426
- disabled: busy,
55427
- onClick: () => {
55428
- if (busy || display.target === null) return;
55429
- const next = nextClimateTarget(display.target, 1, display);
55430
- applyOptimistic({ target: next });
55431
- cap.setTarget({ target: next });
55432
- }
55433
- })
55434
- ]
55435
- }), setpointBounds !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55436
- className: "relative z-10 w-full px-1",
55437
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RangeTrack, {
55438
- value: display.target,
55439
- min: setpointBounds.min,
55440
- max: setpointBounds.max,
55441
- step: display.tempStep,
55442
- accent,
55443
- disabled: busy,
55444
- label: t("uiLibrary.acPanel.setpoint", "Setpoint"),
55445
- onPreview: (next) => {
55446
- applyOptimistic({ target: next });
55447
- },
55448
- onCommit: (next) => {
55449
- applyOptimistic({ target: next });
55450
- cap.setTarget({ target: next });
55451
- }
55841
+ className: "flex items-start gap-4",
55842
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherGraphic, {
55843
+ running,
55844
+ progressPercent: display.progress
55845
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55846
+ className: "flex min-w-0 flex-1 flex-col gap-2",
55847
+ children: [
55848
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55849
+ className: "flex flex-wrap items-center gap-2",
55850
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55851
+ className: cn("text-3xl font-semibold", meta.tone),
55852
+ children: meta.label
55853
+ }), display.powerOn === false && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55854
+ className: "text-xs font-medium text-foreground-subtle",
55855
+ children: t("uiLibrary.dishwasher.powerOff", "Power off")
55856
+ })]
55857
+ }),
55858
+ display.program != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55859
+ className: "text-sm text-foreground-subtle",
55860
+ title: display.program,
55861
+ children: featureLabel(display.program)
55862
+ }),
55863
+ display.remaining != null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55864
+ className: "inline-flex items-center gap-1 text-sm text-foreground-subtle tabular-nums",
55865
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Timer, { className: "h-3.5 w-3.5" }), t("uiLibrary.dishwasher.remaining", "{{time}} left", { time: formatDuration(display.remaining, t) })]
55866
+ }),
55867
+ display.delayedStart != null && display.delayedStart > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55868
+ className: "text-xs text-foreground-subtle tabular-nums",
55869
+ children: t("uiLibrary.dishwasher.startsIn", "Starts in {{time}}", { time: formatDuration(display.delayedStart, t) })
55870
+ }),
55871
+ display.progress != null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55872
+ className: "flex items-center gap-2",
55873
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55874
+ className: "h-1.5 flex-1 rounded bg-surface-hover overflow-hidden",
55875
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55876
+ className: "h-full bg-primary transition-[width] duration-500",
55877
+ style: { width: `${Math.max(0, Math.min(100, display.progress))}%` }
55878
+ })
55879
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55880
+ className: "tabular-nums text-xs text-foreground-subtle",
55881
+ children: [Math.round(display.progress), "%"]
55882
+ })]
55883
+ }),
55884
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55885
+ className: "flex flex-wrap items-center gap-x-3 gap-y-1.5",
55886
+ children: [display.doorOpen === true && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55887
+ className: "inline-flex items-center gap-1 text-xs text-warning",
55888
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DoorOpen, { className: "h-3.5 w-3.5" }), t("uiLibrary.dishwasher.doorOpen", "Door open")]
55889
+ }), visibleDishwasherEvents(display.activeEvents).map((event) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55890
+ className: "text-xs text-warning",
55891
+ title: event,
55892
+ children: featureLabel(event)
55893
+ }, event))]
55894
+ }),
55895
+ display.operationState === "Error" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55896
+ className: "inline-flex items-center gap-1 text-xs text-danger",
55897
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TriangleAlert, { className: "h-3.5 w-3.5" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: display.errorLabel != null ? featureLabel(display.errorLabel) : display.errorCode != null ? t("uiLibrary.dishwasher.errorCode", "Error {{code}}", { code: display.errorCode }) : t("uiLibrary.dishwasher.op.error", "Error") })]
55898
+ }),
55899
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55900
+ className: "flex flex-wrap items-center gap-2",
55901
+ children: [
55902
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
55903
+ label: t("uiLibrary.dishwasher.start", "Start"),
55904
+ busy: busy || !startable,
55905
+ active: running,
55906
+ onClick: () => press(() => cap.start({})),
55907
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Play, { className: "h-4 w-4" })
55908
+ }),
55909
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
55910
+ label: paused ? t("uiLibrary.dishwasher.resume", "Resume") : t("uiLibrary.dishwasher.pause", "Pause"),
55911
+ busy: busy || !(running || paused),
55912
+ active: paused,
55913
+ onClick: () => press(() => paused ? cap.resume({}) : cap.pause({})),
55914
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pause, { className: "h-4 w-4" })
55915
+ }),
55916
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
55917
+ label: t("uiLibrary.dishwasher.stop", "Stop"),
55918
+ busy: busy || !(running || paused),
55919
+ active: false,
55920
+ onClick: () => press(() => cap.stop({})),
55921
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Square, { className: "h-3.5 w-3.5" })
55922
+ }),
55923
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
55924
+ label: t("uiLibrary.dishwasher.power", "Power"),
55925
+ busy: busy || display.powerOn === null,
55926
+ active: display.powerOn === true,
55927
+ onClick: () => {
55928
+ const next = display.powerOn !== true;
55929
+ press(() => cap.setPower({ on: next }));
55930
+ },
55931
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Power, { className: "h-4 w-4" })
55932
+ })
55933
+ ]
55452
55934
  })
55453
- })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55454
- className: "relative z-10 flex items-baseline gap-0.5",
55455
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55456
- className: cn("text-4xl font-bold leading-none tabular-nums text-foreground", climateTargetWidthClass(display)),
55457
- children: display.currentTemp ?? "—"
55458
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55459
- className: "text-lg text-foreground-subtle",
55460
- children: "°"
55461
- })]
55462
- })
55463
- ]
55935
+ ]
55936
+ })]
55937
+ }),
55938
+ programs.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
55939
+ className: "flex items-center gap-2 text-xs text-foreground-subtle",
55940
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55941
+ className: "w-16 shrink-0",
55942
+ children: t("uiLibrary.dishwasher.program", "Program")
55943
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
55944
+ "aria-label": t("uiLibrary.dishwasher.program", "Program"),
55945
+ value: display.selectedProgram ?? "",
55946
+ disabled: busy,
55947
+ onChange: (e) => {
55948
+ const program = e.target.value;
55949
+ if (!program) return;
55950
+ cap.setProgram({ program });
55951
+ },
55952
+ onClick: (e) => {
55953
+ e.stopPropagation();
55954
+ },
55955
+ className: "flex-1 rounded border border-border bg-surface px-2 py-1 text-foreground disabled:opacity-50",
55956
+ children: [display.selectedProgram == null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
55957
+ value: "",
55958
+ children: "—"
55959
+ }), programs.map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
55960
+ value: p,
55961
+ children: featureLabel(p)
55962
+ }, p))]
55963
+ })]
55464
55964
  }),
55465
55965
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55466
- className: "relative z-10 grid grid-cols-3 gap-2",
55966
+ className: "flex flex-wrap gap-4 text-xs",
55467
55967
  children: [
55468
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompactSelect, {
55469
- label: t("uiLibrary.acPanel.mode", "Mode"),
55470
- icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55471
- style: { color: accent },
55472
- className: "flex",
55473
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModeIcon, {
55474
- mode: display.mode,
55475
- heatingOnly,
55476
- className: "h-4 w-4"
55477
- })
55478
- }),
55479
- value: display.mode,
55480
- options: display.availableModes.map((m) => ({
55481
- value: m,
55482
- label: modeLabel(t, m)
55483
- })),
55484
- disabled: busy,
55485
- onChange: (v) => {
55486
- const mode = display.availableModes.find((m) => m === v);
55487
- if (mode) {
55488
- applyOptimistic({ mode });
55489
- cap.setMode({ mode });
55490
- }
55491
- }
55968
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
55969
+ label: t("uiLibrary.dishwasher.halfLoad", "Half load"),
55970
+ checked: display.halfLoad,
55971
+ shown: link,
55972
+ busy: busy || optionTogglesLocked,
55973
+ onChange: (on) => void cap.setHalfLoad({ on })
55974
+ }),
55975
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
55976
+ label: t("uiLibrary.dishwasher.hygienePlus", "Hygiene plus"),
55977
+ checked: display.hygienePlus,
55978
+ shown: link,
55979
+ busy: busy || optionTogglesLocked,
55980
+ onChange: (on) => void cap.setHygienePlus({ on })
55981
+ }),
55982
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
55983
+ label: t("uiLibrary.dishwasher.extraDry", "Extra dry"),
55984
+ checked: display.extraDry,
55985
+ shown: link,
55986
+ busy: busy || optionTogglesLocked,
55987
+ onChange: (on) => void cap.setExtraDry({ on })
55988
+ }),
55989
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
55990
+ label: t("uiLibrary.dishwasher.intensiveZone", "Intensive zone"),
55991
+ checked: display.intensiveZone,
55992
+ shown: link,
55993
+ busy: busy || optionTogglesLocked,
55994
+ onChange: (on) => void cap.setIntensiveZone({ on })
55492
55995
  }),
55493
- display.availableFanModes.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompactSelect, {
55494
- label: t("uiLibrary.acPanel.fan", "Fan"),
55495
- icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Wind, { className: "h-4 w-4 text-sky-500" }),
55496
- value: display.fanMode,
55497
- options: display.availableFanModes.map((f) => ({
55498
- value: f,
55499
- label: fanLabel(f)
55500
- })),
55501
- disabled: busy,
55502
- onChange: (v) => {
55503
- applyOptimistic({ fanMode: v });
55504
- cap.setFanMode({ fanMode: v });
55505
- }
55996
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
55997
+ label: t("uiLibrary.dishwasher.varioSpeedPlus", "VarioSpeed+"),
55998
+ checked: display.varioSpeedPlus,
55999
+ shown: link,
56000
+ busy: busy || optionTogglesLocked,
56001
+ onChange: (on) => void cap.setVarioSpeedPlus({ on })
55506
56002
  }),
55507
- display.availablePresets.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompactSelect, {
55508
- label: t("uiLibrary.acPanel.preset", "Preset"),
55509
- icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CurrentPresetIcon, { className: "h-4 w-4 text-emerald-500" }),
55510
- value: display.preset,
55511
- options: display.availablePresets.map((p) => ({
55512
- value: p,
55513
- label: fanLabel(p)
55514
- })),
55515
- disabled: busy,
55516
- onChange: (v) => {
55517
- applyOptimistic({ preset: v });
55518
- cap.setPreset({ preset: v });
55519
- }
56003
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
56004
+ label: t("uiLibrary.dishwasher.childLock", "Child lock"),
56005
+ checked: display.childLock,
56006
+ shown: link,
56007
+ busy,
56008
+ onChange: (on) => void cap.setChildLock({ on })
55520
56009
  })
55521
56010
  ]
55522
- }),
55523
- (showVSwing || showHSwing) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
55524
- label: t("uiLibrary.acPanel.swing", "Swing"),
55525
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55526
- className: "grid grid-cols-2 gap-1",
55527
- children: [showVSwing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SwingToggle, {
55528
- axis: "vertical",
55529
- on: display.swingVertical ?? false,
55530
- busy,
55531
- onToggle: (next) => {
55532
- applyOptimistic({ swingVertical: next });
55533
- cap.setSwingVertical({ on: next });
55534
- }
55535
- }), showHSwing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SwingToggle, {
55536
- axis: "horizontal",
55537
- on: display.swingHorizontal ?? false,
55538
- busy,
55539
- onToggle: (next) => {
55540
- applyOptimistic({ swingHorizontal: next });
55541
- cap.setSwingHorizontal({ on: next });
55542
- }
55543
- })]
55544
- })
55545
56011
  })
55546
56012
  ]
55547
56013
  });
55548
56014
  }
55549
- /** Labelled control group — a small uppercase header above its control. */
55550
- function Field({ label, children }) {
55551
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55552
- className: "relative z-10 flex flex-col gap-1",
55553
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55554
- className: "text-[10px] font-semibold uppercase tracking-wider text-foreground-subtle",
55555
- children: label
55556
- }), children]
56015
+ /**
56016
+ * One boolean setting. `checked === null` is "the appliance has not said": on a reachable
56017
+ * appliance that means it does not offer the setting (hidden); on an unreachable one the
56018
+ * setting is shown but disabled and marked unknown — never as an unchecked box.
56019
+ */
56020
+ function ToggleRow({ label, checked, shown, busy, onChange }) {
56021
+ const t = useUiText();
56022
+ if (checked === null && shown) return null;
56023
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
56024
+ className: "inline-flex items-center gap-2 cursor-pointer",
56025
+ children: [
56026
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
56027
+ type: "checkbox",
56028
+ checked: checked === true,
56029
+ "aria-checked": checked === null ? "mixed" : checked,
56030
+ disabled: busy || checked === null,
56031
+ onChange: (e) => {
56032
+ onChange(e.target.checked);
56033
+ },
56034
+ onClick: (e) => {
56035
+ e.stopPropagation();
56036
+ },
56037
+ className: "rounded border-border"
56038
+ }),
56039
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }),
56040
+ checked === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56041
+ className: "text-foreground-subtle",
56042
+ children: t("uiLibrary.dishwasher.unknownValue", "—")
56043
+ })
56044
+ ]
55557
56045
  });
55558
56046
  }
55559
- function ModeIcon({ mode, heatingOnly = false, className }) {
55560
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(heatingOnly && mode === "heat" ? Heater : MODE_ICON[mode], {
55561
- className,
55562
- "aria-hidden": true
56047
+ function DishwasherInlineControl(props) {
56048
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherPanel, {
56049
+ ...props,
56050
+ layout: "compact"
55563
56051
  });
55564
56052
  }
55565
- /** Compact labelled dropdown — a leading icon + native `<select>` + chevron,
55566
- * used for the Mode / Fan / Preset selectors so all three fit in one row. */
55567
- function CompactSelect({ label, icon, value, options, disabled, onChange }) {
55568
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55569
- className: "flex min-w-0 flex-col gap-1",
55570
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55571
- className: "text-[10px] font-semibold uppercase tracking-wider text-foreground-subtle",
55572
- children: label
55573
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55574
- className: cn("flex items-center gap-1 overflow-hidden rounded-lg border border-border/60 bg-surface-subtle px-1.5 py-1.5 has-[:focus-visible]:border-primary", disabled && "opacity-40"),
55575
- children: [
55576
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55577
- className: "flex shrink-0",
55578
- children: icon
55579
- }),
55580
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
55581
- "aria-label": label,
55582
- value,
55583
- disabled,
55584
- onChange: (e) => {
55585
- e.stopPropagation();
55586
- if (!disabled) onChange(e.target.value);
55587
- },
55588
- className: "min-w-0 flex-1 cursor-pointer appearance-none truncate bg-transparent text-xs font-medium text-foreground outline-none",
55589
- children: options.map((o) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
55590
- value: o.value,
55591
- children: o.label
55592
- }, o.value))
55593
- }),
55594
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3 w-3 shrink-0 text-foreground-subtle" })
55595
- ]
55596
- })]
56053
+ function DishwasherHeroCard(props) {
56054
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherPanel, {
56055
+ ...props,
56056
+ layout: "full"
55597
56057
  });
55598
56058
  }
55599
- /** Circular ± button for the target. */
55600
- function RoundStep({ label, symbol, disabled, onClick }) {
55601
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
55602
- type: "button",
55603
- "aria-label": label,
55604
- disabled,
55605
- onClick,
55606
- className: cn("flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border bg-surface text-lg leading-none text-foreground-subtle transition-colors hover:bg-surface-hover hover:text-foreground active:scale-95", disabled && "opacity-40 cursor-not-allowed"),
55607
- children: symbol
55608
- });
56059
+ //#endregion
56060
+ //#region src/composites/device-controls/appliance-control.tsx
56061
+ /**
56062
+ * Appliance card for a `DeviceType.Washer` / `DeviceType.Dryer` device.
56063
+ *
56064
+ * These appliances have no program surface: what is worth showing is whether
56065
+ * the cycle is running (the `running-state` cap), for how long, and what the
56066
+ * appliance draws right now (the `power-meter` cap). Both slices are read
56067
+ * straight from the device; neither is required.
56068
+ * - ApplianceInlineControl (device-list row): the running badge round the
56069
+ * drum icon, plus the live watts when there are any.
56070
+ * - ApplianceHeroCard (device-detail hero): the drum (turning only while a
56071
+ * cycle runs), the state, the phase, the dwell and the power.
56072
+ *
56073
+ * UNKNOWN IS NOT OFF (D393). `running: null` is drawn as "Unknown" and the
56074
+ * drum stays still and unbadged; a missing watt reading is a dash, never
56075
+ * "0 W". A slice that has not landed hides its row instead of faking a value.
56076
+ */
56077
+ var KEYFRAMES = `
56078
+ @keyframes apl-tumble { to { transform: rotate(360deg) } }
56079
+ .apl-drum-run { animation: apl-tumble 3s linear infinite; }
56080
+ @media (prefers-reduced-motion: reduce) {
56081
+ .apl-drum-run { animation: none !important; }
55609
56082
  }
55610
- /** Setpoint track. Drag previews; the command is sent when the pointer is released. */
55611
- function RangeTrack({ value, min, max, step, accent, disabled, label, onPreview, onCommit }) {
55612
- const trackRef = (0, react$1.useRef)(null);
55613
- const draggingRef = (0, react$1.useRef)(false);
55614
- const [draft, setDraft] = (0, react$1.useState)(null);
55615
- const shown = draft ?? value;
55616
- const pct = `${Math.min(1, Math.max(0, (shown - min) / (max - min))) * 100}%`;
55617
- const tempAt = (clientX) => {
55618
- const el = trackRef.current;
55619
- if (el === null) return value;
55620
- const rect = el.getBoundingClientRect();
55621
- if (rect.width <= 0) return value;
55622
- return climateTargetFromFraction((clientX - rect.left) / rect.width, {
55623
- min,
55624
- max
55625
- }, step);
55626
- };
55627
- const onPointerDown = (event) => {
55628
- if (disabled) return;
55629
- event.stopPropagation();
55630
- event.preventDefault();
55631
- draggingRef.current = true;
55632
- if (typeof event.currentTarget.setPointerCapture === "function") event.currentTarget.setPointerCapture(event.pointerId);
55633
- const next = tempAt(event.clientX);
55634
- setDraft(next);
55635
- onPreview(next);
56083
+ `;
56084
+ /** Whole watts, or a dash when the appliance has not reported a reading. */
56085
+ function formatWatts(power) {
56086
+ return power === void 0 || !Number.isFinite(power) ? "—" : `${Math.round(power)} W`;
56087
+ }
56088
+ /** The state to a person: running / idle, or why there is none (unknown). */
56089
+ function applianceStateLabel(running, t) {
56090
+ if (running === true) return {
56091
+ label: t("uiLibrary.appliance.running", "Running"),
56092
+ tone: "text-primary"
55636
56093
  };
55637
- const onPointerMove = (event) => {
55638
- if (disabled || !draggingRef.current) return;
55639
- event.stopPropagation();
55640
- const next = tempAt(event.clientX);
55641
- setDraft(next);
55642
- onPreview(next);
56094
+ if (running === false) return {
56095
+ label: t("uiLibrary.appliance.idle", "Idle"),
56096
+ tone: "text-foreground"
55643
56097
  };
55644
- const endDrag = (event) => {
55645
- if (!draggingRef.current) return;
55646
- event.stopPropagation();
55647
- draggingRef.current = false;
55648
- const next = tempAt(event.clientX);
55649
- setDraft(null);
55650
- if (event.currentTarget.hasPointerCapture?.(event.pointerId) === true) event.currentTarget.releasePointerCapture(event.pointerId);
55651
- onCommit(next);
56098
+ return {
56099
+ label: t("uiLibrary.appliance.unknown", "Unknown"),
56100
+ tone: "text-foreground-subtle"
55652
56101
  };
55653
- const onKeyDown = (event) => {
55654
- if (disabled) return;
55655
- const current = draft ?? value;
55656
- if (event.key === "ArrowRight" || event.key === "ArrowUp") {
55657
- event.preventDefault();
55658
- onCommit(nextClimateTarget(current, 1, {
55659
- tempStep: step,
55660
- minTemp: min,
55661
- maxTemp: max
55662
- }));
55663
- } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
55664
- event.preventDefault();
55665
- onCommit(nextClimateTarget(current, -1, {
55666
- tempStep: step,
55667
- minTemp: min,
55668
- maxTemp: max
55669
- }));
55670
- } else if (event.key === "Home") {
55671
- event.preventDefault();
55672
- onCommit(min);
55673
- } else if (event.key === "End") {
55674
- event.preventDefault();
55675
- onCommit(max);
55676
- }
56102
+ }
56103
+ function dwellText(running, since, now, t) {
56104
+ if (running !== true) return null;
56105
+ const { duration } = runningStatusParts(running, since, null, now);
56106
+ if (duration === void 0) return null;
56107
+ return "minutes" in duration ? t("uiLibrary.appliance.forMinutes", "for {{count}} min", { count: duration.minutes }) : t("uiLibrary.appliance.forHours", "for {{count}} h", { count: duration.hours });
56108
+ }
56109
+ function useApplianceState(props) {
56110
+ return {
56111
+ running: useDeviceState(props.trpc, props.deviceId, (s) => s.runningState),
56112
+ power: useDeviceState(props.trpc, props.deviceId, (s) => s.powerMeter)
55677
56113
  };
55678
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55679
- ref: trackRef,
55680
- role: "slider",
55681
- "aria-label": label,
55682
- "aria-valuemin": min,
55683
- "aria-valuemax": max,
55684
- "aria-valuenow": shown,
55685
- "aria-disabled": disabled,
55686
- tabIndex: disabled ? -1 : 0,
55687
- onPointerDown,
55688
- onPointerMove,
55689
- onPointerUp: endDrag,
55690
- onPointerCancel: endDrag,
55691
- onKeyDown,
55692
- className: cn("mt-1 w-full touch-none select-none py-2", disabled ? "cursor-not-allowed opacity-40" : "cursor-ew-resize"),
55693
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55694
- className: "relative h-1.5 w-full rounded-full bg-surface-subtle",
55695
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55696
- className: "absolute inset-y-0 left-0 rounded-full",
55697
- style: {
55698
- width: pct,
55699
- background: accent
55700
- }
55701
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55702
- className: "absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-surface shadow",
55703
- style: {
55704
- left: pct,
55705
- background: accent
55706
- }
55707
- })]
55708
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55709
- className: "mt-0.5 flex justify-between text-[9px] tabular-nums text-foreground-subtle",
55710
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [min, "°"] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [max, "°"] })]
55711
- })]
55712
- });
55713
56114
  }
55714
- /**
55715
- * Animated centerpiece. `fan_only` renders a spinning fan; `off` a dim static
55716
- * vent; every active mode renders drifting particles that FALL (cool/dry/auto)
55717
- * or RISE (heat), with a horizontal sweep overlaid when a swing axis is active.
55718
- */
55719
- function AirflowViz({ mode, accent, periodSec, swaying, forceRising = false }) {
55720
- const columns = [
55721
- 0,
55722
- 1,
55723
- 2,
55724
- 3,
55725
- 4,
55726
- 5
55727
- ];
55728
- const rising = forceRising || mode === "heat";
55729
- const isDroplet = mode === "dry";
56115
+ /** The drum: a ring with a few items in it, turning only while a cycle runs. */
56116
+ function Drum({ running }) {
55730
56117
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55731
- "aria-hidden": true,
55732
- className: "absolute inset-0 overflow-hidden",
55733
- children: mode === "off" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55734
- className: "flex h-full items-center justify-center gap-2 opacity-30",
55735
- children: columns.map((i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "h-8 w-1 rounded-full bg-foreground-subtle opacity-40" }, i))
55736
- }) : mode === "fan_only" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55737
- className: "flex h-full items-center justify-center",
55738
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FanBlades, {
55739
- color: accent,
55740
- periodSec: Math.max(.5, periodSec * .7),
55741
- size: 56
55742
- })
55743
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55744
- "data-testid": forceRising ? "climate-heat-rise" : void 0,
55745
- className: cn("acv-sway-wrap absolute inset-0", swaying && "acv-sway"),
55746
- style: swaying ? { animation: "acv-sway 2.6s ease-in-out infinite" } : void 0,
55747
- children: columns.map((i) => {
55748
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55749
- className: "acv-streak absolute rounded-full",
55750
- style: {
55751
- left: `${8 + i * 15}%`,
55752
- width: isDroplet ? "0.4rem" : "0.3rem",
55753
- height: isDroplet ? "0.4rem" : "1.9rem",
55754
- background: accent,
55755
- boxShadow: `0 0 6px ${accent}`,
55756
- animationName: rising ? "acv-rise" : "acv-fall",
55757
- animationTimingFunction: "linear",
55758
- animationIterationCount: "infinite",
55759
- animationDuration: `${periodSec}s`,
55760
- animationDelay: `${i % 3 * (periodSec / 3) + i * .05}s`
55761
- }
55762
- }, i);
55763
- })
56118
+ "aria-hidden": "true",
56119
+ "data-testid": "appliance-drum",
56120
+ "data-running": running === true ? "running" : running === false ? "idle" : "unknown",
56121
+ className: "relative flex h-28 w-28 shrink-0 items-center justify-center rounded-full border-2 border-border bg-surface-hover",
56122
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56123
+ className: cn("relative h-20 w-20 rounded-full border-2", running === true ? "apl-drum-run border-primary" : "border-border"),
56124
+ "data-testid": "appliance-drum-ring",
56125
+ children: [
56126
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "absolute left-2 top-4 h-3 w-3 rounded-full bg-primary/40" }),
56127
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "absolute bottom-3 right-3 h-4 w-4 rounded-full bg-info/40" }),
56128
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "absolute bottom-4 left-4 h-2.5 w-2.5 rounded-full bg-foreground-subtle/40" })
56129
+ ]
55764
56130
  })
55765
56131
  });
55766
56132
  }
55767
- /** Still radiator fins — the off state of a heating-only climate. */
55768
- function RadiatorFins() {
55769
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55770
- "data-testid": "climate-radiator",
55771
- "aria-hidden": true,
55772
- className: "flex h-full items-end justify-center gap-1.5 px-8 pb-3 opacity-40",
56133
+ function ApplianceHeroCard(props) {
56134
+ const t = useUiText();
56135
+ const { running, power } = useApplianceState(props);
56136
+ const state = running?.running ?? null;
56137
+ const meta = applianceStateLabel(state, t);
56138
+ const phase = running?.phase?.trim() ?? "";
56139
+ const dwell = running === void 0 ? null : dwellText(state, running.since, Date.now(), t);
56140
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56141
+ className: "flex items-center gap-4 rounded-lg border border-border bg-surface p-4",
55773
56142
  children: [
55774
- 0,
55775
- 1,
55776
- 2,
55777
- 3,
55778
- 4,
55779
- 5,
55780
- 6
55781
- ].map((i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "h-10 w-1.5 rounded-t-full bg-foreground-subtle" }, i))
56143
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: KEYFRAMES }),
56144
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Drum, { running: state }),
56145
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56146
+ className: "flex min-w-0 flex-1 flex-col gap-2",
56147
+ children: [
56148
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56149
+ className: "flex flex-wrap items-baseline gap-x-2",
56150
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56151
+ "data-testid": "appliance-state",
56152
+ className: cn("text-3xl font-semibold", meta.tone),
56153
+ children: meta.label
56154
+ }), dwell !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56155
+ className: "text-sm tabular-nums text-foreground-subtle",
56156
+ children: dwell
56157
+ })]
56158
+ }),
56159
+ phase !== "" && state !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56160
+ className: "text-sm text-foreground-subtle",
56161
+ children: phase
56162
+ }),
56163
+ power !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
56164
+ "data-testid": "appliance-power",
56165
+ className: "inline-flex items-center gap-1 text-sm tabular-nums text-foreground-muted",
56166
+ title: power.power === void 0 ? t("uiLibrary.appliance.powerUnknown", "Power unknown") : void 0,
56167
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Zap, {
56168
+ className: "h-3.5 w-3.5",
56169
+ "aria-hidden": true
56170
+ }), formatWatts(power.power)]
56171
+ })
56172
+ ]
56173
+ })
56174
+ ]
55782
56175
  });
55783
56176
  }
55784
- /** Big spinning fan for `fan_only` mode. */
55785
- function FanBlades({ color, periodSec, size }) {
55786
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
55787
- width: size,
55788
- height: size,
55789
- viewBox: "0 0 100 100",
55790
- className: "acv-fan",
55791
- style: {
55792
- animation: `acv-spin ${periodSec}s linear infinite`,
55793
- transformOrigin: "50% 50%"
55794
- },
55795
- "aria-hidden": true,
55796
- children: [[
55797
- 0,
55798
- 120,
55799
- 240
55800
- ].map((deg) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
55801
- d: "M50 50 C 58 30, 78 26, 84 12 C 66 20, 54 34, 50 50 Z",
55802
- fill: color,
55803
- opacity: .85,
55804
- transform: `rotate(${deg} 50 50)`
55805
- }, deg)), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
55806
- cx: "50",
55807
- cy: "50",
55808
- r: "7",
55809
- fill: color
56177
+ function ApplianceInlineControl(props) {
56178
+ const { running, power } = useApplianceState(props);
56179
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56180
+ className: "inline-flex items-center gap-1.5 max-w-full",
56181
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RunningBadge, {
56182
+ running: running?.running ?? null,
56183
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WashingMachine, {
56184
+ className: "h-4 w-4 text-foreground-muted",
56185
+ "aria-hidden": true
56186
+ })
56187
+ }), power !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56188
+ className: "text-xs tabular-nums text-foreground-subtle",
56189
+ children: formatWatts(power.power)
55810
56190
  })]
55811
56191
  });
55812
56192
  }
55813
- /** Swing axis toggle — a card with a MoveVertical/Horizontal glyph that
55814
- * oscillates along its axis when on. On/off is non-destructive → plain tap. */
55815
- function SwingToggle({ axis, on, busy, onToggle }) {
56193
+ //#endregion
56194
+ //#region src/composites/device-controls/heating-valve-readout.tsx
56195
+ function formatHeatingValvePosition(position) {
56196
+ return position !== null ? `${position}%` : "—";
56197
+ }
56198
+ function HeatingValveReadout({ trpc, deviceId, layout }) {
55816
56199
  const t = useUiText();
55817
- const label = axis === "vertical" ? t("uiLibrary.acPanel.vertical", "Vertical") : t("uiLibrary.acPanel.horizontal", "Horizontal");
55818
- const Icon = axis === "vertical" ? MoveVertical : MoveHorizontal;
55819
- const anim = axis === "vertical" ? "acv-swing-v" : "acv-swing-h";
55820
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
55821
- type: "button",
55822
- "aria-label": t("uiLibrary.acPanel.swingAxis", "{{axis}} swing", { axis: label }),
55823
- "aria-pressed": on,
55824
- disabled: busy,
55825
- onClick: () => {
55826
- if (!busy) onToggle(!on);
55827
- },
55828
- className: cn("flex items-center justify-center gap-2 rounded-lg border px-2 py-1.5 text-xs font-medium transition-colors", on ? "border-transparent bg-indigo-400 text-indigo-950 shadow-sm" : "border-border/60 bg-surface-subtle text-foreground-subtle hover:bg-surface-hover hover:text-foreground", busy && "opacity-40 cursor-not-allowed"),
55829
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
55830
- className: "acv-swing h-4 w-4",
55831
- "aria-hidden": true,
55832
- style: on ? { animation: `${anim} 1.4s ease-in-out infinite` } : void 0
55833
- }), label]
56200
+ const { cap, slice } = useDeviceCapability({
56201
+ trpc,
56202
+ deviceId,
56203
+ cap: (d) => d.heatingValve,
56204
+ slice: (d) => d.state.heatingValve
56205
+ });
56206
+ if (cap === void 0 || slice === void 0) return null;
56207
+ const label = t("uiLibrary.heatingValveReadout.valve", "Valve");
56208
+ const value = formatHeatingValvePosition(slice.position);
56209
+ if (layout === "compact") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56210
+ className: "inline-flex items-center text-xs tabular-nums text-foreground-subtle",
56211
+ "aria-label": `${label} ${value}`,
56212
+ children: value
56213
+ });
56214
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56215
+ className: "flex items-center gap-2 text-xs tabular-nums text-foreground-subtle",
56216
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56217
+ className: "font-medium text-foreground",
56218
+ children: value
56219
+ })]
55834
56220
  });
55835
56221
  }
55836
56222
  //#endregion
@@ -57922,6 +58308,10 @@ var CONTROL_KIND_COMPONENTS = {
57922
58308
  InlineControl: PetFountainInlineControl,
57923
58309
  HeroCard: PetFountainHeroCard
57924
58310
  },
58311
+ heater: {
58312
+ InlineControl: HeaterInlineControl,
58313
+ HeroCard: HeaterHeroCard
58314
+ },
57925
58315
  dishwasher: {
57926
58316
  InlineControl: DishwasherInlineControl,
57927
58317
  HeroCard: DishwasherHeroCard
@@ -58009,6 +58399,7 @@ var CONTROL_FILLS = {
58009
58399
  [_camstack_types.DeviceType.Image]: false,
58010
58400
  [_camstack_types.DeviceType.PetFeeder]: false,
58011
58401
  [_camstack_types.DeviceType.PetFountain]: false,
58402
+ [_camstack_types.DeviceType.Heater]: false,
58012
58403
  [_camstack_types.DeviceType.Dishwasher]: false,
58013
58404
  [_camstack_types.DeviceType.Washer]: false,
58014
58405
  [_camstack_types.DeviceType.Dryer]: false
@@ -72481,6 +72872,9 @@ function TimelineSlider({ markers, bands = [], selectedId = null, step = 5, aria
72481
72872
  });
72482
72873
  }
72483
72874
  //#endregion
72875
+ //#region src/ui-library-version.ts
72876
+ var uiLibraryVersion = "1.2.370";
72877
+ //#endregion
72484
72878
  //#region src/hooks/use-account-unit-preferences.ts
72485
72879
  /**
72486
72880
  * Unit preferences in the signed-in user's ACCOUNT preferences (D894): one
@@ -74016,6 +74410,7 @@ exports.ThermostatInlineControl = ThermostatInlineControl;
74016
74410
  exports.TimeSeriesChart = TimeSeriesChart;
74017
74411
  exports.TimelineSlider = TimelineSlider;
74018
74412
  exports.Timer = Timer;
74413
+ exports.TimerOff = TimerOff;
74019
74414
  exports.TimezoneSelector = TimezoneSelector;
74020
74415
  exports.ToggleLeft = ToggleLeft;
74021
74416
  exports.ToggleRight = ToggleRight;
@@ -74423,6 +74818,7 @@ exports.translateAddonKey = translateAddonKey;
74423
74818
  exports.trpc = trpc;
74424
74819
  exports.typeLabel = typeLabel;
74425
74820
  exports.typedToStored = typedToStored;
74821
+ exports.uiLibraryVersion = uiLibraryVersion;
74426
74822
  exports.unitAxes = unitAxes;
74427
74823
  exports.useAccessoriesGetStatus = useAccessoriesGetStatus;
74428
74824
  exports.useAccessoriesSetChildHidden = useAccessoriesSetChildHidden;
@@ -74882,6 +75278,8 @@ exports.useDisplayPaletteGetPalette = useDisplayPaletteGetPalette;
74882
75278
  exports.useDisplayPaletteListPresets = useDisplayPaletteListPresets;
74883
75279
  exports.useDisplayPaletteSetPalette = useDisplayPaletteSetPalette;
74884
75280
  exports.useDisplayPaletteView = useDisplayPaletteView;
75281
+ exports.useDoNotDisturbGetStatus = useDoNotDisturbGetStatus;
75282
+ exports.useDoNotDisturbSetEnabled = useDoNotDisturbSetEnabled;
74885
75283
  exports.useDoorbellEvents = useDoorbellEvents;
74886
75284
  exports.useDoorbellGetStatus = useDoorbellGetStatus;
74887
75285
  exports.useEnumSensorGetStatus = useEnumSensorGetStatus;
@@ -74910,6 +75308,10 @@ exports.useFanControlSetDirection = useFanControlSetDirection;
74910
75308
  exports.useFanControlSetOscillating = useFanControlSetOscillating;
74911
75309
  exports.useFanControlSetPercentage = useFanControlSetPercentage;
74912
75310
  exports.useFanControlSetPreset = useFanControlSetPreset;
75311
+ exports.useFanPresetsApplyPreset = useFanPresetsApplyPreset;
75312
+ exports.useFanPresetsCapturePreset = useFanPresetsCapturePreset;
75313
+ exports.useFanPresetsGetPresets = useFanPresetsGetPresets;
75314
+ exports.useFanPresetsSetPresets = useFanPresetsSetPresets;
74913
75315
  exports.useFeatureProbeGetStatus = useFeatureProbeGetStatus;
74914
75316
  exports.useFloodGetStatus = useFloodGetStatus;
74915
75317
  exports.useGasGetStatus = useGasGetStatus;
@@ -75519,6 +75921,7 @@ exports.useRecordingOnboardGetOptions = useRecordingOnboardGetOptions;
75519
75921
  exports.useRecordingOnboardGetStatus = useRecordingOnboardGetStatus;
75520
75922
  exports.useRecordingOnboardSetSettings = useRecordingOnboardSetSettings;
75521
75923
  exports.useRecordingSignalGetStatus = useRecordingSignalGetStatus;
75924
+ exports.useRegisteredHotkeys = useRegisteredHotkeys;
75522
75925
  exports.useRemoteComponent = useRemoteComponent;
75523
75926
  exports.useRemoteGetConfig = useRemoteGetConfig;
75524
75927
  exports.useRemoteLayoutComponentsListComponents = useRemoteLayoutComponentsListComponents;
@@ -75711,6 +76114,9 @@ exports.useTerminalSessionSetInstanceEnabled = useTerminalSessionSetInstanceEnab
75711
76114
  exports.useTerminalSessionUpdateInstance = useTerminalSessionUpdateInstance;
75712
76115
  exports.useTerminalSessionWriteInput = useTerminalSessionWriteInput;
75713
76116
  exports.useThemeMode = require_theme_index.useThemeMode$1;
76117
+ exports.useTimedRunCancel = useTimedRunCancel;
76118
+ exports.useTimedRunGetStatus = useTimedRunGetStatus;
76119
+ exports.useTimedRunRunFor = useTimedRunRunFor;
75714
76120
  exports.useToastOnToast = useToastOnToast;
75715
76121
  exports.useTrainingCanaryResult = useTrainingCanaryResult;
75716
76122
  exports.useTrainingCancelJob = useTrainingCancelJob;