-
Notifications
You must be signed in to change notification settings - Fork 27
Fix Firefox underline link rendering (#2297) #2640
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: develop
Are you sure you want to change the base?
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 |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| """Guards the cross-browser underline invariant from #2297. | ||
|
|
||
| Firefox derives underline thickness and position from the font's own metrics | ||
| whenever those properties are left to the browser. For Mona Sans that seats the | ||
| line inside the descenders, and `text-decoration-skip-ink` then carves a gap | ||
| around every g/p/y, so the underline renders as disconnected fragments and | ||
| heavier than in Chromium. | ||
|
|
||
| `static/css/v3/foundations.css` pins the three properties once for every v3 | ||
| anchor. These tests fail if a component rule takes that decision back, which is | ||
| the only way the bug can return. They read the stylesheets as text rather than | ||
| rendering anything, so they cost nothing and run in CI with everything else. | ||
| """ | ||
|
|
||
| import re | ||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
|
|
||
| V3_CSS = Path(__file__).resolve().parents[2] / "static" / "css" / "v3" | ||
|
|
||
| # Vendored from the boostlook repo and updated wholesale, so it is not ours to | ||
| # hold to this rule. | ||
| EXCLUDED = {"boostlook-v3.css"} | ||
|
|
||
| # The declarations that hand the decision back to the browser. A percentage is | ||
| # included because Figma exports these as percentages of the font size, which | ||
| # land on fractional pixels (7.5% of 14px = 1.05px) and round per engine. | ||
| HANDS_BACK_CONTROL = re.compile( | ||
| r""" | ||
| (?:text-decoration-thickness|text-underline-offset)\s*:\s* | ||
| (?:auto|[\d.]+%) | ||
| | text-underline-position\s*:\s*from-font | ||
| | text-decoration-skip-ink\s*:\s*auto | ||
| """, | ||
| re.VERBOSE, | ||
| ) | ||
|
|
||
| # `text-decoration` is a shorthand and resets text-decoration-thickness to | ||
| # `auto`. In a :hover or :focus rule it outranks the shared rule, so the bug | ||
| # comes back on hover only, which is the hardest variant to notice. The -line | ||
| # longhand carries the same intent without the reset. | ||
| UNDERLINE_SHORTHAND = re.compile(r"(?<!-)\btext-decoration\s*:\s*[^;]*underline") | ||
| STATE_SELECTOR = re.compile(r":(?:hover|focus|focus-visible|active|visited)") | ||
|
|
||
|
|
||
| def _without_comments(text): | ||
| """Blank out /* ... */ comments, preserving newlines so line numbers hold. | ||
|
|
||
| Needed because foundations.css's own warning comment names the very values | ||
| these tests ban, and would otherwise match. | ||
| """ | ||
| return re.sub( | ||
| r"/\*.*?\*/", | ||
| lambda m: re.sub(r"[^\n]", " ", m.group(0)), | ||
| text, | ||
| flags=re.DOTALL, | ||
| ) | ||
|
|
||
|
|
||
| def _stylesheets(): | ||
| return sorted(p for p in V3_CSS.glob("*.css") if p.name not in EXCLUDED) | ||
|
|
||
|
|
||
| def _rules(path): | ||
| """Yield (selector, body) for each rule in a stylesheet. | ||
|
|
||
| Deliberately naive: a regex, not a parser. It is enough for flat component | ||
| stylesheets and keeps the test dependency-free. | ||
| """ | ||
| source = _without_comments(path.read_text()) | ||
| for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", source): | ||
| selector = match.group(1).strip().split("\n")[-1].strip() | ||
| yield selector, match.group(2) | ||
|
|
||
|
|
||
| def test_stylesheets_are_discoverable(): | ||
| """A silent glob miss would make every test below pass for the wrong reason.""" | ||
| sheets = _stylesheets() | ||
| assert len(sheets) > 20, f"only found {len(sheets)} v3 stylesheets in {V3_CSS}" | ||
| assert (V3_CSS / "foundations.css") in sheets | ||
|
|
||
|
|
||
| def test_shared_underline_rule_is_present(): | ||
| """The other tests only make sense if the rule they defer to still exists.""" | ||
| foundations = (V3_CSS / "foundations.css").read_text() | ||
| for prop, value in ( | ||
| ("text-decoration-skip-ink", "none"), | ||
| ("text-decoration-thickness", "1px"), | ||
| ("text-underline-offset", "2px"), | ||
| ): | ||
| assert re.search(rf"{prop}\s*:\s*{value}", foundations), ( | ||
| f"foundations.css no longer pins {prop}: {value}. If the shared rule " | ||
| f"moved, point these tests at its new home rather than deleting them." | ||
| ) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("path", _stylesheets(), ids=lambda p: p.name) | ||
| def test_no_rule_hands_underline_geometry_back_to_the_browser(path): | ||
| """No v3 rule may set these to `auto`, a percentage, or `from-font`. | ||
|
|
||
| Any of them reopens #2297 for that component alone, which is why this is | ||
| worth a test rather than a comment. | ||
| """ | ||
| offenders = [ | ||
| (i, line.strip()) | ||
| for i, line in enumerate(_without_comments(path.read_text()).splitlines(), start=1) | ||
| if HANDS_BACK_CONTROL.search(line) | ||
| ] | ||
| assert not offenders, ( | ||
| f"{path.name} hands underline geometry back to the browser:\n" | ||
| + "\n".join(f" line {i}: {text}" for i, text in offenders) | ||
| + "\n\nLeave these three properties to the shared rule in foundations.css." | ||
| ) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("path", _stylesheets(), ids=lambda p: p.name) | ||
| def test_state_rules_do_not_reset_thickness_via_the_shorthand(path): | ||
| """A :hover rule using the `text-decoration` shorthand resets thickness. | ||
|
|
||
| It outranks the shared rule, so the underline goes heavy on hover only. Use | ||
| `text-decoration-line` instead, or pin the thickness in the same rule. | ||
| """ | ||
| offenders = [ | ||
| selector | ||
| for selector, body in _rules(path) | ||
| if STATE_SELECTOR.search(selector) | ||
| and UNDERLINE_SHORTHAND.search(body) | ||
| and "text-decoration-thickness" not in body | ||
| ] | ||
| assert not offenders, ( | ||
| f"{path.name} resets text-decoration-thickness on a state rule:\n" | ||
| + "\n".join(f" {s}" for s in offenders) | ||
| + "\n\nUse `text-decoration-line: underline`, which does not reset it." | ||
| ) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -147,6 +147,30 @@ html.v3 code { | |||||||||||||||||||||||||||
| .border-error { border-color: var(--color-stroke-error); } | ||||||||||||||||||||||||||||
| .border-link-accent { border-color: var(--color-stroke-link-accent); } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /* ========================================================================== | ||||||||||||||||||||||||||||
| Underline links (#2297) | ||||||||||||||||||||||||||||
| Left at `auto`, Firefox derives underline thickness and position from the | ||||||||||||||||||||||||||||
| font's own metrics. For Mona Sans that seats the line inside the descenders, | ||||||||||||||||||||||||||||
| and `skip-ink` then carves a gap around every g/p/y, so the underline renders | ||||||||||||||||||||||||||||
| as disconnected fragments and heavier than in Chromium. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| These three properties are inert unless a decoration is actually drawn, so | ||||||||||||||||||||||||||||
| setting them on every v3 anchor is safe and covers links added later. Keep the | ||||||||||||||||||||||||||||
| specificity low so a component can still override deliberately, and do not | ||||||||||||||||||||||||||||
| reintroduce `auto`, a percentage, or `text-underline-position: from-font` in a | ||||||||||||||||||||||||||||
| component rule: any of those puts the bug back for that component only. | ||||||||||||||||||||||||||||
| ========================================================================== */ | ||||||||||||||||||||||||||||
| body.v3 a, | ||||||||||||||||||||||||||||
| /* Non-anchor elements that carry their own underline. .learn-card__link is a | ||||||||||||||||||||||||||||
| span inside the anchor, so the selector above does not reach it. */ | ||||||||||||||||||||||||||||
| body.v3 .learn-card__link, | ||||||||||||||||||||||||||||
| body.v3 .btn-underline, | ||||||||||||||||||||||||||||
| body.v3 .dropdown__item--selected { | ||||||||||||||||||||||||||||
|
Comment on lines
+163
to
+168
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. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Include the mailing-list control in the shared underline rule.
Proposed fix body.v3 .dropdown__item--selected,
+body.v3 .mailing-list-modal__select-all {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
| text-decoration-skip-ink: none; | ||||||||||||||||||||||||||||
| text-decoration-thickness: 1px; | ||||||||||||||||||||||||||||
| text-underline-offset: 2px; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /* Container for v3 content */ | ||||||||||||||||||||||||||||
| .v3-container { | ||||||||||||||||||||||||||||
| width: 100%; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
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.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Limit the hover color to the anchor variant.
templates/v3/includes/_content_detail_card_item.htmlrenders.content-detail-icon__ctaas either an<a>or a<span>. This selector matches both elements, so a non-link span changes to the link-accent color on pointer hover. Scope the rule toa.content-detail-icon__cta:hoverif the color is intended only for links.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents