Spaces:
Running
Running
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55 | """Pixel-art, Terraria-like theme with an animated, scene-based game stage. | |
| Calm, professional, muted. The CSS embeds bundled pixel fonts as base64 (fully | |
| offline) and defines deterministic CSS animations (idle sway, talking bob, blink, | |
| floating dust) so the game feels alive without any runtime model. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| from ..constants import FONTS_DIR | |
| COLORS = { | |
| "bg": "#14161f", "panel": "#21242f", "panel_light": "#2b2f3d", "wood": "#3a2f26", | |
| "parchment": "#e8d8af", "ink": "#12141c", "text": "#ece4d3", "muted": "#bdb39b", | |
| "accent": "#e0ab4a", "accent_dim": "#8a6a2a", "danger": "#d2603f", "good": "#7bbf73", | |
| } | |
| def _font_face(family: str, filename: str) -> str: | |
| path = FONTS_DIR / filename | |
| if not path.exists(): | |
| return "" | |
| data = base64.b64encode(path.read_bytes()).decode("ascii") | |
| return ( | |
| f"@font-face{{font-family:'{family}';font-style:normal;font-weight:400;" | |
| f"font-display:swap;src:url(data:font/woff2;base64,{data}) format('woff2');}}" | |
| ) | |
| def _fonts_css() -> str: | |
| return _font_face("CaseHead", "heading.woff2") + _font_face("CaseBody", "body.woff2") | |
| def build_css() -> str: | |
| c = COLORS | |
| head = "'CaseHead','Silkscreen',monospace" | |
| body = "'CaseBody','Pixelify Sans',ui-monospace,'Courier New',monospace" | |
| return f""" | |
| {_fonts_css()} | |
| :root {{ | |
| --bg:{c['bg']}; --panel:{c['panel']}; --panel2:{c['panel_light']}; --wood:{c['wood']}; | |
| --parchment:{c['parchment']}; --ink:{c['ink']}; --text:{c['text']}; --muted:{c['muted']}; | |
| --accent:{c['accent']}; --accent2:{c['accent_dim']}; --danger:{c['danger']}; --good:{c['good']}; | |
| }} | |
| * {{ image-rendering: pixelated; }} | |
| .gradio-container, body {{ background: var(--bg) !important; color: var(--text) !important; | |
| font-family: {body}; letter-spacing:.3px; }} | |
| /* Light text on every game surface (fixes dark-on-dark). */ | |
| .gradio-container .html-container, .gradio-container .prose, .gradio-container .prose *, | |
| .cz-stage, .cz-dialogue, .cz-note, .cz-verdict, .cz-evi, .cz-chip-row {{ color: var(--text) !important; }} | |
| .cz-muted {{ color: var(--muted) !important; }} | |
| #cz-title {{ font-family:{head}; color:var(--accent); text-align:center; font-size:2.1rem; | |
| line-height:1.4; text-shadow:3px 3px 0 #000; margin:6px 0 0 0; }} | |
| #cz-subtitle {{ text-align:center; color:var(--muted); font-family:{head}; font-size:.66rem; }} | |
| h1,h2,h3 {{ font-family:{head} !important; color:var(--accent) !important; }} | |
| .cz-panel, .block {{ background:var(--panel) !important; border:3px solid var(--ink) !important; | |
| box-shadow: inset 0 0 0 2px var(--panel2), 4px 4px 0 #0008 !important; border-radius:0 !important; }} | |
| /* The hidden audio host must not show as an empty panel. */ | |
| .cz-audio-host, .cz-audio-host.block {{ display:none !important; border:none !important; | |
| box-shadow:none !important; height:0 !important; padding:0 !important; margin:0 !important; }} | |
| button, .gr-button {{ font-family:{head} !important; font-size:.72rem !important; | |
| background:var(--wood) !important; color:var(--parchment) !important; border:3px solid var(--ink) !important; | |
| border-radius:0 !important; text-transform:uppercase; cursor:pointer !important; | |
| box-shadow: inset -3px -3px 0 #00000055, inset 3px 3px 0 #ffffff14, 3px 3px 0 #0007 !important; }} | |
| button:hover, .gr-button:hover {{ background:var(--accent2) !important; color:#1a140a !important; }} | |
| button.cz-accuse {{ background:var(--danger) !important; color:#1a0e0a !important; }} | |
| /* Music button: dim wood when off, lit amber when playing (toggle look + note icon). */ | |
| button.cz-on {{ background:var(--accent) !important; color:#1a140a !important; | |
| box-shadow: inset -3px -3px 0 #00000033, inset 2px 2px 0 #ffffff22, 0 0 0 2px var(--accent) !important; }} | |
| button.cz-music-on {{ background:var(--accent) !important; color:#1a140a !important; | |
| box-shadow: inset -3px -3px 0 #00000033, 0 0 0 2px var(--accent) !important; }} | |
| /* Dropdowns: pointer cursor, light text. */ | |
| .gradio-container input[role='listbox'], .gradio-container .wrap-inner, | |
| .gradio-container .secondary-wrap, .gradio-container [data-testid='dropdown'] *, | |
| .gradio-container .gr-dropdown * {{ cursor:pointer !important; }} | |
| .cz-briefing {{ background:var(--parchment); color:#221c12 !important; padding:14px; | |
| border:3px solid var(--ink); font-family:{body}; line-height:1.5; }} | |
| .cz-briefing * {{ color:#221c12 !important; }} | |
| /* How-to-play onboarding card. */ | |
| .cz-howto {{ background:var(--panel); border:4px solid var(--ink); padding:14px 16px; | |
| box-shadow: inset 0 0 0 3px var(--panel2); }} | |
| .cz-howto-h {{ font-family:{head}; color:var(--accent); font-size:1rem; margin-bottom:12px; }} | |
| .cz-steps {{ display:grid; grid-template-columns:repeat(4,1fr); gap:10px; }} | |
| @media (max-width:820px) {{ .cz-steps {{ grid-template-columns:repeat(2,1fr); }} }} | |
| .cz-step {{ background:var(--panel2); border:3px solid var(--ink); padding:10px; }} | |
| .cz-step b {{ font-family:{head}; color:var(--text); font-size:.66rem; display:block; margin:6px 0 4px; }} | |
| .cz-step-n {{ display:inline-block; width:22px; height:22px; line-height:22px; text-align:center; | |
| background:var(--accent); color:#1a140a; font-family:{head}; font-size:.7rem; border:2px solid var(--ink); }} | |
| .cz-howto-cta {{ margin-top:12px; color:var(--text); font-family:{body}; }} | |
| .cz-howto-cta b {{ color:var(--accent); }} | |
| .cz-flavor {{ font-family:{head}; color:var(--accent); font-size:.82rem; margin:6px 0; | |
| min-height:1.3em; text-align:center; }} | |
| /* Full-screen loading overlay (model warmup + case generation). The Gradio wrapper is | |
| collapsed so the fixed-position content leaves no empty box in the normal flow. A soft | |
| amber glow over the dark base gives it depth instead of a flat black screen. */ | |
| .cz-overlay-host {{ padding:0 !important; margin:0 !important; border:0 !important; | |
| background:transparent !important; box-shadow:none !important; min-height:0 !important; }} | |
| .cz-overlay {{ position:fixed; inset:0; z-index:1000; | |
| background: radial-gradient(120% 80% at 50% 18%, #2a2335 0%, #1a1c26 45%, var(--bg) 100%); | |
| display:flex; align-items:center; justify-content:center; padding:24px; overflow:auto; | |
| /* Hide the native scrollbar (and its up/down arrows) - still scrollable by wheel/touch | |
| on a short screen, but no stray stepper arrows next to the music button while loading. */ | |
| scrollbar-width:none; -ms-overflow-style:none; }} | |
| .cz-overlay::-webkit-scrollbar {{ display:none; }} | |
| .cz-overlay-inner {{ max-width:780px; width:100%; margin:auto; }} | |
| .cz-bigtitle {{ font-family:{head}; color:var(--accent); text-align:center; font-size:3.1rem; | |
| line-height:1.4; text-shadow:4px 4px 0 #000; margin-bottom:10px; }} | |
| .cz-overlay-head {{ font-family:{head}; color:var(--text); text-align:center; font-size:1.5rem; | |
| line-height:1.5; margin-bottom:6px; }} | |
| /* Indeterminate loading bar so the screen visibly reads as "working", not stuck. */ | |
| .cz-loadbar {{ height:8px; max-width:340px; margin:10px auto 16px; background:var(--ink); | |
| border:2px solid #000; overflow:hidden; }} | |
| .cz-loadbar span {{ display:block; height:100%; width:38%; background:var(--accent); | |
| animation: czLoad 1.25s ease-in-out infinite; }} | |
| @keyframes czLoad {{ 0%{{transform:translateX(-110%)}} 100%{{transform:translateX(330%)}} }} | |
| @media (prefers-reduced-motion: reduce) {{ .cz-loadbar span {{ animation:none; width:100%; }} }} | |
| /* Music button floats above the overlay so it is reachable on the loading screen too. A | |
| compact note icon - small and unobtrusive on phones (amber when the track is playing). */ | |
| #czmusicbtn {{ position:fixed; top:10px; right:10px; z-index:1002; | |
| width:42px !important; min-width:42px !important; max-width:42px; height:42px !important; | |
| padding:0 !important; font-size:1.15rem !important; line-height:1 !important; | |
| display:flex; align-items:center; justify-content:center; }} | |
| /* ---- The interrogation stage (flat pixel, no glow/gradient/particles) --- */ | |
| .cz-stage {{ position:relative; height:300px; border:4px solid var(--ink); overflow:hidden; | |
| background:#0d0f15; image-rendering:pixelated; }} | |
| .cz-room {{ position:absolute; inset:0; background-size:cover; background-position:center bottom; | |
| image-rendering:pixelated; }} | |
| .cz-actor {{ position:absolute; left:50%; bottom:0; transform:translateX(-50%); z-index:1; }} | |
| /* 3-frame sheet (neutral, blink, talk) played via background-position. No body bob. */ | |
| .cz-sprite {{ width:240px; height:284px; background-repeat:no-repeat; background-size:720px 284px; | |
| background-position:0 0; image-rendering:pixelated; | |
| animation: czBlink 5.5s steps(1,end) infinite; }} | |
| .cz-sprite.talking {{ animation: czTalkF .22s steps(1,end) infinite; }} | |
| @keyframes czBlink {{ 0%,93%,100%{{background-position:0 0}} 95%,97%{{background-position:-240px 0}} }} | |
| @keyframes czTalkF {{ 0%{{background-position:0 0}} 50%{{background-position:-480px 0}} }} | |
| @media (prefers-reduced-motion: reduce) {{ .cz-sprite, .cz-sprite.talking {{ animation:none; }} }} | |
| .cz-nameplate {{ position:absolute; top:8px; left:8px; z-index:3; font-family:{head}; font-size:.74rem; | |
| color:#1a140a; background:var(--accent); border:3px solid var(--ink); padding:4px 10px; }} | |
| /* z-index:3 keeps the room label in FRONT of the (centered) character sprite. */ | |
| .cz-roomlabel {{ position:absolute; bottom:8px; left:8px; z-index:3; font-family:{head}; font-size:.6rem; | |
| color:var(--text); background:#000c; border:2px solid var(--ink); padding:3px 8px; }} | |
| .cz-loading {{ position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; | |
| justify-content:center; text-align:center; font-family:{head}; color:var(--accent); font-size:1rem; }} | |
| .cz-dots {{ animation: czCaret 1s steps(1) infinite; }} | |
| /* Dialogue box, flat pixel panel attached under the stage. */ | |
| .cz-dialogue {{ background:var(--panel); border:4px solid var(--ink); border-top:none; | |
| padding:12px 14px; min-height:76px; }} | |
| .cz-dialogue .who {{ font-family:{head}; color:var(--accent); font-size:.72rem; margin-bottom:6px; }} | |
| .cz-dialogue .line {{ color:var(--text); font-size:1.05rem; line-height:1.5; }} | |
| .cz-caret {{ color:var(--accent); animation: czCaret 1s steps(1) infinite; }} | |
| @keyframes czCaret {{ 50%{{opacity:0}} }} | |
| .cz-chip-row {{ margin:8px 0; }} | |
| .cz-chip-row:empty {{ display:none; margin:0; }} | |
| /* Collapse the cue strip entirely when there are no chips (no empty box). */ | |
| .cz-cues:empty {{ display:none; }} | |
| .cz-cues {{ padding:0 !important; }} | |
| .cz-chip {{ display:inline-block; padding:2px 8px; margin:2px; background:var(--ink); | |
| border:2px solid var(--accent2); color:var(--accent); font-family:{head}; font-size:.58rem; }} | |
| .cz-chip.break {{ border-color:var(--danger); color:#f6cfc4; animation: czFlash .5s ease 3; }} | |
| @keyframes czFlash {{ 50%{{background:var(--danger); color:#1a0e0a}} }} | |
| /* Room scenery panel. */ | |
| .cz-scene {{ border:3px solid var(--ink); box-shadow: inset 0 0 0 2px var(--panel2); overflow:hidden; }} | |
| .cz-scene img {{ width:100%; display:block; image-rendering:pixelated; }} | |
| .cz-scene .cap {{ font-family:{head}; font-size:.58rem; color:var(--accent); padding:5px 8px; | |
| background:#000a; }} | |
| /* Evidence as pixel inventory slots - each is a <details> you can expand for the full text. */ | |
| .cz-evi {{ display:flex; flex-wrap:wrap; gap:8px; align-items:flex-start; }} | |
| details.cz-slot {{ width:162px; background:var(--panel2); border:3px solid var(--ink); padding:6px; | |
| box-shadow: inset -2px -2px 0 #0006, inset 2px 2px 0 #ffffff10; align-self:flex-start; }} | |
| details.cz-slot > summary {{ display:flex; gap:6px; cursor:pointer; list-style:none; position:relative; }} | |
| details.cz-slot > summary::-webkit-details-marker {{ display:none; }} | |
| details.cz-slot > summary::after {{ content:'+'; position:absolute; top:-3px; right:0; | |
| color:var(--accent); font-family:{head}; font-size:.7rem; line-height:1; }} | |
| details.cz-slot[open] > summary::after {{ content:'-'; }} | |
| .cz-slot img {{ width:38px; height:38px; flex:0 0 38px; object-fit:contain; image-rendering:pixelated; }} | |
| .cz-slot .bd {{ flex:1; min-width:0; padding-right:12px; }} /* min-width:0 lets text clamp */ | |
| .cz-slot .nm {{ font-family:{head}; font-size:.5rem; color:var(--text); line-height:1.3; | |
| white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }} | |
| .cz-slot .ds {{ font-size:.6rem; color:var(--muted); line-height:1.3; display:-webkit-box; | |
| -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }} | |
| .cz-slot[open] .ds {{ -webkit-line-clamp:unset; overflow:visible; }} | |
| /* The notebook is a real paper pad with ruled lines and a pixel binding. */ | |
| .cz-notebook {{ background: | |
| linear-gradient(90deg, #5a4a36 0, #5a4a36 26px, transparent 26px), | |
| repeating-linear-gradient(#efe2bf, #efe2bf 26px, #d9c79a 27px, #efe2bf 28px); | |
| border:3px solid var(--ink); box-shadow: 4px 4px 0 #0007; padding:10px 12px 12px 38px; | |
| color:#241d12 !important; min-height:120px; position:relative; | |
| /* Scroll instead of growing without bound, so a long case can't overflow the page. */ | |
| max-height:360px; overflow-y:auto; }} | |
| .cz-notebook::before {{ content:''; position:absolute; left:9px; top:8px; bottom:8px; width:8px; | |
| background: repeating-linear-gradient(#2b231733, #2b231733 6px, transparent 6px, transparent 12px); }} | |
| .cz-notebook .nb {{ color:#241d12 !important; font-family:{body}; line-height:1.5; padding:2px 0; }} | |
| .cz-notebook .nb b {{ font-family:{head}; font-size:.56rem; }} | |
| .cz-notebook .nb.contradiction b {{ color:#9c2f1c; }} | |
| .cz-notebook .nb.clue b {{ color:#2f6e2a; }} | |
| .cz-notebook .nb.lead b {{ color:#8a6a14; }} | |
| .cz-note {{ border-left:4px solid var(--accent); padding:3px 10px; margin:5px 0; background:#0003; | |
| color:var(--text) !important; }} | |
| .cz-verdict {{ font-family:{head}; padding:14px; border:3px solid var(--ink); background:var(--panel2); | |
| color:var(--text) !important; }} | |
| .cz-verdict ul {{ font-family:{body}; }} | |
| .cz-credit {{ text-align:center; color:var(--muted); font-size:.62rem; font-family:{body}; | |
| padding:10px 0 2px; opacity:.8; }} | |
| footer, #gradio-footer {{ display:none !important; }} | |
| /* ---------------------------- Phone / small screens ---------------------------- */ | |
| @media (max-width:760px) {{ | |
| /* Loading overlay: anchor to the top and let it scroll, so a tall how-to card is never | |
| clipped behind the centered title (the broken-on-phone case). */ | |
| .cz-overlay {{ align-items:flex-start; padding:18px 14px 28px; }} | |
| .cz-bigtitle {{ font-size:2.1rem; }} | |
| .cz-overlay-head {{ font-size:1.05rem; }} | |
| .cz-flavor {{ font-size:.74rem; }} | |
| #cz-title {{ font-size:1.5rem; }} | |
| #cz-subtitle {{ font-size:.56rem; }} | |
| /* Evidence slots flow two-up within the (now full-width) column. */ | |
| details.cz-slot {{ width:calc(50% - 6px); }} | |
| .cz-dialogue .line {{ font-size:.98rem; }} | |
| .cz-howto-h {{ font-size:.86rem; }} | |
| }} | |
| """ | |