From 0ea0b65a65916986dc99af9b2afe6a5970ea6ed4 Mon Sep 17 00:00:00 2001 From: ColtenOuO Date: Fri, 2 Oct 2026 20:53:54 +0000 Subject: [PATCH] Let the candidate resize the code editor font The editor was fixed at 0.875rem, and browser zoom, the only way out, scales the problem statement and the preflight overlay with it. A- and A+ in the editor toolbar now step through six fixed sizes, kept in localStorage like the other preflight choices. The textarea, the highlight overlay and the gutter all read one custom property, so they cannot disagree on a size. The gutter width scales with it but never drops below the old 3.25rem, so a three-digit line number still fits at the largest size and a four-digit one at the smallest. A button at the end of the range is marked aria-disabled rather than disabled, so a keyboard user pressing it keeps focus. Ctrl with + and - is left alone because it is the browser's zoom. Close #218 --- tests/browser/editor-font-size.test.js | 183 +++++++++++++++++++++++++ web/interview.html | 24 ++++ web/interview.js | 53 +++++++ web/styles.css | 20 ++- 4 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 tests/browser/editor-font-size.test.js diff --git a/tests/browser/editor-font-size.test.js b/tests/browser/editor-font-size.test.js new file mode 100644 index 00000000..2db00e3e --- /dev/null +++ b/tests/browser/editor-font-size.test.js @@ -0,0 +1,183 @@ +// Run with: node --test tests/browser/editor-font-size.test.js +// +// The code font size is a candidate's choice kept between visits. What can +// break is the agreement between the textarea, the highlight overlay and the +// gutter: one layer at a different size or offset and the caret drifts away +// from the glyphs it edits, so every offered size is checked in a real browser. + +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; + +import { + DEFAULT_RUNTIME_CONFIG, + launchChromium, + startStaticServer, +} from "./source.js"; + +let browser = null; +let server = null; +let base = ""; + +before(async () => { + browser = await launchChromium(); + if (!browser) return; + ({ server, base } = await startStaticServer({ + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + })); +}); + +after(async () => { + await browser?.close(); + await new Promise((closed) => (server ? server.close(closed) : closed())); +}); + +/// Out from behind the media preflight, which covers the toolbar until a device +/// check clears it. Hidden rather than reached through with a scripted click, +/// so the clicks below still have to land on the buttons a candidate sees. +/// Waits on the placeholder `init()` sets, not on static markup, so the stored +/// size has been applied and the buttons have their handlers. +async function openEditor(page) { + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + await page.evaluate(() => { + document.querySelector("#audio-check").hidden = true; + }); +} + +/// Four-digit line numbers, which the gutter is tightest for at the smallest +/// size. Refilled after every reload, because the buffer is not kept. Set with +/// one input event rather than `page.fill`, which takes half a minute to type +/// this many lines. +const fillLongBuffer = (page) => + page.evaluate(() => { + const editor = document.querySelector("#editor"); + editor.value = Array.from({ length: 1200 }, (_, i) => `x = ${i}`).join( + "\n", + ); + editor.dispatchEvent(new Event("input", { bubbles: true })); + }); + +const editorMetrics = (page) => + page.evaluate(() => { + const layers = ["#editor", "#editor-highlight", "#editor-lines"].map((id) => + document.querySelector(id), + ); + const [editor, , gutter] = layers; + return { + sizes: layers.map((node) => getComputedStyle(node).fontSize), + lineHeights: layers.map((node) => getComputedStyle(node).lineHeight), + gutterRight: gutter.getBoundingClientRect().right, + editorLeft: editor.getBoundingClientRect().left, + highlightLeft: document + .querySelector("#editor-highlight") + .getBoundingClientRect().left, + gutterOverflows: gutter.scrollWidth > gutter.clientWidth, + smallerDisabled: + document + .querySelector("#editor-font-smaller") + .getAttribute("aria-disabled") === "true", + largerDisabled: + document + .querySelector("#editor-font-larger") + .getAttribute("aria-disabled") === "true", + focused: document.activeElement?.id ?? "", + stored: localStorage.getItem("codetrial:editorFontSize"), + }; + }); + +function assertLayersAgree(metrics, label) { + assert.equal(new Set(metrics.sizes).size, 1, `${label}: ${metrics.sizes}`); + assert.equal( + new Set(metrics.lineHeights).size, + 1, + `${label}: ${metrics.lineHeights}`, + ); + assert.equal(metrics.editorLeft, metrics.highlightLeft, label); + assert.equal(metrics.editorLeft, metrics.gutterRight, label); + assert.equal(metrics.gutterOverflows, false, `${label}: gutter overflows`); +} + +/// Clicks once per expected size rather than until the button disables, so a +/// button that stops stepping or never disables fails here instead of +/// clicking until CI times out. +async function stepThrough(page, direction, sizes) { + const disabled = `${direction}Disabled`; + for (const [index, size] of sizes.entries()) { + await page.click(`#editor-font-${direction}`); + const metrics = await editorMetrics(page); + assert.equal(metrics.sizes[0], size, `${direction} to ${size}`); + assertLayersAgree(metrics, `${direction} ${size}`); + assert.equal( + metrics[disabled], + index === sizes.length - 1, + `${direction} at ${size}: disabled only at the end of the ladder`, + ); + assert.equal( + metrics.focused, + `editor-font-${direction}`, + `${direction} at ${size}: focus stays on the button`, + ); + } +} + +test("every offered code font size keeps the editor layers in line, and is remembered", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage(); + try { + await page.goto(`${base}/interview.html`, { + waitUntil: "domcontentloaded", + }); + await openEditor(page); + await fillLongBuffer(page); + + const initial = await editorMetrics(page); + assert.equal(initial.sizes[0], "14px", "0.875rem unless changed"); + assert.equal(initial.smallerDisabled, false); + assert.equal(initial.largerDisabled, false); + assertLayersAgree(initial, "default"); + + await stepThrough(page, "larger", ["16px", "18px", "20px", "24px"]); + + await page.reload({ waitUntil: "domcontentloaded" }); + await openEditor(page); + await fillLongBuffer(page); + const returning = await editorMetrics(page); + assert.equal(returning.sizes[0], "24px", "the choice is remembered"); + assert.equal(returning.largerDisabled, true); + + await stepThrough(page, "smaller", [ + "20px", + "18px", + "16px", + "14px", + "12px", + ]); + + await page.click("#editor-font-larger"); + const back = await editorMetrics(page); + assert.equal(back.sizes[0], "14px"); + assert.equal(back.stored, null, "the default is stored as no preference"); + } finally { + await page.close(); + } +}); + +test("a stored size the page does not offer falls back to the default", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage(); + try { + await page.addInitScript(() => + localStorage.setItem("codetrial:editorFontSize", "9"), + ); + await page.goto(`${base}/interview.html`, { + waitUntil: "domcontentloaded", + }); + await openEditor(page); + const metrics = await editorMetrics(page); + assert.equal(metrics.sizes[0], "14px"); + assertLayersAgree(metrics, "fallback"); + } finally { + await page.close(); + } +}); diff --git a/web/interview.html b/web/interview.html index fb7ecaa9..e8717cd8 100644 --- a/web/interview.html +++ b/web/interview.html @@ -181,6 +181,30 @@

Loading interview...

Java +
+ + +

C, C++ and Java runs are sent to Compiler Explorer.

diff --git a/web/interview.js b/web/interview.js index 583e435c..071b51ab 100644 --- a/web/interview.js +++ b/web/interview.js @@ -159,6 +159,12 @@ const CODE_PUBLISH_DEBOUNCE_MS = 300; /// it is about. Measured against the hint text, not chosen round. const FRAMEWORK_HINT_MS = 12000; const HIDE_EXAMPLES_KEY = "codetrial:hideExamples"; +const EDITOR_FONT_SIZE_KEY = "codetrial:editorFontSize"; +/// In rem. A fixed ladder rather than a free number, so a stored value is +/// either one of these or ignored, and every size the page can show is one the +/// browser check has seen keep the three editor layers in line. +const EDITOR_FONT_SIZES = ["0.75", "0.875", "1", "1.125", "1.25", "1.5"]; +const DEFAULT_EDITOR_FONT_SIZE = "0.875"; // From /runtime-config.js, which is the only thing allowed to name what the // server does. A literal here would be a second answer to "does this server @@ -360,6 +366,9 @@ const nodes = { editor: document.querySelector("#editor"), editorHighlight: document.querySelector("#editor-highlight"), editorLines: document.querySelector("#editor-lines"), + editorStack: document.querySelector(".editor-stack"), + editorFontSmaller: document.querySelector("#editor-font-smaller"), + editorFontLarger: document.querySelector("#editor-font-larger"), compileDisclosure: document.querySelector(".compile-disclosure"), run: document.querySelector("#run-tests"), candidateCaseInput: document.querySelector("#candidate-case-input"), @@ -436,6 +445,7 @@ async function init() { state.transcript = createTranscriptView(document, nodes.transcriptPanel); renderRuntimeConfig(); nodes.hideExamples.checked = readStored(HIDE_EXAMPLES_KEY) === "1"; + applyEditorFontSize(readStored(EDITOR_FONT_SIZE_KEY)); renderProblem(); applyLanguages(null); setLanguage("python"); @@ -640,6 +650,23 @@ function bindEvents() { setLanguage(button.dataset.language), ); } + for (const [button, step] of [ + [nodes.editorFontSmaller, -1], + [nodes.editorFontLarger, 1], + ]) { + button.addEventListener("click", () => { + const index = EDITOR_FONT_SIZES.indexOf(editorFontSize()) + step; + const size = EDITOR_FONT_SIZES[index]; + if (!size) return; + applyEditorFontSize(size); + // The default is stored as no preference, so a later change of default + // reaches whoever never moved off it. + writeStored( + EDITOR_FONT_SIZE_KEY, + size === DEFAULT_EDITOR_FONT_SIZE ? "" : size, + ); + }); + } // The overlay does not scroll on its own; it follows the textarea. nodes.editor.addEventListener("scroll", () => { nodes.editorHighlight.scrollTop = nodes.editor.scrollTop; @@ -2797,6 +2824,32 @@ function paintEditor() { } } +/// Sets one custom property that the textarea, the highlight overlay and the +/// gutter all read, rather than a font size on each: three writes could leave +/// them disagreeing for a frame, or for good if one were missed, and the caret +/// then sits apart from the glyphs it edits. +function applyEditorFontSize(stored) { + const size = EDITOR_FONT_SIZES.includes(stored) + ? stored + : DEFAULT_EDITOR_FONT_SIZE; + nodes.editorStack.style.setProperty("--editor-font-size", `${size}rem`); + const index = EDITOR_FONT_SIZES.indexOf(size); + // aria-disabled rather than disabled: disabling the button just pressed + // drops keyboard focus to , and the click handler already ignores a + // step past either end. + nodes.editorFontSmaller.setAttribute("aria-disabled", String(index === 0)); + nodes.editorFontLarger.setAttribute( + "aria-disabled", + String(index === EDITOR_FONT_SIZES.length - 1), + ); +} + +function editorFontSize() { + return nodes.editorStack.style + .getPropertyValue("--editor-font-size") + .replace(/rem$/, ""); +} + function currentCode() { return nodes.editor.value; } diff --git a/web/styles.css b/web/styles.css index 5e6af1e9..43fb8b6d 100644 --- a/web/styles.css +++ b/web/styles.css @@ -345,7 +345,8 @@ p { /* A tab the judge cannot run, dimmed rather than removed: the row stays the same five tabs everywhere, and the title says which one is out and why. */ -.language-button:disabled { +.language-button:disabled, +.font-size-button[aria-disabled="true"] { opacity: 0.6; cursor: not-allowed; } @@ -456,6 +457,7 @@ p { .difficulty-row, .editor-toolbar, .language-tabs, +.font-size-control, .report-header, .report-actions, .history-header { @@ -640,6 +642,7 @@ p { .pill-button, .tab-button, .language-button, +.font-size-button, .run-button { border: 1px solid var(--hairline); border-radius: 999px; @@ -753,6 +756,7 @@ p { .pill-button, .tab-button, .language-button, +.font-size-button, .run-button { padding: 0.4rem 0.75rem; cursor: pointer; @@ -868,6 +872,12 @@ p { } .editor-stack { + /* Set from interview.js when the candidate picks a size. The gutter scales + with it, or a three-digit line number overflows it at the largest size, + but never drops below 3.25rem, or a four-digit one is clipped at the + smallest. */ + --editor-font-size: 0.875rem; + --editor-gutter: max(3.25rem, calc(var(--editor-font-size) * 3.72)); position: relative; flex: 1; min-height: 25rem; @@ -879,12 +889,12 @@ p { #editor, #editor-highlight { position: absolute; - inset: 0 0 0 3.25rem; + inset: 0 0 0 var(--editor-gutter); margin: 0; border: 0; padding: 1rem; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.875rem; + font-size: var(--editor-font-size); line-height: 1.5; tab-size: 4; /* Not pre-wrap: the gutter numbers logical lines, so a wrapped line would @@ -902,7 +912,7 @@ p { #editor-lines { position: absolute; inset: 0 auto 0 0; - width: 3.25rem; + width: var(--editor-gutter); margin: 0; padding: 1rem 0.5rem; overflow: hidden; @@ -910,7 +920,7 @@ p { background: #181816; border-right: 1px solid var(--hairline); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.875rem; + font-size: var(--editor-font-size); line-height: 1.5; text-align: right; user-select: none;