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
"zipf", //changesWordsFrequency
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/ts/test/funbox/funbox-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ export class PolyglotWordset extends Wordset {
}
}

let tunnelVisionAnimationFrame: number | null = null;

const list: Partial<Record<FunboxName, FunboxFunctions>> = {
"58008": {
getWord(): string {
Expand Down Expand Up @@ -679,6 +681,49 @@ const list: Partial<Record<FunboxName, FunboxFunctions>> = {
return word.toUpperCase();
},
},
tunnel_vision: {
applyGlobalCSS(): void {
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;
}
Comment on lines +686 to +719
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");
if (words) {
words.native.style.removeProperty("--caret-left");
words.native.style.removeProperty("--caret-top");
}
},
},
polyglot: {
async withWords(_words) {
const promises = Config.customPolyglot.map(async (language) =>
Expand Down
16 changes: 16 additions & 0 deletions frontend/static/funbox/tunnel_vision.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#words {
mask-image: radial-gradient(
circle 150px at var(--caret-left) var(--caret-top),
black 0%,
black 50%,
transparent 100%
);
-webkit-mask-image: radial-gradient(
circle 150px at var(--caret-left) var(--caret-top),
black 0%,
black 50%,
transparent 100%
);
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
}
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