From 77df545cfd84f282f1c940712cba5f42376ad436 Mon Sep 17 00:00:00 2001 From: Evan Bonsignori Date: Tue, 15 Sep 2026 23:45:11 +0000 Subject: [PATCH 1/8] Docs 2026: article typography, layout, and Brand migration (#62275) --- data/ui.yml | 5 +- .../components/AutomatedPage.tsx | 17 +- src/content-render/stylesheets/alerts.scss | 6 +- src/content-render/stylesheets/annotate.scss | 45 +-- .../stylesheets/article-section-framing.scss | 83 ++++++ .../stylesheets/heading-links.scss | 4 +- src/content-render/stylesheets/index.scss | 1 + .../content/get-started/foo/for-playwright.md | 16 ++ .../get-started/foo/journey-test-article.md | 30 +- src/fixtures/fixtures/data/ui.yml | 5 +- src/fixtures/tests/breadcrumbs.ts | 53 ++-- .../tests/playwright-rendering.spec.ts | 184 +++++++++--- .../components/DefaultLayout.module.scss | 39 ++- src/frame/components/DefaultLayout.tsx | 54 +++- .../article/ArticleGridLayout.module.scss | 261 +++++++++++++++++- .../components/article/ArticleGridLayout.tsx | 55 +++- .../article/ArticleInlineLayout.module.scss | 9 - .../article/ArticleInlineLayout.tsx | 18 +- src/frame/components/article/ArticlePage.tsx | 145 ++++++---- .../article/ArticleTitle.module.scss | 11 + src/frame/components/article/ArticleTitle.tsx | 4 +- .../article/ViewMarkdownButton.module.scss | 118 ++++++-- .../components/article/ViewMarkdownButton.tsx | 164 ++++++----- .../components/page-header/Breadcrumbs.tsx | 21 +- .../BreadcrumbsScroller.module.scss | 19 +- .../page-header/DocsSecondaryBar.module.scss | 105 ++++--- .../page-header/DocsSecondaryBar.tsx | 117 +++++--- .../sidebar/SidebarCollapseContext.tsx | 10 +- .../components/sidebar/SidebarNav.module.scss | 57 +++- src/frame/components/sidebar/SidebarNav.tsx | 26 +- src/frame/components/ui/Lead/Lead.module.scss | 16 +- src/frame/components/ui/Lead/Lead.tsx | 16 +- .../MarkdownContent.module.scss | 20 +- .../ui/MarkdownContent/stylesheets/code.scss | 11 + .../MarkdownContent/stylesheets/headers.scss | 16 +- .../ui/MarkdownContent/stylesheets/lists.scss | 71 +++-- .../ui/MarkdownContent/stylesheets/table.scss | 28 +- .../components/ui/MiniTocs/MiniTocShared.tsx | 97 +++++++ src/frame/components/ui/MiniTocs/MiniTocs.tsx | 101 +++---- .../ui/MiniTocs/Minitocs.module.scss | 208 ++++++++++++-- .../ui/MiniTocs/OverviewMenu.module.scss | 82 ++++++ .../components/ui/MiniTocs/OverviewMenu.tsx | 108 ++++++++ src/frame/components/ui/MiniTocs/UpNext.tsx | 97 +++++++ src/frame/components/ui/MiniTocs/index.ts | 4 + .../ui/MiniTocs/useActiveSection.ts | 126 +++++++++ .../PermissionsStatement.module.scss | 6 +- .../ui/ScrollButton/ScrollButton.module.scss | 5 +- .../stylesheets/article-link-overrides.scss | 43 +++ .../stylesheets/breadcrumbs-overrides.scss | 32 +++ src/frame/stylesheets/index.scss | 7 +- src/journeys/README.md | 1 - src/journeys/components/JourneyTrackCard.tsx | 57 ---- src/journeys/components/index.ts | 1 - src/landings/components/TocLanding.tsx | 2 +- 54 files changed, 2242 insertions(+), 595 deletions(-) create mode 100644 src/content-render/stylesheets/article-section-framing.scss create mode 100644 src/frame/components/article/ArticleTitle.module.scss create mode 100644 src/frame/components/ui/MiniTocs/MiniTocShared.tsx create mode 100644 src/frame/components/ui/MiniTocs/OverviewMenu.module.scss create mode 100644 src/frame/components/ui/MiniTocs/OverviewMenu.tsx create mode 100644 src/frame/components/ui/MiniTocs/UpNext.tsx create mode 100644 src/frame/components/ui/MiniTocs/useActiveSection.ts create mode 100644 src/frame/stylesheets/article-link-overrides.scss create mode 100644 src/frame/stylesheets/breadcrumbs-overrides.scss delete mode 100644 src/journeys/components/JourneyTrackCard.tsx diff --git a/data/ui.yml b/data/ui.yml index 812d976a5b0d..5422ce4ecde2 100644 --- a/data/ui.yml +++ b/data/ui.yml @@ -118,7 +118,7 @@ pages: all_enterprise_releases: All Enterprise Server releases about_versions: About versions permissions_callout_title: Who can use this feature? - copy_as_markdown: Copy as Markdown + copy_as_markdown: Copy markdown copy_as_markdown_desc: Use with any LLM view_as_markdown: View as Markdown view_as_markdown_desc: Open page in another tab @@ -373,9 +373,6 @@ product_landing: all_content: 'View all {{ title }} content' journey_track_nav: - next_article: Next - more_articles: More articles → - current_progress: 'Article {i} of {n}' up_next: Up next up_next_progress: 'Step {i} of {n} in {track}' diff --git a/src/automated-pipelines/components/AutomatedPage.tsx b/src/automated-pipelines/components/AutomatedPage.tsx index d71eb854310c..7a22bb79b8f7 100644 --- a/src/automated-pipelines/components/AutomatedPage.tsx +++ b/src/automated-pipelines/components/AutomatedPage.tsx @@ -6,9 +6,9 @@ import { Lead } from '@/frame/components/ui/Lead' import { PermissionsStatement } from '@/frame/components/ui/PermissionsStatement' import { ArticleGridLayout } from '@/frame/components/article/ArticleGridLayout' import { ArticleInlineLayout } from '@/frame/components/article/ArticleInlineLayout' -import { MiniTocs } from '@/frame/components/ui/MiniTocs' +import { MiniTocs, UpNext } from '@/frame/components/ui/MiniTocs' import { useAutomatedPageContext } from '@/automated-pipelines/components/AutomatedPageContext' -import { JourneyTrackCard, JourneyTrackNav } from '@/journeys/components' +import { JourneyTrackNav } from '@/journeys/components' type Props = { children?: React.ReactNode @@ -56,20 +56,16 @@ export const AutomatedPage = ({ children, rawChildren, fullWidth }: Props) => { const toc = hasTocContent ? ( <> - {isJourneyTrack && } {miniTocItems.length > 1 && } + {isJourneyTrack && currentJourneyTrack && } ) : undefined return ( - + {currentLayout === 'inline' ? ( <> - {title}} - intro={introProp} - toc={toc} - > + {title}} intro={introProp}> {articleContents} {isJourneyTrack ? ( @@ -80,10 +76,11 @@ export const AutomatedPage = ({ children, rawChildren, fullWidth }: Props) => { ) : ( <> -
+
{title}} + tocBreakpoint="xxl" intro={introProp} toc={toc} > diff --git a/src/content-render/stylesheets/alerts.scss b/src/content-render/stylesheets/alerts.scss index 7ab6af66dc7e..62366c9f0ca3 100644 --- a/src/content-render/stylesheets/alerts.scss +++ b/src/content-render/stylesheets/alerts.scss @@ -9,8 +9,12 @@ $colors: .ghd-alert { padding: var(--base-size-8, 0.5rem) var(--base-size-16, 1rem); + // Docs 2026: brand-align the callout container — rounded corners + brand + // border-radius token. The colored left border is set per-type below; the + // callout *system* redesign (Note/Warning/Tip/Pro tip) is tracked separately. border-left: 0.25em solid - var(--borderColor-default, var(--color-border-default)); + var(--brand-color-border-default, var(--color-border-default)); + border-radius: var(--brand-borderRadius-medium, 0.5rem); margin: 1rem 0; p:last-child, diff --git a/src/content-render/stylesheets/annotate.scss b/src/content-render/stylesheets/annotate.scss index ca0f7a964629..bcc496501233 100644 --- a/src/content-render/stylesheets/annotate.scss +++ b/src/content-render/stylesheets/annotate.scss @@ -31,7 +31,10 @@ .annotate-header header { border-top-left-radius: 6px !important; border-top-right-radius: 6px !important; - border-bottom: 1px solid var(--color-border-default); + // Brand's `subtle` border (#d2d9d4) is the match for Primer's + // --color-border-default (#d0d7de); brand's `default` is much darker (#b6bfb8). + border-bottom: var(--brand-borderWidth-thin, 1px) solid + var(--brand-color-border-subtle, #d2d9d4); } .annotate-beside, @@ -60,20 +63,24 @@ .annotate-beside > div:last-child > .annotate-code { border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; - border-bottom: 1px solid var(--color-border-default); + border-bottom: var(--brand-borderWidth-thin, 1px) solid + var(--brand-color-border-subtle, #d2d9d4); } .annotate-inline { - border-left: 1px solid var(--color-border-default); - border-right: 1px solid var(--color-border-default); - border-bottom: 1px solid var(--color-border-default); + border-left: var(--brand-borderWidth-thin, 1px) solid + var(--brand-color-border-subtle, #d2d9d4); + border-right: var(--brand-borderWidth-thin, 1px) solid + var(--brand-color-border-subtle, #d2d9d4); + border-bottom: var(--brand-borderWidth-thin, 1px) solid + var(--brand-color-border-subtle, #d2d9d4); } .annotate-toggle { background: var(--color-segmented-control-bg); border-radius: 6px; margin-right: 1rem; - font-weight: 500; + font-weight: var(--base-text-weight-medium, 500); } .annotate-option { @@ -93,21 +100,25 @@ // High contrast theme support @media (prefers-contrast: high) { - border-color: var(--color-border-default); + border-color: var(--brand-color-border-subtle, #d2d9d4); &:hover { - background: var(--color-canvas-subtle); - border-color: var(--color-border-emphasis); + background: var(--brand-color-canvas-subtle, #f2f5f3); + // --color-border-emphasis is defined nowhere in this app, so this border + // was computing to currentColor. Brand's `default` is its strongest border. + border-color: var(--brand-color-border-default, #b6bfb8); } &.selected { background: var(--color-accent-emphasis); color: var(--color-fg-on-emphasis); - border-color: var(--color-border-emphasis); + border-color: var(--brand-color-border-default, #b6bfb8); } &:focus-visible { - outline: 2px solid var(--color-focus-outset); + // --color-focus-outset is also undefined in this app; brand's focus token. + outline: var(--brand-borderWidth-thick, 2px) solid + var(--brand-color-focus, #0377ff); outline-offset: 2px; } } @@ -122,16 +133,18 @@ margin: 0 auto; &:hover { - border-radius: 4px; + border-radius: var(--brand-borderRadius-small, 0.25rem); outline: 2px solid var(--color-accent-fg); } } } .annotate-code { - background-color: var(--color-canvas-subtle); - border-left: 1px solid var(--color-border-default); - border-right: 1px solid var(--color-border-default); + background-color: var(--brand-color-canvas-subtle, #f2f5f3); + border-left: var(--brand-borderWidth-thin, 1px) solid + var(--brand-color-border-subtle, #d2d9d4); + border-right: var(--brand-borderWidth-thin, 1px) solid + var(--brand-color-border-subtle, #d2d9d4); @include breakpoint(md) { width: 50%; @@ -146,7 +159,7 @@ width: 50%; padding: 16px 0 16px 16px; - font-size: 14px; + font-size: var(--brand-text-size-100, 0.875rem); margin: 0px; } } diff --git a/src/content-render/stylesheets/article-section-framing.scss b/src/content-render/stylesheets/article-section-framing.scss new file mode 100644 index 000000000000..c6b132334f5b --- /dev/null +++ b/src/content-render/stylesheets/article-section-framing.scss @@ -0,0 +1,83 @@ +// Docs 2026 article section framing: the article body renders as stacked +// sections separated by single horizontal rules (Figma node 795:41405). +// +// Scoped to `#article-contents[data-article-body]`. The id alone is NOT enough: +// AutomatedPage renders the same `#article-contents` wrapper, and it backs the +// GraphQL reference / changelog / breaking-changes / schema-previews pages, +// webhook events and payloads, audit-log events and the github-apps lists — all +// of which would pick up this framing. The attribute is set only by the pages +// this treatment was drawn for (ArticlePage and TocLanding), so auto-generated +// reference pages keep their own look. +#article-contents[data-article-body] { + .markdown-body { + position: relative; + + // Vertical padding gives the first/last section breathing room from the + // top/bottom rules. There are deliberately NO vertical side rules at any + // width — sections are separated by horizontal rules alone, and the flexible + // gap columns either side of the content keep the text off the rails. + padding-top: 2rem; + padding-bottom: 2rem; + + // Closing rule below the last section — the h2 rules only draw the TOP of + // each section, so without this the article would end without a divider. + // Spans the body column, like those rules. + &::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + border-bottom: var(--borderWidth-thin, 1px) solid + var(--brand-color-border-muted, #e4ebe6); + pointer-events: none; + } + + // The Figma section headings have no underline — the section-box top rule is + // the only divider. Drop the @primer/css setext border under h2/h3. + h2, + h3 { + border-bottom: 0; + } + + // Each top-level section (h2) is separated by a SINGLE horizontal rule with + // clear space either side of it: the 3.5rem heading margin is split by the + // rule into ~24px above and 2rem below. The rule spans the width of the + // article body and no further — it is not run out to the rails. The first + // h2's rule is suppressed — the hero divider already sits above it. + h2 { + position: relative; + margin-top: 3.5rem; + } + + h2::before { + content: ""; + position: absolute; + left: 0; + right: 0; + // Sits 2rem above the heading, leaving that gap below the rule and the + // remainder of the heading margin above it. + top: -2rem; + border-bottom: var(--borderWidth-thin, 1px) solid + var(--brand-color-border-muted, #e4ebe6); + pointer-events: none; + } + + > h2:first-of-type { + margin-top: 0; + } + + > h2:first-of-type::before { + display: none; + } + } + + // Journey-track pages render a full-width "Up next" band directly below the + // grid (ArticlePage drops the 24px wrapper/band margins on those pages so the + // band sits flush). The band carries its own full-width top border, which + // already closes the article, so suppress our own closing rule rather than + // stacking two lines. + &[data-has-upnext] .markdown-body::after { + display: none; + } +} diff --git a/src/content-render/stylesheets/heading-links.scss b/src/content-render/stylesheets/heading-links.scss index 561b87382e70..3b46e85c5936 100644 --- a/src/content-render/stylesheets/heading-links.scss +++ b/src/content-render/stylesheets/heading-links.scss @@ -10,7 +10,9 @@ // https://primer.style/design/foundations/icons/link-16 mask: url('data:image/svg+xml;charset=utf8,'); mask-size: cover; - background-color: var(--color-fg-subtle); + // Brand has no `subtle` text step; `muted` is the closest analogue to + // Primer's --color-fg-subtle (#6e7781 -> #58635b). + background-color: var(--brand-color-text-muted, #58635b); @media (forced-colors: active) { background-color: LinkText; } diff --git a/src/content-render/stylesheets/index.scss b/src/content-render/stylesheets/index.scss index 8b8b2fca2457..43671f543aa8 100644 --- a/src/content-render/stylesheets/index.scss +++ b/src/content-render/stylesheets/index.scss @@ -1,6 +1,7 @@ @import "annotate.scss"; @import "heading-links.scss"; @import "markdown-overrides.scss"; +@import "article-section-framing.scss"; @import "syntax-highlighting.scss"; @import "alerts.scss"; @import "octicon-table-optimization.scss"; diff --git a/src/fixtures/fixtures/content/get-started/foo/for-playwright.md b/src/fixtures/fixtures/content/get-started/foo/for-playwright.md index 7f6300a64717..62e9657e3ce7 100644 --- a/src/fixtures/fixtures/content/get-started/foo/for-playwright.md +++ b/src/fixtures/fixtures/content/get-started/foo/for-playwright.md @@ -17,3 +17,19 @@ This is the second heading. For a Playwright test of cross-page anchor navigation, here is a link to the [target heading](/get-started/foo/anchor-scrolling#target-heading). + +### Third-level heading + +This section exercises third-level heading styles. + +#### Fourth-level heading + +This section exercises fourth-level heading styles. + +##### Fifth-level heading + +This section exercises fifth-level heading styles. + +###### Sixth-level heading + +This section exercises sixth-level heading styles. diff --git a/src/fixtures/fixtures/content/get-started/foo/journey-test-article.md b/src/fixtures/fixtures/content/get-started/foo/journey-test-article.md index e6541e22874c..e10a89e10da2 100644 --- a/src/fixtures/fixtures/content/get-started/foo/journey-test-article.md +++ b/src/fixtures/fixtures/content/get-started/foo/journey-test-article.md @@ -2,11 +2,33 @@ title: Journey Test Article intro: This article is used for testing journey branching text. versions: - fpt: '*' - ghes: '*' - ghec: '*' + fpt: "*" + ghes: "*" + ghec: "*" --- ## Test Article -This article exists solely for testing journey navigation features. +This article exists solely for testing journey navigation features. It represents a complete guide in a learning track, with enough reading material to separate the article introduction from the journey navigation at the bottom of the page. Readers can follow the guide in order or use the alternative next step to skip ahead. + +Begin by choosing a repository for the exercise. Review its description and read the introductory documentation before making changes. Understanding the purpose of the repository helps you decide which files belong in your first update and which topics should be left for a later step in the learning track. + +Explore the repository's file list and open a few documents. Notice how the folders group related information together. The same organization can help collaborators find examples, understand project conventions, and identify the right place to contribute a small improvement without needing to read every file in the project. + +Create a branch for your work so that the original version remains available while you experiment. Give the branch a descriptive name that explains the change you intend to make. A focused branch makes it easier to compare your work with the starting point and discuss the proposed update with a reviewer. + +Make a small, self-contained edit to the documentation. Explain the change clearly and check that the surrounding paragraphs still make sense. When you introduce a new example, include enough context for someone who has not followed the rest of the exercise to understand what the example demonstrates and when it is useful. + +Review the differences before saving your work. Look for accidental changes to unrelated lines, missing punctuation, and links that no longer point to the intended location. Keeping the update focused helps a reviewer concentrate on the substance of your contribution rather than spending time separating useful edits from incidental changes. + +Save the changes with a commit message that describes their purpose. A useful message explains what the update accomplishes rather than merely listing the files you edited. This description becomes part of the project's history and can help a future contributor understand why the documentation was changed in this particular way. + +Open a pull request when the update is ready to share. Summarize the problem, describe your approach, and mention any questions that still need discussion. Include the steps you used to verify the change so that a reviewer can repeat those checks and confirm that the proposed improvement behaves as intended. + +Read review feedback carefully and compare it with the proposed changes. Some comments may identify a mistake, while others may ask for clarification or suggest a different approach. Respond with enough detail to explain your reasoning, and keep the discussion focused on improving the contribution rather than defending the first version of the edit. + +Apply any agreed changes and review the updated result. Recheck examples and links after editing because a small revision can affect the surrounding explanation. If the pull request includes automated checks, wait for their results and investigate any failures before considering the work complete or moving to a different task. + +Once the contribution is accepted, review the final version alongside the original goal. Confirm that the documentation now answers the question you set out to address. This short reflection helps connect the individual steps of branching, editing, reviewing, and merging into a repeatable workflow for future contributions to the repository. + +You have now reached the end of this exercise. The journey navigation below the article provides the next recommended step in the learning track. Scrolling back to an earlier part of this guide lets you review the instructions while the article's side panel provides the alternative route to the next guide. diff --git a/src/fixtures/fixtures/data/ui.yml b/src/fixtures/fixtures/data/ui.yml index 812d976a5b0d..5422ce4ecde2 100644 --- a/src/fixtures/fixtures/data/ui.yml +++ b/src/fixtures/fixtures/data/ui.yml @@ -118,7 +118,7 @@ pages: all_enterprise_releases: All Enterprise Server releases about_versions: About versions permissions_callout_title: Who can use this feature? - copy_as_markdown: Copy as Markdown + copy_as_markdown: Copy markdown copy_as_markdown_desc: Use with any LLM view_as_markdown: View as Markdown view_as_markdown_desc: Open page in another tab @@ -373,9 +373,6 @@ product_landing: all_content: 'View all {{ title }} content' journey_track_nav: - next_article: Next - more_articles: More articles → - current_progress: 'Article {i} of {n}' up_next: Up next up_next_progress: 'Step {i} of {n} in {track}' diff --git a/src/fixtures/tests/breadcrumbs.ts b/src/fixtures/tests/breadcrumbs.ts index efa8f699d9b9..9559fcd43eef 100644 --- a/src/fixtures/tests/breadcrumbs.ts +++ b/src/fixtures/tests/breadcrumbs.ts @@ -1,13 +1,13 @@ import { describe, expect, test } from 'vitest' -import type { Element } from 'domhandler' - import { getDOM } from '@/tests/helpers/e2etest' describe('breadcrumbs', () => { test('links always prefixed with language', async () => { const $ = await getDOM('/get-started/start-your-journey/hello-world') const links = $('[data-testid=breadcrumbs-bar] a') + // Home and the two ancestors are links; the current article is static text. + expect(links.length).toBe(3) links.each((i, element) => { const href = $(element).attr('href')! // The Home crumb points at the locale root (`/en` on the default version, @@ -15,8 +15,6 @@ describe('breadcrumbs', () => { // language-prefixed, which is what this test guards. expect(href === '/en' || href.startsWith('/en/')).toBe(true) }) - // Home crumb + the three trail crumbs for this path. - expect.assertions(4) }) test('top-level hidden /search page has no breadcrumbs', async () => { @@ -29,58 +27,61 @@ describe('breadcrumbs', () => { test('short titles are preferred', async () => { const $ = await getDOM('/get-started/foo/bar') - const links = $('[data-testid=breadcrumbs-bar] li:last-child a') - expect(links.text()).toBe('Bar') + const current = $('[data-testid=breadcrumbs-bar] [aria-current=page]') + expect(current.text()).toBe('Bar') }) - test('article pages have breadcrumbs in the secondary bar with home, product, category, subcategory, and article (all shown)', async () => { + test('article pages show home, ancestor links, and a non-clickable current article in the secondary bar', async () => { const $ = await getDOM('/get-started/start-your-journey/hello-world') const links = $('[data-testid=breadcrumbs-bar] a') - // The secondary bar leads with a Home crumb, then the page trail. - expect(links.length).toBe(4) + const current = $('[data-testid=breadcrumbs-bar] [aria-current=page]') + expect(links.length).toBe(3) expect($(links[0]).text()).toBe('Home') expect($(links[1]).text()).toBe('Get started') - expect($(links[1]).attr('class')!.includes('d-none')).toBe(false) + expect($(links[1]).hasClass('d-none')).toBe(false) expect($(links[2]).text()).toBe('Start your journey') - expect($(links[2]).attr('class')!.includes('d-none')).toBe(false) - expect($(links[3]).text()).toBe('Hello World') + expect($(links[2]).hasClass('d-none')).toBe(false) + expect(current.length).toBe(1) + expect(current.text()).toBe('Hello World') + expect(current.is('a')).toBe(false) + expect(current.attr('href')).toBeUndefined() // The secondary-bar variant shows the full trail (no hidden last crumb). - expect($(links[3]).attr('class')!.includes('d-none')).toBe(false) + expect(current.hasClass('d-none')).toBe(false) }) test('works for enterprise-server articles too', async () => { const $ = await getDOM('/enterprise-server@latest/get-started/start-your-journey/hello-world') const links = $('[data-testid=breadcrumbs-bar] a') - expect(links.length).toBe(4) + const current = $('[data-testid=breadcrumbs-bar] [aria-current=page]') + expect(links.length).toBe(3) expect($(links[0]).text()).toBe('Home') expect($(links[1]).text()).toBe('Get started') expect($(links[2]).text()).toBe('Start your journey') - expect($(links[3]).text()).toBe('Hello World') + expect(current.text()).toBe('Hello World') }) test('works for titles that depend on Liquid', async () => { const $fpt = await getDOM('/get-started/start-your-journey/dynamic-title') - const fptLinks = $fpt('[data-testid=breadcrumbs-bar] a') - // [0] is the Home crumb; the article is the last crumb. - expect($fpt(fptLinks[3]).text()).toBe('Hello HubGit') + const fptCurrent = $fpt('[data-testid=breadcrumbs-bar] [aria-current=page]') + expect(fptCurrent.text()).toBe('Hello HubGit') const $ghec = await getDOM( '/enterprise-cloud@latest/get-started/start-your-journey/dynamic-title', ) - const ghecLinks = $ghec('[data-testid=breadcrumbs-bar] a') - expect($ghec(ghecLinks[3]).text()).toBe('Greetings HubGit Enterprise Cloud') + const ghecCurrent = $ghec('[data-testid=breadcrumbs-bar] [aria-current=page]') + expect(ghecCurrent.text()).toBe('Greetings HubGit Enterprise Cloud') }) - test('early access article pages have breadcrumbs with home, product, category, and article', async () => { + test('early access article pages show home and ancestor links with the current article', async () => { const $ = await getDOM('/early-access/secrets/deeper/mariana-trench') const $breadcrumbTitles = $('[data-testid=breadcrumbs-bar] [data-testid=breadcrumb-title]') const $breadcrumbLinks = $('[data-testid=breadcrumbs-bar] a') + const current = $('[data-testid=breadcrumbs-bar] [aria-current=page]') expect($breadcrumbTitles.length).toBe(0) - // Home crumb + the two early-access crumbs. - expect($breadcrumbLinks.length).toBe(3) - expect(($breadcrumbLinks[0] as Element).attribs.title).toBe('Home') - expect(($breadcrumbLinks[1] as Element).attribs.title).toBe('Deeper secrets') - expect(($breadcrumbLinks[2] as Element).attribs.title).toBe('Mariana Trench') + expect($breadcrumbLinks.length).toBe(2) + expect($($breadcrumbLinks[0]).attr('title')).toBe('Home') + expect($($breadcrumbLinks[1]).attr('title')).toBe('Deeper secrets') + expect(current.text()).toBe('Mariana Trench') }) }) diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index c5e18a7880b8..3ce518ec4bc8 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -41,6 +41,15 @@ test('view the for-playwright article', async ({ page }) => { await expect(page).toHaveURL(/for-playwright#second-heading/) }) +test('article heading levels share the same top padding', async ({ page }) => { + await page.goto('/get-started/foo/for-playwright') + + const article = page.locator('#article-contents') + for (const level of [2, 3, 4, 5, 6]) { + await expect(article.getByRole('heading', { level }).first()).toHaveCSS('padding-top', '16px') + } +}) + test('use sidebar to go to Hello World page', async ({ page }) => { await page.goto('/get-started') @@ -299,6 +308,8 @@ test.describe('platform picker', () => { }) test('minitoc matches picker', async ({ page }) => { + // Stay inside the drawer's 1400px reveal breakpoint. + await page.setViewportSize({ width: 1440, height: 900 }) // The fixture frontmatter defaults the platform to Windows. await page.goto('/get-started/liquid/platform-specific') await turnOffExperimentsInPage(page) @@ -368,6 +379,9 @@ test.describe('tool picker', () => { }) test('minitoc matches picker', async ({ page }) => { + // See the note on the platform-specific version of this test: don't sit on + // the drawer's exact reveal breakpoint. + await page.setViewportSize({ width: 1440, height: 900 }) // default tool set to webui in fixture frontmatter await page.goto('/get-started/liquid/tool-specific') await turnOffExperimentsInPage(page) @@ -579,8 +593,12 @@ test.describe('test nav at different viewports', () => { await page.goto('/get-started/foo/bar') // The Docs 2026 secondary bar leads with a Home crumb, then the full trail - // 'Get started / Foo / Bar' (no hidden last crumb). - expect(await page.getByTestId('breadcrumbs-bar').getByRole('link').all()).toHaveLength(4) + // 'Get started / Foo / Bar' (no hidden last crumb). The current page is + // static text rather than a link, so only the three ancestors are links. + expect(await page.getByTestId('breadcrumbs-bar').getByRole('link').all()).toHaveLength(3) + await expect(page.getByTestId('breadcrumbs-bar').locator('[aria-current="page"]')).toHaveText( + 'Bar', + ) await expect(page.getByTestId('breadcrumbs-bar').getByText('Foo')).toBeVisible() await expect(page.getByTestId('breadcrumbs-bar').getByText('Bar')).toBeVisible() @@ -596,8 +614,8 @@ test.describe('test nav at different viewports', () => { }) test('mobile nav opens even when the desktop rail was collapsed', async ({ page }) => { - // Collapse the desktop rail at the xxl breakpoint so the persisted - // `collapsed` state is set (the collapse toggle only exists at 1400px+). + // Collapse the desktop rail with both drawers out (xxl) so the persisted + // `collapsed` state is set via the secondary-bar collapse toggle. page.setViewportSize({ width: 1400, height: 700, @@ -607,9 +625,11 @@ test.describe('test nav at different viewports', () => { // With the rail collapsed the sidebar is not rendered on desktop. await expect(page.getByTestId('sidebar')).toHaveCount(0) - // Drop below xxl where the inline mobile nav lives. `collapsed` persists. + // Drop below lg (1012) where the inline mobile nav toggle lives (Docs 2026: + // the lg–xxl range keeps the desktop collapse toggle instead). `collapsed` + // persists across the resize. page.setViewportSize({ - width: 1013, + width: 1000, height: 700, }) @@ -625,9 +645,9 @@ test.describe('test nav at different viewports', () => { }) test('resizing from mobile to desktop closes the inline nav', async ({ page }) => { - // Start below the xxl (1400px) breakpoint where the inline mobile nav lives. - page.setViewportSize({ - width: 1013, + // Start below the lg (1012px) breakpoint where the inline mobile nav lives. + await page.setViewportSize({ + width: 1000, height: 700, }) await page.goto('/get-started/foo/bar') @@ -673,14 +693,19 @@ test.describe('test nav at different viewports', () => { await page.goto('/get-started/foo/bar') // breadcrumbs show up in the secondary bar; for this page we should have - // a Home crumb plus 'Get started / Foo / Bar' + // a Home crumb plus 'Get started / Foo / Bar' — the last of which is the + // current page, rendered as static text rather than a link. await expect(page.getByTestId('breadcrumbs-bar')).toBeVisible() - expect(await page.getByTestId('breadcrumbs-bar').getByRole('link').all()).toHaveLength(4) + expect(await page.getByTestId('breadcrumbs-bar').getByRole('link').all()).toHaveLength(3) - // the mobile nav toggle is visible and expands the doc-tree nav inline - await expect(page.getByTestId('sidebar-mobile-toggle')).toBeVisible() - await page.getByTestId('sidebar-mobile-toggle').click() + // At lg+ (Docs 2026) the doc-tree rail is shown by default with the desktop + // collapse toggle; the mobile inline-nav toggle is hidden. Clicking the + // collapse toggle hides the rail. await expect(page.getByTestId('sidebar')).toBeVisible() + await expect(page.getByTestId('sidebar-collapse-toggle')).toBeVisible() + await expect(page.getByTestId('sidebar-mobile-toggle')).toBeHidden() + await page.getByTestId('sidebar-collapse-toggle').click() + await expect(page.getByTestId('sidebar')).toBeHidden() }) test('medium viewports - 768-1011', async ({ page }) => { @@ -1495,15 +1520,61 @@ test.describe('Journey Tracks', () => { // go to an article that's part of a journey track await page.goto('/get-started/start-your-journey/hello-world') - // journey card should be visible in sidebar - const journeyCard = page.locator('[data-testid="journey-track-card"]') - await expect(journeyCard).toBeVisible() - - // journey footer nav should be visible + // The journey footer "Up next" nav should be visible. (The Docs 2026 redesign + // removed the sidebar journey card; next-step info now lives in the bottom + // pager + the in-panel "Up next" section.) const journeyNav = page.locator('[data-testid="journey-track-nav"]') await expect(journeyNav).toBeVisible() }) + // Restores the coverage the Docs 2026 migration dropped along with the sidebar + // journey card: `alternativeNextStep` and its AUTOTITLE resolution now render + // in the right-rail "Up next" section instead. That section rides the drawer's + // reveal breakpoint, so it needs a viewport inside the drawer range and a + // fixture long enough to keep the bottom pager outside the viewport. + test('up next displays branching text when present', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/get-started/foo/journey-test-article') + await turnOffExperimentsInPage(page) + await expect(page.getByTestId('journey-track-nav')).not.toBeInViewport() + + const upNext = page.getByTestId('up-next') + await expect(upNext).toBeVisible() + + // Branching text should be rendered with its markdown link resolved + await expect(upNext).toContainText('Want to skip ahead?') + await expect(upNext).not.toContainText('AUTOTITLE') + + const branchingLink = upNext.locator('a').filter({ hasText: 'Hello World' }) + await expect(branchingLink).toBeVisible() + + const href = await branchingLink.getAttribute('href') + expect(href).toContain('/get-started/start-your-journey/hello-world') + }) + + test('up next yields to the bottom pager and reappears when scrolling back up', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('/get-started/foo/journey-test-article') + await turnOffExperimentsInPage(page) + + const upNext = page.getByTestId('up-next') + const journeyNav = page.getByTestId('journey-track-nav') + await expect(journeyNav).not.toBeInViewport() + await expect(upNext).toBeVisible() + + await journeyNav.evaluate((pager) => + pager.scrollIntoView({ block: 'center', behavior: 'instant' }), + ) + await expect(journeyNav).toBeInViewport() + await expect(upNext).toBeHidden() + + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })) + await expect(journeyNav).not.toBeInViewport() + await expect(upNext).toBeVisible() + }) + test('journey footer nav component links to first article in next track from last article in previous track', async ({ page, }) => { @@ -1519,23 +1590,52 @@ test.describe('Journey Tracks', () => { const href = await nextTrackLink.getAttribute('href') expect(href).toContain('/get-started/foo/autotitling') }) +}) - test('journey card displays branching text when present', async ({ page }) => { - await page.goto('/get-started/foo/journey-test-article') +test.describe('Docs 2026 in-article navigation', () => { + // Below the drawer's reveal breakpoint the right-rail "In this article" panel + // is hidden and the collapsed control in the secondary bar is the ONLY + // mini-TOC — the common case for most readers — so it needs its own coverage. + test('the collapsed "In this article" menu navigates below the drawer breakpoint', async ({ + page, + }) => { + await page.setViewportSize({ width: 1100, height: 900 }) + await page.goto('/get-started/liquid/platform-specific') + await turnOffExperimentsInPage(page) - const journeyCard = page.locator('[data-testid="journey-track-card"]') - await expect(journeyCard).toBeVisible() + const subBar = page.getByTestId('overview-subbar') + await expect(subBar).toBeVisible() + // The full drawer must not also be showing at this width. + await expect(page.getByTestId('minitoc')).toBeHidden() - // Branching text should be rendered with markdown links - await expect(journeyCard).toContainText('Want to skip ahead?') + await subBar.getByRole('button').click() + const menu = page.getByTestId('overview-menu') + await expect(menu).toBeVisible() - // AUTOTITLE should be resolved to actual article title - const branchingLink = journeyCard.locator('a').filter({ hasText: 'Hello World' }) - await expect(branchingLink).toBeVisible() - await expect(journeyCard).not.toContainText('AUTOTITLE') + const firstLink = menu.getByRole('link').first() + const href = await firstLink.getAttribute('href') + expect(href).toBeTruthy() + await firstLink.click() + expect(page.url()).toContain(href) + }) + + // Regression guard. Platform/tool-gated headings stay in the DOM with the + // `hidden` attribute, so they measure as an all-zero rect. Before + // useActiveSection filtered by the selection, such a heading always satisfied + // the "scrolled past" threshold, so the collapsed control could end up + // labelled with a section belonging to a platform the reader had not chosen. + test('the collapsed menu is never labelled with a hidden platform section', async ({ page }) => { + await page.setViewportSize({ width: 1100, height: 900 }) + await page.goto('/get-started/liquid/platform-specific?platform=windows') + await turnOffExperimentsInPage(page) - const href = await branchingLink.getAttribute('href') - expect(href).toContain('/get-started/start-your-journey/hello-world') + const trigger = page.getByTestId('overview-subbar').getByRole('button') + await expect(trigger).toBeVisible() + await expect(trigger).not.toContainText('Macintosh') + + // Scroll past the first heading so an active section is actually resolved. + await page.mouse.wheel(0, 2000) + await expect(trigger).not.toContainText('Macintosh') }) }) @@ -1832,32 +1932,34 @@ test.describe('copy as markdown button', () => { // (see src/fixtures/tests/api-article-body.ts), so the copy path succeeds. const articlePath = '/en/get-started/start-your-journey/api-article-body-test-page' - test('swaps the copy icon for a checkmark after a successful copy', async ({ page, context }) => { + test('shows a checkmark after a successful copy', async ({ page, context }) => { // The click handler writes the article markdown to the clipboard. await context.grantPermissions(['clipboard-read', 'clipboard-write']) await page.goto(articlePath) await turnOffExperimentsInPage(page) - const copyButton = page.getByRole('button', { name: 'Copy as Markdown' }) + // `exact` matters: accessible-name matching is substring-based, so a bare + // 'Copy markdown' also matches the code-block copy buttons that articles + // with a ```markdown fence render ('Copy Markdown code to clipboard'). + const copyButton = page.getByRole('button', { name: 'Copy markdown', exact: true }) + await expect(copyButton).toHaveCount(1) await expect(copyButton).toBeVisible() - // Before clicking, the leading icon is the copy icon, not the checkmark. - await expect(copyButton.locator('.octicon-copy')).toBeVisible() - await expect(copyButton.locator('.octicon-check')).toHaveCount(0) + // At rest the button is text-only — no icon at all. The checkmark below is + // purely the success state. + await expect(copyButton.locator('svg')).toHaveCount(0) await copyButton.click() - // After a successful copy, the icon swaps to a checkmark... + // After a successful copy, a checkmark appears... await expect(copyButton.locator('.octicon-check')).toBeVisible() - await expect(copyButton.locator('.octicon-copy')).toHaveCount(0) // ...and the article markdown lands on the clipboard. const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) expect(clipboardText).toContain('About GitHub') - // The checkmark is temporary and reverts to the copy icon (2s timeout). - await expect(copyButton.locator('.octicon-copy')).toBeVisible({ timeout: 5000 }) - await expect(copyButton.locator('.octicon-check')).toHaveCount(0) + // The checkmark is temporary and clears again (2s timeout). + await expect(copyButton.locator('.octicon-check')).toHaveCount(0, { timeout: 5000 }) }) }) diff --git a/src/frame/components/DefaultLayout.module.scss b/src/frame/components/DefaultLayout.module.scss index d71ccc296c89..b04e909fac86 100644 --- a/src/frame/components/DefaultLayout.module.scss +++ b/src/frame/components/DefaultLayout.module.scss @@ -1,8 +1,17 @@ +@import "@primer/css/support/variables/layout.scss"; +@import "@primer/css/support/mixins/layout.scss"; @import "src/frame/stylesheets/breakpoint-xxl.scss"; @import "@primer/react-brand/lib/design-tokens/scss/tokens/functional/size/breakpoints.scss"; .mainContent { scroll-margin-top: 5rem; + // Keep anything that overflows the main column from scrolling the page + // sideways. This backstopped the article section rules while they overshot + // ±1500px to reach the rails; those now span the body column and no further, + // so it is only a guard for wide content (tables, code) today. `clip` (not + // hidden/auto) creates no scroll container, so the sticky rails and secondary + // bar are unaffected. + overflow-x: clip; } // The search results page splits into rail + results at brand's `medium` @@ -14,13 +23,35 @@ } } -// When the inline mobile/tablet nav is open, it takes over the viewport, so hide -// the content column so the full-width rail isn't squeezed beside it. Above the -// xxl breakpoint the rail is a fixed-width sibling, so content always shows. +// Sticky descendants share the header/bar offset; the sub-bar adds 40px. +// These modifiers follow the same visibility state as OverviewSubBar. +.stickyStack { + --docs-sticky-stack: 110px; +} + +.stickyStackWithSubBar { + --docs-sticky-stack: 150px; +} + +// Match the drawer reveal breakpoints in DocsSecondaryBar.module.scss. +.stickyStackYieldsAtXxl { + @include breakpoint-xxl { + --docs-sticky-stack: 110px; + } +} + +.stickyStackYieldsWhenCollapsed { + @media (min-width: 1074px) { + --docs-sticky-stack: 110px; + } +} + +// The open inline nav replaces the content below lg. At lg the 326px desktop +// rail sits beside the content; this breakpoint must match SidebarNav. .contentHiddenForNav { display: none; - @include breakpoint-xxl { + @include breakpoint(lg) { display: flex; } } diff --git a/src/frame/components/DefaultLayout.tsx b/src/frame/components/DefaultLayout.tsx index ea174a327aa8..3bdd638b5a8d 100644 --- a/src/frame/components/DefaultLayout.tsx +++ b/src/frame/components/DefaultLayout.tsx @@ -5,7 +5,7 @@ import cx from 'classnames' import { SidebarNav } from '@/frame/components/sidebar/SidebarNav' import { Header } from '@/frame/components/page-header/Header' -import { DocsSecondaryBar } from '@/frame/components/page-header/DocsSecondaryBar' +import { DocsSecondaryBar, OverviewSubBar } from '@/frame/components/page-header/DocsSecondaryBar' import { SidebarCollapseProvider, useSidebarCollapsed, @@ -19,12 +19,20 @@ import { Breadcrumbs } from '@/frame/components/page-header/Breadcrumbs' import { useLanguages } from '@/languages/components/LanguagesContext' import { ClientSideLanguageRedirect } from './ClientSideLanguageRedirect' import { SearchOverlayContextProvider } from '@/search/components/context/SearchOverlayContext' +import { SelectionProvider } from '@/tools/components/SelectionContext' +import { ActiveSectionProvider, useMiniTocItems } from '@/frame/components/ui/MiniTocs' import styles from './DefaultLayout.module.scss' const MINIMAL_RENDER = Boolean(JSON.parse(process.env.MINIMAL_RENDER || 'false')) -type Props = { children?: React.ReactNode } +type Props = { + children?: React.ReactNode + // Whether this page renders the right-rail "In this article" drawer (article + + // automated pages do; REST reference pages do not). Controls whether the + // secondary bar's collapsed Overview menu yields to the drawer at xxl. + hasDrawer?: boolean +} export const DefaultLayout = (props: Props) => { const mainContext = useMainContext() const { @@ -234,10 +242,16 @@ export const DefaultLayout = (props: Props) => {
) : ( - <> - - {props.children} - + // SelectionProvider wraps both the secondary bar and the content so the + // bar's collapsed "In this article" menu (OverviewMenu) sees the same + // platform/tool selection as the article body and filters its headings + // accordingly. + + + + {props.children} + + )} @@ -249,8 +263,11 @@ export const DefaultLayout = (props: Props) => { // collapsed; on mobile it shows inline (in the page flow, like desktop) only // when the nav is opened from the secondary bar. The content column (flex-1) // fills the row when the rail is absent. -type LayoutBodyProps = { children?: React.ReactNode } -const LayoutBody = ({ children }: LayoutBodyProps) => { +type LayoutBodyProps = { + children?: React.ReactNode + hasDrawer?: boolean +} +const LayoutBody = ({ children, hasDrawer }: LayoutBodyProps) => { const { collapsed, mobileNavOpen } = useSidebarCollapsed() const { currentProduct } = useMainContext() // Matches SidebarNav's own gate rather than testing router.route. There are two search @@ -258,6 +275,10 @@ const LayoutBody = ({ children }: LayoutBodyProps) => { // for '/search' misses every versioned search URL, and this check would then disagree // with SidebarNav about whether the rail is a facet rail. const isSearchResultsPage = currentProduct?.id === 'search' + // Mirrors OverviewSubBar's own render gate (it returns null at <= 1 item), so + // the sticky-stack classes below describe the bar that actually renders. + const miniTocItems = useMiniTocItems() + const hasSubBar = miniTocItems.length > 1 return ( // `d-lg-flex` only goes side-by-side at 1012px. The search page's facet rail // is meant to sit beside the results from brand's `medium` breakpoint, so it @@ -280,10 +301,27 @@ const LayoutBody = ({ children }: LayoutBodyProps) => {
+ {/* Inside
, not before it: as a preceding sibling the "Skip to + main content" link jumped the reader straight past the page's only + in-article navigation. Still within the content column, so on + desktop it starts at the doc-tree drawer's right edge and runs to + the screen edge, sharing that band with the drawer rather than + cutting across above it. (.mainContent uses `overflow-x: clip`, + which creates no scroll container, so sticky still resolves against + the viewport.) */} + diff --git a/src/frame/components/article/ArticleGridLayout.module.scss b/src/frame/components/article/ArticleGridLayout.module.scss index 57d08a68d7ca..a7ca2dfa02ed 100644 --- a/src/frame/components/article/ArticleGridLayout.module.scss +++ b/src/frame/components/article/ArticleGridLayout.module.scss @@ -24,13 +24,266 @@ } } +// `tocBreakpoint="xxl"` variant: the right rail only appears at 1400+, because +// between 1012–1400 the collapsed "In this article" control lives in the +// secondary bar instead. So override the 1012 two-column split back to a single +// column, and re-apply the two-column grid at xxl (1400px). +.containerBoxXxl { + @media (min-width: 1012px) and (max-width: 1399.98px) { + max-width: 720px; + // Center the 720px content column in the space between the left rail and the + // viewport edge (the right drawer is hidden in this range). Without this the + // box left-aligns against the rail, leaving a large gap on the right — which + // the section-box borders make visibly off-center. + margin-left: auto; + margin-right: auto; + padding-top: 0; + grid-template-rows: none; + grid-template-columns: minmax(0, 1fr); + // The drawer cell is display:none here (see .sidebarColumnXxl), so leave + // `sidebar` out of the areas — the stacked layout is just topper/intro/content. + grid-template-areas: + "topper" + "intro" + "content"; + column-gap: 0; + row-gap: 8px; + } + + @media (min-width: 1400px) { + max-width: none; + padding-top: 24px; + // Cancel the article wrapper's horizontal padding (px-md-6 = 40px) so the grid + // spans symmetrically between the two 326px rails. The left rail is a + // full-bleed flex sibling OUTSIDE this grid while the drawer is a column + // INSIDE it; without this, the wrapper's left padding lands between the rail + // and content (but not between content and drawer), skewing the content right + // of center. Negative margins pull the grid back out to the rail edges. + margin-left: -40px; + margin-right: -40px; + grid-template-rows: auto 1fr; + // Fixed-width drawer (326px, matching the left rail) pinned right. The content + // column is centered between the two rails via equal flexible gaps. Its 785px + // max is the Figma prose measure (the 1440+ frame's 849px column less its + // 32px inset either side) — a little wider than before, which the removal of + // the vertical side rules and their 2rem padding makes room for. + grid-template-columns: + minmax(24px, 1fr) minmax(500px, 785px) minmax(24px, 1fr) + 326px; + column-gap: 0; + grid-template-areas: + ". topper . sidebar" + ". intro . sidebar" + ". content . sidebar"; + row-gap: 0; + } +} + +// Docs 2026 header/hero: the topper (the title) and intro (lede, then the +// copy-markdown control) are separated from the body by a single rule below the +// lede. There is no top rule — the header opens directly under the secondary +// bar — and no vertical sides at any width; the article is divided by horizontal +// rules alone. That rule spans the width of the article body, matching the +// section rules below it. +.heroTopper, +.heroIntro { + position: relative; +} + +// Only applies below the drawer breakpoints — at/above them the -48px pull-ups +// further down replace this padding. The article wrapper's own `mt-4` already +// puts 24px between the title and the bar above it; any padding on top of that +// made the header look top-heavy. +.heroTopper { + padding-top: 0; +} + +// Line the article title up with what the column beside it opens with — the +// rail's product title, which starts ~19px below the secondary bar. The grid's +// own top offset (24px padding-top + 24px wrapper my-4) would put the title at +// 48px, so pull the topper up by that and re-add half of it. The 24px lands the +// title's glyphs at 19px, since its 40px/48px line box overflows ~5px above its +// content box. (It was 48px when the copy-markdown button still led the hero.) +.containerBoxXxl .heroTopper { + @media (min-width: 1400px) { + margin-top: -48px; + padding-top: 24px; + } +} + +// Same treatment for the collapsed-rail variant, which brings the drawer layout +// in earlier (~1074px). +.containerBoxXxlCollapsed .heroTopper { + @media (min-width: 1074px) { + margin-top: -48px; + padding-top: 24px; + } +} + +// Bottom rule of the header box (below the lede). +.heroIntro { + padding-bottom: 2rem; +} + +.heroIntro::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + border-bottom: var(--brand-borderWidth-thin, max(1px, 0.0625rem)) solid + var(--brand-color-border-muted, #e4ebe6); + pointer-events: none; +} + +// `tocBreakpoint="xxl"` + left rail collapsed: the freed ~326px of rail width +// leaves room to bring the two-column drawer layout in earlier (~1074px) instead +// of waiting for xxl. Single column below 1074, two columns at/above it. +.containerBoxXxlCollapsed { + @media (min-width: 1012px) and (max-width: 1073.98px) { + max-width: 720px; + // Center the 720px content column between the rail and viewport edge (see + // .containerBoxXxl) — the drawer is hidden in this narrow collapsed range. + margin-left: auto; + margin-right: auto; + padding-top: 0; + grid-template-rows: none; + grid-template-columns: minmax(0, 1fr); + // The drawer cell is display:none here (see .sidebarColumnXxlCollapsed), so + // leave `sidebar` out — the stacked layout is just topper/intro/content. + grid-template-areas: + "topper" + "intro" + "content"; + column-gap: 0; + row-gap: 8px; + } + + @media (min-width: 1074px) { + max-width: none; + padding-top: 24px; + grid-template-rows: auto 1fr; + // Cancel the article wrapper's 40px horizontal padding so the grid spans + // symmetrically to the rail edges (see .containerBoxXxl). + margin-left: -40px; + margin-right: -40px; + // Fixed 326px drawer pinned right, content centered via equal flexible gaps + // (see .containerBoxXxl). + grid-template-columns: + minmax(24px, 1fr) minmax(500px, 785px) minmax(24px, 1fr) + 326px; + column-gap: 0; + grid-template-areas: + ". topper . sidebar" + ". intro . sidebar" + ". content . sidebar"; + row-gap: 0; + } +} + +// Outer sidebar cell. Below lg it stacks above the article with a bottom rule. +// At lg+ it stretches the full height of the grid row track (topper→content) and +// carries the vertical drawer rule, so the border runs the whole article height +// rather than only as tall as the sticky panel content. +.sidebarColumn { + // --borderColor-default is defined nowhere in this app, so this always painted + // the literal #d1d9e0 — a bright light-grey rule that stayed light-mode in dark + // mode. Brand's *muted* (not *default*) is the right swap: it is the token the + // hero rule above and the h2/closing section rules in article-section-framing + // already use, and the drawer is the same weight of divider as those. Brand's + // border-default (#b6bfb8) is far darker than the #d1d9e0 this was painting and + // would read as a heavier line than every other rule on the page. + border-bottom: var(--brand-borderWidth-thin, max(1px, 0.0625rem)) solid + var(--brand-color-border-muted, #e4ebe6); + + @media (min-width: 1012px) { + // Drawer treatment (Docs 2026): a full-height vertical rule separating the + // rail from the article body. The border sits at the toc column's left edge + // (the flexible gap column supplies the space on either side), so the two + // gutters stay symmetric and the content is centered between the rails. + border-bottom: 0; + // Same token as the stacked bottom rule above, for the same reason. + border-left: var(--brand-borderWidth-thin, max(1px, 0.0625rem)) solid + var(--brand-color-border-muted, #e4ebe6); + // The 24px inset from the rail to the panel content lives on .sidebarBox + // (padding-left) rather than here, so the scroll box spans flush to the rail + // and the "Up next" separator can reach the rail's border without being + // clipped by the box's overflow. + // Opaque canvas background + stacking context: the panel sits over anything + // that reaches it from the article column. (It used to cap the section + // rules' right overshoot; those now stop at the body column's edge.) + position: relative; + z-index: 1; + // HOLDBACK: stays on Primer's canvas token deliberately. Brand's + // --brand-color-canvas-default is pure #000 in dark mode, but the page behind + // it is still painted #0d1117 by @primer/css's `body` rule — so adopting the + // brand canvas here makes this surface a visibly darker panel floating on a + // lighter page. The canvas can only move once the page canvas does; those + // should change together in one commit. + background-color: var(--color-canvas-default); + // Extend the border up to the bottom of the secondary bar (like the left + // rail's divider). The grid's 24px padding-top + the article wrapper's 24px + // top margin (`my-4`) otherwise leave the border starting ~48px below the + // bar, so pull the cell up by that 48px. We deliberately do NOT re-add it as + // padding: the sticky panel pins flush under the bar (top: 110px), so keeping + // the resting position flush too means clicking a section (which engages the + // sticky) doesn't jump the list up by 48px. + margin-top: -48px; + } +} + +// `tocBreakpoint="xxl"` variant: the drawer (and its border/inset) only applies +// at 1400+. Below 1400 the sidebar cell is empty (MiniTocs renders nothing +// there — its content shows only at xxl), so drop the border/inset (including the +// base mobile bottom rule) to avoid a stray rule and empty spacer beside/above +// empty space. The 'pb-4 mb-5' utilities are also withheld in the TSX for xxl. +.sidebarColumnXxl { + @media (max-width: 1399.98px) { + // Drawer content is display:none below xxl, so hide the empty cell entirely. + // This removes it from the grid (no implicit row) and zeroes the sticky/padding + // height from .sidebarBox, avoiding a gap between the intro and the content. + display: none; + border-bottom: 0; + border-left: 0; + padding-left: 0; + margin-left: 0; + } +} + +// Collapsed variant: the drawer border/inset only applies at >=1074 (where the +// drawer content appears). Below 1074 the sidebar cell is empty, so drop the +// border/inset (including the base mobile bottom rule) to avoid a stray rule and +// empty spacer beside/above empty space. +.sidebarColumnXxlCollapsed { + @media (max-width: 1073.98px) { + // Hide the empty cell entirely below the collapsed drawer breakpoint (see + // .sidebarColumnXxl) so it contributes no row or height. + display: none; + border-bottom: 0; + border-left: 0; + padding-left: 0; + margin-left: 0; + } +} + +// Inner panel. Sticky so the "In this article" list follows the reader while the +// outer column (and its border) spans the whole article height. .sidebarBox { @media (min-width: 1012px) { position: sticky; - padding-top: 24px; - top: 5em; - max-height: calc(100vh - 5em); + // Stick below the sticky main header (65px) + Docs 2026 secondary bar (45px) + // so the "In this article" heading isn't slid under the bar on scroll, with a + // little breathing room above the heading. + top: 110px; + padding-top: 16px; + // 24px inset from the rail to the panel content (moved here from + // .sidebarColumn so the box spans flush to the rail — see there). + padding-left: 24px; + // Right padding so panel content (TOC items, Up next title) doesn't run up + // against the page's right edge. + padding-right: 24px; + max-height: calc(100vh - 110px); overflow-y: auto; - padding-bottom: 40px !important; + padding-bottom: 40px; } } diff --git a/src/frame/components/article/ArticleGridLayout.tsx b/src/frame/components/article/ArticleGridLayout.tsx index d305dcb43984..93d4cb43db52 100644 --- a/src/frame/components/article/ArticleGridLayout.tsx +++ b/src/frame/components/article/ArticleGridLayout.tsx @@ -1,6 +1,7 @@ import React from 'react' import cx from 'classnames' import { SupportPortalVaIframe, SupportPortalVaIframeProps } from './SupportPortalVaIframe' +import { useSidebarCollapsed } from '@/frame/components/sidebar/SidebarCollapseContext' import styles from './ArticleGridLayout.module.scss' @@ -12,6 +13,13 @@ type Props = { className?: string supportPortalVaIframeProps?: SupportPortalVaIframeProps fullWidth?: boolean + // At which breakpoint the right rail (toc) appears beside the content. + // 'lg' (default, 1012px) is the classic two-column split. 'xxl' (1400px) is + // used by article pages whose collapsed "In this article" control lives in the + // secondary bar between 1012–1400, so the rail should only appear at 1400+. + // When the left doc-tree rail is manually collapsed, the freed ~326px lets the + // drawer appear earlier (~1074px) — handled via the collapsed variant below. + tocBreakpoint?: 'lg' | 'xxl' } export const ArticleGridLayout = ({ intro, @@ -21,13 +29,35 @@ export const ArticleGridLayout = ({ className, supportPortalVaIframeProps, fullWidth, + tocBreakpoint = 'lg', }: Props) => { - const containerBoxStyles = fullWidth ? '' : styles.containerBox + const { collapsed } = useSidebarCollapsed() + // With the left rail collapsed there's room to bring the drawer in earlier + // than xxl. Only relevant to the 'xxl' consumers (article + automated pages). + const xxlCollapsed = tocBreakpoint === 'xxl' && collapsed + const containerBoxStyles = fullWidth + ? '' + : cx( + styles.containerBox, + tocBreakpoint === 'xxl' && + (xxlCollapsed ? styles.containerBoxXxlCollapsed : styles.containerBoxXxl), + ) return (
- {topper &&
{topper}
} + {topper && ( +
+ {topper} +
+ )} {intro && ( -
+
{intro}
)} @@ -35,10 +65,23 @@ export const ArticleGridLayout = ({ {toc && (
- {toc} +
{toc}
)} diff --git a/src/frame/components/article/ArticleInlineLayout.module.scss b/src/frame/components/article/ArticleInlineLayout.module.scss index 85c9e381b29c..0669ef091f2b 100644 --- a/src/frame/components/article/ArticleInlineLayout.module.scss +++ b/src/frame/components/article/ArticleInlineLayout.module.scss @@ -11,7 +11,6 @@ row-gap: 0; grid-template-areas: "topper" - "sidebar" "intro" "content"; grid-template-rows: auto 1fr; @@ -34,11 +33,3 @@ .articleContainer { max-width: 720px; } - -.sidebarBox { - border-bottom: 1px solid var(--color-border-default) !important; - - @include breakpoint-xxl { - border-bottom: none !important; - } -} diff --git a/src/frame/components/article/ArticleInlineLayout.tsx b/src/frame/components/article/ArticleInlineLayout.tsx index f148604b14b2..0da5d1bf222c 100644 --- a/src/frame/components/article/ArticleInlineLayout.tsx +++ b/src/frame/components/article/ArticleInlineLayout.tsx @@ -8,7 +8,6 @@ type Props = { intro?: React.ReactNode introCallOuts?: React.ReactNode topper?: React.ReactNode - toc?: React.ReactNode children?: React.ReactNode className?: string supportPortalVaIframeProps?: SupportPortalVaIframeProps @@ -17,7 +16,6 @@ export const ArticleInlineLayout = ({ intro, introCallOuts, topper, - toc, children, className, supportPortalVaIframeProps, @@ -39,15 +37,13 @@ export const ArticleInlineLayout = ({
)} - {toc && ( -
- {toc} -
- )} + {/* Deliberately no mini-TOC cell. On inline pages DefaultLayout passes + hasDrawer={false}, so the secondary bar's OverviewSubBar owns "In + this article" at every width. Rendering one here too gave an empty + bordered box between the title and the intro (the cell kept + .sidebarBox's !important border while MiniTocs' contents are + display:none below the drawer breakpoint), plus a second nav + landmark with the same label once the drawer revealed at 1400px. */}
import('@/frame/components/ClientSideRefresh'), { @@ -51,7 +50,7 @@ export const ArticlePage = () => { {intro && ( // Note the `_page-intro` is used by the popover preview cards // when it needs this text for in-page links. - + {intro} )} @@ -67,16 +66,52 @@ export const ArticlePage = () => { ) - const toc = ( + // An article with at most one heading and no journey track has nothing to put + // in the rail. Without this guard `toc` is a fragment wrapping two false + // conditionals — truthy — so ArticleGridLayout still renders the sidebar cell + // and paints its full-height border-left beside an empty 326px column at + // 1400px+. Mirrors AutomatedPage, which already guards this way. + const hasTocContent = isJourneyTrack || miniTocItems.length > 1 + const toc = hasTocContent ? ( <> - - {isJourneyTrack && } {miniTocItems.length > 1 && } + {isJourneyTrack && currentJourneyTrack && } + + ) : undefined + + // The title leads the column on its own; the copy-markdown control follows the + // lede below (see `introWithCopy`). + const topper = {title} + + // The copy-markdown control sits under the lede in ONE place — every width, + // both layouts. The two layouts differ only in where the intro callouts go: + // the grid takes them as part of `intro`, the inline layout as its own prop. + const introWithCopy = ( + <> + {introProp} + + + ) + + const gridIntro = ( + <> + {introWithCopy} + {introCalloutsProp} ) const articleContents = ( -
+ // `data-has-upnext` marks pages that render the full-width "Up next" band + // (journey tracks) so the section-box frame extends down 24px to meet it + // rather than stopping at the content bottom (see article-section-framing). +
{renderedPageHast ? ( ) : ( @@ -95,57 +130,57 @@ export const ArticlePage = () => { ) return ( - - - - - - {isDev && } - {router.pathname.includes('/rest/') && } - {currentLayout === 'inline' ? ( - <> - + {/* SelectionProvider is provided by DefaultLayout (wrapping both the + secondary bar and this content) so the collapsed TOC menu shares the + same platform/tool selection. */} + + + + {isDev && } + {router.pathname.includes('/rest/') && } + {currentLayout === 'inline' ? ( + <> + + {articleContents} + + {isJourneyTrack ? ( +
+ +
+ ) : null} + + ) : ( + <> + {/* On journey-track pages the "Up next" band below sits flush to the + article frame (no bottom margin here / no top margin on the band), + so the section-box side rails run down to meet the band's own top + border. Ordinary pages keep the standard my-4 bottom spacing. */} +
+ {title}} - intro={introProp} - introCallOuts={introCalloutsProp} + topper={topper} + tocBreakpoint="xxl" + intro={gridIntro} toc={toc} > {articleContents} - - {isJourneyTrack ? ( -
- -
- ) : null} - - ) : ( - <> -
- {title}} - intro={ - <> - {introProp} - {introCalloutsProp} - - } - toc={toc} - > - {articleContents} - -
+
+
- {isJourneyTrack ? ( -
- -
- ) : null} - - )} -
-
+ {isJourneyTrack ? ( +
+ +
+ ) : null} + + )} +
) } diff --git a/src/frame/components/article/ArticleTitle.module.scss b/src/frame/components/article/ArticleTitle.module.scss new file mode 100644 index 000000000000..d13dba6b3ae3 --- /dev/null +++ b/src/frame/components/article/ArticleTitle.module.scss @@ -0,0 +1,11 @@ +// Docs 2026: article title matches the Figma hero spec (node 795:41412) — +// Mona Sans 40px, weight 460, line-height 1.2. Mona Sans is a variable font, +// so the 460 weight renders precisely. +.title { + font-family: var(--brand-heading-fontFamily); + font-size: 2.5rem; // 40px + font-weight: 460; + line-height: 1.2; + letter-spacing: 0; + color: var(--brand-color-text-default, var(--color-fg-default)); +} diff --git a/src/frame/components/article/ArticleTitle.tsx b/src/frame/components/article/ArticleTitle.tsx index e9ebc3ef6864..6cf4f5f396ac 100644 --- a/src/frame/components/article/ArticleTitle.tsx +++ b/src/frame/components/article/ArticleTitle.tsx @@ -1,4 +1,6 @@ import React from 'react' +import cx from 'classnames' +import styles from './ArticleTitle.module.scss' type Props = { children: React.ReactNode @@ -6,7 +8,7 @@ type Props = { export const ArticleTitle = ({ children }: Props) => { return (
-

+

{children}

diff --git a/src/frame/components/article/ViewMarkdownButton.module.scss b/src/frame/components/article/ViewMarkdownButton.module.scss index cc7e032eb6e1..8bd39057b5d6 100644 --- a/src/frame/components/article/ViewMarkdownButton.module.scss +++ b/src/frame/components/article/ViewMarkdownButton.module.scss @@ -1,27 +1,113 @@ +// The copy-markdown control: a pill-shaped text button with a small rounded +// chevron button beside it, both on a subtle grey wash with no visible border. +// +// Every token below is one that actually resolves in this app. Several obvious +// candidates (--borderRadius-full, --bgColor-neutral-muted, --control-*) live in +// @primer/primitives files this app never imports, so they would silently fall +// back to their literals and stop responding to colour mode. The --brand-* ones +// come from @primer/react-brand's main.css, which src/frame/stylesheets/index.scss +// does import, and flip correctly under [data-color-mode="dark"]. + +// One pill. The chevron is the pill's right-hand end rather than a control +// sitting beside it, so there is no gap: the two halves share a background and +// meet seamlessly. They stay separate buttons because they do separate things — +// the label copies in one click, the chevron opens the menu — and each half +// darkens on its own hover, which is what shows where the split is. +.controls { + display: inline-flex; + align-items: center; +} + +// Shared box for both controls. +// +// The pair is assembled from two different libraries — the label is a +// @primer/react-brand Button, the chevron a @primer/react ActionMenu.Button — so +// several rules here exist purely to make them agree. Where a doubled class or +// !important appears, it is beating a library rule; the specificity that forced +// it is noted. .button { - font-size: 12px; - padding: 4px 8px; - background-color: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + height: var(--brand-control-small-size, 2rem); + min-height: var(--brand-control-small-size, 2rem); + // The label otherwise inherits the document's system stack, which reads as a + // different typeface from the Mona Sans title and lede just above it. + font-family: var(--brand-fontStack-sansSerif, "Mona Sans", sans-serif); + font-size: var(--brand-text-size-100, 0.875rem); + font-weight: var(--base-text-weight-medium, 500); + color: var(--brand-color-text-default, #000); + border: 0; + // @primer/react's ButtonBase leaves a drop shadow on the chevron that the + // brand Button does not have, so at rest the two looked subtly different. + box-shadow: none !important; cursor: pointer; } +// Backgrounds need !important: brand's hover rule is a 7-class selector +// (`.Button--secondary:not(.Button[disabled]):not(…):not([aria-expanded='true']):hover`) +// that outranks anything reasonable here. It also outranked brand's OWN :active +// rule, which is why the label had no pressed state at all. :active is declared +// after :hover so it still wins when both match. +.button.button { + background-color: var( + --brand-button-secondary-bgColor-rest, + rgba(0, 0, 0, 0.06) + ) !important; + + &:hover { + background-color: var( + --brand-button-secondary-bgColor-hover, + rgba(0, 0, 0, 0.1164) + ) !important; + } + + &:active { + background-color: var( + --brand-button-secondary-bgColor-active, + rgba(0, 0, 0, 0.2104) + ) !important; + } + + // One focus ring for both. Brand draws 4px OUTSIDE, @primer/react 2px INSIDE, + // in different blues, so tabbing across the pair jumped from an outer halo to + // an inner hairline — and brand's outer ring came within 2px of the chevron. + &:focus-visible { + outline: 2px solid var(--brand-color-focus, #0377ff) !important; + outline-offset: 2px !important; + } +} + +// The pill's left end: rounded outside, square where it meets the chevron. The +// radius token is 624.9375rem rather than a 9999px-style value, which is fine — +// nothing clips it. .copyButton { - // Brand Button defaults to a 12px gap, a 16px leading-visual icon, and lets - // the label wrap. The narrow TOC column can't fit that, so tighten the - // icon/text spacing, shrink the icon back to the pre-migration 12px, and keep - // the label on a single line. Scoped to the copy button so the sibling - // dropdown's 16px triangle icon is left untouched. - gap: 4px; + border-radius: var(--brand-borderRadius-full, 624.9375rem) 0 0 + var(--brand-borderRadius-full, 624.9375rem); + // Less padding on the seam side so the label sits closer to the chevron than + // to the pill's outer edge. + padding: 0 8px 0 16px; + gap: 8px; white-space: nowrap; svg { - height: 12px; - width: 12px; + height: 16px; + width: 16px; } +} - // The brand Button renders its label through an internal Text component fixed - // at 14px; drop it to 12px to match the compact pre-migration button. - [class*="Button--label"] { - font-size: 12px; - } +// The pill's right end: square at the seam, rounded outside. Width matches the +// height for an even chevron target. +.dropdownButton { + border-radius: 0 var(--brand-borderRadius-full, 624.9375rem) + var(--brand-borderRadius-full, 624.9375rem) 0; + width: 28px; + padding: 0 4px 0 0; +} + +// The control's placement below the article lede. The margin sets the gap from +// the lede — the Lead's own `mb-3` collapses into it, so this value wins — while +// the header's bottom padding provides the space down to the rule beneath. +.belowIntroPlacement { + margin-top: 1.5rem; } diff --git a/src/frame/components/article/ViewMarkdownButton.tsx b/src/frame/components/article/ViewMarkdownButton.tsx index 8339a9c6b342..d85be6a60b87 100644 --- a/src/frame/components/article/ViewMarkdownButton.tsx +++ b/src/frame/components/article/ViewMarkdownButton.tsx @@ -7,7 +7,7 @@ import { LinkExternalIcon, TriangleDownIcon, } from '@primer/octicons-react' -import { ActionList, ActionMenu, ButtonGroup, VisuallyHidden } from '@primer/react' +import { ActionList, ActionMenu, VisuallyHidden } from '@primer/react' import { Button } from '@primer/react-brand' import { announce } from '@primer/live-region-element' import { MARKDOWN_SOURCE_MENU_EVENT_GROUP } from '@/events/components/event-groups' @@ -85,79 +85,97 @@ export const CopyMarkdownMenu = ({ currentPath }: CopyMarkdownMenuProps) => { }, [markdownUrl, t]) return ( -
- - - - - - - - - - - {t('copy_as_markdown')} - - {t('copy_as_markdown_desc')} - - - - - - - {t('view_as_markdown')} - {t('opens_in_new_tab')} - - {t('view_as_markdown_desc')} - - - - - - - - - {t('ask_copilot')} - {t('opens_in_new_tab')} - - {t('ask_copilot_desc')} - - - - - - - - + // Two separate buttons, NOT a Primer ButtonGroup. They read as a single pill + // — the chevron is the pill's right-hand end — but ButtonGroup imposes its + // own fusing (border-radius: 0 on the inner buttons, margin-inline-end: -1px + // on the wrappers), which fights the radii the module sets. They stay + // separate elements because they do separate things: the label copies in one + // click, the chevron opens the menu. +
+ + + {/* `icon` is load-bearing: ActionMenu.Button renders an icon-only + button off it, so it must stay even though the styling is ours. */} + + + + + + + + {t('copy_as_markdown')} + + {t('copy_as_markdown_desc')} + + + + + + + {t('view_as_markdown')} + {t('opens_in_new_tab')} + + {t('view_as_markdown_desc')} + + + + + + + + + {t('ask_copilot')} + {t('opens_in_new_tab')} + + {t('ask_copilot_desc')} + + + + + + +
) } -/** @deprecated Use CopyMarkdownMenu instead */ -export const ViewMarkdownButton = CopyMarkdownMenu +// The single placement of the copy-markdown control: below the article lede, at +// every width and in both article layouts. It used to move into the right-hand +// "In this article" drawer once that drawer appeared, with a second copy here as +// the fallback; it now lives in one place, so there is no visibility pairing and +// no dependence on the sidebar collapse state. +export const CopyMarkdownBelowIntro = ({ currentPath }: CopyMarkdownMenuProps) => { + return ( +
+ +
+ ) +} diff --git a/src/frame/components/page-header/Breadcrumbs.tsx b/src/frame/components/page-header/Breadcrumbs.tsx index d9be4e382051..f77fad5981b1 100644 --- a/src/frame/components/page-header/Breadcrumbs.tsx +++ b/src/frame/components/page-header/Breadcrumbs.tsx @@ -97,19 +97,30 @@ export const Breadcrumbs = ({ inHeader, variant }: Props) => { ) } + // The last crumb is the page being viewed, so it isn't a link to + // itself: brand's `selected` renders it as static text carrying + // aria-current="page" (and pointer-events: none) instead of an . + const isCurrent = i === arr.length - 1 return ( handleClick(event, breadcrumb.href!)} - onMouseEnter={() => prefetch(breadcrumb.href!)} - onFocus={() => prefetch(breadcrumb.href!)} + selected={isCurrent} + // No navigation or prefetch for the page you're already on. + {...(isCurrent + ? {} + : { + onClick: (event: MouseEvent) => + handleClick(event, breadcrumb.href!), + onMouseEnter: () => prefetch(breadcrumb.href!), + onFocus: () => prefetch(breadcrumb.href!), + })} className={cx( // Show the last breadcrumb if it's in the header/bar, but not if it's in the article. // If there's only 1 breadcrumb, show it. - hideLastCrumb && i === arr.length - 1 && arr.length !== 1 && 'd-none', + hideLastCrumb && isCurrent && arr.length !== 1 && 'd-none', )} > {breadcrumb.title} diff --git a/src/frame/components/page-header/BreadcrumbsScroller.module.scss b/src/frame/components/page-header/BreadcrumbsScroller.module.scss index daa100f3a333..79af41b8d8d2 100644 --- a/src/frame/components/page-header/BreadcrumbsScroller.module.scss +++ b/src/frame/components/page-header/BreadcrumbsScroller.module.scss @@ -24,8 +24,11 @@ top: 50%; transform: translateY(-50%); z-index: 1; - color: var(--fgColor-muted, var(--color-fg-muted)); - background-color: var(--bgColor-default, var(--color-canvas-default)); + color: var(--brand-color-text-muted, #58635b); + // Must stay whatever opaque token DocsSecondaryBar paints on `.bar`, or a crumb + // scrolling underneath shows through. That is currently Primer's canvas — see + // the holdback note in DocsSecondaryBar.module.scss. + background-color: var(--color-canvas-default); } .rightChevron { @@ -34,8 +37,9 @@ top: 50%; transform: translateY(-50%); z-index: 1; - color: var(--fgColor-muted, var(--color-fg-muted)); - background-color: var(--bgColor-default, var(--color-canvas-default)); + color: var(--brand-color-text-muted, #58635b); + // Must match `.bar` — see .leftChevron. + background-color: var(--color-canvas-default); } // On hover, keep the solid canvas background (Primer's invisible IconButton @@ -44,11 +48,8 @@ // 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)); + background-color: var(--color-canvas-default) !important; + color: var(--brand-color-text-default, #000000); svg { stroke: currentColor; diff --git a/src/frame/components/page-header/DocsSecondaryBar.module.scss b/src/frame/components/page-header/DocsSecondaryBar.module.scss index 2bfd5df98514..63d05d97d266 100644 --- a/src/frame/components/page-header/DocsSecondaryBar.module.scss +++ b/src/frame/components/page-header/DocsSecondaryBar.module.scss @@ -2,45 +2,44 @@ // segment matches the 326px rail width (toggle cell + breadcrumbs) and carries // the rail's right divider; a bottom border separates the bar from the content // and nav below. On mobile the left segment spans the full width. +@import "@primer/css/support/variables/layout.scss"; +@import "@primer/css/support/mixins/layout.scss"; @import "../../stylesheets/breakpoint-xxl.scss"; .bar { display: flex; align-items: stretch; - border-bottom: 1px solid var(--borderColor-muted, var(--color-border-muted)); - background-color: var(--bgColor-default, var(--color-canvas-default)); + // Brand tokens: the bar's rule matches the article's section rules + // (--brand-color-border-muted) and its surface is the brand canvas. The + // canvas token is shared with BreadcrumbsScroller, whose chevrons repaint it + // to mask crumbs scrolling underneath — keep the two in step. + border-bottom: 1px solid var(--brand-color-border-muted, #e4ebe6); + // HOLDBACK: stays on Primer's canvas token deliberately. Brand's + // --brand-color-canvas-default is pure #000 in dark mode, but the page behind + // it is still painted #0d1117 by @primer/css's `body` rule — so adopting the + // brand canvas here makes this surface a visibly darker panel floating on a + // lighter page. The canvas can only move once the page canvas does; those + // should change together in one commit. + background-color: var(--color-canvas-default); - // Stick just below the sticky main header (65px) on desktop so breadcrumbs + - // collapse toggle stay in view while the content scrolls. - @include breakpoint-xxl { - position: sticky; - top: 65px; - z-index: 1; - } + // Stick just below the sticky main header (65px, sticky at every width) so the + // breadcrumbs + collapse toggle — and, below lg, the "In this article" sub-bar + // row that follows — stay in view while the content scrolls. z-index 2 keeps + // the bar (and its dropdown) above sticky table headers (z-index 1). + position: sticky; + top: 65px; + z-index: 2; } +// The breadcrumb segment spans the whole bar at every width. It deliberately +// does NOT match the 326px rail width / carry the rail's divider: the trail is +// the bar's content, so cramming it into the rail's column truncated it early +// while the rest of the bar sat empty. .leftSegment { display: flex; align-items: stretch; flex: 1 1 auto; min-width: 0; - - // Match the fixed rail width and continue its right divider up through the bar. - @include breakpoint-xxl { - flex: 0 0 326px; - width: 326px; - border-right: 1px solid var(--borderColor-muted, var(--color-border-muted)); - } -} - -// When the rail is collapsed there is no rail to align to, so the breadcrumbs -// span the full width and the rail divider is dropped. -.leftSegmentExpanded { - @include breakpoint-xxl { - flex: 1 1 auto; - width: auto; - border-right: 0; - } } // The nav toggle sits in its own cell with a divider between it and the @@ -50,16 +49,16 @@ align-items: center; flex: 0 0 auto; padding: 0 12px; - border-right: 1px solid var(--borderColor-muted, var(--color-border-muted)); + border-right: 1px solid var(--brand-color-border-muted, #e4ebe6); } -// The desktop rail-collapse toggle shows only at the xxl breakpoint; the mobile -// nav trigger shows only below it. The repo's d-xxl-* utilities cover block/none -// but not inline-flex, so gate these here. +// The desktop rail-collapse toggle shows only at lg+ (where the rail exists); +// the mobile nav trigger shows only below it. The repo's d-lg-* utilities cover +// block/none but not inline-flex, so gate these here. .desktopOnly { display: none !important; - @include breakpoint-xxl { + @include breakpoint(lg) { display: inline-flex !important; } } @@ -67,7 +66,49 @@ .mobileOnly { display: inline-flex !important; - @include breakpoint-xxl { + @include breakpoint(lg) { display: none !important; } } + +// The "In this article" control's row, directly beneath the breadcrumb bar. +// Rendered inside the content column (see DefaultLayout), so on desktop it +// begins at the doc-tree drawer's right edge and runs to the screen edge — +// sharing that band with the drawer rather than cutting across above it. With +// the drawer collapsed or hidden the column is full width, so the row is too. +// The modifiers below hide it once the right-rail drawer takes over the mini-TOC. +.overviewSubBar { + display: flex; + align-items: center; + width: 100%; + // Explicit so the sticky stack is a known constant: DefaultLayout.module.scss + // publishes 110px + this height as `--docs-sticky-stack`, which table.scss + // reads to offset sticky table headers. Changing this height means changing + // the 150px there too. + min-height: 40px; + border-bottom: 1px solid var(--brand-color-border-muted, #e4ebe6); + // Canvas holdback — see the note on `.bar` above. + background-color: var(--color-canvas-default); + + // Stick directly beneath the breadcrumb bar (header 65px + bar 45px = 110px) so + // the "In this article" control stays pinned to the top while the article + // scrolls. z-index matches the bar to stay above sticky table headers. + position: sticky; + top: 110px; + z-index: 2; +} + +// Pages with a right-rail drawer, rail expanded: the drawer takes over at xxl. +.overviewSubBarUntilDrawer { + @include breakpoint-xxl { + display: none; + } +} + +// Pages with a right-rail drawer, rail collapsed: the freed rail width lets the +// drawer appear earlier (~1074px), so the row yields from there. +.overviewSubBarUntilDrawerCollapsed { + @media (min-width: 1074px) { + display: none; + } +} diff --git a/src/frame/components/page-header/DocsSecondaryBar.tsx b/src/frame/components/page-header/DocsSecondaryBar.tsx index 8e5ca4b95b1b..56b618689d8c 100644 --- a/src/frame/components/page-header/DocsSecondaryBar.tsx +++ b/src/frame/components/page-header/DocsSecondaryBar.tsx @@ -6,6 +6,7 @@ import { SidebarCollapseIcon, SidebarExpandIcon } from '@primer/octicons-react' import { useMainContext } from '@/frame/components/context/MainContext' import { useTranslation } from '@/languages/components/useTranslation' import { useSidebarCollapsed } from '@/frame/components/sidebar/SidebarCollapseContext' +import { OverviewMenu, useMiniTocItems } from '@/frame/components/ui/MiniTocs' import { BreadcrumbsScroller } from './BreadcrumbsScroller' import styles from './DocsSecondaryBar.module.scss' @@ -14,6 +15,10 @@ import styles from './DocsSecondaryBar.module.scss' // rail + article content. Holds the nav trigger and the breadcrumb trail. The // same sidebar collapse/expand icon is used on both desktop (collapses the rail) // and mobile (expands the nav inline). +// +// It also hosts the collapsed "In this article" control (`OverviewMenu`), which +// renders as `OverviewSubBar` below — a row beneath this bar, placed inside the +// content column so it sits beside the doc-tree drawer rather than above it. export const DocsSecondaryBar = () => { const router = useRouter() const { isHomepageVersion, currentProduct } = useMainContext() @@ -33,41 +38,85 @@ export const DocsSecondaryBar = () => { } return ( -
-
- {!isEarlyAccessPage && ( -
- {/* Desktop: collapse/expand the whole rail. */} - - {/* Mobile: expand/collapse the inline nav, using the same icon. */} - -
- )} - {/* Remount per route so the scroller re-anchors to the new trail's end. - Its anchor effect only fires on mount + outer-width change; a - client-side nav to a longer trail grows the inner scroll width - without changing the outer width, so without this the stale - scrollLeft would leave the new current page off-screen. */} - + <> +
+
+ {!isEarlyAccessPage && ( +
+ {/* Desktop: collapse/expand the whole rail. */} + + {/* Mobile: expand/collapse the inline nav, using the same icon. */} + +
+ )} + {/* Remount per route so the scroller re-anchors to the new trail's end. + Its anchor effect only fires on mount + outer-width change; a + client-side nav to a longer trail grows the inner scroll width + without changing the outer width, so without this the stale + scrollLeft would leave the new current page off-screen. */} + +
+ + ) +} + +// The "In this article" control as its own row directly beneath the breadcrumb +// bar. It is rendered INSIDE the content column (see DefaultLayout's LayoutBody) +// rather than as a page-wide row, so on desktop it starts at the doc-tree +// drawer's right edge and runs to the screen edge — sharing that horizontal band +// with the drawer instead of cutting across above it. When the drawer is absent +// (collapsed, or below lg) the content column is full width, so the row is too. +// +// It shows wherever the right-rail drawer isn't holding the mini-TOC: below xxl +// with the rail expanded, below ~1074 with it collapsed, and at every width on +// pages without a drawer (e.g. REST reference). +export const OverviewSubBar = ({ hasDrawer = false }: { hasDrawer?: boolean }) => { + const router = useRouter() + const { isHomepageVersion } = useMainContext() + const { collapsed } = useSidebarCollapsed() + const miniTocItems = useMiniTocItems() + + const isSearchResultsPage = router.route === '/search' + + // Match the bar's own visibility rule — the two are a pair. + if (isHomepageVersion || isSearchResultsPage) { + return null + } + if (miniTocItems.length <= 1) { + return null + } + + return ( +
+
) } diff --git a/src/frame/components/sidebar/SidebarCollapseContext.tsx b/src/frame/components/sidebar/SidebarCollapseContext.tsx index 7983d994743c..36a9ffedab43 100644 --- a/src/frame/components/sidebar/SidebarCollapseContext.tsx +++ b/src/frame/components/sidebar/SidebarCollapseContext.tsx @@ -84,17 +84,15 @@ export function SidebarCollapseProvider({ setMobileNavOpen(false) }, [asPath]) - // Growing the viewport to the desktop (xxl) layout takes over from the inline - // mobile nav and hides its toggle, so close the mobile nav when we cross the - // breakpoint. Otherwise its open state stays stuck and keeps the content - // column hidden. 1400px mirrors breakpoint-xxl.scss. + // Close the inline nav when the desktop rail takes over. Keep 1012px aligned + // with SidebarNav's lg breakpoint and DefaultLayout's content visibility. useEffect(() => { if (typeof window === 'undefined' || !window.matchMedia) return - const mql = window.matchMedia('(min-width: 1400px)') + const mql = window.matchMedia('(min-width: 1012px)') const handle = (e: MediaQueryListEvent | MediaQueryList) => { if (e.matches) setMobileNavOpen(false) } - handle(mql) // close immediately if already at/above xxl on mount + handle(mql) // close immediately if already at/above lg on mount mql.addEventListener('change', handle) return () => mql.removeEventListener('change', handle) }, []) diff --git a/src/frame/components/sidebar/SidebarNav.module.scss b/src/frame/components/sidebar/SidebarNav.module.scss index 27657330fe02..abaed40e7f2b 100644 --- a/src/frame/components/sidebar/SidebarNav.module.scss +++ b/src/frame/components/sidebar/SidebarNav.module.scss @@ -1,34 +1,61 @@ +@import "@primer/css/support/variables/layout.scss"; +@import "@primer/css/support/mixins/layout.scss"; @import "src/frame/stylesheets/breakpoint-xxl.scss"; @import "@primer/react-brand/lib/design-tokens/scss/tokens/functional/size/breakpoints.scss"; .sidebarFull { - @include breakpoint-xxl { + @include breakpoint(lg) { // Fixed width for consistent sidebar layout width: 326px; - // 109px accounts for the header (65px) + the Docs 2026 secondary bar (44px) - height: calc(100vh - 109px); - top: 109px; + // Sticky stack = header (65px) + Docs 2026 secondary bar (45px) = 110px. + // Match `top` to the stack exactly so the rail doesn't jump 1px when the + // sticky positioning engages on scroll. + height: calc(100vh - 110px); + top: 110px; + } +} + +// The