# Product specification Project: standarnav — the input and spatial navigation engine published as `@standarx/nav`. Status: draft, v0 in progress. The engine is extracted and lives in `src/`; `0.1.0` is on npm since 2026-09-22, and nothing has been run on a television. Date: 2026-09-18, revised 2026-09-20 against the extracted tree. Owner: Wesley Cormier. This document states the problem, the boundaries, the user contract, the functional requirements and — for every claim the project intends to make in public — the gate that proves it. The extraction has landed, so paths of the form `src/...` are files of **this** repository, read at HEAD on 2026-09-20, and every line number below was checked against them on that date. ## 1. Problem ### 1.1 The platform gave up on spatial navigation Spatial navigation — moving focus up, down, left and right through a two-dimensional layout — was specified as CSS Spatial Navigation Level 1. It never shipped. The CSSWG resolved to move the topic out of CSS (`https://github.com/w3c/csswg-drafts/issues/1948`), the WICG document (`https://wicg.github.io/spatial-navigation/`) dates from 2017 and was last updated in November 2019, and no browser implemented it. The Chromium flag `--enable-spatial-navigation` is an internal vestige that a page cannot ask for; its status has not been re-verified since 2026-08-27. The consequence: on the web, spatial navigation is userland code or it does not exist. ### 1.2 What breaks when a library uses virtual focus The cheapest way to build a spatial navigation library is to keep the "focused" element in a JavaScript variable — a focus key, an id, a node in a tree — and paint it with a CSS class. `document.activeElement` never moves. That costs the screen reader, which follows real focus and is told nothing by a class change; the native control behaviour that hangs off `:focus`, text entry and scroll anchoring included; `:focus`, `:focus-visible` and `:focus-within` in the application's own stylesheets; extensions, developer tools and tests that read the active element; and interoperability with any other library that moves focus, because there are now two cursors. This project starts from the opposite position: the gamepad drives real DOM focus, never a virtual one, and arrow keys and the d-pad produce exactly the same intents. It is [ADR-0005](adr/0005-real-dom-focus.md). ### 1.3 What TV, kiosk and game developers do today Twenty fact sheets covering eighteen distinct projects — two of them are listed twice under two names — were written on 2026-09-18, each verified by reading the library's own source; the table lives in [docs/research/competitors.md](research/competitors.md). Two findings shape this project: - Real DOM focus is **not** a differentiator. lrud-spatial, the WICG polyfill, Tabster, Enact Spotlight, js-spatial-navigation, salutejs, arrow-navigation, dpad-nav and react-js-spatial-navigation all move real focus. - None of them calls `navigator.getGamepads`. Analog sticks, dead zones, trigger thresholds, repeat acceleration and pad-type glyphs are absent from every one of them. So the gap is not "spatial navigation for the web" — several answers exist, some active, some abandoned since 2017. The gap is one engine where a keyboard, a TV remote and a gamepad produce the same intents, and where the gamepad half is part of the library rather than left to the caller. ## 2. Non-goals These are refusals, not backlog items. Each will be reconsidered only through an ADR. | Non-goal | Reason | |---|---| | Shadow DOM traversal in v0 | Piercing open roots means walking every root on every move; the module is light-DOM-only by explicit choice (`src/tabbable.ts:10-12`). So `getFocusables` stops at a shadow boundary while the `contains` of `src/dom/query.ts:24-39` walks `getRootNode()` and hosts and crosses one — an inconsistency this version keeps deliberately, pinned by the skipped fixture at `src/spatial/spatial.browser.test.ts:921`, with coherence a v1 goal. Components that need it can pass their own root. See [ADR-0008](adr/0008-shadow-dom.md). | | RTL mirroring of directions | `moveLeft` means left on the screen. An application that mirrors its layout decides what its left arrow means; the engine does not guess. | | A component library | No menu, no dialog, no grid. The engine navigates whatever markup it is given, and the boundary that keeps it that way is [ADR-0003](adr/0003-package-boundaries.md). | | Styling beyond focus ring defaults | The package ships the focus ring overlay and the six custom properties it reads (R33). No stylesheet ships at all — the overlay paints itself inline ([ADR-0020](adr/0020-focus-ring-defaults.md)) — and there is no theme, no reset, no component CSS. | | Native SDKs | No Tizen `.wgt` tooling, no webOS CLI wrapper, no Android TV leanback integration. The deliverable is a web package. | | React Native | The engine measures DOM rects and calls `element.focus()`. Neither exists in React Native. | ## 3. Targets | Target | What it means here | Status | |---|---|---| | Smart TV web apps — Tizen, webOS | Remote keycodes mapped out of the box; d-pad and OK button drive focus | Keycodes present in `src/keymap.ts:79-85` and covered by `src/keymap.test.ts`; never run on a real set | | Smart TV web apps — Vidaa, Vizio | Same intents, keycodes unknown | Untested, no public Chromium version for either runtime (checked 2026-09-18) | | Steam Deck and HTPC launchers | Gamepad-first browsing of an ordinary web UI | Steam client CEF 109.0.5414.120 in the Steam Deck beta client of 2024-01-18; no newer version disclosed (note below) | | HTML game UIs | Menus, inventories and settings screens driven by the same pad as the game | Supported by design; no example application. The nearest thing is the development playground (`bun run dev`, `playground/`), which mounts the three engines in `mode: "app"` against fixture markup and is not a sample to copy | | Kiosks | Arrow keys or a physical d-pad, `mode: "app"`, no mouse | Supported by design | | Keyboard-only accessibility | Real focus means the browser's own accessibility path is intact; Tab stays sequential and untouched | Enforced by the contract in §4 | Steam Deck note. The Steam client's embedded Chromium (CEF) was 109.0.5414.120 in the beta client of 2024-01-18 (`https://steamdeckhq.com/news/steam-deck-beta-client-1-18-24-descriptions/`, fetched 2026-09-18). No newer version has been disclosed as of 2026-09-18: Valve confirmed a rebuild of the embedded browser in November 2025, moving it from the Alloy runtime to the Chrome runtime, without giving a version number (`https://steamcommunity.com/groups/SteamClientBeta/discussions/3/688615792191756981/`, fetched 2026-09-18). Anything this project says about the Steam Deck therefore assumes Chromium 109 or newer and has never been run on the device. ### 3.1 Browser tiers The owner's decision of 2026-09-18, recorded in [ADR-0013](adr/0013-browser-baseline-and-fallbacks.md): newest first, with fallbacks for older runtimes. The build target is **es2020**. Its parsing floor is set by the two newest syntax features of that level: optional chaining `?.` — Chrome 80, Safari 13.1, Firefox 74, Samsung Internet 13.0 (`https://caniuse.com/mdn-javascript_operators_optional_chaining`) — and nullish coalescing `??` — Chrome 80, Safari 13.1, Firefox 72, Samsung Internet 13.0 (`https://caniuse.com/mdn-javascript_operators_nullish_coalescing`), both fetched 2026-09-18. The two combined put the floor at Chromium 80, Safari 13.1, Firefox 74 and Samsung Internet 13.0. Any newer API is feature-detected. | Tier | Runtimes | Commitment | |---|---|---| | Supported and tested | Chromium ≥ 85, Safari ≥ 15, Firefox ≥ 79 | The browser suite runs one engine per local run, chosen by `SNAV_BROWSER` and defaulting to chromium (`vitest.config.ts`), and all three — chromium, firefox, webkit — as a CI matrix (`.github/workflows/ci.yml:103-129`). The three matrix jobs passed on the last run of the extraction pull request (2026-09-20). A fourth browser run is declared and has not run yet: the same suite on chromium against react 18.3, because every other job installs the lockfile's react 19 and the bottom of the declared peer range was otherwise never exercised (`.github/workflows/ci.yml:75-101`) | | Best-effort | TV runtimes of 2020-2021: Tizen 5.5 and 6.0 (Chromium 69 and 76), webOS 5.x and 6.x (Chromium 68 and 79) | The es2020 output does not parse below Chromium 80. A separate legacy build is a roadmap question with a decision date — **2026-12-31**, and no device report by then means no build — not a v0 promise | | Out of scope | Anything older | — | Runtime-to-Chromium mapping from the Samsung "Web Engine Specifications" and LG "Web API and Web Engine" pages, fetched 2026-09-18: Tizen 4.0/5.0/5.5/6.0/6.5/7.0/8.0/9.0 = Chromium 56/63/69/76/85/94/108/120; webOS 4.x/5.x/6.x/22/23/24/25 = Chromium 53/68/79/87/94/108/120. The full table with its source URLs is [docs/research/tv-runtime-compatibility.md](research/tv-runtime-compatibility.md). Feature detection required by this tiering (browser support from caniuse and MDN BCD, fetched 2026-09-18): | API | Available from | Fallback | |---|---|---| | `WeakRef` | Chrome 84, Safari 14.1, Firefox 79 | Written. `elementHandle` returns a `WeakRef` where the constructor exists and a strong reference that drops itself on the first read finding the element detached — `isConnected` — where it does not (`src/spatial/spatial.ts:127-141`). The constructor is read per call rather than at module scope, so a test can delete the global and exercise the fallback | | `checkVisibility` | Chrome 105, Safari 17.4, Firefox 106 | `offsetParent === null && getClientRects().length === 0` (`src/tabbable.ts:51`) | | `inert` | Chrome 102, Safari 15.5, Firefox 112 | `closest("[inert]")` reads the attribute everywhere (`src/tabbable.ts:55`) | | `Array.prototype.at` | Chrome 92, Safari 15.4, Firefox 90, Samsung Internet 16.0 (`https://caniuse.com/mdn-javascript_builtins_array_at`) | Avoided outright, and the rewrite is done: `getTabbableEdges` indexes `tabbables[tabbables.length - 1]` (`src/tabbable.ts:104-107`). Unlike every other row its floor is **above** the supported tier, so the use it replaced threw on Chromium 85-91, Safari 15.0-15.3 and Firefox 79-89, and `getTabbableEdges` is the entry point for `getFirstTabbable` and `getLastTabbable`. A `lib` bump would have hidden the break rather than fixed it | `tsconfig.json` declares `target: "es2020"` and `lib: ["es2020", "dom", "dom.iterable"]` (read 2026-09-20), so neither `WeakRef` nor `Array.prototype.at` type-checks by accident: `WeakRef` is declared locally behind its feature check (`src/spatial/spatial.ts:109-111`), and a call to `at` is a compile error rather than a review catch. `bun run typecheck` is its own CI job. ## 4. User contract **Focusable in the platform's sense = navigable.** If the browser would focus an element, the engine will move to it. Nothing has to be registered, wrapped in a hook, or listed in a tree. There is no `useFocusable`, no `focusKey`, no `MutationObserver`: candidates are queried and measured at each move, so a virtualised or freshly mutated DOM needs no cache invalidation. "Focusable in the platform's sense" is the selector at `src/tabbable.ts:17-34`: `input` (also excluding `[type='hidden']`), `select`, `textarea` and `button`; then `a[href]`, `area[href]`, `iframe`, `object`, `embed`, `audio[controls]`, `video[controls]`, `summary`, `[contenteditable]` that makes its element editable, and anything carrying `[tabindex]` — minus hidden, inert and disabled elements. Disabled is what the browser calls `:disabled` — a form control carrying `disabled`, or one inside a `
` anywhere but in that fieldset's first `` — plus exception 5 below: `isFocusable` rejects `:disabled,[disabled]` (`src/tabbable.ts:61-63`). A link or a `[tabindex]` element inside such a fieldset is not disabled and stays a candidate. Tests: "drops what a disabled fieldset disables, and keeps its first legend and its links" and "reports the edges of a surface that ends in a disabled fieldset" in `src/tabbable.browser.test.ts`, and "steps over the controls of a disabled fieldset" in `src/spatial/spatial.browser.test.ts`. The browser agrees: a `