open-research-protocol 0.4.27 → 0.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,30 +14,33 @@ GIF_PATH = ASSETS / "terminal-demo.gif"
14
14
  POSTER_PATH = ASSETS / "terminal-demo-poster.png"
15
15
  STORYBOARD_PATH = ASSETS / "terminal-demo-storyboard.png"
16
16
 
17
- WIDTH = 1360
18
- HEIGHT = 840
19
- SIDE_MARGIN = 72
20
- SHELL_TOP = 132
21
- BOTTOM_MARGIN = 72
17
+ WIDTH = 1400
18
+ HEIGHT = 900
19
+ SIDE_MARGIN = 64
20
+ SHELL_TOP = 188
21
+ BOTTOM_MARGIN = 128
22
22
  WINDOW_PADDING = 28
23
23
 
24
- OUTER_BG = "#07131F"
25
- WINDOW_BG = "#0A1728"
26
- WINDOW_EDGE = "#16314D"
27
- TITLEBAR_BG = "#10243A"
28
- TERMINAL_BG = "#0C1B2E"
29
- INK = "#E7F4F1"
30
- MUTED = "#8FA7BF"
31
- DIM = "#6C819A"
32
- ACCENT = "#6EE7D8"
33
- SKY = "#8DBEFF"
34
- SOFT = "#F1D6B8"
35
- CORAL = "#FF9A86"
36
- LIME = "#BDEB9B"
37
- GOLD = "#F6D98D"
24
+ OUTER_BG = "#050C13"
25
+ WINDOW_BG = "#081929"
26
+ WINDOW_EDGE = "#173B5A"
27
+ TITLEBAR_BG = "#10283D"
28
+ TERMINAL_BG = "#071525"
29
+ INK = "#EAF7F3"
30
+ MUTED = "#9AB2C8"
31
+ DIM = "#63798F"
32
+ ACCENT = "#70F0DF"
33
+ SKY = "#8AB8FF"
34
+ SOFT = "#F3D9B7"
35
+ CORAL = "#FF9B88"
36
+ LIME = "#BDF59E"
37
+ GOLD = "#F6D06F"
38
+ MASCOT_DARK = "#14324A"
39
+ MASCOT_BODY = "#DDFCF4"
40
+ MASCOT_WING = "#62DDCC"
38
41
 
39
42
  FONT_MONO = "/System/Library/Fonts/Menlo.ttc"
40
- FONT_SANS_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
43
+ FONT_SANS_BOLD = "/System/Library/Fonts/Supplemental/DIN Alternate Bold.ttf"
41
44
 
42
45
 
43
46
  def load_font(path: str, size: int) -> ImageFont.ImageFont:
@@ -50,160 +53,156 @@ def load_font(path: str, size: int) -> ImageFont.ImageFont:
50
53
  MONO_18 = load_font(FONT_MONO, 18)
51
54
  MONO_20 = load_font(FONT_MONO, 20)
52
55
  MONO_24 = load_font(FONT_MONO, 24)
53
- MONO_28 = load_font(FONT_MONO, 28)
54
56
  MONO_32 = load_font(FONT_MONO, 32)
55
57
  SANS_22 = load_font(FONT_SANS_BOLD, 22)
56
58
  SANS_26 = load_font(FONT_SANS_BOLD, 26)
57
59
  SANS_34 = load_font(FONT_SANS_BOLD, 34)
58
60
  SANS_52 = load_font(FONT_SANS_BOLD, 52)
61
+ SANS_66 = load_font(FONT_SANS_BOLD, 66)
59
62
 
60
63
  CONTENT_X = SIDE_MARGIN + WINDOW_PADDING + 46
61
- CONTENT_Y = SHELL_TOP + 138
62
- CONTENT_WIDTH = (WIDTH - SIDE_MARGIN - WINDOW_PADDING) - CONTENT_X - 20
64
+ CONTENT_Y = SHELL_TOP + 132
65
+ CONTENT_WIDTH = 760
63
66
  LINE_HEIGHT = 34
64
- TYPING_MS = 75
67
+ TYPING_MS = 48
65
68
  COMMAND_SETTLE_MS = 320
66
- OUTPUT_STEP_MS = 240
67
- SCENE_HOLD_MS = 23000
69
+ OUTPUT_STEP_MS = 180
70
+ SCENE_HOLD_MS = 8500
68
71
 
69
72
  SCENES = [
70
73
  {
71
74
  "id": "home",
72
- "label": "home",
73
- "headline": "discover the surface",
75
+ "label": "field guide",
76
+ "headline": "context before claims",
77
+ "sermon": [
78
+ "Read the room first.",
79
+ "Then route the agent.",
80
+ "Receipts beat vibes.",
81
+ ],
74
82
  "command": "orp home",
75
83
  "output_font": "small",
76
84
  "line_height": 30,
77
85
  "output": [
78
- ("ORP 0.4.16", ACCENT),
79
- ("Agent-first CLI for workspace ledgers, secrets, scheduling, and research workflows.", INK),
80
- ("Repo", SKY),
81
- (" root: ~/code/open-research-protocol", INK),
82
- (" config: orp.yml (missing)", ACCENT),
83
- (" git: yes, branch=main, commit=1a2b3c4", SOFT),
86
+ ("ORP 0.4.28", ACCENT),
87
+ ("Agent-first CLI for workspace ledgers, agendas, secrets, and research workflows.", INK),
84
88
  ("Daily Loop", SKY),
85
89
  (" orp workspace tabs main", ACCENT),
86
- (' orp secrets add --alias <alias> --label "<label>" --provider <provider>', INK),
87
- (' orp checkpoint create -m "capture loop state"', SOFT),
90
+ (" orp project refresh --json", INK),
91
+ (" orp agenda focus", SOFT),
92
+ (" orp mode breakdown granular-breakdown", SKY),
88
93
  ],
89
94
  },
90
95
  {
91
- "id": "hosted",
92
- "label": "hosted",
93
- "headline": "see the control plane",
94
- "command": "orp workspaces list",
96
+ "id": "workspace",
97
+ "label": "workspace",
98
+ "headline": "save every live thread",
99
+ "sermon": [
100
+ "A tab is a thread.",
101
+ "A thread needs a path.",
102
+ "A crash should not win.",
103
+ ],
104
+ "command": "orp workspace tabs main",
95
105
  "output": [
96
- ("workspaces.count=2", ACCENT),
97
- ("cursor=", INK),
98
- ("has_more=false", SKY),
99
- ("source=idea_bridge", ACCENT),
100
- ("---", DIM),
101
- ("workspace.id=main-cody-1", INK),
102
- ("workspace.title=main-cody-1", SKY),
103
- ("workspace.tab_count=18", SOFT),
106
+ ("saved tabs: 24", ACCENT),
107
+ ("projects: grouped by repo", SKY),
108
+ ("orp", INK),
109
+ (" path: ~/code/orp", INK),
110
+ (" resume: cd '~/code/orp' && codex resume 019d32d3-d8b2-7fa2-aaec-c74b5134afd6", SOFT),
111
+ ("claude and codex recovery commands stay side-by-side", ACCENT),
104
112
  ],
105
113
  },
106
114
  {
107
115
  "id": "secrets",
108
116
  "label": "secrets",
109
- "headline": "save the key once, reuse it later",
110
- "command": 'orp secrets add --alias openai-primary --label "OpenAI Primary" --provider openai',
117
+ "headline": "save keys without spilling them",
118
+ "sermon": [
119
+ "Name the credential.",
120
+ "Hide the value.",
121
+ "Reuse it safely.",
122
+ ],
123
+ "command": "orp secrets add --alias openai-primary --provider openai",
111
124
  "output": [
112
125
  ("Secret value:", ACCENT),
113
126
  (" sk-...", INK),
114
127
  ("secret.alias=openai-primary", SKY),
115
128
  ("secret.provider=openai", ACCENT),
116
- ("secret.kind=api_key", INK),
117
- ("secret.status=active", SKY),
118
- ("next: orp secrets resolve openai-primary --reveal", SOFT),
129
+ ("username: optional", INK),
130
+ ("value: stored locally, not printed", SKY),
131
+ ("next: orp secrets keychain-spend-policy openai-primary --daily-spend-cap-usd 5", SOFT),
119
132
  ],
120
133
  },
121
134
  {
122
- "id": "workspace",
123
- "label": "workspace",
124
- "headline": "keep the workspace ledger",
125
- "command": "orp workspace tabs main",
126
- "output": [
127
- ("saved tabs: 11", ACCENT),
128
- ("ledger: hosted canonical + local cache", INK),
129
- ("recovery: copy the exact cd && resume command you need", SKY),
130
- ("tools: codex resume and claude --resume are both tracked", ACCENT),
131
- ("tail tabs: orp · orp-web-app · RigidityCore · frg-site", SOFT),
135
+ "id": "research",
136
+ "label": "research",
137
+ "headline": "ask in lanes, spend with consent",
138
+ "sermon": [
139
+ "Dry-run the plan.",
140
+ "Use OpenAI when it helps.",
141
+ "Spend only on purpose.",
132
142
  ],
133
- },
134
- {
135
- "id": "schedule",
136
- "label": "schedule",
137
- "headline": "automate the next loop",
138
- "command": 'orp schedule add codex --name morning-summary --prompt "Summarize this repo"',
143
+ "command": 'orp research ask "Should we expand this project?" --json',
139
144
  "output": [
140
- ("ORP Scheduled Job Created", ACCENT),
141
- ("Name: morning-summary", INK),
142
- ("Kind: codex", SKY),
143
- ("Schedule: daily at 09:00", ACCENT),
144
- ("Prompt source: inline", INK),
145
- ("Codex session id: none", SKY),
146
- ("Next steps:", ACCENT),
147
- (" orp schedule run morning-summary", SOFT),
145
+ ("status: planned", ACCENT),
146
+ ("lanes: plan -> reason -> web -> deep research", SKY),
147
+ ("openai: ready when --execute is explicit", INK),
148
+ ("spend_preflight: checked before provider calls", SOFT),
149
+ ("answer path: orp/research/<run_id>/ANSWER.json", ACCENT),
148
150
  ],
149
151
  },
150
152
  {
151
153
  "id": "governance",
152
154
  "label": "governance",
153
- "headline": "checkpoint the repo safely",
155
+ "headline": "checkpoint the work, not the vibes",
156
+ "sermon": [
157
+ "Progress gets named.",
158
+ "Repos stay recoverable.",
159
+ "Evidence stays separate.",
160
+ ],
154
161
  "command": 'orp checkpoint create -m "capture loop state"',
155
162
  "output": [
156
- ("commit=7f3c2a1", ACCENT),
157
- ("branch=work/release-hardening", INK),
158
- ("message=checkpoint: capture loop state", SKY),
159
- ("checkpoint_log=orp/checkpoints/CHECKPOINT_LOG.md", ACCENT),
160
- ("git_runtime=orp/git/runtime.json", SOFT),
163
+ ("checkpoint: capture loop state", ACCENT),
164
+ ("branch: work/release-hardening", INK),
165
+ ("backup: ready", SKY),
166
+ ("doctor: clean enough to keep moving", ACCENT),
167
+ ("boundary: ORP is process; proof lives in canonical artifacts", SOFT),
161
168
  ],
162
169
  },
163
170
  {
164
- "id": "planning",
165
- "label": "planning",
166
- "headline": "track the live point",
167
- "command": "orp frontier state",
168
- "output": [
169
- ("program_id=sunflower-coda", ACCENT),
170
- ("active_version=v10", INK),
171
- ("active_milestone=v10.3", SKY),
172
- ("active_phase=395", ACCENT),
173
- ("band=verification", INK),
174
- ("next_action=Execute Phase 395", SKY),
175
- ("blocked_by=(none)", SOFT),
171
+ "id": "breakdown",
172
+ "label": "breakdown",
173
+ "headline": "make hard work legible",
174
+ "sermon": [
175
+ "Broad first.",
176
+ "Then lanes.",
177
+ "Then atoms.",
176
178
  ],
177
- },
178
- {
179
- "id": "synthesis",
180
- "label": "synthesis",
181
- "headline": "scan, synthesize, collaborate",
182
- "command": "orp exchange repo synthesize /path/to/source",
179
+ "command": "orp mode breakdown granular-breakdown",
183
180
  "output": [
184
- ("exchange_id=exc_20260331_001", ACCENT),
185
- ("source.mode=local_path", INK),
186
- ("source.local_path=/path/to/source", SKY),
187
- ("source.git_present=true", ACCENT),
188
- ("artifacts.exchange_json=orp/exchange/exc_20260331_001/EXCHANGE.json", INK),
189
- ("artifacts.summary_md=orp/exchange/exc_20260331_001/SUMMARY.md", SKY),
190
- ("artifacts.transfer_map_md=orp/exchange/exc_20260331_001/TRANSFER_MAP.md", SOFT),
181
+ ("sequence:", ACCENT),
182
+ (" L0 whole frame", INK),
183
+ (" L1 boundary", SKY),
184
+ (" L2 major lanes", INK),
185
+ (" L4 atomic obligations", SOFT),
186
+ (" L7 durable checklist", ACCENT),
187
+ ("use when the project feels bigger than your hands", SKY),
191
188
  ],
192
189
  },
193
190
  {
194
- "id": "mode",
195
- "label": "mode",
196
- "headline": "change the lens",
197
- "command": "orp mode nudge sleek-minimal-progressive",
191
+ "id": "share",
192
+ "label": "publish",
193
+ "headline": "share the protocol forward",
194
+ "sermon": [
195
+ "Open research is a relay.",
196
+ "Make the next handoff kinder.",
197
+ "Leave a map.",
198
+ ],
199
+ "command": "orp report summary --json",
198
200
  "output": [
199
- ("mode.id=sleek-minimal-progressive", ACCENT),
200
- ("mode.label=Sleek Minimal Progressive", INK),
201
- ("nudge.title=Subtractive Spark", SKY),
202
- ("nudge.prompt=remove one thing before adding one surprising move", ACCENT),
203
- ("nudge.twist=keep the architecture cleaner than the idea feels", INK),
204
- ("nudge.release=drop the first obvious framing", SOFT),
205
- ("nudge.micro_loop:", ACCENT),
206
- ("- zoom out, rotate, re-enter deliberately", SKY),
201
+ ("packet: ready", ACCENT),
202
+ ("report: current state summarized", INK),
203
+ ("handoff: agent-readable", SKY),
204
+ ("install: npm install -g open-research-protocol", SOFT),
205
+ ("message: keep research open, recoverable, and kind", ACCENT),
207
206
  ],
208
207
  },
209
208
  ]
@@ -247,27 +246,29 @@ def wrap_command(command: str, font: ImageFont.ImageFont, max_width: int) -> lis
247
246
 
248
247
  def draw_background(draw: ImageDraw.ImageDraw) -> None:
249
248
  draw.rectangle((0, 0, WIDTH, HEIGHT), fill=OUTER_BG)
249
+ for y in range(0, HEIGHT, 6):
250
+ blend = y / max(1, HEIGHT)
251
+ red = int(5 + blend * 4)
252
+ green = int(12 + blend * 10)
253
+ blue = int(19 + blend * 20)
254
+ draw.rectangle((0, y, WIDTH, y + 6), fill=(red, green, blue))
250
255
  orbit_colors = [ACCENT, SKY, SOFT, ACCENT, SKY]
251
256
  orbit_points = [
252
- (84, 70),
253
- (WIDTH - 88, 66),
254
- (106, HEIGHT - 118),
255
- (232, HEIGHT - 176),
256
- (WIDTH - 210, HEIGHT - 162),
257
- (WIDTH - 116, HEIGHT - 110),
257
+ (84, 76),
258
+ (WIDTH - 92, 78),
259
+ (118, HEIGHT - 120),
260
+ (268, HEIGHT - 188),
258
261
  ]
259
262
  for idx, (x, y) in enumerate(orbit_points):
260
263
  r = 8 if idx % 2 == 0 else 6
261
264
  color = orbit_colors[idx % len(orbit_colors)]
262
265
  draw.ellipse((x - r, y - r, x + r, y + r), fill=color)
263
- draw.line((106, HEIGHT - 118, 232, HEIGHT - 176), fill="#17324D", width=2)
264
- draw.line((WIDTH - 210, HEIGHT - 162, WIDTH - 116, HEIGHT - 110), fill="#17324D", width=2)
265
266
 
266
267
 
267
268
  def draw_terminal_shell(draw: ImageDraw.ImageDraw) -> tuple[int, int, int, int]:
268
269
  left = SIDE_MARGIN
269
270
  top = SHELL_TOP
270
- right = WIDTH - SIDE_MARGIN
271
+ right = 966
271
272
  bottom = HEIGHT - BOTTOM_MARGIN
272
273
  draw.rounded_rectangle((left, top, right, bottom), radius=36, fill=WINDOW_BG, outline=WINDOW_EDGE, width=2)
273
274
  draw.rounded_rectangle(
@@ -300,11 +301,133 @@ def draw_scene_header(draw: ImageDraw.ImageDraw, scene: dict) -> None:
300
301
  label = scene["label"].upper()
301
302
  label_box = draw.textbbox((0, 0), label, font=MONO_20)
302
303
  label_x = (WIDTH - (label_box[2] - label_box[0])) // 2
303
- draw.text((label_x, 18), label, font=MONO_20, fill=DIM)
304
+ draw.text((label_x, 24), label, font=MONO_20, fill=DIM)
304
305
  headline = scene["headline"]
305
- headline_box = draw.textbbox((0, 0), headline, font=SANS_52)
306
+ headline_box = draw.textbbox((0, 0), headline, font=SANS_66)
306
307
  headline_x = (WIDTH - (headline_box[2] - headline_box[0])) // 2
307
- draw.text((headline_x, 42), headline, font=SANS_52, fill=ACCENT)
308
+ draw.text((headline_x, 58), headline, font=SANS_66, fill=ACCENT)
309
+
310
+
311
+ def draw_speech_bubble(draw: ImageDraw.ImageDraw, scene: dict) -> None:
312
+ left, top, right, bottom = 996, 202, 1332, 398
313
+ draw.rounded_rectangle((left, top, right, bottom), radius=28, fill="#0E2335", outline="#1E4969", width=2)
314
+ draw.polygon([(1060, bottom - 2), (1096, bottom - 2), (1076, bottom + 34)], fill="#0E2335", outline="#1E4969")
315
+ draw.text((left + 28, top + 22), "protocol note", font=MONO_20, fill=SKY)
316
+ y = top + 62
317
+ for line in scene.get("sermon", []):
318
+ for wrapped in wrap_line(str(line), SANS_26, right - left - 54):
319
+ draw.text((left + 28, y), wrapped, font=SANS_26, fill=INK)
320
+ y += 32
321
+
322
+
323
+ def draw_mascot_mouth(
324
+ draw: ImageDraw.ImageDraw,
325
+ cx: int,
326
+ cy: int,
327
+ scene: dict,
328
+ pulse: int,
329
+ scale: float = 1.0,
330
+ ) -> None:
331
+ expression = {
332
+ "home": "smile",
333
+ "workspace": "focused",
334
+ "secrets": "wink",
335
+ "research": "curious",
336
+ "governance": "proud",
337
+ "breakdown": "ooh",
338
+ "share": "beam",
339
+ "hero": "beam",
340
+ }.get(str(scene.get("id", "")), "smile")
341
+ def p(dx: float, dy: float) -> tuple[int, int]:
342
+ return (round(cx + dx * scale), round(cy + dy * scale))
343
+
344
+ def box(x1: float, y1: float, x2: float, y2: float) -> tuple[int, int, int, int]:
345
+ return (*p(x1, y1), *p(x2, y2))
346
+
347
+ width_3 = max(2, round(3 * scale))
348
+ width_4 = max(2, round(4 * scale))
349
+ width_5 = max(2, round(5 * scale))
350
+ wobble = int(math.sin(pulse / 2.0) * 2 * scale)
351
+ if expression == "focused":
352
+ draw.arc(box(-20, 14, 20, 42), start=205, end=335, fill=MASCOT_DARK, width=width_4)
353
+ draw.line((*p(-12, 32 + wobble), *p(12, 32 - wobble)), fill=MASCOT_DARK, width=width_3)
354
+ elif expression == "wink":
355
+ draw.arc(box(-22, 8, 22, 38), start=18, end=162, fill=CORAL, width=width_5)
356
+ draw.ellipse(box(18, 18, 26, 26), fill=GOLD)
357
+ elif expression == "curious":
358
+ draw.ellipse(box(-12, 16, 12, 40), fill=MASCOT_DARK)
359
+ draw.ellipse(box(-5, 22, 5, 34), fill=CORAL)
360
+ elif expression == "proud":
361
+ draw.arc(box(-26, 4, 26, 42), start=22, end=158, fill=MASCOT_DARK, width=width_5)
362
+ draw.ellipse(box(-16, 30, -8, 38), fill=ACCENT)
363
+ draw.ellipse(box(8, 30, 16, 38), fill=ACCENT)
364
+ elif expression == "ooh":
365
+ draw.ellipse(box(-15, 14, 15, 46), fill=MASCOT_DARK)
366
+ draw.ellipse(box(-6, 23, 6, 37), fill="#FBE7C8")
367
+ elif expression == "beam":
368
+ draw.rounded_rectangle(box(-26, 16, 26, 42), radius=round(12 * scale), fill=MASCOT_DARK)
369
+ for tooth_x in (-13, 0, 13):
370
+ draw.line((*p(tooth_x, 17), *p(tooth_x, 40)), fill="#DDFCF4", width=max(1, round(2 * scale)))
371
+ draw.arc(box(-28, 6, 28, 48), start=20, end=160, fill=CORAL, width=width_3)
372
+ else:
373
+ draw.arc(box(-24, 10, 24, 42), start=20, end=160, fill=MASCOT_DARK, width=width_5)
374
+ draw.ellipse(box(-9, 30, 9, 38), fill=CORAL)
375
+
376
+
377
+ def draw_mascot(
378
+ draw: ImageDraw.ImageDraw,
379
+ scene: dict,
380
+ pulse: int = 0,
381
+ *,
382
+ center: tuple[int, int] = (1136, 610),
383
+ scale: float = 1.0,
384
+ ) -> None:
385
+ base_cx, base_cy = center
386
+ cx = base_cx
387
+ cy = base_cy + int(math.sin(pulse / 3.0) * 4 * scale)
388
+
389
+ def p(dx: float, dy: float) -> tuple[int, int]:
390
+ return (round(cx + dx * scale), round(cy + dy * scale))
391
+
392
+ def box(x1: float, y1: float, x2: float, y2: float) -> tuple[int, int, int, int]:
393
+ return (*p(x1, y1), *p(x2, y2))
394
+
395
+ outline_width = max(2, round(3 * scale))
396
+ eye_width = max(2, round(3 * scale))
397
+ draw.ellipse(box(-82, 84, 82, 110), fill="#03101A")
398
+ draw.polygon(
399
+ [
400
+ p(-78, -52),
401
+ p(-50, -122),
402
+ p(-16, -54),
403
+ p(16, -54),
404
+ p(50, -122),
405
+ p(78, -52),
406
+ p(66, 58),
407
+ p(34, 88),
408
+ p(-34, 88),
409
+ p(-66, 58),
410
+ ],
411
+ fill=MASCOT_BODY,
412
+ outline="#122B42",
413
+ )
414
+ draw.polygon([p(-48, -84), p(-28, -52), p(-62, -52)], fill="#CFFFF8")
415
+ draw.polygon([p(48, -84), p(28, -52), p(62, -52)], fill="#CFFFF8")
416
+ draw.pieslice(box(-86, -30, -38, 56), start=92, end=270, fill=MASCOT_WING, outline="#122B42", width=outline_width)
417
+ draw.pieslice(box(38, -30, 86, 56), start=270, end=88, fill=MASCOT_WING, outline="#122B42", width=outline_width)
418
+ draw.ellipse(box(-52, -40, -10, 4), fill=TERMINAL_BG, outline="#122B42", width=eye_width)
419
+ draw.ellipse(box(10, -40, 52, 4), fill=TERMINAL_BG, outline="#122B42", width=eye_width)
420
+ blink = pulse % 17 == 0
421
+ if blink:
422
+ draw.line((*p(-43, -18), *p(-19, -18)), fill=ACCENT, width=max(2, round(4 * scale)))
423
+ draw.line((*p(19, -18), *p(43, -18)), fill=ACCENT, width=max(2, round(4 * scale)))
424
+ else:
425
+ draw.ellipse(box(-39, -30, -23, -14), fill=ACCENT)
426
+ draw.ellipse(box(23, -30, 39, -14), fill=ACCENT)
427
+ draw.polygon([p(-10, -2), p(10, -2), p(0, 15)], fill=GOLD)
428
+ draw_mascot_mouth(draw, cx, cy, scene, pulse, scale=scale)
429
+ draw.ellipse(box(-18, 66, -6, 78), fill=ACCENT)
430
+ draw.ellipse(box(6, 66, 18, 78), fill=SKY)
308
431
 
309
432
 
310
433
  def render_scene(scene: dict, typed_chars: Optional[int] = None, shown_lines: int = 0, cursor: bool = False) -> Image.Image:
@@ -313,6 +436,9 @@ def render_scene(scene: dict, typed_chars: Optional[int] = None, shown_lines: in
313
436
  draw_background(draw)
314
437
  draw_scene_header(draw, scene)
315
438
  draw_terminal_shell(draw)
439
+ pulse = int((typed_chars or 0) + shown_lines * 3)
440
+ draw_speech_bubble(draw, scene)
441
+ draw_mascot(draw, scene, pulse=pulse)
316
442
 
317
443
  command = scene["command"] if typed_chars is None else scene["command"][:typed_chars]
318
444
  command_lines = wrap_command(command, MONO_32, CONTENT_WIDTH)
@@ -340,7 +466,7 @@ def render_scene(scene: dict, typed_chars: Optional[int] = None, shown_lines: in
340
466
  footer_box = draw.textbbox((0, 0), footer, font=MONO_24)
341
467
  footer_width = (footer_box[2] - footer_box[0]) + 46
342
468
  footer_x = (WIDTH - footer_width) // 2
343
- footer_y = HEIGHT - 118
469
+ footer_y = HEIGHT - 84
344
470
  draw.rounded_rectangle((footer_x, footer_y, footer_x + footer_width, footer_y + 54), radius=15, fill=ACCENT)
345
471
  draw.text((footer_x + 23, footer_y + 14), footer, font=MONO_24, fill=WINDOW_BG)
346
472
  return image
@@ -356,7 +482,7 @@ def build_storyboard(final_frames: list[Image.Image]) -> Image.Image:
356
482
  board_height = thumb_height * rows + gutter * (rows + 1) + 72
357
483
  storyboard = Image.new("RGBA", (board_width, board_height), OUTER_BG)
358
484
  draw = ImageDraw.Draw(storyboard)
359
- draw.text((gutter, 20), "ORP terminal walkthrough storyboard", font=SANS_34, fill=INK)
485
+ draw.text((gutter, 20), "ORP mascot walkthrough storyboard", font=SANS_34, fill=INK)
360
486
  for idx, frame in enumerate(final_frames):
361
487
  thumb = frame.copy()
362
488
  thumb.thumbnail((thumb_width, thumb_height))
@@ -370,6 +496,8 @@ def build_storyboard(final_frames: list[Image.Image]) -> Image.Image:
370
496
 
371
497
  def main() -> None:
372
498
  ASSETS.mkdir(parents=True, exist_ok=True)
499
+ for old_scene in ASSETS.glob("terminal-scene-*.png"):
500
+ old_scene.unlink()
373
501
  frames: list[Image.Image] = []
374
502
  durations: list[int] = []
375
503
  final_scene_frames: list[Image.Image] = []