Skip to content
Kinetixui

Connected Product Lab

Build connected products that tell the truth about device state.

Controls, telemetry, pairing and automation patterns for real asynchronous hardware. A switch that has been pressed is a request. It becomes a state when the device says so.

Experimental · 0.2.0

React only, and experimental: the API may still change. There is no transport, no automation engine and no video. Everything interactive on this page is simulation, which is demo state and nothing else.

Explore IoT
bash
pnpm add @kinetixui/iot
SimulationWater pump · demo device
Pump 01Online
Off

Ready

Requested
Unknown
Device reports
off

No change requested. The device reports off.

Simulated — no device is contacted. Readings, commands and delays are scripted in your browser; nothing is sent over a network.

[01]Reference environmentssimulation

Three products, one interaction model.

A home, a farm and a production line name their places differently and want different controls, and they still need the same answers: what did I ask for, what did the device confirm, and how much should I trust this number. Choose an environment and operate it.

Each environment is fabricated and scripted in your browser. Nothing is measured, and no device is contacted.

Home → Floor → Room

A home end to end: rooms with rolled-up health, light, thermostat, plug and lock controls, air quality, a sample camera frame, energy, alerts and scenes.

Smart space environment

A home end to end: rooms with rolled-up health, light, thermostat, plug and lock controls, air quality, a sample camera frame, energy, alerts and scenes.

Built from 13 parts
  • DevicePowerControl
  • DeviceLevelControl
  • DeviceSetpointControl
  • DeviceModeControl
  • TelemetryGrid
  • TelemetryMetric
  • CameraDeviceCard
  • BatteryIndicator
  • EnergySummary
  • AlertList
  • ActivityTimeline
  • RoutineCard
  • CommandLifecycle
SimulatedSimulated — no device is contacted. Readings, commands and delays are scripted in your browser; nothing is sent over a network.
/**
 * Connected Space: one home as a place, not a list. A plan of the house with the devices where they are, a
 * room rail with health rolled up from its devices, real controls for whichever room or device you pick, and
 * a side of attention, energy, activity and scenes. Built from one simulated scenario through selectors, so
 * nothing here is a hand-typed count.
 *
 * The aside is ranked, not stacked. Open alerts are always there, expanded, at every width — that is what the
 * column is for. Everything below them is grouped: what is already acknowledged, what happened earlier in the
 * day, and the routines that run themselves each sit behind a group that says what it holds and how many,
 * counted from the data. Nothing is dropped; it is one press away.
 *
 * State stays honest everywhere: the big number, the switch and the marker on the plan show what the device
 * last CONFIRMED. A request is drawn beside it as a dashed "requested, not yet confirmed" mark, and a failed
 * or unreachable one keeps the last confirmed value and offers Retry (never automatic).
 *
 * On a phone the header is ranked rather than laid out flat. PRIMARY is where you are and what needs you:
 * the house mark, the home's name, and ONE summary status line ("7 of 9 online · 3 need attention")
 * carried by a glyph and words, followed immediately by the floor/room navigation, which is what a phone
 * visitor is actually here to press. SECONDARY is the metadata that explains it: the fabricated address and
 * the simulated clock, quieter and on their own line, wrapping rather than truncating. TERTIARY — energy,
 * activity, scenes — is already behind disclosures in the aside. Nothing truthful is hidden: warnings, stale
 * readings, offline devices, open alerts, requested values and Retry stay where they were.
 *
 * SIMULATED. No device is contacted and nothing is sent over a network. The camera shows a labelled sample
 * frame, never video. Scenes and routines are shown, not executed: KinetixUI has no automation engine.
 */
const DAY_MS = 86_400_000;
const HOME = "home";
/** Fabricated, and said so. Metadata, not identity: it is quiet, it wraps, and it is never ellipsised. */
const HOME_ADDRESS = "12 Example Lane, Sample City (fabricated)";
/** How many of a day's events the aside shows before the rest are grouped behind their real count. */
const ACTIVITY_HEAD = 3;
/**
 * A group nested inside a panel or another disclosure: the same control, one tonal step quieter, so the
 * outer surface stays the card and the group reads as part of it rather than as a second card.
 */
const SUBGROUP = "bg-muted/40 shadow-none";

type IconName = "home" | "bell" | "flame" | "leaf" | "power" | "lock" | "unlock";

/** Small original glyphs for the rail, the header and the mode tiles. Decorative: the words carry the meaning. */
function Glyph({ name, size = 20 }: { name: IconName; size?: number }) {
  const d: Record<IconName, React.ReactNode> = {
    home: <path d="M4 11 12 4l8 7M6 10v9h12v-9M10 19v-5h4v5" />,
    bell: <path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15ZM10 21h4" />,
    flame: <path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9Z" />,
    leaf: <path d="M5 19c0-9 5-14 14-14 0 9-5 14-14 14ZM5 19l7-7" />,
    power: <path d="M12 3v8M7 6.5a7 7 0 1 0 10 0" />,
    lock: <path d="M6 11h12v9H6ZM8.5 11V8a3.5 3.5 0 0 1 7 0v3" />,
    unlock: <path d="M6 11h12v9H6ZM8.5 11V8a3.5 3.5 0 0 1 6.6-1.6" />,
  };
  return (
    <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
      {d[name]}
    </svg>
  );
}

const MODE_ICONS: Record<string, IconName> = { heat: "flame", eco: "leaf", off: "power", locked: "lock", unlocked: "unlock" };
const withIcons = (modes: readonly { id: string; label: string }[]) => modes.map((m) => ({ ...m, icon: MODE_ICONS[m.id] ? <Glyph name={MODE_ICONS[m.id]!} size={22} /> : undefined }));

/** The lifecycle of a request that has not landed, or did not. Nothing for a confirmed one. */
function Lifecycle({ binding }: { binding: ControlBinding }) {
  if (!binding.command || !binding.unsettled) return null;
  return <CommandLifecycle lifecycle={binding.command.lifecycle} formatValue={binding.format} onRetry={binding.retry} onCancel={binding.cancel} className="w-full" />;
}

/** A worded battery row: the caption says what the meter is. Nothing when the device has no battery. */
function Battery({ value, name }: { value: number | undefined; name: string }) {
  if (value === undefined) return null;
  // Wraps below the narrowest phones: the label and the pill together exceed the content width at 320,
  // and the pill cannot shrink past its numeral, meter and word.
  return (
    <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 self-start">
      <span className="text-body-md text-muted-foreground">Battery</span>
      <BatteryIndicator value={value} presentation="pill" label={`${name} battery`} />
    </div>
  );
}

const shown = (value: unknown) => String(value);
const onOff = (value: unknown) => (value === "on" ? "On" : value === "off" ? "Off" : "Unknown");

/** What the plan, the tiles and the rail need to say about one device, read from the simulation. */
type DeviceView = {
  id: string;
  device: ReturnType<typeof deviceOf>;
  category: KinetixDeviceCategory;
  roomId: string;
  roomName: string;
  state: HotspotState;
  statusWord: string;
  /** The CONFIRMED value. */
  value?: string;
  /** The value asked for and not yet confirmed. */
  requested?: string;
  on: boolean;
  power?: ControlBinding;
};

function deviceView(iot: UseIotSimulation, id: string): DeviceView {
  const { sim } = iot;
  const device = deviceOf(sim, id);
  const room = sim.scenario.spaces.find((s) => s.kind === "room" && s.deviceIds?.includes(id));
  const caps = sim.scenario.capabilities[id] ?? [];
  const bind = (cap: string) => (caps.some((c) => c.id === cap) ? controlOf(iot, id, cap) : undefined);
  const power = bind("power");
  const level = bind("level");
  const setpoint = bind("setpoint");
  const lock = bind("lock");
  const controls = [power, level, setpoint, bind("mode"), lock].filter((b): b is ControlBinding => !!b);

  let value: string | undefined;
  let requested: string | undefined;
  let on = false;
  if (device.type === "light" && power) {
    on = power.confirmed === "on";
    value = on && level ? `${shown(level.confirmed)}%` : onOff(power.confirmed);
    requested = power.requested !== undefined ? onOff(power.requested) : level?.requested !== undefined ? `${shown(level.requested)}%` : undefined;
  } else if (power) {
    on = power.confirmed === "on";
    value = onOff(power.confirmed);
    requested = power.requested !== undefined ? onOff(power.requested) : undefined;
  } else if (setpoint) {
    on = bind("mode")?.confirmed === "heat";
    value = `${shown(setpoint.confirmed)} °C`;
    requested = setpoint.requested !== undefined ? `${shown(setpoint.requested)} °C` : undefined;
  } else if (lock) {
    value = lock.format(lock.confirmed);
    requested = lock.requested !== undefined ? lock.format(lock.requested) : undefined;
  } else {
    const sensor = sim.scenario.sensors.find((s) => s.deviceId === id && (s.metric === "air-quality" || s.metric === "temperature"));
    const reading = sensor ? selectReading(sim, id, sensor.metric) : undefined;
    if (sensor && reading) value = sensor.metric === "air-quality" ? `AQI ${reading.value.toFixed(0)}` : `${reading.value.toFixed(1)} ${sensor.unit ?? ""}`.trim();
  }

  const reading = sim.scenario.sensors.filter((s) => s.deviceId === id).map((s) => selectReading(sim, id, s.metric)?.evaluation.state);
  const alerted = selectAlerts(sim).some((a) => a.deviceId === id);
  let state: HotspotState = "confirmed";
  let statusWord = "Online";
  if (device.status === "offline") [state, statusWord] = ["offline", "Offline"];
  else if (controls.some((b) => b.control.availability === "pending")) [state, statusWord] = ["pending", "Waiting for the device"];
  else if (controls.some((b) => b.unsettled)) [state, statusWord] = ["warning", "Request did not land"];
  else if (device.status === "stale" || reading.some((r) => r === "stale" || r === "warning" || r === "critical") || alerted) [state, statusWord] = ["warning", device.status === "stale" ? "Stale" : "Needs attention"];
  return { id, device, category: resolveDeviceCategory(device), roomId: room?.id ?? HOME, roomName: room?.name ?? "Home", state, statusWord, value, requested, on, power };
}

/** Temperature and humidity readings for the devices in a room, as words. `stale` is said, not implied. */
function ambientOf(iot: UseIotSimulation, roomId: string) {
  const ids = iot.sim.scenario.spaces.find((s) => s.id === roomId)?.deviceIds ?? [];
  const read = (metric: string) => {
    for (const id of ids) {
      const sensor = iot.sim.scenario.sensors.find((s) => s.deviceId === id && s.metric === metric);
      const reading = sensor ? selectReading(iot.sim, id, metric) : undefined;
      if (sensor && reading) {
        const text = `${reading.value.toFixed(sensor.decimals ?? 1)} ${sensor.unit ?? ""}`.trim();
        return { text: reading.evaluation.state === "stale" ? `${text} · stale` : text, source: deviceOf(iot.sim, id).name };
      }
    }
    return undefined;
  };
  return { temperature: read("temperature"), humidity: read("humidity") };
}

/**
 * A floor and its rooms inside the rail.
 *
 * Same structure as the kit's `RailGroup` — a labelled nested list — with one difference that only applies
 * below `sm`: one room per row. A two-column grid at 326px leaves about 50px for the room name, which both
 * clips the name and leaves a dead cell beside a floor with an odd number of rooms. One column per room is
 * shorter in total (nothing wraps to three lines), leaves the temperature and the warning glyph room to sit
 * on the name's line, and reads as a list you scan rather than a ragged grid. From `sm` the kit's own
 * three-column grid and `lg` single-column rail are unchanged.
 */
function FloorGroup({ label, children }: { label: string; children: React.ReactNode }) {
  const id = React.useId();
  return (
    <li className="flex flex-col gap-2 lg:gap-1">
      <p id={id} className="px-1 text-label-md uppercase tracking-wide text-muted-foreground">
        {label}
      </p>
      <ul aria-labelledby={id} className="flex flex-col gap-2 sm:grid sm:grid-cols-3 lg:flex lg:flex-col lg:gap-1">
        {children}
      </ul>
    </li>
  );
}

/**
 * `break-words` is inherited, so it reaches the rail item's own name span: a room name wider than its column
 * breaks instead of spilling past the tile. Below `sm` — where the rail is the first thing a phone visitor
 * reaches and the room tiles are full-width rows — the selected room also takes an inset ring, because a 10%
 * tint alone is not an obvious selection at arm's length. From `sm` the rail is exactly as it was.
 */
const railItemClass = (selected: boolean) => (selected ? "break-words ring-2 ring-inset ring-primary sm:ring-0" : "break-words");

const rollupWords = (r: { total: number; offline: number; warning: number; critical: number }) => {
  const parts = [r.critical ? `${r.critical} critical` : "", r.warning ? `${r.warning} needs attention` : "", r.offline ? `${r.offline} offline` : ""].filter(Boolean);
  return parts.length ? parts.join(", ") : "All devices well";
};

/* ---------------------------------- focus compositions ---------------------------------- */

/** The thermostat as the room's hero: a ring around the confirmed target, a request drawn dashed, mode tiles beneath. */
function ClimateHero({ iot, id, roomName }: { iot: UseIotSimulation; id: string; roomName: string }) {
  const target = controlOf(iot, id, "setpoint");
  const mode = controlOf(iot, id, "mode");
  const battery = target.device.battery;
  return (
    <Panel title={target.device.name} description={statusLineOf(target, mode)} as="h5" data-device={id}>
      <div className="grid min-w-0 items-center gap-6 md:grid-cols-2">
        <DeviceSetpointControl
          presentation="ring"
          current={readingOf(iot, id, "temperature").value}
          target={target.confirmed as number}
          requestedTarget={target.requested as number | undefined}
          min={target.capability.min ?? 16}
          max={target.capability.max ?? 26}
          step={target.capability.step ?? 0.5}
          unit="°C"
          secondary={`${roomName} · ${mode.format(mode.confirmed)} mode`}
          control={target.control}
          label={`${target.device.name} target`}
          onCommit={target.send}
        />
        <div className="flex min-w-0 flex-col gap-4">
          <DeviceModeControl
            presentation="tiles"
            modes={withIcons(mode.capability.modes ?? [])}
            value={mode.confirmed as string}
            requested={mode.requested as string | undefined}
            control={mode.control}
            label={`${target.device.name} mode`}
            onSelect={mode.send}
            style={{ gridTemplateColumns: "repeat(3, minmax(0, 1fr))" }}
          />
          <Battery value={battery} name={target.device.name} />
        </div>
      </div>
      <Lifecycle binding={target} />
      <Lifecycle binding={mode} />
    </Panel>
  );
}

/** A light: the object, a big switch, and a fat brightness pill. An offline lamp says so and keeps its last settings. */
function LightPanel({ iot, id }: { iot: UseIotSimulation; id: string }) {
  const power = controlOf(iot, id, "power");
  const level = controlOf(iot, id, "level");
  const offline = power.device.status === "offline";
  return (
    <Panel title={power.device.name} description={statusLineOf(power, level)} as="h5" data-device={id} className={offline ? "border-2 border-dashed border-border" : undefined}>
      <div className="flex min-w-0 items-center gap-4">
        <span className="shrink-0 rounded-2xl bg-muted/60 p-2">
          <DeviceIllustration category="light" on={power.confirmed === "on" && !offline} size="lg" />
        </span>
        <div className="flex min-w-0 flex-col gap-2">
          <p className="text-headline-lg tabular-nums text-foreground">{power.confirmed === "on" ? `${shown(level.confirmed)}%` : "Off"}</p>
          <DevicePowerControl
            size="lg"
            state={power.confirmed as "on" | "off"}
            requested={power.requested as "on" | "off" | undefined}
            control={power.control}
            label={`${power.device.name} power`}
            onToggle={power.send}
          />
        </div>
      </div>
      <DeviceLevelControl variant="pill" size="lg" value={level.confirmed as number} target={level.requested as number | undefined} unit="%" step={5} control={level.control} label={`${power.device.name} brightness`} onCommit={level.send} />
      <Lifecycle binding={power} />
      <Lifecycle binding={level} />
    </Panel>
  );
}

/** A switched plug: one tile, the confirmed state big, the request dashed. */
function PlugTile({ iot, id }: { iot: UseIotSimulation; id: string }) {
  const power = controlOf(iot, id, "power");
  return (
    <div className="flex min-w-0 flex-col gap-3" data-device={id}>
      <Tile
        name={power.device.name}
        state={statusLineOf(power)}
        visual={<DeviceIllustration category="plug" on={power.confirmed === "on"} />}
        value={onOff(power.confirmed)}
        requested={power.requested !== undefined}
        requestedWord={`Requested ${onOff(power.requested)}, not yet confirmed`}
        control={<DevicePowerControl size="lg" state={power.confirmed as "on" | "off"} requested={power.requested as "on" | "off" | undefined} control={power.control} label={`${power.device.name} power`} showLabel={false} onToggle={power.send} />}
      />
      <Lifecycle binding={power} />
    </div>
  );
}

/** The door: locked or unlocked as icon tiles. The lock is slow on purpose, so the gap between asking and locked is visible. */
function LockPanel({ iot, id }: { iot: UseIotSimulation; id: string }) {
  const lock = controlOf(iot, id, "lock");
  return (
    <Panel title={lock.device.name} description={statusLineOf(lock)} as="h5" data-device={id}>
      <div className="flex min-w-0 flex-wrap items-center gap-4">
        <p className="text-headline-lg text-foreground">{lock.format(lock.confirmed)}</p>
        <Battery value={lock.device.battery} name={lock.device.name} />
      </div>
      <DeviceModeControl presentation="tiles" modes={withIcons(lock.capability.modes ?? [])} value={lock.confirmed as string} requested={lock.requested as string | undefined} control={lock.control} label="Front door lock" onSelect={lock.send} style={{ gridTemplateColumns: "repeat(2, minmax(0, 1fr))" }} />
      <Lifecycle binding={lock} />
    </Panel>
  );
}

function ReadingsPanel({ iot, id, title, metrics }: { iot: UseIotSimulation; id: string; title: string; metrics: { metric: string; label: string }[] }) {
  const device = deviceOf(iot.sim, id);
  return (
    <Panel title={title} description={device.name} as="h5" data-device={id}>
      <TelemetryGrid label={`${device.name} readings`}>
        {metrics.map((m) => (
          <TelemetryMetric key={m.metric} {...readingOf(iot, id, m.metric)} label={m.label} />
        ))}
      </TelemetryGrid>
      <Battery value={device.battery} name={device.name} />
    </Panel>
  );
}

function DevicePanel({ iot, id, roomName }: { iot: UseIotSimulation; id: string; roomName: string }) {
  const device = deviceOf(iot.sim, id);
  switch (device.type) {
    case "thermostat":
      return <ClimateHero iot={iot} id={id} roomName={roomName} />;
    case "light":
      return <LightPanel iot={iot} id={id} />;
    case "plug":
      return <PlugTile iot={iot} id={id} />;
    case "lock":
      return <LockPanel iot={iot} id={id} />;
    case "air-quality":
      return (
        <ReadingsPanel
          iot={iot}
          id={id}
          title={`Air · ${roomName}`}
          metrics={[
            { metric: "air-quality", label: "Air quality" },
            { metric: "temperature", label: "Temperature" },
            { metric: "humidity", label: "Humidity" },
          ]}
        />
      );
    case "camera":
      return (
        <div className="min-w-0" data-device={id}>
          <CameraDeviceCard device={device} scene="entrance" posterLabel={SAMPLE_IMAGE_LABEL} privacy="off" now={iot.sim.now} />
        </div>
      );
    default:
      return <ReadingsPanel iot={iot} id={id} title={`Sensor · ${roomName}`} metrics={[{ metric: "temperature", label: "Temperature" }]} />;
  }
}

/* ---------------------------------- the composition ---------------------------------- */

export function SmartSpaceEnvironmentExample() {
  const iot = useIotSimulation(smartSpace, { intervalMs: 1000 });
  const { sim } = iot;
  const [spaceId, setSpaceId] = React.useState("room-hall");
  const [deviceId, setDeviceId] = React.useState<string | null>(null);
  const [enabled, setEnabled] = React.useState<Record<string, boolean>>({});
  const days = React.useMemo(() => Array.from({ length: 7 }, (_, i) => new Date(Date.parse(sim.startAt) - (6 - i) * DAY_MS).toISOString().slice(0, 10)), [sim.startAt]);
  const [day, setDay] = React.useState(days[6]!);

  const spaces = sim.scenario.spaces;
  const rollups = selectSpaceRollups(sim);
  const floors = spaces.filter((s) => s.kind === "floor");
  const room = spaces.find((s) => s.id === spaceId && s.kind === "room");
  const allViews = Object.keys(sim.devices).map((id) => deviceView(iot, id));
  // Open alerts are the point of the aside and are never folded away. Ones already acknowledged are kept —
  // nothing is dropped — but grouped behind a count, so a handled alert stops competing with an open one.
  const openAlerts = selectAlerts(sim);
  const alerts = selectAlerts(sim, { includeAcknowledged: true });
  const handledAlerts = alerts.filter((a) => !openAlerts.some((open) => open.id === a.id));
  const needAttention = openAlerts.length;
  const online = allViews.filter((v) => v.device.status === "online").length;
  const clock = `${sim.now.slice(11, 16)} UTC`;

  const selectRoom = (id: string) => {
    setSpaceId(id);
    setDeviceId(null);
  };
  const selectDevice = (id: string) => {
    const view = allViews.find((v) => v.id === id);
    if (!view) return;
    setSpaceId(view.roomId);
    setDeviceId(id);
  };
  const jumpToAlerts = () => {
    const el = document.getElementById("space-alerts");
    el?.scrollIntoView({ block: "start" });
    el?.focus({ preventScroll: true });
  };

  const hotspots: PlanHotspot[] = allViews
    .filter((v) => homeAnchors[v.id])
    .map((v) => ({ id: v.id, roomId: homeAnchors[v.id]!.roomId, x: homeAnchors[v.id]!.x, y: homeAnchors[v.id]!.y, category: v.category, state: v.state, label: v.device.name, value: v.value, requested: v.requested }));
  const ambient: RoomAmbient = Object.fromEntries(
    spaces
      .filter((s) => s.kind === "room")
      .map((s) => {
        const a = ambientOf(iot, s.id);
        return [s.id, [a.temperature?.text, a.humidity?.text].filter((t): t is string => !!t)];
      }),
  );

  // A scene is fired by hand, so it leads; a routine or a schedule fires itself, so it keeps its own group.
  // Both groups sit under the same "shown, not executed" sentence, and the group states its real count.
  const scenes = sim.automations.filter((a) => a.kind === "scene");
  const selfRunning = sim.automations.filter((a) => a.kind !== "scene");
  const routineCard = (automation: (typeof sim.automations)[number]) => {
    const on = enabled[automation.id] ?? automation.enabled;
    return (
      <RoutineCard
        key={automation.id}
        automation={{ ...automation, enabled: on, status: on ? (automation.status === "disabled" ? "idle" : automation.status) : "disabled" }}
        now={sim.now}
        onToggleEnabled={(next) => setEnabled((prev) => ({ ...prev, [automation.id]: next }))}
      />
    );
  };

  const energy = selectEnergy(sim);
  const dayLabels = energy ? [...energy.week.map((_, i) => new Date(Date.parse(sim.startAt) - (energy.week.length - i) * DAY_MS).toLocaleDateString("en", { weekday: "short", timeZone: "UTC" })), "Today"] : [];
  const activity = selectActivity(sim).map((e) => ({ e, date: new Date(e.timestamp).toISOString().slice(0, 10) }));
  const dayEvents = activity.filter((a) => a.date === day).map((a) => a.e);
  const stripDays = days.map((date) => ({ id: date, date, count: activity.filter((a) => a.date === date).length }));
  // The timeline is newest first, so the head is what happened last. The tail is kept, behind its real count.
  const latestEvents = dayEvents.slice(0, ACTIVITY_HEAD);
  const earlierEvents = dayEvents.slice(ACTIVITY_HEAD);

  const roomDevices = room?.deviceIds ?? [];
  const chosen = deviceId && roomDevices.includes(deviceId) ? deviceId : null;
  const floorName = spaces.find((s) => s.id === room?.parentId)?.name;
  const ambientHere = room ? ambientOf(iot, room.id) : undefined;
  const homeRollup = rollups.get(HOME);
  const air = readingOf(iot, "air-living", "air-quality");
  const airState = selectReading(sim, "air-living", "air-quality")?.evaluation.state;
  const airShown: ShowcaseState = airState === "warning" ? "warning" : airState === "critical" ? "critical" : "confirmed";

  // One summary status line, not two chips racing the title: how much of the home is reachable and how much
  // of it wants you, in one sentence, with the kit's glyph so the state is never colour alone.
  const summaryLine = `${online} of ${allViews.length} online · ${needAttention} ${needAttention === 1 ? "needs" : "need"} attention`;

  const header = (
    <SpaceHeader
      eyebrow={`Home · ${floors.length} floors`}
      title={spaces.find((s) => s.id === HOME)?.name ?? "Home"}
      identity={<HouseMark />}
      status={needAttention ? "warning" : "confirmed"}
      statusWord={<bdi>{summaryLine}</bdi>}
      chips={
        // Secondary metadata, one quiet block rather than three pills racing the title: the fabricated address
        // and the simulated clock. It shares its row with the icon buttons instead of pushing them onto a row
        // of their own, and it wraps — a truncated address tells you neither the street nor that it is fake.
        // "Simulated" stays in the words: that is a truth claim about the clock, not decoration.
        <p className="flex min-w-0 flex-1 flex-col gap-0.5 text-body-sm text-muted-foreground md:flex-none md:flex-row md:items-center md:gap-2">
          <bdi>{HOME_ADDRESS}</bdi>
          <bdi className="tabular-nums">Simulated clock {clock}</bdi>
        </p>
      }
      actions={
        <IconCluster label="Home">
          <IconButton label="Show the whole home" pressed={spaceId === HOME} onClick={() => selectRoom(HOME)}>
            <Glyph name="home" />
          </IconButton>
          <IconButton label="Jump to alerts" badge={needAttention} onClick={jumpToAlerts}>
            <Glyph name="bell" />
          </IconButton>
        </IconCluster>
      }
    />
  );

  const rail = (
    <RailList aria-label="Home and rooms">
      <RailItem
        icon={<Glyph name="home" />}
        name="Whole home"
        state={`${online} of ${allViews.length} online`}
        health={homeRollup && homeRollup.warning + homeRollup.critical > 0 ? "warning" : undefined}
        healthWord={homeRollup ? rollupWords(homeRollup) : undefined}
        selected={spaceId === HOME}
        onSelect={() => selectRoom(HOME)}
        className={railItemClass(spaceId === HOME)}
      />
      {floors.map((floor) => (
        <FloorGroup key={floor.id} label={floor.name}>
          {spaces
            .filter((s) => s.parentId === floor.id)
            .map((r) => {
              const rollup = rollups.get(r.id)!;
              const a = ambientOf(iot, r.id);
              return (
                <RailItem
                  key={r.id}
                  icon={<DeviceIcon category={resolveDeviceCategory(deviceOf(sim, r.deviceIds?.[0] ?? ""))} size={20} />}
                  name={r.name}
                  state={a.temperature?.text ?? `${rollup.total} ${rollup.total === 1 ? "device" : "devices"}`}
                  health={rollup.critical ? "critical" : rollup.warning ? "warning" : rollup.offline ? "offline" : undefined}
                  healthWord={rollupWords(rollup)}
                  count={rollup.total}
                  selected={spaceId === r.id}
                  onSelect={() => selectRoom(r.id)}
                  className={railItemClass(spaceId === r.id)}
                />
              );
            })}
        </FloorGroup>
      ))}
    </RailList>
  );

  const canvas = (
    <SpaceCanvas
      plan={homePlan}
      selectedRoomId={room ? room.id : null}
      onSelectRoom={selectRoom}
      hotspots={hotspots}
      selectedHotspotId={chosen}
      onSelectHotspot={selectDevice}
      ambient={ambient}
      hotspotLabels="selected"
    />
  );

  const focus = room ? (
    <>
      <div className="flex min-w-0 flex-wrap items-end justify-between gap-x-6 gap-y-2 px-1">
        <div className="flex min-w-0 flex-col">
          {floorName ? <p className="text-label-md uppercase tracking-wide text-muted-foreground">{floorName}</p> : null}
          <h4 className="text-headline-lg text-foreground">{room.name}</h4>
          <p className="text-body-md text-muted-foreground">
            <bdi>
              {roomDevices.length} {roomDevices.length === 1 ? "device" : "devices"} · {rollupWords(rollups.get(room.id)!)}
            </bdi>
          </p>
        </div>
        {/* The room's temperature is context for the controls below, not the primary value of the screen, so
            on a phone it is a reading beside the room name rather than a second display-sized number racing
            it. From `sm` it keeps the size it had. */}
        {ambientHere?.temperature ? (
          <p className="flex flex-col items-start sm:items-end">
            <span className="text-headline-sm tabular-nums text-foreground sm:text-display-sm">
              <bdi>{ambientHere.temperature.text}</bdi>
            </span>
            <span className="text-body-sm text-muted-foreground">Room temperature · {ambientHere.temperature.source}</span>
          </p>
        ) : null}
      </div>
      {roomDevices.length > 1 ? (
        <PillSelector
          label={`Devices in ${room.name}`}
          showAll
          allLabel="Whole room"
          value={chosen ?? "all"}
          onChange={(id) => setDeviceId(id === "all" ? null : id)}
          options={roomDevices.map((id) => ({ id, label: deviceOf(sim, id).name, icon: <DeviceIcon category={resolveDeviceCategory(deviceOf(sim, id))} size={16} /> }))}
        />
      ) : null}
      <div className="grid min-w-0 grid-cols-1 gap-4">
        {(chosen ? [chosen] : roomDevices).map((id) => (
          <DevicePanel key={id} iot={iot} id={id} roomName={room.name} />
        ))}
      </div>
    </>
  ) : (
    <>
      <div className="flex min-w-0 flex-col gap-1 px-1">
        <p className="text-label-md uppercase tracking-wide text-muted-foreground">Whole home</p>
        <h4 className="text-headline-lg text-foreground">{spaces.find((s) => s.id === HOME)?.name}</h4>
      </div>
      <Panel title="At a glance" as="h5">
        <div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
          <Stat size="lg" label="Living room" value={ambientOf(iot, "room-living").temperature?.text.split(" ")[0] ?? "—"} unit="°C" />
          <Stat size="lg" label="Air quality" value={air.value === null ? "—" : Math.round(air.value)} unit="AQI" state={airShown} stateWord={airShown === "confirmed" ? "Within range" : undefined} />
          <Stat size="lg" label="Energy today" value={energy ? energy.summary.total.toFixed(1) : "—"} unit={energy?.summary.unit} />
        </div>
      </Panel>
      <div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
        {allViews.map((v) => (
          <Tile
            key={v.id}
            name={v.device.name}
            state={`${v.roomName} · ${v.statusWord}`}
            value={v.value}
            requested={v.requested !== undefined}
            requestedWord={`Requested ${v.requested}, not yet confirmed`}
            visual={<DeviceIllustration category={v.category} on={v.on && v.state !== "offline"} size="md" />}
            onSelect={() => selectDevice(v.id)}
            control={
              v.power ? (
                <DevicePowerControl size="sm" showLabel={false} state={v.power.confirmed as "on" | "off"} requested={v.power.requested as "on" | "off" | undefined} control={v.power.control} label={`${v.device.name} power`} onToggle={v.power.send} />
              ) : undefined
            }
          />
        ))}
      </div>
    </>
  );

  const aside = (
    <>
      <Panel id="space-alerts" tabIndex={-1} title="Needs attention" description={needAttention ? <bdi>{needAttention} to review</bdi> : "All clear"} as="h5" className="scroll-mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:col-span-2 xl:col-span-1">
        <AlertList hideSummary alerts={openAlerts} onAcknowledge={(alert) => iot.acknowledgeAlert(alert.id)} now={sim.now} emptyLabel="Nothing is open. Acknowledged alerts are below." />
        {handledAlerts.length ? (
          <Disclosure title="Acknowledged" count={handledAlerts.length} countNoun="acknowledged alerts" defaultOpen={false} className={SUBGROUP}>
            <AlertList hideSummary alerts={handledAlerts} label="Acknowledged alerts" now={sim.now} />
          </Disclosure>
        ) : null}
      </Panel>

      {energy ? (
        <Disclosure title="Energy" count={energy.summary.items.length} countNoun="devices">
          <EnergySummary
            presentation="sparkline"
            summary={energy.summary}
            today={energy.summary.total}
            days={[...energy.week, energy.summary.total]}
            dayLabels={dayLabels}
            dailyBaseline={sim.scenario.energy?.baseline}
            updatedLabel="Simulated. Moves with the demo clock."
          />
        </Disclosure>
      ) : null}

      <Disclosure title="Activity" count={dayEvents.length} countNoun="events on this day">
        <DateStrip label="Activity day" days={stripDays} value={day} onChange={setDay} now={sim.now} />
        <ActivityTimeline variant="blocks" events={latestEvents} now={sim.now} emptyLabel="Nothing happened on this day." />
        {earlierEvents.length ? (
          <Disclosure title="Earlier on this day" count={earlierEvents.length} countNoun="earlier events" defaultOpen={false} className={SUBGROUP}>
            <ActivityTimeline variant="blocks" events={earlierEvents} label="Earlier activity" now={sim.now} />
          </Disclosure>
        ) : null}
      </Disclosure>

      <Disclosure title="Scenes and routines" count={sim.automations.length} countNoun="scenes and routines">
        <p className="text-body-sm text-muted-foreground">Shown, not executed: KinetixUI has no automation engine.</p>
        {scenes.length ? <div className="flex flex-col gap-3">{scenes.map(routineCard)}</div> : null}
        {/* "Runs on its own" is short enough to sit beside its count and chevron without truncating at 292px. */}
        {selfRunning.length ? (
          <Disclosure title="Runs on its own" count={selfRunning.length} countNoun="routines and schedules" defaultOpen={false} className={SUBGROUP}>
            <div className="flex flex-col gap-3">{selfRunning.map(routineCard)}</div>
          </Disclosure>
        ) : null}
      </Disclosure>
    </>
  );

  return (
    <div className="flex min-w-0 flex-col gap-4">
      <SimNotice scenario={smartSpace}>
        <SimTransport iot={iot} />
      </SimNotice>
      <ShowcaseShell label="Connected space" railLabel="Home and rooms" canvasLabel="Home plan" focusLabel={room ? room.name : "Whole home"} asideLabel="Attention, energy, activity and scenes" header={header} rail={rail} canvas={canvas} focus={focus} aside={aside} />
    </div>
  );
}

apps/web/src/examples/iot/smart-space-environment.tsx

[02]Device controls and state honesty

A request is not a state.

Almost every device UI fills the gap between asked and confirmed with an optimistic update: the switch slides, the icon lights, and the screen asserts a state the device has not reported. On a lamp that is harmless. On a lock, a valve or a pump it is a lie the user finds out about later.

Every control here draws the request differently from the confirmed state, in the picture and in the accessible name, and one function decides what “pending” means so five controls on a screen cannot disagree.

  1. Off

    The device last confirmed it is off. That is the only thing the switch may claim.

  2. Turning on

    You asked. The track moves so the press feels received; the knob stays hollow and the label does not say “On”.

  3. Timed out

    No confirmation arrived inside the window. The screen says so, and still shows what the device last reported.

  4. Unreachable

    The device did not answer at all. A different sentence from a timeout, because the next step differs.

  5. Retry

    A new attempt, counted and bounded, that begins again from “requested” rather than pretending the first one worked.

Use it.

Every change below is a request first. The dashed treatment is what the device has not agreed to yet, and it disappears when the (scripted) device confirms. Nothing here is connected to anything.

SimulatedStudio 2 · two demo devices, one command queue
Ceiling lightOnline
Off
Brightness35%
The ceiling light is off. Turn it on to set a brightness.
Climate unitOnline
20.5°CNow 19°C · Heating
Studio 2 temperature: target 20.5°C, currently 19°C

Last command

Requested
Unknown
Device reports
Light off

No change requested. The device reports Light off.

Simulated — no device is contacted. Readings, commands and delays are scripted in your browser; nothing is sent over a network.

Requested is not confirmed

A live demo of the gap most device UIs hide: what you asked for against what the device reported, through acknowledged, confirmed, failed, timed out and unreachable.

Built from 3 parts
  • CommandLifecycle
  • DevicePowerControl
  • DeviceModeControl
SimulatedSimulated — no device is contacted. Readings, commands and delays are scripted in your browser; nothing is sent over a network.
/**
 * Requested is not confirmed.
 *
 * Press a control and watch two values that most device UIs collapse into one: what you ASKED for, and
 * what the device last REPORTED. The confirmed value changes only when the device confirms it. An
 * acknowledgement ("I got that") is not a confirmation, a timeout is not "off", and a device that
 * stopped answering is `unreachable` rather than quietly assumed to have obeyed.
 *
 * Everything here is scripted in the browser. `@kinetixui/iot` has no transport, so no request leaves
 * the page, and no delay below is a real network delay.
 */
const STAGE_WORD: Record<string, { state: ShowcaseState; word: string }> = {
  requested: { state: "pending", word: "Requested, not yet confirmed" },
  retrying: { state: "pending", word: "Retrying, not yet confirmed" },
  acknowledged: { state: "pending", word: "Acknowledged, not yet confirmed" },
  failed: { state: "warning", word: "Failed. The device did not change." },
  "timed-out": { state: "warning", word: "Timed out. No answer yet." },
  unreachable: { state: "offline", word: "Unreachable. Last confirmed value shown." },
};

/** One device, two values side by side: what it REPORTED (big, solid) and what you ASKED for (dashed, worded). */
function Panel({ title, note, binding, children }: { title: string; note: string; binding: ControlBinding; children: React.ReactNode }) {
  const { command, confirmed, requested, format } = binding;
  const stage = command && binding.unsettled ? STAGE_WORD[command.lifecycle.stage] : undefined;
  const asked = requested !== undefined;
  return (
    <article className="grid min-w-0 gap-5 rounded-2xl bg-card p-4 shadow-sm md:grid-cols-2 md:gap-8 md:p-6">
      <div className="flex min-w-0 flex-col gap-5">
      <header className="flex flex-col gap-1">
        <h4 className="text-title-lg text-foreground">{binding.device.name}</h4>
        <p className="text-body-md text-muted-foreground">
          <span className="font-medium text-foreground">{title}.</span> {note}
        </p>
      </header>

      <div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-stretch gap-2 sm:gap-3">
        <dl className="m-0 flex min-w-0 flex-col gap-1 rounded-xl bg-muted/60 p-3 sm:p-4">
          <dt className="flex items-center gap-1.5 text-label-md text-muted-foreground">
            <StateGlyph state="confirmed" size={14} className="text-success" />
            Device reports
          </dt>
          <dd data-testid={`confirmed-${binding.device.id}`} className="m-0 break-words text-headline-md text-foreground">
            {format(confirmed)}
          </dd>
          <dd className="m-0 text-label-md text-muted-foreground">Confirmed</dd>
        </dl>
        <span aria-hidden="true" className="flex items-center text-muted-foreground rtl:-scale-x-100">
          <svg viewBox="0 0 24 24" width={20} height={20} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" focusable="false">
            <path d="M5 12h14m-6-6 6 6-6 6" />
          </svg>
        </span>
        <dl
          className={cn(
            "m-0 flex min-w-0 flex-col gap-1 rounded-xl border-2 p-3 transition-colors duration-fast motion-reduce:transition-none sm:p-4",
            asked ? "border-dashed border-primary bg-primary/10" : "border-transparent bg-muted/30",
          )}
        >
          <dt className="flex items-center gap-1.5 text-label-md text-muted-foreground">
            <StateGlyph state={asked ? "pending" : "offline"} size={14} className={asked ? "text-primary" : "text-muted-foreground"} />
            You asked for
          </dt>
          <dd data-testid={`requested-${binding.device.id}`} className={cn("m-0 break-words", asked ? "text-headline-md text-foreground" : "text-title-md text-muted-foreground")}>
            {asked ? format(requested) : "Nothing pending"}
          </dd>
          <dd className="m-0 text-label-md text-muted-foreground">{asked ? "Requested" : "No open request"}</dd>
        </dl>
      </div>

      <div className="flex min-h-6 items-center">
        {stage ? <StateBadge state={stage.state}>{stage.word}</StateBadge> : <StateBadge state="confirmed">Nothing waiting on the device</StateBadge>}
      </div>

      </div>

      <div className="flex min-w-0 flex-col gap-4 md:justify-center">
        <div className="flex min-w-0 flex-col gap-4 rounded-xl bg-muted/60 p-4">{children}</div>
        {command && binding.unsettled ? <CommandLifecycle lifecycle={command.lifecycle} formatValue={format} onRetry={binding.retry} onCancel={binding.cancel} /> : null}
      </div>
    </article>
  );
}

export function StateHonestyExample() {
  const iot = useIotSimulation(agritech, { intervalMs: 500 });
  const valve02 = controlOf(iot, "valve-02", "position");
  const valve03 = controlOf(iot, "valve-03", "position");
  const pump = controlOf(iot, "pump-01", "power");
  const pumpGone = !iot.sim.devices["pump-01"]!.reachable;
  const pumpBusy = pump.command ? isLifecyclePending(pump.command.lifecycle) : false;

  return (
    <section aria-label="State honesty demo" className="flex flex-col gap-5">
      <SimNotice scenario={agritech}>
        <SimTransport iot={iot} />
      </SimNotice>

      <div className="flex flex-col gap-4">
        {([["Reliable valve", "Acknowledges, then confirms.", valve02], ["Flaky valve", "Fails once, then works. Retry is offered, never done for you.", valve03]] as const).map(
          ([title, note, valve]) => (
            <Panel key={valve.device.id} title={title} note={note} binding={valve}>
              <DeviceModeControl
                modes={valve.capability.modes ?? []}
                value={valve.confirmed as string}
                requested={valve.requested as string | undefined}
                control={valve.control}
                label={`${valve.device.name} position`}
                onSelect={valve.send}
              />
            </Panel>
          ),
        )}

        <Panel title="Unreachable device" note="Send a request, then lose the pump: it times out, then is called unreachable." binding={pump}>
          <DevicePowerControl
            state={pump.confirmed as "on" | "off"}
            requested={pump.requested as "on" | "off" | undefined}
            control={pump.control}
            label={`${pump.device.name} power`}
            onToggle={pump.send}
          />
          <div className="flex flex-wrap gap-2">
            <button
              type="button"
              className={BUTTON}
              disabled={pumpGone || pumpBusy}
              onClick={() => {
                pump.send(pump.confirmed === "on" ? "off" : "on");
                iot.setReachable("pump-01", false);
              }}
            >
              Request a change, then lose the pump
            </button>
            <button type="button" className={BUTTON} disabled={!pumpGone} onClick={() => iot.setReachable("pump-01", true)}>
              Pump answers again
            </button>
          </div>
        </Panel>
      </div>

      <p className="text-body-md text-muted-foreground">
        An optimistic interface would already show the new value the moment you pressed. Here the confirmed value never moves until the device
        confirms it, so a lock, a valve or a pump is never shown in a state it has not reported.
      </p>
    </section>
  );
}

apps/web/src/examples/iot/state-honesty.tsx

[03]Telemetry

A reading is a value, a time, and how much to trust it.

A metric registry gives a number its unit and its bounds. A reading can be normal, over a threshold, stale, or unavailable, and each is a word and a shape. A gap in a series is drawn as a gap, because a straight line across it claims the sensor was answering.

Telemetry history

Twelve kinds of reading in normal, warning, critical, stale and unavailable states, and trends with a time range, gaps, threshold bands and a data table.

Built from 3 parts
  • TelemetryGrid
  • TelemetryMetric
  • TelemetryTrend
SimulatedSimulated — no device is contacted. Readings, commands and delays are scripted in your browser; nothing is sent over a network.
/**
 * Telemetry over time: twelve kinds of reading in the states a real fleet is actually in, then two
 * trends with a threshold band, a gap where the sensor did not answer, and the data table that backs
 * the chart for anyone who cannot read a line.
 *
 * SIMULATED, fabricated data measured from one fixed instant. Nothing is sampled from a device.
 */
const NOW = AGRITECH_START;
const ago = (ms: number) => new Date(Date.parse(NOW) - ms).toISOString();
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;

const RANGES = [
  { id: "6h", label: "6 hours", ms: 6 * HOUR },
  { id: "24h", label: "24 hours", ms: 24 * HOUR },
  { id: "7d", label: "7 days", ms: 7 * 24 * HOUR },
] as const;

/** Keep the points inside the window. A gap (`quality: "missing"`) survives the cut, so it still draws. */
function within(series: KinetixTelemetrySeries | undefined, windowMs: number): KinetixTelemetrySeries {
  const from = Date.parse(NOW) - windowMs;
  return { deviceId: series?.deviceId ?? "", metric: series?.metric ?? "", points: (series?.points ?? []).filter((p) => Date.parse(String(p.timestamp)) >= from) };
}

/** One series: its newest value as a large numeral, then the chart and the table that backs it. */
function ChartTile({ title, unit, note, series, thresholds, height }: { title: string; unit: string; note?: string; series: KinetixTelemetrySeries; thresholds?: { warningLow: number }; height: number }) {
  const latest = [...series.points].reverse().find((point) => point.quality !== "missing" && typeof point.value === "number");
  return (
    <div className="flex min-w-0 flex-col gap-3 rounded-2xl bg-muted/40 p-4">
      <div className="flex min-w-0 flex-col gap-1">
        <h5 className="text-title-md text-foreground">{title}</h5>
        <p className="tabular-nums text-headline-lg text-foreground">
          {latest ? String(Math.round((latest.value as number) * 10) / 10) : "No reading"}
          {latest ? <bdi className="ms-1 text-title-md text-muted-foreground">{unit}</bdi> : null}
        </p>
        <p className="text-body-sm text-muted-foreground">{note ?? "Latest reading in this range."}</p>
      </div>
      <TelemetryTrend series={series} label={title} thresholds={thresholds} height={height} now={NOW} dataTable />
    </div>
  );
}

export function TelemetryHistoryExample() {
  const [range, setRange] = React.useState<(typeof RANGES)[number]["id"]>("7d");
  const windowMs = RANGES.find((r) => r.id === range)!.ms;
  const find = (deviceId: string, metric: string) => agritech.series.find((s) => s.deviceId === deviceId && s.metric === metric);

  return (
    <section aria-label="Telemetry history" className="flex flex-col gap-4 sm:gap-6">
      <SimNotice text="Simulated — fabricated readings measured from a fixed instant. Nothing is sampled from a device." />

      <Panel title="Readings now" description="Normal, warning, critical, stale and unavailable are different states, never one confident number.">
        <TelemetryGrid label="Readings by kind" className="grid grid-cols-2 gap-2 sm:flex sm:gap-4">
          <TelemetryMetric size="lg" metric="temperature" value={24.6} timestamp={ago(20_000)} now={NOW} />
          <TelemetryMetric size="lg" metric="humidity" value={88} thresholds={{ warningHigh: 80, criticalHigh: 95 }} timestamp={ago(20_000)} now={NOW} />
          <TelemetryMetric size="lg" metric="soil-moisture" value={34} thresholds={{ warningLow: 28 }} timestamp={ago(40_000)} now={NOW} />
          <TelemetryMetric size="lg" metric="pressure" value={null} quality="missing" timestamp={ago(20_000)} now={NOW} />
          <TelemetryMetric size="lg" metric="air-quality" value={212} timestamp={ago(10_000)} now={NOW} />
          <TelemetryMetric size="lg" metric="motion" value={2} unit="events" timestamp={ago(15_000)} now={NOW} />
          <TelemetryMetric size="lg" metric="light-level" value={420} timestamp={ago(30_000)} now={NOW} />
          <TelemetryMetric size="lg" metric="flow" value={18} thresholds={{ warningLow: 20 }} timestamp={ago(10_000)} now={NOW} />
          <TelemetryMetric size="lg" metric="water-level" value={6} thresholds={{ warningLow: 20, criticalLow: 10 }} timestamp={ago(45_000)} now={NOW} />
          <TelemetryMetric size="lg" metric="power" value={1820} timestamp={ago(5_000)} now={NOW} />
          <TelemetryMetric size="lg" metric="battery" value={18} timestamp={ago(60_000)} now={NOW} />
          <TelemetryMetric size="lg" metric="signal-strength" value={61} timestamp={ago(2 * HOUR)} staleAfterMs={15 * MINUTE} now={NOW} />
        </TelemetryGrid>
      </Panel>

      <Panel title="History" description="The band is the product-set threshold. A break in the line is a reading that was due and did not arrive.">
        <PillSelector label="Time range" options={RANGES} value={range} onChange={(id) => setRange(id as (typeof RANGES)[number]["id"])} className="self-start rounded-full bg-muted/60 p-1" />
        <div className="grid gap-4 lg:grid-cols-2">
          <ChartTile title="Soil moisture, Zone 3" unit="%" series={within(find("soil-04", "soil-moisture"), windowMs)} thresholds={{ warningLow: 28 }} height={160} />
          <ChartTile title="Weather station temperature" unit="°C" note="With a gap where the sensor did not answer." series={within(find("weather-01", "temperature"), Math.min(windowMs, 24 * HOUR))} height={160} />
        </div>
      </Panel>
    </section>
  );
}

apps/web/src/examples/iot/telemetry-history.tsx

[04]Alerts

Somebody has to own it.

Severity is a word and a glyph. An alert carries what raised it and what can be done, and an acknowledged alert is still visible as acknowledged rather than removed. Live-region announcements are rare on purpose.

Alert centre

Fleet health above an alert list, with an acknowledge flow that keeps acknowledged distinct from resolved.

Built from 2 parts
  • AlertList
  • DeviceHealthSummary
SimulatedSimulated — no device is contacted. Readings, commands and delays are scripted in your browser; nothing is sent over a network.
/**
 * An alert centre over Site 04: fleet health above, the alert list below, and an acknowledge flow.
 *
 * Acknowledged is not resolved. Acknowledging says "I have seen this"; the alert stays in the list and
 * the device keeps counting against health until the condition itself clears. Use the button to make
 * another sensor stop answering and watch the counts and the list change together.
 *
 * SIMULATED: nothing is sent to a device, and acknowledging changes only this component's state.
 */
/** One-line rows need room for the message; below 768px the fuller list rows read better. */
function useWide(): boolean {
  const [wide, setWide] = React.useState(false);
  React.useEffect(() => {
    if (typeof window.matchMedia !== "function") return;
    const media = window.matchMedia("(min-width: 768px)");
    const read = () => setWide(media.matches);
    read();
    media.addEventListener?.("change", read);
    return () => media.removeEventListener?.("change", read);
  }, []);
  return wide;
}

export function AlertCenterExample() {
  const wide = useWide();
  const iot = useIotSimulation(operations, { intervalMs: 1000 });
  const { sim } = iot;
  const [showAcknowledged, setShowAcknowledged] = React.useState(true);
  const silent = !sim.devices["s-101"]!.reachable;
  const alerts = selectAlerts(sim, { includeAcknowledged: showAcknowledged });

  return (
    <section aria-label="Alert centre" className="flex flex-col gap-4 sm:gap-6">
      <SimNotice scenario={operations}>
        <SimTransport iot={iot} />
      </SimNotice>

      <Panel title="Site 04 health" description="Rolled up from every device on the site.">
        <DeviceHealthSummary summary={selectFleetHealth(sim)} noun={{ one: "device", other: "devices" }} size="lg" />
      </Panel>

      <Panel title="Alerts" description="Acknowledging says “I have seen this”. It does not resolve the alert.">
        <div className="flex flex-wrap items-center gap-3 rounded-xl bg-muted/60 p-3">
          <label className="flex min-h-11 cursor-pointer items-center gap-3 text-body-md text-foreground md:min-h-9">
            <input type="checkbox" className="size-5" checked={showAcknowledged} onChange={(event) => setShowAcknowledged(event.target.checked)} />
            Show acknowledged
          </label>
          <button type="button" className={cn(BUTTON, "min-h-11 md:min-h-9")} onClick={() => iot.setReachable("s-101", silent)}>
            {silent ? "Vessel sensor S-101 answers again" : "Make Vessel sensor S-101 stop answering"}
          </button>
        </div>
        <AlertList
          alerts={alerts}
          deviceName={(id) => deviceOf(sim, id).name}
          onAcknowledge={(alert) => iot.acknowledgeAlert(alert.id)}
          now={sim.now}
          emptyLabel="Nothing to show."
          variant={wide ? "compact" : "list"}
        />
      </Panel>
    </section>
  );
}

apps/web/src/examples/iot/alert-center.tsx

[05]Automation

Edit a rule. Your application runs it.

AutomationBuilder is a keyboard-operable form over a structured rule: a trigger, conditions, actions, and validation with words. It produces a rule and reads one back as a sentence.

There is no automation engine here. Nothing evaluates a trigger, schedules a run or sends a command. Where a demo shows an automation firing, the event is scripted and says so.

Automation builder

A structured rule editor wired to farm vocabulary, starting from the Zone 3 irrigation rule, with inline validation and a saved-as-demo confirmation.

Built from 2 parts
  • AutomationBuilder
  • AutomationRuleView
SimulatedSimulated — no device is contacted. Readings, commands and delays are scripted in your browser; nothing is sent over a network.
/**
 * A rule editor wired to the farm's own vocabulary. It starts from the rule:
 *
 *   When soil moisture falls below 28% (and rain is not expected), open Zone 3 irrigation for 12 minutes.
 *
 * `AutomationBuilder` edits DATA. It validates with `validateAutomationRule`, shows problems inline,
 * and only calls `onSubmit` for a valid rule. Nothing evaluates the rule and nothing is sent: saving
 * here stores the rule in this component's state and says so.
 *
 * The rain forecast is application-provided demo data. KinetixUI fetches no forecast.
 */
const SUBJECTS: readonly KinetixBuilderSubject[] = [
  { id: "soil-moisture", label: "Soil moisture", unit: "%" },
  { id: "rain-forecast", label: "Rain forecast" },
  { id: "air-temperature", label: "Air temperature", unit: "°C" },
];

const TARGETS: readonly KinetixBuilderTarget[] = [
  { id: "zone-3", label: "Zone 3 irrigation", commands: [{ id: "open", label: "Open" }, { id: "close", label: "Close" }] },
  { id: "zone-2", label: "Zone 2 irrigation", commands: [{ id: "open", label: "Open" }, { id: "close", label: "Close" }] },
  { id: "pump-01", label: "Pump Station", commands: [{ id: "turn-on", label: "Turn on" }, { id: "turn-off", label: "Turn off" }] },
];

const BLANK: KinetixAutomationRule = { id: "rule-new", name: "", enabled: true, conditions: [], actions: [] };
const nameOf = (_kind: "subject" | "scope" | "target", id: string) =>
  SUBJECTS.find((s) => s.id === id)?.label.toLowerCase() ?? TARGETS.find((t) => t.id === id)?.label;

export function AutomationBuilderExample() {
  const [draft, setDraft] = React.useState<KinetixAutomationRule>(agritechRule);
  const [saved, setSaved] = React.useState<KinetixAutomationRule | null>(null);

  return (
    <section aria-label="Automation builder" className="flex flex-col gap-4 sm:gap-6">
      <SimNotice scenario={agritech} />

      <div className="flex flex-col gap-4">
        <p className="text-body-md text-muted-foreground">Edited as data. Nothing evaluates the rule and nothing is sent.</p>
        <div className="flex flex-wrap gap-2">
          <button type="button" className={cn(BUTTON, "min-h-11 rounded-full px-4 text-body-md md:min-h-9")} onClick={() => setDraft(BLANK)}>
            Start from a blank rule
          </button>
          <button type="button" className={cn(BUTTON, "min-h-11 rounded-full px-4 text-body-md md:min-h-9")} onClick={() => setDraft(agritechRule)}>
            Reset to the Zone 3 rule
          </button>
        </div>

        <AutomationBuilder
          value={draft}
          onChange={setDraft}
          subjects={SUBJECTS}
          targets={TARGETS}
          labelFor={nameOf}
          submitLabel="Save as demo state"
          onSubmit={(rule) => setSaved(rule)}
        />
      </div>

      {saved ? (
        <div role="status" className="flex flex-col gap-3 rounded-2xl bg-card p-4 shadow-sm sm:p-6">
          <StateBadge state="confirmed" className="text-title-md">
            Saved as demo state — nothing is executed.
          </StateBadge>
          <p className="text-body-md text-muted-foreground">
            The rule is {saved.enabled ? "enabled" : "disabled"}: {summarizeAutomationRule(saved, { label: nameOf })}
          </p>
          <AutomationRuleView rule={saved} labelFor={nameOf} hideSummary />
        </div>
      ) : null}
    </section>
  );
}

apps/web/src/examples/iot/automation-builder.tsx

[06]Pairing

Pairing is a product pattern, not a Bluetooth API.

7 stages, 5 ways a person may be asked to connect, and a registry of 13 ways it goes wrong, each with what to say and what to offer next. It models the screen so it can be built and reviewed before any transport exists. It discovers nothing and connects to nothing.

Pairing flow

Nine steps from choosing a method to done, driven by the package's pairing state machine, with real failure and recovery states.

Built from 3 parts
  • PairingMethodPicker
  • PairingStepper
  • PairingFailure
SimulatedSimulated — no device is contacted. Readings, commands and delays are scripted in your browser; nothing is sent over a network.
/**
 * A nine-step pairing reference. The steps a person sees:
 *
 *   1 choose a method   2 searching   3 device found   4 identify   5 verify it is yours
 *   6 configure         7 choose a place   8 check it works   9 done
 *
 * The state machine is the package's, not this file's: `advancePairing` / `transitionPairing` decide
 * what may follow what, and this component only reports intent to it. "Searching" and "found" are two
 * screens inside the machine's one `discover` stage.
 *
 * NO Bluetooth, Wi-Fi or network scanning happens. Durations come from a fixture and run on a small
 * timer here. Under `prefers-reduced-motion` there are no timers: each simulated wait is finished by a
 * button instead, so nothing advances on its own.
 */
const UI_STEPS = ["Choose a method", "Searching", "Device found", "Identify", "Verify it is yours", "Configure", "Choose a place", "Check it works", "Done"] as const;
const PLACES = ["Living room", "Hallway", "Kitchen", "Office", "Bedroom", "Zone 2", "Zone 3", "Utility yard"];
const DEMO_CODE = "KX2468";
/** The step's one primary action, a filled pill; everything else is quiet. */
const ACTION =
  "inline-flex min-h-11 items-center justify-center self-start rounded-full bg-primary px-6 text-body-md font-medium text-primary-foreground shadow-sm hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none";
const QUIET = cn(BUTTON, "min-h-11 rounded-full bg-card px-5 text-body-md shadow-sm");
/** Stages that begin with a simulated wait; the others begin by asking the person for something. */
const STARTS_WORKING: readonly KinetixPairingStage[] = ["discover", "identify", "verify"];
const durationOf = (stage: KinetixPairingStage) => pairingHappyPath.find((step) => step.stage === stage)?.durationMs ?? 1000;

function uiStepOf(flow: KinetixPairingFlowState, working: boolean): number {
  if (flow.status === "idle" || flow.status === "cancelled") return 0;
  switch (flow.stage) {
    case "discover":
      return working || flow.status === "failed" ? 1 : 2;
    case "identify":
      return 3;
    case "authenticate":
      return 4;
    case "configure":
      return 5;
    case "assign":
      return 6;
    case "verify":
      return 7;
    default:
      return 8;
  }
}

export function PairingFlowExample() {
  const reducedMotion = useReducedMotion();
  const [flow, setFlow] = React.useState<KinetixPairingFlowState>(startPairingFlow);
  // `visit` counts every flow change, so "which wait am I in" can never leak from an earlier visit to a stage.
  const [visit, setVisit] = React.useState(0);
  const [waiting, setWaiting] = React.useState<{ visit: number; working: boolean } | null>(null);
  const [method, setMethod] = React.useState<KinetixPairingMethod>("bluetooth");
  const [scenarioId, setScenarioId] = React.useState("none");
  const [recovered, setRecovered] = React.useState(false);
  const [foundId, setFoundId] = React.useState<string | null>(null);
  const [code, setCode] = React.useState("");
  const [name, setName] = React.useState("");
  const [place, setPlace] = React.useState(PLACES[0]!);
  const [note, setNote] = React.useState("");
  const headingRef = React.useRef<HTMLHeadingElement>(null);
  const failureRef = React.useRef<HTMLDivElement>(null);
  const mounted = React.useRef(false);

  const scenario = pairingFailureScenarios.find((s) => s.id === scenarioId);
  const found = pairingDevices[flow.method ?? method].find((d) => d.id === foundId) ?? pairingDevices[flow.method ?? method][0]!;
  const acceptedCode = found.code ?? DEMO_CODE;
  const codeLength = normalizePairingCode(acceptedCode).length;
  const codeShapeOk = validatePairingCode(code, { length: codeLength });

  const working = waiting?.visit === visit ? waiting.working : STARTS_WORKING.includes(flow.stage) && flow.status === "active";
  const uiStep = uiStepOf(flow, working);
  const started = flow.status !== "idle";

  /** Every change to the flow goes through here, and always through the package's machine. */
  const apply = (next: KinetixPairingFlowState) => {
    setFlow(next);
    setVisit((v) => v + 1);
    setNote("");
  };
  const send = (event: KinetixPairingEvent) => apply(advancePairing(flow, event));
  const replayTo = (stage: KinetixPairingStage) => {
    let state = advancePairing(startPairingFlow(), { type: "start", method: flow.method ?? method });
    while (state.stage !== stage && state.status === "active") state = advancePairing(state, { type: "next" });
    apply(state);
  };
  const setWorking = (value: boolean) => setWaiting({ visit, working: value });

  /** A simulated wait has finished: the scripted failure strikes here, or the flow moves on. */
  const workDone = () => {
    if (flow.status !== "active") return;
    const struck = scenario && !recovered && scenario.stage === flow.stage;
    if (struck) return send({ type: "fail", code: scenario.code });
    if (flow.stage === "authenticate" && normalizePairingCode(code) !== normalizePairingCode(acceptedCode)) {
      return send({ type: "fail", code: "authentication-failed" });
    }
    if (flow.stage === "discover" || flow.stage === "identify") return setWorking(false);
    send({ type: "next" });
  };

  React.useEffect(() => {
    if (!working || flow.status !== "active" || reducedMotion) return;
    const timer = setTimeout(workDone, durationOf(flow.stage));
    return () => clearTimeout(timer);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [working, visit, reducedMotion]);

  React.useEffect(() => {
    if (!mounted.current) {
      mounted.current = true;
      return;
    }
    (flow.status === "failed" ? failureRef.current : headingRef.current)?.focus();
  }, [uiStep, flow.status, visit]);

  const recover = (kind: KinetixPairingRecoveryKind) => {
    const strikes = !!scenario && scenario.stage === flow.stage;
    if (kind === "retry") {
      if (strikes) setRecovered(true);
      setCode("");
      return send({ type: "retry" });
    }
    if (kind === "back") return send({ type: "back" });
    if (kind === "cancel") return send({ type: "cancel" });
    if (kind === "reset") {
      setRecovered(true);
      return replayTo("discover");
    }
    if (kind === "update") {
      setRecovered(true);
      return replayTo(flow.stage);
    }
    setNote(kind === "contact" ? "In a product this would open help. Nothing happens in this demo." : "In a product this would open settings. Nothing happens in this demo.");
  };

  const startOver = () => {
    setFlow(startPairingFlow());
    setVisit((v) => v + 1);
    setRecovered(false);
    setCode("");
    setNote("");
    setFoundId(null);
  };

  const pickScenario = (id: string) => {
    setScenarioId(id);
    setRecovered(false);
    const next = pairingFailureScenarios.find((s) => s.id === id);
    if (next) setMethod(next.method);
  };
  const backAllowed = transitionPairing(flow, { type: "back" }).ok;
  const cancelAllowed = transitionPairing(flow, { type: "cancel" }).ok;

  const heading = flow.status === "cancelled" ? "Setup cancelled" : `Step ${uiStep + 1} of ${UI_STEPS.length}: ${UI_STEPS[uiStep]}`;

  return (
    <section aria-label="Pairing flow" className="flex max-w-3xl flex-col gap-4 sm:gap-6">
      <SimNotice text="Simulated — no Bluetooth, Wi-Fi or network scanning happens. Devices, codes and delays are scripted fixtures in your browser." />

      <label className="flex flex-col gap-1 text-body-md text-foreground">
        Simulate a failure
        <select
          value={scenarioId}
          disabled={started && flow.status !== "cancelled"}
          onChange={(event) => pickScenario(event.target.value)}
          className="min-h-11 rounded-md border border-input bg-background px-2 text-label-md text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
        >
          <option value="none">None: pair successfully</option>
          {pairingFailureScenarios.map((s) => (
            <option key={s.id} value={s.id}>
              {s.title} ({describePairingMethod(s.method)}, {s.code})
            </option>
          ))}
        </select>
        {scenario ? <span className="text-body-sm text-muted-foreground">{scenario.recoveryOutcome}</span> : null}
      </label>

      <PairingStepper flow={flow} horizontal />

      <div className="flex min-w-0 flex-col gap-4 rounded-2xl bg-card p-4 shadow-sm sm:p-6">
        <h4 ref={headingRef} tabIndex={-1} className="text-title-lg text-foreground focus-visible:outline-none">
          {heading}
        </h4>

        {flow.status === "failed" ? (
          <PairingFailure ref={failureRef} tabIndex={-1} code={flow.failure} onAction={(_id, action) => recover(action.kind)} className="focus-visible:outline-none" />
        ) : null}

        {flow.status === "cancelled" ? (
          <>
            <p className="text-body-md text-muted-foreground">Nothing was set up, and nothing was stored.</p>
            <button type="button" className={ACTION} onClick={startOver}>
              Start again
            </button>
          </>
        ) : null}

        {flow.status === "idle" ? (
          <>
            <PairingMethodPicker
              value={method}
              methods={KINETIX_PAIRING_METHODS}
              onChange={(next) => {
                setMethod(next);
                if (scenario && scenario.method !== next) setScenarioId("none");
              }}
            />
            <button type="button" className={ACTION} onClick={() => send({ type: "start", method })}>
              Start setup
            </button>
          </>
        ) : null}

        {flow.status === "active" && working ? (
          <>
            <p className="text-body-md text-muted-foreground">
              {pairingHappyPath.find((s) => s.stage === flow.stage)?.label}… (simulated, about {(durationOf(flow.stage) / 1000).toFixed(1)} s)
            </p>
            {reducedMotion ? (
              <button type="button" className={ACTION} onClick={workDone}>
                Finish this step
              </button>
            ) : null}
          </>
        ) : null}

        {flow.status === "active" && !working && flow.stage === "discover" ? (
          <fieldset className="m-0 flex min-w-0 flex-col gap-2 border-0 p-0">
            <legend className="mb-1 text-title-md text-foreground">
              {pairingDevices[flow.method!].length} sample {pairingDevices[flow.method!].length === 1 ? "device" : "devices"} found (fixture data)
            </legend>
            {pairingDevices[flow.method!].map((device) => (
              <label key={device.id} className="flex min-h-11 items-center gap-2 rounded-xl bg-muted/60 px-4 text-body-md text-foreground has-[:checked]:bg-primary/10 has-[:checked]:ring-2 has-[:checked]:ring-primary">
                <input type="radio" name="found-device" className="size-5" checked={found.id === device.id} onChange={() => setFoundId(device.id)} />
                <span className="min-w-0 break-words">
                  {device.name} <span className="text-muted-foreground">· {device.hint}</span>
                </span>
              </label>
            ))}
            <button type="button" className={ACTION} onClick={() => send({ type: "next" })}>
              Continue with {found.name}
            </button>
          </fieldset>
        ) : null}

        {flow.status === "active" && !working && flow.stage === "identify" ? (
          <>
            <p className="text-body-md text-foreground">
              Does <strong className="font-medium">{found.hint}</strong> match the label on your {found.name}?
            </p>
            <button type="button" className={ACTION} onClick={() => send({ type: "next" })}>
              Yes, that is my device
            </button>
          </>
        ) : null}

        {flow.status === "active" && !working && flow.stage === "authenticate" ? (
          <form
            className="flex min-w-0 flex-col gap-2"
            onSubmit={(event) => {
              event.preventDefault();
              if (codeShapeOk) setWorking(true);
            }}
          >
            <label htmlFor="pairing-code" className="text-body-md text-foreground">
              Setup code
            </label>
            <input
              id="pairing-code"
              value={code}
              onChange={(event) => setCode(event.target.value)}
              autoComplete="off"
              spellCheck={false}
              aria-describedby="pairing-code-hint"
              className="min-h-11 max-w-xs rounded-md border border-input bg-background px-3 font-mono text-label-md text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            />
            <p id="pairing-code-hint" className="text-body-sm text-muted-foreground">
              {codeLength} characters. Demo code: <span className="font-mono text-foreground">{acceptedCode}</span>. The shape is checked here; only the device can say whether it is the right code.
            </p>
            <button type="submit" disabled={!codeShapeOk} className={ACTION}>
              Verify code
            </button>
          </form>
        ) : null}

        {flow.status === "active" && !working && flow.stage === "configure" ? (
          <form
            className="flex min-w-0 flex-col gap-2"
            onSubmit={(event) => {
              event.preventDefault();
              setWorking(true);
            }}
          >
            <label htmlFor="pairing-name" className="text-body-md text-foreground">
              Device name
            </label>
            <input
              id="pairing-name"
              value={name || found.name}
              onChange={(event) => setName(event.target.value)}
              className="min-h-11 max-w-xs rounded-md border border-input bg-background px-3 text-label-md text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            />
            <button type="submit" className={ACTION}>
              Apply settings
            </button>
          </form>
        ) : null}

        {flow.status === "active" && !working && flow.stage === "assign" ? (
          <form
            className="flex min-w-0 flex-col gap-2"
            onSubmit={(event) => {
              event.preventDefault();
              setWorking(true);
            }}
          >
            <label htmlFor="pairing-place" className="text-body-md text-foreground">
              Room or zone
            </label>
            <select
              id="pairing-place"
              value={place}
              onChange={(event) => setPlace(event.target.value)}
              className="min-h-11 max-w-xs rounded-md border border-input bg-background px-2 text-label-md text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              {PLACES.map((p) => (
                <option key={p}>{p}</option>
              ))}
            </select>
            <button type="submit" className={ACTION}>
              Place it here
            </button>
          </form>
        ) : null}

        {flow.status === "complete" ? (
          <>
            <p className="text-body-md text-foreground">
              {name || found.name} is set up in {place}. It responded when checked.
            </p>
            <p className="text-body-sm text-muted-foreground">Simulated: no device was paired, connected to or stored.</p>
            <button type="button" className={ACTION} onClick={startOver}>
              Pair another device
            </button>
          </>
        ) : null}

        {note ? <p className="text-body-sm text-muted-foreground">{note}</p> : null}
      </div>

      {started && flow.status !== "complete" && flow.status !== "cancelled" ? (
        <div className="flex flex-wrap gap-2">
          <button type="button" className={QUIET} disabled={!backAllowed} onClick={() => send({ type: "back" })}>
            Back
          </button>
          <button type="button" className={QUIET} disabled={!cancelAllowed} onClick={() => send({ type: "cancel" })}>
            Cancel setup
          </button>
        </div>
      ) : null}
    </section>
  );
}

apps/web/src/examples/iot/pairing-flow.tsx

[07]Device detail

One device, in depth.

The flagship composition: a pump station with Overview, Controls, Telemetry, Automations, Activity and Settings, built entirely from the parts above. It is source you copy, not a component you install, because a screen layout is where your product’s decisions live.

Device detail

The flagship: one pump in full, with Overview, Controls, Telemetry, Automations, Activity and Settings tabs, and every command showing requested against confirmed.

Built from 12 parts
  • DeviceStatusBadge
  • LastSync
  • ConnectionHealth
  • TelemetryTrend
  • AlertList
  • DevicePowerControl
  • RoutineCard
  • AutomationRuleView
  • ActivityTimeline
  • CommandLifecycle
  • SpaceBreadcrumb
  • TelemetryMetric
SimulatedSimulated — no device is contacted. Readings, commands and delays are scripted in your browser; nothing is sent over a network.
/**
 * One device as a product view rather than a settings page. The device is the pump station of the farm
 * scenario, and the page is composed in two zones:
 *
 *   - the ZONE OF ACTION: who the device is, the one control that matters, and what it is measuring;
 *   - the ZONE OF CONTEXT: what needs attention, what happened, what is scheduled, how healthy it is.
 *
 * Below `lg` the two zones become one designed column: the control first, the readings second, and the
 * rest behind disclosures that say how much is inside. The large "Running / Stopped" word is the
 * CONFIRMED state. A request that has not landed appears beneath it as its own dashed, worded
 * treatment, and never replaces it.
 *
 * SIMULATED. Nothing configures hardware and nothing is sent; the routines are SHOWN, never executed.
 */
const DEVICE_ID = "pump-01";
const METRICS = [
  { id: "flow", label: "Flow" },
  { id: "pressure", label: "Pressure" },
] as const;

export function DeviceDetailExample() {
  const iot = useIotSimulation(agritech, { intervalMs: 1000 });
  const { sim } = iot;
  const [metric, setMetric] = React.useState<(typeof METRICS)[number]["id"]>("flow");

  const device = deviceOf(sim, DEVICE_ID);
  const state = selectDeviceState(sim, DEVICE_ID)!;
  const power = controlOf(iot, DEVICE_ID, "power");
  const tree = React.useMemo(() => buildSpaceTree(sim.scenario.spaces), [sim.scenario.spaces]);
  const home = sim.scenario.spaces.find((space) => space.deviceIds?.includes(DEVICE_ID));

  // CONFIRMED: what the pump last reported. `requested` is separate and only ever shown as a request.
  const running = state.confirmedValues.power === "on";
  const online = device.status === "online";
  const requestedPower = power.requested as "on" | "off" | undefined;
  const alerts = state.alerts.filter((alert) => !alert.resolvedAt);
  const routines = sim.automations.filter((automation) => automation.actions?.toLowerCase().includes("pump"));
  const rules = sim.scenario.rules.filter((rule) => rule.actions.some((action) => action.target === DEVICE_ID));
  const activity = selectActivity(sim, { deviceId: DEVICE_ID });
  const health: ShowcaseState = !online ? "offline" : state.health.level === "critical" ? "critical" : state.health.level === "warning" ? "warning" : "confirmed";
  const flow = readingOf(iot, DEVICE_ID, "flow");
  const pressure = readingOf(iot, DEVICE_ID, "pressure");

  return (
    <article aria-label={`${device.name} detail`} className="flex flex-col gap-4 sm:gap-6">
      <SimNotice scenario={agritech}>
        <SimTransport iot={iot} />
      </SimNotice>

      {/* ------------------------------------------------------------------ identity */}
      <header className="flex min-w-0 flex-col gap-3">
        <div className="flex min-w-0 items-center gap-4">
          <span
            aria-hidden="true"
            className={cn(
              "flex size-16 shrink-0 items-center justify-center rounded-2xl p-2 transition-colors duration-fast motion-reduce:transition-none sm:size-28",
              running && online ? "bg-primary/10" : "bg-card shadow-sm",
            )}
          >
            <DeviceIllustration category={resolveDeviceCategory(device)} on={running && online} size="lg" className="size-full" />
          </span>
          <div className="flex min-w-0 flex-col gap-1">
            {/* Wraps rather than truncates: at 320 the illustration left too little room and the name was cut. */}
            <h4 className="break-words text-headline-md text-foreground">{device.name}</h4>
            {home ? <SpaceBreadcrumb path={spacePath(tree, home.id)} label={`Location of ${device.name}`} /> : null}
            <p className="flex flex-wrap items-center gap-x-4 gap-y-2 text-body-md">
              <DeviceStatusBadge status={device.status} />
              <span className="text-muted-foreground">
                {/* LastSync's own accessible label is the whole sentence ("Last seen 4 seconds ago"); the
                    visible prefix is hidden from assistive technology so it is not read twice. */}
                <span aria-hidden="true">Last seen </span>
                <LastSync value={device.lastSeenAt} now={sim.now} />
              </span>
              {alerts.length > 0 ? (
                <StateBadge state="warning">
                  {alerts.length} open {alerts.length === 1 ? "alert" : "alerts"}
                </StateBadge>
              ) : null}
            </p>
          </div>
        </div>
      </header>

      <div className="grid min-w-0 gap-4 sm:gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)] lg:items-start">
        {/* ============================================================ zone of action */}
        <div className="flex min-w-0 flex-col gap-4 sm:gap-6">
          <section aria-label="Pump control" className="flex min-w-0 flex-col gap-5 rounded-2xl bg-card p-4 shadow-sm sm:p-6">
            <div className="flex min-w-0 flex-col gap-1">
              <div className="flex flex-wrap items-center justify-between gap-2">
                <p className="text-body-md text-muted-foreground">Pump status</p>
                <StateBadge state={online ? "confirmed" : "offline"}>{online ? "Confirmed by the pump" : "Last known, pump offline"}</StateBadge>
              </div>
              {/* The word "Running" is the CONFIRMED state. A request in flight is never shown here. */}
              {/* The request sits under the confirmed word as a full-width block, not beside it as a pill:
                  at phone width a rounded pill wrapped its sentence into a lozenge, and this has to stay
                  readable to stay honest. */}
              <p className="flex min-w-0 flex-col items-start gap-2 text-display-sm text-foreground">
                <span>{running ? "Running" : "Stopped"}</span>
                {requestedPower !== undefined ? (
                  <span className="flex min-w-0 items-start gap-2 self-stretch rounded-xl border-2 border-dashed border-primary bg-primary/10 p-3 text-body-md font-medium text-foreground">
                    <StateGlyph state="pending" className="mt-0.5 text-primary" />
                    {power.format(requestedPower)} requested, not yet confirmed. The pump still reports {running ? "running" : "stopped"}.
                  </span>
                ) : null}
              </p>
              <p className="text-body-md text-muted-foreground">{online ? `As last reported by the pump. ${describeConnectivity(state.connectivity.state)}.` : "Offline. Showing the last known state."}</p>
            </div>

            <div className="flex min-w-0 flex-col gap-4 rounded-xl bg-muted/60 p-4">
              <div className="flex min-h-11 min-w-0 flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between sm:gap-x-4">
                <span className="text-title-md text-foreground">Pump power</span>
                <DevicePowerControl
                  state={power.confirmed as "on" | "off"}
                  requested={requestedPower}
                  control={power.control}
                  label={`${device.name} power`}
                  size="lg"
                  onToggle={power.send}
                />
              </div>
              {power.command && power.unsettled ? (
                <CommandLifecycle lifecycle={power.command.lifecycle} formatValue={power.format} onRetry={power.retry} onCancel={power.cancel} density="compact" />
              ) : null}
            </div>
          </section>

          <section aria-label="Readings" className="flex min-w-0 flex-col gap-4 rounded-2xl bg-card p-4 shadow-sm sm:p-6">
            <div className="grid gap-3 sm:grid-cols-2">
              {/* One step below the confirmed "Running": the readings are what the pump measures, not what it is. */}
              <TelemetryMetric {...flow} label="Flow" size="lg" className="rounded-xl bg-muted/60 p-4" />
              <TelemetryMetric {...pressure} label="Pressure" size="lg" className="rounded-xl bg-muted/60 p-4" />
            </div>
            <div className="flex min-w-0 flex-col gap-3">
              <div className="flex flex-wrap items-center justify-between gap-3">
                <h5 className="text-title-md text-foreground">Last 24 hours</h5>
                <PillSelector label="Chart metric" options={METRICS} value={metric} onChange={(id) => setMetric(id as (typeof METRICS)[number]["id"])} className="rounded-full bg-muted/60 p-1" />
              </div>
              <TelemetryTrend {...trendOf(iot, DEVICE_ID, metric)} label={metric === "flow" ? "Pump flow, last 24 hours" : "Pump pressure, last 24 hours"} height={160} dataTable />
              {metric === "flow" ? <p className="text-body-sm text-muted-foreground">The band below 20 L/min is the low-flow limit. Gaps are drawn as gaps.</p> : null}
            </div>
          </section>

          <Disclosure title="About this device" defaultOpen={false}>
            <dl className="m-0 grid grid-cols-2 gap-x-6 gap-y-4 text-body-md">
              {(
                [
                  ["Device id", device.id],
                  ["Type", device.type],
                  ["Firmware", device.firmwareVersion ?? "Not reported"],
                  ["Site", device.site ?? "Not set"],
                  ["Zone", device.zone ?? "Not set"],
                  ["Battery", device.battery === undefined ? "Not reported" : `${device.battery}%`],
                ] as const
              ).map(([term, value]) => (
                <div key={term} className="min-w-0">
                  <dt className="text-body-sm text-muted-foreground">{term}</dt>
                  <dd className="m-0 break-words text-foreground">{value}</dd>
                </div>
              ))}
            </dl>
            <p className="text-body-sm text-muted-foreground">Read-only. Nothing here configures hardware; it presents what the product knows.</p>
          </Disclosure>
        </div>

        {/* ============================================================ zone of context */}
        <div className="flex min-w-0 flex-col gap-4 sm:gap-6">
          <Disclosure title="Alerts" count={alerts.length} countNoun="open alerts" defaultOpen={alerts.length > 0 ? true : undefined}>
            <AlertList alerts={alerts} deviceName={() => undefined} onAcknowledge={(alert) => iot.acknowledgeAlert(alert.id)} now={sim.now} emptyLabel="No open alerts." hideSummary variant="list" />
          </Disclosure>

          <Disclosure title="Health and connection">
            <div className="flex flex-col gap-1">
              <p className="text-title-md text-foreground">
                {describeDeviceHealth(state.health.level)} · {describeConnectivity(state.connectivity.state)}
              </p>
              <p className="flex items-center gap-2 text-body-md text-muted-foreground">
                <StateBadge state={health}>{state.health.reasons.length === 0 ? "No issues reported." : state.health.reasons.map((reason) => reason.message).join(", ")}</StateBadge>
              </p>
            </div>
            <ConnectionHealth device={device} now={sim.now} />
          </Disclosure>

          <Disclosure title="Automation" count={routines.length + rules.length} countNoun="routines and rules">
            <p className="text-body-sm text-muted-foreground">Shown, not executed: KinetixUI has no automation engine.</p>
            {routines.map((automation) => (
              <RoutineCard key={automation.id} automation={automation} now={sim.now} />
            ))}
            {rules.map((rule) => (
              <AutomationRuleView key={rule.id} rule={rule} />
            ))}
            {routines.length + rules.length === 0 ? <p className="text-body-md text-muted-foreground">No routine or rule involves this pump.</p> : null}
          </Disclosure>

          <Disclosure title="Activity" count={activity.length} countNoun="events">
            <ActivityTimeline events={activity} deviceName={() => undefined} now={sim.now} label={`${device.name} activity`} variant="blocks" />
          </Disclosure>
        </div>
      </div>
    </article>
  );
}

apps/web/src/examples/iot/device-detail.tsx

[08]More layoutsdemo state only

Other arrangements, one at a time.

Earlier compositions, kept because they answer different questions: a fleet overview, a dashboard, a telemetry board, an alert inbox and a troubleshooting grid. The controls change local demo state and nothing else. There is no device, no broker and no request.

Device overview

A fleet at a glance: identity, state, key reading, battery, signal and last contact, worst first.

Built from 2 parts
  • DeviceCard
  • DeviceStateSummary

Cold chain · Site 4

6 devices: 1 error, 1 offline, 1 data is stale, 1 updating, 2 online

Air quality sensorPacking hall
Error
PM2.5UnknownNo reading
Yard probeYard
Offline
TemperatureUnknownNo reading
Loading bay probeBay 2
Data is stale
Temperature6.4 °C
Energy meterPlant room
Updating
Load—Not current while updating
Cold store probeCold store A
Online
Temperature3.8 °C
Site gatewayPlant room
Online
export function DeviceFleetExample() {
  // Worst first. The ordering comes from KINETIX_DEVICE_STATUSES via compareDeviceAttention, so the
  // list and the badges agree about which state is worse instead of holding two opinions.
  const devices = [...DEMO_DEVICES].sort(compareDeviceAttention);

  return (
    <section aria-label="Device fleet" className="flex flex-col gap-4 sm:gap-6">
      <header className="flex flex-wrap items-baseline justify-between gap-3">
        <h3 className="text-headline-sm text-foreground">Cold chain · Site 4</h3>
        <DeviceStateSummary devices={devices} />
      </header>

      <div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,17rem),1fr))] gap-3">
        {devices.map((device) => (
          <DeviceCard
            key={device.id}
            device={device}
            reading={DEMO_READINGS[device.id]}
            now={DEMO_NOW}
          />
        ))}
      </div>
    </section>
  );
}

apps/web/src/examples/iot/device-fleet.tsx

[09]Architecture

Bring your own connection.

KinetixUI IoT starts above the transport. Your application receives device state through whatever it already uses, an adapter turns that into the module’s shapes, and everything from there down is state, controls and patterns.

The protocols in the diagram are examples of infrastructure your application might use. This module opens no connection, holds no credential and parses no device payload.

Read the transport boundary in the docs

Where KinetixUI IoT sits, from the application down to the interface. Everything above the boundary is yours.
  1. Application and provider layerYours

    Wherever device state actually comes from. These are examples of what your application might use; KinetixUI ships none of them.

    • MQTT
    • BLE
    • Matter
    • REST
    • WebSocket
    • Vendor SDK
    • Cloud service
  2. Application adapterYours

    Your code turns whatever the provider sends into KinetixUI's shapes: a device with a status, a reading with a timestamp, a command that was confirmed or was not. It also carries intent the other way, from a callback to the provider.

    The boundary: KinetixUI does not own the transport.

  3. @kinetixui/iot/functionsKinetixUI IoT

    The state model as pure functions: device status and health, telemetry quality and thresholds, the command lifecycle, pairing stages, automation rules, the space hierarchy. No React, no DOM, no clock unless you pass one.

  4. @kinetixui/iot/reactKinetixUI IoT

    Primitives, controls and patterns that draw that state, and report intent through callbacks. They send nothing.

  5. CompositionsYours

    Whole screens assembled from those parts, published as source on this site to copy into your own product. Your layout, your copy, your decisions.

[10]What ships0.2.0

Two layers. The useful half has nothing to do with rendering.

The package is 3 entry points over one codebase. The functions half imports no React and touches no DOM, so it runs in a server route, a worker or a test. That is asserted against the source import graph and the built output, not promised.

Functions and models

ts
import {
  classifyBatteryLevel,
  evaluateReading,
  normalizeDeviceStatus,
} from "@kinetixui/iot/functions";

classifyBatteryLevel(72);        // "high"
normalizeDeviceStatus("ONLINE"); // "online"
evaluateReading({ value: null, metric: "temperature" }).state; // "unavailable"
Device state
Status, health and connectivity kept apart, and a normaliser for whatever spelling a backend sends.
Command lifecycle
Requested, acknowledged, confirmed, timed out, unreachable, retrying — a machine that refuses an illegal step.
Telemetry
A metric registry, thresholds, staleness, gaps, and the sentence a screen reader gets for a series.
Alerts and activity
Severity, acknowledgement, and the reduction a list needs: which of forty devices is asking for help.
Automation rules
A rule model with validation and a plain-language summary. It describes a rule; nothing evaluates one.
Pairing
Stages, methods and a failure registry with recovery actions. No discovery and no connection.
Places
A space tree with a health rollup, for a home, a farm or a factory without hard-coding any of them.
Battery, signal, firmware, energy
Bands and comparisons that keep “not reported” apart from a number.

React components

Module components, not entries in the component catalogue. They are React only.

Primitives
8

One fact, rendered: a status, a battery, a signal, a timestamp, a reading, an identity.

Controls
4

One thing a user can change: power, level, setpoint, mode.

Patterns
27

Compositions with a rule or two: cards, lists, telemetry, alerts, automation, pairing, places, energy and a camera that is never a feed.

Full props and behaviour are in the IoT documentation.

[11]Missing datathe rule

“We do not know” survives.

A device that does not report a battery is not a device with a flat one. Each of these is a place where a convenient default becomes a lie the interface tells about someone’s hardware, sounknown is a first-class result rather than a fallback.

What the interface shows when device data is missing, compared with the misleading alternative
The factMisleadingWhat KinetixUI IoT renders
The device reports no batteryBattery 0%
The device reports no signalNo signal
The reading has no timestampJust now
The sensor did not answer0 °C
TemperatureUnknownNo reading

This is a rule about presentation, not a data-quality guarantee: the module cannot tell you whether a reading is correct, only refuse to present an absent one as a measurement.

Try it

Set a battery nobody reported, drag a reading three days into the past, tell the sensor it errored. The point is what the interface says when the data is imperfect.

Environment Sensor

env-sensor-04

Online
DeviceStatusBadge
Online
BatteryIndicator
SignalStrength
LastSync
SensorReading
Temperature23.4 °C

Every control sets a prop on a real primitive. Nothing here connects to a device — see Bring your own connection below.

[12]Accessibility, RTL and motion

A green dot is not a device state.

Device dashboards lean on colour harder than almost any other interface, and colour is the one channel that disappears in greyscale, in forced-colors mode and for a screen reader. Every state here is a glyph with its own silhouette and a word; colour is only ever a second encoding.

Where a visual shorthand is used, such as a battery bar or 5m ago, the visual is hidden from assistive technology and the full sentence is the accessible name. Nothing is announced twice.

Right-to-left

The components use logical properties (start and end, not left and right), so a right-to-left page mirrors without a second stylesheet. The power switch reverses its knob travel. Time-series plots keep a left-to-right time axis, and identifiers, code and units stay left-to-right inside right-to-left text.

Motion

Motion uses Tailwind’s own motion-reduce: variant, so a reader who asks for reduced motion gets the static version, and no animation carries information that is not also written down. In the simulations, reduced motion stops ambient drift; a request you make still settles.

What assistive technology receives

Online
Online
Battery 72%, high
Signal 84%, excellent
Last seen 5 minutes ago
Temperature23.4 °C
Temperature 23.4 °C

Missing values are stated rather than implied: Battery level unknown, Never seen.

[13]Install@kinetixui/iot

One package, 3 entry points.

bash
pnpm add @kinetixui/iot

react is an optional peer (>=18). The components need it; the functions and models need nothing at all, so a server-side or worker consumer can install this package on its own.

The components style themselves with Tailwind utilities on the token contract rather than importing a stylesheet, so an app needs the tokens loaded and this package inside its Tailwind content. The functions subpath needs neither; the docs have the exact config.

ts
// models and pure functions — no React required
import { classifyBatteryLevel } from "@kinetixui/iot/functions";

// the React components
import { DeviceStatusBadge } from "@kinetixui/iot/react";

// or everything, from the root
import { SensorReading, formatLastSeen } from "@kinetixui/iot";
  • @kinetixui/ioteverything
  • @kinetixui/iot/functionsmodels and pure functions, no React
  • @kinetixui/iot/reactprimitives, controls and patterns
[14]Roadmapno dates

What exists, what does not, and what is only open.

Nothing outside the first column is scheduled. The second column is a set of decisions, not a backlog; the third is work that is deliberately waiting on something.

In the module

In the module
  • 8 primitives, 4 controls and 27 patterns for React
  • Models and pure functions for device state, commands, telemetry, alerts, automation rules, pairing and places
  • Three reference environments on this page, as source to copy
  • Status that is a glyph and a word, never colour alone

Not in the module, by design

Excluded
  • A transport of any kind: no protocol client, no discovery, no vendor adapter
  • An automation engine: rules are edited here and run by your application
  • Video: a camera card is a poster and a state, never a feed
  • Any implementation outside React

Deferred, not scheduled

Deferred
  • A grouped or scheduled command queue, which needs a real product's requirements before it is modelled
  • Optimistic updates with rollback as an opt-in, which needs a rollback story before it is safe to offer

The reference, the models, and every prop.

The documentation carries the type definitions, the command lifecycle, the metric and failure registries, the accessibility contract and the module’s stated limits.