diff --git a/design/companion/build-companion.py b/design/companion/build-companion.py
new file mode 100644
index 0000000..840feea
--- /dev/null
+++ b/design/companion/build-companion.py
@@ -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'{escape(s)}'
+
+
+def rr(x, y, w, h, r, fill, extra=''):
+ return f''
+
+
+def mark(x, y, c, size=1.0):
+ """Orbit's crescent and orbital line, small."""
+ s = size
+ return (f''
+ f'')
+
+
+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'' + 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''
+ + t(60, y + 27, 'Living Room', 16, c['ink'], 700) + t(166, y + 27, 'Watching 308 BritX', 13, c['muted'])
+ + f'')
+
+
+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''
+ 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''
+ for dx, dy, rot in [(0, -82, 0), (0, 82, 180), (-82, 0, 270), (82, 0, 90)]:
+ o += f''
+ o += f'' + t(cx, cy + 9, 'OK', 26, c['on'], 800, 'middle')
+ # Back, the channel rocker, the volume rocker and mute.
+ y = 656
+ o += f'' + f''
+ 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'' + 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''
+ 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'' + 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''
+ 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''
+ o += f''
+ 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 += f'{body}'
+ 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''
+ (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()
diff --git a/design/companion/orbit-companion.figcraft b/design/companion/orbit-companion.figcraft
new file mode 100644
index 0000000..44211b0
Binary files /dev/null and b/design/companion/orbit-companion.figcraft differ
diff --git a/design/companion/orbit-companion.png b/design/companion/orbit-companion.png
new file mode 100644
index 0000000..9e95aa1
Binary files /dev/null and b/design/companion/orbit-companion.png differ
diff --git a/design/companion/orbit-companion.svg b/design/companion/orbit-companion.svg
new file mode 100644
index 0000000..525e4b0
--- /dev/null
+++ b/design/companion/orbit-companion.svg
@@ -0,0 +1 @@
+
\ No newline at end of file