/* Display Screens — port of prototype display-screens.jsx, wired to /api/screens.
   The prototype carries lots of fake fields (uptime, brightness, IP, hardware,
   playing) that the real DB doesn't have. We render '—' for missing values
   and derive what we can (orientation, size, theme from zone). */

/* ─── Table of contents ─────────────────────────────────────────────
   File: public/cms/displays.jsx
   Total top-level defs: 13
   Line numbers are post-TOC (jump directly to them).
   ────────────────────────────────────────────────────────────────

   Helpers + tiles:
     DSP_StatusPill, DSP_ScreenTile, DSP_DetailPanel

   Modals:
     RegisterScreenModal, BulkAssignSpeciesModal,
     BulkMoveZoneModal, SwapScreenModal
     (AttachDeviceModal + PendingDevicesPanel removed Jun 2026 —
      device assignment is kiosk-side self-claim only, no CMS attach.)

   Page root:
     L2919  DisplayScreensScreen

   Other / page-level:
     L34  DSP_THEMES
     L92  DSP_SIZE_PRESETS
     L1431  SCREEN_SIZE_PRESETS
   ──────────────────────────────────────────────────────────────── */

const DSP_THEMES = {
  reef:      { fg: '#5DD3D3', grad: 'linear-gradient(135deg, #1f5961 0%, #0d2a30 100%)' },
  jellyfish: { fg: '#C99BFF', grad: 'linear-gradient(135deg, #3a2454 0%, #15102a 100%)' },
  shark:     { fg: '#9DBCFF', grad: 'linear-gradient(135deg, #1a2748 0%, #0a1024 100%)' },
  pelagic:   { fg: '#7DC0E0', grad: 'linear-gradient(135deg, #14384c 0%, #08182a 100%)' },
  touchpool: { fg: '#F2C879', grad: 'linear-gradient(135deg, #4a3618 0%, #1f1608 100%)' },
  cold:      { fg: '#A6E3FA', grad: 'linear-gradient(135deg, #1a4456 0%, #0a1d28 100%)' },
};

/* "Last seen" was DATETIME in the DB; map age to a status label. */
function statusFromLastSeen(iso) {
  if (!iso) return 'offline';
  const ageMs = Date.now() - new Date(iso).getTime();
  if (ageMs < 5 * 60 * 1000) return 'online';
  if (ageMs < 60 * 60 * 1000) return 'idle';
  return 'offline';
}

/* Per-screen status that knows about the pair-by-code lifecycle.
   Without this, a placeholder row created by the platform admin's
   Assign-screens flow shows as "Offline" — which is wrong because
   there's never been a device. Three distinct states now:
     • 'waiting'  — paired_via_code=true, paired_at=null (slot
                    allocated, hardware not yet attached)
     • 'legacy'   — paired_via_code=false, paired_at=null, no
                    last_seen yet (created via the old typed-code
                    flow, never had a device check in)
     • everything else falls through to last-seen heuristic above.
   The display tile + status pill use this string. */
function screenStatus(screen) {
  if (screen.paired_via_code && !screen.paired_at) return 'waiting';
  if (!screen.paired_at && !screen.last_seen_at && !screen.paired_via_code) return 'legacy';
  /* Liveness audit (2026-06-12): this previously keyed off last_seen_at,
     which is the FEED-FETCH timestamp — bumped by the snapshotter cycle
     and CMS preview iframes, so powered-off panels showed Online/Idle
     forever. is_online (written by real heartbeats, cleared by the
     2-min sweep) is authoritative; last_heartbeat freshness is the
     fallback for rows that pre-date the is_online column. last_seen_at
     no longer drives status — it's metadata ("feed last loaded"). */
  if (screen.is_online === true || screen.is_online === 1) return 'online';
  if (screen.is_online === false || screen.is_online === 0) {
    /* Heartbeat lost within the hour reads as 'idle' so a just-dropped
       panel is distinguishable from one that's been dark for days. */
    if (screen.last_heartbeat) {
      const age = Date.now() - new Date(screen.last_heartbeat).getTime();
      if (age >= 2 * 60 * 1000 && age < 60 * 60 * 1000) return 'idle';
    }
    return 'offline';
  }
  /* is_online never reported (pre-migration rows): heartbeat freshness. */
  return statusFromLastSeen(screen.last_heartbeat);
}

function lastSeenLabel(iso) {
  if (!iso) return 'never';
  const ageMs = Date.now() - new Date(iso).getTime();
  const m = Math.round(ageMs / 60000);
  if (m < 1) return 'just now';
  if (m < 60) return `${m} min ago`;
  const h = Math.round(m / 60);
  if (h < 24) return `${h} h ago`;
  const d = Math.round(h / 24);
  return `${d} d ago`;
}

function inchesFromResolution(r) {
  if (!r) return '—';
  if (r.height >= 2160) return '55"';
  if (r.height >= 1440) return '32"';
  return '24"';
}

/* Screen-size presets — mirrors EDIT_SIZE_PRESETS in single-screen.jsx
   so the inline settings popup on Displays can edit the same field
   the standalone EditScreenModal exposes. Kept in sync manually since
   single-screen.jsx doesn't export it. */
const DSP_SIZE_PRESETS = [
  { id: '24',    label: '24" Full HD',       width: 1920, height: 1080 },
  { id: '32',    label: '32" Full HD',       width: 1920, height: 1080 },
  { id: '43',    label: '43" Full HD',       width: 1920, height: 1080 },
  { id: '55',    label: '55" 4K UHD',        width: 3840, height: 2160 },
  { id: '65',    label: '65" 4K UHD',        width: 3840, height: 2160 },
  { id: '75',    label: '75" 4K UHD',        width: 3840, height: 2160 },
  { id: '85',    label: '85" 4K UHD',        width: 3840, height: 2160 },
  { id: 'custom', label: 'Custom / unknown', width: null, height: null },
];
function dspResolvePresetId(screen) {
  if (!screen) return 'custom';
  const fromInches = screen.display_size_inches ? String(screen.display_size_inches) : null;
  if (fromInches && DSP_SIZE_PRESETS.find((p) => p.id === fromInches)) return fromInches;
  const rw = screen.resolution_width || (screen.resolution && screen.resolution.width);
  const rh = screen.resolution_height || (screen.resolution && screen.resolution.height);
  if (!rw || !rh) return 'custom';
  // Landscape-normalise — portrait flips them.
  const w = Math.max(rw, rh);
  const h = Math.min(rw, rh);
  if (w === 1920 && h === 1080) return '43';   // common-default heuristic
  if (w === 3840 && h === 2160) return '55';   // common-default heuristic
  return 'custom';
}

/* Compact bytes formatter for the offline-cache section.
   Mirrors the kiosk-side helper in display/index.html so the fleet
   view and the on-screen Settings overlay show numbers in the same
   units (no awkward switch from MB to GB between the two surfaces). */
function formatBytesShort(n) {
  if (!n && n !== 0) return '—';
  if (n < 1024) return n + ' B';
  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
  if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
  return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}

function DSP_StatusPill({ status }) {
  const map = {
    /* Semantic tokens (2026-06-12) — the old rgba/hex literals here
       didn't flip with light mode. --aq-*-soft variants are defined
       per-theme in styles.css. */
    online:  { fg: 'var(--aq-success)', bg: 'var(--aq-success-soft)', label: 'Online' },
    offline: { fg: 'var(--aq-danger)',  bg: 'var(--aq-danger-soft)',  label: 'Offline' },
    idle:    { fg: 'var(--aq-warn)',    bg: 'var(--aq-warn-soft)',    label: 'Idle' },
    /* 'waiting' is a placeholder slot allocated by the platform
       admin — paired_via_code=true, no device attached yet. Amber
       so it reads as "needs attention" rather than "broken". */
    waiting: { fg: 'var(--aq-warn)',    bg: 'var(--aq-warn-soft)',    label: 'Waiting for hardware' },
    /* 'legacy' is a pre-pair-by-code screen with no heartbeat ever.
       Distinct from "never" (last_seen=null but device attached). */
    legacy:  { fg: 'var(--aq-text-faint)', bg: 'rgba(255,255,255,0.04)',                                label: 'Unpaired (legacy)' },
    never:   { fg: 'var(--aq-text-faint)', bg: 'rgba(255,255,255,0.04)',                                label: 'Never seen' },
  };
  const t = map[status] || map.online;
  return (
    <span className="ds-statuspill" style={{ background: t.bg, color: t.fg }}>
      <span className="ds-statusdot" style={{ background: t.fg }} />
      {t.label}
    </span>
  );
}

function DSP_ScreenTile({ screen, selected, onClick, onOpen, media, onSetup, dataId }) {
  const t = DSP_THEMES[screen.zone_theme] || DSP_THEMES.reef;
  const portrait = screen.orientation === 'portrait';
  /* "Awaiting device" = a prepared content slot with no hardware bound
     yet — i.e. no device is bound. The reliable signal is paired_at
     (a device self-claims → paired_at stamped) + the allocated/legacy
     statuses; last_seen_at is NOT reliable here (allocated slots can pick
     up a last_seen from snapshot/preview hits without a real device). So
     a 'waiting' slot with a stray last_seen still reads as awaiting.
     These render as a dashed ghost tile (Empty → guided setup, or
     Prepared → waiting for a kiosk). Jun 2026 three-state re-cut. */
  const awaitingDevice = !screen.paired_at
    && (screen.status === 'waiting' || screen.status === 'legacy' || screen.status === 'never');
  /* Tile backdrop priority (Concept A, Jun 2026):
       1. Real screen snapshot (last_snapshot_url) — what is literally
          on the screen, captured server-side by the screen-snapshot
          worker every ~60s. Rendered as a plain static <img>, so a
          100-screen fleet costs the same as any photo grid: no live
          iframes, no video, no per-tile GPU. (The one live iframe lives
          only in the detail panel for the selected screen.)
       2. Featured species photo — short-lived cold-start fallback,
          before the first snapshot lands.
       3. Zone gradient — when there is neither (e.g. 'waiting' slots).
     The old species-photo-as-wallpaper look was decorative, not
     informative, and aspect-cropping an animal into 16:9 read as
     clip-art. The snapshot turns each tile into a true fleet monitor. */
  /* Simplified (2026-06-06): the tile shows the screen's featured species
     photo on the zone theme, not a live screenshot. Live capture (device
     getScreenshot / headless Chromium) was removed — it couldn't reliably
     capture video-background kiosks and didn't scale. The photo is already
     fetched (featured-media) and is a clean, instant representation of
     what's on the screen. */
  const showSpecies = media && media.image_url && screen.status !== 'waiting';
  const hasMedia = showSpecies;
  const bgUrl = showSpecies ? media.image_url : null;
  const bgAlt = (media && media.common_name) || '';
  /* An "empty" slot = allocated, no content yet. Clicking it launches the
     guided Set-up flow (zone, name, size, orientation, then species)
     rather than the near-useless detail panel. */
  const isEmpty = awaitingDevice && !(media && media.common_name);
  /* "prepared" = content sits ready in a zone but no device has claimed
     it. Shown as its own species art, dimmed (standby) — distinct from
     the dashed empty slot and the bright live screen. */
  const isPrepared = awaitingDevice && !!(media && media.common_name);
  const tileClick = (isEmpty && onSetup) ? () => onSetup(screen) : onClick;
  /* "Asleep" = the device is online (heartbeating) but it has reported its
     physical panel OFF — e.g. it's inside a scheduled off-window, or was
     turned off manually. Without this it read as "Live" with a dark panel.
     screen_on is INTEGER 0/1 on SQLite, BOOLEAN on PG; null = not reported
     (older kiosk), which we treat as on. */
  const isAsleep = screen.status === 'online'
    && (screen.screen_on === false || screen.screen_on === 0);
  /* A screen rotates through many species, so a Live tile shows a montage
     ("contact sheet") of their photos + a count when there are 2+, rather
     than one misleading photo. Single-species screens keep the full image;
     non-live states fall back to the dimmed single image + overlay. */
  const speciesList = (media && Array.isArray(media.species))
    ? media.species.filter((s) => s && s.image_url) : [];
  const speciesCount = (media && Number(media.species_count)) || speciesList.length;
  const showSheet = screen.status === 'online' && !isAsleep && speciesList.length >= 2;
  /* One consistent status cue under every tile — scan the dots alone to
     read the fleet: grey = empty, amber = ready/standby, green = live,
     slate = asleep, red = offline. */
  const dotColor = isEmpty ? 'var(--aq-text-faint)'
    : isPrepared ? 'var(--aq-warn)'
    : isAsleep ? '#6b86b0'
    : screen.status === 'online' ? 'var(--aq-success)'
    : screen.status === 'offline' ? 'var(--aq-danger)'
    : screen.status === 'idle' ? 'var(--aq-warn)'
    : 'var(--aq-text-faint)';
  /* The status now lives in the caption (right side), so the tile itself
     stays clean — no on-screen pill/badge. */
  const stateLabel = isEmpty ? 'Empty'
    : isPrepared ? 'Waiting for device'
    : isAsleep ? 'Asleep'
    : screen.status === 'online' ? 'Live'
    : screen.status === 'offline' ? 'Offline'
    : screen.status === 'idle' ? 'Standby'
    : '';
  /* Exception flags — surfaced ONLY on a Live screen, ONLY when something
     needs attention, so a healthy fleet stays calm. Each = a small icon
     top-right of the tile + a one-line reason in the caption. All from data
     the heartbeat already stores. Severity (sev) sorts which reason wins. */
  /* Exception flags removed in the 2026-06-06 simplification. They keyed off
     heartbeat fields (network_label, device_control, cached_assets,
     last_snapshot_at) that aren't reliably reported by the fleet, so they
     added noise more than signal. The status dot + label carry the essential
     state; richer per-screen diagnostics live in the detail panel. */
  const exceptions = [];
  const topException = null;
  const flagIcon = (type) => {
    const p = { width: 13, height: 13, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true };
    if (type === 'wifi') return (<svg {...p}><path d="M12 18h.01" /><path d="M9.8 14.6a3.5 3.5 0 0 1 4.4 0" /><path d="M6.8 11.6a8 8 0 0 1 10.4 0" /><path d="M3.8 8.6a12 12 0 0 1 16.4 0" /><path d="M3 3l18 18" /></svg>);
    if (type === 'ban') return (<svg {...p}><circle cx="12" cy="12" r="9" /><path d="M5.6 5.6l12.8 12.8" /></svg>);
    if (type === 'sync') return (<svg {...p}><path d="M16.5 15.5a4 4 0 0 0-.5-7.96A5 5 0 0 0 7 8a3.5 3.5 0 0 0-.5 7" /><path d="M12 11v6M9.5 14.5 12 17l2.5-2.5" /></svg>);
    if (type === 'clock') return (<svg {...p}><circle cx="12" cy="12" r="9" /><path d="M12 7.5v5l3 2" /></svg>);
    return null;
  };
  /* Status overlay text for non-online states — still shown on top of
     the dimmed image so the operator can't mistake "Standby with a
     pretty fish" for "playing fine". */
  const statusOverlay = (
    screen.status === 'offline' ? 'No signal'
    : screen.status === 'idle'  ? 'Standby'
    : screen.status === 'never' ? 'Awaiting first boot'
    : null
  );
  return (
    <button
      className={`ds-tile ${selected ? 'is-selected' : ''}`}
      data-screen-id={dataId}
      onClick={tileClick}
      onDoubleClick={onOpen}
      title={isEmpty && onSetup ? 'Click to set up this screen' : 'Double-click to open'}
    >
      {/* Per-tile bulk-select checkbox removed May 2026 — see
          DisplaysPage for the broader cleanup note. */}
      <div className={`ds-tile-frame ${portrait ? 'is-portrait' : ''} ${isEmpty ? 'is-empty' : ''} ${isPrepared ? 'is-prepared' : ''}`}>
        {isEmpty ? (
          /* No content, no device — a placeholder slot. Lightest weight:
             a dashed frame + a plus to set it up. */
          <div className="ds-tile-ghost">
            <svg className="ds-tile-plus" width="20" height="20" viewBox="0 0 24 24" fill="none"
              stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true">
              <path d="M12 5v14M5 12h14" />
            </svg>
            <span className="ds-tile-ghost-note">{onSetup ? 'Set up this screen' : 'No content yet'}</span>
          </div>
        ) : isPrepared ? (
          /* Content prepared, no device yet — a configured screen that's
             "off". No species photo (it read as a live screen); just a
             faint monitor glyph. The content + status live in the caption
             and the detail panel. */
          <div className="ds-tile-prepared">
            <svg className="ds-tile-prepared-icon" width="34" height="34" viewBox="0 0 24 24"
              fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
              strokeLinejoin="round" aria-hidden="true">
              <rect x="3" y="4" width="18" height="12" rx="2" />
              <path d="M8 20h8M12 16v4" />
            </svg>
          </div>
        ) : (
        <div
          className={`ds-tile-body ${screen.status}`}
          style={
            (hasMedia || showSheet) ? { background: '#0b1418', position: 'relative', overflow: 'hidden' }
            : screen.status === 'online' ? { background: t.grad }
            : undefined
          }
        >
          {showSheet ? (
            /* Contact sheet — montage of the species this screen rotates
               through + a count. Up to 5 thumbs then a "+N more" cell. */
            <div style={{ position: 'absolute', inset: 0, padding: 6, display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gridAutoRows: '1fr', gap: 4 }}>
              {speciesList.slice(0, speciesCount > 6 ? 5 : 6).map((sp, i) => (
                <div key={sp.species_id || i} style={{ borderRadius: 6, overflow: 'hidden', background: '#0e1b27' }}>
                  <img src={sp.image_url} alt={sp.common_name || ''} loading="lazy"
                    style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
                    onError={(e) => { e.currentTarget.style.display = 'none'; }} />
                </div>
              ))}
              {speciesCount > 6 && (
                <div style={{ borderRadius: 6, background: '#0b1620', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#cfe0ef' }}>
                  <span style={{ fontSize: 15, fontWeight: 500 }}>{`+${speciesCount - 5}`}</span>
                  <span style={{ fontSize: 9, color: '#8aa0b5' }}>more</span>
                </div>
              )}
              <span style={{ position: 'absolute', bottom: 6, right: 6, background: 'rgba(3,12,20,0.82)', color: '#cfe0ef', fontSize: 10, padding: '2px 8px', borderRadius: 20 }}>{`${speciesCount} species`}</span>
            </div>
          ) : hasMedia ? (
            /* Single-species (or non-live) fallback — one image, dimmed on
               idle/offline/asleep so a last-known frame doesn't read as
               "playing fine". */
            <img
              src={bgUrl}
              alt={bgAlt}
              loading="lazy"
              style={{
                position: 'absolute', inset: 0, width: '100%', height: '100%',
                objectFit: 'cover', display: 'block',
                filter: isAsleep ? 'brightness(0.08) saturate(0.3)'
                     : screen.status === 'online' ? 'none'
                     : screen.status === 'idle' ? 'brightness(0.45) saturate(0.6)'
                     : 'brightness(0.25) saturate(0.4) blur(1px)',
                transition: 'filter 200ms ease',
              }}
              onError={(e) => { e.currentTarget.style.display = 'none'; }}
            />
          ) : null}
          {/* Decorative SVG waves — only when we have no real image to
              show (preserves the existing "online but generic" look). */}
          {!hasMedia && !showSheet && screen.status === 'online' && (
            <svg className="ds-tile-pattern" viewBox="0 0 200 120" preserveAspectRatio="none">
              <path d="M0,80 Q50,60 100,75 T200,72" stroke={t.fg} strokeWidth="0.6" fill="none" opacity="0.4" />
              <path d="M0,90 Q60,75 120,85 T240,82" stroke={t.fg} strokeWidth="0.5" fill="none" opacity="0.25" />
              <circle cx="40" cy="25" r="0.8" fill={t.fg} opacity="0.4" />
              <circle cx="100" cy="20" r="1" fill={t.fg} opacity="0.5" />
              <circle cx="160" cy="28" r="0.7" fill={t.fg} opacity="0.3" />
            </svg>
          )}
          {statusOverlay && (
            <div className={`ds-tile-content ${screen.status === 'idle' ? 'ds-tile-idle' : 'ds-tile-offline'}`}>
              {statusOverlay}
            </div>
          )}
          {/* Status moved to the caption (right side) — no on-tile pill,
              keeping the live snapshot clean. */}
          {exceptions.length > 0 && (
            <div className="ds-tile-flags">
              {exceptions.map((ex) => (
                <span key={ex.key} className="ds-tile-flag" style={{ color: ex.color }} title={ex.label}>
                  {flagIcon(ex.type)}
                </span>
              ))}
            </div>
          )}
        </div>
        )}
      </div>
      <div className="ds-tile-info">
        {/* dot · name · status on the first row; an exception reason (if
            any) on a second muted line below. */}
        <div className="ds-tile-info-row">
          <span className="ds-tile-dot" style={{ background: dotColor }} aria-hidden="true" />
          <div
            className="ds-tile-id"
            title={screen.name || screen.screen_code}
          >{screen.name || screen.screen_code}</div>
          {/* Caption grammar unified with the dashboard fleet strip
              (2026-06-12): the dot carries the state colour, the right-
              side text stays quiet. Colouring both doubled the signal
              and made every caption shout. */}
          {stateLabel && (
            <span className="ds-tile-state">{stateLabel}</span>
          )}
        </div>
        {topException && (
          <div className="ds-tile-reason" style={{ color: topException.color }}>{topException.label}</div>
        )}
      </div>
    </button>
  );
}

function DSP_DetailPanel({ screen, zonesList, onClose, onChanged, onOpenSchedules }) {
  // Edit modal opens inline so the curator can change name /
  // orientation / auto-rotate without navigating away from the
  // displays grid. EditScreenModal is exposed by single-screen.jsx
  // as window.EditScreenModal — guarded so the file still parses
  // even if that script hasn't loaded.
  const [editOpen, setEditOpen] = useState(false);
  /* Manage species modal — same component used by the bulk-select bar
     at the bottom of the grid. When opened from here we pass a single
     screen ID and the modal renders the current-assignments list
     + an "add more" picker. */
  const [manageSpeciesOpen, setManageSpeciesOpen] = useState(false);
  /* Settings popover — anchors below the gear icon and holds the
     less-frequent actions (rename, restart, device lifecycle, swap).
     Closed state is `null`; open state is `{}` so we can extend with
     anchor info later if needed. */
  const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
  /* Tab inside the Screen settings popup. Mirrors the CMS Settings
     page's sidebar-nav pattern (settings.jsx) so curators don't have
     to scroll through one long stack of unrelated sections — Identity,
     Audio tour, Maintenance, Device each get their own panel. Default
     'identity' because that's the most common entry-point ("rename
     this tank", "move it to a different zone"). Reset on every open. */
  const [popupTab, setPopupTab] = useState('identity');
  const settingsBtnRef = useRef(null);
  const settingsMenuRef = useRef(null);
  /* Swap menu — picks another screen to exchange content or device
     with. See POST /api/screens/:id/swap-content + /swap-device.
     `mode` is 'content' or 'device' once a target is picked. */
  const [swapMode, setSwapMode] = useState(null);

  /* Close the settings popover on outside-click / Escape. Standard
     popover pattern; ref-guarded so the click on the gear button
     itself (which toggles) doesn't immediately re-close. */
  useEffect(() => {
    if (!settingsMenuOpen) return undefined;
    const onKey = (e) => { if (e.key === 'Escape') setSettingsMenuOpen(false); };
    document.addEventListener('keydown', onKey);
    return () => { document.removeEventListener('keydown', onKey); };
  }, [settingsMenuOpen]);

  /* Hover-to-enlarge popup. Mirrors the campaign-editor's layout-card
     hover pattern (CE_LayoutPicker.onCardEnter): 250ms debounce, then
     a fixed-position 750px-wide kiosk preview anchored to the right
     of the small preview (flips left if it would overflow). The
     popup hosts its own iframe so the user sees a true high-fidelity
     mini-canvas at design resolution, not a CSS-upscaled blurry copy
     of the small preview. Entry uses AquaOSSpring.animate with the
     'magicMove' preset — the same spring config Apple uses for
     shared-element transitions in iOS / macOS Sequoia+. */
  const hoverTimerRef = useRef(null);
  const popupRef = useRef(null);
  const [hoverPos, setHoverPos] = useState(null); // { left, top, w, h, srcLeft, srcTop, srcW, srcH }

  /* Live-iframe scale. The detail-panel preview is ~400px wide but
     the kiosk inside is designed for 1920×1080 (or 1080×1920 for
     portrait). Without scaling, the kiosk's container queries fire
     the small-viewport branch and the chrome reads oversized
     ("ONLINE" pill, EN flag, General/Kids/Expert tabs etc. all huge
     relative to the species art). The fix: render the iframe at
     the design resolution and CSS-scale the whole thing down to
     fit. The kiosk inside thinks it's on a full TV; visually it's
     a true miniature. ResizeObserver keeps the scale honest as the
     panel resizes (e.g. when the curator drags the window). */
  const previewBodyRef = useRef(null);
  const [livePreviewScale, setLivePreviewScale] = useState(0);
  useEffect(() => {
    const el = previewBodyRef.current;
    if (!el || typeof ResizeObserver === 'undefined') return undefined;
    const isPortrait = screen && screen.orientation === 'portrait';
    const designW = isPortrait ? 1080 : 1920;
    const designH = isPortrait ? 1920 : 1080;
    const compute = () => {
      const r = el.getBoundingClientRect();
      if (!r.width || !r.height) return;
      // "Cover" semantics: pick the LARGER of the two ratios so the
      // scaled iframe always fills the container completely. Math.min
      // (contain) left a 1-2px gap on the right edge from sub-pixel
      // rounding — the body's blue gradient showed through. With
      // Math.max the iframe slightly overshoots, and overflow:hidden
      // on .ds-preview-body clips the micro-overhang. The parent
      // already shares the kiosk aspect ratio so the visible crop
      // is sub-pixel; if the ratios ever drift the kiosk content
      // still fills edge-to-edge rather than letterboxing.
      const s = Math.max(r.width / designW, r.height / designH);
      setLivePreviewScale(s);
    };
    compute();
    const ro = new ResizeObserver(compute);
    ro.observe(el);
    return () => ro.disconnect();
  }, [screen && screen.id, screen && screen.orientation, screen && screen.status]);

  /* Hover handlers for the enlarged kiosk popup. Mirrors the campaign
     editor's onCardEnter/onCardLeave (CE_LayoutPicker at line ~2563):
     250ms debounce, prefer right of the small preview, flip to left
     if it would overflow, clamp vertically to viewport. The popup
     sizes from a fixed long-side of 600px so portrait kiosks read
     correctly (337×600) and landscape kiosks read at 600×337. */
  function onPreviewEnter(ev) {
    if (!screen || screen.status !== 'online') return;
    const target = ev.currentTarget;
    if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
    // Tiny 40ms debounce — swallows accidental mouse crossings (a
    // pointer just grazing the tile shouldn't flash the popup) but
    // reads as essentially instant. 250ms (the campaign-editor
    // value) felt sluggish per feedback. The persistent-iframe
    // architecture (popup div is always mounted, just visibility-
    // hidden) means there's no load cost to firing immediately.
    hoverTimerRef.current = setTimeout(() => {
      const rect = target.getBoundingClientRect();
      const isPortrait = screen && screen.orientation === 'portrait';
      // 25% bigger than the campaign editor's 600px — 750 reads as
      // a confident "second canvas" without dominating the viewport.
      const LONG_SIDE = 750;
      const w = isPortrait ? Math.round(LONG_SIDE * 9 / 16) : LONG_SIDE;
      const h = isPortrait ? LONG_SIDE : Math.round(LONG_SIDE * 9 / 16);
      // Prefer right; flip to left if overflowing the viewport.
      let left = rect.right + 16;
      if (left + w > window.innerWidth - 16) left = rect.left - w - 16;
      // Clamp inside the main panel — when flipped left at narrow
      // widths the popup used to land on top of the sidebar, which
      // read as a rendering glitch rather than a preview.
      const mainEl = document.querySelector('.aq-main');
      const mainLeft = mainEl ? mainEl.getBoundingClientRect().left : 0;
      left = Math.max(mainLeft + 12, left);
      // Center vertically against the source preview, clamp to gutter.
      let top = rect.top + (rect.height / 2) - (h / 2);
      top = Math.max(16, Math.min(top, window.innerHeight - h - 16));
      // Source rect captured for FLIP morph — keyframes use these to
      // synthesise the "appearing FROM the small preview" effect.
      setHoverPos({
        left, top, w, h,
        srcLeft: rect.left, srcTop: rect.top,
        srcW: rect.width, srcH: rect.height,
      });
    }, 40);
  }
  function onPreviewLeave() {
    if (hoverTimerRef.current) {
      clearTimeout(hoverTimerRef.current);
      hoverTimerRef.current = null;
    }
    setHoverPos(null);
  }
  // Clean the timer if the screen changes underneath us.
  useEffect(() => () => {
    if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
  }, []);

  /* Guardrails (2026-06-12): the popup previously dismissed ONLY on
     mouseleave — if the pointer never crossed the source again (page
     loaded under the cursor, scroll moved content beneath it) the
     popup stuck open at a stale position. Escape and any scroll now
     dismiss it; both listeners only exist while it's visible. */
  useEffect(() => {
    if (!hoverPos) return undefined;
    const dismiss = () => setHoverPos(null);
    const onKey = (e) => { if (e.key === 'Escape') dismiss(); };
    window.addEventListener('keydown', onKey);
    window.addEventListener('scroll', dismiss, { capture: true, passive: true });
    return () => {
      window.removeEventListener('keydown', onKey);
      window.removeEventListener('scroll', dismiss, { capture: true });
    };
  }, [hoverPos]);

  /* Fire the spring-physics entrance the moment the popup mounts.
     useLayoutEffect (not useEffect) so the animation kicks BEFORE
     the browser paints — otherwise the first frame shows the popup
     in its final state and the animation looks like it starts a
     frame late. Reads the source rect + destination rect from
     hoverPos and constructs the FLIP-from-source transform. */
  React.useLayoutEffect(() => {
    if (!hoverPos || !popupRef.current) return;
    if (!window.AquaOSSpring) return;
    const el = popupRef.current;
    const tx = hoverPos.srcLeft - hoverPos.left;
    const ty = hoverPos.srcTop  - hoverPos.top;
    const sx = hoverPos.srcW / hoverPos.w;
    const sy = hoverPos.srcH / hoverPos.h;
    window.AquaOSSpring.animate(el, [
      {
        transform: `translate(${tx}px, ${ty}px) scale(${sx}, ${sy})`,
        opacity: 0,
      },
      {
        transform: 'translate(0, 0) scale(1, 1)',
        opacity: 1,
      },
    ], { spring: 'snappy' });
  }, [hoverPos]);

  /* Inline form state for the screen-settings popup. Replaces the
     "Edit screen settings…" button that previously chained out to a
     separate EditScreenModal — per user feedback the settings popup
     should be the one-stop control surface, not a menu of links to
     other popups. Initialised from the screen prop when the popup
     opens so the curator always sees the current values. */
  const [ssName,   setSsName]   = useState('');
  const [ssLocHint, setSsLocHint] = useState('');
  const [ssOrient, setSsOrient] = useState('landscape');
  const [ssRotate, setSsRotate] = useState('12');
  const [ssTouch,  setSsTouch]  = useState(false);
  const [ssZoneId, setSsZoneId] = useState('');
  const [ssSizeId, setSsSizeId] = useState('43');
  const [ssBusy,   setSsBusy]   = useState(false);
  const [ssErr,    setSsErr]    = useState(null);
  /* Live device control (Device tab) — drives POST /api/screens/command
     (Fully Cloud). Sliders are "set" controls; we don't read brightness
     back (no telemetry column), so they default to sensible values. */
  const [dcBright, setDcBright] = useState(80);
  const [dcVol,    setDcVol]    = useState(50);
  const [dcBusy,   setDcBusy]   = useState(null);
  const [dcFid,    setDcFid]    = useState(''); // manual Fully device id entry
  /* Inline "are you sure?" rows for the irreversible actions
     (restart, retire, migrate). Each one toggles a confirmation row
     under the relevant button rather than firing window.confirm. */
  const [confirmRestart, setConfirmRestart] = useState(false);
  const [confirmRetire,  setConfirmRetire]  = useState(false);
  const [confirmMigrate, setConfirmMigrate] = useState(false);
  const [confirmDelete,  setConfirmDelete]  = useState(false);

  useEffect(() => {
    if (!settingsMenuOpen || !screen) return;
    setSsName(screen.name || '');
    setSsLocHint(screen.location_hint || '');
    setSsOrient(screen.orientation || 'landscape');
    setSsRotate(String(screen.auto_rotate_seconds || 12));
    setSsTouch(!!screen.is_touch);
    setSsZoneId(screen.zone_id || '');
    setSsSizeId(dspResolvePresetId(screen));
    setDcFid(screen.fully_device_id || '');
    setSsBusy(false); setSsErr(null);
    setConfirmRestart(false); setConfirmRetire(false); setConfirmMigrate(false);
    setConfirmDelete(false);
    // Admins re-open on Identity (the common "rename / re-zone" entry).
    // Non-admins have no Identity tab, so land them on Device — the only
    // tab they can see — rather than a blank pane.
    setPopupTab((Auth.canManageOrg() || Auth.isSiteManager()) ? 'identity' : 'device');
  }, [settingsMenuOpen, screen && screen.id]);

  async function saveScreenSettings() {
    // Validation runs against fields owned by the Identity tab. If the
    // curator is on a different tab when they hit Save, the inline
    // error inside Identity is hidden — so we also surface failures via
    // the footer (rendered below) AND a toast, regardless of which tab
    // is active. Toast covers the silent-validation-failure case.
    if (!ssName.trim()) {
      const msg = 'Display name is required — switch to Identity to fix it.';
      setSsErr(msg);
      setPopupTab('identity');
      if (window.toast) (window.toast.error ? window.toast.error(msg) : window.toast(msg));
      return;
    }
    const rot = Number(ssRotate);
    if (!Number.isFinite(rot) || rot < 3 || rot > 600) {
      const msg = 'Auto-rotate must be a number between 3 and 600 seconds.';
      setSsErr(msg);
      setPopupTab('identity');
      if (window.toast) (window.toast.error ? window.toast.error(msg) : window.toast(msg));
      return;
    }
    // Resolve the size preset → resolution. Custom leaves the field
    // alone so we don't overwrite a curator-set native pixel grid.
    // Portrait orientation swaps the dimensions so the kiosk knows
    // the panel is rotated.
    const preset = DSP_SIZE_PRESETS.find((p) => p.id === ssSizeId) || DSP_SIZE_PRESETS[0];
    let presetW = preset.width;
    let presetH = preset.height;
    if (ssOrient === 'portrait' && presetW && presetH && presetW > presetH) {
      [presetW, presetH] = [presetH, presetW];
    }

    setSsBusy(true); setSsErr(null);
    try {
      await apiFetch(`/api/screens/${screen.id}`, {
        method: 'PUT',
        body: JSON.stringify({
          name: ssName.trim(),
          orientation: ssOrient,
          auto_rotate_seconds: rot,
          is_touch: ssTouch ? 1 : 0,
          // Only send zone when it differs from the current value.
          ...(ssZoneId && ssZoneId !== screen.zone_id ? { zone_id: ssZoneId } : {}),
          // Only send resolution when the preset isn't 'custom'.
          ...(preset.id !== 'custom' && presetW && presetH
            ? { resolution_width: presetW, resolution_height: presetH }
            : {}),
          // v100: persist the exact inch size too — resolution alone is
          // ambiguous (24/32/43 = 1080p; 55/65/75/85 = 4K), so without this
          // the picker collapsed back to 43"/55" on reopen. Mirrors the
          // detail-page Edit modal + the Register modal.
          ...(preset.id !== 'custom'
            ? { display_size_inches: parseInt(preset.id, 10) }
            : {}),
        }),
      });
      if (window.toast) window.toast.success
        ? window.toast.success('Screen updated')
        : window.toast('Screen updated');
      if (onChanged) onChanged();
      setSettingsMenuOpen(false);
    } catch (e) {
      setSsErr(e.message);
    } finally {
      setSsBusy(false);
    }
  }

  /* Live device command → POST /api/screens/command (Fully Cloud dispatch).
     action ∈ on|off|reboot|restart|clearCache|brightness|volume|day|
     evening|night. Returns targeted:0 when no device is linked to Fully
     Cloud yet, which we surface rather than pretending it worked. */
  async function dcCmd(action, value, label) {
    if (!screen) return;
    const tag = action + (value != null ? ':' + value : '');
    setDcBusy(tag);
    try {
      const r = await apiFetch('/api/screens/command', {
        method: 'POST',
        body: JSON.stringify({ scope: 'screen', screen_id: screen.id, action, value }),
      });
      const queued = r && r.targeted === 0;
      const msg = (label || action) + (queued ? ' — no live device linked yet' : ' sent');
      if (window.toast) (window.toast.success ? window.toast.success(msg) : window.toast(msg));
    } catch (e) {
      const m = (label || action) + ' failed: ' + ((e && e.message) || e);
      if (window.toast) (window.toast.error ? window.toast.error(m) : window.toast(m, 'error'));
    } finally {
      setDcBusy(null);
    }
  }

  /* Manually set the Fully Cloud device id (paste from the Fully dashboard)
     when the panel's JS interface won't auto-report it. PUT /api/screens/:id;
     onChanged refreshes the row so the controls light up. */
  async function saveFullyId() {
    if (!screen) return;
    try {
      await apiFetch(`/api/screens/${screen.id}`, {
        method: 'PUT',
        body: JSON.stringify({ fully_device_id: (dcFid || '').trim() || null }),
      });
      if (window.toast) (window.toast.success ? window.toast.success('Fully device id saved') : window.toast('Saved'));
      if (onChanged) onChanged();
    } catch (e) {
      const m = 'Save failed: ' + ((e && e.message) || e);
      if (window.toast) (window.toast.error ? window.toast.error(m) : window.toast(m, 'error'));
    }
  }

  /* Zone choices for the inline dropdown — filtered to the screen's
     own site so the curator can't move the screen across sites here
     (that's a separate flow). */
  const ssZoneChoices = useMemo(() => {
    if (!screen || !Array.isArray(zonesList)) return [];
    const siteId = screen.site_id;
    return siteId ? zonesList.filter((z) => z.site_id === siteId) : zonesList;
  }, [screen, zonesList]);

  if (!screen) return null;
  const t = DSP_THEMES[screen.zone_theme] || DSP_THEMES.reef;
  const portrait = screen.orientation === 'portrait';
  return (
    <aside className="ds-detail">
      <header className="ds-detail-head">
        <div>
          {/* Lead with the human-readable Display name — matches the
              tile caption and is what visitors hear in audio. The
              screen_code (serial) lives below in Settings now, not in
              the panel header. Fallback to the code when unnamed so
              we never render a blank line. */}
          <div
            className="ds-detail-id"
            title={screen.name || screen.screen_code}
            style={{ fontWeight: 600, letterSpacing: '0.02em' }}
          >{screen.name || screen.screen_code}</div>
          <h3>{screen.zone_name}</h3>
          <div className="ds-detail-site">{screen.site_name}</div>
        </div>
        {/* Close × removed per Jun 2026 feedback — the panel closes by
            re-clicking the selected tile / selecting another, so the
            corner × was redundant chrome. */}
      </header>

      {/* Hero preview — Apple Settings-style. For an online screen we
          embed the kiosk's own /display/<code> view in an iframe so
          curators see the LIVE rotation (species transitions, video
          backgrounds, conservation overlays) in real time rather
          than a 60s-stale PNG. The iframe is intentionally non-
          interactive (pointer-events:none) so clicks on the panel
          still go to the surrounding action buttons. The snapshot
          worker keeps capturing in the background so offline screens
          can still show their LAST known frame, and we only fall back
          to the synthetic SVG art if there's no snapshot either. Per
          May 2026 follow-up. */}
      <div className={`ds-preview is-hero ${portrait ? 'is-portrait' : ''}`}>
        <div
          ref={previewBodyRef}
          className={`ds-preview-body ${screen.status}`}
          /* Dark base behind the live iframe (matches the kiosk's own
             #001a3d) so no light gradient ring shows around the scaled
             iframe edges — that ring read as a hairline border. */
          style={screen.status === 'online'
            ? { background: '#001a3d', cursor: 'zoom-in' }
            : undefined}
          onMouseEnter={onPreviewEnter}
          onMouseLeave={onPreviewLeave}
        >
          {screen.status === 'online' && livePreviewScale > 0 && (
            <iframe
              key={`live-${screen.screen_code}-${portrait ? 'p' : 'l'}`}
              src={`/display/${encodeURIComponent(screen.screen_code)}?preview=frame`}
              title={`Live kiosk view · ${screen.zone_name}`}
              loading="lazy"
              // Sandbox: allow scripts (kiosk needs them) + same-origin
              // for the /api/screens/<code> fetch + autoplay so the
              // video background actually plays. Block forms / popups
              // / top-nav so a hijacked kiosk page can't escape the
              // iframe.
              sandbox="allow-scripts allow-same-origin"
              allow="autoplay"
              style={{
                // Render at the kiosk's design resolution then scale
                // down — see the previewBodyRef ResizeObserver above
                // for the rationale. transform-origin top-left so the
                // scaled iframe pins to (0,0) and the absolute inset
                // wrapper visually fills the panel.
                position: 'absolute', top: 0, left: 0,
                width:  portrait ? '1080px' : '1920px',
                height: portrait ? '1920px' : '1080px',
                transform: `scale(${livePreviewScale})`,
                transformOrigin: 'top left',
                border: 0, display: 'block',
                // Non-interactive — clicks pass through to the panel.
                pointerEvents: 'none',
                background: '#001a3d',
              }}
            />
          )}
          {screen.status !== 'online' && screen.last_snapshot_url && (
            <img
              src={screen.last_snapshot_url}
              alt={`Last known view · ${screen.zone_name}`}
              loading="lazy"
              style={{
                position: 'absolute', inset: 0,
                width: '100%', height: '100%',
                objectFit: 'cover', display: 'block',
                // Subtle desaturation hints "this is a last-known
                // frame, not live" without obscuring the content.
                filter: 'grayscale(0.35) brightness(0.85)',
              }}
              onError={(e) => { e.currentTarget.style.display = 'none'; }}
            />
          )}
          {screen.status !== 'online' && !screen.last_snapshot_url && (
            <>
              <svg className="ds-preview-pattern" viewBox="0 0 400 240" preserveAspectRatio="none">
                <path d="M0,160 Q100,130 200,150 T400,144" stroke={t.fg} strokeWidth="1" fill="none" opacity="0.4" />
                <path d="M0,180 Q120,150 240,170 T480,165" stroke={t.fg} strokeWidth="0.8" fill="none" opacity="0.25" />
                <path d="M0,200 Q80,180 160,190 T320,188" stroke={t.fg} strokeWidth="0.6" fill="none" opacity="0.18" />
                {[...Array(20)].map((_, i) => (
                  <circle key={i} cx={20 + i * 20} cy={30 + (i % 4) * 15} r={0.6 + (i % 3) * 0.5} fill={t.fg} opacity={0.3 + (i % 3) * 0.1} />
                ))}
              </svg>
              <div className="ds-preview-content">
                <div className="ds-preview-eyebrow">Now playing</div>
                <div className="ds-preview-title">{screen.zone_name}</div>
              </div>
            </>
          )}
          {screen.status === 'offline' && (
            <div className="ds-preview-fallback">No signal</div>
          )}
          {screen.status === 'idle' && (
            <div className="ds-preview-fallback">Standby</div>
          )}
          {screen.status === 'never' && (
            <div className="ds-preview-fallback">Awaiting first boot</div>
          )}

          {/* Overlay metadata — status pill anchored top-left.
              Last-seen is shown only when it actually adds info
              (i.e. screen is NOT online + recent). For an online +
              just-now screen the pill is redundant noise. */}
          <div className="ds-preview-overlay ds-preview-overlay--status">
            <DSP_StatusPill status={screen.status} />
          </div>
          {screen.last_seen_label
           && screen.status !== 'online'
           && screen.last_seen_label !== 'just now' && (
            <div className="ds-preview-overlay ds-preview-overlay--lastseen">
              {screen.last_seen_label === 'never seen'
                ? 'Never seen'
                : screen.last_seen_label}
            </div>
          )}
        </div>
      </div>

      {/* Action surface — pared down to the two daily-driver actions
          (Manage species + Launch kiosk). Everything else (Edit
          settings, Restart, Replace/Retire/Migrate device, Swap)
          lives behind the subtle gear icon at the far right. Per
          May 2026 follow-up feedback: keep the surface clean, let
          the gear absorb the long tail of operational actions. */}
      <div className="ds-detail-actions">
        <div className="ds-action-primary-row">
          {(Auth.canManageOrg() || Auth.isSiteManager()) && (
            <button
              className="ds-btn primary"
              onClick={() => setManageSpeciesOpen(true)}
              title="Add or remove species playing on this screen"
            ><Icon name="fish" size={12} />Manage species</button>
          )}
          <button
            className="ds-btn"
            onClick={() => {
              // Open the true-to-size preview (renders the kiosk at the
              // panel's native resolution, scaled to fit — see
              // /display/preview.html). ?preview=kiosk inside suppresses
              // heartbeats/analytics so previewing doesn't pollute this
              // screen's telemetry.
              window.open(`/display/preview.html?screen=${encodeURIComponent(screen.screen_code)}`, '_blank', 'noopener');
            }}
            title="Preview the kiosk at this screen's real size"
          ><Icon name="monitor" size={12} />Launch kiosk</button>
          {/* Subtle gear — opens a popup window styled in the shared
              .aq-popup-* design language (same as the system settings
              modal: frosted backdrop, gradient surface, circular close
              button, fade+rise animation). Keeps the visual material
              family consistent across every popup window in the app. */}
          <button
            ref={settingsBtnRef}
            className="ds-iconbtn ds-iconbtn-settings"
            aria-label="Screen settings menu"
            aria-haspopup="dialog"
            aria-expanded={settingsMenuOpen}
            onClick={() => setSettingsMenuOpen((v) => !v)}
            title="More screen actions"
          ><Icon name="settings" size={14} /></button>
          {settingsMenuOpen && ReactDOM.createPortal((
            <div
              className="aq-popup-modal"
              role="dialog"
              aria-modal="true"
              aria-label={`Settings for ${screen.screen_code}`}
              onClick={(e) => { if (e.target.classList.contains('aq-popup-modal')) setSettingsMenuOpen(false); }}
            >
              <div ref={settingsMenuRef} className="aq-popup-card aq-popup-card--xwide">
                <button
                  className="aq-popup-close"
                  aria-label="Close"
                  onClick={() => setSettingsMenuOpen(false)}
                >×</button>
                <header className="aq-popup-header">
                  <h2>Screen settings</h2>
                  <p>{screen.screen_code} · {screen.zone_name}</p>
                </header>
                <div className="aq-popup-body" style={{ flexDirection: 'row', overflow: 'hidden' }}>
                  {/* Sidebar nav — Identity / Audio tour / Device.
                      Mirrors the CMS Settings page pattern (settings.jsx)
                      so curators don't have to scroll through an endless
                      list. Site managers + org admins see all three;
                      non-admins see only Device (Request restart). */}
                  {(function () {
                    const adminOnly = Auth.canManageOrg() || Auth.isSiteManager();
                    /* Maintenance tab merged into Device (Jun 2026) — its two
                       actions (Request restart, Migrate to secure pairing) are
                       device actions, so a separate tab just split device
                       controls across two places. Device is visible to all
                       roles now; its admin-only sections are gated inside, and
                       non-admins still get Request restart. */
                    const tabDefs = [
                      adminOnly && { id: 'identity',    label: 'Identity' },
                      adminOnly && { id: 'audio-tour', label: 'Audio tour' },
                      { id: 'device',     label: 'Device' },
                    ].filter(Boolean);
                    return (
                      <aside style={{
                        width: 168, flexShrink: 0,
                        padding: '14px 10px',
                        display: 'flex', flexDirection: 'column', gap: 2,
                        overflowY: 'auto',
                        background: 'transparent',
                      }}>
                        {tabDefs.map(function (t) {
                          const active = popupTab === t.id;
                          return (
                            <button
                              key={t.id}
                              type="button"
                              onClick={function () { setPopupTab(t.id); }}
                              style={{
                                padding: '8px 11px',
                                textAlign: 'left',
                                fontSize: 13,
                                fontFamily: 'inherit',
                                background: active ? 'var(--aq-surface)' : 'transparent',
                                color: active ? 'var(--aq-text)' : 'var(--aq-text-faint)',
                                border: 0,
                                borderRadius: 7,
                                cursor: 'pointer',
                                fontWeight: active ? 500 : 400,
                                outline: 0,
                              }}
                            >{t.label}</button>
                          );
                        })}
                      </aside>
                    );
                  })()}
                  {/* Right pane — only the active tab's section renders. */}
                  <div style={{ flex: 1, overflowY: 'auto', minWidth: 0 }}>
                  {/* Identity — inline form, replaces the old chained
                      EditScreenModal. Stacked fields for free-text /
                      dropdown inputs; row-grid fields for short
                      controls so their values share a vertical column. */}
                  {/* Settings-page style: each tab content sits inside an
                      .x-card with a single h2 heading, then .x-form-field
                      rows (label left, control right) — matches what the
                      Workspace settings popup uses (settings.jsx). */}
                  {popupTab === 'identity' && (Auth.canManageOrg() || Auth.isSiteManager()) && (
                    <div style={{ padding: 16 }}>
                      <div className="x-card" style={{ padding: 20 }}>
                        <h2 style={{
                          margin: '0 0 16px', fontFamily: 'var(--aq-ff-display)',
                          fontSize: 16, fontWeight: 500, color: 'var(--aq-text)',
                        }}>Identity</h2>

                        <div className="x-form-field">
                          <div className="x-form-label">
                            Display name
                            <small>Make it memorable — this name is spoken in the tank's audio descriptions.</small>
                          </div>
                          <div className="x-form-control">
                            <input
                              className="x-input"
                              id="ss-name" type="text" autoFocus
                              value={ssName} onChange={(e) => setSsName(e.target.value)}
                              placeholder="e.g. Main reef tank"
                            />
                          </div>
                        </div>

                        {ssZoneChoices.length > 0 && (
                          <div className="x-form-field">
                            <div className="x-form-label">Zone<small>Which exhibit area this screen belongs to.</small></div>
                            <div className="x-form-control">
                              <select
                                className="x-input"
                                id="ss-zone"
                                value={ssZoneId}
                                onChange={(e) => setSsZoneId(e.target.value)}
                              >
                                {ssZoneChoices.map((z) => (
                                  <option key={z.id} value={z.id}>{z.name}</option>
                                ))}
                              </select>
                            </div>
                          </div>
                        )}

                        <div className="x-form-field">
                          <div className="x-form-label">Display size<small>Used to render content at the right pixel grid.</small></div>
                          <div className="x-form-control">
                            <select
                              className="x-input"
                              id="ss-size"
                              value={ssSizeId}
                              onChange={(e) => setSsSizeId(e.target.value)}
                            >
                              {DSP_SIZE_PRESETS.map((p) => (
                                <option key={p.id} value={p.id}>{p.label}</option>
                              ))}
                            </select>
                          </div>
                        </div>

                        <div className="x-form-field">
                          <div className="x-form-label">Orientation</div>
                          <div className="x-form-control">
                            <div className="aq-popup-segmented" style={{ alignSelf: 'flex-start' }}>
                              <button
                                type="button"
                                className={ssOrient === 'landscape' ? 'is-active' : ''}
                                onClick={() => setSsOrient('landscape')}
                              >Landscape</button>
                              <button
                                type="button"
                                className={ssOrient === 'portrait' ? 'is-active' : ''}
                                onClick={() => setSsOrient('portrait')}
                              >Portrait</button>
                            </div>
                          </div>
                        </div>

                        <div className="x-form-field">
                          <div className="x-form-label">Auto-rotate<small>Seconds per slide on the kiosk display.</small></div>
                          <div className="x-form-control">
                            <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
                              <input
                                className="x-input"
                                id="ss-rotate" type="number" min="3" max="600"
                                value={ssRotate}
                                onChange={(e) => setSsRotate(e.target.value)}
                                style={{ width: 90 }}
                              />
                              <span style={{ fontSize: 12, color: 'var(--aq-text-faint)' }}>seconds</span>
                            </div>
                          </div>
                        </div>

                        <div className="x-form-field">
                          <div className="x-form-label">Touch input<small>Enable for touchpool / interactive panels.</small></div>
                          <div className="x-form-control">
                            <button
                              id="ss-touch" type="button"
                              className={`aq-popup-toggle ${ssTouch ? 'is-on' : ''}`}
                              onClick={() => setSsTouch((v) => !v)}
                              aria-pressed={ssTouch}
                              style={{ alignSelf: 'flex-start' }}
                            ><span /></button>
                          </div>
                        </div>

                        {ssErr && <div className="aq-popup-error" style={{ marginTop: 12 }}>{ssErr}</div>}
                      </div>
                    </div>
                  )}

                  {/* Audio tour (v82) — mobile-only 45s narrative tour
                      of this tank's inhabitants. See
                      docs/tank-audio-tour-plan.md. Renders unconditionally
                      so a missing window.TankAudioSection surfaces as a
                      visible placeholder rather than silent absence. */}
                  {popupTab === 'audio-tour' && (Auth.canManageOrg() || Auth.isSiteManager()) && (
                    <div style={{ padding: 16 }}>
                      <div className="x-card" style={{ padding: 20 }}>
                        <h2 style={{
                          margin: '0 0 16px', fontFamily: 'var(--aq-ff-display)',
                          fontSize: 16, fontWeight: 500, color: 'var(--aq-text)',
                        }}>Audio tour</h2>
                        {window.TankAudioSection
                          ? <window.TankAudioSection screen={screen} />
                          : <div style={{ fontSize: 12, color: 'var(--aq-text-faint)' }}>
                              Audio tour script not loaded yet — refresh the page.
                            </div>}
                      </div>
                    </div>
                  )}

                  {/* Placards tab removed 2026-05-18 — visitors scan
                      species QRs off the kiosk display itself, and the
                      kiosk auto-scopes /m/<id> URLs to its own
                      screen_code via the qrImgSrc override in
                      public/display/index.html. No separate placard
                      generator step needed. */}

                  {/* Maintenance — light operational actions. Each
                      irreversible action expands an inline confirm row
                      instead of firing window.confirm. */}
                  {/* Device tab — visible to all roles. Admin/site-manager
                      get Live control + Pairing & hardware (gated in the
                      fragment below); everyone gets Request restart at the
                      bottom. The old standalone Maintenance tab folded in
                      here (Jun 2026). */}
                  {popupTab === 'device' && (
                    <div style={{ padding: 16 }}>
                    {(Auth.canManageOrg() || Auth.isSiteManager()) && (<>
                    {/* Live control — brightness/volume/power/modes + a few
                        live stats, driving POST /api/screens/command. Only
                        shown when a device is actually paired: controlling
                        brightness/reboot on an empty slot is meaningless and
                        was the main source of confusion on unassigned slots. */}
                    {screen.paired_at ? (
                    <div className="x-card" style={{ padding: 20, marginBottom: 16 }}>
                      <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
                        <h2 style={{ margin: 0, flex: 1, fontFamily: 'var(--aq-ff-display)', fontSize: 16, fontWeight: 500, color: 'var(--aq-text)' }}>Live control</h2>
                        <span className="ds-statuspill" style={{ background: 'var(--aq-surface-2)', color: screen.is_online ? 'var(--aq-success)' : 'var(--aq-text-faint)' }}>
                          <span className="ds-statusdot" style={{ background: screen.is_online ? 'var(--aq-success)' : 'var(--aq-text-faint)' }} />
                          {screen.is_online ? 'Online' : 'Offline'}
                        </span>
                      </div>

                      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 24px', fontSize: 12, color: 'var(--aq-text-dim)', marginBottom: 18 }}>
                        {/* Telemetry rows render only once the panel has
                            reported them — dash-rows for fields an older
                            kiosk build never sends read as breakage
                            (2026-06-12 feedback). */}
                        {screen.network_label && (
                        <div style={{ display: 'flex', justifyContent: 'space-between' }}><span>Network</span><b style={{ color: 'var(--aq-text)' }}>{screen.network_label}</b></div>
                        )}
                        {screen.cached_assets != null && screen.total_assets != null && screen.total_assets > 0 && (
                        <div style={{ display: 'flex', justifyContent: 'space-between' }}><span>Cache</span><b style={{ color: 'var(--aq-text)' }}>{`${screen.cached_assets}/${screen.total_assets}`}</b></div>
                        )}
                        {screen.current_species && (
                        <div style={{ display: 'flex', justifyContent: 'space-between' }}><span>Now showing</span><b style={{ color: 'var(--aq-text)' }}>{screen.current_species}</b></div>
                        )}
                        <div style={{ display: 'flex', justifyContent: 'space-between' }}><span>Fully device</span><b style={{ color: screen.fully_device_id ? 'var(--aq-success)' : 'var(--aq-text-faint)', fontSize: 12 }}>{screen.fully_device_id ? 'linked' : 'not linked'}</b></div>
                        {/* Device control = whether Fully's PLUS JS interface is
                            live on the panel (heartbeat self-report). 'unavailable'
                            on an online screen means every command here is silently
                            no-op'ing — almost always a lapsed PLUS licence / JS
                            interface switched off. '—' until the panel reports it. */}
                        <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                          <span>Device control</span>
                          <b style={{ fontSize: 12, color:
                            screen.device_control === 'live' ? 'var(--aq-success)'
                            : screen.device_control === 'unavailable' ? 'var(--aq-warn)'
                            : 'var(--aq-text-faint)' }}>
                            {screen.device_control === 'live' ? 'available'
                              : screen.device_control === 'unavailable' ? 'unavailable'
                              : 'awaiting report'}
                          </b>
                        </div>
                      </div>

                      {/* Device auth failure — the panel's requests are being
                          REJECTED (missing/invalid pairing token). Without this
                          banner the screen lives on its offline cache looking
                          healthy while nothing reaches the server (the
                          SUNSHI-01 incident, 2026-06-12). */}
                      {screen.device_auth_failed_at && (!screen.last_heartbeat || new Date(screen.device_auth_failed_at) > new Date(screen.last_heartbeat)) && (
                        <div style={{
                          display: 'flex', gap: 10, alignItems: 'flex-start',
                          padding: '10px 12px', marginBottom: 16, borderRadius: 8,
                          background: 'var(--aq-danger-soft)',
                          border: '1px solid var(--aq-danger-line)',
                          color: 'var(--aq-text)', fontSize: 12, lineHeight: 1.5,
                        }}>
                          <Icon name="alert" size={15} style={{ color: 'var(--aq-danger)', flexShrink: 0, marginTop: 1 }} />
                          <div>
                            <b>This TV needs re-pairing.</b> Its requests are being
                            rejected because it has no valid pairing token (last
                            rejected {lastSeenLabel(screen.device_auth_failed_at)}).
                            It may still display cached content, but nothing —
                            schedules, content updates, heartbeats — reaches it.
                            Open the kiosk's setup on the panel (or via Fully
                            Cloud remote control) and enter the site PIN.
                          </div>
                        </div>
                      )}
                      {/* Online but the JS interface isn't live → controls below
                          (and any on/off schedule) silently do nothing. Surface
                          it loudly so a lapsed PLUS licence isn't invisible. */}
                      {screen.is_online && screen.device_control === 'unavailable' && (
                        <div style={{
                          display: 'flex', gap: 10, alignItems: 'flex-start',
                          padding: '10px 12px', marginBottom: 16, borderRadius: 8,
                          background: 'color-mix(in srgb, var(--aq-warn) 12%, transparent)',
                          border: '1px solid color-mix(in srgb, var(--aq-warn) 35%, transparent)',
                          color: 'var(--aq-text)', fontSize: 12, lineHeight: 1.5,
                        }}>
                          <Icon name="alert" size={15} style={{ color: 'var(--aq-warn)', flexShrink: 0, marginTop: 1 }} />
                          <div>
                            <b>Device control unavailable.</b> This panel is online but
                            Fully's JavaScript Interface isn’t live, so screen on/off,
                            brightness, volume and scheduled power changes won’t take
                            effect. Check the panel’s <b>Fully PLUS licence</b> and that
                            <b> Settings → Advanced Web Settings → Enable JavaScript
                            Interface (PLUS)</b> is on.
                          </div>
                        </div>
                      )}

                      {/* Schedule — which saved on/off schedule this screen
                          follows. The library lives behind the Schedules
                          button on the Displays toolbar; this row makes the
                          assignment visible from the screen itself
                          (2026-06-12 feedback: "I only see this"). */}
                      <div style={{
                        display: 'flex', alignItems: 'center', gap: 10,
                        padding: '10px 12px', marginBottom: 18, borderRadius: 8,
                        background: 'var(--aq-surface-2)',
                      }}>
                        <Icon name="clock" size={14} style={{ color: 'var(--aq-text-dim)', flexShrink: 0 }} />
                        <div style={{ flex: 1, minWidth: 0 }}>
                          <div style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--aq-text)' }}>
                            {screen.schedule_name ? `Schedule: ${screen.schedule_name}` : 'No on/off schedule'}
                          </div>
                          <div style={{ fontSize: 11.5, color: 'var(--aq-text-faint)' }}>
                            {screen.schedule_name
                              ? 'Runs on the panel itself — keeps firing if the network drops.'
                              : 'This screen only turns on/off when someone does it manually.'}
                          </div>
                        </div>
                        {onOpenSchedules && (
                          <button
                            className="aq-popup-btn ghost"
                            onClick={() => { setSettingsMenuOpen(false); onOpenSchedules(); }}
                          >{screen.schedule_name ? 'Manage' : 'Set up'}</button>
                        )}
                      </div>

                      {/* Manual Fully device id — an installer/technician
                          fallback for when the panel's JS interface won't
                          auto-report it. Tucked behind a disclosure so
                          venue managers never meet "paste from Fully
                          Cloud" (2026-06-12 feedback). */}
                      <details style={{ marginBottom: 18 }}>
                        <summary style={{ fontSize: 12, color: 'var(--aq-text-faint)', cursor: 'pointer', userSelect: 'none' }}>Advanced hardware</summary>
                        <div style={{ marginTop: 10 }}>
                          <div style={{ fontSize: 12, color: 'var(--aq-text-dim)', marginBottom: 6 }}>Fully device id (paste from Fully Cloud — normally auto-reported)</div>
                          <div style={{ display: 'flex', gap: 8 }}>
                            <input
                              type="text" value={dcFid} placeholder="e.g. 3a9f…"
                              onChange={(e) => setDcFid(e.target.value)}
                              style={{ flex: 1, fontFamily: 'var(--aq-ff-mono)', fontSize: 12, padding: '8px 10px', background: 'var(--aq-surface-2)', border: '1px solid var(--aq-line)', borderRadius: 8, color: 'var(--aq-text)', outline: 'none' }}
                            />
                            <button className="aq-popup-btn ghost" onClick={saveFullyId} disabled={(dcFid || '') === (screen.fully_device_id || '')}>Save id</button>
                          </div>
                        </div>
                      </details>

                      <div style={{ marginBottom: 14 }}>
                        <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--aq-text-dim)', marginBottom: 6 }}><span>Brightness</span><b>{dcBright}%</b></div>
                        <input type="range" min="0" max="100" value={dcBright} style={{ width: '100%' }}
                          onChange={(e) => setDcBright(+e.target.value)}
                          onMouseUp={() => dcCmd('brightness', dcBright, 'Brightness')}
                          onTouchEnd={() => dcCmd('brightness', dcBright, 'Brightness')} />
                      </div>
                      <div style={{ marginBottom: 18 }}>
                        <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--aq-text-dim)', marginBottom: 6 }}><span>Volume</span><b>{dcVol}%</b></div>
                        <input type="range" min="0" max="100" value={dcVol} style={{ width: '100%' }}
                          onChange={(e) => setDcVol(+e.target.value)}
                          onMouseUp={() => dcCmd('volume', dcVol, 'Volume')}
                          onTouchEnd={() => dcCmd('volume', dcVol, 'Volume')} />
                      </div>

                      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
                        <button className="aq-popup-btn ghost" disabled={!!dcBusy} onClick={() => dcCmd('on', null, 'Screen on')}>Screen on</button>
                        <button className="aq-popup-btn ghost" disabled={!!dcBusy} onClick={() => dcCmd('off', null, 'Screen off')}>Screen off</button>
                        <button className="aq-popup-btn ghost" disabled={!!dcBusy} onClick={() => dcCmd('day', null, 'Day mode')} title="100% brightness · 100% volume">Day</button>
                        <button className="aq-popup-btn ghost" disabled={!!dcBusy} onClick={() => dcCmd('evening', null, 'Evening')} title="60% brightness · 50% volume">Evening</button>
                        <button className="aq-popup-btn ghost" disabled={!!dcBusy} onClick={() => dcCmd('night', null, 'Night')} title="25% brightness · muted">Night</button>
                      </div>
                      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
                        <button className="aq-popup-btn ghost" disabled={!!dcBusy} onClick={() => dcCmd('restart', null, 'Restart app')}>Restart app</button>
                        <button className="aq-popup-btn ghost" disabled={!!dcBusy} onClick={() => dcCmd('clearCache', null, 'Clear cache')}>Clear cache</button>
                        <button className="aq-popup-btn danger" disabled={!!dcBusy} onClick={() => dcCmd('reboot', null, 'Reboot device')}>Reboot device</button>
                      </div>
                    </div>
                    ) : (
                    <div className="x-card" style={{ padding: 20, marginBottom: 16 }}>
                      <h2 style={{ margin: '0 0 8px', fontFamily: 'var(--aq-ff-display)', fontSize: 16, fontWeight: 500, color: 'var(--aq-text)' }}>Live control</h2>
                      <p style={{ margin: 0, fontSize: 12.5, color: 'var(--aq-text-faint)', lineHeight: 1.5 }}>
                        No TV is paired to this slot yet, so there's nothing to control. Brightness, volume, power and reboot appear here once a device claims this tank on the kiosk (site PIN → pick content).
                      </p>
                    </div>
                    )}

                    <div className="x-card" style={{ padding: 20 }}>
                      <h2 style={{
                        margin: '0 0 16px', fontFamily: 'var(--aq-ff-display)',
                        fontSize: 16, fontWeight: 500, color: 'var(--aq-text)',
                      }}>Pairing &amp; hardware</h2>

                      {/* Screen code — moved here from Identity. It's a
                          device-level serial / pairing key, not part of
                          the screen's curator-facing identity, so it
                          belongs with the rest of the device lifecycle
                          actions. Renders as a full-width row (not the
                          grid 200px/1fr form-field pattern) so it lines
                          up with the action rows below. */}
                      <div style={{
                        padding: '14px 30px 14px 2px',
                        borderBottom: '1px solid var(--aq-line)',
                        display: 'flex',
                        alignItems: 'center',
                        gap: 16,
                      }}>
                        <div style={{ flex: 1, minWidth: 0 }}>
                          <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--aq-text)' }}>Screen code</div>
                          <div style={{ fontSize: 12, color: 'var(--aq-text-faint)', marginTop: 4, lineHeight: 1.45 }}>
                            Serial — identifies this device for pairing and support. Not editable.
                          </div>
                        </div>
                        <input
                          id="ss-code" type="text" readOnly
                          value={screen.screen_code || ''}
                          onFocus={(e) => e.currentTarget.select()}
                          style={{
                            fontFamily: 'var(--aq-ff-mono)', letterSpacing: '0.04em',
                            color: 'var(--aq-text-dim)',
                            background: 'transparent', border: 0, padding: 0,
                            fontSize: 13,
                            textAlign: 'right',
                            width: 'auto', minWidth: 100,
                            outline: 0,
                          }}
                        />
                      </div>

                      {/* Devices self-assign on the kiosk (site PIN → pick a
                          tank), so there's no CMS-side attach/replace by code.
                          When unpaired we just explain the model; when paired
                          we keep Retire (un-pair) since that frees the tank
                          without needing a code. */}
                      {!screen.paired_at && (
                        <div className="aq-popup-item aq-popup-item--two-line" style={{ cursor: 'default' }}>
                          <span className="aq-popup-item-title">No device assigned</span>
                          <span className="aq-popup-item-sub">A device claims this tank on the kiosk — it loads the display URL, enters the site PIN, and picks this content. Nothing to assign from here.</span>
                        </div>
                      )}
                      {screen.paired_at && (
                        <>
                          {!confirmRetire ? (
                            <button
                              className="aq-popup-item aq-popup-item--two-line aq-popup-item--danger"
                              onClick={() => setConfirmRetire(true)}
                            >
                              <span className="aq-popup-item-title">Retire the TV</span>
                              <span className="aq-popup-item-sub">Un-pair the TV completely. Zone + content stay, no replacement assigned.</span>
                            </button>
                          ) : (
                            <div className="aq-popup-confirm">
                              <span>Un-pair the TV? Zone, name, species + analytics stay. The TV drops back to its pairing screen.</span>
                              <button
                                type="button" className="aq-popup-btn danger"
                                onClick={async () => {
                                  setConfirmRetire(false);
                                  try {
                                    await apiFetch(`/api/screens/${screen.id}/retire-device`, { method: 'POST' });
                                    if (window.toast) window.toast(`${screen.screen_code} retired — TV will reboot to pairing on next heartbeat.`);
                                    if (onChanged) onChanged();
                                  } catch (e) {
                                    if (window.toast) window.toast(`Retire failed: ${e.message}`, 'error');
                                  }
                                }}
                              >Retire</button>
                              <button
                                type="button" className="aq-popup-btn ghost"
                                onClick={() => setConfirmRetire(false)}
                              >Cancel</button>
                            </div>
                          )}
                        </>
                      )}
                      <button
                        className="aq-popup-item aq-popup-item--two-line"
                        onClick={() => { setSettingsMenuOpen(false); setSwapMode('content'); }}
                      >
                        <span className="aq-popup-item-title">Move content to a different screen…</span>
                        <span className="aq-popup-item-sub">Re-route this zone's content to a sibling screen role, with or without the device.</span>
                      </button>

                      {/* Migrate a legacy screen onto secure pair-by-code.
                          Only for legacy rows (no pairing flags). Folded in
                          from the old Maintenance tab. */}
                      {!screen.paired_at && !screen.paired_via_code && (
                        !confirmMigrate ? (
                          <button
                            className="aq-popup-item aq-popup-item--two-line"
                            onClick={() => setConfirmMigrate(true)}
                          >
                            <span className="aq-popup-item-title">Migrate to secure pairing</span>
                            <span className="aq-popup-item-sub">Force this legacy screen onto the pair-by-code flow. Zone, content + history are kept.</span>
                          </button>
                        ) : (
                          <div className="aq-popup-confirm">
                            <span>Force device re-pair on next heartbeat? Zone, content + history preserved.</span>
                            <button type="button" className="aq-popup-btn primary" onClick={async () => {
                              setConfirmMigrate(false);
                              try {
                                await apiFetch(`/api/screens/${screen.id}/force-repair`, { method: 'POST' });
                                if (window.toast) window.toast('Migration started — device will show a pairing code shortly.');
                                if (onChanged) onChanged();
                              } catch (e) {
                                if (window.toast) window.toast(`Force-repair failed: ${e.message}`, 'error');
                              }
                            }}>Migrate</button>
                            <button type="button" className="aq-popup-btn ghost" onClick={() => setConfirmMigrate(false)}>Cancel</button>
                          </div>
                        )
                      )}
                      {/* Delete the screen slot entirely. Distinct from
                          "Retire the TV" (which only un-pairs a device and
                          keeps the slot): this removes the screen record
                          itself. The main reason it's here is never-paired
                          slots — they have no device to retire, so without
                          this there was no way to remove them at all
                          (sunshine-02 case). Copy warns when a device is
                          still attached, since the backend hard-deletes
                          (cascade) and un-pairs it too. */}
                      {!confirmDelete ? (
                        <button
                          className="aq-popup-item aq-popup-item--two-line aq-popup-item--danger"
                          onClick={() => setConfirmDelete(true)}
                        >
                          <span className="aq-popup-item-title">Delete this screen slot</span>
                          <span className="aq-popup-item-sub">
                            {screen.paired_at
                              ? 'Removes the screen entirely — also un-pairs the attached TV. Species + history are lost.'
                              : 'Removes this empty screen slot entirely. Nothing is paired to it.'}
                          </span>
                        </button>
                      ) : (
                        <div className="aq-popup-confirm">
                          <span>
                            {screen.paired_at
                              ? `Delete ${screen.screen_code} for good? The attached TV is un-paired and the slot, species + analytics are removed. This can't be undone.`
                              : `Delete the empty slot ${screen.screen_code}? This can't be undone.`}
                          </span>
                          <button
                            type="button" className="aq-popup-btn danger"
                            onClick={async () => {
                              setConfirmDelete(false);
                              try {
                                await apiFetch(`/api/screens/${screen.id}`, { method: 'DELETE' });
                                if (window.toast) window.toast(`${screen.screen_code} deleted.`);
                                setSettingsMenuOpen(false);
                                if (onClose) onClose();
                                if (onChanged) onChanged();
                              } catch (e) {
                                if (window.toast) window.toast(`Delete failed: ${e.message}`, 'error');
                              }
                            }}
                          >Delete</button>
                          <button
                            type="button" className="aq-popup-btn ghost"
                            onClick={() => setConfirmDelete(false)}
                          >Cancel</button>
                        </div>
                      )}
                    </div>
                    </>)}

                    {/* Restart — available to EVERY role (the only action
                        operators had on the old Maintenance tab). Only
                        meaningful when a device is attached. */}
                    {screen.paired_at && (
                      <div className="x-card" style={{ padding: 20, marginTop: 16 }}>
                        <h2 style={{ margin: '0 0 12px', fontFamily: 'var(--aq-ff-display)', fontSize: 16, fontWeight: 500, color: 'var(--aq-text)' }}>Restart</h2>
                        {!confirmRestart ? (
                          <button className="aq-popup-item" onClick={() => setConfirmRestart(true)}>Request restart</button>
                        ) : (
                          <div className="aq-popup-confirm">
                            <span>Reload the kiosk on its next heartbeat?</span>
                            <button type="button" className="aq-popup-btn primary" onClick={async () => {
                              setConfirmRestart(false);
                              try {
                                const r = await apiFetch(`/api/screens/${screen.id}/restart`, { method: 'POST' });
                                if (window.toast) window.toast(r && r.message ? r.message : 'Restart queued.');
                              } catch (e) {
                                if (window.toast) window.toast(`Restart failed: ${e.message}`, 'error');
                              }
                            }}>Restart</button>
                            <button type="button" className="aq-popup-btn ghost" onClick={() => setConfirmRestart(false)}>Cancel</button>
                          </div>
                        )}
                      </div>
                    )}
                    </div>
                  )}
                  </div>{/* /right pane */}
                </div>
                {/* Sticky footer with the primary commit. Stays put
                    even when the body scrolls — Apple Settings, Linear
                    settings, GitHub settings all use this pattern. */}
                {(Auth.canManageOrg() || Auth.isSiteManager()) && (
                  <footer className="aq-popup-footer">
                    {/* Error surfaced in the footer so a save failure
                        (validation OR API) is visible no matter which
                        tab the curator was on when they hit Save. */}
                    {ssErr && (
                      <div style={{
                        flex: 1, fontSize: 12,
                        color: 'var(--aq-danger)',
                        padding: '6px 10px',
                        background: 'color-mix(in srgb, var(--aq-danger) 10%, transparent)',
                        border: '1px solid color-mix(in srgb, var(--aq-danger) 30%, transparent)',
                        borderRadius: 6,
                        marginRight: 8,
                      }}>{ssErr}</div>
                    )}
                    <button
                      type="button"
                      className="aq-popup-btn ghost"
                      onClick={() => setSettingsMenuOpen(false)}
                      disabled={ssBusy}
                    >Cancel</button>
                    <button
                      type="button"
                      className="aq-popup-btn primary"
                      onClick={saveScreenSettings}
                      disabled={ssBusy}
                    >{ssBusy ? 'Saving…' : 'Save changes'}</button>
                  </footer>
                )}
              </div>
            </div>
          ), document.body)}
        </div>
      </div>

      {/* Settings / Hardware / Playback-URL panels removed per May
          2026 follow-up. The preview now carries status + last-seen
          as overlays; every other reference field (auto-rotate,
          touch, display size, orientation, template, zone theme,
          playback URL) is editable inside the gear settings popup
          which is where curators end up if they care about those
          values. Offline-cache section below stays because it
          carries actionable status (priority pending / safe to
          disconnect). */}

      {/* Offline cache section — only shown if the kiosk has reported any
          cache stats yet. Older kiosks (or freshly paired screens that
          haven't run the offline-first stack) won't have these fields,
          and we'd rather hide the section than display a row of em-dashes. */}
      {(screen.total_assets || screen.cached_assets || screen.cache_synced_at) ? (
        <section className="ds-section">
          <h4>Offline cache</h4>
          <div className="ds-kv-grid">
            <div>
              <span>Cached</span>
              <b>
                {screen.cached_assets || 0}
                {screen.total_assets ? ` / ${screen.total_assets}` : ''}
                {screen.total_assets ? ` (${Math.round((screen.cached_assets || 0) / screen.total_assets * 100)}%)` : ''}
              </b>
            </div>
            <div>
              <span>Disk used</span>
              <b>{formatBytesShort(screen.cached_bytes || 0)}</b>
            </div>
            <div>
              <span>Priority pending</span>
              <b style={{ color: (screen.priority_pending || 0) > 0 ? 'var(--aq-warn)' : 'var(--aq-success)' }}>
                {screen.priority_pending || 0}
              </b>
            </div>
            <div>
              <span>Upload backlog</span>
              <b style={{ color: (screen.queue_pending || 0) > 0 ? 'var(--aq-warn)' : 'inherit' }}>
                {screen.queue_pending || 0}
              </b>
            </div>
            <div>
              <span>Last sync</span>
              <b>{screen.cache_synced_at ? lastSeenLabel(screen.cache_synced_at) : 'never'}</b>
            </div>
            <div>
              <span>Network</span>
              <b style={{ fontSize: 12 }}>{screen.network_label || '—'}</b>
            </div>
          </div>
          <div style={{ fontSize: 11, color: 'var(--aq-text-3)', marginTop: 8 }}>
            {/* Three honest states (2026-06-12): done / downloading / cache
                not running. The old two-state version showed "still
                downloading" forever on panels whose webview can't run the
                offline cache (no service worker) — nothing was downloading
                and nothing ever would. */}
            {(screen.priority_pending || 0) === 0 && (screen.cached_assets || 0) > 0
              ? '✓ Safe to disconnect — all priority assets cached locally.'
              : (screen.priority_pending || 0) > 0
                ? 'Priority assets still downloading; keep the kiosk online until this clears.'
                : 'Offline media cache isn\u2019t running on this panel — content streams live and needs the network. On/off schedules still work offline.'}
          </div>
        </section>
      ) : null}

      {/* Playback URL / Display engine sections removed per May 2026
          follow-up — they were debug fields rather than user-facing
          data. The actual URL is one click away via the Launch
          kiosk action above. */}

      {/* Inline edit modal — opens the same EditScreenModal used by
          the per-screen drilldown. onSaved fires the parent's
          onChanged so the detail panel re-renders with the new
          fields without a manual reload. */}
      {window.EditScreenModal && (
        <window.EditScreenModal
          open={editOpen}
          screen={screen}
          zonesList={zonesList}
          onClose={() => setEditOpen(false)}
          onSaved={() => { setEditOpen(false); if (onChanged) onChanged(); }}
        />
      )}
      <SwapScreenModal
        open={!!swapMode}
        screen={screen}
        initialMode={swapMode}
        onClose={() => setSwapMode(null)}
        onSwapped={() => { setSwapMode(null); if (onChanged) onChanged(); }}
      />
      {/* Manage species — the same modal the bulk-select bar opens,
          driven with a single-screen ID list. The modal detects
          isSingleScreen mode and renders the current assignments
          with remove buttons at the top, plus an add-picker below. */}
      <BulkAssignSpeciesModal
        open={manageSpeciesOpen}
        screenIds={manageSpeciesOpen ? [screen.id] : []}
        onClose={() => setManageSpeciesOpen(false)}
        onDone={() => { setManageSpeciesOpen(false); if (onChanged) onChanged(); }}
      />
      {/* Hover-zoom popup — PERSISTENTLY MOUNTED whenever the detail
          panel is showing an online screen, so the iframe inside
          loads ONCE alongside the small preview and is ready to
          show instantly on hover. Visibility flips on hoverPos;
          width/height/iframe scale stay constant (using the default
          750px destination size) so the kiosk inside doesn't re-
          render or re-fetch when we show/hide. The FLIP morph
          transform is applied inline based on hoverPos's source
          rect, then the spring animation in useLayoutEffect above
          runs from that transform to identity. */}
      {screen.status === 'online' && (() => {
        const LONG_SIDE = 750;
        const destW = portrait ? Math.round(LONG_SIDE * 9 / 16) : LONG_SIDE;
        const destH = portrait ? LONG_SIDE : Math.round(LONG_SIDE * 9 / 16);
        // When hover is active, position uses hoverPos. When idle,
        // stage the popup off-screen at its destination size so the
        // iframe inside doesn't resize on show — kiosks resizing
        // reflows their CSS container queries which is jarring.
        const visible = !!hoverPos;
        const left = visible ? hoverPos.left : -10000;
        const top  = visible ? hoverPos.top  : -10000;
        const w    = visible ? hoverPos.w    : destW;
        const h    = visible ? hoverPos.h    : destH;
        // FLIP transform — only applies when visible. While hidden
        // we let the popup sit at identity off-screen so cancelling
        // a previous animation doesn't snap visibly.
        const flip = visible
          ? `translate(${hoverPos.srcLeft - left}px, ${hoverPos.srcTop - top}px) `
            + `scale(${hoverPos.srcW / w}, ${hoverPos.srcH / h})`
          : 'none';
        // Iframe scale uses the destination size, NOT the current
        // (possibly off-screen) w/h. The iframe renders the kiosk
        // at design res (1920×1080) and CSS-scales it down. Keeping
        // this constant means the kiosk inside never reflows.
        const iframeScale = Math.max(
          destW / (portrait ? 1080 : 1920),
          destH / (portrait ? 1920 : 1080)
        );
        return (
          <div
            ref={popupRef}
            className="ds-hover-popup"
            style={{
              position: 'fixed',
              left, top, width: w, height: h,
              zIndex: 99999,
              background: '#06090f',
              border: '1px solid rgba(255,255,255,0.18)',
              borderRadius: 12,
              overflow: 'hidden',
              pointerEvents: 'none',
              boxShadow:
                '0 24px 64px -8px rgba(0, 0, 0, 0.65), ' +
                '0 8px 18px -4px rgba(0, 0, 0, 0.45), ' +
                '0 0 0 0.5px rgba(255, 255, 255, 0.04) inset',
              transformOrigin: '0 0',
              transform: flip,
              opacity: visible ? 0 : 0,
              visibility: visible ? 'visible' : 'hidden',
              isolation: 'isolate',
            }}
            aria-hidden="true"
          >
            <iframe
              key={`hover-${screen.screen_code}-${portrait ? 'p' : 'l'}`}
              src={`/display/${encodeURIComponent(screen.screen_code)}?preview=frame`}
              title={`Enlarged kiosk preview · ${screen.zone_name}`}
              sandbox="allow-scripts allow-same-origin"
              allow="autoplay"
              style={{
                position: 'absolute', top: 0, left: 0,
                width:  portrait ? '1080px' : '1920px',
                height: portrait ? '1920px' : '1080px',
                transform: `scale(${iframeScale})`,
                transformOrigin: 'top left',
                border: 0, display: 'block',
                pointerEvents: 'none',
                background: '#001a3d',
              }}
            />
          </div>
        );
      })()}
    </aside>
  );
}

/* Screen size presets — display diagonal → native pixel grid the
   kiosk renders at. Numbers are the manufacturers' typical native
   resolutions; we send them in the POST so the kiosk has accurate
   physical scale to work with. Custom = curator wants a non-stock
   size; we leave resolution_width/_height blank and the server
   falls back to 1920×1080 (migration v26 default). */
const SCREEN_SIZE_PRESETS = [
  { id: '24',   label: '24" Full HD',          width: 1920, height: 1080 },
  { id: '32',   label: '32" Full HD',          width: 1920, height: 1080 },
  { id: '43',   label: '43" Full HD',          width: 1920, height: 1080 },
  { id: '55',   label: '55" 4K UHD',           width: 3840, height: 2160 },
  { id: '65',   label: '65" 4K UHD',           width: 3840, height: 2160 },
  { id: '75',   label: '75" 4K UHD',           width: 3840, height: 2160 },
  { id: '85',   label: '85" 4K UHD',           width: 3840, height: 2160 },
  { id: 'custom', label: 'Custom / unknown',   width: null, height: null },
];

/* SetupScreenModal — guided first-time setup for an EMPTY allocated slot
   (Jun 2026). Allocated screens land blank in the "Unassigned" zone; this
   walks the site manager through the basics (give it a home zone, a name,
   size, orientation) and then hands straight off to the species picker so
   the slot ends up Prepared — instead of hunting through the settings
   popup field by field. Opened by clicking an Empty ghost tile. */
function SetupScreenModal({ open, screen, zonesList, onClose, onSavedBasics }) {
  const [name, setName]     = useState('');
  const [zoneId, setZoneId] = useState('');
  const [sizeId, setSizeId] = useState('43');
  const [orient, setOrient] = useState('landscape');
  const [touch, setTouch]   = useState(false);
  const [busy, setBusy]     = useState(false);
  const [err, setErr]       = useState(null);

  useEffect(() => {
    if (!open || !screen) return;
    setName(screen.name || '');
    setZoneId(screen.zone_id || '');
    setSizeId(dspResolvePresetId(screen));
    setOrient(screen.orientation || 'landscape');
    setTouch(!!screen.is_touch);
    setBusy(false); setErr(null);
  }, [open, screen && screen.id]);

  const zoneChoices = useMemo(() => {
    if (!screen || !Array.isArray(zonesList)) return [];
    return zonesList.filter((z) => z.site_id === screen.site_id);
  }, [screen, zonesList]);

  async function save(goSpecies) {
    if (!name.trim()) { setErr('Give this screen a name.'); return; }
    const preset = DSP_SIZE_PRESETS.find((p) => p.id === sizeId) || null;
    let w = preset ? preset.width : null;
    let h = preset ? preset.height : null;
    if (orient === 'portrait' && w && h && w > h) { [w, h] = [h, w]; }
    setBusy(true); setErr(null);
    try {
      await apiFetch(`/api/screens/${screen.id}`, {
        method: 'PUT',
        body: JSON.stringify({
          name: name.trim(),
          orientation: orient,
          is_touch: touch ? 1 : 0,
          ...(zoneId && zoneId !== screen.zone_id ? { zone_id: zoneId } : {}),
          ...(preset && preset.id !== 'custom' && w && h
            ? { resolution_width: w, resolution_height: h, display_size_inches: parseInt(preset.id, 10) }
            : {}),
        }),
      });
      if (window.toast) (window.toast.success ? window.toast.success('Screen set up') : window.toast('Screen set up'));
      if (onSavedBasics) onSavedBasics(screen, goSpecies);
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(false);
    }
  }

  if (!open || !screen) return null;
  return ReactDOM.createPortal((
    <div
      className="aq-popup-modal"
      role="dialog"
      aria-modal="true"
      aria-label={`Set up ${screen.screen_code}`}
      onClick={(e) => { if (e.target.classList.contains('aq-popup-modal')) onClose(); }}
    >
      <div className="aq-popup-card aq-popup-card--wide ds-setup-card">
        <button className="aq-popup-close" aria-label="Close" onClick={onClose}>×</button>
        <header className="aq-popup-header">
          <h2>Set up this screen</h2>
          <p>{screen.screen_code} · give it a home, then add its content</p>
        </header>
        <div className="aq-popup-body ds-setup-body">
            <div className="x-form-field">
              <div className="x-form-label">Screen name<small>What this tank is called, e.g. "Main reef".</small></div>
              <div className="x-form-control">
                <input className="x-input" type="text" autoFocus value={name}
                  onChange={(e) => setName(e.target.value)} placeholder="e.g. Main reef tank" />
              </div>
            </div>

            <div className="x-form-field">
              <div className="x-form-label">Zone<small>Move it out of "Unassigned" into the exhibit area it lives in.</small></div>
              <div className="x-form-control">
                <select className="x-input" value={zoneId} onChange={(e) => setZoneId(e.target.value)}>
                  {zoneChoices.length === 0 && <option value="">— no zones —</option>}
                  {zoneChoices.map((z) => <option key={z.id} value={z.id}>{z.name}</option>)}
                </select>
              </div>
            </div>

            <div className="x-form-field">
              <div className="x-form-label">Display size<small>Used to render content at the right pixel grid.</small></div>
              <div className="x-form-control">
                <select className="x-input" value={sizeId} onChange={(e) => setSizeId(e.target.value)}>
                  {DSP_SIZE_PRESETS.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
                </select>
              </div>
            </div>

            <div className="x-form-field">
              <div className="x-form-label">Orientation</div>
              <div className="x-form-control">
                <div className="aq-popup-segmented" style={{ alignSelf: 'flex-start' }}>
                  <button type="button" className={orient === 'landscape' ? 'is-active' : ''} onClick={() => setOrient('landscape')}>Landscape</button>
                  <button type="button" className={orient === 'portrait' ? 'is-active' : ''} onClick={() => setOrient('portrait')}>Portrait</button>
                </div>
              </div>
            </div>

            <div className="x-form-field">
              <div className="x-form-label">Touch input<small>Enable for touchpool / interactive panels.</small></div>
              <div className="x-form-control">
                <button type="button" className={`aq-popup-toggle ${touch ? 'is-on' : ''}`}
                  onClick={() => setTouch((v) => !v)} aria-pressed={touch} style={{ alignSelf: 'flex-start' }}><span /></button>
              </div>
            </div>

            {err && <div className="aq-popup-error" style={{ marginTop: 12 }}>{err}</div>}
        </div>
        <footer className="aq-popup-footer">
          <button type="button" className="aq-popup-btn ghost" onClick={onClose} disabled={busy}>Cancel</button>
          <button type="button" className="aq-popup-btn" onClick={() => save(false)} disabled={busy}>Save basics</button>
          <button type="button" className="aq-popup-btn primary" onClick={() => save(true)} disabled={busy}>
            {busy ? 'Saving…' : 'Save & add species →'}
          </button>
        </footer>
      </div>
    </div>
  ), document.body);
}

/* Register-screen modal — supports single + bulk add (1-20 screens at
   once). Resolution is now explicit per size preset; bulk runs auto-
   number the screen codes so the curator doesn't have to type
   REEF-01, REEF-02, REEF-03 by hand. Inline "+ New zone" added so
   they can create a zone without leaving the modal (Wave A item
   per user feedback "Zone, allow add new directly inside screens"). */
function RegisterScreenModal({ open, zonesList, onClose, onCreated }) {
  const [name, setName]       = useState('');
  const [code, setCode]       = useState('');
  const [zoneId, setZoneId]   = useState('');
  const [orient, setOrient]   = useState('landscape');
  /* No default size — force the curator to pick. Per May 2026
     follow-up: defaulting to 32" was confusing; users assumed the
     value was correct and didn't realise they needed to choose. */
  const [sizeId, setSizeId]   = useState('');
  const [quantity, setQuantity] = useState(1);
  const [zoneModalOpen, setZoneModalOpen] = useState(false);
  const [busy, setBusy]       = useState(false);
  const [err, setErr]         = useState(null);

  useEffect(() => {
    if (!open) return;
    setName(''); setCode(''); setOrient('landscape');
    setSizeId(''); setQuantity(1);
    setZoneModalOpen(false);
    setZoneId(zonesList && zonesList[0] ? zonesList[0].id : '');
    setBusy(false); setErr(null);
  }, [open, zonesList]);

  useEffect(() => {
    if (!open) return;
    const onKey = (e) => {
      // Don't close the parent register modal when the curator is
      // inside the nested zone-create modal — Escape should close
      // the inner modal first. The nested modal owns its own Esc.
      if (e.key === 'Escape' && !zoneModalOpen) onClose && onClose();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open, onClose, zoneModalOpen]);

  // Bulk auto-numbering: REEF → REEF-01, REEF-02, … REEF-N.
  // If the curator typed a code ending in -NN they want, we start
  // from there; otherwise we append -01..-NN to whatever they typed.
  function expandCodes(baseCode, qty) {
    const trimmed = baseCode.trim().toUpperCase();
    if (qty <= 1) return [trimmed];
    // Strip a trailing -NN if present so re-numbering doesn't compound
    // (e.g. typing "REEF-05" with qty 3 → REEF-05, REEF-06, REEF-07).
    const m = trimmed.match(/^(.+?)-(\d{1,4})$/);
    const stem = m ? m[1] : trimmed;
    const startAt = m ? parseInt(m[2], 10) : 1;
    const out = [];
    for (let i = 0; i < qty; i++) {
      const n = String(startAt + i).padStart(2, '0');
      out.push(`${stem}-${n}`);
    }
    return out;
  }

  async function submit(e) {
    e && e.preventDefault();
    if (!name.trim()) { setErr('Give this tank a name.'); return; }
    if (!zoneId)       { setErr('Pick a zone for it.'); return; }
    const qty = Math.max(1, Math.min(20, parseInt(quantity, 10) || 1));

    // Size is OPTIONAL now — it's a device property, and the device
    // reports its own resolution when it claims the tank on the kiosk.
    // Requiring it here silently blocked "Add" when nothing was picked
    // (the reported "Add does nothing" bug). When unset we send no
    // resolution at all.
    const sizePreset = SCREEN_SIZE_PRESETS.find((s) => s.id === sizeId) || null;
    let w = sizePreset ? sizePreset.width : null;
    let h = sizePreset ? sizePreset.height : null;
    // Portrait swaps the native dimensions so the kiosk knows the
    // physical screen is rotated 90°.
    if (orient === 'portrait' && w && h && w > h) { [w, h] = [h, w]; }

    setBusy(true); setErr(null);
    try {
      // POST one-by-one. The server doesn't have a bulk endpoint and
      // the volume is bounded (<=20), so sequential is fine. The server
      // auto-generates screen_code per request since we don't supply
      // one — sequential requests get sequential codes because each
      // INSERT lands before the next request starts.
      const failures = [];
      for (let i = 0; i < qty; i++) {
        // Auto-name: if curator's name has no number and qty > 1,
        // append #N so each row reads sensibly in lists.
        const screenName = qty > 1
          ? `${name.trim()} #${String(i + 1).padStart(2, '0')}`
          : name.trim();
        try {
          await apiFetch('/api/screens', {
            method: 'POST',
            body: JSON.stringify({
              zone_id: zoneId,
              name: screenName,
              // screen_code omitted — server auto-generates from site slug
              orientation: orient,
              ...(w && h ? { resolution_width: w, resolution_height: h } : {}),
              ...(sizePreset && sizePreset.id !== 'custom' ? { display_size_inches: parseInt(sizePreset.id, 10) } : {}),
            }),
          });
        } catch (e2) {
          failures.push(`${screenName}: ${e2.message}`);
        }
      }
      if (failures.length > 0 && failures.length === qty) {
        // Total failure — surface to the curator without dismissing.
        setErr(failures.slice(0, 3).join(' · '));
        return;
      }
      const created = qty - failures.length;
      window.toast && window.toast.success(
        created === 1 ? 'Screen registered'
        : `${created} screens registered${failures.length ? ` (${failures.length} failed)` : ''}`
      );
      if (onCreated) onCreated();
      onClose && onClose();
    } catch (e2) {
      setErr(e2.message);
    } finally {
      setBusy(false);
    }
  }

  if (!open) return null;
  return (
    <div
      onClick={(e) => { if (e.target === e.currentTarget) onClose && onClose(); }}
      style={{
        // zIndex bumped 220 → 1000 alongside EditScreenModal to match
        // the project's modal convention. Same root cause as feedback
        // a8a36ad3 — under-z'd modal lets tile snapshots / status
        // pills bleed through onto the form.
        position: 'fixed', inset: 0, zIndex: 1000,
        background: 'rgba(6, 7, 10, 0.78)', backdropFilter: 'blur(6px)',
        display: 'grid', placeItems: 'center', padding: 24,
      }}
    >
      <form
        onSubmit={submit}
        style={{
          width: 'min(520px, 100%)',
          background: 'var(--aq-surface)',
          border: '1px solid var(--aq-line)',
          borderRadius: 14, boxShadow: 'var(--aq-shadow-2)',
          display: 'flex', flexDirection: 'column', maxHeight: '82vh',
        }}
      >
        <header style={{
          padding: '14px 18px', borderBottom: '1px solid var(--aq-line)',
          display: 'flex', alignItems: 'center', gap: 10,
        }}>
          <div style={{
            width: 28, height: 28, borderRadius: 8,
            background: 'var(--aq-accent-soft)', color: 'var(--aq-accent)',
            display: 'grid', placeItems: 'center',
          }}><Icon name="monitor" size={14} /></div>
          <div style={{ flex: 1 }}>
            <div style={{ fontFamily: 'var(--aq-ff-display)', fontSize: 14.5, color: 'var(--aq-text)', fontWeight: 500 }}>
              Add a screen
            </div>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-faint)' }}>
              You don't need the physical TV yet — pair the device later.
            </div>
          </div>
          <button type="button" className="aq-icon-btn" onClick={onClose}><Icon name="close" size={13} /></button>
        </header>

        <div style={{ overflowY: 'auto', padding: 16, display: 'flex', flexDirection: 'column', gap: 14 }}>
          <label style={{ display: 'block' }}>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-dim)', marginBottom: 5 }}>Screen name</div>
            <input
              type="text" required autoFocus
              value={name} onChange={(e) => setName(e.target.value)}
              placeholder="Reef tank — north wall"
              style={{
                width: '100%', padding: '8px 10px', fontSize: 13,
                background: 'var(--aq-surface-2)', border: '1px solid var(--aq-line)',
                borderRadius: 7, color: 'var(--aq-text)', font: 'inherit', outline: 0,
              }}
            />
          </label>

          {/* Screen code input removed — server auto-generates from
              the site slug + next available number. Curators don't
              need to think about codes; they're just URL labels.
              See generateScreenCode in src/routes/screens.js. */}

          <label style={{ display: 'block' }}>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-dim)', marginBottom: 5, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
              <span>Zone</span>
              {/* Inline "+ New zone" — opens the existing
                  CreateZoneModal (window.CreateZoneModal). On
                  created, we re-fetch the zones list via onCreated
                  bubbling up and the new zone appears here. */}
              {window.CreateZoneModal && (
                <button
                  type="button"
                  onClick={() => setZoneModalOpen(true)}
                  style={{
                    background: 'transparent', border: 0, padding: 0,
                    color: 'var(--aq-accent)', fontSize: 11.5,
                    cursor: 'pointer', fontFamily: 'inherit',
                  }}
                >+ Create new zone</button>
              )}
            </div>
            <select
              required
              value={zoneId}
              onChange={(e) => {
                /* The last option is a sentinel that opens the
                   "Create new zone" modal. Picking it does NOT
                   stick — we reset the select to whatever was
                   previously selected (or empty) and pop the modal.
                   Inline create lets the curator add a zone without
                   bouncing out of this flow. */
                if (e.target.value === '__new__') {
                  setZoneModalOpen(true);
                  e.target.value = zoneId || '';
                  return;
                }
                setZoneId(e.target.value);
              }}
              style={{
                width: '100%', padding: '8px 10px', fontSize: 13,
                background: 'var(--aq-surface-2)', border: '1px solid var(--aq-line)',
                borderRadius: 7, color: 'var(--aq-text)', font: 'inherit', outline: 0,
              }}
            >
              {(!zonesList || zonesList.length === 0) && <option value="">— no zones — create one first</option>}
              {(zonesList || []).map((z) => (
                <option key={z.id} value={z.id}>
                  {z.name}{z.site_name ? ` · ${z.site_name}` : ''}
                </option>
              ))}
              {window.CreateZoneModal && (
                <option value="__new__">+ Create new zone…</option>
              )}
            </select>
          </label>

          <label style={{ display: 'block' }}>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-dim)', marginBottom: 5 }}>
              Screen size
            </div>
            <select
              value={sizeId}
              onChange={(e) => setSizeId(e.target.value)}
              style={{
                width: '100%', padding: '8px 10px', fontSize: 13,
                background: 'var(--aq-surface-2)', border: '1px solid var(--aq-line)',
                borderRadius: 7,
                color: sizeId ? 'var(--aq-text)' : 'var(--aq-text-faint)',
                font: 'inherit', outline: 0,
              }}
            >
              <option value="" disabled>Select size…</option>
              {SCREEN_SIZE_PRESETS.map((s) => (
                <option key={s.id} value={s.id}>{s.label}</option>
              ))}
            </select>
          </label>

          <div>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-dim)', marginBottom: 5 }}>Orientation</div>
            <div style={{ display: 'flex', gap: 6 }}>
              {[['landscape', 'Landscape'], ['portrait', 'Portrait']].map(([id, label]) => (
                <button
                  type="button" key={id}
                  onClick={() => setOrient(id)}
                  style={{
                    flex: 1, padding: '8px 10px', borderRadius: 7,
                    background: orient === id ? 'var(--aq-accent)' : 'transparent',
                    color: orient === id ? '#06090f' : 'var(--aq-text-dim)',
                    border: `1px solid ${orient === id ? 'var(--aq-accent)' : 'var(--aq-line)'}`,
                    fontSize: 12, fontWeight: 500, cursor: 'pointer',
                    fontFamily: 'inherit',
                  }}
                >{label}</button>
              ))}
            </div>
          </div>

          {/* Quantity — for adding e.g. 5 identical screens to a
              zone in one go. Codes auto-number from whatever stem
              was typed (REEF → REEF-01, REEF-02, …). Capped at 20
              per submit to keep the sequential POST loop bounded. */}
          <label style={{ display: 'block' }}>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-dim)', marginBottom: 5 }}>
              Quantity <span style={{ color: 'var(--aq-text-faint)' }}>
                {quantity > 1 ? `(bulk — ${quantity} identical screens)` : '(1 = single screen)'}
              </span>
            </div>
            <input
              type="number" min={1} max={20}
              value={quantity}
              onChange={(e) => setQuantity(Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 1)))}
              style={{
                width: 100, padding: '8px 10px', fontSize: 13,
                background: 'var(--aq-surface-2)', border: '1px solid var(--aq-line)',
                borderRadius: 7, color: 'var(--aq-text)', font: 'inherit', outline: 0,
              }}
            />
          </label>

          {err && (
            <div style={{
              padding: '8px 10px', borderRadius: 6,
              background: 'color-mix(in srgb, var(--aq-danger) 12%, transparent)',
              color: 'var(--aq-danger)', fontSize: 12,
            }}>{err}</div>
          )}
        </div>

        <footer style={{
          padding: '10px 16px', borderTop: '1px solid var(--aq-line)',
          background: 'var(--aq-surface-2)',
          display: 'flex', justifyContent: 'flex-end', gap: 8,
        }}>
          <button type="button" className="x-btn ghost" onClick={onClose}>Cancel</button>
          <button
            type="submit" className="x-btn"
            disabled={busy || !name.trim() || !code.trim() || !zoneId}
          >{busy
            ? (quantity > 1 ? `Registering ${quantity}…` : 'Registering…')
            : (quantity > 1 ? `Register ${quantity} screens` : 'Register screen')
          }</button>
        </footer>
      </form>
      {/* Nested CreateZoneModal — fires when curator clicks
          "+ Create new zone" above. On created, onCreated bubbles
          up to the Displays page which re-fetches the zones list;
          we close the inner modal so the picker is visible again
          (the new zone will be at the top of the freshly-loaded
          list and we can't auto-select it without coordinating
          state, but it's an extra click rather than a lost step). */}
      {window.CreateZoneModal && (
        <window.CreateZoneModal
          open={zoneModalOpen}
          onClose={() => setZoneModalOpen(false)}
          onCreated={() => {
            setZoneModalOpen(false);
            // Bubble up — DisplayScreensScreen owns the zones list
            // and a refresh will repopulate the picker.
            if (onCreated) onCreated();
          }}
        />
      )}
    </div>
  );
}

/* Bulk-assign species modal. Lists the org's species, lets the
   curator multi-select, then POSTs /api/screens/bulk/species with
   { species_ids, screen_ids } — the backend inserts a row in
   screen_species per (screen, species) pair (ON CONFLICT DO
   NOTHING so re-runs are safe). Search input filters by common
   or scientific name. */
function BulkAssignSpeciesModal({ open, screenIds, onClose, onDone }) {
  /* Renamed in UI to "Manage species" — feedback e570afe3 (Emma,
     2026-05-12): "inside of assign species, it needs to be manage
     species for a screen, i can actually see what's on there and
     adjust remove or add". When exactly one screen is selected we
     fetch its current assignments and render them at the top with
     inline Remove buttons. With >1 screens the existing add-only
     bulk experience is preserved (assignments differ per screen,
     no useful "current" list to show). */
  const [species, setSpecies] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError]     = useState(null);
  const [query, setQuery]     = useState('');
  const [picked, setPicked]   = useState(() => new Set());
  const [busy, setBusy]       = useState(false);
  const [assigned, setAssigned] = useState([]);          // current species (single-screen mode)
  const [removingId, setRemovingId] = useState(null);    // species being removed
  const isSingleScreen = Array.isArray(screenIds) && screenIds.length === 1;
  const singleScreenId = isSingleScreen ? screenIds[0] : null;

  useEffect(() => {
    if (!open) { setQuery(''); setPicked(new Set()); setError(null); setAssigned([]); return; }
    setLoading(true);
    apiFetch('/api/species?limit=500')
      .then((r) => setSpecies(r.species || []))
      .catch((e) => setError(e.message))
      .finally(() => setLoading(false));
    if (singleScreenId) {
      apiFetch(`/api/screens/${singleScreenId}/species`)
        .then((r) => setAssigned(r.species || []))
        .catch(() => {/* non-fatal; just hide the assigned section */});
    }
  }, [open, singleScreenId]);

  async function removeAssigned(speciesId) {
    if (!singleScreenId || removingId) return;
    setRemovingId(speciesId);
    try {
      await apiFetch(`/api/screens/${singleScreenId}/species/${speciesId}`, { method: 'DELETE' });
      setAssigned((prev) => prev.filter((s) => s.id !== speciesId));
    } catch (e) {
      setError(`Remove failed: ${e.message}`);
    } finally {
      setRemovingId(null);
    }
  }

  useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') onClose && onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open, onClose]);

  const filtered = useMemo(() => {
    /* In single-screen mode, hide species already on this screen so
       the curator doesn't pick something that's already assigned —
       those rows live in the "On this screen" panel above with their
       own Remove control. Feedback e570afe3. */
    const assignedIds = new Set(assigned.map((s) => s.id));
    let list = isSingleScreen ? species.filter((s) => !assignedIds.has(s.id)) : species;
    if (!query) return list;
    const q = query.toLowerCase();
    return list.filter((s) =>
      (s.common_name || '').toLowerCase().includes(q) ||
      (s.scientific_name || '').toLowerCase().includes(q)
    );
  }, [species, query, assigned, isSingleScreen]);

  function togglePick(id) {
    setPicked((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
  }

  async function submit() {
    if (picked.size === 0) { setError('Pick at least one species.'); return; }
    if (!screenIds || screenIds.length === 0) { setError('No screens selected.'); return; }
    setBusy(true); setError(null);
    try {
      const r = await apiFetch('/api/screens/bulk/species', {
        method: 'POST',
        body: JSON.stringify({
          species_ids: Array.from(picked),
          screen_ids: screenIds,
        }),
      });
      if (window.toast) window.toast(`Added ${r.species} species to ${r.screens} screen${r.screens === 1 ? '' : 's'}`);
      onDone && onDone();
      /* Single-screen mode: keep the modal open so the curator can
         continue managing assignments. Re-fetch the current list so
         the just-added species move from picker → "On this screen",
         and reset the picker selection. Feedback e570afe3. */
      if (singleScreenId) {
        try {
          const r2 = await apiFetch(`/api/screens/${singleScreenId}/species`);
          setAssigned(r2.species || []);
          setPicked(new Set());
          setQuery('');
        } catch (_) { /* ignore — UI just won't refresh */ }
      }
    } catch (e) {
      setError(e.message);
    } finally {
      setBusy(false);
    }
  }

  if (!open) return null;
  return (
    <div
      onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose && onClose(); }}
      style={{
        // zIndex bumped 220 → 1000 (feedback a8a36ad3); modals
        // typically 1000+ per styles.css convention.
        position: 'fixed', inset: 0, zIndex: 1000,
        background: 'rgba(6,7,10,0.78)', backdropFilter: 'blur(6px)',
        display: 'grid', placeItems: 'center', padding: 24,
      }}
    >
      <div style={{
        width: 'min(640px, 100%)', maxHeight: '82vh',
        background: 'var(--aq-surface)',
        border: '1px solid var(--aq-line)',
        borderRadius: 14, boxShadow: 'var(--aq-shadow-2)',
        display: 'flex', flexDirection: 'column', overflow: 'hidden',
      }}>
        <header style={{
          padding: '14px 18px', borderBottom: '1px solid var(--aq-line)',
          display: 'flex', alignItems: 'center', gap: 10,
        }}>
          <div style={{
            width: 28, height: 28, borderRadius: 8,
            background: 'var(--aq-accent-soft)', color: 'var(--aq-accent)',
            display: 'grid', placeItems: 'center',
          }}><Icon name="fish" size={14} /></div>
          <div style={{ flex: 1 }}>
            <div style={{ fontFamily: 'var(--aq-ff-display)', fontSize: 14.5, fontWeight: 500, color: 'var(--aq-text)' }}>
              Manage species
            </div>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-faint)' }}>
              {isSingleScreen
                ? 'View and adjust species on this screen — remove existing or add new.'
                : `Add species to the ${screenIds.length} selected screens.`}
            </div>
          </div>
          <button type="button" className="aq-icon-btn" onClick={onClose}><Icon name="close" size={13} /></button>
        </header>

        {/* Single-screen mode: show what's currently assigned with
            inline Remove. Hidden when multiple screens are selected
            because the intersection isn't meaningful. Feedback
            e570afe3. */}
        {isSingleScreen && assigned.length > 0 && (
          <div style={{ borderBottom: '1px solid var(--aq-line)', maxHeight: '34vh', display: 'flex', flexDirection: 'column' }}>
            <div style={{
              padding: '8px 14px 4px', fontSize: 10.5, letterSpacing: '0.06em',
              textTransform: 'uppercase', color: 'var(--aq-text-faint)',
              display: 'flex', alignItems: 'center', gap: 8,
            }}>
              <span>On this screen</span>
              <span style={{ fontWeight: 600, color: 'var(--aq-text-dim)' }}>· {assigned.length}</span>
            </div>
            <div style={{ overflowY: 'auto', padding: '2px 0 6px' }}>
              {assigned.map((s) => (
                <div
                  key={s.id}
                  style={{
                    display: 'flex', alignItems: 'center', gap: 10,
                    padding: '6px 14px', fontSize: 13, color: 'var(--aq-text)',
                  }}
                >
                  <span style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                      {s.common_name || s.scientific_name}
                    </div>
                    <div style={{ fontSize: 11, color: 'var(--aq-text-faint)', fontStyle: 'italic' }}>
                      {s.scientific_name}
                    </div>
                  </span>
                  <button
                    type="button"
                    onClick={() => removeAssigned(s.id)}
                    disabled={!!removingId}
                    title="Remove from this screen"
                    style={{
                      padding: '4px 10px', fontSize: 11,
                      background: 'transparent', color: 'var(--aq-danger)',
                      border: '1px solid color-mix(in srgb, var(--aq-danger) 28%, transparent)',
                      borderRadius: 6, cursor: removingId ? 'wait' : 'pointer',
                      opacity: removingId === s.id ? 0.6 : 1,
                    }}
                  >{removingId === s.id ? 'Removing…' : 'Remove'}</button>
                </div>
              ))}
            </div>
          </div>
        )}
        <div style={{ padding: '10px 14px', borderBottom: '1px solid var(--aq-line)' }}>
          <input
            type="text"
            placeholder={isSingleScreen ? 'Search to add more species…' : 'Search by common or scientific name…'}
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            style={{
              width: '100%', padding: '8px 10px', fontSize: 13,
              background: 'var(--aq-surface-2)', border: '1px solid var(--aq-line)',
              borderRadius: 7, color: 'var(--aq-text)', font: 'inherit', outline: 0,
            }}
          />
        </div>

        <div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
          {loading && (
            <div style={{ padding: 30, textAlign: 'center', color: 'var(--aq-text-faint)' }}>
              Loading species…
            </div>
          )}
          {error && (
            <div style={{
              margin: 14, padding: '8px 10px', borderRadius: 6,
              background: 'color-mix(in srgb, var(--aq-danger) 12%, transparent)',
              color: 'var(--aq-danger)', fontSize: 12,
            }}>{error}</div>
          )}
          {!loading && filtered.map((s) => {
            const on = picked.has(s.id);
            return (
              <button
                key={s.id}
                type="button"
                onClick={() => togglePick(s.id)}
                style={{
                  display: 'flex', alignItems: 'center', gap: 10,
                  width: '100%', textAlign: 'left',
                  padding: '8px 14px',
                  background: on ? 'color-mix(in srgb, var(--aq-accent) 10%, transparent)' : 'transparent',
                  border: 0, borderBottom: '1px solid var(--aq-line)',
                  fontFamily: 'inherit', fontSize: 13, cursor: 'pointer',
                  color: 'var(--aq-text)',
                }}
              >
                <span style={{
                  width: 18, height: 18, borderRadius: 4,
                  background: on ? 'var(--aq-accent)' : 'transparent',
                  border: '1px solid ' + (on ? 'var(--aq-accent)' : 'var(--aq-line)'),
                  color: '#fff', display: 'grid', placeItems: 'center', fontSize: 11,
                }}>{on ? '✓' : ''}</span>
                <span style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ fontWeight: 500 }}>{s.common_name || s.scientific_name}</div>
                  <div style={{ fontSize: 11, color: 'var(--aq-text-faint)', fontStyle: 'italic' }}>
                    {s.scientific_name}
                  </div>
                </span>
              </button>
            );
          })}
          {!loading && filtered.length === 0 && (
            <div style={{ padding: 30, textAlign: 'center', color: 'var(--aq-text-faint)' }}>
              No species match "{query}".
            </div>
          )}
        </div>

        <footer style={{
          padding: '10px 16px', borderTop: '1px solid var(--aq-line)',
          background: 'var(--aq-surface-2)',
          display: 'flex', alignItems: 'center', gap: 8,
        }}>
          <span style={{ flex: 1, fontSize: 12, color: 'var(--aq-text-faint)' }}>
            {picked.size > 0
              ? `${picked.size} to add`
              : (isSingleScreen
                  ? (assigned.length > 0 ? 'Search to add more, or close when done' : 'Select species to add')
                  : 'Select species above')}
          </span>
          <button type="button" className="x-btn ghost" onClick={onClose} disabled={busy}>
            {isSingleScreen ? 'Done' : 'Cancel'}
          </button>
          <button
            type="button" className="x-btn"
            onClick={submit}
            disabled={busy || picked.size === 0}
          >{busy
              ? 'Adding…'
              : (isSingleScreen
                  ? `Add ${picked.size || ''} species`.trim()
                  : `Assign to ${screenIds.length} screen${screenIds.length === 1 ? '' : 's'}`)}</button>
        </footer>
      </div>
    </div>
  );
}

/* Bulk move-to-zone modal. Single zone picker; PATCHes each
   selected screen to set its zone_id. Same per-screen loop
   pattern as bulk-delete — bounded volume, sequential keeps
   error reporting clean. */
function BulkMoveZoneModal({ open, screenIds, zonesList, onClose, onDone }) {
  const [zoneId, setZoneId] = useState('');
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);

  useEffect(() => {
    if (!open) { setZoneId(''); setErr(null); return; }
    setZoneId(zonesList && zonesList[0] ? zonesList[0].id : '');
  }, [open, zonesList]);

  useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') onClose && onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open, onClose]);

  async function submit() {
    if (!zoneId) { setErr('Pick a zone.'); return; }
    setBusy(true); setErr(null);
    let failed = 0;
    for (const id of screenIds) {
      try {
        // Backend exposes PUT (not PATCH) — same shape, just the
        // verb. Whitelist includes zone_id so this is sufficient.
        await apiFetch(`/api/screens/${id}`, {
          method: 'PUT',
          body: JSON.stringify({ zone_id: zoneId }),
        });
      } catch (_) { failed++; }
    }
    setBusy(false);
    if (window.toast) {
      window.toast(failed === 0
        ? `Moved ${screenIds.length} screen${screenIds.length === 1 ? '' : 's'}`
        : `${screenIds.length - failed} moved, ${failed} failed`,
        failed === 0 ? undefined : 'error');
    }
    onDone && onDone();
  }

  if (!open) return null;
  return (
    <div
      onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose && onClose(); }}
      style={{
        // zIndex bumped 220 → 1000 (feedback a8a36ad3); modals
        // typically 1000+ per styles.css convention.
        position: 'fixed', inset: 0, zIndex: 1000,
        background: 'rgba(6,7,10,0.78)', backdropFilter: 'blur(6px)',
        display: 'grid', placeItems: 'center', padding: 24,
      }}
    >
      <div style={{
        width: 'min(440px, 100%)',
        background: 'var(--aq-surface)',
        border: '1px solid var(--aq-line)',
        borderRadius: 14, boxShadow: 'var(--aq-shadow-2)',
        display: 'flex', flexDirection: 'column', overflow: 'hidden',
      }}>
        <header style={{
          padding: '14px 18px', borderBottom: '1px solid var(--aq-line)',
          display: 'flex', alignItems: 'center', gap: 10,
        }}>
          <div style={{
            width: 28, height: 28, borderRadius: 8,
            background: 'var(--aq-accent-soft)', color: 'var(--aq-accent)',
            display: 'grid', placeItems: 'center',
          }}><Icon name="building" size={14} /></div>
          <div style={{ flex: 1 }}>
            <div style={{ fontFamily: 'var(--aq-ff-display)', fontSize: 14.5, fontWeight: 500, color: 'var(--aq-text)' }}>
              Move screens to zone
            </div>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-faint)' }}>
              {screenIds.length} screen{screenIds.length === 1 ? '' : 's'} selected.
            </div>
          </div>
          <button type="button" className="aq-icon-btn" onClick={onClose}><Icon name="close" size={13} /></button>
        </header>
        <div style={{ padding: 16 }}>
          <label style={{ display: 'block' }}>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-dim)', marginBottom: 5 }}>Target zone</div>
            <select
              value={zoneId}
              onChange={(e) => setZoneId(e.target.value)}
              style={{
                width: '100%', padding: '8px 10px', fontSize: 13,
                background: 'var(--aq-surface-2)', border: '1px solid var(--aq-line)',
                borderRadius: 7, color: 'var(--aq-text)', font: 'inherit', outline: 0,
              }}
            >
              {(!zonesList || zonesList.length === 0) && <option value="">— no zones available —</option>}
              {(zonesList || []).map((z) => (
                <option key={z.id} value={z.id}>
                  {z.name}{z.site_name ? ` · ${z.site_name}` : ''}
                </option>
              ))}
            </select>
          </label>
          {err && (
            <div style={{
              marginTop: 10, padding: '8px 10px', borderRadius: 6,
              background: 'color-mix(in srgb, var(--aq-danger) 12%, transparent)',
              color: 'var(--aq-danger)', fontSize: 12,
            }}>{err}</div>
          )}
        </div>
        <footer style={{
          padding: '10px 16px', borderTop: '1px solid var(--aq-line)',
          background: 'var(--aq-surface-2)',
          display: 'flex', justifyContent: 'flex-end', gap: 8,
        }}>
          <button type="button" className="x-btn ghost" onClick={onClose} disabled={busy}>Cancel</button>
          <button
            type="button" className="x-btn"
            onClick={submit}
            disabled={busy || !zoneId}
          >{busy ? 'Moving…' : 'Move screens'}</button>
        </footer>
      </div>
    </div>
  );
}

/* ══════════════════════════════════════════════════════════════════
   SwapScreenModal — exchange content OR device between two screens.
   ──────────────────────────────────────────────────────────────────
   Two distinct primitives, surfaced in one modal because the picker
   (which other screen?) is identical. Curator toggles which kind of
   swap via a segmented control:

     • Swap content → trade zone/name/species. Devices stay put.
                       Use when you labelled the screens wrong.
     • Swap device  → trade pairings.screen_id pointers. Roles stay.
                       Use when you want to physically move a device
                       to fulfil a different role but keep its
                       content config in place.

   POSTs to /api/screens/:id/swap-content or /:id/swap-device.
   Design doc: in-app feedback f8054e9b.
   ══════════════════════════════════════════════════════════════════ */
function SwapScreenModal({ open, screen, initialMode, onClose, onSwapped }) {
  const [mode, setMode] = useState(initialMode || 'content');
  const [otherScreens, setOtherScreens] = useState([]);
  const [pickedId, setPickedId] = useState(null);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);

  useEffect(() => {
    setMode(initialMode || 'content');
  }, [initialMode]);

  useEffect(() => {
    if (!open) { setPickedId(null); setErr(null); setOtherScreens([]); return; }
    apiFetchSiteScoped('/api/screens')
      .then((r) => {
        const all = (r && r.screens) || [];
        // Exclude self.
        setOtherScreens(all.filter((s) => s.id !== (screen && screen.id)));
      })
      .catch((e) => setErr(e.message));
  }, [open, screen && screen.id]);

  async function submit() {
    if (!pickedId) { setErr('Pick a screen to swap with.'); return; }
    if (!screen || !screen.id) return;
    setBusy(true); setErr(null);
    try {
      const path = mode === 'device' ? 'swap-device' : 'swap-content';
      await apiFetch(`/api/screens/${screen.id}/${path}`, {
        method: 'POST',
        body: JSON.stringify({ other_screen_id: pickedId }),
      });
      if (window.toast) window.toast(
        mode === 'device'
          ? 'Devices swapped — each will re-pair on next heartbeat.'
          : 'Content swapped — screens updated.'
      );
      onSwapped && onSwapped();
    } catch (e) {
      setErr(e.message || 'Swap failed');
    } finally {
      setBusy(false);
    }
  }

  if (!open || !screen) return null;
  return (
    <div
      onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose && onClose(); }}
      style={{
        position: 'fixed', inset: 0, zIndex: 1000,
        background: 'rgba(6,7,10,0.78)', backdropFilter: 'blur(6px)',
        display: 'grid', placeItems: 'center', padding: 24,
      }}
    >
      <div style={{
        width: 'min(540px, 100%)', maxHeight: '82vh',
        background: 'var(--aq-surface)',
        border: '1px solid var(--aq-line)',
        borderRadius: 14, boxShadow: 'var(--aq-shadow-2)',
        display: 'flex', flexDirection: 'column', overflow: 'hidden',
      }}>
        <header style={{
          padding: '14px 18px', borderBottom: '1px solid var(--aq-line)',
          display: 'flex', alignItems: 'center', gap: 10,
        }}>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontFamily: 'var(--aq-ff-display)', fontSize: 14.5, fontWeight: 500, color: 'var(--aq-text)' }}>
              Swap with another screen
            </div>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-faint)' }}>
              starting from <strong style={{ color: 'var(--aq-text-dim)' }}>{screen.screen_code}</strong>
            </div>
          </div>
          <button type="button" className="aq-icon-btn" onClick={onClose} disabled={busy}>
            <Icon name="close" size={13} />
          </button>
        </header>

        {/* Mode toggle */}
        <div style={{
          padding: '12px 18px 8px', borderBottom: '1px solid var(--aq-line)',
        }}>
          <div className="ds-segmented" style={{ display: 'inline-flex' }}>
            <button
              className={mode === 'content' ? 'is-active' : ''}
              onClick={() => setMode('content')}
              type="button"
            >Swap content</button>
            <button
              className={mode === 'device' ? 'is-active' : ''}
              onClick={() => setMode('device')}
              type="button"
            >Swap device</button>
          </div>
          <div style={{
            marginTop: 8, fontSize: 11.5, color: 'var(--aq-text-faint)',
            lineHeight: 1.5,
          }}>
            {mode === 'content'
              ? <>Trades zone, name and species assignments between the two screens. Devices stay where they are. <strong>Use when you labelled the screens the wrong way round.</strong></>
              : <>Trades which physical device fulfils each role. The screen roles (zone, name, content) stay put. Both devices will reload to their new role on their next heartbeat. <strong>Use when you want to physically move a device but keep its content config.</strong></>}
          </div>
        </div>

        {/* Picker */}
        <div style={{ flex: 1, overflowY: 'auto' }}>
          <div style={{
            padding: '10px 18px 6px', fontSize: 10.5,
            letterSpacing: '0.06em', textTransform: 'uppercase',
            color: 'var(--aq-text-faint)',
          }}>
            Pick a screen to swap with
          </div>
          {otherScreens.length === 0 && (
            <div style={{ padding: 18, fontSize: 12, color: 'var(--aq-text-faint)' }}>
              No other screens in your organisation.
            </div>
          )}
          {otherScreens.map((s) => (
            <button
              key={s.id}
              type="button"
              onClick={() => setPickedId(s.id)}
              style={{
                display: 'flex', alignItems: 'center', gap: 10,
                width: '100%', textAlign: 'left',
                padding: '8px 18px',
                background: pickedId === s.id
                  ? 'color-mix(in srgb, var(--aq-accent) 10%, transparent)'
                  : 'transparent',
                border: 0, borderBottom: '1px solid var(--aq-line)',
                fontFamily: 'inherit', fontSize: 13, cursor: 'pointer',
                color: 'var(--aq-text)',
              }}
            >
              <span style={{
                width: 16, height: 16, borderRadius: '50%',
                background: pickedId === s.id ? 'var(--aq-accent)' : 'transparent',
                border: '1px solid ' + (pickedId === s.id ? 'var(--aq-accent)' : 'var(--aq-line)'),
              }} />
              <span style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontWeight: 500 }}>{s.screen_code}</div>
                <div style={{ fontSize: 11, color: 'var(--aq-text-faint)' }}>
                  {s.zone_name || 'No zone'}{s.name && s.name !== s.screen_code ? ` · ${s.name}` : ''}
                  {s.paired_at ? '' : ' · waiting for hardware'}
                </div>
              </span>
            </button>
          ))}
        </div>

        {err && (
          <div style={{
            margin: 14, padding: '8px 10px', borderRadius: 6,
            background: 'color-mix(in srgb, var(--aq-danger) 14%, transparent)',
            color: 'var(--aq-danger)', fontSize: 12,
          }}>{err}</div>
        )}

        <footer style={{
          padding: '10px 16px', borderTop: '1px solid var(--aq-line)',
          background: 'var(--aq-surface-2)',
          display: 'flex', gap: 8,
        }}>
          <span style={{ flex: 1 }} />
          <button type="button" className="x-btn ghost" onClick={onClose} disabled={busy}>Cancel</button>
          <button
            type="button" className="x-btn"
            onClick={submit}
            disabled={busy || !pickedId}
          >{busy ? 'Swapping…' : (mode === 'device' ? 'Swap device' : 'Swap content')}</button>
        </footer>
      </div>
    </div>
  );
}
function DisplayScreensScreen({ query: routeQuery }) {
  const [screens, setScreens] = useState([]);
  const [zonesList, setZonesList] = useState([]);    /* full zone records for the New screen picker */
  /* Per-screen featured-species media keyed by screen.id →
     { species_id, common_name, image_url, source }. Drives the
     hero-image backdrop on each tile (replaces the old decorative
     gradient). Fetched in parallel with the screens list. */
  const [mediaByScreen, setMediaByScreen] = useState({});
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [zoneFilter, setZoneFilter]     = useState('All zones');
  const [statusFilter, setStatusFilter] = useState('All');
  const [orientFilter, setOrientFilter] = useState('All');
  const [query, setQuery]               = useState('');
  const [selectedId, setSelectedId]     = useState(null);
  const [reload, setReload] = useState(0);
  /* Inline zone-rename state. Holds the id of the zone currently in
     edit mode + a draft of the new name. The zone header shows an
     input when editingZoneId matches the rendered zone. */
  const [editingZoneId, setEditingZoneId] = useState(null);
  const [editingZoneDraft, setEditingZoneDraft] = useState('');
  /* Soft pagination — large fleets (200+ screens) drag the scroll if
     we render every tile up-front. We cap the initial render to N and
     expose a "Show more" affordance. Reset to default whenever filters
     or the source list change. */
  const PAGE_SIZE = 60;
  const [pageCount, setPageCount] = useState(1);
  /* Replaces the legacy four-prompt newScreen() flow with a real form. */
  const [registerOpen, setRegisterOpen] = useState(false);
  /* Guided setup for an empty slot: clicking an Empty tile opens
     SetupScreenModal (basics), which on "Save & add species" hands off
     to the species picker (setupSpeciesScreen). */
  const [setupScreen, setSetupScreen] = useState(null);
  const [setupSpeciesScreen, setSetupSpeciesScreen] = useState(null);
  /* Add-zone modal — page-level since zones now live inside the
     unified Displays page per Option A of the "merge zones + screens"
     UX decision. CreateZoneModal is exposed by zones.jsx via
     window.CreateZoneModal so we don't have to duplicate the form. */
  const [addZoneOpen, setAddZoneOpen] = useState(false);
  const [schedulesOpen, setSchedulesOpen] = useState(false);
  /* Multi-select UI removed (May 2026 feedback): the per-tile tick
     boxes + floating bulk-actions bar were redundant with the
     right-pane detail view, which already covers every per-screen
     action. State + handlers stripped along with the UI; the
     BulkAssignSpeciesModal component itself is retained because the
     detail panel reuses it for single-screen species management
     (and may be reintroduced behind an explicit multi-select toggle
     later if cross-screen workflows come back). */

  function newScreen() {
    if (zonesList.length === 0) {
      window.toast
        ? window.toast.warn('Create a zone first — every screen lives inside a zone.')
        : window.alert('Create a zone first — every screen lives inside a zone.');
      return;
    }
    setRegisterOpen(true);
  }

  /* Auto-refresh — re-pull every 30 s so newly online/offline screens
     show without a manual reload. Drops the spinner on subsequent
     pulls so the page doesn't flicker. */
  useEffect(() => {
    const t = setInterval(() => setReload((n) => n + 1), 30000);
    return () => clearInterval(t);
  }, []);

  useEffect(() => {
    let cancelled = false;
    if (reload === 0) setLoading(true);
    Promise.all([
      apiFetchSiteScoped('/api/screens'),
      apiFetchSiteScoped('/api/zones'),
      // Featured-species media for the tile backdrop. Swallow-on-
      // failure so an endpoint outage doesn't break the grid — tiles
      // just fall back to the zone-theme gradient.
      apiFetch('/api/screens/featured-media').catch(() => ({ media: {} })),
    ]).then(([screensRes, zonesRes, mediaRes]) => {
      setZonesList((zonesRes && zonesRes.zones) || []);
      if (cancelled) return;
      setMediaByScreen((mediaRes && mediaRes.media) || {});
      const zoneById = new Map((zonesRes.zones || []).map((z) => [z.id, z]));
      const enriched = (screensRes.screens || []).map((s) => {
        const zone = zoneById.get(s.zone_id) || {};
        /* Pair-by-code aware status: 'waiting' for allocated-but-
           unattached, 'legacy' for never-paired pre-v67 rows.
           Falls through to last-seen for normal heartbeat states. */
        const status = screenStatus(s);
        return {
          ...s,
          zone_theme: zone.theme || 'reef',
          status,
          /* Recency label prefers the heartbeat (device truth); the feed
             timestamp only backstops legacy rows that never heartbeated. */
          last_seen_label: (s.last_heartbeat || s.last_seen_at)
            ? lastSeenLabel(s.last_heartbeat || s.last_seen_at) : 'never',
          resolution: { width: s.resolution_width, height: s.resolution_height },
        };
      });
      setScreens(enriched);
      if (enriched.length > 0 && !selectedId) setSelectedId(enriched[0].id);
      setError(null); // a good pull clears any banner left by a prior blip
      setLoading(false);
    }).catch((err) => {
      if (cancelled) return;
      // Only surface on the very first load. A transient upstream blip on a
      // background 30s refresh — e.g. a 502 from a cold-starting machine /
      // proxy after a period of inactivity — must NOT wipe the already-
      // rendered fleet. Keep the last-good grid and let the next tick recover.
      if (reload === 0) setError(err.message);
      else if (window.console) console.warn('Displays background refresh failed:', err.message);
      setLoading(false);
    });
    return () => { cancelled = true; };
  }, [reload]);

  /* Zone-filter options. Built from screens' zone names PLUS every zone
     in zonesList, so empty zones (no screens) are still selectable in
     the dropdown — matching the empty-zone sections rendered below. */
  const zones = useMemo(
    () => ['All zones', ...new Set([
      ...screens.map((s) => s.zone_name),
      ...(zonesList || []).map((z) => z.name),
    ].filter(Boolean))],
    [screens, zonesList]
  );

  const filtered = useMemo(() => {
    return screens.filter((s) => {
      if (zoneFilter !== 'All zones' && s.zone_name !== zoneFilter) return false;
      if (statusFilter !== 'All' && s.status !== statusFilter.toLowerCase()) return false;
      const orient = s.orientation === 'portrait' ? 'Portrait' : 'Landscape';
      if (orientFilter !== 'All' && orient !== orientFilter) return false;
      if (query) {
        const q = query.toLowerCase();
        if (!(
          (s.screen_code || '').toLowerCase().includes(q) ||
          (s.zone_name || '').toLowerCase().includes(q) ||
          (s.name || '').toLowerCase().includes(q)
        )) return false;
      }
      return true;
    });
  }, [screens, zoneFilter, statusFilter, orientFilter, query]);

  /* Reset pagination whenever the filtered set changes shape — the
     user shouldn't have to scroll past stale results from a wider
     filter. */
  useEffect(() => { setPageCount(1); }, [zoneFilter, statusFilter, orientFilter, query]);

  const visibleCap = pageCount * PAGE_SIZE;
  const visible = useMemo(() => filtered.slice(0, visibleCap), [filtered, visibleCap]);
  const hasMore = filtered.length > visibleCap;

  /* Two-level grouping: site → zone → screens (2026-06-12 redesign).
     Zones from different sites can share a name ("Pelagic" existed
     twice on this page with nothing saying which site owned which) —
     the site eyebrow disambiguates. Single-site orgs skip the eyebrow
     entirely so they pay no chrome for a problem they don't have. */
  const groupedBySite = useMemo(() => {
    const sites = {};
    visible.forEach((s) => {
      const site = s.site_name || 'Unassigned site';
      ((sites[site] ||= {})[s.zone_name] ||= []).push(s);
    });
    return sites;
  }, [visible]);
  const siteNames = useMemo(() => Object.keys(groupedBySite), [groupedBySite]);

  /* Empty zones — zones that exist in the org but have no screens, so
     they never appear in `grouped` (which is built from screens). The
     kiosk's assign-screen picker DOES list them, which surprised the
     curator ("zones I don't see in the CMS"). Surface them here, keyed
     by zone id so duplicate-named zones each get their own deletable
     section. Hidden while searching / status-filtering (they'd be noise
     in a narrowed view); honours the zone-name dropdown filter. */
  const emptyZones = useMemo(() => {
    if (query.trim() || statusFilter !== 'All') return [];
    const occupied = new Set(screens.map((s) => s.zone_id).filter(Boolean));
    let list = (zonesList || []).filter((z) => !occupied.has(z.id));
    if (zoneFilter !== 'All zones') list = list.filter((z) => z.name === zoneFilter);
    return list;
  }, [zonesList, screens, query, statusFilter, zoneFilter]);

  /* KPI counts. Allocation model (Jun 2026): the platform admin assigns
     N screen SLOTS to a site (/api/screens/allocate); each slot is one
     device the site has. Site managers fill slots with content; a device
     claims a slot on the kiosk. So a slot moves Empty → Prepared → Live:
       • total    — screens allocated by the platform admin (the "devices
                    the site has").
       • empty    — allocated, no species/content added yet.
       • prepared — has content, waiting for a kiosk to claim it.
       • live     — content + a device, currently playing.
     Content presence reuses the same signal the tiles use (featured
     species media), so KPIs and ghost tiles always agree. */
  const hasContent = (s) => {
    const m = mediaByScreen[s.id];
    return !!(m && m.common_name);
  };
  /* "Awaiting a device" = no device bound (paired_at null) and in an
     allocated/legacy/never state. Mirrors the tile's awaitingDevice
     exactly — keyed off paired_at + status, NOT last_seen_at (which
     allocated slots pick up spuriously). */
  const isAwaiting = (s) => !s.paired_at
    && (s.status === 'waiting' || s.status === 'legacy' || s.status === 'never');
  const asleep = (s) => s.status === 'online' && (s.screen_on === false || s.screen_on === 0);
  const counts = {
    total:    screens.length,
    live:     screens.filter((s) => s.status === 'online' && !asleep(s)).length,
    prepared: screens.filter((s) => isAwaiting(s) && hasContent(s)).length,
    empty:    screens.filter((s) => isAwaiting(s) && !hasContent(s)).length,
    offline:  screens.filter((s) => s.status === 'offline').length,
    idle:     screens.filter((s) => s.status === 'idle').length,
  };

  const selected = screens.find((s) => s.id === selectedId) || null;

  return (
    <div className="ds-content">
      <header className="ds-header">
        <div>
          <h1>Displays</h1>
          {/* One status sentence instead of the four-tile KPI strip
              (2026-06-12 redesign): zeros don't render, the offline
              fact is a working link, and the fleet starts ~130px
              higher. The counts logic is unchanged below. */}
          <div className="ds-statusline">
            <span className={`ds-status-live ${counts.live < counts.total ? 'is-partial' : ''}`}>
              {counts.live} of {counts.total} live
            </span>
            {counts.offline > 0 && (
              <React.Fragment>
                <span className="ds-status-sep">·</span>
                <button
                  type="button"
                  className="ds-status-offline"
                  onClick={() => setStatusFilter('Offline')}
                >{counts.offline} offline</button>
              </React.Fragment>
            )}
            {counts.idle > 0 && (
              <React.Fragment>
                <span className="ds-status-sep">·</span>
                <button
                  type="button"
                  className="ds-status-idle"
                  onClick={() => setStatusFilter('Idle')}
                >{counts.idle} idle</button>
              </React.Fragment>
            )}
            <span className="ds-status-sep">·</span>
            <span className="ds-status-rest">
              {counts.prepared > 0 || counts.empty > 0
                ? [
                    counts.prepared > 0 ? `${counts.prepared} prepared` : null,
                    counts.empty > 0 ? `${counts.empty} empty slot${counts.empty === 1 ? '' : 's'}` : null,
                  ].filter(Boolean).join(' · ')
                : 'nothing waiting for hardware'}
            </span>
          </div>
        </div>
        <div className="ds-header-actions">
          {/* Add zone = content organisation, fine for site managers.
              Add screen = ALLOCATING a device slot, which is the platform
              admin's job (/api/screens/allocate from Org settings). Site
              managers fill allocated slots with content, they don't mint
              new ones — so Add screen is gated to platform admin only. */}
          {(Auth.canManageOrg() || Auth.isSiteManager()) && (
            <button
              className="ds-btn"
              onClick={() => setAddZoneOpen(true)}
              title="Create a new exhibit zone to organise content"
            ><Icon name="plus" size={13} />Add zone</button>
          )}
          {Auth.canSeePlatform() && (
            <button
              className="ds-btn primary"
              onClick={newScreen}
              title="Allocate a screen slot to this site (platform admin)"
            ><Icon name="plus" size={13} />Allocate screen</button>
          )}
        </div>
      </header>

      <div className="ds-toolbar">
        <div className="ds-search">
          <Icon name="search" size={13} />
          <input
            placeholder="Search by ID, zone, or content…"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
          />
        </div>
        <div className="ds-segmented">
          {['All', 'Online', 'Offline', 'Idle'].map((s) => (
            <button
              key={s}
              className={statusFilter === s ? 'is-active' : ''}
              onClick={() => setStatusFilter(s)}
            >{s}</button>
          ))}
        </div>
        <select
          className="ds-select"
          value={zoneFilter}
          onChange={(e) => setZoneFilter(e.target.value)}
        >
          {zones.map((z) => <option key={z}>{z}</option>)}
        </select>
        {(Auth.canManageOrg() || Auth.isSiteManager()) && (
          <button
            className="ds-btn"
            onClick={() => setSchedulesOpen(true)}
            title="Saved on/off schedules, deployed to any set of screens"
          ><Icon name="clock" size={13} />Schedules</button>
        )}
        {/* Orientation filter dropdown removed per May 2026 feedback
            (afbbdcfc). Orientation is shown on each screen card and
            curators almost never filtered by it. The state stays in
            scope (`orientFilter`) defaulting to 'All' so the filter
            predicate above is a no-op; trivial to reintroduce. */}
      </div>

      {loading && (
        /* Skeleton tiles instead of a "Loading fleet…" string —
           keeps the visual rhythm so the page doesn't pop on cold-
           load. Renders one zone block with 4 placeholder bezels
           that pulse. Per May 2026 follow-up. */
        <div className="ds-layout">
          <div className="ds-grid-wrap">
            <section className="ds-zone-block">
              <header className="ds-zone-head">
                <h3 className="ds-skeleton-line" style={{ width: 120 }} aria-hidden="true">&nbsp;</h3>
              </header>
              <div className="ds-grid">
                {[0, 1, 2, 3].map((i) => (
                  <div key={i} className="ds-tile">
                    <div className="ds-tile-frame ds-tile-skeleton">
                      <div className="ds-tile-body" />
                    </div>
                    <div className="ds-tile-info">
                      <div className="ds-skeleton-line" style={{ width: 70 }}>&nbsp;</div>
                    </div>
                  </div>
                ))}
              </div>
            </section>
          </div>
        </div>
      )}
      {error && (
        <div style={{
          padding: '10px 14px',
          background: 'color-mix(in srgb, var(--aq-danger) 12%, transparent)',
          border: '1px solid color-mix(in srgb, var(--aq-danger) 30%, transparent)',
          borderRadius: 6, color: 'var(--aq-danger)', fontSize: 12,
        }}>{error}</div>
      )}

      {!loading && !error && (
        <div className="ds-layout">
          <div className="ds-grid-wrap">
            {/* Site → zone rendering (2026-06-12 redesign). Zones group
                under a site eyebrow so duplicate zone names across sites
                can't be confused; single-site orgs skip the eyebrow.
                "Unmapped" pseudo-zones sink to the bottom of their site. */}
            {(() => {
              const allSites = [...new Set([
                ...siteNames,
                ...emptyZones.map((z) => z.site_name || 'Unassigned site'),
              ])];
              const multiSite = allSites.length > 1;
              return allSites.map((site) => {
              const zonesIn = groupedBySite[site] || {};
              const emptiesIn = emptyZones.filter((z) => (z.site_name || 'Unassigned site') === site);
              const screensIn = Object.values(zonesIn).reduce((a, l) => a.concat(l), []);
              const liveIn = screensIn.filter((s) => s.status === 'online').length;
              return (
                <div key={site} className="ds-site-group">
                  {multiSite && (
                    <div className="ds-site-head">
                      <span className="ds-site-name">{site}</span>
                      <span className="ds-site-count">
                        {screensIn.length} screen{screensIn.length === 1 ? '' : 's'}
                        {screensIn.length > 0 ? ` · ${liveIn} live` : ''}
                      </span>
                      <span className="ds-site-rule" />
                    </div>
                  )}
                  {Object.entries(zonesIn).sort(([a], [b]) => {
                    const ua = (a || '').toLowerCase() === 'unmapped' ? 1 : 0;
                    const ub = (b || '').toLowerCase() === 'unmapped' ? 1 : 0;
                    return ua - ub;
                  }).map(([zone, list]) => {
              const zoneRecord = zonesList.find((z) => z.name === zone);
              const isUnmapped = (zone || '').toLowerCase() === 'unmapped';
              const canDelete = zoneRecord && !isUnmapped &&
                (Auth.canManageOrg() || Auth.isSiteManager());
              return (
                <section
                  key={zone}
                  className="ds-zone-block"
                  style={isUnmapped ? {
                    // Subtle warning tint so curators notice
                    // there's work to do here. Not alarming —
                    // just distinct from a normal zone block.
                    border: '1px solid color-mix(in srgb, var(--aq-warn) 25%, var(--aq-line))',
                    borderRadius: 10, padding: 8,
                    background: 'color-mix(in srgb, var(--aq-warn) 4%, transparent)',
                  } : undefined}
                >
                  <header className="ds-zone-head">
                    {/* Inline rename: double-click the title or click
                        the pencil to enter edit mode. Enter or blur
                        commits via PUT /api/zones/:id; Escape cancels.
                        Per May 2026 follow-up. */}
                    {editingZoneId === (zoneRecord && zoneRecord.id) ? (
                      <input
                        autoFocus
                        type="text"
                        value={editingZoneDraft}
                        onChange={(e) => setEditingZoneDraft(e.target.value)}
                        onKeyDown={(e) => {
                          if (e.key === 'Escape') { setEditingZoneId(null); }
                          if (e.key === 'Enter')   { e.currentTarget.blur(); }
                        }}
                        onBlur={async () => {
                          const newName = (editingZoneDraft || '').trim();
                          setEditingZoneId(null);
                          if (!newName || newName === zone) return;
                          try {
                            await apiFetch(`/api/zones/${zoneRecord.id}`, {
                              method: 'PUT',
                              body: JSON.stringify({ name: newName }),
                            });
                            if (window.toast) window.toast(`Zone renamed to "${newName}"`);
                            setReload((n) => n + 1);
                          } catch (e) {
                            if (window.toast) window.toast(`Rename failed: ${e.message}`, 'error');
                          }
                        }}
                        style={{
                          font: 'inherit',
                          fontSize: 15, fontWeight: 600,
                          background: 'var(--aq-surface-2)',
                          border: '1px solid var(--aq-line)',
                          borderRadius: 6,
                          padding: '4px 8px',
                          color: 'var(--aq-text)',
                          outline: 0,
                          minWidth: 200,
                        }}
                      />
                    ) : (
                      <h3
                        title={zoneRecord && !isUnmapped ? 'Double-click to rename' : undefined}
                        onDoubleClick={() => {
                          if (!zoneRecord || isUnmapped) return;
                          if (!(Auth.canManageOrg() || Auth.isSiteManager())) return;
                          setEditingZoneDraft(zone);
                          setEditingZoneId(zoneRecord.id);
                        }}
                        style={isUnmapped
                          ? { color: 'var(--aq-warn)', cursor: 'default' }
                          : { cursor: zoneRecord ? 'text' : 'default' }}
                      >
                        {isUnmapped && <Icon name="alert" size={12} style={{ marginRight: 6 }} />}
                        {zone}
                      </h3>
                    )}
                    {/* Screen count beside the zone title removed —
                        the actual tiles below already convey scale at
                        a glance. */}
                    {/* Inline pencil — hover-revealed via .ds-zone-edit.
                        Sits next to the delete × in the same hover band. */}
                    {zoneRecord && !isUnmapped && (Auth.canManageOrg() || Auth.isSiteManager()) && editingZoneId !== zoneRecord.id && (
                      <button
                        type="button"
                        className="ds-zone-edit"
                        aria-label={`Rename zone "${zone}"`}
                        title={`Rename "${zone}"`}
                        onClick={() => {
                          setEditingZoneDraft(zone);
                          setEditingZoneId(zoneRecord.id);
                        }}
                      ><Icon name="edit" size={12} /></button>
                    )}
                    {canDelete && (
                      <button
                        type="button"
                        className="ds-zone-delete"
                        aria-label={`Delete zone "${zone}"`}
                        title={list.length > 0
                          ? `Delete "${zone}" — its ${list.length} screen${list.length === 1 ? '' : 's'} will move to "Unmapped"`
                          : `Delete "${zone}"`}
                        onClick={async () => {
                          const msg = list.length > 0
                            ? `Delete "${zone}"? Its ${list.length} screen${list.length === 1 ? '' : 's'} will be moved to "Unmapped".`
                            : `Delete "${zone}"?`;
                          if (!window.confirm(msg)) return;
                          try {
                            const r = await apiFetch(`/api/zones/${zoneRecord.id}`, { method: 'DELETE' });
                            if (window.toast) {
                              window.toast(
                                r.moved_screens > 0
                                  ? `Zone deleted · ${r.moved_screens} screen${r.moved_screens === 1 ? '' : 's'} moved to Unmapped`
                                  : 'Zone deleted'
                              );
                            }
                            setReload((n) => n + 1);
                          } catch (e) {
                            if (window.toast) window.toast(`Delete failed: ${e.message}`, 'error');
                            else window.alert(`Delete failed: ${e.message}`);
                          }
                        }}
                      ><Icon name="close" size={12} /></button>
                    )}
                  </header>
                  {isUnmapped && (
                    <div style={{
                      padding: '6px 10px', marginBottom: 8,
                      fontSize: 11.5, color: 'var(--aq-text-dim)',
                      fontStyle: 'italic',
                    }}>
                      Screens whose zone was deleted live here. Re-home each one by editing the screen and assigning a real zone.
                    </div>
                  )}
                  <div className="ds-grid">
                    {list.map((s) => (
                      <DSP_ScreenTile
                        key={s.id}
                        screen={s}
                        selected={s.id === selectedId}
                        onClick={() => setSelectedId(s.id)}
                        onOpen={() => { window.location.hash = `#screen/${s.screen_code}`; }}
                        media={mediaByScreen[s.id]}
                        onSetup={(Auth.canManageOrg() || Auth.isSiteManager()) ? (scr) => setSetupScreen(scr) : undefined}
                      />
                    ))}
                  </div>
                </section>
              );
            })}

                  {/* Empty zones collapse to single rows (2026-06-12):
                      a full header+prose section per empty zone stacked
                      ~180px of nothing each. One quiet line, actions on
                      hover. Still keyed by zone id so duplicate names
                      stay individually deletable. */}
                  {emptiesIn.map((z) => {
                    const isUnmapped = (z.name || '').toLowerCase() === 'unmapped';
                    const canDelete = !isUnmapped && (Auth.canManageOrg() || Auth.isSiteManager());
                    return (
                      <div key={`empty-${z.id}`} className="ds-empty-zone">
                        <b>{z.name}</b>
                        <span className="ds-empty-zone-note">no screens yet — assign one from a kiosk’s setup screen</span>
                        {canDelete && (
                          <span className="ds-empty-zone-act">
                            <button
                              type="button"
                              aria-label={`Delete empty zone "${z.name}"`}
                              onClick={async () => {
                                if (!window.confirm(`Delete empty zone "${z.name}"? It has no screens.`)) return;
                                try {
                                  await apiFetch(`/api/zones/${z.id}`, { method: 'DELETE' });
                                  if (window.toast) window.toast('Zone deleted');
                                  setReload((n) => n + 1);
                                } catch (e) {
                                  if (window.toast) window.toast(`Delete failed: ${e.message}`, 'error');
                                  else window.alert(`Delete failed: ${e.message}`);
                                }
                              }}
                            >Delete zone</button>
                          </span>
                        )}
                      </div>
                    );
                  })}
                </div>
              );
              });
            })()}

            {filtered.length === 0 && emptyZones.length === 0 && (
              <div className="ds-empty">No screens match those filters.</div>
            )}
            {hasMore && (
              /* "Show more" reveals the next page worth of tiles. The
                 server already returned the full filtered set, so this
                 is purely a render cap (cheap) — no extra fetch. */
              <div style={{
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                gap: 12, padding: '14px 0', marginTop: 6,
                borderTop: '1px solid var(--aq-line)',
              }}>
                <span style={{ fontSize: 12, color: 'var(--aq-text-faint)' }}>
                  Showing {visible.length} of {filtered.length}
                </span>
                <button
                  className="x-btn ghost sm"
                  onClick={() => setPageCount((n) => n + 1)}
                  title={`Reveal the next ${PAGE_SIZE} screens`}
                >Show more</button>
                <button
                  className="x-btn ghost sm"
                  onClick={() => setPageCount(Math.ceil(filtered.length / PAGE_SIZE))}
                  title="Reveal everything"
                >Show all</button>
              </div>
            )}
          </div>

          <DSP_DetailPanel
            onOpenSchedules={() => setSchedulesOpen(true)}
            screen={selected}
            zonesList={zonesList}
            onClose={() => setSelectedId(null)}
            onChanged={() => setReload((n) => n + 1)}
          />
        </div>
      )}

      {/* Bulk-select tick boxes + floating actions bar removed per
          May 2026 feedback ("remove the options to select screens
          with ticks and the lower menu, its redundant"). All
          per-screen actions live in the right-pane detail view —
          if cross-screen workflows come back later they should be
          recreated as an explicit "Multi-select" toggle in the
          toolbar rather than always-on ticks. */}

      <RegisterScreenModal
        open={registerOpen}
        zonesList={zonesList}
        onClose={() => setRegisterOpen(false)}
        onCreated={() => setReload((n) => n + 1)}
      />
      {/* Guided empty-slot setup → hands off to the species picker. */}
      <SetupScreenModal
        open={!!setupScreen}
        screen={setupScreen}
        zonesList={zonesList}
        onClose={() => setSetupScreen(null)}
        onSavedBasics={(scr, goSpecies) => {
          setSetupScreen(null);
          setReload((n) => n + 1);
          if (goSpecies) setSetupSpeciesScreen(scr);
        }}
      />
      <BulkAssignSpeciesModal
        open={!!setupSpeciesScreen}
        screenIds={setupSpeciesScreen ? [setupSpeciesScreen.id] : []}
        onClose={() => setSetupSpeciesScreen(null)}
        onDone={() => { setSetupSpeciesScreen(null); setReload((n) => n + 1); }}
      />
      {/* CreateZoneModal is exposed by zones.jsx — guard the render
          so this file still parses if the zones bundle ever fails to
          load. The modal handles its own backdrop / Escape close. */}
      {window.CreateZoneModal && (
        <window.CreateZoneModal
          open={addZoneOpen}
          onClose={() => setAddZoneOpen(false)}
          onCreated={() => { setAddZoneOpen(false); setReload((n) => n + 1); }}
        />
      )}
      {schedulesOpen && (
        <DSP_SchedulesModal
          screens={screens}
          onClose={() => { setSchedulesOpen(false); setReload((n) => n + 1); }}
        />
      )}
    </div>
  );
}


/* ── Central device schedules ("Model 2") — manager modal ───────────
   Library of named on/off schedules (weekly blocks + closed-date
   overrides) deployed to screens. The kiosk executes them on-device
   (AquaOSSchedule.applyCentral via feed.schedule), so they keep firing
   through network drops. Replaces ad-hoc Day/Evening/Night clicking
   with saved, deployable schedules — multi-select / select-all. */

const DSP_SCHED_ACTIONS = [
  { key: 'on',      label: 'Screen on' },
  { key: 'off',     label: 'Screen off' },
  { key: 'day',     label: 'Day · 100% bright' },
  { key: 'evening', label: 'Evening · 60% bright, 50% vol' },
  { key: 'night',   label: 'Night · 25% bright, muted' },
];
const DSP_SCHED_DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];

function dspSchedUid() { return 'b_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }

function DSP_SchedulesModal({ screens, onClose }) {
  const [schedules, setSchedules] = useState(null);
  const [draft, setDraft] = useState(null);   // { prevName|null, name, weekly, overrides, screen_ids }
  const [busy, setBusy] = useState(false);

  async function load() {
    try {
      /* A "schedule" here is a GROUP of screen-scoped screen_schedules
         rows sharing a name — the server groups them for us. */
      const r = await apiFetch('/api/screen-schedules/groups');
      setSchedules(r.schedules || []);
    } catch (e) {
      setSchedules([]);
      if (window.toast) window.toast(`Could not load schedules: ${e.message}`, 'error');
    }
  }
  useEffect(() => { load(); }, []);
  useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);

  function openNew() {
    setDraft({
      prevName: null, name: '',
      weekly: [
        { id: dspSchedUid(), time: '08:30', action: 'on',  days: [1, 2, 3, 4, 5, 6, 0] },
        { id: dspSchedUid(), time: '18:00', action: 'off', days: [1, 2, 3, 4, 5, 6, 0] },
      ],
      overrides: [],
      screen_ids: [],
    });
  }
  function openEdit(sch) {
    setDraft({
      prevName: sch.name, name: sch.name,
      weekly: (sch.definition.weekly || []).map((b) => ({ ...b, id: b.id || dspSchedUid() })),
      overrides: (sch.definition.overrides || []).map((o) => ({ ...o, id: o.id || dspSchedUid() })),
      screen_ids: sch.screen_ids || [],
    });
  }

  async function save() {
    if (!draft.name.trim()) { if (window.toast) window.toast('Give the schedule a name', 'error'); return; }
    setBusy(true);
    try {
      const definition = {
        weekly: draft.weekly.map(({ id, time, action, days }) => ({ id, time, action, days })),
        overrides: draft.overrides.map(({ id, label, start, end }) => ({ id, label, start, end: end || '', mode: 'closed', blocks: [] })),
      };
      await apiFetch('/api/screen-schedules/deploy', {
        method: 'POST',
        body: JSON.stringify({
          name: draft.name.trim(),
          prev_name: draft.prevName || undefined,
          definition,
          screen_ids: draft.screen_ids,
        }),
      });
      if (window.toast) (window.toast.success ? window.toast.success(`"${draft.name.trim()}" deployed to ${draft.screen_ids.length} screen${draft.screen_ids.length === 1 ? '' : 's'}`) : window.toast('Saved'));
      setDraft(null);
      await load();
    } catch (e) {
      if (window.toast) window.toast(`Save failed: ${e.message}`, 'error');
    } finally { setBusy(false); }
  }

  async function remove() {
    if (!draft || !draft.prevName) { setDraft(null); return; }
    if (!window.confirm(`Delete "${draft.name}"? Assigned screens fall back to their on-device schedule.`)) return;
    setBusy(true);
    try {
      await apiFetch(`/api/screen-schedules/groups/${encodeURIComponent(draft.prevName)}`, { method: 'DELETE' });
      if (window.toast) window.toast('Schedule deleted');
      setDraft(null);
      await load();
    } catch (e) {
      if (window.toast) window.toast(`Delete failed: ${e.message}`, 'error');
    } finally { setBusy(false); }
  }

  /* Screens grouped by site for the deploy picker. */
  const bySite = {};
  (screens || []).forEach((sc) => { (bySite[sc.site_name || 'Unassigned site'] ||= []).push(sc); });
  const assignedElsewhere = (sc) => {
    if (!schedules || !draft) return null;
    const other = schedules.find((x) => x.name !== draft.prevName && (x.screen_ids || []).includes(sc.id));
    return other ? other.name : null;
  };

  const inputStyle = { fontFamily: 'inherit', fontSize: 12.5, padding: '7px 10px', background: 'var(--aq-surface-2)', border: '1px solid var(--aq-line)', borderRadius: 7, color: 'var(--aq-text)', outline: 'none' };

  return (
    <div className="aq-popup-modal" role="dialog" aria-modal="true" aria-label="Device schedules"
      onClick={(e) => { if (e.target.classList.contains('aq-popup-modal')) onClose(); }}>
      <div className="aq-popup-card aq-popup-card--xwide" style={{ maxWidth: 760 }}>
        <button className="aq-popup-close" aria-label="Close" onClick={onClose}>×</button>
        <header className="aq-popup-header">
          <h2 style={{ margin: 0 }}>Screen schedules</h2>
          <p style={{ margin: '4px 0 0', fontSize: 12, color: 'var(--aq-text-faint)' }}>
            Saved on/off schedules, deployed to screens. They run on the panel itself, so they keep firing if the network drops.
          </p>
        </header>

        <div style={{ padding: '16px 24px 20px', overflowY: 'auto', maxHeight: '64vh' }}>
          {schedules === null && <div style={{ fontSize: 12.5, color: 'var(--aq-text-faint)', padding: '16px 0' }}>Loading…</div>}

          {/* ── List view ── */}
          {schedules !== null && !draft && (
            <React.Fragment>
              {schedules.length === 0 && (
                <div style={{ padding: '22px 0 18px', textAlign: 'center' }}>
                  <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--aq-text-dim)', marginBottom: 4 }}>No schedules yet</div>
                  <div style={{ fontSize: 12, color: 'var(--aq-text-faint)' }}>Create one — opening hours, late nights, closed days — and deploy it to any set of screens.</div>
                </div>
              )}
              {schedules.map((sch) => (
                <div key={sch.name} className="ds-sched-row" onClick={() => openEdit(sch)}>
                  <Icon name="clock" size={14} />
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--aq-text)' }}>{sch.name}</div>
                    <div style={{ fontSize: 11.5, color: 'var(--aq-text-faint)' }}>
                      {(sch.definition.weekly || []).length} time block{(sch.definition.weekly || []).length === 1 ? '' : 's'}
                      {(sch.definition.overrides || []).length > 0 ? ` · ${(sch.definition.overrides || []).length} closed period${(sch.definition.overrides || []).length === 1 ? '' : 's'}` : ''}
                    </div>
                  </div>
                  <span style={{ fontSize: 11.5, color: sch.screen_count > 0 ? 'var(--aq-text-dim)' : 'var(--aq-text-faint)' }}>
                    {sch.screen_count > 0 ? `${sch.screen_count} screen${sch.screen_count === 1 ? '' : 's'}` : 'not deployed'}
                  </span>
                  <Icon name="chevron-right" size={12} />
                </div>
              ))}
              <button className="aq-popup-btn" style={{ marginTop: 14 }} onClick={openNew}>
                <Icon name="plus" size={13} /> New schedule
              </button>
            </React.Fragment>
          )}

          {/* ── Editor view ── */}
          {draft && (
            <React.Fragment>
              <div style={{ marginBottom: 18 }}>
                <div className="ds-sched-label">Name</div>
                <input style={{ ...inputStyle, width: '100%' }} value={draft.name} placeholder="e.g. Opening hours, Late Thursdays…"
                  onChange={(e) => setDraft({ ...draft, name: e.target.value })} autoFocus />
              </div>

              <div className="ds-sched-label">Weekly times</div>
              {draft.weekly.map((b) => (
                <div key={b.id} className="ds-sched-block">
                  <input type="time" value={b.time} style={inputStyle}
                    onChange={(e) => setDraft({ ...draft, weekly: draft.weekly.map((x) => x.id === b.id ? { ...x, time: e.target.value } : x) })} />
                  <select value={b.action} style={{ ...inputStyle, cursor: 'pointer' }}
                    onChange={(e) => setDraft({ ...draft, weekly: draft.weekly.map((x) => x.id === b.id ? { ...x, action: e.target.value } : x) })}>
                    {DSP_SCHED_ACTIONS.map((a) => <option key={a.key} value={a.key}>{a.label}</option>)}
                  </select>
                  <div className="ds-sched-days">
                    {DSP_SCHED_DAYS.map((d, i) => (
                      <button key={d} type="button"
                        className={`ds-sched-day ${b.days.includes(i) ? 'is-on' : ''}`}
                        onClick={() => setDraft({
                          ...draft,
                          weekly: draft.weekly.map((x) => x.id === b.id
                            ? { ...x, days: x.days.includes(i) ? x.days.filter((y) => y !== i) : [...x.days, i] }
                            : x),
                        })}
                      >{d}</button>
                    ))}
                  </div>
                  <button type="button" className="ds-sched-x" aria-label="Remove time"
                    onClick={() => setDraft({ ...draft, weekly: draft.weekly.filter((x) => x.id !== b.id) })}>×</button>
                </div>
              ))}
              <button className="aq-popup-btn ghost" style={{ marginTop: 4 }} onClick={() =>
                setDraft({ ...draft, weekly: [...draft.weekly, { id: dspSchedUid(), time: '09:00', action: 'on', days: [1, 2, 3, 4, 5] }] })}>
                <Icon name="plus" size={12} /> Add time
              </button>

              <div className="ds-sched-label" style={{ marginTop: 20 }}>Closed periods <span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>— screens stay off all day</span></div>
              {draft.overrides.map((o) => (
                <div key={o.id} className="ds-sched-block">
                  <input style={{ ...inputStyle, flex: 1, minWidth: 0 }} value={o.label || ''} placeholder="e.g. Christmas closure"
                    onChange={(e) => setDraft({ ...draft, overrides: draft.overrides.map((x) => x.id === o.id ? { ...x, label: e.target.value } : x) })} />
                  <input type="date" value={o.start || ''} style={inputStyle}
                    onChange={(e) => setDraft({ ...draft, overrides: draft.overrides.map((x) => x.id === o.id ? { ...x, start: e.target.value } : x) })} />
                  <span style={{ color: 'var(--aq-text-faint)', fontSize: 12 }}>to</span>
                  <input type="date" value={o.end || ''} style={inputStyle}
                    onChange={(e) => setDraft({ ...draft, overrides: draft.overrides.map((x) => x.id === o.id ? { ...x, end: e.target.value } : x) })} />
                  <button type="button" className="ds-sched-x" aria-label="Remove closed period"
                    onClick={() => setDraft({ ...draft, overrides: draft.overrides.filter((x) => x.id !== o.id) })}>×</button>
                </div>
              ))}
              <button className="aq-popup-btn ghost" style={{ marginTop: 4 }} onClick={() => {
                const today = new Date().toISOString().slice(0, 10);
                setDraft({ ...draft, overrides: [...draft.overrides, { id: dspSchedUid(), label: '', start: today, end: today, mode: 'closed', blocks: [] }] });
              }}>
                <Icon name="plus" size={12} /> Add closed period
              </button>

              <div className="ds-sched-label" style={{ marginTop: 20 }}>
                Deploy to screens
                <span style={{ marginLeft: 'auto', display: 'inline-flex', gap: 12, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>
                  <a style={{ cursor: 'pointer', color: 'var(--aq-text-dim)' }} onClick={() => setDraft({ ...draft, screen_ids: (screens || []).map((sc) => sc.id) })}>Select all</a>
                  <a style={{ cursor: 'pointer', color: 'var(--aq-text-dim)' }} onClick={() => setDraft({ ...draft, screen_ids: [] })}>None</a>
                </span>
              </div>
              {Object.entries(bySite).map(([site, list]) => (
                <div key={site} style={{ marginBottom: 10 }}>
                  {Object.keys(bySite).length > 1 && (
                    <div style={{ fontSize: 10.5, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--aq-text-faint)', margin: '8px 0 4px' }}>{site}</div>
                  )}
                  {list.map((sc) => {
                    const checked = draft.screen_ids.includes(sc.id);
                    const elsewhere = assignedElsewhere(sc);
                    return (
                      <label key={sc.id} className="ds-sched-screen">
                        <input type="checkbox" checked={checked}
                          onChange={() => setDraft({
                            ...draft,
                            screen_ids: checked ? draft.screen_ids.filter((x) => x !== sc.id) : [...draft.screen_ids, sc.id],
                          })} />
                        <span style={{ color: 'var(--aq-text-muted)', fontWeight: 500 }}>{sc.name || sc.screen_code}</span>
                        <span style={{ color: 'var(--aq-text-faint)', fontSize: 11.5 }}>{sc.zone_name}</span>
                        {elsewhere && !checked && (
                          <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--aq-warn)' }}>on “{elsewhere}”</span>
                        )}
                        {elsewhere && checked && (
                          <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--aq-warn)' }}>will move from “{elsewhere}”</span>
                        )}
                      </label>
                    );
                  })}
                </div>
              ))}
            </React.Fragment>
          )}
        </div>

        {draft && (
          <footer style={{ display: 'flex', gap: 8, padding: '14px 24px', borderTop: '1px solid var(--aq-line-soft)' }}>
            {draft.prevName && <button className="aq-popup-btn danger ghost" disabled={busy} onClick={remove}>Delete</button>}
            <span style={{ flex: 1 }} />
            <button className="aq-popup-btn ghost" disabled={busy} onClick={() => setDraft(null)}>Back</button>
            <button className="aq-popup-btn" disabled={busy} onClick={save}>
              {busy ? 'Saving…' : `Save & deploy${draft.screen_ids.length ? ` (${draft.screen_ids.length})` : ''}`}
            </button>
          </footer>
        )}
      </div>
    </div>
  );
}

window.DisplayScreensScreen = DisplayScreensScreen;
