Case study · personal R&D

A Persian-first TV app

A film and series app for the living-⁠room TV: right to left, readable from three metres, driven by a d-⁠pad alone, and drawn from its own design system.

Product designer and design-system owner · Aug–Oct 2026
10-foot UX, RTL, design system · LG webOS and Android TV

The TV app home screen: a full-width featured title with a Watch button, a shuffle button, and a shelf of poster cards with rating rings. Placeholder artwork and fake titles. Demo data.

Home on the TV: a full-bleed featured title, then shelves of posters with rating rings. Placeholder artwork. Demo data.

Six chapters, one press at a time

01

Three metres, one d-pad

02

Focus is the language

03

Right to left first

04

Sign in with a phone

05

Failure that takes the blame

06

Measured on the real TV

Read it the way the app is used: one chapter in focus, the rest waiting in the rail.

At a glance

Role

Product designer and owner: product direction, UX, UI, voice, and QA on the real TV. Owner of the design system too: audit, token architecture, component API and documentation. Built with AI coding agents that I directed and reviewed.

Timeline

August to October 2026, about eight weeks. The design system ran from 16 September: 147 commits in 26 days.

Platform

LG webOS (tested on webOS 24, an LG C4) and Android TV. React and Vite, a Node API, JSON tokens and Figma.

Team

Me plus AI agents. Friends tested early builds on their own TVs.

Status

Personal R&D, running on my own TV. Every component on screen comes from the design system.

What it is

A remote-first film and series app for LG webOS and Android TV, Persian first and English second. It started as the app I wanted on my own sofa and grew into a product with accounts, device sign-in, and a design system the app is built from.

Persian streaming on a TV is mostly phone and web apps stretched onto a television, so people end up casting a phone or squinting at a browser. I wanted the opposite: right to left, readable from three metres, d-pad only, with continue-watching, subtitles and dubs that just work.

49

components the app renders from, with 50 test files

49

components the app renders from, with 50 test files

252

design tokens, each traced to the app’s own stylesheet

252

design tokens, each traced to the app’s own stylesheet

1,365

automated tests passing on the platform, 0 skipped

1,365

automated tests passing on the platform, 0 skipped

PRD snapshot

What it had to do from the sofa

Users

Persian-speaking households watching on a TV, including older viewers who don’t use phones comfortably.

Goals

Play a title in three presses or fewer from Home, and resume where you left off.

Sign in without typing a password on a remote.

Every screen works by d-pad alone: no hover, no pointer, no scrolling surprises.

Speak like a person, in Persian first and English second.

Goal one, drawn: the whole input device, and the press budget

↑

←

OK

→

↓

Back

Home

1

2

3

Playing

Four arrows, OK and back. No pointer, no hover, no keyboard. Home to a playing title in three presses or fewer.

Success criteria

Playback that starts and resumes reliably on real hardware, no “invisible focus” states, zero cross-account data leaks (proven by tests), and copy a stranger understands on first read.

Non-goals

Samsung Tizen and a light theme.

Constraints

One video decoder on webOS, so two video elements at once are forbidden. Android TV’s WebView renders at 960 px wide. Low-end TV GPUs. Persian and English, in both directions.

Challenge 1 of 6

Three metres, one d-pad

Situation

On a TV, focus is the whole interaction model. On webOS, CSS smooth-scroll fights the platform, and rows that skip rendering measure nothing, so focus can land somewhere invisible.

Why it was hard

A layout that reflows breaks the d-pad’s mental map, and the two panel widths (1920 and 960) made every size fragile.

Options

Responsive reflow per breakpoint, or one fixed canvas scaled uniformly.

Decision

One 1920×1080 canvas where 1rem = 100vw/120, so the interface scales but never reflows: nine posters per rail on every TV.

Rails move in whole-card steps with a spring, never showing a sliced card. A comfort multiplier enlarges type for older viewers without changing the map.

One rail, one press: focus on the third poster

01

02

03

03

04

05

06

07

08

09

Nine posters on every TV, moved one whole card per press. The focused card scales by 1.09; its siblings dim to 60%. Fixed counts per rail: posters 9, cast 12, tiles 6, episodes 4, spotlight 3.

1 foot vs 10 feet

Viewing distance, to scale

Laptop · 1 ft

TV · 10 ft

Drawn to scale: the sofa is ten times farther from the screen than the desk. Everything in the table below follows from that.

Laptop, 1 foot

TV, 10 feet

Input

Input

Laptop

Pointer, hover, scroll wheel

Pointer, hover, scroll wheel

TV

A d-pad: four arrows, OK and back

A d-pad: four arrows, OK and back

Layout

Layout

Laptop

Reflows at each breakpoint

Reflows at each breakpoint

TV

One 1920×1080 canvas that scales and never reflows

One 1920×1080 canvas that scales and never reflows

Rails

Rails

Laptop

Scroll freely, cards cut at the edge

Scroll freely, cards cut at the edge

TV

One whole card per press, never a sliced card

One whole card per press, never a sliced card

Feedback

Feedback

Laptop

Hover states

Hover states

TV

Focus: white fill or lift, scale, siblings at 60%

Focus: white fill or lift, scale, siblings at 60%

Type

Type

Laptop

15–16 px body

15–16 px body

TV

20–24 px body, 58 px hero, plus a comfort multiplier

20–24 px body, 58 px hero, plus a comfort multiplier

Canvas diagram: the 1920 grid with rail column counts and the safe inset

The canvas every screen is drawn on, at any panel width.

Outcome

Focus became one visual gesture, and “invisible focus” is now written down as the bug class to design against.

Challenge 2 of 6

Focus is the language

Situation

With no pointer, the focused element is the cursor, the selection and the hover state at once. If it can’t be found from the sofa in a glance, nothing else on the screen matters.

Decision

One gesture everywhere. The focused element turns white or lifts, scales by its role, takes a 4 px ring, and its siblings dim to 0.6.

Scale is set per role, not per screen: pills 1.2, cards 1.09, tiles 1.07, strips 1.02.

Focus scale by role, drawn to scale

Pill

×1.20

Card

×1.09

Tile

×1.07

Strip

×1.02

Left: at rest. Right: focused. Small controls grow the most, wide strips barely move.

Focus gesture recording: d-pad across a rail, scale, lift, siblings dim to 60%

The same gesture on every rail, menu and button.

Motion

Two easings do everything: Settle, cubic-bezier(.22, 1, .36, 1), and Card, cubic-bezier(.2, .8, .2, 1). Reduced motion collapses every transform and blur, and reduced transparency swaps glass for a solid surface with a border.

Outcome

A new control doesn’t design its own focus state. It picks a role, and the scale, ring and dimming come with it.

Challenge 3 of 6

Right to left first, with left-to-right islands

Situation

Persian reads right to left, English left to right, and the app had to be both without keeping two stylesheets in sync.

Decision

Logical properties everywhere, so one stylesheet serves fa and en. Rails, menus and focus order mirror with the language, and a few elements stay left to right on purpose.

A Persian screen, schematic: the page reads right to left, four islands read left to right

← reading starts here

3 · code

1

2

3

4

5

6

1

2

1:24:05

← Back

4

1 · Progress bar

Fills left to right in both languages.

2 · Times

1:24:05 stays left to right inside Persian text.

3 · Codes

Sign-in codes keep their character order.

4 · Back pill

Kept left to right as a deliberate exception.

Rails, menus and focus order mirror with the language; these four never do.

Type

A Latin grotesk and Vazirmatn for Persian in one stack, with per-glyph fallback. Body at 20–24 px, the hero at 58 px, Persian titles at a line height of 1.24, and tabular numbers for anything that changes.

Challenge 4 of 6

Sign in with a phone, and a TV that is a device

Situation

Typing an email with a remote is miserable, and a shared household TV needs a session you can take back.

Decision

Sign in on the phone, through a QR code on the TV. Every sign-in becomes a device record, so signing a TV out ends its stream within one segment.

Playback is bound to the device that started it, and concurrent streams are capped, two by default.

Two screens, one sign-in: who does each step

TV

1

Shows a QR code

No email, no password, no on-screen keyboard.

3

Becomes a device

Each sign-in is a record the viewer can see and revoke.

Phone

2

Opens the sign-in page

The viewer signs in where typing is easy.

Any screen

4

Signs a TV out

Its stream ends within one segment.

1 · TV

Shows a QR code

No email, no password, no on-screen keyboard.

2 · Phone

Opens the sign-in page

The viewer signs in where typing is easy.

3 · TV

Becomes a device

Each sign-in is a record the viewer can see and revoke.

4 · Any screen

Signs a TV out

Its stream ends within one segment.

The typing happens on the phone. A sign-out from any screen reaches the set within one segment.

The TV pairing screen: a large QR code, the activation address, an eight character code and a countdown, with Back and New code buttons. Placeholder address and code. Demo data.

The code screen: scan the QR or type the code on a phone. Placeholder address and code. Demo data.

The phone sign-in page opened from the TV's QR code: the same eight character code shown again, a username and password form, and a sign-in button. Placeholder brand mark and code. Demo data.

The phone side: the QR opens this sign-in page, which shows the same code again. Demo data.

From the TV to the phone and back, without touching the remote’s arrows.

Outcome

A copied link stops at the edge of the household. The sign-in address became a design issue too: testers were confused when the code screen showed the API’s address, so sign-in moved to a short, human one and was re-measured live on the TV.

The device list: three TVs signed in to one account, the current one focused, with a note on which are on the same network. Fake device names. Demo data.

Every TV is a device you can see and sign out. Demo data.

Challenge 5 of 6

Failure that takes the blame

Situation

On 27 August, 1 in 6 playbacks was refused, and viewers saw “code 4” on a film that played fine on the next press.

Options

Raise the deadline, or retry.

Decision

Data first. Successful answers were fast (p50 492 ms, p99 2,766 ms), so a longer deadline would only make failures slower. I shipped one automatic retry after 1.2 s, as a logged experiment with a stated question.

Successful answers on one clock, 0 to 3 seconds

p50 · 492 ms

p99 · 2,766 ms

0 s

3 s

p50 · 492 ms

p99 · 2,766 ms

Half of all successful answers arrived in under half a second. Waiting longer would only have delayed the ones that failed.

Voice

“One bored television”: informal you, dark humour aimed at the situation and never the viewer, and errors that take the blame. Every line has to survive its tenth reading.

Two lines from the app, on its black ground

App ground

‏باز نشد. تقصیر تو نیست — بذار گردن من.‏

‏یه بار دیگه امتحان می‌کنم…‏

“It didn’t open. Not your fault, put it on me.”

“I’ll try once more…” — the line behind the 1.2 s retry.

Three states, never two

Value

Show it.

Absent

Say it’s empty.

Read error

Say it couldn’t be read. Never shown as empty.

Outcome

A failure now costs the viewer one short line instead of a cryptic code.

Challenge 6 of 6

Measured on the real TV

Situation

On a TV, “it works in Chrome” proves nothing. A timeout helper passed every test on Node 22 and broke every account call on the TV for 17 hours, because the TV’s browser is strict where Node 22 is lenient.

Another time, installing a new build changed nothing on the set: the loader was booting an old cached bundle.

Log

What passed, and what the TV said

Tests · Node 22

✓ every test passed

The TV’s browser

✗ every account call broke · 17 h

Tests · Node 24

added as a test gate

Drift · live stylesheet over DevTools

3 of 8 sections closed by measurement

Decision

Make the device the test. Node 24 became a test gate, and design drift is checked by reading the running TV’s stylesheet over DevTools, not by reading the diff.

Outcome

That caught a reduced-transparency block shipped twice, which the diff hid because the screen looked right. Three of eight drift sections were closed by a measurement that contradicted what the doc claimed.

A rule in a file isn’t a rule on a screen. Read the device.

Photo: the real TV with DevTools attached, reading the live stylesheet

Drift is measured on the set, not in the repository.

A review I ran on myself

After shipping fast, I needed an outside eye I didn’t have. I ran a structured review of every screen state: 170 topics and 510 design options, each with its measurement on the 1920 canvas, an NN/g principle, and three options with cost and risk.

I treat the output as proposals, not verdicts, and decide each one myself.

Design system

A system pulled out of a living app

By mid-September the app worked, but its look lived in one growing stylesheet. The same “white at some alpha” surface showed up as nine different values from .04 to .22, with no rule saying which. Figma and code disagreed, and Figma Make couldn’t generate a correct screen because the truth only existed in CSS.

On a TV, drift is expensive: one wrong size breaks the d-pad’s path, one blurred panel too many drops frames on a webOS chip, and one low-contrast label is unreadable from the sofa.

Extract, don’t redesign

The app was live, and a fresh system would have drawn a different app. So I measured instead: tokens were read from the stylesheet, and components moved verbatim with their rules and render tests, 14 families on day one. Every token records its source path and commit.

The app switched to the system with no visual regression, verified on the set itself.

Nine ad-hoc alphas, .04 to .22 with no rule, became five materials named by role

Dashed edge: blurs

01

thin

Control on the page ground

02

medium

Control over artwork

03

over-art

A mark on a picture

04

regular

Chrome over the app’s own drawing

blurs

05

thick

Covers content and takes the remote

blurs

Only the last two blur, backed by three measurements of what this TV’s GPU can afford. The rule: don’t invent a sixth surface, join the scale.

Where the system stops

The system owns what a control is made of. The app owns where it sits. Page layout and skeletons stay in the app, and a drift file tracks the line between them.

252 tokens by group

Colour

144

Spacing

23

Size

21

Radius

19

Shadow

16

Material

14

Scale

9

Z-index

6

Counted from the token file. Contrast is documented per pair (fg on bg 19:1, muted on bg 8.5:1), and the two pairs that fall short are flagged in place, not hidden.

Readable by machines

Figma frames were stale, and Figma Make needed specs. I wrote per-screen PRDs from the code: eight screens plus a gaps file, each with a state table marking which states exist in Figma and which still need drawing, with every UI string verbatim in Persian.

Writing them surfaced 40 faults: 37 real defects, all fixed, and 3 non-issues. It also exposed states nobody had ever photographed: skeletons, 1.2 s retry notes, 300 ms fades.

The design-system page, Cards section: three category tiles with their gradient treatment, in the sidebar-and-content layout of the page. Brand name replaced.

The system documents itself: the generated page, here the Cards section. Brand name replaced.

The app's 17 icon files on a dark ground: top-bar glyphs in outline and solid, sound, translate, and the heart, bookmark and eye list icons in on and off states.

The app’s own icon files, 24 px with a 1.5 px stroke, shown white on dark.

A PRD state table next to its Figma frames: drawn and still-to-draw states

Each screen’s states, marked drawn or still to draw.

End credits

By the numbers

Reel one · the app, 26 Aug → 12 Oct 2026

Commits on the platform

856

git log

Co-authored with Claude

667 of 856

Commits carrying a Claude co-author trailer · git log

Automated tests passing

1,365

0 skipped · README, measured 24 Sep 2026

Test files

694

find over *.test.* files

UX review topics

170

With 510 design options · docs/reviews, 17 Sep 2026

Playbacks refused before the retry

1 in 6

docs/OPEN-EXPERIMENTS.md

Longest outage from an untested runtime gap

17 h

README, test gates

Reel two · the design system, 16 Sep → 12 Oct 2026

Components

49

With 50 test files · ls src/components

Tokens

252

tokens/tokens.json

Icons

19

24 × 24, 1.5 px stroke · icons/

Commits

147

In 26 days · git log

Surface alphas into materials

9 → 5

Design-system README, Materials

Faults found writing PRDs

40

37 fixed, 3 non-issues · docs/prd/09-gaps.md

Counted from the repositories on 12 October 2026. Not measured: active users, watch time, retention. Next I’d track time to first frame, resume success rate and d-pad presses to play.

What I learned

On a TV, the device is the only test that counts. Next time I’d automate a real-set smoke test in the first week.

Extract first, then improve. Measuring the live product made the system trustworthy on day one, and naming surfaces by role, not by value, made the debates go away.

Writing PRDs from the code was the cheapest QA I did.

Next: motion tokens, a Tizen pass, and syncing Figma variables from the token file instead of by hand.

On a TV, focus is the interface. Everything else waits for it.