smarttv-web-app
REFERENCE IMPLEMENTATION (Archive Watch) behind the generic `smart-tv-platform-expansion` skill — consult AFTER it, for the worked example. Smart-TV web patterns for Archive Watch's LG webOS / Samsung
SKILL.md.claude/skills/smarttv-web-app/SKILL.md
GitHubSmart-TV web app — focus, remotes, packaging
Binding spec: docs/templates/TV-DESIGN-template.md (copy to docs/TV-DESIGN.md when adopted) §7. Strategy: Decision 047.
Backlog: docs/templates/TV-PLATFORM-BACKLOG-template.md Phase 3.
One web build ships to LG webOS, Samsung Tizen, and VIDAA/Titan/Zeasn. They are all HTML5 runtimes; the differences are key codes, lifecycle events and packaging — nothing else.
The layer is ADDITIVE — never a fork
tv.js + tv.css sit on top of the same index.html / watch.js /
watch.css the browser serves. No build step, no framework.
tv.jsreturns immediately unless it detects a TV runtime, so phone/desktop pay nothing.- every
tv.cssrule is scoped underhtml.tv, a class onlytv.jsadds — so a TV change cannot regress the phone viewer. Verify this mechanically:
# must print 0
grep -c '^[a-z*\[.#].*{' tv.css | ... # every rule line contains html.tv
- Detection: UA match on
Tizen/Web0S|webOS/VIDAA, plus?tv=1so the TV surface can be developed and screenshotted in a desktop browser. Without that escape hatch it could only ever be tested on a TV.
React-based focus libraries (Norigin et al.) are out — this codebase is vanilla with no build step. The engine is ~200 lines and is ours by necessity.
The spatial-navigation engine
Cards in this app are real <a> elements, so they are focusable without
touching a line of view code. The engine operates over
a[href], button, input, select, [tabindex]:not([tabindex="-1"]).
Reachability filter — a hidden view's cards stay in the DOM, so without
el.closest('[hidden]') plus a zero-size/visibility check the D-pad walks
into the previous screen.
Scoring is the whole trick:
score = distanceAlongAxisOfTravel + misalignmentAcrossIt * MISALIGN_WEIGHT // 3
The weight is what makes a grid feel like a grid: pressing Down from a card lands on the card below it, not a nearer one three columns over. Compare edges, not centres for the "is it in this direction" test — a tall neighbour whose centre sits behind the cursor can still be the correct target.
Always-focused invariant: claim focus on boot, on hashchange, on return
from hidden, and via a MutationObserver (the first render is async on a
catalog fetch). Retry on a timer — views render after the hash changes.
No dead ends: if nothing lies in a direction, stay put. That is a deliberate stop, not a strand — Back always still works.
Per-platform shims — and NOTHING else branches
| Concern | webOS | Tizen |
|---|---|---|
| Back | keydown keyCode 461 |
tizenhwkey event (+ keyCode 10009) |
| Media keys | standard keydown |
must tizen.tvinputdevice.registerKey() first, or they are never delivered |
| Lifecycle | webOSLaunch / webOSRelaunch |
visibilitychange |
| Exit | webOS.platformBack() |
tizen.application.getCurrentApplication().exit() |
| Pointer | Magic Remote cursor — must coexist | none |
| Package | .ipk via ares-package |
signed .wgt via tizen build-web + tizen package |
Also accept Escape (27) and Backspace (8) as Back — aggregator and white-label remotes use them, and getting Back wrong fails certification on every platform.
Magic Remote coexistence is not optional. Hovering must move focus, so that when the user puts the pointer down the D-pad continues from where they were looking: two input models, one focus state.
Packaging
tv/build-tv-packages.sh stages the SAME root files into each vendor layout
(tv/webos/appinfo.json, tv/tizen/config.xml + icons). There is no separate
TV codebase — if you are editing a file under tv/*/app/, stop; the change
belongs upstream in the shared app. Those dirs are gitignored.
Two packaging gotchas:
- Exclude the service worker from the package. A packaged TV app already stores its resources locally, and a stale SW inside the package shadows them. Strip the registration too, or the app registers a file that is not there.
- Keep the Tizen signing certificate. Samsung requires every update to be signed with the same one.
Bump sw.js SHELL on every web-TV change, or installs serve stale assets.
Bugs a real browser found that the shim could not
The Node shim does no layout, has no <dialog> semantics and no media element.
These shipped past it and were caught in Chrome. Check them on any new
TV surface:
The rem trap.
font-sizeonhtml.tvrescales EVERY rem dimension in the app (watch.css has 62 of them) — the nav overflowed and the hero blew out at 1.5x. Set ten-foot type per element; onlybodymay carry the floor.Guessed selectors match nothing. The full-screen player rule targeted
dialog.player; the real markup isdialog#player > .player-stage > video#video, so video rendered 1100px wide in a 1512px viewport. Read the DOM; do not assume it.Back must be LAYERED. An open overlay is the top layer. Running
history.back()with the player<dialog>still open left a film playing over the home page with no way out — an automatic fail on LG's and Samsung's Back tests. Close the overlay + stop playback first, then navigate, then exit at the root.Initial focus on the logo. Technically focused, practically useless. Prefer content over chrome (
.brand, footer links).Focus rings clip inside a row with no padding for the focus lift. Pad rows by the lift, or the focused card's ring is cut off.
A view rendered while hidden has no layout. Its scroll-to-now (a guide's current hour) ran at width 0 and did nothing. Wait for a non-zero width.
Re-claim focus after a route change, once the old view is hidden, and restore remembered focus only on a Back return, never on a forward visit.
prompt()andconfirm()are unusable on TV. Draw your own dialog or use a list (rooms joined from a keypad, not a text prompt).
Also: make Enter activation explicit (preventDefault + click). Chrome
activates a focused <a> on a real Enter, but TV browsers are inconsistent and
"works in Chrome" is not the bar.
Verification — BOTH a Node shim and a real browser
tools/test_tv_focus.mjs loads the real tv.js into a minimal DOM shim
(stubbed getBoundingClientRect, focus(), activeElement) and asserts:
initial focus, rail traversal, column preservation on up/down, reaching the
nav, the no-dead-end edge case, and both Back key codes.
This follows the project's documented web pattern: headless Chrome distorts
timers and AbortSignal, so exercising the real script in a shim is more
trustworthy than driving a browser.
node tools/test_tv_focus.mjs # algorithm — expects 10/10
python3 tools/devserve.py 8123 # then load http://localhost:8123/?tv=1
# in the page: eval tools/tv_browser_tests.js — expects 20/20
tools/tv_browser_tests.js asserts the things only an engine can: computed
type scale, overscan, zero overflow, content-focus, rail traversal, Enter, the
full-bleed player, all six transport keys, and the Back layering.
Note Object.defineProperty(global, 'navigator', …) in the shim — modern Node
makes navigator a getter-only global, so a plain assignment throws.
Three caching traps (each cost real time)
- Service workers are scoped by origin INCLUDING PORT. A leftover SW from
a different local project on the same port controls your app — we hit a
tidbits-v59-testcache serving this one. Unregister + clear caches, or use a fresh port.tools/devserve.pywithholdssw.jsentirely for this reason. python3 -m http.serversends no cache headers; Chrome holds stale assets across reloads. Usetools/devserve.py(no-store).- A hash-only navigation does NOT re-fetch the document — a hash-router app keeps running the OLD JS while you think you're testing the fix. Change the query string between edits. This masked a fix for three cycles.
And fetch() returning the fixed file proves the FILE is fresh, not the RUNNING
script — compare performance.getEntriesByType('resource') encodedBodySize
against the file on disk.
See also
docs/templates/TV-DESIGN-template.md (copy to docs/TV-DESIGN.md when adopted)§7 — the binding rulesweb-platform-patterns— service-worker versioning, view lifecycle, the sticky/scroll-snap CSS gotchas that still applyweb-catalog-data-layer— where the TV build's catalog data comes from