You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Pairing a trainer looks and behaves differently depending on which screen a rider happens to be standing on, and the one screen named for pairing cannot pair the device the ride depends on.
So the trainer slot is dead unless the rider already has a room open — on the screen they opened specifically to set up their equipment. That is the confusion, and it is a one-line symptom of a two-component cause.
The machinery already exists./ride pairs a trainer with no room involved: solo.pair(new FtmsTrainer()) (web/src/routes/ride/+page.svelte:497). /pair was simply never wired to it.
The logic was never the problem
web/src/lib/room/sensor-status.ts already unifies the state machine — trainerState(), sensorState(), pairedElsewhere(), pairedElsewhereAll() — and all three surfaces call it. They then draw the answer three different ways. Only the rendering forked.
One card, everywhere
Keep SensorOverview. Delete DeviceSlot. Turn TrainerButton into SensorOverview's compact variant.
SensorOverview wins because three of the four surfaces already use it and it is the only one with a place for the live reading, the fault hint, the inline error and the simulate affordance — all things DeviceSlot has no slot for and /pair therefore cannot show.
It renders the states the shared function already returns — idle, connecting, connected, failed, plus fault: 'silent' (#520) and pairedElsewhere (#610):
┌ 🔌 TRAINER not paired ┐
│ Required — the workout's targets need it │
│ FTMS · 0x1826 │
│ [ Pair trainer ] │
└──────────────────────────────────────────────────────┘
┌ 🔌 TRAINER paired ┐ ← --color-watt border
│ KICKR CORE 8A2F 214 W · 88 rpm │
│ [ Forget ] │
└──────────────────────────────────────────────────────┘
┌ ⚠ TRAINER paired · silent ┐
│ KICKR CORE 8A2F │
│ No watts yet — turn the cranks │
└──────────────────────────────────────────────────────┘
┌ 📱 TRAINER on your phone ┐ ← dimmed, no action
│ Paired on another of your screens │
└──────────────────────────────────────────────────────┘
The error travels with the card. Today ride.error is a muted paragraph under the slot on /pair, room.rideError is a danger-coloured line beside the button in the Lounge, and error is a prop in SensorOverview — the same failure reports itself three ways. Ride-critical, so it stays put rather than toasting past a rider three metres away (errors.md).
Compact is a density prop, not a fourth shape. The Lounge drops the protocol line and the explanatory copy and keeps name, state and action in the same places, so a rider recognises the card they saw on the pre-ride screen.
One simulation gate
Three gates exist, each documented as deliberately unlike the others:
where
rule
/ride
dev || url.searchParams.has('sim') — a URL any rider can type
room (TrainerButton, RoomSensorOverview)
dev || account.providers.includes('dev')
/pair
dev — heart-rate only, no trainer simulation at all
Simulated watts reach medals, XP and streaks, so this is #123's fairness rule, not a dev convenience — and a rule with three implementations is one rule and two bugs. Collapse to one canSimulate() helper using the room's rule: a dev build, or a server that offers the dev sign-in door (WATTROOM_DEV_LOGIN). That is the strict one, and #418 chose it precisely because it survives a production build.
The CI trap — read this before deleting ?sim=1
WATTROOM.md locks one Playwright flow on every PR: "simulator trainer → ride 2-min workout → .fit produced". It rides a production build, where dev is false — which is the entire reason ?sim=1 exists (ride/+page.svelte:75).
The strict gate still admits CI, because CI's server offers the dev door. But the solo e2e has to sign in through that door rather than typing ?sim=1. Migrate the e2e in the same PR; do not delete the parameter and discover this from a red pipeline on the one flow WATTROOM.md requires.
?replay=<fixture> (#54) is gated behind the same flag and must keep working — it is how a deterministic capture is ridden.
Acceptance criteria
/pair pairs a trainer with no room open, using the same path /ride already uses.
DeviceSlot is deleted; TrainerButton is gone as a separate component; SensorOverview renders every surface, compact in the Lounge.
sensor-status.ts is unchanged — if the rewrite needs to change the state machine, say so in a comment explaining why, because it is the one part that was already right.
Every state renders in every surface: idle, connecting, connected, failed, silent, paired-elsewhere, and no-Web-Bluetooth. Each carries a reason rather than a dead control (ux.md: never render a button that will fail).
The pairing error appears in the same place, in the same colour, on every surface.
One canSimulate() helper; ?sim=1 removed; ?replay= still works behind the unified gate.
The Playwright flow WATTROOM.md requires is migrated in this PR and green.
The two dev pages that use DeviceSlot (dev/pairing, dev/components) are updated, not left broken.
Vitest covers the state→render mapping for all seven states; existing sensor-status tests still pass untouched.
make ci green.
Verify it for real
The verify skill, and this one wants real hardware for at least one pass — docs/HARDWARE-SESSIONS.md has the protocol. Pair from /pair with no room open, then from the Lounge, then from the solo pre-ride screen, and confirm the card reads identically in all three. Then pair on a second screen and confirm the first shows "on your phone" rather than offering a pairing the hub would refuse (#610).
Deliberately not in scope
A first-run walkthrough for a rider who has never paired anything — worth doing, its own issue.
Changing what any sensor does, the BLE layer, or the arbitration in arbitrate.ts.
The ?sim=1 fairness question beyond consolidating the gate: if simulated solo rides should be excluded from streaks and trophies, that is a separate issue.
Related
#565 (why /pair can see the room's trainer at all), #610 (the device claim across screens), #611 (why the solo pre-ride screens grew the grid), #520 (the "paired but silent" fault), #123 (simulators are dev-flag equipment), #418 (why the room's gate is a server gate), #412 (phones are spectators), #54 (?replay=), WATTROOM.md (the locked Playwright flow).
Pairing a trainer looks and behaves differently depending on which screen a rider happens to be standing on, and the one screen named for pairing cannot pair the device the ride depends on.
Mockups: https://claude.ai/code/artifact/9f434188-6178-46d0-8461-96468e579ac2
Three UIs for one job
/pair— "Sensors"DeviceSlot(116 lines)TrainerButton(83 lines)/ride,/ramp, room TrainingSensorOverview(207 lines)Three vocabularies, three layouts, three places an error can appear, for one question a rider asks once: is my trainer connected?
The page named for pairing cannot pair
web/src/routes/pair/+page.svelte:57writes the trainer slot's help text as:and
:126disables it outright:supported={supported && !!ride} <!-- ride = roomConnection.current?.ride -->So the trainer slot is dead unless the rider already has a room open — on the screen they opened specifically to set up their equipment. That is the confusion, and it is a one-line symptom of a two-component cause.
The machinery already exists.
/ridepairs a trainer with no room involved:solo.pair(new FtmsTrainer())(web/src/routes/ride/+page.svelte:497)./pairwas simply never wired to it.The logic was never the problem
web/src/lib/room/sensor-status.tsalready unifies the state machine —trainerState(),sensorState(),pairedElsewhere(),pairedElsewhereAll()— and all three surfaces call it. They then draw the answer three different ways. Only the rendering forked.One card, everywhere
Keep
SensorOverview. DeleteDeviceSlot. TurnTrainerButtonintoSensorOverview's compact variant.SensorOverviewwins because three of the four surfaces already use it and it is the only one with a place for the live reading, the fault hint, the inline error and the simulate affordance — all thingsDeviceSlothas no slot for and/pairtherefore cannot show.It renders the states the shared function already returns —
idle,connecting,connected,failed, plusfault: 'silent'(#520) andpairedElsewhere(#610):The error travels with the card. Today
ride.erroris a muted paragraph under the slot on/pair,room.rideErroris a danger-coloured line beside the button in the Lounge, anderroris a prop inSensorOverview— the same failure reports itself three ways. Ride-critical, so it stays put rather than toasting past a rider three metres away (errors.md).Compact is a density prop, not a fourth shape. The Lounge drops the protocol line and the explanatory copy and keeps name, state and action in the same places, so a rider recognises the card they saw on the pre-ride screen.
One simulation gate
Three gates exist, each documented as deliberately unlike the others:
/ridedev || url.searchParams.has('sim')— a URL any rider can typeTrainerButton,RoomSensorOverview)dev || account.providers.includes('dev')/pairdev— heart-rate only, no trainer simulation at allSimulated watts reach medals, XP and streaks, so this is #123's fairness rule, not a dev convenience — and a rule with three implementations is one rule and two bugs. Collapse to one
canSimulate()helper using the room's rule: a dev build, or a server that offers the dev sign-in door (WATTROOM_DEV_LOGIN). That is the strict one, and #418 chose it precisely because it survives a production build.The CI trap — read this before deleting
?sim=1WATTROOM.md locks one Playwright flow on every PR: "simulator trainer → ride 2-min workout → .fit produced". It rides a production build, where
devis false — which is the entire reason?sim=1exists (ride/+page.svelte:75).The strict gate still admits CI, because CI's server offers the dev door. But the solo e2e has to sign in through that door rather than typing
?sim=1. Migrate the e2e in the same PR; do not delete the parameter and discover this from a red pipeline on the one flow WATTROOM.md requires.?replay=<fixture>(#54) is gated behind the same flag and must keep working — it is how a deterministic capture is ridden.Acceptance criteria
/pairpairs a trainer with no room open, using the same path/ridealready uses.DeviceSlotis deleted;TrainerButtonis gone as a separate component;SensorOverviewrenders every surface, compact in the Lounge.sensor-status.tsis unchanged — if the rewrite needs to change the state machine, say so in a comment explaining why, because it is the one part that was already right.ux.md: never render a button that will fail).canSimulate()helper;?sim=1removed;?replay=still works behind the unified gate.DeviceSlot(dev/pairing,dev/components) are updated, not left broken.TrainerButtonmoves to the card rather than being lost (feat(web): the phone spectator view takes the redesign's shape #412).sensor-statustests still pass untouched.make cigreen.Verify it for real
The
verifyskill, and this one wants real hardware for at least one pass —docs/HARDWARE-SESSIONS.mdhas the protocol. Pair from/pairwith no room open, then from the Lounge, then from the solo pre-ride screen, and confirm the card reads identically in all three. Then pair on a second screen and confirm the first shows "on your phone" rather than offering a pairing the hub would refuse (#610).Deliberately not in scope
arbitrate.ts.?sim=1fairness question beyond consolidating the gate: if simulated solo rides should be excluded from streaks and trophies, that is a separate issue.Related
#565 (why
/paircan see the room's trainer at all), #610 (the device claim across screens), #611 (why the solo pre-ride screens grew the grid), #520 (the "paired but silent" fault), #123 (simulators are dev-flag equipment), #418 (why the room's gate is a server gate), #412 (phones are spectators), #54 (?replay=), WATTROOM.md (the locked Playwright flow).