Spaces:
Sleeping
Sleeping
Commit ·
64b3043
1
Parent(s): 34c2d24
fix: child always fills the arch — materialization is blur/opacity only
Browse filesThe 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>
- render.py +7 -7
- styles.css +43 -43
- 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 |
-
|
|
|
|
|
|
|
| 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;
|
| 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;
|
| 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};
|
| 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}"
|
| 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
|
| 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 |
-
/*
|
| 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
|
| 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
|
| 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
|
| 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
|
| 69 |
--shadow-drop: none;
|
| 70 |
--shadow-drop-lg: none;
|
| 71 |
--shadow-spread: 0;
|
| 72 |
}
|
| 73 |
|
| 74 |
-
/*
|
| 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
|
| 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
|
| 121 |
body::after {
|
| 122 |
content: '';
|
| 123 |
position: fixed;
|
|
@@ -139,7 +139,7 @@ body::after {
|
|
| 139 |
100% { transform: translate(0, 0); }
|
| 140 |
}
|
| 141 |
|
| 142 |
-
/*
|
| 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 |
-
/*
|
| 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 |
-
/*
|
| 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
|
| 203 |
.chatbot,
|
| 204 |
.bubble-wrap,
|
| 205 |
.message-wrap {
|
|
@@ -207,7 +207,7 @@ button[aria-label="Clear"] {
|
|
| 207 |
border: none !important;
|
| 208 |
}
|
| 209 |
|
| 210 |
-
/*
|
| 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 |
-
/*
|
| 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 |
-
/*
|
| 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 |
-
/*
|
| 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 |
-
/*
|
| 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 |
-
/*
|
| 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 |
-
/*
|
| 372 |
footer { display: none !important; }
|
| 373 |
|
| 374 |
-
/*
|
| 375 |
@media (prefers-reduced-motion: reduce) {
|
| 376 |
body::before,
|
| 377 |
body::after,
|
|
@@ -381,13 +381,13 @@ footer { display: none !important; }
|
|
| 381 |
}
|
| 382 |
}
|
| 383 |
|
| 384 |
-
/*
|
| 385 |
.entity-portal {
|
| 386 |
position: relative;
|
| 387 |
aspect-ratio: 5 / 6;
|
| 388 |
}
|
| 389 |
|
| 390 |
-
/* the scene is masked to a pointed (lancet) arch
|
| 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
|
| 470 |
-
.flick-0 { animation-name: flick-terror; } /* terror
|
| 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
|
| 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 |
-
/*
|
| 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
|
| 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
|
| 590 |
horror beat, and must always play */
|
| 591 |
.entity-sway, .entity-fog { animation: none !important; }
|
| 592 |
}
|
| 593 |
|
| 594 |
-
/*
|
| 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
|
| 653 |
.gradio-container h2 { border-bottom: none !important; }
|
| 654 |
|
| 655 |
-
/*
|
| 656 |
|
| 657 |
-
/* bad ending: a low red breath pulsing over the rage portrait
|
| 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
|
| 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
|
| 687 |
cycles over the body ~4 times (3.2s), then the animations die and the
|
| 688 |
-
rage face beneath stays. Opacity only
|
| 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
|
| 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
|
| 44 |
out = render_entity(0)
|
| 45 |
assert "blur(10" in out
|
| 46 |
assert "opacity:0.35" in out
|
| 47 |
-
assert "scale(
|
| 48 |
|
| 49 |
-
def
|
| 50 |
out = render_entity(100)
|
| 51 |
assert "blur(0" in out
|
| 52 |
assert "opacity:1.0" in out
|
| 53 |
-
assert "scale(
|
| 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)
|