diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a3b2eed..3bec945ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Code blocks render their source at 16px with a 20px line, up from the 13px + / 17px they inherited, against the 18px prose. The 13px was JupyterLab's + default, not a theme decision: an element rule in `thebe-core.css` applied it + to every `
`, outranking the `text-sm` on the block's wrapper. The value
+ now goes on the same JupyterLab variables, scoped to the content, so the
+ source block, the output Jupyter renders itself under live compute and the
+ spacing thebe derives from them all follow it, and code does not change size
+ when a reader switches live compute on. Stored cell outputs take the same
+ 16px / 20px, from 14px / 20px. The frame is unchanged: executable cells keep
+ their left rule, plain fences and `{code-block}` directives keep upstream's
+ fill and shadow ([#177](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/177)) ([#184](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/184)).
+- Admonition, exercise, proof and dropdown bodies render at 16px, a step below
+ the 18px prose, as the Sphinx lecture sites' `.admonition { font-size: .9rem
+ }` does; until now nothing sized a callout and every family inherited the
+ full 18px. Root-relative, so a note nested in an exercise stays at 16px
+ rather than stepping down twice, and flat at every width where the Sphinx
+ rule falls to 14.4px below 992px. Callout titles take the container's size;
+ their weight is unchanged ([#178](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/178)) ([#185](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/185)).
+- Content links keep their resting underline, now one solid line on plain
+ links and cross-references alike. Upstream dotted the cross-reference
+ underline to mark a hover-preview card, and which entries in a list were
+ dotted followed which target pages carried a thumbnail image. The underline
+ stays at rest where the Sphinx sites show it on hover only, because colour
+ cannot mark a link on these grounds: 2.02:1 against the body text in light
+ mode and 1.49:1 in dark, against the 3:1 WCAG 1.4.1 asks of a colour-only
+ cue. `:visited` is still unstyled; its dark value waits on the dark palette
+ decision ([#176](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/176)).
+
+### Fixed
+- The site footer's text and links clear WCAG AA in both modes once its 70%
+ opacity is composited. Light-mode text at `#444` landed at 4.17:1 on white
+ and links at `#0072bc` at 3.01:1; dark-mode text at `#a6a6a6` landed at
+ 3.92:1 on the `#222` panel. They are now `#3b3b3b` (4.54:1), `#00436f`
+ (4.55:1) and `#b5b5b5` (4.55:1); the fade, the badge, the 5px rule and the
+ dark link colour are unchanged. The default footer's "A theme by QuantEcon"
+ link puts link text on every page that declares no footer part, so the link
+ figure applies to every site ([#201](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/201)).
+
## [3.0.0] - 2026-09-12
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 19c6f88e0..430a48f1f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -240,13 +240,20 @@ problem. The existing divergences are the model:
- the dark footer link is blue-300, because the inherited `#0072bc` composites
to 2.2:1 at the footer's opacity;
- content is a flat 18px off a `rem` root, rather than the Sphinx builds'
- px root, which is a WCAG 1.4.4 problem.
+ px root, which is a WCAG 1.4.4 problem;
+- content links keep a resting underline, where the lecture builds underline
+ on hover only, because the link colour is 2.02:1 (light) and 1.49:1 (dark)
+ against the body text and colour alone cannot mark a link (WCAG 1.4.1);
+- the footer's text, and its light-mode link, are darker than the lecture
+ builds' (`#3b3b3b` and `#00436f`, `#b5b5b5` in dark mode), because the
+ inherited values composite to 4.17:1, 3.01:1 and 3.92:1 at the footer's
+ 70% opacity.
Changing an inherited value for any *other* reason is a two-theme decision, not
a fix this repo makes alone — it would reintroduce a difference against the live
-sites. Open an issue instead (see #172 for the code palette, #201 for the
-footer). When you compute a contrast ratio, composite any `opacity` on the
-element first: the declared colour is not what the reader sees.
+sites. Open an issue instead (see #172 for the code palette). When you compute
+a contrast ratio, composite any `opacity` on the element first: the declared
+colour is not what the reader sees.
## CI: visual tests and PR previews
diff --git a/app/components/SiteFooter.tsx b/app/components/SiteFooter.tsx
index 6a4cc752d..a8eac52b7 100644
--- a/app/components/SiteFooter.tsx
+++ b/app/components/SiteFooter.tsx
@@ -99,7 +99,7 @@ export function SiteFooter({ content, className }: { content?: GenericParent; cl
return (
diff --git a/docs/code-highlighting.md b/docs/code-highlighting.md
index 5e42f0821..297af755c 100644
--- a/docs/code-highlighting.md
+++ b/docs/code-highlighting.md
@@ -1,5 +1,8 @@
# Code highlighting
+The size and frame of code blocks are described under
+[typography](typography.md#code-blocks); this page covers the token colours.
+
Code blocks use QuantEcon's own token palette, the Sphinx lecture sites'
default `qetheme_code_style`, in light and dark mode. MyST tokenises with
highlight.js rather than Pygments, so the palette is mapped scope by scope onto
diff --git a/docs/index.md b/docs/index.md
index f5162e5d2..5efdca993 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -31,6 +31,7 @@ Every theme option lives under `site.options` and is listed in
| [launch](launch.md) | notebook launch buttons (Colab) and the notebook repo conventions |
| [notebooks](notebooks.md) | notebook output rendering, live compute, collapsible stderr |
| [git-metadata](git-metadata.md) | "Last changed" and the inline changelog |
+| [typography](typography.md) | content size, code blocks, callouts and links |
| [code-highlighting](code-highlighting.md) | the code token palette |
| [text-color-schemes](text-color-schemes.md) | emphasis, strong and definition colours |
| [dark-mode](dark-mode.md) | the dark theme and its tokens |
diff --git a/docs/layout.md b/docs/layout.md
index 5017d973b..5e87810be 100644
--- a/docs/layout.md
+++ b/docs/layout.md
@@ -53,6 +53,11 @@ licensed under a Creative Commons Attribution-ShareAlike 4.0 International.",
and "A theme by QuantEcon". This is the Sphinx sites' footer block, which every
site carried without configuring anything.
+The footer renders at 14.4px, faded to 70% as the Sphinx footer is. Its text
+and link colours are darker than the Sphinx values so that what the reader
+sees, the colour composited at that opacity, clears WCAG AA on the white page
+and on the `#222` dark panel.
+
`site.parts.footer` names a Markdown file that **replaces** the default
outright — licence notice and theme credit included. Set it only to state
different terms:
diff --git a/docs/typography.md b/docs/typography.md
new file mode 100644
index 000000000..80543f9ef
--- /dev/null
+++ b/docs/typography.md
@@ -0,0 +1,40 @@
+# Content typography
+
+Content renders at a flat 18px, set on the article rather than on the root:
+the root stays at the browser's 16px, so a reader's own font-size preference
+still applies and the toolbar's font-size control scales everything with it.
+There is no breakpoint step, so nothing changes size under the reader as the
+window narrows. The values below are measurements of the Sphinx lecture sites,
+rounded to the 16px root where the Sphinx build sizes off its 18px one.
+
+| Surface | Size | Notes |
+| --- | --- | --- |
+| prose | 18px | flat at every width |
+| inline literals | 16px | `#af5f5f`; see [text colour schemes](text-color-schemes.md) |
+| code blocks | 16px / 20px line | source and stored outputs alike; unchanged when live compute starts |
+| callout bodies and titles | 16px | admonitions, exercises, proofs, dropdowns; nested callouts do not step down again |
+| footer | 14.4px | faded to 70%; see [layout](layout.md#footer) |
+
+## Code blocks
+
+Source sits at 16px with a 20px line against the 18px prose, the Sphinx sites'
+0.9 ratio. The frame is upstream's: an executable cell carries a grey frame and
+a blue left rule, a plain fence or `{code-block}` directive a light fill and a
+shadow, so a reader can tell a run cell from a shown snippet. The token palette
+is described under [code highlighting](code-highlighting.md).
+
+## Callouts
+
+Admonition, exercise, proof and dropdown bodies step down to 16px, so an aside
+reads as subordinate to the prose it interrupts; their titles take the same
+size and keep upstream's medium weight. The step is off the root, so a note
+inside an exercise renders at the same 16px as the exercise.
+
+## Links
+
+Content links are QuantEcon blue (`#0072bc`, `#004979` on hover) with a solid
+underline at rest, on plain links and cross-references alike. The Sphinx sites
+underline on hover only; this theme keeps the underline because the link
+colour is too close to the body text (2.02:1 in light mode, 1.49:1 in dark) for
+colour alone to mark a link. Visited links are not coloured. In dark mode links
+are white, weight 600; the dark palette is an open design question (#237).
diff --git a/styles/quantecon.css b/styles/quantecon.css
index b49ebe873..01a1c209a 100644
--- a/styles/quantecon.css
+++ b/styles/quantecon.css
@@ -384,30 +384,51 @@
myst-to-react colours content anchors through two classes: `.link` (plain
links) and `.hover-link` (cross-references, citations, footnotes). Upstream
defines both in `@myst-theme/styles` as blue-700/blue-400 with blue
- decoration colours. These rules re-colour the classes to QuantEcon blue
- (the `--qe-link-*` tokens) -- colour only. The underline itself
- (always-on, dotted for `.hover-link`) is left as upstream ships it.
+ decoration colours, underlined at rest, and dots the `.hover-link`
+ underline. These rules re-colour the classes to QuantEcon blue (the
+ `--qe-link-*` tokens) and draw one solid resting underline on both.
+
+ The underline stays at rest, where the lecture builds show it on hover
+ only, because colour alone cannot mark a link here. A link colour has to
+ clear 4.5:1 against the ground to read as text and 3:1 against the body
+ text to be findable without another cue (WCAG 1.4.1, technique G183); on
+ white against #44403c, and on #222 against #d6d3d1, no colour does both,
+ and the ones that ship are 2.02:1 and 1.49:1 against the body text. The
+ underline is the non-colour cue, so it cannot be hover-only.
+
+ Solid on `.hover-link` as well. Upstream's dots mark a link that opens a
+ hover-preview card, and myst-to-react decides that per target page by
+ whether it has a description or a thumbnail. The lecture pages carry no
+ descriptions, so which entries in one list were dotted followed which
+ lectures happened to have a thumbnail image -- a difference the reader
+ cannot account for, and one that flips when a thumbnail is added.
Specificity is matched to the upstream rules this overrides, with source
order breaking each tie (this file is @import-ed after `@myst-theme/styles`,
see styles/app.css):
- `.link` here (0,1,0) ties upstream `.link` -- wins on order.
+ - `.hover-link` here (0,1,0) ties upstream's dotted `.hover-link` -- wins
+ on order.
- `.link:hover` (0,2,0) ties upstream's compiled `hover:` utility.
- `.dark :is(...)` (0,2,0) ties upstream's `dark:` -> `.link:is(.dark *)`.
- `.dark :is(...):hover` (0,3,0) ties `.link:hover:is(.dark *)`.
`text-decoration-color: currentColor` replaces upstream's separate blue
decoration utilities in one declaration per rule -- the underline always
- draws in the link's own colour.
+ draws in the link's own colour. Thickness and offset stay upstream's
+ (0.1em and 0.15em).
- Two deliberate divergences from the lecture builds, so they are not "fixed"
- later: they underline on hover only, where these underline always, and they
- colour `:visited` links #004979, which these leave alone. */
+ One deliberate divergence from the lecture builds remains, so it is not
+ "fixed" later: they colour `:visited` links #004979, which these leave
+ alone. A visited colour is a light and a dark value together, and the dark
+ half waits on the dark palette decision (#237). */
@layer components {
.link,
.hover-link {
color: var(--qe-link-color, #0072bc);
+ text-decoration-line: underline;
+ text-decoration-style: solid;
text-decoration-color: currentColor;
}
@@ -427,6 +448,78 @@
}
}
+/* ---------------------------------------------------------------------------
+ CODE BLOCKS
+
+ Source at 1rem (16px) with a 1.25 line-height (20px), against the 18px
+ prose. The lecture builds render code at .9rem of their 18px root, 16.2px,
+ with `line-height: 125%`; 1rem is the call the inline literals above make
+ for the same Sphinx quantity, so the whole code column sits on one size.
+ Upstream leaves the `` at 13px / 17px, and that is JupyterLab's
+ default rather than a myst-theme decision: `thebe-core.css` (loaded after
+ the Tailwind bundle, see app/root.tsx) declares `--jp-code-font-size: 13px`
+ on `:root` and applies it through an element rule, `tt, code, kbd, samp,
+ pre`, at (0,0,1) -- on the `` itself, so it outranks anything the
+ `text-sm` on the `.myst-code` wrapper could pass down by inheritance.
+
+ The value goes on the variables, not on a `pre` rule, and on `.article`
+ rather than `:root`: a custom property set on a descendant beats the one
+ thebe sets on `:root` whatever the stylesheet order, and every consumer of
+ the variable inside the content follows it -- the source block, the output
+ Jupyter renders itself once a reader starts live compute (the same
+ variable sizes it), and the two `calc()`s thebe derives from it -- so code
+ does not change size when the live-compute toggle is switched on.
+
+ The stored outputs are the one surface the variable does not reach:
+ `@myst-theme/jupyter` renders them in a `div` carrying `font-mono text-sm`
+ (14px / 20px), so they take their own rule, at (0,2,0) to beat the
+ utilities. The families are left as they come -- thebe's stack on the
+ source, Tailwind's on the outputs. */
+@layer components {
+ .article {
+ --jp-code-font-size: 1rem; /* 16px */
+ --jp-code-line-height: 1.25; /* 20px */
+ }
+
+ .article .myst-jp-safe-output-text {
+ font-size: 1rem; /* 16px */
+ line-height: 1.25; /* 20px */
+ }
+}
+
+/* ---------------------------------------------------------------------------
+ CALLOUTS
+
+ Admonition, exercise, proof and dropdown bodies at 1rem (16px), a step
+ below the 18px prose they interrupt. The lecture builds size every
+ `.admonition` at .9rem of their 18px root, 16.2px, so an aside reads as
+ subordinate to the copy around it by size; without this rule nothing here
+ sizes a callout at all, and every family inherits `.article`'s 18px.
+
+ Root-relative on purpose, like the inline literals: `em` off `.article`
+ would compound, and a `{note}` inside an `{exercise}` -- common in the
+ lectures -- would land at 0.9 x 0.9 x 18px. Flat, per the UNITS note: the
+ Sphinx rule falls to 14.4px below 992px, on exercise and solution text,
+ and this does not.
+
+ The headers take the container's size, as the Sphinx titles do (its
+ stylesheet sets no size on `.admonition-title`). Exercise and proof
+ headers already inherit -- myst-to-react gives them `text-md`, a class
+ Tailwind does not define -- but the admonition and dropdown headers carry
+ `text-lg`, a root-relative 18px, so they need `1em` here, at (0,2,0) to
+ beat the utility. Weight (upstream's `font-medium`) is left as it is.
+ Margin and sidebar asides (`.myst-aside`) are not callouts and stay at
+ upstream's `text-sm`. */
+@layer components {
+ .article :is(.myst-admonition, .myst-exercise, .myst-proof, .myst-dropdown) {
+ font-size: 1rem; /* 16px */
+ }
+
+ .article :is(.myst-admonition-header, .myst-dropdown-header) {
+ font-size: 1em; /* the container's 16px, not `text-lg`'s 18px */
+ }
+}
+
/* ---------------------------------------------------------------------------
SITE FOOTER
@@ -436,10 +529,12 @@
page chrome.
Selectors are `.article .qe-site-footer`, (0,2,0), on purpose. At (0,1,0) a
- bare `.qe-site-footer` loses to the utilities already on the element
- (`text-qetext-light`, `text-opacity-80`, and `.link` on the badge anchor),
- because utilities are emitted after components. The extra `.article` wins on
- specificity instead of relying on source order.
+ bare `.qe-site-footer` would lose to utilities of the same weight emitted
+ later -- the grid utilities on the element, and `.link` on the badge anchor
+ of a site's own footer.md -- because utilities are emitted after
+ components. The extra `.article` wins on specificity instead of relying on
+ source order. The element carries no colour utility: both modes' colours
+ are set here, so the composited contrast noted on each is the whole story.
--------------------------------------------------------------------------- */
@layer components {
.article .qe-site-footer {
@@ -449,10 +544,11 @@
root. */
font-size: 0.9rem; /* 14.4px */
line-height: 1.5; /* 21.6px */
- /* This overrides the element's `text-qetext-light text-opacity-80`
- utilities outright, so that 80% no longer applies; the `opacity: 0.7`
- below is what fades the footer. */
- color: #444;
+ /* #3b3b3b, not the lecture builds' #444: what the reader sees is the
+ colour composited at the 70% opacity below, and #444 lands at #7c7c7c
+ on white, 4.17:1 -- under the 4.5:1 floor for 14.4px text. #3b3b3b
+ composites to #767676, 4.54:1. */
+ color: #3b3b3b;
/* The 36px gap above the rule is padding, not margin: `styles/app.css` sets
`margin-top: 0 !important` on every `.simple-center-grid > *`, and this
footer is a direct child of that grid. */
@@ -462,19 +558,30 @@
extend the document. */
padding-bottom: 2.5em; /* 36px */
/* 0.7 is the lecture footer's own opacity, on the whole element, so the
- rule, badge and text fade together: the colours above are declared at
- full strength and render softer here (the #0072bc rule samples as about
- rgb(75 155 207) on white). Declaring the faded colours directly would
- not reproduce it -- the badge image fades too. */
+ rule, badge and text fade together: the colours here are declared at
+ full strength and render softer (the #0072bc rule samples as about
+ rgb(77 156 208) on white). Declaring the faded colours directly would
+ not reproduce it -- the badge fades too. */
opacity: 0.7;
}
+ /* Dark mode. #a6a6a6, the toolbar's muted text, composites to #7e7e7e on
+ the #222 content panel at the footer's opacity, 3.92:1; #b5b5b5 lands at
+ #898989, 4.55:1. */
+ .dark .article .qe-site-footer {
+ color: #b5b5b5;
+ }
+
.article .qe-site-footer p {
margin: 1em 0; /* 14.4px */
}
.article .qe-site-footer a {
- color: #0072bc; /* rgb(0 114 188), the same blue as the rule above */
+ /* #0072bc's hue and saturation, darkened until the composited link clears
+ AA: at the footer's opacity #0072bc itself lands at #4d9cd0 on white,
+ 3.01:1, and #00436f at #4d7b9a, 4.55:1. The 5px rule above keeps
+ #0072bc; a non-text rule has no 4.5:1 floor. */
+ color: #00436f;
text-decoration: none;
}
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/features.png b/tests/visual/__snapshots__/desktop-chrome-linux/features.png
index 6ae0faed4..20f1d8ba7 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/features.png and b/tests/visual/__snapshots__/desktop-chrome-linux/features.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/history-open.png b/tests/visual/__snapshots__/desktop-chrome-linux/history-open.png
index 88ce83671..4b37f5893 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/history-open.png and b/tests/visual/__snapshots__/desktop-chrome-linux/history-open.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/intro.png b/tests/visual/__snapshots__/desktop-chrome-linux/intro.png
index e712c3bcc..0887e9ca2 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/intro.png and b/tests/visual/__snapshots__/desktop-chrome-linux/intro.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/lists.png b/tests/visual/__snapshots__/desktop-chrome-linux/lists.png
index ddeaaf8bf..b3fcbf4fb 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/lists.png and b/tests/visual/__snapshots__/desktop-chrome-linux/lists.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/notebook.png b/tests/visual/__snapshots__/desktop-chrome-linux/notebook.png
index 638da315f..ec9bee3cc 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/notebook.png and b/tests/visual/__snapshots__/desktop-chrome-linux/notebook.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/rtl.png b/tests/visual/__snapshots__/desktop-chrome-linux/rtl.png
index 8b10bbe6e..01897e5a2 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/rtl.png and b/tests/visual/__snapshots__/desktop-chrome-linux/rtl.png differ
diff --git a/tests/visual/__snapshots__/desktop-chrome-linux/sidebar-open.png b/tests/visual/__snapshots__/desktop-chrome-linux/sidebar-open.png
index 93fd5c8e0..9e7990a70 100644
Binary files a/tests/visual/__snapshots__/desktop-chrome-linux/sidebar-open.png and b/tests/visual/__snapshots__/desktop-chrome-linux/sidebar-open.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/features.png b/tests/visual/__snapshots__/mobile-chrome-linux/features.png
index eb4d933f9..b94f7f9ce 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/features.png and b/tests/visual/__snapshots__/mobile-chrome-linux/features.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/history-open.png b/tests/visual/__snapshots__/mobile-chrome-linux/history-open.png
index d16de8e61..028ce4045 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/history-open.png and b/tests/visual/__snapshots__/mobile-chrome-linux/history-open.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/intro.png b/tests/visual/__snapshots__/mobile-chrome-linux/intro.png
index 868036a38..34c3802ae 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/intro.png and b/tests/visual/__snapshots__/mobile-chrome-linux/intro.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/lists.png b/tests/visual/__snapshots__/mobile-chrome-linux/lists.png
index 6c878cdd7..8a596596d 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/lists.png and b/tests/visual/__snapshots__/mobile-chrome-linux/lists.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/notebook.png b/tests/visual/__snapshots__/mobile-chrome-linux/notebook.png
index 6336464e7..77951809b 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/notebook.png and b/tests/visual/__snapshots__/mobile-chrome-linux/notebook.png differ
diff --git a/tests/visual/__snapshots__/mobile-chrome-linux/rtl.png b/tests/visual/__snapshots__/mobile-chrome-linux/rtl.png
index 8445a8e19..0db6e24d7 100644
Binary files a/tests/visual/__snapshots__/mobile-chrome-linux/rtl.png and b/tests/visual/__snapshots__/mobile-chrome-linux/rtl.png differ
diff --git a/tests/visual/fixture/features.md b/tests/visual/fixture/features.md
index d4d8d7a48..5880817da 100644
--- a/tests/visual/fixture/features.md
+++ b/tests/visual/fixture/features.md
@@ -95,6 +95,16 @@ A tip admonition with a nested list:
- two
:::
+::::{exercise}
+:label: fixture-exercise
+An exercise, with a note inside it. Callouts are sized off the root, so the
+nested one keeps the outer one's size instead of stepping down again.
+
+:::{note}
+A note nested in the exercise.
+:::
+::::
+
### A note in an admonition subsection
A level-three heading, so the "On this page" panel has a nested entry (#182).
diff --git a/tests/visual/theme.spec.ts b/tests/visual/theme.spec.ts
index 5186d6bdf..2ff832bc7 100644
--- a/tests/visual/theme.spec.ts
+++ b/tests/visual/theme.spec.ts
@@ -716,6 +716,156 @@ test.describe("Site footer", () => {
await expect(footer.locator(BADGE)).toHaveCount(0);
await expect(footer.locator('a[href="https://quantecon.org"]')).toHaveCount(0);
});
+
+ // What the reader sees is the declared colour composited at the footer's
+ // opacity on the panel behind it, so that is what is measured, in both
+ // modes: 4.5:1, the floor for the footer's 14.4px normal-weight text. The
+ // declared values alone would pass and mislead.
+ test("footer-contrast", async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== "desktop-chrome", "not viewport-dependent");
+ await page.goto(`${noThebeBase}/`, { waitUntil: "domcontentloaded" });
+ await settle(page);
+ const measure = () =>
+ page.evaluate(() => {
+ const parse = (c: string) => {
+ const m = c.match(/rgba?\(([^)]+)\)/)!;
+ const [r, g, b, a = "1"] = m[1].split(/[\s,/]+/).filter(Boolean);
+ return { rgb: [Number(r), Number(g), Number(b)], a: Number(a) };
+ };
+ const footer = document.querySelector(".qe-site-footer") as HTMLElement;
+ const link = footer.querySelector('a[href="https://quantecon.org"]') as HTMLElement;
+ // The nearest painted ancestor is the ground the fade composites onto.
+ let ground = [255, 255, 255];
+ for (let el = footer.parentElement; el; el = el.parentElement) {
+ const bg = parse(getComputedStyle(el).backgroundColor);
+ if (bg.a > 0) {
+ ground = bg.rgb;
+ break;
+ }
+ }
+ const opacity = Number(getComputedStyle(footer).opacity);
+ const lum = (rgb: number[]) => {
+ const f = (c: number) => {
+ const s = c / 255;
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
+ };
+ return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]);
+ };
+ const contrast = (colour: string) => {
+ const fg = parse(colour).rgb.map((c, i) =>
+ Math.round(opacity * c + (1 - opacity) * ground[i])
+ );
+ const [hi, lo] = [lum(fg), lum(ground)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+ };
+ return {
+ opacity,
+ text: contrast(getComputedStyle(footer).color),
+ link: contrast(getComputedStyle(link).color),
+ };
+ });
+ const light = await measure();
+ expect(light.opacity).toBeCloseTo(0.7, 5);
+ expect(light.text, "light text, composited").toBeGreaterThanOrEqual(4.5);
+ expect(light.link, "light link, composited").toBeGreaterThanOrEqual(4.5);
+ await page.evaluate(() => document.documentElement.classList.add("dark"));
+ const dark = await measure();
+ expect(dark.text, "dark text, composited").toBeGreaterThanOrEqual(4.5);
+ expect(dark.link, "dark link, composited").toBeGreaterThanOrEqual(4.5);
+ });
+});
+
+/**
+ * Content typography set against the 18px prose: code at 16px / 20px on
+ * source and stored output alike, callout bodies at 16px with no compounding
+ * when nested, and one solid resting underline on both content-link classes.
+ * Computed styles rather than pixels: each of these sits inside the 1%
+ * snapshot budget.
+ */
+test.describe("Content typography", () => {
+ const size = (page: Page, selector: string) =>
+ page.evaluate((sel) => {
+ const el = document.querySelector(sel);
+ if (!el) return null;
+ const s = getComputedStyle(el);
+ return { fontSize: s.fontSize, lineHeight: s.lineHeight };
+ }, selector);
+
+ test("code-block-size", async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== "desktop-chrome", "not viewport-dependent");
+ // A plain fence takes the non-executable branch, with no border class.
+ await page.goto("/features", { waitUntil: "domcontentloaded" });
+ await settle(page);
+ const fence = await size(page, ".myst-code:not(.border-l-blue-400) pre");
+ expect(fence, "features.md renders a plain fence").not.toBeNull();
+ expect(fence).toEqual({ fontSize: "16px", lineHeight: "20px" });
+ // A notebook cell takes the executable branch; its stored text output is
+ // rendered by @myst-theme/jupyter in a div of its own.
+ await page.goto("/notebook", { waitUntil: "domcontentloaded" });
+ await settle(page);
+ const cell = await size(page, ".myst-code.border-l-blue-400 pre");
+ expect(cell, "the fixture notebook renders an executable cell").not.toBeNull();
+ expect(cell).toEqual({ fontSize: "16px", lineHeight: "20px" });
+ const output = await size(page, ".myst-jp-safe-output-text");
+ expect(output, "the fixture notebook renders a text output").not.toBeNull();
+ expect(output).toEqual({ fontSize: "16px", lineHeight: "20px" });
+ });
+
+ test("callout-size", async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== "desktop-chrome", "not viewport-dependent");
+ await page.goto("/features", { waitUntil: "domcontentloaded" });
+ await settle(page);
+ expect((await size(page, ".article"))?.fontSize, "prose").toBe("18px");
+ // Admonition and exercise bodies and headers, and the note nested in the
+ // exercise, which must not step down a second time.
+ for (const sel of [
+ ".myst-admonition",
+ ".myst-admonition-header",
+ ".myst-exercise",
+ ".myst-exercise-header",
+ ".myst-exercise .myst-admonition",
+ ".myst-exercise .myst-admonition-header",
+ ]) {
+ const s = await size(page, sel);
+ expect(s, sel).not.toBeNull();
+ expect(s!.fontSize, sel).toBe("16px");
+ }
+ await page.goto("/lists", { waitUntil: "domcontentloaded" });
+ await settle(page);
+ for (const sel of [".myst-proof", ".myst-proof-header"]) {
+ const s = await size(page, sel);
+ expect(s, sel).not.toBeNull();
+ expect(s!.fontSize, sel).toBe("16px");
+ }
+ });
+
+ test("link-underline-solid", async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== "desktop-chrome", "not viewport-dependent");
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await settle(page);
+ const decoration = (selector: string) =>
+ page.evaluate((sel) => {
+ const a = document.querySelector(sel);
+ if (!a) return null;
+ const s = getComputedStyle(a);
+ return {
+ line: s.textDecorationLine,
+ style: s.textDecorationStyle,
+ color: s.color,
+ decorationColor: s.textDecorationColor,
+ };
+ }, selector);
+ // intro.md: a cross-reference (`.hover-link`, which upstream dots) and an
+ // external link (`.link`) in the same section.
+ for (const sel of [".article a.hover-link", ".article a.link"]) {
+ const d = await decoration(sel);
+ expect(d, sel).not.toBeNull();
+ expect(d!.line, sel).toBe("underline");
+ expect(d!.style, sel).toBe("solid");
+ expect(d!.color, sel).toBe("rgb(0, 114, 188)");
+ expect(d!.decorationColor, sel).toBe("rgb(0, 114, 188)");
+ }
+ });
});
/**