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
48 changes: 48 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,50 @@ 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`.

## Keyboard focus ring (Bootstrap formats)

One rule in `_bootstrap-rules.scss` draws the focus ring for every focusable
element: `:focus-visible`. The `focus-ring` mixin in
`_bootstrap-mixins.scss` defines the ring. Do not add focus rules for one
control. A rule with a higher specificity replaces part of the ring on that
control.

- The global rule has specificity (0,1,0) on purpose, so that author rules
such as `a:focus-visible` (0,1,1) win. Do not raise it.
- Bootstrap removes the outline with (0,2,0) rules (`.btn:focus-visible`,
`.nav-link:focus-visible`, `.form-control:focus`, ...). A second rule lists
these classes and includes the ring again. MathJax removes the outline on
`mjx-speech` at runtime, so `:root mjx-speech:focus-visible` (0,2,1) puts
it back. Add a rule only if Bootstrap or a bundled library removes the
outline.
- The inner tone is `box-shadow: var(--quarto-focus-ring-shadow)`. The
property is set on `:root`. An author can remove the inner tone with
`--quarto-focus-ring-shadow: none`. Use the property, not
`$focus-ring-inner-shadow`, in any rule that adds to the ring.

- One exception is the search box. Its visible field is `.aa-Form`, which
holds a borderless `.aa-Input`. The form gets `@include focus-ring` when the
input has focus, and the input gets no ring.
- Another exception is pagination. Some Bootswatch themes (vapor, zephyr)
give `.page-link` a `box-shadow` with a higher specificity, so a
`(0,4,0)` rule includes the ring again.

- The ring follows W3C technique C40. The outer color is an `outline`
(`$focus-ring-outline-width`, 2px). The inner color is a `box-shadow` that
fills the `outline-offset` (`$focus-ring-outline-offset`, 2px). Both are
public `!default` variables in `_bootstrap-variables.scss`.
- On a dark page (`$body-bg` has more contrast with white than with black),
the derived tones swap: white goes on the `outline`. The test is per
stylesheet, not per `light:`/`dark:` key. If a user sets
`$focus-ring-outer-color` or `$focus-ring-inner-color`, nothing swaps.
- The outer color is the `outline` because Bootswatch themes often set
`box-shadow` on controls, but rarely `outline`. Forced-colors mode also
keeps the `outline`.
- `_bootstrap-variables.scss` sets Bootstrap's focus halos to `none`. Do not
turn them on again.
- If an element must not show the ring, set both `outline: none` and
`box-shadow: none`. An example is `#quarto-document-content`, which gets
focus from the skip link.
- If a scroll container clips the ring, add `$focus-ring-extent` of padding
on that side. The TOC sidebars and the collapsed navbar menu are examples.
1 change: 1 addition & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ All changes included in 1.11:
- ([#14615](https://github.com/quarto-dev/quarto-cli/issues/14615)): Fix invalid `role="menu"` on the website navbar's collapse toggle button, flagged by axe-core (`aria-allowed-role`) and WAVE (`aria-role-mismatch`).
- ([#12116](https://github.com/quarto-dev/quarto-cli/issues/12116), [#4935](https://github.com/quarto-dev/quarto-cli/issues/4935)): Website sidebar section toggles are now `<button>` elements, so they are keyboard accessible and properly announced.
- ([#14774](https://github.com/quarto-dev/quarto-cli/issues/14774)): Fix missing keyboard focus indicator on the code tools button and on a website's sidebar toggle and sidebar search buttons.
- ([#15139](https://github.com/quarto-dev/quarto-cli/issues/15139)): HTML output now uses one high-contrast keyboard focus ring for all focusable elements, which stays visible in forced-colors mode. Set its colors with the new `$focus-ring-outer-color` and `$focus-ring-inner-color` Sass variables, and its size with `$focus-ring-outline-width` and `$focus-ring-outline-offset`. Quarto no longer uses Bootstrap's `$focus-ring-color`, `$focus-ring-box-shadow`, `$btn-focus-width`, and `$btn-focus-box-shadow`.
- ([#14376](https://github.com/quarto-dev/quarto-cli/issues/14376)): Add a distinct, localizable `aria-label` to each navigation landmark of websites and books: the navbar (`Site`), the sidebar (`Section`, or `Site` when there is no navbar), the narrow-viewport toolbar (`Toolbar`), the previous/next page navigation (`Page`), and the breadcrumbs (`Breadcrumbs`) (previously hardcoded English `breadcrumb`). The new `navigation-*-label` language keys can be overridden with `language:` metadata.
- ([#14376](https://github.com/quarto-dev/quarto-cli/issues/14376)): Translate the new `navigation-*-label` keys in all 33 built-in language files. The values are drawn from existing human-translated interface strings (LibreOffice, GNOME, Wikidata) and each one carries a comment naming its source; values that were adapted rather than used verbatim are marked `needs review`.
- ([#14376](https://github.com/quarto-dev/quarto-cli/issues/14376)): Label the table of contents `<nav>` with its localized title (`aria-labelledby`), in `html` and `revealjs` output, so assistive technology can tell it apart from other navigation landmarks.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@
// A: 3:1
// AA: 4.5:1
// AAA: 7:1
$goalContrastRatio: 3;
@if $level == "AA" {
// A number: that ratio (e.g. 9 for the two tones of the focus ring)
$goalContrastRatio: $level;
@if $level == "A" {
$goalContrastRatio: 3;
} @else if $level == "AA" {
$goalContrastRatio: 4.5;
} @else {
} @else if $level == "AAA" {
$goalContrastRatio: 7;
}

Expand Down
39 changes: 39 additions & 0 deletions src/resources/formats/html/bootstrap/_bootstrap-mixins.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,42 @@
// Keyboard focus ring (WCAG 2.4.7, 1.4.11), after W3C technique C40: two
// tones at least 9:1 apart, so one of them reaches 3:1 against any solid
// background. The outer tone is the outline, which theme rules rarely
// override and forced-colors mode keeps; the inner tone is a box-shadow that
// fills the outline offset. Resolved here, after all defaults, so that
// $primary-text-emphasis exists and every rules layer can use the result.
$focus-ring-inner: if($focus-ring-inner-color, $focus-ring-inner-color, $white);
$focus-ring-outer: $focus-ring-outer-color;
@if not $focus-ring-outer {
// Shift the derived color only as far as needed to reach 9:1
$focus-ring-outer: theme-contrast($primary-text-emphasis, $focus-ring-inner, 9);
}
// On a dark page the tones swap, so the outline (the part that touches the
// page) is the tone that contrasts with the page background. Colors set
// explicitly are never swapped.
$focus-ring-page-is-dark: not $focus-ring-outer-color and
not $focus-ring-inner-color and
quarto-contrast($body-bg, $white) > quarto-contrast($body-bg, $black);
$focus-ring-outline-color: if(
$focus-ring-page-is-dark,
$focus-ring-inner,
$focus-ring-outer
);
$focus-ring-shadow-color: if(
$focus-ring-page-is-dark,
$focus-ring-outer,
$focus-ring-inner
);
// how far the ring reaches outside the element's border box
$focus-ring-extent: $focus-ring-outline-width + $focus-ring-outline-offset;
$focus-ring-inner-shadow: 0 0 0 $focus-ring-outline-offset $focus-ring-shadow-color;

@mixin focus-ring {
outline: $focus-ring-outline-width solid $focus-ring-outline-color;
outline-offset: $focus-ring-outline-offset;
// --quarto-focus-ring-shadow is set on :root in _bootstrap-rules.scss
box-shadow: var(--quarto-focus-ring-shadow);
}

@mixin body-secondary {
@if variable-exists(body-secondary) {
color: $body-secondary;
Expand Down
76 changes: 65 additions & 11 deletions src/resources/formats/html/bootstrap/_bootstrap-rules.scss
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,55 @@ ol > li:not(:has(> p)) > ol > li:has(> p) {
margin-top: 1rem;
}

// The inner tone is a custom property, so an author can remove it with
// one line (`--quarto-focus-ring-shadow: none`) without matching the
// specificity of the rules below.
:root {
--quarto-focus-ring-shadow: #{$focus-ring-inner-shadow};
}

// One keyboard focus ring for every focusable element (see the focus-ring
// mixin). The rule has (0,1,0) specificity on purpose, so that author rules
// such as `a:focus-visible` (0,1,1) win over it.
:focus-visible {
@include focus-ring;
}

// Bootstrap removes the outline on these controls with (0,2,0) rules
// (`.btn:focus-visible { outline: 0 }`, `.form-control:focus`, ...), which
// beat the (0,1,0) rule above. These rules tie with Bootstrap and win on
// order; author rules on the same classes come later and still win.
// Don't add rules for other controls: only outline removals by Bootstrap
// or a bundled library belong here.
.btn:focus-visible,
.btn-close:focus-visible,
.nav-link:focus-visible,
.navbar-toggler:focus-visible,
.accordion-button:focus-visible,
.carousel-control-prev:focus-visible,
.carousel-control-next:focus-visible,
.form-control:focus-visible,
.form-select:focus-visible,
.form-check-input:focus-visible,
.form-range:focus-visible {
@include focus-ring;
}

// MathJax injects `mjx-speech:focus { outline: none }` (0,1,1) at runtime,
// after this stylesheet, so a tie loses on order. (0,2,1) puts the ring back
// on the focusable speech element of each equation.
:root mjx-speech:focus-visible {
@include focus-ring;
}

// Exception: some Bootswatch themes give pagination links their own
// box-shadow (vapor's glow on the active link, zephyr's drop shadow), which
// replaces the ring's inner tone. On vapor's dark page that leaves no
// visible ring. (0,4,0) beats those theme rules.
.pagination .page-item .page-link:focus-visible {
@include focus-ring;
}

// skip-to-content link (WCAG 2.4.1 Bypass Blocks); visually hidden via
// Bootstrap's .visually-hidden-focusable helper until it receives focus
#quarto-skip-link:focus {
Expand All @@ -36,14 +85,14 @@ ol > li:not(:has(> p)) > ol > li:has(> p) {
text-decoration: none;
border: 1px solid $border-color;
border-radius: $border-radius;
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.25);
}

// the skip-link target receives programmatic focus (tabindex="-1"); don't
// draw a focus ring around the entire content area
#quarto-document-content:focus,
#quarto-content:focus {
outline: none;
box-shadow: none;
}

// Grid layout
Expand Down Expand Up @@ -1061,16 +1110,6 @@ 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.
// See https://github.com/quarto-dev/quarto-cli/issues/14774
.quarto-btn-toggle:focus-visible,
.quarto-search-button:focus-visible,
.code-tools-button:focus-visible {
outline: revert;
}

.code-tools-button {
font-size: 1rem;
padding: 0.15rem 0.15rem;
Expand Down Expand Up @@ -1120,6 +1159,21 @@ td code:not(.sourceCode) {
padding-top: 1em;
}

// TOC links run to the edge of these scroll containers, which would clip
// their focus ring
.sidebar.toc-left,
.sidebar.margin-sidebar {
padding-right: $focus-ring-extent;
}

// The collapsed navbar menu scrolls, and its links run to its edges. The
// padding makes room for the focus ring; the negative margin keeps the links
// where they were.
.navbar-nav-scroll {
padding: $focus-ring-extent;
margin: -$focus-ring-extent;
}

.sidebar.quarto-banner-title-block-sidebar {
& > * {
padding-top: 1.65em;
Expand Down
19 changes: 19 additions & 0 deletions src/resources/formats/html/bootstrap/_bootstrap-variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,25 @@ $zindex-pagelayout: 998;
// above the fixed/headroom navbar ($zindex-fixed: 1030)
$zindex-skip-link: 1070 !default;

// Keyboard focus ring (see the `:focus-visible` rule in _bootstrap-rules.scss).
// When unset, the outer color comes from $primary-text-emphasis and the
// inner color is $white; both resolve in the rules layer, after Bootstrap
// has defined $primary-text-emphasis. An author who sets the outer color
// is responsible for its 9:1 contrast against the inner color.
$focus-ring-outer-color: null !default;
$focus-ring-inner-color: null !default;
// Width of the outer tone, and of the gap that the inner tone fills. Keep
// the width at 2px or more for WCAG 2.4.13 (Focus Appearance).
$focus-ring-outline-width: 2px !default;
$focus-ring-outline-offset: 2px !default;

// Turn off Bootstrap's translucent box-shadow focus halos; the focus ring
// replaces them.
$focus-ring-box-shadow: none !default;
$form-select-focus-box-shadow: none !default;
$form-range-thumb-focus-box-shadow: none !default;
$btn-focus-width: 0 !default;

$popover-bg: if(variable-exists(body-bg), $body-bg, null) !default;
$input-bg: if(variable-exists(body-bg), $body-bg, null) !default;

Expand Down
11 changes: 3 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 Expand Up @@ -454,6 +446,9 @@ $sidebar-section-bottom-margin: 0.2em;
@include media-breakpoint-down(lg) {
.quarto-secondary-nav {
display: block;
// Paint above the navbar (position: relative), so that the focus ring of
// the buttons at the top edge is not covered
position: relative;

button.quarto-search-button {
padding-right: 0em;
Expand Down
29 changes: 12 additions & 17 deletions src/resources/projects/website/search/quarto-search.scss
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,6 @@ $quarto-search-collapse-icon-size: 26px !default;
.aa-Form {
background-color: inherit;
border: none;
&:focus-within {
box-shadow: none;
outline: none;
}
.aa-InputWrapper {
display: none;
&:focus-within {
Expand Down Expand Up @@ -114,6 +110,17 @@ $quarto-search-collapse-icon-size: 26px !default;
}
}

// The search box is a form that holds an icon, the input, and buttons. Draw
// the focus ring on the form, which is the visible field, instead of on the
// borderless input inside it.
.aa-Form:has(.aa-Input:focus-visible) {
@include focus-ring;
}
.aa-Form .aa-Input:focus-visible {
outline: none;
box-shadow: none;
}

.aa-Autocomplete,
.aa-DetachedFormContainer {
// Search box
Expand All @@ -128,10 +135,6 @@ $quarto-search-collapse-icon-size: 26px !default;
margin: 0;
position: relative;
width: 100%;
&:focus-within {
box-shadow: rgba($primary, 0.6) 0 0 0 1px;
outline: currentColor none medium;
}
.aa-InputWrapperPrefix {
align-items: center;
display: flex;
Expand Down Expand Up @@ -183,12 +186,6 @@ $quarto-search-collapse-icon-size: 26px !default;
color: $input-color;
opacity: 0.8;
}
// Focus is set and styled on the parent, it isn't necessary here
&:focus {
border-color: none;
box-shadow: none;
outline: none;
}
// Remove native appearence
&::-webkit-search-decoration,
&::-webkit-search-cancel-button,
Expand Down Expand Up @@ -581,10 +578,8 @@ $quarto-search-collapse-icon-size: 26px !default;
cursor: pointer;
margin: 0 0 0 0.5em;
padding: 0 0.5em;
&:hover,
&:focus {
&:hover {
box-shadow: rgba($primary, 0.6) 0 0 0 1px;
outline: currentColor none medium;
}
}
}
Expand Down
3 changes: 1 addition & 2 deletions tests/docs/playwright/html/code-tools-focus-indicator.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ code-tools: true

Regression test for #14774: the Code button is a bare Bootstrap `.btn`
(no `btn-*` variant class), so Bootstrap's `outline: 0` left it with no
keyboard focus indicator. Quarto restores the native ring with
`outline: revert`.
keyboard focus indicator. It now gets Quarto's global focus ring.

```r
1 + 1
Expand Down
15 changes: 15 additions & 0 deletions tests/docs/playwright/html/focus-ring-author-override.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
title: Focus ring author override
format:
html:
theme: [default, focus-ring-author-override.scss]
---

Fixture for author focus styles. The theme sets its own outline on
`a:focus-visible` and removes the Quarto inner tone with
`--quarto-focus-ring-shadow: none`. The author rule must win over the
global focus ring.

An [inline link](#target) in the body.

## Target {#target}
8 changes: 8 additions & 0 deletions tests/docs/playwright/html/focus-ring-author-override.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*-- scss:rules --*/
// A typical author focus rule: an element selector with (0,1,1)
// specificity that sets only the outline, plus one line to remove the
// Quarto inner tone.
a:focus-visible {
outline: 3px solid #b5361f;
--quarto-focus-ring-shadow: none;
}
Loading
Loading