-
-
Notifications
You must be signed in to change notification settings - Fork 1
feat(links): underline content links on hover and focus only, and mark the ones that open a preview #239
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat(links): underline content links on hover and focus only, and mark the ones that open a preview #239
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,9 +32,23 @@ inside an exercise renders at the same 16px as the exercise. | |
|
|
||
| ## Links | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rewritten on the rebase to describe what the sheet now does, with the contrast residue and the token-recolouring caveat stated in prose as the description asked. Please read it as yours: if the decision on the residue changes, this section and the CONTRIBUTING list are the two places that must move with it. |
||
|
|
||
| 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). | ||
| Content links are QuantEcon blue (`#0072bc`, `#004979` on hover) with no | ||
| underline at rest, on plain links and cross-references alike; a solid underline | ||
| in the link's own colour appears on hover and on keyboard focus. This is the | ||
| Sphinx sites' behaviour. Visited links are not coloured. In dark mode links are | ||
| white, weight 600; the dark palette is an open design question (#237). | ||
|
|
||
| Links that open a hover preview -- cross-references, links to other lectures | ||
| that carry a description or thumbnail, Wikipedia and GitHub links -- carry a | ||
| small stacked-squares glyph after the label, at the size, gap and opacity | ||
| upstream gives the external-link arrow. Footnote markers and citations open a | ||
| preview too but take no glyph. The glyph is a pre-coloured image rather than | ||
| an icon in `currentColor`, so a site that re-points `--qe-link-color` recolours | ||
| the text and not the glyph. | ||
|
|
||
| The glyph is the non-colour cue on the links that have one. A plain internal | ||
| link with no preview has none at rest: `#0072bc` is 2.02:1 against the body | ||
| text, short of the 3:1 WCAG 1.4.1 asks before colour alone may mark a link, | ||
| and no link colour clears both that and 4.5:1 on white. That residue is the | ||
| cost of matching the lecture builds; closing it means a non-colour resting | ||
| cue, not a different blue. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -382,38 +382,34 @@ | |
| CONTENT LINKS | ||
|
|
||
| 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, 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. | ||
| links) and `.hover-link` (cross-references, citations, footnotes, and any | ||
| link that opens a hover preview). Upstream defines both in | ||
| `@myst-theme/styles` as blue-700/blue-400, underlined at rest, with | ||
| `.hover-link`'s underline dotted. These rules re-colour them to QuantEcon | ||
| blue (the `--qe-link-*` tokens) and take the resting underline away, so | ||
| content links read as the lecture builds' do: no underline until hover or | ||
| keyboard focus, then a solid one in the link's own colour. | ||
|
|
||
| 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. | ||
| - `.link` here (0,1,0) ties upstream `.link` -- wins on order. It also | ||
| ties `.hover-link, .hover-text { text-decoration-style: dotted }`, | ||
| which is why `solid` is restated below rather than assumed. | ||
| - `.link:hover` (0,2,0) ties upstream's compiled `hover:` utility. It has | ||
| to carry `text-decoration-line` itself: this file's own hover | ||
| underline (`.article :where(a):hover`, also (0,2,0)) sits in | ||
| `@layer base`, and the `none` below is in `@layer components`, which | ||
| wins whatever the specificity -- so without it no state would | ||
| underline. `:focus-visible` has no base-layer counterpart at all. | ||
| - `.dark :is(...)` (0,2,0) ties upstream's `dark:` -> `.link:is(.dark *)`. | ||
| - `.dark :is(...):hover` (0,3,0) ties `.link:hover:is(.dark *)`. | ||
|
|
||
| The dark rules stay colour-only: nothing under `.dark` declares | ||
| `text-decoration-line`, so the none/underline pair set here carries through | ||
| both themes unchanged. | ||
|
|
||
| `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. Thickness and offset stay upstream's | ||
|
|
@@ -427,15 +423,25 @@ | |
| .link, | ||
| .hover-link { | ||
| color: var(--qe-link-color, #0072bc); | ||
| text-decoration-line: underline; | ||
| text-decoration-style: solid; | ||
| text-decoration-color: currentColor; | ||
| text-decoration-line: none; | ||
| /* Restated rather than inherited: upstream's dotted rule for `.hover-link` | ||
| is (0,1,0) too, and would otherwise pick the style of the underline the | ||
| hover rule below draws. */ | ||
| text-decoration-style: solid; | ||
| } | ||
|
|
||
| /* Keyboard focus carries the same cue as the pointer. Without it a keyboard | ||
| reader has colour alone to go on, and #0072bc against the #44403c prose is | ||
| 2.02:1 -- short of the 3:1 WCAG 1.4.1 asks of a colour-only distinction | ||
| (the underline on hover/focus is the other half of technique G183). */ | ||
| .link:hover, | ||
| .hover-link:hover { | ||
| .hover-link:hover, | ||
| .link:focus-visible, | ||
| .hover-link:focus-visible { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This never matches on a footnote marker. The focusable element there is the inner HashLink anchor, not the |
||
| color: var(--qe-link-hover-color, #004979); | ||
| text-decoration-color: currentColor; | ||
| text-decoration-line: underline; | ||
| } | ||
|
|
||
| /* Dark mode needs its own rules despite the tokens flipping under `.dark`: | ||
|
|
@@ -448,6 +454,97 @@ | |
| } | ||
| } | ||
|
|
||
| /* --------------------------------------------------------------------------- | ||
| HOVER-PREVIEW AFFORDANCE | ||
|
|
||
| `.hover-link` is what myst-to-react hangs on every link that opens a hover | ||
| popover: links to other lectures and to Wikipedia (a card with a thumbnail | ||
| and summary), cross-references (an excerpt of the target), citations and | ||
| footnotes. With the resting underline gone these are indistinguishable from | ||
| a plain `.link`, so they carry a small stacked-squares glyph after the label | ||
| -- the affordance upstream already gives external links through | ||
| `.link-icon`, at its size, gap and opacity. | ||
|
|
||
| Footnote markers are a `<sup class="hover-link">` rather than an anchor, so | ||
| keying on `a.hover-link` passes over them; citations are opted out below. | ||
| The `:has()` guard mirrors upstream's own, keeping the icon off links that | ||
| wrap an image instead of text. | ||
|
|
||
| Two constraints decide how it is built, and they pull against each other. | ||
|
|
||
| `content` is U+2060 WORD JOINER and the glyph is painted into | ||
| `padding-inline-start`, rather than the obvious inline-block box. A box is | ||
| an atomic inline, which UAX #14 treats as a break opportunity, and the icon | ||
| then strands itself on a line of its own whenever the label's last word | ||
| lands near the column edge -- 31 of 61 label lengths in a 300px column, in | ||
| both Chromium and WebKit. A word joiner forbids the break on both sides, so | ||
| the icon always travels with the last word. Upstream's `.link-icon` has the | ||
| fault this avoids: its `whitespace-nowrap` wrapper does not prevent it. | ||
|
|
||
| The glyph is then a `background-image` in three pre-coloured copies, not | ||
| one `currentColor` fill behind a `mask-image`. The mask is the tidier | ||
| construction -- one image, colour tracked for free -- but WebKit does not | ||
| render a mask on a non-replaced *inline* box: it paints the whole padding | ||
| box and knocks the glyph out of it, a solid rectangle where the icon should | ||
| be. (Chromium renders it correctly; WebKit renders the same mask correctly | ||
| on an `inline-block`, which is the display this cannot use.) Neither | ||
| `mask-mode: alpha`, `-webkit-mask-source-type`, `mask-clip` nor a | ||
| white-stroked source changes it. A `background-image` renders correctly on | ||
| an inline box in both engines, at the cost of baking the colour in -- hence | ||
| the three copies below, one per state the link can be in. | ||
|
|
||
| A data URI also has no path to rewrite, so the icon survives | ||
| `myst build --html`: mystmd's static rewriter handles `.html`, `.js` and | ||
| `.json` and never rewrites `url()` inside a stylesheet. */ | ||
| @layer components { | ||
| a.hover-link:not(:has(img, figure, picture, video))::after { | ||
|
mmcky marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two things the description's link-kind table understates, both worth checking on a real lecture build rather than the fixture. |
||
| /* heroicons 24/outline `Square2Stack`, stroked in the three link colours: | ||
| the `--qe-link-*` tokens' light values and the white both of them take | ||
| under `.dark`. Declared here rather than with the other design tokens | ||
| so the whole affordance stays in one place. The names say what the | ||
| glyph is for, not what it depicts, so swapping it is three URLs. */ | ||
| --qe-preview-icon-rest: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%230072bc'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M16.5 8.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v8.25A2.25 2.25 0 0 0 6 16.5h2.25m8.25-8.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-7.5A2.25 2.25 0 0 1 8.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 0 0-2.25 2.25v6'/%3E%3C/svg%3E"); | ||
| --qe-preview-icon-active: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23004979'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M16.5 8.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v8.25A2.25 2.25 0 0 0 6 16.5h2.25m8.25-8.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-7.5A2.25 2.25 0 0 1 8.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 0 0-2.25 2.25v6'/%3E%3C/svg%3E"); | ||
| --qe-preview-icon-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23ffffff'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M16.5 8.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v8.25A2.25 2.25 0 0 0 6 16.5h2.25m8.25-8.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-7.5A2.25 2.25 0 0 1 8.25 18v-1.5m8.25-8.25h-6a2.25 2.25 0 0 0-2.25 2.25v6'/%3E%3C/svg%3E"); | ||
| content: '\2060'; | ||
| display: inline; | ||
| /* Upstream's geometry: a 0.85em glyph behind a 0.125rem gap. `em` for the | ||
| glyph so it tracks the content size, `rem` for the gap because that is | ||
| what `.link-icon`'s `ml-0.5` resolves to. */ | ||
| padding-inline-start: calc(0.85em + 0.125rem); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The description lists print and forced-colors as out of scope; the print half is one line and worth taking now. |
||
| opacity: 0.8; | ||
| background-image: var(--qe-preview-icon-rest); | ||
| background-repeat: no-repeat; | ||
| /* The glyph sits at the far end of the padding, leaving the gap next to | ||
| the label. Mirrored for right-to-left in styles/rtl.css. */ | ||
| background-position: right center; | ||
| background-size: 0.85em 0.85em; | ||
| } | ||
|
|
||
| /* (0,2,1), which outranks the (0,1,2) base rule above. */ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The arithmetic here and in the two comments below is off, and one conclusion depends on it. |
||
| a.hover-link:hover::after, | ||
| a.hover-link:focus-visible::after { | ||
| background-image: var(--qe-preview-icon-active); | ||
| } | ||
|
|
||
| /* Both dark states are white, because `--qe-link-color` and | ||
| `--qe-link-hover-color` both are. The first selector ties the hover rule | ||
| above at (0,2,1) and wins on order; the other two settle it outright. | ||
| None of them declares `content`, so a link the base rule passed over -- | ||
| one wrapping an image -- still generates no pseudo-element here. */ | ||
| .dark a.hover-link::after, | ||
| .dark a.hover-link:hover::after, | ||
| .dark a.hover-link:focus-visible::after { | ||
| background-image: var(--qe-preview-icon-dark); | ||
| } | ||
|
|
||
| /* Citations render `<cite><a class="hover-link">`, where the label is | ||
| already a bracketed marker and the popover is the bibliography entry. */ | ||
| cite a.hover-link::after { | ||
| content: none; | ||
| } | ||
| } | ||
|
|
||
| /* --------------------------------------------------------------------------- | ||
| CODE BLOCKS | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added the
([#176](…)) ([#239](…)).link group here and on the Changed entry, per CONTRIBUTING. Consider also dropping the last two sentences (the word joiner and the data URI): they describe how the stylesheet is built rather than what a reader of the release notes sees, and both live in the CSS comment already. The Changed entry's "narrowing the divergence recorded under 3.0.0" is history rather than the change; "where upstream underlines always" already says it.