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 @@
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;