/* global React, CloudGate, signalR, Ic */
// =================== REMOTE VIEW (Phase 2 — Dashboard + Inventory) ===================
// Full-screen overlay that mirrors a connected OmniGate / GateScan host.
// Architecture:
//   1. Open: fetch a JWT from /api/remote/issue-jwt, connect to /peer, call
//      SubscribeToHost(hardwareId).  The hub forwards a "subscriberJoined"
//      frame to the host which then sends us back a "config" frame with
//      every device + gateway in its registry.
//   2. From then on, every state change on the host streams in as one of:
//        prop          — single property change on a device, gateway, or
//                        URA4 model object.
//        tags          — batched array of newly-read RFID tags (100 ms
//                        windows).  Singular legacy "tag" frames still
//                        accepted.
//        clearTags     — operator hit Clear on a device.
//        roomState     — hub-emitted: who's the controller, who's watching.
//   3. The user's button clicks send "cmd" frames back through the hub.
//      Hub gates these: only the controller's commands are forwarded to
//      the host; observers' commands are dropped at the hub.
//
// View modes:
//   "dashboard"        — devices grid + gateways grid (the WPF dashboard).
//   { kind:'inventory', instanceId } — per-device tag table + counters +
//                                       Start / Stop / Clear (controller only).
const { useState: useStateR, useEffect: useEffectR, useRef: useRefR, useMemo: useMemoR } = React;

function RemoteView({ reader, onClose }) {
  // ── Connection lifecycle state ────────────────────────────────────────
  // phase: 'idle' → 'fetching' → 'connecting' → 'subscribed' → 'error' / 'closed'
  const [phase, setPhase] = useStateR('idle');
  const [error, setError] = useStateR(null);

  // ── Host snapshot state ───────────────────────────────────────────────
  // Updated by every incoming sync frame.  Stored as plain objects keyed by
  // InstanceId so prop updates can find their target in O(1).
  const [devices,      setDevices]      = useStateR({});      // { instanceId → device }
  const [gateways,     setGateways]     = useStateR({});      // { instanceId → gateway }
  const [tagsByDevice, setTagsByDevice] = useStateR({});      // { instanceId → [{epc, …}] }
  const [roomState,    setRoomState]    = useStateR(null);    // last roomState frame

  // ── UI navigation state ───────────────────────────────────────────────
  const [view, setView] = useStateR({ kind: 'dashboard' });

  // ── Refs ──────────────────────────────────────────────────────────────
  const connRef       = useRefR(null);
  const myConnIdRef   = useRefR(null);     // our own ConnectionId for is-controller check

  // ───────────────────────────────────────────────────────────────────────
  // Frame handlers — pure state-update reducers.  Kept inline so the
  // closure captures the latest setState refs without re-wiring on every
  // render.
  // ───────────────────────────────────────────────────────────────────────
  const handleConfig = (doc) => {
    const newDevices  = {};
    const newGateways = {};
    (doc.devices || []).forEach((entry) => {
      const cfg = entry.instanceConfig || {};
      const rt  = entry.runtime        || {};
      newDevices[cfg.InstanceId] = {
        instanceId:       cfg.InstanceId,
        deviceType:       cfg.DeviceType,
        displayName:      rt.displayName || cfg.DisplayName || cfg.InstanceId,
        ipAddress:        cfg.IpAddress,
        port:             cfg.Port,
        locationId:       cfg.LocationId,
        // Persisted-config fields the Settings page edits.  Kept on the
        // device object (flat) so SettingsPanel can read them directly.
        configurationName: cfg.ConfigurationName || '',
        isAutoConnect:    !!cfg.IsAutoConnect,
        isAutoStart:      !!cfg.IsAutoStart,
        antennaFunctions: Array.isArray(cfg.AntennaFunctions)
                            ? [...cfg.AntennaFunctions]
                            : ['Inventory','Inventory','Inventory','Inventory'],
        state:            rt.state || 'Disconnected',
        hwVersion:        rt.hwVersion || '-',
        fwVersion:        rt.fwVersion || '-',
        apiVersion:       rt.apiVersion || '-',
        nbrTotalTagReads: rt.nbrTotalTagReads || 0,
        nbrTagsPerSecond: rt.nbrTagsPerSecond || 0,
        nbrUniqueTags:    rt.nbrUniqueTags || 0,
        stoppedByUser:    !!rt.stoppedByUser,
        model:            rt.model || {},
      };
    });
    (doc.gateways || []).forEach((entry) => {
      const cfg = entry.config  || {};
      const rt  = entry.runtime || {};
      newGateways[cfg.InstanceId] = {
        instanceId:  cfg.InstanceId,
        gatewayType: cfg.GatewayType,
        displayName: cfg.DisplayName || cfg.InstanceId,
        state:       rt.state || 'Idle',
        isSending:   !!rt.isSending,
        queueDepth:  rt.queueDepth || 0,
        tagsSent:    rt.tagsSent   || 0,
        lastSent:    rt.lastSent   || null,
        // Keep the persisted config block so the gateway editor can
        // read IsAutoConnect / IsAutoStart / Endpoint / SelectedDeviceNames
        // etc., and gwconfig prop frames have somewhere to land.
        config:      cfg,
      };
    });
    setDevices(newDevices);
    setGateways(newGateways);
    // Preserve any tag tables we've already accumulated — config snapshots
    // are emitted both on first connect and every time a new subscriber
    // joins (the host re-broadcasts), so a hard reset here would wipe the
    // tags from underneath every existing viewer the moment a second one
    // joins.  Just make sure each newly-seen device has an empty array so
    // the InventoryPanel renders cleanly.
    setTagsByDevice(prev => {
      const next = { ...prev };
      Object.keys(newDevices).forEach(id => { if (!(id in next)) next[id] = []; });
      return next;
    });
  };

  // Equality check for prop values: primitives compare directly, arrays
  // are compared element-by-element, anything else falls back to JSON
  // serialisation (good enough for the small objects we have here).
  const propEqual = (a, b) => {
    if (a === b) return true;
    if (a == null || b == null) return false;
    if (Array.isArray(a) && Array.isArray(b)) {
      if (a.length !== b.length) return false;
      for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
      return true;
    }
    if (typeof a === 'object' || typeof b === 'object') {
      try { return JSON.stringify(a) === JSON.stringify(b); }
      catch { return false; }
    }
    return false;
  };

  const handleProp = (doc) => {
    const { instanceId, target, prop, value } = doc;
    if (!instanceId || !prop) return;
    if (target === 'device' || target === 'model') {
      setDevices(prev => {
        const cur = prev[instanceId]; if (!cur) return prev;
        const camel = prop.charAt(0).toLowerCase() + prop.slice(1);
        // No-op if the value didn't actually change. Without this guard,
        // the host's 1-second stats sync (NbrTotalTagReads, NbrUniqueTags,
        // NbrTagsPerSecond) triggers three React re-renders per second
        // even when the values are identical — which causes any open
        // native <select> dropdown (e.g. DR / Miller / Session) to close
        // after ~500ms before the user can pick a value.
        const oldVal = target === 'model' ? cur.model?.[prop] : cur[camel];
        if (propEqual(oldVal, value)) return prev;
        const next = target === 'model'
          ? { ...cur, model: { ...cur.model, [prop]: value } }
          : { ...cur, [camel]: value };
        return { ...prev, [instanceId]: next };
      });
    } else if (target === 'gateway') {
      setGateways(prev => {
        const cur = prev[instanceId]; if (!cur) return prev;
        const camel = prop.charAt(0).toLowerCase() + prop.slice(1);
        if (propEqual(cur[camel], value)) return prev;
        return { ...prev, [instanceId]: { ...cur, [camel]: value } };
      });
    } else if (target === 'gwconfig') {
      // Live update for the persisted config block — same shape as the
      // initial snapshot's `entry.config`, PascalCase keys preserved so
      // the editor (which reads gateway.config.IsAutoConnect etc.) sees
      // changes immediately.
      setGateways(prev => {
        const cur = prev[instanceId]; if (!cur) return prev;
        if (propEqual(cur.config?.[prop], value)) return prev;
        return { ...prev, [instanceId]: {
          ...cur,
          config: { ...(cur.config || {}), [prop]: value },
        }};
      });
    }
  };

  const handleTagsBatch = (doc) => {
    const { instanceId, tags } = doc;
    if (!instanceId || !Array.isArray(tags) || tags.length === 0) return;
    setTagsByDevice(prev => {
      // Merge by EPC: existing tags get their count / RSSI / timestamp
      // refreshed; new tags get appended.  Caps at 5000 per device so a
      // long inventory doesn't crush the DOM.
      const existing  = prev[instanceId] || [];
      const byEpc     = new Map(existing.map(t => [t.epc, t]));
      for (const tag of tags) {
        byEpc.set(tag.epc, {
          epc:        tag.epc,
          pc:         tag.pc,
          tid:        tag.tid,
          rssi:       tag.rssi,
          ant:        tag.ant,
          count:      tag.count,
          timeStamp:  tag.timeStamp,
        });
      }
      const merged = Array.from(byEpc.values());
      const trimmed = merged.length > 5000 ? merged.slice(-5000) : merged;

      // ── Counter strategy ────────────────────────────────────────────
      // The HOST is authoritative for:
      //   • nbrTotalTagReads, nbrUniqueTags  — arrive via "prop" frames
      //     from the host's 1-second stats sync (OnStatsSyncElapsed).
      //     Client-side sum-of-counts drifts (e.g. when the list gets
      //     trimmed at 5000 entries) so we DO NOT overwrite them here.
      //   • state, lastTagEpc on Dashboard, NbrTagsPerSecond — see below.
      // The client derives the rest from the tag flow:
      //   • lastTagEpc — newest timestamp in the current batch (host
      //     doesn't stream LastTagEpc on every read to avoid flooding).
      //   • nbrLastHour / nbrLast24Hours — rolling-window counts.
      //   • nbrTagsPerSecond — rough estimate from batch size; the host
      //     also pushes the authoritative value once a second, so client
      //     estimates get corrected on the next stat-sync tick.
      setDevices(prevD => {
        const cur = prevD[instanceId];
        if (!cur) return prevD;
        let newestTs = -Infinity, newestEpc = cur.lastTagEpc || '';
        const nowMs    = Date.now();
        const hourCut  = nowMs - 3_600_000;
        const dayCut   = nowMs - 86_400_000;
        let nLastHour = 0, nLast24h = 0;
        for (const t of trimmed) {
          const ts = Date.parse(t.timeStamp);
          if (Number.isFinite(ts)) {
            if (ts > newestTs) { newestTs = ts; newestEpc = t.epc; }
            if (ts >= hourCut) nLastHour++;
            if (ts >= dayCut)  nLast24h++;
          }
        }
        return {
          ...prevD,
          [instanceId]: {
            ...cur,
            // Rough TagsPerSecond — this batch came over the last ~100 ms.
            // Overwritten by the host's authoritative value via "prop"
            // every second; this is the best estimate between ticks.
            nbrTagsPerSecond: Math.round(tags.length * 10),
            nbrLastHour:      nLastHour,
            nbrLast24Hours:   nLast24h,
            lastTagEpc:       newestEpc,
          }
        };
      });
      return { ...prev, [instanceId]: trimmed };
    });
  };

  const handleSingleTag = (doc) => {
    // Wrap singular legacy "tag" in the plural shape and reuse.
    handleTagsBatch({ instanceId: doc.instanceId, tags: [{
      epc: doc.epc, pc: doc.pc, tid: doc.tid,
      rssi: doc.rssi, ant: doc.ant, count: doc.count,
      timeStamp: doc.timeStamp
    }]});
  };

  // Themed-dialog state (Load Configuration / Save Configuration /
  // confirmation prompts).  null = no modal.  Shape:
  //   { kind: 'load' | 'save', instanceId, files: string[] }
  //   { kind: 'confirm', title, message, onConfirm }
  //   { kind: 'busy',    title, message }
  const [dialog, setDialog] = useStateR(null);

  // Inbound: host replied to a `listConfigs` cmd with the saved-config
  // filenames.  If a load/save dialog is already open (the user clicked
  // Load or Save which fired listConfigs), refresh its list.  Otherwise
  // (this can happen as the echo after saveConfigByName) just remember
  // the list so a subsequent dialog opens populated.
  const lastConfigListRef = useRefR({ instanceId: null, files: [] });
  const handleConfigList = (doc) => {
    const inst  = doc.instanceId || '';
    const files = Array.isArray(doc.files) ? doc.files : [];
    lastConfigListRef.current = { instanceId: inst, files };
    setDialog(prev => {
      if (!prev) return prev;
      if ((prev.kind === 'load' || prev.kind === 'save')
          && prev.instanceId === inst) {
        return { ...prev, files, loading: false };
      }
      return prev;
    });
  };

  // Host responded to our `getConfig` cmd with a serialised model.  Build
  // a Blob, generate an object URL, and synthesise a click on a hidden
  // anchor so the browser pops its native Save-As dialog.  No file picker
  // shows up if the browser is set to auto-download — that's expected.
  const handleConfigBlob = (doc) => {
    const json = doc.json || '';
    const safeName = (doc.name || 'Ura4Config').replace(/[^A-Za-z0-9._-]/g, '_');
    try {
      const blob = new Blob([json], { type: 'application/json' });
      const url  = URL.createObjectURL(blob);
      const a    = document.createElement('a');
      a.href     = url;
      a.download = `${safeName}.json`;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      // Revoke after a tick so the download has a chance to start in Firefox.
      setTimeout(() => URL.revokeObjectURL(url), 1000);
    } catch (err) {
      console.error('configBlob download failed', err);
    }
  };

  const handleClearTags = (doc) => {
    const { instanceId } = doc;
    if (!instanceId) return;
    setTagsByDevice(prev => ({ ...prev, [instanceId]: [] }));
    setDevices(prev => {
      const cur = prev[instanceId]; if (!cur) return prev;
      return { ...prev, [instanceId]: { ...cur, nbrUniqueTags: 0, nbrTotalTagReads: 0 } };
    });
  };

  // Inbound nav frame — the host's local operator (or another remote
  // session) switched views; follow along.  Uses the raw setView so the
  // change does NOT echo back as an outbound nav — only direct user clicks
  // in this browser do (via setViewAndSync below).
  const handleNav = (doc) => {
    const { instanceId, variant } = doc;
    if (variant === 'Dashboard') setView({ kind: 'dashboard' });
    else if (variant === 'Inventory'     && instanceId) setView({ kind: 'inventory',     instanceId });
    else if (variant === 'Configuration' && instanceId) setView({ kind: 'configuration', instanceId });
    else if (variant === 'Gateway'       && instanceId) setView({ kind: 'gateway',       instanceId });
    else if (variant === 'Settings') setView({ kind: 'settings' });
  };

  // ── Settings page state ───────────────────────────────────────────
  // The WPF GlobalSettingsViewModel uses these tabIds:
  //   "global" — the Global tab
  //   <deviceInstanceId> — one per device
  //   <gatewayInstanceId> — one per gateway
  // We mirror the same identifiers so the wire stays bi-directional.
  const [settingsTab, setSettingsTab] = useStateR('global');

  // Host's Downloads folder listing (Kentekens.csv, slotplaten.csv, …).
  // Pushed by the host in its config snapshot and after every upload; the
  // controller can also refresh on demand.  [{ name, size, modified }]
  const [hostFiles, setHostFiles] = useStateR([]);
  const handleFileList = (doc) =>
    setHostFiles(Array.isArray(doc.files) ? doc.files : []);

  // The hidden <input type=file> for "Upload file to host" lives at the top of
  // RemoteView's tree (not deep in the Settings tab) so it survives re-renders.
  // closeGuardUntil neutralises the spurious outside-click/focus event the OS
  // file dialog hands back to the page when it closes — without it the drawer
  // overlay reads that event as "clicked outside" and shuts the whole modal.
  const uploadInputRef    = useRefR(null);
  const closeGuardUntilRef = useRefR(0);

  // "Update settings.json" — separate hidden input (.json only). A picked file
  // is held in pendingSettingsFile so we can show a confirm dialog before it
  // overwrites the host's settings.json and restarts it. settingsStatus surfaces
  // progress / the host's accept-or-reject result.
  const settingsInputRef = useRefR(null);
  const [pendingSettingsFile, setPendingSettingsFile] = useStateR(null);
  const [settingsStatus, setSettingsStatus] = useStateR('');
  const handleSettingsResult = (doc) =>
    setSettingsStatus((doc.ok ? '✅ ' : '❌ ') + (doc.message || (doc.ok ? 'Applied.' : 'Rejected.')));

  // Inbound peer-driven tab change.  Switch to settings view if not
  // already there, then select the matching tab.  No outbound echo —
  // the `_suppressTabSync` analogue is handled by checking against our
  // current tab before sending the outbound frame.
  const handleSettingsTab = (doc) => {
    const id = doc.tabId;
    if (!id) return;
    setSettingsTab(id);
    setView(prev => prev.kind === 'settings' ? prev : { kind: 'settings' });
  };

  // Inbound peer-driven field change.  WPF sends values as strings via
  // the settingsprop channel; we keep them as strings here so the
  // browser's outbound shape matches.  Device tabs: write to the
  // device object (flat fields).  Gateway tabs: write to the gateway's
  // config block (PascalCase) so the GatewayPanel re-reads it.
  // The Global tab has no editable props yet — bare upload command.
  const handleSettingsProp = (doc) => {
    const tabId = doc.tabId, prop = doc.prop, value = doc.value;
    if (!tabId || !prop) return;
    // Gateway tab?  Mirror into the gateway's config block.
    setGateways(prev => {
      const gw = prev[tabId]; if (!gw) return prev;
      // Map settingsprop names to GatewayBaseConfig PascalCase props.
      let nextConfig = { ...(gw.config || {}) };
      if (prop === 'IsAutoConnect')      nextConfig.IsAutoConnect = value === 'True' || value === 'true' || value === true;
      else if (prop === 'IsAutoStart')   nextConfig.IsAutoStart   = value === 'True' || value === 'true' || value === true;
      else if (prop === 'DisplayName')   nextConfig.DisplayName   = value;
      else if (prop === 'deviceSelection') {
        nextConfig.SelectedDeviceNames = String(value || '').split(',').filter(s => s.length);
      }
      return { ...prev, [tabId]: { ...gw, config: nextConfig,
                                   displayName: nextConfig.DisplayName || gw.displayName } };
    });
    // Device tab?  Mirror into the device's flat fields.
    setDevices(prev => {
      const d = prev[tabId]; if (!d) return prev;
      const camel = prop.charAt(0).toLowerCase() + prop.slice(1);
      let next = { ...d };
      if (prop === 'Port') {
        const p = parseInt(value, 10);
        next.port = Number.isFinite(p) ? p : d.port;
      } else if (prop === 'IsAutoConnect' || prop === 'IsAutoStart') {
        next[camel] = value === 'True' || value === 'true' || value === true;
      } else if (prop === 'antennaFunctions') {
        next.antennaFunctions = String(value || '').split(',');
      } else {
        next[camel] = value;
      }
      return { ...prev, [tabId]: next };
    });
  };

  const handleSettingsSave = (_doc) => {
    // Browser doesn't persist anything itself — the host saves to
    // settings.json on its own when it sees the frame.  No-op here
    // (could surface a toast in future).
  };

  // ── Connect on mount ──────────────────────────────────────────────────
  useEffectR(() => {
    let cancelled = false;
    (async () => {
      try {
        setPhase('fetching');
        const r = await fetch('/api/remote/issue-jwt?readerId=' + reader.Id);
        if (!r.ok) throw new Error(`issue-jwt → ${r.status} ${await r.text()}`);
        const { Jwt, HardwareId } = await r.json();
        if (cancelled) return;

        setPhase('connecting');
        const conn = new signalR.HubConnectionBuilder()
          .withUrl('/peer', { accessTokenFactory: () => Jwt })
          // Infinite reconnect.  The DEFAULT policy gives up after ~4 attempts
          // (~42 s) and fires onclose → the page goes dark forever and never
          // re-subscribes.  If the hub/server is down longer than that (a
          // redeploy, or the >60 s blips we see in OmniGate's log), CloudGate
          // would stay disconnected while OmniGate is fine.  Mirror OmniGate's
          // InfiniteRetryPolicy: ramp 0→2→5→10→30 s, then 30 s forever.
          .withAutomaticReconnect({
            nextRetryDelayInMilliseconds: (ctx) => {
              const n = ctx.previousRetryCount;
              return n === 0 ? 0
                   : n === 1 ? 2000
                   : n === 2 ? 5000
                   : n === 3 ? 10000
                   :           30000;
            }
          })
          .build();
        // Tolerate marginal links / brief server stalls before declaring the
        // connection dead (matches the hub's 60 s ClientTimeoutInterval).
        conn.serverTimeoutInMilliseconds = 60000;
        connRef.current = conn;

        conn.on('ReceiveSystemMessage', (msg) => {
          // First message after a fresh connect carries our ConnectionId —
          // we need it to compare against roomState.controllerId.
          const m = String(msg);
          if (m.startsWith('Connected. Your ID: '))
            myConnIdRef.current = m.replace('Connected. Your ID: ', '').trim();
        });

        conn.on('ReceiveSync', (fromConnectionId, json) => {
          let evt;
          try { evt = JSON.parse(json); } catch { return; }
          if (!evt || typeof evt !== 'object') return;
          switch (evt.type) {
            case 'roomState':   setRoomState(evt); break;
            case 'config':      handleConfig(evt); break;
            case 'prop':        handleProp(evt); break;
            case 'tags':        handleTagsBatch(evt); break;
            case 'tag':         handleSingleTag(evt); break;
            case 'clearTags':   handleClearTags(evt); break;
            case 'nav':         handleNav(evt); break;
            case 'configBlob':  handleConfigBlob(evt); break;
            case 'configList':  handleConfigList(evt); break;
            case 'settingstab': handleSettingsTab(evt); break;
            case 'settingsprop':handleSettingsProp(evt); break;
            case 'settingssave':handleSettingsSave(evt); break;
            case 'filelist':    handleFileList(evt); break;
            case 'settingsresult': handleSettingsResult(evt); break;
            // Other frame types (settings* / dlg / modelLoaded / error) are
            // accepted but ignored for the Phase 2 MVP — those pages aren't
            // ported yet.
            default: break;
          }
        });

        conn.on('ReceiveSubscribedToHost', () => setPhase('subscribed'));
        conn.on('ReceiveUnsubscribedFromHost', () => {
          // Hub told us the host went offline.  Drop snapshot so the UI
          // doesn't display stale state with no way to refresh it.
          setDevices({}); setGateways({}); setTagsByDevice({});
          setRoomState(null);
          setPhase('connecting');
        });

        // ── Transport lifecycle (withAutomaticReconnect) ───────────────
        // SignalR.JS emits these when the WebSocket drops / recovers / dies.
        // Without these handlers the UI sticks at phase='subscribed' (green
        // "you have control") forever, even after the connection is gone —
        // every commit() then silently drops at the hub. We clear roomState
        // so `isController` flips false and the chip flips to "observing /
        // connecting" until the transport is back AND the host re-confirms
        // us as a subscriber.
        conn.onreconnecting((err) => {
          console.warn('[remote-view] reconnecting', err);
          setRoomState(null);   // controllerId stale → isController = false
          setPhase('connecting');
        });
        conn.onreconnected(async () => {
          console.log('[remote-view] reconnected; re-registering & re-subscribing');
          myConnIdRef.current = conn.connectionId || null;
          try {
            await conn.invoke('Register', window.__currentUser?.UserName || 'browser');
            await conn.invoke('SubscribeToHost', HardwareId);
            // phase stays 'connecting' until ReceiveSubscribedToHost fires.
          } catch (e) {
            console.error('[remote-view] re-subscribe failed', e);
            setError(String(e.message || e));
            setPhase('error');
          }
        });
        conn.onclose((err) => {
          // Reached after withAutomaticReconnect exhausts its retry schedule
          // (or on an explicit stop). No more reconnect attempts will be made.
          console.error('[remote-view] connection closed', err);
          setDevices({}); setGateways({}); setTagsByDevice({});
          setRoomState(null);
          if (err) setError(String(err.message || err));
          setPhase('error');
        });

        await conn.start();
        if (cancelled) { try { await conn.stop(); } catch (_) {} return; }
        myConnIdRef.current = conn.connectionId || null;
        await conn.invoke('Register', window.__currentUser?.UserName || 'browser');
        await conn.invoke('SubscribeToHost', HardwareId);
      } catch (err) {
        if (cancelled) return;
        setError(String(err.message || err));
        setPhase('error');
      }
    })();

    return () => {
      cancelled = true;
      const c = connRef.current;
      if (c) { try { c.stop(); } catch (_) {} connRef.current = null; }
    };
  }, [reader.Id]);

  // ── Derived: are we the controller? ───────────────────────────────────
  const myConnId   = connRef.current?.connectionId ?? myConnIdRef.current;
  const isRoomController = roomState && myConnId && roomState.controllerId === myConnId;
  // CloudGate rule: only CustomerAdmin and higher may CHANGE reader settings.
  // A lower role can still be the hub's controller and view live state, but all
  // edit controls (which gate on `isController`) stay disabled for them.
  const isController = isRoomController && CloudGate.canEdit();
  const controllerInfo = roomState
    ? roomState.subscribers?.find(s => s.ConnectionId === roomState.controllerId)
    : null;

  // ── Outbound: commands + nav (controller-gated; hub also enforces) ────
  const sendToHost = (payload) => {
    const conn = connRef.current;
    if (!conn) { console.warn('[sendToHost] no conn', payload); return; }
    try {
      conn.invoke('SendToHost', reader.HardwareId || '',
                  JSON.stringify(payload))
        .catch(err => console.warn('[sendToHost] hub rejected:', err?.message || err, payload));
    }
    catch (err) {
      console.warn('[sendToHost] invoke threw:', err?.message || err, payload);
    }
  };
  const sendCmd = (instanceId, cmd, value) => {
    if (!isController) return;
    const payload = { type: 'cmd', instanceId, cmd };
    if (value !== undefined) payload.value = value;
    sendToHost(payload);
  };

  // Variant for cmds that carry an arbitrary extra field (currently only
  // `applyConfig` which ships the JSON text in `config`).  Could grow if
  // we add more cmds with payloads.
  const sendCmdWithExtras = (instanceId, cmd, extras) => {
    if (!isController) return;
    sendToHost({ type: 'cmd', instanceId, cmd, ...extras });
  };

  // ── File transfer to the host's Downloads folder ──────────────────────
  // Mirrors the WPF client's RemoteProtocolService.Receive(FileUploadMessage):
  // split the file into raw 192 000-byte chunks, base64 each chunk
  // INDEPENDENTLY (the host decodes per-chunk and concatenates), and send
  // one "fileupload" frame per chunk.  Controller-only — the hub drops
  // non-controller uplinks anyway.  After the last chunk the host writes the
  // file and broadcasts a fresh "filelist", so the list self-refreshes.
  const CHUNK_BYTES = 192000;
  const base64FromBytes = (bytes) => {
    let binary = '';
    const SUB = 0x8000;   // build the binary string in slices to dodge call-stack limits
    for (let i = 0; i < bytes.length; i += SUB)
      binary += String.fromCharCode.apply(null, bytes.subarray(i, i + SUB));
    return btoa(binary);
  };
  const uploadFileToHost = async (file) => {
    if (!isController || !file) return;
    const bytes = new Uint8Array(await file.arrayBuffer());
    const total = Math.max(1, Math.ceil(bytes.length / CHUNK_BYTES));
    for (let i = 0; i < total; i++) {
      const slice = bytes.subarray(i * CHUNK_BYTES, Math.min((i + 1) * CHUNK_BYTES, bytes.length));
      sendToHost({
        type:     'fileupload',
        filename: file.name,
        index:    i,
        total,
        data:     base64FromBytes(slice),
      });
    }
  };
  const refreshHostFiles = () => { if (isController) sendToHost({ type: 'listfiles' }); };

  // Close the modal only for genuine close clicks — ignore the stray event the
  // native file dialog bounces back to the page right after it closes.
  const guardedClose = () => {
    if (performance.now() < closeGuardUntilRef.current) return;
    onClose();
  };
  // Open the OS file picker (controller-only). The picker can stay open for an
  // arbitrary time, so a fixed timer is wrong — instead guard "indefinitely"
  // and only disarm a short tail AFTER the window regains focus (which fires
  // exactly when the picker closes, for both OK and Cancel). That tail swallows
  // the stray click/focus event the dialog leaves on the page, which would
  // otherwise hit the drawer overlay and close the whole modal.
  const triggerUpload = () => {
    if (!isController) return;
    closeGuardUntilRef.current = Number.MAX_SAFE_INTEGER;
    const onWinFocus = () => {
      window.removeEventListener('focus', onWinFocus);
      setTimeout(() => { closeGuardUntilRef.current = 0; }, 700);
    };
    window.addEventListener('focus', onWinFocus);
    if (uploadInputRef.current) uploadInputRef.current.click();
  };
  const onUploadInputChange = (e) => {
    e.stopPropagation();
    const f = e.target.files && e.target.files[0];
    e.target.value = '';                 // allow re-picking the same file
    if (f) uploadFileToHost(f);
  };

  // ── Update settings.json (replace host config + restart) ──────────────
  // Same chunking as a normal upload, but each frame carries kind:'settings'
  // so the host replaces settings.json and restarts instead of saving to
  // Downloads. settings.json is tiny → one chunk, well under any size limit.
  const triggerSettingsUpdate = () => {
    if (!isController) return;
    setSettingsStatus('');
    closeGuardUntilRef.current = Number.MAX_SAFE_INTEGER;
    const onWinFocus = () => {
      window.removeEventListener('focus', onWinFocus);
      setTimeout(() => { closeGuardUntilRef.current = 0; }, 700);
    };
    window.addEventListener('focus', onWinFocus);
    if (settingsInputRef.current) settingsInputRef.current.click();
  };
  const onSettingsInputChange = async (e) => {
    e.stopPropagation();
    const f = e.target.files && e.target.files[0];
    e.target.value = '';
    if (!f) return;
    // Client-side sanity check so an obviously-wrong file is caught before the
    // round-trip; the host still validates authoritatively.
    try { JSON.parse(await f.text()); }
    catch { setSettingsStatus(`❌ '${f.name}' is not valid JSON.`); return; }
    setPendingSettingsFile(f);   // → confirm dialog
  };
  const sendSettingsFile = async (file) => {
    if (!isController || !file) return;
    setSettingsStatus(`Sending '${file.name}' to host…`);
    const bytes = new Uint8Array(await file.arrayBuffer());
    const total = Math.max(1, Math.ceil(bytes.length / CHUNK_BYTES));
    for (let i = 0; i < total; i++) {
      const slice = bytes.subarray(i * CHUNK_BYTES, Math.min((i + 1) * CHUNK_BYTES, bytes.length));
      sendToHost({
        type: 'fileupload', kind: 'settings',
        filename: file.name, index: i, total, data: base64FromBytes(slice),
      });
    }
    setSettingsStatus('Sent — validating on host…');
  };

  // Controller-only view changes are mirrored to the host AND every other
  // observer via a single hub broadcast (BroadcastFromController), so the
  // OmniGate WPF screen + the customer's CloudGate window both follow the
  // support user's clicks.  Observer view changes stay purely local — they
  // can poke around without dragging the support session sideways.
  const setViewAndSync = (next) => {
    setView(next);
    if (!isController) return;
    let payload = null;
    if (next.kind === 'dashboard')
      payload = { type: 'nav', instanceId: '',               variant: 'Dashboard' };
    else if (next.kind === 'inventory')
      payload = { type: 'nav', instanceId: next.instanceId,  variant: 'Inventory' };
    else if (next.kind === 'configuration')
      payload = { type: 'nav', instanceId: next.instanceId,  variant: 'Configuration' };
    else if (next.kind === 'gateway')
      payload = { type: 'nav', instanceId: next.instanceId,  variant: 'Gateway' };
    else if (next.kind === 'settings')
      payload = { type: 'nav', instanceId: '',               variant: 'Settings' };
    if (!payload) return;
    const conn = connRef.current;
    if (!conn) return;
    try {
      conn.invoke('BroadcastFromController',
                  reader.HardwareId || '',
                  JSON.stringify(payload));
    } catch (_) { /* hub will have rejected — UI already shows observer mode */ }
  };

  // ── Render ────────────────────────────────────────────────────────────
  // Visual palette mirrors OmniGate WPF (Shell/Views/MainWindow.xaml +
  // Dashboard/DashboardView.xaml):
  //   #212529 — overlay body background
  //   #272B2F — sidebar / header bar background
  //   #3E434A — section dividers
  //   #FFE7B5 — Wheat title / value highlight
  //   #6B7280 — muted column headers + section labels
  //   #94A3B8 — subtitle text
  return (
    <>
      {/* Persistent upload input — owned by RemoteView so it isn't torn down
          mid-pick by a Settings-tab re-render. */}
      <input ref={uploadInputRef} type="file" style={{display:'none'}}
             onChange={onUploadInputChange}/>
      {/* Separate .json input for "Update settings.json". */}
      <input ref={settingsInputRef} type="file" accept=".json,application/json"
             style={{display:'none'}} onChange={onSettingsInputChange}/>
      {pendingSettingsFile && (
        <ConfirmDialog
          title="Replace settings.json?"
          message={`This will overwrite settings.json on the device with '${pendingSettingsFile.name}' and RESTART OmniGate on that device.\n\nThe current settings.json is backed up first. (This does NOT upload to CloudGate.)`}
          confirmLabel="Replace & restart" danger
          onCancel={() => { setPendingSettingsFile(null); setSettingsStatus(''); }}
          onConfirm={() => { const f = pendingSettingsFile; setPendingSettingsFile(null); sendSettingsFile(f); }}/>
      )}
      <div className="drawer-overlay" onClick={guardedClose}/>
      <div role="dialog" aria-label="Remote view"
           style={{position:'fixed', inset:'24px', background:'#212529',
                   borderRadius:20, boxShadow:'0 8px 40px rgba(0,0,0,0.6)',
                   display:'flex', flexDirection:'column', zIndex:1000,
                   color:'#E5E7EB',
                   fontFamily:"'Rubik', 'Inter', system-ui, sans-serif",
                   overflow:'hidden'}}>

        <div style={{flex:1, display:'flex', minHeight:0}}>
          {/* ── SIDEBAR ───────────────────────────────────────────────── */}
          <RemoteNav devices={devices} gateways={gateways} view={view}
                     setView={setViewAndSync}
                     isController={isController}/>

          {/* ── MAIN: header + content ────────────────────────────────── */}
          <div style={{flex:1, display:'flex', flexDirection:'column', minWidth:0}}>
            <RemoteHeader reader={reader} phase={phase}
                          roomState={roomState}
                          isController={isController}
                          controllerInfo={controllerInfo}
                          onClose={guardedClose}/>

            {error && (
              <div style={{padding:12, color:'#FCA5A5', fontSize:13,
                           background:'#7F1D1D33', borderBottom:'1px solid #3E434A'}}>
                {error}
              </div>
            )}

            <div style={{flex:1, overflow:'auto', padding:'20px 32px'}}>
              {phase !== 'subscribed' && !error && (
                <div style={{color:'#94A3B8', fontStyle:'italic'}}>
                  {phase === 'fetching'   && 'Requesting access…'}
                  {phase === 'connecting' && 'Connecting to host…'}
                  {phase === 'idle'       && 'Initializing…'}
                </div>
              )}
              {phase === 'subscribed' && view.kind === 'dashboard' && (
                <DashboardPanel devices={devices} gateways={gateways}
                                hostUsername={reader.HardwareId || reader.Name || `Reader-${reader.Id}`}
                                myConnId={myConnId}
                                roomState={roomState}/>
              )}
              {phase === 'subscribed' && view.kind === 'inventory' && (
                devices[view.instanceId] ? (
                <InventoryPanel
                  device={devices[view.instanceId]}
                  tags={tagsByDevice[view.instanceId] || []}
                  isController={isController}
                  onStart={() => sendCmd(view.instanceId, 'startInventory')}
                  onStop ={() => sendCmd(view.instanceId, 'stopInventory')}
                  onClear={() => sendCmd(view.instanceId, 'clearTags')}
                  onSetModelProp={(prop, value) => {
                    if (!isController) return;
                    sendToHost({
                      type:       'setprop',
                      instanceId: view.instanceId,
                      target:     'model',
                      prop, value
                    });
                  }}/>
                ) : (
                  <div style={{color:COL.muted}}>Device not in snapshot yet — waiting…</div>
                )
              )}
              {phase === 'subscribed' && view.kind === 'configuration' && (
                devices[view.instanceId] ? (
                <ConfigurationPanel
                  device={devices[view.instanceId]}
                  isController={isController}
                  onConnect    ={() => sendCmd(view.instanceId, 'connect')}
                  onDisconnect ={() => sendCmd(view.instanceId, 'disconnect')}
                  onUpload     ={() => sendCmd(view.instanceId, 'upload')}
                  onDownload   ={() => sendCmd(view.instanceId, 'download')}
                  onLoad       ={() => {
                    setDialog({ kind:'load', instanceId:view.instanceId, files:[], loading:true });
                    sendCmd(view.instanceId, 'listConfigs');
                  }}
                  onSave       ={() => {
                    setDialog({ kind:'save', instanceId:view.instanceId, files:[], loading:true });
                    sendCmd(view.instanceId, 'listConfigs');
                  }}
                  onDefault    ={() => {
                    setDialog({
                      kind:    'confirm',
                      title:   'Reset to factory defaults',
                      message: "Reset this device's configuration to factory defaults?\n\nAll current parameter values will be discarded.",
                      confirmLabel: 'Reset',
                      danger: true,
                      onConfirm: () => sendCmd(view.instanceId, 'defaultConfig'),
                    });
                  }}
                  onCopyToAll  ={() => {
                    setDialog({
                      kind:    'confirm',
                      title:   'Copy to all readers',
                      message: "Copy this device's configuration to ALL other readers, and upload it to the connected ones?\n\nEvery other reader's RFID configuration will be overwritten. IP address, name and port are preserved.",
                      confirmLabel: 'Copy to all',
                      danger: true,
                      onConfirm: () => sendCmd(view.instanceId, 'copyConfigToAll'),
                    });
                  }}
                  onSetModelProp={(prop, value) => {
                    if (!isController) return;
                    sendToHost({
                      type:       'setprop',
                      instanceId: view.instanceId,
                      target:     'model',
                      prop, value
                    });
                  }}
                  onSetPower={(antenna, power) => {
                    if (!isController) return;
                    sendCmdWithExtras(view.instanceId, 'setPower',
                                      { antenna, power });
                  }}
                  onSetWorkTime={(antenna, workTime) => {
                    if (!isController) return;
                    sendCmdWithExtras(view.instanceId, 'setWorkTime',
                                      { antenna, workTime });
                  }}
                  onSetAntennaEnabled={(antenna, enabled) => {
                    if (!isController) return;
                    sendCmdWithExtras(view.instanceId, 'setAntennaEnabled',
                                      { antenna, enabled });
                  }}/>
                ) : (
                  <div style={{color:COL.muted}}>Device not in snapshot yet — waiting…</div>
                )
              )}
              {phase === 'subscribed' && view.kind === 'settings' && (
                <SettingsPanel
                  devices={devices}
                  gateways={gateways}
                  isController={isController}
                  hostFiles={hostFiles}
                  onTriggerUpload={triggerUpload}
                  onRefreshFiles={refreshHostFiles}
                  onTriggerSettingsUpdate={triggerSettingsUpdate}
                  settingsStatus={settingsStatus}
                  selectedTab={settingsTab}
                  onSelectTab={(tabId) => {
                    setSettingsTab(tabId);
                    if (!isController) return;
                    sendToHost({ type:'settingstab', tabId });
                  }}
                  onSetSettingsProp={(tabId, prop, value) => {
                    if (!isController) return;
                    sendToHost({ type:'settingsprop', tabId, prop,
                                 value: String(value) });
                    // Optimistic local mirror so the UI doesn't lag a
                    // round-trip behind the keystroke.  The peer's echo
                    // will re-confirm it in handleSettingsProp above.
                    handleSettingsProp({ tabId, prop, value: String(value) });
                  }}
                  onSettingsSave={(tabId) => {
                    if (!isController) return;
                    sendToHost({ type:'settingssave', tabId });
                  }}
                  // Re-use the existing GatewayPanel callbacks so the
                  // gateway tab inside Settings behaves identically to
                  // the sidebar's Gateway entry.
                  onGatewayConnect    ={(id) => sendCmd(id, 'connect')}
                  onGatewayDisconnect ={(id) => sendCmd(id, 'disconnect')}
                  onGatewaySetRuntime ={(id, prop, value) => {
                    if (!isController) return;
                    sendToHost({ type:'setprop', instanceId:id,
                                 target:'gateway', prop, value });
                  }}
                  onGatewaySetConfig  ={(id, prop, value) => {
                    if (!isController) return;
                    sendToHost({ type:'setprop', instanceId:id,
                                 target:'gwconfig', prop, value });
                  }}/>
              )}
              {phase === 'subscribed' && view.kind === 'gateway' && (
                <GatewayPanel
                  gateway={gateways[view.instanceId]}
                  devices={devices}
                  isController={isController}
                  onConnect   ={() => sendCmd(view.instanceId, 'connect')}
                  onDisconnect={() => sendCmd(view.instanceId, 'disconnect')}
                  onSetRuntimeProp={(prop, value) => {
                    if (!isController) return;
                    sendToHost({
                      type:       'setprop',
                      instanceId: view.instanceId,
                      target:     'gateway',
                      prop, value
                    });
                  }}
                  onSetConfigProp={(prop, value) => {
                    if (!isController) return;
                    sendToHost({
                      type:       'setprop',
                      instanceId: view.instanceId,
                      target:     'gwconfig',
                      prop, value
                    });
                  }}/>
              )}
            </div>
          </div>
        </div>
      </div>

      {/* Themed modal — Load / Save Configuration / confirmation prompts.
          Rendered above the drawer (zIndex > 1000) so it captures focus
          without dimming what's underneath. */}
      {dialog && dialog.kind === 'confirm' && (
        <ConfirmDialog
          title       ={dialog.title}
          message     ={dialog.message}
          confirmLabel={dialog.confirmLabel || 'OK'}
          danger      ={!!dialog.danger}
          onCancel    ={() => setDialog(null)}
          onConfirm   ={() => { dialog.onConfirm && dialog.onConfirm(); setDialog(null); }}/>
      )}
      {dialog && (dialog.kind === 'load' || dialog.kind === 'save') && (
        <ConfigFileDialog
          mode        ={dialog.kind}
          files       ={dialog.files}
          loading     ={!!dialog.loading}
          suggestedName={(devices[dialog.instanceId]
                           && devices[dialog.instanceId].model
                           && devices[dialog.instanceId].model.ConfigurationName) || ''}
          onCancel    ={() => setDialog(null)}
          onLoadPick  ={(name) => {
            // Step 2 of the Load flow: confirm before discarding the
            // current config on the device.
            const inst = dialog.instanceId;
            setDialog({
              kind:    'confirm',
              title:   'Load configuration',
              message: `Load '${name}' and replace the current configuration on this device?`,
              confirmLabel: 'Load',
              danger:  false,
              onConfirm: () => sendCmdWithExtras(inst, 'loadConfigByName', { name }),
            });
          }}
          onSavePick  ={(name) => {
            const inst   = dialog.instanceId;
            const exists = (dialog.files || []).includes(name);
            if (!exists) {
              sendCmdWithExtras(inst, 'saveConfigByName', { name });
              setDialog(null);
              return;
            }
            setDialog({
              kind:    'confirm',
              title:   'Overwrite configuration',
              message: `'${name}' already exists. Overwrite it with the current settings?`,
              confirmLabel: 'Overwrite',
              danger:  true,
              onConfirm: () => sendCmdWithExtras(inst, 'saveConfigByName', { name }),
            });
          }}/>
      )}
    </>
  );
}

// =================== Sub-components ===================
// Visual mirror of OmniGate WPF (Shell/Views/MainWindow.xaml +
// Dashboard/DashboardView.xaml).

const COL = {
  bg:       '#212529',
  panel:    '#272B2F',
  border:   '#3E434A',
  text:     '#E5E7EB',
  wheat:    '#FFE7B5',
  muted:    '#94A3B8',
  faint:    '#6B7280',
  teal:     '#0F766E',
  green:    '#22C55E',
  amber:    '#F59E0B',
  red:      '#EF4444',
  blue:     '#1D4ED8',
};

function RemoteHeader({ reader, phase, roomState, isController, controllerInfo, onClose }) {
  return (
    <div style={{padding:'14px 24px', borderBottom:`1px solid ${COL.border}`,
                 display:'flex', alignItems:'center', justifyContent:'space-between',
                 background:COL.panel, borderTopRightRadius:20}}>
      <div style={{display:'flex', alignItems:'center', gap:14}}>
        <RfidTile size={28}/>
        <div style={{fontSize:18, color:COL.wheat, fontWeight:500}}>
          OmniGate · Device Control Platform
        </div>
        <div style={{fontSize:11, color:COL.muted, marginLeft:2, marginTop:6}}>
          {reader.Name || ('Reader #' + reader.Id)}
        </div>
        <PhasePill phase={phase}/>
        {roomState && (
          <span style={{fontSize:12, color:COL.muted, marginLeft:4}}>
            · {roomState.subscribers?.length || 0} viewer{(roomState.subscribers?.length || 0) === 1 ? '' : 's'} ·{' '}
            {isController
              ? <span style={{color:COL.green, fontWeight:600}}>you have control</span>
              : <span>observing
                  {controllerInfo
                    ? <> — <span style={{color:COL.wheat}}>{controllerInfo.Username || controllerInfo.ConnectionId}</span> ({controllerInfo.LevelName}) has control</>
                    : null}
                </span>}
          </span>
        )}
      </div>
      <button onClick={onClose}
              aria-label="Close remote view"
              style={{width:32, height:32, borderRadius:6,
                      background:'transparent', border:`1px solid ${COL.border}`,
                      color:COL.text, cursor:'pointer',
                      display:'flex', alignItems:'center', justifyContent:'center',
                      fontSize:14, fontWeight:500}}>
        ✕
      </button>
    </div>
  );
}

function PhasePill({ phase }) {
  const bg = phase === 'subscribed' ? COL.green
           : phase === 'error'      ? COL.red
           : COL.amber;
  return (
    <span style={{fontSize:9, color:'#fff', background: bg,
                   padding:'2px 8px', borderRadius:99, textTransform:'uppercase',
                   letterSpacing:'0.06em', fontWeight:700, marginLeft:6}}>
      {phase}
    </span>
  );
}

function RfidTile({ size = 48 }) {
  return (
    <div style={{width:size, height:size, borderRadius:size * 0.18,
                 background:'linear-gradient(135deg,#6366F1,#8B5CF6)',
                 display:'flex', alignItems:'center', justifyContent:'center',
                 color:'#fff', fontWeight:800, fontSize: Math.max(9, size * 0.28),
                 letterSpacing:'0.05em', fontFamily:'inherit',
                 boxShadow:'0 2px 6px rgba(0,0,0,0.35)'}}>
      RFID
    </div>
  );
}

function RemoteNav({ devices, gateways, view, setView, isController }) {
  const deviceList  = Object.values(devices).sort((a, b) =>
    String(a.instanceId).localeCompare(String(b.instanceId), undefined, { numeric:true }));
  const gatewayList = Object.values(gateways);
  // Observer clicks are no-ops; the active row still highlights because
  // the controller's nav broadcast drives `view`.
  const go = (next) => { if (isController) setView(next); };

  // OmniGate MainViewModel uses two header colours — teal for devices
  // ("#0F766E") and amber-orange for gateways ("#92400E").
  const groupHeader = (name, color) => (
    <div style={{padding:'10px 20px 2px', fontSize:16, fontWeight:600,
                 color, fontFamily:'inherit', whiteSpace:'nowrap',
                 overflow:'hidden', textOverflow:'ellipsis'}}>
      — {name} ————————
    </div>
  );

  const subItem = (label, active, onClick, enabled = true, accent = COL.teal) => (
    <button onClick={onClick}
            disabled={!enabled || !isController}
            style={{
              display:'block', width:'100%', textAlign:'left',
              padding:'8px 28px',
              background: active ? '#374151' : 'transparent',
              border:'none',
              borderLeft: active ? `3px solid ${accent}` : '3px solid transparent',
              color: active ? COL.wheat
                            : (!enabled ? COL.faint
                                        : (isController ? '#D1D5DB' : COL.faint)),
              fontSize:13, fontFamily:'inherit',
              cursor: (enabled && isController) ? 'pointer' : 'not-allowed',
              opacity: enabled ? 1 : 0.55,
            }}>
      {label}
    </button>
  );

  const dashActive     = view.kind === 'dashboard';
  const settingsActive = view.kind === 'settings';
  const isConfigFor    = (id) => view.kind === 'configuration' && view.instanceId === id;
  const isInventoryFor = (id) => view.kind === 'inventory'     && view.instanceId === id;
  const isGatewayFor   = (id) => view.kind === 'gateway'       && view.instanceId === id;
  // Orange used by OmniGate WPF for gateway group headers.  Matches
  // MainViewModel.cs:238 `AddGateway` with header colour "#92400E".
  const GW_ACCENT = '#B45309';

  return (
    <div style={{width:228, background:COL.panel,
                 borderTopLeftRadius:20, borderBottomLeftRadius:20,
                 // No overflow here — the inner device/gateway list owns
                 // the scroll so the Settings button stays pinned at the
                 // bottom regardless of how many devices are in the list.
                 overflow:'hidden', flexShrink:0,
                 display:'flex', flexDirection:'column'}}>
      {/* Brand — matches MainWindow.xaml header column */}
      <div style={{padding:'20px 0 12px', textAlign:'center'}}>
        <div style={{display:'flex', justifyContent:'center', marginBottom:8}}>
          <RfidTile size={48}/>
        </div>
        <div style={{fontSize:22, fontWeight:700, color:COL.wheat,
                     fontFamily:'inherit'}}>OmniGate</div>
        <div style={{fontSize:11, color:COL.muted, marginTop:2}}>Device Control</div>
      </div>
      <div style={{height:1, margin:'0 20px', background:COL.border}}/>

      {/* Dashboard pinned at top */}
      {subItem('Dashboard', dashActive,
               () => go({ kind:'dashboard' }), true)}
      <div style={{height:1, margin:'4px 20px 6px', background:COL.border}}/>

      {/* Device groups — scrollable when content exceeds available height
          so the Settings entry below stays pinned to the bottom. */}
      <div style={{flex:1, minHeight:0, overflow:'auto'}}>
        {deviceList.length === 0 && gatewayList.length === 0 && (
          <div style={{padding:'12px 20px', fontSize:12, color:COL.faint, fontStyle:'italic'}}>
            Waiting for host…
          </div>
        )}
        {deviceList.map(d => (
          <div key={'d-' + d.instanceId} style={{marginBottom:6}}>
            {groupHeader(d.displayName, COL.teal)}
            {subItem('Configuration', isConfigFor(d.instanceId),
                     () => go({ kind:'configuration', instanceId: d.instanceId }), true, COL.teal)}
            {subItem('Inventory',     isInventoryFor(d.instanceId),
                     () => go({ kind:'inventory',     instanceId: d.instanceId }), true, COL.teal)}
          </div>
        ))}
        {/* Gateway groups — single "Gateway" sub-item per gateway, orange
            divider exactly like OmniGate MainViewModel.  The detail panel
            is itself read-only for now (Phase 2 MVP shows status only). */}
        {gatewayList.map(g => (
          <div key={'g-' + g.instanceId} style={{marginBottom:6}}>
            {groupHeader(g.displayName, GW_ACCENT)}
            {subItem('Gateway', isGatewayFor(g.instanceId),
                     () => go({ kind:'gateway', instanceId: g.instanceId }),
                     true, GW_ACCENT)}
          </div>
        ))}
      </div>

      {/* Settings pinned to bottom */}
      <div style={{height:1, margin:'6px 20px 4px', background:COL.border}}/>
      {subItem('Settings', settingsActive,
               () => go({ kind: 'settings' }), true)}
      <div style={{textAlign:'center', fontSize:10, color:'#4B5563', padding:'6px 0 10px'}}>
        Meiwenti
      </div>
    </div>
  );
}

function DashboardPanel({ devices, gateways, hostUsername, myConnId, roomState }) {
  // Sort by InstanceId so the order matches OmniGate WPF's sidebar order.
  const devs = Object.values(devices).sort((a, b) =>
    String(a.instanceId).localeCompare(String(b.instanceId), undefined, { numeric:true }));
  const gws  = Object.values(gateways);

  return (
    <div style={{maxWidth:1280}}>
      <h2 style={{fontSize:24, color:COL.wheat, margin:'4px 0 18px', fontWeight:600}}>
        Dashboard
      </h2>

      {/* DEVICES */}
      <CapsLabel>Devices</CapsLabel>
      {devs.length === 0
        ? <EmptyRow text="No devices connected."/>
        : <WpfTable
            headers={[
              { label:'Device',       w:140, align:'left'   },
              { label:'Status',       w:140, align:'center' },
              { label:'Last tag (EPC)',     align:'left'   },
              { label:'Tags/sec',     w: 90, align:'right'  },
              { label:'Total reads',  w:110, align:'right'  },
              { label:'Unique',       w: 80, align:'right'  },
              { label:'Last hour',    w: 90, align:'right'  },
              { label:'Last 24h',     w: 90, align:'right'  },
            ]}
            rows={devs.map(d => [
              <span style={{color:COL.wheat, fontWeight:500}}>{d.displayName}</span>,
              <StatePill state={d.state}/>,
              <span style={{fontFamily:'var(--mono)', fontSize:13, color:COL.wheat}}>
                {d.lastTagEpc || '—'}
              </span>,
              <Num value={d.nbrTagsPerSecond}/>,
              <Num value={d.nbrTotalTagReads}/>,
              <Num value={d.nbrUniqueTags}/>,
              <Num value={d.nbrLastHour}/>,
              <Num value={d.nbrLast24Hours}/>,
            ])}/>}

      <div style={{height:24}}/>

      {/* GATEWAYS */}
      <CapsLabel>Gateways</CapsLabel>
      {gws.length === 0
        ? <EmptyRow text="No gateways connected."/>
        : <WpfTable
            headers={[
              { label:'Gateway',     w:140, align:'left'   },
              { label:'State',       w:140, align:'center' },
              { label:'Queue depth', w:120, align:'right'  },
              { label:'Tags sent',   w:120, align:'right'  },
              // Last sent has no fixed width — it absorbs extra horizontal space
              // so the leading columns (Gateway / State) keep their declared
              // widths instead of being scaled up by the browser. Without
              // this, the State pill drifts right and misaligns with the
              // Devices Status pill above.
              { label:'Last sent',          align:'left'   },
              { label:'Sending',     w:90,  align:'center' },
            ]}
            rows={gws.map(g => [
              <span style={{color:COL.wheat, fontWeight:500}}>{g.displayName}</span>,
              <StatePill state={g.state}/>,
              <Num value={g.queueDepth}/>,
              <Num value={g.tagsSent}/>,
              g.lastSent
                ? <span style={{color:COL.faint, fontSize:13}}>
                    {(() => { try { return new Date(g.lastSent).toLocaleTimeString(); }
                              catch { return '—'; } })()}
                  </span>
                : <span style={{color:COL.faint}}>—</span>,
              <SendingDot on={!!g.isSending}/>,
            ])}/>}

      <div style={{height:24}}/>

      {/* P2P HUB — mirrors the WPF "P2P HUB" strip */}
      <CapsLabel>P2P Hub</CapsLabel>
      <WpfTable
        headers={[
          // Match Devices/Gateways first-column width so the Hub status
          // pill lines up vertically with the Status / State pills above.
          { label:'Name',          w:140, align:'left'   },
          { label:'State',         w:140, align:'center' },
          { label:'Connection ID',       align:'center' },
        ]}
        rows={[[
          <span style={{color:COL.wheat, fontWeight:500}}>{hostUsername}</span>,
          <StatePill state={roomState?.hostOnline ? 'Connected' : 'Disconnected'}/>,
          <span style={{color:COL.faint, fontSize:12, fontFamily:'var(--mono)'}}>
            {myConnId || '—'}
          </span>,
        ]]}/>
    </div>
  );
}

function InventoryPanel({ device, tags, isController, onStart, onStop, onClear, onSetModelProp }) {
  if (!device) {
    return <div style={{color:COL.muted}}>Device not in snapshot yet — waiting…</div>;
  }
  const inventorying = device.state === 'Inventory';
  const online       = device.state === 'Online';
  const model        = device.model || {};

  // Filter mode: matches OmniGate URA4ViewModel.InventoriesFilter.
  //   0 = EPC   1 = TID   2 = USER
  const filterIdx    = Number(model.InventoryFilterIndex ?? 0);

  // ── FilterBytes display helpers ──────────────────────────────────
  // Input behaviour: block non-hex, force uppercase, separate pairs with '-'.
  //   user types  "ab12cdef"  → field shows "AB-12-CD-EF"
  //   user types  "g7"        → field shows "7"        (non-hex stripped)
  // Committed value sent to host is the clean hex string without dashes,
  // matching what URA4Device.PassFilter expects (HexStringToBinaryString
  // already strips spaces; we just don't include the dashes).
  const cleanHex = (s) => String(s ?? '').toUpperCase().replace(/[^0-9A-F]/g, '');
  const formatHex = (s) => {
    const h = cleanHex(s);
    const out = [];
    for (let i = 0; i < h.length; i += 2) out.push(h.slice(i, i + 2));
    return out.join('-');
  };

  // Local input buffers so the controller's keystrokes don't lag behind the
  // round-trip to the host.  We mirror the device-model values into local
  // state on first render and on incoming prop frames.  FilterBytes commits
  // live (after each completed pair); Ptr/Len commit onBlur.
  const [filterBytes, setFilterBytes] = useStateR(formatHex(model.FilterBytes));
  const [userPtr,     setUserPtr]     = useStateR(String(model.UserPtr ?? 0));
  const [userLen,     setUserLen]     = useStateR(String(model.UserLen ?? 0));
  useEffectR(() => { setFilterBytes(formatHex(model.FilterBytes)); }, [model.FilterBytes]);
  useEffectR(() => { setUserPtr(String(model.UserPtr ?? 0));  }, [model.UserPtr]);
  useEffectR(() => { setUserLen(String(model.UserLen ?? 0));  }, [model.UserLen]);

  // Track the last value we committed to the host for each field so we don't
  // re-emit identical setprops on every keystroke / re-render.  Initialised
  // from the model and kept in lock-step on incoming prop frames.
  const lastCommittedFilterRef = useRefR(cleanHex(model.FilterBytes));
  const lastCommittedPtrRef    = useRefR(Number(model.UserPtr ?? 0));
  const lastCommittedLenRef    = useRefR(Number(model.UserLen ?? 0));
  useEffectR(() => { lastCommittedFilterRef.current = cleanHex(model.FilterBytes); },
             [model.FilterBytes]);
  useEffectR(() => { lastCommittedPtrRef.current = Number(model.UserPtr ?? 0); },
             [model.UserPtr]);
  useEffectR(() => { lastCommittedLenRef.current = Number(model.UserLen ?? 0); },
             [model.UserLen]);

  const onFilterInputChange = (raw) => {
    const display = formatHex(raw);
    setFilterBytes(display);
    // Commit only when the hex string has an even length (a full pair just
    // completed) AND differs from the last committed value.  This produces
    // one round-trip per byte, not one per keystroke.
    const hex = cleanHex(raw);
    if (hex.length % 2 === 0 && hex !== lastCommittedFilterRef.current) {
      if (isController && onSetModelProp) onSetModelProp('FilterBytes', hex);
      lastCommittedFilterRef.current = hex;
    }
  };

  // Ptr / Len: commit live on every change (when valid AND different from
  // the last committed value).  These are pure host-side filter knobs —
  // OmniGate's OnUserPtrChanged / OnUserLenChanged are no-ops on the
  // device side, so there's no SDK round-trip cost to re-emitting them.
  const onPtrInputChange = (raw) => {
    setUserPtr(raw);
    if (!isValidUInt(raw)) return;
    const n = Number(raw);
    if (n === lastCommittedPtrRef.current) return;
    if (isController && onSetModelProp) onSetModelProp('UserPtr', n);
    lastCommittedPtrRef.current = n;
  };
  const onLenInputChange = (raw) => {
    setUserLen(raw);
    if (!isValidUInt(raw)) return;
    const n = Number(raw);
    if (n === lastCommittedLenRef.current) return;
    if (isController && onSetModelProp) onSetModelProp('UserLen', n);
    lastCommittedLenRef.current = n;
  };

  // ── Validation helpers ────────────────────────────────────────────
  // FilterBytes is now sanitised at input time (non-hex stripped, forced
  // uppercase, pairs grouped with '-'), so we don't need a separate
  // valid/invalid state for it any more — the field can never hold an
  // invalid value.  Ptr / Len still need numeric guards.
  const isValidUInt = (s) => {
    if (s === '' || s === null || s === undefined) return false;
    const n = Number(s);
    return Number.isInteger(n) && n >= 0;
  };

  const ptrValid = isValidUInt(userPtr);
  const lenValid = isValidUInt(userLen);

  const commitProp = (prop, raw, parse, valid = true) => {
    if (!isController || !onSetModelProp) return;
    if (!valid) return;                 // silently drop invalid commits
    const v = parse ? parse(raw) : raw;
    onSetModelProp(prop, v);
  };

  // Action buttons — same enable rules as Phase-2 MVP.
  const btnBase = {
    padding:'10px 28px', borderRadius:6, border:'1px solid #4B5563',
    background:'#1F2937', color:COL.text, cursor:'pointer', fontSize:14,
    fontFamily:'inherit', minWidth:120
  };
  const btnState = (enabled, kind) => {
    const c = kind === 'primary' ? COL.teal
            : kind === 'danger'  ? '#B91C1C'
            : '#4B5563';
    return { ...btnBase,
      background: enabled ? c : '#1F2937',
      borderColor: enabled ? c : '#4B5563',
      color:      enabled ? '#fff' : COL.faint,
      cursor:     enabled ? 'pointer' : 'not-allowed',
      opacity:    enabled ? 1 : 0.55
    };
  };

  const fieldLabel = { fontSize:14, color:COL.text, fontFamily:'inherit', marginRight:8 };
  const fieldBox = {
    background:COL.bg, border:`1px solid ${COL.border}`, borderRadius:4,
    color:COL.wheat, padding:'6px 10px', fontFamily:'var(--mono)', fontSize:14,
    minWidth:60
  };

  // Render in first-seen order — `tags` comes from a JS Map keyed by EPC
  // which preserves insertion order on re-set, so the array is already in
  // the order the reader first saw each EPC.  Previously we sorted by
  // timeStamp desc here, which caused a re-read tag to jump to the top
  // every time its row updated (visible "EPC order changes" bug).
  // Cap at 500 to keep the DOM cheap; show the 500 most recently *first-seen*
  // (i.e. the tail of the array — earliest entries scroll off the top).
  const visibleTags = tags.length > 500 ? tags.slice(-500) : tags;

  return (
    <div style={{maxWidth:1280, display:'flex', flexDirection:'column',
                 minHeight:'calc(100vh - 200px)'}}>
      {/* Title — centred, large wheat — matches URA4InventoryView.xaml caption */}
      <div style={{display:'flex', alignItems:'center', justifyContent:'center',
                   gap:14, margin:'4px 0 24px'}}>
        <div style={{fontSize:28, fontWeight:600, color:COL.wheat,
                     fontFamily:"'Rubik', system-ui, sans-serif"}}>
          {device.displayName}
        </div>
        <div style={{fontSize:24, fontWeight:300, color:COL.muted}}>·</div>
        <div style={{fontSize:28, fontWeight:600, color:COL.wheat,
                     fontFamily:"'Rubik', system-ui, sans-serif"}}>
          Inventory
        </div>
        <StatePill state={device.state}/>
      </div>

      {/* ── Filter-data row (matches WPF URA4InventoryView Filter panel)
            — controller can edit; observer sees the controller's values
              streaming back via the regular prop pipeline. */}
      <div style={{border:`1px solid ${COL.border}`, borderRadius:6,
                   padding:'14px 18px', marginBottom:14, background:COL.bg,
                   display:'flex', alignItems:'center', gap:18, flexWrap:'wrap'}}>
        <span style={fieldLabel}>Filter data (Hex):</span>
        <input type="text"
               value={filterBytes}
               readOnly={!isController}
               disabled={!isController}
               onChange={e => onFilterInputChange(e.target.value)}
               onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }}
               title="Hex only (0-9 / A-F). Non-hex chars are stripped. Pairs auto-separated with '-' and sent to the host as each pair completes."
               spellCheck={false}
               autoCapitalize="characters"
               style={{...fieldBox, flex:1, minWidth:260,
                       letterSpacing:'0.5px',
                       opacity: isController ? 1 : 0.7,
                       cursor: isController ? 'text' : 'not-allowed'}}/>
        <span style={fieldLabel}>Ptr (bits):</span>
        <input type="number" min={0} step={1}
               value={userPtr}
               readOnly={!isController}
               disabled={!isController}
               onChange={e => onPtrInputChange(e.target.value)}
               onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }}
               title={ptrValid ? '' : 'Must be a non-negative integer'}
               style={{...fieldBox, width:90,
                       borderColor: ptrValid ? COL.border : '#DC2626',
                       opacity: isController ? 1 : 0.7,
                       cursor: isController ? 'text' : 'not-allowed'}}/>
        <span style={fieldLabel}>Len (bits):</span>
        <input type="number" min={0} step={1}
               value={userLen}
               readOnly={!isController}
               disabled={!isController}
               onChange={e => onLenInputChange(e.target.value)}
               onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); }}
               title={lenValid ? '' : 'Must be a non-negative integer'}
               style={{...fieldBox, width:90,
                       borderColor: lenValid ? COL.border : '#DC2626',
                       opacity: isController ? 1 : 0.7,
                       cursor: isController ? 'text' : 'not-allowed'}}/>
        <select value={filterIdx}
                disabled={!isController}
                onChange={e => commitProp('InventoryFilterIndex', e.target.value, raw => parseInt(raw, 10))}
                style={{...fieldBox, fontFamily:'inherit', fontWeight:600,
                        color:COL.wheat, width:96,
                        opacity: isController ? 1 : 0.7,
                        cursor: isController ? 'pointer' : 'not-allowed'}}>
          <option value={0}>EPC</option>
          <option value={1}>TID</option>
          <option value={2}>USER</option>
        </select>
      </div>

      {/* ── Tag table ─────────────────────────────────────────────────── */}
      <div style={{border:`1px solid ${COL.border}`, borderRadius:6, overflow:'hidden',
                   flex:1, minHeight:240, display:'flex', flexDirection:'column'}}>
        <table style={{width:'100%', borderCollapse:'collapse', fontSize:14}}>
          <thead>
            <tr style={{background:COL.bg}}>
              <th style={thStyle(true)}>EPC</th>
              <th style={thStyle(true)}>TID</th>
              <th style={thStyle(true,  90)}>USER</th>
              <th style={thStyle(false, 80)}>Rssi</th>
              <th style={thStyle(false, 100)}>Cnt</th>
              <th style={thStyle(false, 70)}>Ant</th>
            </tr>
          </thead>
        </table>
        <div style={{flex:1, overflow:'auto'}}>
          <table style={{width:'100%', borderCollapse:'collapse', fontSize:14,
                         tableLayout:'fixed'}}>
            <colgroup>
              <col/>
              <col/>
              <col style={{width:90}}/>
              <col style={{width:80}}/>
              <col style={{width:100}}/>
              <col style={{width:70}}/>
            </colgroup>
            <tbody>
              {visibleTags.length === 0 && (
                <tr><td colSpan={6} style={{textAlign:'center', padding:'40px 12px',
                                              color:COL.faint, fontStyle:'italic'}}>
                  {inventorying ? 'Waiting for first tag…' : 'No tags read yet.'}
                </td></tr>
              )}
              {visibleTags.map((t, i) => (
                <tr key={t.epc || i}
                    style={{background: i % 2 === 0 ? COL.panel : '#2A2E33',
                            borderBottom:'1px solid #2D3138'}}>
                  <td style={tdStyle(true)}>{t.epc}</td>
                  <td style={tdStyle(true)}>{t.tid || ''}</td>
                  <td style={tdStyle(true)}>{t.userData || ''}</td>
                  <td style={tdStyle(false)}>{t.rssi}</td>
                  <td style={tdStyle(false)}><Num value={t.count}/></td>
                  <td style={tdStyle(false)}>{t.ant}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
      {tags.length > 500 && (
        <div style={{fontSize:11, color:COL.faint, marginTop:6}}>
          Showing 500 most recent of {tags.length.toLocaleString()}.
        </div>
      )}

      {/* ── Bottom strip: counters · actions ──────────────────────────── */}
      <div style={{display:'grid',
                   gridTemplateColumns:'auto 1fr auto',
                   alignItems:'center', gap:24, marginTop:18}}>
        {/* Counters — match WPF labels exactly */}
        <div style={{display:'flex', flexDirection:'column', gap:6, fontSize:14}}>
          <CounterLine label="Tags:"          value={device.nbrUniqueTags    ?? 0}/>
          <CounterLine label="Tag reads:"     value={device.nbrTotalTagReads ?? 0}/>
          <CounterLine label="Tag read/sec:"  value={device.nbrTagsPerSecond ?? 0}/>
        </div>
        {/* Centre — Start / Stop */}
        <div style={{display:'flex', justifyContent:'center', gap:12}}>
          <button onClick={onStart} disabled={!isController || !online}
                  style={btnState(isController && online, 'primary')}>
            Start
          </button>
          <button onClick={onStop}  disabled={!isController || !inventorying}
                  style={btnState(isController && inventorying, 'danger')}>
            Stop
          </button>
        </div>
        {/* Right — Clear */}
        <button onClick={onClear} disabled={!isController}
                style={btnState(isController, 'plain')}>
          Clear
        </button>
      </div>
      {!isController && (
        <div style={{fontSize:11, color:COL.muted, marginTop:8, textAlign:'center'}}>
          Controls disabled — observer mode
        </div>
      )}
    </div>
  );
}

// Tag table header / cell style helpers — kept inline since the table is
// hand-rolled (not WpfTable) so we can keep header + body in sync with the
// same column widths while letting the body scroll vertically.
const thStyle = (left, w) => ({
  textAlign: left ? 'left' : 'center',
  width: w ? w : undefined,
  padding:'10px 14px',
  borderBottom:`1px solid ${COL.border}`,
  color:COL.wheat, fontWeight:600,
  fontSize:14, fontFamily:'inherit',
});
const tdStyle = (left) => ({
  textAlign: left ? 'left' : 'center',
  padding:'10px 14px',
  color: COL.text,
  fontFamily: left ? 'var(--mono)' : 'inherit',
  fontSize: left ? 13 : 14,
  whiteSpace:'nowrap',
  overflow:'hidden', textOverflow:'ellipsis',
});

function CounterLine({ label, value }) {
  return (
    <div style={{display:'flex', gap:8, fontFamily:'inherit'}}>
      <span style={{color:COL.text, minWidth:120}}>{label}</span>
      <span style={{color:COL.wheat, fontFamily:'var(--mono)',
                    fontVariantNumeric:'tabular-nums', fontWeight:600}}>
        {Number(value || 0).toLocaleString()}
      </span>
    </div>
  );
}

// ════════════════════════════════════════════════════════════════════
// ConfigurationPanel — mirrors OmniGate WPF URA4ConfigurationView.xaml.
//
// The host streams the full URA4WorkModel on every PropertyChanged (target=
// "model") and accepts the same shape via setprop, so this panel is purely
// presentational: pick the right control type per field, send a setprop on
// change.  Observers see updates but their inputs are disabled.
//
// Layout: a responsive 3-column grid of "field cards" to mimic the WPF
// 3-column form layout.  Each card is one labelled control.
// ════════════════════════════════════════════════════════════════════
// React.memo with a custom comparator so this panel re-renders only when
// inputs that the form actually depends on change — i.e. the RFID model
// object, device state, or controller-role. The parent RemoteView calls
// setDevices ~10×/sec when a tag stream is active (handleTagsBatch updates
// derived counters), which would otherwise close any open native <select>
// dropdown (DR / Miller / Session…) within ~100 ms. handleProp preserves
// the model object reference when a model property is unchanged (v1.7.10
// propEqual), so reference-equality on `device.model` is a safe and cheap
// fence. The inline callback props change identity every render but always
// do the same thing — we deliberately ignore them in the comparator.
const ConfigurationPanel = React.memo(function ConfigurationPanel(
                            { device, isController, onConnect, onDisconnect,
                              onUpload, onDownload, onLoad, onSave,
                              onDefault, onCopyToAll,
                              onSetModelProp, onSetPower, onSetWorkTime,
                              onSetAntennaEnabled }) {
  // All four file-buttons now defer to dialogs owned by RemoteView; they
  // each take a single no-arg click handler.
  if (!device) {
    return <div style={{color:COL.muted}}>Device not in snapshot yet — waiting…</div>;
  }
  const model        = device.model || {};
  const connected    = device.state !== 'Disconnected';
  // "Wait mode" in the URA4 state machine == DeviceState.Online.  Upload /
  // Download only work in this state — the SDK needs the device to be
  // idle (connected but not currently streaming tags).
  const online       = device.state === 'Online';
  const inventorying = device.state === 'Inventory';
  // Load / Save / Default / Copy-to-all are forbidden during inventory
  // (they mutate the model and would race the live tag stream / SDK
  // upload), but otherwise always available — even when disconnected.
  const fileBtnEnabled = isController && !inventorying;

  // Option lists — pulled verbatim from URA4ViewModel.cs (the lists the
  // WPF combos bind to). Keep parity so what the controller picks here
  // matches what they'd pick on the desktop.
  const RfLinks    = ['FM0-40kHz','Miller2-40kHz','Miller4-40kHz','Miller4-640kHz'];
  const Protocols  = ['ISO18000-6C'];
  const WorkModes  = ['Answer','Active'];
  const InvFilter  = ['EPC','TID','USER'];
  const EpcTarget  = ['A','B','AB'];
  const EpcMiller  = ['FM0','M2','M4','M8'];
  const EpcDR      = ['DR8','DR64/3'];
  const EpcTRext   = ['No pilot','Pilot'];
  const EpcSession = ['S0','S1','S2','S3'];
  const EpcTargetAB= ['A','B'];
  const EpcQ       = ['Fixed','Dynamic'];
  const OnOff      = ['Off','On'];
  const num0to15   = Array.from({length:16}, (_, i) => i);
  const num1to33   = Array.from({length:33}, (_, i) => i + 1);
  const num1to80   = Array.from({length:80}, (_, i) => i + 1);

  const commit = (prop, value) => {
    // Diagnostic: every model-prop edit goes through here. If a dropdown
    // change appears to "revert", check this log line in DevTools — it
    // tells us whether the change was actually sent or dropped at the
    // controller gate. (roomState / myConnId live in the parent RemoteView
    // scope; isController already encodes their comparison so we don't
    // need them directly here.)
    console.log('[commit]', {
      prop, value, isController, connected, deviceState: device.state,
    });
    if (!isController) {
      console.warn('[commit] dropped: not controller');
      return;
    }
    if (!onSetModelProp) return;
    onSetModelProp(prop, value);
  };

  // ── Styling helpers ──────────────────────────────────────────────
  const cardStyle = {
    background: COL.panel, border: `1px solid ${COL.border}`, borderRadius: 6,
    padding: '10px 14px', display: 'flex', alignItems: 'center',
    justifyContent: 'space-between', gap: 12, minHeight: 48,
    opacity: connected ? 1 : 0.55,
  };
  const labelStyle = {
    color: COL.text, fontSize: 14, fontFamily: 'inherit',
    whiteSpace: 'nowrap',
  };
  const inputStyle = {
    background: COL.bg, border: `1px solid ${COL.border}`, borderRadius: 4,
    color: COL.wheat, padding: '6px 10px', fontFamily: 'var(--mono)',
    fontSize: 14, minWidth: 140,
  };
  const selectStyle = {
    ...inputStyle, fontFamily: 'inherit', cursor: isController && connected ? 'pointer' : 'not-allowed',
  };
  const btnBase = {
    padding:'8px 22px', borderRadius:6, border:`1px solid ${COL.border}`,
    background:'#1F2937', color:COL.text, cursor:'pointer', fontSize:14,
    fontFamily:'inherit', minWidth:110,
  };
  const btnState = (enabled, kind) => {
    const c = kind === 'primary' ? COL.teal
            : kind === 'danger'  ? '#B91C1C'
            : '#4B5563';
    return { ...btnBase,
      background: enabled ? c : '#1F2937',
      borderColor: enabled ? c : '#4B5563',
      // Disabled text uses the lighter `muted` token (#94A3B8) instead of
      // `faint` (#6B7280) so the label stays readable on the dark button
      // background.  Opacity bumped from 0.55 → 0.75 for the same reason.
      color:      enabled ? '#fff' : COL.muted,
      cursor:     enabled ? 'pointer' : 'not-allowed',
      opacity:    enabled ? 1 : 0.75,
    };
  };

  // A field rendering combo for a labelled <select>, value bound to a
  // numeric index, options either an array of strings (index → label)
  // or an array of numbers (used both as value and as label).
  //   inline = true   → strip the bordered card chrome so it can sit inside
  //                     a parent card (used for RF + Inventory clusters).
  //   valueOverride   → read the current value from this explicit source
  //                     instead of model[prop].  Needed when the read path
  //                     and write path differ — e.g. RfPower writes go to
  //                     the [JsonIgnore] `RfPower` shortcut (which sets
  //                     antenna 1) but reads must come from the serialized
  //                     `RfPowers[0]` array element.
  // ComboField — see the top-level OptimisticCombo component for the actual
  // implementation. We delegate so the dropdown can own a local optimistic
  // state (impossible to do here because ConfigurationPanel re-creates this
  // closure on every render, defeating useState).
  const ComboField = ({ label, prop, options, valueIsIndex = true,
                        disabled = false, inline = false,
                        valueOverride = undefined }) => (
    <OptimisticCombo
      label={label} prop={prop} options={options}
      valueIsIndex={valueIsIndex}
      disabled={disabled || !isController || !online}
      inline={inline}
      modelValue={valueOverride !== undefined ? valueOverride : model[prop]}
      commit={commit}
      styles={{ cardStyle, inlineRowStyle, labelStyle, selectStyle }}
    />
  );

  // Read-only "value chip" — used for HW/FW/API/Temperature.
  const ReadOnlyField = ({ label, value }) => (
    <div style={cardStyle}>
      <span style={labelStyle}>{label}</span>
      <span style={{...inputStyle, background: 'transparent',
                    border: 'none', color: COL.wheat, textAlign: 'right',
                    minWidth: 0}}>
        {value ?? '—'}
      </span>
    </div>
  );

  // Inventory: when EPC or TID is selected, ptr/len are hidden (matches WPF
  // URA4ConfigurationView.xaml DataTriggers on InventoryCombo SelectedIndex
  // 0/1).  Bind to InventoryIndex (the device-side mode) — *not* to
  // InventoryFilterIndex which is the separate host-side filter knob on
  // the Inventory page.
  const invIdx = Number(model.InventoryIndex ?? 0);
  const showPtrLen = invIdx === 2;   // USER

  // ── Filter bytes (string) — only meaningful in USER mode, same as ptr/len.
  // Local state so the user can type without each keystroke being clobbered
  // by an echo of the model prop. Commit on blur / Enter.
  const [filterBytesCfg, setFilterBytesCfg] = useStateR(String(model.FilterBytes ?? ''));
  useEffectR(() => { setFilterBytesCfg(String(model.FilterBytes ?? '')); },
             [model.FilterBytes]);
  const commitFilterBytesCfg = () => {
    if (!isController) return;
    const clean = filterBytesCfg.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
    if (clean.length % 2 !== 0) return;   // require even-length hex
    if (clean === String(model.FilterBytes ?? '')) return;
    commit('FilterBytes', clean);
  };

  // ── Per-antenna work time (dwell, ms) — shares the Output-power antenna
  //    selector.  Local state + commit on blur/Enter (so we don't fire a
  //    setWorkTime cmd on every keystroke).  Re-seeds when the selected
  //    antenna's stored value changes (antenna switch or host read-back).
  //    Writes go through the `setWorkTime` cmd: antenna=0 when "All" is
  //    checked, otherwise 1..4.  -1 = unset → shown blank.
  const wtAnt = (() => { const a = Number(model.SelectedAntenna ?? 1);
                         return a >= 1 && a <= 4 ? a : 1; })();
  const wtModelVal = Array.isArray(model.AntennaWorkTimes) && model.AntennaWorkTimes.length >= 4
                       ? model.AntennaWorkTimes[wtAnt - 1] : -1;
  const [workTimeCfg, setWorkTimeCfg] = useStateR(wtModelVal >= 0 ? String(wtModelVal) : '');
  useEffectR(() => { setWorkTimeCfg(wtModelVal >= 0 ? String(wtModelVal) : ''); },
             [wtModelVal]);
  // Push immediately on any valid change (so the up/down arrows reach the
  // device), as well as on blur/Enter.
  const pushWorkTime = (raw) => {
    if (!isController || !onSetWorkTime) return;
    const v = parseInt(raw, 10);
    if (!Number.isInteger(v) || v < 10 || v > 65535 || v === wtModelVal) return;
    const allAnt = model.AllAntennas !== undefined ? !!model.AllAntennas : true;
    onSetWorkTime(allAnt ? 0 : wtAnt, v);
  };

  // ── On/Off pair (used for cw — the WPF version is two side-by-side
  //     command buttons, NOT a stateful toggle.  They emit the action
  //     when clicked; neither button reflects the current value back.
  //     We mirror that here — same look regardless of model state, so
  //     the controller doesn't get a misleading "active" visual that
  //     could drift from the actual device state. */
  const OnOffButtons = ({ label, prop }) => {
    const enabled = isController && connected;
    const plainBtn = (text, val) => (
      <button onClick={() => commit(prop, val)}
              disabled={!enabled}
              style={{flex:1, padding:'8px 0', borderRadius:4,
                      border:`1px solid ${COL.border}`,
                      background: COL.bg,
                      color: enabled ? COL.text : COL.faint,
                      cursor: enabled ? 'pointer' : 'not-allowed',
                      fontFamily:'inherit', fontSize:14,
                      opacity: enabled ? 1 : 0.55}}>
        {text}
      </button>
    );
    return (
      <div style={cardStyle}>
        <span style={labelStyle}>{label}</span>
        <div style={{display:'flex', gap:6, minWidth:140}}>
          {plainBtn('On', 1)}
          {plainBtn('Off', 0)}
        </div>
      </div>
    );
  };

  // The Load / Save / Default / Copy-to-all buttons no longer carry any
  // logic of their own — the parent RemoteView opens themed dialogs
  // (ConfigFileDialog / ConfirmDialog) and sends the resulting cmd.
  // Buttons just forward the click.

  // ── Render — 3-column WPF-style layout (URA4ConfigurationView.xaml) ──
  return (
    <div style={{maxWidth:1280}}>
      {/* Title — "Gate 1  -  Configuration" matches WPF Caption row */}
      <div style={{display:'flex', alignItems:'center', justifyContent:'center',
                   gap:14, margin:'4px 0 18px', flexWrap:'wrap'}}>
        <div style={{fontSize:28, fontWeight:600, color:COL.wheat,
                     fontFamily:"'Rubik', system-ui, sans-serif"}}>
          {device.displayName}
        </div>
        <div style={{fontSize:24, fontWeight:300, color:COL.muted}}>-</div>
        <div style={{fontSize:28, fontWeight:600, color:COL.wheat,
                     fontFamily:"'Rubik', system-ui, sans-serif"}}>
          Configuration
        </div>
        <StatePill state={device.state}/>
      </div>

      {/* 3-column grid — left / middle / right, each laid out top-down to
          match URA4ConfigurationView.xaml exactly. */}
      <div style={{display:'grid', gap:12, alignItems:'start',
                   gridTemplateColumns:'1fr 1fr 1fr'}}>

        {/* ── Column 1 (left) ──────────────────────────────────────── */}
        <div style={{display:'flex', flexDirection:'column', gap:10}}>
          {/* IP + Connect/Disconnect — combined card (WPF "IP address" panel) */}
          <div style={{background:COL.panel, border:`1px solid ${COL.border}`,
                       borderRadius:6, padding:'12px 14px'}}>
            <div style={{display:'grid', gridTemplateColumns:'auto 1fr',
                         gap:'6px 12px', alignItems:'center'}}>
              <span style={labelStyle}>Device IP address:</span>
              <span style={{...inputStyle, color: COL.wheat, textAlign:'center',
                            minWidth:0}}>
                {/* IP comes from the flat fields populated by handleConfig
                    (device.ipAddress), with a fallback to model.IpAddress
                    in case a prop frame arrived before the snapshot.  We
                    deliberately don't read device.instanceConfig — it's
                    not preserved in the client-side device shape. */}
                {device.ipAddress
                 || (device.model && device.model.IpAddress)
                 || '—'}
              </span>
            </div>
            <div style={{display:'flex', gap:10, marginTop:10, justifyContent:'center'}}>
              <button onClick={onConnect}
                      disabled={!isController || connected}
                      style={btnState(isController && !connected, 'primary')}>
                Connect
              </button>
              <button onClick={onDisconnect}
                      disabled={!isController || !connected}
                      style={btnState(isController && connected, 'danger')}>
                Disconnect
              </button>
            </div>
          </div>

          {/* RF — Output power (per-antenna) + Sensitivity.  The output-power
              combo binds to RfPowers[SelectedAntenna-1] (or RfPower for
              legacy snapshots).  Writes go through the new `setPower` cmd
              which the host routes to either every antenna (antenna=0,
              when AllAntennas) or just one (antenna=1..4).  The "All"
              checkbox and antenna spinner on the right pipe through the
              normal setprop pipeline to the model's AllAntennas /
              SelectedAntenna fields.  Per spec: flipping "All" does NOT
              push a power change — only the next combo edit does. */}
          {(() => {
            const allAntennas    = model.AllAntennas !== undefined
                                     ? !!model.AllAntennas : true;
            const rawAnt         = Number(model.SelectedAntenna ?? 1);
            const selectedAnt    = rawAnt >= 1 && rawAnt <= 4 ? rawAnt : 1;
            const arr            = Array.isArray(model.RfPowers) ? model.RfPowers : null;
            const displayedPower = arr && arr.length >= 4
                                     ? arr[selectedAnt - 1]
                                     : (model.RfPower ?? -1);
            const onPowerChange = (newPower) => {
              if (!isController || !onSetPower) return;
              const antenna = allAntennas ? 0 : selectedAnt;
              onSetPower(antenna, newPower);
            };
            // Per-antenna enable/disable ("Active").  Reads model.AntennaEnabled
            // (default true), writes via the setAntennaEnabled cmd routed to
            // every antenna (0) when "All" is checked, else just the spinner's.
            const enArr         = Array.isArray(model.AntennaEnabled) ? model.AntennaEnabled : null;
            const antennaActive = enArr && enArr.length >= 4 ? !!enArr[selectedAnt - 1] : true;
            const onActiveChange = (checked) => {
              if (!isController || !onSetAntennaEnabled) return;
              onSetAntennaEnabled(allAntennas ? 0 : selectedAnt, checked);
            };
            // For the read-only Sensitivity display: the URA4 SDK doesn't
            // expose a sensitivity get/set, so model.RfSensitivity is
            // almost always -1.  Render -1 (or any out-of-range value) as
            // an em-dash so the field reads "no value" instead of empty.
            const rawSens = Number(model.RfSensitivity);
            const sensText = Number.isInteger(rawSens) && rawSens >= 1 && rawSens <= 80
                                ? String(rawSens) : '—';
            return (
              <div style={{background:COL.panel, border:`1px solid ${COL.border}`,
                           borderRadius:6, padding:'10px 14px',
                           display:'flex', gap:14, alignItems:'stretch'}}>
                {/* Left side: Active + Output-power + Sensitivity + Antenna time */}
                <div style={{flex:1, display:'flex', flexDirection:'column', gap:8,
                             minWidth:0}}>
                  {/* Active: per-antenna enable/disable (same selector as power) */}
                  <div style={inlineRowStyle}>
                    <span style={labelStyle}>Active:</span>
                    <label style={{display:'flex', alignItems:'center', gap:6,
                                   color:COL.text, fontSize:14,
                                   cursor: isController && online ? 'pointer' : 'not-allowed',
                                   opacity: isController && online ? 1 : 0.6}}>
                      <input type="checkbox"
                             checked={antennaActive}
                             disabled={!isController || !online}
                             onChange={(e) => onActiveChange(e.target.checked)}
                             style={{accentColor: COL.teal}}/>
                      Enabled
                    </label>
                  </div>
                  <div style={inlineRowStyle}>
                    <span style={labelStyle}>Output power dBm:</span>
                    <select value={displayedPower >= 0 ? displayedPower : ''}
                            disabled={!isController || !online}
                            onChange={(e) => onPowerChange(parseInt(e.target.value, 10))}
                            style={selectStyle}>
                      {displayedPower < 0 && <option value="">—</option>}
                      {num1to33.map((n) => (
                        <option key={n} value={n}>{n}</option>
                      ))}
                    </select>
                  </div>
                  <div style={inlineRowStyle}>
                    <span style={labelStyle}>Sensitivity dBm:</span>
                    <span style={{...inputStyle, background:'#1F2937',
                                  textAlign:'center', minWidth:120,
                                  cursor:'default'}}>
                      {sensText}
                    </span>
                  </div>
                  {/* Per-antenna work time (ms, 10..65535).  Same antenna
                      selector as Output power.  Up/down arrows push to the
                      device immediately; typing also commits on blur / Enter. */}
                  <div style={inlineRowStyle}>
                    <span style={labelStyle}>Antenna time ms:</span>
                    <input type="number" min={10} max={65535} step={10}
                           value={workTimeCfg}
                           disabled={!isController || !online}
                           onChange={(e) => { setWorkTimeCfg(e.target.value); pushWorkTime(e.target.value); }}
                           onBlur={() => pushWorkTime(workTimeCfg)}
                           onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }}
                           placeholder="—"
                           style={selectStyle}/>
                  </div>
                </div>

                {/* Right side: bordered box wrapping "All" + Ant spinner.
                    Mirrors the WPF layout — checkbox aligned with the
                    Output-power row baseline, spinner aligned with the
                    Sensitivity row baseline.  Flex column stretches to
                    match the card height; justify-content:space-between
                    pushes the children to opposite ends.            */}
                <div style={{display:'flex', flexDirection:'column',
                             justifyContent:'space-between',
                             alignItems:'flex-start',
                             flexShrink:0,
                             border:`1px solid ${COL.border}`,
                             borderRadius:4, padding:'8px 12px',
                             minWidth:120}}>
                  <label style={{display:'flex', alignItems:'center', gap:6,
                                 color:COL.text, fontSize:14,
                                 cursor: isController && online ? 'pointer' : 'not-allowed',
                                 opacity: isController && online ? 1 : 0.6}}>
                    <input type="checkbox"
                           checked={allAntennas}
                           disabled={!isController || !online}
                           onChange={(e) => commit('AllAntennas', e.target.checked)}
                           style={{accentColor: COL.teal}}/>
                    All
                  </label>
                  <div style={{display:'flex', alignItems:'center', gap:8}}>
                    <span style={{color:COL.text, fontSize:14}}>Ant:</span>
                    <input type="number" min={1} max={4} step={1}
                           value={selectedAnt}
                           disabled={!isController || !online || allAntennas}
                           onChange={(e) => {
                             const v = parseInt(e.target.value, 10);
                             if (Number.isInteger(v) && v >= 1 && v <= 4) {
                               commit('SelectedAntenna', v);
                             }
                           }}
                           title={allAntennas
                                    ? 'Disabled while "All" is checked'
                                    : 'Antenna 1–4 (changes which antenna the controls edit)'}
                           style={{...inputStyle, width:64, minWidth:64,
                                   textAlign:'center', fontFamily:'var(--mono)',
                                   opacity: allAntennas ? 0.55 : 1}}/>
                  </div>
                </div>
              </div>
            );
          })()}

          <ComboField label="RF Link:"     prop="RfLinkIndex"   options={RfLinks}/>
          <ComboField label="Protocol:"    prop="ProtocolIndex" options={Protocols} disabled/>
          <ComboField label="Work mode:"   prop="WorkModeIndex" options={WorkModes} disabled/>

          {/* Inventory — combined card (always shows Inventory; ptr/len only
              when USER is selected, exactly like the WPF DataTrigger). */}
          <div style={{background:COL.panel, border:`1px solid ${COL.border}`,
                       borderRadius:6, padding:'10px 14px',
                       display:'flex', flexDirection:'column', gap:8}}>
            <ComboField label="Inventory:" prop="InventoryIndex"
                        options={InvFilter} inline/>
            {showPtrLen && (
              <UIntField label="User ptr:" prop="UserPtr" value={model.UserPtr}
                         commit={commit} isController={isController} connected={online}
                         cardStyle={inlineRowStyle} labelStyle={labelStyle}
                         inputStyle={inputStyle}/>
            )}
            {showPtrLen && (
              <UIntField label="User len:" prop="UserLen" value={model.UserLen}
                         commit={commit} isController={isController} connected={online}
                         cardStyle={inlineRowStyle} labelStyle={labelStyle}
                         inputStyle={inputStyle}/>
            )}
            {showPtrLen && (
              // Filter bytes (even-length hex). Local state + commit-on-blur
              // so typing doesn't fight echoes from the prop stream.
              <div style={inlineRowStyle}>
                <span style={labelStyle}>Filter bytes:</span>
                <input
                  type="text"
                  value={filterBytesCfg}
                  disabled={!isController}
                  readOnly={!isController}
                  spellCheck={false}
                  autoCapitalize="characters"
                  placeholder="FF00"
                  onChange={(e) => setFilterBytesCfg(
                    e.target.value.replace(/[^0-9A-Fa-f]/g, '').toUpperCase())}
                  onBlur={commitFilterBytesCfg}
                  onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }}
                  style={{...inputStyle, fontFamily:'var(--mono)'}}
                  title="Hex only (0-9, A-F). Sent to host when input loses focus and length is even."
                />
              </div>
            )}
          </div>
        </div>

        {/* ── Column 2 (middle) — EPC parameters ───────────────────── */}
        <div style={{display:'flex', flexDirection:'column', gap:10}}>
          <ComboField label="Target:"   prop="EpcTargetModeIndex" options={EpcTarget}/>
          <ComboField label="Miller:"   prop="EpcMillerIndex"     options={EpcMiller}/>
          <ComboField label="DR:"       prop="EpcDRIndex"         options={EpcDR}/>
          <ComboField label="TRext:"    prop="EpcTRextIndex"      options={EpcTRext}/>
          <ComboField label="Session:"  prop="EpcSessionIndex"    options={EpcSession}/>
          <ComboField label="Target:"   prop="EpcTargetABIndex"   options={EpcTargetAB}/>
          <ComboField label="Q:"        prop="EpcQIndex"          options={EpcQ}/>
          <ComboField label="startQ:"   prop="EpcQStartIndex"     options={num0to15}/>
          <ComboField label="minQ:"     prop="EpcQMinIndex"       options={num0to15}/>
          <ComboField label="maxQ:"     prop="EpcQMaxIndex"       options={num0to15}/>
        </div>

        {/* ── Column 3 (right) — Versions + misc switches ──────────── */}
        <div style={{display:'flex', flexDirection:'column', gap:10}}>
          {/* Versions / Temperature — combined card (WPF "SW & HW version").
              Row gap widened (8→12px) so the card is a touch taller and the
              next item (Tag Focus) lines up with TRext in the middle column. */}
          <div style={{background:COL.panel, border:`1px solid ${COL.border}`,
                       borderRadius:6, padding:'10px 14px',
                       display:'grid', gridTemplateColumns:'auto 1fr',
                       gap:'12px 14px', alignItems:'center'}}>
            <span style={labelStyle}>Hardware version:</span>
            <span style={readChip}>{device.hwVersion || '—'}</span>
            <span style={labelStyle}>Firmware version:</span>
            <span style={readChip}>{device.fwVersion || '—'}</span>
            <span style={labelStyle}>API version:</span>
            <span style={readChip}>{device.apiVersion || '—'}</span>
            <span style={labelStyle}>Temperature:</span>
            <span style={readChip}>{model.Temperature || '—'}</span>
          </div>

          <ComboField    label="Tag Focus:" prop="TagFocusIndex" options={OnOff}/>
          <ComboField    label="FastID:"    prop="FastIdIndex"   options={OnOff}/>
          <ComboField    label="Buzzer:"    prop="BuzzerIndex"   options={OnOff}/>
          <OnOffButtons  label="cw:"        prop="ContinuousWaveIndex"/>
        </div>
      </div>

      {/* ── Bottom: WPF "Config buttons" row.  Load / Save open the
            themed ConfigFileDialog backed by the host's saved-configs
            folder.  Default and Copy-to-all open a themed ConfirmDialog. */}
      {/* All six buttons share the same primary teal styling so enabled vs.
            disabled is a clear contrast — `neutral` looked too close to the
            disabled grey before.  Enable rules:
              • Load / Save / Default / Copy-to-all: !inventorying
                (Disconnected/Online/Connecting all allowed — these mutate the
                 model and the device sync follows once back at Online).
              • Upload / Download: requires `online` (== DeviceState.Online,
                 a.k.a. "wait" mode) — SDK needs an idle connected device.
            All require isController as well. */}
      <div style={{display:'grid', gap:10, marginTop:18,
                   gridTemplateColumns:'repeat(6, 1fr)'}}>
        <button onClick={onLoad}
                disabled={!fileBtnEnabled}
                title={inventorying
                         ? 'Disabled while the device is in Inventory mode'
                         : 'Open the saved-config picker and load one onto this device'}
                style={btnState(fileBtnEnabled, 'primary')}>
          Load
        </button>
        <button onClick={onSave}
                disabled={!fileBtnEnabled}
                title={inventorying
                         ? 'Disabled while the device is in Inventory mode'
                         : "Save this device's current config to the host under a name"}
                style={btnState(fileBtnEnabled, 'primary')}>
          Save
        </button>
        <button onClick={onUpload}
                disabled={!isController || !online}
                title={online
                         ? 'Push the current config to the device'
                         : 'Disabled — requires the device to be connected and not currently inventorying'}
                style={btnState(isController && online, 'primary')}>
          Upload
        </button>
        <button onClick={onDownload}
                disabled={!isController || !online}
                title={online
                         ? "Read the device's current config back into the model"
                         : 'Disabled — requires the device to be connected and not currently inventorying'}
                style={btnState(isController && online, 'primary')}>
          Download
        </button>
        <button onClick={onDefault}
                disabled={!fileBtnEnabled}
                title={inventorying
                         ? 'Disabled while the device is in Inventory mode'
                         : "Reset to the Ura4Default saved configuration on the host"}
                style={btnState(fileBtnEnabled, 'primary')}>
          Default
        </button>
        <button onClick={onCopyToAll}
                disabled={!fileBtnEnabled}
                title={inventorying
                         ? 'Disabled while the device is in Inventory mode'
                         : "Apply this device's RFID config to every other URA4 device and Upload to each connected one"}
                style={btnState(fileBtnEnabled, 'primary')}>
          Copy to all
        </button>
      </div>
    </div>
  );
}, (prev, next) => {
  // Equal (skip re-render) iff none of the inputs that actually affect the
  // form's rendered output have changed. Compare model by reference because
  // handleProp keeps it stable when no model prop was updated.
  const a = prev.device || {};
  const b = next.device || {};
  if (a === b) return true;
  if (a.state         !== b.state)         return false;
  if (a.model         !== b.model)         return false;
  if (a.displayName   !== b.displayName)   return false;
  if (a.hwVersion     !== b.hwVersion)     return false;
  if (a.fwVersion     !== b.fwVersion)     return false;
  if (a.apiVersion    !== b.apiVersion)    return false;
  if (prev.isController !== next.isController) return false;
  // The callback props (onSetModelProp etc.) change identity every render
  // because the parent declares them inline; they always do the same thing,
  // so we ignore them on purpose.
  return true;
});

// Shared style fragments referenced from ConfigurationPanel.  Declared
// outside the component so they don't allocate on each render.
const inlineRowStyle = {
  display: 'flex', alignItems: 'center', justifyContent: 'space-between',
  gap: 12, minHeight: 36,
};
const readChip = {
  background: '#1F2937', border: '1px solid #3E434A', borderRadius: 4,
  color: '#FFE7B5', padding: '4px 12px', textAlign: 'center',
  fontFamily: 'var(--mono)', fontSize: 14, minWidth: 130,
};

// ── UIntField (used for UserPtr / UserLen in the Configuration panel) ──
// Live-commits on every keystroke when the value is a valid non-negative
// integer.  De-duplicates against the model so incoming prop frames don't
// echo back.
function UIntField({ label, prop, value, commit, isController, connected,
                    cardStyle, labelStyle, inputStyle }) {
  const [local, setLocal] = useStateR(String(value ?? 0));
  useEffectR(() => { setLocal(String(value ?? 0)); }, [value]);
  const lastRef = useRefR(Number(value ?? 0));
  useEffectR(() => { lastRef.current = Number(value ?? 0); }, [value]);

  const valid = local !== '' && Number.isInteger(Number(local)) && Number(local) >= 0;
  const onChange = (raw) => {
    setLocal(raw);
    if (!valid && raw !== '') return;
    if (raw === '') return;
    const n = Number(raw);
    if (Number.isInteger(n) && n >= 0 && n !== lastRef.current) {
      commit(prop, n);
      lastRef.current = n;
    }
  };
  return (
    <div style={cardStyle}>
      <span style={labelStyle}>{label}</span>
      <input type="number" min={0} step={1}
             value={local}
             disabled={!isController || !connected}
             onChange={e => onChange(e.target.value)}
             style={{...inputStyle, width:120, textAlign:'right',
                     borderColor: valid ? COL.border : '#DC2626'}}/>
    </div>
  );
}

// ════════════════════════════════════════════════════════════════════
// Themed modal dialogs.  Visual style matches the OmniGate WPF dialogs
// (dark #272B2F panel with wheat title, rounded corners, two-button
// footer).  Used in place of browser-native window.confirm() and
// <input type=file>.  Mounted at the top of <RemoteView>'s tree so
// they sit above the drawer overlay.
// ════════════════════════════════════════════════════════════════════
function ConfirmDialog({ title, message, confirmLabel = 'OK', danger = false,
                         onCancel, onConfirm }) {
  return (
    <DialogShell title={title} onCancel={onCancel}>
      <div style={{padding:'18px 26px 4px', color:COL.text,
                   fontSize:14, lineHeight:1.5, whiteSpace:'pre-wrap',
                   fontFamily:'inherit'}}>
        {message}
      </div>
      <DialogFooter>
        <DialogButton onClick={onCancel}>Cancel</DialogButton>
        <DialogButton onClick={onConfirm} kind={danger ? 'danger' : 'primary'}>
          {confirmLabel}
        </DialogButton>
      </DialogFooter>
    </DialogShell>
  );
}

function ConfigFileDialog({ mode, files, loading, suggestedName,
                            onCancel, onLoadPick, onSavePick }) {
  // `selected` doubles as the Save-mode text input value.  Pre-populate
  // it with the current ConfigurationName so users can hit Enter to
  // overwrite without retyping.
  const [selected, setSelected] = useStateR(mode === 'save' ? (suggestedName || '') : '');
  // Keyboard: Enter = primary action when usable, Esc = Cancel.
  const onKey = (e) => {
    if (e.key === 'Escape') { e.stopPropagation(); onCancel(); }
    else if (e.key === 'Enter') {
      e.stopPropagation();
      if (mode === 'load' && selected) onLoadPick(selected);
      else if (mode === 'save' && (selected || '').trim()) onSavePick(selected.trim());
    }
  };
  const title         = mode === 'load' ? 'Load Configuration' : 'Save Configuration';
  const actionLabel   = mode === 'load' ? 'Load' : 'Save';
  const canAct        = mode === 'load' ? !!selected : !!(selected || '').trim();
  const doAct         = () => {
    if (!canAct) return;
    if (mode === 'load') onLoadPick(selected);
    else onSavePick(selected.trim());
  };

  return (
    <DialogShell title={title} onCancel={onCancel} onKeyDown={onKey} width={500}>
      <div style={{padding:'14px 26px 4px', color:COL.muted, fontSize:13,
                   fontFamily:'inherit', textTransform:'uppercase',
                   letterSpacing:'0.04em'}}>
        Saved configurations
      </div>

      {/* File list */}
      <div style={{margin:'8px 22px 0', maxHeight:300, overflowY:'auto',
                   border:`1px solid ${COL.border}`, borderRadius:6,
                   background:COL.bg}}>
        {loading && (
          <div style={{padding:'18px 18px', color:COL.faint, fontStyle:'italic',
                       fontSize:13}}>
            Loading list…
          </div>
        )}
        {!loading && files.length === 0 && (
          <div style={{padding:'18px 18px', color:COL.faint, fontStyle:'italic',
                       fontSize:13}}>
            No saved configurations on the host yet.
          </div>
        )}
        {!loading && files.map((name, i) => {
          const active = name === selected;
          return (
            <div key={name + i}
                 onClick={() => setSelected(name)}
                 onDoubleClick={() => { setSelected(name); setTimeout(doAct, 0); }}
                 style={{padding:'10px 16px', cursor:'pointer',
                         color: active ? COL.wheat : COL.text,
                         background: active ? '#374151' : 'transparent',
                         borderBottom: i < files.length - 1
                                          ? `1px solid #2D3138` : 'none',
                         fontFamily:'inherit', fontSize:14}}>
              {name}
            </div>
          );
        })}
      </div>

      {/* Save-mode: a free-text name input under the list.  Clicking a
          row in the list still works — it just fills the input so the
          user can edit/confirm before committing. */}
      {mode === 'save' && (
        <div style={{display:'flex', alignItems:'center', gap:12,
                     padding:'14px 22px 0'}}>
          <span style={{color:COL.text, fontFamily:'inherit', fontSize:13}}>
            Name:
          </span>
          <input type="text"
                 autoFocus
                 value={selected}
                 onChange={(e) => setSelected(e.target.value)}
                 placeholder="Ura4Default"
                 style={{flex:1, background:COL.bg, color:COL.wheat,
                         border:`1px solid ${COL.border}`, borderRadius:4,
                         padding:'6px 10px', fontSize:14,
                         fontFamily:'var(--mono)'}}/>
        </div>
      )}

      <DialogFooter>
        <DialogButton onClick={onCancel}>Cancel</DialogButton>
        <DialogButton onClick={doAct}
                      kind={canAct ? 'primary' : 'neutral'}
                      disabled={!canAct}>
          {actionLabel}
        </DialogButton>
      </DialogFooter>
    </DialogShell>
  );
}

// Shared visual chassis for both dialogs above.  Backdrop catches outside
// clicks; the panel inside stops propagation so clicks within it don't
// dismiss the dialog.  Esc handled via the wrapping div.
function DialogShell({ title, onCancel, onKeyDown, width = 420, children }) {
  // Focus the panel so onKeyDown actually fires.  Has to be a ref so we
  // can call .focus() once on mount; afterwards keyboard works naturally.
  const panelRef = useRefR(null);
  useEffectR(() => { panelRef.current && panelRef.current.focus(); }, []);
  return (
    <div onClick={onCancel}
         style={{position:'fixed', inset:0, zIndex:2000,
                 background:'rgba(0,0,0,0.55)',
                 display:'flex', alignItems:'center', justifyContent:'center',
                 fontFamily:"'Rubik', 'Inter', system-ui, sans-serif"}}>
      <div ref={panelRef}
           tabIndex={-1}
           onClick={(e) => e.stopPropagation()}
           onKeyDown={onKeyDown}
           style={{minWidth:width, maxWidth:'min(720px, 90vw)',
                   maxHeight:'85vh', overflow:'hidden',
                   background:COL.panel, border:`1px solid ${COL.border}`,
                   borderRadius:10, boxShadow:'0 14px 50px rgba(0,0,0,0.55)',
                   outline:'none', color:COL.text,
                   display:'flex', flexDirection:'column'}}>
        <div style={{padding:'14px 22px', fontSize:17, fontWeight:600,
                     color:COL.wheat, borderBottom:`1px solid ${COL.border}`}}>
          {title}
        </div>
        <div style={{flex:1, overflowY:'auto'}}>
          {children}
        </div>
      </div>
    </div>
  );
}

function DialogFooter({ children }) {
  return (
    <div style={{padding:'16px 22px 18px', display:'flex',
                 justifyContent:'flex-end', gap:10,
                 borderTop:`1px solid ${COL.border}`, marginTop:14}}>
      {children}
    </div>
  );
}

function DialogButton({ children, onClick, kind = 'neutral', disabled = false }) {
  const c = kind === 'primary' ? COL.teal
          : kind === 'danger'  ? '#B91C1C'
          : '#4B5563';
  return (
    <button onClick={onClick}
            disabled={disabled}
            style={{minWidth:110, padding:'8px 22px', borderRadius:6,
                    border:`1px solid ${disabled ? '#4B5563' : c}`,
                    background: disabled ? '#1F2937' : c,
                    color: disabled ? COL.muted : '#fff',
                    cursor: disabled ? 'not-allowed' : 'pointer',
                    fontFamily:'inherit', fontSize:14,
                    opacity: disabled ? 0.75 : 1}}>
      {children}
    </button>
  );
}

// ════════════════════════════════════════════════════════════════════
// GatewayPanel — mirrors OmniGate WPF FloriTrack/PositoGatewayView.xaml.
//
// Two-column layout:
//   Left  : Auto-connect | Auto-start | Connection (state + button) |
//           Sending toggle | live stats (queue depth / tags sent /
//           last sent).
//   Right : "Receive tags from" — checkbox per device, with Select-All.
//
// The host already supports everything we need: connect / disconnect
// cmds, setprop target=gateway (runtime: IsSending) and target=gwconfig
// (persisted: IsAutoConnect, IsAutoStart, SelectedDeviceNames, …).
// Each control here just sends one of those frames; the host then fans
// the change back out via prop frames so every viewer (including this
// browser) re-renders with the canonical value.
// ════════════════════════════════════════════════════════════════════
function GatewayPanel({ gateway, devices, isController,
                        onConnect, onDisconnect,
                        onSetRuntimeProp, onSetConfigProp,
                        // showAutoToggles=true renders Auto-connect /
                        // Auto-start-sending / Sending rows above the
                        // Connection card. Settings page passes true;
                        // the sidebar gateway page leaves it false to
                        // keep the layout focused on connect+devices.
                        showAutoToggles = false }) {
  if (!gateway) {
    return <div style={{color:COL.muted}}>Gateway not in snapshot yet — waiting…</div>;
  }

  const cfg          = gateway.config || {};
  const state        = gateway.state || 'Idle';
  const isConnected  = state === 'Connected';
  const isConnecting = state === 'Connecting';
  const editable     = isController;

  // Selected device names — comparison is case-insensitive to mirror the
  // host (StringComparer.OrdinalIgnoreCase in PersistConfig).
  const selectedNames = Array.isArray(cfg.SelectedDeviceNames)
    ? cfg.SelectedDeviceNames : [];
  const isSelected = (name) =>
    selectedNames.some(s => String(s).toLowerCase() === String(name).toLowerCase());

  // Device list to choose from, in the same order MainViewModel sidebar
  // uses (numeric InstanceId sort) so it lines up with what the operator
  // sees on the WPF host.
  const allDevices = Object.values(devices || {}).sort((a, b) =>
    String(a.instanceId).localeCompare(String(b.instanceId), undefined, { numeric:true })
  );

  const toggleDevice = (name, want) => {
    if (!editable) return;
    let next;
    if (want) {
      next = isSelected(name) ? selectedNames : [...selectedNames, name];
    } else {
      next = selectedNames.filter(s =>
        String(s).toLowerCase() !== String(name).toLowerCase());
    }
    onSetConfigProp('SelectedDeviceNames', next);
  };
  const selectAll = () => {
    if (!editable) return;
    onSetConfigProp('SelectedDeviceNames', allDevices.map(d => d.displayName));
  };

  // ── Style helpers (matched to the rest of the remote view) ───────
  const cardStyle = {
    background:COL.panel, border:`1px solid ${COL.border}`, borderRadius:6,
    padding:'12px 16px',
  };
  const rowStyle = {
    display:'flex', alignItems:'center', justifyContent:'space-between',
    gap:14, minHeight:36,
  };
  const labelStyle = {
    color:COL.text, fontSize:14, fontFamily:'inherit', whiteSpace:'nowrap',
  };
  const onOffSelect = (value, onChange, disabled) => (
    <select value={value ? '1' : '0'}
            disabled={!editable || disabled}
            onChange={(e) => onChange(e.target.value === '1')}
            style={{background:COL.bg, border:`1px solid ${COL.border}`,
                    borderRadius:4, color:COL.wheat, padding:'6px 10px',
                    fontFamily:'inherit', fontSize:14, minWidth:120,
                    cursor: editable && !disabled ? 'pointer' : 'not-allowed',
                    opacity: editable && !disabled ? 1 : 0.6}}>
      <option value="0">Off</option>
      <option value="1">On</option>
    </select>
  );

  // Connect / Disconnect button text + handler flip with state (same
  // logic as GatewayBaseViewModel.ConnectionButtonCommand).
  const btnLabel    = isConnected ? 'Disconnect' : isConnecting ? 'Connecting…' : 'Connect';
  const btnHandler  = isConnected ? onDisconnect : onConnect;
  const btnEnabled  = editable && (isConnected || (!isConnecting && state !== 'Connecting'));
  const btnPrimary  = isConnected ? '#B91C1C' : COL.teal;
  const btnStyle = {
    padding:'8px 22px', borderRadius:6,
    border:`1px solid ${btnEnabled ? btnPrimary : '#4B5563'}`,
    background: btnEnabled ? btnPrimary : '#1F2937',
    color:      btnEnabled ? '#fff'     : COL.muted,
    cursor:     btnEnabled ? 'pointer'  : 'not-allowed',
    fontFamily:'inherit', fontSize:14, minWidth:120,
    opacity:    btnEnabled ? 1 : 0.75,
  };

  return (
    <div style={{maxWidth:1100}}>
      {/* Title — matches Configuration / Inventory caption layout */}
      <div style={{display:'flex', alignItems:'center', justifyContent:'center',
                   gap:14, margin:'4px 0 18px'}}>
        <div style={{fontSize:28, fontWeight:600, color:COL.wheat,
                     fontFamily:"'Rubik', system-ui, sans-serif"}}>
          {gateway.displayName}
        </div>
        <div style={{fontSize:24, fontWeight:300, color:COL.muted}}>·</div>
        <div style={{fontSize:28, fontWeight:600, color:COL.wheat,
                     fontFamily:"'Rubik', system-ui, sans-serif"}}>
          Configuration
        </div>
        <StatePill state={state}/>
      </div>

      <div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:14,
                   alignItems:'start'}}>

        {/* ── Left column ───────────────────────────────────────────── */}
        {/* Auto-connect / Auto-start-sending / Sending only render when
            showAutoToggles is true (Settings page). The sidebar gateway
            page passes the default (false) and shows only Connection,
            keeping that page focused on connect + device selection. */}
        <div style={{display:'flex', flexDirection:'column', gap:10}}>
          {showAutoToggles && (
            /* Auto connect */
            <div style={cardStyle}>
              <div style={rowStyle}>
                <span style={labelStyle}>Auto connect:</span>
                {onOffSelect(!!cfg.IsAutoConnect,
                             (v) => {
                               onSetConfigProp('IsAutoConnect', v);
                               // Mirror the WPF dependency rule:
                               // turning Auto-connect OFF also turns
                               // Auto-start OFF.  See GatewayBaseViewModel
                               // OnIsAutoConnectChanged.
                               if (!v && cfg.IsAutoStart)
                                 onSetConfigProp('IsAutoStart', false);
                             },
                             false)}
              </div>
            </div>
          )}

          {showAutoToggles && (
            /* Auto start sending */
            <div style={cardStyle}>
              <div style={rowStyle}>
                <span style={labelStyle}>Auto start sending:</span>
                {onOffSelect(!!cfg.IsAutoStart,
                             (v) => {
                               onSetConfigProp('IsAutoStart', v);
                               // Mirror: turning Auto-start ON forces
                               // Auto-connect ON.
                               if (v && !cfg.IsAutoConnect)
                                 onSetConfigProp('IsAutoConnect', true);
                             },
                             false)}
              </div>
            </div>
          )}

          {/* Connection — state text + Connect/Disconnect button */}
          <div style={cardStyle}>
            <div style={rowStyle}>
              <span style={labelStyle}>Connection:</span>
              <div style={{display:'flex', alignItems:'center', gap:14}}>
                <span style={{color:COL.muted, fontSize:14, fontFamily:'var(--mono)'}}>
                  {state}
                </span>
                <button onClick={btnHandler} disabled={!btnEnabled} style={btnStyle}>
                  {btnLabel}
                </button>
              </div>
            </div>
          </div>

          {showAutoToggles && (
            /* Sending toggle (runtime — target=gateway, NOT gwconfig).
               Only meaningful while connected, mirroring CanToggleSending
               from the WPF VM. */
            <div style={cardStyle}>
              <div style={rowStyle}>
                <span style={labelStyle}>Sending:</span>
                {onOffSelect(!!gateway.isSending,
                             (v) => onSetRuntimeProp('IsSending', v),
                             !isConnected)}
              </div>
            </div>
          )}

        </div>

        {/* ── Right column ──────────────────────────────────────────── */}
        <div style={{display:'flex', flexDirection:'column', gap:10}}>
          {/* Receive tags from — list of devices with Select All */}
          <div style={{...cardStyle, display:'flex', flexDirection:'column',
                       gap:10, minHeight:200}}>
            <div style={{display:'flex', alignItems:'center',
                         justifyContent:'space-between', gap:14}}>
              <span style={{...labelStyle, fontSize:15}}>Receive tags from:</span>
              <button onClick={selectAll} disabled={!editable}
                      style={{padding:'6px 14px', borderRadius:4,
                              border:`1px solid ${editable ? COL.border : '#4B5563'}`,
                              background:'#1F2937',
                              color: editable ? COL.text : COL.muted,
                              cursor: editable ? 'pointer' : 'not-allowed',
                              fontFamily:'inherit', fontSize:13}}>
                Select All
              </button>
            </div>

            {allDevices.length === 0 && (
              <div style={{padding:'12px 4px', color:COL.faint, fontStyle:'italic',
                           fontSize:13}}>
                No devices in the host&apos;s registry.
              </div>
            )}

            <div style={{display:'flex', flexDirection:'column', gap:6,
                         maxHeight:340, overflowY:'auto', paddingRight:4}}>
              {allDevices.map(d => {
                const checked = isSelected(d.displayName);
                return (
                  <label key={d.instanceId}
                         style={{display:'flex', alignItems:'center', gap:8,
                                 padding:'6px 4px',
                                 color:COL.wheat, fontSize:14, fontFamily:'inherit',
                                 cursor: editable ? 'pointer' : 'not-allowed',
                                 opacity: editable ? 1 : 0.7}}>
                    <input type="checkbox"
                           checked={checked}
                           disabled={!editable}
                           onChange={(e) => toggleDevice(d.displayName, e.target.checked)}
                           style={{accentColor: COL.teal}}/>
                    {d.displayName}
                  </label>
                );
              })}
            </div>
          </div>

          {/* Live stats — moved here, directly under the gates list.
              Matches the WPF layout where the Live block sits under
              the "Receive Tags From" panel in the right column. */}
          <div style={cardStyle}>
            <div style={{fontSize:11, color:COL.muted, textTransform:'uppercase',
                         letterSpacing:'0.06em', marginBottom:8,
                         textAlign:'left'}}>
              Live
            </div>
            <div style={{display:'grid', gridTemplateColumns:'auto 1fr',
                         gap:'6px 14px', alignItems:'center'}}>
              <span style={labelStyle}>Queue depth:</span>
              <span style={{color:COL.wheat, fontFamily:'var(--mono)',
                            fontVariantNumeric:'tabular-nums', fontWeight:600,
                            textAlign:'right'}}>
                {Number(gateway.queueDepth ?? 0).toLocaleString()}
              </span>
              <span style={labelStyle}>Tags sent:</span>
              <span style={{color:COL.wheat, fontFamily:'var(--mono)',
                            fontVariantNumeric:'tabular-nums', fontWeight:600,
                            textAlign:'right'}}>
                {Number(gateway.tagsSent ?? 0).toLocaleString()}
              </span>
              <span style={labelStyle}>Last sent:</span>
              <span style={{color:COL.wheat, fontFamily:'var(--mono)',
                            textAlign:'right'}}>
                {gateway.lastSent
                  ? (() => { try { return new Date(gateway.lastSent).toLocaleString(); }
                             catch { return gateway.lastSent; } })()
                  : '—'}
              </span>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

// ════════════════════════════════════════════════════════════════════
// SettingsPanel — mirrors OmniGate WPF GlobalSettingsView.xaml.
//
// Tab strip across the top:
//   • Global (host-side admin only — currently a placeholder)
//   • One tab per device (DeviceSettingsTabViewModel)
//   • One tab per gateway (re-uses GatewayPanel)
//
// Wire format: bi-directional via settingstab / settingsprop /
// settingssave frames (the same channel WPF↔WPF uses).  Values cross
// the wire as strings — bool → "True"/"False", int → "120", etc.
// ════════════════════════════════════════════════════════════════════
function SettingsPanel({ devices, gateways, isController, selectedTab,
                         hostFiles, onTriggerUpload, onRefreshFiles,
                         onTriggerSettingsUpdate, settingsStatus,
                         onSelectTab, onSetSettingsProp, onSettingsSave,
                         onGatewayConnect, onGatewayDisconnect,
                         onGatewaySetRuntime, onGatewaySetConfig }) {

  // ── Tab list (Global → devices → gateways) ───────────────────────
  const deviceList = Object.values(devices).sort((a, b) =>
    String(a.instanceId).localeCompare(String(b.instanceId), undefined, { numeric:true }));
  const gatewayList = Object.values(gateways);

  const tabs = [
    { id:'global', title:'Global', kind:'global' },
    ...deviceList.map(d  => ({ id:d.instanceId,  title:d.displayName, kind:'device',  device:d  })),
    ...gatewayList.map(g => ({ id:g.instanceId,  title:g.displayName, kind:'gateway', gateway:g })),
  ];

  // Fallback if the inbound tabId points at something we don't know.
  const activeTab = tabs.find(t => t.id === selectedTab) || tabs[0];

  // ── Style helpers ────────────────────────────────────────────────
  const tabStripStyle = {
    display:'flex', gap:2, padding:'4px 4px 0',
    borderBottom:`1px solid ${COL.border}`, overflowX:'auto', flexShrink:0,
  };
  const tabBtn = (tab) => {
    const active = tab.id === activeTab.id;
    return (
      <button key={tab.id}
              onClick={() => onSelectTab(tab.id)}
              style={{padding:'10px 20px',
                      border:'none', borderRadius:'8px 8px 0 0',
                      background: active ? '#4B5563' : COL.panel,
                      color: COL.text, cursor:'pointer',
                      fontFamily:'inherit', fontSize:14,
                      whiteSpace:'nowrap'}}>
        {tab.title}
      </button>
    );
  };

  return (
    <div style={{maxWidth:1200}}>
      {/* Title — matches WPF "Settings" header */}
      <div style={{padding:'14px 0 18px', fontSize:24, fontWeight:600,
                   color:COL.wheat, fontFamily:"'Rubik', system-ui, sans-serif"}}>
        Settings
      </div>

      {/* Tab strip */}
      <div style={tabStripStyle}>
        {tabs.map(tabBtn)}
      </div>

      {/* Tab content — background matches the WPF dark canvas */}
      <div style={{background:'#212529', padding:24, minHeight:400,
                   border:`1px solid ${COL.border}`, borderTop:'none',
                   borderRadius:'0 0 8px 8px'}}>
        {activeTab.kind === 'global' && (
          <GlobalSettingsTab files={hostFiles} isController={isController}
                             onTriggerUpload={onTriggerUpload} onRefreshFiles={onRefreshFiles}
                             onTriggerSettingsUpdate={onTriggerSettingsUpdate}
                             settingsStatus={settingsStatus}/>
        )}
        {activeTab.kind === 'device' && (
          <DeviceSettingsTab
            device={activeTab.device}
            isController={isController}
            onSetProp={(prop, value) => onSetSettingsProp(activeTab.id, prop, value)}
            onSave   ={() => onSettingsSave(activeTab.id)}/>
        )}
        {activeTab.kind === 'gateway' && (
          <GatewayPanel
            gateway={activeTab.gateway}
            devices={devices}
            isController={isController}
            onConnect       ={() => onGatewayConnect(activeTab.id)}
            onDisconnect    ={() => onGatewayDisconnect(activeTab.id)}
            onSetRuntimeProp={(prop, value) => onGatewaySetRuntime(activeTab.id, prop, value)}
            onSetConfigProp ={(prop, value) => onGatewaySetConfig(activeTab.id, prop, value)}
            showAutoToggles/>
        )}
      </div>
    </div>
  );
}

function fmtFileSize(bytes) {
  const n = Number(bytes) || 0;
  if (n < 1024)            return `${n} B`;
  if (n < 1024 * 1024)     return `${(n / 1024).toFixed(1)} KB`;
  return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function fmtFileTime(ms) {
  const t = Number(ms) || 0;
  if (!t) return '';
  try { return new Date(t).toLocaleString(); } catch { return ''; }
}

function GlobalSettingsTab({ files, isController, onTriggerUpload, onRefreshFiles,
                            onTriggerSettingsUpdate, settingsStatus }) {
  const list = Array.isArray(files) ? files : [];

  const cell = { padding:'7px 12px', fontSize:13, borderBottom:`1px solid ${COL.border}` };
  const head = { ...cell, color:COL.muted, fontWeight:600, borderBottom:`1px solid ${COL.border}` };

  return (
    <div style={{maxWidth:680}}>
      <div style={{fontSize:16, fontWeight:600, color:COL.wheat, marginBottom:12}}>
        Global Settings
      </div>

      {/* Downloads folder — the files the FloriTrack gateway loads. */}
      <div style={{fontSize:14, fontWeight:600, color:COL.wheat, margin:'8px 0'}}>
        Files in the device&apos;s Downloads folder
      </div>

      <div style={{display:'flex', gap:8, marginBottom:10, alignItems:'center'}}>
        <button type="button" onClick={onRefreshFiles} disabled={!isController}
                style={{padding:'8px 16px', borderRadius:6, border:`1px solid ${COL.border}`,
                        background: COL.panel, color: COL.text,
                        cursor: isController ? 'pointer' : 'not-allowed',
                        opacity: isController ? 1 : 0.5, fontFamily:'inherit', fontSize:13}}>
          Refresh
        </button>
        <button type="button" onClick={onTriggerUpload} disabled={!isController}
                style={{padding:'8px 16px', borderRadius:6, border:'none',
                        background:'#4B5563', color: COL.text,
                        cursor: isController ? 'pointer' : 'not-allowed',
                        opacity: isController ? 1 : 0.5, fontFamily:'inherit', fontSize:13}}>
          Upload file to device…
        </button>
        {!isController && (
          <span style={{color:COL.muted, fontSize:12}}>
            Take control to upload or refresh.
          </span>
        )}
      </div>

      <div style={{border:`1px solid ${COL.border}`, borderRadius:6,
                   background:'#1A1D21', overflow:'hidden'}}>
        <div style={{display:'grid', gridTemplateColumns:'1fr 120px 200px'}}>
          <div style={head}>Name</div>
          <div style={head}>Size</div>
          <div style={head}>Modified</div>
          {list.length === 0 && (
            <div style={{...cell, gridColumn:'1 / 4', color:COL.muted, fontStyle:'italic',
                         borderBottom:'none'}}>
              No files in the Downloads folder.
            </div>
          )}
          {list.map((f, i) => (
            <React.Fragment key={f.name + i}>
              <div style={{...cell, color:COL.text, fontFamily:'var(--mono)'}}>{f.name}</div>
              <div style={{...cell, color:COL.muted}}>{fmtFileSize(f.size)}</div>
              <div style={{...cell, color:COL.muted}}>{fmtFileTime(f.modified)}</div>
            </React.Fragment>
          ))}
        </div>
      </div>

      {/* Replace the host's settings.json and restart it. */}
      <div style={{fontSize:14, fontWeight:600, color:COL.wheat, margin:'24px 0 8px'}}>
        Configuration
      </div>
      <div style={{display:'flex', gap:10, alignItems:'center'}}>
        <button type="button" onClick={onTriggerSettingsUpdate} disabled={!isController}
                style={{padding:'8px 16px', borderRadius:6, border:'none',
                        background:'#4B5563', color: COL.text,
                        cursor: isController ? 'pointer' : 'not-allowed',
                        opacity: isController ? 1 : 0.5, fontFamily:'inherit', fontSize:13}}>
          Update settings.json…
        </button>
        {settingsStatus
          ? <span style={{color:COL.muted, fontSize:12}}>{settingsStatus}</span>
          : <span style={{color:COL.muted, fontSize:12}}>
              Replaces settings.json on the device and restarts it (a backup is kept). Does not upload to CloudGate.
            </span>}
      </div>
    </div>
  );
}

// Known device types — mirrors DeviceRegistry.KnownDeviceTypes on the
// host.  Kept as a module-level constant so the dropdown options don't
// re-instantiate on every render (avoids the React focus-jump pattern).
const KNOWN_DEVICE_TYPES = ['URA4', 'Device2'];

// Settings styles — also at module scope so the inputs aren't a new
// object reference per render.
const SETTINGS_LABEL = {
  color: '#94A3B8', fontFamily: "'Rubik', system-ui, sans-serif",
  fontSize: 16, marginBottom: 4, display: 'block',
};
const SETTINGS_INPUT = {
  width: '100%', boxSizing: 'border-box',
  background: '#272B2F', border: '1px solid #3E434A', borderRadius: 5,
  color: '#FFE7B5', padding: '10px 14px',
  fontFamily: "'Rubik', system-ui, sans-serif", fontSize: 16,
  outline: 'none',
};
const SETTINGS_PANEL = {
  background: '#1A1D21', border: '1px solid #3E434A',
  padding: 18, borderRadius: 4,
};

// ── DeviceSettingsTab ──────────────────────────────────────────────
// Mirrors OmniGate WPF GlobalSettingsUra4View.xaml: two bordered
// panels side-by-side.  Left panel = identity / network fields;
// right panel = behaviour (Auto connect / Auto start / per-antenna
// function).  Save button at the bottom.
//
// All edits round-trip via settingsprop frames so the WPF VM picks
// them up via its existing RemoteSettingsPropMessage listener (no
// extra host code).  Inputs use the JSX directly (no nested component
// wrappers) so React keeps the same DOM node across re-renders ⇒ the
// caret stays in place while you type.
function DeviceSettingsTab({ device, isController, onSetProp, onSave }) {
  if (!device) return <div style={{color:COL.muted}}>Device not in snapshot.</div>;

  const editable = isController;
  const inputStyle = {
    ...SETTINGS_INPUT,
    opacity: editable ? 1 : 0.7,
    cursor: editable ? 'text' : 'not-allowed',
  };

  // Antenna functions ship as a single comma-joined string per the
  // WPF wire protocol ("antennaFunctions" / "Inventory,In,Out,Inventory").
  const antennaFns = Array.isArray(device.antennaFunctions)
                       ? device.antennaFunctions
                       : ['Inventory','Inventory','Inventory','Inventory'];
  const setAntenna = (idx, fn) => {
    const next = antennaFns.slice();
    next[idx] = fn;
    onSetProp('antennaFunctions', next.join(','));
  };
  const antBtnStyle = (active) => ({
    flex: 1, margin: '0 2px', padding: '10px 0',
    background: '#1F2937', border: '1px solid #3E434A', borderRadius: 5,
    color: COL.wheat, fontFamily: "'Rubik', system-ui, sans-serif",
    fontSize: 14,
    cursor: editable ? 'pointer' : 'not-allowed',
    opacity: editable ? (active ? 1 : 0.45) : 0.5,
  });
  const idleActiveCombo = (prop, value) => (
    <select value={value ? 'True' : 'False'}
            disabled={!editable}
            onChange={(e) => onSetProp(prop, e.target.value)}
            style={{...inputStyle, fontFamily: 'inherit',
                    cursor: editable ? 'pointer' : 'not-allowed',
                    textAlign:'center', textAlignLast:'center'}}>
      <option value="False">Idle</option>
      <option value="True">Active</option>
    </select>
  );

  return (
    <div>
      <div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:10}}>

        {/* ── Left panel: identity + network fields ─────────────── */}
        <div style={{...SETTINGS_PANEL,
                     maxHeight:'calc(100vh - 320px)', overflowY:'auto'}}>
          <label style={SETTINGS_LABEL}>Device Type</label>
          <select value={device.deviceType || ''}
                  disabled={!editable}
                  onChange={(e) => onSetProp('DeviceType', e.target.value)}
                  style={{...inputStyle, marginBottom:20,
                          fontFamily:'inherit',
                          cursor: editable ? 'pointer' : 'not-allowed'}}>
            {KNOWN_DEVICE_TYPES.map(t => (
              <option key={t} value={t}>{t}</option>
            ))}
            {/* Show the actual value if it's outside the known list so
                we don't silently change it to "URA4". */}
            {device.deviceType && !KNOWN_DEVICE_TYPES.includes(device.deviceType) && (
              <option value={device.deviceType}>{device.deviceType}</option>
            )}
          </select>

          <label style={SETTINGS_LABEL}>Display Name</label>
          <input type="text" value={device.displayName ?? ''}
                 disabled={!editable}
                 onChange={(e) => onSetProp('DisplayName', e.target.value)}
                 style={{...inputStyle, marginBottom:20}}/>

          <label style={SETTINGS_LABEL}>IP Address</label>
          <input type="text" value={device.ipAddress ?? ''}
                 placeholder="192.168.1.100"
                 disabled={!editable}
                 onChange={(e) => onSetProp('IpAddress', e.target.value)}
                 style={{...inputStyle, marginBottom:20,
                         fontFamily:'var(--mono)'}}/>

          <label style={SETTINGS_LABEL}>Port</label>
          <input type="number" min={1} max={65535} step={1}
                 value={device.port ?? ''}
                 disabled={!editable}
                 onChange={(e) => onSetProp('Port', e.target.value)}
                 style={{...inputStyle, marginBottom:20,
                         fontFamily:'var(--mono)'}}/>

          <label style={SETTINGS_LABEL}>Location Id</label>
          <input type="text" value={device.locationId ?? ''}
                 disabled={!editable}
                 onChange={(e) => onSetProp('LocationId', e.target.value)}
                 style={{...inputStyle, marginBottom:20,
                         fontFamily:'var(--mono)'}}/>

          <label style={SETTINGS_LABEL}>Configuration Name</label>
          <input type="text" value={device.configurationName ?? ''}
                 disabled={!editable}
                 onChange={(e) => onSetProp('ConfigurationName', e.target.value)}
                 style={{...inputStyle, marginBottom:0}}/>
        </div>

        {/* ── Right panel: behaviour (Auto*, Antennas) ──────────── */}
        <div style={{...SETTINGS_PANEL,
                     maxHeight:'calc(100vh - 320px)', overflowY:'auto'}}>
          <label style={SETTINGS_LABEL}>Auto connect</label>
          <div style={{marginBottom:20}}>
            {idleActiveCombo('IsAutoConnect', device.isAutoConnect)}
          </div>

          <label style={SETTINGS_LABEL}>Auto start inventory</label>
          <div style={{marginBottom:20}}>
            {idleActiveCombo('IsAutoStart', device.isAutoStart)}
          </div>

          <label style={{...SETTINGS_LABEL, marginTop:8, marginBottom:8}}>
            Antenna definitions
          </label>
          {[0,1,2,3].map(i => {
            const fn = antennaFns[i] || 'Inventory';
            return (
              <div key={i} style={{display:'flex', alignItems:'center',
                                   gap:4, margin:'4px 0'}}>
                <span style={{width:60, color:COL.wheat,
                              fontFamily:"'Rubik', system-ui, sans-serif",
                              fontSize:15}}>
                  Ant {i + 1}
                </span>
                <button disabled={!editable}
                        onClick={() => setAntenna(i, 'In')}
                        style={antBtnStyle(fn === 'In')}>
                  In
                </button>
                <button disabled={!editable}
                        onClick={() => setAntenna(i, 'Out')}
                        style={antBtnStyle(fn === 'Out')}>
                  Out
                </button>
                <button disabled={!editable}
                        onClick={() => setAntenna(i, 'Inventory')}
                        style={antBtnStyle(fn === 'Inventory')}>
                  Inventory
                </button>
              </div>
            );
          })}
        </div>
      </div>

      {/* Save bar — bordered footer with centered Save button.
          Matches the WPF "Save bar" Border at the bottom of the page. */}
      <div style={{marginTop:14, border:'1px solid #3E434A',
                   padding:'14px 0', borderRadius:4,
                   display:'flex', justifyContent:'center'}}>
        <button onClick={onSave}
                disabled={!editable}
                style={{padding:'10px 36px', borderRadius:6,
                        border:`1px solid ${editable ? COL.teal : '#4B5563'}`,
                        background: editable ? COL.teal : '#1F2937',
                        color: editable ? '#fff' : COL.muted,
                        cursor: editable ? 'pointer' : 'not-allowed',
                        fontFamily:'inherit', fontSize:14, minWidth:120,
                        opacity: editable ? 1 : 0.75}}>
          Save
        </button>
      </div>
    </div>
  );
}

// ── Generic helpers ─────────────────────────────────────────────────────

function CapsLabel({ children }) {
  return (
    <div style={{fontSize:11, color:COL.faint, textTransform:'uppercase',
                 letterSpacing:'0.06em', fontWeight:700, margin:'8px 0 10px'}}>
      {children}
    </div>
  );
}

// OptimisticCombo — labelled <select> with local "in-flight" state.
//
// Controlled <select> bound directly to model[prop] has two problems:
//   1. While the dropdown is open, any parent re-render (tag-batch at 10 Hz
//      during inventory) re-sets <select>.value, which closes the dropdown.
//   2. After the user picks a value the host has to echo it back before the
//      display catches up; until then the combo briefly shows the OLD model
//      value, looking like it "snapped back".
//
// This component keeps a local pending state. When the user picks, we
// optimistically store the pick and show it immediately. We also fire
// `commit(prop, v)` so the host applies the change and broadcasts a `prop`
// frame back. Once the model echo reaches us (or a 5 s timeout), we clear
// the pending state and resume tracking the model. If the host rejects the
// change, the timeout drops the pending value and we revert to the model.
function OptimisticCombo({ label, prop, options, valueIsIndex,
                           disabled, inline, modelValue, commit, styles }) {
  const [pending, setPending] = React.useState(null);

  // Clear the pending optimistic value when the model catches up to it,
  // OR after 5 s if the host never echoes (rejected / dropped).
  React.useEffect(() => {
    if (pending == null) return;
    if (modelValue === pending) { setPending(null); return; }
    const t = setTimeout(() => setPending(null), 5000);
    return () => clearTimeout(t);
  }, [pending, modelValue]);

  const raw = pending != null ? pending : modelValue;
  const val = valueIsIndex
    ? (Number.isInteger(raw) && raw >= 0 && raw < options.length ? raw : -1)
    : (raw ?? '');

  const onChange = (e) => {
    const v = valueIsIndex ? parseInt(e.target.value, 10) : e.target.value;
    setPending(v);
    commit(prop, v);
  };

  return (
    <div style={inline ? styles.inlineRowStyle : styles.cardStyle}>
      <span style={styles.labelStyle}>{label}</span>
      <select value={val} disabled={disabled} onChange={onChange}
              style={styles.selectStyle}>
        {valueIsIndex && val === -1 && <option value={-1}>—</option>}
        {options.map((opt, i) => (
          <option key={i} value={valueIsIndex ? i : opt}>
            {valueIsIndex ? opt : String(opt)}
          </option>
        ))}
      </select>
    </div>
  );
}

function EmptyRow({ text }) {
  return (
    <div style={{padding:'14px 18px', background:'#272B2F',
                 border:`1px solid ${COL.border}`, borderRadius:6,
                 color:COL.faint, fontStyle:'italic', fontSize:13}}>
      {text}
    </div>
  );
}

// WpfTable mirrors the look of DashboardView.xaml: dark header strip with
// muted column labels, alternating dark rows with a thin bottom border, and
// configurable column widths/alignment per header.
function WpfTable({ headers, rows }) {
  return (
    <div style={{border:`1px solid ${COL.border}`, borderRadius:6, overflow:'hidden'}}>
      <table style={{width:'100%', borderCollapse:'collapse', fontSize:14}}>
        <thead>
          <tr style={{background:COL.bg}}>
            {headers.map((h, i) => (
              <th key={i}
                  style={{textAlign:h.align || 'left',
                          width: h.w ? h.w : undefined,
                          padding:'10px 14px',
                          borderBottom:`1px solid ${COL.border}`,
                          color:COL.faint, fontWeight:600,
                          fontSize:13, fontFamily:'inherit'}}>
                {h.label}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {rows.map((row, i) => (
            <tr key={i} style={{background: i % 2 === 0 ? COL.panel : '#2A2E33',
                                borderBottom:`1px solid #2D3138`}}>
              {row.map((cell, j) => (
                <td key={j}
                    style={{textAlign: headers[j]?.align || 'left',
                            padding:'12px 14px', color:COL.text,
                            fontFamily:'inherit'}}>
                  {cell}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function Num({ value }) {
  return (
    <span style={{fontFamily:'var(--mono)', fontVariantNumeric:'tabular-nums'}}>
      {Number(value || 0).toLocaleString()}
    </span>
  );
}

function Stat({ label, value }) {
  return (
    <div style={{background:COL.panel, border:`1px solid ${COL.border}`,
                 borderRadius:6, padding:'14px 18px'}}>
      <div style={{fontSize:11, color:COL.faint, textTransform:'uppercase',
                   letterSpacing:'0.06em', fontWeight:700, marginBottom:6}}>
        {label}
      </div>
      <div style={{fontSize:26, fontWeight:700, color:COL.wheat,
                   fontFamily:"'Rubik', system-ui, sans-serif"}}>
        {Number(value).toLocaleString()}
      </div>
    </div>
  );
}

// StatePill — filled oval with leading dot, matching the WPF Dashboard
// (DashboardView.xaml around the "Status" column).
function StatePill({ state }) {
  const palette = {
    Online:        { bg:COL.teal,    fg:'#fff'    },
    Connected:     { bg:COL.teal,    fg:'#fff'    },
    Inventory:     { bg:COL.green,   fg:'#fff'    },   // matches WPF "Inventory" green pill
    Uploading:     { bg:'#7C3AED',   fg:'#fff'    },
    Downloading:   { bg:'#7C3AED',   fg:'#fff'    },
    Connecting:    { bg:'#92400E',   fg:'#fff'    },
    Disconnected:  { bg:'#374151',   fg:COL.muted },
    Idle:          { bg:'#374151',   fg:COL.muted },
    Sending:       { bg:COL.green,   fg:'#fff'    },
  };
  const c = palette[state] || palette.Disconnected;
  return (
    <span style={{display:'inline-flex', alignItems:'center', gap:6,
                  padding:'4px 12px', borderRadius:99,
                  background:c.bg, color:c.fg, fontSize:12, fontWeight:500,
                  fontFamily:"'Rubik', system-ui, sans-serif",
                  letterSpacing:'0.01em'}}>
      <span style={{width:6, height:6, borderRadius:'50%',
                    background:'currentColor', opacity:0.85}}/>
      {state || '?'}
    </span>
  );
}

function SendingDot({ on }) {
  return (
    <span style={{display:'inline-block', width:12, height:12, borderRadius:'50%',
                  background: on ? COL.green : '#4B5563'}}/>
  );
}

window.RemoteView = RemoteView;

// =================== HUB CLIENTS (admin only) ===================
// Polls /api/remote/connected every 5s.  Lets the SystemAdmin see every peer
// currently attached to the /peer hub — useful for verifying an OmniGate host
// has actually registered, and (later) for spotting stale or rogue sessions.
function HubClientsPage() {
  const [data, setData]   = useStateR({ Count: 0, Clients: [] });
  const [error, setError] = useStateR(null);
  const [stamp, setStamp] = useStateR(new Date());

  useEffectR(() => {
    let cancelled = false;
    const poll = async () => {
      if (cancelled) return;
      try {
        const r = await fetch('/api/remote/connected');
        if (!r.ok) {
          setError('HTTP ' + r.status);
          return;
        }
        const body = await r.json();
        if (cancelled) return;
        setData(body);
        setStamp(new Date());
        setError(null);
      } catch (e) {
        if (!cancelled) setError(String(e.message || e));
      }
    };
    poll();
    const t = setInterval(poll, 5_000);
    return () => { cancelled = true; clearInterval(t); };
  }, []);

  return (
    <div className="page">
      <div className="page-header">
        <div>
          <h1 className="page-title">Hub clients</h1>
          <p className="page-sub">Live view of every peer connected to the SignalR /peer hub. Refreshes every 5 s.</p>
        </div>
        <div className="page-actions">
          <span style={{fontSize:12, color:'var(--text-muted)'}}>
            {data.Count} connected · last poll {stamp.toLocaleTimeString()}
          </span>
        </div>
      </div>
      {error && (
        <div style={{padding:12, marginBottom:14, border:'1px solid var(--err, #dc2626)', borderRadius:6, background:'var(--err-soft, oklch(0.95 0.06 25))', fontSize:13}}>
          {error}
        </div>
      )}
      <div className="card">
        <table className="tbl">
          <thead><tr>
            <th>Peer</th>
            <th>Hardware ID</th>
            <th>Reader</th>
            <th>Level</th>
            <th>Tenant</th>
            <th>Organization</th>
            <th>Site / Zone</th>
            <th>Connected at</th>
            <th>Connection Id</th>
          </tr></thead>
          <tbody>
            {data.Clients.length === 0 && (
              <tr><td colSpan="9" style={{textAlign:'center', padding:24, color:'var(--text-muted)', fontSize:12, fontStyle:'italic'}}>
                No clients connected.
              </td></tr>
            )}
            {data.Clients.map(c => (
              <tr key={c.ConnectionId}>
                <td>
                  <span style={{fontWeight:600, color: c.PeerType === 'Host' ? 'var(--brand, #6366f1)' : 'var(--text)'}}>
                    {c.PeerType || '?'}
                  </span>
                </td>
                <td style={{fontFamily:'var(--mono)', fontSize:12}}>{c.Username || '—'}</td>
                <td style={{fontSize:13}}>{(() => {
                  // Match the host's HardwareId (c.Username = its registration key)
                  // to a configured Reader so the operator sees the friendly name.
                  const hw = (c.Username || '').trim().toLowerCase();
                  const rd = hw ? (CloudGate.DB.Readers || []).find(r => (r.HardwareId || '').trim().toLowerCase() === hw) : null;
                  return rd ? rd.Name : '—';
                })()}</td>
                <td>{c.LevelName || '—'} ({c.Level})</td>
                <td style={{fontSize:12}}>{c.Tenant || '—'}</td>
                <td style={{fontSize:12}}>{c.Organization || '—'}</td>
                <td style={{fontSize:12}}>{[c.Site, c.Zone].filter(Boolean).join(' / ') || '—'}</td>
                <td style={{fontSize:12, color:'var(--text-muted)'}}>{CloudGate.relTime(c.ConnectedAt)}</td>
                <td style={{fontFamily:'var(--mono)', fontSize:11, color:'var(--text-faint)'}}>{c.ConnectionId}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

window.HubClientsPage = HubClientsPage;
