Spaces:
Paused
Paused
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>
- app.py +18 -14
- render.py +48 -16
- styles.css +79 -10
- 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 |
-
|
| 62 |
-
|
| 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
|
| 77 |
tier = get_tier(state["affinity"])
|
| 78 |
bond = _render_bond(state["affinity"], tier)
|
| 79 |
-
treasure_html = render_treasure(state["treasure"], struck=struck
|
| 80 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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;
|
| 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 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
| 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 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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:
|
|
|
|
|
|
|
| 413 |
}
|
| 414 |
|
| 415 |
-
.entity-flash-
|
| 416 |
-
|
| 417 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
}
|
| 419 |
|
| 420 |
-
@keyframes entity-flash-
|
| 421 |
0% { opacity: 0; }
|
| 422 |
-
|
| 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
|