Skip to content

Whether content links keep a resting underline or underline only on hover #176

Description

@mmcky

Found in DrDrij's review of the v2.5.0 build of QuantEcon/lecture-python-programming#363 (deployed at https://6a9b93b9108dd4beb6bebadc--epic-agnesi-957267.netlify.app, which is origin/main at 6d773b6). Content links in this theme carry an always-on underline, dotted on some entries and solid on others; the deployed Sphinx lectures carry no resting underline at all and underline on hover. PR #167 coloured those links QuantEcon blue and wrote the rest down as deliberately deferred, at styles/quantecon.css:342-343 — "Known differences from the Sphinx build, deliberately out of scope here: it underlines only on hover, and colours :visited links #004979" — and its body ends "Happy to follow up on either". This issue takes that call.

Everything below was measured on 2026-09-09 with Playwright/Chromium at 1440x900 against the reviewer's own deploy and against python-programming.quantecon.org/intro.html. Sphinx declarations are quoted from the stylesheet that page loads, _static/styles/quantecon-book-theme.css?digest=1c349b9a0045df0ce6f4a606c2cc9441cbf3bef6, 81,062 bytes as fetched.

What ships today

The landing page carries 29 anchors with a content-link class. 28 of them are underlined at rest. The single exception is the Creative Commons link in the footer part, which .article .qe-site-footer a { text-decoration: none } (SITE FOOTER block, styles/quantecon.css) overrides at (0,2,1). The first eight index entries, with the target page's frontmatter alongside:

Entry Class Resting decoration Colour Target has a thumbnail
1 About These Lectures hover-link underline, dotted #0072bc yes
2 Getting Started hover-link underline, dotted #0072bc yes
3 An Introductory Example hover-link underline, dotted #0072bc yes
4 Functions link underline, solid #0072bc no
5 Python Essentials link underline, solid #0072bc no
6 OOP I: Objects and Methods link underline, solid #0072bc no
7 Names and Namespaces hover-link underline, dotted #0072bc yes
8 OOP II: Building Classes link underline, solid #0072bc no

Dotted exists at rest and nowhere else: hovering a dotted entry produces exactly the state a solid one does, text-decoration-style: solid and rgb(0, 73, 121), because .article :where(a):hover (styles/quantecon.css:200-202) is (0,2,0) and is a shorthand that resets the style, while upstream's dotted rule is (0,1,0).

The decoration itself is upstream and the theme never touches it. @myst-theme/styles@1.3.0/typography.css:13-15 and hover.css:41-42 are the whole of it:

  .link,
  .hover-link {
    @apply text-blue-700 dark:text-blue-400 underline decoration-[0.1em] underline-offset-[0.15em] decoration-blue-400 dark:decoration-blue-500 hover:text-blue-500 dark:hover:text-blue-300 hover:decoration-blue-500 dark:hover:decoration-blue-300;
  }

  .hover-link {
    @apply decoration-dotted;
  }

The theme's CONTENT LINKS block (styles/quantecon.css:344-365) sets color and text-decoration-color only, never text-decoration-line or -style, exactly as its own comment at line 326 records: "The underline itself (always-on, dotted for .hover-link) is left as upstream ships it."

Which entries go dotted, and why that is the weak part

myst-to-react@1.3.0/dist/links/index.js:26 decides the class:

    const skipPreview = !page || (!page.description && !page.thumbnail);

A page with neither a description nor a thumbnail renders as .link; anything else renders as .hover-link wrapped in a hover preview card. The deploy's config.json lists 26 slugged pages, of which 12 have a thumbnail and zero have a description — so on these lectures the class is decided by whether the lecture happens to have a thumbnail image, and by nothing else.

The dotted line is therefore not meaningless in code: it marks the links that open a preview card. I hovered one and the card does appear, containing the page title and the thumbnail. But the title in the card is the same string as the link text, and the description that would make the card worth opening is empty on every page, so what the reader gets for the extra decoration is a picture. Meanwhile four adjacent entries in one numbered list are underlined two different ways, and adding a thumbnail to a lecture silently flips its entry in every list that references it.

That part fails on its own terms whatever is decided about the resting underline, and it is not a separate call under options 1 or 2 below: option 1 removes all resting decoration and the split goes with it, option 2 unifies the style outright. It is a live question only under option 3.

What the Sphinx build does

Measured on python-programming.quantecon.org/intro.html, the same eight entries are plain a.reference.internal, rgb(0, 114, 188), text-decoration-line: none at rest; on hover, solid underline and rgb(0, 73, 121). Three declarations carry it:

a{color:#0072bc;overflow-wrap:break-word;text-decoration:none;transition:all .15s linear}
a:hover{text-decoration:underline}
a:hover,a:visited{color:#004979}

The text-decoration-style longhand appears zero times in the whole 81KB sheet, and the only dotted decoration anywhere in it is normalize.css's abbr[title]{text-decoration:underline dotted}. The one a,a:visited{text-decoration:underline} rule is inside @media print. Dark mode is body.dark-theme a{color:var(--qe-dark-link)} with --qe-dark-link:#6cb6ff, --qe-dark-link-hover:#91cdff, --qe-dark-link-visited:#a08fff.

The cost of going colour-only

Removing the resting underline makes colour the only at-rest cue that a run of text is a link, which is the situation WCAG 1.4.1 (Use of Color, Level A) is about. The sufficient technique G183 asks for two things together: 3:1 luminance contrast between the link text and the surrounding body text, and an additional visual cue on hover and focus. The theme would keep the hover cue. It does not have the 3:1, and neither does Sphinx:

Build Mode Link Body text Contrast 3:1
Theme light #0072bc #44403c 2.02:1
Theme dark #ffffff #d6d3d1 1.49:1
Sphinx light #0072bc #444444 1.92:1
Sphinx dark #6cb6ff #d4d4e4 1.47:1

The important part is that no link colour fixes this, in either mode, while the body text and grounds stay where they are. A colour has to clear 4.5:1 against the ground it sits on to be readable as normal text at all, and 3:1 against the body text to be findable without an underline; those two windows do not overlap. Grounds measured on the deploy: white in light mode, and rgb(34, 34, 34) as the nearest painted background behind a content link in dark mode (body itself is #1c1917).

Mode Ground Body text Link luminance for 4.5:1 on ground Link luminance for 3:1 vs body Overlap
Light #ffffff #44403c ≤ 0.183 ≥ 0.257 none
Dark #222222 #d6d3d1 ≥ 0.247 ≤ 0.185 none

Going the other way does not help either: a dark-mode link lighter than #d6d3d1 by 3:1 would need a relative luminance of 2.06, and the scale stops at 1.0. Using #1c1917 as the dark ground instead only moves the lower bound to 0.220, still no overlap.

So the resting underline is currently the only thing distinguishing a link from body text at rest, and in dark mode it is doing that work almost alone — #ffffff against #d6d3d1 at 1.49:1 is very nearly the same tone. Option 1 therefore carries a dark-mode link colour change with it, which is a second choice, not a detail. Worth knowing before that colour is picked: #167 chose white on the strength of &.dark-theme a { color: #fff !important }, and the string color:#fff!important appears zero times in the sheet the lectures actually load, which paints dark links #6cb6ff. Moving to #6cb6ff would be 7.40:1 on this theme's #222222 content ground and 1.44:1 against its body text — parity, not a fix.

:visited, the other half of the deferred note

Nothing in this theme styles :visited. The only occurrence of the word anywhere under styles/ or app/ is the comment at styles/quantecon.css:343. Sphinx sets a:hover,a:visited{color:#004979} in light mode and body.dark-theme a:visited{color:var(--qe-dark-link-visited)} = #a08fff in dark. On a lecture index this is genuinely useful, and it is one rule per mode — but it is the same decision as the underline, for two measured reasons.

#004979 against the theme's body text #44403c is 1.09:1. Under option 1, a visited link would be all but indistinguishable from the prose around it, and on an index page most links are visited after one reading session. Under option 2 it keeps the underline and is fine. Separately, Sphinx gives :visited the same #004979 it gives :hover, so a visited link there looks permanently hovered; and the theme has no dark :visited value to copy, because its dark link is #ffffff (Sphinx's #a08fff is 5.96:1 on this theme's #222222 ground, 1.79:1 against its body text).

Where the byline touches this

The page-header author names are <button> elements carrying upstream's hover:underline (@myst-theme/frontmatter@1.3.0/dist/AuthorPopover.js:11), and measured on the deploy they have no decoration at rest and gain an underline on hover. So the header credit already behaves the way Sphinx's content links do, while content links do not — the two surfaces are inconsistent with each other today. Option 1 makes them agree; option 2 leaves the byline as the only hover-only-underline text on the page. Its wrong blue is a separate defect and is not part of this choice.

Options

  1. Remove the resting underline. Content links then match the deployed Sphinx build exactly, and match what the byline already does. Cost: colour becomes the only at-rest cue, which the table above shows cannot be repaired by choosing a different colour in either mode; and it forces a dark-mode link colour decision, because white on #d6d3d1 at 1.49:1 is not a usable cue on its own. It also makes the :visited question sharper rather than easier, at 1.09:1 against body text.
  2. Keep a resting underline and make it uniformly solid. A deliberate divergence from Sphinx, on the grounds that the underline is the only non-colour cue available and dark mode needs it most. Removes the thumbnail-driven split in the same stroke, and leaves :visited free to use a colour without the colour having to carry the whole signal. Cost: a documented difference from the Sphinx build that the parity docs have to record, and the byline stays the odd one out. A uniformly dotted underline is the same option with worse legibility at 18px and is not recommended.
  3. Accept as is. Records that the current behaviour ships. Cost: it keeps the dotted/solid split, which is the one part of this that no framing rescues — so taking option 3 means either accepting the split as well, or making the split a separate call on its own.

Options 1 and 2 both change what renders, so both cost the same visual-baseline refresh (tests/visual/fixture/intro.md:9 and :14 and features.md:50 all render content-link anchors); option 3 costs none. The refresh does not discriminate between 1 and 2.

Both option 1 and option 2 are answers to a design question the measurements narrow but do not settle, and DrDrij raised it. This is also the second open contrast question on the theme — #172 has the light-mode code tokens — and the two are worth reading as one ledger rather than one at a time.

What closing this means

The choice is recorded in this issue: which option, and with it the dark-mode link colour and the :visited values in both modes that the option implies. Written down at that level, the implementing change can be built straight from this issue without reopening the argument.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

design-reviewdiscussOpen-ended team deliberation or a decision to be made

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions