<script lang="ts">
  /**
   * Connection screen (FR-9.1–9.3).
   *
   * The one thing this screen must get right: **connected is not
   * controllable**. A trainer can be attached at the BLE level and still refuse
   * the FTMS control point, in which case nothing you do moves the resistance.
   * So a trainer row carries two independent badges and the ride screen stays
   * gated until control is real.
   *
   * Everything else on the row is there to be *glanced* at from a bike. This
   * screen used to spell out every fact in words — a kind label, a MAC address,
   * two captioned state columns, a Forget button on every stranger's phone that
   * wandered past. A rider looking for their trainer had to read all of it. So:
   * the kind is an icon, the address is gone (it is a number nobody types and
   * on Android it is a randomised one that changes anyway — where two rows are
   * genuinely ambiguous, four characters of it come back as a tiebreak), states
   * are badges that only appear once there is something to report, and the
   * strangers fold away into a collapsed list at the bottom.
   */
  import { app } from '../lib/app.svelte';
  import { api } from '../lib/bridge';
  import { connectionText, rssiBars } from '../lib/format';
  import type { DeviceInfo, DeviceKind } from '../lib/types';
  import ClickPanel from './ClickPanel.svelte';

  const devices = $derived(app.devices.devices);
  const scanning = $derived(app.devices.scanning);
  const trainerReady = $derived(app.trainerReady);

  /** The rider's kit. Everything else is somebody's phone on the next street. */
  const mine = $derived(devices.filter((d) => d.kind !== 'unknown' || d.remembered));
  const others = $derived(devices.filter((d) => d.kind === 'unknown' && !d.remembered));
  /** No trainer row at all — including one hiding in `others`, because a
   *  trainer that does not advertise FTMS until it is connected classifies as
   *  unknown. That is exactly the case the fold below must not swallow. */
  const noTrainer = $derived(!devices.some((d) => d.kind === 'trainer'));

  /** Short enough to sit under a name without becoming a second line of prose. */
  const KIND_LABEL: Record<DeviceKind, string> = {
    trainer: 'Trainer',
    // Named for the shift paddle, which is printed on the pod — unlike left and
    // right, which nothing in the advertisement actually tells us (§2.3.1).
    clickMinus: 'Zwift Click · − pod',
    clickPlus: 'Zwift Click · + pod',
    heartRate: 'Heart rate',
    unknown: 'Bluetooth device',
  };

  function isConnected(d: DeviceInfo): boolean {
    return d.state === 'Connected' || d.state === 'Controlling';
  }

  function busy(d: DeviceInfo): boolean {
    return d.state === 'Connecting' || d.state === 'Reconnecting';
  }

  /**
   * Two peripherals that advertise the same name are common — a pair of Click
   * pods, or a room full of "(no name)". Only then is any of the address worth
   * showing, and only the tail of it: enough to tell two rows apart, not a
   * number to read out.
   */
  function ambiguous(d: DeviceInfo): boolean {
    return devices.some((o) => o.id !== d.id && o.name === d.name);
  }

  function shortId(address: string): string {
    const hex = address.replace(/[^0-9a-zA-Z]/g, '').toUpperCase();
    return hex.slice(-4);
  }

  /**
   * The rider's *setup*, as roles rather than radios.
   *
   * This is the model every professional app uses and this screen did not:
   * Zwift asks "what is filling the power slot?", not "which of these fourteen
   * peripherals is your trainer?". A rider arriving here has three questions —
   * is the trainer on, will it take control, is the Click awake — and a list
   * sorted by signal strength answers none of them without reading.
   *
   * So the roles come first and the list becomes what it always was: the raw
   * radio, for when a role needs filling by hand.
   */
  type Role = {
    key: string;
    /** The slot, not the device: what would be missing without it. */
    title: string;
    kind: DeviceKind;
    /** Required to ride. The trainer is; nothing else is (FR-9.3 gate). */
    essential: boolean;
    filled: boolean;
    /** The device or pod filling it, or what is standing in the way. */
    primary: string;
    detail: string | null;
    tone: string;
    action: { label: string; run: () => void } | null;
    secondary: { label: string; run: () => void } | null;
  };

  /** The best candidate for a role: one already up, else the loudest seen. */
  function candidate(kind: DeviceKind): DeviceInfo | undefined {
    return (
      devices.find((d) => d.kind === kind && isConnected(d)) ??
      devices.find((d) => d.kind === kind && busy(d)) ??
      devices.find((d) => d.kind === kind)
    );
  }

  /**
   * Drop the link and take it again.
   *
   * The honest fix for "connected but not controlling": FTMS control is
   * requested once, at connect time, so a trainer that refused it then will go
   * on refusing until the link is rebuilt. Offering "acquire control" as if it
   * were a separate button the rider had failed to press would be a lie about
   * what the protocol does.
   */
  async function reacquire(id: string) {
    await api.disconnect(id);
    await api.connect(id);
  }

  const roles = $derived.by((): Role[] => {
    const out: Role[] = [];

    const trainer = candidate('trainer');
    const controlling = trainer?.controlAcquired === true && isConnected(trainer);
    out.push({
      key: 'trainer',
      title: 'Trainer',
      kind: 'trainer',
      essential: true,
      filled: controlling,
      primary: trainer ? trainer.name : 'Not found',
      // FR-9.3 lives here now: the two states are one line apart and the tile
      // is only green when *both* are true, because a trainer that is attached
      // and uncontrollable is a trainer that will not move the resistance.
      detail: !trainer
        ? 'Turn the pedals to wake it'
        : busy(trainer)
          ? connectionText(trainer.state).label + '…'
          : !isConnected(trainer)
            ? 'Found — not connected'
            : controlling
              ? 'Connected · FTMS control'
              : 'Connected · control refused',
      tone: controlling ? 'tone-ok' : trainer && isConnected(trainer) ? 'tone-warn' : 'tone-idle',
      action:
        trainer && !isConnected(trainer) && !busy(trainer)
          ? { label: 'Connect', run: () => app.run(() => api.connect(trainer.id)) }
          : trainer && isConnected(trainer) && !controlling
            ? { label: 'Reconnect', run: () => app.run(() => reacquire(trainer.id)) }
            : null,
      secondary:
        trainer && isConnected(trainer)
          ? { label: 'Disconnect', run: () => app.run(() => api.disconnect(trainer.id)) }
          : null,
    });

    const minus = app.controller?.minus;
    const plus = app.controller?.plus;
    const live = minus?.state === 'connected' ? minus : plus?.state === 'connected' ? plus : null;
    const searching = minus?.state === 'searching' || plus?.state === 'searching';
    out.push({
      key: 'click',
      title: 'Shifter',
      kind: 'clickMinus',
      essential: false,
      filled: live != null,
      primary: live ? `Zwift Click · ${live.symbol} pod` : searching ? 'Searching…' : 'Not connected',
      detail: live
        ? [
            live.pod === 'minus' ? 'All ten buttons' : 'Fallback — no D-pad',
            live.batteryPercent != null ? `${live.batteryPercent}%` : null,
            live.buttonsSeen > 0 ? `${live.buttonsSeen} presses` : 'no presses yet',
          ]
            .filter(Boolean)
            .join(' · ')
        : 'Press a button to wake it — the keyboard works meanwhile',
      tone: live ? 'tone-ok' : 'tone-idle',
      action: live
        ? null
        : { label: 'Find it', run: () => app.run(() => api.connectController()) },
      secondary: live
        ? { label: 'Disconnect', run: () => app.run(() => api.disconnectController()) }
        : null,
    });

    const hr = candidate('heartRate');
    const hrLive = hr != null && isConnected(hr);
    out.push({
      key: 'hr',
      title: 'Heart rate',
      kind: 'heartRate',
      essential: false,
      filled: hrLive,
      primary: hr ? hr.name : 'Not found',
      detail: !hr
        ? 'Wet the strap and put it on'
        : !hrLive
          ? busy(hr)
            ? connectionText(hr.state).label + '…'
            : 'Found — not connected'
          : hr.heartRateBpm != null
            ? `${hr.heartRateBpm} bpm${hr.batteryPct != null ? ` · ${hr.batteryPct}%` : ''}`
            : 'Connected — no reading yet',
      tone: hrLive && hr.heartRateBpm != null ? 'tone-ok' : hrLive ? 'tone-warn' : 'tone-idle',
      action:
        hr && !hrLive && !busy(hr)
          ? { label: 'Connect', run: () => app.run(() => api.connect(hr.id)) }
          : null,
      secondary: hrLive ? { label: 'Disconnect', run: () => app.run(() => api.disconnect(hr.id)) } : null,
    });

    return out;
  });

  /** The Click panel is a repair manual, so it appears when there is something
   *  to repair. Connected, the tile above has already said everything it knows. */
  const clickNeedsHelp = $derived(app.controller?.minus.state !== 'connected');

  /**
   * What the row has to say, as badges. Nothing is emitted for a device that is
   * merely sitting in the scan results: "Discovered" told the rider only what
   * the row's own presence already told them, and it was on every row at once.
   */
  function badges(d: DeviceInfo): { label: string; tone: string }[] {
    const out: { label: string; tone: string }[] = [];
    const conn = connectionText(d.state);

    if (typeof d.state !== 'string') out.push({ label: conn.label, tone: 'tone-bad' });
    else if (busy(d)) out.push({ label: conn.label + '…', tone: 'tone-warn' });
    else if (isConnected(d)) out.push({ label: 'Connected', tone: 'tone-ok' });

    // FR-9.3, kept as its own badge beside the link rather than folded into it.
    if (d.kind === 'trainer' && isConnected(d)) {
      out.push(
        d.controlAcquired
          ? { label: 'Control', tone: 'tone-ok' }
          : { label: 'No control', tone: 'tone-warn' },
      );
    }
    // A monitor that is connected but silent looks exactly like a working one
    // until the number appears, so the live reading is the row's proof.
    if (d.kind === 'heartRate' && isConnected(d)) {
      out.push(
        d.heartRateBpm != null
          ? { label: `${d.heartRateBpm} bpm`, tone: 'tone-ok' }
          : { label: 'no data', tone: 'tone-idle' },
      );
    }
    if (d.batteryPct != null) out.push({ label: `${d.batteryPct}%`, tone: 'tone-idle' });
    return out;
  }
</script>

<!-- The kind, as a glyph. Same information the words carried, at a glance and
     without a line of its own. -->
{#snippet kindIcon(kind: DeviceKind)}
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
    {#if kind === 'trainer'}
      <circle cx="12" cy="12" r="8" />
      <circle cx="12" cy="12" r="2.4" />
      <path d="M12 4v3.6M12 16.4V20M4 12h3.6M16.4 12H20" stroke-linecap="round" />
    {:else if kind === 'heartRate'}
      <path
        d="M12 20s-6.5-4-8.4-7.6C1.9 9.6 3.4 6.3 6.5 5.7c2-.4 3.9.7 5.5 2.7 1.6-2 3.5-3.1 5.5-2.7 3.1.6 4.6 3.9 2.9 6.7C18.5 16 12 20 12 20z"
        stroke-linejoin="round"
      />
    {:else if kind === 'clickMinus' || kind === 'clickPlus'}
      <rect x="4.5" y="3.5" width="15" height="17" rx="3.5" />
      {#if kind === 'clickPlus'}
        <path d="M12 8.5v7M8.5 12h7" stroke-linecap="round" />
      {:else}
        <path d="M8.5 12h7" stroke-linecap="round" />
      {/if}
    {:else}
      <path d="M8 7.5 16 16.5 12 20V4l4 3.5L8 16.5" stroke-linejoin="round" />
    {/if}
  </svg>
{/snippet}

{#snippet roleTile(role: Role)}
  <article class="tile" class:filled={role.filled} class:wanted={role.essential && !role.filled}>
    <div class="head">
      <span class="icon" class:on={role.filled}>{@render kindIcon(role.kind)}</span>
      <span class="slot">{role.title}</span>
      <span class="mark {role.tone}">
        {#if role.filled}
          <!-- A tick, not a word: this is the one thing on the screen that is
               read from two metres away while plugging something in. -->
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
            <path d="M5 12.5 10 17.5 19 7" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
        {:else}
          <span class="dot"></span>
        {/if}
      </span>
    </div>
    <span class="who">{role.primary}</span>
    {#if role.detail}<span class="detail {role.tone}">{role.detail}</span>{/if}
    <div class="act">
      {#if role.action}
        <button class="btn" onclick={role.action.run}>{role.action.label}</button>
      {/if}
      {#if role.secondary}
        <button class="btn ghost" onclick={role.secondary.run}>{role.secondary.label}</button>
      {/if}
    </div>
  </article>
{/snippet}

{#snippet deviceRow(device: DeviceInfo)}
  {@const bars = rssiBars(device.rssi)}
  <article class="row" class:live={isConnected(device)}>
    <span class="icon" class:on={isConnected(device)} title={KIND_LABEL[device.kind]}>
      {@render kindIcon(device.kind)}
    </span>

    <div class="identity">
      <span class="name">
        {device.name}
        {#if ambiguous(device)}<span class="tail">{shortId(device.address)}</span>{/if}
      </span>
      <span class="sub">
        {KIND_LABEL[device.kind]}
        <!-- FR-1.5. Paired before, so it comes back on its own the next time it
             advertises — this launch or any other. Worth saying: it is the
             difference between a device the rider has to fetch and one that
             simply turns up, and it is what Forget takes back. -->
        {#if device.remembered}· remembered{/if}
      </span>
      {#if device.error}<span class="error">{device.error}</span>{/if}
    </div>

    <span class="badges">
      {#each badges(device) as badge}
        <span class="chip {badge.tone}"><span class="dot"></span>{badge.label}</span>
      {/each}
    </span>

    <span class="bars" title="{device.rssi} dBm">
      {#each [1, 2, 3, 4] as bar}
        <i class:on={bar <= bars} style:height="{bar * 25}%"></i>
      {/each}
    </span>

    <div class="controls">
      {#if isConnected(device)}
        <button class="btn ghost" onclick={() => app.run(() => api.disconnect(device.id))}>
          Disconnect
        </button>
      {:else}
        <button
          class="btn"
          disabled={busy(device)}
          onclick={() => app.run(() => api.connect(device.id))}
        >
          {busy(device) ? 'Connecting…' : 'Connect'}
        </button>
      {/if}
      <!-- Only where it means something. Forgetting a device that was never
           remembered is a no-op the rider had to read past on every row. -->
      {#if device.remembered}
        <button
          class="btn ghost danger"
          title="Stop reconnecting to this device automatically"
          onclick={() => app.run(() => api.forget(device.id))}
        >
          Forget
        </button>
      {/if}
    </div>
  </article>
{/snippet}

<div class="screen">
  <header>
    <h1>Devices</h1>
    <button
      class="chip scan"
      class:live={scanning}
      onclick={() => app.run(() => (scanning ? api.stopScan() : api.startScan()))}
    >
      <span class="dot"></span>{scanning ? 'Scanning' : 'Scan off'}
    </button>
    <button class="btn icon-btn" title="Rider setup — weight, FTP, units" onclick={() => app.openSettings()}>
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
        <circle cx="12" cy="12" r="3.2" />
        <path
          d="M19.4 14a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5V20a2 2 0 1 1-4 0v-.1A1.6 1.6 0 0 0 9 18.4a1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.6 1.6 0 0 0 4.6 8a1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V2a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.5 1.6 1.6 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V8a1.6 1.6 0 0 0 1.5 1H22a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z"
        />
      </svg>
      <span class="sr">Rider setup</span>
    </button>
    <!-- Disabled until control is real: there is no ride without a trainer, and
         offering one would be offering a session that records nothing. -->
    <button
      class="btn primary"
      disabled={!trainerReady}
      title={trainerReady ? '' : 'Connect a trainer and acquire FTMS control first'}
      onclick={() => app.goToRide()}
    >
      Ride
    </button>
  </header>

  <!--
    The setup, as slots. See the `Role` model for why this comes before the
    device list rather than after it.
  -->
  <section class="roles">
    {#each roles as role (role.key)}
      {@render roleTile(role)}
    {/each}
  </section>

  <!--
    The Click panel is what to *do* when a pod will not come up — two peripherals
    that sleep in seconds and must be woken by hand (FR-1.4, FR-1.8). With the
    tile above carrying the state, it only earns its space while there is
    something to fix.
  -->
  {#if clickNeedsHelp}
    <ClickPanel />
  {/if}

  <div class="list">
    <!-- The raw radio, below the roles it fills. Still everything FR-9.1 asks
         for — it is just no longer the first thing a rider has to read. -->
    {#if mine.length > 0}<h2 class="label list-head">All devices</h2>{/if}
    {#each mine as device (device.id)}
      {@render deviceRow(device)}
    {/each}

    {#if mine.length === 0}
      <!-- A-4 / FR-1.8: absent is not the same as missing. Most of this used to
           be a paragraph and a bulleted list; it is one line per device now,
           which is all any of them ever said. -->
      <div class="empty">
        <h2>Nothing found yet</h2>
        <p>Devices sleep until you touch them:</p>
        <ul>
          <li><strong>Trainer</strong> — turn the pedals.</li>
          <li><strong>Zwift Click</strong> — press a button.</li>
          <li><strong>Heart rate</strong> — wet the strap and put it on.</li>
        </ul>
        <p class="quiet">They appear here as soon as they advertise.</p>
      </div>
    {/if}

    {#if others.length > 0}
      <!-- Still listed (FR-9.1), just not in the way. A scan in a flat picks up
           a dozen phones and TVs, and every one of them was a full-height row
           between the rider and their trainer. -->
      <details class="others" open={noTrainer}>
        <summary>
          {others.length} other Bluetooth {others.length === 1 ? 'device' : 'devices'}
          <!-- Some trainers do not advertise FTMS until something connects, so
               when there is no trainer on screen this is where it will be. -->
          {#if noTrainer}<span class="hint">— a trainer that hides its services is in here</span>{/if}
        </summary>
        {#each others as device (device.id)}
          {@render deviceRow(device)}
        {/each}
      </details>
    {/if}
  </div>
</div>

<style>
  /*
   * A column, not a fixed grid — the same lesson as `.ride`.
   *
   * This was `grid-template-rows: auto auto 1fr` with *four* children: header,
   * ClickPanel, the trainer gate, and the list. The gate is conditional, so
   * when it rendered it took the 1fr track and the device list fell into an
   * implicit `auto` row past the bottom of the screen — with `overflow-y` set
   * on the list rather than on the thing that was actually overflowing, so
   * nothing scrolled. On a desktop window everything fit and it never showed.
   * On a phone it meant the trainer could not be reached at all: the Click
   * panel alone is taller than the viewport, and the D100's Connect button was
   * below the fold with no way to get to it.
   *
   * Flex has no fixed track count, so a conditional child cannot displace
   * anything.
   */
  .screen {
    display: flex;
    flex-direction: column;
    height: 100%;
    min-height: 0;
  }

  .screen > :global(*) {
    flex: none;
  }

  header {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: clamp(1rem, 2.4vw, 1.8rem) var(--edge) 0.7rem;
  }

  h1 {
    margin: 0;
    font-size: clamp(1.4rem, 2.2vw, 2rem);
    font-weight: 300;
    letter-spacing: -0.03em;
  }

  /* The scan state and the scan switch are the same control: two buttons that
     swapped places under the cursor was a way to stop a scan by accident. */
  .scan {
    min-height: var(--touch-min);
    color: var(--ink-dim);
  }

  .scan.live {
    color: var(--ok);
  }

  header .btn.primary {
    margin-left: auto;
  }

  /* An icon control still needs a name for a screen reader, and the keycap is
     hidden on touch by app.css — so the button is never empty either way. */
  .icon-btn {
    padding: 0.5em;
    aspect-ratio: 1;
  }

  .icon-btn svg {
    width: 1.15rem;
    height: 1.15rem;
  }

  .sr {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* ---- role tiles ------------------------------------------------------ */

  .roles {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(13.5rem, 1fr));
    gap: 0.5rem;
    padding: 0 var(--edge) 0.7rem;
  }

  .tile {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    padding: 0.7rem 0.8rem 0.6rem;
    border-radius: 0.7rem;
    background: var(--bg-lift);
    /* Structure comes from space, per app.css — the border only appears when
       it carries meaning, which here is "this slot is filled". */
    border: 1px solid transparent;
    min-width: 0;
  }

  .tile.filled {
    border-color: color-mix(in srgb, var(--ok) 28%, transparent);
  }

  /* The one slot a ride cannot happen without. Amber, not red: nothing is
     broken, something is simply not connected yet. */
  .tile.wanted {
    border-color: color-mix(in srgb, var(--warn) 22%, transparent);
  }

  .head {
    display: flex;
    align-items: center;
    gap: 0.5rem;
  }

  .slot {
    font-size: var(--type-label);
    font-weight: 600;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--ink-dim);
  }

  .mark {
    margin-left: auto;
    display: inline-flex;
    align-items: center;
  }

  .mark svg {
    width: 1.05rem;
    height: 1.05rem;
  }

  .who {
    margin-top: 0.3rem;
    font-size: 1.05rem;
    font-weight: 600;
    letter-spacing: -0.015em;
    color: var(--ink-soft);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .tile.filled .who {
    color: var(--ink);
  }

  .detail {
    font-size: 0.78rem;
    color: var(--ink-dim);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .act {
    display: flex;
    gap: 0.3rem;
    margin-top: 0.5rem;
  }

  .act .btn {
    padding: 0.4em 0.75em;
    font-size: 0.82rem;
  }

  .act:empty {
    display: none;
  }

  .list {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    padding: 0.2rem var(--edge) 2rem;
  }

  /*
   * On a phone the whole screen scrolls as one document instead of pinning a
   * header over a scrolling list. Giving the list its own scroll region there
   * would leave it a few pixels tall — the Click panel is taller than the
   * viewport on its own — so the list would technically scroll and still be
   * unusable.
   */
  :global([data-size='compact']) .screen {
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
  }

  :global([data-size='compact']) .list {
    flex: none;
    overflow-y: visible;
  }

  /* One line, wrapping to two only when it must — no fixed track count, so a
     row with nothing to report does not reserve space for the badges it does
     not have. */
  .list-head {
    margin: 0.2rem 0 0.1rem;
  }

  .row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.55rem 0.8rem;
    padding: 0.6rem 0.25rem;
    border-bottom: 1px solid var(--hairline);
  }

  .icon {
    display: grid;
    place-items: center;
    width: 2rem;
    height: 2rem;
    flex: none;
    border-radius: 0.5rem;
    background: var(--bg-lift);
    color: var(--ink-dim);
  }

  .icon.on {
    background: color-mix(in srgb, var(--ok) 14%, transparent);
    color: var(--ok);
  }

  .icon svg {
    width: 1.15rem;
    height: 1.15rem;
  }

  .identity {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    flex: 1 1 9rem;
    min-width: 0;
  }

  .name {
    display: flex;
    align-items: baseline;
    gap: 0.4rem;
    min-width: 0;
    font-size: 1.02rem;
    font-weight: 600;
    letter-spacing: -0.01em;
    color: var(--ink-soft);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .row.live .name {
    color: var(--ink);
  }

  .tail {
    flex: none;
    color: var(--ink-faint);
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.06em;
  }

  .sub {
    font-size: 0.76rem;
    color: var(--ink-dim);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .error {
    font-size: 0.8rem;
    color: var(--bad);
  }

  .badges {
    display: flex;
    gap: 0.3rem;
    flex-wrap: wrap;
  }

  .badges .chip {
    background: transparent;
    padding: 0;
    font-size: 0.82rem;
  }

  .bars {
    display: flex;
    align-items: flex-end;
    gap: 2px;
    height: 1rem;
    flex: none;
  }

  .bars i {
    width: 3px;
    background: var(--ink-faint);
    border-radius: 1px;
  }

  .bars i.on {
    background: var(--ok);
  }

  .controls {
    display: flex;
    gap: 0.3rem;
    margin-left: auto;
  }

  .controls .btn {
    padding: 0.45em 0.8em;
    font-size: 0.85rem;
  }

  .others {
    margin-top: 0.8rem;
    font-size: 0.85rem;
  }

  .others summary {
    cursor: pointer;
    padding: 0.5rem 0.25rem;
    color: var(--ink-dim);
    min-height: var(--touch-min);
  }

  .hint {
    color: var(--ink-faint);
  }

  .empty {
    max-width: 34rem;
    margin: clamp(1.5rem, 6vh, 4rem) auto;
    color: var(--ink-soft);
  }

  .empty h2 {
    margin: 0 0 0.4rem;
    font-size: 1.35rem;
    font-weight: 300;
    letter-spacing: -0.02em;
    color: var(--ink);
  }

  .empty p {
    margin: 0.3rem 0;
    font-size: 0.9rem;
  }

  .empty ul {
    margin: 0.5rem 0;
    padding-left: 1.1rem;
    line-height: 1.8;
    font-size: 0.9rem;
  }

  .empty strong {
    color: var(--ink);
    font-weight: 600;
  }

  .quiet {
    color: var(--ink-faint);
    font-size: 0.85rem;
  }

  /*
   * Narrow: the badges and the buttons drop below the name rather than
   * squeezing it. Driven by the measured size class rather than a `max-width`
   * of its own — see ui/src/lib/viewport.ts for why there is exactly one
   * definition of "narrow" in this codebase.
   */
  :global([data-size='compact']) .badges {
    order: 3;
    flex: 1 1 100%;
    padding-left: 2.55rem;
  }

  :global([data-size='compact']) .controls {
    order: 4;
    flex: 1 1 100%;
    margin-left: 2.55rem;
  }
</style>
