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:
@@ -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 |
Reference in New Issue
Block a user