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

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.
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
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.
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
The typing happens on the phone. A sign-out from any screen reaches the set within one segment.

The code screen: scan the QR or type the code on a phone. Placeholder address 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.

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
0 s
3 s
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 system documents itself: the generated page, here the Cards section. Brand name replaced.

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.
