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)");
+    }
+  });
 });
 
 /**