From e5b3984d5c87cb397c2ec80b2c45a0103e6b029e Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 9 Oct 2026 15:23:40 +0200 Subject: [PATCH 1/5] Add Playwright test for notebook preview header under website navbar On a website with a navbar, the notebook preview page has two fixed-top bars: the site's #quarto-header and the preview's #quarto-embed-header. The site navbar paints over the preview bar, hiding the back link, the notebook title and the download link (#14972). The test checks that each of those controls is the topmost element at its center. It fails on current main ("back link is covered by another element"). The fixture uses stored notebook outputs so no kernel is needed in CI. --- .../notebook-preview-navbar/.gitignore | 3 ++ .../notebook-preview-navbar/_quarto.yml | 10 +++++ .../website/notebook-preview-navbar/index.qmd | 5 +++ .../notebook-preview-navbar/notebook.ipynb | 38 +++++++++++++++++++ .../website-notebook-preview-navbar.spec.ts | 36 ++++++++++++++++++ 5 files changed, 92 insertions(+) create mode 100644 tests/docs/playwright/website/notebook-preview-navbar/.gitignore create mode 100644 tests/docs/playwright/website/notebook-preview-navbar/_quarto.yml create mode 100644 tests/docs/playwright/website/notebook-preview-navbar/index.qmd create mode 100644 tests/docs/playwright/website/notebook-preview-navbar/notebook.ipynb create mode 100644 tests/integration/playwright/tests/website-notebook-preview-navbar.spec.ts diff --git a/tests/docs/playwright/website/notebook-preview-navbar/.gitignore b/tests/docs/playwright/website/notebook-preview-navbar/.gitignore new file mode 100644 index 00000000000..91e4dc52c80 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-navbar/.gitignore @@ -0,0 +1,3 @@ +/.quarto/ +/_site/ +**/*.quarto_ipynb diff --git a/tests/docs/playwright/website/notebook-preview-navbar/_quarto.yml b/tests/docs/playwright/website/notebook-preview-navbar/_quarto.yml new file mode 100644 index 00000000000..06a1460526f --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-navbar/_quarto.yml @@ -0,0 +1,10 @@ +project: + type: website + +website: + title: "notebook-preview-navbar" + navbar: + left: + - index.qmd + +format: html diff --git a/tests/docs/playwright/website/notebook-preview-navbar/index.qmd b/tests/docs/playwright/website/notebook-preview-navbar/index.qmd new file mode 100644 index 00000000000..ff868c9c4ce --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-navbar/index.qmd @@ -0,0 +1,5 @@ +--- +title: "Home" +--- + +{{< embed notebook.ipynb#answer >}} diff --git a/tests/docs/playwright/website/notebook-preview-navbar/notebook.ipynb b/tests/docs/playwright/website/notebook-preview-navbar/notebook.ipynb new file mode 100644 index 00000000000..4a8eaaca779 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-navbar/notebook.ipynb @@ -0,0 +1,38 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "answer", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "#| label: answer\n", + "1 + 1" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/integration/playwright/tests/website-notebook-preview-navbar.spec.ts b/tests/integration/playwright/tests/website-notebook-preview-navbar.spec.ts new file mode 100644 index 00000000000..c6e361ef8dd --- /dev/null +++ b/tests/integration/playwright/tests/website-notebook-preview-navbar.spec.ts @@ -0,0 +1,36 @@ +import { expect, test, type Locator } from "@playwright/test"; + +// True when the element at the center of `locator` belongs to it, i.e. no +// other element (such as a fixed navbar) is painted on top of it. +async function isTopmostAtCenter(locator: Locator): Promise { + return await locator.evaluate((el) => { + const box = el.getBoundingClientRect(); + const hit = document.elementFromPoint( + box.left + box.width / 2, + box.top + box.height / 2, + ); + return hit !== null && el.contains(hit); + }); +} + +// On a website with a navbar, the notebook preview page has two fixed-top +// headers: the site navbar and the notebook preview header. The preview +// header controls must stay visible and clickable (#14972). +test("notebook preview header is not hidden by the website navbar", async ({ page }) => { + await page.goto("./website/notebook-preview-navbar/_site/notebook-preview.html"); + + const navbar = page.locator("#quarto-header"); + const embedHeader = page.locator("#quarto-embed-header"); + await expect(navbar).toBeVisible(); + await expect(embedHeader).toBeVisible(); + + const backLink = embedHeader.locator(".quarto-back-link"); + const title = embedHeader.locator("h6"); + const download = embedHeader.getByRole("link", { name: "Download Notebook" }); + + const controls = { "back link": backLink, title, "download link": download }; + for (const [name, control] of Object.entries(controls)) { + await expect(control).toBeVisible(); + expect(await isTopmostAtCenter(control), `${name} is covered by another element`).toBe(true); + } +}); From 6b72557b0d9dbbff53cd8a0d2218bc5e8448e3a1 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 9 Oct 2026 15:28:25 +0200 Subject: [PATCH 2/5] Stack notebook preview header inside the website header On a website, the notebook preview page carries two fixed-top bars: the site's #quarto-header and the preview's #quarto-embed-header. Both sit at top: 0 and the site header paints over the preview bar, hiding the back link, notebook title and download link (#14972). The notebook view postprocessor now moves #quarto-embed-header into #quarto-header as its last child and drops the bar's own fixed-top and headroom classes. The site navigation script already sizes the body offset from the header height (ResizeObserver) and runs Headroom on the whole header, so the embed template's inline script skips its own padding and Headroom setup in that case. Same placement approach as the draft alert and dashboard navigation, which also insert into #quarto-header. Pages without a site header (standalone documents, manuscripts) keep the bar fixed at the top as before. --- src/format/html/format-html-notebook.ts | 12 ++++++ .../formats/html/embed/template.html | 39 +++++++++++-------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/format/html/format-html-notebook.ts b/src/format/html/format-html-notebook.ts index ff378ace349..d424d06346f 100644 --- a/src/format/html/format-html-notebook.ts +++ b/src/format/html/format-html-notebook.ts @@ -36,6 +36,18 @@ const kQuartoCellDecoratorClass = "cell-decorator"; export function notebookViewPostProcessor() { return (doc: Document): Promise => { doc.body.classList.add(kQuartoNbClass); + + // When the page has a site header, the notebook preview header joins it + // so the two fixed bars stack instead of overlapping + const embedHeaderEl = doc.getElementById("quarto-embed-header"); + const siteHeaderEl = doc.getElementById("quarto-header"); + if (embedHeaderEl && siteHeaderEl) { + embedHeaderEl.classList.remove("fixed-top"); + embedHeaderEl.classList.remove("headroom"); + siteHeaderEl.appendChild(embedHeaderEl); + } + + const cells = doc.querySelectorAll("div.cell"); let cellCount = 0; for (const cell of cells) { diff --git a/src/resources/formats/html/embed/template.html b/src/resources/formats/html/embed/template.html index 79079db8931..cd374a9bca6 100644 --- a/src/resources/formats/html/embed/template.html +++ b/src/resources/formats/html/embed/template.html @@ -68,14 +68,19 @@ window.document.addEventListener("DOMContentLoaded", function () { var header = window.document.querySelector("#quarto-embed-header"); - const titleBannerEl = window.document.querySelector("body > #title-block-header"); - if (titleBannerEl) { - titleBannerEl.style.paddingTop = header.clientHeight + "px"; - } - const contentEl = window.document.getElementById('quarto-content'); - for (const child of contentEl.children) { - child.style.paddingTop = header.clientHeight + "px"; - child.style.marginTop = "1em"; + // Inside the site header, the site navigation offsets the content + // and manages headroom + const inSiteHeader = header.parentElement.id === "quarto-header"; + if (!inSiteHeader) { + const titleBannerEl = window.document.querySelector("body > #title-block-header"); + if (titleBannerEl) { + titleBannerEl.style.paddingTop = header.clientHeight + "px"; + } + const contentEl = window.document.getElementById('quarto-content'); + for (const child of contentEl.children) { + child.style.paddingTop = header.clientHeight + "px"; + child.style.marginTop = "1em"; + } } // Use the article root if the `back` call doesn't work. This isn't perfect @@ -97,14 +102,16 @@ }, 100); } - const headroom = new window.Headroom(header, { - tolerance: 5, - onPin: function () { - }, - onUnpin: function () { - }, - }); - headroom.init(); + if (!inSiteHeader) { + const headroom = new window.Headroom(header, { + tolerance: 5, + onPin: function () { + }, + onUnpin: function () { + }, + }); + headroom.init(); + } }); From 47039a5160ba5b584d6626b6431fdeff6ba0373a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 9 Oct 2026 15:40:41 +0200 Subject: [PATCH 3/5] Test notebook preview header across site header configurations The site header (#quarto-header) exists whenever a website has a navbar, a sidebar (its collapsed toggle bar on narrow screens) or an announcement, so the notebook preview header must stay uncovered in each case. A standalone document has no site header and keeps the preview bar fixed at the top; that path is checked too. Each case also checks that the first notebook cell starts below the header, so content is offset by the stacked header height. --- .../notebook-preview-standalone/.gitignore | 2 + .../notebook-preview-standalone/index.qmd | 5 ++ .../notebook.ipynb | 38 +++++++++ .../notebook-preview-announcement/.gitignore | 3 + .../notebook-preview-announcement/_quarto.yml | 9 ++ .../notebook-preview-announcement/index.qmd | 5 ++ .../notebook.ipynb | 38 +++++++++ .../notebook-preview-sidebar/.gitignore | 3 + .../notebook-preview-sidebar/_quarto.yml | 10 +++ .../notebook-preview-sidebar/index.qmd | 5 ++ .../notebook-preview-sidebar/notebook.ipynb | 38 +++++++++ .../tests/notebook-preview-header.spec.ts | 85 +++++++++++++++++++ .../website-notebook-preview-navbar.spec.ts | 36 -------- 13 files changed, 241 insertions(+), 36 deletions(-) create mode 100644 tests/docs/playwright/html/notebook-preview-standalone/.gitignore create mode 100644 tests/docs/playwright/html/notebook-preview-standalone/index.qmd create mode 100644 tests/docs/playwright/html/notebook-preview-standalone/notebook.ipynb create mode 100644 tests/docs/playwright/website/notebook-preview-announcement/.gitignore create mode 100644 tests/docs/playwright/website/notebook-preview-announcement/_quarto.yml create mode 100644 tests/docs/playwright/website/notebook-preview-announcement/index.qmd create mode 100644 tests/docs/playwright/website/notebook-preview-announcement/notebook.ipynb create mode 100644 tests/docs/playwright/website/notebook-preview-sidebar/.gitignore create mode 100644 tests/docs/playwright/website/notebook-preview-sidebar/_quarto.yml create mode 100644 tests/docs/playwright/website/notebook-preview-sidebar/index.qmd create mode 100644 tests/docs/playwright/website/notebook-preview-sidebar/notebook.ipynb create mode 100644 tests/integration/playwright/tests/notebook-preview-header.spec.ts delete mode 100644 tests/integration/playwright/tests/website-notebook-preview-navbar.spec.ts diff --git a/tests/docs/playwright/html/notebook-preview-standalone/.gitignore b/tests/docs/playwright/html/notebook-preview-standalone/.gitignore new file mode 100644 index 00000000000..66901a1bd5e --- /dev/null +++ b/tests/docs/playwright/html/notebook-preview-standalone/.gitignore @@ -0,0 +1,2 @@ +/notebook.out.ipynb +**/*.quarto_ipynb diff --git a/tests/docs/playwright/html/notebook-preview-standalone/index.qmd b/tests/docs/playwright/html/notebook-preview-standalone/index.qmd new file mode 100644 index 00000000000..ff868c9c4ce --- /dev/null +++ b/tests/docs/playwright/html/notebook-preview-standalone/index.qmd @@ -0,0 +1,5 @@ +--- +title: "Home" +--- + +{{< embed notebook.ipynb#answer >}} diff --git a/tests/docs/playwright/html/notebook-preview-standalone/notebook.ipynb b/tests/docs/playwright/html/notebook-preview-standalone/notebook.ipynb new file mode 100644 index 00000000000..4a8eaaca779 --- /dev/null +++ b/tests/docs/playwright/html/notebook-preview-standalone/notebook.ipynb @@ -0,0 +1,38 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "answer", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "#| label: answer\n", + "1 + 1" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/docs/playwright/website/notebook-preview-announcement/.gitignore b/tests/docs/playwright/website/notebook-preview-announcement/.gitignore new file mode 100644 index 00000000000..91e4dc52c80 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-announcement/.gitignore @@ -0,0 +1,3 @@ +/.quarto/ +/_site/ +**/*.quarto_ipynb diff --git a/tests/docs/playwright/website/notebook-preview-announcement/_quarto.yml b/tests/docs/playwright/website/notebook-preview-announcement/_quarto.yml new file mode 100644 index 00000000000..6e9f2c4a616 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-announcement/_quarto.yml @@ -0,0 +1,9 @@ +project: + type: website + +website: + title: "notebook-preview-announcement" + announcement: + content: "Site announcement" + +format: html diff --git a/tests/docs/playwright/website/notebook-preview-announcement/index.qmd b/tests/docs/playwright/website/notebook-preview-announcement/index.qmd new file mode 100644 index 00000000000..ff868c9c4ce --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-announcement/index.qmd @@ -0,0 +1,5 @@ +--- +title: "Home" +--- + +{{< embed notebook.ipynb#answer >}} diff --git a/tests/docs/playwright/website/notebook-preview-announcement/notebook.ipynb b/tests/docs/playwright/website/notebook-preview-announcement/notebook.ipynb new file mode 100644 index 00000000000..4a8eaaca779 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-announcement/notebook.ipynb @@ -0,0 +1,38 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "answer", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "#| label: answer\n", + "1 + 1" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/docs/playwright/website/notebook-preview-sidebar/.gitignore b/tests/docs/playwright/website/notebook-preview-sidebar/.gitignore new file mode 100644 index 00000000000..91e4dc52c80 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-sidebar/.gitignore @@ -0,0 +1,3 @@ +/.quarto/ +/_site/ +**/*.quarto_ipynb diff --git a/tests/docs/playwright/website/notebook-preview-sidebar/_quarto.yml b/tests/docs/playwright/website/notebook-preview-sidebar/_quarto.yml new file mode 100644 index 00000000000..762a0f7920e --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-sidebar/_quarto.yml @@ -0,0 +1,10 @@ +project: + type: website + +website: + title: "notebook-preview-sidebar" + sidebar: + contents: + - index.qmd + +format: html diff --git a/tests/docs/playwright/website/notebook-preview-sidebar/index.qmd b/tests/docs/playwright/website/notebook-preview-sidebar/index.qmd new file mode 100644 index 00000000000..ff868c9c4ce --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-sidebar/index.qmd @@ -0,0 +1,5 @@ +--- +title: "Home" +--- + +{{< embed notebook.ipynb#answer >}} diff --git a/tests/docs/playwright/website/notebook-preview-sidebar/notebook.ipynb b/tests/docs/playwright/website/notebook-preview-sidebar/notebook.ipynb new file mode 100644 index 00000000000..4a8eaaca779 --- /dev/null +++ b/tests/docs/playwright/website/notebook-preview-sidebar/notebook.ipynb @@ -0,0 +1,38 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "answer", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "#| label: answer\n", + "1 + 1" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/integration/playwright/tests/notebook-preview-header.spec.ts b/tests/integration/playwright/tests/notebook-preview-header.spec.ts new file mode 100644 index 00000000000..393d204fdfb --- /dev/null +++ b/tests/integration/playwright/tests/notebook-preview-header.spec.ts @@ -0,0 +1,85 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +// True when the element at the center of `locator` belongs to it, i.e. no +// other element (such as a fixed navbar) is painted on top of it. +async function isTopmostAtCenter(locator: Locator): Promise { + return await locator.evaluate((el) => { + const box = el.getBoundingClientRect(); + const hit = document.elementFromPoint( + box.left + box.width / 2, + box.top + box.height / 2, + ); + return hit !== null && el.contains(hit); + }); +} + +function embedHeaderControls(page: Page) { + const embedHeader = page.locator("#quarto-embed-header"); + return { + "back link": embedHeader.locator(".quarto-back-link"), + title: embedHeader.locator("h6"), + "download link": embedHeader.getByRole("link", { name: "Download Notebook" }), + }; +} + +// The notebook preview page has its own fixed header bar. On a website with +// a site header (navbar, sidebar toggle, announcement), both bars sit at the +// top of the page, and the site header must not cover the preview controls +// (#14972). +const testCases = [ + { + name: "website navbar", + url: "./website/notebook-preview-navbar/_site/notebook-preview.html", + siteHeader: true, + }, + { + name: "website announcement only", + url: "./website/notebook-preview-announcement/_site/notebook-preview.html", + siteHeader: true, + }, + { + name: "website sidebar only, narrow viewport", + url: "./website/notebook-preview-sidebar/_site/notebook-preview.html", + siteHeader: true, + viewport: { width: 400, height: 800 }, + }, + { + name: "standalone document, no site header", + url: "./html/notebook-preview-standalone/notebook-preview.html", + siteHeader: false, + }, +]; + +test.describe("Notebook preview header", () => { + for (const { name, url, siteHeader, viewport } of testCases) { + test.describe(name, () => { + if (viewport) { + test.use({ viewport }); + } + + test.beforeEach(async ({ page }) => { + await page.goto(url); + }); + + test("controls are not covered by another element", async ({ page }) => { + await expect(page.locator("#quarto-header")).toHaveCount(siteHeader ? 1 : 0); + for (const [label, control] of Object.entries(embedHeaderControls(page))) { + await expect(control).toBeVisible(); + expect(await isTopmostAtCenter(control), `${label} is covered by another element`).toBe(true); + } + }); + + test("first notebook cell starts below the header", async ({ page }) => { + const header = page.locator("#quarto-embed-header"); + // div.cell matches every notebook cell; the first is the top of the content + const cell = page.locator("div.cell").first(); + await expect(cell).toBeVisible(); + await expect.poll(async () => { + const headerBox = await header.boundingBox(); + const cellBox = await cell.boundingBox(); + return cellBox!.y - (headerBox!.y + headerBox!.height); + }).toBeGreaterThanOrEqual(0); + }); + }); + } +}); diff --git a/tests/integration/playwright/tests/website-notebook-preview-navbar.spec.ts b/tests/integration/playwright/tests/website-notebook-preview-navbar.spec.ts deleted file mode 100644 index c6e361ef8dd..00000000000 --- a/tests/integration/playwright/tests/website-notebook-preview-navbar.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { expect, test, type Locator } from "@playwright/test"; - -// True when the element at the center of `locator` belongs to it, i.e. no -// other element (such as a fixed navbar) is painted on top of it. -async function isTopmostAtCenter(locator: Locator): Promise { - return await locator.evaluate((el) => { - const box = el.getBoundingClientRect(); - const hit = document.elementFromPoint( - box.left + box.width / 2, - box.top + box.height / 2, - ); - return hit !== null && el.contains(hit); - }); -} - -// On a website with a navbar, the notebook preview page has two fixed-top -// headers: the site navbar and the notebook preview header. The preview -// header controls must stay visible and clickable (#14972). -test("notebook preview header is not hidden by the website navbar", async ({ page }) => { - await page.goto("./website/notebook-preview-navbar/_site/notebook-preview.html"); - - const navbar = page.locator("#quarto-header"); - const embedHeader = page.locator("#quarto-embed-header"); - await expect(navbar).toBeVisible(); - await expect(embedHeader).toBeVisible(); - - const backLink = embedHeader.locator(".quarto-back-link"); - const title = embedHeader.locator("h6"); - const download = embedHeader.getByRole("link", { name: "Download Notebook" }); - - const controls = { "back link": backLink, title, "download link": download }; - for (const [name, control] of Object.entries(controls)) { - await expect(control).toBeVisible(); - expect(await isTopmostAtCenter(control), `${name} is covered by another element`).toBe(true); - } -}); From 64107569d8e99fd26ce86f49327d5d2946b185e2 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 9 Oct 2026 15:51:42 +0200 Subject: [PATCH 4/5] Make the notebook preview header a navigation landmark The notebook preview bar holding the back link, notebook title and download link was a plain
, outside every landmark on pages without a site header, so screen reader users could not reach it through landmark navigation, and axe reported it under `region` (#14968). The bar is now a