diff --git a/content/code-security/concepts/secret-security/secret-scanning.md b/content/code-security/concepts/secret-security/secret-scanning.md index 7746028dab8e..9e59b32b436a 100644 --- a/content/code-security/concepts/secret-security/secret-scanning.md +++ b/content/code-security/concepts/secret-security/secret-scanning.md @@ -59,7 +59,9 @@ Beyond the default detection of partner and provider secrets, you can expand and * **Generic patterns.** Expand detection to secrets that aren't tied to a specific service provider, such as private keys, connection strings, and generic API keys. * **Custom patterns.** Define your own regular expressions to detect organization-specific secrets that aren't covered by default patterns. +{% ifversion secret-scanning-validity-check-partner-patterns %} * **Validity checks.** Prioritize remediation by checking whether detected secrets are still active. +{% endif %} {% ifversion secret-scanning-ai-generic-secret-detection %} * **{% data variables.secret-scanning.ai-detected-secrets-caps %}.** Use AI to detect unstructured secrets like passwords. {% endif %} diff --git a/content/code-security/concepts/secret-security/validity-checks.md b/content/code-security/concepts/secret-security/validity-checks.md index 52d3876dd7cb..bbdc4d814f78 100644 --- a/content/code-security/concepts/secret-security/validity-checks.md +++ b/content/code-security/concepts/secret-security/validity-checks.md @@ -1,7 +1,7 @@ --- title: Validity checks shortTitle: Validity checks -intro: Validity checks and extended metadata checks help you prioritize remediation of exposed credentials that pose immediate security risks. +intro: Validity checks{% ifversion secret-scanning-extended-metadata-checks %} and extended metadata checks{% endif %} help you prioritize remediation of exposed credentials that pose immediate security risks. product: | {% data reusables.gated-features.secret-scanning %}{% ifversion secret-risk-assessment %} @@ -33,6 +33,8 @@ Some secrets require more than the token itself to confirm whether they are acti {% data variables.product.github %} displays the validation status of the secret in the alert view, so you can see if the secret is `active`, `inactive`, or if the validation status is `unknown`. For most secrets, you can optionally perform an "on-demand" validity check for the secret in the alert view. +{% ifversion secret-scanning-extended-metadata-checks %} + ## About extended metadata checks {% data reusables.security-configurations.extended-metadata-checks %} @@ -49,14 +51,16 @@ You can enable extended metadata checks if validity checks are enabled. Then, yo The specific metadata available depends on what the service provider shares with {% data variables.product.github %}. Not all secret types support extended metadata checks. For more information, see [AUTOTITLE](/code-security/tutorials/remediate-leaked-secrets/evaluating-alerts). -## Getting started with validity and extended metadata checks +{% endif %} + +## Getting started with validity{% ifversion secret-scanning-extended-metadata-checks %} and extended metadata{% endif %} checks -You can enable validity and extended metadata checks at the repository, organization, or enterprise level to help prioritize which exposed credentials pose the most immediate security risks. +You can enable validity{% ifversion secret-scanning-extended-metadata-checks %} and extended metadata{% endif %} checks at the repository, organization, or enterprise level to help prioritize which exposed credentials pose the most immediate security risks. For large organizations, we recommend using **security configurations** to enable these features at the organization or enterprise level. Security configurations allow you to centrally manage {% data variables.product.prodname_secret_scanning %} settings and apply them consistently across many repositories. To get started: -* For repositories, see [AUTOTITLE](/code-security/how-tos/secure-your-secrets/customize-leak-detection/enable-validity-checks) +* For repositories, see [AUTOTITLE](/code-security/how-tos/secure-your-secrets/customize-leak-detection/enable-validity-checks){% ifversion secret-scanning-extended-metadata-checks %} and [AUTOTITLE](/code-security/how-tos/secure-your-secrets/customize-leak-detection/enable-metadata-checks){% endif %} * For an organization, see [AUTOTITLE](/code-security/how-tos/secure-at-scale/configure-organization-security/establish-complete-coverage/create-custom-configuration) * For an enterprise, see [AUTOTITLE](/code-security/how-tos/secure-at-scale/configure-enterprise-security/establish-complete-coverage/create-custom-configuration) diff --git a/content/code-security/how-tos/secure-at-scale/configure-enterprise-security/establish-complete-coverage/create-custom-configuration.md b/content/code-security/how-tos/secure-at-scale/configure-enterprise-security/establish-complete-coverage/create-custom-configuration.md index 15cc256d2e3e..48b3ab20d714 100644 --- a/content/code-security/how-tos/secure-at-scale/configure-enterprise-security/establish-complete-coverage/create-custom-configuration.md +++ b/content/code-security/how-tos/secure-at-scale/configure-enterprise-security/establish-complete-coverage/create-custom-configuration.md @@ -43,7 +43,7 @@ When creating a security configuration, keep in mind that: 1. To help identify your {% data variables.product.prodname_custom_security_configuration %} and clarify its purpose on the "{% data variables.product.prodname_security_configurations_caps %}" page, name your configuration and create a description. 1. Optionally, enable "{% data variables.product.prodname_secret_protection %}", a paid feature for private {% ifversion ghec %}and internal {% endif %} repositories. Enabling {% data variables.product.prodname_secret_protection %} enables alerts for {% data variables.product.prodname_secret_scanning %}. In addition, you can choose whether to enable, disable, or keep the existing settings for the following {% data variables.product.prodname_secret_scanning %} features:{% ifversion secret-scanning-validity-check-partner-patterns %} * **Validity checks**. To learn more about validity checks for partner patterns, see [AUTOTITLE](/code-security/tutorials/remediate-leaked-secrets/evaluating-alerts#checking-a-secrets-validity).{% ifversion ghes > 3.19 %} - Your site administrator must enable validity checks before you can use this feature. See [AUTOTITLE](/code-security/how-tos/secure-at-scale/configure-enterprise-security/configure-specific-tools/configure-secret-scanning).{% endif %}{% endif %}{% ifversion fpt or ghec %} + Your site administrator must enable validity checks before you can use this feature. See [AUTOTITLE](/code-security/how-tos/secure-at-scale/configure-enterprise-security/configure-specific-tools/configure-secret-scanning).{% endif %}{% endif %}{% ifversion secret-scanning-extended-metadata-checks %} * **Extended metadata**. To learn more about extended metadata checks, see [About extended metadata checks](/code-security/concepts/secret-security/validity-checks#about-extended-metadata-checks) and [AUTOTITLE](/code-security/tutorials/remediate-leaked-secrets/evaluating-alerts#reviewing-extended-metadata-for-a-token). > [!NOTE] > You can only enable extended metadata checks if validity checks are enabled.{% endif %} diff --git a/content/code-security/how-tos/secure-at-scale/configure-organization-security/establish-complete-coverage/create-custom-configuration.md b/content/code-security/how-tos/secure-at-scale/configure-organization-security/establish-complete-coverage/create-custom-configuration.md index 94312dce266b..ea2fef71d58b 100644 --- a/content/code-security/how-tos/secure-at-scale/configure-organization-security/establish-complete-coverage/create-custom-configuration.md +++ b/content/code-security/how-tos/secure-at-scale/configure-organization-security/establish-complete-coverage/create-custom-configuration.md @@ -51,7 +51,7 @@ When creating a security configuration, keep in mind that: 1. To help identify your {% data variables.product.prodname_custom_security_configuration %} and clarify its purpose on the "{% data variables.product.prodname_security_configurations_caps %}" page, name your configuration and create a description. 1. Optionally, enable "{% data variables.product.prodname_secret_protection %}", a paid feature for private {% ifversion ghec %}and internal {% endif %} repositories. Enabling {% data variables.product.prodname_secret_protection %} enables alerts for {% data variables.product.prodname_secret_scanning %}. In addition, you can choose whether to enable, disable, or keep the existing settings for the following {% data variables.product.prodname_secret_scanning %} features:{% ifversion secret-scanning-validity-check-partner-patterns %} * **Validity checks**. To learn more about validity checks for partner patterns, see [AUTOTITLE](/code-security/tutorials/remediate-leaked-secrets/evaluating-alerts#checking-a-secrets-validity).{% ifversion ghes > 3.19 %} - Your site administrator must enable validity checks before you can use this feature. See [AUTOTITLE](/code-security/how-tos/secure-at-scale/configure-enterprise-security/configure-specific-tools/configure-secret-scanning).{% endif %}{% endif %}{% ifversion fpt or ghec %} + Your site administrator must enable validity checks before you can use this feature. See [AUTOTITLE](/code-security/how-tos/secure-at-scale/configure-enterprise-security/configure-specific-tools/configure-secret-scanning).{% endif %}{% endif %}{% ifversion secret-scanning-extended-metadata-checks %} * **Extended metadata**. To learn more about extended metadata checks, see [About extended metadata checks](/code-security/concepts/secret-security/validity-checks#about-extended-metadata-checks) and [AUTOTITLE](/code-security/tutorials/remediate-leaked-secrets/evaluating-alerts#reviewing-extended-metadata-for-a-token). > [!NOTE] > You can only enable extended metadata checks if validity checks are enabled.{% endif %} diff --git a/content/code-security/reference/secret-security/supported-secret-scanning-patterns.md b/content/code-security/reference/secret-security/supported-secret-scanning-patterns.md index c61ce8046687..426def18926f 100644 --- a/content/code-security/reference/secret-security/supported-secret-scanning-patterns.md +++ b/content/code-security/reference/secret-security/supported-secret-scanning-patterns.md @@ -42,11 +42,15 @@ If you use the REST API for {% data variables.product.prodname_secret_scanning % | Push protection (default) | {% octicon "x" aria-label="Not supported" %} | {% octicon "x" aria-label="Not supported" %} | {% octicon "check" aria-label="Supported" %} (most) | | Push protection (configurable) | {% octicon "check" aria-label="Supported" %} | {% octicon "x" aria-label="Not supported" %} | Some | | Validity checks | {% octicon "x" aria-label="Not supported" %} | {% octicon "x" aria-label="Not supported" %} | Some | -| Extended metadata | {% octicon "x" aria-label="Not supported" %} | {% octicon "x" aria-label="Not supported" %} | Some | +| Extended metadata | {% octicon "x" aria-label="Not supported" %} | {% octicon "x" aria-label="Not supported" %} | {% ifversion secret-scanning-extended-metadata-checks %}Some{% else %}{% data variables.product.company_short %} tokens only{% endif %} | | Base64 format support | {% octicon "x" aria-label="Not supported" %} | {% octicon "x" aria-label="Not supported" %} | Some | +{% ifversion secret-scanning-validity-check-partner-patterns %} + > [!NOTE] -> Validity and extended metadata checks are only available to users with {% data variables.product.prodname_team %} or {% data variables.product.prodname_enterprise %} who enable the feature as part of {% data variables.product.prodname_GH_secret_protection %}. +> Validity{% ifversion secret-scanning-extended-metadata-checks %} and extended metadata{% endif %} checks for partner patterns are only available to users with {% data variables.product.prodname_team %} or {% data variables.product.prodname_enterprise %} who enable the feature as part of {% data variables.product.prodname_GH_secret_protection %}. + +{% endif %} ## Supported generic patterns diff --git a/content/code-security/tutorials/remediate-leaked-secrets/evaluating-alerts.md b/content/code-security/tutorials/remediate-leaked-secrets/evaluating-alerts.md index 69f2634fe1a2..9bd8613c471a 100644 --- a/content/code-security/tutorials/remediate-leaked-secrets/evaluating-alerts.md +++ b/content/code-security/tutorials/remediate-leaked-secrets/evaluating-alerts.md @@ -57,12 +57,16 @@ With a {% data variables.copilot.copilot_enterprise %} license, you can ask {% d You can use the REST API to retrieve a list of the most recent validation status for each of your tokens. For more information, see [AUTOTITLE](/rest/secret-scanning) in the REST API documentation. You can also use webhooks to be notified of activity relating to a {% data variables.product.prodname_secret_scanning %} alert. For more information, see the `secret_scanning_alert` event in [AUTOTITLE](/webhooks/webhook-events-and-payloads?actionType=created#secret_scanning_alert). +{% ifversion secret-scanning-validity-check-partner-patterns %} + ## Performing an on-demand validity check Once you have enabled validity checks for partner patterns for your repository, you can perform an "on-demand" validity check for any supported secret by clicking **{% octicon "sync" aria-hidden="true" aria-label="sync" %} Verify secret** in the alert view. {% data variables.product.company_short %} will send the pattern to the relevant partner and display the validation status of the secret in the alert view. ![Screenshot of the UI showing a {% data variables.product.prodname_secret_scanning %} alert. A button, labeled "Verify secret" is highlighted with an orange outline.](/assets/images/help/security/secret-scanning-verify-secret.png) +{% endif %} + ## Reviewing {% data variables.product.company_short %} token metadata > [!NOTE] diff --git a/content/code-security/tutorials/secret-scanning-partner-program.md b/content/code-security/tutorials/secret-scanning-partner-program.md index 58521106d551..fb1eea76eff2 100644 --- a/content/code-security/tutorials/secret-scanning-partner-program.md +++ b/content/code-security/tutorials/secret-scanning-partner-program.md @@ -90,25 +90,28 @@ The message body is a JSON array that contains one or more objects, with each ob The list of valid values for `source` are: -* Content -* Commit -* Pull_request_title -* Pull_request_description -* Pull_request_comment -* Issue_title -* Issue_description -* Issue_comment -* Discussion_title -* Discussion_body -* Discussion_comment -* Commit_comment -* Gist_content -* Gist_comment -* Wiki_content -* Wiki_commit -* Npm -* Manual_submission -* Unknown +* `content` +* `commit` +* `pull_request_title` +* `pull_request_description` +* `pull_request_comment` +* `issue_title` +* `issue_description` +* `issue_comment` +* `discussion_title` +* `discussion_body` +* `discussion_comment` +* `commit_comment` +* `gist_content` +* `gist_comment` +* `wiki_content` +* `wiki_commit` +* `npm` +* `manual_submission` +* `action_logs` +* `unknown` + +A `source` of `action_logs` means the match was found in the logs of a {% data variables.product.prodname_actions %} workflow run in a public repository. ### Implement signature verification in your secret alert service diff --git a/content/copilot/concepts/about-cloud-and-local-sandboxes.md b/content/copilot/concepts/about-cloud-and-local-sandboxes.md index 74df2c81958e..bd9b9ade2b34 100644 --- a/content/copilot/concepts/about-cloud-and-local-sandboxes.md +++ b/content/copilot/concepts/about-cloud-and-local-sandboxes.md @@ -19,21 +19,24 @@ docsTeamMetrics: ## Introduction -{% data variables.product.prodname_copilot_short %} cloud and local sandboxes are the execution platform powering secure sandboxed experiences for {% data variables.copilot.copilot_cli %}, both locally and in the cloud. As {% data variables.product.prodname_copilot_short %} takes more actions on your behalf—running tools, executing commands, and modifying files—sandboxing provides the isolation, portability, and policy controls needed to adopt agentic workflows safely. +{% data variables.product.prodname_copilot_short %} cloud and local sandboxes are the execution platform powering secure sandboxed experiences for {% data variables.copilot.copilot_cli %} and the {% data variables.copilot.github_copilot_app %}. As {% data variables.product.prodname_copilot_short %} takes more actions on your behalf—running tools, executing commands, and modifying files—sandboxing provides the isolation, portability, and policy controls needed to adopt agentic workflows safely. -Sandboxing currently applies to {% data variables.copilot.copilot_cli_short %} sessions. You can also choose to use cloud sandboxing when you start a new session in the {% data variables.copilot.github_copilot_app %}. For more information, see [AUTOTITLE](/copilot/how-tos/github-copilot-app/agent-sessions#starting-a-session). +Sandboxing is available in both {% data variables.copilot.copilot_cli %} and the {% data variables.copilot.github_copilot_app %}, but the two surfaces expose it differently: + +* In {% data variables.copilot.copilot_cli_short %}, you control both local and cloud sandboxing with the commands and settings described in this article. +* In the {% data variables.copilot.github_copilot_app %}, you choose cloud sandboxing when you start a new session, without running any commands. This feature is currently in {% data variables.release-phases.public_preview %} and subject to change. For more information, see [AUTOTITLE](/copilot/how-tos/github-copilot-app/agent-sessions#starting-a-session). With sandboxing, you can choose where {% data variables.product.prodname_copilot_short %} runs: * **Local sandboxing**: Run {% data variables.product.prodname_copilot_short %} securely on your own machine. The commands that {% data variables.product.prodname_copilot_short %} runs have restricted access to your filesystem, network, and system capabilities. You can use local sandboxing at no extra charge. -* **Cloud sandboxing**: Run the entire {% data variables.copilot.copilot_cli_short %} session remotely, inside a fully isolated, ephemeral Linux environment hosted by {% data variables.product.github %}. Cloud sandboxing is billed based on usage. +* **Cloud sandboxing**: Run an entire {% data variables.product.prodname_copilot_short %} session remotely, inside a fully isolated, ephemeral Linux environment hosted by {% data variables.product.github %}. Cloud sandboxing is billed based on usage. ## Local sandboxing > [!NOTE] > Local sandboxing is currently an experimental feature. To use it, start {% data variables.copilot.copilot_cli_short %} with the `‑‑experimental` command line option, or enter `/experimental on` during a session. -Local sandboxing lets {% data variables.product.prodname_copilot_short %} run in a sandboxed environment directly on your machine, with restricted access to your filesystem, network connectivity, and system capabilities. +Local sandboxing lets {% data variables.product.prodname_copilot_short %} run in a sandboxed environment directly on your machine, with restricted access to your filesystem, network connectivity, and system capabilities. You configure local sandboxing in {% data variables.copilot.copilot_cli_short %}, using the commands and settings described in the following sections. Local sandboxing is turned off by default. Until you enable it, the shell commands that {% data variables.product.prodname_copilot_short %} runs execute directly on your machine with the same access as your user account: they can read, write, and delete wherever you can, reach any network your machine can reach, and use your credentials without restriction. Enabling local sandboxing constrains this access to a policy that you control. @@ -105,7 +108,7 @@ Enterprises can require local sandboxing and enforce its configuration through s ## Cloud sandboxing -Cloud sandboxing lets you run {% data variables.copilot.copilot_cli_short %} sessions inside fully isolated, ephemeral Linux environments hosted by {% data variables.product.github %}. Each cloud sandbox session is isolated from your local environment and from other sessions. +Cloud sandboxing lets you run sessions inside fully isolated, ephemeral Linux environments hosted by {% data variables.product.github %}. You can use cloud sandboxing from both {% data variables.copilot.copilot_cli_short %} and the {% data variables.copilot.github_copilot_app %}. Each cloud sandbox session is isolated from your local environment and from other sessions. Cloud sandboxing is built on Azure Container Apps Sandboxes, with {% data variables.product.github %} providing the identity, policy, and billing layer. @@ -130,6 +133,8 @@ Running `copilot ‑‑cloud` starts a single {% data variables.copilot.copilot_ > [!NOTE] > Cloud sandboxing is only available for interactive {% data variables.copilot.copilot_cli_short %} sessions. You can't run the CLI programmatically in a cloud sandbox—that is, you can't combine the `‑‑cloud` option with the `-p` or `-i` options. +In the {% data variables.copilot.github_copilot_app %}, you don't use a command to start a cloud sandbox session. Instead, choose the cloud sandbox option when you create a new session. For more information, see [AUTOTITLE](/copilot/how-tos/github-copilot-app/agent-sessions#starting-a-session). + ### Continue sessions across devices Because cloud sandbox sessions run in {% data variables.product.github %}-hosted infrastructure, you can pick up a {% data variables.product.prodname_copilot_short %} session on any device, regardless of where the session was originally started. This enables more flexible workflows without needing to copy files or reinstall dependencies. @@ -146,7 +151,7 @@ Cloud sandbox policies share the same configuration as {% data variables.copilot A cloud sandbox session has three main states: -* **Active**: The session is running, and you are interacting with it from {% data variables.copilot.copilot_cli_short %}. +* **Active**: The session is running, and you are interacting with it from {% data variables.copilot.copilot_cli_short %} or the {% data variables.copilot.github_copilot_app %}. * **Stopped**: The session is not currently running, but its state is saved. When you resume it, your files, environment variables, and in-progress work are restored. * **Deleted**: The session and its saved state are removed and cannot be recovered. @@ -154,7 +159,7 @@ When you stop a session, the cloud sandbox creates a snapshot of its state so yo ## Authentication and access -Sandboxing uses your existing {% data variables.copilot.copilot_cli_short %} authentication. If you can sign in to {% data variables.copilot.copilot_cli_short %} and have access to {% data variables.product.prodname_copilot_short %}, you can use sandboxing. You don't need to configure a separate cloud provider, manage API keys, or set up infrastructure. +Sandboxing uses your existing {% data variables.product.prodname_copilot_short %} authentication. If you can sign in to {% data variables.copilot.copilot_cli_short %} or the {% data variables.copilot.github_copilot_app %} and have access to {% data variables.product.prodname_copilot_short %}, you don't need to configure separate authentication for the sandboxing options available in that surface. You don't need to configure a separate cloud provider, manage API keys, or set up infrastructure. An organization or enterprise owner must enable the **Cloud Sandbox access** policy in the organization or enterprise settings before members can use cloud sandboxes. diff --git a/content/copilot/concepts/agents/about-third-party-coding-agents.md b/content/copilot/concepts/agents/about-third-party-coding-agents.md index aee2ce7d7d97..0c371a3f594c 100644 --- a/content/copilot/concepts/agents/about-third-party-coding-agents.md +++ b/content/copilot/concepts/agents/about-third-party-coding-agents.md @@ -79,7 +79,7 @@ Security validation does not require a {% data variables.product.prodname_GHAS % Coding agents consume **{% data variables.product.prodname_actions %} minutes** and **{% data variables.product.prodname_ai_credits_short %}**. Each agent session consumes {% data variables.product.prodname_ai_credits_short %} based on the model used and the number of tokens processed. -Within your included {% data variables.product.prodname_actions %} minutes and {% data variables.product.prodname_ai_credits_short %}, you can use agents without incurring additional costs. See [AUTOTITLE](/copilot/concepts/billing/organizations-and-enterprises/usage-based-billing). +Within your included {% data variables.product.prodname_actions %} minutes and {% data variables.product.prodname_ai_credits_short %}, you can use agents without incurring additional costs. See [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/billing). ## Partner agents diff --git a/content/copilot/concepts/agents/cloud-agent/about-cloud-agent.md b/content/copilot/concepts/agents/cloud-agent/about-cloud-agent.md index 066ec9e4ab7d..d130401f5417 100644 --- a/content/copilot/concepts/agents/cloud-agent/about-cloud-agent.md +++ b/content/copilot/concepts/agents/cloud-agent/about-cloud-agent.md @@ -90,7 +90,7 @@ The {% data variables.product.prodname_copilot_short %} usage metrics APIs inclu * The number of pull requests created by {% data variables.copilot.copilot_cloud_agent %} that have been merged * Median time to merge for merged pull requests, including pull requests created by {% data variables.copilot.copilot_cloud_agent %} -These metrics can help you track adoption of {% data variables.copilot.copilot_cloud_agent %} and monitor changes in pull request throughput and time to merge over time. See [AUTOTITLE](/copilot/concepts/copilot-usage-metrics/copilot-metrics). +These metrics can help you track adoption of {% data variables.copilot.copilot_cloud_agent %} and monitor changes in pull request throughput and time to merge over time. See [AUTOTITLE](/copilot/concepts/billing-and-usage/copilot-usage-metrics/copilot-metrics). ## Integrating {% data variables.copilot.copilot_cloud_agent %} with third-party tools @@ -132,7 +132,7 @@ The more {% data variables.copilot.copilot_cloud_agent %} knows about the code i {% data variables.copilot.copilot_cloud_agent %} uses {% data variables.product.prodname_actions %} minutes and {% data variables.product.prodname_ai_credits_short %}. The {% data variables.product.prodname_ai_credits_short %} consumed depend on the model used and the number of tokens processed during the session. -Within your included {% data variables.product.prodname_actions %} minutes and {% data variables.product.prodname_ai_credits_short %}, you can use {% data variables.copilot.copilot_cloud_agent %} without incurring additional costs. See [AUTOTITLE](/copilot/concepts/billing/organizations-and-enterprises/usage-based-billing). +Within your included {% data variables.product.prodname_actions %} minutes and {% data variables.product.prodname_ai_credits_short %}, you can use {% data variables.copilot.copilot_cloud_agent %} without incurring additional costs. See [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/billing). {% data variables.product.prodname_copilot_short %} code review also consumes {% data variables.product.prodname_actions %} minutes on private repositories. See [AUTOTITLE](/billing/concepts/product-billing/github-actions#copilot-code-review-and-github-actions-minutes). diff --git a/content/copilot/concepts/agents/code-review.md b/content/copilot/concepts/agents/code-review.md index b903de7175cd..564aff871051 100644 --- a/content/copilot/concepts/agents/code-review.md +++ b/content/copilot/concepts/agents/code-review.md @@ -112,7 +112,7 @@ For pull requests authored by other bots, or when a bot requests the review, usa ### What happens when a budget is reached -For {% data variables.copilot.copilot_business_short %} and {% data variables.copilot.copilot_enterprise_short %}, code review access is governed by budget controls. If a user reaches their user-level budget, or if the enterprise or cost center spending limit is exhausted, code reviews are blocked along with other {% data variables.product.prodname_ai_credits_short %}-consuming features. See [AUTOTITLE](/copilot/concepts/billing/budgets-for-usage-based-billing#what-happens-when-a-user-is-blocked). +For {% data variables.copilot.copilot_business_short %} and {% data variables.copilot.copilot_enterprise_short %}, code review access is governed by budget controls. If a user reaches their user-level budget, or if the enterprise or cost center spending limit is exhausted, code reviews are blocked along with other {% data variables.product.prodname_ai_credits_short %}-consuming features. See [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/budgets#what-happens-when-a-user-is-blocked). ### Users without a {% data variables.product.prodname_copilot_short %} license or plan that includes {% data variables.copilot.copilot_code-review_short %} diff --git a/content/copilot/concepts/context/spaces.md b/content/copilot/concepts/context/spaces.md index 154f403d2701..f7ce0889d749 100644 --- a/content/copilot/concepts/context/spaces.md +++ b/content/copilot/concepts/context/spaces.md @@ -65,7 +65,7 @@ You can use {% data variables.copilot.copilot_spaces %} in {% data variables.cop Questions you submit in a space count as {% data variables.copilot.copilot_chat_short %} requests and consume {% data variables.product.prodname_ai_credits_short %} based on the model used and the number of tokens processed. * If you're a {% data variables.copilot.copilot_free_short %} user, this usage counts toward your monthly chat limit. -* For {% data variables.copilot.copilot_business_short %} and {% data variables.copilot.copilot_enterprise_short %}, usage draws from your enterprise's shared {% data variables.product.prodname_ai_credits_short %} pool. For details on how consumption is calculated, see [AUTOTITLE](/copilot/concepts/billing/organizations-and-enterprises/usage-based-billing). +* For {% data variables.copilot.copilot_business_short %} and {% data variables.copilot.copilot_enterprise_short %}, usage draws from your enterprise's shared {% data variables.product.prodname_ai_credits_short %} pool. For details on how consumption is calculated, see [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/billing). ## Next steps diff --git a/content/copilot/get-started/plans.md b/content/copilot/get-started/plans.md index 97d3deaad128..a74ecb2b3ffb 100644 --- a/content/copilot/get-started/plans.md +++ b/content/copilot/get-started/plans.md @@ -67,7 +67,7 @@ The table below provides an overview of differences between plans. All plans inc {% endrowheaders %} -Each plan comes with an allowance of {% data variables.product.prodname_ai_credits %}. For more information, including how {% data variables.product.prodname_ai_credits %} work, see [AUTOTITLE](/copilot/concepts/billing/usage-based-billing-for-individuals) and [AUTOTITLE](/copilot/concepts/billing/organizations-and-enterprises/usage-based-billing). +Each plan comes with an allowance of {% data variables.product.prodname_ai_credits %}. For more information, including how {% data variables.product.prodname_ai_credits %} work, see [AUTOTITLE](/copilot/concepts/billing-and-usage/individuals/billing) and [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/billing). For more detail on what's uniquely available in each plan, see the following sections: * [Individual plans](#individual-plans) @@ -93,7 +93,7 @@ The following table shows what's included with each paid plan. {% data variables.copilot.copilot_free_short %} and {% data variables.copilot.copilot_student_short %} both have an allowance of {% data variables.product.prodname_ai_credits_short %}. -For more information on how {% data variables.product.prodname_ai_credits %} work, see [AUTOTITLE](/copilot/concepts/billing/usage-based-billing-for-individuals) +For more information on how {% data variables.product.prodname_ai_credits %} work, see [AUTOTITLE](/copilot/concepts/billing-and-usage/individuals/billing) ### Inline suggestions and {% data variables.copilot.copilot_chat_short %} @@ -187,7 +187,7 @@ With these plans you'll receive access to the following features and capabilitie {% data variables.product.prodname_copilot_short %} usage is measured in {% data variables.product.prodname_ai_credits_short %} under usage-based billing. Each license contributes {% data variables.product.prodname_ai_credits_short %} to a shared enterprise pool, and usage beyond the pool is charged at {% data variables.product.prodname_ai_credits_value %} per {% data variables.product.prodname_ai_credit_singular %}. Code completions and {% data variables.copilot.next_edit_suggestions %} are not billed in {% data variables.product.prodname_ai_credits_short %} and remain unlimited for all paid plans. -For a full explanation of how {% data variables.product.prodname_ai_credits_short %} work, including pooling, additional usage, and what happens when credits run out, see [AUTOTITLE](/copilot/concepts/billing/organizations-and-enterprises/usage-based-billing). +For a full explanation of how {% data variables.product.prodname_ai_credits_short %} work, including pooling, additional usage, and what happens when credits run out, see [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/billing). ### Inline suggestions and {% data variables.copilot.copilot_chat_short %} diff --git a/content/copilot/how-tos/administer-copilot/view-impact-dashboard.md b/content/copilot/how-tos/administer-copilot/view-impact-dashboard.md index 56fe09ed0aa1..33f4a8448d06 100644 --- a/content/copilot/how-tos/administer-copilot/view-impact-dashboard.md +++ b/content/copilot/how-tos/administer-copilot/view-impact-dashboard.md @@ -15,7 +15,7 @@ category: Instead of a flat active-user count, the impact dashboard groups users into adoption cohorts based on how they engage with {% data variables.product.prodname_copilot_short %}, and connects that engagement to pull request throughput. This gives you a more meaningful signal of adoption depth than daily or weekly active user counts alone. -For a detailed explanation of what the dashboard shows, including adoption cohorts, engagement trends, potential return on investment, and recommendations, see [AUTOTITLE](/copilot/concepts/copilot-usage-metrics/copilot-metrics). +For a detailed explanation of what the dashboard shows, including adoption cohorts, engagement trends, potential return on investment, and recommendations, see [AUTOTITLE](/copilot/concepts/billing-and-usage/copilot-usage-metrics/copilot-metrics). ## Prerequisite diff --git a/content/copilot/how-tos/administer-copilot/view-usage-and-adoption.md b/content/copilot/how-tos/administer-copilot/view-usage-and-adoption.md index d55ae1337e05..52399c9d7300 100644 --- a/content/copilot/how-tos/administer-copilot/view-usage-and-adoption.md +++ b/content/copilot/how-tos/administer-copilot/view-usage-and-adoption.md @@ -26,7 +26,7 @@ After your initial rollout, the {% data variables.product.prodname_copilot_short {% data reusables.copilot.access-copilot-metrics-dashboard %} 1. In the left sidebar, click **{% data variables.product.prodname_copilot_short %} usage**. -Data in the dashboard is primarily based on IDE telemetry and is supplemented by server-side telemetry to capture additional active users. Data may appear up to three full UTC days behind the current date. See [AUTOTITLE](/copilot/concepts/copilot-usage-metrics/copilot-metrics). +Data in the dashboard is primarily based on IDE telemetry and is supplemented by server-side telemetry to capture additional active users. Data may appear up to three full UTC days behind the current date. See [AUTOTITLE](/copilot/concepts/billing-and-usage/copilot-usage-metrics/copilot-metrics). ## Using {% data variables.copilot.copilot_chat_short %} to analyze exported data diff --git a/content/copilot/how-tos/copilot-cli/use-copilot-cli/set-session-limit.md b/content/copilot/how-tos/copilot-cli/use-copilot-cli/set-session-limit.md index acd352e880f2..bd2ad22daf3d 100644 --- a/content/copilot/how-tos/copilot-cli/use-copilot-cli/set-session-limit.md +++ b/content/copilot/how-tos/copilot-cli/use-copilot-cli/set-session-limit.md @@ -66,4 +66,4 @@ When the limit is hit, the agent stops cleanly and lets you know. ## Further reading * [AUTOTITLE](/copilot/tutorials/optimize-ai-usage) -* [What are {% data variables.product.prodname_ai_credits %}](/copilot/concepts/billing/usage-based-billing-for-individuals#what-are-github-ai-credits) +* [What are {% data variables.product.prodname_ai_credits %}](/copilot/concepts/billing-and-usage/individuals/billing#what-are-github-ai-credits) diff --git a/content/copilot/how-tos/copilot-integrations/integrate-cloud-agent-with-jira.md b/content/copilot/how-tos/copilot-integrations/integrate-cloud-agent-with-jira.md index 2c554d68422d..566bacc2099b 100644 --- a/content/copilot/how-tos/copilot-integrations/integrate-cloud-agent-with-jira.md +++ b/content/copilot/how-tos/copilot-integrations/integrate-cloud-agent-with-jira.md @@ -140,7 +140,7 @@ Use custom instructions to set defaults that apply to every session, such as the {% data variables.copilot.copilot_cloud_agent %} uses {% data variables.product.prodname_actions %} minutes and {% data variables.product.prodname_ai_credits_short %}. -For more information, see [AUTOTITLE](/copilot/concepts/billing/organizations-and-enterprises/usage-based-billing). +For more information, see [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/billing). ## Adding or removing an organization to the {% data variables.product.prodname_copilot %} for Jira app diff --git a/content/copilot/how-tos/copilot-sdk/features/usage-and-billing.md b/content/copilot/how-tos/copilot-sdk/features/usage-and-billing.md index 1fb8d7297ada..5f8983b20ccf 100644 --- a/content/copilot/how-tos/copilot-sdk/features/usage-and-billing.md +++ b/content/copilot/how-tos/copilot-sdk/features/usage-and-billing.md @@ -376,7 +376,7 @@ The example uses the fields below. The generated `UsageGetMetricsResult` type is | `modelMetrics` | `Record` | Per-model breakdown; each entry has `usage.inputTokens`, `usage.outputTokens`, and `totalNanoAiu` | > [!NOTE] -> Cost is reported in **nano-AI units** (the field is named `totalNanoAiu`). The exact conversion to AI credits and the precise meaning of premium request accounting are defined by GitHub Copilot billing, not by the SDK—treat [GitHub's Copilot billing documentation](/copilot/concepts/billing) as the source of truth and verify before surfacing currency-like values to users. The examples divide by `1e9` as a convenience, following the SI `nano` prefix; confirm this matches current billing before relying on it. The `modelMetrics` and `tokenDetails` maps are keyed by runtime strings (model IDs and token-type names) that the SDK type system does not validate. +> Cost is reported in **nano-AI units** (the field is named `totalNanoAiu`). The exact conversion to AI credits and the precise meaning of premium request accounting are defined by GitHub Copilot billing, not by the SDK—treat [GitHub's Copilot billing documentation](/copilot/concepts/billing-and-usage) as the source of truth and verify before surfacing currency-like values to users. The examples divide by `1e9` as a convenience, following the SI `nano` prefix; confirm this matches current billing before relying on it. The `modelMetrics` and `tokenDetails` maps are keyed by runtime strings (model IDs and token-type names) that the SDK type system does not validate. {% codetabs %} {% codetab typescript %} diff --git a/content/copilot/how-tos/manage-and-track-spending/manage-company-spending.md b/content/copilot/how-tos/manage-and-track-spending/manage-company-spending.md index b1434f4b9418..f77d6ab3daf7 100644 --- a/content/copilot/how-tos/manage-and-track-spending/manage-company-spending.md +++ b/content/copilot/how-tos/manage-and-track-spending/manage-company-spending.md @@ -37,7 +37,7 @@ To track {% data variables.product.prodname_ai_credits_short %} consumption, nav ### Controlling usage with budgets -You can set budgets at the user, cost center, and enterprise level to control how {% data variables.product.prodname_ai_credits_short %} are consumed. For an overview of how budget controls work, see [AUTOTITLE](/copilot/concepts/billing/budgets-for-usage-based-billing). For guidance on choosing the right configuration, see [AUTOTITLE](/copilot/tutorials/budgets/optimizing-your-budget-configuration). +You can set budgets at the user, cost center, and enterprise level to control how {% data variables.product.prodname_ai_credits_short %} are consumed. For an overview of how budget controls work, see [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/budgets). For guidance on choosing the right configuration, see [AUTOTITLE](/copilot/tutorials/budgets/optimizing-your-budget-configuration). ## Mapping spending to groups of users diff --git a/content/copilot/how-tos/troubleshoot-copilot/troubleshoot-common-issues.md b/content/copilot/how-tos/troubleshoot-copilot/troubleshoot-common-issues.md index a98e31b0030f..121f7ef03f33 100644 --- a/content/copilot/how-tos/troubleshoot-copilot/troubleshoot-common-issues.md +++ b/content/copilot/how-tos/troubleshoot-copilot/troubleshoot-common-issues.md @@ -71,7 +71,7 @@ Most people see rate limiting for select models, due to limited capacity. Service-level request rate limits ensure high service quality for all {% data variables.product.prodname_copilot_short %} users and should not affect typical or even deeply engaged {% data variables.product.prodname_copilot_short %} usage. We are aware of some use cases that are affected by it. {% data variables.product.github %} is iterating on {% data variables.product.prodname_copilot_short %}’s rate-limiting heuristics to ensure it doesn’t block legitimate use cases. -If you are rate limited, the error message may tell you to wait for your limit to reset, suggest a retry time, or prompt you to upgrade your plan for additional usage. For more information about what to do while your limit resets, see [AUTOTITLE](/copilot/concepts/usage-limits#what-to-do-if-you-hit-a-limit). +If you are rate limited, the error message may tell you to wait for your limit to reset, suggest a retry time, or prompt you to upgrade your plan for additional usage. For more information about what to do while your limit resets, see [AUTOTITLE](/copilot/concepts/billing-and-usage/individuals/usage-limits#what-to-do-if-you-hit-a-limit). In case you experience repeated rate limiting in {% data variables.product.prodname_copilot_short %} contact {% data variables.contact.contact_support_page %}. diff --git a/content/copilot/reference/copilot-billing/license-changes.md b/content/copilot/reference/copilot-billing/license-changes.md index f6baba4efbff..d0e0d38992ff 100644 --- a/content/copilot/reference/copilot-billing/license-changes.md +++ b/content/copilot/reference/copilot-billing/license-changes.md @@ -36,7 +36,7 @@ Paying for, renewing, upgrading, downgrading, converting from a trial, or resumi For example, if you exhaust your {% data variables.product.prodname_ai_credits_short %} on May 28 and renew or upgrade your plan on May 30, your allowance does not reset until June 1. -Any additional usage beyond the included allowance is charged separately and is unaffected by this monthly reset. See [AUTOTITLE](/copilot/concepts/billing/usage-based-billing-for-individuals) and [AUTOTITLE](/billing/how-tos/set-up-budgets#managing-budgets-for-your-personal-account). +Any additional usage beyond the included allowance is charged separately and is unaffected by this monthly reset. See [AUTOTITLE](/copilot/concepts/billing-and-usage/individuals/billing) and [AUTOTITLE](/billing/how-tos/set-up-budgets#managing-budgets-for-your-personal-account). ## Organizations diff --git a/content/copilot/reference/copilot-billing/models-and-pricing.md b/content/copilot/reference/copilot-billing/models-and-pricing.md index 808a74eda135..835b6680274d 100644 --- a/content/copilot/reference/copilot-billing/models-and-pricing.md +++ b/content/copilot/reference/copilot-billing/models-and-pricing.md @@ -18,8 +18,8 @@ The cost of an interaction depends on two things: the model and the number of to How {% data variables.product.prodname_copilot_short %} usage is tracked and billed depends on your plan type: -* Individual plans ({% data variables.copilot.copilot_free_short %}, {% data variables.copilot.copilot_pro_short %}, {% data variables.copilot.copilot_pro_plus_short %}, and {% data variables.copilot.copilot_max_short %}) include {% data variables.product.prodname_ai_credits %} allowances that vary by plan. For details, see [AUTOTITLE](/copilot/concepts/billing/usage-based-billing-for-individuals). -* {% data variables.copilot.copilot_business_short %} and {% data variables.copilot.copilot_enterprise_short %} include per-user {% data variables.product.prodname_ai_credits %} allowances that are pooled at the billing entity level. For details, see [AUTOTITLE](/copilot/concepts/billing/organizations-and-enterprises/usage-based-billing). +* Individual plans ({% data variables.copilot.copilot_free_short %}, {% data variables.copilot.copilot_pro_short %}, {% data variables.copilot.copilot_pro_plus_short %}, and {% data variables.copilot.copilot_max_short %}) include {% data variables.product.prodname_ai_credits %} allowances that vary by plan. For details, see [AUTOTITLE](/copilot/concepts/billing-and-usage/individuals/billing). +* {% data variables.copilot.copilot_business_short %} and {% data variables.copilot.copilot_enterprise_short %} include per-user {% data variables.product.prodname_ai_credits %} allowances that are pooled at the billing entity level. For details, see [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/billing). When usage exceeds the included allowances for any {% data variables.product.prodname_copilot_short %} plan, additional usage is billed in {% data variables.product.prodname_ai_credits %} at the per-token rates shown in the pricing tables below (1 {% data variables.product.prodname_ai_credit_singular %} = {% data variables.product.prodname_ai_credits_value %}). diff --git a/content/copilot/reference/copilot-billing/request-based-billing-legacy/copilot-requests.md b/content/copilot/reference/copilot-billing/request-based-billing-legacy/copilot-requests.md index b44fbe59f402..66cb5e231a52 100644 --- a/content/copilot/reference/copilot-billing/request-based-billing-legacy/copilot-requests.md +++ b/content/copilot/reference/copilot-billing/request-based-billing-legacy/copilot-requests.md @@ -61,7 +61,7 @@ The following {% data variables.product.prodname_copilot_short %} features can u > [!NOTE] > Billing for premium requests began on June 18, 2025, for all paid {% data variables.product.prodname_copilot_short %} plans on {% data variables.product.prodname_dotcom_the_website %}, and on August 1, 2025, on {% data variables.enterprise.data_residency_site %}. The request counters were only set to zero for paid plans. -If you're on a **paid plan**, you get unlimited inline suggestions. Rate limiting is in place to accommodate for high demand. See [AUTOTITLE](/copilot/concepts/usage-limits). +If you're on a **paid plan**, you get unlimited inline suggestions. Rate limiting is in place to accommodate for high demand. See [AUTOTITLE](/copilot/concepts/billing-and-usage/individuals/usage-limits). Paid plans also receive a monthly allowance of premium requests, which can be used for advanced chat interactions, inline suggestions using premium models, and other premium features. @@ -86,7 +86,7 @@ Unused requests for the previous month do not carry over to the following month. > > * Users who subscribe, or have subscribed, to {% data variables.copilot.copilot_pro_short %} or {% data variables.copilot.copilot_pro_plus_short %} through {% data variables.product.prodname_mobile %} on iOS or Android. -If you use all of your premium requests, you can still use {% data variables.product.prodname_copilot_short %} with one of the included models for the rest of the month. This is subject to change. Response times for the included models may vary during periods of high usage. Requests to the included models may be subject to rate limiting. See [AUTOTITLE](/copilot/concepts/usage-limits). +If you use all of your premium requests, you can still use {% data variables.product.prodname_copilot_short %} with one of the included models for the rest of the month. This is subject to change. Response times for the included models may vary during periods of high usage. Requests to the included models may be subject to rate limiting. See [AUTOTITLE](/copilot/concepts/billing-and-usage/individuals/usage-limits). If you need more premium requests beyond your monthly allowance set a budget for additional premium requests or upgrade to a higher plan. See [AUTOTITLE](/billing/how-tos/set-up-budgets). diff --git a/content/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans.md b/content/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans.md index 8eaedc745014..fb187296c811 100644 --- a/content/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans.md +++ b/content/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans.md @@ -51,4 +51,4 @@ The following table shows the model multipliers per supported model. ## Further reading * To learn more about premium requests, see [AUTOTITLE](/copilot/reference/copilot-billing/request-based-billing-legacy/copilot-requests). -* To understand more about how the new usage-based billing model works, see [AUTOTITLE](/copilot/concepts/billing/usage-based-billing-for-individuals). +* To understand more about how the new usage-based billing model works, see [AUTOTITLE](/copilot/concepts/billing-and-usage/individuals/billing). diff --git a/content/copilot/reference/copilot-billing/request-based-billing-legacy/what-changed-with-billing.md b/content/copilot/reference/copilot-billing/request-based-billing-legacy/what-changed-with-billing.md index 195bad25df1b..6ecc410dda8e 100644 --- a/content/copilot/reference/copilot-billing/request-based-billing-legacy/what-changed-with-billing.md +++ b/content/copilot/reference/copilot-billing/request-based-billing-legacy/what-changed-with-billing.md @@ -18,7 +18,7 @@ contentType: reference Before June 1, 2026, billing was premium request-based: each model interaction cost one **premium request unit** (PRU), and a **multiplier** was applied based on which model you used—more powerful models used more premium requests. Model multipliers are a concept specific to this legacy request-based billing model. -As of June 1, 2026, {% data variables.product.github %} replaced request-based billing with usage-based billing, where the cost of an interaction depends on two things: the **model** and the **number of tokens consumed**. Under usage-based billing, each plan comes with an included allowance of {% data variables.product.prodname_ai_credits %}, with the option to set a budget for additional usage. To learn more about usage-based billing, see [AUTOTITLE](/copilot/concepts/billing/usage-based-billing-for-individuals). +As of June 1, 2026, {% data variables.product.github %} replaced request-based billing with usage-based billing, where the cost of an interaction depends on two things: the **model** and the **number of tokens consumed**. Under usage-based billing, each plan comes with an included allowance of {% data variables.product.prodname_ai_credits %}, with the option to set a budget for additional usage. To learn more about usage-based billing, see [AUTOTITLE](/copilot/concepts/billing-and-usage/individuals/billing). ## What are my options as a {% data variables.copilot.copilot_pro_short %} and {% data variables.copilot.copilot_pro_plus_short %} user on an existing annual plan? @@ -33,5 +33,5 @@ Your existing annual plan will continue to track your {% data variables.product. ## Further reading -* To understand more about how the new usage-based billing works, see [AUTOTITLE](/copilot/concepts/billing/usage-based-billing-for-individuals). +* To understand more about how the new usage-based billing works, see [AUTOTITLE](/copilot/concepts/billing-and-usage/individuals/billing). * To learn more about model prices under usage-based billing, see [AUTOTITLE](/copilot/reference/copilot-billing/models-and-pricing). \ No newline at end of file diff --git a/content/copilot/reference/copilot-cli-reference/cli-command-reference.md b/content/copilot/reference/copilot-cli-reference/cli-command-reference.md index 2826af53253d..c208d085e5a7 100644 --- a/content/copilot/reference/copilot-cli-reference/cli-command-reference.md +++ b/content/copilot/reference/copilot-cli-reference/cli-command-reference.md @@ -750,7 +750,7 @@ copilot --deny-tool='write(secret.txt)' | Variable | Description | |----------|-------------| -| `COPILOT_ALLOW_ALL` | Set to `true` to allow all permissions automatically (equivalent to `--allow-all`). | +| `COPILOT_ALLOW_ALL` | Allow all permissions automatically (equivalent to `--allow-all`). Accepts `true`, `1`, `yes`, `on`, or `y` (case-insensitive, surrounding whitespace ignored) to turn it on; `false`, `0`, `no`, `off`, `n`, or an empty value to turn it off. Setting it to exactly `true` also trusts the working directory without prompting, loading that directory's skills, plugins, MCP servers, and hooks (including hooks that run shell commands). The other truthy spellings only auto-approve tools. | | `COPILOT_AUTO_UPDATE` | Set to `false` to disable automatic updates of the CLI and first-party plugins. | | `COPILOT_CACHE_HOME` | Override the cache directory (used for marketplace caches, auto-update packages, and other ephemeral data). See [AUTOTITLE](/copilot/reference/copilot-cli-reference/cli-config-dir-reference#changing-the-location-of-the-configuration-directory) for platform defaults. | | `COPILOT_CHILD_OOM_SCORE_ADJ` | Linux only. Overrides the `oom_score_adj` bias applied to shell command process trees spawned by the CLI, so the kernel's out-of-memory (OOM) killer reclaims a runaway child process tree (for example, a build) before the CLI itself. Default: `300`. Range: `-1000`–`1000`. Set to `off` to disable the bias. | diff --git a/content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md b/content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md index 9c5d5e334d12..c1d36c44bd23 100644 --- a/content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md +++ b/content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md @@ -525,6 +525,7 @@ These settings apply across all your sessions and repositories. You can use the | `terminalProgress` | `boolean` | `true` | Emit OSC 9;4 terminal progress indicators while the agent is working. Supported terminals include Windows Terminal, iTerm2, Ghostty, and ConEmu. | | `theme` | `"default"` \| `"github"` \| `"dim"` \| `"high-contrast"` \| `"colorblind"` | `"github"` | Color palette for terminal output. Managed by the `/settings` and `/theme` slash commands. `colorMode` is a deprecated alias for this setting. | | `toolSearch` | `boolean` | model- and feature-dependent | Controls tool search (deferred tool loading). Set `toolSearch: false` to opt out of tool search. | +| `transcriptView` | `"default"` \| `"concise"` | `"default"` | Set to `"concise"` to group tool activity into expandable work summaries in the timeline. Set to `"default"` to show the full native transcript. | | `updateTerminalTitle` | `boolean` | `true` | Show the current intent in the terminal tab or window title. | | `worktreeBaseRef` | `"head"` \| `"defaultBranch"` | `"head"` | Starting point for new worktrees created by `/worktree`, `/worktree new`, and `--worktree`. `"defaultBranch"` starts from the remote default branch instead of the current checkout. | diff --git a/content/copilot/reference/copilot-usage-metrics/copilot-usage-metrics.md b/content/copilot/reference/copilot-usage-metrics/copilot-usage-metrics.md index 7be1c16e1bf6..58159677d4f9 100644 --- a/content/copilot/reference/copilot-usage-metrics/copilot-usage-metrics.md +++ b/content/copilot/reference/copilot-usage-metrics/copilot-usage-metrics.md @@ -21,7 +21,7 @@ The {% data variables.product.prodname_copilot_short %} usage metrics dashboard * Team-level metrics are not pre-aggregated; you construct them by joining the user-teams report with the per-user usage metrics report. See [AUTOTITLE](/copilot/reference/copilot-usage-metrics/team-level-metrics). * Repository-level reports contain one record per repository with pull request activity on the requested day. See [Repository-level fields (API only)](#repository-level-fields-api-only) later in this article. -For guidance on how to read and interpret these metrics, see [AUTOTITLE](/copilot/concepts/copilot-usage-metrics/copilot-metrics). +For guidance on how to read and interpret these metrics, see [AUTOTITLE](/copilot/concepts/billing-and-usage/copilot-usage-metrics/copilot-metrics). ## {% data variables.product.prodname_copilot_short %} usage dashboard metrics @@ -47,7 +47,7 @@ These metrics appear directly in the {% data variables.product.prodname_copilot_ ## Impact dashboard metrics -These metrics appear in the impact dashboard, which groups users into adoption cohorts and connects that adoption to pull request output. For guidance on interpreting adoption cohorts, see [AUTOTITLE](/copilot/concepts/copilot-usage-metrics/copilot-metrics#understanding-adoption-cohorts). +These metrics appear in the impact dashboard, which groups users into adoption cohorts and connects that adoption to pull request output. For guidance on interpreting adoption cohorts, see [AUTOTITLE](/copilot/concepts/billing-and-usage/copilot-usage-metrics/copilot-metrics#understanding-adoption-cohorts). | Metric | Description | |:--|:--| @@ -298,7 +298,7 @@ These metrics come from server-side job activity. The nested `user_initiated_int ### Pull request activity fields > [!IMPORTANT] -> Organization- and enterprise-level reports may show different totals due to differences in user deduplication and attribution timing. For guidance on interpreting pull request metrics across scopes, see [AUTOTITLE](/copilot/concepts/copilot-usage-metrics/copilot-metrics#interpreting-pull-request-lifecycle-metrics-across-scopes). +> Organization- and enterprise-level reports may show different totals due to differences in user deduplication and attribution timing. For guidance on interpreting pull request metrics across scopes, see [AUTOTITLE](/copilot/concepts/billing-and-usage/copilot-usage-metrics/copilot-metrics#interpreting-pull-request-lifecycle-metrics-across-scopes). The `pull_requests` object appears in aggregated enterprise and organization reports, and in repository-level reports. It captures daily pull request creation, review, merge, and suggestion activity, including activity performed by {% data variables.copilot.copilot_cloud_agent %} and {% data variables.copilot.copilot_code-review_short %}. diff --git a/content/copilot/reference/copilot-usage-metrics/interpret-copilot-metrics.md b/content/copilot/reference/copilot-usage-metrics/interpret-copilot-metrics.md index e0e614cc09dc..5df91fd6d691 100644 --- a/content/copilot/reference/copilot-usage-metrics/interpret-copilot-metrics.md +++ b/content/copilot/reference/copilot-usage-metrics/interpret-copilot-metrics.md @@ -60,7 +60,7 @@ The "Language usage" and "Language usage per day" charts show which programming ## Reviewing adoption cohorts -The impact dashboard's adoption cohort distribution shows the share of users in each phase, and how that distribution shifts over time. For what each phase means and how classification works, see [AUTOTITLE](/copilot/concepts/copilot-usage-metrics/copilot-metrics#understanding-adoption-cohorts). +The impact dashboard's adoption cohort distribution shows the share of users in each phase, and how that distribution shifts over time. For what each phase means and how classification works, see [AUTOTITLE](/copilot/concepts/billing-and-usage/copilot-usage-metrics/copilot-metrics#understanding-adoption-cohorts). | Signal | What it suggests | Suggested action | |:--|:--|:--| diff --git a/content/copilot/reference/hooks-reference.md b/content/copilot/reference/hooks-reference.md index 23f47571a662..a7a3acf7e112 100644 --- a/content/copilot/reference/hooks-reference.md +++ b/content/copilot/reference/hooks-reference.md @@ -292,6 +292,9 @@ Each hook event delivers a JSON payload to the hook handler. Two payload formats ### `sessionEnd` / `SessionEnd` +> [!NOTE] +> **{% data variables.copilot.copilot_cli_short %} only — `/clear` in interactive mode.** `/clear` closes the old session and fires its `sessionEnd` hooks with `reason: "user_exit"` while the CLI keeps running. The replacement session has its own independent lifecycle. Because the CLI itself isn't exiting, these hooks dispatch detached—they run in the background with their full `timeoutSec` while `/clear` returns immediately, so a hook doing real work is neither cut short nor able to stall the prompt. A detached hook still running when you later quit the CLI is terminated along with the process. + **camelCase input:** ```typescript diff --git a/content/copilot/tutorials/budgets/getting-started-with-budget-controls.md b/content/copilot/tutorials/budgets/getting-started-with-budget-controls.md index 3b3570605dfb..4b4af4c4fd76 100644 --- a/content/copilot/tutorials/budgets/getting-started-with-budget-controls.md +++ b/content/copilot/tutorials/budgets/getting-started-with-budget-controls.md @@ -13,7 +13,7 @@ category: Under usage-based billing, your enterprise's included {% data variables.product.prodname_ai_credits_short %} are pooled and shared across all licensed users. Without budget controls in place, a single heavy user or automated agent session can consume a disproportionate share of the pool early in the billing cycle, leaving less for everyone else. -Before you begin, make sure you understand how the four budget controls work and how the system evaluates them. See [AUTOTITLE](/copilot/concepts/billing/budgets-for-usage-based-billing). +Before you begin, make sure you understand how the four budget controls work and how the system evaluates them. See [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/budgets). ## Step 1: Set a universal user-level budget @@ -29,7 +29,7 @@ Once you have a universal ULB in place, review your AI usage dashboard to identi For these users, set an individual user-level budget override. Individual overrides take precedence over the universal default. You can increase or decrease the limit for a specific user without affecting anyone else. -If the override is only meant to be temporary, such as a higher limit for one sprint or one incident, set an expiration date on it. {% data variables.product.github %} removes the budget on that date. The user then falls back to their cost center or universal budget, if one applies, so you don't have to track the cleanup yourself. See [AUTOTITLE](/copilot/concepts/billing/budgets-for-usage-based-billing#expiration-dates-for-individual-user-level-budgets). +If the override is only meant to be temporary, such as a higher limit for one sprint or one incident, set an expiration date on it. {% data variables.product.github %} removes the budget on that date. The user then falls back to their cost center or universal budget, if one applies, so you don't have to track the cleanup yourself. See [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/budgets#expiration-dates-for-individual-user-level-budgets). > [!TIP] > If you're setting up budgets for the first time and don't yet have usage data, start with a universal ULB that feels reasonable for your organization and revisit after your first billing cycle. Your AI usage dashboard will give you the data you need to tune individual overrides. diff --git a/content/copilot/tutorials/budgets/optimizing-your-budget-configuration.md b/content/copilot/tutorials/budgets/optimizing-your-budget-configuration.md index 15bdad4eb115..9c59064ef50a 100644 --- a/content/copilot/tutorials/budgets/optimizing-your-budget-configuration.md +++ b/content/copilot/tutorials/budgets/optimizing-your-budget-configuration.md @@ -18,7 +18,7 @@ category: - Manage Copilot for a team --- -Before optimizing your budget configuration, make sure you understand how budget controls work and how the system evaluates them. See [AUTOTITLE](/copilot/concepts/billing/budgets-for-usage-based-billing). +Before optimizing your budget configuration, make sure you understand how budget controls work and how the system evaluates them. See [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/budgets). If you haven't set up budgets yet, start with [AUTOTITLE](/copilot/tutorials/budgets/getting-started-with-budget-controls) to get the basics in place, then come back to this guide to optimize your configuration. @@ -36,7 +36,7 @@ Here's how to estimate: If you also use cost center budgets, the sum of your cost center budgets and your enterprise budget should cover the gap. The enterprise budget applies to users not assigned to a cost center. -If you want each cost center to stay within the {% data variables.product.prodname_ai_credits_short %} its own licenses fund, apply an included usage control to that cost center. This automatically caps the team's draw from the shared pool, so heavy use by one team doesn't consume another team's share before metered budgets apply. See [AUTOTITLE](/copilot/concepts/billing/budgets-for-usage-based-billing#included-usage-controls-for-cost-centers). +If you want each cost center to stay within the {% data variables.product.prodname_ai_credits_short %} its own licenses fund, apply an included usage control to that cost center. This automatically caps the team's draw from the shared pool, so heavy use by one team doesn't consume another team's share before metered budgets apply. See [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/budgets#included-usage-controls-for-cost-centers). > [!TIP] > Whenever you raise user-level budgets, re-check this calculation. Raising ULBs without raising the enterprise budget can cause the enterprise budget to block users before they reach their individual budgets. diff --git a/content/copilot/tutorials/optimize-ai-usage.md b/content/copilot/tutorials/optimize-ai-usage.md index c69480d84316..c66cea8436dc 100644 --- a/content/copilot/tutorials/optimize-ai-usage.md +++ b/content/copilot/tutorials/optimize-ai-usage.md @@ -121,7 +121,7 @@ In {% data variables.copilot.copilot_cli_short %} and {% data variables.copilot. * You want to cap {% data variables.product.prodname_ai_credits_short %} usage on a single session to avoid unexpected costs. * You're tuning agent efficiency and want to find the minimum {% data variables.product.prodname_ai_credits_short %} that still produces a good result. -Session limits are soft limits that help you control how many {% data variables.product.prodname_ai_credits_short %} any single task consumes, but they don't replace user-level budgets or spending limits, which govern your overall monthly consumption. For those controls, see [AUTOTITLE](/copilot/concepts/billing/budgets-for-usage-based-billing). +Session limits are soft limits that help you control how many {% data variables.product.prodname_ai_credits_short %} any single task consumes, but they don't replace user-level budgets or spending limits, which govern your overall monthly consumption. For those controls, see [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/budgets). For information on how to set a session limit in {% data variables.copilot.copilot_cli_short %}, see [AUTOTITLE](/copilot/how-tos/copilot-cli/use-copilot-cli/set-session-limit). diff --git a/content/copilot/tutorials/roll-out-at-scale/assign-licenses/choose-enterprise-plan.md b/content/copilot/tutorials/roll-out-at-scale/assign-licenses/choose-enterprise-plan.md index 5133be553827..8f9ab76e23d9 100644 --- a/content/copilot/tutorials/roll-out-at-scale/assign-licenses/choose-enterprise-plan.md +++ b/content/copilot/tutorials/roll-out-at-scale/assign-licenses/choose-enterprise-plan.md @@ -42,7 +42,7 @@ For a full comparison, see our [plans page](https://github.com/features/copilot/ {% data variables.product.prodname_ai_credits_short %} are consumed by advanced {% data variables.product.prodname_copilot_short %} features and models, including AI agents. Each plan's included {% data variables.product.prodname_ai_credits_short %} are pooled across your enterprise, so heavier users can draw from lighter users' unused portions. By giving members access to more {% data variables.product.prodname_ai_credits_short %}, you can scale your company with AI agents and drive real business outcomes. -By default, usage can continue beyond the included pool, with additional usage charged at {% data variables.product.prodname_ai_credits_value %} per {% data variables.product.prodname_ai_credit_singular %}. You can control this with budget controls. See [AUTOTITLE](/copilot/concepts/billing/organizations-and-enterprises/usage-based-billing). +By default, usage can continue beyond the included pool, with additional usage charged at {% data variables.product.prodname_ai_credits_value %} per {% data variables.product.prodname_ai_credit_singular %}. You can control this with budget controls. See [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/billing). ## How does {% data variables.copilot.copilot_enterprise_short %} support business goals? diff --git a/content/copilot/tutorials/roll-out-at-scale/govern-at-scale/pilot-a-feature-or-model.md b/content/copilot/tutorials/roll-out-at-scale/govern-at-scale/pilot-a-feature-or-model.md index 4eb33f9f13da..be17fa5c6054 100644 --- a/content/copilot/tutorials/roll-out-at-scale/govern-at-scale/pilot-a-feature-or-model.md +++ b/content/copilot/tutorials/roll-out-at-scale/govern-at-scale/pilot-a-feature-or-model.md @@ -44,7 +44,7 @@ Many of the features and models you'll want to pilot are billed through usage, s A handful of active developers using a frontier model or an agentic feature could consume more than you'd expect. Work out a rough estimate of what the pilot could cost, and decide on a ceiling you're willing to spend before you enable the feature. -Start by understanding how the feature or model is billed. Usage-based features consume {% data variables.product.prodname_ai_credits %}, and the cost of each interaction depends on the model and the number of tokens consumed. To understand which features count toward usage, how included credits are pooled across your enterprise, and how overages are charged, see [AUTOTITLE](/copilot/concepts/billing/organizations-and-enterprises/usage-based-billing). +Start by understanding how the feature or model is billed. Usage-based features consume {% data variables.product.prodname_ai_credits %}, and the cost of each interaction depends on the model and the number of tokens consumed. To understand which features count toward usage, how included credits are pooled across your enterprise, and how overages are charged, see [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/billing). To produce an estimate, combine three numbers: @@ -93,7 +93,7 @@ As an organization owner or billing manager, create a budget scoped to the pilot If you want to prevent any single pilot user from running up consumption, also set a universal user-level budget. This is the only control that is always a hard stop, and it counts a user's consumption from both the shared pool and metered usage toward the same limit. Set it to a modest amount that lets participants do real work but stops any one user from consuming an outsized share of the pilot's budget. -For how budgets meter and block usage at the user, organization, and enterprise levels, see [AUTOTITLE](/copilot/concepts/billing/budgets-for-usage-based-billing). +For how budgets meter and block usage at the user, organization, and enterprise levels, see [AUTOTITLE](/copilot/concepts/billing-and-usage/organizations-and-enterprises/budgets). ## Monitor the pilot @@ -103,7 +103,7 @@ Throughout the pilot, track adoption, collect developer feedback, and watch cost Use {% data variables.product.prodname_copilot_short %} usage metrics to see how many of your pilot users are active, how often they use the feature, and how those numbers trend over the pilot period. Adoption that holds steady or grows is a strong signal; usage that spikes and then fades may mean the feature isn't fitting into developers' real workflows. -* To understand what the metrics cover and how to interpret them, see [AUTOTITLE](/copilot/concepts/copilot-usage-metrics/copilot-metrics). +* To understand what the metrics cover and how to interpret them, see [AUTOTITLE](/copilot/concepts/billing-and-usage/copilot-usage-metrics/copilot-metrics). * To view the dashboard for your organization or enterprise, see [AUTOTITLE](/copilot/how-tos/administer-copilot/view-usage-and-adoption). > [!TIP] diff --git a/data/features/secret-scanning-extended-metadata-checks.yml b/data/features/secret-scanning-extended-metadata-checks.yml index 0bab65aa8723..6cfea1ddec95 100644 --- a/data/features/secret-scanning-extended-metadata-checks.yml +++ b/data/features/secret-scanning-extended-metadata-checks.yml @@ -3,3 +3,4 @@ versions: fpt: '*' ghec: '*' + ghes: '>=3.23' diff --git a/data/ui.yml b/data/ui.yml index 812d976a5b0d..5ceaa8643ce5 100644 --- a/data/ui.yml +++ b/data/ui.yml @@ -3,6 +3,8 @@ meta: header: github_docs: GitHub Docs + docs_title: Docs + close_menu_label: Close menu contact: Contact notices: release_candidate: @@ -17,7 +19,6 @@ header: ghes_release_notes_upgrade_patch_and_release: 📣 This is not the latest patch release of this release series, and this is not the latest release of Enterprise Server. sign_up_cta: Sign up menu: Menu - open_menu_label: Open menu go_home: Home collapse_sidebar: Collapse sidebar expand_sidebar: Expand sidebar @@ -25,7 +26,8 @@ header: scroll_breadcrumbs_right: Scroll breadcrumbs right picker: - language_picker_label: Language + version_picker_label: 'Select your version:' + plan_picker_label: 'Select your plan:' product_picker_default_text: All products version_picker_default_text: Choose a version @@ -118,7 +120,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 +375,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/package-lock.json b/package-lock.json index e2e0f203f109..ec47731da378 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "@primer/octicons-react": "^19.22.0", "@primer/primitives": "^11.3.1", "@primer/react": "38.19.0", - "@primer/react-brand": "^0.74.0", + "@primer/react-brand": "^0.75.1", "accept-language-parser": "^1.5.0", "ajv": "^8.18.0", "ajv-errors": "^3.0.0", @@ -3893,13 +3893,13 @@ } }, "node_modules/@primer/react-brand": { - "version": "0.74.0", - "resolved": "https://registry.npmjs.org/@primer/react-brand/-/react-brand-0.74.0.tgz", - "integrity": "sha512-bOgO48EBP7EYRE4/JSqrd33jj4islNBgCUNTs+DZqwCjsxLxlnzq8ezoeEQP2Pr1fpZcke7ImtPzU7h3VQuXLQ==", + "version": "0.75.1", + "resolved": "https://registry.npmjs.org/@primer/react-brand/-/react-brand-0.75.1.tgz", + "integrity": "sha512-dSjcFTq9k4iutPtmIwxmuUYe+WM3bi/ZPtUTo5Z/mQqIEJr4fkFSVQQsnrPbscDnlgfiPTn9PVxc6O2pXZNwZw==", "license": "MIT", "dependencies": { - "@oddbird/popover-polyfill": "0.5.2", - "@primer/behaviors": "^1.8.2" + "@oddbird/popover-polyfill": "0.7.2", + "@primer/behaviors": "^1.10.3" }, "engines": { "node": ">=24.0.0", @@ -3921,9 +3921,9 @@ } }, "node_modules/@primer/react-brand/node_modules/@oddbird/popover-polyfill": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@oddbird/popover-polyfill/-/popover-polyfill-0.5.2.tgz", - "integrity": "sha512-iFrvar5SOMtKFOSjYvs4z9UlLqDdJbMx0mgISLcPedv+g0ac5sgeETLGtipHCVIae6HJPclNEH5aCyD1RZaEHw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@oddbird/popover-polyfill/-/popover-polyfill-0.7.2.tgz", + "integrity": "sha512-f9R6XrZEshs/5qugqNH8wAqVzwuXLsnJW+iFQnPzX+/5pEr13b5bmxDVbJWj1U+E7LKJMcSu+IFif49mgacbYQ==", "license": "BSD-3-Clause" }, "node_modules/@primer/react/node_modules/@github/tab-container-element": { diff --git a/package.json b/package.json index 98c20df90fbd..cac350e18a65 100644 --- a/package.json +++ b/package.json @@ -192,7 +192,7 @@ "@primer/octicons-react": "^19.22.0", "@primer/primitives": "^11.3.1", "@primer/react": "38.19.0", - "@primer/react-brand": "^0.74.0", + "@primer/react-brand": "^0.75.1", "accept-language-parser": "^1.5.0", "ajv": "^8.18.0", "ajv-errors": "^3.0.0", 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-pipelines/config.yml b/src/content-pipelines/config.yml index db6f97e96ace..e2b9719e25a3 100644 --- a/src/content-pipelines/config.yml +++ b/src/content-pipelines/config.yml @@ -22,6 +22,7 @@ copilot-cli: target-articles: - content/copilot/reference/copilot-cli-reference/cli-command-reference.md - content/copilot/reference/copilot-cli-reference/cli-plugin-reference.md + - content/copilot/reference/copilot-cli-reference/cli-programmatic-reference.md - content/copilot/reference/copilot-cli-reference/acp-server.md - content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md - content/copilot/reference/hooks-reference.md @@ -33,6 +34,7 @@ copilot-cli: content/copilot/reference/copilot-cli-reference/cli-plugin-reference.md covers only plugin-specific content. All information relating to files in the ~/.copilot configuration directory, and settings in those files, should go in content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md. All reference details relating to hooks should go in content/copilot/reference/hooks-reference.md. + All reference details relating to using Copilot CLI programmatically (e.g. command line options and environment variables) should go in content/copilot/reference/copilot-cli-referencecli-programmatic-reference.md. Tabular information (but not discursive explanatory text) about using the CLI programmatically should also go in the main content/copilot/reference/copilot-cli-reference/cli-command-reference.md article. All other CLI topics (MCP, skills, agents, permissions, etc.) belong in content/copilot/reference/copilot-cli-reference/cli-command-reference.md even when they mention plugins. Do not overwrite the paragraph starting "OTel configuration can also be set in..." in cli-command-reference.md diff --git a/src/content-pipelines/state/copilot-cli.sha b/src/content-pipelines/state/copilot-cli.sha index 93d3d808abd3..207b324b78cd 100644 --- a/src/content-pipelines/state/copilot-cli.sha +++ b/src/content-pipelines/state/copilot-cli.sha @@ -1 +1 @@ -5ce1aa09128c57a8a240d93f5089e35969c8c4d9 +ceff936d1ade82fd8a7f162f19152228496b892d 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..5ceaa8643ce5 100644 --- a/src/fixtures/fixtures/data/ui.yml +++ b/src/fixtures/fixtures/data/ui.yml @@ -3,6 +3,8 @@ meta: header: github_docs: GitHub Docs + docs_title: Docs + close_menu_label: Close menu contact: Contact notices: release_candidate: @@ -17,7 +19,6 @@ header: ghes_release_notes_upgrade_patch_and_release: 📣 This is not the latest patch release of this release series, and this is not the latest release of Enterprise Server. sign_up_cta: Sign up menu: Menu - open_menu_label: Open menu go_home: Home collapse_sidebar: Collapse sidebar expand_sidebar: Expand sidebar @@ -25,7 +26,8 @@ header: scroll_breadcrumbs_right: Scroll breadcrumbs right picker: - language_picker_label: Language + version_picker_label: 'Select your version:' + plan_picker_label: 'Select your plan:' product_picker_default_text: All products version_picker_default_text: Choose a version @@ -118,7 +120,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 +375,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-header.spec.ts b/src/fixtures/tests/playwright-header.spec.ts new file mode 100644 index 000000000000..15c39b8f9470 --- /dev/null +++ b/src/fixtures/tests/playwright-header.spec.ts @@ -0,0 +1,1511 @@ +import { test, expect, type Locator, type Page } from '@playwright/test' + +import { turnOffExperimentsInPage } from '../helpers/turn-off-experiments' +import { + COLOR_MODE_COOKIE_NAME, + USER_LANGUAGE_COOKIE_NAME, + USER_VERSION_COOKIE_NAME, +} from '../../frame/lib/constants' + +const ARTICLE = '/en/get-started/foo/bar' +// `find-page.ts` narrows `context.languages` to English alone for early-access +// pages, which makes this the production route through the single-language +// branch of the header's language slot. +const ENGLISH_ONLY_ARTICLE = '/en/early-access/secrets/deeper/mariana-trench' +const SEARCH_LABEL = 'Search or ask Copilot' +const LANGUAGE_LABEL = 'Select language: current language is English' +const PLAN_LABEL = 'Select your plan:' +const VERSION_LABEL = 'Select your version:' +// The pill's line-height is the Docs design's own decision, set in +// HeaderPicker.module.scss -- Brand's --brand-text-lineHeight-100 is 1.5 -- so +// unlike the sizes below it is not resolved from a token. +const PILL_LINE_HEIGHT = 1.2 +const PLAN_TRIGGER_TESTID = 'version-picker-button' +const LANGUAGE_TRIGGER_TESTID = 'language-picker-button' +// Brand renders the trailing slot on `trailingComponent != null`, so the wrapper +// survives a child that renders nothing. Its class name is CSS-module hashed, so +// only the stable fragment can be matched -- and an absence assertion on a name +// Brand might rename would pass vacuously, which is why the test below always +// pairs it with a page where the same selector must still match. +const BRAND_TRAILING_SLOT = '[class*="SubdomainNavBar-trailing-component"]' + +/** + * Resolve Brand custom properties in whatever theme the page is currently in, + * instead of hardcoding light-mode RGB values. The probe is appended inside + * `locator` on purpose: the plan menu renders inside its own nested Brand + * ThemeProvider, so tokens have to be read from within that subtree to reflect + * the color mode the menu actually paints with. The hidden probe only + * normalizes CSS color syntax into rgb(); it never styles the UI. + */ +async function resolveThemeTokens(locator: Locator, tokens: string[]) { + return locator.evaluate((element, tokenNames: string[]) => { + const probe = document.createElement('span') + probe.hidden = true + element.append(probe) + try { + const resolved: Record = {} + for (const token of tokenNames) { + if (!getComputedStyle(element).getPropertyValue(token).trim()) { + throw new Error(`Missing theme token ${token}`) + } + probe.style.color = `var(${token})` + resolved[token] = getComputedStyle(probe).color + } + return resolved + } finally { + probe.remove() + } + }, tokens) +} + +/** + * Resolve Brand length tokens to pixels, so the pill's geometry can be checked + * against the tokens it is built from instead of the numbers those tokens happen + * to produce today. The probe is laid out (absolute + hidden rather than + * `hidden`) so `width` resolves through calc()/max() to a used pixel value. + */ +async function resolveTokenPixels(locator: Locator, tokens: string[]) { + return locator.evaluate((element, tokenNames: string[]) => { + const probe = document.createElement('div') + probe.style.position = 'absolute' + probe.style.visibility = 'hidden' + probe.style.pointerEvents = 'none' + element.append(probe) + try { + const resolved: Record = {} + for (const token of tokenNames) { + if (!getComputedStyle(element).getPropertyValue(token).trim()) { + throw new Error(`Missing theme token ${token}`) + } + probe.style.width = `var(${token})` + const width = parseFloat(getComputedStyle(probe).width) + if (!Number.isFinite(width) || width <= 0) { + throw new Error(`Unresolved length token ${token}`) + } + resolved[token] = width + } + return resolved + } finally { + probe.remove() + } + }, tokens) +} + +/** Read raw custom-property values (font weights resolve to plain numbers). */ +async function resolveTokenValues(locator: Locator, tokens: string[]) { + return locator.evaluate((element, tokenNames: string[]) => { + const resolved: Record = {} + for (const token of tokenNames) { + const value = getComputedStyle(element).getPropertyValue(token).trim() + if (!value) throw new Error(`Missing theme token ${token}`) + resolved[token] = value + } + return resolved + }, tokens) +} + +// Used only to prove the emulated color scheme actually reached Brand's tokens. +// Without it, a dark-mode run that silently stayed light would satisfy every +// "resolved token" assertion below and the dark coverage would be vacuous. +function relativeLuminance(color: string) { + const [r, g, b] = color + .match(/\d+(?:\.\d+)?/g)! + .slice(0, 3) + .map(Number) + const channel = (value: number) => { + const ratio = value / 255 + return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) +} + +async function expectHeaderPlanPicker(page: Page) { + const picker = page.getByTestId('desktop-header').getByTestId('version-picker') + const label = picker.getByText(PLAN_LABEL, { exact: true }) + const button = picker.getByRole('button') + const value = button.getByTestId('field') + + await expect(label).toBeVisible() + await expect(value).toHaveText('Free, Pro, & Team') + await expect(button).toHaveAccessibleName(`${PLAN_LABEL} Free, Pro, & Team`) + await expect(button).not.toContainText(PLAN_LABEL) + expect(await label.evaluate((element) => element.closest('button'))).toBeNull() + const labelId = await label.getAttribute('id') + const valueId = await value.getAttribute('id') + expect(labelId).toBeTruthy() + expect(valueId).toBeTruthy() + await expect(button).toHaveAttribute('aria-labelledby', `${labelId} ${valueId}`) + + // Every size below is arithmetic over Brand tokens, so resolve the tokens and + // derive the expectations rather than hardcoding today's pixels: a + // @primer/react-brand bump that moves --base-size-* then updates both sides at + // once, instead of failing CI with no user-visible regression. + const sizes = await resolveTokenPixels(picker, [ + '--brand-text-size-100', + '--base-size-2', + '--base-size-8', + '--base-size-12', + '--brand-borderWidth-thin', + ]) + const weights = await resolveTokenValues(picker, [ + '--base-text-weight-normal', + '--base-text-weight-bold', + ]) + const fontSize = sizes['--brand-text-size-100'] + const paddingInline = sizes['--base-size-12'] + const paddingBlock = sizes['--base-size-8'] + sizes['--brand-borderWidth-thin'] + const labelGap = sizes['--base-size-8'] + sizes['--base-size-2'] + const lineHeight = fontSize * PILL_LINE_HEIGHT + const pillHeight = lineHeight + paddingBlock * 2 + + await expect(label).toHaveCSS('font-weight', weights['--base-text-weight-normal']) + await expect(value).toHaveCSS('font-weight', weights['--base-text-weight-bold']) + const labelStyle = await label.evaluate((element) => ({ + fontSize: parseFloat(getComputedStyle(element).fontSize), + })) + const valueStyle = await value.evaluate((element) => { + const style = getComputedStyle(element) + return { fontSize: parseFloat(style.fontSize), lineHeight: parseFloat(style.lineHeight) } + }) + const buttonStyle = await button.evaluate((element) => { + const style = getComputedStyle(element) + return { + paddingInlineStart: parseFloat(style.paddingInlineStart), + paddingInlineEnd: parseFloat(style.paddingInlineEnd), + paddingBlockStart: parseFloat(style.paddingBlockStart), + paddingBlockEnd: parseFloat(style.paddingBlockEnd), + } + }) + expect(labelStyle.fontSize).toBeCloseTo(fontSize, 1) + expect(valueStyle.fontSize).toBeCloseTo(fontSize, 1) + expect(valueStyle.lineHeight).toBeCloseTo(lineHeight, 1) + expect(buttonStyle.paddingInlineStart).toBeCloseTo(paddingInline, 1) + expect(buttonStyle.paddingInlineEnd).toBeCloseTo(paddingInline, 1) + expect(buttonStyle.paddingBlockStart).toBeCloseTo(paddingBlock, 1) + expect(buttonStyle.paddingBlockEnd).toBeCloseTo(paddingBlock, 1) + for (const side of ['top', 'right', 'bottom', 'left']) { + await expect(button).toHaveCSS(`border-${side}-width`, '0px') + } + + const tokens = await resolveThemeTokens(picker, [ + '--brand-color-text-muted', + '--brand-color-text-default', + '--brand-color-canvas-subtle', + '--brand-color-canvas-default', + ]) + const colors = { + muted: tokens['--brand-color-text-muted'], + text: tokens['--brand-color-text-default'], + subtle: tokens['--brand-color-canvas-subtle'], + canvas: tokens['--brand-color-canvas-default'], + } + await expect(label).toHaveCSS('color', colors.muted) + await expect(value).toHaveCSS('color', colors.text) + await expect(button).toHaveCSS('background-color', colors.subtle) + expect(colors.subtle).not.toBe(colors.canvas) + expect(colors.muted).not.toBe(colors.text) + + await page.evaluate(() => document.fonts.ready) + const labelBox = (await label.boundingBox())! + const buttonBox = (await button.boundingBox())! + expect(buttonBox.x - (labelBox.x + labelBox.width)).toBeCloseTo(labelGap, 0) + expect(labelBox.y + labelBox.height / 2).toBeCloseTo(buttonBox.y + buttonBox.height / 2, 0) + expect(buttonBox.height).toBeCloseTo(pillHeight, 0) + // The normal plan name must fit even with Signup visible at 1012px. Keep + // ellipsis available for unusually long labels, not this default English one. + await expect + .poll(() => value.evaluate((element) => element.scrollWidth - element.clientWidth)) + .toBeLessThanOrEqual(0) + // A full-radius pill is the contract, not whether the token is 999px or 9999px. + const radii = await button.evaluate((element) => { + const style = getComputedStyle(element) + return [ + style.borderTopLeftRadius, + style.borderTopRightRadius, + style.borderBottomLeftRadius, + style.borderBottomRightRadius, + ].map(parseFloat) + }) + for (const radius of radii) expect(radius).toBeGreaterThanOrEqual(buttonBox.height / 2) + + await expectFilledTriangleCaret(button, colors.text) +} + +/** + * Both header triggers end in the same caret, so both are checked the same way. + * The design's caret is a filled triangle. Brand's ActionMenu.Button hardcodes a + * ChevronDownIcon and only loses to a caller-supplied trailingVisual because it + * spreads rest props after that default -- a single shared cast (ActionMenuTrigger) + * relies on that. A Brand upgrade that destructures trailingVisual would silently + * restore the chevron on both controls at once, so assert the chevron is gone and + * that the glyph really has the triangle's geometry: the triangle's path is + * ~7.15 x 3.82 user units, where chevron-down's is ~9.56 x 5.31. + */ +async function expectFilledTriangleCaret(trigger: Locator, color: string) { + const caret = trigger.locator('svg.octicon-triangle-down') + await expect(caret).toBeVisible() + await expect(caret).toHaveCSS('width', '16px') + await expect(caret).toHaveCSS('height', '16px') + await expect(caret.locator('path')).toHaveCSS('fill', color) + await expect(trigger.locator('svg.octicon-chevron-down')).toHaveCount(0) + const glyph = await caret.locator('path').evaluate((path) => { + const { width, height } = (path as SVGPathElement).getBBox() + return { width, height } + }) + expect(glyph.width).toBeGreaterThan(6.5) + expect(glyph.width).toBeLessThan(8.2) + expect(glyph.height).toBeGreaterThan(3.2) + expect(glyph.height).toBeLessThan(4.6) +} + +/** + * The language trigger deliberately does *not* match the plan pill: Figma draws + * it as a flat control -- a 16px globe, the language in muted 14px regular, then + * the same filled caret. Only the dropdown below it is shared, so this asserts + * the trigger keeps its own treatment and never drifts into the pill (which is + * exactly what reusing the shared pill class would do). + */ +async function expectHeaderLanguageTrigger(page: Page) { + const picker = page.getByTestId('desktop-header').getByTestId('language-picker') + const trigger = picker.getByTestId(LANGUAGE_TRIGGER_TESTID) + const value = picker.getByTestId('language-picker-field') + await expect(trigger).toHaveAccessibleName(LANGUAGE_LABEL) + await expect(value).toHaveText('English') + + const sizes = await resolveTokenPixels(picker, ['--brand-text-size-100']) + const weights = await resolveTokenValues(picker, ['--base-text-weight-normal']) + const tokens = await resolveThemeTokens(picker, [ + '--brand-color-text-muted', + '--brand-color-text-default', + '--brand-color-canvas-subtle', + ]) + await expect(value).toHaveCSS('color', tokens['--brand-color-text-muted']) + await expect(value).toHaveCSS('font-weight', weights['--base-text-weight-normal']) + const valueFontSize = await value.evaluate((element) => + parseFloat(getComputedStyle(element).fontSize), + ) + expect(valueFontSize).toBeCloseTo(sizes['--brand-text-size-100'], 1) + + // Flat, not a pill: no fill at rest, no border, and a small corner rather than + // the pill's full radius. The canvas-subtle comparison keeps this honest -- it + // is the fill the pill carries and the fill this control only takes on hover + // and while open. + await expect(trigger).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)') + expect(tokens['--brand-color-canvas-subtle']).not.toBe('rgba(0, 0, 0, 0)') + for (const side of ['top', 'right', 'bottom', 'left']) { + await expect(trigger).toHaveCSS(`border-${side}-width`, '0px') + } + for (const corner of ['top-left', 'top-right', 'bottom-left', 'bottom-right']) { + await expect(trigger).toHaveCSS(`border-${corner}-radius`, '6px') + } + const triggerBox = (await trigger.boundingBox())! + // Brand's ActionMenu remaps --brand-borderRadius-medium to the full radius on + // its own trigger, so a 6px corner is the difference between this control and + // a pill rather than a cosmetic detail. + expect(triggerBox.height / 2).toBeGreaterThan(6) + + const globe = trigger.locator('svg.octicon-globe') + await expect(globe).toBeVisible() + await expect(globe).toHaveCSS('width', '16px') + await expect(globe).toHaveCSS('height', '16px') + const globeBox = (await globe.boundingBox())! + const valueBox = (await value.boundingBox())! + expect(globeBox.x + globeBox.width).toBeLessThanOrEqual(valueBox.x + 1) + + await expectFilledTriangleCaret(trigger, tokens['--brand-color-text-muted']) +} + +/** + * The two header dropdowns are the same control with different content: both are + * Brand ActionMenus whose surface and rows come entirely from the shared + * HeaderPicker.module.scss. Every design assertion below therefore runs against + * both -- that is what proves they are identical rather than merely similar -- + * so only the content is parameterized here. + */ +type HeaderDropdown = { + name: string + pickerTestId: string + triggerTestId: string + /** The span each row wraps its label in. */ + itemTestId: string + expectTrigger: (page: Page) => Promise + /** The row that opens already chosen: tinted, with the trailing green dot. */ + selectedRow: string + /** Another selectable row: no tint, no dot. */ + unselectedRow: string + /** Rows that navigate instead of selecting, so they stay plain menuitems. */ + navigationRowCount: number + /** The plan menu keeps one rule between its versions and its navigation rows. */ + separatorCount: number + /** The final row -- whatever a clipped menu loses first. */ + lastRowRole: 'menuitem' | 'menuitemradio' + lastRowName: RegExp +} + +const PLAN_DROPDOWN: HeaderDropdown = { + name: 'plan', + pickerTestId: 'version-picker', + triggerTestId: PLAN_TRIGGER_TESTID, + itemTestId: 'version-picker-item', + expectTrigger: expectHeaderPlanPicker, + selectedRow: 'Free, Pro, & Team', + unselectedRow: 'Enterprise Cloud', + navigationRowCount: 2, + separatorCount: 1, + lastRowRole: 'menuitem', + lastRowName: /About versions/, +} + +const LANGUAGE_DROPDOWN: HeaderDropdown = { + name: 'language', + pickerTestId: 'language-picker', + triggerTestId: LANGUAGE_TRIGGER_TESTID, + itemTestId: 'language-picker-item', + expectTrigger: expectHeaderLanguageTrigger, + selectedRow: 'English', + unselectedRow: '日本語', + // Every language row selects a language, so this menu has no navigation rows + // and nothing to divide. + navigationRowCount: 0, + separatorCount: 0, + lastRowRole: 'menuitemradio', + lastRowName: /日本語/, +} + +/** + * A Docs 2026 header dropdown, rebuilt on Brand's ActionMenu. Opens the menu, + * checks the surface, rows, selection indicator and the absence of Brand's own + * leading check slot, then closes it and confirms focus returns to the trigger. + */ +async function expectHeaderDropdownDesign( + page: Page, + colorScheme: 'light' | 'dark', + dropdown: HeaderDropdown, +) { + const picker = page.getByTestId('desktop-header').getByTestId(dropdown.pickerTestId) + const trigger = picker.getByTestId(dropdown.triggerTestId) + await expect(trigger).toHaveAttribute('aria-expanded', 'false') + await trigger.click() + await expect(trigger).toHaveAttribute('aria-expanded', 'true') + + // Brand's menu is not portalled -- it renders inside the picker wrapper. + const menu = picker.getByRole('menu') + await expect(menu).toBeVisible() + + const tokens = await resolveThemeTokens(menu, [ + '--brand-color-canvas-default', + '--brand-color-border-subtle', + '--brand-color-border-muted', + '--brand-color-text-default', + '--brand-color-success-fg', + ]) + const luminance = relativeLuminance(tokens['--brand-color-canvas-default']) + if (colorScheme === 'dark') { + expect(luminance).toBeLessThan(0.2) + } else { + expect(luminance).toBeGreaterThan(0.8) + } + + // Menu surface: canvas-default fill, 1px subtle border, 6px radius, 8px pad. + // Brand's own defaults are a border-muted border and a 16px radius. + await expect(menu).toHaveCSS('background-color', tokens['--brand-color-canvas-default']) + for (const side of ['top', 'right', 'bottom', 'left']) { + await expect(menu).toHaveCSS(`border-${side}-width`, '1px') + await expect(menu).toHaveCSS(`border-${side}-style`, 'solid') + await expect(menu).toHaveCSS(`border-${side}-color`, tokens['--brand-color-border-subtle']) + await expect(menu).toHaveCSS(`padding-${side}`, '8px') + } + for (const corner of ['top-left', 'top-right', 'bottom-left', 'bottom-right']) { + await expect(menu).toHaveCSS(`border-${corner}-radius`, '6px') + } + // The design's menu is 256px wide; a long row may grow it, never shrink it. + const menuBox = (await menu.boundingBox())! + expect(menuBox.width).toBeGreaterThanOrEqual(256) + // Brand anchors with `allowOutOfBounds`, so nothing clamps a menu that would + // overhang -- which matters most for the language menu, the one control sitting + // at the header's right edge. `menuAlignment` is what keeps it on screen, so + // assert the result instead of trusting the prop. + const viewportWidth = page.viewportSize()!.width + expect(menuBox.x).toBeGreaterThanOrEqual(-1) + expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(viewportWidth + 1) + + const selectableRows = menu.getByRole('menuitemradio') + const navigationRows = menu.getByRole('menuitem') + expect(await selectableRows.count()).toBeGreaterThanOrEqual(2) + // In the plan menu "All Enterprise Server releases" and "About versions" + // navigate rather than select, so they stay plain menuitems. The language menu + // has no such rows. + await expect(navigationRows).toHaveCount(dropdown.navigationRowCount) + + // A single rule divides the versions from those two navigation rows. Brand has + // no divider child, so the picker renders the separator itself; it must not be + // focusable, and must be neither the first nor the last row, because Brand + // focuses the first
  • and wires its arrow-key wrap-around to the first and + // the last. The language menu divides nothing, so it carries no separator. + const separator = menu.locator('[role="separator"]') + await expect(separator).toHaveCount(dropdown.separatorCount) + if (dropdown.separatorCount > 0) { + await expect(separator).not.toHaveAttribute('tabindex') + await expect(separator).not.toHaveAttribute('data-value') + await expect(separator).toHaveCSS('border-top-color', tokens['--brand-color-border-subtle']) + await expect(separator).toHaveCSS('border-top-width', '1px') + await expect(separator).toHaveCSS('border-top-style', 'solid') + const rule = await menu.evaluate((list) => { + const rows = Array.from(list.querySelectorAll('li')) + const index = rows.findIndex((row) => row.getAttribute('role') === 'separator') + const style = getComputedStyle(list) + const ruleStyle = getComputedStyle(rows[index]) + return { + index, + total: rows.length, + previousRole: rows[index - 1]?.getAttribute('role') ?? null, + nextRole: rows[index + 1]?.getAttribute('role') ?? null, + nextText: rows[index + 1]?.textContent?.trim() ?? null, + width: rows[index].getBoundingClientRect().width, + innerWidth: + list.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), + marginTop: parseFloat(ruleStyle.marginTop), + marginBottom: parseFloat(ruleStyle.marginBottom), + } + }) + // After the last version row and before "All Enterprise Server releases". + expect(rule.index).toBeGreaterThan(0) + expect(rule.index).toBe(rule.total - 1 - dropdown.navigationRowCount) + expect(rule.previousRole).toBe('menuitemradio') + expect(rule.nextRole).toBe('menuitem') + expect(rule.nextText).toMatch(/All Enterprise Server releases/) + // A plain
  • is a block box, so the rule spans the menu's inner width + // rather than sitting inside a row's own 12px insets. + expect(rule.width).toBeCloseTo(rule.innerWidth, 0) + expect(rule.marginTop).toBeCloseTo(8, 0) + expect(rule.marginBottom).toBeCloseTo(8, 0) + } + + const rows = menu.locator('li:not([role="separator"])') + const rowCount = await rows.count() + expect(rowCount).toBe((await selectableRows.count()) + dropdown.navigationRowCount) + for (let index = 0; index < rowCount; index++) { + const row = rows.nth(index) + expect((await row.boundingBox())!.height).toBeCloseTo(32, 0) + await expect(row).toHaveCSS('padding-left', '12px') + // The reserved indicator column replaces Brand's 48px single-selection + // gutter: a 12px inset, the 16px dot, then a 12px gap before the label. + await expect(row).toHaveCSS('padding-right', '40px') + for (const corner of ['top-left', 'top-right', 'bottom-left', 'bottom-right']) { + await expect(row).toHaveCSS(`border-${corner}-radius`, '6px') + } + + const label = row.getByTestId(dropdown.itemTestId) + await expect(label).toBeVisible() + await expect(label).toHaveCSS('font-size', '14px') + await expect(label).toHaveCSS('font-weight', '500') + await expect(label).toHaveCSS('color', tokens['--brand-color-text-default']) + const letterSpacing = await label.evaluate((element) => + parseFloat(getComputedStyle(element).letterSpacing), + ) + expect(letterSpacing).toBeCloseTo(0.14, 2) + } + + const selectedRow = menu.getByRole('menuitemradio', { name: dropdown.selectedRow, exact: true }) + await expect(selectedRow).toHaveAttribute('aria-checked', 'true') + await expect(selectedRow).toHaveCSS('background-color', tokens['--brand-color-border-muted']) + + // Selected row: a trailing 16px green dot at the row's right edge. + const dot = selectedRow.locator('svg.octicon-dot-fill') + await expect(dot).toBeVisible() + await expect(dot).toHaveCSS('width', '16px') + await expect(dot).toHaveCSS('height', '16px') + await expect(dot).toHaveCSS('fill', tokens['--brand-color-success-fg']) + const selectedBox = (await selectedRow.boundingBox())! + const dotBox = (await dot.boundingBox())! + expect(selectedBox.x + selectedBox.width - (dotBox.x + dotBox.width)).toBeCloseTo(12, 0) + expect(dotBox.y + dotBox.height / 2).toBeCloseTo(selectedBox.y + selectedBox.height / 2, 0) + + // Brand renders a leading check slot on every row of a single-selection menu; + // the design marks the current row with the trailing dot instead. Assert the + // rendered result rather than Brand's hashed class names: the selected row's + // only visible glyph is the dot. + await expect(selectedRow.locator('svg.octicon-check')).not.toBeVisible() + const visibleGlyphs = await selectedRow + .locator('svg') + .evaluateAll((svgs) => + svgs.filter((svg) => svg.getClientRects().length > 0).map((svg) => svg.getAttribute('class')), + ) + expect(visibleGlyphs).toHaveLength(1) + expect(visibleGlyphs[0]).toContain('octicon-dot-fill') + // When Brand renders that slot it must be hidden outright. Written so a future + // Brand release that stops rendering it altogether does not fail the suite. + const leadingSlotDisplay = await selectedRow.evaluate((row) => { + const first = row.firstElementChild + return first && row.children.length > 1 ? getComputedStyle(first).display : null + }) + expect(leadingSlotDisplay === null || leadingSlotDisplay === 'none').toBe(true) + + // Non-selected rows carry no trailing indicator at all. + const unselectedRow = menu.getByRole('menuitemradio', { + name: dropdown.unselectedRow, + exact: true, + }) + await expect(unselectedRow).toHaveAttribute('aria-checked', 'false') + await expect(unselectedRow.locator('svg.octicon-dot-fill')).toHaveCount(0) + await expect(unselectedRow).not.toHaveCSS( + 'background-color', + tokens['--brand-color-border-muted'], + ) + + for (let index = 0; index < dropdown.navigationRowCount; index++) { + const extra = navigationRows.nth(index) + // axe rejects aria-checked on role=menuitem, so the extras must opt out of + // the selection semantics ActionMenu.Overlay injects into its children. + await expect(extra).not.toHaveAttribute('aria-checked') + await expect(extra.locator('svg.octicon-dot-fill')).toHaveCount(0) + } + + await page.keyboard.press('Escape') + await expect(menu).not.toBeVisible() + await expect(trigger).toHaveAttribute('aria-expanded', 'false') + await expect(trigger).toBeFocused() +} + +// The properties a shared stylesheet is supposed to fix identically for both +// dropdowns. Content-dependent geometry (the menu's used width, a row's text) is +// deliberately absent: only the styling has to match. +const SURFACE_PROPERTIES = [ + 'background-color', + 'min-width', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'border-top-width', + 'border-top-style', + 'border-top-color', + 'border-top-left-radius', + 'border-bottom-right-radius', +] +const ROW_PROPERTIES = [ + 'display', + 'align-items', + 'position', + 'min-height', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'border-top-left-radius', + 'background-color', +] +const LABEL_PROPERTIES = [ + 'font-family', + 'font-size', + 'font-weight', + 'letter-spacing', + 'color', + 'white-space', +] +const DOT_PROPERTIES = ['position', 'right', 'width', 'height', 'fill'] + +/** + * A style fingerprint of an open header dropdown: the surface, the selected row, + * its label and its trailing dot. Two dropdowns whose styling really does come + * from one shared module produce equal fingerprints -- which is a stronger claim + * than each one separately matching the design, and it is the claim the user + * actually made ("the language dropdown needs to look like the version + * dropdown"). + */ +async function dropdownStyleFingerprint(menu: Locator, dropdown: HeaderDropdown) { + const selectedRow = menu.getByRole('menuitemradio', { name: dropdown.selectedRow, exact: true }) + const read = (locator: Locator, properties: string[]) => + locator.evaluate((element, names: string[]) => { + const style = getComputedStyle(element) + return Object.fromEntries(names.map((name) => [name, style.getPropertyValue(name)])) + }, properties) + + return { + surface: await read(menu, SURFACE_PROPERTIES), + row: await read(selectedRow, ROW_PROPERTIES), + label: await read(selectedRow.getByTestId(dropdown.itemTestId), LABEL_PROPERTIES), + dot: await read(selectedRow.locator('svg.octicon-dot-fill'), DOT_PROPERTIES), + // Brand's leading check slot is hidden structurally, so it has to be hidden + // in both menus or one of them grows a check icon the other does not have. + leadingSlotDisplay: await selectedRow.evaluate((row) => { + const first = row.firstElementChild + return first && row.children.length > 1 ? getComputedStyle(first).display : null + }), + } +} + +async function expectDesktopHeaderSections(page: Page, signupVisible: boolean) { + const header = page.getByTestId('desktop-header') + await expect(header.getByRole('button', { name: SEARCH_LABEL, exact: true })).toBeInViewport({ + ratio: 1, + }) + await expect(header.getByRole('button', { name: LANGUAGE_LABEL, exact: true })).toBeInViewport({ + ratio: 1, + }) + await expect(header.getByRole('button', { name: 'Menu', exact: true })).not.toBeVisible() + if (signupVisible) { + await expect(header.getByTestId('header-signup')).toBeInViewport({ ratio: 1 }) + } else { + await expect(header.getByTestId('header-signup')).toHaveCount(0) + } + await page.evaluate(() => document.fonts.ready) + + await expect(async () => { + const layout = await header.evaluate((element) => { + const search = element.querySelector('[data-testid="toggle-search"]')! + const language = element.querySelector('[data-testid="language-picker"]')! + const signup = element.querySelector('[data-testid="header-signup"]') + // Find the native section wrappers from stable Docs control anchors, not + // Brand's private CSS class names or a hardcoded number of parent hops. + let sectionRow = search.parentElement! + while (!sectionRow.contains(language)) sectionRow = sectionRow.parentElement! + const sectionFor = (control: HTMLElement) => { + if (!sectionRow.contains(control)) throw new Error('Header control left its section row') + let section = control + while (section.parentElement !== sectionRow) section = section.parentElement! + return section + } + const measure = (section: HTMLElement) => { + const style = getComputedStyle(section) + return { + rect: section.getBoundingClientRect().toJSON(), + borderStart: style.borderInlineStartWidth, + borderEnd: style.borderInlineEndWidth, + borderStartStyle: style.borderInlineStartStyle, + borderEndStyle: style.borderInlineEndStyle, + borderStartColor: style.borderInlineStartColor, + borderEndColor: style.borderInlineEndColor, + } + } + const searchSection = sectionFor(search) + const languageSection = sectionFor(language) + const signupSection = signup ? sectionFor(signup) : null + const visibleSections = Array.from(sectionRow.children).filter((child) => { + const rect = child.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 && getComputedStyle(child).visibility !== 'hidden' + }) + const headerRect = element.getBoundingClientRect() + return { + header: headerRect.toJSON(), + viewportWidth: document.documentElement.clientWidth, + contentBottom: headerRect.bottom - parseFloat(getComputedStyle(element).borderBottomWidth), + search: measure(searchSection), + language: measure(languageSection), + signup: signupSection ? measure(signupSection) : null, + searchButton: search.getBoundingClientRect().toJSON(), + languageButton: language.querySelector('button')!.getBoundingClientRect().toJSON(), + signupButton: signup?.getBoundingClientRect().toJSON(), + separateSections: + searchSection !== languageSection && + (!signupSection || + (signupSection !== searchSection && signupSection !== languageSection)), + sectionOrder: visibleSections.map((section) => + section === searchSection + ? 'search' + : section === languageSection + ? 'language' + : section === signupSection + ? 'signup' + : 'unexpected', + ), + } + }) + + expect(layout.separateSections).toBe(true) + expect(layout.sectionOrder).toEqual( + signupVisible ? ['search', 'language', 'signup'] : ['search', 'language'], + ) + expect(layout.header.left).toBeCloseTo(0, 0) + expect(layout.header.right).toBeCloseTo(layout.viewportWidth, 0) + // DOM order alone misses flex-order regressions, overlaps, and blank cells. + expect(layout.searchButton.right).toBeLessThan(layout.languageButton.left) + expect(layout.search.rect.right).toBeCloseTo(layout.language.rect.left, 0) + const lastSection = signupVisible ? layout.signup! : layout.language + expect(lastSection.rect.right).toBeCloseTo(layout.header.right, 0) + for (const section of [ + layout.search, + layout.language, + ...(layout.signup ? [layout.signup] : []), + ]) { + expect(section.rect.width).toBeGreaterThan(0) + expect(section.rect.top).toBeCloseTo(layout.header.top, 0) + expect(section.rect.bottom).toBeCloseTo(layout.contentBottom, 0) + } + // Search owns the full-height divider before Language. Language must not + // double that border; Signup owns its own separate full-height left divider. + expect(layout.search.borderEnd).toBe('1px') + expect(layout.search.borderEndStyle).toBe('solid') + expect(layout.search.borderEndColor).not.toBe('rgba(0, 0, 0, 0)') + expect(layout.language.borderStart).toBe('0px') + expect(layout.language.borderEnd).toBe('0px') + if (signupVisible) { + expect(layout.languageButton.right).toBeLessThan(layout.signupButton!.left) + expect(layout.language.rect.right).toBeCloseTo(layout.signup!.rect.left, 0) + expect(layout.signup!.borderStart).toBe('1px') + expect(layout.signup!.borderStartStyle).toBe('solid') + expect(layout.signup!.borderStartColor).toBe(layout.search.borderEndColor) + } + }).toPass() +} + +async function expectDocsSearchOpen(page: Page) { + const searchInput = page.getByTestId('overlay-search-input') + await expect(searchInput).toBeFocused() + // Focus alone misses a backdrop incorrectly layered above the portalled dialog. + await searchInput.click() + await expect(searchInput).toBeFocused() + await expect(page.getByRole('dialog')).toHaveCount(1) + // Brand mounts its native dialog even while closed. Only the existing Docs + // dialog may become modal; opening both would leave competing focus traps. + const brandDialog = page.getByTestId('desktop-header').locator('dialog') + await expect(brandDialog).toHaveCount(1) + await expect(brandDialog).toHaveJSProperty('open', false) + await expect(page).toHaveURL((url) => url.searchParams.get('search-overlay-open') === 'true') +} + +async function expectBackgroundIsolated(page: Page, isolated: boolean) { + for (const locator of [ + page.getByText('Skip to main content', { exact: true }), + // Brand's own skip link sits outside the inert wrapper (it renders as a + // sibling before
    ) yet still targets #main-content, which is inert + // while the menu is open. Matched by CSS rather than text or role: Brand + // wraps the label in a span, so getByText resolves to both the and that + // span -- a strict mode violation -- and aria-hidden removes it from the + // accessibility tree that getByRole searches once isolated. + page.locator('[data-container="header"] a[href="#main-content"]'), + page.locator('#main-content'), + page.getByTestId('sidebar-mobile-toggle'), + page.locator('footer').first(), + ]) { + await expect + .poll(() => locator.evaluate((element) => !!element.closest('[inert]'))) + .toBe(isolated) + await expect + .poll(() => locator.evaluate((element) => !!element.closest('[aria-hidden="true"]'))) + .toBe(isolated) + } +} + +test.describe('Brand header', () => { + test.beforeEach(async ({ page }) => { + // These regressions cover header coordination, not remote search quality. + // Return empty suggestions so they also run without Elasticsearch or Copilot. + await page.route('**/api/search/combined-search/v1?**', (route) => + route.fulfill({ + json: { + aiAutocompleteSuggestions: { hits: [] }, + generalSearchResults: { hits: [], meta: { found: { value: 0 } } }, + }, + }), + ) + }) + + for (const width of [1012, 1440]) { + for (const hasAccount of [false, true]) { + test(`desktop plan pill, language trigger and divided right-hand sections at ${width}px, signup ${hasAccount ? 'absent' : 'visible'}`, async ({ + page, + context, + }) => { + await page.setViewportSize({ width, height: 800 }) + await page.emulateMedia({ colorScheme: 'light' }) + await page.goto(ARTICLE) + await turnOffExperimentsInPage(page) + if (hasAccount) { + await context.addCookies([ + { + name: COLOR_MODE_COOKIE_NAME, + value: encodeURIComponent(JSON.stringify({ color_mode: 'light' })), + url: page.url(), + }, + ]) + await page.reload() + } + + // Wait for account detection/desktop slots before measuring the pill: + // Signup mounting must not shrink a name that only fit before hydration. + await expectDesktopHeaderSections(page, !hasAccount) + await expectHeaderPlanPicker(page) + // 1012px is where the two triggers compete for room with Signup, so it is + // also where the flat language control is most likely to be "fixed" by + // giving it the pill's class. + await expectHeaderLanguageTrigger(page) + }) + } + } + + /** + * Brand renders its trailing slot whenever `trailingComponent` is not null, + * so a `LanguagePicker` that returned `null` from inside the slot would still + * leave the wrapper behind: an empty divided cell at the header's right edge + * on desktop, and a full-width 16px-padded block in the narrow menu. Header.tsx + * therefore withholds the prop itself rather than letting the picker opt out, + * and that decision is invisible to every other test here -- they all run on + * multi-language pages, where the slot is supposed to be present. + * + * Each absence is paired with the same assertion on a multi-language page. + * Brand's class name is hashed, so `BRAND_TRAILING_SLOT` on its own would keep + * passing the day Brand renames it; proving the selector still matches + * something is what stops this from becoming a test of nothing. + */ + test('the language slot is omitted, not left empty, when only English is available', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 800 }) + await page.emulateMedia({ colorScheme: 'light' }) + + await page.goto(ARTICLE) + await turnOffExperimentsInPage(page) + await expect(page.getByTestId('desktop-header').locator(BRAND_TRAILING_SLOT)).toHaveCount(1) + + await page.goto(ENGLISH_ONLY_ARTICLE) + await turnOffExperimentsInPage(page) + const header = page.getByTestId('desktop-header') + // The plan picker still renders here, so an empty header would fail this + // rather than passing as a trivially absent language control. + await expect(header.getByRole('button', { name: PLAN_LABEL, exact: false })).toBeVisible() + await expect(page.getByTestId('language-picker')).toHaveCount(0) + await expect(header.locator(BRAND_TRAILING_SLOT)).toHaveCount(0) + + // Independently of Brand's class names: every divided cell in the header's + // section row still holds a control. An empty slot is exactly a cell that + // does not, and it would carry its own gridline and margin. + await page.evaluate(() => document.fonts.ready) + await expect(async () => { + const sections = await header.evaluate((element) => { + const search = element.querySelector('[data-testid="toggle-search"]')! + const signup = element.querySelector('[data-testid="header-signup"]')! + let sectionRow = search.parentElement! + while (!sectionRow.contains(signup)) sectionRow = sectionRow.parentElement! + const headerRight = element.getBoundingClientRect().right + const visible = Array.from(sectionRow.children).filter((child) => { + const rect = child.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 + }) + return { + empty: visible.filter((child) => !child.querySelector('button, a')).length, + lastReachesEdge: + Math.abs(visible[visible.length - 1].getBoundingClientRect().right - headerRight) < 1, + lastHoldsSignup: visible[visible.length - 1].contains(signup), + } + }) + expect(sections.empty).toBe(0) + expect(sections.lastHoldsSignup).toBe(true) + expect(sections.lastReachesEdge).toBe(true) + }).toPass() + + // The narrow menu is where the leftover wrapper would be most visible: a + // full-width padded block above Sign up rather than a thin cell. + await page.setViewportSize({ width: 390, height: 800 }) + await page.getByRole('button', { name: 'Menu', exact: true }).click() + await expect(page.getByTestId('header-signup')).toBeVisible() + await expect(page.getByTestId('language-picker')).toHaveCount(0) + await expect(header.locator(BRAND_TRAILING_SLOT)).toHaveCount(0) + }) + + for (const colorScheme of ['light', 'dark'] as const) { + for (const dropdown of [PLAN_DROPDOWN, LANGUAGE_DROPDOWN]) { + test(`the ${dropdown.name} dropdown matches the Docs 2026 design in ${colorScheme} mode`, async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 800 }) + // No color_mode cookie, so the page stays in `auto` and resolves the + // scheme from this media emulation. Set before navigating so the first + // paint already uses it. + await page.emulateMedia({ colorScheme }) + await page.goto(ARTICLE) + await turnOffExperimentsInPage(page) + + // Each trigger resolves every color through tokens, so both are worth + // re-checking in dark mode rather than only in the light-mode loop above. + // The two triggers are intentionally different -- a filled pill for the + // plan, a flat control for the language -- which is why only the dropdown + // below them is shared. + await dropdown.expectTrigger(page) + await expectHeaderDropdownDesign(page, colorScheme, dropdown) + }) + } + } + + /** + * The sticky ladder: header > Docs 2026 secondary bar > sticky table headers. + * + * Brand's ActionMenu is not portalled, so the plan and language dropdowns + * render inside the header's stacking context and hang well below it, across + * the secondary bar. The bar is sticky at every width and sits above sticky + * table headers, so if the header does not outrank the bar, the bar paints a + * band straight through the open menu and eats the clicks behind it -- which + * is invisible to every other test here, because the menu still has the right + * geometry, styling and roles while being covered. + * + * Asserted by hit-testing rather than by comparing z-index values: equal + * z-index is resolved by DOM order, so the numbers alone do not say which + * element a reader actually reaches. + */ + test('an open dropdown stays clickable where the secondary bar crosses it', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 800 }) + await page.emulateMedia({ colorScheme: 'light' }) + await page.goto(ARTICLE) + await turnOffExperimentsInPage(page) + + await page.getByTestId(PLAN_TRIGGER_TESTID).click() + const menu = page.getByTestId('version-picker').getByRole('menu') + await expect(menu).toBeVisible() + + const overlap = await page.evaluate(() => { + const bar = document.querySelector('[class*="DocsSecondaryBar"][class*="bar"]') + const menuEl = document.querySelector('[data-testid="version-picker"] [role="menu"]') + if (!bar || !menuEl) return { barFound: !!bar, menuFound: !!menuEl, crosses: false } + const b = bar.getBoundingClientRect() + const m = menuEl.getBoundingClientRect() + const crosses = m.bottom > b.top && m.top < b.bottom + // Sample the full height of the band the two share. + const x = m.left + m.width / 2 + const top = Math.max(m.top, b.top) + 2 + const bottom = Math.min(m.bottom, b.bottom) - 2 + const covered = [] + for (let y = top; y <= bottom; y += 6) { + const el = document.elementFromPoint(x, y) + if (!el || !el.closest('[role="menu"]')) covered.push(Math.round(y)) + } + // A row the bar crosses must receive its own clicks, not just paint above. + const row = [ + ...document.querySelectorAll('[data-testid="version-picker"] [role="menuitemradio"]'), + ].find((candidate) => { + const r = candidate.getBoundingClientRect() + return r.top < b.bottom && r.bottom > b.top + }) + const rowRect = row?.getBoundingClientRect() + const rowHit = rowRect + ? document.elementFromPoint( + rowRect.left + rowRect.width / 2, + rowRect.top + rowRect.height / 2, + ) + : null + return { + barFound: true, + menuFound: true, + crosses, + covered, + rowCrossesBar: !!row, + rowReceivesItsOwnClick: !!(row && rowHit && row.contains(rowHit)), + } + }) + + // If the menu stopped overlapping the bar, this test would pass while + // asserting nothing, so require the overlap it exists to check. + expect(overlap.barFound).toBe(true) + expect(overlap.crosses).toBe(true) + expect(overlap.covered).toEqual([]) + expect(overlap.rowCrossesBar).toBe(true) + expect(overlap.rowReceivesItsOwnClick).toBe(true) + }) + + test('the language dropdown is styled identically to the plan dropdown', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 800 }) + await page.emulateMedia({ colorScheme: 'light' }) + await page.goto(ARTICLE) + await turnOffExperimentsInPage(page) + + // Opened one at a time: Brand closes a menu as soon as the other trigger is + // clicked, and both menus read their tokens from the same page and theme. + const fingerprints: Record = {} + for (const dropdown of [PLAN_DROPDOWN, LANGUAGE_DROPDOWN]) { + const picker = page.getByTestId('desktop-header').getByTestId(dropdown.pickerTestId) + await picker.getByTestId(dropdown.triggerTestId).click() + const menu = picker.getByRole('menu') + await expect(menu).toBeVisible() + fingerprints[dropdown.name] = await dropdownStyleFingerprint(menu, dropdown) + await page.keyboard.press('Escape') + await expect(menu).not.toBeVisible() + } + expect(fingerprints[LANGUAGE_DROPDOWN.name]).toEqual(fingerprints[PLAN_DROPDOWN.name]) + }) + + // Below 1012px both pickers move inside SubdomainNavBar's narrow menu, which is a + // scrolling panel. Brand's ActionMenu is absolutely positioned and — unlike the + // @primer/react menu it replaced — is not portalled, so it regresses easily into + // rendering outside that panel: cut off mid-list, or running past the viewport's + // right edge. Both of those still satisfy toBeVisible(), so assert geometry. The + // inline-flow rule that fixes it now lives in the shared module, so a change to it + // moves both dropdowns at once and both are covered here. + for (const dropdown of [PLAN_DROPDOWN, LANGUAGE_DROPDOWN]) { + for (const width of [390, 1000]) { + test(`the ${dropdown.name} dropdown stays inside the narrow menu at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 800 }) + await page.emulateMedia({ colorScheme: 'light' }) + await page.goto(ARTICLE) + await turnOffExperimentsInPage(page) + + await page.getByRole('button', { name: 'Menu', exact: true }).click() + const trigger = page.getByTestId(dropdown.triggerTestId) + await expect(trigger).toBeVisible() + await trigger.click() + await expect(page.getByRole('menu')).toBeVisible() + + const layout = await page.getByRole('menu').evaluate((element) => { + // The panel is found by its scrolling, not by Brand's hashed class name. + let panel = element.parentElement + while (panel) { + const { overflowX, overflowY } = getComputedStyle(panel) + if (/auto|scroll|hidden|clip/.test(`${overflowX}${overflowY}`)) break + panel = panel.parentElement + } + const menuRect = element.getBoundingClientRect() + const rows = Array.from(element.querySelectorAll('li')) + return { + menu: { left: menuRect.left, right: menuRect.right, bottom: menuRect.bottom }, + panel: panel ? panel.getBoundingClientRect().toJSON() : null, + lastRowBottom: rows[rows.length - 1].getBoundingClientRect().bottom, + viewportWidth: document.documentElement.clientWidth, + scrollsHorizontally: + document.documentElement.scrollWidth > document.documentElement.clientWidth, + } + }) + + expect(layout.panel).not.toBeNull() + // Inside the panel, so no row is cut off... + expect(layout.menu.bottom).toBeLessThanOrEqual(layout.panel.bottom + 1) + expect(layout.menu.right).toBeLessThanOrEqual(layout.panel.right + 1) + expect(layout.lastRowBottom).toBeLessThanOrEqual(layout.panel.bottom + 1) + // ...and inside the viewport, so no row is sliced by the screen edge. + expect(layout.menu.left).toBeGreaterThanOrEqual(-1) + expect(layout.menu.right).toBeLessThanOrEqual(layout.viewportWidth + 1) + expect(layout.scrollsHorizontally).toBe(false) + + // The final row is whatever a clipped menu loses first. + await expect( + page.getByRole(dropdown.lastRowRole, { name: dropdown.lastRowName }), + ).toBeVisible() + + // Escape still closes only the picker, leaving the narrow menu open. + await page.keyboard.press('Escape') + await expect(page.getByRole('menu')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Close menu', exact: true })).toBeVisible() + }) + } + } + + // Brand staggers the narrow menu's items in at 80ms per slot and hardcodes the + // signup CTA's wrapper to slot 10 -- the moment ten `SubdomainNavBar.Link` + // children would have finished cascading in. Docs passes zero links, so the + // shipped 800ms is a dead second: the pickers ride the panel's fade and + // "Sign up" trails them. Header.module.scss cuts it to a single slot, so assert + // the computed delay rather than a wall clock, and assert that only the delay + // moved -- duration and fill mode still have to be Brand's. + test('signup follows the narrow menu pickers by one stagger step, not ten', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await page.goto(ARTICLE) + await page.getByRole('button', { name: 'Menu', exact: true }).click() + + const signup = page.getByTestId('header-signup') + await expect(signup).toBeVisible() + const animation = await signup.evaluate((element) => { + // Brand hashes this class and exposes no test id for it, so match the + // stable part of the name -- the same anchor the override in + // Header.module.scss uses. + const area = element.closest('[class*="SubdomainNavBar-button-area--visible"]') + if (!area) throw new Error('Signup is not inside the narrow-menu button area') + const { animationDelay, animationDuration, animationFillMode } = getComputedStyle(area) + return { + delay: parseFloat(animationDelay), + duration: parseFloat(animationDuration), + fillMode: animationFillMode, + } + }) + + // Brand's untouched default is calc(10 * 80ms). + expect(animation.delay).not.toBeCloseTo(0.8, 3) + // Still staggered after the pickers, but by one 80ms slot rather than ten. + expect(animation.delay).toBeGreaterThan(0) + expect(animation.delay).toBeLessThanOrEqual(0.16) + expect(animation.duration).toBeCloseTo(0.5, 3) + expect(animation.fillMode).toBe('both') + }) + + for (const width of [390, 1440]) { + for (const activation of ['click', 'Enter', 'Space']) { + test(`${activation} opens only Docs search at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }) + await page.goto(ARTICLE) + await turnOffExperimentsInPage(page) + + const searchTrigger = page.getByRole('button', { name: SEARCH_LABEL, exact: true }) + await expect(searchTrigger).toHaveCount(1) + if (activation === 'click') { + await searchTrigger.click() + } else { + await searchTrigger.focus() + await page.keyboard.press(activation) + } + await expectDocsSearchOpen(page) + + await page.keyboard.press('Escape') + await expect(page.getByTestId('overlay-search-input')).toHaveCount(0) + await expect(searchTrigger).toBeFocused() + await expect(page).not.toHaveURL((url) => url.searchParams.has('search-overlay-open')) + }) + } + } + + test('the same search trigger receives focus after resizing an open overlay', async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 800 }) + await page.goto(ARTICLE) + const searchTrigger = page.getByRole('button', { name: SEARCH_LABEL, exact: true }) + // Keep the original element, not just a locator that could match a replacement. + const originalTrigger = await searchTrigger.elementHandle() + expect(originalTrigger).not.toBeNull() + await searchTrigger.click() + await expectDocsSearchOpen(page) + + await page.setViewportSize({ width: 390, height: 800 }) + await expectDocsSearchOpen(page) + await page.keyboard.press('Escape') + await expect(searchTrigger).toHaveCount(1) + await expect(searchTrigger).toBeVisible() + await expect(searchTrigger).toBeFocused() + expect(await originalTrigger!.evaluate((element) => element === document.activeElement)).toBe( + true, + ) + + await searchTrigger.press('Enter') + await expectDocsSearchOpen(page) + await page.setViewportSize({ width: 1440, height: 800 }) + await page.keyboard.press('Escape') + await expect(searchTrigger).toBeFocused() + expect(await originalTrigger!.evaluate((element) => element === document.activeElement)).toBe( + true, + ) + await originalTrigger!.dispose() + }) + + test('clicking outside Docs search dismisses the overlay and clears its query state', async ({ + page, + }) => { + await page.goto(ARTICLE) + const searchTrigger = page.getByRole('button', { name: SEARCH_LABEL, exact: true }) + await searchTrigger.click() + await expectDocsSearchOpen(page) + + // The far corner is outside the centered desktop dialog and hits its backdrop. + await page.mouse.click(1, page.viewportSize()!.height - 1) + await expect(page.getByTestId('overlay-search-input')).toHaveCount(0) + await expect(page).not.toHaveURL((url) => url.searchParams.has('search-overlay-open')) + await expect(page.getByTestId('desktop-header').locator('dialog')).toHaveJSProperty( + 'open', + false, + ) + // PRC restores focus during mousedown capture; the browser then transfers it + // to the clicked backdrop. Persistent return focus is an Escape contract only. + await expect(searchTrigger).toBeVisible() + await expect(searchTrigger).toBeEnabled() + }) + + test('slash stays literal in editable fields and ignores modified shortcuts', async ({ + page, + }) => { + await page.goto(ARTICLE) + await expect(page.getByTestId('toggle-search')).toBeVisible() + // Use real DOM fields without depending on survey or search results data. + await page.locator('#main-content').evaluate((main) => { + const fields = document.createElement('div') + fields.innerHTML = ` + + + +
    + ` + main.prepend(fields) + }) + + for (const label of [ + 'Header shortcut input', + 'Header shortcut textarea', + 'Header shortcut select', + 'Header shortcut editor', + ]) { + const field = page.getByLabel(label, { exact: true }) + await field.focus() + await page.keyboard.press('/') + await expect(field).toBeFocused() + await expect(page.getByTestId('overlay-search-input')).toHaveCount(0) + } + await expect(page.getByLabel('Header shortcut input', { exact: true })).toHaveValue('/') + await expect(page.getByLabel('Header shortcut textarea', { exact: true })).toHaveValue('/') + await expect(page.getByLabel('Header shortcut editor', { exact: true })).toHaveText('/') + + const searchTrigger = page.getByTestId('toggle-search') + await searchTrigger.focus() + for (const shortcut of ['Control+/', 'Meta+/', 'Alt+/']) { + await page.keyboard.press(shortcut) + await expect(page.getByTestId('overlay-search-input')).toHaveCount(0) + } + await page.keyboard.press('/') + await expectDocsSearchOpen(page) + await page.keyboard.press('Escape') + await expect(searchTrigger).toBeFocused() + }) + + for (const picker of ['language', 'version']) { + test(`Escape closes the nested ${picker} picker before the utility menu`, async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await page.goto(ARTICLE) + const body = page.locator('body') + const originalOverflow = await body.evaluate((element) => element.style.overflow) + await page.getByRole('button', { name: 'Menu', exact: true }).click() + const closeMenu = page.getByRole('button', { name: 'Close menu', exact: true }) + await expect(closeMenu).toHaveAttribute('aria-expanded', 'true') + await expect(body).toHaveCSS('overflow', 'hidden') + await expectBackgroundIsolated(page, true) + + const pickerTrigger = + picker === 'language' + ? page.getByRole('button', { name: LANGUAGE_LABEL, exact: true }) + : page.getByTestId('version-picker').getByRole('button') + const option = page.getByRole('menuitemradio', { + name: picker === 'language' ? 'English' : 'Enterprise Cloud', + exact: true, + }) + await pickerTrigger.click() + await expect(option).toBeVisible() + await page.keyboard.press('Escape') + await expect(option).not.toBeVisible() + await expect(pickerTrigger).toBeFocused() + await expect(closeMenu).toHaveAttribute('aria-expanded', 'true') + await expect(body).toHaveCSS('overflow', 'hidden') + await expectBackgroundIsolated(page, true) + + await page.keyboard.press('Escape') + await expect(page.getByRole('button', { name: 'Menu', exact: true })).toBeFocused() + await expectBackgroundIsolated(page, false) + await expect + .poll(() => body.evaluate((element) => element.style.overflow)) + .toBe(originalOverflow) + }) + } + + for (const activation of ['click', '/']) { + test(`${activation} search closes an open utility menu before focusing the overlay`, async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await page.goto(ARTICLE) + await page.getByRole('button', { name: 'Menu', exact: true }).click() + await expectBackgroundIsolated(page, true) + + const searchTrigger = page.getByRole('button', { name: SEARCH_LABEL, exact: true }) + if (activation === 'click') { + await searchTrigger.click() + } else { + await page.keyboard.press('/') + } + await expectDocsSearchOpen(page) + await expect(page.getByTestId('version-picker')).not.toBeVisible() + await expect(page.getByRole('button', { name: 'Menu', exact: true })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + await expectBackgroundIsolated(page, false) + await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden') + + await page.keyboard.press('Escape') + await expect(searchTrigger).toBeFocused() + }) + } + + test('query-driven search opens Docs search and preserves the input', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await page.goto(`${ARTICLE}?search-overlay-open=true&search-overlay-input=header%20query`) + await expectDocsSearchOpen(page) + await expect(page.getByTestId('overlay-search-input')).toHaveValue('header query') + await expect(page.getByRole('button', { name: 'Menu', exact: true })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + await expectBackgroundIsolated(page, false) + + await page.keyboard.press('Escape') + await expect(page.getByTestId('toggle-search')).toBeFocused() + await expect(page).toHaveURL( + (url) => url.searchParams.get('search-overlay-input') === 'header query', + ) + await expect(page).not.toHaveURL((url) => url.searchParams.has('search-overlay-open')) + }) + + test('resizing from 1011px to 1012px closes the utility menu and restores content', async ({ + page, + }) => { + await page.setViewportSize({ width: 1011, height: 800 }) + await page.goto(ARTICLE) + const body = page.locator('body') + const originalOverflow = await body.evaluate((element) => element.style.overflow) + await page.getByRole('button', { name: 'Menu', exact: true }).click() + await expectBackgroundIsolated(page, true) + await expect(body).toHaveCSS('overflow', 'hidden') + + await page.setViewportSize({ width: 1012, height: 800 }) + await expect(page.getByRole('button', { name: 'Close menu', exact: true })).toHaveCount(0) + await expect(page.getByTestId('version-picker').getByRole('button')).toBeVisible() + await expect(page.getByRole('button', { name: LANGUAGE_LABEL, exact: true })).toBeVisible() + await expectBackgroundIsolated(page, false) + await expect + .poll(() => body.evaluate((element) => element.style.overflow)) + .toBe(originalOverflow) + + await page.setViewportSize({ width: 1011, height: 800 }) + await expect(page.getByRole('button', { name: 'Menu', exact: true })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + await expect(page.getByTestId('version-picker')).not.toBeVisible() + await expectBackgroundIsolated(page, false) + await page.getByTestId('sidebar-mobile-toggle').click() + await expect(page.getByTestId('sidebar')).toBeVisible() + }) + + test('version selection closes the utility menu, navigates, and persists the cookie', async ({ + page, + context, + }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await page.goto(ARTICLE) + await page.getByRole('button', { name: 'Menu', exact: true }).click() + await page.getByTestId('version-picker').getByRole('button').click() + await page.getByRole('menuitemradio', { name: 'Enterprise Cloud', exact: true }).click() + + await expect(page).toHaveURL('/en/enterprise-cloud@latest/get-started/foo/bar') + await expect(page.getByRole('button', { name: 'Menu', exact: true })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + await expectBackgroundIsolated(page, false) + await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden') + await expect + .poll( + async () => + (await context.cookies()).find((cookie) => cookie.name === USER_VERSION_COOKIE_NAME) + ?.value, + ) + .toBe('enterprise-cloud@latest') + + await page.getByRole('button', { name: 'Menu', exact: true }).click() + await expect(page.getByTestId('version-picker').getByTestId('field')).toHaveText( + 'Enterprise Cloud', + ) + await expect(page.getByTestId('version-picker').getByRole('button')).toHaveAccessibleName( + `${PLAN_LABEL} Enterprise Cloud`, + ) + await expect(page.getByTestId('header-signup')).toBeVisible() + }) + + test('language selection from the utility menu preserves the version and cookie', async ({ + page, + context, + }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await page.goto('/en/enterprise-cloud@latest/get-started/foo/bar') + await page.getByRole('button', { name: 'Menu', exact: true }).click() + await page.getByRole('button', { name: LANGUAGE_LABEL, exact: true }).click() + await page.getByRole('menuitemradio', { name: '日本語', exact: true }).click() + + await expect(page).toHaveURL('/ja/enterprise-cloud@latest/get-started/foo/bar') + await expect(page.getByTestId('language-picker')).not.toBeVisible() + await expectBackgroundIsolated(page, false) + await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden') + await expect + .poll( + async () => + (await context.cookies()).find((cookie) => cookie.name === USER_LANGUAGE_COOKIE_NAME) + ?.value, + ) + .toBe('ja') + }) + + for (const linkName of ['Github Home', 'Docs home']) { + test(`${linkName} preserves the locale and version with client-side navigation`, async ({ + page, + }) => { + await page.goto('/ja/enterprise-cloud@latest/get-started/foo/bar') + const homeLink = page + .getByTestId('desktop-header') + .getByRole('link', { name: linkName, exact: true }) + await expect(homeLink).toHaveAttribute('href', '/ja/enterprise-cloud@latest') + const originalDocument = await page.locator('html').elementHandle() + expect(originalDocument).not.toBeNull() + await homeLink.click() + await expect(page).toHaveURL('/ja/enterprise-cloud@latest') + expect( + await originalDocument!.evaluate((element) => element === document.documentElement), + ).toBe(true) + await originalDocument!.dispose() + }) + } + + test('signup keeps its external destination and new-tab behavior', async ({ page }) => { + await page.goto(ARTICLE) + const signup = page.getByTestId('header-signup') + await expect(signup).toHaveAttribute( + 'href', + 'https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs', + ) + await expect(signup).toHaveAttribute('target', '_blank') + await expect(signup).toHaveAttribute('rel', /noopener/) + }) + + test('signup is absent for a reader with an account', async ({ page, context }) => { + await page.goto(ARTICLE) + await context.addCookies([ + { + name: COLOR_MODE_COOKIE_NAME, + value: encodeURIComponent(JSON.stringify({ color_mode: 'light' })), + url: page.url(), + }, + ]) + await page.reload() + await expect(page.getByTestId('version-picker').getByRole('button')).toBeVisible() + await expect(page.getByTestId('header-signup')).toHaveCount(0) + }) + + test('signup is absent on Enterprise Server', async ({ page }) => { + await page.goto('/en/enterprise-server@latest/get-started/foo/bar') + await expect(page).toHaveURL(/\/en\/enterprise-server@[\d.]+\/get-started\/foo\/bar$/) + await expect(page.getByTestId('version-picker').getByRole('button')).toBeVisible() + await expect(page.getByTestId('header-signup')).toHaveCount(0) + }) + + test('the version picker announces a version, not a plan, on Enterprise Server', async ({ + page, + }) => { + await page.goto('/en/enterprise-server@latest/get-started/foo/bar') + const picker = page.getByTestId('desktop-header').getByTestId('version-picker') + const button = picker.getByRole('button') + const value = (await button.getByTestId('field').textContent())! + // versionTitle is `${planTitle} ${release}` for a numbered release, so the + // plan label would announce "Select your plan: Enterprise Server 3.19". + expect(value).toMatch(/^Enterprise Server [\d.]+$/) + await expect(picker.getByText(VERSION_LABEL, { exact: true })).toBeVisible() + await expect(button).toHaveAccessibleName(`${VERSION_LABEL} ${value}`) + await expect(picker.getByText(PLAN_LABEL, { exact: true })).toHaveCount(0) + }) + + test('the first skip link and the Brand skip link both target main content', async ({ page }) => { + await page.goto(ARTICLE) + await page.keyboard.press('Tab') + const firstSkip = page.getByRole('link', { name: 'Skip to main content', exact: true }) + await expect(firstSkip).toBeFocused() + await expect(firstSkip).toHaveAttribute('href', '#main-content') + await firstSkip.press('Enter') + await expect(page).toHaveURL(/#main-content$/) + + const brandSkip = page.getByRole('link', { name: 'Skip to content', exact: true }) + await brandSkip.focus() + await expect(brandSkip).toBeVisible() + await expect(brandSkip).toHaveAttribute('href', '#main-content') + await brandSkip.press('Enter') + await expect(page.locator('#main-content')).toBeFocused() + }) +}) diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index c5e18a7880b8..c238b036fa50 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -25,7 +25,10 @@ test('logo link keeps current version', async ({ page }) => { // Basically clicking into any page that isn't the home page for this version. await page.getByTestId('product').getByRole('link', { name: 'Get started' }).click() await expect(page).toHaveURL(/\/en\/enterprise-cloud@latest\/get-started/) - await page.getByRole('link', { name: 'GitHub Docs' }).click() + await page + .getByTestId('desktop-header') + .getByRole('link', { name: 'Github Home', exact: true }) + .click() await expect(page).toHaveURL(/\/en\/enterprise-cloud@latest/) }) @@ -41,6 +44,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') @@ -109,7 +121,7 @@ test('press "/" to open the search overlay', async ({ page }) => { await turnOffExperimentsInPage(page) // Wait for the header search button to render, so the keydown listener is attached. - await page.locator('[data-testid="search"]:visible').waitFor() + await page.getByTestId('toggle-search').waitFor() const searchInput = page.getByTestId('overlay-search-input') // The overlay (and its input) is not in the DOM until it's opened. @@ -119,16 +131,17 @@ test('press "/" to open the search overlay', async ({ page }) => { await page.keyboard.press('/') await expect(searchInput).toBeFocused() - // Escape closes it again. + // Escape closes it again and returns focus to the same responsive trigger. await page.keyboard.press('Escape') await expect(searchInput).toHaveCount(0) + await expect(page.getByTestId('toggle-search')).toBeFocused() }) test('"/" typed inside the search input is a literal slash', async ({ page }) => { await page.goto('/') await turnOffExperimentsInPage(page) - await page.locator('[data-testid="search"]:visible').waitFor() + await page.getByTestId('toggle-search').waitFor() await page.keyboard.press('/') const searchInput = page.getByTestId('overlay-search-input') @@ -145,7 +158,7 @@ test('do a search from home page and click on "Foo" page', async ({ page }) => { await page.goto('/') await turnOffExperimentsInPage(page) - await page.locator('[data-testid="search"]:visible').click() + await page.getByTestId('toggle-search').click() await page.getByTestId('overlay-search-input').fill('serve playwright') await page.waitForTimeout(1000) await page.getByText('View more results').click() @@ -167,7 +180,7 @@ test('open search, and perform a general search', async ({ page }) => { await page.goto('/') await turnOffExperimentsInPage(page) - await page.locator('[data-testid="search"]:visible').click() + await page.getByTestId('toggle-search').click() await page.getByTestId('overlay-search-input').fill('serve playwright') // Wait for the results to load // NOTE: In the UI we wait for results to load before allowing "enter", because we don't want @@ -193,7 +206,7 @@ test('open search, and select a general search article', async ({ page }) => { await page.goto('/') - await page.locator('[data-testid="search"]:visible').click() + await page.getByTestId('toggle-search').click() await page.getByTestId('overlay-search-input').fill('serve playwright') // Let new suggestions load @@ -212,7 +225,7 @@ test('open search, and get auto-complete results', async ({ page }) => { await page.goto('/') - await page.locator('[data-testid="search"]:visible').click() + await page.getByTestId('toggle-search').click() let listGroup = page.getByTestId('ai-autocomplete-suggestions') @@ -260,7 +273,7 @@ test('search from enterprise-cloud and filter by top-level Fooing', async ({ pag await page.goto('/enterprise-cloud@latest') await turnOffExperimentsInPage(page) - await page.locator('[data-testid="search"]:visible').click() + await page.getByTestId('toggle-search').click() await page.getByTestId('overlay-search-input').fill('fixture') await page.waitForTimeout(1000) await page.getByText('View more results').click() @@ -299,6 +312,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 +383,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 +597,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 +618,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 +629,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 +649,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') @@ -649,20 +673,41 @@ test.describe('test nav at different viewports', () => { }) test('large -> x-large viewports - 1012+', async ({ page }) => { - page.setViewportSize({ - width: 1013, + await page.setViewportSize({ + width: 1012, height: 700, }) await page.goto('/get-started/foo/bar') - await page.getByTestId('version-picker').getByRole('button').click() - expect((await page.getByRole('menuitemradio').all()).length).toBeGreaterThan(0) + // Both complete pickers are visible directly in the wide header. + await expect( + page.getByTestId('version-picker').getByText('Select your plan:', { exact: true }), + ).toBeVisible() + const planButton = page.getByTestId('version-picker').getByRole('button') + await expect(planButton).toHaveAccessibleName('Select your plan: Free, Pro, & Team') + await expect(planButton).toHaveText('Free, Pro, & Team') + await planButton.click() + const planMenu = page.getByTestId('version-picker').getByRole('menu') + await expect(planMenu).toBeVisible() await expect(page.getByRole('menuitemradio', { name: 'Enterprise Cloud' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(planMenu).not.toBeVisible() - await page.getByRole('button', { name: 'Select language: current language is English' }).click() - await expect(page.getByRole('menuitemradio', { name: 'English' })).toBeVisible() + // The language picker is the same kind of nested dropdown as the plan one. + const languageButton = page.getByRole('button', { + name: 'Select language: current language is English', + }) + await expect(languageButton).toHaveText('English') + await languageButton.click() + const languageMenu = page.getByTestId('language-picker').getByRole('menu') + await expect(languageMenu).toBeVisible() + await expect(page.getByRole('menuitemradio', { name: 'English', exact: true })).toBeVisible() + await expect(page.getByRole('menuitemradio', { name: '日本語', exact: true })).toBeVisible() + await page.keyboard.press('Escape') + await expect(languageMenu).not.toBeVisible() await expect(page.getByTestId('header-signup')).toBeVisible() + await expect(page.getByRole('button', { name: 'Menu', exact: true })).not.toBeVisible() }) test('large viewports - 1012-1279', async ({ page }) => { @@ -673,86 +718,71 @@ 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) - - // 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() - await expect(page.getByTestId('sidebar')).toBeVisible() - }) - - test('medium viewports - 768-1011', async ({ page }) => { - page.setViewportSize({ - width: 1000, - height: 700, - }) - await page.goto('/get-started/foo/bar') - - await page.getByTestId('version-picker').getByRole('button').click() - expect((await page.getByRole('menuitemradio').all()).length).toBeGreaterThan(0) - await expect(page.getByRole('menuitemradio', { name: 'Enterprise Cloud' })).toBeVisible() - - await page.getByTestId('mobile-menu').click() - await expect(page.getByRole('menuitemradio', { name: 'English' })).toBeVisible() - - await expect(page.getByTestId('mobile-signup')).toBeVisible() - - await expect(page.getByTestId('sidebar-mobile-toggle')).toBeVisible() - await page.getByTestId('sidebar-mobile-toggle').click() - await expect(page.getByTestId('sidebar')).toBeVisible() - }) - - test('small viewports - 544-767', async ({ page }) => { - page.setViewportSize({ - width: 555, - height: 700, - }) - await page.goto('/get-started/foo/bar') + expect(await page.getByTestId('breadcrumbs-bar').getByRole('link').all()).toHaveLength(3) - await expect(page.getByTestId('header-signup')).not.toBeVisible() - - await expect(page.getByTestId('language-picker')).not.toBeVisible() - - await expect(page.getByTestId('version-picker').getByRole('button')).toBeVisible() - - await page.getByTestId('mobile-menu').click() - await expect(page.getByRole('menuitemradio', { name: 'English' })).toBeVisible() - - await expect(page.getByTestId('mobile-signup')).toBeVisible() - - 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() - }) - - test('x-small viewports - 0-544', async ({ page }) => { - page.setViewportSize({ - width: 345, - height: 700, + 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() + }) + + for (const { name, width } of [ + { name: 'medium viewports - 768-1011', width: 1000 }, + { name: 'small viewports - 544-767', width: 555 }, + { name: 'x-small viewports - 0-544', width: 345 }, + ]) { + test(name, async ({ page }) => { + await page.setViewportSize({ width, height: 700 }) + await page.goto('/get-started/foo/bar') + await turnOffExperimentsInPage(page) + + // Both selectors and the signup action move into the utility menu below 1012px. + await expect(page.getByTestId('header-signup')).not.toBeVisible() + await expect(page.getByTestId('language-picker')).not.toBeVisible() + await expect(page.getByTestId('version-picker')).not.toBeVisible() + await expect(page.getByTestId('toggle-search')).toBeVisible() + + await page.getByRole('button', { name: 'Menu', exact: true }).click() + await expect( + page.getByTestId('version-picker').getByText('Select your plan:', { exact: true }), + ).toBeVisible() + const planButton = page.getByTestId('version-picker').getByRole('button') + await expect(planButton).toHaveAccessibleName('Select your plan: Free, Pro, & Team') + await expect(planButton).toHaveText('Free, Pro, & Team') + await planButton.click() + const planMenu = page.getByTestId('version-picker').getByRole('menu') + await expect(planMenu).toBeVisible() + await expect(page.getByRole('menuitemradio', { name: 'Enterprise Cloud' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(planMenu).not.toBeVisible() + + // Language selection remains a complete nested dropdown, not a bare list. + await page + .getByRole('button', { name: 'Select language: current language is English' }) + .click() + const languageMenu = page.getByTestId('language-picker').getByRole('menu') + await expect(languageMenu).toBeVisible() + await expect(page.getByRole('menuitemradio', { name: 'English', exact: true })).toBeVisible() + await expect(page.getByRole('menuitemradio', { name: '日本語', exact: true })).toBeVisible() + await page.keyboard.press('Escape') + await expect(languageMenu).not.toBeVisible() + await expect(page.getByTestId('header-signup')).toBeVisible() + + // The independent secondary-bar navigation is intentionally inert until the + // modal header menu closes, then still expands the doc tree inline. + await page.getByRole('button', { name: 'Close menu', exact: true }).click() + await expect(page.getByTestId('sidebar-mobile-toggle')).toBeVisible() + await page.getByTestId('sidebar-mobile-toggle').click() + await expect(page.getByTestId('sidebar')).toBeVisible() }) - await page.goto('/get-started/foo/bar') - await turnOffExperimentsInPage(page) - - await expect(page.getByTestId('header-signup')).not.toBeVisible() - - await expect(page.getByTestId('language-picker')).not.toBeVisible() - - await expect(page.getByTestId('version-picker').getByRole('button')).not.toBeVisible() - - await expect(page.getByTestId('version-picker')).not.toBeVisible() - await page.getByTestId('mobile-menu').click() - await expect(page.getByTestId('open-mobile-menu').getByTestId('version-picker')).toBeVisible() - - await expect(page.getByTestId('open-mobile-menu').getByTestId('language-picker')).toBeVisible() - - await expect(page.getByTestId('mobile-signup')).toBeVisible() - - await expect(page.getByTestId('sidebar-mobile-toggle')).toBeVisible() - await page.getByTestId('sidebar-mobile-toggle').click() - await expect(page.getByTestId('sidebar')).toBeVisible() - }) + } test('do a search when the viewport is x-small', async ({ page }) => { test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search') @@ -764,7 +794,7 @@ test.describe('test nav at different viewports', () => { await page.goto('/get-started/foo/bar') await turnOffExperimentsInPage(page) - await page.locator('[data-testid="mobile-search-button"]:visible').click() + await page.getByTestId('toggle-search').click() await page.getByTestId('overlay-search-input').fill('serve playwright') await page.waitForTimeout(1000) await page.getByText('View more results').click() @@ -785,7 +815,7 @@ test.describe('test nav at different viewports', () => { await page.goto('/get-started/foo/bar') await turnOffExperimentsInPage(page) - await page.locator('[data-testid="mobile-search-button"]:visible').click() + await page.getByTestId('toggle-search').click() await page.getByTestId('overlay-search-input').fill('serve playwright') await page.waitForTimeout(1000) await page.getByText('View more results').click() @@ -1178,7 +1208,7 @@ test('open search, and ask Copilot (Ask AI) a question', async ({ page }) => { await page.goto('/') await turnOffExperimentsInPage(page) - await page.locator('[data-testid="search"]:visible').click() + await page.getByTestId('toggle-search').click() await page.getByTestId('overlay-search-input').fill('How do I create a Repository?') // Pressing enter should ask AI the question await page.keyboard.press('Enter') @@ -1220,7 +1250,7 @@ test('open search, Ask AI returns 400 error and shows general search results', a await page.goto('/') await turnOffExperimentsInPage(page) - await page.locator('[data-testid="search"]:visible').click() + await page.getByTestId('toggle-search').click() await page.getByTestId('overlay-search-input').fill('foo') // Pressing enter should trigger Ask AI, get 400 error, and show general search results await page.keyboard.press('Enter') @@ -1495,15 +1525,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 +1595,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 +1937,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..93c832ddee7f 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,41 @@ } } -// 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. +// +// The header half of the stack comes from --docs-header-height, not a literal +// 65px: the Brand header is 3.5rem below 48rem and 4rem above, so a fixed px +// stack would be wrong at one of the two sizes. The 45px is the secondary bar, +// which is a fixed height; keep it in step with DocsSecondaryBar.module.scss +// and SidebarNav.module.scss, which compose the same two parts. +.stickyStack { + --docs-sticky-stack: calc(var(--docs-header-height) + 45px); +} + +.stickyStackWithSubBar { + --docs-sticky-stack: calc(var(--docs-header-height) + 45px + 40px); +} + +// Match the drawer reveal breakpoints in DocsSecondaryBar.module.scss. +.stickyStackYieldsAtXxl { + @include breakpoint-xxl { + --docs-sticky-stack: calc(var(--docs-header-height) + 45px); + } +} + +.stickyStackYieldsWhenCollapsed { + @media (min-width: 1074px) { + --docs-sticky-stack: calc(var(--docs-header-height) + 45px); + } +} + +// 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..b7d3bf153f6e 100644 --- a/src/frame/components/DefaultLayout.tsx +++ b/src/frame/components/DefaultLayout.tsx @@ -1,11 +1,11 @@ -import React from 'react' +import React, { useState } from 'react' import Head from 'next/head' import { useRouter } from 'next/router' 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 { @@ -42,6 +50,7 @@ export const DefaultLayout = (props: Props) => { const { t } = useTranslation('meta') const router = useRouter() const { languages } = useLanguages() + const [isNarrowMenuOpen, setIsNarrowMenuOpen] = useState(false) // This is only true when we do search indexing which renders every page // just to be able to `cheerio` load the main body (and the meta @@ -215,30 +224,40 @@ export const DefaultLayout = (props: Props) => {
    Skip to main content -
    - - {isHomepageVersion ? ( -
    -
    -
    - - +
    +
    + + {isHomepageVersion ? ( +
    +
    +
    + + - {props.children} -
    - + {props.children} +
    + +
    -
  • - ) : ( - <> - - {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 +268,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 +280,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 +306,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..3d5965eef9c5 100644 --- a/src/frame/components/article/ArticleGridLayout.module.scss +++ b/src/frame/components/article/ArticleGridLayout.module.scss @@ -24,13 +24,268 @@ } } +// `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 (see `.sidebarBox`), 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 + 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. The header half comes from + // --docs-header-height rather than a literal 65px, since the Brand header is + // 3.5rem below 48rem and 4rem above. + top: calc(var(--docs-header-height) + 45px); + 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 - var(--docs-header-height) - 45px); 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/ActionMenuTrigger.tsx b/src/frame/components/page-header/ActionMenuTrigger.tsx new file mode 100644 index 000000000000..bcf7db25490b --- /dev/null +++ b/src/frame/components/page-header/ActionMenuTrigger.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps, ComponentType, ReactNode } from 'react' +import { ActionMenu } from '@primer/react-brand' + +// Brand's ActionMenu.Button hardcodes `trailingVisual={}` and its +// props type does not declare `trailingVisual`. It spreads rest props *after* that +// default, so a caller-supplied icon still wins at runtime, and Docs 2026 specifies a +// filled triangle caret rather than a chevron. This cast is the single deliberate +// divergence from the stock component's typed API, and it is shared by both header +// pickers so there is only one line to fix. If a future @primer/react-brand release +// destructures `trailingVisual` out of its rest props, this is the line that stops +// working and the caret silently reverts to a chevron. +type WithTrailingVisual = { trailingVisual?: ReactNode } + +export const ActionMenuTrigger = ActionMenu.Button as ComponentType< + ComponentProps & WithTrailingVisual +> 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..416a0edaba75 100644 --- a/src/frame/components/page-header/DocsSecondaryBar.module.scss +++ b/src/frame/components/page-header/DocsSecondaryBar.module.scss @@ -2,45 +2,48 @@ // 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 (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). + // + // `top` tracks the Brand header through --docs-header-height rather than a + // literal 65px: the header is 3.5rem below 48rem and 4rem above, so a fixed + // px offset would leave a gap or overlap at one of the two sizes. + position: sticky; + top: var(--docs-header-height); + 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 +53,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 +70,50 @@ .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 the header + bar stack plus this height as `--docs-sticky-stack`, + // which table.scss reads to offset sticky table headers. Changing this height + // means changing the matching 40px in .stickyStackWithSubBar 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 + 45px bar) 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. Composed from + // --docs-header-height for the same reason `.bar` above is. + position: sticky; + top: calc(var(--docs-header-height) + 45px); + 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/page-header/Header.module.scss b/src/frame/components/page-header/Header.module.scss index a22fddf048e4..7224b5e171e3 100644 --- a/src/frame/components/page-header/Header.module.scss +++ b/src/frame/components/page-header/Header.module.scss @@ -1,53 +1,72 @@ -@import "@primer/css/support/variables/layout.scss"; -@import "@primer/css/support/mixins/layout.scss"; -@import "src/frame/stylesheets/breakpoint-xxl.scss"; - .header { - display: unset; + // Let the navbar stick to the page, not a header-height containing block. + display: contents; } -// Contains the logo and version picker and used when the smaller width search -// input UI is closed. -.logoWithClosedSearch { - display: flex; - align-items: center; - color: var(--color-bg-default); +.stickyContainer { + position: sticky; + top: 0; + // Sticky ladder, top to bottom: this header (3) > the Docs 2026 secondary bar + // (2, DocsSecondaryBar.module.scss) > sticky table headers (1, table.scss). + // + // 3 rather than 2 because Brand's ActionMenu is NOT portalled: the plan and + // language dropdowns render inside this container, and they are taller than + // the header, so they overlap the secondary bar. At an equal z-index the bar + // wins on DOM order and paints a 45px band straight through the open menu, + // swallowing clicks on the rows behind it. + // + // This also sets a stacking context, which is what keeps the search overlay's + // backdrop (z-index 1000) from escaping: the container composites as a whole + // at 3, so Primer's portalled dialog -- also 3, but appended to after + // this -- still wins on DOM order and stays clickable above the backdrop. + z-index: 3; } -// Contains the logo and version picker and used when the smaller width search -// input UI is open (where we don't show the logo or version picker). As the -// width increases to md and above and search no longer takes up the whole header -// width, we show the logo and version picker. -.logoWithOpenSearch { - display: none; - - @include breakpoint(md) { - display: flex; - align-items: center; - color: var(--color-bg-default); - } +.searchTrigger { + min-width: 0; } -// Prevent the ... menu from still showing if someone opens it and then widens -// their browser to lg. -.menuOverlay { - min-width: 20rem; +@media (min-width: 63.25rem) { + // Give search the remaining space before shrinking the plan picker. + .stickyContainer :has(> .searchTrigger) { + flex-grow: 1; + justify-content: flex-end; + min-width: min-content; + } - @include breakpoint(lg) { - display: none; + .stickyContainer .searchTrigger { + // Brand's flex-basis still sets the preferred width; its min-width is the floor. + width: 0; } } -.displayOverLarge { - display: none; - visibility: none; +// Brand's own stagger step: its narrow-menu links animate in at +// `calc(var(--animation-order) * 80ms)`, one slot each. +$narrow-menu-stagger-step: 80ms; - @include breakpoint(lg) { - display: flex !important; - visibility: visible !important; +@media screen and (max-width: 48rem) { + // Brand hardcodes the narrow menu's CTA area to stagger slot 10 -- the moment + // ten `SubdomainNavBar.Link` children have finished cascading in. Docs passes + // zero links (its navigation is the sidebar doc tree), so that 800ms is spent + // waiting out a queue that does not exist: the plan and language pickers ride + // the panel's 500ms fade while "Sign up" lands nearly a second later. One slot + // keeps the CTA arriving deliberately after the pickers, without the dead time. + // + // Brand's class name is CSS-module-hashed and it exposes no test id for this + // element, so match the stable part of the name. An attribute selector is not + // rewritten inside a CSS module, so it passes through as authored; only + // `.stickyContainer` is hashed, and it is also what wins the specificity + // (two selectors vs. Brand's single class) without `!important`. + // + // Only the delay is overridden -- duration, fill mode, easing and the + // keyframes stay exactly as Brand ships them. + .stickyContainer [class*="SubdomainNavBar-button-area--visible"] { + animation-delay: calc(1 * #{$narrow-menu-stagger-step}); } } -.headerContainer { - row-gap: 1rem; +@media print { + .header > * { + display: none; + } } diff --git a/src/frame/components/page-header/Header.tsx b/src/frame/components/page-header/Header.tsx index c48c00e91cf9..1cfec5d90274 100644 --- a/src/frame/components/page-header/Header.tsx +++ b/src/frame/components/page-header/Header.tsx @@ -1,161 +1,121 @@ -import { useEffect, useRef, useState } from 'react' -import type { JSX } from 'react' -import cx from 'classnames' +import type { AnchorHTMLAttributes } from 'react' import { useRouter } from 'next/router' -import { MarkGithubIcon } from '@primer/octicons-react' +import { SubdomainNavBar } from '@primer/react-brand' import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion' -import { Link } from '@/frame/components/Link' import { useMainContext } from '@/frame/components/context/MainContext' import { HeaderNotifications } from '@/frame/components/page-header/HeaderNotifications' import { useTranslation } from '@/languages/components/useTranslation' import { VersionPicker } from '@/versions/components/VersionPicker' -import { SearchBarButton } from '@/search/components/input/SearchBarButton' -import { HeaderSearchAndWidgets } from './HeaderSearchAndWidgets' -import { useInnerWindowWidth } from './hooks/useInnerWindowWidth' +import { LanguagePicker } from '@/languages/components/LanguagePicker' +import { useLanguages } from '@/languages/components/LanguagesContext' import { useMultiQueryParams } from '@/search/components/hooks/useMultiQueryParams' import { SearchOverlayContainer } from '@/search/components/input/SearchOverlayContainer' -import { useSearchOverlayContext } from '@/search/components/context/SearchOverlayContext' +import { useHasAccount } from '@/frame/components/hooks/useHasAccount' +import { useHeaderNavigation } from '@/frame/components/page-header/hooks/useHeaderNavigation' import styles from './Header.module.scss' -export const Header = () => { +// Brand forwards these to its anchor, but its CTA type only lists HTMLAttributes. +const signupLinkProps = { + href: 'https://github.com/signup?ref_cta=Sign+up&ref_loc=docs+header&ref_page=docs', + target: '_blank', + rel: 'noopener', +} satisfies AnchorHTMLAttributes + +// Brand renders its own "Skip to content" anchor as a sibling before
    , +// outside the inert wrapper DefaultLayout draws around the page, so the hook +// needs this id to find and neutralize it while the narrow menu is open. +const SKIP_TO_CONTENT_TARGET_ID = 'main-content' + +type Props = { + isNarrowMenuOpen: boolean + onNarrowMenuToggle: (isOpen: boolean) => void +} + +export const Header = ({ isNarrowMenuOpen, onNarrowMenuToggle }: Props) => { const router = useRouter() const { error } = useMainContext() + const { languages } = useLanguages() + // This context already excludes unavailable languages, including early-access pages. + // Omit the slot itself so Brand does not render an empty divided language cell. + const languagePickerVisible = Object.keys(languages).length > 1 const { currentVersion } = useVersion() - const { t } = useTranslation(['header']) + const { t } = useTranslation(['header', 'search']) const { params, updateParams } = useMultiQueryParams() - const [scroll, setScroll] = useState(false) - const { width } = useInnerWindowWidth() - const searchButtonRefLarge = useRef(null) - const searchButtonRefSmall = useRef(null) - const { isSearchOpen, setIsSearchOpen } = useSearchOverlayContext() - - // The lg breakpoint (1012px) determines which search button is visible. - // Pass the correct ref to SearchOverlayContainer so Primer's Overlay - // restores focus to the visible trigger element on close. - const isLargeViewport = width !== null && width >= 1012 - const searchButtonRef = isLargeViewport ? searchButtonRefLarge : searchButtonRefSmall - - const SearchButtonLarge: JSX.Element = ( - - ) - - const SearchButtonSmall: JSX.Element = ( - - ) - - useEffect(() => { - function onScroll() { - setScroll(window.scrollY > 10) - } - window.addEventListener('scroll', onScroll) - return () => { - window.removeEventListener('scroll', onScroll) - } - }, []) - - useEffect(() => { - const close = (e: { key: string }) => { - if (e.key === 'Escape') { - setIsSearchOpen(false) - } - } - window.addEventListener('keydown', close) - return () => window.removeEventListener('keydown', close) - }, []) - - // Pressing "/" anywhere on the page opens the search overlay, matching the - // shortcut on github.com. Ignore the key when the user is typing in a form - // field or editable element so we never swallow a literal "/", and when a - // modifier is held so we don't clash with browser or OS shortcuts. - useEffect(() => { - const openOnSlash = (e: KeyboardEvent) => { - if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey) { - return - } - const target = e.target as HTMLElement | null - const tagName = target?.tagName - if ( - tagName === 'INPUT' || - tagName === 'TEXTAREA' || - tagName === 'SELECT' || - target?.isContentEditable - ) { - return - } - e.preventDefault() - setIsSearchOpen(true) - } - window.addEventListener('keydown', openOnSlash) - return () => window.removeEventListener('keydown', openOnSlash) - }, []) + const { hasAccount } = useHasAccount() + const signupCTAVisible = + hasAccount === false && + (currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest') - let homeURL = `/${router.locale}` - if (currentVersion !== DEFAULT_VERSION) { - homeURL += `/${currentVersion}` - } + const homeURL = `/${router.locale}${currentVersion === DEFAULT_VERSION ? '' : `/${currentVersion}`}` + const { + setHeaderRef, + searchButtonRef, + closeNarrowMenu, + handleClickCapture, + handleClick, + isSearchOpen, + setIsSearchOpen, + } = useHeaderNavigation({ + homeURL, + searchTriggerClassName: styles.searchTrigger, + skipToContentTargetId: SKIP_TO_CONTENT_TARGET_ID, + isNarrowMenuOpen, + onNarrowMenuToggle, + }) return ( -
    + {error !== '404' && ( +
    + +
    )} - > - {error !== '404' && } -
    -
    +
    + {signupCTAVisible && ( + + {t('header.sign_up_cta')} + + )} + { updateParams={updateParams} searchButtonRef={searchButtonRef} /> -
    +
    ) } diff --git a/src/frame/components/page-header/HeaderPicker.module.scss b/src/frame/components/page-header/HeaderPicker.module.scss new file mode 100644 index 000000000000..9fe39b34714b --- /dev/null +++ b/src/frame/components/page-header/HeaderPicker.module.scss @@ -0,0 +1,293 @@ +/* + * Shared styling for the two Docs 2026 header pickers: the plan/version picker + * (VersionPicker, `variant="header"`) and the language picker (LanguagePicker, + * `variant="header"`). Both are Primer Brand ActionMenus rendered inside + * SubdomainNavBar, so the menu surface, the rows and the parts of the trigger + * that are common live here — a single source of truth the two pickers cannot + * drift apart from. + * + * Only the trigger's own treatment differs: the plan control is the design's + * filled pill (`.headerPillButton`), while Figma draws the language control as + * a flat, transparent control (`.headerFlatButton`). + */ + +.headerPicker { + display: flex; + align-items: center; + gap: calc(var(--base-size-8, 0.5rem) + var(--base-size-2, 0.125rem)); + min-width: 0; + max-width: 100%; +} + +/* + * Trigger, shared + */ + +.headerPicker .headerButton { + min-width: 0; + max-width: 100%; + // Brand's size="small" button sets a 32px min-height. Both triggers size + // themselves from their own padding and min-height instead. + height: auto; + font-family: var(--brand-body-fontFamily); + font-size: var(--brand-text-size-100, 0.875rem); + line-height: 1.2; +} + +// Brand caps the trigger label at 200px, and its own Text wrapper sits between the +// button and the value, so both spans have to shrink for the trigger to ellipsize +// with the header. Found through the value's own class rather than Brand's hashed +// names. +.headerPicker .headerButton span:has(.headerValue) { + display: block; + min-width: 0; + max-width: none; + overflow: hidden; +} + +.headerPicker .headerButton [data-testid="Button-leading-visual"], +.headerPicker .headerButton [data-testid="Button-trailing-visual"] { + flex: none; + color: inherit; +} + +.headerValue { + display: block; + overflow: hidden; + // Brand's Text wrapper sets size, weight and line height on the element between + // the button and this one, so none of these can be inherited from the trigger. + font-size: var(--brand-text-size-100, 0.875rem); + // 14px x 1.2 = the 16.8px line box the pill's height is built on. + line-height: 1.2; + // Brand centres its button label; a truncated value should stay left aligned. + text-align: start; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* + * Trigger: the plan picker's pill + */ + +.headerPicker .headerPillButton { + // Brand's Button variants paint themselves through these custom properties, and the + // rules that read them (rest, :hover, :active, [aria-expanded='true']) carry five + // class-level selectors — more than anything scoped to this module can outrank. Set + // the properties instead of fighting the rules, so the pill keeps the single stable + // fill the design draws in every state. + --brand-button-secondary-bgColor-rest: var(--brand-color-canvas-subtle); + --brand-button-secondary-bgColor-hover: var(--brand-color-canvas-subtle); + --brand-button-secondary-bgColor-active: var(--brand-color-canvas-subtle); + --brand-button-secondary-borderColor-rest: transparent; + --brand-button-secondary-borderColor-hover: transparent; + --brand-button-secondary-borderColor-active: transparent; + --brand-button-secondary-fgColor-rest: var(--brand-color-text-default); + + // The design's padding around a 16.8px line box makes the pill 34.8px, so the + // padding has to win over Brand's 32px floor. + min-height: 0; + padding-block: calc( + var(--base-size-8, 0.5rem) + var(--brand-borderWidth-thin, 1px) + ); + padding-inline: var(--base-size-12, 0.75rem); + // Design: 12px between the value and the caret. + gap: var(--base-size-12, 0.75rem); + color: var(--brand-color-text-default); + background-color: var(--brand-color-canvas-subtle); + border: 0; + border-radius: var(--brand-borderRadius-full, 999px); + font-weight: var(--base-text-weight-bold, 700); +} + +.headerPicker .headerPillButton .headerValue { + color: var(--brand-color-text-default); + font-weight: var(--base-text-weight-bold, 700); +} + +/* + * Trigger: the language picker's flat control + * + * Figma (node 471:69020) draws this one as a flat control rather than a pill: a + * globe, the language name in muted 14px regular, then the same caret. The fill + * only appears on hover and while the menu is open, which Brand's own rules + * apply from these custom properties — setting `background-color` here as well + * would tie with Brand's `[aria-expanded='true']` rule on specificity. + */ + +.headerPicker .headerFlatButton { + --brand-button-secondary-bgColor-rest: transparent; + --brand-button-secondary-bgColor-hover: var(--brand-color-canvas-subtle); + --brand-button-secondary-bgColor-active: var(--brand-color-canvas-subtle); + --brand-button-secondary-borderColor-rest: transparent; + --brand-button-secondary-borderColor-hover: transparent; + --brand-button-secondary-borderColor-active: transparent; + --brand-button-secondary-fgColor-rest: var(--brand-color-text-muted); + + min-height: var(--base-size-32, 2rem); + padding-block: 0; + padding-inline: var(--base-size-8, 0.5rem); + gap: var(--base-size-8, 0.5rem); + color: var(--brand-color-text-muted); + border: 0; + // Brand's ActionMenu remaps --brand-borderRadius-medium to the full radius on + // its own trigger, so the flat control has to name the 6px value itself. + border-radius: var(--base-size-6, 0.375rem); + font-weight: var(--base-text-weight-normal, 400); + // The secondary variant blurs whatever sits behind it; there is nothing behind + // a transparent control in a flat header bar. + backdrop-filter: none; +} + +.headerPicker .headerFlatButton .headerValue { + color: var(--brand-color-text-muted); + font-weight: var(--base-text-weight-normal, 400); +} + +/* + * Label (the plan picker's "Select your plan:") + */ + +.headerLabel { + color: var(--brand-color-text-muted); + font-family: var(--brand-body-fontFamily); + font-size: var(--brand-text-size-100, 0.875rem); + font-weight: var(--base-text-weight-normal, 400); + line-height: 1.2; + white-space: nowrap; +} + +/* + * Dropdown menu + */ + +// Brand's menu is not portalled — it renders inside this wrapper — so the surface can +// be styled from here instead of through Brand's hashed module class names. +.headerPicker [role="menu"] { + // The design's menu is 256px. Long titles such as "Enterprise Server 3.22" grow it + // past that rather than clipping. + min-width: 16rem; + // Matches Brand's size="small" default; restated so the design's 8px does not hinge + // on the size prop. + padding: var(--base-size-8, 0.5rem); + // Brand defaults to border-muted and a 16px (borderRadius-large) corner. + border-color: var(--brand-color-border-subtle); + border-radius: var(--base-size-6, 0.375rem); +} + +// Below Brand's 1012px breakpoint the pickers move inside SubdomainNavBar's narrow +// menu, which is a scrolling panel capped at the viewport height. Brand's menu is +// absolutely positioned and, unlike the @primer/react one it replaced, is not +// portalled — so there it renders outside the panel's box and is cut off mid-list. +// Letting it flow inline under the trigger makes the panel itself the scroll surface, +// which keeps every row reachable. Brand writes the anchored offsets as inline +// styles, so only `inset` needs `!important` to be overridden. +@media not all and (min-width: 63.25rem) { + .headerPicker { + align-items: flex-start; + } + + // The menu now adds height below the trigger, so centring would drag the label down + // with it. This keeps it on the trigger's own text baseline. + .headerPicker .headerLabel { + padding-block: calc( + var(--base-size-8, 0.5rem) + var(--brand-borderWidth-thin, 1px) + ); + } + + // Only once the menu is open, give its column the panel's full width, so a menu + // sized to its longest row cannot run past the viewport's right edge. The closed + // row keeps the label beside the trigger. + .headerPicker:has([role="menu"]) { + flex-wrap: wrap; + } + + .headerPicker > div:has([role="menu"]) { + flex: 1 1 100%; + min-width: 0; + } + + .headerPicker [role="menu"] { + position: static; + inset: auto !important; + width: 100%; + min-width: 0; + // The panel scrolls instead, so the menu does not need its own scroll area. + max-height: none; + margin-block-start: var(--base-size-8, 0.5rem); + } +} + +.headerPicker .headerMenuItem { + // Anchor for the trailing selected dot. + position: relative; + grid-template-columns: 1fr; + grid-template-areas: "text"; + align-items: center; + min-height: var(--base-size-32, 2rem); + padding-block: 0; + padding-inline: var(--base-size-12, 0.75rem); + // Reserve the indicator column: a 12px inset, the 16px dot, and a 12px gap before + // the label. This also replaces the 48px right padding Brand adds to the rows of a + // single-selection menu. + padding-inline-end: calc( + var(--base-size-12, 0.75rem) * 2 + var(--base-size-16, 1rem) + ); + border-radius: var(--base-size-6, 0.375rem); +} + +// Brand renders a leading check-icon slot on every row of a single-selection menu; the +// design marks the selected row with a trailing dot instead. The slot is the row's +// first span and exists only when a text span follows it, which keeps this structural +// rather than dependent on Brand's hashed class names. +.headerPicker .headerMenuItem > span:first-child:not(:last-child) { + display: none; +} + +// Brand's hover token resolves to the same grey the design uses for the *selected* +// row, so hover is repointed at canvas-subtle and the selected row keeps its own fill +// while hovered. Both selectors clear the five class-level units in Brand's own +// `:hover:not(...)` rule. +.headerPicker + .headerMenuItem:hover:not(.headerMenuItemSelected):not( + [aria-disabled="true"] + ) { + background-color: var(--brand-color-canvas-subtle); +} + +.headerPicker .headerMenuItem.headerMenuItemSelected, +.headerPicker + .headerMenuItem.headerMenuItemSelected:hover:not([aria-disabled="true"]) { + background-color: var(--brand-color-border-muted); +} + +.headerMenuItemLabel { + display: inline-flex; + align-items: center; + color: var(--brand-color-text-default); + font-size: var(--brand-text-size-100, 0.875rem); + font-weight: var(--base-text-weight-medium, 500); + // Design: 0.14px at 14px. Brand's --brand-text-letterSpacing-100 is 0.21px. + letter-spacing: 0.01em; + white-space: nowrap; +} + +.headerMenuItemDot { + position: absolute; + inset-inline-end: var(--base-size-12, 0.75rem); + top: 50%; + transform: translateY(-50%); + // Octicons fill with currentColor. green-6 in light, green-3 in dark. + color: var(--brand-color-success-fg); +} + +// Brand has no divider child, so the plan menu draws the rule that separates the +// versions from the two navigation rows itself. A plain
  • (no +// `.ActionMenu__item`) is a block box, so the border spans the menu's inner width. +.headerPicker .headerMenuSeparator { + height: 0; + margin-block: var(--base-size-8, 0.5rem); + padding: 0; + list-style: none; + border-block-start: var(--brand-borderWidth-thin, 1px) solid + var(--brand-color-border-subtle); +} diff --git a/src/frame/components/page-header/HeaderSearchAndWidgets.module.scss b/src/frame/components/page-header/HeaderSearchAndWidgets.module.scss deleted file mode 100644 index 7a8b9ecdbce9..000000000000 --- a/src/frame/components/page-header/HeaderSearchAndWidgets.module.scss +++ /dev/null @@ -1,38 +0,0 @@ -@import "@primer/css/support/variables/layout.scss"; -@import "@primer/css/support/mixins/layout.scss"; - -.displayUnderLarge { - display: flex; - visibility: visible; - - @include breakpoint(lg) { - display: none !important; - visibility: hidden !important; - } -} - -// The ... menu button when the smaller width search UI is open. Since the search -// UI is open, we don't show the button at smaller widths but we do show it as -// the browser width increases to md, and then at lg and above widths we hide -// the button again since the pickers and sign-up button are shown in the header. -.menuButtonSearchOpen { - margin-left: 0.5rem; - display: none; - - @include breakpoint(md) { - display: inline-block; - margin-left: 0.25rem; - } -} - -// The ... menu button when the smaller width search UI is closed, the button is -// shown up to lg. At lg and above we don't show the button since the pickers -// and sign-up button are shown in the header. -.menuButtonSearchClosed { - margin-left: 1rem; - - @include breakpoint(lg) { - margin-left: 0; - display: none; - } -} diff --git a/src/frame/components/page-header/HeaderSearchAndWidgets.tsx b/src/frame/components/page-header/HeaderSearchAndWidgets.tsx deleted file mode 100644 index 1826e92f2e23..000000000000 --- a/src/frame/components/page-header/HeaderSearchAndWidgets.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import cx from 'classnames' -import type { JSX } from 'react' -import { KebabHorizontalIcon, LinkExternalIcon } from '@primer/octicons-react' -import { IconButton, ActionMenu, ActionList } from '@primer/react' - -import { LanguagePicker } from '@/languages/components/LanguagePicker' -import { useTranslation } from '@/languages/components/useTranslation' -import { VersionPicker } from '@/versions/components/VersionPicker' -import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion' -import { useHasAccount } from '../hooks/useHasAccount' - -import styles from './HeaderSearchAndWidgets.module.scss' - -type Props = { - isSearchOpen: boolean - width: number | null - SearchButton: JSX.Element | null -} - -export function HeaderSearchAndWidgets({ width, isSearchOpen, SearchButton }: Props) { - const { currentVersion } = useVersion() - const { t } = useTranslation(['header']) - const { hasAccount } = useHasAccount() - const signupCTAVisible = - hasAccount === false && // don't show if `null` - (currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest') - - return ( - <> -
    -
    - -
    - - {signupCTAVisible && ( -
    - )} - - {/* Below large widths we show the search as a button which needs to be grouped with the widgets */} -
    {SearchButton}
    - - {/* The ... navigation menu at medium and smaller widths */} -
    - - - - - - - - {width && width > 544 ? ( - - ) : ( - - )} - - {width && width < 545 && ( - <> - - - - )} - {signupCTAVisible && ( - - {t`sign_up_cta`} - - - )}{' '} - - - - -
    -
    - - ) -} diff --git a/src/frame/components/page-header/hooks/useHeaderNavigation.ts b/src/frame/components/page-header/hooks/useHeaderNavigation.ts new file mode 100644 index 000000000000..faf8ec5ae5d5 --- /dev/null +++ b/src/frame/components/page-header/hooks/useHeaderNavigation.ts @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import type { MouseEvent, RefObject } from 'react' +import { useRouter } from 'next/router' +import { SubdomainNavBar } from '@primer/react-brand' +import type { SubdomainNavBarHandle } from '@primer/react-brand' + +import { useSearchOverlayContext } from '@/search/components/context/SearchOverlayContext' + +type Props = { + homeURL: string + searchTriggerClassName: string + skipToContentTargetId: string + isNarrowMenuOpen: boolean + onNarrowMenuToggle: (isOpen: boolean) => void +} + +export function useHeaderNavigation({ + homeURL, + searchTriggerClassName, + skipToContentTargetId, + isNarrowMenuOpen, + onNarrowMenuToggle, +}: Props) { + const router = useRouter() + const { isSearchOpen, setIsSearchOpen } = useSearchOverlayContext() + const headerRef = useRef(null) + + const setHeaderRef = useCallback((header: SubdomainNavBarHandle | null) => { + headerRef.current = header + }, []) + + // Resolved on every read rather than captured when the header mounts. This is + // the search overlay's `returnFocusRef`, and Brand owns that subtree: if a + // future version re-creates the trigger (opening the narrow menu, its + // `--search-open` animation), a node cached at mount would leave Escape + // restoring focus to a detached element, and the failure would be silent. + const searchButtonRef = useMemo>( + () => ({ + get current() { + return ( + headerRef.current?.querySelector( + `.${searchTriggerClassName} button`, + ) ?? null + ) + }, + }), + [searchTriggerClassName], + ) + + const closeNarrowMenu = useCallback(() => { + // Brand 0.75 has no close-menu API. Use its exported control hook rather + // than a private CSS selector, synthetic Escape, or remounting the navbar. + const menuButton = headerRef.current?.querySelector( + `[data-testid="${SubdomainNavBar.testIds.menuButton}"]`, + ) + if (menuButton?.getAttribute('aria-expanded') === 'true') menuButton.click() + }, []) + + // Brand renders its own skip link as a sibling *before* `
    `, which puts + // it outside the inert wrapper DefaultLayout draws around the rest of the page. + // Left alone it stays focusable while the narrow menu is open and still points + // at #main-content — which is inert — so activating it would move focus + // nowhere. Mirror exactly what DefaultLayout does to the Docs skip link. + useEffect(() => { + const skipLink = document + .querySelector('[data-container="header"]') + ?.querySelector(`a[href="#${skipToContentTargetId}"]`) + if (!skipLink) return + if (isNarrowMenuOpen) { + skipLink.setAttribute('inert', '') + skipLink.setAttribute('aria-hidden', 'true') + } else { + skipLink.removeAttribute('inert') + skipLink.removeAttribute('aria-hidden') + } + }, [isNarrowMenuOpen, skipToContentTargetId]) + + useEffect(() => { + if (isSearchOpen && isNarrowMenuOpen) closeNarrowMenu() + }, [isSearchOpen, isNarrowMenuOpen, closeNarrowMenu]) + + useEffect(() => { + router.events.on('routeChangeStart', closeNarrowMenu) + return () => router.events.off('routeChangeStart', closeNarrowMenu) + }, [router.events, closeNarrowMenu]) + + useEffect(() => () => onNarrowMenuToggle(false), [onNarrowMenuToggle]) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.isComposing) return + if (event.key === 'Escape' && isSearchOpen) { + setIsSearchOpen(false) + return + } + if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) return + const target = event.target as HTMLElement | null + if ( + target?.tagName === 'INPUT' || + target?.tagName === 'TEXTAREA' || + target?.tagName === 'SELECT' || + target?.isContentEditable + ) { + return + } + event.preventDefault() + setIsSearchOpen(true) + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isSearchOpen, setIsSearchOpen]) + + const handleClickCapture = (event: MouseEvent) => { + if (!(event.target instanceof Element)) return + const trigger = event.target.closest(`.${searchTriggerClassName} button`) + if (!trigger || !event.currentTarget.contains(trigger)) return + + // The compound Search overrides onSearchOpen and cannot host our Copilot + // dialog. Intercept only our trigger, keeping Brand's native dialog closed. + event.preventDefault() + event.stopPropagation() + setIsSearchOpen(true) + } + + const handleClick = (event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + !(event.target instanceof Element) + ) { + return + } + const link = event.target.closest('a[href]') + if ( + !link || + link.getAttribute('href') !== homeURL || + (link.target && link.target !== '_self') || + link.hasAttribute('download') + ) { + return + } + event.preventDefault() + closeNarrowMenu() + void router.push(homeURL, undefined, { locale: false }) + } + + return { + setHeaderRef, + searchButtonRef, + closeNarrowMenu, + handleClickCapture, + handleClick, + isSearchOpen: isSearchOpen && !isNarrowMenuOpen, + setIsSearchOpen, + } +} diff --git a/src/frame/components/page-header/hooks/useInnerWindowWidth.ts b/src/frame/components/page-header/hooks/useInnerWindowWidth.ts deleted file mode 100644 index faf60d062a88..000000000000 --- a/src/frame/components/page-header/hooks/useInnerWindowWidth.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useState, useEffect } from 'react' -import throttle from 'lodash/throttle' - -export function useInnerWindowWidth() { - const hasWindow = typeof window !== 'undefined' - - function getWidth() { - const width = hasWindow ? window.innerWidth : null - return { - width, - } - } - - const [width, setWidth] = useState(getWidth()) - - useEffect(() => { - if (hasWindow) { - const handleResize = throttle(function () { - setWidth(getWidth()) - }, 100) - - window.addEventListener('resize', handleResize) - return () => window.removeEventListener('resize', handleResize) - } - }, [hasWindow]) - - return width -} 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..05cd443b8f39 100644 --- a/src/frame/components/sidebar/SidebarNav.module.scss +++ b/src/frame/components/sidebar/SidebarNav.module.scss @@ -1,34 +1,63 @@ +@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 + Docs 2026 secondary bar (45px). Match `top` to the + // stack exactly so the rail doesn't jump 1px when the sticky positioning + // engages on scroll. The header contributes --docs-header-height rather than + // a literal 65px: the Brand header is 3.5rem below 48rem and 4rem above, so + // a fixed px stack would be wrong at one of the two sizes. + height: calc(100vh - var(--docs-header-height) - 45px); + top: calc(var(--docs-header-height) + 45px); + } +} + +// The