Coverage for scanpath_studio/easter_egg.py: 100%
24 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
1"""UX-39 — the app's one easter egg: triple-click the title and eyes pop up.
3Triple-click the "Scanpath Studio" heading and a pair of oversized googly eyes
4pops up beside it, blinks once, follows the cursor for a few seconds, and fades
5out on its own. That is all it does.
7It lives entirely in the browser because it is deliberately **not** a feature: a
8same-origin ``st.iframe`` script (the technique ``tour.py`` uses to reach the
9parent document) binds one ``click`` listener keyed on ``event.detail >= 3``, so
10there is no session-state key, no rerun, and nothing to expose on the deep link
11/ CLI / headless API (``AGENTS.md`` → *Exposing a feature on every surface*).
13Four details that are load-bearing rather than decorative:
15- **It anchors to the heading's text, not its element.** ``st.title`` renders an
16 ``h1`` that spans the whole 4/5-width header column, so the eyes are placed
17 against a ``Range`` over its contents instead — otherwise they would land far
18 out to the right of "Studio", next to nothing.
19- **It re-measures every frame.** A ``requestAnimationFrame`` loop re-anchors the
20 overlay (``position: fixed``) and aims the pupils, so scrolling, a resize, or a
21 rerun reflow can't leave the eyes stranded mid-page.
22- **It cleans up after a removed iframe.** Streamlit re-mounts this embed on
23 every rerun, killing the old JS context mid-animation, which would orphan its
24 nodes in the parent DOM forever. The script drops any leftover ``.sps-egg``
25 before wiring itself, and marks the heading (``dataset.spsEggWired``) so the
26 re-mount doesn't stack a second listener on a surviving node.
27- **It never blocks the app.** The overlay is ``pointer-events: none`` so it
28 cannot swallow a click meant for the Corpus Analysis button beside it, and the
29 handler clears the text selection a triple-click makes for free.
30"""
32from __future__ import annotations
34import json
36import streamlit as st
38from scanpath_studio.html_embed import embed_html_iframe
40# The app title in the *parent* document: `app._render_about_panel` wraps the
41# header in `st.container(key="about_header")`, which Streamlit renders as
42# `.st-key-about_header`.
43TITLE_SELECTOR = ".st-key-about_header h1"
45# Below the spotlight tour's card (999990) and backdrop (999980) — a tour step
46# that dims the app must stay on top of a stray pair of eyes.
47_Z_INDEX = 999950
49# How long the eyes stay before they fade, and how long the fade takes.
50_LIFETIME_MS = 4200
51_FADE_MS = 520
53_CSS = """
54.sps-egg {
55 position: fixed;
56 z-index: __Z__;
57 display: flex;
58 gap: var(--sps-egg-gap, 6px);
59 pointer-events: none;
60 transform-origin: 50% 100%;
61 filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.35));
62 animation: sps-egg-pop 420ms cubic-bezier(0.2, 1.5, 0.4, 1) both;
63}
64.sps-egg--out { animation: sps-egg-away __FADE__ms ease-in both; }
65.sps-egg__eye {
66 position: relative;
67 width: var(--sps-egg-size, 48px);
68 height: var(--sps-egg-size, 48px);
69 border-radius: 50%;
70 border: 2px solid rgba(0, 0, 0, 0.55);
71 background: radial-gradient(circle at 32% 28%, #fff 0 55%, #e6e6ea 100%);
72 overflow: hidden;
73 animation: sps-egg-blink 260ms ease-in-out 1.05s 1;
74}
75.sps-egg__pupil {
76 position: absolute;
77 top: 50%;
78 left: 50%;
79 width: 42%;
80 height: 42%;
81 margin: -21% 0 0 -21%;
82 border-radius: 50%;
83 background: #16181d;
84 transform: translate(var(--sps-egg-px, 0px), var(--sps-egg-py, 0px));
85}
86.sps-egg__pupil::after {
87 content: "";
88 position: absolute;
89 top: 14%;
90 left: 16%;
91 width: 30%;
92 height: 30%;
93 border-radius: 50%;
94 background: rgba(255, 255, 255, 0.85);
95}
96@keyframes sps-egg-pop {
97 from { transform: translateY(16px) scale(0.2); opacity: 0; }
98 60% { transform: translateY(0) scale(1.15); opacity: 1; }
99 to { transform: none; opacity: 1; }
100}
101@keyframes sps-egg-away {
102 from { opacity: 1; }
103 to { opacity: 0; transform: translateY(-10px) scale(0.75); }
104}
105@keyframes sps-egg-blink {
106 40%, 60% { transform: scaleY(0.08); }
107}
108@media (prefers-reduced-motion: reduce) {
109 .sps-egg, .sps-egg__eye { animation: none !important; }
110}
111"""
113_MARKUP = (
114 '<div class="sps-egg__eye"><div class="sps-egg__pupil"></div></div>'
115 '<div class="sps-egg__eye"><div class="sps-egg__pupil"></div></div>'
116)
118# Placeholders rather than an f-string: the script is mostly braces, and
119# doubling every one of them to satisfy `str.format` makes it unreviewable.
120_JS = """
121(function () {
122 const doc = window.parent.document;
123 const win = doc.defaultView;
124 if (!doc.body) return;
126 if (!doc.getElementById("sps-egg-style")) {
127 const style = doc.createElement("style");
128 style.id = "sps-egg-style";
129 style.textContent = __CSS__;
130 doc.head.appendChild(style);
131 }
133 // A rerun destroys the iframe that owns the running animation, so anything
134 // still on screen from the previous run is now inert. Drop it.
135 const clear = () => doc.querySelectorAll(".sps-egg").forEach((e) => e.remove());
136 clear();
138 // `st.title`'s h1 fills the header column; the glyphs are what we want to
139 // sit beside, so measure a range over its contents instead.
140 const textRect = (el) => {
141 const range = doc.createRange();
142 range.selectNodeContents(el);
143 const rect = range.getBoundingClientRect();
144 return rect.width ? rect : el.getBoundingClientRect();
145 };
147 const pop = (title) => {
148 clear();
149 const wrap = doc.createElement("div");
150 wrap.className = "sps-egg";
151 wrap.innerHTML = __MARKUP__;
152 doc.body.appendChild(wrap);
154 const fontPx = parseFloat(win.getComputedStyle(title).fontSize) || 32;
155 const size = Math.max(26, Math.min(72, fontPx * 1.45));
156 wrap.style.setProperty("--sps-egg-size", size + "px");
157 wrap.style.setProperty("--sps-egg-gap", size * 0.14 + "px");
159 const eyes = [...wrap.querySelectorAll(".sps-egg__eye")];
160 let pointer = null;
161 const onMove = (ev) => { pointer = { x: ev.clientX, y: ev.clientY }; };
162 doc.addEventListener("mousemove", onMove);
164 let frame = 0;
165 const place = () => {
166 const rect = textRect(title);
167 const width = wrap.offsetWidth || size * 2;
168 // Beside the heading when its own column has room. The header's other
169 // column holds the Corpus Analysis button, and eyes parked over the
170 // app's only nav control read as a rendering bug rather than a joke —
171 // so when it's tight they peek over the top of the title instead,
172 // where the page has nothing but padding. Never shrink to fit: "big"
173 // is the whole idea.
174 const colRight = Math.min(
175 title.getBoundingClientRect().right, win.innerWidth
176 );
177 if (rect.right + size * 0.3 + width <= colRight - 4) {
178 wrap.style.left = rect.right + size * 0.3 + "px";
179 wrap.style.top = rect.top + rect.height / 2 - size / 2 + "px";
180 } else {
181 wrap.style.left =
182 Math.max(8, rect.left + (rect.width - width) / 2) + "px";
183 wrap.style.top = Math.max(4, rect.top - size * 0.8) + "px";
184 }
185 eyes.forEach((eye) => {
186 const box = eye.getBoundingClientRect();
187 if (!pointer || !box.width) return;
188 const dx = pointer.x - (box.left + box.width / 2);
189 const dy = pointer.y - (box.top + box.height / 2);
190 const dist = Math.hypot(dx, dy) || 1;
191 // Reach the rim only once the cursor is a comfortable way off.
192 const travel = size * 0.16 * Math.min(1, dist / 140);
193 const pupil = eye.querySelector(".sps-egg__pupil");
194 pupil.style.setProperty("--sps-egg-px", (dx / dist) * travel + "px");
195 pupil.style.setProperty("--sps-egg-py", (dy / dist) * travel + "px");
196 });
197 frame = win.requestAnimationFrame(place);
198 };
199 place();
201 const teardown = () => {
202 win.cancelAnimationFrame(frame);
203 doc.removeEventListener("mousemove", onMove);
204 wrap.remove();
205 };
206 win.setTimeout(() => {
207 wrap.classList.add("sps-egg--out");
208 win.setTimeout(teardown, __FADE__);
209 }, __LIFETIME__);
210 };
212 let tries = 0;
213 (function wire() {
214 const title = doc.querySelector(__SELECTOR__);
215 if (!title) {
216 // A one-shot bind during React hydration is silently lost.
217 if (++tries < 30) win.setTimeout(wire, 100);
218 return;
219 }
220 if (title.dataset.spsEggWired) return;
221 title.dataset.spsEggWired = "1";
222 title.addEventListener("click", (ev) => {
223 if (ev.detail < 3) return;
224 doc.getSelection()?.removeAllRanges(); // triple-click selects the text
225 pop(title);
226 });
227 })();
228})();
229"""
232def egg_script() -> str:
233 """The same-origin ``<script>`` that arms the triple-click easter egg."""
234 css = _CSS.replace("__Z__", str(_Z_INDEX)).replace("__FADE__", str(_FADE_MS))
235 body = (
236 _JS.replace("__CSS__", json.dumps(css))
237 .replace("__MARKUP__", json.dumps(_MARKUP))
238 .replace("__SELECTOR__", json.dumps(TITLE_SELECTOR))
239 .replace("__LIFETIME__", str(_LIFETIME_MS))
240 .replace("__FADE__", str(_FADE_MS))
241 )
242 return f"<script>{body}</script>"
245def egg_suppressed(query_params, session_state=None) -> bool:
246 """True when this session shouldn't arm the egg.
248 Embeds (``?embed=true``) are somebody else's UI — an external review tool
249 frames this app and owns the surrounding chrome. A running tour or tutorial
250 is mid-explanation, and the spotlight step that highlights the header would
251 be competing with a pair of eyes for the same corner of the screen. Takes the
252 params as a mapping (like :func:`tour.tour_suppressed`) because AppTest can't
253 inject query params.
254 """
255 if (query_params.get("embed") or "").lower() in {"true", "1"}:
256 return True
257 state = session_state if session_state is not None else {}
258 return bool(state.get("tour_mode") or state.get("tutorial_active"))
261def render_easter_egg() -> None:
262 """Arm the easter egg for this run, unless the session suppresses it."""
263 if egg_suppressed(st.query_params, st.session_state):
264 return
265 embed_html_iframe(egg_script(), height=0)