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>
This commit is contained in:
2026-08-21 20:15:10 +02:00
co-authored by Claude Opus 5
parent 4269c5a446
commit cdff678167
2 changed files with 844 additions and 527 deletions
+186 -238
View File
@@ -10,25 +10,24 @@
* required. Pairing both is not merely redundant: it is the configuration in * required. Pairing both is not merely redundant: it is the configuration in
* which the `` pod stops reporting its own paddle. * which the `` pod stops reporting its own paddle.
* *
* The `+` pod keeps a card, because it is the fallback that matters when the * The `+` pod keeps a row of its own, because it is the fallback that matters
* `` pod is flat or left in the garage — a rider with one working pod should * when the `` pod is flat or left in the garage.
* still get a working controller.
* *
* They are named for the shift paddle each carries, not for the side of the * 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 * bar. Nothing a pod advertises says which end of the handlebar it is clamped
* clamped to, so left and right would be a guess; the paddle is printed on * to, so left and right would be a guess; the paddle is printed on the pod,
* the pod, and pressing it settles the question on screen (`confirmed`). * and pressing it settles the question on screen (`confirmed`).
* *
* The second job of this panel is to say what to *do* when nothing is * 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 * 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 * (A-4), which no rider can guess from the words "not connected". But that is
* is also why connecting is not a button they have to win a race with: the * the only thing worth a sentence, and only while it is true: connected, this
* running scan picks a pod up the moment it wakes and connects it (FR-1.5), * panel is two lines and a badge. The five-step drill it used to open with
* and once paired it goes back to the pod it knows. The buttons here are for * lives behind a summary now, and the pods' MAC addresses are gone — they
* overriding that, not for driving it. * identified nothing a rider could act on.
*/ */
import { app } from '../lib/app.svelte'; 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 controller = $derived(app.controller);
const pods = $derived(controller ? [controller.minus, controller.plus] : []); 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. */ /** What each pod is for, so a rider who has lost one knows what they lost. */
const PURPOSE: Record<Pod, string> = { const PURPOSE: Record<Pod, string> = {
minus: 'All ten buttons · relays the + pod', minus: 'All ten buttons',
plus: 'Fallback · shift up, A B Y Z', plus: 'Fallback · shift up, A B Y Z',
}; };
@@ -80,15 +79,6 @@
<section class="click"> <section class="click">
<header> <header>
<h2>Zwift Click</h2> <h2>Zwift Click</h2>
<span class="summary" class:tone-ok={anyConnected} class:tone-warn={!anyConnected}>
{#if minusLive}
Connected · all ten buttons
{:else if plusOnly}
+ pod only
{:else}
Not connected
{/if}
</span>
<div class="actions"> <div class="actions">
{#if !scanning} {#if !scanning}
<!-- Nothing can be picked up automatically while the scan is off, so <!-- Nothing can be picked up automatically while the scan is off, so
@@ -96,9 +86,9 @@
<button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button> <button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button>
{:else if !minusLive} {:else if !minusLive}
<!-- The pod, not both: it is the one that carries the whole <!-- The pod, not both: it is the one that carries the whole
controller. The + pod has its own button on its own card. --> controller. The + pod has its own button on its own row. -->
<button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}> <button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}>
{busy ? 'Searching…' : 'Look for the pod'} {busy ? 'Searching…' : 'Find pod'}
</button> </button>
{/if} {/if}
{#if anyConnected} {#if anyConnected}
@@ -109,93 +99,74 @@
</div> </div>
</header> </header>
<p class="lede"> <!-- Only while it is telling the rider something they cannot see. Connected,
the badge above has already said it. -->
{#if !scanning} {#if !scanning}
<strong>The scan is off</strong>, so pods will not be picked up. Start it and press a button <p class="lede"><strong>The scan is off</strong> pods will not be picked up.</p>
on a pod.
{:else if minusLive}
The <strong> pod is connected</strong>, 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} {:else if plusOnly}
Running on the <strong>+ pod alone</strong> — its paddle, face buttons and shift-down on <p class="lede">
<span class="kbd">Y</span>. Press a button on the pod to get the D-pad back. Running on the <strong>+ pod alone</strong>: shift down with <span class="kbd">Y</span>.
{:else} Press a button on the pod for the D-pad.
<strong>Press any button on the pod.</strong> It only advertises while awake, and the
running scan connects it as soon as it does — no need to press anything here.
{/if}
</p> </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"> <div class="pods">
{#each pods as pod (pod.pod)} {#each pods as pod (pod.pod)}
{@const dormant = pod.pod === 'plus' && minusLive && pod.state !== 'connected'}
<article class="pod" class:live={pod.state === 'connected'}> <article class="pod" class:live={pod.state === 'connected'}>
<div class="title"> <!-- 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> <span class="paddle" class:on={pod.state === 'connected'}>{pod.symbol}</span>
<span class="what">
<span class="label">{pod.symbol} pod</span> <div class="what">
<span class="purpose">{PURPOSE[pod.pod]}</span> <span class="pod-name">{pod.symbol} pod</span>
</span> <span class="purpose">
<span class="state {STATE_TONE[pod.state]}"> {#if dormant}
<span class="dot"></span>{STATE_TEXT[pod.state]} <!-- 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> </span>
</div> </div>
<dl class="facts"> <span class="facts">
<div> {#if pod.batteryPercent != null}<span>{pod.batteryPercent}%</span>{/if}
<dt>Battery</dt>
<dd>{pod.batteryPercent != null ? `${pod.batteryPercent}%` : '—'}</dd>
</div>
<div>
<dt>Buttons seen</dt>
<!-- Connected and silent looks exactly like working until you press <!-- Connected and silent looks exactly like working until you press
something, so the count is the honest test of the link. --> something, so the count is the honest test of the link. -->
<dd> {#if pod.state === 'connected'}
{pod.buttonsSeen === 0 ? 'none yet' : `${pod.buttonsSeen}`} <span>{pod.buttonsSeen === 0 ? 'no presses yet' : `${pod.buttonsSeen} presses`}</span>
{#if pod.lastButton}<span class="last">· {pod.lastButton}</span>{/if}
</dd>
</div>
<div>
<dt>Address</dt>
<dd class="addr">{pod.address ?? '—'}</dd>
</div>
</dl>
{#if pod.pod === 'plus' && minusLive && pod.state !== 'connected'}
<!-- 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. -->
<p class="note tone-ok">
Not needed — the pod is relaying this pod's paddle and face buttons.
</p>
{:else if pod.confirmed}
<p class="note tone-ok">Confirmed — this pod sent its own {pod.symbol} paddle.</p>
{:else if pod.state === 'connected'}
<p class="note">
Press the <strong>{pod.symbol} paddle</strong> on this pod to confirm it is the one.
</p>
{/if} {/if}
</span>
{#if pod.contradicted} <span class="state {dormant ? 'tone-idle' : STATE_TONE[pod.state]}">
<p class="note tone-warn"> <span class="dot"></span>{dormant ? 'Standing by' : STATE_TEXT[pod.state]}
This pod sent the other paddle. If the pair is the wrong way round, swap them. </span>
</p>
{/if}
{#if pod.error}
<!-- Verbatim (FR-9.2). Rust writes these as instructions, not codes. -->
<p class="note tone-bad">{pod.error}</p>
{/if}
<div class="controls"> <div class="controls">
{#if pod.state === 'connected' || pod.state === 'reconnecting'} {#if pod.state === 'connected' || pod.state === 'reconnecting'}
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Disconnect</button> <button class="btn ghost" onclick={() => disconnect(pod.pod)}>Disconnect</button>
{:else if pod.state === 'searching'} {:else if pod.state === 'searching'}
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop searching</button> <button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop</button>
{:else} {:else}
<button class="btn ghost" onclick={() => connect(pod.pod)}> <button class="btn ghost" onclick={() => connect(pod.pod)}>
{pod.pod === 'plus' && minusLive ? 'Connect anyway' : 'Look for it now'} {dormant ? 'Connect anyway' : 'Find it'}
</button> </button>
{/if} {/if}
</div> </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> </article>
{/each} {/each}
@@ -207,11 +178,9 @@
{#if mixedUp || controller?.swapped} {#if mixedUp || controller?.swapped}
<div class="swap"> <div class="swap">
<span> <span>
{#if mixedUp} {mixedUp
A pod is sending the other pod's paddle — the pair may be filed the wrong way round. ? 'A pod is sending the other pods paddle — the pair may be filed the wrong way round.'
{:else} : 'The pods are swapped from what their advertisement claims.'}
The pods are swapped from what their advertisement claims.
{/if}
</span> </span>
<button class="btn ghost" onclick={() => app.run(() => api.swapControllerPods())}> <button class="btn ghost" onclick={() => app.run(() => api.swapControllerPods())}>
Swap + / Swap + /
@@ -222,84 +191,62 @@
{#if !minusLive} {#if !minusLive}
<!-- <!--
FR-1.8. "Not connected" on its own reads as a broken app, and the real 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, cause — a pod that is simply asleep — is something only the rider can fix.
so it is spelled out here rather than left to be guessed at. 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" open={!anyConnected}> <details class="help">
<summary>The pod will not connect — what to try</summary> <summary>Still not connecting?</summary>
<ol> <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> <li>
<strong>Press any button on the pod.</strong> This is almost always the whole answer. <strong>Or use the + pod</strong> — you lose the D-pad, <span class="kbd">Y</span> still
A Click sleeps within seconds and only advertises while awake, so a pod that is not shifts down.
broadcasting is the normal case, not a fault. The scan below is running: press a
button and the pod connects itself, usually within a second or two.
</li> </li>
<li> <li>
<strong>Keep the scan on.</strong> Auto-connect works off the device scan, so a The keyboard mirrors every Click action; <span class="kbd">?</span> lists them. A ride
stopped scan means nothing gets picked up. Restart it above. never depends on a pod.
</li> </li>
<li> </ul>
<strong>Close anything else holding the pod.</strong> One app at a time — Zwift left
running in the background keeps the link, and this app will never see the pod.
</li>
<li>
<strong>Bring it closer, or charge it.</strong> A flat pod stops advertising
altogether, and after about thirty failed attempts the app stops chasing it and says
so on the card.
</li>
<li>
<strong>Use the + pod instead.</strong> If the pod is flat or not with you, the app
falls back to the + pod on its own after about a minute — or press
<em>Look for it now</em> on its card. You lose the D-pad; <span class="kbd">Y</span>
still shifts down.
</li>
</ol>
<p class="fallback">
Meanwhile the keyboard mirrors every Click action — <span class="kbd">+</span>
<span class="kbd"></span> shift, <span class="kbd"></span>
<span class="kbd"></span> trim the gradient. Press
<span class="kbd">?</span> for the full list. A ride never depends on a pod.
</p>
</details> </details>
{/if} {/if}
</section> </section>
<style> <style>
.click { .click {
margin: 0 var(--edge) 0.8rem; margin: 0 var(--edge) 0.7rem;
padding: 0.9rem 1rem 1rem; padding: 0.75rem 0.9rem 0.85rem;
border-radius: 0.7rem; border-radius: 0.7rem;
background: var(--bg-lift); background: var(--bg-lift);
} }
header { header {
display: flex; display: flex;
align-items: baseline; align-items: center;
gap: 0.75rem; gap: 0.6rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
h2 { h2 {
margin: 0; margin: 0;
font-size: 1.05rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.summary {
font-size: 0.85rem;
font-weight: 600;
}
.lede { .lede {
margin: 0.55rem 0 0; margin: 0.45rem 0 0;
font-size: 0.86rem; font-size: 0.84rem;
line-height: 1.5; line-height: 1.45;
color: var(--ink-soft); color: var(--ink-dim);
} }
.lede strong { .lede strong {
color: var(--ink); color: var(--ink-soft);
} }
.actions { .actions {
@@ -308,43 +255,44 @@
margin-left: auto; margin-left: auto;
} }
.pods { .actions .btn {
display: grid; padding: 0.45em 0.8em;
grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); font-size: 0.85rem;
gap: 0.6rem;
margin-top: 0.8rem;
} }
.pod { .pods {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.55rem; gap: 0.3rem;
padding: 0.8rem 0.9rem; margin-top: 0.6rem;
border-radius: 0.55rem; }
border: 1px solid var(--hairline);
/* 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 { .pod.live {
border-color: color-mix(in srgb, var(--ok) 35%, transparent); border-color: color-mix(in srgb, var(--ok) 30%, transparent);
} }
.title {
display: flex;
align-items: center;
gap: 0.65rem;
}
/* The paddle glyph is the pod's identity — big enough to match against the
one printed on the hardware at arm's length. */
.paddle { .paddle {
display: grid; display: grid;
place-items: center; place-items: center;
width: 2.1rem; width: 1.9rem;
height: 2.1rem; height: 1.9rem;
border-radius: 0.45rem; border-radius: 0.45rem;
background: var(--hairline); background: var(--hairline);
color: var(--ink-dim); color: var(--ink-dim);
font-size: 1.3rem; font-size: 1.15rem;
font-weight: 300; font-weight: 300;
line-height: 1; line-height: 1;
flex: none; flex: none;
@@ -358,96 +306,72 @@
.what { .what {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 10rem;
min-width: 0; min-width: 0;
} }
.label { /* Not `.label`: that is app.css's uppercase, letter-spaced section label,
font-size: 1rem; and a pod's name is a name, not a heading. */
.pod-name {
font-size: 0.95rem;
font-weight: 600; font-weight: 600;
} }
.purpose { .purpose {
font-size: 0.78rem; font-size: 0.76rem;
color: var(--ink-dim); 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 { .state {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4em; gap: 0.4em;
margin-left: auto; font-size: 0.84rem;
font-size: 0.88rem;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
} }
.facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
margin: 0;
}
.facts div {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
dt {
font-size: 0.72rem;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--ink-dim);
}
dd {
margin: 0;
font-size: 0.9rem;
color: var(--ink-soft);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.last {
color: var(--ink-dim);
}
.addr {
font-size: 0.78rem;
color: var(--ink-dim);
}
.note {
margin: 0;
font-size: 0.84rem;
color: var(--ink-soft);
line-height: 1.45;
}
.note strong {
color: var(--ink);
}
.controls { .controls {
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
margin-top: auto; margin-left: auto;
padding-top: 0.15rem; }
.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 { .swap {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
margin-top: 0.6rem; flex-wrap: wrap;
padding: 0.55rem 0.75rem; margin-top: 0.5rem;
padding: 0.45rem 0.7rem;
border-radius: 0.5rem; border-radius: 0.5rem;
background: rgba(255, 207, 74, 0.07); background: rgba(255, 207, 74, 0.07);
color: var(--ink-soft); color: var(--ink-soft);
font-size: 0.85rem; font-size: 0.83rem;
} }
.swap button { .swap button {
@@ -455,34 +379,58 @@
} }
.help { .help {
margin-top: 0.7rem; margin-top: 0.55rem;
font-size: 0.86rem; font-size: 0.83rem;
color: var(--ink-soft); color: var(--ink-dim);
} }
summary { summary {
cursor: pointer; cursor: pointer;
color: var(--ink-soft); color: var(--ink-dim);
font-weight: 600; font-weight: 600;
min-height: var(--touch-min);
display: flex;
align-items: center;
} }
.help ol { /* `display: flex` on a summary drops the browser's own marker, and a
margin: 0.5rem 0 0; disclosure with nothing to disclose-looking about it does not read as one. */
padding-left: 1.2rem; 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; line-height: 1.6;
} }
.help li {
margin-bottom: 0.4rem;
}
.help strong { .help strong {
color: var(--ink); color: var(--ink-soft);
} }
.fallback { /* Narrow: the state and the buttons drop below the pod's name rather than
margin: 0.5rem 0 0; crushing it. */
color: var(--ink-dim); :global([data-size='compact']) .facts,
line-height: 1.6; :global([data-size='compact']) .state {
order: 3;
}
:global([data-size='compact']) .controls {
order: 4;
flex: 1 1 100%;
margin-left: 2.3rem;
} }
</style> </style>
File diff suppressed because it is too large Load Diff