Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .claude/rules/formats/sass-theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ paths:
- "src/format/reveal/format-reveal-theme*"
- "src/format/dashboard/format-dashboard-shared*"
- "src/resources/formats/**/*.scss"
- "src/resources/projects/**/*.scss"
---

# Sass Theming
Expand All @@ -22,3 +23,30 @@ background-color: var(--r-background-color, $body-bg);
Read `llm-docs/sass-theming-architecture.md` for full compilation pipeline details.

For the three-tier callout CSS architecture (Bootstrap, RevealJS, standalone HTML), see `llm-docs/callout-styling-html.md`.

## Focus indicators

Never author a focus ring. The browser's own ring adapts to the platform accent
color, to the user's accessibility settings, and to forced-colors mode, where
`box-shadow` is dropped entirely. An authored ring does none of that, and a
second convention next to the first is visible as an inconsistency when a user
tabs through a page.

So the only focus rules Quarto writes are ones that undo a vendor rule which
suppressed the browser ring:

```scss
// a bare .btn: Bootstrap's .btn:focus-visible sets outline: 0
.code-tools-button:focus-visible {
outline: revert;
}
```

A bare `<button>` with no `.btn` class needs no rule at all — nothing suppressed
its ring. Bootstrap's reboot only clears the ring for `:focus:not(:focus-visible)`,
which is the mouse-click case.

When a control has no visible focus indicator, look for the suppression and
delete or revert it, rather than adding a ring of your own. See
`_bootstrap-rules.scss` for the one `outline: revert` rule, and quarto-cli#14774
and quarto-cli#12118 for the two times this came up.
18 changes: 15 additions & 3 deletions src/resources/formats/html/bootstrap/_bootstrap-rules.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1061,9 +1061,21 @@ td code:not(.sourceCode) {
padding: 0.7rem;
}

// These buttons are bare .btn elements (no btn-* variant class), so
// Bootstrap's .btn:focus-visible leaves them with no focus indicator.
// Restore the browser's ring; unlike box-shadow it survives forced colors.
// Quarto's focus-indicator convention: never author a focus ring. The
// browser's own ring adapts to the platform accent color, to the user's
// accessibility settings, and to forced-colors mode; an authored ring
// cannot. So the only focus rules Quarto writes are ones that undo a
// vendor rule which suppressed that ring.
//
// This is the one such rule. These three buttons are bare .btn elements
// (no btn-* variant class), so Bootstrap's .btn:focus-visible sets
// outline: 0 and substitutes a box-shadow that only the variant classes
// define — leaving no indicator at all. `revert` restores the browser's.
//
// A bare <button> with no .btn class needs no rule: nothing suppressed
// its ring in the first place. Bootstrap's reboot only clears the ring
// for :focus:not(:focus-visible), which is the mouse-click case.
//
// See https://github.com/quarto-dev/quarto-cli/issues/14774
.quarto-btn-toggle:focus-visible,
.quarto-search-button:focus-visible,
Expand Down
8 changes: 0 additions & 8 deletions src/resources/projects/website/navigation/quarto-nav.scss
Original file line number Diff line number Diff line change
Expand Up @@ -409,14 +409,6 @@ $sidebar-section-bottom-margin: 0.2em;
padding-left: 0.5rem;
}

// A bare button gets no focus ring from Bootstrap's reboot, so state one
// explicitly rather than depend on the UA default (quarto-cli#14774).
.sidebar-item .sidebar-item-toggle:focus-visible,
.sidebar-item button.sidebar-item-text:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}

.sidebar-item .sidebar-item-toggle .bi {
// The dongle for opening and closing sections
font-size: 0.7rem;
Expand Down
3 changes: 3 additions & 0 deletions tests/docs/playwright/website/bare-btn-focus/_quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,8 @@ website:
contents:
- index.qmd
- about.qmd
- section: "Group"
contents:
- about.qmd

format: html
12 changes: 9 additions & 3 deletions tests/docs/playwright/website/bare-btn-focus/index.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
title: "Home"
---

Regression test for #14774: below 992px the secondary nav shows the
sidebar toggle and sidebar search buttons. Both are bare Bootstrap
`.btn` elements and must keep a visible keyboard focus indicator.
Regression tests for Quarto's focus-indicator convention.

Below 992px the secondary nav shows the sidebar toggle and sidebar search
buttons. Both are bare Bootstrap `.btn` elements and must keep a visible
keyboard focus indicator (#14774).

At full width the sidebar shows a collapsible "Group" section. Its toggle
is a bare `<button>` with no `.btn` class, and it takes the browser's own
focus ring with no Quarto rule involved (#14826).
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { expect, Locator, Page, test } from "@playwright/test";
import { getUrl } from "../src/utils";

// Regression tests for #14774. Quarto emits three buttons that carry the
// Bootstrap `btn` class with no `btn-*` variant class: the code tools
// button, the sidebar toggle, and the sidebar search button. Bootstrap's
// .btn:focus-visible removes the native focus ring (outline: 0) and
// substitutes a box-shadow that only the variant classes define, so these
// buttons took keyboard focus with no visible indicator. Quarto restores
// the browser's native ring with `outline: revert`.
// Every Quarto control must show a visible indicator on keyboard focus
// (WCAG 2.2 SC 2.4.7). Quarto's convention is to use the browser's own
// ring and never author one, so these tests assert only that an outline
// is drawn — not which outline. See `.claude/rules/formats/sass-theming.md`.
//
// Three buttons carry the Bootstrap `btn` class with no `btn-*` variant
// class: the code tools button, the sidebar toggle, and the sidebar search
// button. Bootstrap's .btn:focus-visible removes the native ring
// (outline: 0) and substitutes a box-shadow that only the variant classes
// define, so these took keyboard focus with no indicator at all. Quarto
// restores the browser's ring with `outline: revert` (#14774).
//
// The sidebar section toggles are bare `<button>` elements with no `btn`
// class, so nothing suppresses their ring and Quarto writes no rule for
// them (#14826).

// Move focus with real Tab presses so the button matches :focus-visible —
// the fix only applies to keyboard focus, and programmatic locator.focus()
Expand Down Expand Up @@ -74,3 +82,26 @@ test.describe("website secondary nav buttons", () => {
});
}
});

test.describe("website sidebar section toggle", () => {
// The left sidebar is display: none below the lg breakpoint (992px), so
// its section toggles are only focusable at full width.
test.use({ viewport: { width: 1400, height: 900 } });

test("shows a focus indicator on keyboard focus", async ({
page,
browserName,
}) => {
await page.goto(getUrl("website/bare-btn-focus/_site/index.html"), {
waitUntil: "load",
});

const button = page.locator(
"#quarto-sidebar button.sidebar-item-toggle",
).first();
await expect(button).toBeVisible();
expect(await tabUntilFocused(page, browserName, button)).toBe(true);

await expect(button).not.toHaveCSS("outline-style", "none");
});
});