Files
BikeControl/ui/src/components/ClickPanel.svelte
T
dtourolleandClaude Opus 5 cdff678167 Pair roles, not radios
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) <noreply@anthropic.com>
2026-08-21 20:15:10 +02:00

437 lines
13 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">
/**
* The Zwift Click (FR-1.4, FR-9.19.2).
*
* A Click v2 is two peripherals, but it is **one controller**: connected on
* its own, the `` pod delivers all ten buttons — its own paddle and D-pad,
* *and* the `+` paddle and face buttons relayed from its twin (§2.3.1,
* confirmed on this hardware 2026-08-21). So one link is the whole thing, and
* this panel says so rather than presenting two halves that both look
* required. Pairing both is not merely redundant: it is the configuration in
* which the `` pod stops reporting its own paddle.
*
* 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`).
*
* 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". 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 } from '../lib/bridge';
const controller = $derived(app.controller);
const pods = $derived(controller ? [controller.minus, controller.plus] : []);
/** Auto-connect rides on the device scan, so a stopped scan is a reason a
* pod stays missing — and the fix belongs next to the symptom. */
const scanning = $derived(app.devices.scanning);
const anyConnected = $derived(pods.some((p) => p.state === 'connected'));
/** The pod that speaks for the pair. Connected, this is the whole controller. */
const minusLive = $derived(controller?.minus.state === 'connected');
/** Running on the fallback: the `+` pod alone, with no `` paddle to shift
* down with beyond its `Y` button. Worth saying out loud. */
const plusOnly = $derived(!minusLive && controller?.plus.state === 'connected');
const busy = $derived(pods.some((p) => p.state === 'searching'));
/** A pod reporting the other's paddle: the pair may be filed the wrong way
* round, and the rider is the only one who can say. */
const mixedUp = $derived(pods.some((p) => p.contradicted));
const STATE_TEXT: Record<PodState, string> = {
idle: 'Not connected',
searching: 'Searching…',
connected: 'Connected',
reconnecting: 'Reconnecting…',
gaveUp: 'Gave up',
};
const STATE_TONE: Record<PodState, string> = {
idle: 'tone-idle',
searching: 'tone-warn',
connected: 'tone-ok',
reconnecting: 'tone-warn',
gaveUp: 'tone-bad',
};
/** What each pod is for, so a rider who has lost one knows what they lost. */
const PURPOSE: Record<Pod, string> = {
minus: 'All ten buttons',
plus: 'Fallback · shift up, A B Y Z',
};
function connect(pod: Pod) {
app.run(() => api.connectController(pod));
}
function disconnect(pod: Pod) {
app.run(() => api.disconnectController(pod));
}
</script>
<section class="click">
<header>
<h2>Zwift Click</h2>
<div class="actions">
{#if !scanning}
<!-- Nothing can be picked up automatically while the scan is off, so
the way to fix that sits here rather than only in the header. -->
<button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button>
{:else if !minusLive}
<!-- The pod, not both: it is the one that carries the whole
controller. The + pod has its own button on its own row. -->
<button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}>
{busy ? 'Searching…' : 'Find pod'}
</button>
{/if}
{#if anyConnected}
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
Disconnect
</button>
{/if}
</div>
</header>
<!-- Only while it is telling the rider something they cannot see. Connected,
the badge above has already said it. -->
{#if !scanning}
<p class="lede"><strong>The scan is off</strong> — pods will not be picked up.</p>
{:else if plusOnly}
<p class="lede">
Running on the <strong>+ pod alone</strong>: shift down with <span class="kbd">Y</span>.
Press a button on the pod for the D-pad.
</p>
{:else if !minusLive}
<p class="lede">
<strong>Press any button on the pod.</strong> It only advertises while awake; the running
scan connects it as soon as it does.
</p>
{/if}
<div class="pods">
{#each pods as pod (pod.pod)}
{@const dormant = pod.pod === 'plus' && minusLive && pod.state !== 'connected'}
<article class="pod" class:live={pod.state === 'connected'}>
<!-- The paddle glyph is the pod's identity — big enough to match
against the one printed on the hardware at arm's length. -->
<span class="paddle" class:on={pod.state === 'connected'}>{pod.symbol}</span>
<div class="what">
<span class="pod-name">{pod.symbol} pod</span>
<span class="purpose">
{#if dormant}
<!-- Not a fault, and the panel must not let it read as one: this
pod is idle because the pod is already sending its buttons. -->
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}
</span>
</div>
<span class="facts">
{#if pod.batteryPercent != null}<span>{pod.batteryPercent}%</span>{/if}
<!-- Connected and silent looks exactly like working until you press
something, so the count is the honest test of the link. -->
{#if pod.state === 'connected'}
<span>{pod.buttonsSeen === 0 ? 'no presses yet' : `${pod.buttonsSeen} presses`}</span>
{/if}
</span>
<span class="state {dormant ? 'tone-idle' : STATE_TONE[pod.state]}">
<span class="dot"></span>{dormant ? 'Standing by' : STATE_TEXT[pod.state]}
</span>
<div class="controls">
{#if pod.state === 'connected' || pod.state === 'reconnecting'}
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Disconnect</button>
{:else if pod.state === 'searching'}
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop</button>
{:else}
<button class="btn ghost" onclick={() => connect(pod.pod)}>
{dormant ? 'Connect anyway' : 'Find it'}
</button>
{/if}
</div>
<!-- Verbatim (FR-9.2). Rust writes these as instructions, not codes. -->
{#if pod.error}<p class="note tone-bad">{pod.error}</p>{/if}
</article>
{/each}
{#if pods.length === 0}
<p class="note">Waiting for the controller supervisor…</p>
{/if}
</div>
{#if mixedUp || controller?.swapped}
<div class="swap">
<span>
{mixedUp
? 'A pod is sending the other pods paddle — the pair may be filed the wrong way round.'
: 'The pods are swapped from what their advertisement claims.'}
</span>
<button class="btn ghost" onclick={() => app.run(() => api.swapControllerPods())}>
Swap + /
</button>
</div>
{/if}
{#if !minusLive}
<!--
FR-1.8. "Not connected" on its own reads as a broken app, and the real
cause — a pod that is simply asleep — is something only the rider can fix.
The lede above says that much; the rest is for the rider it did not work
for, and stays folded until they ask, because it is five paragraphs of
things that are usually already true.
-->
<details class="help">
<summary>Still not connecting?</summary>
<ul>
<li><strong>Press a button.</strong> A pod that is asleep does not advertise at all.</li>
<li><strong>Keep the scan on.</strong> Auto-connect runs off it.</li>
<li><strong>Close Zwift.</strong> One app at a time holds a pod.</li>
<li><strong>Charge it.</strong> A flat pod stops advertising, and the app stops chasing.</li>
<li>
<strong>Or use the + pod</strong> — you lose the D-pad, <span class="kbd">Y</span> still
shifts down.
</li>
<li>
The keyboard mirrors every Click action; <span class="kbd">?</span> lists them. A ride
never depends on a pod.
</li>
</ul>
</details>
{/if}
</section>
<style>
.click {
margin: 0 var(--edge) 0.7rem;
padding: 0.75rem 0.9rem 0.85rem;
border-radius: 0.7rem;
background: var(--bg-lift);
}
header {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.lede {
margin: 0.45rem 0 0;
font-size: 0.84rem;
line-height: 1.45;
color: var(--ink-dim);
}
.lede strong {
color: var(--ink-soft);
}
.actions {
display: flex;
gap: 0.4rem;
margin-left: auto;
}
.actions .btn {
padding: 0.45em 0.8em;
font-size: 0.85rem;
}
.pods {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 0.6rem;
}
/* A row, not a card: two pods stacked read as one controller with a fallback,
which is what they are. Two equal cards read as two things to pair. */
.pod {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem 0.7rem;
padding: 0.45rem 0.55rem;
border-radius: 0.5rem;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.015);
}
.pod.live {
border-color: color-mix(in srgb, var(--ok) 30%, transparent);
}
.paddle {
display: grid;
place-items: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 0.45rem;
background: var(--hairline);
color: var(--ink-dim);
font-size: 1.15rem;
font-weight: 300;
line-height: 1;
flex: none;
}
.paddle.on {
background: color-mix(in srgb, var(--ok) 18%, transparent);
color: var(--ok);
}
.what {
display: flex;
flex-direction: column;
flex: 1 1 10rem;
min-width: 0;
}
/* Not `.label`: that is app.css's uppercase, letter-spaced section label,
and a pod's name is a name, not a heading. */
.pod-name {
font-size: 0.95rem;
font-weight: 600;
}
.purpose {
font-size: 0.76rem;
color: var(--ink-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.facts {
display: flex;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--ink-dim);
white-space: nowrap;
}
.state {
display: inline-flex;
align-items: center;
gap: 0.4em;
font-size: 0.84rem;
font-weight: 600;
white-space: nowrap;
}
.controls {
display: flex;
gap: 0.4rem;
margin-left: auto;
}
.controls .btn {
padding: 0.4em 0.7em;
font-size: 0.82rem;
}
.note {
flex: 1 1 100%;
margin: 0;
font-size: 0.82rem;
color: var(--ink-soft);
line-height: 1.4;
}
.swap {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: 0.5rem;
padding: 0.45rem 0.7rem;
border-radius: 0.5rem;
background: rgba(255, 207, 74, 0.07);
color: var(--ink-soft);
font-size: 0.83rem;
}
.swap button {
margin-left: auto;
}
.help {
margin-top: 0.55rem;
font-size: 0.83rem;
color: var(--ink-dim);
}
summary {
cursor: pointer;
color: var(--ink-dim);
font-weight: 600;
min-height: var(--touch-min);
display: flex;
align-items: center;
}
/* `display: flex` on a summary drops the browser's own marker, and a
disclosure with nothing to disclose-looking about it does not read as one. */
summary::before {
content: '';
width: 0;
height: 0;
margin-right: 0.5rem;
border-left: 5px solid currentColor;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
opacity: 0.6;
transition: transform 120ms ease;
}
details[open] > summary::before {
transform: rotate(90deg);
}
.help ul {
margin: 0.2rem 0 0;
padding-left: 1.1rem;
line-height: 1.6;
}
.help strong {
color: var(--ink-soft);
}
/* Narrow: the state and the buttons drop below the pod's name rather than
crushing it. */
:global([data-size='compact']) .facts,
:global([data-size='compact']) .state {
order: 3;
}
:global([data-size='compact']) .controls {
order: 4;
flex: 1 1 100%;
margin-left: 2.3rem;
}
</style>