diff --git a/.changeset/nfs-icon-alpha-plugins.md b/.changeset/nfs-icon-alpha-plugins.md
new file mode 100644
index 0000000000..42c22bbd6e
--- /dev/null
+++ b/.changeset/nfs-icon-alpha-plugins.md
@@ -0,0 +1,12 @@
+---
+'@backstage/plugin-api-docs': patch
+'@backstage/plugin-catalog': patch
+'@backstage/plugin-catalog-unprocessed-entities': patch
+'@backstage/plugin-devtools': patch
+'@backstage/plugin-scaffolder': patch
+'@backstage/plugin-search': patch
+'@backstage/plugin-techdocs': patch
+'@backstage/plugin-user-settings': patch
+---
+
+Updated alpha plugin icons to follow the new frontend icon sizing rules when rendered in plugin and navigation surfaces.
diff --git a/.changeset/nfs-icon-foundations.md b/.changeset/nfs-icon-foundations.md
new file mode 100644
index 0000000000..b36ee47573
--- /dev/null
+++ b/.changeset/nfs-icon-foundations.md
@@ -0,0 +1,7 @@
+---
+'@backstage/core-components': patch
+'@backstage/frontend-app-api': patch
+'@backstage/frontend-plugin-api': patch
+---
+
+Clarified the `IconElement` sizing contract for the new frontend system and aligned legacy system icon rendering with the new icon API.
diff --git a/.changeset/nfs-icon-plugin-app.md b/.changeset/nfs-icon-plugin-app.md
new file mode 100644
index 0000000000..0f6280a6fb
--- /dev/null
+++ b/.changeset/nfs-icon-plugin-app.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-app': patch
+---
+
+The app nav now falls back to `plugin.icon` for navigation items that don't have an explicit icon set.
diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx
index efe1f97dfb..984488cdbb 100644
--- a/packages/core-components/src/layout/Sidebar/Items.tsx
+++ b/packages/core-components/src/layout/Sidebar/Items.tsx
@@ -116,6 +116,17 @@ const makeSidebarStyles = (sidebarConfig: SidebarConfig) =>
font: 'inherit',
textTransform: 'none',
},
+ itemIcon: {
+ display: 'inline-flex',
+ fontSize: theme.typography.fontSize,
+ lineHeight: 0,
+ '& svg': {
+ width: '1.5em',
+ height: '1.5em',
+ fontSize: 'inherit',
+ flexShrink: 0,
+ },
+ },
closed: {
width: sidebarConfig.drawerWidthClosed,
justifyContent: 'center',
@@ -401,7 +412,9 @@ const SidebarItemBase = forwardRef<
const displayItemIcon = (
-
+
+
+
{!isOpen && hasSubmenu ? : <>>}
);
diff --git a/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.test.ts b/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.test.ts
index cfe6236b95..558c160ecb 100644
--- a/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.test.ts
+++ b/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.test.ts
@@ -59,6 +59,8 @@ describe('DefaultIconsApi', () => {
expect(result).toBeTruthy();
// @ts-expect-error accessing internal React element structure
expect(result.type).toBe(MyIcon);
+ // @ts-expect-error accessing internal React element structure
+ expect(result.props.fontSize).toBe('inherit');
});
it('should wrap IconElement values in a component for getIcon()', () => {
@@ -69,10 +71,43 @@ describe('DefaultIconsApi', () => {
expect(icon).toBeDefined();
expect(typeof icon).toBe('function');
// @ts-expect-error testing runtime behavior
- expect(icon({})).toBe(element);
+ const result = icon({});
+ expect(result.type).toBe('span');
+ expect(result.props.style).toEqual({ fontSize: '1.5rem' });
+ expect(result.props.children).toBe(element.props.children);
expect(api.getIcon('myIcon')).toBe(icon);
});
+ it('should honor fontSize for getIcon()', () => {
+ const element = createElement('svg');
+ const api = new DefaultIconsApi({ myIcon: element });
+ const icon = api.getIcon('myIcon');
+
+ // @ts-expect-error testing runtime behavior
+ const result = icon({ fontSize: 'small' });
+ expect(result.props.style.fontSize).toBe('1.25rem');
+ });
+
+ it('should forward runtime props to the original icon element', () => {
+ const element = createElement('svg', {
+ className: 'existing',
+ style: { color: 'red' },
+ });
+ const api = new DefaultIconsApi({ myIcon: element });
+ const icon = api.getIcon('myIcon');
+
+ // @ts-expect-error testing runtime behavior
+ const result = icon({ className: 'extra', style: { width: '2em' } });
+
+ expect(result.type).toBe('svg');
+ expect(result.props.className).toBe('existing extra');
+ expect(result.props.style).toEqual({
+ color: 'red',
+ fontSize: '1.5rem',
+ width: '2em',
+ });
+ });
+
it('should wrap null IconElement in a component for getIcon()', () => {
const api = new DefaultIconsApi({ empty: null });
diff --git a/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.ts b/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.ts
index cc5ef69b6f..52ac07551a 100644
--- a/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.ts
+++ b/packages/frontend-app-api/src/apis/implementations/IconsApi/DefaultIconsApi.ts
@@ -19,7 +19,22 @@ import {
IconElement,
IconsApi,
} from '@backstage/frontend-plugin-api';
-import { createElement, isValidElement } from 'react';
+import { cloneElement, createElement, isValidElement } from 'react';
+
+const legacyFontSizeMap = {
+ inherit: 'inherit',
+ small: '1.25rem',
+ medium: '1.5rem',
+ large: '2.1875rem',
+} as const;
+
+function mergeClassNames(...classNames: Array) {
+ const merged = classNames.filter(Boolean).join(' ');
+ if (merged) {
+ return merged;
+ }
+ return undefined;
+}
/**
* Implementation for the {@link IconsApi}
@@ -39,7 +54,10 @@ export class DefaultIconsApi implements IconsApi {
return [key, value];
}
deprecatedKeys.push(key);
- return [key, createElement(value as IconComponent)];
+ return [
+ key,
+ createElement(value as IconComponent, { fontSize: 'inherit' }),
+ ];
}),
);
@@ -65,7 +83,37 @@ export class DefaultIconsApi implements IconsApi {
if (el === undefined) {
return undefined;
}
- component = () => el;
+ component = props => {
+ if (el === null) {
+ return null;
+ }
+
+ const {
+ fontSize = 'medium',
+ className,
+ style,
+ ...rest
+ } = props as {
+ fontSize?: keyof typeof legacyFontSizeMap;
+ className?: string;
+ style?: Record;
+ } & Record;
+
+ const elementProps = el.props as {
+ className?: string;
+ style?: Record;
+ };
+
+ return cloneElement(el, {
+ ...rest,
+ className: mergeClassNames(elementProps.className, className),
+ style: {
+ ...elementProps.style,
+ fontSize: legacyFontSizeMap[fontSize],
+ ...style,
+ },
+ });
+ };
this.#components.set(key, component);
return component;
}
diff --git a/packages/frontend-plugin-api/src/icons/types.ts b/packages/frontend-plugin-api/src/icons/types.ts
index 1a45bcd8fc..180763ad09 100644
--- a/packages/frontend-plugin-api/src/icons/types.ts
+++ b/packages/frontend-plugin-api/src/icons/types.ts
@@ -42,7 +42,14 @@ export type IconComponent = ComponentType<{
*
* @remarks
*
- * Icons should be exactly 24x24 pixels in size.
+ * Icon elements should behave like rendering a plain icon directly, for example
+ * from `@remixicon/react`, and are expected to be sized by the surrounding UI.
+ * Icons should be exactly 24x24 pixels in size by default.
+ *
+ * Using icons from `@remixicon/react` is preferred. Using icons from
+ * `@material-ui/icons` or `AppIcon` and its variants from
+ * `@backstage/core-components` is supported while migrating, but deprecated.
+ * When using those icons, you must set `fontSize="inherit"` on the element.
*
* @public
*/
diff --git a/plugins/api-docs/src/alpha.tsx b/plugins/api-docs/src/alpha.tsx
index 4afe97dee9..2724619a68 100644
--- a/plugins/api-docs/src/alpha.tsx
+++ b/plugins/api-docs/src/alpha.tsx
@@ -43,7 +43,7 @@ const apiDocsNavItem = NavItemBlueprint.make({
params: {
title: 'APIs',
routeRef: rootRoute,
- icon: () => ,
+ icon: () => ,
},
});
@@ -211,7 +211,7 @@ const apiDocsApisEntityContent = EntityContentBlueprint.make({
export default createFrontendPlugin({
pluginId: 'api-docs',
title: 'APIs',
- icon: ,
+ icon: ,
info: { packageJson: () => import('../package.json') },
routes: {
root: rootRoute,
diff --git a/plugins/app/src/extensions/AppNav.tsx b/plugins/app/src/extensions/AppNav.tsx
index ffa6ced7b0..129a94cd9e 100644
--- a/plugins/app/src/extensions/AppNav.tsx
+++ b/plugins/app/src/extensions/AppNav.tsx
@@ -172,6 +172,7 @@ function NavContentRenderer(props: {
// We want the priority: page (config/params) -> nav item -> plugin -> pluginId
const resolvedTitle = node.instance.getData(coreExtensionData.title);
const pluginTitle = node.spec.plugin.title;
+ const pluginIcon = node.spec.plugin.icon;
const pluginId = node.spec.plugin.pluginId;
const hasExplicitPageTitle =
resolvedTitle !== undefined &&
@@ -194,6 +195,8 @@ function NavContentRenderer(props: {
icon = ;
} else if (resolvedIcon) {
icon = resolvedIcon;
+ } else if (pluginIcon) {
+ icon = pluginIcon;
}
if (!title || !icon) {
diff --git a/plugins/app/src/extensions/IconsApi.ts b/plugins/app/src/extensions/IconsApi.tsx
similarity index 100%
rename from plugins/app/src/extensions/IconsApi.ts
rename to plugins/app/src/extensions/IconsApi.tsx
diff --git a/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx b/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx
index 8fd614c886..d2987f3197 100644
--- a/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx
+++ b/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx
@@ -69,7 +69,7 @@ export const catalogUnprocessedEntitiesNavItem = NavItemBlueprint.make({
export default createFrontendPlugin({
pluginId: 'catalog-unprocessed-entities',
title: 'Unprocessed Entities',
- icon: ,
+ icon: ,
info: { packageJson: () => import('../../package.json') },
routes: {
root: rootRouteRef,
diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx
index ab71cf39dc..6d64c65b74 100644
--- a/plugins/catalog/src/alpha/pages.tsx
+++ b/plugins/catalog/src/alpha/pages.tsx
@@ -59,7 +59,7 @@ export const catalogPage = PageBlueprint.makeWithOverrides({
return originalFactory({
path: '/catalog',
routeRef: rootRouteRef,
- icon: ,
+ icon: ,
title: 'Catalog',
loader: async () => {
const { BaseCatalogPage } = await import('../components/CatalogPage');
diff --git a/plugins/catalog/src/alpha/plugin.tsx b/plugins/catalog/src/alpha/plugin.tsx
index 8db026016a..243b93db9b 100644
--- a/plugins/catalog/src/alpha/plugin.tsx
+++ b/plugins/catalog/src/alpha/plugin.tsx
@@ -40,7 +40,7 @@ import contextMenuItems from './contextMenuItems';
export default createFrontendPlugin({
pluginId: 'catalog',
title: 'Catalog',
- icon: ,
+ icon: ,
info: {
packageJson: () => import('../../package.json'),
},
diff --git a/plugins/devtools/src/alpha/plugin.tsx b/plugins/devtools/src/alpha/plugin.tsx
index b1cfa7027f..b991e34bf9 100644
--- a/plugins/devtools/src/alpha/plugin.tsx
+++ b/plugins/devtools/src/alpha/plugin.tsx
@@ -89,7 +89,7 @@ export const devToolsNavItem = NavItemBlueprint.make({
export default createFrontendPlugin({
pluginId: 'devtools',
title: 'DevTools',
- icon: ,
+ icon: ,
info: { packageJson: () => import('../../package.json') },
routes: {
root: rootRouteRef,
diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx
index 374249a804..0c105f3a7c 100644
--- a/plugins/scaffolder/src/alpha/plugin.tsx
+++ b/plugins/scaffolder/src/alpha/plugin.tsx
@@ -61,7 +61,7 @@ const scaffolderEntityIconLink = EntityIconLinkBlueprint.make({
export default createFrontendPlugin({
pluginId: 'scaffolder',
title: 'Create',
- icon: ,
+ icon: ,
info: { packageJson: () => import('../../package.json') },
routes: {
root: rootRouteRef,
diff --git a/plugins/search/src/alpha.tsx b/plugins/search/src/alpha.tsx
index a2ecb61e0e..9625f5e946 100644
--- a/plugins/search/src/alpha.tsx
+++ b/plugins/search/src/alpha.tsx
@@ -277,7 +277,7 @@ export const searchNavItem = NavItemBlueprint.make({
export default createFrontendPlugin({
pluginId: 'search',
title: 'Search',
- icon: ,
+ icon: ,
info: { packageJson: () => import('../package.json') },
extensions: [searchApi, searchPage, searchNavItem],
routes: {
diff --git a/plugins/techdocs/src/alpha/index.tsx b/plugins/techdocs/src/alpha/index.tsx
index 6acc6b5e6d..1ec92edfd3 100644
--- a/plugins/techdocs/src/alpha/index.tsx
+++ b/plugins/techdocs/src/alpha/index.tsx
@@ -280,7 +280,7 @@ const techDocsNavItem = NavItemBlueprint.make({
export default createFrontendPlugin({
pluginId: 'techdocs',
title: 'Docs',
- icon: ,
+ icon: ,
info: { packageJson: () => import('../../package.json') },
extensions: [
techDocsClientApi,
diff --git a/plugins/user-settings/src/alpha.tsx b/plugins/user-settings/src/alpha.tsx
index c0342a9b46..87b858fcec 100644
--- a/plugins/user-settings/src/alpha.tsx
+++ b/plugins/user-settings/src/alpha.tsx
@@ -63,7 +63,7 @@ export const settingsNavItem = NavItemBlueprint.make({
export default createFrontendPlugin({
pluginId: 'user-settings',
title: 'Settings',
- icon: ,
+ icon: ,
info: { packageJson: () => import('../package.json') },
extensions: [userSettingsPage, settingsNavItem],
routes: {