# Instructions

- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.

# Test info

- Name: sprite-state.e2e.test.ts >> sprite-state machine (D-35) + nametag (D-27a) >> player sprite frame switches to Run<R> on rightward movement, back to Stand on stop
- Location: test/e2e/sprite-state.e2e.test.ts:16:3

# Error details

```
Error: expect(locator).toBeVisible() failed

Locator: locator('canvas[data-game-ready="true"]')
Expected: visible
Timeout: 15000ms
Error: element(s) not found

Call log:
  - Expect "toBeVisible" with timeout 15000ms
  - waiting for locator('canvas[data-game-ready="true"]')

```

# Page snapshot

```yaml
- generic [ref=e4]:
  - heading "Log in to BN Online" [level=1] [ref=e5]
  - generic [ref=e6]:
    - text: Username
    - textbox "Username" [ref=e7]: uat_a
  - generic [ref=e8]:
    - text: Password
    - textbox "Password" [ref=e9]: UATa-a6115437c931876a48151cca
  - button "Log in" [ref=e10] [cursor=pointer]
  - alert [ref=e11]: Too many requests. Please try again later.
```

# Test source

```ts
  72  |  *      https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
  73  |  *   6. Returns { fg: "<computed color string>", bg: "<computed bg string>",
  74  |  *                ratio: <number> } for use in expect() assertions.
  75  |  *
  76  |  * [unit->REQ-CLI-06]
  77  |  */
  78  | // eslint-disable-next-line @typescript-eslint/no-explicit-any
  79  | export const computeContrastInPage: string = /* javascript */ `
  80  | function computeContrastInPage(selector) {
  81  |   var el = document.querySelector(selector);
  82  |   if (!el) throw new Error('computeContrastInPage: element not found: ' + selector);
  83  | 
  84  |   // --- Parse "rgb(r, g, b)" or "rgba(r, g, b, a)" → [r, g, b] ---
  85  |   function parseRGB(str) {
  86  |     var m = str.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/);
  87  |     if (!m) throw new Error('Cannot parse color: ' + str);
  88  |     return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
  89  |   }
  90  | 
  91  |   function isTransparent(str) {
  92  |     if (str === 'transparent') return true;
  93  |     var m = str.match(/rgba\\(\\d+,\\s*\\d+,\\s*\\d+,\\s*([\\d.]+)\\)/);
  94  |     if (m && parseFloat(m[1]) === 0) return true;
  95  |     return false;
  96  |   }
  97  | 
  98  |   // --- Compute WCAG relative luminance for an [r,g,b] triple ---
  99  |   function luminance(r, g, b) {
  100 |     var c = [r / 255, g / 255, b / 255].map(function (v) {
  101 |       return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
  102 |     });
  103 |     return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
  104 |   }
  105 | 
  106 |   // --- Foreground color ---
  107 |   var fgStr = window.getComputedStyle(el).color;
  108 | 
  109 |   // --- Walk ancestors for first opaque background ---
  110 |   var bgStr = null;
  111 |   var node = el;
  112 |   while (node && node !== document.documentElement) {
  113 |     var bg = window.getComputedStyle(node).backgroundColor;
  114 |     if (bg && !isTransparent(bg)) {
  115 |       bgStr = bg;
  116 |       break;
  117 |     }
  118 |     node = node.parentElement;
  119 |   }
  120 |   if (!bgStr) {
  121 |     bgStr = window.getComputedStyle(document.body).backgroundColor;
  122 |   }
  123 |   if (!bgStr || isTransparent(bgStr)) {
  124 |     // Ultimate fallback: treat as white (safest for reporting purposes)
  125 |     bgStr = 'rgb(255, 255, 255)';
  126 |   }
  127 | 
  128 |   var fg = parseRGB(fgStr);
  129 |   var bg = parseRGB(bgStr);
  130 |   var L1 = luminance(fg[0], fg[1], fg[2]);
  131 |   var L2 = luminance(bg[0], bg[1], bg[2]);
  132 |   var ratio = (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
  133 | 
  134 |   return { fg: fgStr, bg: bgStr, ratio: Math.round(ratio * 100) / 100 };
  135 | }
  136 | `;
  137 | 
  138 | /**
  139 |  * Drive the LoginScene DOM form for a single page.
  140 |  *
  141 |  * Selectors are the canonical surface emitted by `apps/client/public/forms/login.html`
  142 |  * (loaded by LoginScene via `this.load.html('login-form', ...)`):
  143 |  *   - `#username` / `[name=username]` (autocomplete=username)
  144 |  *   - `#password` / `[name=password]` (autocomplete=current-password)
  145 |  *   - `button[type=submit]`
  146 |  *
  147 |  * Navigates to `/${inviteSuffix}` first; the LoginScene mounts the form on
  148 |  * scene start. After submit, GameScene transition is signalled by the
  149 |  * `<canvas data-game-ready="true">` attribute (set on first state snapshot).
  150 |  */
  151 | export async function loginAs(
  152 |   page: Page,
  153 |   account: Account,
  154 |   inviteSuffix: string,
  155 | ): Promise<void> {
  156 |   await page.goto(`/${inviteSuffix}`);
  157 |   // Login form is part of the Phaser DOMElement scene — wait for the input
  158 |   // node to render before attempting to fill.
  159 |   await page.waitForSelector('#username', { timeout: 10_000 });
  160 |   await page.fill('#username', account.username);
  161 |   await page.fill('#password', account.password);
  162 |   await page.click('button[type=submit]');
  163 | }
  164 | 
  165 | /**
  166 |  * Wait for GameScene to be ready (canvas data-game-ready="true").
  167 |  * Per plan 06-07, GameScene sets this attribute on the first state snapshot
  168 |  * (server's onCreate broadcast); seeing it confirms WS connect + room join
  169 |  * + initial state apply succeeded end-to-end.
  170 |  */
  171 | export async function waitForGameReady(page: Page): Promise<void> {
> 172 |   await expect(page.locator('canvas[data-game-ready="true"]')).toBeVisible({
      |                                                                ^ Error: expect(locator).toBeVisible() failed
  173 |     timeout: 15_000,
  174 |   });
  175 | }
  176 | 
```