Pabloler21 Claude Opus 4.8 commited on
Commit
8c5bc54
·
1 Parent(s): 0d9b37b

feat: Day 5 — horror CSS, custom Bond heartbeat meter, vivid recall

Browse files

- styles.css: dissolve Gradio chrome into one void; breathing vignette
+ animated film grain; Hollow's bubbles float (italic/glow), visitor
bubbles solid. Wired via launch(css_paths=...).
- app.py: replace gr.Slider with custom HTML Bond meter (_render_bond,
heartbeat). Chatbot buttons=[] + show_label=False. Start affinity 20.
- schemas.py: affinity_delta clamp +-5 -> +-8.
- engine.py: explicit generous affinity scale (+6..+8 for vulnerable
shares); new_memories only from visitor, never from Hollow's reply.
- character.py: recall now claims one specific memory vividly in first
person, then '...no. that was yours. wasn't it?'.

32 tests passing. Checkpoint passed: vivid first-person recall fires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (5) hide show
  1. app.py +26 -13
  2. character.py +7 -3
  3. engine.py +10 -4
  4. schemas.py +1 -1
  5. styles.css +248 -150
app.py CHANGED
@@ -6,9 +6,27 @@ from memory import apply_update, get_tier, should_recall
6
  from render import render_treasure
7
 
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  def _init_state():
10
  return {
11
- "affinity": 15,
12
  "treasure": [],
13
  "claimed": [],
14
  "history": [],
@@ -35,8 +53,7 @@ def chat(user_msg: str, state: dict, history: list):
35
  gr.update(interactive=True),
36
  history,
37
  state,
38
- state["affinity"],
39
- f"*{tier}*",
40
  render_treasure(state["treasure"]),
41
  )
42
 
@@ -66,8 +83,7 @@ def chat(user_msg: str, state: dict, history: list):
66
  gr.update(interactive=True),
67
  new_history,
68
  state,
69
- state["affinity"],
70
- f"*{tier}*",
71
  render_treasure(state["treasure"]),
72
  )
73
 
@@ -82,15 +98,12 @@ with gr.Blocks(title="Hollow") as demo:
82
  chatbot = gr.Chatbot(
83
  value=[{"role": "assistant", "content": OPENING_LINE}],
84
  label="",
 
85
  height=500,
 
86
  )
87
 
88
- with gr.Row():
89
- affinity_slider = gr.Slider(
90
- minimum=0, maximum=100, value=15,
91
- interactive=False, label="Bond", scale=4,
92
- )
93
- tier_label = gr.Markdown("*Hollow*", scale=1)
94
 
95
  with gr.Row():
96
  msg_input = gr.Textbox(
@@ -111,7 +124,7 @@ with gr.Blocks(title="Hollow") as demo:
111
  ).then(
112
  fn=chat,
113
  inputs=[msg_input, state, chatbot],
114
- outputs=[msg_input, send_btn, chatbot, state, affinity_slider, tier_label, treasure_panel],
115
  )
116
 
117
  msg_input.submit(
@@ -121,7 +134,7 @@ with gr.Blocks(title="Hollow") as demo:
121
  ).then(
122
  fn=chat,
123
  inputs=[msg_input, state, chatbot],
124
- outputs=[msg_input, send_btn, chatbot, state, affinity_slider, tier_label, treasure_panel],
125
  )
126
 
127
  if __name__ == "__main__":
 
6
  from render import render_treasure
7
 
8
 
9
+ def _render_bond(affinity: int, tier: str) -> str:
10
+ """Custom HTML Bond meter — a thin thread with a heartbeat marker.
11
+ Replaces gr.Slider so we fully control the look (no Gradio orange/track)."""
12
+ pct = max(0, min(100, int(affinity)))
13
+ return (
14
+ '<div class="bond-meter">'
15
+ '<div class="bond-head">'
16
+ '<span class="bond-name">bond</span>'
17
+ f'<span class="bond-tier">{tier}</span>'
18
+ '</div>'
19
+ '<div class="bond-track">'
20
+ f'<div class="bond-fill" style="width:{pct}%"></div>'
21
+ f'<div class="bond-beat" style="left:{pct}%"></div>'
22
+ '</div>'
23
+ '</div>'
24
+ )
25
+
26
+
27
  def _init_state():
28
  return {
29
+ "affinity": 20,
30
  "treasure": [],
31
  "claimed": [],
32
  "history": [],
 
53
  gr.update(interactive=True),
54
  history,
55
  state,
56
+ _render_bond(state["affinity"], tier),
 
57
  render_treasure(state["treasure"]),
58
  )
59
 
 
83
  gr.update(interactive=True),
84
  new_history,
85
  state,
86
+ _render_bond(state["affinity"], tier),
 
87
  render_treasure(state["treasure"]),
88
  )
89
 
 
98
  chatbot = gr.Chatbot(
99
  value=[{"role": "assistant", "content": OPENING_LINE}],
100
  label="",
101
+ show_label=False,
102
  height=500,
103
+ buttons=[],
104
  )
105
 
106
+ bond_panel = gr.HTML(value=_render_bond(20, "Hollow"))
 
 
 
 
 
107
 
108
  with gr.Row():
109
  msg_input = gr.Textbox(
 
124
  ).then(
125
  fn=chat,
126
  inputs=[msg_input, state, chatbot],
127
+ outputs=[msg_input, send_btn, chatbot, state, bond_panel, treasure_panel],
128
  )
129
 
130
  msg_input.submit(
 
134
  ).then(
135
  fn=chat,
136
  inputs=[msg_input, state, chatbot],
137
+ outputs=[msg_input, send_btn, chatbot, state, bond_panel, treasure_panel],
138
  )
139
 
140
  if __name__ == "__main__":
character.py CHANGED
@@ -52,9 +52,13 @@ def build_system_prompt(affinity: int, treasure: list[str], recall_memory: str |
52
 
53
  if recall_memory:
54
  parts.append(
55
- f"\nThis turn, you must retell this memory in first person, as if it happened to you: \"{recall_memory}\"\n"
56
- "After retelling it, pause and doubt say something like '...no. that was yours. wasn't it?' "
57
- "or '...i don't know why i said that. i'm sorry.' Do this naturally, within the flow of the conversation."
 
 
 
 
58
  )
59
 
60
  return "\n".join(parts)
 
52
 
53
  if recall_memory:
54
  parts.append(
55
+ f"\nIMPORTANT — this turn, claim this one specific memory as your own: \"{recall_memory}\".\n"
56
+ "Retell it vividly, in first person, with a concrete sensory detail, as if you truly lived it. "
57
+ "Make this the heart of your reply. Do NOT speak about 'memories' in the abstract claim THIS one, "
58
+ "concretely, by name. (For example, if the memory were a red bicycle: "
59
+ "'i had a red bicycle once. i loved it more than anything. when it was taken i cried for days.') "
60
+ "Then stop, as if something is wrong, and quietly doubt — but with the version that names it as theirs: "
61
+ "'...no. that was yours. wasn't it?' Do this naturally, within the flow of the conversation."
62
  )
63
 
64
  return "\n".join(parts)
engine.py CHANGED
@@ -4,11 +4,17 @@ IS_SPACE = bool(os.environ.get("SPACE_ID"))
4
 
5
  _EXTRACTION_SYSTEM = (
6
  "You are a JSON extraction assistant. Given a conversation exchange, extract two things:\n"
7
- "1. affinity_delta: integer from -5 to +5. Positive if the visitor is kind, shares memories, shows care. "
8
- "Negative if cruel, dismissive, or says they're leaving.\n"
9
- "2. new_memories: list of personal facts the visitor revealed about themselves. "
 
 
 
 
 
 
10
  "Only clearly personal facts (names, people, places, feelings). Empty list if nothing personal.\n"
11
- 'Respond ONLY with valid JSON. No markdown. Example: {"affinity_delta": 2, "new_memories": ["has a dog named Nala"]}'
12
  )
13
 
14
 
 
4
 
5
  _EXTRACTION_SYSTEM = (
6
  "You are a JSON extraction assistant. Given a conversation exchange, extract two things:\n"
7
+ "1. affinity_delta: integer from -8 to +8 how much the visitor deepened the bond this turn. "
8
+ "Use this scale and be generous when they open up:\n"
9
+ " +6 to +8: shared something vulnerable or intimate a fear, a loss, grief, loneliness, a tender memory.\n"
10
+ " +3 to +5: shared a personal fact or memory, or was warm and caring.\n"
11
+ " +1 to +2: ordinary politeness or small talk.\n"
12
+ " 0: neutral or off-topic.\n"
13
+ " -3 to -8: cruel, mocking, dismissive, or says they are leaving.\n"
14
+ "2. new_memories: list of personal facts the VISITOR revealed about THEMSELVES, taken ONLY from what "
15
+ "the visitor said this turn — NEVER from Hollow's reply, even if Hollow repeats or claims the memory. "
16
  "Only clearly personal facts (names, people, places, feelings). Empty list if nothing personal.\n"
17
+ 'Respond ONLY with valid JSON. No markdown. Example: {"affinity_delta": 6, "new_memories": ["is afraid of being forgotten"]}'
18
  )
19
 
20
 
schemas.py CHANGED
@@ -9,4 +9,4 @@ class TurnUpdate(BaseModel):
9
  @field_validator("affinity_delta")
10
  @classmethod
11
  def clamp_delta(cls, v):
12
- return max(-5, min(5, v))
 
9
  @field_validator("affinity_delta")
10
  @classmethod
11
  def clamp_delta(cls, v):
12
+ return max(-8, min(8, v))
styles.css CHANGED
@@ -1,57 +1,62 @@
1
  /* ─────────────────────────────────────────────────
2
  HOLLOW — Horror CSS for Gradio 6.x
3
- Overrides theme variables + adds atmosphere.
4
- No JS. @keyframes only for the Bond heartbeat.
 
 
5
  ───────────────────────────────────────────────── */
6
 
7
  @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');
8
 
9
- /* ── 1. CSS custom-property overrides ─────────── */
10
  :root {
11
- /* Core backgrounds */
12
  --body-background-fill: #0a0810;
13
- --background-fill-primary: #0f0d16;
14
- --background-fill-secondary: #0c0a14; /* Hollow's bubble bg */
15
 
16
- /* Accent drives user bubble + focus highlights */
17
  --color-accent: #7a4f8a;
18
- --color-accent-soft: #130f1e; /* visitor's bubble bg */
19
 
20
- /* Borders */
21
- --border-color-primary: #1a1628;
22
- --border-color-accent: #3a2a50;
23
- --border-color-accent-subdued:#1a1628;
24
 
25
  /* Text */
26
- --body-text-color: #c0b8ce;
27
- --body-text-color-subdued: #5a5070;
28
- --block-label-text-color: #52486a;
 
 
 
 
 
29
 
30
  /* Inputs */
31
- --input-background-fill: #0c0a14;
32
- --input-background-fill-focus:#0f0c18;
33
- --input-border-color: #1a1628;
34
- --input-border-color-focus: #3a2a50;
35
- --input-placeholder-color: #38305a;
36
- --input-shadow: none;
37
- --input-shadow-focus: 0 0 0 2px #1e1630;
38
 
39
  /* Buttons */
40
- --button-primary-background-fill: #1d1230;
41
- --button-primary-background-fill-hover: #2c1a40;
42
- --button-primary-text-color: #c0b8ce;
43
- --button-secondary-background-fill: #181428;
44
- --button-secondary-background-fill-hover: #231c38;
45
- --button-secondary-text-color: #c0b8ce;
46
- --button-shadow: none;
47
- --button-shadow-active: none;
48
- --button-shadow-hover: none;
49
-
50
- /* Blocks / panels */
51
- --block-background-fill: #0f0d16;
52
- --block-border-color: #16122a;
53
  --block-shadow: none;
54
- --block-label-background-fill: #0a0810;
55
 
56
  /* Slider */
57
  --slider-color: #7a4f8a;
@@ -60,75 +65,137 @@
60
  --font: 'Crimson Text', Georgia, 'Times New Roman', serif;
61
  --font-mono: 'Special Elite', 'Courier New', monospace;
62
 
63
- /* Shadows */
64
- --shadow-drop: 0 1px 6px rgba(0,0,0,0.6);
65
- --shadow-drop-lg: 0 2px 12px rgba(0,0,0,0.7);
66
  --shadow-spread: 0;
67
  }
68
 
69
- /* ── 2. Body atmosphere ────────────────────────── */
70
  body {
71
  background-color: #0a0810 !important;
72
  background-image:
73
- radial-gradient(ellipse at 12% 0%, rgba(55,18,80,0.18) 0%, transparent 55%),
74
- radial-gradient(ellipse at 88% 100%, rgba(25,8,45,0.22) 0%, transparent 55%);
75
  background-attachment: fixed;
76
  min-height: 100vh;
77
  }
78
 
79
- /* ── 3. Container ──────────────────────────────── */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  .gradio-container {
81
  background: transparent !important;
82
  font-family: 'Crimson Text', Georgia, serif !important;
83
  font-size: 1.05rem !important;
84
  }
85
 
86
- /* Fog gradient pinned to viewport top */
87
- .gradio-container::before {
88
- content: '';
89
- position: fixed;
90
- top: 0; left: 0; right: 0;
91
- height: 90px;
92
- background: linear-gradient(to bottom, rgba(6,4,12,0.72) 0%, transparent 100%);
93
- pointer-events: none;
94
- z-index: 999;
95
  }
96
 
97
- /* ── 4. Header typography ─────────────────────── */
98
  .gradio-container h2 {
99
  font-family: 'Special Elite', 'Courier New', monospace !important;
100
- color: #7a5a8a !important;
101
- letter-spacing: 0.14em !important;
102
- font-size: 1.55rem !important;
103
  font-weight: 400 !important;
104
  text-shadow:
105
- 0 0 22px rgba(110,55,145,0.40),
106
- 0 0 60px rgba(80,30,115,0.18);
107
  animation: title-flicker 8s ease-in-out infinite;
108
  }
109
 
110
  @keyframes title-flicker {
111
- 0%, 92%, 96%, 100% { opacity: 1; }
112
- 94% { opacity: 0.72; }
113
- 95% { opacity: 1; }
 
114
  }
115
 
116
- .gradio-container h2 + p em,
117
- .gradio-container .prose em,
118
- .gradio-container > .wrap > .contain > .wrap > .block > label > span {
119
- color: #42365a !important;
120
  font-style: italic;
121
- font-size: 0.9em;
122
- letter-spacing: 0.04em;
123
  }
124
 
125
- /* ── 5. Hollow's messages (bot) ───────────────── */
126
- /* Gradio 6 sets: background-color: var(--background-fill-secondary)
127
- We set that var to #0c0a14 above, then add visual polish here. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  .bot {
 
 
129
  border-left: 2px solid #3a2a50 !important;
130
- border-radius: 0 8px 8px 0 !important;
131
- box-shadow: 0 1px 8px rgba(0,0,0,0.5) !important;
132
  }
133
 
134
  .bot .prose,
@@ -136,17 +203,18 @@ body {
136
  .bot p {
137
  font-family: 'Crimson Text', Georgia, serif !important;
138
  font-style: italic !important;
139
- font-size: 1.12rem !important;
140
- color: #afa8be !important;
141
- line-height: 1.65 !important;
 
142
  }
143
 
144
- /* ── 6. Visitor's messages (user) ─────────────── */
145
- /* Gradio 6 sets: background-color: var(--color-accent-soft)
146
- We set that var to #130f1e above. */
147
  .user {
148
- border-radius: 8px 0 0 8px !important;
149
- border-right: 1px solid #1e1830 !important;
 
 
150
  }
151
 
152
  .user .prose,
@@ -155,18 +223,30 @@ body {
155
  font-family: 'Crimson Text', Georgia, serif !important;
156
  font-style: normal !important;
157
  font-size: 1.05rem !important;
158
- color: #ccc4da !important;
159
  line-height: 1.6 !important;
160
  }
161
 
162
- /* ── 7. Text input ────────────────────────────── */
163
  textarea,
164
  .gradio-container input[type="text"] {
 
 
 
 
165
  font-family: 'Crimson Text', Georgia, serif !important;
166
- font-size: 1.05rem !important;
167
- color: #c0b8ce !important;
168
  caret-color: #7a4f8a;
169
  letter-spacing: 0.01em;
 
 
 
 
 
 
 
 
170
  }
171
 
172
  textarea::placeholder {
@@ -174,93 +254,111 @@ textarea::placeholder {
174
  font-style: italic;
175
  }
176
 
177
- /* ── 8. Buttons ───────────────────────────────── */
178
  .gradio-container button {
179
  font-family: 'Crimson Text', Georgia, serif !important;
180
- font-size: 1.1rem !important;
181
  letter-spacing: 0.05em;
182
- transition: background 0.3s, box-shadow 0.3s !important;
 
 
 
 
 
183
  }
184
 
185
  .gradio-container button:hover {
186
- box-shadow: 0 0 14px rgba(105,52,140,0.38) !important;
 
 
 
187
  }
188
 
189
- /* ── 9. Bond meter (heartbeat) ────────────────── */
190
- input[type="range"] {
191
- -webkit-appearance: none;
192
- appearance: none;
193
- height: 3px !important;
194
- background: linear-gradient(to right, #2a1840 0%, #7a4f8a 100%) !important;
195
- border-radius: 2px !important;
196
- outline: none;
197
- border: none !important;
198
  }
199
 
200
- input[type="range"]::-webkit-slider-thumb {
201
- -webkit-appearance: none;
202
- width: 10px;
203
- height: 22px;
204
- background: #8a5f9a;
205
- border-radius: 2px;
206
- border: none;
207
- cursor: default;
208
- box-shadow: 0 0 6px rgba(130,65,170,0.55);
209
- animation: heartbeat 2.4s ease-in-out infinite;
210
  }
211
 
212
- input[type="range"]::-moz-range-thumb {
213
- width: 10px;
214
- height: 22px;
215
- background: #8a5f9a;
216
- border-radius: 2px;
217
- border: none;
218
- cursor: default;
219
- box-shadow: 0 0 6px rgba(130,65,170,0.55);
220
- animation: heartbeat 2.4s ease-in-out infinite;
221
  }
222
 
223
- @keyframes heartbeat {
224
- 0%, 100% { transform: scaleY(1.0); box-shadow: 0 0 4px rgba(120,60,160,0.40); }
225
- 8% { transform: scaleY(1.5); box-shadow: 0 0 14px rgba(155,75,195,0.85); }
226
- 18% { transform: scaleY(1.0); box-shadow: 0 0 4px rgba(120,60,160,0.40); }
227
- 30% { transform: scaleY(1.25); box-shadow: 0 0 9px rgba(138,68,178,0.62); }
228
- 45% { transform: scaleY(1.0); box-shadow: 0 0 4px rgba(120,60,160,0.35); }
229
  }
230
 
231
- /* Tier label next to slider */
232
- .gradio-container .prose p strong,
233
- .gradio-container .wrap .prose em {
234
- color: #7a5a8a !important;
235
- font-family: 'Special Elite', monospace !important;
236
- font-style: normal !important;
237
- font-size: 0.88rem !important;
238
- letter-spacing: 0.06em;
239
  }
240
 
241
- /* ── 10. Block / panel chrome ─────────────────── */
242
- .block {
243
- border-color: #14112a !important;
244
- box-shadow: none !important;
 
 
 
 
 
245
  }
246
 
247
- /* ── 11. Scrollbar ────────────────────────────── */
248
- ::-webkit-scrollbar {
249
- width: 5px;
250
- height: 5px;
251
- }
252
- ::-webkit-scrollbar-track {
253
- background: #0a0810;
254
- }
255
- ::-webkit-scrollbar-thumb {
256
- background: #2a1f40;
257
- border-radius: 3px;
258
  }
259
- ::-webkit-scrollbar-thumb:hover {
260
- background: #3d2f58;
 
 
 
 
 
261
  }
262
 
263
- /* ── 12. Hide Gradio footer ───────────────────── */
264
- footer {
265
- display: none !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  }
 
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;
15
+ --background-fill-primary: #0c0a14;
16
+ --background-fill-secondary: transparent; /* Hollow's words float */
17
 
18
+ /* Accent drives the visitor bubble + focus */
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;
26
 
27
  /* Text */
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;
35
+ --block-info-text-color: #352f4a;
36
 
37
  /* Inputs */
38
+ --input-background-fill: transparent;
39
+ --input-background-fill-focus: transparent;
40
+ --input-border-color: #221b34;
41
+ --input-border-color-focus: #5a3e72;
42
+ --input-placeholder-color: #38305a;
43
+ --input-shadow: none;
44
+ --input-shadow-focus: none;
45
 
46
  /* Buttons */
47
+ --button-secondary-background-fill: rgba(30,20,46,0.5);
48
+ --button-secondary-background-fill-hover: rgba(44,26,64,0.6);
49
+ --button-secondary-text-color: #9a7caa;
50
+ --button-shadow: none;
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;
 
 
58
  --block-shadow: none;
59
+ --panel-background-fill: transparent;
60
 
61
  /* Slider */
62
  --slider-color: #7a4f8a;
 
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:
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;
81
  min-height: 100vh;
82
  }
83
 
84
+ /* Vignette + top fog, breathing slowly like something asleep */
85
+ body::before {
86
+ content: '';
87
+ position: fixed;
88
+ inset: 0;
89
+ pointer-events: none;
90
+ z-index: 1000;
91
+ background:
92
+ radial-gradient(ellipse at 50% 36%, transparent 38%, rgba(4,3,8,0.58) 100%),
93
+ linear-gradient(to bottom, rgba(6,4,12,0.62) 0%, transparent 13%);
94
+ animation: dread-breath 11s ease-in-out infinite;
95
+ }
96
+
97
+ @keyframes dread-breath {
98
+ 0%, 100% { opacity: 0.85; }
99
+ 50% { opacity: 1.00; }
100
+ }
101
+
102
+ /* Film grain — fixed, jittering, very faint */
103
+ body::after {
104
+ content: '';
105
+ position: fixed;
106
+ inset: -50%;
107
+ width: 200%;
108
+ height: 200%;
109
+ pointer-events: none;
110
+ z-index: 1001;
111
+ opacity: 0.05;
112
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
113
+ animation: grain 1.1s steps(4) infinite;
114
+ }
115
+
116
+ @keyframes grain {
117
+ 0% { transform: translate(0, 0); }
118
+ 25% { transform: translate(-2%, 1%); }
119
+ 50% { transform: translate(1%, -2%); }
120
+ 75% { transform: translate(-1%, 2%); }
121
+ 100% { transform: translate(0, 0); }
122
+ }
123
+
124
+ /* ── 3. Container ─────────────────────────────── */
125
  .gradio-container {
126
  background: transparent !important;
127
  font-family: 'Crimson Text', Georgia, serif !important;
128
  font-size: 1.05rem !important;
129
  }
130
 
131
+ /* Dissolve every panel into the void */
132
+ .block,
133
+ .form,
134
+ .panel,
135
+ .gr-box,
136
+ .gr-panel {
137
+ background: transparent !important;
138
+ border: none !important;
139
+ box-shadow: none !important;
140
  }
141
 
142
+ /* ── 4. Title ─────────────────────────────────── */
143
  .gradio-container h2 {
144
  font-family: 'Special Elite', 'Courier New', monospace !important;
145
+ color: #8a6a9a !important;
146
+ letter-spacing: 0.18em !important;
147
+ font-size: 1.75rem !important;
148
  font-weight: 400 !important;
149
  text-shadow:
150
+ 0 0 24px rgba(110,55,145,0.45),
151
+ 0 0 64px rgba(80,30,115,0.20);
152
  animation: title-flicker 8s ease-in-out infinite;
153
  }
154
 
155
  @keyframes title-flicker {
156
+ 0%, 91%, 96%, 100% { opacity: 1; }
157
+ 93% { opacity: 0.66; }
158
+ 94% { opacity: 1; }
159
+ 95% { opacity: 0.82; }
160
  }
161
 
162
+ /* Subtitle whisper */
163
+ .gradio-container h2 + p,
164
+ .gradio-container h2 + p em {
165
+ color: #443a5e !important;
166
  font-style: italic;
167
+ font-size: 0.92rem;
168
+ letter-spacing: 0.05em;
169
  }
170
 
171
+ /* ── 5. Kill Gradio chatbot chrome ────────────── */
172
+ /* Toolbar (share / copy / fullscreen) breaks the fiction.
173
+ Best killed via app.py flags too; this is the backstop. */
174
+ .icon-button-wrapper,
175
+ .chatbot .icon-button-wrapper,
176
+ button[aria-label="Share"],
177
+ button[aria-label="Copy"],
178
+ button[aria-label="Copy conversation"],
179
+ button[aria-label="Fullscreen"],
180
+ button[aria-label="Clear"] {
181
+ display: none !important;
182
+ }
183
+
184
+ /* The message scroll area — let it bleed into the void */
185
+ .chatbot,
186
+ .bubble-wrap,
187
+ .message-wrap {
188
+ background: transparent !important;
189
+ border: none !important;
190
+ }
191
+
192
+ /* ── 6. Hollow's words (bot) — formless, haunted ── */
193
  .bot {
194
+ background: transparent !important;
195
+ border: none !important;
196
  border-left: 2px solid #3a2a50 !important;
197
+ border-radius: 0 !important;
198
+ box-shadow: none !important;
199
  }
200
 
201
  .bot .prose,
 
203
  .bot p {
204
  font-family: 'Crimson Text', Georgia, serif !important;
205
  font-style: italic !important;
206
+ font-size: 1.16rem !important;
207
+ color: #b3aac4 !important;
208
+ line-height: 1.7 !important;
209
+ text-shadow: 0 0 18px rgba(120,70,160,0.18);
210
  }
211
 
212
+ /* ── 7. Visitor's words (user) — contained, human ── */
 
 
213
  .user {
214
+ background: var(--color-accent-soft) !important;
215
+ border: 1px solid #211a32 !important;
216
+ border-radius: 7px !important;
217
+ box-shadow: none !important;
218
  }
219
 
220
  .user .prose,
 
223
  font-family: 'Crimson Text', Georgia, serif !important;
224
  font-style: normal !important;
225
  font-size: 1.05rem !important;
226
+ color: #cec6dc !important;
227
  line-height: 1.6 !important;
228
  }
229
 
230
+ /* ── 8. Input a line of confession, not a box ── */
231
  textarea,
232
  .gradio-container input[type="text"] {
233
+ background: transparent !important;
234
+ border: none !important;
235
+ border-bottom: 1px solid #2a2340 !important;
236
+ border-radius: 0 !important;
237
  font-family: 'Crimson Text', Georgia, serif !important;
238
+ font-size: 1.08rem !important;
239
+ color: #c8c0d6 !important;
240
  caret-color: #7a4f8a;
241
  letter-spacing: 0.01em;
242
+ box-shadow: none !important;
243
+ transition: border-color 0.4s ease, box-shadow 0.4s ease !important;
244
+ }
245
+
246
+ textarea:focus,
247
+ .gradio-container input[type="text"]:focus {
248
+ border-bottom-color: #6a4a82 !important;
249
+ box-shadow: 0 8px 18px -14px rgba(120,70,160,0.7) !important;
250
  }
251
 
252
  textarea::placeholder {
 
254
  font-style: italic;
255
  }
256
 
257
+ /* ── 9. Buttons (the → send) ──────────────────── */
258
  .gradio-container button {
259
  font-family: 'Crimson Text', Georgia, serif !important;
260
+ font-size: 1.25rem !important;
261
  letter-spacing: 0.05em;
262
+ color: #9a7caa !important;
263
+ background: rgba(30,20,46,0.45) !important;
264
+ border: 1px solid #2a2040 !important;
265
+ border-radius: 6px !important;
266
+ box-shadow: none !important;
267
+ transition: color 0.35s, border-color 0.35s, box-shadow 0.35s, background 0.35s !important;
268
  }
269
 
270
  .gradio-container button:hover {
271
+ color: #d3bee2 !important;
272
+ border-color: #5a3e72 !important;
273
+ background: rgba(44,26,64,0.6) !important;
274
+ box-shadow: 0 0 16px rgba(120,60,160,0.38) !important;
275
  }
276
 
277
+ /* ── 10. Bond meter custom HTML, a pulsing thread ── */
278
+ /* Rendered by _render_bond() in app.py. Full control:
279
+ no Gradio orange, no track, no number box, no reset. */
280
+ .bond-meter {
281
+ padding: 4px 2px 2px;
282
+ user-select: none;
 
 
 
283
  }
284
 
285
+ .bond-head {
286
+ display: flex;
287
+ justify-content: space-between;
288
+ align-items: baseline;
289
+ margin-bottom: 9px;
 
 
 
 
 
290
  }
291
 
292
+ .bond-name {
293
+ font-family: 'Special Elite', monospace;
294
+ font-size: 0.7rem;
295
+ letter-spacing: 0.32em;
296
+ text-transform: uppercase;
297
+ color: #4a4063;
 
 
 
298
  }
299
 
300
+ .bond-tier {
301
+ font-family: 'Special Elite', monospace;
302
+ font-size: 0.84rem;
303
+ letter-spacing: 0.1em;
304
+ color: #9a6faa;
305
+ text-shadow: 0 0 12px rgba(120,60,160,0.4);
306
  }
307
 
308
+ .bond-track {
309
+ position: relative;
310
+ height: 2px;
311
+ border-radius: 2px;
312
+ background: #1a1428;
 
 
 
313
  }
314
 
315
+ .bond-fill {
316
+ position: absolute;
317
+ left: 0;
318
+ top: 0;
319
+ height: 100%;
320
+ background: linear-gradient(to right, #3a2152, #9a6faa);
321
+ border-radius: 2px;
322
+ box-shadow: 0 0 10px rgba(120,60,160,0.5);
323
+ transition: width 1.2s cubic-bezier(0.2,0.7,0.2,1);
324
  }
325
 
326
+ .bond-beat {
327
+ position: absolute;
328
+ top: -8px;
329
+ width: 8px;
330
+ height: 18px;
331
+ margin-left: -4px;
332
+ background: #b07fc0;
333
+ border-radius: 2px;
334
+ box-shadow: 0 0 8px rgba(150,80,190,0.7);
335
+ animation: heartbeat 2.4s ease-in-out infinite;
336
+ transition: left 1.2s cubic-bezier(0.2,0.7,0.2,1);
337
  }
338
+
339
+ @keyframes heartbeat {
340
+ 0%, 100% { transform: scaleY(1.0); box-shadow: 0 0 4px rgba(120,60,160,0.40); }
341
+ 8% { transform: scaleY(1.5); box-shadow: 0 0 14px rgba(155,75,195,0.85); }
342
+ 18% { transform: scaleY(1.0); box-shadow: 0 0 4px rgba(120,60,160,0.40); }
343
+ 30% { transform: scaleY(1.25); box-shadow: 0 0 9px rgba(138,68,178,0.62); }
344
+ 45% { transform: scaleY(1.0); box-shadow: 0 0 4px rgba(120,60,160,0.35); }
345
  }
346
 
347
+ /* ── 12. Scrollbar ────────────────────────────── */
348
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
349
+ ::-webkit-scrollbar-track { background: transparent; }
350
+ ::-webkit-scrollbar-thumb { background: #2a1f40; border-radius: 3px; }
351
+ ::-webkit-scrollbar-thumb:hover { background: #3d2f58; }
352
+
353
+ /* ── 13. Hide Gradio footer ───────────────────── */
354
+ footer { display: none !important; }
355
+
356
+ /* ── 14. Respect reduced motion ───────────────── */
357
+ @media (prefers-reduced-motion: reduce) {
358
+ body::before,
359
+ body::after,
360
+ .gradio-container h2,
361
+ .bond-beat {
362
+ animation: none !important;
363
+ }
364
  }