Skip to content

feat(funbox): add tunnel vision effect (@d1rshan)#7709

Draft
d1rshan wants to merge 2 commits intomonkeytypegame:masterfrom
d1rshan:feat/tunnel-vision
Draft

feat(funbox): add tunnel vision effect (@d1rshan)#7709
d1rshan wants to merge 2 commits intomonkeytypegame:masterfrom
d1rshan:feat/tunnel-vision

Conversation

@d1rshan
Copy link
Copy Markdown
Contributor

@d1rshan d1rshan commented Mar 23, 2026

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.

Copilot AI review requested due to automatic review settings March 23, 2026 17:27
@monkeytypegeorge monkeytypegeorge added frontend User interface or web stuff assets Languages, themes, layouts, etc. packages Changes in local packages labels Mar 23, 2026
@github-actions github-actions bot added the waiting for review Pull requests that require a review before continuing label Mar 23, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_vision to the shared funbox name schema.
  • Register tunnel_vision metadata (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.

Comment on lines +686 to +719
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;
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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

Copilot uses AI. Check for mistakes.
@d1rshan d1rshan marked this pull request as draft March 25, 2026 16:33
@github-actions github-actions bot removed the waiting for review Pull requests that require a review before continuing label Mar 25, 2026
@d1rshan d1rshan force-pushed the feat/tunnel-vision branch from c11c64b to 49b4c65 Compare April 10, 2026 18:38
@d1rshan d1rshan requested a review from Copilot April 10, 2026 18:43
@github-actions github-actions bot added the waiting for review Pull requests that require a review before continuing label Apr 10, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Comment on lines +692 to +696
applyGlobalCSS(): void {
const words = qs("#words");
const wordsWrapper = qs("#wordsWrapper");
if (!words || !wordsWrapper) return;

Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +697 to +727
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);
};
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +170 to +176
let tunnelVisionAnimationFrame: number | null = null;

function getTunnelVisionRadiusPx(): number {
const fontSizePx = convertRemToPixels(Config.fontSize);

return Math.max(48, Math.min(220, fontSizePx * 4.5));
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +13
#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%
);
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 24 to 29
"nospace", //nospace
"plus_one", //toPush:
"read_ahead_easy", //changesWordVisibility
"tunnel_vision", //changesWordVisibility
"tts", //speaks
"layout_mirror", //changesLayout
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

assets Languages, themes, layouts, etc. frontend User interface or web stuff packages Changes in local packages waiting for review Pull requests that require a review before continuing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants