Files
BikeControl/ui/src/components/ConnectionScreen.svelte
T
dtourolleandClaude Opus 5 8964a0fd74 Pair the pod that works
Both pods, same bench, minutes apart, no writes:

  − pod   buttons stop at ~51 s, every session; flag 0 -> 1; 2 key offers
  + pod   133 s, 78 paddle edges, flag never flipped, no key offer at all

So the `−` pod is not the controller. It relays more when it works — all
ten buttons, its twin's included — but "when it works" is under a minute
without a Zwift blessing in the last day, and the `+` pod alone is a
complete shifter: its paddle shifts up, `Y` shifts down, both already
mapped. Shifting is what a ride cannot do without.

`take_plus_pod` becomes `take_minus_pod` — the same rule with the pods
exchanged — and the housekeeping, the connect guard and the
no-pod-named default follow it. Opening both is still what stops the `−`
pod reporting its own paddle, so it is still one link, just the other one.

The UI stops telling riders to press the pod that dies: the panel asks
for the `+` pod, the tile prefers it, and the `−` pod's row says what it
actually offers — all ten buttons, for about fifty seconds.

This settles A-2 from the other end too. The keep-alive that "removes the
daily unlock, but only for the right controller" removes nothing. The
right controller never needed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:09:26 +02:00

876 lines
27 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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, type Pod } 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.
*/
/** Drop a pod's link and take it again — the fix for a wedged session. */
async function reconnectPod(pod: Pod) {
await api.disconnectController(pod);
await api.connectController(pod);
}
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;
// The `+` pod first: it is the one that keeps reporting (§2.3.3).
const live = plus?.state === 'connected' ? plus : minus?.state === 'connected' ? minus : 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 === 'plus' ? 'Shift up · Y shifts down' : 'All ten buttons, for ~50 s',
live.batteryPercent != null ? `${live.batteryPercent}%` : null,
live.buttonsSeen > 0 ? `${live.buttonsSeen} presses` : 'no presses yet',
]
.filter(Boolean)
.join(' · ')
: 'Press a button on the + pod — the keyboard works meanwhile',
tone: live ? 'tone-ok' : 'tone-idle',
// Connected, the useful action is *rebuild the link*. A Click can wedge
// while looking perfectly healthy — battery arriving every five seconds,
// every press ignored (§7.1) — and the supervisor's own recovery is
// deliberately slow, because it cannot tell a wedged pod from a rider who
// simply is not shifting. The rider can, instantly.
action: live
? { label: 'Reconnect', run: () => app.run(() => reconnectPod(live.pod)) }
: { 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?.plus.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>