Pabloler21 Claude Fable 5 commited on
Commit
64b3043
·
1 Parent(s): 34c2d24

fix: child always fills the arch — materialization is blur/opacity only

Browse files

The scale ramp shrank the child inside a fixed frame at low bond (ugly
gap, looked like a bug on the Space). Size is now constant everywhere,
including the neutral dissolve.

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

Files changed (3) hide show
  1. render.py +7 -7
  2. styles.css +43 -43
  3. tests/test_render.py +4 -4
render.py CHANGED
@@ -62,7 +62,6 @@ def render_entity(affinity: int, mode: str = "idle", seq: int = 0) -> str:
62
  t = min(a, 45) / 45 # fully materialized by bond 45 — the player must SEE him early
63
  blur = round(10 * (1 - t), 1)
64
  opacity = round(0.35 + 0.65 * t, 2)
65
- scale = round(0.62 + 0.38 * t, 2)
66
  almost_on = a >= 76
67
  flash_key = f"flash-{seq % 2}"
68
 
@@ -71,7 +70,9 @@ def render_entity(affinity: int, mode: str = "idle", seq: int = 0) -> str:
71
  grain_op = round(0.13 - 0.08 * t, 2)
72
  vig_op = round(1 - 0.6 * t, 2)
73
 
74
- look = f"filter:blur({blur}px);opacity:{opacity};transform:scale({scale});"
 
 
75
 
76
  ghost = ""
77
  tint = ""
@@ -79,7 +80,7 @@ def render_entity(affinity: int, mode: str = "idle", seq: int = 0) -> str:
79
  figure = (
80
  '<div class="entity-figure">'
81
  f'<img class="entity-img entity-rage" src="{_src("rage")}" '
82
- 'style="filter:blur(0px);opacity:1.0;transform:scale(1.0);">'
83
  "</div>"
84
  )
85
  flash = ""
@@ -115,7 +116,7 @@ def render_entity(affinity: int, mode: str = "idle", seq: int = 0) -> str:
115
  figure = (
116
  '<div class="entity-figure">'
117
  f'<img class="entity-img entity-end" src="{_src("end")}" '
118
- 'style="filter:blur(0px);opacity:1.0;transform:scale(1.0);">'
119
  "</div>"
120
  )
121
  flash = ""
@@ -141,7 +142,7 @@ def render_entity(affinity: int, mode: str = "idle", seq: int = 0) -> str:
141
  f'<div class="entity-figure{sway}">'
142
  f'<img class="entity-img" src="{_src("base")}" style="{look}">'
143
  f'<img class="entity-img entity-almost" src="{_src("almost")}" '
144
- f'style="filter:blur({blur}px);opacity:{almost_op};transform:scale({scale});">'
145
  "</div>"
146
  )
147
  flash = ""
@@ -149,13 +150,12 @@ def render_entity(affinity: int, mode: str = "idle", seq: int = 0) -> str:
149
  strong = " entity-flash-strong" if mode == "flash_strong" else ""
150
  # a fast subliminal flicker through every face — too quick to read,
151
  # the portrait seems to convulse through what it could become
152
- flash_look = f"transform:scale({scale});"
153
  layers = "".join(
154
  f'<img class="entity-flick flick-{i}" src="{_src(n)}">'
155
  for i, n in enumerate(("terror", "almost", "end", "base"))
156
  )
157
  flash = (
158
- f'<div class="entity-flash-wrap {flash_key}{strong}" style="{flash_look}">'
159
  f"{layers}</div>"
160
  )
161
  if mode == "flash_strong":
 
62
  t = min(a, 45) / 45 # fully materialized by bond 45 — the player must SEE him early
63
  blur = round(10 * (1 - t), 1)
64
  opacity = round(0.35 + 0.65 * t, 2)
 
65
  almost_on = a >= 76
66
  flash_key = f"flash-{seq % 2}"
67
 
 
70
  grain_op = round(0.13 - 0.08 * t, 2)
71
  vig_op = round(1 - 0.6 * t, 2)
72
 
73
+ # the child always fills the arch — materialization is fog/blur/opacity
74
+ # only, never size (a shrunken child inside a fixed frame reads as a bug)
75
+ look = f"filter:blur({blur}px);opacity:{opacity};"
76
 
77
  ghost = ""
78
  tint = ""
 
80
  figure = (
81
  '<div class="entity-figure">'
82
  f'<img class="entity-img entity-rage" src="{_src("rage")}" '
83
+ 'style="filter:blur(0px);opacity:1.0;">'
84
  "</div>"
85
  )
86
  flash = ""
 
116
  figure = (
117
  '<div class="entity-figure">'
118
  f'<img class="entity-img entity-end" src="{_src("end")}" '
119
+ 'style="filter:blur(0px);opacity:1.0;">'
120
  "</div>"
121
  )
122
  flash = ""
 
142
  f'<div class="entity-figure{sway}">'
143
  f'<img class="entity-img" src="{_src("base")}" style="{look}">'
144
  f'<img class="entity-img entity-almost" src="{_src("almost")}" '
145
+ f'style="filter:blur({blur}px);opacity:{almost_op};">'
146
  "</div>"
147
  )
148
  flash = ""
 
150
  strong = " entity-flash-strong" if mode == "flash_strong" else ""
151
  # a fast subliminal flicker through every face — too quick to read,
152
  # the portrait seems to convulse through what it could become
 
153
  layers = "".join(
154
  f'<img class="entity-flick flick-{i}" src="{_src(n)}">'
155
  for i, n in enumerate(("terror", "almost", "end", "base"))
156
  )
157
  flash = (
158
+ f'<div class="entity-flash-wrap {flash_key}{strong}">'
159
  f"{layers}</div>"
160
  )
161
  if mode == "flash_strong":
styles.css CHANGED
@@ -1,14 +1,14 @@
1
- /* ─────────────────────────────────────────────────
2
- HOLLOW Horror CSS for Gradio 6.x
3
  Strategy: dissolve the Gradio "form" chrome into one
4
  continuous void, then layer atmosphere on top.
5
  No JS. @keyframes only (grain, vignette breath,
6
  title flicker, Bond heartbeat).
7
- ───────────────────────────────────────────────── */
8
 
9
  @import url('https://fonts.googleapis.com/css2?family=Special+Elite&family=Crimson+Text:ital,wght@0,400;0,600;1,400;1,600&display=swap');
10
 
11
- /* ── 1. Theme variable overrides ──────────────── */
12
  :root {
13
  /* Backgrounds */
14
  --body-background-fill: #0a0810;
@@ -19,7 +19,7 @@
19
  --color-accent: #7a4f8a;
20
  --color-accent-soft: #15101f; /* visitor bubble bg */
21
 
22
- /* Borders kept near-invisible; the void is continuous */
23
  --border-color-primary: #14101f;
24
  --border-color-accent: #2e2142;
25
  --border-color-accent-subdued: #14101f;
@@ -28,7 +28,7 @@
28
  --body-text-color: #c0b8ce;
29
  --body-text-color-subdued: #5a5070;
30
 
31
- /* Labels almost gone, like worn engraving */
32
  --block-label-text-color: #352f4a;
33
  --block-title-text-color: #352f4a;
34
  --block-label-background-fill: transparent;
@@ -51,7 +51,7 @@
51
  --button-shadow-active: none;
52
  --button-shadow-hover: none;
53
 
54
- /* Blocks / panels chrome dissolved */
55
  --block-background-fill: transparent;
56
  --block-border-color: transparent;
57
  --block-border-width: 0px;
@@ -65,13 +65,13 @@
65
  --font: 'Crimson Text', Georgia, 'Times New Roman', serif;
66
  --font-mono: 'Special Elite', 'Courier New', monospace;
67
 
68
- /* Shadows off depth comes from atmosphere, not cards */
69
  --shadow-drop: none;
70
  --shadow-drop-lg: none;
71
  --shadow-spread: 0;
72
  }
73
 
74
- /* ── 2. The void + breathing fog ──────────────── */
75
  body {
76
  background-color: #0a0810 !important;
77
  background-image:
@@ -81,7 +81,7 @@ body {
81
  min-height: 100vh;
82
  }
83
 
84
- /* Dried-blood stains own fixed layer ABOVE the vignette so the corner
85
  darkening can't eat them. Painted over the scene like stains on glass.
86
  Kept off the deep edges; the breathing pulse comes from being in this
87
  layer's neighbourhood, not the vignette. */
@@ -117,7 +117,7 @@ body::before {
117
  50% { opacity: 1.00; }
118
  }
119
 
120
- /* Film grain fixed, jittering, very faint */
121
  body::after {
122
  content: '';
123
  position: fixed;
@@ -139,7 +139,7 @@ body::after {
139
  100% { transform: translate(0, 0); }
140
  }
141
 
142
- /* ── 3. Container ─────────────────────────────── */
143
  .gradio-container {
144
  background: transparent !important;
145
  font-family: 'Crimson Text', Georgia, serif !important;
@@ -157,7 +157,7 @@ body::after {
157
  box-shadow: none !important;
158
  }
159
 
160
- /* ── 4. Title ─────────────────────��───────────── */
161
  .gradio-container h2 {
162
  font-family: 'Special Elite', 'Courier New', monospace !important;
163
  color: #8a6a9a !important;
@@ -186,7 +186,7 @@ body::after {
186
  letter-spacing: 0.05em;
187
  }
188
 
189
- /* ── 5. Kill Gradio chatbot chrome ────────────── */
190
  /* Toolbar (share / copy / fullscreen) breaks the fiction.
191
  Best killed via app.py flags too; this is the backstop. */
192
  .icon-button-wrapper,
@@ -199,7 +199,7 @@ button[aria-label="Clear"] {
199
  display: none !important;
200
  }
201
 
202
- /* The message scroll area let it bleed into the void */
203
  .chatbot,
204
  .bubble-wrap,
205
  .message-wrap {
@@ -207,7 +207,7 @@ button[aria-label="Clear"] {
207
  border: none !important;
208
  }
209
 
210
- /* ── 6. Hollow's words (bot) formless, haunted ── */
211
  .bot {
212
  background: transparent !important;
213
  border: none !important;
@@ -227,7 +227,7 @@ button[aria-label="Clear"] {
227
  text-shadow: 0 0 18px rgba(120,70,160,0.18);
228
  }
229
 
230
- /* ── 7. Visitor's words (user) contained, human ── */
231
  .user {
232
  background: var(--color-accent-soft) !important;
233
  border: 1px solid #211a32 !important;
@@ -245,7 +245,7 @@ button[aria-label="Clear"] {
245
  line-height: 1.6 !important;
246
  }
247
 
248
- /* ── 8. Input a line of confession, not a box ── */
249
  textarea,
250
  .gradio-container input[type="text"] {
251
  background: transparent !important;
@@ -272,7 +272,7 @@ textarea::placeholder {
272
  font-style: italic;
273
  }
274
 
275
- /* ── 9. Buttons (the send) ──────────────────── */
276
  .gradio-container button {
277
  font-family: 'Crimson Text', Georgia, serif !important;
278
  font-size: 1.25rem !important;
@@ -292,7 +292,7 @@ textarea::placeholder {
292
  box-shadow: 0 0 16px rgba(120,60,160,0.38) !important;
293
  }
294
 
295
- /* ── 10. Bond meter custom HTML, a pulsing thread ── */
296
  /* Rendered by _render_bond() in app.py. Full control:
297
  no Gradio orange, no track, no number box, no reset. */
298
  .bond-meter {
@@ -362,16 +362,16 @@ textarea::placeholder {
362
  45% { transform: scaleY(1.0); box-shadow: 0 0 4px rgba(120,60,160,0.35); }
363
  }
364
 
365
- /* ── 12. Scrollbar ────────────────────────────── */
366
  ::-webkit-scrollbar { width: 5px; height: 5px; }
367
  ::-webkit-scrollbar-track { background: transparent; }
368
  ::-webkit-scrollbar-thumb { background: #2a1f40; border-radius: 3px; }
369
  ::-webkit-scrollbar-thumb:hover { background: #3d2f58; }
370
 
371
- /* ── 13. Hide Gradio footer ───────────────────── */
372
  footer { display: none !important; }
373
 
374
- /* ── 14. Respect reduced motion ───────────────── */
375
  @media (prefers-reduced-motion: reduce) {
376
  body::before,
377
  body::after,
@@ -381,13 +381,13 @@ footer { display: none !important; }
381
  }
382
  }
383
 
384
- /* ── 15. Entity panel the child behind a gothic arch ── */
385
  .entity-portal {
386
  position: relative;
387
  aspect-ratio: 5 / 6;
388
  }
389
 
390
- /* the scene is masked to a pointed (lancet) arch a cathedral window */
391
  .entity-stage {
392
  position: absolute;
393
  inset: 0;
@@ -398,7 +398,7 @@ footer { display: none !important; }
398
  }
399
 
400
  /* carved-stone lancet arch: gradient bevel (light top-left, recessed bottom-
401
- right), dark inner groove, faint highlight. No keystone, no corner blocks
402
  just worn stone tracing the masked edge so the child sits flush inside it. */
403
  .entity-stage::after {
404
  content: '';
@@ -466,8 +466,8 @@ footer { display: none !important; }
466
 
467
  .entity-flash-wrap.entity-flash-strong .entity-flick { animation-duration: 0.7s; }
468
 
469
- /* each face blinks in its own scattered windows they overlap into chaos */
470
- .flick-0 { animation-name: flick-terror; } /* terror appears most */
471
  .flick-1 { animation-name: flick-almost; }
472
  .flick-2 { animation-name: flick-end; }
473
  .flick-3 { animation-name: flick-base; } /* settles back to the portrait */
@@ -493,7 +493,7 @@ footer { display: none !important; }
493
  90%, 100% { opacity: 1; }
494
  }
495
 
496
- /* subliminal full-screen echo recall turns and the finale only */
497
  .entity-ghost {
498
  position: fixed;
499
  inset: 0;
@@ -562,7 +562,7 @@ footer { display: none !important; }
562
  background: radial-gradient(ellipse at 50% 42%, transparent 42%, rgba(5,4,9,0.82) 100%);
563
  }
564
 
565
- /* ── 16. Treasure stone reliquary with inner scroll ── */
566
  /* the frame lives on the panel; the fade mask lives on the inner scroller,
567
  so the mask can't eat the border-image */
568
  .treasure-panel {
@@ -583,15 +583,15 @@ footer { display: none !important; }
583
  }
584
 
585
  /* reduced motion: kill real MOTION (drift, sway, glitch translate) but keep
586
- the terror flash and echo they are opacity fades, not movement, and they
587
  are the core horror beat. Strip only the glitch from the strong variant. */
588
  @media (prefers-reduced-motion: reduce) {
589
- /* kill ambient drift only the flicker and screamer are opacity, the core
590
  horror beat, and must always play */
591
  .entity-sway, .entity-fog { animation: none !important; }
592
  }
593
 
594
- /* ── 17. Carved-stone frames weathered tablet (Worn Tablet) ── */
595
  /* One reusable 9-slice border-image: directional bevel baked into an SVG
596
  gradient (light top-left, recessed bottom-right), a dark inner groove, a
597
  faint highlight, and rounded chipped corner blocks. Paired with an inset
@@ -649,12 +649,12 @@ footer { display: none !important; }
649
  border-image-width: 9px !important;
650
  }
651
 
652
- /* Title no underline rule */
653
  .gradio-container h2 { border-bottom: none !important; }
654
 
655
- /* ── 18. Three endings rage portrait & fog dissolve ── */
656
 
657
- /* bad ending: a low red breath pulsing over the rage portrait the only
658
  color in the entire game, reserved for this moment */
659
  .entity-rage-tint {
660
  position: absolute;
@@ -670,22 +670,22 @@ footer { display: none !important; }
670
  50% { opacity: 1; }
671
  }
672
 
673
- /* neutral ending: materialization in reverse the child sinks back into
674
  the fog. One-shot on mount; the animation overrides the inline idle style
675
- and `forwards` holds the dissolved frame. Opacity/blur are the beat itself
676
  do NOT add these to the prefers-reduced-motion kill lists. */
677
  .entity-dissolve {
678
  animation: entity-dissolve 9s ease-in forwards;
679
  }
680
 
681
  @keyframes entity-dissolve {
682
- 0% { filter: blur(0px); opacity: 1; transform: scale(1); }
683
- 100% { filter: blur(10px); opacity: 0.18; transform: scale(0.62); }
684
  }
685
 
686
- /* bad finale frenzy: the capture-flash flicker but sustained every face
687
  cycles over the body ~4 times (3.2s), then the animations die and the
688
- rage face beneath stays. Opacity only must survive reduced-motion. */
689
  .entity-frenzy-wrap {
690
  position: absolute;
691
  inset: 0;
@@ -698,7 +698,7 @@ footer { display: none !important; }
698
 
699
  /* frenzy ghost: the terror face stabs the FULL screen three times across the
700
  convulsion (vs the single half-second stab of capture flashes). No flash-0/1
701
- class here fresh element on mount, plays once. Opacity only. */
702
  .entity-ghost-frenzy {
703
  animation: ghost-frenzy 3.2s steps(1, end) forwards;
704
  }
 
1
+ /* ─────────────────────────────────────────────────
2
+ HOLLOW — Horror CSS for Gradio 6.x
3
  Strategy: dissolve the Gradio "form" chrome into one
4
  continuous void, then layer atmosphere on top.
5
  No JS. @keyframes only (grain, vignette breath,
6
  title flicker, Bond heartbeat).
7
+ ───────────────────────────────────────────────── */
8
 
9
  @import url('https://fonts.googleapis.com/css2?family=Special+Elite&family=Crimson+Text:ital,wght@0,400;0,600;1,400;1,600&display=swap');
10
 
11
+ /* ── 1. Theme variable overrides ──────────────── */
12
  :root {
13
  /* Backgrounds */
14
  --body-background-fill: #0a0810;
 
19
  --color-accent: #7a4f8a;
20
  --color-accent-soft: #15101f; /* visitor bubble bg */
21
 
22
+ /* Borders — kept near-invisible; the void is continuous */
23
  --border-color-primary: #14101f;
24
  --border-color-accent: #2e2142;
25
  --border-color-accent-subdued: #14101f;
 
28
  --body-text-color: #c0b8ce;
29
  --body-text-color-subdued: #5a5070;
30
 
31
+ /* Labels — almost gone, like worn engraving */
32
  --block-label-text-color: #352f4a;
33
  --block-title-text-color: #352f4a;
34
  --block-label-background-fill: transparent;
 
51
  --button-shadow-active: none;
52
  --button-shadow-hover: none;
53
 
54
+ /* Blocks / panels — chrome dissolved */
55
  --block-background-fill: transparent;
56
  --block-border-color: transparent;
57
  --block-border-width: 0px;
 
65
  --font: 'Crimson Text', Georgia, 'Times New Roman', serif;
66
  --font-mono: 'Special Elite', 'Courier New', monospace;
67
 
68
+ /* Shadows off — depth comes from atmosphere, not cards */
69
  --shadow-drop: none;
70
  --shadow-drop-lg: none;
71
  --shadow-spread: 0;
72
  }
73
 
74
+ /* ── 2. The void + breathing fog ──────────────── */
75
  body {
76
  background-color: #0a0810 !important;
77
  background-image:
 
81
  min-height: 100vh;
82
  }
83
 
84
+ /* Dried-blood stains — own fixed layer ABOVE the vignette so the corner
85
  darkening can't eat them. Painted over the scene like stains on glass.
86
  Kept off the deep edges; the breathing pulse comes from being in this
87
  layer's neighbourhood, not the vignette. */
 
117
  50% { opacity: 1.00; }
118
  }
119
 
120
+ /* Film grain — fixed, jittering, very faint */
121
  body::after {
122
  content: '';
123
  position: fixed;
 
139
  100% { transform: translate(0, 0); }
140
  }
141
 
142
+ /* ── 3. Container ─────────────────────────────── */
143
  .gradio-container {
144
  background: transparent !important;
145
  font-family: 'Crimson Text', Georgia, serif !important;
 
157
  box-shadow: none !important;
158
  }
159
 
160
+ /* ── 4. Title ─────────────────────────────────── */
161
  .gradio-container h2 {
162
  font-family: 'Special Elite', 'Courier New', monospace !important;
163
  color: #8a6a9a !important;
 
186
  letter-spacing: 0.05em;
187
  }
188
 
189
+ /* ── 5. Kill Gradio chatbot chrome ────────────── */
190
  /* Toolbar (share / copy / fullscreen) breaks the fiction.
191
  Best killed via app.py flags too; this is the backstop. */
192
  .icon-button-wrapper,
 
199
  display: none !important;
200
  }
201
 
202
+ /* The message scroll area — let it bleed into the void */
203
  .chatbot,
204
  .bubble-wrap,
205
  .message-wrap {
 
207
  border: none !important;
208
  }
209
 
210
+ /* ── 6. Hollow's words (bot) — formless, haunted ── */
211
  .bot {
212
  background: transparent !important;
213
  border: none !important;
 
227
  text-shadow: 0 0 18px rgba(120,70,160,0.18);
228
  }
229
 
230
+ /* ── 7. Visitor's words (user) — contained, human ── */
231
  .user {
232
  background: var(--color-accent-soft) !important;
233
  border: 1px solid #211a32 !important;
 
245
  line-height: 1.6 !important;
246
  }
247
 
248
+ /* ── 8. Input — a line of confession, not a box ── */
249
  textarea,
250
  .gradio-container input[type="text"] {
251
  background: transparent !important;
 
272
  font-style: italic;
273
  }
274
 
275
+ /* ── 9. Buttons (the → send) ──────────────────── */
276
  .gradio-container button {
277
  font-family: 'Crimson Text', Georgia, serif !important;
278
  font-size: 1.25rem !important;
 
292
  box-shadow: 0 0 16px rgba(120,60,160,0.38) !important;
293
  }
294
 
295
+ /* ── 10. Bond meter — custom HTML, a pulsing thread ── */
296
  /* Rendered by _render_bond() in app.py. Full control:
297
  no Gradio orange, no track, no number box, no reset. */
298
  .bond-meter {
 
362
  45% { transform: scaleY(1.0); box-shadow: 0 0 4px rgba(120,60,160,0.35); }
363
  }
364
 
365
+ /* ── 12. Scrollbar ────────────────────────────── */
366
  ::-webkit-scrollbar { width: 5px; height: 5px; }
367
  ::-webkit-scrollbar-track { background: transparent; }
368
  ::-webkit-scrollbar-thumb { background: #2a1f40; border-radius: 3px; }
369
  ::-webkit-scrollbar-thumb:hover { background: #3d2f58; }
370
 
371
+ /* ── 13. Hide Gradio footer ───────────────────── */
372
  footer { display: none !important; }
373
 
374
+ /* ── 14. Respect reduced motion ───────────────── */
375
  @media (prefers-reduced-motion: reduce) {
376
  body::before,
377
  body::after,
 
381
  }
382
  }
383
 
384
+ /* ── 15. Entity panel — the child behind a gothic arch ── */
385
  .entity-portal {
386
  position: relative;
387
  aspect-ratio: 5 / 6;
388
  }
389
 
390
+ /* the scene is masked to a pointed (lancet) arch — a cathedral window */
391
  .entity-stage {
392
  position: absolute;
393
  inset: 0;
 
398
  }
399
 
400
  /* carved-stone lancet arch: gradient bevel (light top-left, recessed bottom-
401
+ right), dark inner groove, faint highlight. No keystone, no corner blocks —
402
  just worn stone tracing the masked edge so the child sits flush inside it. */
403
  .entity-stage::after {
404
  content: '';
 
466
 
467
  .entity-flash-wrap.entity-flash-strong .entity-flick { animation-duration: 0.7s; }
468
 
469
+ /* each face blinks in its own scattered windows — they overlap into chaos */
470
+ .flick-0 { animation-name: flick-terror; } /* terror — appears most */
471
  .flick-1 { animation-name: flick-almost; }
472
  .flick-2 { animation-name: flick-end; }
473
  .flick-3 { animation-name: flick-base; } /* settles back to the portrait */
 
493
  90%, 100% { opacity: 1; }
494
  }
495
 
496
+ /* subliminal full-screen echo — recall turns and the finale only */
497
  .entity-ghost {
498
  position: fixed;
499
  inset: 0;
 
562
  background: radial-gradient(ellipse at 50% 42%, transparent 42%, rgba(5,4,9,0.82) 100%);
563
  }
564
 
565
+ /* ── 16. Treasure — stone reliquary with inner scroll ── */
566
  /* the frame lives on the panel; the fade mask lives on the inner scroller,
567
  so the mask can't eat the border-image */
568
  .treasure-panel {
 
583
  }
584
 
585
  /* reduced motion: kill real MOTION (drift, sway, glitch translate) but keep
586
+ the terror flash and echo — they are opacity fades, not movement, and they
587
  are the core horror beat. Strip only the glitch from the strong variant. */
588
  @media (prefers-reduced-motion: reduce) {
589
+ /* kill ambient drift only — the flicker and screamer are opacity, the core
590
  horror beat, and must always play */
591
  .entity-sway, .entity-fog { animation: none !important; }
592
  }
593
 
594
+ /* ── 17. Carved-stone frames — weathered tablet (Worn Tablet) ── */
595
  /* One reusable 9-slice border-image: directional bevel baked into an SVG
596
  gradient (light top-left, recessed bottom-right), a dark inner groove, a
597
  faint highlight, and rounded chipped corner blocks. Paired with an inset
 
649
  border-image-width: 9px !important;
650
  }
651
 
652
+ /* Title — no underline rule */
653
  .gradio-container h2 { border-bottom: none !important; }
654
 
655
+ /* ── 18. Three endings — rage portrait & fog dissolve ── */
656
 
657
+ /* bad ending: a low red breath pulsing over the rage portrait — the only
658
  color in the entire game, reserved for this moment */
659
  .entity-rage-tint {
660
  position: absolute;
 
670
  50% { opacity: 1; }
671
  }
672
 
673
+ /* neutral ending: materialization in reverse — the child sinks back into
674
  the fog. One-shot on mount; the animation overrides the inline idle style
675
+ and `forwards` holds the dissolved frame. Opacity/blur are the beat itself —
676
  do NOT add these to the prefers-reduced-motion kill lists. */
677
  .entity-dissolve {
678
  animation: entity-dissolve 9s ease-in forwards;
679
  }
680
 
681
  @keyframes entity-dissolve {
682
+ 0% { filter: blur(0px); opacity: 1; }
683
+ 100% { filter: blur(10px); opacity: 0.18; }
684
  }
685
 
686
+ /* bad finale frenzy: the capture-flash flicker but sustained — every face
687
  cycles over the body ~4 times (3.2s), then the animations die and the
688
+ rage face beneath stays. Opacity only — must survive reduced-motion. */
689
  .entity-frenzy-wrap {
690
  position: absolute;
691
  inset: 0;
 
698
 
699
  /* frenzy ghost: the terror face stabs the FULL screen three times across the
700
  convulsion (vs the single half-second stab of capture flashes). No flash-0/1
701
+ class here — fresh element on mount, plays once. Opacity only. */
702
  .entity-ghost-frenzy {
703
  animation: ghost-frenzy 3.2s steps(1, end) forwards;
704
  }
tests/test_render.py CHANGED
@@ -40,17 +40,17 @@ class TestRenderEntity:
40
  assert "entity-stage" in out
41
  assert "data:image/webp;base64," in out
42
 
43
- def test_low_affinity_is_blurred_faint_far(self):
44
  out = render_entity(0)
45
  assert "blur(10" in out
46
  assert "opacity:0.35" in out
47
- assert "scale(0.62)" in out
48
 
49
- def test_full_affinity_is_sharp_full_close(self):
50
  out = render_entity(100)
51
  assert "blur(0" in out
52
  assert "opacity:1.0" in out
53
- assert "scale(1.0)" in out
54
 
55
  def test_affinity_is_clamped(self):
56
  assert render_entity(-10) == render_entity(0)
 
40
  assert "entity-stage" in out
41
  assert "data:image/webp;base64," in out
42
 
43
+ def test_low_affinity_is_blurred_faint(self):
44
  out = render_entity(0)
45
  assert "blur(10" in out
46
  assert "opacity:0.35" in out
47
+ assert "scale(" not in out # size never changes — frame always fits
48
 
49
+ def test_full_affinity_is_sharp_full(self):
50
  out = render_entity(100)
51
  assert "blur(0" in out
52
  assert "opacity:1.0" in out
53
+ assert "scale(" not in out
54
 
55
  def test_affinity_is_clamped(self):
56
  assert render_entity(-10) == render_entity(0)