feat(funbox): add tunnel vision effect (@d1rshan)#7709
feat(funbox): add tunnel vision effect (@d1rshan)#7709d1rshan wants to merge 2 commits intomonkeytypegame:masterfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new “tunnel vision” funbox that limits visible words to a small radial area around the caret via a CSS mask and caret-position-driven CSS variables.
Changes:
- Add
tunnel_visionto the shared funbox name schema. - Register
tunnel_visionmetadata (properties + frontend hooks) in the funbox list. - Add CSS + frontend funbox function plumbing, plus validation test updates.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/schemas/src/configs.ts | Allows tunnel_vision as a valid FunboxName. |
| packages/funbox/src/list.ts | Adds funbox metadata entry for tunnel_vision (CSS + visibility-changing properties). |
| packages/funbox/test/validation.spec.ts | Adds a compatibility test for multiple changesWordsVisibility funboxes. |
| frontend/static/funbox/tunnel_vision.css | Implements tunnel effect via mask-image/-webkit-mask-image using caret CSS vars. |
| frontend/src/ts/test/funbox/funbox-functions.ts | Implements caret tracking + CSS variable updates for tunnel vision. |
| frontend/tests/test/funbox/funbox-validation.spec.ts | Includes tunnel_vision in mode/funbox validation test cases. |
| const words = qs("#words"); | ||
| if (!words) return; | ||
|
|
||
| const updateCaretPos = (): void => { | ||
| const caretElem = qs("#caret"); | ||
| if (caretElem !== null) { | ||
| const caretStyle = caretElem.getStyle(); | ||
| const left = caretStyle.left || "0px"; | ||
| const top = caretStyle.top || "0px"; | ||
| const marginLeft = caretStyle.marginLeft || "0px"; | ||
| const marginTop = caretStyle.marginTop || "0px"; | ||
|
|
||
| words.native.style.setProperty( | ||
| "--caret-left", | ||
| `calc(${left} + ${marginLeft})`, | ||
| ); | ||
| words.native.style.setProperty( | ||
| "--caret-top", | ||
| `calc(${top} + ${marginTop})`, | ||
| ); | ||
| } | ||
| tunnelVisionAnimationFrame = requestAnimationFrame(updateCaretPos); | ||
| }; | ||
|
|
||
| if (tunnelVisionAnimationFrame !== null) { | ||
| cancelAnimationFrame(tunnelVisionAnimationFrame); | ||
| } | ||
| updateCaretPos(); | ||
| }, | ||
| clearGlobal(): void { | ||
| if (tunnelVisionAnimationFrame !== null) { | ||
| cancelAnimationFrame(tunnelVisionAnimationFrame); | ||
| tunnelVisionAnimationFrame = null; | ||
| } |
There was a problem hiding this comment.
tunnel_vision applyGlobalCSS bails if #words missing. applyGlobalCSS is triggered on funbox config changes (can happen off the test page), so this can leave the effect permanently inactive when user later navigates to the test page unless they toggle funbox again. Also, once started it schedules a perpetual requestAnimationFrame loop that won’t stop on page navigation (clearGlobal only runs when funbox removed), which can waste CPU. Suggest: don’t early-return; instead start a loop that waits for #words/#caret to exist (poll via timeout or MutationObserver), updates vars when present, and cancels itself when #words is no longer in DOM / page isn’t test; ensure clearGlobal cancels any pending loop.
| const words = qs("#words"); | |
| if (!words) return; | |
| const updateCaretPos = (): void => { | |
| const caretElem = qs("#caret"); | |
| if (caretElem !== null) { | |
| const caretStyle = caretElem.getStyle(); | |
| const left = caretStyle.left || "0px"; | |
| const top = caretStyle.top || "0px"; | |
| const marginLeft = caretStyle.marginLeft || "0px"; | |
| const marginTop = caretStyle.marginTop || "0px"; | |
| words.native.style.setProperty( | |
| "--caret-left", | |
| `calc(${left} + ${marginLeft})`, | |
| ); | |
| words.native.style.setProperty( | |
| "--caret-top", | |
| `calc(${top} + ${marginTop})`, | |
| ); | |
| } | |
| tunnelVisionAnimationFrame = requestAnimationFrame(updateCaretPos); | |
| }; | |
| if (tunnelVisionAnimationFrame !== null) { | |
| cancelAnimationFrame(tunnelVisionAnimationFrame); | |
| } | |
| updateCaretPos(); | |
| }, | |
| clearGlobal(): void { | |
| if (tunnelVisionAnimationFrame !== null) { | |
| cancelAnimationFrame(tunnelVisionAnimationFrame); | |
| tunnelVisionAnimationFrame = null; | |
| } | |
| // Ensure any previous observer is cleaned up before starting a new one | |
| const win = window as any; | |
| const existingObserver: MutationObserver | undefined = | |
| win.__tunnelVisionObserver; | |
| if (existingObserver) { | |
| existingObserver.disconnect(); | |
| win.__tunnelVisionObserver = undefined; | |
| } | |
| const startCaretLoop = (): void => { | |
| // Cancel any existing animation frame before starting a new loop | |
| if (tunnelVisionAnimationFrame !== null) { | |
| cancelAnimationFrame(tunnelVisionAnimationFrame); | |
| tunnelVisionAnimationFrame = null; | |
| } | |
| const updateCaretPos = (): void => { | |
| const wordsElem = qs("#words"); | |
| // If #words is missing or detached, stop the loop to avoid CPU waste | |
| if (!wordsElem || !document.body.contains(wordsElem.native)) { | |
| if (tunnelVisionAnimationFrame !== null) { | |
| cancelAnimationFrame(tunnelVisionAnimationFrame); | |
| tunnelVisionAnimationFrame = null; | |
| } | |
| return; | |
| } | |
| const caretElem = qs("#caret"); | |
| if (caretElem !== null) { | |
| const caretStyle = caretElem.getStyle(); | |
| const left = caretStyle.left || "0px"; | |
| const top = caretStyle.top || "0px"; | |
| const marginLeft = caretStyle.marginLeft || "0px"; | |
| const marginTop = caretStyle.marginTop || "0px"; | |
| wordsElem.native.style.setProperty( | |
| "--caret-left", | |
| `calc(${left} + ${marginLeft})`, | |
| ); | |
| wordsElem.native.style.setProperty( | |
| "--caret-top", | |
| `calc(${top} + ${marginTop})`, | |
| ); | |
| } | |
| tunnelVisionAnimationFrame = requestAnimationFrame(updateCaretPos); | |
| }; | |
| tunnelVisionAnimationFrame = requestAnimationFrame(updateCaretPos); | |
| }; | |
| const wordsNow = qs("#words"); | |
| if (wordsNow && document.body.contains(wordsNow.native)) { | |
| // We are on the test page and #words is ready; start immediately | |
| startCaretLoop(); | |
| return; | |
| } | |
| // Wait for #words to appear (e.g. when user navigates to the test page) | |
| const observer = new MutationObserver(() => { | |
| const wordsElem = qs("#words"); | |
| if (wordsElem && document.body.contains(wordsElem.native)) { | |
| const winLocal = window as any; | |
| const currentObserver: MutationObserver | undefined = | |
| winLocal.__tunnelVisionObserver; | |
| if (currentObserver) { | |
| currentObserver.disconnect(); | |
| winLocal.__tunnelVisionObserver = undefined; | |
| } | |
| startCaretLoop(); | |
| } | |
| }); | |
| observer.observe(document.body, { | |
| childList: true, | |
| subtree: true, | |
| }); | |
| win.__tunnelVisionObserver = observer; | |
| }, | |
| clearGlobal(): void { | |
| // Cancel any pending animation frame | |
| if (tunnelVisionAnimationFrame !== null) { | |
| cancelAnimationFrame(tunnelVisionAnimationFrame); | |
| tunnelVisionAnimationFrame = null; | |
| } | |
| // Disconnect any active observer waiting for #words | |
| const win = window as any; | |
| const existingObserver: MutationObserver | undefined = | |
| win.__tunnelVisionObserver; | |
| if (existingObserver) { | |
| existingObserver.disconnect(); | |
| win.__tunnelVisionObserver = undefined; | |
| } |
c11c64b to
49b4c65
Compare
| applyGlobalCSS(): void { | ||
| const words = qs("#words"); | ||
| const wordsWrapper = qs("#wordsWrapper"); | ||
| if (!words || !wordsWrapper) return; | ||
|
|
There was a problem hiding this comment.
applyGlobalCSS returns early when #words/#wordsWrapper missing, but existing tunnelVisionAnimationFrame (from a prior run) is not cancelled because cancelAnimationFrame happens later. Cancel/reset the RAF loop before the DOM null-check (or in the early-return path) to avoid a runaway loop on pages where #words isn't mounted.
| const updateCaretPos = (): void => { | ||
| const caretElem = qs("#caret"); | ||
| if (caretElem !== null) { | ||
| const wordsRect = words.native.getBoundingClientRect(); | ||
| const wordsWrapperRect = wordsWrapper.native.getBoundingClientRect(); | ||
| const caretRect = caretElem.native.getBoundingClientRect(); | ||
| const caretLeft = | ||
| caretRect.left - wordsRect.left + caretRect.width / 2; | ||
| const caretTop = caretRect.top - wordsRect.top + caretRect.height / 2; | ||
| const wrapperCaretLeft = | ||
| caretRect.left - wordsWrapperRect.left + caretRect.width / 2; | ||
| const wrapperCaretTop = | ||
| caretRect.top - wordsWrapperRect.top + caretRect.height / 2; | ||
| const radius = `${getTunnelVisionRadiusPx()}px`; | ||
|
|
||
| words.native.style.setProperty("--caret-left", `${caretLeft}px`); | ||
| words.native.style.setProperty("--caret-top", `${caretTop}px`); | ||
| words.native.style.setProperty("--tunnel-radius", radius); | ||
|
|
||
| wordsWrapper.native.style.setProperty( | ||
| "--caret-left", | ||
| `${wrapperCaretLeft}px`, | ||
| ); | ||
| wordsWrapper.native.style.setProperty( | ||
| "--caret-top", | ||
| `${wrapperCaretTop}px`, | ||
| ); | ||
| wordsWrapper.native.style.setProperty("--tunnel-radius", radius); | ||
| } | ||
| tunnelVisionAnimationFrame = requestAnimationFrame(updateCaretPos); | ||
| }; |
There was a problem hiding this comment.
updateCaretPos schedules requestAnimationFrame unconditionally, even when #caret is missing. That creates a permanent 60fps loop doing DOM queries/layout reads while the funbox is active. Consider stopping the loop when caret isn't found (and restarting via key/input/caret-move events), or at least throttling/falling back to setTimeout until the caret exists.
| let tunnelVisionAnimationFrame: number | null = null; | ||
|
|
||
| function getTunnelVisionRadiusPx(): number { | ||
| const fontSizePx = convertRemToPixels(Config.fontSize); | ||
|
|
||
| return Math.max(48, Math.min(220, fontSizePx * 4.5)); | ||
| } |
There was a problem hiding this comment.
getTunnelVisionRadiusPx hardcodes 48/220/4.5 without context, and it's called every animation frame. Extract named constants (or comment what they represent) and consider caching the radius until fontSize changes to avoid repeated getComputedStyle work.
| #words { | ||
| mask-image: radial-gradient( | ||
| circle var(--tunnel-radius, 150px) at var(--caret-left) var(--caret-top), | ||
| black 0%, | ||
| black 50%, | ||
| transparent 100% | ||
| ); | ||
| -webkit-mask-image: radial-gradient( | ||
| circle var(--tunnel-radius, 150px) at var(--caret-left) var(--caret-top), | ||
| black 0%, | ||
| black 50%, | ||
| transparent 100% | ||
| ); |
There was a problem hiding this comment.
mask-image uses var(--caret-left) / var(--caret-top) with no fallback, so the whole radial-gradient is invalid until JS sets the vars (can cause a visible flash where everything is fully visible). Add fallback values (e.g., 50% 50% or 0 0) to keep the gradient valid immediately.
| "nospace", //nospace | ||
| "plus_one", //toPush: | ||
| "read_ahead_easy", //changesWordVisibility | ||
| "tunnel_vision", //changesWordVisibility | ||
| "tts", //speaks | ||
| "layout_mirror", //changesLayout |
There was a problem hiding this comment.
Comment says "changesWordVisibility" but the actual funbox property is "changesWordsVisibility" (plural). Update the comment to match the property name to avoid confusion when maintaining the zen-mode blocklist.
Adds a new funbox effect 'tunnel vision' - only a small radial area around the caret will be visible.
recording-2026-03-23_22-40-58.mp4
There are a few things left to improve, but I’d love feedback on the effect before i continue to work on it.