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

1"""UX-39 — the app's one easter egg: triple-click the title and eyes pop up. 

2 

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. 

6 

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*). 

12 

13Four details that are load-bearing rather than decorative: 

14 

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""" 

31 

32from __future__ import annotations 

33 

34import json 

35 

36import streamlit as st 

37 

38from scanpath_studio.html_embed import embed_html_iframe 

39 

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" 

44 

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 

48 

49# How long the eyes stay before they fade, and how long the fade takes. 

50_LIFETIME_MS = 4200 

51_FADE_MS = 520 

52 

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""" 

112 

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) 

117 

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; 

125 

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 } 

132 

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(); 

137 

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 }; 

146 

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); 

153 

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"); 

158 

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); 

163 

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(); 

200 

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 }; 

211 

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""" 

230 

231 

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>" 

243 

244 

245def egg_suppressed(query_params, session_state=None) -> bool: 

246 """True when this session shouldn't arm the egg. 

247 

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")) 

259 

260 

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)