Design: Orbit Companion phone screens, every look (FigCraft)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-06 20:48:57 +00:00
co-authored by Claude Opus 5.5
parent 8432b4735c
commit db8a360ee1
4 changed files with 201 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
Orbit Companion (name pending; Paul, 6 November 2026): the phone side of Orbit, served by the TV itself on the home
Wi-Fi, so it works on any phone with nothing to install. Design proof only, drawn as SVG and converted and exported with
FigCraft. Every colour comes from the look the TV is using (Themes.kt), so the phone always matches the TV.
Screens: Remote (home), Rooms, Guide with Watch on a TV, Catch Up with a queue, Create a channel, Arcade controller;
then the Remote in all six looks.
"""
import subprocess
from html import escape
from pathlib import Path
HERE = Path(__file__).resolve().parent
LOOKS = {
'Orbit Night': dict(bg='#071632', surface='#12356B', raised='#194C91', accent='#FFDE59', ink='#FFFFFF', muted='#B7CBE8', deep='#0C2148', on='#071632', mint='#7EE0A8'),
'Daybreak': dict(bg='#E8F1FB', surface='#FFFFFF', raised='#D2E2F6', accent='#1E66D0', ink='#0E1E3C', muted='#4A5D7A', deep='#DCE8F6', on='#FFFFFF', mint='#1C8A58'),
'Nebula': dict(bg='#170B2E', surface='#3A1D6B', raised='#522D8F', accent='#FF7AC6', ink='#FFFFFF', muted='#C8B4E6', deep='#22113F', on='#170B2E', mint='#8FE8C0'),
'Retro Tube': dict(bg='#1A120B', surface='#3B2A1A', raised='#574029', accent='#FFB347', ink='#FFECD2', muted='#D2B48C', deep='#241911', on='#1A120B', mint='#A8DB8C'),
'Deep Sea': dict(bg='#04212B', surface='#0B4A5C', raised='#126378', accent='#6EF0D8', ink='#F0FFFC', muted='#A0D2D2', deep='#072C38', on='#04212B', mint='#9CF5B4'),
'High Contrast': dict(bg='#000000', surface='#1A1A1A', raised='#333333', accent='#FFFF00', ink='#FFFFFF', muted='#DCDCDC', deep='#0D0D0D', on='#000000', mint='#66FF99'),
}
F = 'Barlow, DejaVu Sans, sans-serif'
W, H = 390, 844
def t(x, y, s, size, fill, weight=400, anchor='start', extra=''):
return f'<text x="{x}" y="{y}" font-family="{F}" font-size="{size}" font-weight="{weight}" fill="{fill}" text-anchor="{anchor}" {extra}>{escape(s)}</text>'
def rr(x, y, w, h, r, fill, extra=''):
return f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{r}" fill="{fill}" {extra}/>'
def mark(x, y, c, size=1.0):
"""Orbit's crescent and orbital line, small."""
s = size
return (f'<path d="M{x + 14 * s} {y}C{x - 4 * s} {y + 4 * s} {x - 6 * s} {y + 26 * s} {x + 12 * s} {y + 30 * s}C{x + 2 * s} {y + 22 * s} {x + 2 * s} {y + 8 * s} {x + 14 * s} {y}Z" fill="{c["ink"]}"/>'
f'<path d="M{x - 4 * s} {y + 24 * s}C{x + 6 * s} {y + 30 * s} {x + 26 * s} {y + 20 * s} {x + 32 * s} {y + 10 * s}" fill="none" stroke="{c["accent"]}" stroke-width="{2.4 * s}" stroke-linecap="round"/>')
def header(c, title, back=False):
o = rr(0, 0, W, H, 0, c['bg']) + t(24, 22, '20:14', 13, c['muted'], 600) + t(W - 24, 22, 'Wi-Fi', 12, c['muted'], 600, 'end')
if back:
o += f'<path d="M30 70L20 80L30 90" fill="none" stroke="{c["ink"]}" stroke-width="2.5" stroke-linecap="round"/>' + t(46, 86, title, 22, c['ink'], 700)
else:
o += mark(24, 58, c) + t(64, 86, 'ORBIT', 22, c['ink'], 800, extra='letter-spacing="3"') + t(140, 86, title, 18, c['muted'], 500)
return o
def room_pill(c, y=108):
return (rr(24, y, W - 48, 44, 22, c['surface']) + f'<circle cx="46" cy="{y + 22}" r="5" fill="{c["mint"]}"/>'
+ t(60, y + 27, 'Living Room', 16, c['ink'], 700) + t(166, y + 27, 'Watching 308 BritX', 13, c['muted'])
+ f'<path d="M{W - 50} {y + 18}L{W - 44} {y + 25}L{W - 38} {y + 18}" fill="none" stroke="{c["muted"]}" stroke-width="2"/>')
def tabbar(c, active):
tabs = ['Remote', 'Guide', 'Catch Up', 'Create', 'Rooms']
o = rr(0, H - 84, W, 84, 0, c['deep'])
for i, name in enumerate(tabs):
x = 39 + i * 78
on = name == active
o += rr(x - 14, H - 72, 28, 4, 2, c['accent'] if on else c['deep'])
o += f'<circle cx="{x}" cy="{H - 52}" r="9" fill="none" stroke="{c["accent"] if on else c["muted"]}" stroke-width="2"/>'
o += t(x, H - 28, name, 11, c['accent'] if on else c['muted'], 700 if on else 500, 'middle')
return o
def now_card(c, y=168):
return (rr(24, y, W - 48, 118, 18, c['deep'], f'stroke="{c["raised"]}" stroke-width="1"')
+ rr(40, y + 16, 86, 86, 12, c['raised']) + t(83, y + 66, 'BritX', 15, c['ink'], 800, 'middle')
+ t(140, y + 34, 'NOW · 308', 12, c['accent'], 700, extra='letter-spacing="1"') + t(140, y + 58, 'Hot Fuzz', 22, c['ink'], 700)
+ t(140, y + 78, '21:00 to 23:05', 13, c['muted'])
+ rr(140, y + 90, 210, 5, 2.5, c['raised']) + rr(140, y + 90, 60, 5, 2.5, c['accent']))
def remote(c):
o = header(c, 'Remote') + room_pill(c) + now_card(c)
for i, (label, glyph) in enumerate([('Guide', '▦'), ('Catch Up', '↺'), ('Drive-In', '◐'), ('Arcade', '◆')]):
x = 24 + i * 87
o += rr(x, 302, 80, 66, 14, c['surface']) + t(x + 40, 330, glyph, 20, c['accent'], 700, 'middle') + t(x + 40, 356, label, 12, c['ink'], 600, 'middle')
cx, cy = W / 2, 512
o += f'<circle cx="{cx}" cy="{cy}" r="118" fill="{c["surface"]}"/><circle cx="{cx}" cy="{cy}" r="117" fill="none" stroke="{c["raised"]}" stroke-width="2"/>'
for dx, dy, rot in [(0, -82, 0), (0, 82, 180), (-82, 0, 270), (82, 0, 90)]:
o += f'<path d="M-9 4L0 -6L9 4" transform="translate({cx + dx} {cy + dy}) rotate({rot})" fill="none" stroke="{c["ink"]}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>'
o += f'<circle cx="{cx}" cy="{cy}" r="50" fill="{c["accent"]}"/>' + t(cx, cy + 9, 'OK', 26, c['on'], 800, 'middle')
# Back, the channel rocker, the volume rocker and mute.
y = 656
o += f'<circle cx="54" cy="{y + 30}" r="28" fill="{c["surface"]}"/>' + f'<path d="M62 {y + 22}H50L46 {y + 30}L50 {y + 38}H62" fill="none" stroke="{c["ink"]}" stroke-width="2.5" stroke-linejoin="round"/>'
o += rr(100, y, 86, 60, 30, c['surface']) + t(122, y + 37, '+', 22, c['ink'], 700, 'middle') + t(143, y + 36, 'CH', 11, c['muted'], 700, 'middle') + t(165, y + 37, '−', 22, c['ink'], 700, 'middle')
o += rr(204, y, 86, 60, 30, c['surface']) + t(226, y + 37, '−', 22, c['ink'], 700, 'middle') + t(247, y + 36, 'VOL', 11, c['muted'], 700, 'middle') + t(269, y + 37, '+', 22, c['ink'], 700, 'middle')
o += f'<circle cx="{W - 54}" cy="{y + 30}" r="28" fill="{c["surface"]}"/>' + t(W - 54, y + 36, 'MUTE', 11, c['ink'], 700, 'middle')
return o + tabbar(c, 'Remote')
def rooms(c):
o = header(c, 'Rooms', back=True) + t(24, 128, 'Every Orbit in the house, on this Wi-Fi.', 14, c['muted'])
rows = [('Living Room', 'Watching 308 BritX · Hot Fuzz', True, c['mint']), ('Upstairs', 'Orbit open · TV Guide', False, c['mint']), ('Kitchen', 'Orbit closed · tap to wake it', False, c['muted'])]
y = 150
for name, line, chosen, dot in rows:
o += rr(24, y, W - 48, 76, 16, c['surface'], f'stroke="{c["accent"] if chosen else c["surface"]}" stroke-width="2"')
o += rr(40, y + 20, 48, 34, 6, c['raised']) + rr(56, y + 56, 16, 4, 2, c['raised'])
o += t(104, y + 34, name, 18, c['ink'], 700) + t(104, y + 56, line, 13, c['muted'])
o += f'<circle cx="{W - 48}" cy="{y + 38}" r="6" fill="{dot}"/>'
y += 90
o += rr(24, y + 10, W - 48, 52, 26, c['raised']) + t(W / 2, y + 42, 'Add this phone to another room', 15, c['ink'], 700, 'middle')
return o + tabbar(c, 'Rooms')
def guide(c):
o = header(c, 'Guide') + room_pill(c)
chips = ['All', 'Entertainment', 'Films', 'Kids']
x = 24
for i, ch in enumerate(chips):
w = 18 + 8 * len(ch)
o += rr(x, 166, w, 30, 15, c['accent'] if i == 0 else c['surface']) + t(x + w / 2, 186, ch, 13, c['on'] if i == 0 else c['ink'], 700, 'middle'); x += w + 8
rows = [('101', 'Orbit 1', 'Friends', '20:30'), ('109', 'Signature', 'Succession', '21:00'), ('110', 'Unscripted', 'The Traitors', '20:00'), ('205', 'Kitchen', 'MasterChef', '20:30'), ('308', 'BritX', 'Hot Fuzz', '21:00'), ('310', 'Christmas', 'The Holiday', '20:12')]
y = 212
for i, (num, name, now, at) in enumerate(rows):
o += rr(24, y, W - 48, 62, 14, c['surface'] if i != 1 else c['raised'])
o += t(40, y + 26, num, 13, c['accent'], 700) + t(40, y + 46, name, 13, c['muted'], 600)
o += t(126, y + 36, now, 16, c['ink'], 700) + t(W - 40, y + 36, at, 13, c['muted'], 500, 'end')
y += 70
# The sheet for the chosen row: watch it on a TV.
o += rr(0, 560, W, 200, 24, c['deep'], f'stroke="{c["raised"]}" stroke-width="1"') + rr(W / 2 - 20, 572, 40, 4, 2, c['raised'])
o += t(24, 610, 'Succession', 20, c['ink'], 700) + t(24, 632, '109 Signature · 21:00 to 22:05', 13, c['muted'])
o += rr(24, 650, W - 48, 48, 24, c['accent']) + t(W / 2, 680, 'Watch on Living Room', 16, c['on'], 800, 'middle')
o += t(W / 2, 726, 'or choose another room', 13, c['muted'], 600, 'middle')
return o + tabbar(c, 'Guide')
def catchup(c):
o = header(c, 'Catch Up') + room_pill(c)
items = [('Hot Fuzz', '308 BritX · aired 19:00', True), ('The Great British Bake Off', '205 Kitchen · aired 18:00', True), ('Taskmaster', '108 Play · aired 21:00 last night', False), ('Gogglebox', '110 Unscripted · aired 20:00', False)]
y = 168
for title, line, queued in items:
o += rr(24, y, W - 48, 70, 14, c['surface']) + rr(36, y + 11, 76, 48, 8, c['raised'])
o += t(124, y + 32, title, 15, c['ink'], 700) + t(124, y + 52, line, 12, c['muted'])
o += f'<circle cx="{W - 48}" cy="{y + 35}" r="14" fill="{c["accent"] if queued else "none"}" stroke="{c["accent"]}" stroke-width="2"/>' + t(W - 48, y + 41, '✓' if queued else '+', 15, c['on'] if queued else c['accent'], 800, 'middle')
y += 78
o += rr(24, y + 8, W - 48, 150, 18, c['deep'], f'stroke="{c["raised"]}" stroke-width="1"')
o += t(40, y + 38, 'UP NEXT ON LIVING ROOM', 12, c['accent'], 700, extra='letter-spacing="1.5"')
o += t(40, y + 66, '1 Hot Fuzz', 15, c['ink'], 600) + t(40, y + 92, '2 The Great British Bake Off', 15, c['ink'], 600)
o += t(40, y + 124, 'Plays after the programme on now.', 13, c['muted'])
return o + tabbar(c, 'Catch Up')
def create(c):
o = header(c, 'Create a channel', back=True) + room_pill(c)
fields = [('Name', 'Sunday Sci-Fi'), ('Shows', 'The X-Files, Fringe and 6 more'), ('How it plays', 'In order'), ('When it is on', 'Weekends, 18:00 to 00:00')]
y = 170
for label, value in fields:
o += rr(24, y, W - 48, 64, 14, c['surface']) + t(40, y + 26, label, 12, c['muted'], 600) + t(40, y + 48, value, 16, c['ink'], 700)
o += f'<path d="M{W - 48} {y + 26}L{W - 42} {y + 32}L{W - 48} {y + 38}" fill="none" stroke="{c["muted"]}" stroke-width="2"/>'
y += 74
o += rr(24, y + 6, W - 48, 86, 14, c['deep']) + t(40, y + 32, 'THE DIRECTOR SUGGESTS', 12, c['accent'], 700, extra='letter-spacing="1.5"')
o += t(40, y + 56, 'Farscape · Stargate SG-1 · Firefly', 15, c['ink'], 600) + t(40, y + 76, 'From your library, like the shows you chose.', 12, c['muted'])
o += rr(24, y + 110, W - 48, 52, 26, c['accent']) + t(W / 2, y + 142, 'Create channel', 17, c['on'], 800, 'middle')
return o + tabbar(c, 'Create')
def arcade(c):
o = header(c, 'Arcade', back=True) + t(24, 128, 'Playing Comet Trail on Living Room', 14, c['muted'])
o += rr(24, 146, W - 48, 64, 14, c['surface']) + t(40, 174, 'SCORE', 12, c['muted'], 700) + t(40, 198, '1,240', 20, c['ink'], 800)
o += t(W - 40, 174, 'BEST', 12, c['muted'], 700, 'end') + t(W - 40, 198, '3,880', 20, c['accent'], 800, 'end')
cx, cy = W / 2, 430
for dx, dy, rot in [(0, -96, 0), (0, 96, 180), (-96, 0, 270), (96, 0, 90)]:
o += f'<g transform="translate({cx + dx} {cy + dy})"><circle r="48" fill="{c["surface"]}"/><path d="M-12 6L0 -8L12 6" transform="rotate({rot})" fill="none" stroke="{c["ink"]}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></g>'
o += f'<circle cx="{cx}" cy="{cy}" r="34" fill="{c["raised"]}"/>'
o += rr(24, 600, (W - 60) / 2, 64, 32, c['accent']) + t(24 + (W - 60) / 4, 640, 'OK', 20, c['on'], 800, 'middle')
o += rr(36 + (W - 60) / 2, 600, (W - 60) / 2, 64, 32, c['surface']) + t(W - 24 - (W - 60) / 4, 640, 'Pause', 18, c['ink'], 700, 'middle')
o += t(W / 2, 700, 'Swipe works too. The TV shows the game.', 13, c['muted'], 500, 'middle')
return o + tabbar(c, 'Remote')
def phone(x, y, body, scale=1.0, label=None, c=None):
g = f'<g transform="translate({x} {y}) scale({scale})"><clipPath id="clip{x}_{y}"><rect width="{W}" height="{H}" rx="44"/></clipPath>'
g += f'<g clip-path="url(#clip{x}_{y})">{body}</g><rect width="{W}" height="{H}" rx="44" fill="none" stroke="#2A3550" stroke-width="6"/></g>'
if label: g += t(x + W * scale / 2, y + H * scale + 40, label, 22, '#B7CBE8', 600, 'middle')
return g
def board():
night = LOOKS['Orbit Night']
screens = [('Remote', remote), ('Rooms', rooms), ('Guide · watch on a TV', guide), ('Catch Up · queue next', catchup), ('Create a channel', create), ('Arcade controller', arcade)]
o = [rr(0, 0, 2860, 2380, 0, '#050F24')]
o.append(t(80, 110, 'Orbit Companion', 64, '#FFFFFF', 800) + t(80, 160, 'The phone side of Orbit, served by the TV on your Wi-Fi. Any phone, nothing to install, always in the TV\'s look.', 28, '#B7CBE8'))
for i, (label, fn) in enumerate(screens):
o.append(phone(80 + i * 460, 220, fn(night), 1.0, label))
o.append(t(80, 1250, 'The same Remote in every look', 40, '#FFDE59', 700))
for i, (name, c) in enumerate(LOOKS.items()):
o.append(phone(80 + i * 460, 1300, remote(c), .95, name))
svg = f'<svg xmlns="http://www.w3.org/2000/svg" width="2860" height="2380" viewBox="0 0 2860 2380">{"".join(o)}</svg>'
(HERE / 'orbit-companion.svg').write_text(svg)
subprocess.run(['figcraft-cli', 'convert', str(HERE / 'orbit-companion.svg'), str(HERE / 'orbit-companion.figcraft')], check=True, stdout=subprocess.DEVNULL)
subprocess.run(['figcraft-cli', 'export', str(HERE / 'orbit-companion.figcraft'), '--out', str(HERE / 'orbit-companion.png')], check=True, stdout=subprocess.DEVNULL)
if __name__ == '__main__':
board()
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 810 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB