From 1324091e272adf2f0f4976fc14e2e15d86a2d345 Mon Sep 17 00:00:00 2001 From: Ken Lui <116421546+kenlhlui@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:45:43 -0400 Subject: [PATCH 1/2] fix: reflow and resize-text failures at high zoom At 400% zoom the page scrolled horizontally and the floating Back to top button sat on top of the sort control. Horizontal overflow (1.4.10, 1.4.4): - html had overflow-x: hidden, which did not remove the overflow, it only put the overflowing content out of reach. Removed, and the sources fixed instead. - The UTL logo's fixed 70px height made it 313px wide, wider than a 320px viewport on its own. Both logos now cap by max-height and scale down by width. - The GitHub / Search syntax row was an unwrapped flex row that outgrew 360px at 200% text. It wraps now. - "Learn@MDL" is one unbreakable token and overflows 320px at 200% text, so the h1 may break it rather than cap its size. - The per-page and sort pairs wrap internally before the row does. Back to top (1.4.10, 2.4.11): Docked in the pinned search bar instead of floating bottom-right, the same move already made for the Filters button and for the same reason: a fixed overlay has no way to avoid what is under it. At high zoom that was the sort select; at full size it was still covering card links. In the bar it has a slot of its own, obscures nothing, and is operable at any scroll position rather than only past 300px. The bar's measured height is unchanged, so this costs no vertical space at high zoom. Its show/hide scroll listener is gone with it. Card grid (1.4.10): The column count was pinned to --max-cards-per-row from 481px up, which at 200% zoom left three ~170px columns breaking titles one word per line. The track floor now caps the count without forcing it, so the grid drops to two columns and then one as the panel narrows. Desktop is unchanged. Verified in headless Chromium: no horizontal scrolling from 280px to 1600px at 100% and 200% text, across the default, searched, filtered, no-results, 100-per-page, drawer-open and scrolled-to-bottom states; and Back to top is 48x48, keyboard reachable, and scrolls to top with focus. Co-Authored-By: Claude Opus 5 (1M context) --- _layouts/minimal_search.html | 8 +- assets/css/search.css | 159 ++++++++++++++++++++--------------- assets/js/search/scroll.js | 7 +- 3 files changed, 101 insertions(+), 73 deletions(-) diff --git a/_layouts/minimal_search.html b/_layouts/minimal_search.html index 2c4c2afd..ef274552 100644 --- a/_layouts/minimal_search.html +++ b/_layouts/minimal_search.html @@ -58,6 +58,12 @@

Lear longer hides on scroll, so a floating button would permanently cover the first card. Shown only in the mobile layout. --> + + {% octicon arrow-up height:24 %} @@ -129,8 +135,6 @@

Results

- {% octicon arrow-up height:24 %} - diff --git a/assets/css/search.css b/assets/css/search.css index bb1f4474..2e4a8c04 100644 --- a/assets/css/search.css +++ b/assets/css/search.css @@ -82,10 +82,10 @@ box-sizing: border-box; } -html { - overflow-x: hidden; - width: 100%; -} +/* No overflow-x clip here on purpose. Hiding horizontal overflow on the root + does not remove it, it just makes whatever sits past the edge unreachable -- + a worse 1.4.10 outcome than a scrollbar. The widths below are sized so that + nothing overflows down to 320px, so there is nothing to clip. */ a { color: inherit; @@ -123,6 +123,10 @@ header h1 { font-size: 2.5rem; margin-bottom: 10px; text-shadow: 2px 2px 4px rgba(0,0,0,0.2); + /* "Learn@MDL" is one unbreakable token, and at 2.5rem it is wider than a + 320px viewport once text is resized to 200%. Breaking it is the lesser + evil: capping the size instead would be the thing 1.4.4 forbids. */ + overflow-wrap: break-word; } header p { @@ -130,13 +134,23 @@ header p { opacity: 0.9; } +/* Capped by height on roomy screens, but free to scale down by width once the + viewport is narrower than the lockup. Fixed heights made the UTL logo 313px + wide, which is wider than the 320px reflow target on its own (1.4.10). */ +.logo-mdl, +.logo-utl { + width: auto; + height: auto; + max-width: 100%; +} + .logo-mdl { - height: 100px; + max-height: 100px; margin-bottom: 15px; } .logo-utl { - height: 70px; + max-height: 70px; margin-bottom: 30px; } @@ -151,6 +165,7 @@ header p.header-meta { align-items: center; justify-content: center; gap: 16px; + flex-wrap: wrap; } .header-links a { @@ -196,10 +211,27 @@ header p.header-meta { margin-bottom: 20px; } +/* A control row at every width: the search field takes the slack and the two + buttons keep their intrinsic size. Wraps rather than overflows, so the row + still fits a 320px viewport (1.4.10) and still fits once the labels grow at + 200% text (1.4.4). Bottom-aligned so the buttons line up with the input + rather than with the label above it. */ .sticky-search-container { max-width: 600px; margin: 0 auto; width: 100%; + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px; +} + +/* Small basis on purpose: the row has to stay on one line down to 320px, or + the pinned bar doubles in height and swallows a 256px-tall viewport. The + field gives up width to the buttons rather than pushing them to a new row. */ +.search-box { + flex: 1 1 120px; + min-width: 0; } .main-content { @@ -475,6 +507,9 @@ header p.header-meta { flex-wrap: wrap; } +/* Each label-and-select pair wraps internally before the row does, so at 320px + or at 200% text the label stacks over its control instead of pushing the + results panel wider than the viewport (1.4.10, 1.4.4) */ .per-page-selector, .sort-options { display: flex; @@ -482,6 +517,8 @@ header p.header-meta { gap: 8px; font-size: 14px; color: var(--text-muted); + flex-wrap: wrap; + min-width: 0; } .per-page-selector select, @@ -493,29 +530,34 @@ header p.header-meta { color: var(--text-body); cursor: pointer; background: var(--surface); + max-width: 100%; } +/* Caps the column count without forcing it. Each track's floor is whichever + is larger: 240px, or the width one of --max-cards-per-row columns would get. + So the grid never exceeds that many columns, and drops to fewer once the + panel is too narrow to give each card 240px -- which is what happens at high + zoom. The previous rule pinned the count from 481px up, which at 200% zoom + left three ~170px columns breaking titles one word per line (1.4.10). */ .guides-grid { + --card-gap: 20px; display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); + grid-template-columns: repeat( + auto-fit, + minmax( + max( + 240px, + (100% - (var(--max-cards-per-row) - 1) * var(--card-gap)) + / var(--max-cards-per-row) + ), + 1fr + ) + ); grid-auto-columns: 1fr; - gap: 20px; + gap: var(--card-gap); margin-bottom: 30px; } -/* Limit maximum number of columns */ -@media (min-width: 481px) { - .guides-grid { - grid-template-columns: repeat(var(--max-cards-per-row), 1fr); - } -} - -@media (max-width: 480px) { - .guides-grid { - grid-template-columns: 1fr; - } -} - .guides-card { border-radius: 10px; padding: 20px; @@ -809,36 +851,36 @@ header p.header-meta { margin-bottom: 10px; } +/* Docked in the search bar rather than floating over the page. A fixed + bottom-right overlay has no way to avoid the content beneath it: at high zoom + it landed on the sort control, and at full size it still covered card links. + Here it holds a slot of its own, so it obscures nothing (1.4.10, 2.4.11) and + is operable at any scroll position instead of only past 300px. + Height matches the Filters button so the row reads as one control group. */ #back-to-top { - position: fixed; - bottom: 40px; - right: 40px; - width: 50px; - height: 50px; + flex-shrink: 0; + box-sizing: border-box; + min-width: 44px; + min-height: 44px; + height: 53px; + padding: 0 10px; background-color: var(--surface); color: var(--uoft-blue); - border-radius: 50%; + border: 2px solid var(--uoft-blue); + border-radius: 8px; display: flex; align-items: center; justify-content: center; text-decoration: none; - box-shadow: 0 4px 12px rgba(0,0,0,0.3); - opacity: 0; - visibility: hidden; - transition: opacity 0.3s, visibility 0.3s, transform 0.3s, scale 150ms ease-out; - z-index: 1000; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + transition: background-color 0.3s, color 0.3s, scale 150ms ease-out; cursor: pointer; } -#back-to-top.show { - opacity: 1; - visibility: visible; -} - #back-to-top:hover { - transform: translateY(-5px); - box-shadow: 0 6px 16px rgba(0,0,0,0.4); - background-color: var(--surface-subtle); + background-color: var(--uoft-blue); + color: var(--on-navy); + text-decoration: none; } .visually-hidden { @@ -895,19 +937,6 @@ header p.header-meta { grid-template-columns: 1fr; } - /* Search field and Filters share one row, bottom-aligned so the button - lines up with the input rather than with the label above it */ - .sticky-search-container { - display: flex; - align-items: flex-end; - gap: 10px; - } - - .search-box { - flex: 1; - min-width: 0; - } - .filter-toggle { display: inline-flex; align-items: center; @@ -1012,28 +1041,27 @@ header p.header-meta { } .guides-grid { - gap: 15px; + --card-gap: 15px; } .results-controls { width: 100%; justify-content: space-between; + /* Below the width where the two groups fit side by side they stack, + rather than the row forcing a horizontal scrollbar */ + gap: 10px 15px; } .pagination { flex-wrap: wrap; } - #back-to-top { - bottom: 20px; - right: 20px; - width: 45px; - height: 45px; - } - - #back-to-top svg { - width: 20px; - height: 20px; + /* Trimmed to match the shorter input at this size, still past the 44px + target floor */ + #back-to-top, + .filter-toggle { + height: 48px; + min-height: 48px; } header h1 { @@ -1074,8 +1102,7 @@ header p.header-meta { } .guides-card:hover, - .tag:hover, - #back-to-top:hover { + .tag:hover { transform: none; } diff --git a/assets/js/search/scroll.js b/assets/js/search/scroll.js index 057ccd61..05b0bb30 100644 --- a/assets/js/search/scroll.js +++ b/assets/js/search/scroll.js @@ -46,11 +46,8 @@ export function initScroll() { scrollTimeout = setTimeout(handleScroll, 150); }, { passive: true }); - // Show/hide back to top button based on scroll position - window.addEventListener('scroll', () => { - backToTopBtn.classList.toggle('show', window.scrollY > 300); - }, { passive: true }); - + // Back to top now sits in the pinned bar rather than appearing past a scroll + // threshold, so there is nothing to show or hide -- just the jump itself. // Smooth scroll to top, and take keyboard focus there too backToTopBtn.addEventListener('click', (e) => { e.preventDefault(); From d4eef709c960ea9021db00a66a4337ead3d01c4f Mon Sep 17 00:00:00 2001 From: Ken Lui <116421546+kenlhlui@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:55:15 -0400 Subject: [PATCH 2/2] fix: reposition Back to Top button for better accessibility and visibility at high zoom --- _layouts/minimal_search.html | 8 +--- assets/css/search.css | 90 +++++++++++++++++++++++++++--------- assets/js/search/scroll.js | 7 ++- 3 files changed, 75 insertions(+), 30 deletions(-) diff --git a/_layouts/minimal_search.html b/_layouts/minimal_search.html index ef274552..2c4c2afd 100644 --- a/_layouts/minimal_search.html +++ b/_layouts/minimal_search.html @@ -58,12 +58,6 @@

Lear longer hides on scroll, so a floating button would permanently cover the first card. Shown only in the mobile layout. --> - - {% octicon arrow-up height:24 %} @@ -135,6 +129,8 @@

Results

+ {% octicon arrow-up height:24 %} + diff --git a/assets/css/search.css b/assets/css/search.css index 2e4a8c04..d1c4774b 100644 --- a/assets/css/search.css +++ b/assets/css/search.css @@ -851,36 +851,49 @@ header p.header-meta { margin-bottom: 10px; } -/* Docked in the search bar rather than floating over the page. A fixed - bottom-right overlay has no way to avoid the content beneath it: at high zoom - it landed on the sort control, and at full size it still covered card links. - Here it holds a slot of its own, so it obscures nothing (1.4.10, 2.4.11) and - is operable at any scroll position instead of only past 300px. - Height matches the Filters button so the row reads as one control group. */ +/* Floats bottom-right, which means it can sit on top of content. Two rules + keep that from hiding anything: --back-to-top-clearance reserves room below + the page and past every focus target, and the button is dropped entirely at + high zoom, where it used to land on the sort control (1.4.10, 2.4.11). */ +:root { + --back-to-top-clearance: 100px; /* 40px offset + 50px button + 10px */ +} + #back-to-top { - flex-shrink: 0; - box-sizing: border-box; - min-width: 44px; - min-height: 44px; - height: 53px; - padding: 0 10px; + position: fixed; + bottom: 40px; + right: 40px; + width: 50px; + height: 50px; background-color: var(--surface); color: var(--uoft-blue); - border: 2px solid var(--uoft-blue); - border-radius: 8px; + border-radius: 50%; display: flex; align-items: center; justify-content: center; text-decoration: none; - box-shadow: 0 4px 12px rgba(0,0,0,0.2); - transition: background-color 0.3s, color 0.3s, scale 150ms ease-out; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s, transform 0.3s, scale 150ms ease-out; + z-index: 1000; cursor: pointer; } +#back-to-top.show { + opacity: 1; + visibility: visible; +} + #back-to-top:hover { - background-color: var(--uoft-blue); - color: var(--on-navy); - text-decoration: none; + transform: translateY(-5px); + box-shadow: 0 6px 16px rgba(0,0,0,0.4); + background-color: var(--surface-subtle); +} + +/* The last card can scroll clear of the button */ +body { + padding-bottom: max(20px, var(--back-to-top-clearance)); } .visually-hidden { @@ -919,6 +932,8 @@ header p.header-meta { #results, .results-section :is(a, button, input, select, [tabindex]) { scroll-margin-top: calc(var(--sticky-search-h) + 10px); + /* ...and the floating Back to top below it */ + scroll-margin-bottom: var(--back-to-top-clearance); } .section-heading:focus, @@ -1033,7 +1048,7 @@ header p.header-meta { @media (max-width: 768px) { body { - padding: 8px; + padding: 8px 8px max(8px, var(--back-to-top-clearance)); } .container { @@ -1058,12 +1073,27 @@ header p.header-meta { /* Trimmed to match the shorter input at this size, still past the 44px target floor */ - #back-to-top, .filter-toggle { height: 48px; min-height: 48px; } + #back-to-top { + bottom: 20px; + right: 20px; + width: 45px; + height: 45px; + } + + #back-to-top svg { + width: 20px; + height: 20px; + } + + :root { + --back-to-top-clearance: 75px; /* 20px + 45px + 10px */ + } + header h1 { font-size: 2rem; } @@ -1096,13 +1126,29 @@ header p.header-meta { padding: 10px 0; } } +/* 480px wide is 400% zoom on a 1280px screen (or a phone); 500px tall is 200% + on a 720px-tall one. There the button would cover controls, so it goes. Home + still jumps to the top, and the search bar is pinned there anyway. Sits + after the 768px block so its zero clearance wins over the 75px there. */ +@media (max-width: 480px), (max-height: 500px) { + #back-to-top, + #back-to-top.show { + display: none; + } + + :root { + --back-to-top-clearance: 0px; + } +} + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-property: opacity, visibility, background-color, color, border-color, box-shadow !important; } .guides-card:hover, - .tag:hover { + .tag:hover, + #back-to-top:hover { transform: none; } diff --git a/assets/js/search/scroll.js b/assets/js/search/scroll.js index 05b0bb30..057ccd61 100644 --- a/assets/js/search/scroll.js +++ b/assets/js/search/scroll.js @@ -46,8 +46,11 @@ export function initScroll() { scrollTimeout = setTimeout(handleScroll, 150); }, { passive: true }); - // Back to top now sits in the pinned bar rather than appearing past a scroll - // threshold, so there is nothing to show or hide -- just the jump itself. + // Show/hide back to top button based on scroll position + window.addEventListener('scroll', () => { + backToTopBtn.classList.toggle('show', window.scrollY > 300); + }, { passive: true }); + // Smooth scroll to top, and take keyboard focus there too backToTopBtn.addEventListener('click', (e) => { e.preventDefault();