From d91555ef83402bc205da248829705b0f0818996b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Fri, 26 Sep 2025 11:41:30 +0200 Subject: [PATCH 1/9] fix: avoid code-annnotation-line-highlight overflow --- .../formats/html/bootstrap/_bootstrap-rules.scss | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss index 728b83d262..b89c910be5 100644 --- a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss +++ b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss @@ -2163,6 +2163,7 @@ pre.code-annotation-code { } } +#code-annotation-line-highlight, #code-annotation-line-highlight-gutter { width: 100%; border-top: solid $code-annotation-higlight-color 1px; @@ -2171,15 +2172,6 @@ pre.code-annotation-code { background-color: $code-annotation-higlight-bg; } -#code-annotation-line-highlight { - margin-left: -4em; - width: calc(100% + 4em); - border-top: solid $code-annotation-higlight-color 1px; - border-bottom: solid $code-annotation-higlight-color 1px; - z-index: 2; - background-color: $code-annotation-higlight-bg; -} - code.sourceCode .code-annotation-anchor.code-annotation-active { background-color: var(--quarto-hl-normal-color, #aaaaaa); border: solid var(--quarto-hl-normal-color, #aaaaaa) 1px; From 2bf963e3d6e7529ceabfbbbbc93eef2d4e08c1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:47:24 +0100 Subject: [PATCH 2/9] fix: place code annotation highlight bands from the line positions The band and the gutter band were placed from offsetTop, which is relative to the code element, but each band is positioned against another containing block. In dashboards the band sat above its lines, and in Reveal.js a negative margin pushed it out of the slide. --- .../html/templates/quarto-html-after-body.ejs | 82 ++++++++++--------- src/resources/formats/revealjs/quarto.scss | 11 +-- 2 files changed, 45 insertions(+), 48 deletions(-) diff --git a/src/resources/formats/html/templates/quarto-html-after-body.ejs b/src/resources/formats/html/templates/quarto-html-after-body.ejs index e1748528f6..2ceb407c76 100644 --- a/src/resources/formats/html/templates/quarto-html-after-body.ejs +++ b/src/resources/formats/html/templates/quarto-html-after-body.ejs @@ -544,48 +544,52 @@ return targetCell + "-" + line; }) - let top = null; - let height = null; - let parent = null; + // Each band is positioned against its own containing block, which is + // not the offsetParent of the lines, so place it from client rects. + const placeBand = (band, firstEl, lastEl) => { + const container = band.offsetParent; + if (container === null) { + return; + } + const containerRect = container.getBoundingClientRect(); + // Reveal.js scales slides with a transform, so convert client + // pixels back to the CSS pixels of the container. + const scale = containerRect.height / container.offsetHeight || 1; + const firstRect = firstEl.getBoundingClientRect(); + const lastRect = lastEl.getBoundingClientRect(); + const top = (firstRect.top - containerRect.top) / scale - container.clientTop; + const height = (lastRect.bottom - firstRect.top) / scale; + band.style.top = top - 2 + "px"; + band.style.height = height + 4 + "px"; + }; + if (lineIds.length > 0) { - //compute the position of the single el (top and bottom and make a div) - const el = window.document.getElementById(lineIds[0]); - top = el.offsetTop; - height = el.offsetHeight; - parent = el.parentElement.parentElement; - - if (lineIds.length > 1) { - const lastEl = window.document.getElementById(lineIds[lineIds.length - 1]); - const bottom = lastEl.offsetTop + lastEl.offsetHeight; - height = bottom - top; + const el = window.document.getElementById(lineIds[0]); + const lastEl = window.document.getElementById(lineIds[lineIds.length - 1]); + const parent = el.parentElement.parentElement; + + // cook up a div (if necessary) and position it + let div = window.document.getElementById("code-annotation-line-highlight"); + if (div === null) { + div = window.document.createElement("div"); + div.setAttribute("id", "code-annotation-line-highlight"); + div.style.position = 'absolute'; + parent.appendChild(div); } - - if (top !== null && height !== null && parent !== null) { - // cook up a div (if necessary) and position it - let div = window.document.getElementById("code-annotation-line-highlight"); - if (div === null) { - div = window.document.createElement("div"); - div.setAttribute("id", "code-annotation-line-highlight"); - div.style.position = 'absolute'; - parent.appendChild(div); - } - div.style.top = top - 2 + "px"; - div.style.height = height + 4 + "px"; - div.style.left = 0; - - let gutterDiv = window.document.getElementById("code-annotation-line-highlight-gutter"); - if (gutterDiv === null) { - gutterDiv = window.document.createElement("div"); - gutterDiv.setAttribute("id", "code-annotation-line-highlight-gutter"); - gutterDiv.style.position = 'absolute'; - const codeCell = window.document.getElementById(targetCell); - const gutter = codeCell.querySelector('.code-annotation-gutter'); - gutter.appendChild(gutterDiv); - } - - gutterDiv.style.top = top - 2 + "px"; - gutterDiv.style.height = height + 4 + "px"; + div.style.left = 0; + placeBand(div, el, lastEl); + + let gutterDiv = window.document.getElementById("code-annotation-line-highlight-gutter"); + if (gutterDiv === null) { + gutterDiv = window.document.createElement("div"); + gutterDiv.setAttribute("id", "code-annotation-line-highlight-gutter"); + gutterDiv.style.position = 'absolute'; + const codeCell = window.document.getElementById(targetCell); + const gutter = codeCell.querySelector('.code-annotation-gutter'); + gutter.appendChild(gutterDiv); } + placeBand(gutterDiv, el, lastEl); + selectedAnnoteEl = annoteEl; } }; diff --git a/src/resources/formats/revealjs/quarto.scss b/src/resources/formats/revealjs/quarto.scss index 856a1ac51b..5a606d75e1 100644 --- a/src/resources/formats/revealjs/quarto.scss +++ b/src/resources/formats/revealjs/quarto.scss @@ -769,22 +769,15 @@ div.cell-output-display div.pagedtable-wrapper table.table { left: auto; } +.reveal #code-annotation-line-highlight, .reveal #code-annotation-line-highlight-gutter { + box-sizing: border-box; width: 100%; border-top: solid var(--quarto-hl-co-color) 1px; border-bottom: solid var(--quarto-hl-co-color) 1px; z-index: 2; } -.reveal #code-annotation-line-highlight { - margin-left: -8em; - width: calc(100% + 4em); - border-top: solid var(--quarto-hl-co-color) 1px; - border-bottom: solid var(--quarto-hl-co-color) 1px; - z-index: 2; - margin-bottom: -2px; -} - .reveal code.sourceCode .code-annotation-anchor.code-annotation-active { background-color: var(--quarto-hl-normal-color, #aaaaaa); border: solid var(--quarto-hl-normal-color, #aaaaaa) 1px; From 922c9db92c33c26f82c2b8c88a39b58de2b94409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:47:24 +0100 Subject: [PATCH 3/9] test: cover code annotation highlight placement and contrast --- .../dashboard/code-annotation-highlight.qmd | 29 ++++ .../code-annotation-highlight-contrast.qmd | 32 ++++ .../html/code-annotation-highlight.qmd | 27 +++ .../revealjs/code-annotation-highlight.qmd | 32 ++++ .../html-code-annotation-highlight.spec.ts | 159 ++++++++++++++++++ 5 files changed, 279 insertions(+) create mode 100644 tests/docs/playwright/dashboard/code-annotation-highlight.qmd create mode 100644 tests/docs/playwright/html/code-annotation-highlight-contrast.qmd create mode 100644 tests/docs/playwright/html/code-annotation-highlight.qmd create mode 100644 tests/docs/playwright/revealjs/code-annotation-highlight.qmd create mode 100644 tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts diff --git a/tests/docs/playwright/dashboard/code-annotation-highlight.qmd b/tests/docs/playwright/dashboard/code-annotation-highlight.qmd new file mode 100644 index 0000000000..0477002b86 --- /dev/null +++ b/tests/docs/playwright/dashboard/code-annotation-highlight.qmd @@ -0,0 +1,29 @@ +--- +title: "Code annotation line highlight" +format: dashboard +code-annotations: below +--- + +## Row + +```{.python} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. + +```{.python code-line-numbers="true"} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. diff --git a/tests/docs/playwright/html/code-annotation-highlight-contrast.qmd b/tests/docs/playwright/html/code-annotation-highlight-contrast.qmd new file mode 100644 index 0000000000..876f3e7656 --- /dev/null +++ b/tests/docs/playwright/html/code-annotation-highlight-contrast.qmd @@ -0,0 +1,32 @@ +--- +title: "Code annotation line highlight contrast" +format: + html: + theme: + light: cosmo + dark: darkly + respect-user-color-scheme: true +code-annotations: below +--- + +```{.python} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. + +```{.python code-line-numbers="true"} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. diff --git a/tests/docs/playwright/html/code-annotation-highlight.qmd b/tests/docs/playwright/html/code-annotation-highlight.qmd new file mode 100644 index 0000000000..7e8fed11a1 --- /dev/null +++ b/tests/docs/playwright/html/code-annotation-highlight.qmd @@ -0,0 +1,27 @@ +--- +title: "Code annotation line highlight" +format: html +code-annotations: below +--- + +```{.python} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. + +```{.python code-line-numbers="true"} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. diff --git a/tests/docs/playwright/revealjs/code-annotation-highlight.qmd b/tests/docs/playwright/revealjs/code-annotation-highlight.qmd new file mode 100644 index 0000000000..29ac7d23c8 --- /dev/null +++ b/tests/docs/playwright/revealjs/code-annotation-highlight.qmd @@ -0,0 +1,32 @@ +--- +title: "Code annotation line highlight" +format: revealjs +code-annotations: below +--- + +## Without line numbers + +```{.python} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. + + +## With line numbers + +```{.python code-line-numbers="true"} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. diff --git a/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts b/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts new file mode 100644 index 0000000000..276b9dea47 --- /dev/null +++ b/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts @@ -0,0 +1,159 @@ +import { expect, Locator, Page, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { getUrl } from "../src/utils"; + +// Regression test for #13448. Selecting a code annotation draws a band over +// its lines, and a second band in the annotation gutter. Both bands must stay +// inside the code block, cover the line-number column, and line up with each +// other and with the annotated lines. + +type Box = { x: number; y: number; width: number; height: number }; + +const tolerance = 1.5; + +const boxOf = async (locator: Locator): Promise => { + const box = await locator.boundingBox(); + expect(box).not.toBeNull(); + return box!; +}; + +const annotatedLines = async (page: Page, dt: Locator): Promise => { + const cell = await dt.getAttribute("data-target-cell"); + const annotation = await dt.getAttribute("data-target-annotation"); + return page.evaluate(([cell, annotation]) => { + const span = document.querySelector( + `span[data-code-cell="${cell}"][data-code-annotation="${annotation}"]`, + )!; + const lines = span.getAttribute("data-code-lines")!.split(","); + const first = document.getElementById(`${cell}-${lines[0]}`)! + .getBoundingClientRect(); + const last = document.getElementById(`${cell}-${lines[lines.length - 1]}`)! + .getBoundingClientRect(); + return { + x: first.x, + y: first.y, + width: first.width, + height: last.bottom - first.top, + }; + }, [cell, annotation]); +}; + +const expectBandsPlaced = async (page: Page, block: Locator) => { + const terms = block.locator("xpath=following-sibling::*[1]") + .locator("dt[data-target-cell]"); + const count = await terms.count(); + expect(count).toBeGreaterThan(1); + const sourceCode = await boxOf(block.locator("div.sourceCode")); + for (let i = 0; i < count; i++) { + const dt = terms.nth(i); + await dt.click(); + const band = await boxOf(page.locator("#code-annotation-line-highlight")); + const gutterBand = await boxOf( + page.locator("#code-annotation-line-highlight-gutter"), + ); + const lines = await annotatedLines(page, dt); + + // The band spans the code block, line-number column included, and does + // not overflow it. + expect(Math.abs(band.x - sourceCode.x)).toBeLessThanOrEqual(tolerance); + expect( + Math.abs(band.x + band.width - (sourceCode.x + sourceCode.width)), + ).toBeLessThanOrEqual(tolerance); + + // The band covers the annotated lines and little more. + expect(band.y).toBeLessThanOrEqual(lines.y); + expect(band.y + band.height).toBeGreaterThanOrEqual( + lines.y + lines.height, + ); + expect(lines.y - band.y).toBeLessThanOrEqual(2 + tolerance); + expect(band.y + band.height - (lines.y + lines.height)) + .toBeLessThanOrEqual(2 + tolerance); + + // The gutter band lines up with the band. + expect(Math.abs(gutterBand.y - band.y)).toBeLessThanOrEqual(tolerance); + expect(Math.abs(gutterBand.height - band.height)).toBeLessThanOrEqual( + tolerance, + ); + + await dt.click(); + await expect(page.locator("#code-annotation-line-highlight")) + .toHaveCount(0); + } +}; + +const htmlCases = [ + { name: "html", url: "html/code-annotation-highlight.html" }, + { name: "dashboard", url: "dashboard/code-annotation-highlight.html" }, +]; + +test.describe("code annotation line highlight", () => { + for (const { name, url } of htmlCases) { + for (const [label, index] of [["without", 0], ["with", 1]] as const) { + test(`${name} — ${label} line numbers, bands fit the code block`, async ({ page }) => { + await page.goto(getUrl(url), { waitUntil: "load" }); + const block = page.locator(".code-copy-outer-scaffold") + .filter({ has: page.locator("pre.code-annotated") }) + .nth(index); + await block.scrollIntoViewIfNeeded(); + await expectBandsPlaced(page, block); + }); + } + } + + for (const [slide, index] of [ + ["without-line-numbers", 1], + ["with-line-numbers", 2], + ] as const) { + test(`revealjs — ${slide}, bands fit the code block`, async ({ page }) => { + await page.goto(getUrl("revealjs/code-annotation-highlight.html"), { + waitUntil: "load", + }); + await page.waitForFunction(() => (window as any).Reveal?.isReady()); + await page.evaluate((h) => (window as any).Reveal.slide(h), index); + const section = page.locator(`section#${slide}`); + await expect(section).toHaveClass(/present/); + const block = section.locator(".code-copy-outer-scaffold"); + await expect(block).toBeVisible(); + await expectBandsPlaced(page, block); + }); + } + + for (const scheme of ["light", "dark"] as const) { + test(`html — ${scheme} theme, highlighted code keeps its contrast`, async ({ page }) => { + // Known gap, older than #13448: the light band tint lowers the + // contrast of the arrow theme's `.im` tokens to 4.24:1. The tokens + // leave no room for a tint, as #14959 shows for Reveal.js. + test.fail(scheme === "light"); + await page.emulateMedia({ colorScheme: scheme }); + await page.goto(getUrl("html/code-annotation-highlight-contrast.html"), { + waitUntil: "load", + }); + await expect(page.locator("body")).toHaveClass( + scheme === "dark" ? /quarto-dark/ : /quarto-light/, + ); + await page.locator("dt[data-target-cell]").first().click(); + await expect(page.locator("#code-annotation-line-highlight")) + .toBeVisible(); + await page.addScriptTag({ + content: readFileSync( + join( + __dirname, + "../../../../src/resources/formats/html/axe/axe.min.js", + ), + "utf8", + ), + }); + const violations = await page.evaluate(async () => { + const axe = (window as any).axe; + const result = await axe.run("div.sourceCode", { + runOnly: { type: "rule", values: ["color-contrast"] }, + }); + return result.violations.flatMap((v: { nodes: { target: string[] }[] }) => + v.nodes.map((n) => n.target.join(" ")) + ); + }); + expect(violations).toEqual([]); + }); + } +}); From 44bdb03f53c9e27aee4a6d78e45e09dd5c28f32f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:47:24 +0100 Subject: [PATCH 4/9] docs: add changelog entry for the code annotation highlight fix --- news/changelog-1.11.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index a60538ef04..8cc4a081ea 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -67,6 +67,7 @@ All changes included in 1.11: - ([#12650](https://github.com/quarto-dev/quarto-cli/issues/12650)): Fix markdown formatting and HTML entities in a document title appearing literally in the appendix citation, the BibTeX citation, and the Google Scholar `citation_title`. (author: @jolars) - ([#13444](https://github.com/quarto-dev/quarto-cli/issues/13444)): Fix the code copy button covering the code annotation marker on the first line of an annotated code block, which stopped the annotation from showing on hover or click (regression introduced in v1.8.15). - ([#13446](https://github.com/quarto-dev/quarto-cli/issues/13446)): Fix the code copy button sitting above the code frame, and the extra space around the code, when a code block starts or ends a callout (regression introduced in v1.8.15). +- ([#13448](https://github.com/quarto-dev/quarto-cli/issues/13448)): Fix the code annotation line highlight spilling out to the left of the code block, and sitting above the gutter highlight, in `html`, `dashboard` and `revealjs` output (regression introduced in v1.8.15). (author: @mcanouil) - ([#14684](https://github.com/quarto-dev/quarto-cli/issues/14684)): Add a "Skip to main content" link to Bootstrap-themed HTML output (documents, websites, books, dashboards) so keyboard users can bypass the navbar and sidebars. - ([#14973](https://github.com/quarto-dev/quarto-cli/issues/14973)): Fix Safari sometimes opening pages with a light and dark theme at the top instead of at the URL fragment target, and toggling the color scheme scrolling back to the top of the page. - ([#14976](https://github.com/quarto-dev/quarto-cli/pull/14976)): Fix KaTeX math not rendering on pages with Jupyter widgets when `html-math-method` is set with the object form (`method: katex` and `url`). From b3248c84077c0c787f045f7518f923ab8683a71e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:04:16 +0100 Subject: [PATCH 5/9] fix: keep the code annotation highlight inside the code block without the copy button --- .../html/templates/quarto-html-after-body.ejs | 9 +++- .../code-annotation-highlight-no-copy.qmd | 29 +++++++++++ .../html-code-annotation-highlight.spec.ts | 51 +++++++++++++------ 3 files changed, 72 insertions(+), 17 deletions(-) create mode 100644 tests/docs/playwright/html/code-annotation-highlight-no-copy.qmd diff --git a/src/resources/formats/html/templates/quarto-html-after-body.ejs b/src/resources/formats/html/templates/quarto-html-after-body.ejs index 2ceb407c76..5ad2cdc1f5 100644 --- a/src/resources/formats/html/templates/quarto-html-after-body.ejs +++ b/src/resources/formats/html/templates/quarto-html-after-body.ejs @@ -576,8 +576,15 @@ div.style.position = 'absolute'; parent.appendChild(div); } - div.style.left = 0; placeBand(div, el, lastEl); + // Without the copy button wrapper, the band's containing block is + // wider than the code block, so span the code block explicitly. + const codeBlock = parent.closest("div.sourceCode") || parent; + const containerRect = div.offsetParent.getBoundingClientRect(); + const scale = containerRect.height / div.offsetParent.offsetHeight || 1; + const blockRect = codeBlock.getBoundingClientRect(); + div.style.left = (blockRect.left - containerRect.left) / scale - div.offsetParent.clientLeft + "px"; + div.style.width = blockRect.width / scale + "px"; let gutterDiv = window.document.getElementById("code-annotation-line-highlight-gutter"); if (gutterDiv === null) { diff --git a/tests/docs/playwright/html/code-annotation-highlight-no-copy.qmd b/tests/docs/playwright/html/code-annotation-highlight-no-copy.qmd new file mode 100644 index 0000000000..5f66c8e990 --- /dev/null +++ b/tests/docs/playwright/html/code-annotation-highlight-no-copy.qmd @@ -0,0 +1,29 @@ +--- +title: "Code annotation line highlight no copy" +format: + html: + code-copy: false +code-annotations: below +--- + +```{.python} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. + +```{.python code-line-numbers="true"} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. diff --git a/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts b/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts index 276b9dea47..3a1d40670e 100644 --- a/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts +++ b/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts @@ -39,15 +39,27 @@ const annotatedLines = async (page: Page, dt: Locator): Promise => { }, [cell, annotation]); }; +// The click handlers attach on DOMContentLoaded scripts, so retry a click that +// lands before they exist. +const selectAnnotation = async (page: Page, dt: Locator) => { + await expect(async () => { + if (await page.locator("#code-annotation-line-highlight").count() === 0) { + await dt.click(); + } + await expect(page.locator("#code-annotation-line-highlight")) + .toBeVisible({ timeout: 500 }); + }).toPass(); +}; + const expectBandsPlaced = async (page: Page, block: Locator) => { - const terms = block.locator("xpath=following-sibling::*[1]") + const terms = block.locator("xpath=following::dl[1]") .locator("dt[data-target-cell]"); const count = await terms.count(); expect(count).toBeGreaterThan(1); - const sourceCode = await boxOf(block.locator("div.sourceCode")); + const sourceCode = await boxOf(block); for (let i = 0; i < count; i++) { const dt = terms.nth(i); - await dt.click(); + await selectAnnotation(page, dt); const band = await boxOf(page.locator("#code-annotation-line-highlight")); const gutterBand = await boxOf( page.locator("#code-annotation-line-highlight-gutter"), @@ -85,6 +97,10 @@ const expectBandsPlaced = async (page: Page, block: Locator) => { const htmlCases = [ { name: "html", url: "html/code-annotation-highlight.html" }, { name: "dashboard", url: "dashboard/code-annotation-highlight.html" }, + { + name: "html without code copy", + url: "html/code-annotation-highlight-no-copy.html", + }, ]; test.describe("code annotation line highlight", () => { @@ -92,7 +108,7 @@ test.describe("code annotation line highlight", () => { for (const [label, index] of [["without", 0], ["with", 1]] as const) { test(`${name} — ${label} line numbers, bands fit the code block`, async ({ page }) => { await page.goto(getUrl(url), { waitUntil: "load" }); - const block = page.locator(".code-copy-outer-scaffold") + const block = page.locator("div.sourceCode") .filter({ has: page.locator("pre.code-annotated") }) .nth(index); await block.scrollIntoViewIfNeeded(); @@ -101,10 +117,12 @@ test.describe("code annotation line highlight", () => { } } - for (const [slide, index] of [ - ["without-line-numbers", 1], - ["with-line-numbers", 2], - ] as const) { + for ( + const [slide, index] of [ + ["without-line-numbers", 1], + ["with-line-numbers", 2], + ] as const + ) { test(`revealjs — ${slide}, bands fit the code block`, async ({ page }) => { await page.goto(getUrl("revealjs/code-annotation-highlight.html"), { waitUntil: "load", @@ -113,7 +131,7 @@ test.describe("code annotation line highlight", () => { await page.evaluate((h) => (window as any).Reveal.slide(h), index); const section = page.locator(`section#${slide}`); await expect(section).toHaveClass(/present/); - const block = section.locator(".code-copy-outer-scaffold"); + const block = section.locator("div.sourceCode"); await expect(block).toBeVisible(); await expectBandsPlaced(page, block); }); @@ -124,7 +142,7 @@ test.describe("code annotation line highlight", () => { // Known gap, older than #13448: the light band tint lowers the // contrast of the arrow theme's `.im` tokens to 4.24:1. The tokens // leave no room for a tint, as #14959 shows for Reveal.js. - test.fail(scheme === "light"); + test.fixme(scheme === "light"); await page.emulateMedia({ colorScheme: scheme }); await page.goto(getUrl("html/code-annotation-highlight-contrast.html"), { waitUntil: "load", @@ -132,9 +150,10 @@ test.describe("code annotation line highlight", () => { await expect(page.locator("body")).toHaveClass( scheme === "dark" ? /quarto-dark/ : /quarto-light/, ); - await page.locator("dt[data-target-cell]").first().click(); - await expect(page.locator("#code-annotation-line-highlight")) - .toBeVisible(); + await selectAnnotation( + page, + page.locator("dt[data-target-cell]").first(), + ); await page.addScriptTag({ content: readFileSync( join( @@ -149,9 +168,9 @@ test.describe("code annotation line highlight", () => { const result = await axe.run("div.sourceCode", { runOnly: { type: "rule", values: ["color-contrast"] }, }); - return result.violations.flatMap((v: { nodes: { target: string[] }[] }) => - v.nodes.map((n) => n.target.join(" ")) - ); + return result.violations.flatMap(( + v: { nodes: { target: string[] }[] }, + ) => v.nodes.map((n) => n.target.join(" "))); }); expect(violations).toEqual([]); }); From b72c021700b1e0d07f0a4316816e2d2d27551a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Canouil?= <8896044+mcanouil@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:10:41 +0100 Subject: [PATCH 6/9] fix: skip placing the annotation highlight when its container is hidden --- .../html/templates/quarto-html-after-body.ejs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/resources/formats/html/templates/quarto-html-after-body.ejs b/src/resources/formats/html/templates/quarto-html-after-body.ejs index 5ad2cdc1f5..162012b198 100644 --- a/src/resources/formats/html/templates/quarto-html-after-body.ejs +++ b/src/resources/formats/html/templates/quarto-html-after-body.ejs @@ -546,7 +546,7 @@ // Each band is positioned against its own containing block, which is // not the offsetParent of the lines, so place it from client rects. - const placeBand = (band, firstEl, lastEl) => { + const placeBand = (band, firstEl, lastEl, codeBlock) => { const container = band.offsetParent; if (container === null) { return; @@ -561,6 +561,13 @@ const height = (lastRect.bottom - firstRect.top) / scale; band.style.top = top - 2 + "px"; band.style.height = height + 4 + "px"; + if (codeBlock) { + // Without the copy button wrapper, the containing block is wider + // than the code block, so span the code block explicitly. + const blockRect = codeBlock.getBoundingClientRect(); + band.style.left = (blockRect.left - containerRect.left) / scale - container.clientLeft + "px"; + band.style.width = blockRect.width / scale + "px"; + } }; if (lineIds.length > 0) { @@ -576,15 +583,7 @@ div.style.position = 'absolute'; parent.appendChild(div); } - placeBand(div, el, lastEl); - // Without the copy button wrapper, the band's containing block is - // wider than the code block, so span the code block explicitly. - const codeBlock = parent.closest("div.sourceCode") || parent; - const containerRect = div.offsetParent.getBoundingClientRect(); - const scale = containerRect.height / div.offsetParent.offsetHeight || 1; - const blockRect = codeBlock.getBoundingClientRect(); - div.style.left = (blockRect.left - containerRect.left) / scale - div.offsetParent.clientLeft + "px"; - div.style.width = blockRect.width / scale + "px"; + placeBand(div, el, lastEl, parent.closest("div.sourceCode") || parent); let gutterDiv = window.document.getElementById("code-annotation-line-highlight-gutter"); if (gutterDiv === null) { From c3746dc3a9c8442bfa17f593c9d9e4ad1d078b55 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 12:28:24 +0100 Subject: [PATCH 7/9] fix: account for container scroll when placing the code annotation highlight The band is placed from client rects relative to its offsetParent. When that offsetParent is a positioned element that scrolls, which happens with code-copy: false, the rect difference already includes the scroll, and setting it as an absolute offset applies it a second time, so the band sat above its lines by the scroll offset. Adding the container's scrollTop and scrollLeft places it back on the lines. A new Playwright case selects an annotation inside a scrolled positioned box. The light theme contrast test switches from test.fixme to test.fail so it keeps running and flags when the arrow theme contrast gap (#14959) is resolved, as the Playwright test conventions ask for known failures. --- .../html/templates/quarto-html-after-body.ejs | 4 +-- .../code-annotation-highlight-scrolled.qmd | 34 +++++++++++++++++++ .../html-code-annotation-highlight.spec.ts | 15 +++++++- 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 tests/docs/playwright/html/code-annotation-highlight-scrolled.qmd diff --git a/src/resources/formats/html/templates/quarto-html-after-body.ejs b/src/resources/formats/html/templates/quarto-html-after-body.ejs index 162012b198..85dec87864 100644 --- a/src/resources/formats/html/templates/quarto-html-after-body.ejs +++ b/src/resources/formats/html/templates/quarto-html-after-body.ejs @@ -557,7 +557,7 @@ const scale = containerRect.height / container.offsetHeight || 1; const firstRect = firstEl.getBoundingClientRect(); const lastRect = lastEl.getBoundingClientRect(); - const top = (firstRect.top - containerRect.top) / scale - container.clientTop; + const top = (firstRect.top - containerRect.top) / scale - container.clientTop + container.scrollTop; const height = (lastRect.bottom - firstRect.top) / scale; band.style.top = top - 2 + "px"; band.style.height = height + 4 + "px"; @@ -565,7 +565,7 @@ // Without the copy button wrapper, the containing block is wider // than the code block, so span the code block explicitly. const blockRect = codeBlock.getBoundingClientRect(); - band.style.left = (blockRect.left - containerRect.left) / scale - container.clientLeft + "px"; + band.style.left = (blockRect.left - containerRect.left) / scale - container.clientLeft + container.scrollLeft + "px"; band.style.width = blockRect.width / scale + "px"; } }; diff --git a/tests/docs/playwright/html/code-annotation-highlight-scrolled.qmd b/tests/docs/playwright/html/code-annotation-highlight-scrolled.qmd new file mode 100644 index 0000000000..8c6db7221d --- /dev/null +++ b/tests/docs/playwright/html/code-annotation-highlight-scrolled.qmd @@ -0,0 +1,34 @@ +--- +title: "Code annotation line highlight in a scrolled container" +format: + html: + code-copy: false +code-annotations: below +--- + +::: {#scroll-box style="position: relative; height: 200px; overflow: auto;"} + +Filler paragraph so the code block starts below the visible area of the box. + +Filler paragraph so the code block starts below the visible area of the box. + +Filler paragraph so the code block starts below the visible area of the box. + +Filler paragraph so the code block starts below the visible area of the box. + +```{.python} +# Some comment +# to avoid overlapping copy button and annotation marker +import polars as pl # <1> +df = pl.DataFrame({"a": [1, 2]}) # <2> +df.head() # <2> +``` + +1. Import the library. +2. Build and show a data frame. + +Filler paragraph after the annotations. + +Filler paragraph after the annotations. + +::: diff --git a/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts b/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts index 3a1d40670e..12266d2ac9 100644 --- a/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts +++ b/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts @@ -137,12 +137,25 @@ test.describe("code annotation line highlight", () => { }); } + test("html — inside a scrolled positioned container, bands fit the code block", async ({ page }) => { + // Without the copy button wrapper, the band's containing block is the + // positioned scroll box, so its scroll offset must not shift the band. + await page.goto(getUrl("html/code-annotation-highlight-scrolled.html"), { + waitUntil: "load", + }); + const box = page.locator("#scroll-box"); + const block = box.locator("div.sourceCode"); + await block.scrollIntoViewIfNeeded(); + expect(await box.evaluate((el) => el.scrollTop)).toBeGreaterThan(0); + await expectBandsPlaced(page, block); + }); + for (const scheme of ["light", "dark"] as const) { test(`html — ${scheme} theme, highlighted code keeps its contrast`, async ({ page }) => { // Known gap, older than #13448: the light band tint lowers the // contrast of the arrow theme's `.im` tokens to 4.24:1. The tokens // leave no room for a tint, as #14959 shows for Reveal.js. - test.fixme(scheme === "light"); + test.fail(scheme === "light"); await page.emulateMedia({ colorScheme: scheme }); await page.goto(getUrl("html/code-annotation-highlight-contrast.html"), { waitUntil: "load", From d81c7dfe65ab79bde1c6550517e3ad557bbaeb9d Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 12:35:20 +0100 Subject: [PATCH 8/9] test: disable line numbers on the unnumbered Reveal.js highlight slide Reveal.js turns code-line-numbers on by default, so the slide meant to cover code without line numbers rendered numbered code too, and both slides exercised the same layout. Setting code-line-numbers="false" on that block restores the unnumbered case. --- tests/docs/playwright/revealjs/code-annotation-highlight.qmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/docs/playwright/revealjs/code-annotation-highlight.qmd b/tests/docs/playwright/revealjs/code-annotation-highlight.qmd index 29ac7d23c8..734e720db6 100644 --- a/tests/docs/playwright/revealjs/code-annotation-highlight.qmd +++ b/tests/docs/playwright/revealjs/code-annotation-highlight.qmd @@ -6,7 +6,7 @@ code-annotations: below ## Without line numbers -```{.python} +```{.python code-line-numbers="false"} # Some comment # to avoid overlapping copy button and annotation marker import polars as pl # <1> From 6f696928a7a89b31cc060e327e8ff502a593e6b9 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 28 Oct 2026 12:42:49 +0100 Subject: [PATCH 9/9] test: assert the line-number layout each highlight case claims to cover The Reveal.js slide meant for unnumbered code rendered numbered code, because Reveal.js turns code-line-numbers on by default, and the band geometry checks pass either way. Each case now checks whether its code block carries number-lines before measuring, so a fixture that drifts from its label fails instead of silently covering the wrong layout. --- .../html-code-annotation-highlight.spec.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts b/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts index 12266d2ac9..53c7b104cf 100644 --- a/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts +++ b/tests/integration/playwright/tests/html-code-annotation-highlight.spec.ts @@ -94,6 +94,17 @@ const expectBandsPlaced = async (page: Page, block: Locator) => { } }; +// Guards the fixture against format defaults: Reveal.js numbers code lines +// unless told otherwise, so a case can silently test the wrong layout. +const expectLineNumbers = async (block: Locator, numbered: boolean) => { + const pre = block.locator("pre.code-annotated"); + if (numbered) { + await expect(pre).toHaveClass(/\bnumber-lines\b/); + } else { + await expect(pre).not.toHaveClass(/\bnumber-lines\b/); + } +}; + const htmlCases = [ { name: "html", url: "html/code-annotation-highlight.html" }, { name: "dashboard", url: "dashboard/code-annotation-highlight.html" }, @@ -111,6 +122,7 @@ test.describe("code annotation line highlight", () => { const block = page.locator("div.sourceCode") .filter({ has: page.locator("pre.code-annotated") }) .nth(index); + await expectLineNumbers(block, label === "with"); await block.scrollIntoViewIfNeeded(); await expectBandsPlaced(page, block); }); @@ -118,9 +130,9 @@ test.describe("code annotation line highlight", () => { } for ( - const [slide, index] of [ - ["without-line-numbers", 1], - ["with-line-numbers", 2], + const [slide, index, numbered] of [ + ["without-line-numbers", 1, false], + ["with-line-numbers", 2, true], ] as const ) { test(`revealjs — ${slide}, bands fit the code block`, async ({ page }) => { @@ -133,6 +145,7 @@ test.describe("code annotation line highlight", () => { await expect(section).toHaveClass(/present/); const block = section.locator("div.sourceCode"); await expect(block).toBeVisible(); + await expectLineNumbers(block, numbered); await expectBandsPlaced(page, block); }); } @@ -145,6 +158,7 @@ test.describe("code annotation line highlight", () => { }); const box = page.locator("#scroll-box"); const block = box.locator("div.sourceCode"); + await expectLineNumbers(block, false); await block.scrollIntoViewIfNeeded(); expect(await box.evaluate((el) => el.scrollTop)).toBeGreaterThan(0); await expectBandsPlaced(page, block);