@camstack/ui-library 1.2.368 → 1.2.369

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) => {
@@ -11526,6 +11559,14 @@ var useFanControlSetDirection = /*#__PURE__*/ alias(() => trpc.fanControl.setDir
11526
11559
  var useFanControlSetOscillating = /*#__PURE__*/ alias(() => trpc.fanControl.setOscillating.useMutation);
11527
11560
  /** Generated alias around `trpc.fanControl.getStatus.useQuery`. */
11528
11561
  var useFanControlGetStatus = /*#__PURE__*/ alias(() => trpc.fanControl.getStatus.useQuery);
11562
+ /** Generated alias around `trpc.fanPresets.getPresets.useQuery`. */
11563
+ var useFanPresetsGetPresets = /*#__PURE__*/ alias(() => trpc.fanPresets.getPresets.useQuery);
11564
+ /** Generated alias around `trpc.fanPresets.setPresets.useMutation`. */
11565
+ var useFanPresetsSetPresets = /*#__PURE__*/ alias(() => trpc.fanPresets.setPresets.useMutation);
11566
+ /** Generated alias around `trpc.fanPresets.applyPreset.useMutation`. */
11567
+ var useFanPresetsApplyPreset = /*#__PURE__*/ alias(() => trpc.fanPresets.applyPreset.useMutation);
11568
+ /** Generated alias around `trpc.fanPresets.capturePreset.useQuery`. */
11569
+ var useFanPresetsCapturePreset = /*#__PURE__*/ alias(() => trpc.fanPresets.capturePreset.useQuery);
11529
11570
  /** Generated alias around `trpc.featureProbe.getStatus.useQuery`. */
11530
11571
  var useFeatureProbeGetStatus = /*#__PURE__*/ alias(() => trpc.featureProbe.getStatus.useQuery);
11531
11572
  /** Generated alias around `trpc.flood.getStatus.useQuery`. */
@@ -13056,6 +13097,12 @@ var useTerminalSessionPullOutput = /*#__PURE__*/ alias(() => trpc.terminalSessio
13056
13097
  var useTerminalSessionWriteInput = /*#__PURE__*/ alias(() => trpc.terminalSession.writeInput.useMutation);
13057
13098
  /** Generated alias around `trpc.terminalSession.close.useMutation`. */
13058
13099
  var useTerminalSessionClose = /*#__PURE__*/ alias(() => trpc.terminalSession.close.useMutation);
13100
+ /** Generated alias around `trpc.timedRun.runFor.useMutation`. */
13101
+ var useTimedRunRunFor = /*#__PURE__*/ alias(() => trpc.timedRun.runFor.useMutation);
13102
+ /** Generated alias around `trpc.timedRun.cancel.useMutation`. */
13103
+ var useTimedRunCancel = /*#__PURE__*/ alias(() => trpc.timedRun.cancel.useMutation);
13104
+ /** Generated alias around `trpc.timedRun.getStatus.useQuery`. */
13105
+ var useTimedRunGetStatus = /*#__PURE__*/ alias(() => trpc.timedRun.getStatus.useQuery);
13059
13106
  /** Generated alias around `trpc.toast.onToast.useSubscription`. */
13060
13107
  var useToastOnToast = /*#__PURE__*/ alias(() => trpc.toast.onToast.useSubscription);
13061
13108
  /** Generated alias around `trpc.training.listBaseModels.useQuery`. */
@@ -54144,6 +54191,137 @@ function PetFeederHeroCard({ trpc, deviceId, features, optimistic }) {
54144
54191
  });
54145
54192
  }
54146
54193
  //#endregion
54194
+ //#region src/composites/device-controls/timed-run-control.tsx
54195
+ /**
54196
+ * "Run for N" - the inline control of the `timed-run` cap (D993).
54197
+ *
54198
+ * A number, the unit it is in, and a Run button: the hub turns the device on and
54199
+ * switches it off again after that long. While a run is armed the control shows
54200
+ * the time left (counted down from the slice's `activeUntil`) and a Cancel. The
54201
+ * maximum is the device type's own bound (`TIMED_RUN_LIMITS`); a value above it
54202
+ * is refused by the hub with a typed reason, which this control says in words.
54203
+ *
54204
+ * Rendered only when the device binds `timed-run`. An unknown slice is "nothing
54205
+ * armed", never a running timer.
54206
+ */
54207
+ var TICK_MS = 1e3;
54208
+ var SECONDS_PER_MINUTE$1 = 60;
54209
+ var MS_PER_SECOND = 1e3;
54210
+ var DEFAULT_MINUTES = 30;
54211
+ var DEFAULT_SECONDS = 60;
54212
+ /** `m:ss` for a countdown; a started run never shows a negative time. */
54213
+ function formatCountdown(remainingMs) {
54214
+ const total = Math.max(0, Math.ceil(remainingMs / MS_PER_SECOND));
54215
+ const minutes = Math.floor(total / SECONDS_PER_MINUTE$1);
54216
+ const seconds = total % SECONDS_PER_MINUTE$1;
54217
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
54218
+ }
54219
+ /** The largest value the field accepts, in the limit's unit. */
54220
+ function maxInUnit(limit) {
54221
+ return limit.unit === "min" ? limit.maxSeconds / SECONDS_PER_MINUTE$1 : limit.maxSeconds;
54222
+ }
54223
+ function useNow(active) {
54224
+ const [now, setNow] = (0, react$1.useState)(() => Date.now());
54225
+ (0, react$1.useEffect)(() => {
54226
+ if (!active) return void 0;
54227
+ setNow(Date.now());
54228
+ const handle = setInterval(() => setNow(Date.now()), TICK_MS);
54229
+ return () => clearInterval(handle);
54230
+ }, [active]);
54231
+ return now;
54232
+ }
54233
+ function TimedRunControl({ trpc, deviceId, limit }) {
54234
+ const t = useUiText();
54235
+ const { cap, slice, online } = useDeviceCapability({
54236
+ trpc,
54237
+ deviceId,
54238
+ cap: (d) => d.timedRun,
54239
+ slice: (d) => d.state.timedRun
54240
+ });
54241
+ const max = maxInUnit(limit);
54242
+ const [draft, setDraft] = (0, react$1.useState)(Math.min(max, limit.unit === "min" ? DEFAULT_MINUTES : DEFAULT_SECONDS));
54243
+ const [refusal, setRefusal] = (0, react$1.useState)(null);
54244
+ const activeUntil = slice?.activeUntil ?? null;
54245
+ const now = useNow(activeUntil !== null);
54246
+ if (cap === void 0) return null;
54247
+ const unitLabel = limit.unit === "min" ? t("uiLibrary.timedRun.minutes", "min") : t("uiLibrary.timedRun.seconds", "s");
54248
+ const valid = Number.isFinite(draft) && draft > 0 && draft <= max;
54249
+ const armed = activeUntil !== null && activeUntil > now;
54250
+ const run = () => {
54251
+ if (!valid) return;
54252
+ setRefusal(null);
54253
+ cap.runFor({
54254
+ duration: draft,
54255
+ unit: limit.unit
54256
+ }).then((result) => {
54257
+ if (!result.ok) setRefusal(result.reason);
54258
+ });
54259
+ };
54260
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54261
+ className: "flex flex-col gap-1.5",
54262
+ "data-testid": "timed-run-control",
54263
+ children: [
54264
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54265
+ className: "flex items-center gap-2",
54266
+ children: [
54267
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
54268
+ type: "number",
54269
+ inputMode: "numeric",
54270
+ min: 1,
54271
+ max,
54272
+ step: 1,
54273
+ value: Number.isFinite(draft) ? draft : "",
54274
+ disabled: !online,
54275
+ "aria-label": t("uiLibrary.timedRun.duration", "Run for"),
54276
+ className: "w-20 tabular-nums",
54277
+ onChange: (event) => setDraft(event.target.valueAsNumber)
54278
+ }),
54279
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54280
+ className: "text-xs text-foreground-muted",
54281
+ children: unitLabel
54282
+ }),
54283
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
54284
+ size: "sm",
54285
+ disabled: !online || !valid,
54286
+ onClick: run,
54287
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Timer, {
54288
+ className: "h-4 w-4",
54289
+ "aria-hidden": true
54290
+ }), t("uiLibrary.timedRun.run", "Run")]
54291
+ }),
54292
+ armed && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54293
+ className: "text-xs font-medium tabular-nums text-foreground",
54294
+ "data-testid": "timed-run-countdown",
54295
+ children: t("uiLibrary.timedRun.remaining", "{{time}} left", { time: formatCountdown(activeUntil - now) })
54296
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
54297
+ size: "sm",
54298
+ variant: "outline",
54299
+ disabled: !online,
54300
+ "aria-label": t("uiLibrary.timedRun.cancel", "Cancel the timer"),
54301
+ onClick: () => void cap.cancel({}),
54302
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TimerOff, {
54303
+ className: "h-4 w-4",
54304
+ "aria-hidden": true
54305
+ })
54306
+ })] })
54307
+ ]
54308
+ }),
54309
+ !valid && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54310
+ className: "text-xs text-danger",
54311
+ children: t("uiLibrary.timedRun.range", "Enter 1 to {{max}} {{unit}}", {
54312
+ max,
54313
+ unit: unitLabel
54314
+ })
54315
+ }),
54316
+ refusal !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
54317
+ className: "text-xs text-danger",
54318
+ role: "alert",
54319
+ 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.")
54320
+ })
54321
+ ]
54322
+ });
54323
+ }
54324
+ //#endregion
54147
54325
  //#region src/composites/device-controls/pet-fountain-panel.tsx
54148
54326
  /**
54149
54327
  * Pet-fountain controls for a `DeviceType.PetFountain` device.
@@ -54172,6 +54350,7 @@ var KEYFRAMES$2 = `
54172
54350
  .pfn-ripple, .pfn-bubble { animation: none !important; opacity: 0; }
54173
54351
  }
54174
54352
  `;
54353
+ var PUMP_LIMIT = _camstack_types.TIMED_RUN_LIMITS[_camstack_types.DeviceType.PetFountain];
54175
54354
  var LITRES_TO_ML = 1e3;
54176
54355
  var SECONDS_PER_MINUTE = 60;
54177
54356
  var MAX_PUMP_MINUTES = 240;
@@ -54336,6 +54515,11 @@ function PetFountainPanel({ trpc, deviceId, layout, optimistic }) {
54336
54515
  ]
54337
54516
  })
54338
54517
  }),
54518
+ PUMP_LIMIT !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TimedRunControl, {
54519
+ trpc,
54520
+ deviceId,
54521
+ limit: PUMP_LIMIT
54522
+ }),
54339
54523
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
54340
54524
  variant: "outline",
54341
54525
  size: "sm",
@@ -54560,795 +54744,215 @@ function PetFountainHeroCard({ trpc, deviceId, features, optimistic }) {
54560
54744
  });
54561
54745
  }
54562
54746
  //#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
- }
54747
+ //#region src/composites/device-controls/ac-panel.tsx
54748
+ /** Modes that mean the device can cool, dry, or move air — an air conditioner. */
54749
+ var COOLING_MODES = new Set([
54750
+ "cool",
54751
+ "heat_cool",
54752
+ "dry",
54753
+ "fan_only"
54754
+ ]);
54755
+ /** A heater offers heat (and maybe auto/off) and none of the cooling modes. */
54756
+ function climateOffersCooling(modes) {
54757
+ return modes.some((mode) => COOLING_MODES.has(mode));
54622
54758
  }
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);
54759
+ /** Setpoint track bounds. Absent when the integration did not report both ends. */
54760
+ function climateSetpointBounds(status) {
54761
+ const min = status.minTemp;
54762
+ const max = status.maxTemp;
54763
+ if (typeof min !== "number" || typeof max !== "number") return null;
54764
+ if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return null;
54765
+ return {
54766
+ min,
54767
+ max
54768
+ };
54769
+ }
54770
+ /** Map a 0–1 drag fraction onto the setpoint, snapped to `tempStep`. */
54771
+ function climateTargetFromFraction(fraction, bounds, tempStep) {
54772
+ const step = resolveClimateTempStep(tempStep);
54773
+ const clamped = Math.min(1, Math.max(0, fraction));
54774
+ const raw = bounds.min + clamped * (bounds.max - bounds.min);
54775
+ const steps = Math.round((raw - bounds.min) / step);
54776
+ const snapped = bounds.min + steps * step;
54777
+ const rounded = Math.round(snapped * 1e3) / 1e3;
54778
+ return Math.min(bounds.max, Math.max(bounds.min, rounded));
54779
+ }
54780
+ /** AC-appropriate short mode labels. Exhaustive over HvacMode. */
54781
+ function modeLabel(t, mode) {
54782
+ switch (mode) {
54783
+ case "cool": return t("uiLibrary.acPanel.modeCool", "Cool");
54784
+ case "heat": return t("uiLibrary.acPanel.modeHeat", "Heat");
54785
+ case "auto": return t("uiLibrary.acPanel.modeAuto", "Auto");
54786
+ case "heat_cool": return t("uiLibrary.acPanel.modeHeatCool", "Heat·Cool");
54787
+ case "fan_only": return t("uiLibrary.acPanel.modeFan", "Fan");
54788
+ case "dry": return t("uiLibrary.acPanel.modeDry", "Dry");
54789
+ case "off": return t("uiLibrary.acPanel.modeOff", "Off");
54635
54790
  }
54636
54791
  }
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");
54792
+ /** Per-mode icon (lucide). Exhaustive over HvacMode. */
54793
+ var MODE_ICON = {
54794
+ cool: Snowflake,
54795
+ heat: Flame,
54796
+ auto: Sparkles,
54797
+ heat_cool: Thermometer,
54798
+ fan_only: Fan,
54799
+ dry: Droplets,
54800
+ off: Power
54801
+ };
54802
+ /** Mode accent hex — drives the airflow particles, the glow, and the target
54803
+ * value colour. Mirrors the thermostat gauge palette. Exhaustive. */
54804
+ var MODE_ACCENT = {
54805
+ heat: "#f59e0b",
54806
+ cool: "#60a5fa",
54807
+ auto: "#c084fc",
54808
+ heat_cool: "#e879f9",
54809
+ fan_only: "#94a3b8",
54810
+ dry: "#22d3ee",
54811
+ off: "#a1a1aa"
54812
+ };
54813
+ /** Title-case a snake/lower token for a selector option ('medium_low' → 'Medium
54814
+ * low', 'turbo' → 'Turbo'). Shared by the fan + preset selectors. */
54815
+ function fanLabel(token) {
54816
+ const spaced = token.replace(/_/g, " ");
54817
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
54818
+ }
54819
+ /** Preset → icon. Unknown presets fall back to a neutral sparkle. */
54820
+ function presetIcon(preset) {
54821
+ switch (preset) {
54822
+ case "turbo": return Zap;
54823
+ case "quiet": return Feather;
54824
+ case "sleep": return Moon;
54825
+ case "eco": return Leaf;
54826
+ case "8c_heat": return Snowflake;
54827
+ case "none": return Ban;
54828
+ default: return Sparkles;
54829
+ }
54640
54830
  }
54641
54831
  /**
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.
54832
+ * Fan level → airflow animation period. Higher speed ⇒ shorter period. Unknown
54833
+ * strings fall back to a medium pace. Turbo (a preset) shortens it further.
54644
54834
  */
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));
54835
+ function airflowPeriodSec(fanMode) {
54836
+ switch (fanMode) {
54837
+ case "high": return 1;
54838
+ case "medium_high": return 1.3;
54839
+ case "medium": return 1.6;
54840
+ case "medium_low": return 1.9;
54841
+ case "low": return 2.2;
54842
+ default: return 1.8;
54843
+ }
54652
54844
  }
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
- });
54845
+ var KEYFRAMES$1 = `
54846
+ @keyframes acv-fall { 0%{transform:translateY(-1.75rem);opacity:0} 18%{opacity:.7} 82%{opacity:.7} 100%{transform:translateY(7.5rem);opacity:0} }
54847
+ @keyframes acv-rise { 0%{transform:translateY(7.5rem);opacity:0} 18%{opacity:.7} 82%{opacity:.7} 100%{transform:translateY(-1.75rem);opacity:0} }
54848
+ @keyframes acv-sway { 0%,100%{transform:translateX(-9%)} 50%{transform:translateX(9%)} }
54849
+ @keyframes acv-swing-h { 0%,100%{transform:translateX(-22%)} 50%{transform:translateX(22%)} }
54850
+ @keyframes acv-swing-v { 0%,100%{transform:translateY(-22%)} 50%{transform:translateY(22%)} }
54851
+ @keyframes acv-spin { to { transform:rotate(360deg) } }
54852
+ @keyframes acv-glow { 0%,100%{opacity:.35;transform:scale(1)} 50%{opacity:.6;transform:scale(1.06)} }
54853
+ @media (prefers-reduced-motion: reduce) {
54854
+ .acv-streak, .acv-fan, .acv-sway-wrap, .acv-glow, .acv-swing { animation: none !important; }
54664
54855
  }
54665
- function useDishwasher(trpc, deviceId) {
54666
- return useDeviceCapability({
54856
+ `;
54857
+ function AcPanel({ trpc, deviceId, layout, optimistic }) {
54858
+ const t = useUiText();
54859
+ const { cap, slice, available } = useDeviceCapability({
54667
54860
  trpc,
54668
54861
  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
- })]
54862
+ cap: (d) => d.climateControl,
54863
+ slice: (d) => d.state.climateControl
54699
54864
  });
54700
- }
54701
- function DishwasherPanel({ trpc, deviceId, layout }) {
54702
- const t = useUiText();
54703
- const { cap, slice, available } = useDishwasher(trpc, deviceId);
54865
+ const { display, applyOptimistic } = useOptimisticSlice(slice, { enabled: optimistic ?? true });
54704
54866
  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
- };
54867
+ if (display === void 0) return null;
54868
+ const busy = !available;
54718
54869
  if (layout === "compact") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54719
- className: "inline-flex items-center gap-1.5 max-w-full",
54870
+ "data-mode": display.mode,
54871
+ className: "inline-flex items-center gap-1",
54720
54872
  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
54873
  /* @__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" })
54874
+ className: cn("text-xs tabular-nums font-medium", MODE_TEXT_COLOR[display.mode]),
54875
+ children: display.target !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
54876
+ display.target,
54877
+ "°",
54878
+ display.currentTemp !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
54879
+ className: "text-foreground-subtle font-normal",
54880
+ children: [
54881
+ " ",
54882
+ "(",
54883
+ display.currentTemp,
54884
+ "°)"
54885
+ ]
54886
+ })
54887
+ ] }) : display.currentTemp !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
54888
+ className: "text-foreground-subtle",
54889
+ children: [display.currentTemp, "°"]
54890
+ }) : null
54746
54891
  }),
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" })
54892
+ display.target !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
54893
+ type: "button",
54894
+ "aria-label": t("uiLibrary.acPanel.decrease", "Decrease target"),
54895
+ disabled: busy,
54896
+ onClick: (e) => {
54897
+ e.stopPropagation();
54898
+ if (!busy && display.target !== null) {
54899
+ const next = nextClimateTarget(display.target, -1, display);
54900
+ applyOptimistic({ target: next });
54901
+ cap.setTarget({ target: next });
54902
+ }
54903
+ },
54904
+ 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"),
54905
+ children: "−"
54906
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
54907
+ type: "button",
54908
+ "aria-label": t("uiLibrary.acPanel.increase", "Increase target"),
54909
+ disabled: busy,
54910
+ onClick: (e) => {
54911
+ e.stopPropagation();
54912
+ if (!busy && display.target !== null) {
54913
+ const next = nextClimateTarget(display.target, 1, display);
54914
+ applyOptimistic({ target: next });
54915
+ cap.setTarget({ target: next });
54916
+ }
54917
+ },
54918
+ 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"),
54919
+ children: "+"
54920
+ })] }),
54921
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
54922
+ "aria-label": t("uiLibrary.acPanel.mode", "Mode"),
54923
+ value: display.mode,
54924
+ disabled: busy,
54925
+ onChange: (e) => {
54926
+ e.stopPropagation();
54927
+ const mode = display.availableModes.find((m) => m === e.target.value);
54928
+ if (!busy && mode) {
54929
+ applyOptimistic({ mode });
54930
+ cap.setMode({ mode });
54931
+ }
54932
+ },
54933
+ className: cn("bg-transparent px-0.5 text-xs text-foreground-subtle hover:text-foreground cursor-pointer", busy && "opacity-40 cursor-not-allowed"),
54934
+ children: display.availableModes.map((m) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
54935
+ value: m,
54936
+ children: modeLabel(t, m)
54937
+ }, m))
54753
54938
  })
54754
54939
  ]
54755
54940
  });
54756
- const programs = display.availablePrograms;
54941
+ const accent = MODE_ACCENT[display.mode];
54942
+ const on = display.mode !== "off";
54943
+ const heatingOnly = !climateOffersCooling(display.availableModes);
54944
+ const setpointBounds = climateSetpointBounds(display);
54945
+ const CurrentPresetIcon = presetIcon(display.preset);
54946
+ const boost = display.preset === "turbo";
54947
+ const period = Math.max(.55, airflowPeriodSec(display.fanMode) * (boost ? .6 : 1));
54948
+ const showVSwing = display.swingVertical !== null;
54949
+ const showHSwing = display.swingHorizontal !== null;
54950
+ const swaying = on && (display.swingVertical === true || display.swingHorizontal === true);
54757
54951
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
54758
- className: "flex flex-col gap-3 rounded-lg border border-border bg-surface p-4",
54952
+ "data-mode": display.mode,
54953
+ 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
54954
  children: [
54760
- /* @__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",
54887
- 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 })
54894
- }),
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 })
54901
- }),
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 })
54908
- }),
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 })
54915
- }),
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 })
54922
- }),
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 })
54929
- })
54930
- ]
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
- }),
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", "—")
54963
- })
54964
- ]
54965
- });
54966
- }
54967
- function DishwasherInlineControl(props) {
54968
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherPanel, {
54969
- ...props,
54970
- layout: "compact"
54971
- });
54972
- }
54973
- function DishwasherHeroCard(props) {
54974
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherPanel, {
54975
- ...props,
54976
- layout: "full"
54977
- });
54978
- }
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; }
55002
- }
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`;
55007
- }
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"
55013
- };
55014
- if (running === false) return {
55015
- label: t("uiLibrary.appliance.idle", "Idle"),
55016
- tone: "text-foreground"
55017
- };
55018
- return {
55019
- label: t("uiLibrary.appliance.unknown", "Unknown"),
55020
- tone: "text-foreground-subtle"
55021
- };
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)
55033
- };
55034
- }
55035
- /** The drum: a ring with a few items in it, turning only while a cycle runs. */
55036
- function Drum({ running }) {
55037
- 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
- ]
55050
- })
55051
- });
55052
- }
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",
55062
- 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
- ]
55095
- });
55096
- }
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)
55110
- })]
55111
- });
55112
- }
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 }) {
55119
- const t = useUiText();
55120
- const { cap, slice } = useDeviceCapability({
55121
- trpc,
55122
- deviceId,
55123
- cap: (d) => d.heatingValve,
55124
- slice: (d) => d.state.heatingValve
55125
- });
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
55133
- });
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
- })]
55140
- });
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;
55161
- return {
55162
- min,
55163
- max
55164
- };
55165
- }
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
- }
55187
- }
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);
55214
- }
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;
55225
- }
55226
- }
55227
- /**
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.
55230
- */
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
- }
55240
- }
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; }
55251
- }
55252
- `;
55253
- function AcPanel({ trpc, deviceId, layout, optimistic }) {
55254
- const t = useUiText();
55255
- const { cap, slice, available } = useDeviceCapability({
55256
- trpc,
55257
- deviceId,
55258
- cap: (d) => d.climateControl,
55259
- slice: (d) => d.state.climateControl
55260
- });
55261
- const { display, applyOptimistic } = useOptimisticSlice(slice, { enabled: optimistic ?? true });
55262
- if (cap === void 0) return null;
55263
- if (display === void 0) return null;
55264
- const busy = !available;
55265
- if (layout === "compact") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55266
- "data-mode": display.mode,
55267
- className: "inline-flex items-center gap-1",
55268
- children: [
55269
- /* @__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
55287
- }),
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))
55334
- })
55335
- ]
55336
- });
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);
55347
- 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]),
55350
- children: [
55351
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: KEYFRAMES }),
54955
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: KEYFRAMES$1 }),
55352
54956
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55353
54957
  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
54958
  children: [
@@ -55490,347 +55094,1080 @@ function AcPanel({ trpc, deviceId, layout, optimistic }) {
55490
55094
  }
55491
55095
  }
55492
55096
  }),
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
- }
55097
+ display.availableFanModes.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompactSelect, {
55098
+ label: t("uiLibrary.acPanel.fan", "Fan"),
55099
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Wind, { className: "h-4 w-4 text-sky-500" }),
55100
+ value: display.fanMode,
55101
+ options: display.availableFanModes.map((f) => ({
55102
+ value: f,
55103
+ label: fanLabel(f)
55104
+ })),
55105
+ disabled: busy,
55106
+ onChange: (v) => {
55107
+ applyOptimistic({ fanMode: v });
55108
+ cap.setFanMode({ fanMode: v });
55109
+ }
55110
+ }),
55111
+ display.availablePresets.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompactSelect, {
55112
+ label: t("uiLibrary.acPanel.preset", "Preset"),
55113
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CurrentPresetIcon, { className: "h-4 w-4 text-emerald-500" }),
55114
+ value: display.preset,
55115
+ options: display.availablePresets.map((p) => ({
55116
+ value: p,
55117
+ label: fanLabel(p)
55118
+ })),
55119
+ disabled: busy,
55120
+ onChange: (v) => {
55121
+ applyOptimistic({ preset: v });
55122
+ cap.setPreset({ preset: v });
55123
+ }
55124
+ })
55125
+ ]
55126
+ }),
55127
+ (showVSwing || showHSwing) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
55128
+ label: t("uiLibrary.acPanel.swing", "Swing"),
55129
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55130
+ className: "grid grid-cols-2 gap-1",
55131
+ children: [showVSwing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SwingToggle, {
55132
+ axis: "vertical",
55133
+ on: display.swingVertical ?? false,
55134
+ busy,
55135
+ onToggle: (next) => {
55136
+ applyOptimistic({ swingVertical: next });
55137
+ cap.setSwingVertical({ on: next });
55138
+ }
55139
+ }), showHSwing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SwingToggle, {
55140
+ axis: "horizontal",
55141
+ on: display.swingHorizontal ?? false,
55142
+ busy,
55143
+ onToggle: (next) => {
55144
+ applyOptimistic({ swingHorizontal: next });
55145
+ cap.setSwingHorizontal({ on: next });
55146
+ }
55147
+ })]
55148
+ })
55149
+ })
55150
+ ]
55151
+ });
55152
+ }
55153
+ /** Labelled control group — a small uppercase header above its control. */
55154
+ function Field({ label, children }) {
55155
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55156
+ className: "relative z-10 flex flex-col gap-1",
55157
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55158
+ className: "text-[10px] font-semibold uppercase tracking-wider text-foreground-subtle",
55159
+ children: label
55160
+ }), children]
55161
+ });
55162
+ }
55163
+ function ModeIcon({ mode, heatingOnly = false, className }) {
55164
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(heatingOnly && mode === "heat" ? Heater : MODE_ICON[mode], {
55165
+ className,
55166
+ "aria-hidden": true
55167
+ });
55168
+ }
55169
+ /** Compact labelled dropdown — a leading icon + native `<select>` + chevron,
55170
+ * used for the Mode / Fan / Preset selectors so all three fit in one row. */
55171
+ function CompactSelect({ label, icon, value, options, disabled, onChange }) {
55172
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55173
+ className: "flex min-w-0 flex-col gap-1",
55174
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55175
+ className: "text-[10px] font-semibold uppercase tracking-wider text-foreground-subtle",
55176
+ children: label
55177
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55178
+ 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"),
55179
+ children: [
55180
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55181
+ className: "flex shrink-0",
55182
+ children: icon
55183
+ }),
55184
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
55185
+ "aria-label": label,
55186
+ value,
55187
+ disabled,
55188
+ onChange: (e) => {
55189
+ e.stopPropagation();
55190
+ if (!disabled) onChange(e.target.value);
55191
+ },
55192
+ className: "min-w-0 flex-1 cursor-pointer appearance-none truncate bg-transparent text-xs font-medium text-foreground outline-none",
55193
+ children: options.map((o) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
55194
+ value: o.value,
55195
+ children: o.label
55196
+ }, o.value))
55197
+ }),
55198
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDown, { className: "h-3 w-3 shrink-0 text-foreground-subtle" })
55199
+ ]
55200
+ })]
55201
+ });
55202
+ }
55203
+ /** Circular ± button for the target. */
55204
+ function RoundStep({ label, symbol, disabled, onClick }) {
55205
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
55206
+ type: "button",
55207
+ "aria-label": label,
55208
+ disabled,
55209
+ onClick,
55210
+ 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"),
55211
+ children: symbol
55212
+ });
55213
+ }
55214
+ /** Setpoint track. Drag previews; the command is sent when the pointer is released. */
55215
+ function RangeTrack({ value, min, max, step, accent, disabled, label, onPreview, onCommit }) {
55216
+ const trackRef = (0, react$1.useRef)(null);
55217
+ const draggingRef = (0, react$1.useRef)(false);
55218
+ const [draft, setDraft] = (0, react$1.useState)(null);
55219
+ const shown = draft ?? value;
55220
+ const pct = `${Math.min(1, Math.max(0, (shown - min) / (max - min))) * 100}%`;
55221
+ const tempAt = (clientX) => {
55222
+ const el = trackRef.current;
55223
+ if (el === null) return value;
55224
+ const rect = el.getBoundingClientRect();
55225
+ if (rect.width <= 0) return value;
55226
+ return climateTargetFromFraction((clientX - rect.left) / rect.width, {
55227
+ min,
55228
+ max
55229
+ }, step);
55230
+ };
55231
+ const onPointerDown = (event) => {
55232
+ if (disabled) return;
55233
+ event.stopPropagation();
55234
+ event.preventDefault();
55235
+ draggingRef.current = true;
55236
+ if (typeof event.currentTarget.setPointerCapture === "function") event.currentTarget.setPointerCapture(event.pointerId);
55237
+ const next = tempAt(event.clientX);
55238
+ setDraft(next);
55239
+ onPreview(next);
55240
+ };
55241
+ const onPointerMove = (event) => {
55242
+ if (disabled || !draggingRef.current) return;
55243
+ event.stopPropagation();
55244
+ const next = tempAt(event.clientX);
55245
+ setDraft(next);
55246
+ onPreview(next);
55247
+ };
55248
+ const endDrag = (event) => {
55249
+ if (!draggingRef.current) return;
55250
+ event.stopPropagation();
55251
+ draggingRef.current = false;
55252
+ const next = tempAt(event.clientX);
55253
+ setDraft(null);
55254
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId) === true) event.currentTarget.releasePointerCapture(event.pointerId);
55255
+ onCommit(next);
55256
+ };
55257
+ const onKeyDown = (event) => {
55258
+ if (disabled) return;
55259
+ const current = draft ?? value;
55260
+ if (event.key === "ArrowRight" || event.key === "ArrowUp") {
55261
+ event.preventDefault();
55262
+ onCommit(nextClimateTarget(current, 1, {
55263
+ tempStep: step,
55264
+ minTemp: min,
55265
+ maxTemp: max
55266
+ }));
55267
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
55268
+ event.preventDefault();
55269
+ onCommit(nextClimateTarget(current, -1, {
55270
+ tempStep: step,
55271
+ minTemp: min,
55272
+ maxTemp: max
55273
+ }));
55274
+ } else if (event.key === "Home") {
55275
+ event.preventDefault();
55276
+ onCommit(min);
55277
+ } else if (event.key === "End") {
55278
+ event.preventDefault();
55279
+ onCommit(max);
55280
+ }
55281
+ };
55282
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55283
+ ref: trackRef,
55284
+ role: "slider",
55285
+ "aria-label": label,
55286
+ "aria-valuemin": min,
55287
+ "aria-valuemax": max,
55288
+ "aria-valuenow": shown,
55289
+ "aria-disabled": disabled,
55290
+ tabIndex: disabled ? -1 : 0,
55291
+ onPointerDown,
55292
+ onPointerMove,
55293
+ onPointerUp: endDrag,
55294
+ onPointerCancel: endDrag,
55295
+ onKeyDown,
55296
+ className: cn("mt-1 w-full touch-none select-none py-2", disabled ? "cursor-not-allowed opacity-40" : "cursor-ew-resize"),
55297
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55298
+ className: "relative h-1.5 w-full rounded-full bg-surface-subtle",
55299
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55300
+ className: "absolute inset-y-0 left-0 rounded-full",
55301
+ style: {
55302
+ width: pct,
55303
+ background: accent
55304
+ }
55305
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55306
+ 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",
55307
+ style: {
55308
+ left: pct,
55309
+ background: accent
55310
+ }
55311
+ })]
55312
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55313
+ className: "mt-0.5 flex justify-between text-[9px] tabular-nums text-foreground-subtle",
55314
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [min, "°"] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [max, "°"] })]
55315
+ })]
55316
+ });
55317
+ }
55318
+ /**
55319
+ * Animated centerpiece. `fan_only` renders a spinning fan; `off` a dim static
55320
+ * vent; every active mode renders drifting particles that FALL (cool/dry/auto)
55321
+ * or RISE (heat), with a horizontal sweep overlaid when a swing axis is active.
55322
+ */
55323
+ function AirflowViz({ mode, accent, periodSec, swaying, forceRising = false }) {
55324
+ const columns = [
55325
+ 0,
55326
+ 1,
55327
+ 2,
55328
+ 3,
55329
+ 4,
55330
+ 5
55331
+ ];
55332
+ const rising = forceRising || mode === "heat";
55333
+ const isDroplet = mode === "dry";
55334
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55335
+ "aria-hidden": true,
55336
+ className: "absolute inset-0 overflow-hidden",
55337
+ children: mode === "off" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55338
+ className: "flex h-full items-center justify-center gap-2 opacity-30",
55339
+ children: columns.map((i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "h-8 w-1 rounded-full bg-foreground-subtle opacity-40" }, i))
55340
+ }) : mode === "fan_only" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55341
+ className: "flex h-full items-center justify-center",
55342
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FanBlades, {
55343
+ color: accent,
55344
+ periodSec: Math.max(.5, periodSec * .7),
55345
+ size: 56
55346
+ })
55347
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55348
+ "data-testid": forceRising ? "climate-heat-rise" : void 0,
55349
+ className: cn("acv-sway-wrap absolute inset-0", swaying && "acv-sway"),
55350
+ style: swaying ? { animation: "acv-sway 2.6s ease-in-out infinite" } : void 0,
55351
+ children: columns.map((i) => {
55352
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55353
+ className: "acv-streak absolute rounded-full",
55354
+ style: {
55355
+ left: `${8 + i * 15}%`,
55356
+ width: isDroplet ? "0.4rem" : "0.3rem",
55357
+ height: isDroplet ? "0.4rem" : "1.9rem",
55358
+ background: accent,
55359
+ boxShadow: `0 0 6px ${accent}`,
55360
+ animationName: rising ? "acv-rise" : "acv-fall",
55361
+ animationTimingFunction: "linear",
55362
+ animationIterationCount: "infinite",
55363
+ animationDuration: `${periodSec}s`,
55364
+ animationDelay: `${i % 3 * (periodSec / 3) + i * .05}s`
55365
+ }
55366
+ }, i);
55367
+ })
55368
+ })
55369
+ });
55370
+ }
55371
+ /** Still radiator fins — the off state of a heating-only climate. */
55372
+ function RadiatorFins() {
55373
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55374
+ "data-testid": "climate-radiator",
55375
+ "aria-hidden": true,
55376
+ className: "flex h-full items-end justify-center gap-1.5 px-8 pb-3 opacity-40",
55377
+ children: [
55378
+ 0,
55379
+ 1,
55380
+ 2,
55381
+ 3,
55382
+ 4,
55383
+ 5,
55384
+ 6
55385
+ ].map((i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "h-10 w-1.5 rounded-t-full bg-foreground-subtle" }, i))
55386
+ });
55387
+ }
55388
+ /** Big spinning fan for `fan_only` mode. */
55389
+ function FanBlades({ color, periodSec, size }) {
55390
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
55391
+ width: size,
55392
+ height: size,
55393
+ viewBox: "0 0 100 100",
55394
+ className: "acv-fan",
55395
+ style: {
55396
+ animation: `acv-spin ${periodSec}s linear infinite`,
55397
+ transformOrigin: "50% 50%"
55398
+ },
55399
+ "aria-hidden": true,
55400
+ children: [[
55401
+ 0,
55402
+ 120,
55403
+ 240
55404
+ ].map((deg) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
55405
+ d: "M50 50 C 58 30, 78 26, 84 12 C 66 20, 54 34, 50 50 Z",
55406
+ fill: color,
55407
+ opacity: .85,
55408
+ transform: `rotate(${deg} 50 50)`
55409
+ }, deg)), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
55410
+ cx: "50",
55411
+ cy: "50",
55412
+ r: "7",
55413
+ fill: color
55414
+ })]
55415
+ });
55416
+ }
55417
+ /** Swing axis toggle — a card with a MoveVertical/Horizontal glyph that
55418
+ * oscillates along its axis when on. On/off is non-destructive → plain tap. */
55419
+ function SwingToggle({ axis, on, busy, onToggle }) {
55420
+ const t = useUiText();
55421
+ const label = axis === "vertical" ? t("uiLibrary.acPanel.vertical", "Vertical") : t("uiLibrary.acPanel.horizontal", "Horizontal");
55422
+ const Icon = axis === "vertical" ? MoveVertical : MoveHorizontal;
55423
+ const anim = axis === "vertical" ? "acv-swing-v" : "acv-swing-h";
55424
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
55425
+ type: "button",
55426
+ "aria-label": t("uiLibrary.acPanel.swingAxis", "{{axis}} swing", { axis: label }),
55427
+ "aria-pressed": on,
55428
+ disabled: busy,
55429
+ onClick: () => {
55430
+ if (!busy) onToggle(!on);
55431
+ },
55432
+ 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"),
55433
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Icon, {
55434
+ className: "acv-swing h-4 w-4",
55435
+ "aria-hidden": true,
55436
+ style: on ? { animation: `${anim} 1.4s ease-in-out infinite` } : void 0
55437
+ }), label]
55438
+ });
55439
+ }
55440
+ //#endregion
55441
+ //#region src/composites/device-controls/heater-control.tsx
55442
+ /**
55443
+ * Heater controls for a `DeviceType.Heater` device (a dumb plug-in stove).
55444
+ *
55445
+ * A heater is a switch that heats while it is on, and the hub can switch it off
55446
+ * again after a chosen time (`timed-run`, D993):
55447
+ * - HeaterInlineControl (device-list row): the on/off switch and a one-tap
55448
+ * "Run N min".
55449
+ * - HeaterHeroCard (device-detail hero): the glowing heat backdrop (the same
55450
+ * rising streaks a heating air conditioner draws) while it is on, the
55451
+ * on/off switch, the power draw when the plug measures one, and the timed
55452
+ * run (number, unit, Run, countdown, Cancel).
55453
+ *
55454
+ * Every reading may be unknown (`null`) and is drawn as a dash, never as zero.
55455
+ */
55456
+ var HEATER_LIMIT = _camstack_types.TIMED_RUN_LIMITS[_camstack_types.DeviceType.Heater];
55457
+ var QUICK_RUN_MINUTES = 30;
55458
+ var HEAT_ACCENT = "var(--color-warning)";
55459
+ var HEAT_PERIOD_SEC = 1.8;
55460
+ function useHeater({ trpc, deviceId, optimistic }) {
55461
+ const { cap, slice, available } = useDeviceCapability({
55462
+ trpc,
55463
+ deviceId,
55464
+ cap: (d) => d.switch,
55465
+ slice: (d) => d.state.switch
55466
+ });
55467
+ const running = useDeviceCapability({
55468
+ trpc,
55469
+ deviceId,
55470
+ cap: (d) => d.runningState,
55471
+ slice: (d) => d.state.runningState
55472
+ });
55473
+ const power = useDeviceCapability({
55474
+ trpc,
55475
+ deviceId,
55476
+ cap: (d) => d.powerMeter,
55477
+ slice: (d) => d.state.powerMeter
55478
+ });
55479
+ const timer = useDeviceCapability({
55480
+ trpc,
55481
+ deviceId,
55482
+ cap: (d) => d.timedRun,
55483
+ slice: (d) => d.state.timedRun
55484
+ });
55485
+ const { display, applyOptimistic } = useOptimisticSlice(slice, { enabled: optimistic ?? true });
55486
+ return {
55487
+ cap,
55488
+ display,
55489
+ available,
55490
+ applyOptimistic,
55491
+ running: running.slice,
55492
+ power: power.slice,
55493
+ timer
55494
+ };
55495
+ }
55496
+ function HeaterInlineControl(props) {
55497
+ const t = useUiText();
55498
+ const { cap, display, available, applyOptimistic, timer } = useHeater(props);
55499
+ if (cap === void 0 || display === void 0) return null;
55500
+ const on = display.on === true;
55501
+ const quickMinutes = HEATER_LIMIT === void 0 ? QUICK_RUN_MINUTES : Math.min(QUICK_RUN_MINUTES, maxInUnit(HEATER_LIMIT));
55502
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55503
+ className: "inline-flex items-center gap-2",
55504
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55505
+ onClick: (e) => e.stopPropagation(),
55506
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Switch, {
55507
+ checked: on,
55508
+ disabled: !available,
55509
+ "aria-label": t("uiLibrary.heater.power", "Heater"),
55510
+ onCheckedChange: (next) => {
55511
+ applyOptimistic({ on: next });
55512
+ cap.setState({ on: next });
55513
+ }
55514
+ })
55515
+ }), timer.cap !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55516
+ onClick: (e) => e.stopPropagation(),
55517
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Button, {
55518
+ size: "sm",
55519
+ variant: "outline",
55520
+ disabled: !timer.online,
55521
+ onClick: () => void timer.cap?.runFor({
55522
+ duration: quickMinutes,
55523
+ unit: "min"
55524
+ }),
55525
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Timer, {
55526
+ className: "h-3.5 w-3.5",
55527
+ "aria-hidden": true
55528
+ }), t("uiLibrary.heater.quickRun", "Run {{minutes}} min", { minutes: quickMinutes })]
55529
+ })
55530
+ })]
55531
+ });
55532
+ }
55533
+ function HeaterHeroCard(props) {
55534
+ const t = useUiText();
55535
+ const { cap, display, available, applyOptimistic, running, power, timer } = useHeater(props);
55536
+ if (cap === void 0 || display === void 0) return null;
55537
+ const on = display.on === true;
55538
+ const heating = running?.running ?? on;
55539
+ const watts = power?.power ?? null;
55540
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55541
+ className: "relative flex flex-col items-stretch gap-3 overflow-hidden rounded-lg border border-border bg-surface p-4",
55542
+ children: [
55543
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: KEYFRAMES$1 }),
55544
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55545
+ className: "relative flex h-32 items-center justify-center overflow-hidden rounded-lg border border-border-subtle bg-background-elevated",
55546
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55547
+ className: cn("pointer-events-none absolute inset-0", heating ? "opacity-60" : "opacity-20"),
55548
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AirflowViz, {
55549
+ mode: heating ? "heat" : "off",
55550
+ accent: HEAT_ACCENT,
55551
+ periodSec: HEAT_PERIOD_SEC,
55552
+ swaying: false,
55553
+ forceRising: true
55554
+ })
55555
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55556
+ className: "relative z-10 flex flex-col items-center gap-1",
55557
+ children: [
55558
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Flame, {
55559
+ className: cn("h-8 w-8", heating ? "text-warning" : "text-foreground-subtle"),
55560
+ "aria-hidden": true
55561
+ }),
55562
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55563
+ className: "text-sm font-medium text-foreground",
55564
+ children: heating ? t("uiLibrary.heater.heating", "Heating") : t("uiLibrary.heater.off", "Off")
55565
+ }),
55566
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55567
+ className: "text-xs tabular-nums text-foreground-muted",
55568
+ "data-testid": "heater-watts",
55569
+ children: watts === null ? "—" : `${Math.round(watts)} W`
55570
+ })
55571
+ ]
55572
+ })]
55573
+ }),
55574
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
55575
+ className: "flex items-center justify-between gap-3 text-sm text-foreground",
55576
+ children: [t("uiLibrary.heater.power", "Heater"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Switch, {
55577
+ checked: on,
55578
+ disabled: !available,
55579
+ onCheckedChange: (next) => {
55580
+ applyOptimistic({ on: next });
55581
+ cap.setState({ on: next });
55582
+ }
55583
+ })]
55584
+ }),
55585
+ timer.cap !== void 0 && HEATER_LIMIT !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TimedRunControl, {
55586
+ trpc: props.trpc,
55587
+ deviceId: props.deviceId,
55588
+ limit: HEATER_LIMIT
55589
+ })
55590
+ ]
55591
+ });
55592
+ }
55593
+ //#endregion
55594
+ //#region src/composites/device-controls/dishwasher-control.tsx
55595
+ /**
55596
+ * Dishwasher controls for a `DeviceType.Dishwasher` device.
55597
+ *
55598
+ * Reads the `dishwasher` cap status slice and renders inline + hero surfaces
55599
+ * mirroring vacuum-control / pet-feeder patterns.
55600
+ *
55601
+ * Salt and rinse aid are not here: they are items of the device's `consumables`
55602
+ * cap, shown by the page's Consumables section like every device's (D915).
55603
+ *
55604
+ * UNKNOWN IS NOT OFF. The slice carries `null` for everything the appliance has not
55605
+ * reported, and this view renders that as "—" / a disabled control, never as an unchecked
55606
+ * box or a zero. It also does not echo a command before the appliance confirms it: the
55607
+ * slice moves when the appliance says so.
55608
+ */
55609
+ /** What the operation state is called to a person, and how it is toned. */
55610
+ function dishwasherOperationMeta(operationState, t = defaultUiText) {
55611
+ switch (operationState) {
55612
+ case "Run": return {
55613
+ label: t("uiLibrary.dishwasher.op.run", "Running"),
55614
+ tone: "text-primary"
55615
+ };
55616
+ case "Pause": return {
55617
+ label: t("uiLibrary.dishwasher.op.pause", "Paused"),
55618
+ tone: "text-warning"
55619
+ };
55620
+ case "Finished": return {
55621
+ label: t("uiLibrary.dishwasher.op.finished", "Finished"),
55622
+ tone: "text-success"
55623
+ };
55624
+ case "Error": return {
55625
+ label: t("uiLibrary.dishwasher.op.error", "Error"),
55626
+ tone: "text-danger"
55627
+ };
55628
+ case "Aborting": return {
55629
+ label: t("uiLibrary.dishwasher.op.aborting", "Stopping"),
55630
+ tone: "text-warning"
55631
+ };
55632
+ case "ActionRequired": return {
55633
+ label: t("uiLibrary.dishwasher.op.actionRequired", "Action required"),
55634
+ tone: "text-warning"
55635
+ };
55636
+ case "DelayedStart": return {
55637
+ label: t("uiLibrary.dishwasher.op.delayedStart", "Delayed start"),
55638
+ tone: "text-primary"
55639
+ };
55640
+ case "Ready": return {
55641
+ label: t("uiLibrary.dishwasher.op.ready", "Ready"),
55642
+ tone: "text-foreground"
55643
+ };
55644
+ case "Inactive": return {
55645
+ label: t("uiLibrary.dishwasher.op.inactive", "Inactive"),
55646
+ tone: "text-foreground-subtle"
55647
+ };
55648
+ case null: return {
55649
+ label: t("uiLibrary.dishwasher.unknown", "Unknown"),
55650
+ tone: "text-foreground-subtle"
55651
+ };
55652
+ }
55653
+ }
55654
+ /** The headline for the whole slice: the appliance's state, or why there is none. */
55655
+ function dishwasherHeadline(status, t = defaultUiText) {
55656
+ switch (status.availability) {
55657
+ case "offline": return {
55658
+ label: t("uiLibrary.dishwasher.offline", "Offline"),
55659
+ tone: "text-foreground-subtle"
55660
+ };
55661
+ case "description-missing": return {
55662
+ label: t("uiLibrary.dishwasher.descriptionMissing", "Waiting for the appliance profile"),
55663
+ tone: "text-warning"
55664
+ };
55665
+ case "available": return dishwasherOperationMeta(status.operationState, t);
55666
+ }
55667
+ }
55668
+ /** Last segment of a feature key, spaced out: `Dishcare.Dishwasher.Program.Eco50` -> `Eco50`. */
55669
+ function featureLabel(key) {
55670
+ return (key.split(".").pop() ?? key).replace(/([a-z0-9])([A-Z])/g, "$1 $2");
55671
+ }
55672
+ /**
55673
+ * Salt and rinse aid are consumable items (D915). Their events must not
55674
+ * reappear on the hero — the Consumables section is the only surface.
55675
+ */
55676
+ function isConsumableDishwasherEvent(event) {
55677
+ const short = (event.split(".").pop() ?? event).toLowerCase();
55678
+ return short.includes("salt") || short.includes("rinse");
55679
+ }
55680
+ /** Raised events the hero may name. Consumable events are dropped. */
55681
+ function visibleDishwasherEvents(events) {
55682
+ return events.filter((event) => !isConsumableDishwasherEvent(event));
55683
+ }
55684
+ /** Whole seconds as a short duration: `1 h 24 min`, `42 min`, `30 s`. */
55685
+ function formatDuration(seconds, t = defaultUiText) {
55686
+ if (seconds < 60) return t("uiLibrary.dishwasher.durationSeconds", "{{n}} s", { n: seconds });
55687
+ const minutes = Math.round(seconds / 60);
55688
+ if (minutes < 60) return t("uiLibrary.dishwasher.durationMinutes", "{{n}} min", { n: minutes });
55689
+ const hours = Math.floor(minutes / 60);
55690
+ const rest = minutes % 60;
55691
+ return rest === 0 ? t("uiLibrary.dishwasher.durationHours", "{{n}} h", { n: hours }) : t("uiLibrary.dishwasher.durationHoursMinutes", "{{h}} h {{m}} min", {
55692
+ h: hours,
55693
+ m: rest
55694
+ });
55695
+ }
55696
+ function useDishwasher(trpc, deviceId) {
55697
+ return useDeviceCapability({
55698
+ trpc,
55699
+ deviceId,
55700
+ cap: (d) => d.dishwasher,
55701
+ slice: (d) => d.state.dishwasher
55702
+ });
55703
+ }
55704
+ function DishwasherIconButton({ label, busy, active, onClick, children }) {
55705
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
55706
+ type: "button",
55707
+ "aria-label": label,
55708
+ title: label,
55709
+ disabled: busy,
55710
+ onClick: (e) => {
55711
+ e.stopPropagation();
55712
+ onClick();
55713
+ },
55714
+ 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"),
55715
+ children
55716
+ });
55717
+ }
55718
+ function DishwasherGraphic({ running, progressPercent }) {
55719
+ const pct = progressPercent != null ? Math.max(0, Math.min(100, progressPercent)) : running ? 40 : 0;
55720
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55721
+ "aria-hidden": "true",
55722
+ 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",
55723
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55724
+ className: cn("absolute inset-x-0 bottom-0 bg-primary/25 transition-[height] duration-500", running && "animate-pulse"),
55725
+ style: { height: `${pct}%` }
55726
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55727
+ className: "absolute inset-0 flex items-center justify-center text-foreground-subtle",
55728
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Waves, { className: "h-10 w-10 opacity-70" })
55729
+ })]
55730
+ });
55731
+ }
55732
+ function DishwasherPanel({ trpc, deviceId, layout }) {
55733
+ const t = useUiText();
55734
+ const { cap, slice, available } = useDishwasher(trpc, deviceId);
55735
+ if (cap === void 0) return null;
55736
+ if (slice === void 0) return null;
55737
+ const display = slice;
55738
+ const link = display.availability === "available";
55739
+ const busy = !available || !link;
55740
+ const optionTogglesLocked = display.childLock === true;
55741
+ const meta = dishwasherHeadline(display, t);
55742
+ const running = display.operationState === "Run";
55743
+ const paused = display.operationState === "Pause";
55744
+ const startable = display.remoteStartAllowed === true && display.selectedProgram !== null;
55745
+ const press = (fn) => {
55746
+ if (busy) return;
55747
+ fn();
55748
+ };
55749
+ if (layout === "compact") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55750
+ className: "inline-flex items-center gap-1.5 max-w-full",
55751
+ children: [
55752
+ display.doorOpen === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55753
+ className: "inline-flex text-warning shrink-0",
55754
+ title: t("uiLibrary.dishwasher.doorOpen", "Door open"),
55755
+ "aria-label": t("uiLibrary.dishwasher.doorOpen", "Door open"),
55756
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DoorOpen, { className: "h-3.5 w-3.5" })
55757
+ }),
55758
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55759
+ className: cn("text-xs font-medium tabular-nums shrink-0", meta.tone),
55760
+ children: meta.label
55761
+ }),
55762
+ display.program != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55763
+ className: "text-xs text-foreground-subtle truncate max-w-[7rem]",
55764
+ title: display.program,
55765
+ children: featureLabel(display.program)
55766
+ }),
55767
+ display.remaining != null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55768
+ className: "inline-flex items-center gap-0.5 text-xs text-foreground-subtle tabular-nums shrink-0",
55769
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Timer, { className: "h-3 w-3" }), formatDuration(display.remaining, t)]
55770
+ }),
55771
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
55772
+ label: t("uiLibrary.dishwasher.start", "Start"),
55773
+ busy: busy || !startable,
55774
+ active: running,
55775
+ onClick: () => press(() => cap.start({})),
55776
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Play, { className: "h-3 w-3" })
55777
+ }),
55778
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
55779
+ label: paused ? t("uiLibrary.dishwasher.resume", "Resume") : t("uiLibrary.dishwasher.pause", "Pause"),
55780
+ busy: busy || !(running || paused),
55781
+ active: paused,
55782
+ onClick: () => press(() => paused ? cap.resume({}) : cap.pause({})),
55783
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pause, { className: "h-3 w-3" })
55784
+ })
55785
+ ]
55786
+ });
55787
+ const programs = display.availablePrograms;
55788
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55789
+ className: "flex flex-col gap-3 rounded-lg border border-border bg-surface p-4",
55790
+ children: [
55791
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55792
+ className: "flex items-start gap-4",
55793
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherGraphic, {
55794
+ running,
55795
+ progressPercent: display.progress
55796
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55797
+ className: "flex min-w-0 flex-1 flex-col gap-2",
55798
+ children: [
55799
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55800
+ className: "flex flex-wrap items-center gap-2",
55801
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55802
+ className: cn("text-3xl font-semibold", meta.tone),
55803
+ children: meta.label
55804
+ }), display.powerOn === false && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55805
+ className: "text-xs font-medium text-foreground-subtle",
55806
+ children: t("uiLibrary.dishwasher.powerOff", "Power off")
55807
+ })]
55808
+ }),
55809
+ display.program != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55810
+ className: "text-sm text-foreground-subtle",
55811
+ title: display.program,
55812
+ children: featureLabel(display.program)
55813
+ }),
55814
+ display.remaining != null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55815
+ className: "inline-flex items-center gap-1 text-sm text-foreground-subtle tabular-nums",
55816
+ 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) })]
55817
+ }),
55818
+ display.delayedStart != null && display.delayedStart > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55819
+ className: "text-xs text-foreground-subtle tabular-nums",
55820
+ children: t("uiLibrary.dishwasher.startsIn", "Starts in {{time}}", { time: formatDuration(display.delayedStart, t) })
55821
+ }),
55822
+ display.progress != null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55823
+ className: "flex items-center gap-2",
55824
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55825
+ className: "h-1.5 flex-1 rounded bg-surface-hover overflow-hidden",
55826
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
55827
+ className: "h-full bg-primary transition-[width] duration-500",
55828
+ style: { width: `${Math.max(0, Math.min(100, display.progress))}%` }
55829
+ })
55830
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55831
+ className: "tabular-nums text-xs text-foreground-subtle",
55832
+ children: [Math.round(display.progress), "%"]
55833
+ })]
55834
+ }),
55835
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55836
+ className: "flex flex-wrap items-center gap-x-3 gap-y-1.5",
55837
+ children: [display.doorOpen === true && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
55838
+ className: "inline-flex items-center gap-1 text-xs text-warning",
55839
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DoorOpen, { className: "h-3.5 w-3.5" }), t("uiLibrary.dishwasher.doorOpen", "Door open")]
55840
+ }), visibleDishwasherEvents(display.activeEvents).map((event) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55841
+ className: "text-xs text-warning",
55842
+ title: event,
55843
+ children: featureLabel(event)
55844
+ }, event))]
55845
+ }),
55846
+ display.operationState === "Error" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55847
+ className: "inline-flex items-center gap-1 text-xs text-danger",
55848
+ 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") })]
55849
+ }),
55850
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55851
+ className: "flex flex-wrap items-center gap-2",
55852
+ children: [
55853
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
55854
+ label: t("uiLibrary.dishwasher.start", "Start"),
55855
+ busy: busy || !startable,
55856
+ active: running,
55857
+ onClick: () => press(() => cap.start({})),
55858
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Play, { className: "h-4 w-4" })
55859
+ }),
55860
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
55861
+ label: paused ? t("uiLibrary.dishwasher.resume", "Resume") : t("uiLibrary.dishwasher.pause", "Pause"),
55862
+ busy: busy || !(running || paused),
55863
+ active: paused,
55864
+ onClick: () => press(() => paused ? cap.resume({}) : cap.pause({})),
55865
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pause, { className: "h-4 w-4" })
55866
+ }),
55867
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
55868
+ label: t("uiLibrary.dishwasher.stop", "Stop"),
55869
+ busy: busy || !(running || paused),
55870
+ active: false,
55871
+ onClick: () => press(() => cap.stop({})),
55872
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Square, { className: "h-3.5 w-3.5" })
55873
+ }),
55874
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherIconButton, {
55875
+ label: t("uiLibrary.dishwasher.power", "Power"),
55876
+ busy: busy || display.powerOn === null,
55877
+ active: display.powerOn === true,
55878
+ onClick: () => {
55879
+ const next = display.powerOn !== true;
55880
+ press(() => cap.setPower({ on: next }));
55881
+ },
55882
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Power, { className: "h-4 w-4" })
55883
+ })
55884
+ ]
55885
+ })
55886
+ ]
55887
+ })]
55888
+ }),
55889
+ programs.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
55890
+ className: "flex items-center gap-2 text-xs text-foreground-subtle",
55891
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55892
+ className: "w-16 shrink-0",
55893
+ children: t("uiLibrary.dishwasher.program", "Program")
55894
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
55895
+ "aria-label": t("uiLibrary.dishwasher.program", "Program"),
55896
+ value: display.selectedProgram ?? "",
55897
+ disabled: busy,
55898
+ onChange: (e) => {
55899
+ const program = e.target.value;
55900
+ if (!program) return;
55901
+ cap.setProgram({ program });
55902
+ },
55903
+ onClick: (e) => {
55904
+ e.stopPropagation();
55905
+ },
55906
+ className: "flex-1 rounded border border-border bg-surface px-2 py-1 text-foreground disabled:opacity-50",
55907
+ children: [display.selectedProgram == null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
55908
+ value: "",
55909
+ children: "—"
55910
+ }), programs.map((p) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
55911
+ value: p,
55912
+ children: featureLabel(p)
55913
+ }, p))]
55914
+ })]
55915
+ }),
55916
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
55917
+ className: "flex flex-wrap gap-4 text-xs",
55918
+ children: [
55919
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
55920
+ label: t("uiLibrary.dishwasher.halfLoad", "Half load"),
55921
+ checked: display.halfLoad,
55922
+ shown: link,
55923
+ busy: busy || optionTogglesLocked,
55924
+ onChange: (on) => void cap.setHalfLoad({ on })
55925
+ }),
55926
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
55927
+ label: t("uiLibrary.dishwasher.hygienePlus", "Hygiene plus"),
55928
+ checked: display.hygienePlus,
55929
+ shown: link,
55930
+ busy: busy || optionTogglesLocked,
55931
+ onChange: (on) => void cap.setHygienePlus({ on })
55932
+ }),
55933
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
55934
+ label: t("uiLibrary.dishwasher.extraDry", "Extra dry"),
55935
+ checked: display.extraDry,
55936
+ shown: link,
55937
+ busy: busy || optionTogglesLocked,
55938
+ onChange: (on) => void cap.setExtraDry({ on })
55939
+ }),
55940
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
55941
+ label: t("uiLibrary.dishwasher.intensiveZone", "Intensive zone"),
55942
+ checked: display.intensiveZone,
55943
+ shown: link,
55944
+ busy: busy || optionTogglesLocked,
55945
+ onChange: (on) => void cap.setIntensiveZone({ on })
55946
+ }),
55947
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
55948
+ label: t("uiLibrary.dishwasher.varioSpeedPlus", "VarioSpeed+"),
55949
+ checked: display.varioSpeedPlus,
55950
+ shown: link,
55951
+ busy: busy || optionTogglesLocked,
55952
+ onChange: (on) => void cap.setVarioSpeedPlus({ on })
55506
55953
  }),
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
- }
55954
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToggleRow, {
55955
+ label: t("uiLibrary.dishwasher.childLock", "Child lock"),
55956
+ checked: display.childLock,
55957
+ shown: link,
55958
+ busy,
55959
+ onChange: (on) => void cap.setChildLock({ on })
55520
55960
  })
55521
55961
  ]
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
55962
  })
55546
55963
  ]
55547
55964
  });
55548
55965
  }
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]
55966
+ /**
55967
+ * One boolean setting. `checked === null` is "the appliance has not said": on a reachable
55968
+ * appliance that means it does not offer the setting (hidden); on an unreachable one the
55969
+ * setting is shown but disabled and marked unknown — never as an unchecked box.
55970
+ */
55971
+ function ToggleRow({ label, checked, shown, busy, onChange }) {
55972
+ const t = useUiText();
55973
+ if (checked === null && shown) return null;
55974
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
55975
+ className: "inline-flex items-center gap-2 cursor-pointer",
55976
+ children: [
55977
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
55978
+ type: "checkbox",
55979
+ checked: checked === true,
55980
+ "aria-checked": checked === null ? "mixed" : checked,
55981
+ disabled: busy || checked === null,
55982
+ onChange: (e) => {
55983
+ onChange(e.target.checked);
55984
+ },
55985
+ onClick: (e) => {
55986
+ e.stopPropagation();
55987
+ },
55988
+ className: "rounded border-border"
55989
+ }),
55990
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }),
55991
+ checked === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
55992
+ className: "text-foreground-subtle",
55993
+ children: t("uiLibrary.dishwasher.unknownValue", "—")
55994
+ })
55995
+ ]
55557
55996
  });
55558
55997
  }
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
55998
+ function DishwasherInlineControl(props) {
55999
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherPanel, {
56000
+ ...props,
56001
+ layout: "compact"
55563
56002
  });
55564
56003
  }
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
- })]
56004
+ function DishwasherHeroCard(props) {
56005
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DishwasherPanel, {
56006
+ ...props,
56007
+ layout: "full"
55597
56008
  });
55598
56009
  }
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
- });
56010
+ //#endregion
56011
+ //#region src/composites/device-controls/appliance-control.tsx
56012
+ /**
56013
+ * Appliance card for a `DeviceType.Washer` / `DeviceType.Dryer` device.
56014
+ *
56015
+ * These appliances have no program surface: what is worth showing is whether
56016
+ * the cycle is running (the `running-state` cap), for how long, and what the
56017
+ * appliance draws right now (the `power-meter` cap). Both slices are read
56018
+ * straight from the device; neither is required.
56019
+ * - ApplianceInlineControl (device-list row): the running badge round the
56020
+ * drum icon, plus the live watts when there are any.
56021
+ * - ApplianceHeroCard (device-detail hero): the drum (turning only while a
56022
+ * cycle runs), the state, the phase, the dwell and the power.
56023
+ *
56024
+ * UNKNOWN IS NOT OFF (D393). `running: null` is drawn as "Unknown" and the
56025
+ * drum stays still and unbadged; a missing watt reading is a dash, never
56026
+ * "0 W". A slice that has not landed hides its row instead of faking a value.
56027
+ */
56028
+ var KEYFRAMES = `
56029
+ @keyframes apl-tumble { to { transform: rotate(360deg) } }
56030
+ .apl-drum-run { animation: apl-tumble 3s linear infinite; }
56031
+ @media (prefers-reduced-motion: reduce) {
56032
+ .apl-drum-run { animation: none !important; }
55609
56033
  }
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);
56034
+ `;
56035
+ /** Whole watts, or a dash when the appliance has not reported a reading. */
56036
+ function formatWatts(power) {
56037
+ return power === void 0 || !Number.isFinite(power) ? "—" : `${Math.round(power)} W`;
56038
+ }
56039
+ /** The state to a person: running / idle, or why there is none (unknown). */
56040
+ function applianceStateLabel(running, t) {
56041
+ if (running === true) return {
56042
+ label: t("uiLibrary.appliance.running", "Running"),
56043
+ tone: "text-primary"
55636
56044
  };
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);
56045
+ if (running === false) return {
56046
+ label: t("uiLibrary.appliance.idle", "Idle"),
56047
+ tone: "text-foreground"
55643
56048
  };
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);
56049
+ return {
56050
+ label: t("uiLibrary.appliance.unknown", "Unknown"),
56051
+ tone: "text-foreground-subtle"
55652
56052
  };
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
- }
56053
+ }
56054
+ function dwellText(running, since, now, t) {
56055
+ if (running !== true) return null;
56056
+ const { duration } = runningStatusParts(running, since, null, now);
56057
+ if (duration === void 0) return null;
56058
+ return "minutes" in duration ? t("uiLibrary.appliance.forMinutes", "for {{count}} min", { count: duration.minutes }) : t("uiLibrary.appliance.forHours", "for {{count}} h", { count: duration.hours });
56059
+ }
56060
+ function useApplianceState(props) {
56061
+ return {
56062
+ running: useDeviceState(props.trpc, props.deviceId, (s) => s.runningState),
56063
+ power: useDeviceState(props.trpc, props.deviceId, (s) => s.powerMeter)
55677
56064
  };
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
56065
  }
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";
56066
+ /** The drum: a ring with a few items in it, turning only while a cycle runs. */
56067
+ function Drum({ running }) {
55730
56068
  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
- })
56069
+ "aria-hidden": "true",
56070
+ "data-testid": "appliance-drum",
56071
+ "data-running": running === true ? "running" : running === false ? "idle" : "unknown",
56072
+ className: "relative flex h-28 w-28 shrink-0 items-center justify-center rounded-full border-2 border-border bg-surface-hover",
56073
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56074
+ className: cn("relative h-20 w-20 rounded-full border-2", running === true ? "apl-drum-run border-primary" : "border-border"),
56075
+ "data-testid": "appliance-drum-ring",
56076
+ children: [
56077
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "absolute left-2 top-4 h-3 w-3 rounded-full bg-primary/40" }),
56078
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "absolute bottom-3 right-3 h-4 w-4 rounded-full bg-info/40" }),
56079
+ /* @__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" })
56080
+ ]
55764
56081
  })
55765
56082
  });
55766
56083
  }
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",
56084
+ function ApplianceHeroCard(props) {
56085
+ const t = useUiText();
56086
+ const { running, power } = useApplianceState(props);
56087
+ const state = running?.running ?? null;
56088
+ const meta = applianceStateLabel(state, t);
56089
+ const phase = running?.phase?.trim() ?? "";
56090
+ const dwell = running === void 0 ? null : dwellText(state, running.since, Date.now(), t);
56091
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56092
+ className: "flex items-center gap-4 rounded-lg border border-border bg-surface p-4",
55773
56093
  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))
56094
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: KEYFRAMES }),
56095
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Drum, { running: state }),
56096
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56097
+ className: "flex min-w-0 flex-1 flex-col gap-2",
56098
+ children: [
56099
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56100
+ className: "flex flex-wrap items-baseline gap-x-2",
56101
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56102
+ "data-testid": "appliance-state",
56103
+ className: cn("text-3xl font-semibold", meta.tone),
56104
+ children: meta.label
56105
+ }), dwell !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56106
+ className: "text-sm tabular-nums text-foreground-subtle",
56107
+ children: dwell
56108
+ })]
56109
+ }),
56110
+ phase !== "" && state !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56111
+ className: "text-sm text-foreground-subtle",
56112
+ children: phase
56113
+ }),
56114
+ power !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
56115
+ "data-testid": "appliance-power",
56116
+ className: "inline-flex items-center gap-1 text-sm tabular-nums text-foreground-muted",
56117
+ title: power.power === void 0 ? t("uiLibrary.appliance.powerUnknown", "Power unknown") : void 0,
56118
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Zap, {
56119
+ className: "h-3.5 w-3.5",
56120
+ "aria-hidden": true
56121
+ }), formatWatts(power.power)]
56122
+ })
56123
+ ]
56124
+ })
56125
+ ]
55782
56126
  });
55783
56127
  }
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
56128
+ function ApplianceInlineControl(props) {
56129
+ const { running, power } = useApplianceState(props);
56130
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56131
+ className: "inline-flex items-center gap-1.5 max-w-full",
56132
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RunningBadge, {
56133
+ running: running?.running ?? null,
56134
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WashingMachine, {
56135
+ className: "h-4 w-4 text-foreground-muted",
56136
+ "aria-hidden": true
56137
+ })
56138
+ }), power !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56139
+ className: "text-xs tabular-nums text-foreground-subtle",
56140
+ children: formatWatts(power.power)
55810
56141
  })]
55811
56142
  });
55812
56143
  }
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 }) {
56144
+ //#endregion
56145
+ //#region src/composites/device-controls/heating-valve-readout.tsx
56146
+ function formatHeatingValvePosition(position) {
56147
+ return position !== null ? `${position}%` : "—";
56148
+ }
56149
+ function HeatingValveReadout({ trpc, deviceId, layout }) {
55816
56150
  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]
56151
+ const { cap, slice } = useDeviceCapability({
56152
+ trpc,
56153
+ deviceId,
56154
+ cap: (d) => d.heatingValve,
56155
+ slice: (d) => d.state.heatingValve
56156
+ });
56157
+ if (cap === void 0 || slice === void 0) return null;
56158
+ const label = t("uiLibrary.heatingValveReadout.valve", "Valve");
56159
+ const value = formatHeatingValvePosition(slice.position);
56160
+ if (layout === "compact") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56161
+ className: "inline-flex items-center text-xs tabular-nums text-foreground-subtle",
56162
+ "aria-label": `${label} ${value}`,
56163
+ children: value
56164
+ });
56165
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
56166
+ className: "flex items-center gap-2 text-xs tabular-nums text-foreground-subtle",
56167
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
56168
+ className: "font-medium text-foreground",
56169
+ children: value
56170
+ })]
55834
56171
  });
55835
56172
  }
55836
56173
  //#endregion
@@ -57922,6 +58259,10 @@ var CONTROL_KIND_COMPONENTS = {
57922
58259
  InlineControl: PetFountainInlineControl,
57923
58260
  HeroCard: PetFountainHeroCard
57924
58261
  },
58262
+ heater: {
58263
+ InlineControl: HeaterInlineControl,
58264
+ HeroCard: HeaterHeroCard
58265
+ },
57925
58266
  dishwasher: {
57926
58267
  InlineControl: DishwasherInlineControl,
57927
58268
  HeroCard: DishwasherHeroCard
@@ -58009,6 +58350,7 @@ var CONTROL_FILLS = {
58009
58350
  [_camstack_types.DeviceType.Image]: false,
58010
58351
  [_camstack_types.DeviceType.PetFeeder]: false,
58011
58352
  [_camstack_types.DeviceType.PetFountain]: false,
58353
+ [_camstack_types.DeviceType.Heater]: false,
58012
58354
  [_camstack_types.DeviceType.Dishwasher]: false,
58013
58355
  [_camstack_types.DeviceType.Washer]: false,
58014
58356
  [_camstack_types.DeviceType.Dryer]: false
@@ -74016,6 +74358,7 @@ exports.ThermostatInlineControl = ThermostatInlineControl;
74016
74358
  exports.TimeSeriesChart = TimeSeriesChart;
74017
74359
  exports.TimelineSlider = TimelineSlider;
74018
74360
  exports.Timer = Timer;
74361
+ exports.TimerOff = TimerOff;
74019
74362
  exports.TimezoneSelector = TimezoneSelector;
74020
74363
  exports.ToggleLeft = ToggleLeft;
74021
74364
  exports.ToggleRight = ToggleRight;
@@ -74910,6 +75253,10 @@ exports.useFanControlSetDirection = useFanControlSetDirection;
74910
75253
  exports.useFanControlSetOscillating = useFanControlSetOscillating;
74911
75254
  exports.useFanControlSetPercentage = useFanControlSetPercentage;
74912
75255
  exports.useFanControlSetPreset = useFanControlSetPreset;
75256
+ exports.useFanPresetsApplyPreset = useFanPresetsApplyPreset;
75257
+ exports.useFanPresetsCapturePreset = useFanPresetsCapturePreset;
75258
+ exports.useFanPresetsGetPresets = useFanPresetsGetPresets;
75259
+ exports.useFanPresetsSetPresets = useFanPresetsSetPresets;
74913
75260
  exports.useFeatureProbeGetStatus = useFeatureProbeGetStatus;
74914
75261
  exports.useFloodGetStatus = useFloodGetStatus;
74915
75262
  exports.useGasGetStatus = useGasGetStatus;
@@ -75711,6 +76058,9 @@ exports.useTerminalSessionSetInstanceEnabled = useTerminalSessionSetInstanceEnab
75711
76058
  exports.useTerminalSessionUpdateInstance = useTerminalSessionUpdateInstance;
75712
76059
  exports.useTerminalSessionWriteInput = useTerminalSessionWriteInput;
75713
76060
  exports.useThemeMode = require_theme_index.useThemeMode$1;
76061
+ exports.useTimedRunCancel = useTimedRunCancel;
76062
+ exports.useTimedRunGetStatus = useTimedRunGetStatus;
76063
+ exports.useTimedRunRunFor = useTimedRunRunFor;
75714
76064
  exports.useToastOnToast = useToastOnToast;
75715
76065
  exports.useTrainingCanaryResult = useTrainingCanaryResult;
75716
76066
  exports.useTrainingCancelJob = useTrainingCancelJob;