From eab88f0335cae1a9750a66d08bdfed6e2c775b7d Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 22 Sep 2026 22:10:45 -0400 Subject: [PATCH 1/5] fix(theme): CSS-only PF page inset without PageMainContainer Replace the PageMainContainer layout wrapper with SidebarPage scroll and sticky convex corner masks so BUI siblings share one PatternFly-style well while staying compatible with the default Backstage app layout. Fixes: https://redhat.atlassian.net/browse/RHIDP-14506 Signed-off-by: Yi Cai Co-authored-by: Cursor --- .../theme/.changeset/calm-panel-scroll.md | 5 + .../app-legacy/src/components/Root/Root.tsx | 3 +- workspaces/theme/packages/app/src/App.tsx | 7 +- .../theme/packages/app/src/modules/index.ts | 3 +- .../packages/app/src/modules/layout/index.tsx | 56 ------- .../plugins/bcc-test/report-alpha.api.md | 2 +- .../plugins/bui-test/report-alpha.api.md | 2 +- .../plugins/mui4-test/report-alpha.api.md | 2 +- .../plugins/mui5-test/report-alpha.api.md | 2 +- .../theme/plugins/theme/report-legacy.api.md | 4 - workspaces/theme/plugins/theme/report.api.md | 4 - .../src/assets/bui/component-overrides.css | 8 +- .../src/components/PageMainContainer.tsx | 54 ------ .../plugins/theme/src/components/index.ts | 1 - workspaces/theme/plugins/theme/src/index.ts | 2 +- .../theme/plugins/theme/src/legacyExports.ts | 2 +- .../theme/src/utils/createComponents.test.ts | 63 +++---- .../theme/src/utils/createComponents.ts | 156 +++++++++--------- 18 files changed, 119 insertions(+), 257 deletions(-) create mode 100644 workspaces/theme/.changeset/calm-panel-scroll.md delete mode 100644 workspaces/theme/packages/app/src/modules/layout/index.tsx delete mode 100644 workspaces/theme/plugins/theme/src/components/PageMainContainer.tsx diff --git a/workspaces/theme/.changeset/calm-panel-scroll.md b/workspaces/theme/.changeset/calm-panel-scroll.md new file mode 100644 index 00000000000..df2cc4f9458 --- /dev/null +++ b/workspaces/theme/.changeset/calm-panel-scroll.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-theme': minor +--- + +Replace PageMainContainer with a CSS-only PatternFly page-inset on BackstageSidebarPage (sticky convex corner masks), restoring default app layout compatibility with upstream Backstage. diff --git a/workspaces/theme/packages/app-legacy/src/components/Root/Root.tsx b/workspaces/theme/packages/app-legacy/src/components/Root/Root.tsx index 19bebd7b903..b4b46d5188e 100644 --- a/workspaces/theme/packages/app-legacy/src/components/Root/Root.tsx +++ b/workspaces/theme/packages/app-legacy/src/components/Root/Root.tsx @@ -46,7 +46,6 @@ import { NotificationsSidebarItem } from '@backstage/plugin-notifications'; import { LogoFull, LogoIcon, - PageMainContainer, } from '@red-hat-developer-hub/backstage-plugin-theme/legacy'; const SidebarLogo = () => { @@ -122,6 +121,6 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( - {children} + {children} ); diff --git a/workspaces/theme/packages/app/src/App.tsx b/workspaces/theme/packages/app/src/App.tsx index 4228ee103fd..21e9c6a4363 100644 --- a/workspaces/theme/packages/app/src/App.tsx +++ b/workspaces/theme/packages/app/src/App.tsx @@ -10,13 +10,12 @@ * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * See the License for the permissions and limitations under the License. */ import { createApp } from '@backstage/frontend-defaults'; -import { appLayoutModule, navModule } from './modules'; +import { navModule } from './modules'; export default createApp({ - features: [appLayoutModule, navModule], + features: [navModule], }); diff --git a/workspaces/theme/packages/app/src/modules/index.ts b/workspaces/theme/packages/app/src/modules/index.ts index 04ebc328481..1e921ce4d04 100644 --- a/workspaces/theme/packages/app/src/modules/index.ts +++ b/workspaces/theme/packages/app/src/modules/index.ts @@ -13,7 +13,6 @@ * See the License for the permissions and limitations under the License. */ -import { appLayoutModule } from './layout'; import { navModule } from './nav'; -export { appLayoutModule, navModule }; +export { navModule }; diff --git a/workspaces/theme/packages/app/src/modules/layout/index.tsx b/workspaces/theme/packages/app/src/modules/layout/index.tsx deleted file mode 100644 index d2c8926eb0c..00000000000 --- a/workspaces/theme/packages/app/src/modules/layout/index.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the permissions and limitations under the License. - */ - -import { SidebarPage } from '@backstage/core-components'; -import { - coreExtensionData, - createExtension, - createExtensionInput, - createFrontendModule, -} from '@backstage/frontend-plugin-api'; -import { PageMainContainer } from '@red-hat-developer-hub/backstage-plugin-theme'; - -/** - * Replaces the default `app/layout` so all non-sidebar content (PluginHeader, - * Containers, routes) lives under one PatternFly-aligned main well. - */ -const AppLayout = createExtension({ - name: 'layout', - attachTo: { id: 'app/root', input: 'children' }, - inputs: { - nav: createExtensionInput([coreExtensionData.reactElement], { - singleton: true, - }), - content: createExtensionInput([coreExtensionData.reactElement], { - singleton: true, - }), - }, - output: [coreExtensionData.reactElement], - factory: ({ inputs }) => [ - coreExtensionData.reactElement( - - {inputs.nav.get(coreExtensionData.reactElement)} - - {inputs.content.get(coreExtensionData.reactElement)} - - , - ), - ], -}); - -export const appLayoutModule = createFrontendModule({ - pluginId: 'app', - extensions: [AppLayout], -}); diff --git a/workspaces/theme/plugins/bcc-test/report-alpha.api.md b/workspaces/theme/plugins/bcc-test/report-alpha.api.md index 9d3a88fd224..e14d404a930 100644 --- a/workspaces/theme/plugins/bcc-test/report-alpha.api.md +++ b/workspaces/theme/plugins/bcc-test/report-alpha.api.md @@ -32,7 +32,6 @@ const _default: OverridableFrontendPlugin< title?: string | undefined; }; output: - | ExtensionDataRef | ExtensionDataRef | ExtensionDataRef< RouteRef, @@ -41,6 +40,7 @@ const _default: OverridableFrontendPlugin< optional: true; } > + | ExtensionDataRef | ExtensionDataRef< string, 'core.title', diff --git a/workspaces/theme/plugins/bui-test/report-alpha.api.md b/workspaces/theme/plugins/bui-test/report-alpha.api.md index cef91e307bc..a6bdd9a6bac 100644 --- a/workspaces/theme/plugins/bui-test/report-alpha.api.md +++ b/workspaces/theme/plugins/bui-test/report-alpha.api.md @@ -32,7 +32,6 @@ const _default: OverridableFrontendPlugin< title?: string | undefined; }; output: - | ExtensionDataRef | ExtensionDataRef | ExtensionDataRef< RouteRef, @@ -41,6 +40,7 @@ const _default: OverridableFrontendPlugin< optional: true; } > + | ExtensionDataRef | ExtensionDataRef< string, 'core.title', diff --git a/workspaces/theme/plugins/mui4-test/report-alpha.api.md b/workspaces/theme/plugins/mui4-test/report-alpha.api.md index 7caef2f3742..44cb74f5b73 100644 --- a/workspaces/theme/plugins/mui4-test/report-alpha.api.md +++ b/workspaces/theme/plugins/mui4-test/report-alpha.api.md @@ -32,7 +32,6 @@ const plugin: OverridableFrontendPlugin< title?: string | undefined; }; output: - | ExtensionDataRef | ExtensionDataRef | ExtensionDataRef< RouteRef, @@ -41,6 +40,7 @@ const plugin: OverridableFrontendPlugin< optional: true; } > + | ExtensionDataRef | ExtensionDataRef< string, 'core.title', diff --git a/workspaces/theme/plugins/mui5-test/report-alpha.api.md b/workspaces/theme/plugins/mui5-test/report-alpha.api.md index f6ddea6d7fe..b20c2139ac1 100644 --- a/workspaces/theme/plugins/mui5-test/report-alpha.api.md +++ b/workspaces/theme/plugins/mui5-test/report-alpha.api.md @@ -32,7 +32,6 @@ const _default: OverridableFrontendPlugin< title?: string | undefined; }; output: - | ExtensionDataRef | ExtensionDataRef | ExtensionDataRef< RouteRef, @@ -41,6 +40,7 @@ const _default: OverridableFrontendPlugin< optional: true; } > + | ExtensionDataRef | ExtensionDataRef< string, 'core.title', diff --git a/workspaces/theme/plugins/theme/report-legacy.api.md b/workspaces/theme/plugins/theme/report-legacy.api.md index 200ba21edce..b16baa8043a 100644 --- a/workspaces/theme/plugins/theme/report-legacy.api.md +++ b/workspaces/theme/plugins/theme/report-legacy.api.md @@ -7,7 +7,6 @@ import { AppTheme } from '@backstage/core-plugin-api'; import { default as DarkIcon } from '@mui/icons-material/Brightness2Rounded'; import { JSX as JSX_2 } from 'react/jsx-runtime'; import { default as LightIcon } from '@mui/icons-material/WbSunnyRounded'; -import { PropsWithChildren } from 'react'; import { ReactNode } from 'react'; import { Theme } from '@mui/material'; import { UnifiedThemeOptions } from '@backstage/theme'; @@ -91,9 +90,6 @@ export const LogoFull: (props: React.ComponentProps<'svg'>) => JSX_2.Element; // @public (undocumented) export const LogoIcon: (props: React.ComponentProps<'svg'>) => JSX_2.Element; -// @public (undocumented) -export const PageMainContainer: (input: PropsWithChildren) => JSX_2.Element; - // @public (undocumented) export interface RHDHThemePalette { // (undocumented) diff --git a/workspaces/theme/plugins/theme/report.api.md b/workspaces/theme/plugins/theme/report.api.md index 766258c7607..7ab10c77010 100644 --- a/workspaces/theme/plugins/theme/report.api.md +++ b/workspaces/theme/plugins/theme/report.api.md @@ -5,7 +5,6 @@ ```ts import { FrontendModule } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react/jsx-runtime'; -import { PropsWithChildren } from 'react'; // @public (undocumented) export const LogoFull: (props: React.ComponentProps<'svg'>) => JSX_2.Element; @@ -13,9 +12,6 @@ export const LogoFull: (props: React.ComponentProps<'svg'>) => JSX_2.Element; // @public (undocumented) export const LogoIcon: (props: React.ComponentProps<'svg'>) => JSX_2.Element; -// @public (undocumented) -export const PageMainContainer: (input: PropsWithChildren) => JSX_2.Element; - // @public const rhdhThemeModule: FrontendModule; export default rhdhThemeModule; diff --git a/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css b/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css index 20f30ee7ced..5cfe066c821 100644 --- a/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css +++ b/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css @@ -832,10 +832,10 @@ body[data-theme-name='dark-customized'][data-theme-mode='dark'] { /* * Page inset / rounded content well: - * Prefer a PatternFly-style main container (`RHDHPageMainContainer` / - * `#rhdh-page-main-container`) beside the sidebar — see createComponents.ts. - * Do not clip/round BackstageSidebarPage-root itself; that fights scrolling - * when Header + Containers are loose siblings of nav (BUI pages). + * BackstageSidebarPage is the sole scrollport. Sticky ::before corner masks + * (see createComponents.ts) fake PF `.pf-v6-c-page__main-container` rounding + * for both classic
and fragmented BUI Header + Container siblings — + * no extra DOM wrapper required. */ } diff --git a/workspaces/theme/plugins/theme/src/components/PageMainContainer.tsx b/workspaces/theme/plugins/theme/src/components/PageMainContainer.tsx deleted file mode 100644 index 189e2be66b5..00000000000 --- a/workspaces/theme/plugins/theme/src/components/PageMainContainer.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright Red Hat, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { PropsWithChildren } from 'react'; -import Box from '@mui/material/Box'; -import { styled } from '@mui/material/styles'; - -/** - * PatternFly-aligned main content well beside the sidebar. - * - * Mirrors `.pf-v6-c-page__main-container`: everything except the sidebar - * (headers, page body, footers) should live under this wrapper so page-inset - * rounding and scrolling apply to one surface — not the SidebarPage root. - * - * Use inside `SidebarPage` as a sibling of `Sidebar` / `nav`: - * - * ```tsx - * - * ... - * {children} - * - * ``` - * - * @public - */ -const PageMainContainerRoot = styled(Box, { - name: 'RHDHPageMainContainer', - slot: 'root', -})(() => ({ - display: 'flex', - flexDirection: 'column', - flex: '1 1 auto', - minWidth: 0, - minHeight: 0, -})); - -export const PageMainContainer = ({ children }: PropsWithChildren) => ( - - {children} - -); diff --git a/workspaces/theme/plugins/theme/src/components/index.ts b/workspaces/theme/plugins/theme/src/components/index.ts index a06708d0d22..011c9dad5a7 100644 --- a/workspaces/theme/plugins/theme/src/components/index.ts +++ b/workspaces/theme/plugins/theme/src/components/index.ts @@ -16,5 +16,4 @@ export * from './LogoFull'; export * from './LogoIcon'; -export * from './PageMainContainer'; export * from './ThemeProvider'; diff --git a/workspaces/theme/plugins/theme/src/index.ts b/workspaces/theme/plugins/theme/src/index.ts index b94c22633ac..8e78a1c699f 100644 --- a/workspaces/theme/plugins/theme/src/index.ts +++ b/workspaces/theme/plugins/theme/src/index.ts @@ -28,7 +28,7 @@ import './assets/fonts/font.min.css'; import './assets/bui/tokens.css'; import './assets/bui/component-overrides.css'; -export { LogoFull, LogoIcon, PageMainContainer } from './components'; +export { LogoFull, LogoIcon } from './components'; /** * RHDH themes as NFS extensions (ThemeBlueprint). diff --git a/workspaces/theme/plugins/theme/src/legacyExports.ts b/workspaces/theme/plugins/theme/src/legacyExports.ts index fe07c07a63b..1338a9f2143 100644 --- a/workspaces/theme/plugins/theme/src/legacyExports.ts +++ b/workspaces/theme/plugins/theme/src/legacyExports.ts @@ -21,7 +21,7 @@ import './assets/fonts/font.min.css'; export * from './hooks'; export * from './themes'; -export { LogoFull, LogoIcon, PageMainContainer } from './components'; +export { LogoFull, LogoIcon } from './components'; export type { BackstageThemePalette, RHDHThemePalette, diff --git a/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts b/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts index e55fa306ee7..5853289f179 100644 --- a/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts +++ b/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts @@ -92,13 +92,11 @@ describe('createComponents', () => { expect(actual.BackstageSidebarPage?.styleOverrides?.root).toEqual( expect.objectContaining({ minHeight: '100vh', - display: 'flex', - flexDirection: 'column', }), ); }); - it('stretches main with mainSectionBackgroundColor inside the page inset', () => { + it('uses SidebarPage as the PF page-inset scrollport with sticky corner masks', () => { const actual = createComponents({ palette: customDarkTheme() }); const root = actual.BackstageSidebarPage?.styleOverrides?.root as | Record @@ -109,35 +107,37 @@ describe('createComponents', () => { expect(desktop).toEqual( expect.objectContaining({ boxSizing: 'border-box', - overflow: 'hidden', + overflowY: 'auto', height: '100vh', maxHeight: '100vh', - overscrollBehavior: 'none', - paddingTop: '1.5rem', - paddingRight: '1.5rem', - paddingBottom: '1.5rem', + overscrollBehavior: 'contain', + backgroundColor: '#292929', + borderStyle: 'solid', + borderColor: '#151515', + borderWidth: '1.5rem 1.5rem 1.5rem 0', + }), + ); + expect(desktop?.['&::before']).toEqual( + expect.objectContaining({ + position: 'sticky', + top: 0, + pointerEvents: 'none', + zIndex: 2, + height: 'calc(100vh - 2 * 1.5rem)', }), ); expect( - desktop?.[ - "& > [class*='MuiLinearProgress-root'], & > main, & > [class*='RHDHPageMainContainer']" - ], + desktop?.["& > [class*='MuiLinearProgress-root'], & > main"], ).toEqual( expect.objectContaining({ backgroundColor: '#292929', - borderRadius: '1rem', - margin: 0, - flex: '1 1 auto', - minHeight: 0, - overflowY: 'auto', - overscrollBehaviorY: 'contain', - display: 'flex', - flexDirection: 'column', + borderRadius: 0, + overflow: 'visible', }), ); }); - it('makes NFS BUI main a flex column without unlocking viewport height', () => { + it('does not require a PageMainContainer wrapper for the content well', () => { const actual = createComponents({ palette: customDarkTheme() }); const root = actual.BackstageSidebarPage?.styleOverrides?.root as | Record @@ -145,16 +145,8 @@ describe('createComponents', () => { const desktop = root?.['@media (min-width: 600px)'] as | Record | undefined; - expect( - desktop?.[ - "& > [class*='MuiLinearProgress-root'], & > main, & > [class*='RHDHPageMainContainer']" - ], - ).toEqual( - expect.objectContaining({ - display: 'flex', - flexDirection: 'column', - }), - ); + expect(JSON.stringify(desktop)).not.toContain('RHDHPageMainContainer'); + expect(desktop?.['&::before']).toBeDefined(); }); it('offsets BUI dialogs below the masthead so Inspect Entity stays visible', () => { @@ -174,9 +166,7 @@ describe('createComponents', () => { | Record | undefined; expect( - desktop?.[ - "& > [class*='bui-Container']:not([class*='bui-Header']), & > [class*='RHDHPageMainContainer'] [class*='bui-Container']:not([class*='bui-Header'])" - ], + desktop?.["& > [class*='bui-Container']:not([class*='bui-Header'])"], ).toEqual( expect.objectContaining({ backgroundColor: '#292929', @@ -184,7 +174,7 @@ describe('createComponents', () => { ); }); - it('grows BackstageContent article to fill the flex column', () => { + it('paints BackstageContent article with mainSectionBackgroundColor', () => { const actual = createComponents({ palette: customDarkTheme() }); const root = actual.BackstageSidebarPage?.styleOverrides?.root as | Record @@ -193,12 +183,9 @@ describe('createComponents', () => { | Record | undefined; expect( - desktop?.[ - '& > article, & > [class*="BackstageContent-root"], & > [class*="RHDHPageMainContainer"] > article, & > [class*="RHDHPageMainContainer"] > [class*="BackstageContent-root"]' - ], + desktop?.['& > article, & > [class*="BackstageContent-root"]'], ).toEqual( expect.objectContaining({ - flex: 1, backgroundColor: '#292929', }), ); diff --git a/workspaces/theme/plugins/theme/src/utils/createComponents.ts b/workspaces/theme/plugins/theme/src/utils/createComponents.ts index b610b2aa611..793262dda7f 100644 --- a/workspaces/theme/plugins/theme/src/utils/createComponents.ts +++ b/workspaces/theme/plugins/theme/src/utils/createComponents.ts @@ -44,7 +44,6 @@ export type Components = UnifiedThemeOptions['components'] & { CatalogReactUserListPicker?: Component; PrivateTabIndicator?: Component; RHDHPageWithoutFixHeight?: Component; - RHDHPageMainContainer?: Component; }; export const createComponents = (themeConfig: ThemeConfig): Components => { @@ -813,85 +812,94 @@ export const createComponents = (themeConfig: ThemeConfig): Components => { // height; without a min-height here the page-inset background stops // early and body/html shows through (RHDHBUGS-3498). minHeight: '100vh', - // Let BUI Container's flex: 1 grow into the remaining viewport below - // PluginHeader / Header slots (those slots set flex: none). - display: 'flex', - flexDirection: 'column', - // Controls the page inset as in PF6 -- only in desktop view + // Controls the page inset as in PF6 -- only in desktop view. + // CSS-only: SidebarPage is the sole scrollport so BUI siblings + // (PluginHeader + Containers) and classic
share one rounded + // well without an extra DOM wrapper (PageMainContainer). '@media (min-width: 600px)': { - backgroundColor: - general.pageInsetBackgroundColor ?? general.appBarBackgroundColor, - // Fixed viewport shell. Inset uses padding (border-box) so in-flow - // children still respect Backstage's paddingLeft for the sidebar. - // Do not position the main well absolutely — that ignores drawer padding. boxSizing: 'border-box', height: '100vh', maxHeight: '100vh', minHeight: '0 !important', - overflow: 'hidden', - overscrollBehavior: 'none', - paddingTop: general.pageInset, - paddingRight: general.pageInset, - paddingBottom: general.pageInset, - // PatternFly `.pf-v6-c-page__main-container` equivalent + classic
- "& > [class*='MuiLinearProgress-root'], & > main, & > [class*='RHDHPageMainContainer']": - { - // Match PF border-radius; clip-path also rounds the scrollbar track. - borderRadius: '1rem', - clipPath: 'rect(0 100% 100% 0 round 1rem)', - margin: 0, - // Fill the inset well so short pages use mainSectionBackgroundColor - // (#292929) instead of leaving a pageInset (#151515) band below content. - backgroundColor: general.mainSectionBackgroundColor, - // Flex-fill the padded content box; minHeight:0 enables internal scroll. - flex: '1 1 auto', - minHeight: 0, - alignSelf: 'stretch', - width: '100%', - // Scroll only inside this well; contain so wheel/trackpad does not - // chain to the document once you hit the top/bottom. - overflowY: 'auto', - overscrollBehaviorY: 'contain', - display: 'flex', - flexDirection: 'column', - }, - // NFS / BUI pages use Container instead of
. Match the content - // well color (same token as BackstageContent) and rely on flex: 1 - // from BUI rather than 100vh so PluginHeader siblings are not overflowed. - "& > [class*='bui-Container']:not([class*='bui-Header']), & > [class*='RHDHPageMainContainer'] [class*='bui-Container']:not([class*='bui-Header'])": - { - backgroundColor: general.mainSectionBackgroundColor, - }, + overflowX: 'hidden', + overflowY: 'auto', + overscrollBehavior: 'contain', + // Inset frame via borders (not padding) so the content box aligns + // with Backstage's paddingLeft drawer offset and still shows the + // page-inset color around the well. + backgroundColor: general.mainSectionBackgroundColor, + borderStyle: 'solid', + borderColor: + general.pageInsetBackgroundColor ?? general.appBarBackgroundColor, + borderWidth: `${general.pageInset} ${general.pageInset} ${general.pageInset} 0`, + // Sticky PF corner masks: paint page-inset only in the region + // outside a normal convex 1rem radius (circle centered inside the + // well, not at the corner — that produced inverted "bites"). + // ::before is first-in-tree so `position: sticky; top: 0` stays on + // the visible well (an ::after at the end of a long page would not). + '&::before': { + content: '""', + position: 'sticky', + top: 0, + display: 'block', + width: '100%', + height: `calc(100vh - 2 * ${general.pageInset})`, + marginBottom: `calc(0px - (100vh - 2 * ${general.pageInset}))`, + pointerEvents: 'none', + zIndex: 2, + // Each layer is a 1rem corner tile. Transparent inside the arc, + // inset color in the square outside it → convex card corner. + backgroundImage: [ + `radial-gradient(circle at 100% 100%, transparent 1rem, ${ + general.pageInsetBackgroundColor ?? + general.appBarBackgroundColor + } 1.01rem)`, + `radial-gradient(circle at 0% 100%, transparent 1rem, ${ + general.pageInsetBackgroundColor ?? + general.appBarBackgroundColor + } 1.01rem)`, + `radial-gradient(circle at 100% 0%, transparent 1rem, ${ + general.pageInsetBackgroundColor ?? + general.appBarBackgroundColor + } 1.01rem)`, + `radial-gradient(circle at 0% 0%, transparent 1rem, ${ + general.pageInsetBackgroundColor ?? + general.appBarBackgroundColor + } 1.01rem)`, + ].join(', '), + backgroundPosition: + 'top left, top right, bottom left, bottom right', + backgroundRepeat: 'no-repeat', + backgroundSize: '1rem 1rem', + }, + // No nested scroll/radius on children — the shell scrolls as one. + "& > [class*='MuiLinearProgress-root'], & > main": { + margin: 0, + borderRadius: 0, + clipPath: 'none', + backgroundColor: general.mainSectionBackgroundColor, + overflow: 'visible', + height: 'auto', + maxHeight: 'none', + }, + "& > [class*='bui-Container']:not([class*='bui-Header'])": { + backgroundColor: general.mainSectionBackgroundColor, + }, // When a BackstagePage-root is present, the MUI page already has // its own header; hide the sibling BUI PluginHeader to avoid duplication. - "&:has([class*='BackstagePage-root']) > .bui-PluginHeader, &:has([class*='BackstagePage-root']) > [class*='RHDHPageMainContainer'] > .bui-PluginHeader": - { - display: 'none', - }, - // Settings and other pages render BackstageContent as
. - // Grow it to fill the flex column so pageInset doesn't show as a band. - '& > article, & > [class*="BackstageContent-root"], & > [class*="RHDHPageMainContainer"] > article, & > [class*="RHDHPageMainContainer"] > [class*="BackstageContent-root"]': - { - flex: 1, - backgroundColor: general.mainSectionBackgroundColor, - }, - // Prevent TechDocs double scrollbar: unlock the main well and let this - // shell scroll instead (ToC has its own scrollbar). - "&:has(> main:has([data-testid='techdocs-native-shadowroot']))": { - overflowY: 'auto', - overscrollBehaviorY: 'contain', + "&:has([class*='BackstagePage-root']) > .bui-PluginHeader": { + display: 'none', }, + '& > article, & > [class*="BackstageContent-root"]': { + backgroundColor: general.mainSectionBackgroundColor, + }, + // TechDocs ToC has its own scrollbar — avoid forcing a nested main well. "& > main:has([data-testid='techdocs-native-shadowroot'])": { height: 'auto !important', maxHeight: 'none !important', - flex: '0 0 auto', - borderRadius: '1rem', marginRight: '0.5rem', }, - // The Backstage suspense is an MUI LinearProgress that is not wrapped by - // a `main`. Fill the padded shell like the main well. "& > [class*='MuiLinearProgress-root']": { - backgroundColor: general.mainSectionBackgroundColor, "& > [class*='MuiLinearProgress-']": { height: '0.5rem !important', }, @@ -900,22 +908,6 @@ export const createComponents = (themeConfig: ThemeConfig): Components => { }, }, }; - // PatternFly `.pf-v6-c-page__main-container` — fixed inset frame beside sidebar - components.RHDHPageMainContainer = { - styleOverrides: { - root: { - '@media (min-width: 600px)': { - // Inherits height/radius/overflow via BackstageSidebarPage - // `> RHDHPageMainContainer` selectors; flex column stacks BUI chrome. - display: 'flex', - flexDirection: 'column', - flex: '1 1 auto', - minHeight: 0, - minWidth: 0, - }, - }, - }, - }; components.BackstageContent = { styleOverrides: { root: { From 0844487a0764142c493c1747b88b512c61142e09 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Wed, 23 Sep 2026 13:22:39 -0400 Subject: [PATCH 2/5] fix(theme): clip page-inset scrollbar and contain DependencyGraph fullscreen Use margin + clip-path on BackstageSidebarPage so the scrollbar follows the rounded well, and position .fullscreen so its absolute control stays on the graph. Co-authored-by: Cursor --- .../theme/.changeset/calm-panel-scroll.md | 2 +- .../src/assets/bui/component-overrides.css | 4 +- .../theme/src/utils/createComponents.test.ts | 34 ++++++--- .../theme/src/utils/createComponents.ts | 72 ++++++++++++++----- 4 files changed, 84 insertions(+), 28 deletions(-) diff --git a/workspaces/theme/.changeset/calm-panel-scroll.md b/workspaces/theme/.changeset/calm-panel-scroll.md index df2cc4f9458..d66ec5fd971 100644 --- a/workspaces/theme/.changeset/calm-panel-scroll.md +++ b/workspaces/theme/.changeset/calm-panel-scroll.md @@ -2,4 +2,4 @@ '@red-hat-developer-hub/backstage-plugin-theme': minor --- -Replace PageMainContainer with a CSS-only PatternFly page-inset on BackstageSidebarPage (sticky convex corner masks), restoring default app layout compatibility with upstream Backstage. +Replace PageMainContainer with a CSS-only PatternFly page-inset: BackstageSidebarPage is the sole scrollport, clipped with `clip-path` so the scrollbar follows the rounded well (no extra DOM wrapper). diff --git a/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css b/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css index 5cfe066c821..53790a95756 100644 --- a/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css +++ b/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css @@ -832,8 +832,8 @@ body[data-theme-name='dark-customized'][data-theme-mode='dark'] { /* * Page inset / rounded content well: - * BackstageSidebarPage is the sole scrollport. Sticky ::before corner masks - * (see createComponents.ts) fake PF `.pf-v6-c-page__main-container` rounding + * BackstageSidebarPage is the sole scrollport. createComponents.ts applies + * margin inset + clip-path so the scrollbar is clipped into the rounded well * for both classic
and fragmented BUI Header + Container siblings — * no extra DOM wrapper required. */ diff --git a/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts b/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts index 5853289f179..d624b66956a 100644 --- a/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts +++ b/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts @@ -96,7 +96,7 @@ describe('createComponents', () => { ); }); - it('uses SidebarPage as the PF page-inset scrollport with sticky corner masks', () => { + it('clips the SidebarPage scrollport so the scrollbar follows the rounded well', () => { const actual = createComponents({ palette: customDarkTheme() }); const root = actual.BackstageSidebarPage?.styleOverrides?.root as | Record @@ -108,13 +108,17 @@ describe('createComponents', () => { expect.objectContaining({ boxSizing: 'border-box', overflowY: 'auto', - height: '100vh', - maxHeight: '100vh', + width: 'calc(100% - 1.5rem) !important', + marginTop: '1.5rem', + marginRight: '1.5rem', + marginBottom: '1.5rem', + marginLeft: 0, + height: 'calc(100vh - 2 * 1.5rem)', + maxHeight: 'calc(100vh - 2 * 1.5rem)', overscrollBehavior: 'contain', backgroundColor: '#292929', - borderStyle: 'solid', - borderColor: '#151515', - borderWidth: '1.5rem 1.5rem 1.5rem 0', + borderRadius: '1rem', + clipPath: 'inset(0 round 1rem)', }), ); expect(desktop?.['&::before']).toEqual( @@ -123,7 +127,6 @@ describe('createComponents', () => { top: 0, pointerEvents: 'none', zIndex: 2, - height: 'calc(100vh - 2 * 1.5rem)', }), ); expect( @@ -135,6 +138,21 @@ describe('createComponents', () => { overflow: 'visible', }), ); + expect(desktop?.['& .fullscreen']).toEqual( + expect.objectContaining({ + position: 'relative', + }), + ); + expect( + desktop?.[ + '& .fullscreen > .MuiIconButton-root, & .fullscreen .MuiIconButton-root[class*="fullscreenButton"]' + ], + ).toEqual( + expect.objectContaining({ + top: '0.5rem !important', + right: '0.5rem !important', + }), + ); }); it('does not require a PageMainContainer wrapper for the content well', () => { @@ -146,9 +164,7 @@ describe('createComponents', () => { | Record | undefined; expect(JSON.stringify(desktop)).not.toContain('RHDHPageMainContainer'); - expect(desktop?.['&::before']).toBeDefined(); }); - it('offsets BUI dialogs below the masthead so Inspect Entity stays visible', () => { const actual = createComponents({}); const overrides = actual.MuiCssBaseline?.styleOverrides; diff --git a/workspaces/theme/plugins/theme/src/utils/createComponents.ts b/workspaces/theme/plugins/theme/src/utils/createComponents.ts index 793262dda7f..01bddb7c6f3 100644 --- a/workspaces/theme/plugins/theme/src/utils/createComponents.ts +++ b/workspaces/theme/plugins/theme/src/utils/createComponents.ts @@ -116,6 +116,19 @@ export const createComponents = (themeConfig: ThemeConfig): Components => { '@media (min-width: 600px)': { height: '100%', overflow: 'hidden', + // Shows through SidebarPage's page-inset margin (clip-path well). + backgroundColor: + general.pageInsetBackgroundColor ?? + general.appBarBackgroundColor ?? + theme.palette.background.default, + }, + }, + '#rhdh-sidebar-layout': { + '@media (min-width: 600px)': { + backgroundColor: + general.pageInsetBackgroundColor ?? + general.appBarBackgroundColor ?? + theme.palette.background.default, }, }, 'h1, h2, h3, h4, h5, h6': { @@ -778,6 +791,10 @@ export const createComponents = (themeConfig: ThemeConfig): Components => { '@media (min-width: 600px)': { '#rhdh-above-sidebar-header-container:has(*) ~ #rhdh-sidebar-layout': { + // Page inset is margin on SidebarPage; don't double-gap under the masthead. + "& [class*='BackstageSidebarPage-root']": { + marginTop: '0 !important', + }, "& main, & [class*='MuiLinearProgress-root']": { marginTop: '0 !important', }, @@ -816,27 +833,38 @@ export const createComponents = (themeConfig: ThemeConfig): Components => { // CSS-only: SidebarPage is the sole scrollport so BUI siblings // (PluginHeader + Containers) and classic
share one rounded // well without an extra DOM wrapper (PageMainContainer). + // + // Scrollbar clipping: border-radius alone does not clip Chromium + // scrollbars. clip-path on this same scrollport does. Margin (not + // border) forms the page inset so the clipped edge is the well + // edge — the overflow/scrollbar edge — not the outer border-box. + // + // Backstage SidebarPage sets width:100%; horizontal margins would + // otherwise overflow and get clipped (right gutter disappears). + // Left rounding sits after paddingLeft (drawer spacer), so sticky + // corner masks still paint the visible left curve against the sidebar. '@media (min-width: 600px)': { boxSizing: 'border-box', - height: '100vh', - maxHeight: '100vh', + // Override Backstage `width: 100%` so margin-right is not pushed + // off-screen by the parent overflow:hidden. + width: `calc(100% - ${general.pageInset}) !important`, + marginTop: general.pageInset, + marginRight: general.pageInset, + marginBottom: general.pageInset, + marginLeft: 0, + height: `calc(100vh - 2 * ${general.pageInset})`, + maxHeight: `calc(100vh - 2 * ${general.pageInset})`, minHeight: '0 !important', overflowX: 'hidden', overflowY: 'auto', overscrollBehavior: 'contain', - // Inset frame via borders (not padding) so the content box aligns - // with Backstage's paddingLeft drawer offset and still shows the - // page-inset color around the well. backgroundColor: general.mainSectionBackgroundColor, - borderStyle: 'solid', - borderColor: - general.pageInsetBackgroundColor ?? general.appBarBackgroundColor, - borderWidth: `${general.pageInset} ${general.pageInset} ${general.pageInset} 0`, - // Sticky PF corner masks: paint page-inset only in the region - // outside a normal convex 1rem radius (circle centered inside the - // well, not at the corner — that produced inverted "bites"). - // ::before is first-in-tree so `position: sticky; top: 0` stays on - // the visible well (an ::after at the end of a long page would not). + borderRadius: '1rem', + // Clips the scrollbar into the rounded well (border-radius cannot). + clipPath: 'inset(0 round 1rem)', + // Visible left corners: clip-path rounds the border-box (under the + // drawer spacer). Sticky masks paint the curve at the content edge + // after paddingLeft where it meets the sidebar. '&::before': { content: '""', position: 'sticky', @@ -847,8 +875,6 @@ export const createComponents = (themeConfig: ThemeConfig): Components => { marginBottom: `calc(0px - (100vh - 2 * ${general.pageInset}))`, pointerEvents: 'none', zIndex: 2, - // Each layer is a 1rem corner tile. Transparent inside the arc, - // inset color in the square outside it → convex card corner. backgroundImage: [ `radial-gradient(circle at 100% 100%, transparent 1rem, ${ general.pageInsetBackgroundColor ?? @@ -904,6 +930,20 @@ export const createComponents = (themeConfig: ThemeConfig): Components => { height: '0.5rem !important', }, }, + // DependencyGraph's IconButton is `position:absolute; right:0` but + // `.fullscreen` is not positioned. Our clip-path on SidebarPage + // becomes the absolute containing block, so the control sticks to + // the page-well corner. Re-contain it to the graph, then inset it + // slightly so the 1rem curve does not shear the icon. + '& .fullscreen': { + position: 'relative', + }, + '& .fullscreen > .MuiIconButton-root, & .fullscreen .MuiIconButton-root[class*="fullscreenButton"]': + { + top: '0.5rem !important', + right: '0.5rem !important', + zIndex: 3, + }, }, }, }, From a1ad5494c546533e1faa7377d07ad16f0bf3fb35 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Thu, 24 Sep 2026 12:08:11 -0400 Subject: [PATCH 3/5] fix(theme): apply SidebarPage mainSection background below 600px Keep page-inset layout desktop-only, but paint the content well on all viewports so mobile no longer falls through to body --bui-bg-app. Co-authored-by: Cursor --- .../plugins/theme/src/utils/createComponents.test.ts | 10 +++++++++- .../theme/plugins/theme/src/utils/createComponents.ts | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts b/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts index d624b66956a..3bd2d166d48 100644 --- a/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts +++ b/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts @@ -96,6 +96,15 @@ describe('createComponents', () => { ); }); + it('paints BackstageSidebarPage with mainSectionBackgroundColor on all viewports', () => { + const actual = createComponents({ palette: customDarkTheme() }); + expect(actual.BackstageSidebarPage?.styleOverrides?.root).toEqual( + expect.objectContaining({ + backgroundColor: '#292929', + }), + ); + }); + it('clips the SidebarPage scrollport so the scrollbar follows the rounded well', () => { const actual = createComponents({ palette: customDarkTheme() }); const root = actual.BackstageSidebarPage?.styleOverrides?.root as @@ -116,7 +125,6 @@ describe('createComponents', () => { height: 'calc(100vh - 2 * 1.5rem)', maxHeight: 'calc(100vh - 2 * 1.5rem)', overscrollBehavior: 'contain', - backgroundColor: '#292929', borderRadius: '1rem', clipPath: 'inset(0 round 1rem)', }), diff --git a/workspaces/theme/plugins/theme/src/utils/createComponents.ts b/workspaces/theme/plugins/theme/src/utils/createComponents.ts index 01bddb7c6f3..005c994980e 100644 --- a/workspaces/theme/plugins/theme/src/utils/createComponents.ts +++ b/workspaces/theme/plugins/theme/src/utils/createComponents.ts @@ -829,6 +829,10 @@ export const createComponents = (themeConfig: ThemeConfig): Components => { // height; without a min-height here the page-inset background stops // early and body/html shows through (RHDHBUGS-3498). minHeight: '100vh', + // All viewports: paint the content well. Page-inset chrome below is + // desktop-only; without this, mobile falls through to body + // (--bui-bg-app / page-inset) instead of mainSectionBackgroundColor. + backgroundColor: general.mainSectionBackgroundColor, // Controls the page inset as in PF6 -- only in desktop view. // CSS-only: SidebarPage is the sole scrollport so BUI siblings // (PluginHeader + Containers) and classic
share one rounded @@ -858,7 +862,6 @@ export const createComponents = (themeConfig: ThemeConfig): Components => { overflowX: 'hidden', overflowY: 'auto', overscrollBehavior: 'contain', - backgroundColor: general.mainSectionBackgroundColor, borderRadius: '1rem', // Clips the scrollbar into the rounded well (border-radius cannot). clipPath: 'inset(0 round 1rem)', From d73622db4bff04bf4542113f9c400b0e74c8ee6a Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Thu, 24 Sep 2026 22:45:39 -0400 Subject: [PATCH 4/5] fix(theme): stop page-inset overscroll ghost and keep Header corner masks Disable rubber-band overscroll on the SidebarPage well, and raise sticky corner mask z-index above Backstage Header so MUI test pages keep top-left rounding. Co-authored-by: Cursor --- .../src/assets/bui/component-overrides.css | 6 +++--- .../theme/src/utils/createComponents.test.ts | 4 ++-- .../plugins/theme/src/utils/createComponents.ts | 17 ++++++++++------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css b/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css index 53790a95756..bc323af6cdf 100644 --- a/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css +++ b/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css @@ -833,9 +833,9 @@ body[data-theme-name='dark-customized'][data-theme-mode='dark'] { /* * Page inset / rounded content well: * BackstageSidebarPage is the sole scrollport. createComponents.ts applies - * margin inset + clip-path so the scrollbar is clipped into the rounded well - * for both classic
and fragmented BUI Header + Container siblings — - * no extra DOM wrapper required. + * margin inset + clip-path, sticky left-edge corner masks, and + * overscroll-behavior: none — for both classic
and fragmented BUI + * Header + Container siblings; no extra DOM wrapper. */ } diff --git a/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts b/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts index 3bd2d166d48..074c50668e4 100644 --- a/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts +++ b/workspaces/theme/plugins/theme/src/utils/createComponents.test.ts @@ -124,7 +124,7 @@ describe('createComponents', () => { marginLeft: 0, height: 'calc(100vh - 2 * 1.5rem)', maxHeight: 'calc(100vh - 2 * 1.5rem)', - overscrollBehavior: 'contain', + overscrollBehavior: 'none', borderRadius: '1rem', clipPath: 'inset(0 round 1rem)', }), @@ -134,7 +134,7 @@ describe('createComponents', () => { position: 'sticky', top: 0, pointerEvents: 'none', - zIndex: 2, + zIndex: 101, }), ); expect( diff --git a/workspaces/theme/plugins/theme/src/utils/createComponents.ts b/workspaces/theme/plugins/theme/src/utils/createComponents.ts index 005c994980e..2e36b563717 100644 --- a/workspaces/theme/plugins/theme/src/utils/createComponents.ts +++ b/workspaces/theme/plugins/theme/src/utils/createComponents.ts @@ -845,8 +845,6 @@ export const createComponents = (themeConfig: ThemeConfig): Components => { // // Backstage SidebarPage sets width:100%; horizontal margins would // otherwise overflow and get clipped (right gutter disappears). - // Left rounding sits after paddingLeft (drawer spacer), so sticky - // corner masks still paint the visible left curve against the sidebar. '@media (min-width: 600px)': { boxSizing: 'border-box', // Override Backstage `width: 100%` so margin-right is not pushed @@ -861,13 +859,18 @@ export const createComponents = (themeConfig: ThemeConfig): Components => { minHeight: '0 !important', overflowX: 'hidden', overflowY: 'auto', - overscrollBehavior: 'contain', + // `contain` still allows rubber-band overscroll, which reveals an + // empty rounded well above the content. `none` disables that. + overscrollBehavior: 'none', borderRadius: '1rem', // Clips the scrollbar into the rounded well (border-radius cannot). clipPath: 'inset(0 round 1rem)', - // Visible left corners: clip-path rounds the border-box (under the - // drawer spacer). Sticky masks paint the curve at the content edge - // after paddingLeft where it meets the sidebar. + // Left corners: clip-path rounds the border-box (under the drawer + // spacer), so the visible edge after paddingLeft stays square. + // Sticky masks paint the curve at the content edge. Keep + // overscroll-behavior: none so rubber-band does not show a second well. + // z-index must sit above Backstage Header (z-index: 100) or MUI + // Page headers cover the top-left mask tile. '&::before': { content: '""', position: 'sticky', @@ -877,7 +880,7 @@ export const createComponents = (themeConfig: ThemeConfig): Components => { height: `calc(100vh - 2 * ${general.pageInset})`, marginBottom: `calc(0px - (100vh - 2 * ${general.pageInset}))`, pointerEvents: 'none', - zIndex: 2, + zIndex: 101, backgroundImage: [ `radial-gradient(circle at 100% 100%, transparent 1rem, ${ general.pageInsetBackgroundColor ?? From 616f7c08ae79fc4df2a7f177c0daecc65af9f7d9 Mon Sep 17 00:00:00 2001 From: Yi Cai Date: Tue, 29 Sep 2026 16:52:08 -0400 Subject: [PATCH 5/5] fix(theme): show BUI table sort chevron on keyboard focus Match hover affordance for sortable headers and document that arrow keys (not Tab) move between headers inside the React Aria table. Signed-off-by: Yi Cai Co-authored-by: Cursor --- .../src/assets/bui/component-overrides.css | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css b/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css index bc323af6cdf..f5d56badae3 100644 --- a/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css +++ b/workspaces/theme/plugins/theme/src/assets/bui/component-overrides.css @@ -619,6 +619,26 @@ body[data-theme-name='dark-customized'][data-theme-mode='dark'] { #f2f2f2 ); } + /* + * Hover/focus parity: BUI only reveals the sort chevron on .bui-TableHead:hover + * (or when already sorted). Show it on keyboard focus too so mouse and keyboard + * users get the same sortable affordance. + * + * Keyboard note (React Aria / BUI table): Tab enters or leaves the table as a + * single stop. To move between sortable headers while one is focused, use the + * arrow keys — not Tab. + */ + .bui-TableHead:focus-visible .bui-TableHeadSortButton, + .bui-TableHead[data-focused] .bui-TableHeadSortButton, + .bui-TableHead[data-focus-visible] .bui-TableHeadSortButton { + opacity: 1; + } + .bui-TableHead:focus-visible, + .bui-TableHead[data-focused], + .bui-TableHead[data-focus-visible] { + outline: 2px solid var(--bui-ring, var(--bui-bg-solid)); + outline-offset: -2px; + } .bui-TableHead[data-sort-direction='ascending'] .bui-TableHeadSortButton, .bui-TableHead[data-sort-direction='descending'] .bui-TableHeadSortButton { color: var(--pf-t--global--color--brand--clicked, #004d99);