# NEXT Athlete OS RC111 · Pass player standardization QA

## Scope

RC111 is the first controlled touch of the active pass player after RC110 standardized the pass page shell. The change is intentionally surgical: standardize state/classes/markup around the existing player without replacing the player lifecycle.

## Reference implementation / guardrails read first

- `docs/DO_NOT_TOUCH_WITHOUT_DESIGN.md`
- `docs/ARCHITECTURE_DECISIONS.md`
- `docs/RC110_PASS_PAGE_STANDARDIZATION_QA.md`
- Current RC110/RC109 code paths in `preview.html`, `assets/js/app.js`, `assets/css/next-system.css`, and existing renderers/services.

## What changed

### New pass-player view helper

Added:

- `assets/js/renderers/pass-player-renderer.js`

The helper is deliberately small and pure. It does not own the workout lifecycle, timers, autoplay, preloading, wake lock, or video source decisions. It only provides:

- `applyState({ mode, source, step })`
- `startStage(...)`
- `restStage(...)`
- `fallbackCard(...)`
- `sourceFromVideo(video, state)`

It exposes the existing app to a stable state contract through:

- `data-pass-mode`
- `data-video-source`
- `.is-pass-mode-*`
- `.is-video-source-*`

These are applied to:

- `document.body`
- `#exerciseScreen`
- `#videoWrap`

### Existing player lifecycle is still owned by `app.js`

The following existing functions remain the active owners of pass-player behaviour:

- `startWorkout()`
- `renderStep()`
- `renderVideo()`
- `renderRest()`
- `stopCurrentVideo()`
- existing timer/progress/preload/wake-lock/autoplay/iOS fallback logic

RC111 only added class/state hooks and reused fragments around those functions.

### Start/rest/fallback markup standardized

`renderPassStartScreen()` and `renderRest()` now use the new helper when available, while keeping old class names alongside new canonical classes:

- `.start-stage` + `.next-pass-start-stage`
- `.rest-stage` + `.next-pass-rest-stage`
- `.no-video` + `.next-pass-fallback`

This keeps the existing visual behaviour and tests compatible while giving us a stable standard for future player work.

### CSS ownership moved into `next-system.css`

Added an RC111 CSS block for pass player state shells and fallback cards:

- `.next-pass-stage`
- `.next-pass-start-stage`
- `.next-pass-rest-stage`
- `.next-pass-fallback`
- state selectors using `data-pass-mode` and `data-video-source`

### Real-DOM bug found and fixed

Real-DOM QA exposed a player-layer issue: the hidden preload wrapper / iOS tap fallback could become visible because the modern preview path no longer relies on old legacy CSS for those rules.

Fixed in `assets/css/next-system.css`:

- `.preload-video-wrap` is forced into an invisible 1×1 preload layer.
- `.video-tap-fallback.hide` is fully hidden.
- visible `.video-tap-fallback` remains positioned as an overlay only when explicitly needed.

## What did NOT change

- No new player lifecycle.
- No rewrite of `startWorkout`, `renderStep`, `renderVideo`, or timers.
- No changed video source priority.
- No changed YouTube/OTA/server backup business logic.
- No new renderer replacing the existing player.
- No new SVG fallback system.
- No image generation.
- No cutout/foreground/background layered image model.
- No server deploy test from this sandbox.

## Real-DOM QA method

The RC111 player QA uses the same useful method established in RC108-RC110:

- Loads actual `preview.html`.
- Inlines actual CSS and JS.
- Runs the real DOM with Playwright/Chromium using `page.set_content()`.
- Uses real app functions: `startWorkout()`, `primeWorkoutStart()`, `renderRest()`.
- Mocks only local fetch endpoints:
  - `ota_youtube_proxy.php`
  - `video_backup_api.php`
- Blocks external iframe/network loading in the sandbox.

Because external YouTube network is blocked in the sandbox, the YouTube screenshot shows a grey iframe area. The important verification is DOM/state based: iframe exists and `data-video-source="youtube"` is applied through the real app path.

## Screenshots produced

Start state:

- `qa_screenshots/rc111_pass_player_start_mobile_390x844.png`
- `qa_screenshots/rc111_pass_player_start_ipad_820x1180.png`
- `qa_screenshots/rc111_pass_player_start_desktop_1440x1024.png`

YouTube direct state:

- `qa_screenshots/rc111_pass_player_youtube_mobile_390x844.png`
- `qa_screenshots/rc111_pass_player_youtube_ipad_820x1180.png`
- `qa_screenshots/rc111_pass_player_youtube_desktop_1440x1024.png`

Rest state:

- `qa_screenshots/rc111_pass_player_rest_mobile_390x844.png`
- `qa_screenshots/rc111_pass_player_rest_ipad_820x1180.png`
- `qa_screenshots/rc111_pass_player_rest_desktop_1440x1024.png`

Metrics:

- `qa_screenshots/rc111_pass_player_dom_metrics.json`

## Metrics verified

Start state:

- `helperLoaded === true`
- `passMode === "start"`
- `videoSource === "start"`
- `.next-pass-start-stage.start-stage` present
- no iframe/video required
- no broken local images

YouTube state:

- `helperLoaded === true`
- `passMode === "exercise"`
- `videoSource === "youtube"`
- iframe present inside `#videoWrap`
- no broken local images

Rest state:

- `helperLoaded === true`
- `passMode === "rest"`
- `videoSource === "rest"`
- `.next-pass-rest-stage.rest-stage` present
- `#videoWrap` has `rest-video-wrap`
- `#videoWrap` does not retain `start-video-wrap`
- no broken local images

## Commands run and passed

```bash
node tests/rc111_pass_player_standardization_smoke.js
node tests/rc110_pass_page_standardization_smoke.js
node tests/rc109_asset_path_cleanup_smoke.js
node tests/rc107_asset_and_library_smoke.js
node tests/rc109_library_compactness_static_smoke.js
node tests/rc104_direct_composite_smoke.js
node tests/rc106_smoke.js assets/js/core/exercise-illustrations.js "$PWD"
find assets/js -name '*.js' -print0 | xargs -0 -n1 node --check
php -l ota_youtube_proxy.php
php -l video_backup_api.php
python3 - <<'PY'
import json
p='qa_screenshots/rc111_pass_player_dom_metrics.json'
j=json.load(open(p))
assert len(j['start'])==3 and len(j['youtube'])==3 and len(j['rest'])==3
for m in j['start']:
    assert m['helperLoaded'] and m['passMode']=='start' and m['videoSource']=='start' and m['hasStartStage'] and not m['hasIframe'] and not m['brokenImages']
for m in j['youtube']:
    assert m['helperLoaded'] and m['passMode']=='exercise' and m['videoSource']=='youtube' and m['hasIframe'] and not m['brokenImages']
for m in j['rest']:
    assert m['helperLoaded'] and m['passMode']=='rest' and m['videoSource']=='rest' and m['hasRestStage'] and 'rest-video-wrap' in m['wrapClass'] and 'start-video-wrap' not in m['wrapClass'] and not m['brokenImages']
print('RC111 real DOM metrics OK')
PY
```

## Deploy caveat

The real deployed host was not tested from this sandbox. RC111 is verified with real preview DOM/CSS/JS using the local inlined Playwright method. Final validation should still be done on the deployed `preview.html` host, especially for real YouTube iframe loading and OTA/proxy behaviour.
