From 798fea1218651d4a09d513f7745c62d63d47fc1b Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 24 Feb 2026 18:57:56 +0000 Subject: [PATCH 1/5] feat(ui): add Guidelines section with Cards + Table page layout story - Add `packages/ui/src/guidelines/CardsWithTable.stories.tsx` as the first guideline story, showing three data-driven metric cards in a 3-column grid above a paginated table of catalog services. - Update Storybook `storySort` order so the new `Guidelines` group appears at the top of the `Backstage UI` section. - Wrap story content in a `Box bg="neutral-1"` when the Spotify theme is active, preserving `layout: centered` centering and always applying `borderRadius`. - Import `Box` from the UI package into the Storybook preview. - Remove `transition` from `Container` CSS to avoid animation on resize. - Set `padding-inline: 0` on `.bui-Container` inside the Spotify theme so containers render flush within the themed wrapper. Signed-off-by: Charles de Dreuille Co-authored-by: Cursor --- .storybook/preview.tsx | 40 ++- .storybook/themes/spotify.css | 4 + .../components/Container/Container.module.css | 1 - .../src/guidelines/CardsWithTable.stories.tsx | 279 ++++++++++++++++++ 4 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/guidelines/CardsWithTable.stories.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 6a0177cafe..3d9e197bbc 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -10,6 +10,7 @@ import { apis } from './support/apis'; import { useGlobals } from 'storybook/preview-api'; import { UnifiedThemeProvider, themes } from '@backstage/theme'; import { allModes } from './modes'; +import { Box } from '../packages/ui/src/components/Box'; // Default Backstage theme CSS (from packages/ui) import '../packages/ui/src/css/styles.css'; @@ -70,7 +71,13 @@ export default definePreview({ options: { storySort: { - order: ['Backstage UI', 'Plugins', 'Layout', 'Navigation'], + order: [ + 'Backstage UI', + 'Guidelines', + 'Plugins', + 'Layout', + 'Navigation', + ], }, }, @@ -114,7 +121,7 @@ export default definePreview({ }, decorators: [ - Story => { + (Story, context) => { const [globals] = useGlobals(); const selectedTheme = globals.themeMode === 'light' ? themes.light : themes.dark; @@ -138,7 +145,7 @@ export default definePreview({ (element as HTMLElement).style.backgroundColor = 'var(--bui-bg-app)'; }); - return ( + const content = ( {/* @ts-ignore */} @@ -147,6 +154,33 @@ export default definePreview({ ); + + if (selectedThemeName !== 'spotify') { + return content; + } + + const layout = context.parameters?.layout ?? 'padded'; + const isFullscreen = context.parameters?.layout === 'fullscreen'; + + return ( + + {content} + + ); }, ], diff --git a/.storybook/themes/spotify.css b/.storybook/themes/spotify.css index b72683f53e..89b71c48d3 100644 --- a/.storybook/themes/spotify.css +++ b/.storybook/themes/spotify.css @@ -213,6 +213,10 @@ .bui-Tag { border-radius: var(--bui-radius-full); } + + .bui-Container { + padding-inline: 0; + } } [data-theme-mode='light'][data-theme-name='spotify'] { diff --git a/packages/ui/src/components/Container/Container.module.css b/packages/ui/src/components/Container/Container.module.css index a0dec1e581..ef1d85c66c 100644 --- a/packages/ui/src/components/Container/Container.module.css +++ b/packages/ui/src/components/Container/Container.module.css @@ -21,7 +21,6 @@ max-width: 120rem; padding-inline: var(--bui-space-4); margin-inline: auto; - transition: padding 0.2s ease-in-out; } @media (min-width: 640px) { diff --git a/packages/ui/src/guidelines/CardsWithTable.stories.tsx b/packages/ui/src/guidelines/CardsWithTable.stories.tsx new file mode 100644 index 0000000000..51cc416423 --- /dev/null +++ b/packages/ui/src/guidelines/CardsWithTable.stories.tsx @@ -0,0 +1,279 @@ +/* + * 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. + */ + +/* eslint-disable no-restricted-syntax */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { MemoryRouter } from 'react-router-dom'; +import { + Card, + CardHeader, + CardBody, + Container, + Grid, + Flex, + Text, + Table, + CellText, + CellProfile, + useTable, + type ColumnConfig, + PluginHeader, + HeaderPage, + Button, +} from '..'; + +// --------------------------------------------------------------------------- +// Metric card data +// --------------------------------------------------------------------------- + +interface MetricCard { + label: string; + value: string; + trend: string; + trendColor: 'primary' | 'secondary' | 'success' | 'danger' | 'warning'; +} + +const metrics: MetricCard[] = [ + { + label: 'Total Components', + value: '142', + trend: '+12 this week', + trendColor: 'success', + }, + { + label: 'Active Services', + value: '58', + trend: '94% healthy', + trendColor: 'success', + }, + { + label: 'Recent Deployments', + value: '23', + trend: 'in the last 24 h', + trendColor: 'secondary', + }, +]; + +// --------------------------------------------------------------------------- +// Table data +// --------------------------------------------------------------------------- + +interface ServiceItem { + id: string; + name: string; + owner: string; + ownerAvatar: string; + type: 'service' | 'library' | 'website' | 'documentation'; + lifecycle: 'production' | 'experimental'; + description: string; +} + +const services: ServiceItem[] = [ + { + id: '1', + name: 'authentication-service', + owner: 'security-team', + ownerAvatar: 'https://github.com/identicons/security-team.png', + type: 'service', + lifecycle: 'production', + description: 'Handles user authentication, sessions and token refresh.', + }, + { + id: '2', + name: 'api-gateway', + owner: 'platform-team', + ownerAvatar: 'https://github.com/identicons/platform-team.png', + type: 'service', + lifecycle: 'production', + description: 'Routes and validates all inbound API requests.', + }, + { + id: '3', + name: 'frontend-core', + owner: 'design-system', + ownerAvatar: 'https://github.com/identicons/design-system.png', + type: 'library', + lifecycle: 'production', + description: 'Shared UI components and design tokens.', + }, + { + id: '4', + name: 'data-pipeline', + owner: 'data-team', + ownerAvatar: 'https://github.com/identicons/data-team.png', + type: 'service', + lifecycle: 'experimental', + description: 'Streaming data ingestion pipeline for analytics.', + }, + { + id: '5', + name: 'developer-portal', + owner: 'devex-team', + ownerAvatar: 'https://github.com/identicons/devex-team.png', + type: 'website', + lifecycle: 'production', + description: 'Internal developer portal built on Backstage.', + }, + { + id: '6', + name: 'notification-service', + owner: 'platform-team', + ownerAvatar: 'https://github.com/identicons/platform-team.png', + type: 'service', + lifecycle: 'production', + description: 'Sends emails, Slack messages and push notifications.', + }, + { + id: '7', + name: 'search-indexer', + owner: 'search-team', + ownerAvatar: 'https://github.com/identicons/search-team.png', + type: 'service', + lifecycle: 'experimental', + description: 'Indexes catalog entities for full-text search.', + }, + { + id: '8', + name: 'billing-service', + owner: 'payments-team', + ownerAvatar: 'https://github.com/identicons/payments-team.png', + type: 'service', + lifecycle: 'production', + description: 'Manages subscriptions, invoices and payment processing.', + }, +]; + +// --------------------------------------------------------------------------- +// Stat card component +// --------------------------------------------------------------------------- + +const StatCard = ({ label, value, trend, trendColor }: MetricCard) => ( + + {label} + + + + {value} + + + {trend} + + + + +); + +// --------------------------------------------------------------------------- +// Page layout component (the actual story render) +// --------------------------------------------------------------------------- + +const columns: ColumnConfig[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + defaultWidth: '3fr', + cell: item => ( + + ), + }, + { + id: 'owner', + label: 'Owner', + defaultWidth: '2fr', + cell: item => , + }, + { + id: 'type', + label: 'Type', + defaultWidth: '1fr', + cell: item => , + }, + { + id: 'lifecycle', + label: 'Lifecycle', + defaultWidth: '1fr', + cell: item => ( + + ), + }, +]; + +const CardsWithTableLayout = () => { + const { tableProps } = useTable({ + mode: 'complete', + getData: () => services, + paginationOptions: { pageSize: 5 }, + }); + + return ( + <> + + Custom action} + /> + + + + {metrics.map(metric => ( + + ))} + + + + + + ); +}; + +// --------------------------------------------------------------------------- +// Storybook meta +// --------------------------------------------------------------------------- + +const meta = { + title: 'Guidelines/Cards with Table', + parameters: { + layout: 'fullscreen', + }, + decorators: [ + (Story: () => JSX.Element) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; From 4c2c350490a4f8d83a9ae004521143bd8ec4ccbe Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 24 Feb 2026 19:05:25 +0000 Subject: [PATCH 2/5] Create chatty-wasps-sink.md Signed-off-by: Charles de Dreuille --- .changeset/chatty-wasps-sink.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chatty-wasps-sink.md diff --git a/.changeset/chatty-wasps-sink.md b/.changeset/chatty-wasps-sink.md new file mode 100644 index 0000000000..4b9d5d3525 --- /dev/null +++ b/.changeset/chatty-wasps-sink.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Removed the `transition` on `Container` padding to prevent an unwanted animation when the viewport is resized. From b5fae7405566e61b2699bbc1370b9d7e7afb3f2e Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 24 Feb 2026 19:07:59 +0000 Subject: [PATCH 3/5] Update preview.tsx Signed-off-by: Charles de Dreuille --- .storybook/preview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 3d9e197bbc..9ba9057142 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -168,7 +168,7 @@ export default definePreview({ style={{ minHeight: 'calc(100vh - 32px)', width: 'calc(100vw - 32px)', - borderRadius: '8px', + borderRadius: 'var(--bui-radius-3)', ...(layout === 'centered' && { display: 'flex', alignItems: 'center', From bf14b6e877cd197c1fa28d0d8ff7aa53601aedd8 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 25 Feb 2026 08:37:48 +0000 Subject: [PATCH 4/5] Update chatty-wasps-sink.md Signed-off-by: Charles de Dreuille --- .changeset/chatty-wasps-sink.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/chatty-wasps-sink.md b/.changeset/chatty-wasps-sink.md index 4b9d5d3525..7f12c5372c 100644 --- a/.changeset/chatty-wasps-sink.md +++ b/.changeset/chatty-wasps-sink.md @@ -3,3 +3,5 @@ --- Removed the `transition` on `Container` padding to prevent an unwanted animation when the viewport is resized. + +Affected components: Container From e1c87b33caa01bd0725da709765ba350b1e82ad2 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 25 Feb 2026 10:15:28 +0000 Subject: [PATCH 5/5] Improve modes Signed-off-by: Charles de Dreuille --- .storybook/modes.ts | 15 ++ .storybook/preview.tsx | 70 +++++----- .../src/components/Button/Button.stories.tsx | 132 ++++-------------- .../components/Container/Container.module.css | 1 + 4 files changed, 81 insertions(+), 137 deletions(-) diff --git a/.storybook/modes.ts b/.storybook/modes.ts index 7087fa35eb..ae4ff76a7c 100644 --- a/.storybook/modes.ts +++ b/.storybook/modes.ts @@ -15,4 +15,19 @@ export const allModes = { themeMode: 'dark', themeName: 'spotify', }, + 'light spotify neutral-1': { + themeMode: 'light', + themeName: 'spotify', + background: 'neutral-1', + }, + 'light spotify neutral-2': { + themeMode: 'light', + themeName: 'spotify', + background: 'neutral-2', + }, + 'light spotify neutral-3': { + themeMode: 'light', + themeName: 'spotify', + background: 'neutral-3', + }, } as const; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 9ba9057142..c5074f7749 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,14 +3,13 @@ import addonDocs from '@storybook/addon-docs'; import addonThemes from '@storybook/addon-themes'; import addonLinks from '@storybook/addon-links'; import { definePreview } from '@storybook/react-vite'; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import { TestApiProvider } from '@backstage/test-utils'; import { AlertDisplay } from '@backstage/core-components'; import { apis } from './support/apis'; import { useGlobals } from 'storybook/preview-api'; import { UnifiedThemeProvider, themes } from '@backstage/theme'; import { allModes } from './modes'; -import { Box } from '../packages/ui/src/components/Box'; // Default Backstage theme CSS (from packages/ui) import '../packages/ui/src/css/styles.css'; @@ -20,6 +19,7 @@ import './storybook.css'; // Custom themes import './themes/spotify.css'; +import { Box } from '../packages/ui/src/components/Box'; export default definePreview({ tags: ['manifest'], @@ -31,8 +31,8 @@ export default definePreview({ toolbar: { icon: 'circlehollow', items: [ - { value: 'light', icon: 'circlehollow', title: 'Light' }, - { value: 'dark', icon: 'circle', title: 'Dark' }, + { value: 'light', icon: 'sun', title: 'Light' }, + { value: 'dark', icon: 'moon', title: 'Dark' }, ], dynamicTitle: true, }, @@ -50,11 +50,26 @@ export default definePreview({ dynamicTitle: true, }, }, + background: { + name: 'Background', + description: 'Global background for components', + defaultValue: 'app', + toolbar: { + icon: 'contrast', + items: [ + { value: 'app', title: 'App Background' }, + { value: 'neutral-1', title: 'Neutral 1 Background' }, + { value: 'neutral-2', title: 'Neutral 2 Background' }, + { value: 'neutral-3', title: 'Neutral 3 Background' }, + ], + }, + }, }, initialGlobals: { themeMode: 'light', themeName: 'backstage', + background: 'app', }, parameters: { @@ -127,6 +142,8 @@ export default definePreview({ globals.themeMode === 'light' ? themes.light : themes.dark; const selectedThemeMode = globals.themeMode || 'light'; const selectedThemeName = globals.themeName || 'backstage'; + const selectedBackground = globals.background || 'app'; + const isFullscreen = context.parameters.layout === 'fullscreen'; useEffect(() => { document.body.removeAttribute('data-theme-mode'); @@ -140,47 +157,34 @@ export default definePreview({ }, [selectedTheme, selectedThemeName]); document.body.style.backgroundColor = 'var(--bui-bg-app)'; + document.body.style.padding = + isFullscreen && selectedBackground !== 'app' ? '1rem' : ''; const docsStoryElements = document.getElementsByClassName('docs-story'); Array.from(docsStoryElements).forEach(element => { (element as HTMLElement).style.backgroundColor = 'var(--bui-bg-app)'; }); - const content = ( + return ( {/* @ts-ignore */} - + {Array.from({ + length: + selectedBackground === 'app' + ? 0 + : parseInt(selectedBackground.split('-')[1], 10), + }).reduce( + children => ( + + {children} + + ), + , + )} ); - - if (selectedThemeName !== 'spotify') { - return content; - } - - const layout = context.parameters?.layout ?? 'padded'; - const isFullscreen = context.parameters?.layout === 'fullscreen'; - - return ( - - {content} - - ); }, ], diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx index 5e5df2073b..2e2f94b4e1 100644 --- a/packages/ui/src/components/Button/Button.stories.tsx +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ import preview from '../../../../../.storybook/preview'; +import { allModes } from '../../../../../.storybook/modes'; import { Button } from './Button'; import { Flex } from '../Flex'; import { Box } from '../Box'; @@ -55,100 +56,37 @@ export const Variants = meta.story({ control: false, }, }, + chromatic: { + modes: { + 'light spotify neutral-1': allModes['light spotify neutral-1'], + 'light spotify neutral-2': allModes['light spotify neutral-2'], + 'light spotify neutral-3': allModes['light spotify neutral-3'], + }, + }, }, render: () => ( - - Default - - - - - - - - + + + + - - Neutral 1 - - - - - - - - - - - Neutral 2 - - - - - - - - - - - Neutral 3 - - - - - - - - + + + + ), @@ -208,20 +146,6 @@ export const Destructive = meta.story({ - - On Neutral 1 - - - - - - Sizes diff --git a/packages/ui/src/components/Container/Container.module.css b/packages/ui/src/components/Container/Container.module.css index ef1d85c66c..b93665525f 100644 --- a/packages/ui/src/components/Container/Container.module.css +++ b/packages/ui/src/components/Container/Container.module.css @@ -21,6 +21,7 @@ max-width: 120rem; padding-inline: var(--bui-space-4); margin-inline: auto; + padding-bottom: var(--bui-space-8); } @media (min-width: 640px) {