From cdff678167727fc069213cb838d1b21e6985f921 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 21 Aug 2026 20:15:10 +0200 Subject: [PATCH] Pair roles, not radios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device screen answered the wrong question. A rider arriving at it has three: is the trainer on, will it take control, is the Click awake. A list sorted by signal strength answers none of them without being read, and it opened with a MAC address on every row — a number nobody types, acts on, or can tell from the one below it, and on Android a randomised one that changes anyway. So the setup comes first, as slots: Trainer, Shifter, Heart rate. Each says what is filling it, what is standing in the way, and the one action that would fix it. The list is still everything FR-9.1 asks for; it is just no longer the first thing to read. - FR-9.3 lives in the trainer tile: green only when connected *and* controlling, because a trainer that is attached and uncontrollable will not move the resistance. Its action then is Reconnect, since FTMS control is requested once at connect time — offering "acquire control" as a button the rider had failed to press would be a lie about what the protocol does. - Peripherals of no known role fold into a collapsed list. A scan in a flat picks up a dozen phones and a TV, and each was a full-height row between the rider and their trainer. The fold opens itself when no trainer has been identified at all, because a trainer that does not advertise FTMS until something connects classifies as unknown — that is the one case this must not swallow. - Kind is a glyph, identity is the name. Where two rows would otherwise be indistinguishable — a pair of pods, a room of "(no name)" — four characters of the address disambiguate them and nothing more. - Forget appears only on remembered devices. Forgetting a device that was never remembered is a no-op the rider had to read past on every row. - The Click panel is a repair manual, so it appears when there is something to repair. Its five-step drill used to be `open` by default in exactly the state riders hit most; it is now folded behind a summary, and its lede renders only while it is saying something the tile cannot. Co-Authored-By: Claude Opus 5 (1M context) --- ui/src/components/ClickPanel.svelte | 438 +++++----- ui/src/components/ConnectionScreen.svelte | 933 +++++++++++++++------- 2 files changed, 844 insertions(+), 527 deletions(-) diff --git a/ui/src/components/ClickPanel.svelte b/ui/src/components/ClickPanel.svelte index 642725a..c939335 100644 --- a/ui/src/components/ClickPanel.svelte +++ b/ui/src/components/ClickPanel.svelte @@ -10,25 +10,24 @@ * required. Pairing both is not merely redundant: it is the configuration in * which the `−` pod stops reporting its own paddle. * - * The `+` pod keeps a card, because it is the fallback that matters when the - * `−` pod is flat or left in the garage — a rider with one working pod should - * still get a working controller. + * The `+` pod keeps a row of its own, because it is the fallback that matters + * when the `−` pod is flat or left in the garage. * * They are named for the shift paddle each carries, not for the side of the - * bar. Nothing a pod advertises says which end of the handlebar it is - * clamped to, so left and right would be a guess; the paddle is printed on - * the pod, and pressing it settles the question on screen (`confirmed`). + * bar. Nothing a pod advertises says which end of the handlebar it is clamped + * to, so left and right would be a guess; the paddle is printed on the pod, + * and pressing it settles the question on screen (`confirmed`). * * The second job of this panel is to say what to *do* when nothing is - * connected. A Click sleeps within seconds and only advertises while awake - * (A-4), which no rider can guess from the words "not connected" — and which - * is also why connecting is not a button they have to win a race with: the - * running scan picks a pod up the moment it wakes and connects it (FR-1.5), - * and once paired it goes back to the pod it knows. The buttons here are for - * overriding that, not for driving it. + * connected — a Click sleeps within seconds and only advertises while awake + * (A-4), which no rider can guess from the words "not connected". But that is + * the only thing worth a sentence, and only while it is true: connected, this + * panel is two lines and a badge. The five-step drill it used to open with + * lives behind a summary now, and the pods' MAC addresses are gone — they + * identified nothing a rider could act on. */ import { app } from '../lib/app.svelte'; - import { api, type Pod, type PodState, type PodStatus } from '../lib/bridge'; + import { api, type Pod, type PodState } from '../lib/bridge'; const controller = $derived(app.controller); const pods = $derived(controller ? [controller.minus, controller.plus] : []); @@ -64,7 +63,7 @@ /** What each pod is for, so a rider who has lost one knows what they lost. */ const PURPOSE: Record = { - minus: 'All ten buttons · relays the + pod', + minus: 'All ten buttons', plus: 'Fallback · shift up, A B Y Z', }; @@ -80,15 +79,6 @@

Zwift Click

- - {#if minusLive} - Connected · all ten buttons - {:else if plusOnly} - + pod only - {:else} - Not connected - {/if} -
{#if !scanning} + controller. The + pod has its own button on its own row. --> {/if} {#if anyConnected} @@ -109,93 +99,74 @@
-

- {#if !scanning} - The scan is off, so pods will not be picked up. Start it and press a button - on a pod. - {:else if minusLive} - The − pod is connected, and it relays its twin: all ten buttons arrive over - this one link. There is nothing to pair the + pod for. It reconnects on its own if it drops, - and on the next launch. - {:else if plusOnly} - Running on the + pod alone — its paddle, face buttons and shift-down on - Y. Press a button on the − pod to get the D-pad back. - {:else} - Press any button on the − pod. It only advertises while awake, and the - running scan connects it as soon as it does — no need to press anything here. - {/if} -

+ + {#if !scanning} +

The scan is off — pods will not be picked up.

+ {:else if plusOnly} +

+ Running on the + pod alone: shift down with Y. + Press a button on the − pod for the D-pad. +

+ {:else if !minusLive} +

+ Press any button on the − pod. It only advertises while awake; the running + scan connects it as soon as it does. +

+ {/if}
{#each pods as pod (pod.pod)} + {@const dormant = pod.pod === 'plus' && minusLive && pod.state !== 'connected'}
-
- {pod.symbol} - - {pod.symbol} pod - {PURPOSE[pod.pod]} - - - {STATE_TEXT[pod.state]} + + {pod.symbol} + +
+ {pod.symbol} pod + + {#if dormant} + + Relayed by the − pod + {:else if pod.confirmed} + Confirmed — sent its own {pod.symbol} paddle + {:else if pod.state === 'connected'} + Press its {pod.symbol} paddle to confirm + {:else} + {PURPOSE[pod.pod]} + {/if}
-
-
-
Battery
-
{pod.batteryPercent != null ? `${pod.batteryPercent}%` : '—'}
-
-
-
Buttons seen
- -
- {pod.buttonsSeen === 0 ? 'none yet' : `${pod.buttonsSeen}`} - {#if pod.lastButton}· {pod.lastButton}{/if} -
-
-
-
Address
-
{pod.address ?? '—'}
-
-
+ + {#if pod.batteryPercent != null}{pod.batteryPercent}%{/if} + + {#if pod.state === 'connected'} + {pod.buttonsSeen === 0 ? 'no presses yet' : `${pod.buttonsSeen} presses`} + {/if} + - {#if pod.pod === 'plus' && minusLive && pod.state !== 'connected'} - -

- Not needed — the − pod is relaying this pod's paddle and face buttons. -

- {:else if pod.confirmed} -

Confirmed — this pod sent its own {pod.symbol} paddle.

- {:else if pod.state === 'connected'} -

- Press the {pod.symbol} paddle on this pod to confirm it is the one. -

- {/if} - - {#if pod.contradicted} -

- This pod sent the other paddle. If the pair is the wrong way round, swap them. -

- {/if} - - {#if pod.error} - -

{pod.error}

- {/if} + + {dormant ? 'Standing by' : STATE_TEXT[pod.state]} +
{#if pod.state === 'connected' || pod.state === 'reconnecting'} {:else if pod.state === 'searching'} - + {:else} {/if}
+ + + {#if pod.error}

{pod.error}

{/if}
{/each} @@ -207,11 +178,9 @@ {#if mixedUp || controller?.swapped}
- {#if mixedUp} - A pod is sending the other pod's paddle — the pair may be filed the wrong way round. - {:else} - The pods are swapped from what their advertisement claims. - {/if} + {mixedUp + ? 'A pod is sending the other pod’s paddle — the pair may be filed the wrong way round.' + : 'The pods are swapped from what their advertisement claims.'}
diff --git a/ui/src/components/ConnectionScreen.svelte b/ui/src/components/ConnectionScreen.svelte index 0b3b6af..5dad704 100644 --- a/ui/src/components/ConnectionScreen.svelte +++ b/ui/src/components/ConnectionScreen.svelte @@ -5,8 +5,18 @@ * 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 every trainer row carries two independent states, side by side, and the - * ride screen stays gated until control is real. + * 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'; @@ -18,14 +28,23 @@ 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 = { - trainer: 'Smart trainer · FTMS', + 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 monitor', - unknown: 'Unidentified', + heartRate: 'Heart rate', + unknown: 'Bluetooth device', }; function isConnected(d: DeviceInfo): boolean { @@ -35,186 +54,418 @@ 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; + } + +{#snippet kindIcon(kind: DeviceKind)} + +{/snippet} + +{#snippet roleTile(role: Role)} +
+
+ {@render kindIcon(role.kind)} + {role.title} + + {#if role.filled} + + + + + {:else} + + {/if} + +
+ {role.primary} + {#if role.detail}{role.detail}{/if} +
+ {#if role.action} + + {/if} + {#if role.secondary} + + {/if} +
+
+{/snippet} + +{#snippet deviceRow(device: DeviceInfo)} + {@const bars = rssiBars(device.rssi)} +
+ + {@render kindIcon(device.kind)} + + +
+ + {device.name} + {#if ambiguous(device)}{shortId(device.address)}{/if} + + + {KIND_LABEL[device.kind]} + + {#if device.remembered}· remembered{/if} + + {#if device.error}{device.error}{/if} +
+ + + {#each badges(device) as badge} + {badge.label} + {/each} + + + + {#each [1, 2, 3, 4] as bar} + + {/each} + + +
+ {#if isConnected(device)} + + {:else} + + {/if} + + {#if device.remembered} + + {/if} +
+
+{/snippet} +
-
-

Devices

-

- {#if scanning} - Scanning for Bluetooth peripherals… - {:else} - Scan stopped. - {/if} -

-
-
- {#if scanning} - - {:else} - - {/if} - - -
+

Devices

+ + + +
- +
+ {#each roles as role (role.key)} + {@render roleTile(role)} + {/each} +
- {#if !trainerReady} -
- - No trainer under control yet. The ride screen stays locked until an FTMS - trainer accepts the control point. -
+ + {#if clickNeedsHelp} + {/if}
- {#each devices as device (device.id)} - {@const conn = connectionText(device.state)} - {@const bars = rssiBars(device.rssi)} -
-
- - {device.name} - - {#if device.remembered} - Remembered - {/if} - - {KIND_LABEL[device.kind]} · {device.address} - {#if device.error} - {device.error} - {/if} -
- -
- - {#each [1, 2, 3, 4] as bar} - - {/each} - - {device.rssi} -
- - -
- - Bluetooth - - {busy(device) ? conn.label + '…' : conn.label} - - - - {#if device.kind === 'trainer'} - - FTMS control - - {device.controlAcquired ? 'Acquired' : 'Not acquired'} - - - {:else if device.kind === 'clickMinus' || device.kind === 'clickPlus'} - - {@const pod = device.kind === 'clickPlus' ? app.controller?.plus : app.controller?.minus} - - Click pod - - {pod?.symbol ?? '?'} pod{pod?.state === 'connected' - ? ' · linked' - : ''} - - - {:else if device.kind === 'heartRate'} - - - Heart rate - - {device.heartRateBpm != null - ? `${device.heartRateBpm} bpm` - : '—'} - - - {#if device.batteryPct != null} - - Battery - {device.batteryPct}% - - {/if} - {:else if device.batteryPct != null} - - Battery - {device.batteryPct}% - - {/if} -
- -
- {#if isConnected(device)} - - {:else} - - {/if} - -
-
+ + {#if mine.length > 0}

All devices

{/if} + {#each mine as device (device.id)} + {@render deviceRow(device)} {/each} - {#if devices.length === 0} - + {#if mine.length === 0} +

Nothing found yet

-

Most devices sleep until you touch them. To wake them:

+

Devices sleep until you touch them:

    -
  • Trainer — turn the pedals for a few seconds.
  • -
  • Zwift Click — press any button on the pod.
  • -
  • - Heart rate — wet a strap's contacts and put it on; on a sports watch, - switch on Broadcast Heart Rate. -
  • +
  • Trainer — turn the pedals.
  • +
  • Zwift Click — press a button.
  • +
  • Heart rate — wet the strap and put it on.
-

- They will appear here as soon as they advertise. Scanning continues in the background. -

+

They appear here as soon as they advertise.

{/if} + + {#if others.length > 0} + +
+ + {others.length} other Bluetooth {others.length === 1 ? 'device' : 'devices'} + + {#if noTrainer}— a trainer that hides its services is in here{/if} + + {#each others as device (device.id)} + {@render deviceRow(device)} + {/each} +
+ {/if}
@@ -248,52 +499,154 @@ header { display: flex; - align-items: flex-end; - gap: var(--gap); - padding: clamp(1.4rem, 3vw, 2.6rem) var(--edge) 1rem; + align-items: center; + gap: 0.6rem; + padding: clamp(1rem, 2.4vw, 1.8rem) var(--edge) 0.7rem; } h1 { margin: 0; - font-size: clamp(1.6rem, 2.6vw, 2.4rem); + font-size: clamp(1.4rem, 2.2vw, 2rem); font-weight: 300; letter-spacing: -0.03em; } - header p { - margin: 0.2rem 0 0; + /* 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); - font-size: 0.92rem; } - .actions { - display: flex; - gap: 0.5rem; + .scan.live { + color: var(--ok); + } + + header .btn.primary { margin-left: auto; } - .gate { - display: flex; - align-items: center; - gap: 0.6rem; - margin: 0 var(--edge) 0.6rem; - padding: 0.7rem 0.9rem; - border-radius: 0.55rem; - background: rgba(255, 207, 74, 0.06); - color: var(--ink-soft); - font-size: 0.9rem; + /* 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; } - .gate strong { - color: var(--warn); + .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.4rem var(--edge) 2rem; + padding: 0.2rem var(--edge) 2rem; } /* @@ -313,72 +666,108 @@ 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: grid; - grid-template-columns: minmax(0, 1.5fr) auto minmax(0, 1.4fr) auto; + display: flex; align-items: center; - gap: var(--gap); - padding: 1rem 0.25rem; + flex-wrap: wrap; + gap: 0.55rem 0.8rem; + padding: 0.6rem 0.25rem; border-bottom: 1px solid var(--hairline); } - .row.live .name { - color: var(--ink); + .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.18rem; + gap: 0.1rem; + flex: 1 1 9rem; min-width: 0; } .name { display: flex; align-items: baseline; - gap: 0.45rem; + gap: 0.4rem; min-width: 0; - font-size: 1.12rem; + font-size: 1.02rem; font-weight: 600; letter-spacing: -0.01em; + color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .badge { - flex: none; - padding: 0.1rem 0.4rem; - border: 1px solid var(--hairline); - border-radius: 0.3rem; - color: var(--ink-dim); - font-size: 0.68rem; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; + .row.live .name { + color: var(--ink); } - .meta { - font-size: 0.8rem; + .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.82rem; + font-size: 0.8rem; color: var(--bad); } - .signal { + .badges { display: flex; - align-items: center; - gap: 0.45rem; + 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: 1.15rem; + height: 1rem; + flex: none; } .bars i { @@ -391,58 +780,57 @@ background: var(--ok); } - .dbm { - font-size: 0.78rem; - color: var(--ink-dim); - min-width: 2.3em; - } - - .states { - display: flex; - gap: var(--gap); - flex-wrap: wrap; - } - - .state { - display: flex; - flex-direction: column; - gap: 0.2rem; - } - - .state .value { - display: inline-flex; - align-items: center; - gap: 0.4em; - font-size: 0.92rem; - font-weight: 600; - white-space: nowrap; - } - .controls { display: flex; - gap: 0.4rem; - justify-content: flex-end; + 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: 42rem; - margin: clamp(2rem, 8vh, 6rem) auto; - text-align: left; + max-width: 34rem; + margin: clamp(1.5rem, 6vh, 4rem) auto; color: var(--ink-soft); } .empty h2 { - margin: 0 0 0.5rem; - font-size: 1.6rem; + 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.6rem 0; + margin: 0.5rem 0; padding-left: 1.1rem; - line-height: 1.9; + line-height: 1.8; + font-size: 0.9rem; } .empty strong { @@ -452,43 +840,24 @@ .quiet { color: var(--ink-faint); - font-size: 0.88rem; + font-size: 0.85rem; } /* - * Narrow: a device row becomes three stacked bands instead of four columns. - * 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. + * 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']) .row, - :global([data-size='medium']) .row { - grid-template-columns: 1fr auto; - grid-template-areas: - 'identity signal' - 'states states' - 'controls controls'; + :global([data-size='compact']) .badges { + order: 3; + flex: 1 1 100%; + padding-left: 2.55rem; } - :global([data-size='compact']) .identity, - :global([data-size='medium']) .identity { - grid-area: identity; - } - - :global([data-size='compact']) .signal, - :global([data-size='medium']) .signal { - grid-area: signal; - } - - :global([data-size='compact']) .states, - :global([data-size='medium']) .states { - grid-area: states; - } - - :global([data-size='compact']) .controls, - :global([data-size='medium']) .controls { - grid-area: controls; - justify-content: flex-start; - flex-wrap: wrap; + :global([data-size='compact']) .controls { + order: 4; + flex: 1 1 100%; + margin-left: 2.55rem; }