What's On v2 design: one top bar, quiet Home, single-screen What's On with pop-up, one-screen Drive-In, restyled TV Guide, navigation map (design only, mock data)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m56s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m39s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-04 17:03:42 +00:00
co-authored by Claude Opus 5.5
parent 4b2ded2405
commit 4adb402dd2
20 changed files with 336 additions and 0 deletions
+13
View File
@@ -20,3 +20,16 @@ The full set of boards (states, components, navigation map, wireframe) is on the
The spec is https://claude.ai/artifact/BBUQyr3BUAva8uhaFodKU1
Fonts: Barlow, Barlow Condensed and Anton, from design/fonts and the app's res/font.
## v2: simplified (Paul, 4 November 2026, 16:59)
These are now the current designs. v1 above is superseded.
- `build-v2.py` writes the `v2/` frames. It reuses v1's helpers and assets.
- The frames:
1. Home: the categories in the centre;
2. What's On on one screen: Tonight's Pick, Tonight on Orbit, Coming Up;
3. the What's On pop-up, with Remind me and Back;
4. the Drive-In on one screen, with its art blended into navy;
5. the TV Guide in the new colours, opened on a category;
6. the remote navigation map.
- Each frame is in `v2/` as `.figcraft`, `.fig` and `.png`. Mock data throughout.
+323
View File
@@ -0,0 +1,323 @@
"""What's On v2 (Paul, 4 November 2026, 16:59): one top bar, a quiet Home of categories, What's On on one screen with a
pop-up, the Drive-In on one screen, and the TV Guide in the same colours. Design only; every programme, time and date is
MOCK DATA. Run: python3 design/whats-on/build-v2.py, then design/whats-on/convert.sh v2.
"""
import importlib.util, io, base64, math
from pathlib import Path
from PIL import Image
HERE = Path(__file__).parent
spec = importlib.util.spec_from_file_location("v1", HERE / "build-whats-on.py")
v1 = importlib.util.module_from_spec(spec); spec.loader.exec_module(v1)
Screen, LOGO, LOT, TICKET = v1.Screen, v1.LOGO, v1.LOT, v1.TICKET
NAVY, DEEP, PANEL, BLUE, GOLD, MUTED, WHITE = v1.NAVY, v1.DEEP, v1.PANEL, v1.BLUE, v1.GOLD, v1.MUTED, v1.WHITE
SKY, MINT, PINK, LIVE = v1.SKY, v1.MINT, v1.PINK, v1.LIVE
BODY, COND, HEAD = v1.BODY, v1.COND, v1.HEAD
RES = v1.RES
OUT = HERE / "v2"
OUT.mkdir(exist_ok=True)
for name, file in {"one": "orbit_one_epg", "gold": "orbit_gold_epg", "toonz": "toonz_epg", "wild": "orbit_wild_epg", "odyssey": "orbit_odyssey_epg",
"evidence": "evidence_epg", "heroes": "heroes_epg", "scifi": "orbit_sci_fi_epg"}.items():
LOGO[name] = v1.data_uri(RES / f"{file}.webp")
def icon(name, colour):
"""A category icon, recoloured: white on blue, navy on the gold focused tile, as the app tints it."""
im = Image.open(RES / f"orbit_icon_{name}.webp").convert("RGBA")
r, g, b = int(colour[1:3], 16), int(colour[3:5], 16), int(colour[5:7], 16)
tinted = Image.new("RGBA", im.size, (r, g, b, 0)); tinted.putalpha(im.getchannel("A"))
buf = io.BytesIO(); tinted.save(buf, "PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
class Screen2(Screen):
def save(self):
self.parts.append("</svg>")
(OUT / f"{self.name}.svg").write_text("\n".join(self.parts))
def bar(self, active, clock=True):
"""The one top bar, on every screen. The focused tab is gold; the current destination is underlined in gold."""
self.group("Top bar")
self.img(96, 60, 208, 52, LOGO["orbit"], "Orbit logo")
x = 344
for tab in ["Home", "What's On", "TV Guide", "Drive-In", "Arcade"]:
w = int(len(tab) * 14.5) + 44
on = tab == active
self.text(x + w / 2, 96, tab, 26, WHITE if on else MUTED, BODY, 600, "middle")
if on:
self.rect(x + 14, 108, w - 28, 4, GOLD, rx=2, id="Current destination")
x += w + 8
if clock:
self.text(1824, 92, "18:42", 40, WHITE, COND, 500, "end")
self.text(1824, 116, "Saturday 14 November", 20, MUTED, BODY, 400, "end")
self.rect(96, 134, 1728, 2, BLUE)
self.end()
def shade(self, id, stops, vertical=False):
"""A navy gradient over artwork, so it blends into the interface."""
a = 'x1="0" x2="0" y1="0" y2="1"' if vertical else 'x1="0" x2="1" y1="0" y2="0"'
s = "".join(f'<stop offset="{o}" stop-color="{NAVY}" stop-opacity="{op}"/>' for o, op in stops)
self.parts.append(f'<defs><linearGradient id="{id}" {a}>{s}</linearGradient></defs>')
def home():
s = Screen2("1-home", "Home")
s.bar("Home")
s.mock(y=150)
s.group("Categories")
s.text(960, 290, "What would you like to watch?", 40, WHITE, HEAD, anchor="middle")
cats = [("all", "All Channels"), ("entertainment", "Entertainment"), ("movies", "Movies"), ("comedy", "Comedy"),
("cartoons", "Cartoons"), ("kids", "Kids"), ("documentaries", "Documentaries"), ("sport", "Sport")]
for i, (key, label) in enumerate(cats):
col, row = i % 4, i // 4
x, y = 270 + col * 352, 350 + row * 262
focused = i == 2
s.group(f"Category {label}")
s.rect(x, y, 328, 238, GOLD if focused else PANEL, rx=14)
s.img(x + 124, y + 46, 80, 80, icon(key, NAVY if focused else WHITE), f"{label} icon")
s.text(x + 164, y + 176, label, 30, NAVY if focused else WHITE, BODY, 600, "middle")
if focused:
s.focus(x, y, 328, 238, 14)
s.end()
s.end()
s.group("Gentle pointer to What's On")
s.text(960, 930, "Tonight's Pick and the evening's highlights are in What's On", 22, MUTED, anchor="middle")
s.end()
s.hints([("OK", "Open the category's channels"), ("◀ ▶ ▲ ▼", "Move"), ("▲", "Top bar"), ("Back", "Close Orbit")])
s.save()
def tonights_pick(s, focused=True):
s.group("Tonight's Pick")
x, y, w, h = 96, 168, 1120, 560
s.img(x, y, w, h, LOT, "Programme artwork (Emby backdrop; this frame uses the Drive-In art as a stand-in)", "xMidYMid slice")
s.shade("pickfade", [(0, 0.97), (0.45, 0.88), (0.75, 0.35), (1, 0.15)])
s.rect(x, y, w, h, "url(#pickfade)", id="Navy blend")
s.rect(x, y, w, h, "none", rx=14, stroke=BLUE)
s.tag(x + 32, y + 30, "TONIGHT'S PICK", GOLD, 22)
s.img(x + 32, y + 92, 192, 60, LOGO["afterdark"], "Channel badge L")
s.text(x + 32, y + 200, "Tonight 21:00", 40, GOLD, COND, 500)
s.text(x + 32, y + 270, "JOE BOB SATURDAY", 62, WHITE, HEAD)
s.text(x + 32, y + 318, "Tourist Trap (1979)", 30, WHITE, BODY, 600)
s.text(x + 32, y + 368, "The drive-in host introduces a cult mannequin", 24, "#E4ECF8")
s.text(x + 32, y + 400, "chiller. A weekly After Dark event.", 24, "#E4ECF8")
s.tag(x + 32, y + 490, "WEEKLY EVENT", GOLD, 18)
s.text(x + 230, y + 510, "131 min · OK for details and Remind me", 20, MUTED)
s.text(x + w - 24, y + h - 20, "Artwork: the programme's Emby backdrop (stand-in shown)", 16, MUTED, anchor="end")
if focused:
s.focus(x, y, w, h, 14)
s.end()
def whats_on(name="2-whats-on", dim=False):
s = Screen2(name, "What's On")
s.bar("What's On")
s.mock(y=150)
tonights_pick(s, not dim)
s.group("Tonight on Orbit")
s.text(1248, 196, "TONIGHT ON ORBIT", 30, WHITE, HEAD)
rows = [("19:30", "riot", "Futurama", "Next in turn"), ("20:00", "drivein", "Back to the Future", "Orbit Marquee"),
("21:00", "three", "Scottish Comedy Night", "Still Game, Limmy's Show!"), ("22:00", "paradox", "The X-Files", "A complete two-part story"),
("22:30", "ringx", "Saturday Night Main Event", "Ring X Saturday")]
for i, (t, logo, title, sub) in enumerate(rows):
y = 218 + i * 102
s.group(f"Tonight row {i + 1}")
s.rect(1248, y, 576, 92, PANEL, rx=10)
s.text(1268, y + 58, t, 36, GOLD, COND, 500)
s.img(1372, y + 10, 112, 35, LOGO[logo], "Channel badge S")
s.text(1372, y + 70, title, 22, WHITE, BODY, 600)
s.text(1804, y + 30, sub, 16, MUTED, anchor="end")
s.end()
s.end()
s.group("Coming Up")
s.text(96, 790, "COMING UP", 30, WHITE, HEAD)
s.text(320, 788, "Premieres, specials and events, already booked", 20, MUTED)
cards = [("PPV SUNDAY", GOLD, "ringx", "WrestleMania III", "Sunday 19:08", None),
("FIRST ON ORBIT", SKY, "premiere", "A new-arrival film", "Friday 20:00", None),
("AT THE DRIVE-IN", GOLD, "drivein", "The Back to the Future Trilogy", "Saturday 19:00", "Go to the Drive-In ▶")]
for i, (tag, colour, logo, title, when, link) in enumerate(cards):
x = 96 + i * 584
s.group(f"Coming Up {i + 1}")
s.rect(x, 812, 560, 172, PANEL, rx=12)
s.tag(x + 22, 830, tag, colour)
s.img(x + 426, 826, 112, 35, LOGO[logo], "Channel badge S")
s.text(x + 22, 906, title, 26, WHITE, BODY, 600)
s.text(x + 22, 950, when, 30, GOLD, COND, 500)
if link:
s.text(x + 538, 950, link, 19, GOLD, BODY, 600, "end")
s.end()
s.end()
s.group("Remote hints")
s.rect(96, 1006, 1728, 1, BLUE)
s.text(96, 1036, "OK", 19, GOLD, BODY, 600); s.text(132, 1036, "Details, then Watch or Remind me", 19, MUTED)
s.text(470, 1036, "◀ ▶ ▲ ▼", 19, GOLD, BODY, 600); s.text(568, 1036, "Move", 19, MUTED)
s.text(650, 1036, "Back", 19, GOLD, BODY, 600); s.text(706, 1036, "Home", 19, MUTED)
s.text(1824, 1036, "Chosen from confirmed bookings · refreshed daily at 12:00", 19, MUTED, anchor="end")
s.end()
if dim:
popup(s)
s.save()
def popup(s):
"""Details as a pop-up over What's On: the style Paul liked, with Remind me and Back (Watch live when it's on now)."""
s.rect(0, 0, 1920, 1080, NAVY, id="Dim", opacity=0.78)
s.group("Details pop-up")
x, y, w, h = 360, 170, 1200, 740
s.rect(x, y, w, h, DEEP, rx=18, stroke=BLUE)
s.img(x + 40, y + 36, 192, 60, LOGO["paradox"], "Channel badge L")
s.text(x + 40, y + 160, "THE X-FILES", 72, WHITE, HEAD)
s.text(x + 40, y + 210, "Season 2, Episode 25 · Anasazi", 30, WHITE, BODY, 600)
bx = x + 40
bx += s.tag(bx, y + 236, "STORY · PART 1 OF 2", SKY, 18) + 12
s.rect(bx, y + 236, 104, 30, "none", rx=4, stroke=MUTED); s.text(bx + 52, y + 258, "45 min", 18, WHITE, BODY, 600, "middle")
s.rect(x + 40, y + 290, w - 80, 120, PANEL, rx=12, id="Booked time")
s.text(x + 66, y + 346, "Tonight 22:00 to 22:45", 44, GOLD, COND, 500)
s.rect(x + 470, y + 316, 96, 32, "none", rx=4, stroke=MINT); s.text(x + 518, y + 339, "BOOKED", 17, MINT, BODY, 600, "middle")
s.text(x + 66, y + 390, "Then part 2, The Blessing Way, straight after at 22:45", 21, MUTED)
for i, line in enumerate(["Mulder receives leaked files that could prove the existence of extraterrestrial", "life, and the trail leads to a buried railcar in the New Mexico desert. [Emby synopsis]"]):
s.text(x + 40, y + 466 + i * 34, line, 23, "#E4ECF8")
s.text(x + 40, y + 552, "Why it's here: a complete two-part story tonight, on Paradox's late block.", 20, MUTED)
s.rect(x + 40, y + 610, 318, 72, GOLD, rx=8); s.text(x + 199, y + 656, "Remind me at 22:00", 26, NAVY, BODY, 600, "middle")
s.focus(x + 40, y + 610, 318, 72, 8)
s.rect(x + 378, y + 610, 150, 72, PANEL, rx=8); s.text(x + 453, y + 656, "Back", 26, WHITE, BODY, 600, "middle")
s.text(x + w - 40, y + 656, "On now? The first button is Watch live: one press to tune in.", 19, MUTED, anchor="end")
s.end()
def drive_in():
s = Screen2("4-drive-in", "Orbit Drive-In")
s.img(0, 0, 1920, 1080, LOT, "Drive-In lot (approved artwork)", "xMidYMid slice")
s.shade("lotfade", [(0, 0.96), (0.42, 0.9), (0.62, 0.55), (1, 0.35)])
s.rect(0, 0, 1920, 1080, "url(#lotfade)", id="Navy blend (horizontal)")
s.shade("lotfadev", [(0, 0.9), (0.2, 0.2), (0.75, 0.25), (1, 0.95)], vertical=True)
s.rect(0, 0, 1920, 1080, "url(#lotfadev)", id="Navy blend (vertical)")
s.bar("Drive-In")
s.mock(y=150)
s.group("Tonight's film")
s.img(96, 170, 220, 69, LOGO["drivein"], "Drive-In logo")
s.tag(96, 268, "TONIGHT · ORBIT MARQUEE", GOLD, 22)
s.text(96, 360, "Gates 19:55 · Feature 20:00", 42, GOLD, COND, 500)
s.text(96, 460, "BACK TO THE FUTURE", 92, WHITE, HEAD)
s.text(96, 512, "1985 · 116 min · then The Late Show at 23:00", 28, "#E4ECF8")
s.img(96, 590, 300, 93, TICKET, "Ticket")
s.text(420, 626, "Ticket held", 28, MINT, BODY, 600)
s.text(420, 662, "Reminder 5 minutes before, and at the start", 21, MUTED)
s.rect(96, 730, 300, 72, GOLD, rx=8); s.text(246, 776, "Return ticket", 26, NAVY, BODY, 600, "middle")
s.focus(96, 730, 300, 72, 8)
s.rect(416, 730, 300, 72, "none", rx=8, stroke=GOLD, sw=2); s.text(566, 776, "Go to the big screen", 24, GOLD, BODY, 600, "middle")
s.end()
s.group("Upcoming screenings")
s.rect(1216, 196, 608, 790, NAVY, rx=14, opacity=0.86)
s.text(1244, 246, "THIS WEEK'S BILLS", 30, WHITE, HEAD)
bills = [("SAT", "23:00", "THE LATE SHOW", "Evil Dead II", False), ("SUN", "20:00", "ORBIT MARQUEE", "Jaws", True), ("MON", "20:00", "ORBIT MARQUEE", "Ghostbusters", False),
("FRI", "20:00", "DOUBLE BILL", "Terminator Double Bill", False), ("SAT", "19:00", "TRIPLE BILL", "Back to the Future Trilogy", False)]
for i, (dow, t, kind, title, held) in enumerate(bills):
y = 270 + i * 136
s.rect(1236, y, 568, 124, PANEL, rx=10)
s.text(1258, y + 40, dow, 18, MUTED, BODY, 600)
s.text(1258, y + 84, t, 38, GOLD, COND, 500)
s.text(1360, y + 46, kind, 15, GOLD, BODY, 600, spacing=0.5)
s.text(1360, y + 82, title, 23, WHITE, BODY, 600)
if held:
s.tag(1360, y + 92, "TICKET HELD", MINT, 14)
else:
s.text(1782, y + 108, "OK: take a ticket", 16, GOLD, BODY, 600, "end")
s.end()
s.hints([("OK", "Ticket or big screen"), ("▶", "This week's bills"), ("◀ ▶ ▲ ▼", "Move"), ("Back", "Home")], "Bills come from the Drive-In's own booked calendar")
s.save()
def guide():
s = Screen2("5-tv-guide", "TV Guide, opened on Movies")
s.bar("TV Guide")
s.mock(y=150)
s.group("Category chips (the guide's own filter, restyled)")
x = 96
for i, label in enumerate(["All Channels", "Entertainment", "Movies", "Comedy", "Cartoons", "Kids", "Documentaries", "Sport"]):
w = int(len(label) * 12) + 40
on = label == "Movies"
s.rect(x, 154, w, 44, GOLD if on else PANEL, rx=22)
s.text(x + w / 2, 184, label, 21, NAVY if on else WHITE, BODY, 600, "middle")
x += w + 10
s.end()
s.group("Now and Next preview")
s.rect(96, 222, 1728, 190, DEEP, rx=12)
s.img(124, 246, 192, 60, LOGO["premiere"], "Channel badge L")
s.text(124, 350, "Raiders of the Lost Ark", 34, WHITE, HEAD)
s.text(124, 388, "Premiere · 18:15 to 20:10 · 1981 · 115 min", 21, MUTED)
s.rect(1180, 246, 620, 142, PANEL, rx=10, id="Live picture")
s.text(1490, 324, "Live picture of the focused channel", 20, MUTED, anchor="middle")
s.end()
s.group("Grid")
s.rect(96, 428, 1728, 46, BLUE, rx=6)
s.text(116, 459, "Saturday", 20, WHITE, BODY, 600)
for i, t in enumerate(["18:30", "19:00", "19:30", "20:00"]):
s.text(400 + i * 356, 459, t, 22, WHITE, COND, 500)
s.rect(400 + int(0.4 * 356), 474, 4, 472, GOLD, id="Now line", opacity=0.9)
nx = 400 + int(0.4 * 356) + 2
s.parts.append(f'<polygon id="Now marker" points="{nx - 10},428 {nx + 10},428 {nx},444" fill="{GOLD}"/>')
chans = [("premiere", "301", [("Raiders of the Lost Ark", 0, 1.6), ("The Goonies", 1.6, 2.4)]),
("scifi", "303", [("Aliens", 0, 2.3), ("Tron", 2.3, 1.7)]),
("afterdark", "305", [("Night of the Demons", 0, 1.2), ("The Fog", 1.2, 2.8)]),
("drivein", "300", [("Gates open", 0, 2.9), ("Back to the Future", 2.9, 1.1)])]
for r, (logo, num, progs) in enumerate(chans):
y = 482 + r * 118
s.group(f"Row {num}")
s.rect(96, y, 296, 110, PANEL, rx=8)
s.text(116, y + 40, num, 22, GOLD, COND, 500)
s.img(170, y + 36, 192, 60, LOGO[logo], "Channel badge")
for j, (title, start, span) in enumerate(progs):
px = 400 + int(start * 356); pw = int(span * 356) - 8
focused = r == 0 and j == 0
s.rect(px, y, pw, 110, GOLD if focused else "#0F2C5C", rx=8)
s.text(px + 18, y + 48, title, 23, NAVY if focused else WHITE, BODY, 600)
s.text(px + 18, y + 82, "18:15 to 20:10" if focused else "", 18, NAVY if focused else MUTED)
if focused:
s.focus(px, y, pw, 110, 8)
s.end()
s.end()
s.hints([("OK", "Watch, or details if later"), ("▲ ▼", "Channels"), ("◀ ▶", "Time"), ("▲ at the top", "Categories, then the top bar"), ("Back", "Home")])
s.save()
def nav_map():
s = Screen2("6-navigation", "Remote navigation between the screens")
s.bar("Home", clock=False)
s.group("Map")
s.text(96, 200, "ONE TOP BAR, FIVE DESTINATIONS, FEW PRESSES", 36, GOLD, HEAD)
s.text(96, 246, "The top bar: ◀ ▶ between the five destinations, ▼ into the screen, ▲ back up to the bar", 21, GOLD, BODY, 600)
top = ["Home", "What's On", "TV Guide", "Drive-In", "Arcade"]
for k, label in enumerate(top):
x = 96 + k * 352
s.rect(x, 270, 320, 130, PANEL, rx=12, stroke=GOLD if label == "What's On" else BLUE, sw=3 if label == "What's On" else 2)
s.text(x + 160, 346, label, 30, WHITE, BODY, 600, "middle")
if k < 4:
s.text(x + 336, 346, "⇄", 26, GOLD, BODY, 600, "middle")
below = [(1, "Details pop-up", "OK on a card", "Back closes it"), (2, "Live TV (full screen)", "OK on a programme on now", "Back to the guide"),
(3, "Live TV (Drive-In)", "OK on Go to the big screen", "Back to the Drive-In")]
for k, label, down, up in below:
x = 96 + k * 352
s.rect(x + 158, 400, 4, 130, GOLD, id=f"Arrow to {label}")
s.parts.append(f'<polygon points="{x + 150},522 {x + 170},522 {x + 160},540" fill="{GOLD}"/>')
s.text(x + 172, 446, down, 17, GOLD, BODY, 600)
s.text(x + 172, 474, up, 17, MUTED)
s.rect(x, 544, 320, 130, DEEP, rx=12, stroke=BLUE)
s.text(x + 160, 620, label, 26, WHITE, BODY, 600, "middle")
s.text(96, 620, "Home: OK on a", 19, GOLD, BODY, 600); s.text(96, 646, "category opens the", 19, MUTED); s.text(96, 672, "TV Guide filtered to it", 19, MUTED)
rules = ["Every screen has the same top bar. ▲ from a screen's top row reaches it; ◀ ▶ moves along it; ▼ goes into the screen.",
"Tune in fast: on What's On, OK on a Tonight row that's on now opens the pop-up with Watch live focused, so it's 2 presses.",
"In the TV Guide, OK on a programme on now tunes straight in: 1 press. Home's categories open the TV Guide filtered to them.",
"Back is always one step: the pop-up closes to its card; a screen goes to Home; Home leaves Orbit, as it does today.",
"Focus: 4 px gold ring 3 px outside the item, a 4% lift and a shadow; gold fill on tabs, chips and buttons. 120 ms moves.",
"What's On opens on Tonight's Pick. It is rebuilt once a day at 12:00 from confirmed bookings, never from proposals."]
for i, r in enumerate(rules):
s.text(96, 790 + i * 38, "· " + r, 21, "#E4ECF8")
s.end()
s.save()
if __name__ == "__main__":
home(); whats_on(); whats_on("3-whats-on-popup", dim=True); drive_in(); guide(); nav_map()
print("wrote", sorted(p.name for p in OUT.glob("*.svg")))
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 846 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB