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>
876 lines
27 KiB
Svelte
876 lines
27 KiB
Svelte
<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>
|