Off-Brand UI: bespoke courtroom panel (reference / post-freeze)

#1
by Farseen0 - opened
Files changed (1) hide show
  1. app.py +298 -70
app.py CHANGED
@@ -6,9 +6,17 @@ confidence) steers him; surface three contradictions and his voice cracks.
6
  Boots anywhere: with WITNESSBOX_BACKEND unset it runs the offline mock end to
7
  end (type your questions). Set WITNESSBOX_BACKEND=modal + Modal Space secrets
8
  for live Whisper ASR / MiniCPM4.1-8B / VoxCPM2 and push-to-talk.
 
 
 
 
 
9
  """
10
  from __future__ import annotations
11
 
 
 
 
12
  import os
13
 
14
  import numpy as np
@@ -19,10 +27,70 @@ from witnessbox.backends import get_backends
19
  from witnessbox.engine import WitnessBoxEngine
20
  from witnessbox.witness import WITNESS_NAME, WITNESS_ROLE
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  CSS = """
23
- /* ===== WitnessBox — a courtroom palette: parchment · ink · oxblood · brass =====
24
- Overrides Gradio's variables in BOTH light and dark mode so the look is
25
- consistent and there's not a drop of the default indigo anywhere. */
 
 
26
  .gradio-container, .gradio-container.dark {
27
  --wb-ink:#2b2014; --wb-ink-soft:#6d5a3a;
28
  --wb-card:#f5edd9; --wb-card-2:#efe4cb;
@@ -30,7 +98,7 @@ CSS = """
30
  --wb-oxblood:#7c1f25; --wb-oxblood-hi:#94333a;
31
  --wb-brass:#9c7a3c; --wb-walnut:#241a10;
32
 
33
- --body-background-fill: radial-gradient(125% 95% at 50% -12%, #f1e7cf 0%, #e3d3ab 72%);
34
  --body-text-color: var(--wb-ink);
35
  --body-text-color-subdued: var(--wb-ink-soft);
36
  --background-fill-primary: var(--wb-card);
@@ -58,26 +126,104 @@ CSS = """
58
  --color-accent-soft:#ecdfbc;
59
  --link-text-color: var(--wb-oxblood);
60
  --link-text-color-hover: var(--wb-oxblood-hi);
61
- --block-shadow: 0 2px 7px rgba(60,40,15,.10);
62
  --block-radius:12px;
63
  font-family:'Iowan Old Style','Palatino Linotype','Palatino',Georgia,'Times New Roman',serif;
64
  }
65
- .gradio-container { background: var(--body-background-fill); }
66
-
67
- /* ---- header ---- */
68
- #wb-title {text-align:center; padding:14px 0 2px;}
69
- #wb-title .wb-crest {font-size:1.5rem; line-height:1; opacity:.85;}
70
- #wb-title h1 {color:var(--wb-ink)!important; font-variant:small-caps; letter-spacing:1.5px;
71
- font-weight:700; font-size:2.7rem; margin:.05em 0 .04em; line-height:1.05;}
72
- #wb-title .wb-sub {color:var(--wb-ink-soft)!important; font-size:1.06rem; letter-spacing:.2px;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  #wb-title .wb-sub b {color:var(--wb-oxblood); font-weight:700;}
74
- #wb-title .wb-rule {width:210px; height:2px; margin:11px auto 0;
75
- background:linear-gradient(90deg,transparent,var(--wb-brass),transparent);}
76
 
77
  /* ---- small-caps block labels everywhere ---- */
78
  .gradio-container .block-label, .gradio-container label > span:first-child {
79
  font-variant:small-caps; letter-spacing:.6px; font-weight:700;}
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  /* ---- chatbot = a deposition transcript on aged paper ---- */
82
  #wb-chat {background:var(--wb-card)!important; border:1px solid var(--wb-border)!important;}
83
  #wb-chat *, #wb-chat .message, #wb-chat .prose {color:var(--wb-ink)!important;}
@@ -86,68 +232,142 @@ CSS = """
86
  #wb-chat .bot, #wb-chat [data-role="assistant"] {background:#fbf6e8!important; border-left:3px solid var(--wb-oxblood)!important;}
87
  #wb-chat .placeholder, #wb-chat .placeholder * {color:var(--wb-ink-soft)!important; opacity:.7;}
88
 
89
- /* ---- banner ---- */
90
- .wb-banner {text-align:center; font-size:1.15rem; font-variant:small-caps; letter-spacing:.6px;
91
- padding:12px; border-radius:10px; border:1px solid var(--wb-border);}
92
-
93
- /* ---- custom HTML panels (stance / counters) ---- */
94
- .wb-card {background:var(--wb-card); border:1px solid var(--wb-border); border-radius:10px; padding:13px 15px; box-shadow:0 1px 0 #fffdf6 inset;}
95
- .wb-bar-track {background:#e0d3af; border-radius:7px; height:16px; overflow:hidden; border:1px solid var(--wb-border);}
96
- .wb-bar-fill {height:100%; transition:width .4s ease;}
97
- .wb-disclaimer {font-size:11px; color:var(--wb-ink-soft); font-style:italic;}
98
- .wb-tier {font-variant:small-caps; font-weight:700; color:var(--wb-oxblood); letter-spacing:.5px;}
99
-
100
- /* ---- portrait framing ---- */
101
- #wb-portrait img {border:1px solid var(--wb-border-2); border-radius:10px;}
102
 
103
  /* ---- evidence: a walnut 'exhibit' readout ---- */
104
- #wb-evidence textarea {font-family:ui-monospace,Menlo,Consolas,monospace!important;
105
  background:var(--wb-walnut)!important; color:#ecdfae!important;
106
  border:1px solid var(--wb-brass)!important; border-radius:8px;}
107
 
108
- /* ---- every component label = the same oxblood plate (no stray pink/purple) ---- */
109
  .gradio-container .block-label, .gradio-container .block-label * {
110
  background:var(--wb-oxblood)!important; color:#f4e8c9!important; opacity:1!important;
111
  border-color:var(--wb-border)!important;}
112
  .gradio-container .block-label svg, .gradio-container .block-label path {fill:#f4e8c9!important;}
113
-
114
- /* ---- accordion header: dark, legible, small-caps ---- */
115
  .gradio-container .label-wrap, .gradio-container .label-wrap > span {
116
  color:var(--wb-ink)!important; font-weight:700; font-variant:small-caps; letter-spacing:.5px;}
117
  .gradio-container .label-wrap .icon, .gradio-container .label-wrap svg {color:var(--wb-oxblood)!important; opacity:1;}
118
 
119
- /* Paint the page edge-to-edge in parchment in BOTH modes (no dark gutters). */
120
- html, body, gradio-app {background:#e6d8b8 !important;}
121
  """
 
122
 
123
 
124
  # --------------------------------------------------------------------------- #
125
- # render helpers
126
  # --------------------------------------------------------------------------- #
127
- def _bar(label: str, pct: float, color: str, sub: str = "") -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  pct = max(0, min(100, int(round(pct))))
129
  return (
130
- f"<div class='wb-card' style='margin-bottom:8px'>"
131
- f"<div style='display:flex;justify-content:space-between'>"
132
- f"<b>{label}</b><span>{pct}</span></div>"
133
- f"<div class='wb-bar-track'><div class='wb-bar-fill' style='width:{pct}%;background:{color}'></div></div>"
134
- f"{f'<div class=wb-disclaimer>{sub}</div>' if sub else ''}</div>"
135
  )
136
 
137
 
138
- def _stance_html(stance) -> str:
139
- color = {"CONFIDENT": "#2f7d3b", "NEUTRAL": "#b08900", "HESITANT": "#9c3b2f"}.get(stance.tier, "#b08900")
140
- sub = "Perceived delivery — NOT a lie detector. Reads pauses &amp; pace, not truth."
141
- head = f"<div class='wb-tier'>Delivery&nbsp;·&nbsp;{stance.tier}</div>"
142
- return head + _bar("Perceived confidence", stance.confidence, color, sub)
 
 
 
 
 
 
 
143
 
144
 
145
  def _counters_html(status: dict) -> str:
146
- catches = f"<div class='wb-card' style='margin-bottom:8px'><b>Contradictions</b> " \
147
- f"<span style='float:right'>{status['catches']} / {status['catches_to_win']}</span></div>"
148
- cred = _bar("Your standing with the bench", status["credibility"], "#43607f")
149
- comp = _bar(f"Witness composure · {status['witness_tier']}", status["composure"], "#7a4a2f")
150
- return catches + cred + comp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
 
153
  def _parse_mic(mic):
@@ -173,12 +393,6 @@ def _concat(a, b, sr):
173
  return np.concatenate([a.astype(np.float32), gap, b.astype(np.float32)])
174
 
175
 
176
- def _banner(kind: str, text: str) -> str:
177
- colors = {"win": "#2f7d3b;color:#fff", "lose": "#7a2f2f;color:#fff", "info": "#e9dfc3;color:#5a4220"}
178
- bg = colors.get(kind, colors["info"])
179
- return f"<div class='wb-banner' style='background:{bg}'>{text}</div>"
180
-
181
-
182
  # --------------------------------------------------------------------------- #
183
  # callbacks
184
  # --------------------------------------------------------------------------- #
@@ -198,6 +412,7 @@ def on_start(engine):
198
  gr.update(value=opening_audio),
199
  _stance_html(_neutral("awaiting your first question")),
200
  _counters_html(intro["status"]),
 
201
  gr.update(value="", visible=False),
202
  _banner("info", "Examination open. Mind how you say it — he listens for doubt."),
203
  footer,
@@ -210,7 +425,7 @@ def on_start(engine):
210
 
211
  def on_ask(engine, mic, typed):
212
  if engine is None:
213
- return (engine, gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(),
214
  _banner("info", "Press “Call the witness” to begin."), gr.skip())
215
 
216
  try:
@@ -219,7 +434,7 @@ def on_ask(engine, mic, typed):
219
  except Exception as exc: # surface the real cause in the UI, don't silently toast
220
  import traceback
221
  traceback.print_exc()
222
- return (engine, gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(),
223
  _banner("lose", f"Turn failed — {type(exc).__name__}: {exc}"), gr.skip())
224
 
225
  # Rebuild the chat from the transcript (engine keeps it consistent with what
@@ -248,6 +463,8 @@ def on_ask(engine, mic, typed):
248
  banner = _banner("win", "🩻 He breaks. Three contradictions on the record — you win.")
249
  elif result.events.lost:
250
  banner = _banner("lose", "The bench excuses the witness. You’ve lost the room.")
 
 
251
  elif result.events.near_miss:
252
  banner = _banner("info", "He flinched. You’re circling something — name the specific fact.")
253
  else:
@@ -263,6 +480,7 @@ def on_ask(engine, mic, typed):
263
  gr.update(value=audio_val),
264
  _stance_html(result.stance),
265
  _counters_html(result.status),
 
266
  evidence_update,
267
  banner,
268
  gr.update(value=""), # clear typed box
@@ -278,11 +496,12 @@ def build() -> gr.Blocks:
278
  secondary_hue=gr.themes.colors.amber, # brass
279
  neutral_hue=gr.themes.colors.stone, # warm paper greys, never blue-greys
280
  )
281
- with gr.Blocks(css=CSS, title="WitnessBox", theme=theme) as demo:
282
  engine_state = gr.State(None)
283
  gr.HTML(
284
  "<div id='wb-title'>"
285
  "<div class='wb-crest'>⚖️</div>"
 
286
  "<h1>WitnessBox</h1>"
287
  f"<div class='wb-sub'>Cross-examine <b>{WITNESS_NAME}</b>, {WITNESS_ROLE}. "
288
  "Your <b>voice</b> is the weapon.</div>"
@@ -293,19 +512,14 @@ def build() -> gr.Blocks:
293
 
294
  with gr.Row():
295
  with gr.Column(scale=2):
296
- _portrait = "assets/marcus_reid.png"
297
- gr.Image(
298
- value=_portrait if os.path.exists(_portrait) else None,
299
- show_label=False, height=300, elem_id="wb-portrait",
300
- show_download_button=False, container=True,
301
- )
302
- stance_html = gr.HTML(label="Delivery")
303
  with gr.Column(scale=4):
304
  chat = gr.Chatbot(type="messages", height=420, label="The Stand",
305
  elem_id="wb-chat")
306
  witness_audio = gr.Audio(label="Witness", autoplay=True, interactive=False)
307
  with gr.Column(scale=2):
308
- counters_html = gr.HTML()
309
 
310
  with gr.Accordion("🔎 Contradiction Engine (live verdict)", open=True):
311
  evidence = gr.Textbox(
@@ -331,17 +545,31 @@ def build() -> gr.Blocks:
331
  footer = gr.Markdown("")
332
 
333
  outs_start = [engine_state, chat, witness_audio, stance_html, counters_html,
334
- evidence, banner, footer, ask_btn, begin_btn, mic, typed]
335
  begin_btn.click(on_start, [engine_state], outs_start)
336
 
337
  outs_ask = [engine_state, chat, witness_audio, stance_html, counters_html,
338
- evidence, banner, typed]
339
  ask_btn.click(on_ask, [engine_state, mic, typed], outs_ask)
340
  typed.submit(on_ask, [engine_state, mic, typed], outs_ask)
341
 
342
  return demo
343
 
344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  demo = build()
346
 
347
  if __name__ == "__main__":
 
6
  Boots anywhere: with WITNESSBOX_BACKEND unset it runs the offline mock end to
7
  end (type your questions). Set WITNESSBOX_BACKEND=modal + Modal Space secrets
8
  for live Whisper ASR / MiniCPM4.1-8B / VoxCPM2 and push-to-talk.
9
+
10
+ The UI is a bespoke courtroom instrument panel — a custom SVG delivery gauge,
11
+ three wax case-file seals that stamp as contradictions land, and a witness
12
+ "dock" whose portrait visibly cracks as Reid breaks — built from custom
13
+ HTML/CSS over Gradio's event layer (no stock-component look).
14
  """
15
  from __future__ import annotations
16
 
17
+ import base64
18
+ import io
19
+ import math
20
  import os
21
 
22
  import numpy as np
 
27
  from witnessbox.engine import WitnessBoxEngine
28
  from witnessbox.witness import WITNESS_NAME, WITNESS_ROLE
29
 
30
+
31
+ # --------------------------------------------------------------------------- #
32
+ # baked-in assets (data URIs) — computed once at import, so app.py stays clean
33
+ # --------------------------------------------------------------------------- #
34
+ def _portrait_uri() -> str:
35
+ """The witness portrait, downscaled to a light JPEG data URI for the dock."""
36
+ p = "assets/marcus_reid.png"
37
+ if not os.path.exists(p):
38
+ return ""
39
+ try:
40
+ from PIL import Image
41
+ im = Image.open(p).convert("RGB")
42
+ im.thumbnail((560, 560))
43
+ buf = io.BytesIO()
44
+ im.save(buf, "JPEG", quality=82)
45
+ return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
46
+ except Exception:
47
+ try:
48
+ return "data:image/png;base64," + base64.b64encode(open(p, "rb").read()).decode()
49
+ except Exception:
50
+ return ""
51
+
52
+
53
+ PORTRAIT_DATA_URI = _portrait_uri()
54
+
55
+ # A fracture overlay for the dock — dark cracks with a faint glass highlight.
56
+ _CRACK_SVG = (
57
+ "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'>"
58
+ "<g fill='none' stroke='#190d07' stroke-width='0.7' opacity='0.9' stroke-linejoin='round'>"
59
+ "<path d='M53 -3 L49 17 L59 32 L46 49 L56 69 L45 103'/>"
60
+ "<path d='M49 17 L30 23 M49 17 L70 10'/>"
61
+ "<path d='M59 32 L82 38 M46 49 L24 55 M56 69 L80 75 M56 69 L61 90'/>"
62
+ "<path d='M30 23 L16 18 M70 10 L84 4 M24 55 L9 61'/>"
63
+ "</g>"
64
+ "<g fill='none' stroke='#f3e6c6' stroke-width='0.32' opacity='0.45'>"
65
+ "<path d='M53.7 -3 L49.7 17 L59.7 32 L46.7 49 L56.7 69 L45.7 103'/>"
66
+ "</g></svg>"
67
+ )
68
+ WB_CRACK_URI = "data:image/svg+xml;base64," + base64.b64encode(_CRACK_SVG.encode()).decode()
69
+
70
+ # Subtle paper grain (fractal noise) laid over the parchment.
71
+ _NOISE_SVG = (
72
+ "<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'>"
73
+ "<filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter>"
74
+ "<rect width='100%' height='100%' filter='url(#n)'/></svg>"
75
+ )
76
+ WB_NOISE_URI = "data:image/svg+xml;base64," + base64.b64encode(_NOISE_SVG.encode()).decode()
77
+
78
+ # Fonts: a dramatic display serif (masthead/plates) + a typewriter mono (case-file
79
+ # captions, seals, evidence). Falls back to a serif stack if the CDN is blocked.
80
+ HEAD = (
81
+ "<link rel='preconnect' href='https://fonts.googleapis.com'>"
82
+ "<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin>"
83
+ "<link rel='stylesheet' href='https://fonts.googleapis.com/css2?"
84
+ "family=Playfair+Display:wght@600;700;800&family=Cutive+Mono&display=swap'>"
85
+ )
86
+
87
+
88
  CSS = """
89
+ /* ===== WitnessBox — a bespoke courtroom panel ============================
90
+ Palette: parchment · ink · oxblood · brass. Gradio's CSS variables are
91
+ overridden in BOTH light and dark mode (no default indigo anywhere), and
92
+ the signature pieces — delivery gauge, case-file seals, witness dock — are
93
+ hand-built HTML/SVG, not stock components. */
94
  .gradio-container, .gradio-container.dark {
95
  --wb-ink:#2b2014; --wb-ink-soft:#6d5a3a;
96
  --wb-card:#f5edd9; --wb-card-2:#efe4cb;
 
98
  --wb-oxblood:#7c1f25; --wb-oxblood-hi:#94333a;
99
  --wb-brass:#9c7a3c; --wb-walnut:#241a10;
100
 
101
+ --body-background-fill: radial-gradient(125% 95% at 50% -12%, #f3e9d2 0%, #e7d7af 72%);
102
  --body-text-color: var(--wb-ink);
103
  --body-text-color-subdued: var(--wb-ink-soft);
104
  --background-fill-primary: var(--wb-card);
 
126
  --color-accent-soft:#ecdfbc;
127
  --link-text-color: var(--wb-oxblood);
128
  --link-text-color-hover: var(--wb-oxblood-hi);
129
+ --block-shadow: 0 2px 9px rgba(60,40,15,.12);
130
  --block-radius:12px;
131
  font-family:'Iowan Old Style','Palatino Linotype','Palatino',Georgia,'Times New Roman',serif;
132
  }
133
+
134
+ /* Frame the whole app like a document on a desk: parchment everywhere (no dark
135
+ gutters), a centred column with inked edges, grain + a soft vignette. */
136
+ html, body, gradio-app {background:#e0cfa6 !important;}
137
+ .gradio-container {
138
+ background: var(--body-background-fill);
139
+ max-width:1180px; margin:0 auto;
140
+ border-left:1px solid var(--wb-border-2); border-right:1px solid var(--wb-border-2);
141
+ box-shadow:0 0 70px rgba(40,25,8,.14);
142
+ }
143
+ .gradio-container::after {content:""; position:fixed; inset:0; pointer-events:none; z-index:2;
144
+ background-image:url("%%NOISE%%"); background-size:140px; opacity:.045; mix-blend-mode:multiply;}
145
+ .gradio-container::before {content:""; position:fixed; inset:0; pointer-events:none; z-index:2;
146
+ background:radial-gradient(115% 85% at 50% 42%, transparent 56%, rgba(40,25,8,.22));}
147
+
148
+ /* ---- masthead ---- */
149
+ #wb-title {text-align:center; padding:12px 0 4px; position:relative; z-index:3;}
150
+ #wb-title .wb-crest {font-size:1.45rem; line-height:1; opacity:.82;}
151
+ #wb-title h1 {font-family:'Playfair Display',Georgia,serif; color:var(--wb-ink)!important;
152
+ font-weight:800; font-size:3rem; letter-spacing:1px; margin:.02em 0 .02em; line-height:1;}
153
+ #wb-title .wb-case {font-family:'Cutive Mono',ui-monospace,monospace; text-transform:uppercase;
154
+ letter-spacing:2.5px; font-size:.7rem; color:var(--wb-oxblood); margin-bottom:3px;}
155
+ #wb-title .wb-sub {color:var(--wb-ink-soft)!important; font-size:1.04rem; letter-spacing:.2px;}
156
  #wb-title .wb-sub b {color:var(--wb-oxblood); font-weight:700;}
157
+ #wb-title .wb-rule {width:240px; height:3px; margin:11px auto 0;
158
+ border-top:1px solid var(--wb-brass); border-bottom:1px solid var(--wb-brass); opacity:.55;}
159
 
160
  /* ---- small-caps block labels everywhere ---- */
161
  .gradio-container .block-label, .gradio-container label > span:first-child {
162
  font-variant:small-caps; letter-spacing:.6px; font-weight:700;}
163
 
164
+ /* ---- witness dock: a framed mugshot with a brass plate that reacts to tier ---- */
165
+ .wb-dock {position:relative; z-index:3;}
166
+ .wb-dock-frame {position:relative; border:2px solid var(--wb-border-2); border-radius:10px;
167
+ overflow:hidden; background:#241a10;
168
+ box-shadow:0 7px 20px rgba(40,25,8,.30), inset 0 0 0 4px rgba(0,0,0,.08);}
169
+ .wb-mug {display:block; width:100%; height:auto; filter:saturate(.97) contrast(1.02);}
170
+ .wb-dock-empty {aspect-ratio:1/1; display:flex; align-items:center; justify-content:center;
171
+ color:#caa552; font-size:3rem; background:radial-gradient(circle at 50% 35%,#3a2c1a,#241a10);}
172
+ .wb-wash {position:absolute; inset:0; pointer-events:none; opacity:0; transition:opacity .6s ease;
173
+ mix-blend-mode:multiply;}
174
+ .wb-crack {position:absolute; inset:0; pointer-events:none; opacity:0; transition:opacity .6s ease;
175
+ background:url("%%CRACK%%") center/100% 100% no-repeat;}
176
+ .wb-state-tag {position:absolute; left:8px; bottom:8px; font-family:'Cutive Mono',monospace;
177
+ font-size:.7rem; letter-spacing:1.5px; text-transform:uppercase; color:#f4e8c9;
178
+ background:rgba(36,18,10,.72); padding:2px 9px; border:1px solid var(--wb-brass); border-radius:4px;}
179
+ .wb-tier-rattled .wb-wash {opacity:.30; background:radial-gradient(135% 115% at 50% 122%, rgba(150,42,30,.55), transparent 60%);}
180
+ .wb-tier-cornered .wb-wash {opacity:.52; background:radial-gradient(140% 120% at 50% 122%, rgba(152,38,28,.72), transparent 62%);}
181
+ .wb-tier-breaking .wb-wash {opacity:.78; background:radial-gradient(150% 130% at 50% 122%, rgba(172,30,24,.88), transparent 66%);}
182
+ .wb-tier-cornered .wb-crack {opacity:.40;}
183
+ .wb-tier-breaking .wb-crack {opacity:.94;}
184
+ .wb-tier-breaking .wb-dock-frame {animation:wb-shake 1.7s ease-in-out infinite;}
185
+ @keyframes wb-shake {0%,100%{transform:translate(0,0)} 14%{transform:translate(-1px,1px)}
186
+ 28%{transform:translate(1px,-1px)} 42%{transform:translate(-1px,0)} 56%{transform:translate(1px,1px)} 70%{transform:translate(-1px,-1px)}}
187
+ .wb-plate {margin-top:9px; text-align:center; border:1px solid #7d5f2c; border-radius:6px;
188
+ padding:5px 8px; background:linear-gradient(180deg,#bd963f,#9c7a3c);
189
+ box-shadow:inset 0 1px 0 #ffe7a8, 0 1px 3px rgba(40,25,8,.25);}
190
+ .wb-plate b {display:block; font-family:'Playfair Display',serif; color:#1c1409;
191
+ letter-spacing:2.5px; font-size:1.05rem;}
192
+ .wb-plate i {font-style:normal; font-family:'Cutive Mono',monospace; color:#33260f;
193
+ font-size:.68rem; letter-spacing:1.2px; opacity:.9;}
194
+
195
+ /* ---- delivery gauge (custom SVG instrument) ---- */
196
+ .wb-instrument {background:var(--wb-card); border:1px solid var(--wb-border); border-radius:10px;
197
+ padding:9px 12px 11px; text-align:center; box-shadow:var(--block-shadow); position:relative; z-index:3;}
198
+ .wb-instrument-cap, .wb-casefile-cap {font-family:'Cutive Mono',ui-monospace,monospace; font-size:.68rem;
199
+ letter-spacing:2.5px; text-transform:uppercase; color:var(--wb-oxblood); margin-bottom:2px;}
200
+ .wb-instrument svg {width:100%; max-width:240px; height:auto;}
201
+ .wb-gauge-read {display:flex; align-items:baseline; justify-content:center; gap:12px; margin-top:-8px;}
202
+ .wb-gauge-tier {font-variant:small-caps; font-weight:800; letter-spacing:1px; font-size:1.3rem;}
203
+ .wb-gauge-tier.wb-confident {color:#2f7d3b;} .wb-gauge-tier.wb-neutral {color:#9a7500;} .wb-gauge-tier.wb-hesitant {color:#9c3b2f;}
204
+ .wb-gauge-num {font-family:'Cutive Mono',monospace; font-size:1.05rem; color:var(--wb-ink);}
205
+ .wb-gauge-num i {font-style:normal; font-size:.66rem; opacity:.6;}
206
+ .wb-disclaimer {font-size:10.5px; color:var(--wb-ink-soft); font-style:italic; margin-top:3px; line-height:1.3;}
207
+
208
+ /* ---- case file: three wax seals + composure/standing meters ---- */
209
+ .wb-casefile {background:var(--wb-card); border:1px solid var(--wb-border); border-radius:10px;
210
+ padding:12px 13px 13px; box-shadow:var(--block-shadow); position:relative; z-index:3;}
211
+ .wb-seals {display:flex; gap:10px; justify-content:center; margin:7px 0 5px;}
212
+ .wb-seal {width:50px; height:50px; border-radius:50%; display:flex; align-items:center; justify-content:center;
213
+ font-family:'Playfair Display',serif; font-size:1.15rem; border:2px dashed var(--wb-border-2);
214
+ color:var(--wb-border-2); background:#efe3c6;}
215
+ .wb-seal.stamped {border:2px solid #5b141a; color:#f6e7c8; transform:rotate(-8deg);
216
+ background:radial-gradient(circle at 38% 30%, #ad3f45, #7c1f25 58%, #5c1217);
217
+ box-shadow:0 2px 6px rgba(60,10,10,.45), inset 0 1px 2px rgba(255,205,175,.55);}
218
+ .wb-seals-cap {text-align:center; font-family:'Cutive Mono',monospace; font-size:.7rem;
219
+ letter-spacing:1px; color:var(--wb-ink-soft); margin-bottom:4px;}
220
+ .wb-meter {margin-top:11px;}
221
+ .wb-meter-top {display:flex; justify-content:space-between; font-size:.8rem; font-variant:small-caps;
222
+ letter-spacing:.5px; color:var(--wb-ink); margin-bottom:3px;}
223
+ .wb-meter-top i {font-style:normal; font-family:'Cutive Mono',monospace; opacity:.7; font-size:.74rem;}
224
+ .wb-meter-track {height:9px; background:#e0d3af; border:1px solid var(--wb-border); border-radius:6px; overflow:hidden;}
225
+ .wb-meter-fill {height:100%; transition:width .5s ease;}
226
+
227
  /* ---- chatbot = a deposition transcript on aged paper ---- */
228
  #wb-chat {background:var(--wb-card)!important; border:1px solid var(--wb-border)!important;}
229
  #wb-chat *, #wb-chat .message, #wb-chat .prose {color:var(--wb-ink)!important;}
 
232
  #wb-chat .bot, #wb-chat [data-role="assistant"] {background:#fbf6e8!important; border-left:3px solid var(--wb-oxblood)!important;}
233
  #wb-chat .placeholder, #wb-chat .placeholder * {color:var(--wb-ink-soft)!important; opacity:.7;}
234
 
235
+ /* ---- verdict banner (wax-stamp feel on win/lose) ---- */
236
+ .wb-banner {text-align:center; font-size:1.14rem; font-variant:small-caps; letter-spacing:.6px;
237
+ padding:12px; border-radius:10px; border:1px solid var(--wb-border); position:relative; z-index:3;}
238
+ .wb-banner-info {background:linear-gradient(180deg,#efe4c7,#e7d9b6); color:#5a4220;}
239
+ .wb-banner-win {background:radial-gradient(circle at 50% -25%, #3c6c33, #234020); color:#eef7e6;
240
+ border:2px solid #2f7d3b; font-weight:700; text-shadow:0 1px 0 rgba(0,0,0,.3);}
241
+ .wb-banner-lose {background:radial-gradient(circle at 50% -25%, #80312f, #4f1d1d); color:#f6e6e6;
242
+ border:2px solid #7c1f25; font-weight:700;}
 
 
 
 
 
243
 
244
  /* ---- evidence: a walnut 'exhibit' readout ---- */
245
+ #wb-evidence textarea {font-family:'Cutive Mono',ui-monospace,Menlo,monospace!important;
246
  background:var(--wb-walnut)!important; color:#ecdfae!important;
247
  border:1px solid var(--wb-brass)!important; border-radius:8px;}
248
 
249
+ /* ---- component labels = the same oxblood plate (no stray pink/purple) ---- */
250
  .gradio-container .block-label, .gradio-container .block-label * {
251
  background:var(--wb-oxblood)!important; color:#f4e8c9!important; opacity:1!important;
252
  border-color:var(--wb-border)!important;}
253
  .gradio-container .block-label svg, .gradio-container .block-label path {fill:#f4e8c9!important;}
 
 
254
  .gradio-container .label-wrap, .gradio-container .label-wrap > span {
255
  color:var(--wb-ink)!important; font-weight:700; font-variant:small-caps; letter-spacing:.5px;}
256
  .gradio-container .label-wrap .icon, .gradio-container .label-wrap svg {color:var(--wb-oxblood)!important; opacity:1;}
257
 
258
+ /* primary action reads like a gavel strike */
259
+ .gradio-container button.primary, .gradio-container .primary {font-variant:small-caps; letter-spacing:1px; font-weight:700;}
260
  """
261
+ CSS = CSS.replace("%%CRACK%%", WB_CRACK_URI).replace("%%NOISE%%", WB_NOISE_URI)
262
 
263
 
264
  # --------------------------------------------------------------------------- #
265
+ # render helpers — hand-built HTML/SVG instruments
266
  # --------------------------------------------------------------------------- #
267
+ def _arc_points(cx: float, cy: float, r: float, a0: float, a1: float, n: int = 16) -> str:
268
+ pts = []
269
+ for i in range(n + 1):
270
+ a = a0 + (a1 - a0) * i / n
271
+ pts.append(f"{cx + r * math.cos(a):.1f},{cy - r * math.sin(a):.1f}")
272
+ return " ".join(pts)
273
+
274
+
275
+ def _gauge_svg(confidence: float, tier: str) -> str:
276
+ """A semicircular needle gauge: red (hesitant) · amber (neutral) · green (confident)."""
277
+ cx, cy, R = 100.0, 98.0, 80.0
278
+ zones = [
279
+ ("hesitant", math.pi, 2 * math.pi / 3, "#9c3b2f"),
280
+ ("neutral", 2 * math.pi / 3, math.pi / 3, "#b08900"),
281
+ ("confident", math.pi / 3, 0.0, "#2f7d3b"),
282
+ ]
283
+ active = (tier or "").lower()
284
+ seg = ""
285
+ for name, a0, a1, col in zones:
286
+ op = "1" if name == active else "0.22"
287
+ seg += (f"<polyline points='{_arc_points(cx, cy, R, a0, a1)}' fill='none' "
288
+ f"stroke='{col}' stroke-width='13' opacity='{op}'/>")
289
+ conf = max(0.0, min(100.0, float(confidence)))
290
+ a = math.pi - (conf / 100.0) * math.pi
291
+ nx, ny = cx + 66 * math.cos(a), cy - 66 * math.sin(a)
292
+ needle = (f"<line x1='{cx}' y1='{cy}' x2='{nx:.1f}' y2='{ny:.1f}' "
293
+ f"stroke='#241a10' stroke-width='2.6' stroke-linecap='round'/>")
294
+ hub = (f"<circle cx='{cx}' cy='{cy}' r='6' fill='#241a10'/>"
295
+ f"<circle cx='{cx}' cy='{cy}' r='2.4' fill='#caa552'/>")
296
+ return f"<svg viewBox='0 0 200 110' xmlns='http://www.w3.org/2000/svg'>{seg}{needle}{hub}</svg>"
297
+
298
+
299
+ def _stance_html(stance) -> str:
300
+ tier = stance.tier
301
+ return (
302
+ "<div class='wb-instrument'>"
303
+ "<div class='wb-instrument-cap'>Delivery Read</div>"
304
+ f"{_gauge_svg(stance.confidence, tier)}"
305
+ "<div class='wb-gauge-read'>"
306
+ f"<span class='wb-gauge-tier wb-{tier.lower()}'>{tier.title()}</span>"
307
+ f"<span class='wb-gauge-num'>{int(round(stance.confidence))}<i>%</i></span>"
308
+ "</div>"
309
+ "<div class='wb-disclaimer'>Perceived delivery — not a lie detector. "
310
+ "Reads pace &amp; pauses, not whether anything is true.</div>"
311
+ "</div>"
312
+ )
313
+
314
+
315
+ def _meter_html(label: str, pct: float, color: str) -> str:
316
  pct = max(0, min(100, int(round(pct))))
317
  return (
318
+ "<div class='wb-meter'>"
319
+ f"<div class='wb-meter-top'><span>{label}</span><i>{pct}</i></div>"
320
+ f"<div class='wb-meter-track'><div class='wb-meter-fill' style='width:{pct}%;background:{color}'></div></div>"
321
+ "</div>"
 
322
  )
323
 
324
 
325
+ def _seals_html(catches: int, need: int) -> str:
326
+ roman = ["", "", "", "", ""]
327
+ seals = ""
328
+ for i in range(need):
329
+ if i < catches:
330
+ seals += "<div class='wb-seal stamped'><span>⚖</span></div>"
331
+ else:
332
+ seals += f"<div class='wb-seal'><span>{roman[i] if i < len(roman) else i + 1}</span></div>"
333
+ return (
334
+ f"<div class='wb-seals'>{seals}</div>"
335
+ f"<div class='wb-seals-cap'>Contradictions on the record · {catches} / {need}</div>"
336
+ )
337
 
338
 
339
  def _counters_html(status: dict) -> str:
340
+ return (
341
+ "<div class='wb-casefile'>"
342
+ "<div class='wb-casefile-cap'>Case File</div>"
343
+ f"{_seals_html(status['catches'], status['catches_to_win'])}"
344
+ f"{_meter_html('Witness composure', status['composure'], '#7a4a2f')}"
345
+ f"{_meter_html('Your standing with the bench', status['credibility'], '#43607f')}"
346
+ "</div>"
347
+ )
348
+
349
+
350
+ def _dock_html(tier: str) -> str:
351
+ """The witness in the dock; `tier` (composed→rattled→cornered→breaking) drives the visual break."""
352
+ if PORTRAIT_DATA_URI:
353
+ face = f"<img class='wb-mug' src='{PORTRAIT_DATA_URI}' alt='Marcus Reid'/>"
354
+ else:
355
+ face = "<div class='wb-dock-empty'>⚖️</div>"
356
+ return (
357
+ f"<div class='wb-dock wb-tier-{tier}'>"
358
+ "<div class='wb-dock-frame'>"
359
+ f"{face}"
360
+ "<div class='wb-wash'></div><div class='wb-crack'></div>"
361
+ f"<div class='wb-state-tag'>{tier}</div>"
362
+ "</div>"
363
+ "<div class='wb-plate'><b>MARCUS REID</b><i>CFO · Halcyon Dynamics</i></div>"
364
+ "</div>"
365
+ )
366
+
367
+
368
+ def _banner(kind: str, text: str) -> str:
369
+ kind = kind if kind in ("win", "lose", "info") else "info"
370
+ return f"<div class='wb-banner wb-banner-{kind}'>{text}</div>"
371
 
372
 
373
  def _parse_mic(mic):
 
393
  return np.concatenate([a.astype(np.float32), gap, b.astype(np.float32)])
394
 
395
 
 
 
 
 
 
 
396
  # --------------------------------------------------------------------------- #
397
  # callbacks
398
  # --------------------------------------------------------------------------- #
 
412
  gr.update(value=opening_audio),
413
  _stance_html(_neutral("awaiting your first question")),
414
  _counters_html(intro["status"]),
415
+ _dock_html("composed"),
416
  gr.update(value="", visible=False),
417
  _banner("info", "Examination open. Mind how you say it — he listens for doubt."),
418
  footer,
 
425
 
426
  def on_ask(engine, mic, typed):
427
  if engine is None:
428
+ return (engine, gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(),
429
  _banner("info", "Press “Call the witness” to begin."), gr.skip())
430
 
431
  try:
 
434
  except Exception as exc: # surface the real cause in the UI, don't silently toast
435
  import traceback
436
  traceback.print_exc()
437
+ return (engine, gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(), gr.skip(),
438
  _banner("lose", f"Turn failed — {type(exc).__name__}: {exc}"), gr.skip())
439
 
440
  # Rebuild the chat from the transcript (engine keeps it consistent with what
 
463
  banner = _banner("win", "🩻 He breaks. Three contradictions on the record — you win.")
464
  elif result.events.lost:
465
  banner = _banner("lose", "The bench excuses the witness. You’ve lost the room.")
466
+ elif result.evidence: # a contradiction just landed (non-winning) — the gotcha moment
467
+ banner = _banner("win", f"⚖ Contradiction admitted — {result.status['catches']} of {result.status['catches_to_win']} on the record.")
468
  elif result.events.near_miss:
469
  banner = _banner("info", "He flinched. You’re circling something — name the specific fact.")
470
  else:
 
480
  gr.update(value=audio_val),
481
  _stance_html(result.stance),
482
  _counters_html(result.status),
483
+ _dock_html(result.status["witness_tier"]),
484
  evidence_update,
485
  banner,
486
  gr.update(value=""), # clear typed box
 
496
  secondary_hue=gr.themes.colors.amber, # brass
497
  neutral_hue=gr.themes.colors.stone, # warm paper greys, never blue-greys
498
  )
499
+ with gr.Blocks(css=CSS, head=HEAD, title="WitnessBox", theme=theme) as demo:
500
  engine_state = gr.State(None)
501
  gr.HTML(
502
  "<div id='wb-title'>"
503
  "<div class='wb-crest'>⚖️</div>"
504
+ "<div class='wb-case'>Cross-Examination · The People v. Halcyon Dynamics</div>"
505
  "<h1>WitnessBox</h1>"
506
  f"<div class='wb-sub'>Cross-examine <b>{WITNESS_NAME}</b>, {WITNESS_ROLE}. "
507
  "Your <b>voice</b> is the weapon.</div>"
 
512
 
513
  with gr.Row():
514
  with gr.Column(scale=2):
515
+ dock_html = gr.HTML(_dock_html("composed"))
516
+ stance_html = gr.HTML(_stance_html(_neutral_seed()))
 
 
 
 
 
517
  with gr.Column(scale=4):
518
  chat = gr.Chatbot(type="messages", height=420, label="The Stand",
519
  elem_id="wb-chat")
520
  witness_audio = gr.Audio(label="Witness", autoplay=True, interactive=False)
521
  with gr.Column(scale=2):
522
+ counters_html = gr.HTML(_counters_seed())
523
 
524
  with gr.Accordion("🔎 Contradiction Engine (live verdict)", open=True):
525
  evidence = gr.Textbox(
 
545
  footer = gr.Markdown("")
546
 
547
  outs_start = [engine_state, chat, witness_audio, stance_html, counters_html,
548
+ dock_html, evidence, banner, footer, ask_btn, begin_btn, mic, typed]
549
  begin_btn.click(on_start, [engine_state], outs_start)
550
 
551
  outs_ask = [engine_state, chat, witness_audio, stance_html, counters_html,
552
+ dock_html, evidence, banner, typed]
553
  ask_btn.click(on_ask, [engine_state, mic, typed], outs_ask)
554
  typed.submit(on_ask, [engine_state, mic, typed], outs_ask)
555
 
556
  return demo
557
 
558
 
559
+ def _neutral_seed():
560
+ from witnessbox.stance import _neutral
561
+ return _neutral("awaiting examination")
562
+
563
+
564
+ def _counters_seed():
565
+ return _counters_html({
566
+ "catches": 0, "catches_to_win": getattr(config, "CATCHES_TO_WIN", 3),
567
+ "composure": getattr(config, "COMPOSURE_START", 100),
568
+ "credibility": getattr(config, "CREDIBILITY_START", 100),
569
+ "witness_tier": "composed",
570
+ })
571
+
572
+
573
  demo = build()
574
 
575
  if __name__ == "__main__":