From 5a17392f4410ee5101c1a3cf1256b6749a9a48d3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:34:03 -0700 Subject: [PATCH 1/6] fix: pin @github/copilot to deterministic version in changelog-agent workflow (#62357) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: heiskr <1221423+heiskr@users.noreply.github.com> Co-authored-by: Kevin Heis --- .github/workflows/changelog-agent.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/changelog-agent.yml b/.github/workflows/changelog-agent.yml index 8421b78f0165..c34095467eba 100644 --- a/.github/workflows/changelog-agent.yml +++ b/.github/workflows/changelog-agent.yml @@ -412,7 +412,7 @@ jobs: - name: Install GitHub Copilot CLI if: steps.check_parent.outputs.has_parent == 'true' && steps.check_existing.outputs.exists == 'false' - run: npm install -g @github/copilot + run: npm install -g @github/copilot@1.0.71 - name: Generate changelog draft via Copilot if: steps.check_parent.outputs.has_parent == 'true' && steps.check_existing.outputs.exists == 'false' From d4f860ab7a8f5a4f9646e792b48ab5a9753a17c7 Mon Sep 17 00:00:00 2001 From: Evan Bonsignori Date: Thu, 23 Jul 2026 11:40:51 -0700 Subject: [PATCH 2/6] Test: guard against DOCS_BOT_PAT_BASE in pull_request local actions (#62393) Co-authored-by: Kevin Heis --- src/workflows/tests/actions-workflows.ts | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/workflows/tests/actions-workflows.ts b/src/workflows/tests/actions-workflows.ts index fbf5efb34647..1aec25261404 100644 --- a/src/workflows/tests/actions-workflows.ts +++ b/src/workflows/tests/actions-workflows.ts @@ -36,6 +36,7 @@ interface WorkflowJob { interface WorkflowStep { uses?: string + with?: Record [key: string]: unknown } @@ -217,4 +218,45 @@ describe('GitHub Actions workflows', () => { } }, ) + + // A long-lived shared PAT (DOCS_BOT_PAT_BASE) must never be handed to a + // local composite action (`uses: ./...`) inside a `pull_request_target` + // workflow. That trigger runs with full repository secrets even for PRs + // opened from forks by anonymous outside contributors, and the local action + // lives in the checked-out PR workspace, so a malicious fork PR could rewrite + // it to exfiltrate the token. Such jobs should generate a short-lived, scoped + // GitHub App token instead. + // + // NOTE: this intentionally does NOT cover plain `pull_request`. That trigger + // does not expose secrets to fork PRs — only to same-repo branch PRs from + // contributors who already have write access — and passing the PAT to local + // actions there (e.g. get-docs-early-access) is a longstanding, accepted + // pattern across many workflows. See #62343. + const pullRequestTargetWorkflows = workflows.filter(({ data }) => { + const on = (data.on || {}) as Record + // Use key presence, not truthiness: a trigger with no nested value parses + // to null, which a truthy check would skip. + return 'pull_request_target' in on + }) + + test.each(pullRequestTargetWorkflows)( + 'does not pass DOCS_BOT_PAT_BASE to a local composite action in $filename', + ({ filename, data }) => { + for (const [name, job] of Object.entries(data.jobs)) { + for (const step of job.steps || []) { + const usesLocalAction = typeof step.uses === 'string' && step.uses.startsWith('./') + if (!usesLocalAction || !step.with) continue + const passesPat = Object.values(step.with).some( + (value) => typeof value === 'string' && value.includes('DOCS_BOT_PAT_BASE'), + ) + if (passesPat) { + throw new Error( + `Job ${filename} # ${name} passes DOCS_BOT_PAT_BASE into local action ${step.uses}; ` + + `pull_request_target workflows must use a scoped GitHub App token instead`, + ) + } + } + } + }, + ) }) From a81b3ba8607c750b1dd151f0e2b430ba58e519c2 Mon Sep 17 00:00:00 2001 From: Evan Bonsignori Date: Thu, 23 Jul 2026 11:42:14 -0700 Subject: [PATCH 3/6] Add frosted scrim behind landing hero intro text (#62350) --- .../components/shared/LandingHero.module.scss | 27 ++++++++++++------- .../components/shared/LandingHero.tsx | 7 ++++- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/landings/components/shared/LandingHero.module.scss b/src/landings/components/shared/LandingHero.module.scss index 220648dedce6..e2ecbf713ec7 100644 --- a/src/landings/components/shared/LandingHero.module.scss +++ b/src/landings/components/shared/LandingHero.module.scss @@ -50,15 +50,24 @@ } } -// Where the art IS shown, reserve a right-side gutter so the text column wraps -// before it reaches the artwork. Without this, the 48rem text block overlaps -// the right-anchored isometric art in the ~866–1130px band, dropping the muted -// intro to ~1.2:1 over the orange/pink fill (WCAG 1.4.3 AA fail). The art is -// pinned to the border box (background-origin above), so this padding insets -// only the text, not the artwork. 22rem clears the opaque art (~300px) plus a -// gap at every width in the band. +// Where the art IS shown, the intro can extend far enough right to overlap the +// right-anchored isometric art (the heading is short enough that it never +// does). Instead of reserving a right gutter that shrinks the text column, put +// a frosted-glass panel behind the intro so it stays readable over the art — +// matching the homepage hero treatment (HomePageHero.module.scss `.content`). +// Guarded to >=866px because the art is hidden below that, so no panel is +// needed on mobile. @media (min-width: 866px) { - .landingHero { - padding-right: 22rem; + .heroIntroScrim { + display: inline-block; + backdrop-filter: blur(1rem); + background-color: color-mix( + in srgb, + var(--color-canvas-default) 70%, + transparent + ); + border-radius: var(--brand-borderRadius-medium, 0.75rem); + padding: 0.5rem 0.75rem; + margin: -0.5rem -0.75rem; } } diff --git a/src/landings/components/shared/LandingHero.tsx b/src/landings/components/shared/LandingHero.tsx index 1893ac5cef9d..37160993b776 100644 --- a/src/landings/components/shared/LandingHero.tsx +++ b/src/landings/components/shared/LandingHero.tsx @@ -33,7 +33,12 @@ export const LandingHero = ({ title, intro, heroImage, introLinks }: LandingHero {title} {intro && ( - + )} From 3379a99cac41822ce6fe9dadfb24d1774188a005 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Thu, 23 Jul 2026 11:52:45 -0700 Subject: [PATCH 4/6] Limit auto hard-purge to latest version of each plan (#62313) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e11d034-61da-499c-bbc3-0722e31d9b5f --- src/workflows/purge-fastly-changed-content.ts | 34 ++++++++++++++++--- .../tests/purge-fastly-changed-content.ts | 19 +++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/workflows/purge-fastly-changed-content.ts b/src/workflows/purge-fastly-changed-content.ts index ae43b674a599..a571f0211f0c 100644 --- a/src/workflows/purge-fastly-changed-content.ts +++ b/src/workflows/purge-fastly-changed-content.ts @@ -2,6 +2,7 @@ import type { Octokit } from '@octokit/rest' import { fetchWithRetry } from '@/frame/lib/fetch-utils' import warmServer from '@/frame/lib/warm-server' +import { allVersions } from '@/versions/lib/all-versions' import github from './github' import { getActionContext } from './action-context' @@ -19,13 +20,31 @@ import { getActionContext } from './action-context' // fresh. By the time deployment succeeds, the old pods are already terminated // (Heaven waits for the rollout to fully reconcile), so this is deterministic. // -// Scope: content/ only. data/ changes (reusables, variables, ...) fan out to -// far too many URLs to enumerate cheaply, so they stay covered by the soft -// purge of the whole language. +// Scope: content/ only, and only the latest release of each plan (fpt, ghec, +// latest ghes — see PURGEABLE_PAGE_VERSIONS). data/ changes (reusables, +// variables, ...) fan out to far too many URLs to enumerate cheaply, so they +// stay covered by the soft purge of the whole language. const PROD_HOST = 'docs.github.com' const CONTENT_PREFIX = 'content/' +// Which page versions we hard-purge. A page that applies to every +// version fans out to one URL per version: fpt + ghec + one per *supported* GHES +// release (8+ URLs today, growing with each GHES release). Enumerating all of +// them for a large deploy blew past Fastly's URL-purge rate limit (HTTP 429) and +// failed the job. So we only target the newest ("latest") release of each plan: +// free-pro-team (fpt), enterprise-cloud (ghec), and the newest supported +// enterprise-server (ghes). Older still-supported GHES releases change far less +// often and remain covered by the routine soft purge of the whole `language:en` +// key that always runs. A version is the latest of its plan exactly when its +// `version` equals its plan's `latestVersion`, so this set is {fpt, ghec, latest +// ghes} and updates itself as GHES releases come and go. +export const PURGEABLE_PAGE_VERSIONS = new Set( + Object.values(allVersions) + .filter((version) => version.version === version.latestVersion) + .map((version) => version.version), +) + // A defensive ceiling. A normal content deploy touches a handful of files -> // tens of URLs. If a deploy changes so much that we'd hard-purge more than this, // skip the targeted purge: the soft purge of the whole `language:en` key (which @@ -216,14 +235,19 @@ export function contentFilesToEnglishUrls( return [...urls] } -// Build relativePath -> English permalink hrefs from the warmed page list. +// Build relativePath -> English permalink hrefs from the warmed page list. Only +// the latest release of each plan is kept (see PURGEABLE_PAGE_VERSIONS) to cap +// the URL fan-out that was tripping Fastly's purge rate limiter. async function loadEnglishPermalinkIndex(): Promise> { const { pageList } = await warmServer(['en']) const index = new Map() for (const page of pageList) { if (page.languageCode !== 'en') continue const hrefs = page.permalinks - .filter((permalink) => permalink.languageCode === 'en') + .filter( + (permalink) => + permalink.languageCode === 'en' && PURGEABLE_PAGE_VERSIONS.has(permalink.pageVersion), + ) .map((permalink) => permalink.href) if (hrefs.length) { index.set(page.relativePath, hrefs) diff --git a/src/workflows/tests/purge-fastly-changed-content.ts b/src/workflows/tests/purge-fastly-changed-content.ts index 44d50ab3f7db..0b7190757d9b 100644 --- a/src/workflows/tests/purge-fastly-changed-content.ts +++ b/src/workflows/tests/purge-fastly-changed-content.ts @@ -15,8 +15,11 @@ const { contentFilesToEnglishUrls, hardPurgeUrls, rateLimitDelayMs, + PURGEABLE_PAGE_VERSIONS, } = await import('../purge-fastly-changed-content') +const { latest: latestGhes } = await import('@/versions/lib/enterprise-server-releases') + afterEach(() => { vi.clearAllMocks() }) @@ -150,6 +153,22 @@ describe('contentFilesToEnglishUrls', () => { }) }) +describe('PURGEABLE_PAGE_VERSIONS', () => { + test('is only the latest release of each plan (fpt, ghec, latest ghes)', () => { + expect([...PURGEABLE_PAGE_VERSIONS].sort()).toEqual( + ['free-pro-team@latest', 'enterprise-cloud@latest', `enterprise-server@${latestGhes}`].sort(), + ) + }) + + test('excludes older, still-supported GHES releases', () => { + for (const version of PURGEABLE_PAGE_VERSIONS) { + if (version.startsWith('enterprise-server@')) { + expect(version).toBe(`enterprise-server@${latestGhes}`) + } + } + }) +}) + describe('hardPurgeUrls', () => { // A minimal stand-in for a fetch Response, with a case-insensitive headers.get. function fakeResponse( From befa83087d5296e6adcbb2fe008105f21ad61381 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Thu, 23 Jul 2026 12:23:58 -0700 Subject: [PATCH 5/6] Add daily full-language soft-purge sweep to Fastly (#62178) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/purge-fastly.yml | 29 ++++++++++++++++++++---- src/workflows/tests/actions-workflows.ts | 9 +++++--- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/.github/workflows/purge-fastly.yml b/.github/workflows/purge-fastly.yml index 84256702d1df..4300740fd25e 100644 --- a/.github/workflows/purge-fastly.yml +++ b/.github/workflows/purge-fastly.yml @@ -9,6 +9,10 @@ name: Purge Fastly on: deployment_status: + schedule: + # Daily soft purge of all languages to catch reusables and AUTOTITLE links. + # Intentionally off-peak, around 6-7pm PT. + - cron: '20 2 * * *' workflow_dispatch: inputs: languages: @@ -58,6 +62,17 @@ jobs: - name: Check out repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Generate GitHub App token + # Lets a scheduled-run failure open a tracking issue in docs-engineering + # (the default GITHUB_TOKEN can't write cross-repo). + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.DOCS_BOT_APP_ID }} + private-key: ${{ secrets.DOCS_BOT_APP_PRIVATE_KEY }} + owner: github + repositories: docs-engineering + - uses: ./.github/actions/node-npm-setup - name: Validate confirmation input @@ -76,8 +91,9 @@ jobs: - name: Purge Fastly # Auto post-deploy runs wait for the build, purge English only (temporary), - # and stay soft. A manual run uses the inputs: blank languages = all, the - # `hard` toggle, or a confirmed full-cache purge. + # and stay soft. The daily scheduled run waits for nothing, purges all + # languages (blank = all), and stays soft. A manual run uses the inputs: + # blank languages = all, the `hard` toggle, or a confirmed full-cache purge. # # Raw inputs are passed through the environment and quoted, never spliced # into the command string, so a value like `en' --everything` can't break @@ -89,10 +105,8 @@ jobs: EVERYTHING_INPUT: ${{ inputs.everything }} run: | args=() - if [ "$EVENT_NAME" != "workflow_dispatch" ]; then - args+=(--wait-for-build) - fi if [ "$EVENT_NAME" = "deployment_status" ]; then + args+=(--wait-for-build) args+=(--languages en) elif [ -n "$LANGUAGES_INPUT" ]; then args+=(--languages "$LANGUAGES_INPUT") @@ -123,3 +137,8 @@ jobs: if: ${{ failure() && github.event_name != 'workflow_dispatch' }} with: slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} + + - uses: ./.github/actions/create-workflow-failure-issue + if: ${{ failure() && github.event_name != 'workflow_dispatch' }} + with: + token: ${{ steps.app-token.outputs.token }} diff --git a/src/workflows/tests/actions-workflows.ts b/src/workflows/tests/actions-workflows.ts index 1aec25261404..2c7062970ebf 100644 --- a/src/workflows/tests/actions-workflows.ts +++ b/src/workflows/tests/actions-workflows.ts @@ -109,9 +109,12 @@ const alertWorkflows = workflows.filter(({ data }) => ) // to generate list, console.log(new Set(workflows.map(({ data }) => Object.keys(data.on)).flat())) -const dailyWorkflows = scheduledWorkflows.filter(({ data }) => - data.on.schedule!.find(({ cron }: { cron: string }) => /^20 \d{1,2} /.test(cron)), -) +const dailyWorkflows = scheduledWorkflows + // purge-fastly's daily soft purge runs every day off-peak (02:20 UTC) + .filter(({ filename }) => filename !== 'purge-fastly.yml') + .filter(({ data }) => + data.on.schedule!.find(({ cron }: { cron: string }) => /^20 \d{1,2} /.test(cron)), + ) // Weekly workflows have a single day-of-week digit (e.g. "20 16 * * 1") const weeklyWorkflows = dailyWorkflows.filter(({ data }) => From b5eac08a4fe927b4b7933b09211c48bd4bcd952a Mon Sep 17 00:00:00 2001 From: Evan Bonsignori Date: Thu, 23 Jul 2026 12:34:39 -0700 Subject: [PATCH 6/6] Fix breadcrumb scroll button: one-crumb nudge and opaque hover (#62422) --- .../tests/playwright-rendering.spec.ts | 75 +++++++++++++++++++ .../BreadcrumbsScroller.module.scss | 18 +++++ .../page-header/BreadcrumbsScroller.tsx | 38 ++++++++-- 3 files changed, 125 insertions(+), 6 deletions(-) diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index a8eeed35e7d0..70514a4233fb 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -732,6 +732,81 @@ test.describe('test nav at different viewports', () => { }) }) +test.describe('secondary-bar breadcrumb scroller', () => { + // The secondary bar (and its breadcrumb scroller) only renders at wide + // viewports, and the fixture trail is short enough to fit there, so we cap the + // scroller width to force a deterministic overflow independent of title + // lengths — then exercise the chevrons. + test('chevrons scroll one crumb at a time instead of jumping to the ends', async ({ page }) => { + // Smooth-scroll settle waits across several chevron clicks add up past the + // default 5s cap. + test.setTimeout(20000) + page.setViewportSize({ width: 1300, height: 700 }) + await page.goto('/get-started/foo/bar') + + const bar = page.getByTestId('breadcrumbs-bar') + await expect(bar).toBeVisible() + + const scrollArea = page.locator('[data-search="breadcrumbs"]') + await expect(scrollArea).toBeVisible() + + // Force a deterministic overflow independent of title lengths: cap the + // scroll region, drop the nav's min-width:100% (which otherwise stretches the + // short fixture trail to fill the container so it never overflows), and pad + // the crumbs so several are hidden at once — enough that a per-crumb nudge is + // distinguishable from a jump to the end. + await page.addStyleTag({ + content: ` + [data-search="breadcrumbs"] { max-width: 360px; } + [data-search="breadcrumbs"] nav { min-width: 0 !important; } + [data-search="breadcrumbs"] li { padding-right: 60px; } + `, + }) + + const scrollLeftOf = () => scrollArea.evaluate((el) => el.scrollLeft) + const maxScrollOf = () => scrollArea.evaluate((el) => el.scrollWidth - el.clientWidth) + await expect.poll(maxScrollOf).toBeGreaterThan(0) + + // Anchor to the right end explicitly so we start from a known state: fully + // scrolled right (current page visible), only the left chevron active. + await scrollArea.evaluate((el) => el.scrollTo({ left: el.scrollWidth, behavior: 'instant' })) + const maxScroll = await maxScrollOf() + await expect.poll(scrollLeftOf).toBe(maxScroll) + + const leftChevron = page.getByRole('button', { name: 'Scroll breadcrumbs left' }) + const rightChevron = page.getByRole('button', { name: 'Scroll breadcrumbs right' }) + // At the right extreme the left chevron is active and the right one is hidden. + await expect(leftChevron).toBeVisible() + await expect(rightChevron).toBeHidden() + + // One left click nudges toward the start by a single crumb — it must move, + // but must NOT jump all the way to 0 (the old behavior) while more than one + // crumb is still hidden to the left. + await leftChevron.click() + await expect.poll(scrollLeftOf).toBeLessThan(maxScroll) + const afterOneLeft = await scrollLeftOf() + expect(afterOneLeft).toBeGreaterThan(0) + // The right chevron appears once we're no longer at the right extreme. + await expect(rightChevron).toBeVisible() + + // A right click walks back toward the current page by one crumb, not a full + // jump back to the right extreme. + await rightChevron.click() + await expect.poll(scrollLeftOf).toBeGreaterThan(afterOneLeft) + + // Repeated left clicks eventually reach the start, which hides the left + // chevron (canScrollLeft flips false). Drive off the chevron's own visibility + // rather than an exact scrollLeft, since smooth scrolling can leave a + // sub-pixel remainder. + for (let i = 0; i < 6 && (await leftChevron.isVisible()); i++) { + await leftChevron.click() + await page.waitForTimeout(200) + } + await expect(leftChevron).toBeHidden() + await expect.poll(scrollLeftOf).toBeLessThanOrEqual(1) + }) +}) + test.describe('survey', () => { test.skip(!ANALYTICS_ENABLED, 'Analytics are disabled') diff --git a/src/frame/components/page-header/BreadcrumbsScroller.module.scss b/src/frame/components/page-header/BreadcrumbsScroller.module.scss index 1eed9814d65a..cfee24334cdf 100644 --- a/src/frame/components/page-header/BreadcrumbsScroller.module.scss +++ b/src/frame/components/page-header/BreadcrumbsScroller.module.scss @@ -38,6 +38,24 @@ background-color: var(--bgColor-default, var(--color-canvas-default)); } +// On hover, keep the solid canvas background (Primer's invisible IconButton +// otherwise swaps in a translucent tint, letting crumbs bleed through the +// button) and instead signal interactivity by bolding the chevron: a +// currentColor stroke thickens the fill-based octicon glyph. +.leftChevron:hover, +.rightChevron:hover { + background-color: var( + --bgColor-default, + var(--color-canvas-default) + ) !important; + color: var(--fgColor-default, var(--color-fg-default)); + + svg { + stroke: currentColor; + stroke-width: 0.75px; + } +} + // Hidden chevrons are removed from view and interaction (and from the tab // order / a11y tree in the component). Because they're absolutely positioned // they already occupy no layout space, so the scroll area is unaffected. diff --git a/src/frame/components/page-header/BreadcrumbsScroller.tsx b/src/frame/components/page-header/BreadcrumbsScroller.tsx index 41258760949e..655fb5604a33 100644 --- a/src/frame/components/page-header/BreadcrumbsScroller.tsx +++ b/src/frame/components/page-header/BreadcrumbsScroller.tsx @@ -9,6 +9,10 @@ import { Breadcrumbs } from './Breadcrumbs' import styles from './BreadcrumbsScroller.module.scss' +// Padding past the ~28px chevron so a revealed crumb clears it rather than +// landing half-hidden underneath. +const CHEVRON_PAD = 32 + // Wraps the secondary-bar breadcrumbs in a horizontal scroll region. When the // trail is too long to fit, the region scrolls — anchored to the RIGHT so the // current page is shown first — with a left chevron to reveal the leftmost @@ -67,20 +71,42 @@ export const BreadcrumbsScroller = () => { return () => observer.disconnect() }, [anchorRight]) - // Reveal the start of the trail (Home) in one click. Breadcrumb trails are - // short, so scrolling fully to the left is clearer than a partial nudge that - // can leave the leading crumb half-hidden behind the chevron. + // Reveal the previous crumb: align the crumb straddling the left edge to that + // edge. Targeting the boundary crumb (not stepping back from the first fully + // visible one) also covers a current crumb wider than the viewport. Falls back + // to the far left only when nothing is clipped left. const scrollLeftClick = useCallback(() => { const el = scrollRef.current if (!el) return - el.scrollTo({ left: 0 }) + const containerLeft = el.getBoundingClientRect().left + CHEVRON_PAD + const crumbs = Array.from(el.querySelectorAll('li')) + // The rightmost crumb still clipped/off to the left: the one to reveal. + let target: Element | undefined + for (const crumb of crumbs) { + if (crumb.getBoundingClientRect().left < containerLeft - 1) target = crumb + } + if (!target) { + el.scrollTo({ left: 0 }) + return + } + el.scrollBy({ left: target.getBoundingClientRect().left - containerLeft }) }, []) - // Return to the current page (the end of the trail) in one click. + // Reveal the next crumb toward the current page. Mirrors scrollLeftClick, + // including the oversized-crumb case. Falls back to the far right only when + // nothing is clipped right. const scrollRightClick = useCallback(() => { const el = scrollRef.current if (!el) return - el.scrollTo({ left: el.scrollWidth }) + const containerRight = el.getBoundingClientRect().right - CHEVRON_PAD + const crumbs = Array.from(el.querySelectorAll('li')) + // The leftmost crumb still clipped/off to the right: the one to reveal. + const target = crumbs.find((crumb) => crumb.getBoundingClientRect().right > containerRight + 1) + if (!target) { + el.scrollTo({ left: el.scrollWidth }) + return + } + el.scrollBy({ left: target.getBoundingClientRect().right - containerRight }) }, []) // When a crumb link receives focus via keyboard (Tab), the browser does not