From 791123a05c0a9901c1a5e6f67a6f644f9cd74017 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 21 Oct 2026 17:29:15 +0200 Subject: [PATCH 1/4] Keep the code copy button inside code blocks in callouts Since code blocks are wrapped in .code-copy-outer-scaffold, the callout first/last-child rules hit the scaffold instead of div.sourceCode. The padding-top added to a first child stopped the code block's own 1em margin from collapsing, and the absolutely positioned copy button anchors to the scaffold's padding box, so in a default-style callout starting with code the button sat about 19px above the code, half outside it. The last-child rules, which meant to exempt code blocks with :not(.sourceCode), padded the scaffold too, leaving an extra gap below code at the end of a callout. The `> div > :last-child` branch also matched the button itself, the scaffold's last child, and gave it 8.5px of bottom padding. At a callout edge, the scaffold is now spaced with margin instead of padding and the inner div.sourceCode margin is dropped, for bare code blocks and for cells whose code is at that edge. The button sits 5px inside the code top, as it does outside callouts, and code at the top or bottom of a callout gets the same gap as a paragraph there. This is a visible change from 1.7, where the code frame of a titled callout sat flush against the header. The general padding-to-margin change asked for in #13238 touches non-code first children too and is left out. Fixes #13446 --- .../html/bootstrap/_bootstrap-rules.scss | 32 +++- .../html/callout-code-copy-button.qmd | 157 ++++++++++++++++++ .../html-callout-code-copy-button.spec.ts | 95 +++++++++++ 3 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 tests/docs/playwright/html/callout-code-copy-button.qmd create mode 100644 tests/integration/playwright/tests/html-callout-code-copy-button.spec.ts diff --git a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss index 97b0e374e1..18f016005f 100644 --- a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss +++ b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss @@ -1522,11 +1522,41 @@ kbd, } .callout.callout-titled .callout-body > :last-child:not(.sourceCode), -.callout.callout-titled .callout-body > div > :last-child:not(.sourceCode) { +.callout.callout-titled .callout-body > div > :last-child:not(.sourceCode, .code-copy-button) { padding-bottom: 0.5rem; margin-bottom: 0; } +// Code blocks are wrapped in .code-copy-outer-scaffold, which positions the +// copy button. At a callout edge, space the scaffold with margin instead of +// padding and drop the code block's own margin, so the button stays inside +// the code frame and the gap matches a paragraph's. +.callout .callout-body-container > .code-copy-outer-scaffold:first-child > div.sourceCode, +.callout .callout-body-container > .cell:first-child > .code-copy-outer-scaffold:first-child > div.sourceCode { + margin-top: 0; +} + +.callout .callout-body-container > .code-copy-outer-scaffold:last-child > div.sourceCode, +.callout .callout-body-container > .cell:last-child > .code-copy-outer-scaffold:last-child > div.sourceCode { + margin-bottom: 0; +} + +.callout.callout-style-default .callout-body > .code-copy-outer-scaffold:first-child, +.callout.callout-style-default .callout-body > .cell:first-child:has(> .code-copy-outer-scaffold:first-child) { + padding-top: 0; + margin-top: 0.5rem; +} + +.callout.callout-titled .callout-body > .code-copy-outer-scaffold:last-child, +.callout.callout-titled .callout-body > .cell:last-child:has(> .code-copy-outer-scaffold:last-child) { + padding-bottom: 0; + margin-bottom: 0.5rem; +} + +.callout.callout-titled .callout-body > .cell:last-child > .code-copy-outer-scaffold:last-child { + padding-bottom: 0; +} + .callout:not(.callout-titled) .callout-body > :first-child, .callout:not(.callout-titled) .callout-body > div > :first-child { margin-top: 0.25rem; diff --git a/tests/docs/playwright/html/callout-code-copy-button.qmd b/tests/docs/playwright/html/callout-code-copy-button.qmd new file mode 100644 index 0000000000..4a2530aba1 --- /dev/null +++ b/tests/docs/playwright/html/callout-code-copy-button.qmd @@ -0,0 +1,157 @@ +--- +title: "Code copy button in callouts" +format: html +code-annotations: hover +--- + +## Plain + +::: {#plain} + +```bash +echo "plain" +``` + +::: + +## Titled, paragraph only + +::: {#titled-paragraph .callout-note} + +## Titled, paragraph only + +A paragraph. + +::: + +## Titled, code first + +::: {#titled-first .callout-note} + +## Titled, code first + +```bash +echo "first" +``` + +A paragraph. + +::: + +## Titled, code last + +::: {#titled-last .callout-note} + +## Titled, code last + +A paragraph. + +```bash +echo "last" +``` + +::: + +## Collapsible, code first + +::: {#collapse-first .callout-note collapse="false"} + +## Collapsible, code first + +```bash +echo "first" +``` + +A paragraph. + +::: + +## Titled, cell first + +::: {#titled-cell-first .callout-note} + +## Titled, cell first + +::: {.cell} + +```{.r .cell-code} +1 + 1 +``` + +::: + +A paragraph. + +::: + +## Titled, cell last + +::: {#titled-cell-last .callout-note} + +## Titled, cell last + +A paragraph. + +::: {.cell} + +```{.r .cell-code} +1 + 1 +``` + +::: + +::: + +## Titled, annotated code first + +::: {#titled-annotated .callout-note} + +## Titled, annotated code first + +```python +import polars as pl # <1> +``` + +1. Import polars. + +::: + +## Simple, paragraph only + +::: {#simple-paragraph .callout-note appearance="simple"} + +A paragraph. + +::: + +## Simple, code first + +::: {#simple-first .callout-note appearance="simple"} + +```bash +echo "first" +``` + +A paragraph. + +::: + +## Minimal, paragraph only + +::: {#minimal-paragraph .callout-note appearance="minimal"} + +A paragraph. + +::: + +## Minimal, code last + +::: {#minimal-last .callout-note appearance="minimal"} + +A paragraph. + +```bash +echo "last" +``` + +::: diff --git a/tests/integration/playwright/tests/html-callout-code-copy-button.spec.ts b/tests/integration/playwright/tests/html-callout-code-copy-button.spec.ts new file mode 100644 index 0000000000..e3d5d93800 --- /dev/null +++ b/tests/integration/playwright/tests/html-callout-code-copy-button.spec.ts @@ -0,0 +1,95 @@ +import { expect, test, type Page } from "@playwright/test"; +import { getUrl } from "../src/utils"; + +// Regression tests for #13446 and #13238. Code blocks are wrapped in +// div.code-copy-outer-scaffold, which holds div.sourceCode and the copy +// button. The callout first/last-child rules must treat that wrapper like a +// code block: the button stays inside the code frame, and code at the edge of +// a callout gets the same gap as a paragraph there. + +interface Geometry { + // Copy button top minus div.sourceCode top + buttonOffset: number | null; + buttonHeight: number | null; + // Visible top of the first block below the header (or callout top) + topGap: number; + // Callout bottom minus visible bottom of the last block + bottomGap: number; +} + +async function geometry(page: Page, id: string): Promise { + return await page.evaluate((id) => { + const el = document.getElementById(id)!; + // Code frame is the div.sourceCode border box; text is a paragraph's + // content box. + const visible = (e: Element) => { + const r = e.getBoundingClientRect(); + if (e.tagName !== "P") return { top: r.top, bottom: r.bottom }; + const cs = getComputedStyle(e); + return { + top: r.top + parseFloat(cs.paddingTop), + bottom: r.bottom - parseFloat(cs.paddingBottom), + }; + }; + const header = el.querySelector(".callout-header"); + const ref = header && getComputedStyle(header).display !== "none" + ? header.getBoundingClientRect().bottom + : el.getBoundingClientRect().top; + const blocks = Array.from( + el.querySelectorAll("div.sourceCode, .callout-body-container > p"), + ); + const div = el.querySelector("div.sourceCode"); + const button = el.querySelector(".code-copy-button"); + return { + buttonOffset: div && button + ? button.getBoundingClientRect().top - div.getBoundingClientRect().top + : null, + buttonHeight: button ? button.getBoundingClientRect().height : null, + topGap: visible(blocks[0]).top - ref, + bottomGap: el.getBoundingClientRect().bottom - + visible(blocks[blocks.length - 1]).bottom, + }; + }, id); +} + +// Code callouts, with the paragraph-only callout of the same appearance +// they are compared with at the edge where the code sits. +const cases = [ + { id: "titled-first", edge: "top", reference: "titled-paragraph" }, + { id: "titled-last", edge: "bottom", reference: "titled-paragraph" }, + { id: "titled-cell-first", edge: "top", reference: "titled-paragraph" }, + { id: "titled-cell-last", edge: "bottom", reference: "titled-paragraph" }, + { id: "titled-annotated", edge: "top", reference: "titled-paragraph" }, + { id: "collapse-first", edge: null, reference: null }, + { id: "simple-first", edge: "top", reference: "simple-paragraph" }, + { id: "minimal-last", edge: "bottom", reference: "minimal-paragraph" }, +] as const; + +test.describe("Code copy button in callouts", () => { + test.beforeEach(async ({ page }) => { + await page.goto(getUrl("html/callout-code-copy-button.html"), { + waitUntil: "load", + }); + }); + + for (const { id, edge, reference } of cases) { + test(`${id}: copy button sits inside the code frame`, async ({ page }) => { + const plain = await geometry(page, "plain"); + const code = await geometry(page, id); + expect(code.buttonOffset).not.toBeNull(); + expect(Math.abs(code.buttonOffset! - plain.buttonOffset!)) + .toBeLessThanOrEqual(1); + expect(Math.abs(code.buttonHeight! - plain.buttonHeight!)) + .toBeLessThanOrEqual(1); + }); + + if (edge && reference) { + test(`${id}: code at the ${edge} edge is spaced like a paragraph`, async ({ page }) => { + const code = await geometry(page, id); + const para = await geometry(page, reference); + const gap = edge === "top" ? "topGap" : "bottomGap"; + expect(Math.abs(code[gap] - para[gap])).toBeLessThanOrEqual(1); + }); + } + } +}); From d896bdd92cfa745a122dccbd5d1c734cf1c1592c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 21 Oct 2026 17:45:43 +0200 Subject: [PATCH 2/4] Nest the callout code-edge rules under their shared prefixes Each block now reads as code at the first or last edge of a callout body, with the long shared selector prefix written once. The compiled CSS is byte-identical to the flat form. --- .../html/bootstrap/_bootstrap-rules.scss | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss index 18f016005f..512c1bb7ae 100644 --- a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss +++ b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss @@ -1531,30 +1531,41 @@ kbd, // copy button. At a callout edge, space the scaffold with margin instead of // padding and drop the code block's own margin, so the button stays inside // the code frame and the gap matches a paragraph's. -.callout .callout-body-container > .code-copy-outer-scaffold:first-child > div.sourceCode, -.callout .callout-body-container > .cell:first-child > .code-copy-outer-scaffold:first-child > div.sourceCode { - margin-top: 0; -} +.callout .callout-body-container { + > .code-copy-outer-scaffold:first-child, + > .cell:first-child > .code-copy-outer-scaffold:first-child { + > div.sourceCode { + margin-top: 0; + } + } -.callout .callout-body-container > .code-copy-outer-scaffold:last-child > div.sourceCode, -.callout .callout-body-container > .cell:last-child > .code-copy-outer-scaffold:last-child > div.sourceCode { - margin-bottom: 0; + > .code-copy-outer-scaffold:last-child, + > .cell:last-child > .code-copy-outer-scaffold:last-child { + > div.sourceCode { + margin-bottom: 0; + } + } } -.callout.callout-style-default .callout-body > .code-copy-outer-scaffold:first-child, -.callout.callout-style-default .callout-body > .cell:first-child:has(> .code-copy-outer-scaffold:first-child) { - padding-top: 0; - margin-top: 0.5rem; +.callout.callout-style-default .callout-body { + > .code-copy-outer-scaffold:first-child, + > .cell:first-child:has(> .code-copy-outer-scaffold:first-child) { + padding-top: 0; + margin-top: 0.5rem; + } } -.callout.callout-titled .callout-body > .code-copy-outer-scaffold:last-child, -.callout.callout-titled .callout-body > .cell:last-child:has(> .code-copy-outer-scaffold:last-child) { - padding-bottom: 0; - margin-bottom: 0.5rem; -} +.callout.callout-titled .callout-body { + > .code-copy-outer-scaffold:last-child, + > .cell:last-child:has(> .code-copy-outer-scaffold:last-child) { + padding-bottom: 0; + margin-bottom: 0.5rem; + } -.callout.callout-titled .callout-body > .cell:last-child > .code-copy-outer-scaffold:last-child { - padding-bottom: 0; + // Undoes the `> div > :last-child` padding above for code ending a cell. + > .cell:last-child > .code-copy-outer-scaffold:last-child { + padding-bottom: 0; + } } .callout:not(.callout-titled) .callout-body > :first-child, From 76115d6f31b43b563cb0dbb188e163090b1efd18 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 21 Oct 2026 18:11:53 +0200 Subject: [PATCH 3/4] Check the annotation marker stays hoverable in a callout With the copy button back inside the code frame, an annotated first line in a callout puts the button over the annotation marker, the same overlap #13444 fixes outside callouts. A hit test on the marker covers the callout case. It fails without the annotated button rule and passes with it. That rule also drops the annotated button's top margin, so its position is now compared with an annotated block outside a callout instead of a plain one. --- .../html/callout-code-copy-button.qmd | 12 +++++ .../html-callout-code-copy-button.spec.ts | 50 ++++++++++++++----- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/tests/docs/playwright/html/callout-code-copy-button.qmd b/tests/docs/playwright/html/callout-code-copy-button.qmd index 4a2530aba1..fab7ae78c5 100644 --- a/tests/docs/playwright/html/callout-code-copy-button.qmd +++ b/tests/docs/playwright/html/callout-code-copy-button.qmd @@ -14,6 +14,18 @@ echo "plain" ::: +## Plain, annotated + +::: {#plain-annotated} + +```python +import polars as pl # <1> +``` + +1. Import polars. + +::: + ## Titled, paragraph only ::: {#titled-paragraph .callout-note} diff --git a/tests/integration/playwright/tests/html-callout-code-copy-button.spec.ts b/tests/integration/playwright/tests/html-callout-code-copy-button.spec.ts index e3d5d93800..1bcd9a0d8e 100644 --- a/tests/integration/playwright/tests/html-callout-code-copy-button.spec.ts +++ b/tests/integration/playwright/tests/html-callout-code-copy-button.spec.ts @@ -52,17 +52,18 @@ async function geometry(page: Page, id: string): Promise { }, id); } -// Code callouts, with the paragraph-only callout of the same appearance -// they are compared with at the edge where the code sits. +// Code callouts, with the block outside a callout their copy button is +// compared with, and the paragraph-only callout of the same appearance they +// are compared with at the edge where the code sits. const cases = [ - { id: "titled-first", edge: "top", reference: "titled-paragraph" }, - { id: "titled-last", edge: "bottom", reference: "titled-paragraph" }, - { id: "titled-cell-first", edge: "top", reference: "titled-paragraph" }, - { id: "titled-cell-last", edge: "bottom", reference: "titled-paragraph" }, - { id: "titled-annotated", edge: "top", reference: "titled-paragraph" }, - { id: "collapse-first", edge: null, reference: null }, - { id: "simple-first", edge: "top", reference: "simple-paragraph" }, - { id: "minimal-last", edge: "bottom", reference: "minimal-paragraph" }, + { id: "titled-first", button: "plain", edge: "top", reference: "titled-paragraph" }, + { id: "titled-last", button: "plain", edge: "bottom", reference: "titled-paragraph" }, + { id: "titled-cell-first", button: "plain", edge: "top", reference: "titled-paragraph" }, + { id: "titled-cell-last", button: "plain", edge: "bottom", reference: "titled-paragraph" }, + { id: "titled-annotated", button: "plain-annotated", edge: "top", reference: "titled-paragraph" }, + { id: "collapse-first", button: "plain", edge: null, reference: null }, + { id: "simple-first", button: "plain", edge: "top", reference: "simple-paragraph" }, + { id: "minimal-last", button: "plain", edge: "bottom", reference: "minimal-paragraph" }, ] as const; test.describe("Code copy button in callouts", () => { @@ -72,9 +73,9 @@ test.describe("Code copy button in callouts", () => { }); }); - for (const { id, edge, reference } of cases) { + for (const { id, button, edge, reference } of cases) { test(`${id}: copy button sits inside the code frame`, async ({ page }) => { - const plain = await geometry(page, "plain"); + const plain = await geometry(page, button); const code = await geometry(page, id); expect(code.buttonOffset).not.toBeNull(); expect(Math.abs(code.buttonOffset! - plain.buttonOffset!)) @@ -92,4 +93,29 @@ test.describe("Code copy button in callouts", () => { }); } } + + // Inside the code frame, the button of an annotated first line must still + // leave the annotation marker hoverable, as it does outside a callout. + test("titled-annotated: copy button does not cover the marker", async ({ page }) => { + const anchor = page.locator( + "div#titled-annotated button.code-annotation-anchor", + ); + await expect(anchor).toHaveCount(1); + // elementFromPoint only sees the viewport. + await anchor.scrollIntoViewIfNeeded(); + const box = (await anchor.boundingBox())!; + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + // Hovering the marker also reveals the copy button. + await page.mouse.move(x, y); + const hit = await page.evaluate( + ([px, py]) => + document.elementFromPoint(px, py)?.closest( + ".code-annotation-anchor, .code-copy-button", + )?.className ?? null, + [x, y], + ); + expect(hit).toContain("code-annotation-anchor"); + }); }); From c74ef0ceec63fafb16acb3ca34160b13408d49ab Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 21 Oct 2026 18:44:35 +0200 Subject: [PATCH 4/4] Add #13446 changelog entry --- 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 1ab5a6c4de..e17740dfb3 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -62,6 +62,7 @@ All changes included in 1.11: - ([#12452](https://github.com/quarto-dev/quarto-cli/issues/12452)): Fix repeated HTML escaping in Google Scholar metadata, including apostrophes in titles, special characters in authors and URLs, and nested reference metadata. - ([#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). - ([#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`).