diff --git a/.changeset/bui-themer-bui-palette-additions.md b/.changeset/bui-themer-bui-palette-additions.md new file mode 100644 index 0000000000..645641b787 --- /dev/null +++ b/.changeset/bui-themer-bui-palette-additions.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-mui-to-bui': minor +--- + +This is the first release of the Material UI to Backstage UI migration helper plugin. It adds a new page at `/mui-to-bui` that converts an existing MUI v5 theme into Backstage UI (BUI) CSS variables, with live preview and copy/download. diff --git a/.changeset/unified-theme-attr-stack.md b/.changeset/unified-theme-attr-stack.md new file mode 100644 index 0000000000..003e05018f --- /dev/null +++ b/.changeset/unified-theme-attr-stack.md @@ -0,0 +1,5 @@ +--- +'@backstage/theme': patch +--- + +The `UnifiedThemeProvider` now coordinates theme attributes on the document `body` in case multiple theme providers are rendered. diff --git a/packages/app/package.json b/packages/app/package.json index 56b5178692..1623ca09e5 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -56,6 +56,7 @@ "@backstage/plugin-home": "workspace:^", "@backstage/plugin-kubernetes": "workspace:^", "@backstage/plugin-kubernetes-cluster": "workspace:^", + "@backstage/plugin-mui-to-bui": "workspace:^", "@backstage/plugin-notifications": "workspace:^", "@backstage/plugin-org": "workspace:^", "@backstage/plugin-permission-react": "workspace:^", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index ad525420a7..45f91ce8a9 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -73,6 +73,7 @@ import { } from '@backstage/plugin-notifications'; import { CustomizableHomePage } from './components/home/CustomizableHomePage'; import { HomePage } from './components/home/HomePage'; +import { BuiThemerPage } from '@backstage/plugin-mui-to-bui'; const app = createApp({ apis, @@ -208,6 +209,7 @@ const routes = ( {customDevToolsPage} } /> + } /> ); diff --git a/packages/frontend-defaults/src/createPublicSignInApp.test.tsx b/packages/frontend-defaults/src/createPublicSignInApp.test.tsx index 7e12499a1c..e80d058140 100644 --- a/packages/frontend-defaults/src/createPublicSignInApp.test.tsx +++ b/packages/frontend-defaults/src/createPublicSignInApp.test.tsx @@ -96,6 +96,7 @@ describe('createPublicSignInApp', () => {
{ - document.body.setAttribute('data-theme-mode', themeMode); - document.body.setAttribute('data-theme-name', themeName); - - return () => { - document.body.removeAttribute('data-theme-mode'); - document.body.removeAttribute('data-theme-name'); - }; - }, [themeMode, themeName]); + useApplyThemeAttributes( + v4Theme ? v4Theme.palette.type : v5Theme?.palette.mode, + 'backstage', + ); let cssBaseline: JSX.Element | undefined = undefined; if (!noCssBaseline) { diff --git a/packages/theme/src/unified/useApplyThemeAttributes.test.tsx b/packages/theme/src/unified/useApplyThemeAttributes.test.tsx new file mode 100644 index 0000000000..b6c1ec1852 --- /dev/null +++ b/packages/theme/src/unified/useApplyThemeAttributes.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 '@testing-library/jest-dom'; +import { renderHook } from '@testing-library/react'; +import { useApplyThemeAttributes } from './useApplyThemeAttributes'; + +describe('useApplyThemeAttributes', () => { + beforeEach(() => { + document.body.removeAttribute('data-unified-theme-stack'); + document.body.removeAttribute('data-theme-mode'); + document.body.removeAttribute('data-theme-name'); + }); + + it('pushes attributes on mount and pops on unmount', () => { + const { unmount } = renderHook(() => + useApplyThemeAttributes('light', 'one'), + ); + expect(document.body.getAttribute('data-theme-mode')).toBe('light'); + expect(document.body.getAttribute('data-theme-name')).toBe('one'); + expect( + JSON.parse(document.body.getAttribute('data-unified-theme-stack') || '[]') + .length, + ).toBe(1); + + unmount(); + expect(document.body.getAttribute('data-theme-mode')).toBeNull(); + expect(document.body.getAttribute('data-theme-name')).toBeNull(); + expect(document.body.getAttribute('data-unified-theme-stack')).toBeNull(); + }); + + it('stacks multiple mounts and applies top-most', () => { + const r1 = renderHook(() => useApplyThemeAttributes('light', 'one')); + const r2 = renderHook(() => useApplyThemeAttributes('dark', 'two')); + + expect(document.body.getAttribute('data-theme-mode')).toBe('dark'); + expect(document.body.getAttribute('data-theme-name')).toBe('two'); + + r2.unmount(); + expect(document.body.getAttribute('data-theme-mode')).toBe('light'); + expect(document.body.getAttribute('data-theme-name')).toBe('one'); + + r1.unmount(); + expect(document.body.getAttribute('data-theme-mode')).toBeNull(); + expect(document.body.getAttribute('data-theme-name')).toBeNull(); + }); +}); diff --git a/packages/theme/src/unified/useApplyThemeAttributes.ts b/packages/theme/src/unified/useApplyThemeAttributes.ts new file mode 100644 index 0000000000..8295d69ec6 --- /dev/null +++ b/packages/theme/src/unified/useApplyThemeAttributes.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 { useEffect } from 'react'; + +type ThemeFrame = { mode?: string; name?: string }; + +const STACK_ATTR = 'data-unified-theme-stack'; +const MODE_ATTR = 'data-theme-mode'; +const NAME_ATTR = 'data-theme-name'; + +function mutateAttrStack(mutator: (stack: ThemeFrame[]) => void) { + const stack = (() => { + const raw = document.body.getAttribute(STACK_ATTR); + if (!raw) { + return [] as ThemeFrame[]; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as ThemeFrame[]) : []; + } catch { + return [] as ThemeFrame[]; + } + })(); + + mutator(stack); + + if (stack.length === 0) { + document.body.removeAttribute(STACK_ATTR); + } else { + document.body.setAttribute(STACK_ATTR, JSON.stringify(stack)); + } + + const top = stack[stack.length - 1]; + if (top?.mode) { + document.body.setAttribute(MODE_ATTR, String(top.mode)); + } else { + document.body.removeAttribute(MODE_ATTR); + } + if (top?.name) { + document.body.setAttribute(NAME_ATTR, String(top.name)); + } else { + document.body.removeAttribute(NAME_ATTR); + } +} + +export function useApplyThemeAttributes(themeMode: string, themeName: string) { + useEffect(() => { + mutateAttrStack(stack => { + stack.push({ mode: themeMode, name: themeName }); + }); + + return () => { + mutateAttrStack(stack => { + stack.pop(); + }); + }; + }, [themeMode, themeName]); +} diff --git a/plugins/app/src/alpha/appModulePublicSignIn.test.tsx b/plugins/app/src/alpha/appModulePublicSignIn.test.tsx index a40bf19dd6..787b8949e5 100644 --- a/plugins/app/src/alpha/appModulePublicSignIn.test.tsx +++ b/plugins/app/src/alpha/appModulePublicSignIn.test.tsx @@ -99,6 +99,7 @@ describe('appModulePublicSignIn', () => {
`) + +Add a route for the page in your app: + +```tsx +// packages/app/src/App.tsx +import React from 'react'; +import { Route } from 'react-router-dom'; +import { FlatRoutes } from '@backstage/core-app-api'; +import { BuiThemerPage } from '@backstage/plugin-mui-to-bui'; + +export const App = () => ( + + {/* ...your other routes */} + } /> + +); +``` + +#### New frontend system + +If package discovery is enabled in your app, this plugin is picked up automatically after installation — no code changes required. Just navigate to `/mui-to-bui`. + +If you prefer explicit registration (or don't use discovery), register the plugin as a feature. The page route (`/mui-to-bui`) is provided by the plugin. + +```tsx +// packages/app/src/App.tsx (or your app entry where you call createApp) +import React from 'react'; +import { createApp } from '@backstage/frontend-defaults'; +import buiThemerPlugin from '@backstage/plugin-mui-to-bui'; + +const app = createApp({ + features: [ + // ...other features + buiThemerPlugin, + ], +}); + +export default app.createRoot(); +``` + +## Accessing the Themer page + +- Navigate to `/mui-to-bui` in your Backstage app (for example `http://localhost:3000/mui-to-bui`). +- Optional: Add a sidebar/link in your app that points to `/mui-to-bui` if you want a permanent navigation entry. diff --git a/plugins/bui-themer/catalog-info.yaml b/plugins/bui-themer/catalog-info.yaml new file mode 100644 index 0000000000..9e3605267d --- /dev/null +++ b/plugins/bui-themer/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-mui-to-bui + title: '@backstage/plugin-mui-to-bui' +spec: + lifecycle: experimental + type: backstage-frontend-plugin + owner: maintainers diff --git a/plugins/bui-themer/dev/index.tsx b/plugins/bui-themer/dev/index.tsx new file mode 100644 index 0000000000..8837d540c5 --- /dev/null +++ b/plugins/bui-themer/dev/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 { createDevApp } from '@backstage/dev-utils'; +import { buiThemerPlugin, BuiThemerPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(buiThemerPlugin) + .addPage({ + element: , + title: 'Root Page', + path: '/mui-to-bui', + }) + .render(); diff --git a/plugins/bui-themer/knip-report.md b/plugins/bui-themer/knip-report.md new file mode 100644 index 0000000000..2661c35327 --- /dev/null +++ b/plugins/bui-themer/knip-report.md @@ -0,0 +1,2 @@ +# Knip report + diff --git a/plugins/bui-themer/package.json b/plugins/bui-themer/package.json new file mode 100644 index 0000000000..a04bfbf474 --- /dev/null +++ b/plugins/bui-themer/package.json @@ -0,0 +1,69 @@ +{ + "name": "@backstage/plugin-mui-to-bui", + "version": "0.1.0", + "backstage": { + "role": "frontend-plugin", + "pluginId": "mui-to-bui", + "pluginPackages": [ + "@backstage/plugin-mui-to-bui" + ] + }, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/bui-themer" + }, + "license": "Apache-2.0", + "sideEffects": false, + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/core-compat-api": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/theme": "workspace:^", + "@backstage/ui": "workspace:^", + "@mui/material": "^5.12.2", + "@mui/system": "^5.16.14" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/dev-utils": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^16.0.0", + "@types/react": "^18.0.0", + "react": "^18.0.2", + "react-dom": "^18.0.2", + "react-router-dom": "^6.3.0" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0", + "react-router-dom": "^6.3.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } +} diff --git a/plugins/bui-themer/report.api.md b/plugins/bui-themer/report.api.md new file mode 100644 index 0000000000..593130ea86 --- /dev/null +++ b/plugins/bui-themer/report.api.md @@ -0,0 +1,66 @@ +## API Report File for "@backstage/plugin-mui-to-bui" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; +import { JSX as JSX_2 } from 'react'; +import { JSX as JSX_3 } from 'react/jsx-runtime'; +import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; +import { RouteRef } from '@backstage/frontend-plugin-api'; +import { RouteRef as RouteRef_2 } from '@backstage/core-plugin-api'; + +// @public (undocumented) +export const BuiThemerPage: () => JSX_3.Element; + +// @public (undocumented) +export const buiThemerPlugin: BackstagePlugin< + { + root: RouteRef_2; + }, + {} +>; + +// @public (undocumented) +const _default: OverridableFrontendPlugin< + { + root: RouteRef; + }, + {}, + { + 'page:mui-to-bui': ExtensionDefinition<{ + kind: 'page'; + name: undefined; + config: { + path: string | undefined; + }; + configInput: { + path?: string | undefined; + }; + output: + | ExtensionDataRef + | ExtensionDataRef + | ExtensionDataRef< + RouteRef, + 'core.routing.ref', + { + optional: true; + } + >; + inputs: {}; + params: { + defaultPath?: [Error: `Use the 'path' param instead`]; + path: string; + loader: () => Promise; + routeRef?: RouteRef; + }; + }>; + } +>; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemePreview.test.tsx b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemePreview.test.tsx new file mode 100644 index 0000000000..375f9411a7 --- /dev/null +++ b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemePreview.test.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 { screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { renderInTestApp } from '@backstage/test-utils'; +import { BuiThemePreview } from './BuiThemePreview'; + +describe('BuiThemePreview', () => { + it('renders headings and sample components', async () => { + const { container } = await renderInTestApp( + , + ); + + expect(container.firstChild).toHaveAttribute('data-theme-mode', 'light'); + expect(await screen.findByText('Button Variants')).toBeInTheDocument(); + expect(screen.getByText('Button Variants')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Primary' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Secondary' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Tertiary' }), + ).toBeInTheDocument(); + expect(screen.getByText('Form Inputs')).toBeInTheDocument(); + expect(screen.getByText('Tag Variants')).toBeInTheDocument(); + expect(screen.getByText('Text Variants')).toBeInTheDocument(); + }); +}); diff --git a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemePreview.tsx b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemePreview.tsx new file mode 100644 index 0000000000..e59f4e78dd --- /dev/null +++ b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemePreview.tsx @@ -0,0 +1,167 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 { + Box, + Button, + Card, + CardBody, + CardHeader, + Checkbox, + Flex, + Radio, + RadioGroup, + Select, + Text, + TextField, + TagGroup, + Tag, +} from '@backstage/ui'; + +interface IsolatedPreviewProps { + mode: 'light' | 'dark'; + styleObject: Record; +} + +export function BuiThemePreview({ mode, styleObject }: IsolatedPreviewProps) { + return ( +
+ + + Button Variants + + + + + + + + + + + + + + + + + + + + Form Inputs + + + +