diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bec945ce..91aa29415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Links that open a hover preview carry a small stacked-squares glyph after the label. `.hover-link` + is what myst-to-react hangs on links to other lectures and to Wikipedia (which + render a card with a thumbnail and summary) and on cross-references (which render + an excerpt of the target); with the resting underline gone, nothing otherwise + distinguished them from a plain link. The icon takes the size, gap and opacity + upstream gives external links through `.link-icon`. Footnote markers and citations + take no glyph — they lose their resting underline with everything else, and render + as bare markers ([#176](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/176)) ([#239](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/239)). + ### 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 @@ -39,15 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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)). +- Content links no longer underline at rest. `.link` and `.hover-link` render + undecorated and draw a solid underline in `currentColor` on hover — and now on + keyboard focus too — where upstream underlines always, dotted for `.hover-link`. + This is the Sphinx lecture builds' behaviour. `:visited` is still left alone ([#176](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/176)) ([#239](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/239)). ### Fixed - The site footer's text and links clear WCAG AA in both modes once its 70% diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 430a48f1f..b86ba0ebb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -241,9 +241,6 @@ problem. The existing divergences are the model: 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; -- 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 diff --git a/UPSTREAM-CANDIDATES.yml b/UPSTREAM-CANDIDATES.yml index ef9d3bf61..6d641fcd5 100644 --- a/UPSTREAM-CANDIDATES.yml +++ b/UPSTREAM-CANDIDATES.yml @@ -126,3 +126,31 @@ upstream_candidates: route to `shouldRevalidate: () => false`; React Router 7 keeps the same hash-removal semantics, so the migration tracked in #28 does not repair it. No upstream issue found on jupyter-book/myst-theme at filing time. + + - id: link-icon-stranding + title: External-link icon strands on its own line (.link-icon) + description: | + Upstream's `.link-icon` -- the external-link glyph myst-to-react + appends to wiki and external links -- is an inline-block box, which + UAX #14 treats as a break opportunity, so the icon wraps onto 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, Chromium and WebKit alike; + the `whitespace-nowrap` wrapper does not prevent it). This theme's + hover-preview glyph avoids the fault by construction: an `::after` + whose content is a word joiner, with the icon painted into + `padding-inline-start` (styles/quantecon.css, HOVER-PREVIEW + AFFORDANCE). + status: pending + target: jupyter-book/myst-theme + provenance: + - local_pr: 239 + note: | + The construction that avoids it, documented alongside the + reproduction counts; the upstream fix is the same technique + applied to `.link-icon` (or any non-atomic inline rendering). + upstream: + pr: null + notes: | + A rendering defect rather than a QuantEcon style choice, so it + qualifies for upstream regardless of what this theme ships. No + upstream issue found on jupyter-book/myst-theme at filing time. diff --git a/docs/typography.md b/docs/typography.md index 80543f9ef..b484187e6 100644 --- a/docs/typography.md +++ b/docs/typography.md @@ -32,9 +32,23 @@ 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). +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. diff --git a/styles/quantecon.css b/styles/quantecon.css index 01a1c209a..052bc391e 100644 --- a/styles/quantecon.css +++ b/styles/quantecon.css @@ -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,33 @@ .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). + + The `:has()` selector is for footnote markers: they are a + `` whose focusable element is the HashLink anchor + *inside* it, so `:focus-visible` on the sup itself never matches, and that + anchor's own `no-underline` utility keeps upstream from underlining it. + Keying on the inner focus underlines the marker for a keyboard reader the + way hovering does for a pointer. */ .link:hover, - .hover-link:hover { + .hover-link:hover, + .link:focus-visible, + .hover-link:focus-visible, + .hover-link:has(:focus-visible) { 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 +462,113 @@ } } +/* --------------------------------------------------------------------------- + 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 `` 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 { + /* 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); + 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,2), which outranks the (0,1,3) base rule above (`:not()` counts the + specificity of its most specific argument, one element type). */ + 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,2) 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 ``, where the label is + already a bracketed marker and the popover is the bibliography entry. + (0,1,3): this TIES the base rule and wins on source order alone, so it + has to stay below it -- grouped above, every citation gets the glyph + back silently. */ + cite a.hover-link::after { + content: none; + } + + /* Print drops `background-image` under the default `print-color-adjust: + economy` but keeps the padding, so without this a printed or PDF-exported + lecture shows a 0.85em blank tail after every previewable link -- and no + resting underline marks them either. No pseudo-element at all on paper. + (Forced-colors stays deferred, per the PR scope: the baked strokes ignore + the system `LinkText` colour that upstream's `.link-icon` follows.) */ + @media print { + a.hover-link::after { + content: none; + } + } +} + /* --------------------------------------------------------------------------- CODE BLOCKS diff --git a/styles/rtl.css b/styles/rtl.css index 5189e4002..b5e8005be 100644 --- a/styles/rtl.css +++ b/styles/rtl.css @@ -17,6 +17,9 @@ * KaTeX lays out LTR. * 4. The theme's own parenthesised list markers (styles/lists.css) hang in * the gutter with a physical `left` and translate; hung from the right. + * 5. The hover-preview icon (styles/quantecon.css) places its glyph with a + * physical `background-position`, which has no logical form; mirrored + * here. * * Unlayered on purpose: every rule targets a class that Tailwind only sees * in node_modules or generates at build time, and `@layer` would purge them; @@ -108,3 +111,11 @@ right: -0.4em; transform: translateX(100%); } + +/* 5. The hover-preview icon (styles/quantecon.css) is painted into the + pseudo-element's inline-start padding, which flips on its own; only the + glyph's physical `background-position` needs mirroring, so it keeps to the + far end of the padding and the gap stays against the label. */ +[dir='rtl'] a.hover-link::after { + background-position: left center; +} diff --git a/tests/visual/theme.spec.ts b/tests/visual/theme.spec.ts index 2ff832bc7..6f81709c8 100644 --- a/tests/visual/theme.spec.ts +++ b/tests/visual/theme.spec.ts @@ -839,7 +839,7 @@ test.describe("Content typography", () => { } }); - test("link-underline-solid", async ({ page }, testInfo) => { + test("link-states-and-preview-glyph", async ({ page }, testInfo) => { test.skip(testInfo.project.name !== "desktop-chrome", "not viewport-dependent"); await page.goto("/", { waitUntil: "domcontentloaded" }); await settle(page); @@ -848,23 +848,76 @@ test.describe("Content typography", () => { const a = document.querySelector(sel); if (!a) return null; const s = getComputedStyle(a); + const after = getComputedStyle(a, "::after"); return { line: s.textDecorationLine, style: s.textDecorationStyle, color: s.color, decorationColor: s.textDecorationColor, + afterContent: after.content, + afterImage: after.backgroundImage, }; }, 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"]) { + // intro.md: a cross-reference (`.hover-link`, which opens a preview) and + // an external link (`.link`) in the same section, plus a footnote marker + // (`sup.hover-link`, not an anchor). + const xref = ".article a.hover-link"; + const plain = ".article a.link"; + for (const sel of [xref, plain]) { const d = await decoration(sel); expect(d, sel).not.toBeNull(); - expect(d!.line, sel).toBe("underline"); + expect(d!.line, `${sel} at rest`).toBe("none"); expect(d!.style, sel).toBe("solid"); expect(d!.color, sel).toBe("rgb(0, 114, 188)"); expect(d!.decorationColor, sel).toBe("rgb(0, 114, 188)"); } + // Only the previewable link carries the glyph: a word joiner with the + // icon painted behind it. The plain link and the footnote marker do not. + const glyph = await decoration(xref); + expect(glyph!.afterContent).toBe('"\u2060"'); + expect(glyph!.afterImage).toMatch(/^url\("data:image\/svg\+xml/); + for (const sel of [plain, ".article sup.hover-link"]) { + const d = await decoration(sel); + expect(d, sel).not.toBeNull(); + expect(d!.afterContent, `${sel} has no glyph`).toBe("none"); + } + // Keyboard focus underlines in the hover colour. Focus is moved off and + // back with the keyboard so `:focus-visible` matches in every engine. + await page.locator(xref).focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + expect(await page.evaluate((sel) => document.activeElement?.matches(sel), xref)).toBe(true); + const focused = await decoration(xref); + expect(focused!.line, "focused").toBe("underline"); + expect(focused!.style, "focused").toBe("solid"); + expect(focused!.color, "focused").toBe("rgb(0, 73, 121)"); + expect(focused!.decorationColor, "focused").toBe("rgb(0, 73, 121)"); + // A footnote marker's focusable element is the HashLink anchor inside + // the sup, so the sup's underline keys on `:has(:focus-visible)` + // (styles/quantecon.css); the same keyboard dance makes it deterministic. + const supAnchor = ".article sup.hover-link a"; + await page.locator(supAnchor).focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + expect(await page.evaluate((sel) => document.activeElement?.matches(sel), supAnchor)).toBe(true); + const supFocused = await decoration(".article sup.hover-link"); + expect(supFocused!.line, "footnote marker, inner anchor focused").toBe("underline"); + // Pointer hover draws the same underline. `.first()`: the footer part + // renders a second `a.link`. + await page.locator(plain).first().hover(); + const hovered = await decoration(plain); + expect(hovered!.line, "hovered").toBe("underline"); + expect(hovered!.color, "hovered").toBe("rgb(0, 73, 121)"); + // Dark mode: white text, white glyph, and still no resting underline. + // Drop the pointer and the keyboard focus first so both links are at rest. + await page.mouse.move(0, 0); + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + await page.evaluate(() => document.documentElement.classList.add("dark")); + const dark = await decoration(xref); + expect(dark!.line, "dark, at rest").toBe("none"); + expect(dark!.color, "dark").toBe("rgb(255, 255, 255)"); + // cssnano shortens the baked stroke to `%23fff` in the built sheet. + expect(dark!.afterImage, "dark glyph").toMatch(/stroke='%23fff(fff)?'/); }); });