Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/__tests__/test/funbox/funbox-validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe("funbox-validation", () => {
"nospace", //nospace
"plus_one", //toPush:
"read_ahead_easy", //changesWordVisibility
"tunnel_vision", //changesWordVisibility
"tts", //speaks
"layout_mirror", //changesLayout
Comment on lines 24 to 29
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.
"zipf", //changesWordsFrequency
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/ts/test/funbox/funbox-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { WordGenError } from "../../utils/word-gen-error";
import { FunboxName, KeymapLayout, Layout } from "@monkeytype/schemas/configs";
import { Language, LanguageObject } from "@monkeytype/schemas/languages";
import { qs } from "../../utils/dom";
import { convertRemToPixels } from "../../utils/numbers";

export type FunboxFunctions = {
getWord?: (wordset?: Wordset, wordIndex?: number) => string;
Expand Down Expand Up @@ -166,6 +167,14 @@ export class PolyglotWordset extends Wordset {
}
}

let tunnelVisionAnimationFrame: number | null = null;

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

return Math.max(48, Math.min(220, fontSizePx * 4.5));
}
Comment on lines +170 to +176
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.

const list: Partial<Record<FunboxName, FunboxFunctions>> = {
"58008": {
getWord(): string {
Expand Down Expand Up @@ -679,6 +688,68 @@ const list: Partial<Record<FunboxName, FunboxFunctions>> = {
return word.toUpperCase();
},
},
tunnel_vision: {
applyGlobalCSS(): void {
const words = qs("#words");
const wordsWrapper = qs("#wordsWrapper");
if (!words || !wordsWrapper) return;

Comment on lines +692 to +696
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.
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);
};
Comment on lines +697 to +727
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.

if (tunnelVisionAnimationFrame !== null) {
cancelAnimationFrame(tunnelVisionAnimationFrame);
}
updateCaretPos();
},
clearGlobal(): void {
if (tunnelVisionAnimationFrame !== null) {
cancelAnimationFrame(tunnelVisionAnimationFrame);
tunnelVisionAnimationFrame = null;
}
Comment on lines +693 to +738
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.
const words = qs("#words");
const wordsWrapper = qs("#wordsWrapper");
if (words) {
words.native.style.removeProperty("--caret-left");
words.native.style.removeProperty("--caret-top");
words.native.style.removeProperty("--tunnel-radius");
}
if (wordsWrapper) {
wordsWrapper.native.style.removeProperty("--caret-left");
wordsWrapper.native.style.removeProperty("--caret-top");
wordsWrapper.native.style.removeProperty("--tunnel-radius");
}
},
},
polyglot: {
async withWords(_words) {
const promises = Config.customPolyglot.map(async (language) =>
Expand Down
44 changes: 44 additions & 0 deletions frontend/static/funbox/tunnel_vision.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#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%
);
Comment on lines +1 to +13
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.
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
}

body.fb-tunnel-vision #wordsWrapper.tape {
isolation: isolate;
}

body.fb-tunnel-vision #wordsWrapper.tape #words {
mask-image: none;
-webkit-mask-image: none;
}

body.fb-tunnel-vision #wordsWrapper.tape::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: radial-gradient(
circle var(--tunnel-radius, 150px) at var(--caret-left) var(--caret-top),
transparent 0%,
transparent 50%,
var(--bg-color) 100%
);
}

body.fb-tunnel-vision #wordsWrapper.tape #caret,
body.fb-tunnel-vision #wordsWrapper.tape #paceCaret {
z-index: 2;
}
20 changes: 20 additions & 0 deletions packages/funbox/__test__/validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,26 @@ describe("validation", () => {
true,
);
});

it("should reject multiple word visibility funboxes", () => {
//GIVEN
getFunboxMock.mockReturnValueOnce([
{
name: "plus_one",
properties: ["changesWordsVisibility"],
} as FunboxMetadata,
{
name: "tunnel_vision",
properties: ["changesWordsVisibility"],
} as FunboxMetadata,
]);

//WHEN / THEN
expect(Validation.checkCompatibility(["plus_one", "tunnel_vision"])).toBe(
false,
);
});

describe("should validate two funboxes modifying the wordset", () => {
const testCases = [
{
Expand Down
8 changes: 8 additions & 0 deletions packages/funbox/src/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,14 @@ const list: Record<FunboxName, FunboxMetadata> = {
difficultyLevel: 0,
name: "no_quit",
},
tunnel_vision: {
name: "tunnel_vision",
description: "Only the area around the caret is visible.",
canGetPb: true,
difficultyLevel: 2,
properties: ["hasCssFile", "changesWordsVisibility"],
frontendFunctions: ["applyGlobalCSS", "clearGlobal"],
},
};

export function getObject(): Record<FunboxName, FunboxMetadata> {
Expand Down
1 change: 1 addition & 0 deletions packages/schemas/src/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ export const FunboxNameSchema = z.enum([
"asl",
"rot13",
"no_quit",
"tunnel_vision",
]);
export type FunboxName = z.infer<typeof FunboxNameSchema>;

Expand Down
Loading