diff --git a/.github/workflows/frontend-chromatic.yml b/.github/workflows/frontend-chromatic.yml new file mode 100644 index 000000000000..d68c1fd68dce --- /dev/null +++ b/.github/workflows/frontend-chromatic.yml @@ -0,0 +1,45 @@ +name: Frontend Chromatic + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - frontend/** + - .github/workflows/frontend-chromatic.yml + +permissions: + contents: read + +jobs: + chromatic: + name: Chromatic + runs-on: ubuntu-latest + if: github.event.pull_request.draft == false + + defaults: + run: + working-directory: frontend + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: frontend/.nvmrc + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Publish to Chromatic + uses: chromaui/action@v11 + with: + workingDir: frontend + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + exitZeroOnChanges: true + exitOnceUploaded: true + onlyChanged: true diff --git a/frontend/.claude/context/ui-patterns.md b/frontend/.claude/context/ui-patterns.md index b09123037321..0898f143520a 100644 --- a/frontend/.claude/context/ui-patterns.md +++ b/frontend/.claude/context/ui-patterns.md @@ -1,5 +1,9 @@ # UI Patterns & Best Practices +## Storybook First + +Before building or composing UI, query Storybook MCP (`list-all-documentation`, then `get-documentation`) to discover existing components and their props. Storybook is the source of truth for the design system — don't grep source files to find components when the catalogue is available. + ## Table Components ### Pattern: Reusable Table Components diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 7cb3d28f9bcb..4d19d9f28f5b 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -10,6 +10,7 @@ module.exports = { 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:@dword-design/import-alias/recommended', + 'plugin:storybook/recommended', ], 'globals': { '$': true, diff --git a/frontend/.gitignore b/frontend/.gitignore index 29f737c9d966..37a1bdaaf119 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -33,3 +33,6 @@ common/project.js # Playwright e2e/playwright-report/ e2e/test-results/ + +*storybook.log +storybook-static diff --git a/frontend/.storybook/docs-theme.scss b/frontend/.storybook/docs-theme.scss new file mode 100644 index 000000000000..9b11ad002b94 --- /dev/null +++ b/frontend/.storybook/docs-theme.scss @@ -0,0 +1,104 @@ +// ============================================================================= +// Storybook Docs Theme Overrides +// ============================================================================= +// The Storybook docs renderer uses its own styling that doesn't respond to +// our theme toggle. These overrides ensure documentation pages are readable +// in both light and dark mode. +// ============================================================================= + +// Light mode — fix invisible inline code +.sbdocs code { + color: #1a2634; +} + +.dark { + // Docs page background + .sbdocs-wrapper { + background-color: var(--color-surface-default, #101628); + color: var(--color-text-default, #ffffff); + } + + .sbdocs-content { + color: var(--color-text-default, #ffffff); + } + + // Headings + .sbdocs h1, + .sbdocs h2, + .sbdocs h3, + .sbdocs h4, + .sbdocs h5 { + color: var(--color-text-default, #ffffff); + } + + // Body text + .sbdocs p, + .sbdocs li, + .sbdocs td, + .sbdocs th { + color: var(--color-text-default, #ffffff); + } + + // Code — inline and blocks + .sbdocs code { + background-color: var(--color-surface-emphasis, #202839); + color: var(--color-text-default, #ffffff); + } + + .sbdocs pre { + background-color: var(--color-surface-emphasis, #202839); + color: var(--color-text-default, #ffffff); + } + + // Storybook Source/code block container and all its children + .docblock-source, + .docblock-source > * { + background-color: var(--color-surface-emphasis, #202839) !important; + color: var(--color-text-default, #ffffff) !important; + } + + // Copy button inside code blocks + .docblock-source button { + background-color: var(--color-surface-muted, #161d30) !important; + color: var(--color-text-default, #ffffff) !important; + border-color: var(--color-border-default, rgba(255, 255, 255, 0.16)) !important; + } + + // Syntax highlighting tokens + .docblock-source .token { + color: var(--color-text-default, #ffffff) !important; + } + + // Horizontal rules + .sbdocs hr { + border-color: var(--color-border-default, rgba(255, 255, 255, 0.16)); + } + + // Canvas (story preview) background + .docs-story { + background-color: var(--color-surface-default, #101628); + } + + // Table borders + .sbdocs table { + border-color: var(--color-border-default, rgba(255, 255, 255, 0.16)); + } + + .sbdocs th, + .sbdocs td { + border-color: var(--color-border-default, rgba(255, 255, 255, 0.16)); + } + + // Table row backgrounds (override Storybook's alternating white rows) + .sbdocs tr { + background-color: var(--color-surface-default, #101628); + } + + .sbdocs tr:nth-child(even) { + background-color: var(--color-surface-subtle, #15192b); + } + + .sbdocs thead tr { + background-color: var(--color-surface-muted, #161d30); + } +} diff --git a/frontend/.storybook/main.js b/frontend/.storybook/main.js new file mode 100644 index 000000000000..acc9acfa933f --- /dev/null +++ b/frontend/.storybook/main.js @@ -0,0 +1,77 @@ +const path = require('path') +const webpack = require('webpack') + +/** @type { import('storybook').StorybookConfig } */ +const config = { + stories: [ + '../documentation/**/*.mdx', + '../documentation/**/*.stories.@(js|jsx|ts|tsx)', + ], + staticDirs: ['../web'], + addons: [ + '@storybook/addon-webpack5-compiler-swc', + '@storybook/addon-docs', + '@storybook/addon-a11y', + ], + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + swc: () => ({ + jsc: { + transform: { + react: { + runtime: 'automatic', + }, + }, + parser: { + syntax: 'typescript', + tsx: true, + }, + }, + }), + webpackFinal: async (config) => { + config.resolve = config.resolve || {} + config.resolve.alias = { + ...config.resolve.alias, + common: path.resolve(__dirname, '../common'), + components: path.resolve(__dirname, '../web/components'), + project: path.resolve(__dirname, '../web/project'), + // Stub CommonJS modules that break Storybook's ESM bundler. + // code-help contains SDK snippets using module.exports — not needed for component rendering. + 'common/code-help': path.resolve(__dirname, 'mocks/code-help.js'), + // Stub CommonJS data layer that breaks ESM bundler + [path.resolve(__dirname, '../common/data/base/_data.js')]: path.resolve(__dirname, 'mocks/_data.js'), + // Mock dompurify (CJS/ESM export mismatch) + 'dompurify': path.resolve(__dirname, 'mocks/dompurify.js'), + } + + config.module = config.module || {} + config.module.rules = config.module.rules || [] + config.module.rules.push({ + test: /\.scss$/, + use: [ + 'style-loader', + { loader: 'css-loader', options: { importLoaders: 1 } }, + { + loader: 'sass-loader', + options: { + sassOptions: { + silenceDeprecations: ['slash-div'], + }, + }, + }, + ], + }) + + config.plugins = config.plugins || [] + config.plugins.push( + new webpack.DefinePlugin({ + E2E: false, + }), + ) + + return config + }, +} +module.exports = config diff --git a/frontend/.storybook/manager-head.html b/frontend/.storybook/manager-head.html new file mode 100644 index 000000000000..26112dc381c5 --- /dev/null +++ b/frontend/.storybook/manager-head.html @@ -0,0 +1,6 @@ + diff --git a/frontend/.storybook/manager.js b/frontend/.storybook/manager.js new file mode 100644 index 000000000000..21b88523b8eb --- /dev/null +++ b/frontend/.storybook/manager.js @@ -0,0 +1,61 @@ +import { addons } from 'storybook/manager-api' +import { create } from 'storybook/theming' + +// Primitive palette — mirrors _primitives.scss +// Storybook manager runs outside the app, so CSS vars aren't available. +const slate = { + 0: '#ffffff', + 50: '#fafafb', + 100: '#eff1f4', + 200: '#e0e3e9', + 300: '#9da4ae', + 500: '#656d7b', + 600: '#1a2634', + 850: '#161d30', + 900: '#15192b', +} +const purple = { 600: '#6837fc' } + +const shared = { + brandTitle: 'Flagsmith', + brandUrl: 'https://flagsmith.com', + brandImage: '/static/images/nav-logo.png', + brandTarget: '_blank', + fontBase: '"Inter", sans-serif', + colorPrimary: purple[600], + colorSecondary: purple[600], +} + +const dark = create({ + base: 'dark', + ...shared, + appBg: slate[900], + appContentBg: slate[850], + appBorderColor: 'rgba(255, 255, 255, 0.1)', + barBg: slate[900], + barTextColor: slate[300], + barSelectedColor: purple[600], + textColor: slate[200], + textMutedColor: slate[300], + textInverseColor: slate[900], +}) + +const light = create({ + base: 'light', + ...shared, + appBg: slate[50], + appContentBg: slate[0], + appBorderColor: slate[100], + barBg: slate[0], + barTextColor: slate[500], + barSelectedColor: purple[600], + textColor: slate[600], + textMutedColor: slate[300], + textInverseColor: slate[0], +}) + +const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + +addons.setConfig({ + theme: prefersDark ? dark : light, +}) diff --git a/frontend/.storybook/mocks/_data.js b/frontend/.storybook/mocks/_data.js new file mode 100644 index 000000000000..0d8b01837372 --- /dev/null +++ b/frontend/.storybook/mocks/_data.js @@ -0,0 +1,8 @@ +// Stub for common/data/base/_data — CommonJS file that breaks Storybook's ESM bundler. +// This is the Flux data layer used for API calls, not needed for component rendering. +module.exports = { + get: () => Promise.resolve(), + post: () => Promise.resolve(), + put: () => Promise.resolve(), + delete: () => Promise.resolve(), +} diff --git a/frontend/.storybook/mocks/code-help.js b/frontend/.storybook/mocks/code-help.js new file mode 100644 index 000000000000..183fdf0c1ea3 --- /dev/null +++ b/frontend/.storybook/mocks/code-help.js @@ -0,0 +1,3 @@ +// Stub for common/code-help — CommonJS files that break Storybook's ESM bundler. +// These are SDK code snippets used in the docs UI, not needed for component rendering. +module.exports = {} diff --git a/frontend/.storybook/mocks/dompurify.js b/frontend/.storybook/mocks/dompurify.js new file mode 100644 index 000000000000..347047839d5f --- /dev/null +++ b/frontend/.storybook/mocks/dompurify.js @@ -0,0 +1,7 @@ +// Mock for dompurify — the real module's CJS/ESM export mismatch breaks Storybook. +// Tooltip only uses sanitize() to clean HTML before rendering. +export function sanitize(html) { + return html +} + +export default { sanitize } diff --git a/frontend/.storybook/modes.js b/frontend/.storybook/modes.js new file mode 100644 index 000000000000..01033618c933 --- /dev/null +++ b/frontend/.storybook/modes.js @@ -0,0 +1,9 @@ +/** @type { Record } */ +export const allModes = { + light: { + theme: 'light', + }, + dark: { + theme: 'dark', + }, +} diff --git a/frontend/.storybook/preview.js b/frontend/.storybook/preview.js new file mode 100644 index 000000000000..7a4841ace6fc --- /dev/null +++ b/frontend/.storybook/preview.js @@ -0,0 +1,68 @@ +import '../web/styles/styles.scss' +import './docs-theme.scss' +import { allModes } from './modes' + +// Register safe globals that project-components.js would normally set. +// Only import components that use the automatic JSX transform (TSX files). +// Legacy .js files (Flex, Column, Input) use old JSX transform and crash here. +import Tooltip from '../web/components/Tooltip' +import Row from '../web/components/base/grid/Row' +import FormGroup from '../web/components/base/grid/FormGroup' + +window.Tooltip = Tooltip +window.Row = Row +window.FormGroup = FormGroup + +/** @type { import('storybook').Preview } */ +const preview = { + globalTypes: { + theme: { + description: 'Dark mode toggle', + toolbar: { + title: 'Theme', + icon: 'moon', + items: [ + { value: 'light', title: 'Light', icon: 'sun' }, + { value: 'dark', title: 'Dark', icon: 'moon' }, + ], + dynamicTitle: true, + }, + }, + }, + initialGlobals: { + theme: window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light', + }, + decorators: [ + (Story, context) => { + const theme = context.globals.theme || 'light' + const isDark = theme === 'dark' + + document.documentElement.setAttribute( + 'data-bs-theme', + isDark ? 'dark' : 'light', + ) + document.body.classList.toggle('dark', isDark) + + return Story() + }, + ], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + backgrounds: { disable: true }, + chromatic: { + modes: { + light: allModes.light, + dark: allModes.dark, + }, + }, + }, +} + +export default preview diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index df68667a4f6b..3e78dd6201d0 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -723,28 +723,6 @@ const Constants = { WEBHOOKS_DESCRIPTION: 'Receive a webhook for when feature values are changed.', }, - tagColors: [ - '#3d4db6', - '#ea5a45', - '#c6b215', - '#60bd4e', - '#fe5505', - '#1492f4', - '#14c0f4', - '#c277e0', - '#039587', - '#344562', - '#ffa500', - '#3cb371', - '#d3d3d3', - '#5D6D7E', - '#641E16', - '#5B2C6F', - '#D35400', - '#F08080', - '#AAC200', - '#DE3163', - ], untaggedTag: { color: '#dedede', label: 'Untagged' }, } diff --git a/frontend/common/constants/tag-colours.ts b/frontend/common/constants/tag-colours.ts new file mode 100644 index 000000000000..b892aabbcab2 --- /dev/null +++ b/frontend/common/constants/tag-colours.ts @@ -0,0 +1,22 @@ +export const tagColors = [ + '#3d4db6', + '#ea5a45', + '#c6b215', + '#60bd4e', + '#fe5505', + '#1492f4', + '#14c0f4', + '#c277e0', + '#039587', + '#344562', + '#ffa500', + '#3cb371', + '#d3d3d3', + '#5D6D7E', + '#641E16', + '#5B2C6F', + '#D35400', + '#F08080', + '#AAC200', + '#DE3163', +] as const diff --git a/frontend/common/theme/index.ts b/frontend/common/theme/index.ts new file mode 100644 index 000000000000..69e4acfaebc0 --- /dev/null +++ b/frontend/common/theme/index.ts @@ -0,0 +1,8 @@ +export { tokens, radius, shadow, duration, easing } from './tokens' +export type { + TokenCategory, + TokenEntry, + TokenName, + RadiusScale, + ShadowScale, +} from './tokens' diff --git a/frontend/common/theme/tokens.json b/frontend/common/theme/tokens.json new file mode 100644 index 000000000000..672535b68241 --- /dev/null +++ b/frontend/common/theme/tokens.json @@ -0,0 +1,160 @@ +{ + "primitives": { + "slate-0": "#ffffff", + "slate-50": "#fafafb", + "slate-100": "#eff1f4", + "slate-200": "#e0e3e9", + "slate-300": "#9da4ae", + "slate-400": "#767d85", + "slate-500": "#656d7b", + "slate-600": "#1a2634", + "slate-700": "#2d3443", + "slate-800": "#202839", + "slate-850": "#161d30", + "slate-900": "#15192b", + "slate-950": "#101628", + "purple-50": "#f5f0ff", + "purple-100": "#e8dbff", + "purple-200": "#d4bcff", + "purple-300": "#b794ff", + "purple-400": "#906af6", + "purple-500": "#7a4dfc", + "purple-600": "#6837fc", + "purple-700": "#4e25db", + "purple-800": "#3919b7", + "purple-900": "#2a2054", + "purple-950": "#1e163e", + "red-50": "#fef2f1", + "red-100": "#fce5e4", + "red-200": "#f9cbc9", + "red-300": "#f5a5a2", + "red-400": "#f57c78", + "red-500": "#ef4d56", + "red-600": "#e61b26", + "red-700": "#bb1720", + "red-800": "#90141b", + "red-900": "#701116", + "red-950": "#500d11", + "green-50": "#eef9f6", + "green-100": "#d6f1eb", + "green-200": "#b5e5da", + "green-300": "#87d4c4", + "green-400": "#56ccad", + "green-500": "#27ab95", + "green-600": "#13787b", + "green-700": "#116163", + "green-800": "#0e4a4c", + "green-900": "#0c3a3b", + "green-950": "#09292a", + "gold-50": "#fefbf0", + "gold-100": "#fdf6e0", + "gold-200": "#faeec5", + "gold-300": "#fae392", + "gold-400": "#f9dc80", + "gold-500": "#f7d56e", + "gold-600": "#e5c55f", + "gold-700": "#d4b050", + "gold-800": "#b38f30", + "gold-900": "#8b7027", + "gold-950": "#64511e", + "blue-50": "#eef8fb", + "blue-100": "#d6eef5", + "blue-200": "#b3e0ed", + "blue-300": "#7ecde2", + "blue-400": "#45bce0", + "blue-500": "#0aaddf", + "blue-600": "#0b8bb2", + "blue-700": "#0b7190", + "blue-800": "#0b576e", + "blue-900": "#094456", + "blue-950": "#07313e", + "orange-50": "#fff5ec", + "orange-100": "#ffe9d4", + "orange-200": "#ffd7b5", + "orange-300": "#ffc08a", + "orange-400": "#efb47c", + "orange-500": "#ff9f43", + "orange-600": "#fa810c", + "orange-700": "#d06907", + "orange-800": "#9f5208", + "orange-900": "#7b4008", + "orange-950": "#592f07", + "slate-1000": "#000000" + }, + "color": { + "surface": { + "default": { "cssVar": "--color-surface-default", "light": "#ffffff", "dark": "#101628" }, + "subtle": { "cssVar": "--color-surface-subtle", "light": "#fafafb", "dark": "#15192b" }, + "muted": { "cssVar": "--color-surface-muted", "light": "#eff1f4", "dark": "#161d30" }, + "emphasis": { "cssVar": "--color-surface-emphasis", "light": "#e0e3e9", "dark": "#202839" }, + "hover": { "cssVar": "--color-surface-hover", "light": "rgba(0, 0, 0, 0.08)", "dark": "rgba(255, 255, 255, 0.08)", "_comment": "neutral-1000/neutral-0 at alpha" }, + "active": { "cssVar": "--color-surface-active", "light": "rgba(0, 0, 0, 0.16)", "dark": "rgba(255, 255, 255, 0.16)", "_comment": "neutral-1000/neutral-0 at alpha" }, + "action": { "cssVar": "--color-surface-action", "light": "#6837fc", "dark": "#906af6" }, + "action-hover": { "cssVar": "--color-surface-action-hover", "light": "#4e25db", "dark": "#6837fc" }, + "action-active": { "cssVar": "--color-surface-action-active", "light": "#3919b7", "dark": "#4e25db" }, + "action-subtle": { "cssVar": "--color-surface-action-subtle", "light": "rgba(104, 55, 252, 0.08)", "dark": "rgba(255, 255, 255, 0.08)" }, + "action-muted": { "cssVar": "--color-surface-action-muted", "light": "rgba(104, 55, 252, 0.16)", "dark": "rgba(255, 255, 255, 0.16)" }, + "danger": { "cssVar": "--color-surface-danger", "light": "rgba(239, 77, 86, 0.08)", "dark": "oklch(from var(--red-500) 0.18 0.02 h)" }, + "success": { "cssVar": "--color-surface-success", "light": "rgba(39, 171, 149, 0.08)", "dark": "oklch(from var(--green-500) 0.18 0.02 h)" }, + "warning": { "cssVar": "--color-surface-warning", "light": "rgba(255, 159, 67, 0.08)", "dark": "oklch(from var(--orange-500) 0.18 0.02 h)" }, + "info": { "cssVar": "--color-surface-info", "light": "rgba(10, 173, 223, 0.08)", "dark": "oklch(from var(--blue-500) 0.18 0.02 h)" } + }, + "text": { + "default": { "cssVar": "--color-text-default", "light": "#1a2634", "dark": "#ffffff" }, + "secondary": { "cssVar": "--color-text-secondary", "light": "#656d7b", "dark": "#9da4ae" }, + "tertiary": { "cssVar": "--color-text-tertiary", "light": "#9da4ae", "dark": "rgba(255, 255, 255, 0.48)" }, + "disabled": { "cssVar": "--color-text-disabled", "light": "#9da4ae", "dark": "rgba(255, 255, 255, 0.32)" }, + "action": { "cssVar": "--color-text-action", "light": "#6837fc", "dark": "#906af6" }, + "danger": { "cssVar": "--color-text-danger", "light": "#ef4d56", "dark": "#ef4d56" }, + "success": { "cssVar": "--color-text-success", "light": "#27ab95", "dark": "#27ab95" }, + "warning": { "cssVar": "--color-text-warning", "light": "#ff9f43", "dark": "#ff9f43" }, + "info": { "cssVar": "--color-text-info", "light": "#0aaddf", "dark": "#0aaddf" } + }, + "border": { + "default": { "cssVar": "--color-border-default", "light": "rgba(101, 109, 123, 0.16)", "dark": "rgba(255, 255, 255, 0.16)" }, + "strong": { "cssVar": "--color-border-strong", "light": "rgba(101, 109, 123, 0.24)", "dark": "rgba(255, 255, 255, 0.24)" }, + "disabled": { "cssVar": "--color-border-disabled", "light": "rgba(101, 109, 123, 0.08)", "dark": "rgba(255, 255, 255, 0.08)" }, + "action": { "cssVar": "--color-border-action", "light": "#6837fc", "dark": "#906af6" }, + "danger": { "cssVar": "--color-border-danger", "light": "#ef4d56", "dark": "#ef4d56" }, + "success": { "cssVar": "--color-border-success", "light": "#27ab95", "dark": "#27ab95" }, + "warning": { "cssVar": "--color-border-warning", "light": "#ff9f43", "dark": "#ff9f43" }, + "info": { "cssVar": "--color-border-info", "light": "#0aaddf", "dark": "#0aaddf" } + }, + "icon": { + "default": { "cssVar": "--color-icon-default", "light": "#1a2634", "dark": "#ffffff" }, + "secondary": { "cssVar": "--color-icon-secondary", "light": "#656d7b", "dark": "#9da4ae" }, + "disabled": { "cssVar": "--color-icon-disabled", "light": "#9da4ae", "dark": "rgba(255, 255, 255, 0.32)" }, + "action": { "cssVar": "--color-icon-action", "light": "#6837fc", "dark": "#906af6" }, + "danger": { "cssVar": "--color-icon-danger", "light": "#ef4d56", "dark": "#ef4d56" }, + "success": { "cssVar": "--color-icon-success", "light": "#27ab95", "dark": "#27ab95" }, + "warning": { "cssVar": "--color-icon-warning", "light": "#ff9f43", "dark": "#ff9f43" }, + "info": { "cssVar": "--color-icon-info", "light": "#0aaddf", "dark": "#0aaddf" } + } + }, + "radius": { + "none": { "cssVar": "--radius-none", "value": "0px", "description": "Sharp corners. Tables, dividers." }, + "xs": { "cssVar": "--radius-xs", "value": "2px", "description": "Barely rounded. Badges, tags." }, + "sm": { "cssVar": "--radius-sm", "value": "4px", "description": "Buttons, inputs, small interactive elements." }, + "md": { "cssVar": "--radius-md", "value": "6px", "description": "Default component radius. Cards, dropdowns, tooltips." }, + "lg": { "cssVar": "--radius-lg", "value": "8px", "description": "Large cards, panels." }, + "xl": { "cssVar": "--radius-xl", "value": "10px", "description": "Extra-large containers." }, + "2xl": { "cssVar": "--radius-2xl", "value": "18px", "description": "Modals." }, + "full": { "cssVar": "--radius-full", "value": "9999px", "description": "Pill shapes, avatars, circular elements." } + }, + "shadow": { + "sm": { "cssVar": "--shadow-sm", "light": "0px 1px 2px oklch(from var(--slate-1000) l c h / 0.05)", "dark": "0px 1px 2px oklch(from var(--slate-1000) l c h / 0.20)", "description": "Subtle lift. Buttons on hover, input focus ring companion." }, + "md": { "cssVar": "--shadow-md", "light": "0px 4px 8px oklch(from var(--slate-1000) l c h / 0.12)", "dark": "0px 4px 8px oklch(from var(--slate-1000) l c h / 0.30)", "description": "Cards, dropdowns, popovers. Default elevation for floating elements." }, + "lg": { "cssVar": "--shadow-lg", "light": "0px 8px 16px oklch(from var(--slate-1000) l c h / 0.15)", "dark": "0px 8px 16px oklch(from var(--slate-1000) l c h / 0.35)", "description": "Modals, drawers, slide-over panels. High elevation for overlay content." }, + "xl": { "cssVar": "--shadow-xl", "light": "0px 12px 24px oklch(from var(--slate-1000) l c h / 0.20)", "dark": "0px 12px 24px oklch(from var(--slate-1000) l c h / 0.40)", "description": "Toast notifications, command palettes. Maximum elevation for urgent content." } + }, + "duration": { + "fast": { "cssVar": "--duration-fast", "value": "100ms", "description": "Quick feedback. Hover states, toggle switches, checkbox ticks." }, + "normal": { "cssVar": "--duration-normal", "value": "200ms", "description": "Standard transitions. Dropdown open, tooltip appear, tab switch." }, + "slow": { "cssVar": "--duration-slow", "value": "300ms", "description": "Deliberate emphasis. Modal enter, drawer slide, accordion expand." } + }, + "easing": { + "standard": { "cssVar": "--easing-standard", "value": "cubic-bezier(0.2, 0, 0.38, 0.9)", "description": "Default for most transitions. Smooth deceleration. Use for elements moving within the page." }, + "entrance": { "cssVar": "--easing-entrance", "value": "cubic-bezier(0.0, 0, 0.38, 0.9)", "description": "Elements entering the viewport. Decelerates into resting position. Modals, toasts, slide-ins." }, + "exit": { "cssVar": "--easing-exit", "value": "cubic-bezier(0.2, 0, 1, 0.9)", "description": "Elements leaving the viewport. Accelerates out of view. Closing modals, dismissing toasts." } + } +} diff --git a/frontend/common/theme/tokens.ts b/frontend/common/theme/tokens.ts new file mode 100644 index 000000000000..eb361c183eaf --- /dev/null +++ b/frontend/common/theme/tokens.ts @@ -0,0 +1,163 @@ +// ============================================================================= +// Design Tokens — AUTO-GENERATED from common/theme/tokens.json +// Do not edit manually. Run: npm run generate:tokens +// ============================================================================= + +export const tokens = { + border: { + action: 'var(--color-border-action, #6837fc)', + danger: 'var(--color-border-danger, #ef4d56)', + default: 'var(--color-border-default)', + disabled: 'var(--color-border-disabled)', + info: 'var(--color-border-info, #0aaddf)', + strong: 'var(--color-border-strong)', + success: 'var(--color-border-success, #27ab95)', + warning: 'var(--color-border-warning, #ff9f43)', + }, + icon: { + action: 'var(--color-icon-action, #6837fc)', + danger: 'var(--color-icon-danger, #ef4d56)', + default: 'var(--color-icon-default, #1a2634)', + disabled: 'var(--color-icon-disabled, #9da4ae)', + info: 'var(--color-icon-info, #0aaddf)', + secondary: 'var(--color-icon-secondary, #656d7b)', + success: 'var(--color-icon-success, #27ab95)', + warning: 'var(--color-icon-warning, #ff9f43)', + }, + surface: { + action: 'var(--color-surface-action, #6837fc)', + actionActive: 'var(--color-surface-action-active, #3919b7)', + actionHover: 'var(--color-surface-action-hover, #4e25db)', + actionMuted: 'var(--color-surface-action-muted)', + actionSubtle: 'var(--color-surface-action-subtle)', + active: 'var(--color-surface-active)', + danger: 'var(--color-surface-danger)', + default: 'var(--color-surface-default, #ffffff)', + emphasis: 'var(--color-surface-emphasis, #e0e3e9)', + hover: 'var(--color-surface-hover)', + info: 'var(--color-surface-info)', + muted: 'var(--color-surface-muted, #eff1f4)', + subtle: 'var(--color-surface-subtle, #fafafb)', + success: 'var(--color-surface-success)', + warning: 'var(--color-surface-warning)', + }, + text: { + action: 'var(--color-text-action, #6837fc)', + danger: 'var(--color-text-danger, #ef4d56)', + default: 'var(--color-text-default, #1a2634)', + disabled: 'var(--color-text-disabled, #9da4ae)', + info: 'var(--color-text-info, #0aaddf)', + secondary: 'var(--color-text-secondary, #656d7b)', + success: 'var(--color-text-success, #27ab95)', + tertiary: 'var(--color-text-tertiary, #9da4ae)', + warning: 'var(--color-text-warning, #ff9f43)', + }, +} as const + +export type TokenEntry = { + value: string + description: string +} + +// Radius +export const radius: Record = { + '2xl': { + description: 'Modals.', + value: 'var(--radius-2xl, 18px)', + }, + 'full': { + description: 'Pill shapes, avatars, circular elements.', + value: 'var(--radius-full, 9999px)', + }, + 'lg': { + description: 'Large cards, panels.', + value: 'var(--radius-lg, 8px)', + }, + 'md': { + description: 'Default component radius. Cards, dropdowns, tooltips.', + value: 'var(--radius-md, 6px)', + }, + 'none': { + description: 'Sharp corners. Tables, dividers.', + value: 'var(--radius-none, 0px)', + }, + 'sm': { + description: 'Buttons, inputs, small interactive elements.', + value: 'var(--radius-sm, 4px)', + }, + 'xl': { + description: 'Extra-large containers.', + value: 'var(--radius-xl, 10px)', + }, + 'xs': { + description: 'Barely rounded. Badges, tags.', + value: 'var(--radius-xs, 2px)', + }, +} +// Shadow +export const shadow: Record = { + 'lg': { + description: + 'Modals, drawers, slide-over panels. High elevation for overlay content.', + value: + 'var(--shadow-lg, 0px 8px 16px oklch(from var(--slate-1000) l c h / 0.15))', + }, + 'md': { + description: + 'Cards, dropdowns, popovers. Default elevation for floating elements.', + value: + 'var(--shadow-md, 0px 4px 8px oklch(from var(--slate-1000) l c h / 0.12))', + }, + 'sm': { + description: 'Subtle lift. Buttons on hover, input focus ring companion.', + value: + 'var(--shadow-sm, 0px 1px 2px oklch(from var(--slate-1000) l c h / 0.05))', + }, + 'xl': { + description: + 'Toast notifications, command palettes. Maximum elevation for urgent content.', + value: + 'var(--shadow-xl, 0px 12px 24px oklch(from var(--slate-1000) l c h / 0.20))', + }, +} +// Duration +export const duration: Record = { + 'fast': { + description: + 'Quick feedback. Hover states, toggle switches, checkbox ticks.', + value: 'var(--duration-fast, 100ms)', + }, + 'normal': { + description: + 'Standard transitions. Dropdown open, tooltip appear, tab switch.', + value: 'var(--duration-normal, 200ms)', + }, + 'slow': { + description: + 'Deliberate emphasis. Modal enter, drawer slide, accordion expand.', + value: 'var(--duration-slow, 300ms)', + }, +} +// Easing +export const easing: Record = { + 'entrance': { + description: + 'Elements entering the viewport. Decelerates into resting position. Modals, toasts, slide-ins.', + value: 'var(--easing-entrance)', + }, + 'exit': { + description: + 'Elements leaving the viewport. Accelerates out of view. Closing modals, dismissing toasts.', + value: 'var(--easing-exit)', + }, + 'standard': { + description: + 'Default for most transitions. Smooth deceleration. Use for elements moving within the page.', + value: 'var(--easing-standard)', + }, +} + +export type TokenCategory = keyof typeof tokens +export type TokenName = keyof (typeof tokens)[C] +export type RadiusScale = keyof typeof radius +export type ShadowScale = keyof typeof shadow diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index fc48e4e17841..65743f977b50 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -38,6 +38,7 @@ export type UpdateProjectBody = { stale_flags_limit_days?: number | null only_allow_lower_case_feature_names?: boolean feature_name_regex?: string | null + require_feature_owners?: boolean } export type UpdateOrganisationBody = { diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 2265891e198c..64e7d8fea531 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -226,6 +226,7 @@ export type Project = { total_segments?: number only_allow_lower_case_feature_names?: boolean feature_name_regex?: string | null + require_feature_owners?: boolean environments: Environment[] } export type ImportStrategy = 'SKIP' | 'OVERWRITE_DESTRUCTIVE' diff --git a/frontend/common/utils/__tests__/convertToPConfidence.test.ts b/frontend/common/utils/__tests__/convertToPConfidence.test.ts new file mode 100644 index 000000000000..c0634c866fc0 --- /dev/null +++ b/frontend/common/utils/__tests__/convertToPConfidence.test.ts @@ -0,0 +1,45 @@ +// Tests for the convertToPConfidence function extracted from Confidence.tsx +// The function maps p-values to confidence levels. + +describe('convertToPConfidence', () => { + // Inline the function to test it independently of the component + const convertToPConfidence = (value: number) => { + if (value > 0.05) return 'LOW' + if (value >= 0.01) return 'REASONABLE' + if (value > 0.002) return 'HIGH' + return 'VERY_HIGH' + } + + it('returns LOW for p-value above 0.05', () => { + expect(convertToPConfidence(0.1)).toBe('LOW') + expect(convertToPConfidence(0.5)).toBe('LOW') + expect(convertToPConfidence(1)).toBe('LOW') + }) + + it('returns REASONABLE for p-value between 0.01 and 0.05', () => { + expect(convertToPConfidence(0.05)).toBe('REASONABLE') + expect(convertToPConfidence(0.03)).toBe('REASONABLE') + expect(convertToPConfidence(0.01)).toBe('REASONABLE') + }) + + it('returns HIGH for p-value between 0.002 and 0.01', () => { + expect(convertToPConfidence(0.009)).toBe('HIGH') + expect(convertToPConfidence(0.005)).toBe('HIGH') + expect(convertToPConfidence(0.003)).toBe('HIGH') + }) + + it('returns VERY_HIGH for p-value at or below 0.002', () => { + expect(convertToPConfidence(0.002)).toBe('VERY_HIGH') + expect(convertToPConfidence(0.001)).toBe('VERY_HIGH') + expect(convertToPConfidence(0)).toBe('VERY_HIGH') + }) + + it('handles boundary values correctly', () => { + expect(convertToPConfidence(0.05)).toBe('REASONABLE') // exactly 0.05 + expect(convertToPConfidence(0.0500001)).toBe('LOW') // just above 0.05 + expect(convertToPConfidence(0.01)).toBe('REASONABLE') // exactly 0.01 + expect(convertToPConfidence(0.0099)).toBe('HIGH') // just below 0.01 + expect(convertToPConfidence(0.002)).toBe('VERY_HIGH') // exactly 0.002 + expect(convertToPConfidence(0.0021)).toBe('HIGH') // just above 0.002 + }) +}) diff --git a/frontend/common/utils/__tests__/fromParam.test.ts b/frontend/common/utils/__tests__/fromParam.test.ts new file mode 100644 index 000000000000..b8bfc9402613 --- /dev/null +++ b/frontend/common/utils/__tests__/fromParam.test.ts @@ -0,0 +1,38 @@ +// Tests for URL parameter parsing (replacement for Utils.fromParam) +// The inline implementation uses Object.fromEntries(new URLSearchParams(...)) + +describe('fromParam (URLSearchParams)', () => { + const fromParam = (search: string) => + Object.fromEntries(new URLSearchParams(search)) + + it('returns empty object for empty search string', () => { + expect(fromParam('')).toEqual({}) + }) + + it('parses a single parameter', () => { + expect(fromParam('?tab=features')).toEqual({ tab: 'features' }) + }) + + it('parses multiple parameters', () => { + expect(fromParam('?tab=features&page=2&search=hello')).toEqual({ + page: '2', + search: 'hello', + tab: 'features', + }) + }) + + it('decodes encoded characters', () => { + expect(fromParam('?name=hello%20world&q=a%26b')).toEqual({ + name: 'hello world', + q: 'a&b', + }) + }) + + it('handles parameters without values', () => { + expect(fromParam('?flag=')).toEqual({ flag: '' }) + }) + + it('works without leading question mark', () => { + expect(fromParam('tab=settings')).toEqual({ tab: 'settings' }) + }) +}) diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 88389946f1a4..73c60d11f14d 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -22,6 +22,7 @@ import _ from 'lodash' import ErrorMessage from 'components/ErrorMessage' import WarningMessage from 'components/WarningMessage' import Constants from 'common/constants' +import { tagColors } from 'common/constants/tag-colours' import { defaultFlags } from 'common/stores/default-flags' import Color from 'color' import { selectBuildVersion } from 'common/services/useBuildVersion' @@ -589,7 +590,7 @@ const Utils = Object.assign({}, require('./base/_utils'), { }, getTagColour(index: number) { - return Constants.tagColors[index % (Constants.tagColors.length - 1)] + return tagColors[index % (tagColors.length - 1)] }, getTypedValue( diff --git a/frontend/documentation/CategoricalPalette.stories.tsx b/frontend/documentation/CategoricalPalette.stories.tsx new file mode 100644 index 000000000000..2bcfcadc6a99 --- /dev/null +++ b/frontend/documentation/CategoricalPalette.stories.tsx @@ -0,0 +1,152 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import './docs.scss' +import DocPage from './components/DocPage' +import Swatch from './components/Swatch' + +// --------------------------------------------------------------------------- +// Colour data — inlined to avoid importing Constants (which pulls in the +// full app dependency tree and breaks Storybook's ESM context). +// Source of truth: common/constants.ts +// --------------------------------------------------------------------------- + +const TAG_COLOURS = [ + { hex: '#3d4db6', name: 'Indigo' }, + { hex: '#ea5a45', name: 'Coral' }, + { hex: '#c6b215', name: 'Gold' }, + { hex: '#60bd4e', name: 'Green' }, + { hex: '#fe5505', name: 'Orange' }, + { hex: '#1492f4', name: 'Blue' }, + { hex: '#14c0f4', name: 'Cyan' }, + { hex: '#c277e0', name: 'Lavender' }, + { hex: '#039587', name: 'Teal' }, + { hex: '#344562', name: 'Navy' }, + { hex: '#ffa500', name: 'Amber' }, + { hex: '#3cb371', name: 'Mint' }, + { hex: '#d3d3d3', name: 'Silver' }, + { hex: '#5D6D7E', name: 'Slate' }, + { hex: '#641E16', name: 'Maroon' }, + { hex: '#5B2C6F', name: 'Plum' }, + { hex: '#D35400', name: 'Burnt Orange' }, + { hex: '#F08080', name: 'Salmon' }, + { hex: '#AAC200', name: 'Lime' }, + { hex: '#DE3163', name: 'Cerise' }, +] + +const DEFAULT_TAG_COLOUR = '#dedede' + +const PROJECT_COLOURS = [ + '#906AF6', + '#FAE392', + '#42D0EB', + '#56CCAD', + '#FFBE71', + '#F57C78', +] + +const FEATURE_HEALTH = { + healthyColor: '#60bd4e', + unhealthyColor: '#D35400', +} + +const meta: Meta = { + parameters: { chromatic: { disableSnapshot: true }, layout: 'padded' }, + title: 'Design System/Tag & Project Colours', +} +export default meta + +// --------------------------------------------------------------------------- +// Stories +// --------------------------------------------------------------------------- + +export const TagColours: StoryObj = { + name: 'Tag colours', + render: () => ( + + 20 decorative colours users pick from when creating tags. Will be + defined in _categorical.scss as CSS custom properties ( + --color-tag-1 through --color-tag-20). + Currently in constants.ts pending migration. These are + NOT semantic tokens — they are categorical identifiers that need + to be visually distinct from each other. + + } + > +
+ {TAG_COLOURS.map(({ hex, name }) => ( + + ))} +
+

+ Default tag colour: {DEFAULT_TAG_COLOUR} +

+
+ ), +} + +export const ProjectColours: StoryObj = { + name: 'Project colours', + render: () => ( + + 6 colours assigned by index for project avatar badges. Will be defined + in _categorical.scss as --color-project-1{' '} + through --color-project-6. Currently in{' '} + constants.ts pending migration. Decorative — not + tied to any UI role or theme. + + } + > +
+ {PROJECT_COLOURS.map((colour: string, i: number) => ( + + ))} +
+
+ ), +} + +export const FeatureHealthColours: StoryObj = { + name: 'Feature health colours', + render: () => ( + + Status colours for feature health indicators. Currently hardcoded in{' '} + common/constants.ts as{' '} + Constants.featureHealth. These should migrate to semantic + feedback tokens: var(--color-success-default) and{' '} + var(--color-warning-default). + + } + > +
+
+ +
+ Healthy +
+ Should use var(--color-success-default) +
+
+
+
+ +
+ Unhealthy +
+ Should use var(--color-warning-default) +
+
+
+
+
+ ), +} diff --git a/frontend/documentation/ColourPalette.stories.tsx b/frontend/documentation/ColourPalette.stories.tsx new file mode 100644 index 000000000000..f6dd6c6b0e08 --- /dev/null +++ b/frontend/documentation/ColourPalette.stories.tsx @@ -0,0 +1,79 @@ +import React, { useEffect, useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import './docs.scss' +import DocPage from './components/DocPage' +import ScaleRow from './components/ScaleRow' +import type { Scale } from './components/ScaleRow' + +// @ts-expect-error raw-loader import +import primitivesSource from '!!raw-loader!../web/styles/_primitives.scss' + +const meta: Meta = { + parameters: { chromatic: { disableSnapshot: true }, layout: 'padded' }, + title: 'Design System/Palette', +} +export default meta + +// --------------------------------------------------------------------------- +// Parse _primitives.scss at build time +// --------------------------------------------------------------------------- + +function parsePrimitives(source: string): Scale[] { + const scales: Scale[] = [] + let current: Scale | null = null + + for (const line of source.split('\n')) { + // Section comment: "// Slate (neutrals)" → name = "Slate" + const sectionMatch = line.match(/^\/\/\s+(\w+)\s+\(/) + if (sectionMatch) { + current = { name: sectionMatch[1], swatches: [] } + scales.push(current) + continue + } + + // Variable: "$slate-50: #fafafb;" → step=50, hex=#fafafb + const varMatch = line.match(/^\$(\w+)-(\d+):\s*(#[0-9a-fA-F]{6});/) + if (varMatch && current) { + current.swatches.push({ + hex: varMatch[3], + step: varMatch[2], + variable: `$${varMatch[1]}-${varMatch[2]}`, + }) + } + } + + return scales +} + +// --------------------------------------------------------------------------- +// Story +// --------------------------------------------------------------------------- + +const PalettePage: React.FC = () => { + const [scales, setScales] = useState([]) + + useEffect(() => { + setScales(parsePrimitives(primitivesSource)) + }, []) + + return ( + + Auto-generated from web/styles/_primitives.scss. Add a + new variable to the SCSS file and it will appear here automatically. + + } + > + {scales.map((scale) => ( + + ))} + + ) +} + +export const Primitives: StoryObj = { + render: () => , +} diff --git a/frontend/documentation/DecisionFramework.mdx b/frontend/documentation/DecisionFramework.mdx new file mode 100644 index 000000000000..7629a604ad27 --- /dev/null +++ b/frontend/documentation/DecisionFramework.mdx @@ -0,0 +1,141 @@ +import { Meta } from '@storybook/addon-docs/blocks' + + + +# Where does a new colour go? + +Use this decision framework when you need to add a colour to the codebase. It ensures colours end up in the right layer. + +### Is it for tags, projects, or data visualisation? + +→ Add to `_categorical.scss` as a CSS custom property. +→ Does NOT need a primitive scale. +→ These are decorative identifiers, not UI roles. + +### Does it fit an existing colour family? + +→ Use the closest primitive from `_primitives.scss`. +→ Pick the nearest step in the 50–950 scale. +→ Create or use a semantic token in `tokens.json` for the UI role. +→ Available families: **slate, purple, red, green, gold, blue, orange**. + +### Is it a completely new hue not covered by any family? + +→ This is rare. Discuss with the team first. +→ If approved: add a full 50–950 scale to `_primitives.scss`. +→ Then create semantic tokens that reference it. +→ Example: product adds a pink/magenta brand element. + +--- + +## Semantic token categories + + + + + + + + + + + +
CategoryToken prefixUse forExamples
Surface{'--color-surface-*'}Backgrounds — page, card, panel, input, hover. Includes action (brand purple) and feedback surfaces.Page bg, card bg, button bg (surface-action), banner bg (surface-danger, surface-success).
Text{'--color-text-*'}Body text, headings, muted/subtle text. Includes feedback text colours.Body copy, headings, error text (text-danger), success text (text-success).
Border{'--color-border-*'}Dividers, input borders, card outlines. Includes feedback borders.Input borders, card outlines, error borders (border-danger).
Icon{'--color-icon-*'}Icon fills — default, secondary, disabled, and feedback variants.Navigation icons, status icons (icon-danger, icon-success).
+ +--- + +## What is NOT a semantic token + +- **Tag colours** — 20 decorative values defined in `_categorical.scss` as CSS custom properties. Users pick these when creating tags. Many don't map to any primitive family (indigo, lime, cerise). Forcing them into scales would reduce visual distinction. +- **Project colours** — 6 values in `_categorical.scss`, assigned by index for project avatar badges. Decorative, not semantic. +- **Chart/data viz palettes** — future work. When needed, add `{'--color-chart-*'}` variables to `_categorical.scss`. +- **Third-party brand colours** — GitHub, Google, etc. Use their official values directly. + +--- + +## How to create a semantic token + +### 1. Name it + +Follow the naming convention: `--color-[category]-[variant]` + + + + + + + + + +
PartOptionsExample
categorysurface, text, border, icon--color-text-
variantdefault, secondary, disabled, danger, success, warning, info. Surface also has: subtle, muted, emphasis, hover, active. Text also has: tertiary.--color-text-danger
+ +### 2. Pick the primitive values + +Open `_primitives.scss` and choose the right step from the 50–950 scale. The pattern for light vs dark: + + + + + + + + + + + +
VariantLight modeDark modeWhy
default500–600400–500Needs to read well on light/dark surfaces
hover600–700 (darker)400–600 (lighter)Light: darken on hover. Dark: lighten on hover.
active700–800 (darkest)300–400 (lightest)More intense than hover in both modes
subtle8% opacity of defaultOpaque dark tintLight: transparent tint. Dark: opaque to avoid stacking issues.
+ +### 3. Add to `tokens.json` + +Add the token entry to the appropriate colour category with `light` and `dark` values: + +```json +{ + "color": { + "text": { + "danger": { + "cssVar": "--color-text-danger", + "light": "#ef4d56", + "dark": "#ef4d56" + } + } + } +} +``` + +Run `git commit` — the pre-commit hook generates `_tokens.scss` and `tokens.ts` automatically. + +### 4. Use it + +```scss +// In SCSS — each element type gets its own token +.banner--danger { + background: var(--color-surface-danger); + color: var(--color-text-danger); + border: 1px solid var(--color-border-danger); +} +``` + +```tsx +// Icons reference the icon token + + +// Or via TS exports +import { tokens } from 'common/theme/tokens' + +``` + +Dark mode works automatically — no extra overrides needed. + +### 5. Verify + +- Run Storybook and check the **Design System / Semantic Tokens** story — your new token should appear automatically. +- Toggle light/dark in the toolbar and confirm both values look correct. +- Check contrast: text tokens on surface tokens should meet WCAG AA (4.5:1 for normal text, 3:1 for large text). + +### Common mistakes + +- **Using a hex value instead of the JSON** — always add tokens to `tokens.json`, never edit `_tokens.scss` or `tokens.ts` directly. +- **Forgetting the `dark` value** — every colour token needs both `light` and `dark` in the JSON. Missing it means the light mode value shows in dark mode. +- **Using transparency in dark mode subtle tokens** — use opaque values in dark mode to avoid stacking/blending issues with layered surfaces. +- **Using one token for multiple element types** — don't use `--color-text-danger` for a background. Text, surface, border, and icon each have their own danger token with values optimised for that context. diff --git a/frontend/documentation/ElevationTokens.stories.tsx b/frontend/documentation/ElevationTokens.stories.tsx new file mode 100644 index 000000000000..c23dc989835b --- /dev/null +++ b/frontend/documentation/ElevationTokens.stories.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import './docs.scss' +import DocPage from './components/DocPage' +import { shadow } from 'common/theme/tokens' + +const meta: Meta = { + parameters: { chromatic: { disableSnapshot: true }, layout: 'padded' }, + title: 'Design System/Elevation', +} +export default meta + +const SHADOW_SCALE = Object.entries(shadow).map(([, entry]) => ({ + description: entry.description, + token: entry.value.match(/var\((--[^,)]+)/)?.[1] ?? '', + value: entry.value.match(/,\s*(.+)\)$/)?.[1]?.trim() ?? entry.value, +})) + +export const Overview: StoryObj = { + render: () => ( + + Four elevation levels. Dark mode uses stronger shadows since surface + colour differentiation replaces visual lift. Defined in{' '} + _tokens.scss with .dark overrides. + + } + > +
+ {SHADOW_SCALE.map(({ description, token, value }) => ( +
+ {token} + + {value} + + + {description} + +
+ ))} +
+ +

Dark mode strategy

+

+ In dark mode, shadows are harder to perceive. The dark overrides use + stronger opacity values (0.20–0.40 vs 0.05–0.20). Additionally, higher + elevation surfaces should use progressively lighter background colours + (e.g. --color-surface-emphasis for modals) to maintain + visual hierarchy without relying solely on shadows. +

+
+ ), +} diff --git a/frontend/documentation/Introduction.mdx b/frontend/documentation/Introduction.mdx new file mode 100644 index 000000000000..f2df79553edc --- /dev/null +++ b/frontend/documentation/Introduction.mdx @@ -0,0 +1,72 @@ +{/* Introduction.mdx */} +import { Meta } from '@storybook/addon-docs/blocks' + + + +# Flagsmith Frontend + +Welcome to the Flagsmith Storybook — the single source of truth for UI components, design tokens, and visual patterns used across the frontend. + +If a component doesn't have a story, it doesn't exist. + +## Getting started + +```bash +npm run storybook +``` + +Launches Storybook at [http://localhost:6006](http://localhost:6006). Browse the sidebar to explore components and documentation. + +## Why Storybook + +- **Develop in isolation** — build and test components without spinning up the full app +- **Visual documentation** — every component variant is visible and interactive +- **Dark mode validation** — toggle themes in the toolbar to verify both modes +- **Accessibility** — the a11y addon runs checks automatically on every story + +## Dark mode + +Use the **Theme** toggle in the toolbar (moon/sun icon) to switch between light and dark mode. Every story should look correct in both themes. + +## Writing stories + +Create a `*.stories.tsx` file in the `documentation/` directory. Use the existing stories as a reference. + +A good story should: + +- Cover all meaningful variants of the component +- Work in both light and dark mode +- Include representative content, not just placeholder text + +## Project structure + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathPurpose
documentation/Storybook stories and documentation (self-contained, portable)
web/components/React components
web/styles/SCSS styles and design tokens
common/Shared state, services, types, and utilities
+ +## Links + +- [Frontend README](https://github.com/Flagsmith/flagsmith/blob/main/frontend/README.md) diff --git a/frontend/documentation/MotionTokens.stories.tsx b/frontend/documentation/MotionTokens.stories.tsx new file mode 100644 index 000000000000..6a6b5f260ecb --- /dev/null +++ b/frontend/documentation/MotionTokens.stories.tsx @@ -0,0 +1,165 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import './docs.scss' +import DocPage from './components/DocPage' +import { duration, easing } from 'common/theme/tokens' + +const meta: Meta = { + parameters: { chromatic: { disableSnapshot: true }, layout: 'padded' }, + title: 'Design System/Motion', +} +export default meta + +const DURATION_SCALE = Object.entries(duration).map(([, entry]) => ({ + description: entry.description, + token: entry.value.match(/var\((--[^,)]+)/)?.[1] ?? '', + value: entry.value.match(/,\s*(.+)\)$/)?.[1]?.trim() ?? '', +})) + +const EASING_SCALE = Object.entries(easing).map(([, entry]) => ({ + description: entry.description, + token: entry.value.match(/var\((--[^,)]+)/)?.[1] ?? '', + value: entry.value.match(/var\((--[^,)]+)\)/)?.[0] ?? entry.value, +})) + +const DemoBox: React.FC<{ + duration: string + easing: string + label: string +}> = ({ duration, easing, label }) => { + const [active, setActive] = useState(false) + + return ( +
setActive((v) => !v)} + role='button' + style={{ + background: active + ? 'var(--color-surface-action, #6837fc)' + : 'var(--color-surface-muted, #eff1f4)', + borderRadius: 'var(--radius-md, 6px)', + color: active ? '#ffffff' : 'var(--color-text-default)', + cursor: 'pointer', + fontSize: 13, + fontWeight: 600, + padding: '12px 16px', + textAlign: 'center', + transform: active ? 'scale(1.05)' : 'scale(1)', + transition: `all ${duration} ${easing}`, + userSelect: 'none', + }} + tabIndex={0} + > + {label} — click me +
+ ) +} + +export const Overview: StoryObj = { + render: () => ( + + Duration and easing tokens for consistent animation. Defined in{' '} + _tokens.scss. Pair a duration with an easing:{' '} + + {'transition: all var(--duration-normal) var(--easing-standard)'} + + + } + > +

Duration

+ + + + + + + + + + {DURATION_SCALE.map(({ description, token, value }) => ( + + + + + + ))} + +
TokenValueUsage
+ {token} + + {value} + + {description} +
+ +

Easing

+ + + + + + + + + + {EASING_SCALE.map(({ description, token, value }) => ( + + + + + + ))} + +
TokenValueUsage
+ {token} + + {value} + + {description} +
+ +

Interactive demo

+

+ Click each box to see the duration + easing combination in action. +

+
+ + + +
+
+ ), +} diff --git a/frontend/documentation/RadiusTokens.stories.tsx b/frontend/documentation/RadiusTokens.stories.tsx new file mode 100644 index 000000000000..610cffd4bf75 --- /dev/null +++ b/frontend/documentation/RadiusTokens.stories.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import './docs.scss' +import DocPage from './components/DocPage' +import { radius } from 'common/theme/tokens' + +const meta: Meta = { + parameters: { chromatic: { disableSnapshot: true }, layout: 'padded' }, + title: 'Design System/Border Radius', +} +export default meta + +const RADIUS_SCALE = Object.entries(radius).map(([, entry]) => ({ + description: entry.description, + token: entry.value.match(/var\((--[^,)]+)/)?.[1] ?? '', + value: entry.value.match(/,\s*(.+)\)$/)?.[1]?.trim() ?? '', +})) + +export const Overview: StoryObj = { + render: () => ( + + Defined in _tokens.scss. Use{' '} + {'border-radius: var(--radius-md)'} in SCSS or{' '} + {'radius.md'} from common/theme/tokens in + TypeScript. + + } + > + + + + + + + + + + + {RADIUS_SCALE.map(({ description, token, value }) => ( + + + + + + + ))} + +
PreviewTokenValueUsage
+
+
+ {token} + + {value} + + {description} +
+
+ ), +} diff --git a/frontend/documentation/SemanticTokens.stories.tsx b/frontend/documentation/SemanticTokens.stories.tsx new file mode 100644 index 000000000000..825481d4eaf6 --- /dev/null +++ b/frontend/documentation/SemanticTokens.stories.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import './docs.scss' +import DocPage from './components/DocPage' +import TokenGroup from './components/TokenGroup' +import type { TokenGroupData } from './components/TokenGroup' + +const meta: Meta = { + parameters: { chromatic: { disableSnapshot: true }, layout: 'padded' }, + title: 'Design System/Semantic Colour Tokens', +} +export default meta + +// --------------------------------------------------------------------------- +// Helpers — read --color-* custom properties from the document +// --------------------------------------------------------------------------- + +const TOKEN_PREFIX = '--color-' + +/** Group labels derived from the second segment: --color-{group}-* */ +const GROUP_LABELS: Record = { + border: 'Border', + brand: 'Brand', + danger: 'Danger', + icon: 'Icon', + info: 'Info', + success: 'Success', + surface: 'Surface', + text: 'Text', + warning: 'Warning', +} + +/** Extract the group key from a CSS variable name. */ +function groupKey(cssVar: string): string { + return cssVar.replace(TOKEN_PREFIX, '').split('-')[0] +} + +/** + * Read all --color-* custom properties defined on :root. + * We iterate the stylesheet rules rather than computed styles so we only + * pick up tokens we explicitly defined (not inherited browser defaults). + */ +function readTokens(): TokenGroupData[] { + const tokenVars = new Set() + + for (const sheet of Array.from(document.styleSheets)) { + try { + for (const rule of Array.from(sheet.cssRules)) { + if ( + rule instanceof CSSStyleRule && + (rule.selectorText === ':root' || rule.selectorText === '.dark') + ) { + for (let i = 0; i < rule.style.length; i++) { + const prop = rule.style[i] + if (prop.startsWith(TOKEN_PREFIX)) { + tokenVars.add(prop) + } + } + } + } + } catch { + // cross-origin stylesheets throw — skip them + } + } + + const computed = getComputedStyle(document.documentElement) + const grouped: Record = {} + + Array.from(tokenVars) + .sort() + .forEach((cssVar) => { + const key = groupKey(cssVar) + if (!grouped[key]) grouped[key] = [] + grouped[key].push({ + computed: computed.getPropertyValue(cssVar).trim(), + cssVar, + }) + }) + + // Order groups to match the SCSS file + const ORDER = [ + 'brand', + 'surface', + 'text', + 'border', + 'icon', + 'danger', + 'success', + 'warning', + 'info', + ] + return ORDER.filter((key) => grouped[key]).map((key) => ({ + title: GROUP_LABELS[key] || key, + tokens: grouped[key], + })) +} + +// --------------------------------------------------------------------------- +// Story +// --------------------------------------------------------------------------- + +const TokensPage: React.FC = () => { + const [groups, setGroups] = useState([]) + + // No dependency array — re-runs on every render so computed values + // update immediately when the theme is toggled via the toolbar. + useEffect(() => { + const timer = setTimeout(() => setGroups(readTokens()), 50) + return () => clearTimeout(timer) + }) + + return ( + + Auto-generated from CSS custom properties. Source of truth:{' '} + common/theme/tokens.json. Toggle the theme in the toolbar + to see computed values update. + + } + > + {groups.map((group) => ( + + ))} + + ) +} + +export const Overview: StoryObj = { + render: () => , +} diff --git a/frontend/documentation/TokenMaintenance.mdx b/frontend/documentation/TokenMaintenance.mdx new file mode 100644 index 000000000000..4954be5a5a27 --- /dev/null +++ b/frontend/documentation/TokenMaintenance.mdx @@ -0,0 +1,108 @@ +import { Meta } from '@storybook/addon-docs/blocks' + + + +# Token Maintenance Guide + +How to add, modify, or remove design tokens. Follow this process to keep the token system consistent. + +## Where tokens live + + + + + + + + + + + + + +
FilePurposeWhen to edit
common/theme/tokens.jsonSingle source of truth. All token values and descriptions.Every token change starts here.
common/theme/tokens.tsGenerated. TypeScript exports with var(--token, fallback) pattern.Never — auto-generated.
web/styles/_tokens.scssGenerated. CSS custom properties with :root and .dark.Never — auto-generated.
web/styles/_token-utilities.scssGenerated. Utility classes consuming tokens (e.g. .bg-surface-subtle, .icon-secondary).Never — auto-generated.
documentation/TokenReference.generated.stories.tsxGenerated. Flat JSX for Storybook MCP.Never — auto-generated.
web/styles/_primitives.scssRaw colour scales (50-950).Only when adding a new colour family or adjusting a scale.
+ +--- + +## Adding a new non-colour token + +Example: adding a new radius value `--radius-3xl` (24px). + +1. Add the entry to `tokens.json` in the `radius` object: + +```json +"3xl": { "cssVar": "--radius-3xl", "value": "24px", "description": "Extra-large container corners." } +``` + +2. Run `git commit`. The pre-commit hook automatically regenerates `tokens.ts`, `_tokens.scss`, `_token-utilities.scss`, and `TokenReference.generated.stories.tsx`, then stages them. No manual step needed. The new radius utility class (e.g. `.rounded-3xl`) is created automatically. + +**Note:** Spacing is handled by Bootstrap utility classes (`gap-2`, `p-3`, `m-4`, etc.) — there are no custom spacing tokens. + +--- + +## Adding a new colour token + +Example: adding `--color-text-link`. + +1. Add the entry to `tokens.json` under the `color.text` object: + +```json +"link": { "cssVar": "--color-text-link", "light": "#6837fc", "dark": "#906af6" } +``` + +2. Run `git commit`. The pre-commit hook regenerates all four files. The colour also appears in the Semantic Tokens visual story (auto-read from CSS vars) and a utility class is created (e.g. `.text-link`). + +--- + +## Modifying a token value + +1. Change the value in `tokens.json`. +2. Run `git commit` — all generated files update automatically. + +--- + +## Removing a token + +1. Search the codebase for `var(--token-name)` usages — remove or replace all references first. +2. Remove the entry from `tokens.json`. +3. Run `git commit` — the generated files update without the removed token. + +--- + +## The one rule + +**Token values must only be defined in `tokens.json`.** No hardcoded hex values, no hardcoded pixel values anywhere else. SCSS files use `var(--token)`. TypeScript uses the exports from `common/theme/tokens`. Utility classes are auto-generated — use `className="bg-surface-subtle"` in JSX instead of writing SCSS. + +--- + +## How the codegen works + + + + + + + + + + + +
StepWhat happensTriggered by
1scripts/generate-tokens.mjs reads tokens.jsonPre-commit hook (lint-staged) when tokens.json is staged
2Generates tokens.ts, _tokens.scss, _token-utilities.scss, and TokenReference.generated.stories.tsxAutomatic
3Generated files are auto-staged and included in the commitAutomatic
4Chromatic deploys the updated Storybook. Storybook MCP serves the flat JSX — AI agents can read all token data.CI (on PR)
+ +You can also run `npm run generate:tokens` manually at any time. + +--- + +## Story types + + + + + + + + + + + +
StoryTypePurpose
Token ReferenceAuto-generated (codegen)MCP-optimised. Every token inlined as flat JSX. AI agents read this.
Semantic Colour TokensAuto-generated (runtime)Visual. Reads --color-* CSS vars from the DOM. Shows computed colours with theme toggle.
Colour PaletteAuto-generated (build time)Visual. Parses _primitives.scss and renders swatch grids.
Radius, Elevation, MotionImports from tokens.tsVisual. Shows previews (rounded boxes, shadow cards, interactive demos). Data derived from tokens.ts.
diff --git a/frontend/documentation/TokenReference.generated.stories.tsx b/frontend/documentation/TokenReference.generated.stories.tsx new file mode 100644 index 000000000000..f85d80ec4671 --- /dev/null +++ b/frontend/documentation/TokenReference.generated.stories.tsx @@ -0,0 +1,645 @@ +// AUTO-GENERATED from common/theme/tokens.json — do not edit manually +// Run: npm run generate:tokens + +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import './docs.scss' +import DocPage from './components/DocPage' + +const meta: Meta = { + parameters: { chromatic: { disableSnapshot: true }, layout: 'padded' }, + title: 'Design System/Token Reference (MCP)', +} +export default meta + +export const AllTokens: StoryObj = { + name: 'All tokens (MCP reference)', + render: () => ( + +

Colour: surface

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TokenValue
+ --color-surface-default + + var(--slate-0) +
+ --color-surface-subtle + + var(--slate-50) +
+ --color-surface-muted + + var(--slate-100) +
+ --color-surface-emphasis + + var(--slate-200) +
+ --color-surface-hover + + oklch(from var(--slate-1000) l c h / 0.08) +
+ --color-surface-active + + oklch(from var(--slate-1000) l c h / 0.16) +
+ --color-surface-action + + var(--purple-600) +
+ --color-surface-action-hover + + var(--purple-700) +
+ --color-surface-action-active + + var(--purple-800) +
+ --color-surface-action-subtle + + oklch(from var(--purple-600) l c h / 0.08) +
+ --color-surface-action-muted + + oklch(from var(--purple-600) l c h / 0.16) +
+ --color-surface-danger + + oklch(from var(--red-500) l c h / 0.08) +
+ --color-surface-success + + oklch(from var(--green-500) l c h / 0.08) +
+ --color-surface-warning + + oklch(from var(--orange-500) l c h / 0.08) +
+ --color-surface-info + + oklch(from var(--blue-500) l c h / 0.08) +
+

Colour: text

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TokenValue
+ --color-text-default + + var(--slate-600) +
+ --color-text-secondary + + var(--slate-500) +
+ --color-text-tertiary + + var(--slate-300) +
+ --color-text-disabled + + var(--slate-300) +
+ --color-text-action + + var(--purple-600) +
+ --color-text-danger + + var(--red-500) +
+ --color-text-success + + var(--green-500) +
+ --color-text-warning + + var(--orange-500) +
+ --color-text-info + + var(--blue-500) +
+

Colour: border

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TokenValue
+ --color-border-default + + oklch(from var(--slate-500) l c h / 0.16) +
+ --color-border-strong + + oklch(from var(--slate-500) l c h / 0.24) +
+ --color-border-disabled + + oklch(from var(--slate-500) l c h / 0.08) +
+ --color-border-action + + var(--purple-600) +
+ --color-border-danger + + var(--red-500) +
+ --color-border-success + + var(--green-500) +
+ --color-border-warning + + var(--orange-500) +
+ --color-border-info + + var(--blue-500) +
+

Colour: icon

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TokenValue
+ --color-icon-default + + var(--slate-600) +
+ --color-icon-secondary + + var(--slate-500) +
+ --color-icon-disabled + + var(--slate-300) +
+ --color-icon-action + + var(--purple-600) +
+ --color-icon-danger + + var(--red-500) +
+ --color-icon-success + + var(--green-500) +
+ --color-icon-warning + + var(--orange-500) +
+ --color-icon-info + + var(--blue-500) +
+

Radius

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TokenValueUsage
+ --radius-none + + 0px + Sharp corners. Tables, dividers.
+ --radius-xs + + 2px + Barely rounded. Badges, tags.
+ --radius-sm + + 4px + Buttons, inputs, small interactive elements.
+ --radius-md + + 6px + Default component radius. Cards, dropdowns, tooltips.
+ --radius-lg + + 8px + Large cards, panels.
+ --radius-xl + + 10px + Extra-large containers.
+ --radius-2xl + + 18px + Modals.
+ --radius-full + + 9999px + Pill shapes, avatars, circular elements.
+

Shadow

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TokenValueUsage
+ --shadow-sm + + + 0px 1px 2px oklch(from var(--slate-1000) l c h / 0.05) + + Subtle lift. Buttons on hover, input focus ring companion.
+ --shadow-md + + + 0px 4px 8px oklch(from var(--slate-1000) l c h / 0.12) + + + Cards, dropdowns, popovers. Default elevation for floating + elements. +
+ --shadow-lg + + + 0px 8px 16px oklch(from var(--slate-1000) l c h / 0.15) + + + Modals, drawers, slide-over panels. High elevation for overlay + content. +
+ --shadow-xl + + + 0px 12px 24px oklch(from var(--slate-1000) l c h / 0.20) + + + Toast notifications, command palettes. Maximum elevation for + urgent content. +
+

Duration

+ + + + + + + + + + + + + + + + + + + + + + + + + +
TokenValueUsage
+ --duration-fast + + 100ms + + Quick feedback. Hover states, toggle switches, checkbox ticks. +
+ --duration-normal + + 200ms + + Standard transitions. Dropdown open, tooltip appear, tab switch. +
+ --duration-slow + + 300ms + + Deliberate emphasis. Modal enter, drawer slide, accordion expand. +
+

Easing

+ + + + + + + + + + + + + + + + + + + + + + + + + +
TokenValueUsage
+ --easing-standard + + cubic-bezier(0.2, 0, 0.38, 0.9) + + Default for most transitions. Smooth deceleration. Use for + elements moving within the page. +
+ --easing-entrance + + cubic-bezier(0.0, 0, 0.38, 0.9) + + Elements entering the viewport. Decelerates into resting position. + Modals, toasts, slide-ins. +
+ --easing-exit + + cubic-bezier(0.2, 0, 1, 0.9) + + Elements leaving the viewport. Accelerates out of view. Closing + modals, dismissing toasts. +
+ +

Dark mode shadows

+

+ Dark mode overrides use stronger opacity (0.20-0.40 vs 0.05-0.20). + Higher elevation surfaces should use lighter backgrounds + (--color-surface-emphasis) rather than relying solely on shadows. +

+ +

Motion pairing

+

+ Combine a duration with an easing: transition: all + var(--duration-normal) var(--easing-standard). Use --easing-entrance for + elements appearing, --easing-exit for elements leaving. +

+
+ ), +} diff --git a/frontend/documentation/Typography.mdx b/frontend/documentation/Typography.mdx new file mode 100644 index 000000000000..f1f0fea36d07 --- /dev/null +++ b/frontend/documentation/Typography.mdx @@ -0,0 +1,63 @@ +import { Meta } from '@storybook/addon-docs/blocks' + + + +# Typography Tokens + +Font tokens defined in `common/theme/tokens.json`. Generated into CSS custom properties in `_tokens.scss`. + +## Headings + + + + + + + + + + + + + +
TokenSizeLine heightWeight
--font-h1-size42px46px700
--font-h2-size34px40px700
--font-h3-size30px40px600
--font-h4-size24px32px600
--font-h5-size18px28px600
--font-h6-size16px24px600
+ +## Body text + + + + + + + + + + + + +
TokenSizeLine heightWeightUsage
--font-body-size14px20px400Default body text
--font-body-sm-size13px18px400Table headers, subtitles
--font-caption-size12px16px400Helper text, small labels
--font-caption-xs-size11px14px400Badges, minimal text
--font-label-size12px16px600Form labels, section labels
+ +## Font weights + + + + + + + + + + + +
TokenValueUsage
--font-weight-regular400Body text, form inputs
--font-weight-medium500Buttons, inputs, alerts, tabs
--font-weight-semibold600Active tab states, sidebar links
--font-weight-bold700Headings, unread badges
+ +## Font family + + + + + + + + +
TokenValue
--font-family'OpenSans', sans-serif
diff --git a/frontend/documentation/components/AccordionCard.stories.tsx b/frontend/documentation/components/AccordionCard.stories.tsx new file mode 100644 index 000000000000..92bda33cbfd6 --- /dev/null +++ b/frontend/documentation/components/AccordionCard.stories.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import AccordionCard from 'components/base/accordion/AccordionCard' + +const meta: Meta = { + parameters: { layout: 'padded' }, + title: 'Components/Layout/AccordionCard', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + +

Accordion content goes here.

+
+ ), +} + +export const DefaultOpen: Story = { + render: () => ( + +

This accordion starts open.

+
+ ), +} diff --git a/frontend/documentation/components/Banner.stories.tsx b/frontend/documentation/components/Banner.stories.tsx new file mode 100644 index 000000000000..c03732aaffc7 --- /dev/null +++ b/frontend/documentation/components/Banner.stories.tsx @@ -0,0 +1,179 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import Banner from 'components/Banner' +import type { BannerProps } from 'components/Banner' +import { Button } from 'components/base/forms/Button' + +const meta: Meta = { + argTypes: { + children: { + control: 'text', + description: + 'Banner message content. Can include a CTA button as a child.', + }, + variant: { + control: 'select', + description: 'Feedback colour variant.', + options: ['success', 'warning', 'danger', 'info'], + }, + }, + args: { + children: 'This is a banner message.', + variant: 'info', + }, + component: Banner, + parameters: { layout: 'padded' }, + title: 'Components/Feedback/Banner', +} + +export default meta + +type Story = StoryObj + +// --------------------------------------------------------------------------- +// Default — interactive playground +// --------------------------------------------------------------------------- + +export const Default: Story = {} + +// --------------------------------------------------------------------------- +// Individual variants +// --------------------------------------------------------------------------- + +export const Success: Story = { + args: { + children: 'Your changes have been saved successfully.', + variant: 'success', + }, + parameters: { + docs: { + description: { + story: 'Use `success` for confirming a completed action.', + }, + }, + }, +} + +export const Warning: Story = { + args: { + children: 'Your trial is ending in 3 days.', + variant: 'warning', + }, + parameters: { + docs: { + description: { + story: + 'Use `warning` for cautionary messages that need attention but are not critical.', + }, + }, + }, +} + +export const Danger: Story = { + args: { + children: 'Your API key has been revoked.', + variant: 'danger', + }, + parameters: { + docs: { + description: { + story: 'Use `danger` for errors or critical issues.', + }, + }, + }, +} + +export const Info: Story = { + args: { + children: 'A new version of Flagsmith is available.', + variant: 'info', + }, + parameters: { + docs: { + description: { + story: 'Use `info` for neutral informational messages.', + }, + }, + }, +} + +// --------------------------------------------------------------------------- +// With CTA (passed as children) +// --------------------------------------------------------------------------- + +export const WithCTA: Story = { + name: 'With CTA button', + parameters: { + docs: { + description: { + story: + 'Add a CTA by passing a `Button` as part of `children`. This keeps the Banner API simple — the banner renders whatever you give it.', + }, + }, + }, + render: () => ( + + Your trial is ending in 3 days. + + + ), +} + +export const DangerWithCTA: Story = { + name: 'Danger with CTA', + parameters: { + docs: { + description: { + story: + 'For danger banners, use `theme="danger"` on the CTA button for visual consistency.', + }, + }, + }, + render: () => ( + + Your API key has been revoked. + + + ), +} + +// --------------------------------------------------------------------------- +// All variants +// --------------------------------------------------------------------------- + +export const AllVariants: Story = { + name: 'All variants', + parameters: { + docs: { + description: { + story: + 'All four banner variants. Each has a default icon that matches the variant. Banners are persistent — not closable or dismissable.', + }, + }, + }, + render: () => ( +
+ + Your changes have been saved successfully. + + + Your trial is ending in 3 days. + + + + Your API key has been revoked. + + + A new version of Flagsmith is available. +
+ ), +} diff --git a/frontend/documentation/components/BooleanDotIndicator.stories.tsx b/frontend/documentation/components/BooleanDotIndicator.stories.tsx new file mode 100644 index 000000000000..25c91f6d4cc6 --- /dev/null +++ b/frontend/documentation/components/BooleanDotIndicator.stories.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import BooleanDotIndicator from 'components/BooleanDotIndicator' + +const meta: Meta = { + parameters: { layout: 'centered' }, + title: 'Components/Data Display/BooleanDotIndicator', +} +export default meta + +type Story = StoryObj + +export const Enabled: Story = { + render: () => , +} + +export const Disabled: Story = { + render: () => , +} + +export const AllStates: Story = { + render: () => ( +
+
+ Enabled +
+
+ Disabled +
+
+ ), +} diff --git a/frontend/documentation/components/Breadcrumb.stories.tsx b/frontend/documentation/components/Breadcrumb.stories.tsx new file mode 100644 index 000000000000..a712e3fe02fa --- /dev/null +++ b/frontend/documentation/components/Breadcrumb.stories.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' +import { MemoryRouter } from 'react-router-dom' + +import Breadcrumb from 'components/Breadcrumb' + +const meta: Meta = { + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { layout: 'padded' }, + title: 'Components/Navigation/Breadcrumb', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + ), +} + +export const SingleLevel: Story = { + render: () => ( + + ), +} diff --git a/frontend/documentation/components/Button.stories.tsx b/frontend/documentation/components/Button.stories.tsx new file mode 100644 index 000000000000..42b165d680d4 --- /dev/null +++ b/frontend/documentation/components/Button.stories.tsx @@ -0,0 +1,194 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import { + Button, + themeClassNames, + sizeClassNames, +} from 'components/base/forms/Button' +import type { ButtonType } from 'components/base/forms/Button' + +const themeOptions = Object.keys(themeClassNames) as Array< + keyof typeof themeClassNames +> +const sizeOptions = Object.keys(sizeClassNames) as Array< + keyof typeof sizeClassNames +> + +const meta: Meta = { + argTypes: { + children: { + control: 'text', + description: 'Button label content.', + }, + disabled: { + control: 'boolean', + description: 'Disables the button, preventing interaction.', + }, + size: { + control: 'select', + description: 'Size of the button.', + options: sizeOptions, + table: { defaultValue: { summary: 'default' } }, + }, + theme: { + control: 'select', + description: 'Visual variant of the button.', + options: themeOptions, + table: { defaultValue: { summary: 'primary' } }, + }, + }, + args: { + children: 'Button', + disabled: false, + size: 'default', + theme: 'primary', + }, + component: Button, + parameters: { layout: 'centered' }, + title: 'Components/Forms/Button', +} + +export default meta + +type Story = StoryObj + +// --------------------------------------------------------------------------- +// Default — interactive playground +// --------------------------------------------------------------------------- + +export const Default: Story = {} + +// --------------------------------------------------------------------------- +// All Variants +// --------------------------------------------------------------------------- + +export const Variants: Story = { + parameters: { + docs: { + description: { + story: + 'All available button themes. Use `primary` for main actions, `secondary` for alternatives, `outline` for low-emphasis actions, `danger` for destructive actions, and `success` for positive confirmations.', + }, + }, + }, + render: () => ( +
+ + + + + + + +
+ ), +} + +// --------------------------------------------------------------------------- +// Sizes +// --------------------------------------------------------------------------- + +export const Sizes: Story = { + parameters: { + docs: { + description: { + story: 'Button sizes from large to extra small.', + }, + }, + }, + render: () => ( +
+ + + + +
+ ), +} + +// --------------------------------------------------------------------------- +// Disabled +// --------------------------------------------------------------------------- + +export const Disabled: Story = { + parameters: { + docs: { + description: { + story: 'Disabled buttons are non-interactive and visually muted.', + }, + }, + }, + render: () => ( +
+ + + + +
+ ), +} + +// --------------------------------------------------------------------------- +// With Icons +// --------------------------------------------------------------------------- + +export const WithIcons: Story = { + parameters: { + docs: { + description: { + story: + 'Buttons support `iconLeft` and `iconRight` props. Pass any `IconName` from the icon system.', + }, + }, + }, + render: () => ( +
+ + + +
+ ), +} diff --git a/frontend/documentation/components/ButtonDropdown.stories.tsx b/frontend/documentation/components/ButtonDropdown.stories.tsx new file mode 100644 index 000000000000..af48c266d4f9 --- /dev/null +++ b/frontend/documentation/components/ButtonDropdown.stories.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import ButtonDropdown from 'components/base/forms/ButtonDropdown' + +const meta: Meta = { + parameters: { layout: 'centered' }, + title: 'Components/Forms/ButtonDropdown', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + {} }, + { label: 'Duplicate', onClick: () => {} }, + { label: 'Delete', onClick: () => {} }, + ]} + > + Actions + + ), +} diff --git a/frontend/documentation/components/Card.stories.tsx b/frontend/documentation/components/Card.stories.tsx new file mode 100644 index 000000000000..590ec7d0223a --- /dev/null +++ b/frontend/documentation/components/Card.stories.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import Card from 'components/Card' + +const meta: Meta = { + parameters: { layout: 'padded' }, + title: 'Components/Layout/Card', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + +
Card title
+

Card content goes here.

+
+ ), +} + +export const Nested: Story = { + render: () => ( + +
Outer card
+ +

Nested card content.

+
+
+ ), +} diff --git a/frontend/documentation/components/Checkbox.stories.tsx b/frontend/documentation/components/Checkbox.stories.tsx new file mode 100644 index 000000000000..399eec44a2ff --- /dev/null +++ b/frontend/documentation/components/Checkbox.stories.tsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import Checkbox from 'components/base/forms/Checkbox' + +const meta: Meta = { + parameters: { layout: 'centered' }, + title: 'Components/Forms/Checkbox', +} +export default meta + +type Story = StoryObj + +const InteractiveCheckbox = () => { + const [checked, setChecked] = useState(false) + return ( + + ) +} + +export const Default: Story = { + render: () => , +} + +export const States: Story = { + render: () => ( +
+ {}} /> + {}} /> +
+ ), +} diff --git a/frontend/documentation/components/CheckboxGroup.stories.tsx b/frontend/documentation/components/CheckboxGroup.stories.tsx new file mode 100644 index 000000000000..c4f45c69f5ed --- /dev/null +++ b/frontend/documentation/components/CheckboxGroup.stories.tsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import CheckboxGroup from 'components/base/forms/CheckboxGroup' + +const meta: Meta = { + parameters: { layout: 'centered' }, + title: 'Components/Forms/CheckboxGroup', +} +export default meta + +type Story = StoryObj + +const items = [ + { label: 'Read', value: 'read' }, + { label: 'Write', value: 'write' }, + { label: 'Admin', value: 'admin' }, +] + +const Interactive = () => { + const [selected, setSelected] = useState(['read']) + return ( + + ) +} + +export const Default: Story = { + render: () => , +} + +export const AllSelected: Story = { + render: () => ( + {}} + /> + ), +} + +export const NoneSelected: Story = { + render: () => ( + {}} /> + ), +} diff --git a/frontend/documentation/components/ClearFilters.stories.tsx b/frontend/documentation/components/ClearFilters.stories.tsx new file mode 100644 index 000000000000..94c54fa0b481 --- /dev/null +++ b/frontend/documentation/components/ClearFilters.stories.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import ClearFilters from 'components/ClearFilters' + +const meta: Meta = { + parameters: { layout: 'centered' }, + title: 'Components/Data Display/ClearFilters', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => {}} />, +} diff --git a/frontend/documentation/components/Confidence.stories.tsx b/frontend/documentation/components/Confidence.stories.tsx new file mode 100644 index 000000000000..9ceacd160f3d --- /dev/null +++ b/frontend/documentation/components/Confidence.stories.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import Confidence from 'components/Confidence' + +const meta: Meta = { + parameters: { layout: 'centered' }, + title: 'Components/Data Display/Confidence', +} +export default meta + +type Story = StoryObj + +export const VeryHigh: Story = { + render: () => , +} + +export const High: Story = { + render: () => , +} + +export const Reasonable: Story = { + render: () => , +} + +export const Low: Story = { + render: () => , +} diff --git a/frontend/documentation/components/DocPage.tsx b/frontend/documentation/components/DocPage.tsx new file mode 100644 index 000000000000..6a50ffd68514 --- /dev/null +++ b/frontend/documentation/components/DocPage.tsx @@ -0,0 +1,17 @@ +import React from 'react' + +type DocPageProps = { + children: React.ReactNode + description: React.ReactNode + title: string +} + +const DocPage: React.FC = ({ children, description, title }) => ( +
+

{title}

+

{description}

+ {children} +
+) + +export default DocPage diff --git a/frontend/documentation/components/DropdownMenu.stories.tsx b/frontend/documentation/components/DropdownMenu.stories.tsx new file mode 100644 index 000000000000..eb6d28ce4f18 --- /dev/null +++ b/frontend/documentation/components/DropdownMenu.stories.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import DropdownMenu from 'components/base/DropdownMenu' + +const meta: Meta = { + parameters: { layout: 'centered' }, + title: 'Components/Data Display/DropdownMenu', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + {} }, + { label: 'Duplicate', onClick: () => {} }, + { label: 'Archive', onClick: () => {} }, + { label: 'Delete', onClick: () => {} }, + ]} + /> + ), +} diff --git a/frontend/documentation/components/EmptyState.stories.tsx b/frontend/documentation/components/EmptyState.stories.tsx new file mode 100644 index 000000000000..da53a8fc234b --- /dev/null +++ b/frontend/documentation/components/EmptyState.stories.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import EmptyState from 'components/EmptyState' +import Button from 'components/base/forms/Button' + +const meta: Meta = { + parameters: { layout: 'padded' }, + title: 'Components/Feedback/EmptyState', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + ), +} + +export const WithAction: Story = { + render: () => ( + Create segment} + /> + ), +} diff --git a/frontend/documentation/components/FormGroup.stories.tsx b/frontend/documentation/components/FormGroup.stories.tsx new file mode 100644 index 000000000000..05b719ec58e3 --- /dev/null +++ b/frontend/documentation/components/FormGroup.stories.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import type { Meta, StoryObj } from 'storybook' + +import FormGroup from 'components/base/grid/FormGroup' + +const meta: Meta = { + parameters: { layout: 'padded' }, + title: 'Components/Layout/FormGroup', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( +
+ + + + + + +