Files
pauljdandClaude Opus 5.5 e9a1a558e1
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m44s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m21s
Align the top-right buttons with the content edge
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 22:23:35 +00:00

214 lines
14 KiB
Python

#!/usr/bin/env python3
"""Orbit's next interface: custom icons, main menu, grid guide and watching screen.
Writes SVG sources, then native VectorCraft icons and FigCraft screens. Programmes shown are fictional.
Yellow stays reserved for remote focus, as in the existing app.
"""
from pathlib import Path
import base64, glob, html, math, os, re, subprocess
P = Path(__file__).resolve().parent
ROOT = P.parent.parent
COLLECTION = ROOT.parent / '2026-10-31/referenced-chatgpt-conversation-this-is-an/Orbit-Channel-Designs'
ENV = dict(os.environ, XDG_DATA_HOME=str(ROOT / '.tooling/font-data'))
NAVY, PANEL, BLUE, FOCUS, MUTED, SILVER = '#071632', '#12356B', '#194C91', '#FFDE59', '#B7CBE8', '#E8EDF5'
(P / 'icons').mkdir(exist_ok=True); (P / 'sources').mkdir(exist_ok=True); (P / 'review').mkdir(exist_ok=True)
def svg(w, h, body):
return f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{w}" height="{h}" viewBox="0 0 {w} {h}">{body}</svg>'
def text(x, y, value, size=24, fill='white', weight='400', family='Barlow', anchor='start', extra=''):
return f'<text x="{x}" y="{y}" font-family="{family}" font-size="{size}" font-weight="{weight}" fill="{fill}" text-anchor="{anchor}" {extra}>{html.escape(value)}</text>'
def rect(x, y, w, h, fill, r=0, extra=''):
return f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{r}" fill="{fill}" {extra}/>'
def gear_path():
points, teeth = [], 8
for i in range(teeth * 4):
a = 2 * math.pi * i / (teeth * 4) - math.pi / 8
r = 40 if i % 4 in (0, 1) else 31
points.append(f'{48 + r * math.cos(a):.1f},{48 + r * math.sin(a):.1f}')
return ' '.join(points)
def icon(name, c, accent):
s = f'fill="none" stroke="{c}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"'
a = f'fill="none" stroke="{accent}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"'
return {
'all': f'<rect x="10" y="26" width="76" height="56" rx="9" {s}/><path d="M34 26L24 10M62 26L72 10" {s}/><path d="M26 66C40 72 62 60 70 42" {a}/><circle cx="70" cy="42" r="3.5" fill="{accent}"/>',
'entertainment': f'<rect x="10" y="20" width="76" height="56" rx="9" {s}/><path d="M34 88H62" {s}/><path d="M48 32L52.700 42.500 64 43.700 55.500 51.300 57.900 62.500 48 56.700 38.100 62.500 40.500 51.300 32 43.700 43.300 42.500Z" {a}/>',
'movies': f'<rect x="12" y="38" width="72" height="46" rx="6" {s}/><path d="M12 38L78 18 84 32 18 52" {s}/><path d="M32 32L38 44M50 26L56 40M66 22L72 36" {a}/>',
'comedy': f'<circle cx="48" cy="48" r="36" {s}/><circle cx="35" cy="40" r="3.800" fill="{c}"/><circle cx="61" cy="40" r="3.800" fill="{c}"/><path d="M28 54C34 74 62 74 68 54Z" {a}/>',
'kids': f'<path d="M48 8C66 22 68 48 62 68H34C28 48 30 22 48 8Z" {s}/><circle cx="48" cy="36" r="8" {a}/><path d="M34 54L18 70 32 72M62 54L78 70 64 72M42 80V90M54 80V90" {s}/>',
'documentaries': f'<circle cx="44" cy="52" r="34" {s}/><path d="M12 42C28 50 36 38 44 46S40 70 50 84M62 24C56 34 66 40 76 40" {s}/><path d="M60 24C66 8 80 6 90 8 90 22 82 34 66 32Z" {a}/>',
'sport': f'<path d="M14 30V84M82 30V84" {s}/><path d="M14 42H82M14 56H82M14 70H82" {a}/><circle cx="14" cy="26" r="5" fill="{c}"/><circle cx="82" cy="26" r="5" fill="{c}"/>',
'gear': f'<polygon points="{gear_path()}" {s}/><circle cx="48" cy="48" r="12" {a}/>',
'profile': f'<circle cx="48" cy="33" r="19" fill="{c}"/><path d="M12 86C12 64 28 56 48 56S84 64 84 86Z" fill="{c}"/>',
'office': f'<path d="M10 28H86V42C78 42 78 54 86 54V68H10V54C18 54 18 42 10 42Z" {s}/><path d="M60 30V66" {a} stroke-dasharray="5 7"/><path d="M26 42H48M26 54H42" {s}/>',
}[name]
ICONS = ['all', 'entertainment', 'movies', 'comedy', 'kids', 'documentaries', 'sport', 'gear', 'office', 'profile']
for name in ICONS:
src = P / 'icons' / f'orbit_icon_{name}.svg'
src.write_text(svg(96, 96, icon(name, SILVER, '#7FB2FF')))
for out, options in ((src.with_suffix('.vectorcraft'), []), (src.with_suffix('.png'), ['--scale', '2'])):
subprocess.run(['vectorcraft-cli', 'convert', str(src), str(out), *options], env=ENV, check=True, stdout=subprocess.DEVNULL)
# Orbit's own mark: the same crescent and orbit every channel carries, in the network's blue and silver.
ORBIT_DEFS = '<defs><linearGradient id="orbitsilver" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset=".44" stop-color="#E8EDF5"/><stop offset=".5" stop-color="#B7CBE8"/><stop offset="1" stop-color="#F5F7FF"/></linearGradient></defs>'
ORBIT_MARK = '<path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#orbitsilver)"/><path d="M129 369C102 399 111 425 161 428C267 432 431 340 445 289C455 250 405 250 369 261" fill="none" stroke="#5FA8FF" stroke-width="13"/><circle cx="430" cy="282" r="11" fill="#5FA8FF"/>'
def orbit_logo(x, y, height):
"""Mark and wordmark, height tall. Drawn 640 by 160."""
return (f'<g transform="translate({x},{y}) scale({height / 160})">' + ORBIT_DEFS + f'<g transform="translate(-14,-22) scale(.36)">{ORBIT_MARK}</g>' +
text(168, 118, 'ORBIT', 104, 'url(#orbitsilver)', '500', 'Barlow Condensed', extra='letter-spacing="14"') + '</g>')
for name, body in (('orbit_logo', svg(640, 160, orbit_logo(0, 0, 160))),
('orbit_mark', svg(512, 512, ORBIT_DEFS + f'<g transform="translate(-24,-30) scale(1.08)">{ORBIT_MARK}</g>'))):
src = P / 'icons' / f'{name}.svg'
src.write_text(body)
for out, options in ((src.with_suffix('.vectorcraft'), []), (src.with_suffix('.png'), [])):
subprocess.run(['vectorcraft-cli', 'convert', str(src), str(out), *options], env=ENV, check=True, stdout=subprocess.DEVNULL)
def use_icon(name, x, y, size, colour=SILVER, accent='#7FB2FF'):
return f'<g transform="translate({x},{y}) scale({size / 96})">{icon(name, colour, accent)}</g>'
counter = [0]
def logo(folder, x, y, width):
"""Inline a channel's outlined guide logo, with its ids made unique for this page."""
counter[0] += 1
found = glob.glob(str(COLLECTION / folder / 'exports/*_epg.svg'))[0]
s = open(found).read()
inner = s[s.index('>', s.index('<svg')) + 1:s.rindex('</svg>')]
for name in set(re.findall(r'id="([^"]+)"', inner)):
inner = inner.replace(f'id="{name}"', f'id="l{counter[0]}{name}"').replace(f'url(#{name})', f'url(#l{counter[0]}{name})').replace(f'href="#{name}"', f'href="#l{counter[0]}{name}"')
return f'<g transform="translate({x},{y}) scale({width / 512})">{inner}</g>'
def still():
out = P / 'sources/still.jpg'
subprocess.run(['ffmpeg', '-y', '-v', 'error', '-ss', '1.2', '-i', str(COLLECTION / 'depth-native/city/heroes/heroes_city.mp4'), '-frames:v', '1', '-q:v', '3', str(out)], check=True)
return 'data:image/jpeg;base64,' + base64.b64encode(out.read_bytes()).decode()
STILL = still()
def picture(x, y, w, h):
return f'<image x="{x}" y="{y}" width="{w}" height="{h}" preserveAspectRatio="xMidYMid slice" href="{STILL}"/>'
def top_bar(title='orbit'):
return (orbit_logo(60, 26, 76) +
text(1656, 74, 'Sun 1 Nov 20:14', 28, 'white', '600', anchor='end') + rect(1688, 28, 76, 68, PANEL, 12) + use_icon('gear', 1700, 36, 52) + rect(1784, 28, 76, 68, PANEL, 12) + use_icon('profile', 1798, 38, 48))
def legend(items, y=1040, x=60):
out = ''
for key, label in items:
out += text(x, y, key, 24, 'white', '600')
x += 18 + (32 * len(key) if key[0] in '▲▼◄►' else 15 * len(key))
out += text(x, y, label, 22, MUTED)
x += 40 + 11 * len(label)
return out
def category_rows(selected, y=132, x=60, w=350):
out = ''
for i, (name, label) in enumerate([('all', 'All Channels'), ('entertainment', 'Entertainment'), ('movies', 'Movies'), ('comedy', 'Comedy'), ('kids', 'Kids'), ('documentaries', 'Documentaries'), ('sport', 'Sport')]):
ry = y + i * 106
on = i == selected
out += rect(x, ry, w, 94, FOCUS if on else PANEL, 10)
out += use_icon(name, x + 16, ry + 19, 56, NAVY if on else SILVER, NAVY if on else '#7FB2FF')
out += text(x + 90, ry + 58, label, 30, NAVY if on else 'white', '600')
return out
def progress(x, y, w, fraction):
return rect(x, y, w, 8, '#0B2452', 4) + rect(x, y, w * fraction, 8, SILVER, 4)
# Main menu: television first. One live picture, and two small promises beside it.
home = rect(0, 0, 1920, 1080, NAVY) + top_bar() + category_rows(0)
home += picture(440, 132, 860, 484) + rect(440, 132, 860, 484, 'none', 0, f'stroke="{BLUE}" stroke-width="3"')
home += rect(440, 628, 860, 234, PANEL, 10) + logo('heroes', 456, 650, 260)
home += text(736, 676, 'NOW 20:00 to 20:30', 24, MUTED, '600') + text(736, 726, 'The Night Watch', 44, 'white', '600') + progress(736, 750, 540, .47) + text(736, 806, 'NEXT 20:30 City Under Siege', 26, MUTED)
home += text(1330, 158, 'On now', 28, 'white', '600')
for i, (folder, title, frac) in enumerate([('orbit-terra', 'Life on the Ice Shelf', .62), ('paradox', 'The Signal Room', .2), ('orbit-gold', 'Mind the Shop', .81)]):
ry = 176 + i * 112
home += rect(1330, ry, 530, 102, PANEL, 10) + logo(folder, 1338, ry + 16, 220) + text(1566, ry + 46, title, 25, 'white', '600') + progress(1566, ry + 66, 276, frac)
home += rect(1330, 522, 530, 340, PANEL, 10) + rect(1330, 522, 530, 62, '#7A5A14', 10) + rect(1330, 560, 530, 24, '#7A5A14')
home += use_icon('office', 1344, 530, 46, '#FFF3C4', '#FFF3C4') + text(1402, 564, 'Tonight at OrbitOffice', 28, '#FFF3C4', '600')
home += text(1356, 644, 'Late Night Spooks', 50, 'white', '500', 'Barlow Condensed') + text(1356, 692, 'Three films from 21:00', 26, MUTED)
home += text(1356, 748, 'The Last Screening', 25, 'white') + text(1356, 786, 'Midnight Visitors', 25, 'white') + text(1356, 824, 'The Hollow House', 25, 'white')
home += legend([('▲▼', 'Move'), ('OK', 'Select'), ('►', 'Picture and events'), ('Back', 'Full screen')], 960)
(P / 'sources/orbit_main_menu.svg').write_text(svg(1920, 1080, home))
# Grid guide: picture one side, the highlighted programme the other, channels and times below.
guide = rect(0, 0, 1920, 1080, NAVY) + top_bar()
guide += picture(60, 116, 576, 324) + rect(60, 116, 576, 324, 'none', 0, f'stroke="{BLUE}" stroke-width="3"')
guide += logo('orbit-odyssey', 660, 112, 300) + text(660, 240, 'Under Northern Stars', 52, 'white', '600')
guide += text(660, 286, '20:30 to 21:30 • Sun 1 Nov • Orbit Odyssey', 26, MUTED, '600')
guide += text(660, 336, 'A crew of astronomers follow a once-in-a-century comet across the Arctic,', 27, 'white') + text(660, 372, 'racing the polar night to capture it from the top of the world.', 27, 'white')
guide += text(660, 422, 'OK Watch • Hold OK Go to channel number', 23, MUTED)
gx, gy, rowh, left = 400, 462, 90, 60
guide += rect(left, gy, 330, 48, PANEL) + text(left + 18, gy + 34, 'All Channels', 26, 'white', '600') + use_icon('all', left + 278, gy + 6, 36)
span = 1860 - gx
times = ['20:00', '20:30', '21:00', '21:30']
guide += rect(gx - 10, gy, span + 10, 48, PANEL)
for i, t in enumerate(times):
guide += text(gx + i * span / 4 + 10, gy + 34, t, 25, 'white', '600')
rows = [(1, 'orbit-1', [(0, 1, 'The Evening Exchange'), (1, 2, 'Family Favourites'), (3, 1, 'Home Run')]),
(21, 'heroes', [(0, 1, 'The Night Watch'), (1, 1, 'City Under Siege'), (2, 2, 'The Silver Shield')]),
(34, 'orbit-odyssey', [(0, 1, 'Maps of the Deep'), (1, 2, 'Under Northern Stars'), (3, 1, 'Engines')]),
(40, 'orbit-terra', [(0, 2, 'Life on the Ice Shelf'), (2, 2, 'The Long Migration')]),
(52, 'paradox', [(0, 1, 'The Signal Room'), (1, 1, 'Mirror Town'), (2, 1, 'The Static Hour'), (3, 1, 'Lost Hours')])]
for i, (number, folder, shows) in enumerate(rows):
ry = gy + 56 + i * rowh
guide += rect(left, ry, 330, rowh - 8, PANEL) + text(left + 16, ry + 52, f'{100 + number}', 26, MUTED, '600') + logo(folder, left + 66, ry + 2, 250)
for start, length, title in shows:
focused = title == 'Under Northern Stars'
x = gx + start * span / 4
width = length * span / 4 - 6
guide += rect(x, ry, width, rowh - 8, FOCUS if focused else (BLUE if start == 0 else '#102D5E'))
guide += text(x + 16, ry + 50, title, 27, NAVY if focused else 'white', '600' if focused else '400')
now_x = gx + span * .47 / 2
guide += rect(now_x - 1, gy + 48, 3, 5 * rowh + 2, '#FFFFFF', 0, 'opacity=".85"')
guide += legend([('▲▼', 'Channel'), ('◄►', 'Time'), ('OK', 'Watch'), ('Hold OK', 'Number'), ('Back', 'Categories')], 1050)
(P / 'sources/orbit_grid_guide.svg').write_text(svg(1920, 1080, guide))
# Watching: the classic bar on OK, and the small number box on hold.
paper = SILVER
watch = picture(0, 0, 1920, 1080)
watch += text(1820, 110, '134', 84, '#47FF52', '700', 'monospace', 'end')
watch += rect(760, 90, 400, 170, NAVY, 14, f'stroke="{FOCUS}" stroke-width="3"') + text(960, 136, 'Go to channel', 28, MUTED, '600', anchor='middle')
for i, d in enumerate(['1', '3', '_']):
watch += rect(838 + i * 84, 156, 72, 84, FOCUS if i == 2 else PANEL, 8) + text(874 + i * 84, 216, d, 54, NAVY if i == 2 else 'white', '600', anchor='middle')
watch += rect(132, 780, 1656, 46, paper) + text(158, 812, '134 Orbit Odyssey', 25, NAVY, '600') + text(1762, 812, '20:42 Sun 1 Nov', 24, NAVY, anchor='end')
watch += rect(132, 826, 1656, 128, BLUE) + text(160, 876, 'NOW', 24, FOCUS, '600') + text(290, 876, 'Under Northern Stars', 38, 'white', '600') + progress(1270, 866, 490, .2)
watch += text(160, 930, 'NEXT', 22, MUTED, '600') + text(290, 930, '21:30 Engines', 27, 'white')
watch += rect(132, 954, 1656, 58, paper)
x = 160
for key, label in [('▲▼', 'Channel'), ('◄', 'Last channel'), ('►', 'Subtitles'), ('Hold OK', 'Number'), ('Back', 'TV guide')]:
watch += text(x, 994, key, 25, NAVY, '600'); x += 18 + (32 * len(key) if key[0] in '▲▼◄►' else 16 * len(key))
watch += text(x, 994, label, 25, NAVY, '600'); x += 60 + 13 * len(label)
(P / 'sources/orbit_watching.svg').write_text(svg(1920, 1080, watch))
for name in ('orbit_main_menu', 'orbit_grid_guide', 'orbit_watching'):
native = P / 'review' / f'{name}.figcraft'
subprocess.run(['figcraft-cli', 'convert', str(P / 'sources' / f'{name}.svg'), str(native)], env=ENV, check=True, stdout=subprocess.DEVNULL)
subprocess.run(['figcraft-cli', 'export', str(native), '--out', str(native.with_suffix('.png'))], env=ENV, check=True, stdout=subprocess.DEVNULL)
print('built')