From f8f22c531b448aa4d59e0a30a0e3e549217e20d2 Mon Sep 17 00:00:00 2001 From: marissahuysentruyt Date: Fri, 11 Sep 2026 10:48:14 -0400 Subject: [PATCH 1/6] fix(header): wordmark fade and keep blur on until 1200px --- blocks/header/header.css | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/blocks/header/header.css b/blocks/header/header.css index e6e388e4..8806198e 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -25,7 +25,7 @@ header { backdrop-filter: blur(var(--sh-glass-blur)); } - @media (width >= 900px) { + @media (width >= 1200px) { box-shadow: none; background-color: unset; border-radius: 0; @@ -50,7 +50,6 @@ header { & > .brand-section svg > path:nth-child(2) { opacity: 0; - transition: opacity 0.5s ease; } } @@ -119,7 +118,7 @@ header { backdrop-filter var(--header-surface-transition); } - @media (width >= 900px) { + @media (width >= 1200px) { &::before { display: none; } @@ -153,6 +152,13 @@ header { height: 56px; } + /* Wordmark. Declared here rather than only under `is-scrolled` so the fade + runs both when the class is added and when it is removed. */ + svg > path:nth-child(2) { + opacity: 1; + transition: opacity 0.5s ease; + } + /* Narrow screens keep the "A" and drop the wordmark. */ @media (width < 900px) { a { @@ -195,7 +201,7 @@ header { } @media (prefers-reduced-motion: reduce) { - header .brand-section svg { + header .brand-section svg > path:nth-child(2) { transition: none; } From 6732dafef10096d6a6019f616a49c299f8187547 Mon Sep 17 00:00:00 2001 From: marissahuysentruyt Date: Fri, 11 Sep 2026 10:50:42 -0400 Subject: [PATCH 2/6] fix(pagenav): use grid to accommodate minmax margins for consistent spacing to content --- blocks/page-nav/page-nav.css | 37 ++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/blocks/page-nav/page-nav.css b/blocks/page-nav/page-nav.css index f37f4b12..a2c038a1 100644 --- a/blocks/page-nav/page-nav.css +++ b/blocks/page-nav/page-nav.css @@ -1,33 +1,37 @@ -/* Desktop-only in-page table of contents. Below 900px the block is removed +/* Desktop-only in-page table of contents. Below 1200px the block is removed from the DOM and the accessibility tree (see page-nav.js). */ .page-nav { display: none; } -@media (width >= 900px) { +@media (width >= 1200px) { .page-nav { - --pagenav-clamp-preferred-width: calc(100% - var(--pagenav-track-width)); + display: grid; - display: block; + /* Column 1 is an empty spacer that acts as the margin-inline-start */ + grid-template-columns: + minmax(var(--pagenav-inline-start-margin), 1fr) + minmax(var(--pagenav-min-inline-size), var(--pagenav-track-width)); + grid-template-rows: min-content auto; position: sticky; inset-block-start: var(--sh-header-height); /* match the header so the page nav sticks in place */ - max-block-size: calc(100vh - var(--sh-header-height, 0px)); - max-inline-size: var(--pagenav-track-width); + max-block-size: calc(100dvh - var(--sh-header-height, 0px)); + margin-inline-end: var(--pagenav-inline-end-margin); font-family: var(--s2-font-family, 'Adobe Clean', 'Trebuchet MS', sans-serif); font-size: var(--s2-component-m-medium-font-size); line-height: 1.4; color: var(--s2-gray-700); - margin-inline: - clamp(var(--pagenav-inline-start-margin), var(--pagenav-clamp-preferred-width), var(--pagenav-clamp-preferred-width)) - 24px; - + overflow-y: scroll; + & ul { + grid-column: 2; + grid-row: 1; border-inline-start: 2px solid rgb(from var(--s2-gray-1000) r g b / 12%); list-style: none; margin: 0; padding: 0; } - + & li + li { margin-block-start: var(--s2-spacing-50); } @@ -66,7 +70,10 @@ from the DOM and the accessibility tree (see page-nav.js). */ } } - .page-nav ul a[aria-current="location"] { + /* [aria-current] is the scroll-spy state; .is-current is a visual-only + stand-in forced on the last link on the page */ + .page-nav ul a[aria-current="location"], + .page-nav ul a.is-current { font-weight: var(--s2-component-m-bold-font-weight); color: var(--s2-gray-1000); @@ -76,6 +83,8 @@ from the DOM and the accessibility tree (see page-nav.js). */ } .page-nav > .page-nav-widgets { + grid-column: 2; + grid-row: 2; display: flex; flex-direction: column; align-items: flex-start; @@ -85,8 +94,8 @@ from the DOM and the accessibility tree (see page-nav.js). */ } /* Headings opted-in by the block (see init() in page-nav.js) get scroll-margin so anchor scrolls -clear the sticky chrome. At width >=900px the page-nav lives in the side rail, so only the site header matters. */ -@media (width >= 900px) { +clear the sticky chrome. At width >=1200px the page-nav lives in the side rail, so only the site header matters. */ +@media (width >= 1200px) { .page-nav-target { scroll-margin-block-start: var(--sh-header-height, 56px); } From 63d6b0c366dcfde3f53191eb33b60bc0694c9afe Mon Sep 17 00:00:00 2001 From: marissahuysentruyt Date: Fri, 11 Sep 2026 10:51:12 -0400 Subject: [PATCH 3/6] fix(styles): coordinate sitenav and pagenav display --- styles/styles.css | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/styles/styles.css b/styles/styles.css index 048a22c5..df75e288 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -504,6 +504,12 @@ div[data-status], --pagenav-track-width: 0; } + /* Page nav does not display below 1200px (see page-nav.css), so its + track collapses rather than reserving an empty column. */ + @media (width >= 900px) and (width < 1200px) { + --pagenav-track-width: 56px; + } + /* Only allow transition once sitenav is in the DOM */ &:has(#sitenav) { transition: @@ -549,6 +555,25 @@ div[data-status], } } + /* status-table page doesn't have a page nav */ + &:has(meta[name="template"][content="status-table"]) { + --pagenav-track-width: 0; + } + + @media (width >= 900px) { + &:has(meta[name="template"][content="status-table"]) { + --pagenav-track-width: var(--pagenav-inline-end-margin); + } + } + + @media (width >= 1200px) { + &:has(meta[name="template"][content="status-table"]) { + --pagenav-min-inline-size: 0; + --pagenav-inline-start-margin: 0; + --pagenav-track-width: 0; + } + } + /* pages that are not component pages */ &:not(:has(meta[name="template"][content="component"])) { .section { @@ -647,7 +672,7 @@ div[data-status], .page-nav { display: none; - @media (width >= 900px) { + @media (width >= 1200px) { display: block; grid-area: pagenav; } From 18b49751e0948a4463a0194306aac620addfabf1 Mon Sep 17 00:00:00 2001 From: marissahuysentruyt Date: Fri, 11 Sep 2026 10:51:33 -0400 Subject: [PATCH 4/6] fix(header): remove unused custom prop --- blocks/header/header.css | 1 - 1 file changed, 1 deletion(-) diff --git a/blocks/header/header.css b/blocks/header/header.css index 8806198e..9772c759 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -4,7 +4,6 @@ header { --header-surface-transition: 200ms cubic-bezier(0.23, 1, 0.32, 1); --header-inner-shadow: light-dark(rgb(255 255 255), rgb(248 248 248)); --header-outer-shadow: light-dark(rgb(255 255 255), var(--s2-gray-800)); - --sh-brand-wordmark-opacity: 1; --sh-brand-mark-width-full: 140px; --sh-brand-mark-width-compact: 28px; --sh-brand-mark-width: var(--sh-brand-mark-width-full); From 373cce4de93a3ecf5468870971bd1a2e0b706a95 Mon Sep 17 00:00:00 2001 From: marissahuysentruyt Date: Fri, 11 Sep 2026 12:07:09 -0400 Subject: [PATCH 5/6] wip --- blocks/page-nav/page-nav.js | 242 +++++++++++- blocks/sitenav/sitenav.js | 9 +- scripts/utils/lazyhash.js | 6 +- test/a11y/blocks/page-nav.spec.js | 8 +- test/blocks/page-nav.test.js | 592 +++++++++++++++++++++++++++++- 5 files changed, 839 insertions(+), 18 deletions(-) diff --git a/blocks/page-nav/page-nav.js b/blocks/page-nav/page-nav.js index 7369b816..95633612 100644 --- a/blocks/page-nav/page-nav.js +++ b/blocks/page-nav/page-nav.js @@ -50,18 +50,31 @@ async function decorateSeeInFigmaWidget(a) { const WIDGETS = [ { - name: 'copy-markdown', tag: 'button', label: 'Copy markdown', icon: 'copy', decorate: decorateCopyMarkdown, + name: 'copy-markdown', + tag: 'button', + label: 'Copy markdown', + icon: 'copy', + decorate: decorateCopyMarkdown, }, { - name: 'go-to-impl', tag: 'a', label: 'Go to implementation', icon: 'openin', decorate: decorateGoToImplWidget, + name: 'go-to-impl', + tag: 'a', + label: 'Go to implementation', + icon: 'openin', + decorate: decorateGoToImplWidget, }, { - name: 'see-in-figma', tag: 'a', label: 'See in Figma', icon: 'vectordraw', decorate: decorateSeeInFigmaWidget, private: true, + name: 'see-in-figma', + tag: 'a', + label: 'See in Figma', + icon: 'vectordraw', + decorate: decorateSeeInFigmaWidget, + private: true, }, ]; // Builds and decorates the URL-appropriate widget buttons/links and appends -// them below the nav's table of contents. Widgets that decorate themselves away +// them below the nav's table of contents. Widgets decorate themselves away async function renderWidgets(el) { const isComponentPage = isComponentPath(window.location.pathname); const isPrivate = isPrivatePage(); @@ -95,6 +108,13 @@ function slugify(text) { // Keeps each link's aria-current in sync with the heading the visitor is reading. function watchScrollSpy(headings, linkById) { let activeId = null; + // A click (or the initial-hash scroll) can itself cause a heading to cross + // the observer's band — most visibly when the target undershoots (a last + // heading too close to the page's end) and an earlier heading is left + // sitting in the band instead. Without this, the observer's own async + // reaction to that scroll fires right after and steals the highlight back. + // Suppressed until the visitor actually takes over scrolling themselves. + let suppressed = false; const setActive = (id) => { if (id === activeId) { @@ -102,6 +122,7 @@ function watchScrollSpy(headings, linkById) { } if (activeId && linkById.get(activeId)) { linkById.get(activeId).removeAttribute('aria-current'); + linkById.get(activeId).classList.remove('is-current'); } activeId = id; if (id && linkById.get(id)) { @@ -109,6 +130,10 @@ function watchScrollSpy(headings, linkById) { } }; + ['wheel', 'touchstart', 'keydown'].forEach((type) => { + window.addEventListener(type, () => { suppressed = false; }, { passive: true }); + }); + // Top offset matches the site header so a heading registers as "active" // the moment it scrolls under the sticky chrome. Bottom -50% keeps it // from activating until it's well into the viewport. @@ -129,7 +154,7 @@ function watchScrollSpy(headings, linkById) { visible.delete(e.target); } }); - if (!visible.size) { + if (suppressed || !visible.size) { return; } const topmost = [...visible].sort( @@ -139,6 +164,138 @@ function watchScrollSpy(headings, linkById) { }, { rootMargin }); headings.forEach((h) => observer.observe(h)); + + return { setActive, suppress: () => { suppressed = true; } }; +} + +// Jump immediately so navigation feels responsive, then briefly correct any +// drift caused by incomplete images before the target. Manual scrolling always +// cancels correction so page-nav never fights the visitor. +const PAGE_NAV_IMAGE_TIMEOUT = 1000; +const PAGE_NAV_POSITION_TOLERANCE = 2; +const SCROLL_KEYS = new Set([ + 'ArrowUp', + 'ArrowDown', + 'PageUp', + 'PageDown', + 'Home', + 'End', + ' ', +]); + +let activeNavigation; + +function getExpectedHeadingTop(heading) { + const scrollMargin = Number.parseFloat(getComputedStyle(heading).scrollMarginBlockStart); + if (Number.isFinite(scrollMargin)) { + return scrollMargin; + } + const headerHeight = Number.parseFloat( + getComputedStyle(document.documentElement).getPropertyValue('--sh-header-height'), + ); + return Number.isFinite(headerHeight) ? headerHeight : 56; +} + +function isEditableTarget(target) { + return target instanceof Element + && (target.isContentEditable + || target.closest('input, textarea, select, button, [contenteditable]:not([contenteditable="false"])')); +} + +function navigateToHeading(heading) { + activeNavigation?.cancel(); + + heading.scrollIntoView({ block: 'start', behavior: 'instant' }); + heading.focus({ preventScroll: true }); + + const pendingImages = [...document.images].filter((image) => ( + image.complete === false + && heading.compareDocumentPosition(image) === Node.DOCUMENT_POSITION_PRECEDING + )); + if (!pendingImages.length) { + activeNavigation = undefined; + return; + } + + let active = true; + let timeoutId; + const imageListeners = []; + const interactionListeners = []; + const cleanup = () => { + imageListeners.forEach(({ image, settle }) => { + image.removeEventListener('load', settle); + image.removeEventListener('error', settle); + }); + imageListeners.length = 0; + interactionListeners.forEach(({ type, listener, options }) => { + window.removeEventListener(type, listener, options); + }); + interactionListeners.length = 0; + }; + const cancel = () => { + if (!active) { + return; + } + active = false; + clearTimeout(timeoutId); + cleanup(); + if (activeNavigation?.cancel === cancel) { + activeNavigation = undefined; + } + }; + const correctPosition = () => { + const expectedTop = getExpectedHeadingTop(heading); + if (Math.abs(heading.getBoundingClientRect().top - expectedTop) + > PAGE_NAV_POSITION_TOLERANCE) { + heading.scrollIntoView({ block: 'start', behavior: 'instant' }); + } + }; + const cancelOnInteraction = () => cancel(); + const cancelOnKeydown = (event) => { + if (SCROLL_KEYS.has(event.key) && !isEditableTarget(event.target)) { + cancel(); + } + }; + [ + { type: 'wheel', listener: cancelOnInteraction, options: { passive: true } }, + { type: 'touchstart', listener: cancelOnInteraction, options: { passive: true } }, + { type: 'keydown', listener: cancelOnKeydown }, + ].forEach(({ type, listener, options }) => { + window.addEventListener(type, listener, options); + interactionListeners.push({ type, listener, options }); + }); + activeNavigation = { cancel }; + + let remaining = pendingImages.length; + const settleImage = () => { + if (!active) { + return; + } + remaining -= 1; + correctPosition(); + if (!remaining) { + cancel(); + } + }; + pendingImages.forEach((image) => { + let settled = false; + const settle = () => { + if (settled || !active) { + return; + } + settled = true; + settleImage(); + }; + image.addEventListener('load', settle, { once: true }); + image.addEventListener('error', settle, { once: true }); + imageListeners.push({ image, settle }); + if (image.complete) { + settle(); + } + }); + if (active) { + timeoutId = setTimeout(cancel, PAGE_NAV_IMAGE_TIMEOUT); + } } (() => { @@ -211,7 +368,8 @@ function watchScrollSpy(headings, linkById) { linkById.set(h.id, a); }); - // TODO: Revisit the back-to-top items after the section links. + // TODO: Revisit the back-to-top items after the section links. if we have a back-to-top, + // it'll be treated differently. // if (h1) { // const topLi = document.createElement('li'); // const topLink = document.createElement('a'); @@ -227,11 +385,11 @@ function watchScrollSpy(headings, linkById) { // sit below the table of contents. renderWidgets(pageNav); - // The nav is a desktop-only side rail (see detail template grid at >=900px). + // The nav is a desktop-only side rail (see detail template grid at >=1200px). // Below that it is removed from the DOM and the accessibility tree entirely: a // comment placeholder holds its slot so the