Pabloler21 Claude Fable 5 commited on
Commit
2822e77
·
1 Parent(s): 6b813ec

feat: UX pass — sharp at bond 90, fog thins, restart-proof flash, ghost echo, treasure scroll, native typing dots, autoscroll

Browse files

- materialization completes at bond 90 (was never sharp at 100 behind fixed fog)
- fog/grain/vignette opacity now computed from affinity: the curtain lifts
- flash animation names alternate per turn (flash-0/flash-1) so one-shot
animations restart even when Gradio morphs the DOM in place; flash 1.8s
- subliminal full-screen terror echo (~5% opacity) on recall turns and finale
- faint dried-blood stains in the page background
- treasure: fixed 500px panel with inner scroll + edge fade masks, older
memories sink (opacity floor 0.45), claimed memories visibly dimmed
- removed manual ... bubble — Gradio native pending indicator only
- chatbot autoscroll=True

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (4) hide show
  1. app.py +18 -14
  2. render.py +48 -16
  3. styles.css +79 -10
  4. tests/test_render.py +47 -0
app.py CHANGED
@@ -58,10 +58,8 @@ def _init_state():
58
  def _start_turn(user_msg: str, state: dict, history: list):
59
  if not user_msg.strip() or state.get("ended"):
60
  return gr.update(), gr.update(interactive=not state.get("ended")), history
61
- new_history = history + [
62
- {"role": "user", "content": user_msg},
63
- {"role": "assistant", "content": "..."},
64
- ]
65
  return gr.update(interactive=False), gr.update(interactive=False), new_history
66
 
67
 
@@ -73,11 +71,12 @@ def _play_finale(state: dict, history: list):
73
  output tuple; time.sleep between yields sets the dramatic pace."""
74
  claimed = state["claimed"]
75
  struck: set[str] = set()
76
- chat_hist = list(history[:-1]) # drop the "..." placeholder bubble
77
  tier = get_tier(state["affinity"])
78
  bond = _render_bond(state["affinity"], tier)
79
- treasure_html = render_treasure(state["treasure"], struck=struck)
80
- entity = render_entity(state["affinity"])
 
81
  locked = gr.update(interactive=False)
82
  input_locked = gr.update(interactive=False)
83
  mutated = False
@@ -88,13 +87,14 @@ def _play_finale(state: dict, history: list):
88
 
89
  if step["strike"] is not None:
90
  struck.add(claimed[step["strike"]])
91
- treasure_html = render_treasure(state["treasure"], struck=struck)
 
92
 
93
  if step["stage"] == "turn" and not mutated:
94
  mutated = True
95
  bond = _render_bond(state["affinity"], tier, label="mine")
96
  treasure_html = render_treasure(state["treasure"], mine=True)
97
- entity = render_entity(state["affinity"], "end")
98
  input_locked = gr.update(interactive=False, placeholder="stay.")
99
 
100
  yield (input_locked, locked, chat_hist, state, bond, treasure_html, entity)
@@ -116,8 +116,11 @@ def chat(user_msg: str, state: dict, history: list):
116
  history,
117
  state,
118
  _render_bond(state["affinity"], tier),
119
- render_treasure(state["treasure"], mine=state.get("ended", False)),
120
- render_entity(state["affinity"], "end" if state.get("ended") else "idle"),
 
 
 
121
  )
122
  return
123
 
@@ -146,7 +149,7 @@ def chat(user_msg: str, state: dict, history: list):
146
  state["history"].append({"role": "assistant", "content": reply})
147
  state["turn"] += 1
148
 
149
- new_history = history[:-1] + [{"role": "assistant", "content": reply}]
150
 
151
  mode = "flash_strong" if (do_recall and recall_memory) else ("flash" if captured else "idle")
152
  tier = get_tier(state["affinity"])
@@ -156,8 +159,8 @@ def chat(user_msg: str, state: dict, history: list):
156
  new_history,
157
  state,
158
  _render_bond(state["affinity"], tier),
159
- render_treasure(state["treasure"]),
160
- render_entity(state["affinity"], mode),
161
  )
162
 
163
 
@@ -177,6 +180,7 @@ with gr.Blocks(title="Hollow") as demo:
177
  show_label=False,
178
  height=500,
179
  buttons=[],
 
180
  )
181
 
182
  bond_panel = gr.HTML(value=_render_bond(20, "Hollow"))
 
58
  def _start_turn(user_msg: str, state: dict, history: list):
59
  if not user_msg.strip() or state.get("ended"):
60
  return gr.update(), gr.update(interactive=not state.get("ended")), history
61
+ # only the user bubble — Gradio's native pending indicator does the rest
62
+ new_history = history + [{"role": "user", "content": user_msg}]
 
 
63
  return gr.update(interactive=False), gr.update(interactive=False), new_history
64
 
65
 
 
71
  output tuple; time.sleep between yields sets the dramatic pace."""
72
  claimed = state["claimed"]
73
  struck: set[str] = set()
74
+ chat_hist = list(history)
75
  tier = get_tier(state["affinity"])
76
  bond = _render_bond(state["affinity"], tier)
77
+ treasure_html = render_treasure(state["treasure"], struck=struck,
78
+ claimed=set(claimed))
79
+ entity = render_entity(state["affinity"], seq=state["turn"])
80
  locked = gr.update(interactive=False)
81
  input_locked = gr.update(interactive=False)
82
  mutated = False
 
87
 
88
  if step["strike"] is not None:
89
  struck.add(claimed[step["strike"]])
90
+ treasure_html = render_treasure(state["treasure"], struck=struck,
91
+ claimed=set(claimed))
92
 
93
  if step["stage"] == "turn" and not mutated:
94
  mutated = True
95
  bond = _render_bond(state["affinity"], tier, label="mine")
96
  treasure_html = render_treasure(state["treasure"], mine=True)
97
+ entity = render_entity(state["affinity"], "end", seq=state["turn"])
98
  input_locked = gr.update(interactive=False, placeholder="stay.")
99
 
100
  yield (input_locked, locked, chat_hist, state, bond, treasure_html, entity)
 
116
  history,
117
  state,
118
  _render_bond(state["affinity"], tier),
119
+ render_treasure(state["treasure"], mine=state.get("ended", False),
120
+ claimed=set(state["claimed"])),
121
+ render_entity(state["affinity"],
122
+ "end" if state.get("ended") else "idle",
123
+ seq=state["turn"]),
124
  )
125
  return
126
 
 
149
  state["history"].append({"role": "assistant", "content": reply})
150
  state["turn"] += 1
151
 
152
+ new_history = history + [{"role": "assistant", "content": reply}]
153
 
154
  mode = "flash_strong" if (do_recall and recall_memory) else ("flash" if captured else "idle")
155
  tier = get_tier(state["affinity"])
 
159
  new_history,
160
  state,
161
  _render_bond(state["affinity"], tier),
162
+ render_treasure(state["treasure"], claimed=set(state["claimed"])),
163
+ render_entity(state["affinity"], mode, seq=state["turn"]),
164
  )
165
 
166
 
 
180
  show_label=False,
181
  height=500,
182
  buttons=[],
183
+ autoscroll=True,
184
  )
185
 
186
  bond_panel = gr.HTML(value=_render_bond(20, "Hollow"))
render.py CHANGED
@@ -6,7 +6,7 @@ _PLACEHOLDER = "...nothing yet. but i'm listening."
6
 
7
  _PANEL = (
8
  "background:#0c0a12;border:1px solid #2a1f3d;border-radius:4px;"
9
- "padding:10px;min-height:460px;"
10
  )
11
  _HEADER = (
12
  "font-size:10px;letter-spacing:2px;text-transform:uppercase;color:#5a4570;"
@@ -20,6 +20,7 @@ _ITEM = (
20
  _EMPTY = "font-size:11px;color:#4a3f56;font-style:italic;"
21
  _STRUCK = "text-decoration:line-through;opacity:0.35;"
22
  _MINE = "font-size:12px;color:#7a5f96;font-style:italic;letter-spacing:2px;"
 
23
 
24
 
25
  _ASSETS = Path(__file__).parent / "assets"
@@ -41,20 +42,30 @@ def _src(name: str) -> str:
41
  return f"data:image/webp;base64,{_IMG[name]}"
42
 
43
 
44
- def render_entity(affinity: int, mode: str = "idle") -> str:
45
  """The entity panel: Hollow materializes as affinity rises.
46
 
47
  mode: "idle" | "flash" (memory captured) | "flash_strong" (recall turn)
48
  | "end" (finale — dissolving portrait)
 
 
49
  """
50
  a = max(0, min(100, int(affinity)))
51
- blur = round(14 * (1 - a / 100), 1)
52
- opacity = round(0.25 + 0.75 * a / 100, 2)
53
- scale = round(0.62 + 0.38 * a / 100, 2)
 
54
  almost_on = a >= 76
 
 
 
 
 
 
55
 
56
  look = f"filter:blur({blur}px);opacity:{opacity};transform:scale({scale});"
57
 
 
58
  if mode == "end":
59
  figure = (
60
  '<div class="entity-figure">'
@@ -63,6 +74,11 @@ def render_entity(affinity: int, mode: str = "idle") -> str:
63
  "</div>"
64
  )
65
  flash = ""
 
 
 
 
 
66
  else:
67
  sway = "" if almost_on else " entity-sway"
68
  almost_op = opacity if almost_on else 0
@@ -78,32 +94,48 @@ def render_entity(affinity: int, mode: str = "idle") -> str:
78
  strong = " entity-flash-strong" if mode == "flash_strong" else ""
79
  flash = (
80
  f'<div class="entity-flash-wrap" style="{look}">'
81
- f'<img class="entity-flash{strong}" src="{_src("terror")}">'
82
  "</div>"
83
  )
 
 
 
 
 
84
 
85
  return (
86
  '<div class="entity-stage">'
87
  f"{figure}{flash}"
88
- '<div class="entity-fog"></div>'
89
- '<div class="entity-grain"></div>'
90
- '<div class="entity-vig"></div>'
91
  "</div>"
 
92
  )
93
 
94
 
95
  def render_treasure(treasure: list[str], struck: set[str] | None = None,
96
- mine: bool = False) -> str:
97
  struck = struck or set()
 
98
  header = f'<div style="{_HEADER}">✦ Treasure</div>'
99
  if mine:
100
  body = f'<div style="{_MINE}">...mine now.</div>'
101
  elif not treasure:
102
  body = f'<div style="{_EMPTY}">{_PLACEHOLDER}</div>'
103
  else:
104
- body = "".join(
105
- f'<div style="{_ITEM}{_STRUCK if m in struck else ""}">'
106
- f"{html.escape(m, quote=True)}</div>"
107
- for m in treasure
108
- )
109
- return f'<div style="{_PANEL}">{header}{body}</div>'
 
 
 
 
 
 
 
 
 
 
6
 
7
  _PANEL = (
8
  "background:#0c0a12;border:1px solid #2a1f3d;border-radius:4px;"
9
+ "padding:10px;"
10
  )
11
  _HEADER = (
12
  "font-size:10px;letter-spacing:2px;text-transform:uppercase;color:#5a4570;"
 
20
  _EMPTY = "font-size:11px;color:#4a3f56;font-style:italic;"
21
  _STRUCK = "text-decoration:line-through;opacity:0.35;"
22
  _MINE = "font-size:12px;color:#7a5f96;font-style:italic;letter-spacing:2px;"
23
+ _CLAIMED = "border-left-color:#3a2a50;color:#5a4f72;"
24
 
25
 
26
  _ASSETS = Path(__file__).parent / "assets"
 
42
  return f"data:image/webp;base64,{_IMG[name]}"
43
 
44
 
45
+ def render_entity(affinity: int, mode: str = "idle", seq: int = 0) -> str:
46
  """The entity panel: Hollow materializes as affinity rises.
47
 
48
  mode: "idle" | "flash" (memory captured) | "flash_strong" (recall turn)
49
  | "end" (finale — dissolving portrait)
50
+ seq: turn counter — alternates one-shot animation names (flash-0/flash-1)
51
+ so the flash restarts even when Gradio morphs the DOM in place.
52
  """
53
  a = max(0, min(100, int(affinity)))
54
+ t = min(a, 90) / 90 # fully materialized at bond 90, not 100
55
+ blur = round(14 * (1 - t), 1)
56
+ opacity = round(0.25 + 0.75 * t, 2)
57
+ scale = round(0.62 + 0.38 * t, 2)
58
  almost_on = a >= 76
59
+ flash_key = f"flash-{seq % 2}"
60
+
61
+ # the fog curtain thins as the child becomes real
62
+ fog_op = round(1 - 0.85 * t, 2)
63
+ grain_op = round(0.13 - 0.08 * t, 2)
64
+ vig_op = round(1 - 0.6 * t, 2)
65
 
66
  look = f"filter:blur({blur}px);opacity:{opacity};transform:scale({scale});"
67
 
68
+ ghost = ""
69
  if mode == "end":
70
  figure = (
71
  '<div class="entity-figure">'
 
74
  "</div>"
75
  )
76
  flash = ""
77
+ ghost = (
78
+ f'<div class="entity-ghost {flash_key}" '
79
+ f"style=\"background-image:url('{_src('terror')}')\"></div>"
80
+ )
81
+ fog_op, grain_op, vig_op = 0.15, 0.05, 0.4
82
  else:
83
  sway = "" if almost_on else " entity-sway"
84
  almost_op = opacity if almost_on else 0
 
94
  strong = " entity-flash-strong" if mode == "flash_strong" else ""
95
  flash = (
96
  f'<div class="entity-flash-wrap" style="{look}">'
97
+ f'<img class="entity-flash {flash_key}{strong}" src="{_src("terror")}">'
98
  "</div>"
99
  )
100
+ if mode == "flash_strong":
101
+ ghost = (
102
+ f'<div class="entity-ghost {flash_key}" '
103
+ f"style=\"background-image:url('{_src('terror')}')\"></div>"
104
+ )
105
 
106
  return (
107
  '<div class="entity-stage">'
108
  f"{figure}{flash}"
109
+ f'<div class="entity-fog" style="opacity:{fog_op}"></div>'
110
+ f'<div class="entity-grain" style="opacity:{grain_op}"></div>'
111
+ f'<div class="entity-vig" style="opacity:{vig_op}"></div>'
112
  "</div>"
113
+ f"{ghost}"
114
  )
115
 
116
 
117
  def render_treasure(treasure: list[str], struck: set[str] | None = None,
118
+ mine: bool = False, claimed: set[str] | None = None) -> str:
119
  struck = struck or set()
120
+ claimed = claimed or set()
121
  header = f'<div style="{_HEADER}">✦ Treasure</div>'
122
  if mine:
123
  body = f'<div style="{_MINE}">...mine now.</div>'
124
  elif not treasure:
125
  body = f'<div style="{_EMPTY}">{_PLACEHOLDER}</div>'
126
  else:
127
+ n = len(treasure)
128
+ parts = []
129
+ for i, m in enumerate(treasure):
130
+ # older memories sink into the dark; newest stays fully lit
131
+ age_op = max(0.45, round(1 - 0.07 * (n - 1 - i), 2))
132
+ style = f"{_ITEM}opacity:{age_op};"
133
+ if m in claimed:
134
+ style += _CLAIMED
135
+ if m in struck:
136
+ style += _STRUCK
137
+ parts.append(
138
+ f'<div style="{style}">{html.escape(m, quote=True)}</div>'
139
+ )
140
+ body = "".join(parts)
141
+ return f'<div class="treasure-panel" style="{_PANEL}">{header}{body}</div>'
styles.css CHANGED
@@ -75,6 +75,12 @@
75
  body {
76
  background-color: #0a0810 !important;
77
  background-image:
 
 
 
 
 
 
78
  radial-gradient(ellipse at 14% -5%, rgba(58,20,84,0.20) 0%, transparent 52%),
79
  radial-gradient(ellipse at 86% 105%, rgba(24,8,44,0.26) 0%, transparent 52%);
80
  background-attachment: fixed;
@@ -396,7 +402,8 @@ footer { display: none !important; }
396
  object-fit: cover;
397
  }
398
 
399
- /* terror flash: one-shot animation, restarts on Gradio re-mount */
 
400
  .entity-flash-wrap {
401
  position: absolute;
402
  inset: 0;
@@ -409,22 +416,33 @@ footer { display: none !important; }
409
  height: 100%;
410
  object-fit: cover;
411
  opacity: 0;
412
- animation: entity-flash-anim 1.2s ease-out forwards;
 
 
413
  }
414
 
415
- .entity-flash-strong {
416
- animation: entity-flash-anim 2s ease-out forwards,
417
- entity-glitch 2s steps(2) forwards;
 
 
 
 
 
 
 
 
 
418
  }
419
 
420
- @keyframes entity-flash-anim {
421
  0% { opacity: 0; }
422
- 12% { opacity: 1; }
423
  70% { opacity: 1; }
424
  100% { opacity: 0; }
425
  }
426
 
427
- @keyframes entity-glitch {
428
  0%, 100% { transform: translate(0, 0); }
429
  20% { transform: translate(-3px, 1px); }
430
  40% { transform: translate(2px, -2px); }
@@ -432,6 +450,46 @@ footer { display: none !important; }
432
  80% { transform: translate(3px, -1px); }
433
  }
434
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  /* atmosphere overlays, in front of the portrait */
436
  .entity-fog {
437
  position: absolute;
@@ -464,11 +522,22 @@ footer { display: none !important; }
464
  background: radial-gradient(ellipse at 50% 42%, transparent 42%, rgba(5,4,9,0.82) 100%);
465
  }
466
 
 
 
 
 
 
 
 
 
 
 
 
467
  /* finale: struck-through treasure + decomposing type already inline;
468
  reduced motion */
469
  @media (prefers-reduced-motion: reduce) {
470
- .entity-sway, .entity-fog, .entity-flash, .entity-flash-strong {
471
  animation: none !important;
472
  }
473
- .entity-flash, .entity-flash-strong { opacity: 0 !important; }
474
  }
 
75
  body {
76
  background-color: #0a0810 !important;
77
  background-image:
78
+ /* old stains — desaturated dried-blood gray, barely there */
79
+ radial-gradient(ellipse 16% 11% at 78% 22%, rgba(74,52,56,0.10) 0%, transparent 70%),
80
+ radial-gradient(ellipse 9% 15% at 11% 63%, rgba(66,46,50,0.09) 0%, transparent 70%),
81
+ radial-gradient(ellipse 13% 8% at 56% 89%, rgba(70,48,52,0.08) 0%, transparent 70%),
82
+ radial-gradient(ellipse 6% 10% at 33% 31%, rgba(62,44,48,0.07) 0%, transparent 70%),
83
+ radial-gradient(ellipse 8% 6% at 92% 74%, rgba(70,50,54,0.08) 0%, transparent 70%),
84
  radial-gradient(ellipse at 14% -5%, rgba(58,20,84,0.20) 0%, transparent 52%),
85
  radial-gradient(ellipse at 86% 105%, rgba(24,8,44,0.26) 0%, transparent 52%);
86
  background-attachment: fixed;
 
402
  object-fit: cover;
403
  }
404
 
405
+ /* terror flash: one-shot; animation-name alternates per turn (flash-0/flash-1)
406
+ so the animation restarts even when Gradio morphs the DOM in place */
407
  .entity-flash-wrap {
408
  position: absolute;
409
  inset: 0;
 
416
  height: 100%;
417
  object-fit: cover;
418
  opacity: 0;
419
+ animation-duration: 1.8s;
420
+ animation-timing-function: ease-out;
421
+ animation-fill-mode: forwards;
422
  }
423
 
424
+ .entity-flash.flash-0 { animation-name: entity-flash-a; }
425
+ .entity-flash.flash-1 { animation-name: entity-flash-b; }
426
+
427
+ .entity-flash.entity-flash-strong { animation-duration: 2.2s; }
428
+ .entity-flash.entity-flash-strong.flash-0 { animation-name: entity-flash-a, entity-glitch-a; }
429
+ .entity-flash.entity-flash-strong.flash-1 { animation-name: entity-flash-b, entity-glitch-b; }
430
+
431
+ @keyframes entity-flash-a {
432
+ 0% { opacity: 0; }
433
+ 10% { opacity: 1; }
434
+ 70% { opacity: 1; }
435
+ 100% { opacity: 0; }
436
  }
437
 
438
+ @keyframes entity-flash-b {
439
  0% { opacity: 0; }
440
+ 10% { opacity: 1; }
441
  70% { opacity: 1; }
442
  100% { opacity: 0; }
443
  }
444
 
445
+ @keyframes entity-glitch-a {
446
  0%, 100% { transform: translate(0, 0); }
447
  20% { transform: translate(-3px, 1px); }
448
  40% { transform: translate(2px, -2px); }
 
450
  80% { transform: translate(3px, -1px); }
451
  }
452
 
453
+ @keyframes entity-glitch-b {
454
+ 0%, 100% { transform: translate(0, 0); }
455
+ 20% { transform: translate(-3px, 1px); }
456
+ 40% { transform: translate(2px, -2px); }
457
+ 60% { transform: translate(-2px, 2px); }
458
+ 80% { transform: translate(3px, -1px); }
459
+ }
460
+
461
+ /* subliminal full-screen echo — recall turns and the finale only */
462
+ .entity-ghost {
463
+ position: fixed;
464
+ inset: 0;
465
+ pointer-events: none;
466
+ z-index: 998;
467
+ background-size: cover;
468
+ background-position: center 25%;
469
+ opacity: 0;
470
+ filter: grayscale(1) blur(3px);
471
+ animation-duration: 2.4s;
472
+ animation-timing-function: ease-out;
473
+ animation-fill-mode: forwards;
474
+ }
475
+
476
+ .entity-ghost.flash-0 { animation-name: entity-ghost-a; }
477
+ .entity-ghost.flash-1 { animation-name: entity-ghost-b; }
478
+
479
+ @keyframes entity-ghost-a {
480
+ 0% { opacity: 0; }
481
+ 15% { opacity: 0.055; }
482
+ 60% { opacity: 0.04; }
483
+ 100% { opacity: 0; }
484
+ }
485
+
486
+ @keyframes entity-ghost-b {
487
+ 0% { opacity: 0; }
488
+ 15% { opacity: 0.055; }
489
+ 60% { opacity: 0.04; }
490
+ 100% { opacity: 0; }
491
+ }
492
+
493
  /* atmosphere overlays, in front of the portrait */
494
  .entity-fog {
495
  position: absolute;
 
522
  background: radial-gradient(ellipse at 50% 42%, transparent 42%, rgba(5,4,9,0.82) 100%);
523
  }
524
 
525
+ /* ── 16. Treasure — fixed-height case with inner scroll ── */
526
+ /* items sink into the dark at both edges instead of clipping */
527
+ .treasure-panel {
528
+ height: 500px;
529
+ overflow-y: auto;
530
+ -webkit-mask-image: linear-gradient(
531
+ to bottom, transparent 0, #000 16px, #000 calc(100% - 22px), transparent 100%);
532
+ mask-image: linear-gradient(
533
+ to bottom, transparent 0, #000 16px, #000 calc(100% - 22px), transparent 100%);
534
+ }
535
+
536
  /* finale: struck-through treasure + decomposing type already inline;
537
  reduced motion */
538
  @media (prefers-reduced-motion: reduce) {
539
+ .entity-sway, .entity-fog, .entity-flash, .entity-flash-strong, .entity-ghost {
540
  animation: none !important;
541
  }
542
+ .entity-flash, .entity-flash-strong, .entity-ghost { opacity: 0 !important; }
543
  }
tests/test_render.py CHANGED
@@ -97,3 +97,50 @@ class TestRenderTreasureFinale:
97
  out = render_treasure(["had a dog named Nala"], mine=True)
98
  assert "...mine now." in out
99
  assert "had a dog named Nala" not in out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  out = render_treasure(["had a dog named Nala"], mine=True)
98
  assert "...mine now." in out
99
  assert "had a dog named Nala" not in out
100
+
101
+
102
+ class TestRenderEntityUX:
103
+ def test_fully_sharp_at_bond_90(self):
104
+ out = render_entity(90)
105
+ assert "blur(0.0px)" in out
106
+ assert "opacity:1.0" in out
107
+
108
+ def test_atmosphere_thins_as_bond_rises(self):
109
+ low = render_entity(0)
110
+ high = render_entity(90)
111
+ assert 'class="entity-fog" style="opacity:1.0"' in low
112
+ assert 'class="entity-fog" style="opacity:0.15"' in high
113
+
114
+ def test_flash_animation_key_alternates_by_seq(self):
115
+ assert "flash-0" in render_entity(40, "flash", seq=0)
116
+ assert "flash-1" in render_entity(40, "flash", seq=1)
117
+ assert "flash-0" in render_entity(40, "flash", seq=2)
118
+
119
+ def test_ghost_echo_only_on_strong_flash_and_end(self):
120
+ assert "entity-ghost" not in render_entity(40, "idle")
121
+ assert "entity-ghost" not in render_entity(40, "flash")
122
+ assert "entity-ghost" in render_entity(40, "flash_strong")
123
+ assert "entity-ghost" in render_entity(95, "end")
124
+
125
+
126
+ class TestRenderTreasureUX:
127
+ def test_panel_has_scroll_class(self):
128
+ assert "treasure-panel" in render_treasure([])
129
+ assert "treasure-panel" in render_treasure(["had a dog named Nala"])
130
+
131
+ def test_older_memories_fade(self):
132
+ out = render_treasure(["oldest memory", "middle memory", "newest memory"])
133
+ assert "opacity:0.86" in out # oldest of three: 1 - 0.07*2
134
+ assert "opacity:1.0" in out # newest stays fully lit
135
+
136
+ def test_age_fade_has_floor(self):
137
+ out = render_treasure([f"memory {i}" for i in range(20)])
138
+ assert "opacity:0.45" in out
139
+
140
+ def test_claimed_memories_marked(self):
141
+ out = render_treasure(["mine still", "taken one"], claimed={"taken one"})
142
+ assert "border-left-color:#3a2a50" in out
143
+
144
+ def test_unclaimed_render_unmarked(self):
145
+ out = render_treasure(["mine still"])
146
+ assert "border-left-color:#3a2a50" not in out