diff --git a/.github/workflows/frontend-chromatic.yml b/.github/workflows/frontend-chromatic.yml new file mode 100644 index 000000000000..8a2eb69fefa9 --- /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 --legacy-peer-deps + + - name: Publish to Chromatic + uses: chromaui/action@latest + with: + workingDir: frontend + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + exitZeroOnChanges: true + exitOnceUploaded: true + onlyChanged: true 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..d0a8677283a6 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/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 000000000000..2fc34036e3a1 --- /dev/null +++ b/frontend/.storybook/main.ts @@ -0,0 +1,54 @@ +import type { StorybookConfig } from '@storybook/react-webpack5' +import path from 'path' + +const config: StorybookConfig = { + stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ + '@storybook/addon-webpack5-compiler-swc', + '@storybook/addon-essentials', + '@storybook/addon-a11y', + '@storybook/addon-interactions', + ], + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, + swc: () => ({ + jsc: { + transform: { + react: { + runtime: 'automatic', + }, + }, + parser: { + syntax: 'typescript', + tsx: true, + }, + }, + }), + webpackFinal: async (config) => { + // Path aliases — match the project's webpack.config.js + 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'), + } + + // SCSS support + 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 } }, + 'sass-loader', + ], + }) + + return config + }, +} +export default config diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts new file mode 100644 index 000000000000..64e6c01f552b --- /dev/null +++ b/frontend/.storybook/preview.ts @@ -0,0 +1,50 @@ +import type { Preview } from '@storybook/react' + +// Import the project's global styles (includes tokens) +import '../web/styles/styles.scss' + +const preview: 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: 'light', + }, + decorators: [ + (Story, context) => { + const theme = context.globals.theme || 'light' + const isDark = theme === 'dark' + + // Mirror the project's setDarkMode() logic + 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 }, + }, +} + +export default preview diff --git a/frontend/DESIGN_SYSTEM_AUDIT.md b/frontend/DESIGN_SYSTEM_AUDIT.md new file mode 100644 index 000000000000..190201bef347 --- /dev/null +++ b/frontend/DESIGN_SYSTEM_AUDIT.md @@ -0,0 +1,652 @@ +# Flagsmith Frontend Design System Audit + +**Issue**: #6606 +**Date**: 2026-02-27 +**Scope**: Code-first audit of token misuse, dark mode gaps, and component fragmentation + +--- + +## Executive Summary + +This audit scanned the Flagsmith frontend codebase for design system inconsistencies across 7 areas (colours, typography, spacing, buttons, forms, icons, notifications) and catalogued 11 UI component categories. + +### Key findings + +| Severity | Count | Description | +|----------|-------|-------------| +| **P0** | 21 | Broken in dark mode or accessibility issue | +| **P1** | 34 | Visual inconsistency with the token system | +| **P2** | 30 | Token hygiene (hardcoded value that should use a variable) | + +### Top 5 fixes by impact + +1. **Icon.tsx default fills** — ~60 icons default to `#1A2634` (near-black), invisible in dark mode. Switching to `currentColor` would fix all at once. +2. **Feature pipeline status** — Entire release pipeline visualisation is broken in dark mode (white circles, grey lines on dark background). +3. **Chart components** — All 4 Recharts-based charts use hardcoded light-mode axis/grid colours. Need dark mode conditionals. +4. **Button variants missing dark mode** — `btn-tertiary`, `btn-danger`, and `btn--transparent` have no dark mode overrides. +5. **Toast notifications** — No dark mode styles at all. + +### Top 5 consolidation opportunities + +1. **Icons** — Refactor the monolithic `Icon.tsx` (70 inline SVGs), unify with 19 separate SVG components and `IonIcon` usage. +2. **Modals** — Migrate from global imperative API, consolidate 13+ near-identical confirmation modals. +3. **Notifications** — Remove 2 duplicate legacy `.js` message components, unify 4 alert variants into a single `Alert` component. +4. **Menus/Dropdowns** — Extract a shared dropdown primitive from 4 independent implementations. +5. **Layout** — Convert legacy JS class components (`Flex.js`, `Column.js`), remove Material UI dependency from `AccordionCard`. + +--- + +# Part A — Component Inventory + +## 1. Modals/Dialogs + +**Files**: 52 files in `web/components/modals/` + +**Base infrastructure** (6 files): +- `web/components/modals/base/Modal.tsx` — Core modal system: `openModal`, `openModal2`, `openConfirm` globals +- `web/components/modals/base/ModalDefault.tsx` — Standard modal wrapper using reactstrap `Modal` +- `web/components/modals/base/ModalConfirm.tsx` — Confirmation dialog (yes/no with danger variant) +- `web/components/modals/base/ModalAlert.tsx` — Simple alert modal with single OK button +- `web/components/modals/base/ModalHeader.tsx` — Custom header with close button +- `web/components/modals/base/ModalClose.tsx` — Close button component + +**Confirmation modals** (13 files): +`ConfirmCloneSegment`, `ConfirmDeleteAccount`, `ConfirmDeleteRole`, `ConfirmHideFlags`, `ConfirmRemoveAuditWebhook`, `ConfirmRemoveEnvironment`, `ConfirmRemoveFeature`, `ConfirmRemoveOrganisation`, `ConfirmRemoveProject`, `ConfirmRemoveSegment`, `ConfirmRemoveTrait`, `ConfirmRemoveWebhook`, `ConfirmToggleFeature`, `ConfirmToggleEnvFeature` + +**Creation/editing modals** (15+ files): +`CreateAuditLogWebhook`, `CreateEditIntegrationModal`, `CreateGroup`, `CreateMetadataField`, `CreateOrganisation`, `CreateProject.js`, `CreateRole`, `CreateSAML`, `CreateSegment`, `CreateTrait`, `CreateUser`, `CreateWebhook.js`, `CreateWebhook.tsx`, `ChangeEmailAddress`, `ChangeRequestModal`, `ForgotPasswordModal`, `InviteUsers`, `Payment.js` + +**Complex multi-tab modals** (2 subdirectories): +- `create-feature/` (7 files) +- `create-experiment/` (2 files) + +**Also**: `web/components/InlineModal.tsx` — separate inline (non-overlay) modal + +**Variant count**: 4 base modal types (ModalDefault, ModalConfirm, ModalAlert, InlineModal) + +**Pattern issues**: +- Modal system uses deprecated `react-dom` `render`/`unmountComponentAtNode` (removed in React 18) and attaches `openModal`/`closeModal` to `global` +- `openModal2` exists for stacking modals, acknowledged as a pattern to avoid +- Duplicate: `CreateWebhook.js` and `CreateWebhook.tsx` coexist +- `CreateProject.js` and `Payment.js` remain as unconverted JS class components +- 13+ confirmation modals each implement their own layout rather than composing from a shared template +- `InlineModal` has `displayName = 'Popover'`, which is misleading + +**Consolidation notes**: The 13+ confirmation modals follow nearly identical patterns (title, message, ModalHR, Cancel/Confirm buttons) and could be a single configurable `ConfirmModal`. The global `openModal`/`closeModal` imperative API should migrate to a React context-based modal manager. + +--- + +## 2. Menus/Dropdowns + +**Files**: +- `web/components/base/DropdownMenu.tsx` — Icon-triggered vertical "more" menu using portal positioning +- `web/components/base/forms/ButtonDropdown.tsx` — Split button with dropdown actions +- `web/components/base/Popover.tsx` — Toggle-based popover using `FocusMonitor` HOC +- `web/components/InlineModal.tsx` — Panel-style dropdown (used by TableFilter) + +**Variant count**: 4 distinct dropdown/overlay patterns + +**Pattern issues**: +- `DropdownMenu` and `ButtonDropdown` both implement their own outside-click handling, positioning logic, and dropdown rendering independently +- Both share CSS class `feature-action__list` / `feature-action__item` but are not composed from shared primitives +- `Popover` uses a different mechanism (`FocusMonitor` HOC) for state +- `InlineModal` functions as another dropdown variant but is named "Modal" + +**Consolidation notes**: A single base `Dropdown`/`Popover` primitive with portal support, outside-click handling, and positioning could replace all four. + +--- + +## 3. Selects + +**Files**: 28 files (2 base + 26 domain-specific) + +**Base**: `web/components/base/select/SearchableSelect.tsx`, `web/components/base/select/multi-select/MultiSelect.tsx` + +**Domain-specific** (26): `EnvironmentSelect`, `EnvironmentTagSelect`, `OrgEnvironmentSelect`, `OrganisationSelect.js`, `ProjectSelect.js`, `FlagSelect.js`, `SegmentSelect`, `GroupSelect`, `ConnectedGroupSelect`, `MyGroupsSelect`, `UserSelect`, `RolesSelect`, `MyRoleSelect`, `IntegrationSelect`, `DateSelect`, `ConversionEventSelect`, `IdentitySelect`, `GitHubRepositoriesSelect`, `GitHubResourcesSelect`, `MyRepositoriesSelect`, `RepositoriesSelect`, `ColourSelect`, `SupportedContentTypesSelect`, `SelectOrgAndProject`, `RuleConditionPropertySelect`, `EnvironmentSelectDropdown` + +**Pattern issues**: 3 remain as `.js` files. Potential overlap between `GroupSelect`/`ConnectedGroupSelect`/`MyGroupsSelect`. + +**Consolidation notes**: The large number of domain selects is reasonable since each encapsulates data fetching. Ensure all consistently use `SearchableSelect` or `MultiSelect` as their base. Convert `.js` files to TypeScript. + +--- + +## 4. Toasts/Notifications + +**Files**: + +Toast system: +- `web/project/toast.tsx` — Global toast with `success` and `danger` themes, attached to `window.toast` + +Inline alert components (6 files, 2 duplicated): +- `web/components/messages/ErrorMessage.tsx` — TypeScript FC version +- `web/components/messages/SuccessMessage.tsx` — TypeScript FC version +- `web/components/ErrorMessage.js` — Legacy class component duplicate +- `web/components/SuccessMessage.js` — Legacy class component duplicate +- `web/components/InfoMessage.tsx` — Info alert with collapsible content +- `web/components/WarningMessage.tsx` — Warning alert + +**Variant count**: 1 toast system (2 themes) + 4 inline alert variants + +**Pattern issues**: +- `ErrorMessage` and `SuccessMessage` each exist in two versions (legacy `.js` + modern `.tsx`) +- Toast only supports `success` and `danger` — no `info` or `warning` toast themes +- Toast is attached to `window` as a global function + +**Consolidation notes**: Remove legacy `.js` duplicates. Unify all inline alerts into a single `Alert` component with a `variant` prop. Add `info`/`warning` themes to the toast system. + +--- + +## 5. Tables & Filters + +**Files**: 1 core table + 9 filter components + +**Core**: `web/components/PanelSearch.tsx` — Searchable, paginated, sortable list using `react-virtualized` + +**Filters** (in `web/components/tables/`): +`TableFilter`, `TableFilterItem`, `TableFilterOptions`, `TableGroupsFilter`, `TableOwnerFilter`, `TableSearchFilter`, `TableSortFilter`, `TableTagFilter`, `TableValueFilter` + +**Pattern issues**: `PanelSearch` is monolithic (20+ props). Uses `react-virtualized` (older library). + +**Consolidation notes**: Filter components are well-structured around `TableFilter` base. Consider decomposing `PanelSearch` into smaller composable pieces. + +--- + +## 6. Tabs + +**Files**: +- `web/components/navigation/TabMenu/Tabs.tsx` — Main container with `tab` and `pill` themes, URL param sync, overflow handling +- `web/components/navigation/TabMenu/TabItem.tsx` — Tab content wrapper +- `web/components/navigation/TabMenu/TabButton.tsx` — Tab button using `Button` component + +**Variant count**: 1 implementation with 2 themes + +**Pattern issues**: Well-consolidated. Minor prop bloat (13 props including feature-specific `isRoles`). + +--- + +## 7. Buttons + +**Files**: `web/components/base/forms/Button.tsx` + +**Themes** (9): `primary`, `secondary`, `tertiary`, `danger`, `success`, `text`, `outline`, `icon`, `project` + +**Sizes** (5): `default`, `large`, `small`, `xSmall`, `xxSmall` + +**Pattern issues**: Doubles as a link when `href` is provided. Plan-gating (`feature` prop) is baked in rather than being a wrapper. + +**Consolidation notes**: Clean system overall. Consider extracting link behaviour and plan-gating concern. + +--- + +## 8. Icons + +**Files**: +- `web/components/Icon.tsx` — 70 inline SVG icons in a switch statement +- `web/components/svg/` — 19 standalone SVG components: `ArrowUpIcon`, `AuditLogIcon`, `CaretDownIcon`, `CaretRightIcon`, `DocumentationIcon`, `DropIcon`, `EnvironmentSettingsIcon`, `FeaturesIcon`, `LogoutIcon`, `NavIconSmall`, `OrgSettingsIcon`, `PlayIcon`, `PlusIcon`, `ProjectSettingsIcon`, `SegmentsIcon`, `SparklesIcon`, `UpgradeIcon`, `UserSettingsIcon`, `UsersIcon` + +**Also**: `@ionic/react` `IonIcon` used in `InfoMessage`, `SuccessMessage`, `AccordionCard` — a third icon system. + +**Variant count**: 70 inline + 19 SVG files + IonIcon = 3 separate icon systems, ~89 total icons + +**Pattern issues**: +- `Icon.tsx` is extremely large because every icon is an inline SVG in a switch +- The 19 `svg/` components are completely separate, not accessible via `Icon.tsx` +- `IonIcon` adds a third, heavy dependency for just a few icons + +**Consolidation notes**: Highest priority. Refactor `Icon.tsx` to import individual SVG files. Integrate `svg/` components. Migrate `IonIcon` usage. Consider SVG sprites or individual imports for tree-shaking. + +--- + +## 9. Empty States + +**Files**: +- `web/components/EmptyState.tsx` — Generic empty state with icon, title, description, docs link, action +- `web/components/pages/features/components/FeaturesEmptyState.tsx` — Specialised onboarding variant + +**Variant count**: 2 (1 generic + 1 specialised) + +**Pattern issues**: `FeaturesEmptyState` does not use the generic `EmptyState`. Reasonable separation since it serves a different purpose (onboarding walkthrough). + +--- + +## 10. Tooltips + +**Files**: +- `web/components/Tooltip.tsx` — Main tooltip using `react-tooltip`, with HTML sanitisation and portal support +- `web/components/base/LabelWithTooltip.tsx` — Convenience wrapper: label + info icon tooltip + +**Variant count**: 2 (1 core + 1 convenience wrapper) + +**Pattern issues**: +- Inverted API: `title` is the trigger element and `children` is the tooltip content +- `children` rendered via `dangerouslySetInnerHTML` (sanitised with DOMPurify) +- New `id` (GUID) generated on every render +- `TooltipPortal` creates DOM nodes but never removes them (memory leak) + +--- + +## 11. Layout + +**Files**: + +Grid primitives in `web/components/base/grid/`: +- `Panel.tsx` — Panel with optional title/action header (class component) +- `FormGroup.tsx` — Simple `.form-group` wrapper +- `Row.tsx` — Flex row with `space` and `noWrap` props +- `Flex.js` — Legacy flex wrapper (class component, `module.exports`) +- `Column.js` — Legacy flex-column wrapper (class component, `module.exports`) + +Composite: +- `web/components/PanelSearch.tsx` — Searchable panel/list +- `web/components/base/accordion/AccordionCard.tsx` — Collapsible card using Material UI's `Collapse` and `IconButton` + +**Pattern issues**: +- `Panel` is a class component (`PureComponent`) +- `Flex.js` and `Column.js` are the oldest-style components (class, `module.exports`, `propTypes`, globals) +- `AccordionCard` depends on `@material-ui/core` — the only place Material UI appears, adding a heavy dependency + +**Consolidation notes**: Convert `Panel`, `Flex.js`, `Column.js` to TypeScript FCs. Replace Material UI in `AccordionCard` with CSS transition or `
`/``. Consider removing `Flex`/`Column` given modern CSS utility classes. + +--- + +# Part B — Token & Consistency Findings + +## 1. Colours + +### P0 — Broken Dark Mode / Accessibility + +**1.1 Icon.tsx — ~60 icons default to `#1A2634` fill, invisible in dark mode (CRITICAL)** +- `web/components/Icon.tsx` — lines 225, 482, 559, 578, 597, 616, 636, 655, 674, 693, 712, 731, 751, 771, 790, 809, 828, 867, 888, 908, 928, 1011, 1031, 1050, 1069, 1089, 1108, 1127, 1146, 1165, 1185, 1205, 1224, 1244, 1251, 1258, 1278, 1328, 1345, 1365, 1406 +- Pattern: `fill={fill || '#1A2634'}` — dark navy fill is invisible on dark backgrounds unless every caller passes an explicit fill +- **Fix**: Switch default to `currentColor` or add `getDarkMode()` awareness + +**1.2 Icon.tsx — 3 icons hardcode `#163251`, an orphan colour** +- `web/components/Icon.tsx:237,245,253` — `fill || '#163251'` — not in any token + +**1.3 Icon.tsx `expand` icon defaults to `fill='#000000'`** +- `web/components/Icon.tsx:1502` — black fill invisible on dark backgrounds + +**1.4 Feature pipeline status — entire component has no `.dark` override** +- `web/styles/components/_feature-pipeline-status.scss:2,4,16,20,30,39,43,52` +- Hardcoded: `white` (bg), `#AAA` (border/bg), `#6837fc` (border), `#53af41` (border/bg) +- White circles and grey lines invisible on dark backgrounds + +**1.5 FeaturesPage action item — hardcoded dark text** +- `web/styles/project/_FeaturesPage.scss:42` — `color: #2d3443` +- Nearly invisible on dark backgrounds. Value doesn't match `$body-color` (`#1a2634`) + +**1.6 Striped section — no dark mode override** +- `web/styles/styles.scss:66` — `background-color: #f7f7f7` +- Should use `$bg-light200`; light grey will clash with dark body + +**1.7 Alert bar — orphan colour, no dark override** +- `web/styles/styles.scss:172,175` — `color: #fff`, `background-color: #384f68` +- `#384f68` not in any token definition + +**1.8 Modal tab dropdown hover — wrong purple, no dark override** +- `web/styles/project/_modals.scss:294` — `background-color: #6610f210 !important` +- `#6610f2` does not match `$primary` (`#6837fc`); non-standard 8-digit hex + +**1.9 BooleanDotIndicator — orphan disabled colour** +- `web/components/BooleanDotIndicator.tsx:4` — `enabled ? '#6837fc' : '#dbdcdf'` +- `#dbdcdf` is an orphan; disabled dot barely visible in dark mode + +**1.10 DateSelect — dark fill invisible** +- `web/components/DateSelect.tsx:136` — `fill={isOpen ? '#1A2634' : '#9DA4AE'}` +- `#1A2634` invisible on dark backgrounds + +### P1 — Visual Inconsistency + +**1.11 Charts use hardcoded light-mode colours (4 files)** +- `web/components/organisation-settings/usage/OrganisationUsage.container.tsx:44-63` +- `web/components/organisation-settings/usage/components/SingleSDKLabelsChart.tsx:42-62` +- `web/components/feature-page/FeatureNavTab/FeatureAnalytics.tsx:101-107` +- `web/components/modals/create-experiment/ExperimentResultsTab.tsx:81-129` +- Hardcoded: `stroke='#EFF1F4'` (grid), `fill: '#656D7B'` (tick), `fill: '#1A2634'` (tick) +- All charts render light-mode colours on dark backgrounds + +**1.12 `#53af41` orphan green used across release pipeline** +- `web/components/release-pipelines/StageStatus.tsx:91` +- `web/components/release-pipelines/StageSummaryData.tsx:57,79` +- `web/styles/components/_feature-pipeline-status.scss:20,39,52` +- Not in token palette; closest is `$success` (`#27ab95`) + +**1.13 Unread badge — off-brand primary** +- `web/styles/project/_utils.scss:155` — `background: #7b51fb` +- Should use `$primary` (`#6837fc`) + +**1.14 Button remove hover — inconsistent red** +- `web/styles/project/_buttons.scss:183` — `fill: #d93939` +- Neither `$danger` nor `$btn-danger-hover`; a third red shade + +**1.15 Panel change-request — raw rgba with wrong base colour** +- `web/styles/components/_panel.scss:225,228-229,233,240` +- `rgba(102, 51, 255, 0.08)` uses `#6633ff` not `#6837fc`; should use `$primary-alfa-8` + +**1.16 ArrowUpIcon / DocumentationIcon / Logo — off-brand purple** +- `web/components/svg/ArrowUpIcon.tsx:18`, `web/components/svg/DocumentationIcon.tsx:23`, `web/components/Logo.tsx:20` +- `fill='#63f'` = `#6633ff`, not `$primary` (`#6837fc`) + +**1.17 `#53af41` orphan green in CreatePipelineStage** +- `web/components/release-pipelines/CreatePipelineStage.tsx:180,184,187` +- Hardcoded danger colour with inline box-shadow + +**1.18 VCSProviderTag — orphan dark colour** +- `web/components/tags/VCSProviderTag.tsx:47` — `backgroundColor: isWarning ? '#ff9f43' : '#343a40'` +- `#343a40` not in any token + +**1.19 PanelSearch — inline style colours bypass dark mode** +- `web/components/PanelSearch.tsx:225,230` — `style={{ color: isActive ? '#6837FC' : '#656d7b' }}` +- Should use CSS classes with `.dark` support + +**1.20 FeaturesPage hover uses full `$primary` in dark mode** +- `web/styles/project/_FeaturesPage.scss:72` — `.dark .feature-action__item:hover` uses `background: $primary` +- Light mode uses `$bg-light200`; dark mode is jarring/oversaturated + +**1.21 Step list — orphan colour** +- `web/styles/project/_lists.scss:9,14,21` — `#2e2e2e` (border and background) +- Not in the token palette + +### P2 — Token Hygiene + +**1.22 ~35 TSX files hardcode `fill='#9DA4AE'` / `fill='#656D7B'` / `fill='#6837FC'`** +- Values match tokens (`$text-icon-light-grey`, `$text-icon-grey`, `$primary`) but bypass the token system +- If token values change, these will not update +- Files include: `ChangeRequestsList`, `SegmentOverrideActions`, `TopNavbar`, `AccountDropdown`, `SDKKeysPage`, `RolesTable`, `SamlTab`, `GroupSelect`, `UserGroupList`, and many more + +**1.23 Admin dashboard tables — ~20 orphan colours in inline styles** +- `web/components/pages/admin-dashboard/components/OrganisationUsageTable.tsx` +- `web/components/pages/admin-dashboard/components/ReleasePipelineStatsTable.tsx` +- `web/components/pages/admin-dashboard/components/IntegrationAdoptionTable.tsx` +- Colours: `#f8f9fa`, `#fafbfc`, `#eee`, `#e74c3c`, `#e9ecef`, `#e0e0e0`, `#f4f5f7` + +**1.24 Language colour blocks — intentional but undocumented** +- `web/styles/components/_color-block.scss:21-36` — GitHub language colours (`#3572A5`, `#f1e05a`, etc.) +- Should be extracted to named variables + +**1.25 GitHub icon colours — brand colours** +- `web/components/Icon.tsx:1417-1488` — `#8957e5`, `#238636`, `#da3633` +- Likely intentional but should be named constants + +**1.26 SidebarLink — orphan muted colour** +- `web/components/navigation/SidebarLink.tsx:42` — `fill={'#767D85'}` +- Closest token is `$text-icon-grey` (`#656D7B`) + +**1.27 CreateFeature tab — orphan purple** +- `web/components/modals/create-feature/tabs/CreateFeature.tsx:107` — `color: '#6A52CF'` + +--- + +## 2. Typography + +### Systemic Issues + +**Missing general-purpose font-weight tokens**: The token system only defines weight variables scoped to components (`$btn-font-weight: 700`, `$input-font-weight: 500`). No general tokens like `$font-weight-regular: 400`, `$font-weight-medium: 500`, `$font-weight-semibold: 600`, `$font-weight-bold: 700`. + +**Off-scale values indicate design drift**: Values like 9px, 10px, 15px, 17px, 19px, 20px, 22px, 34px appear but have no corresponding token. + +### P0 + +**2.1 FeaturesPage — hardcoded dark text colour** +- `web/styles/project/_FeaturesPage.scss:42` — `color: #2d3443` with no adequate dark mode override +- (Also listed under Colours 1.5) + +**2.2 Dark mode text colour gaps in inline styles** +- `web/components/pages/admin-dashboard/components/OrganisationUsageTable.tsx:21` — `color: '#e74c3c'` +- `web/components/pages/admin-dashboard/components/ReleasePipelineStatsTable.tsx:404` — `color: '#27AB95'` +- `web/components/tags/TagFilter.tsx:45` — `color: '#656D7B'` +- `web/components/navigation/AccountDropdown.tsx:80` — `color: '#656D7B'` +- Inline colours cannot respond to dark mode + +### P1 — Hardcoded Font-Size Off-Scale + +| File | Line | Value | Notes | +|------|------|-------|-------| +| `web/styles/3rdParty/_hljs.scss` | 100 | `font-size: 17px` | Between `$h6` (16px) and `$h5` (18px) | +| `web/styles/3rdParty/_hw-badge.scss` | 13 | `font-size: 9px` | Below `$font-caption-xs` (11px) | +| `web/styles/project/_utils.scss` | 164 | `font-size: 10px` | Below scale | +| `web/styles/project/_alert.scss` | 52 | `font-size: 20px` | Between `$h5` (18px) and `$h4` (24px) | +| `web/styles/project/_icons.scss` | 9 | `font-size: 19px` | Between `$h5` (18px) and `$h4` (24px) | +| `web/components/pages/admin-dashboard/components/ReleasePipelineStatsTable.tsx` | 282 | `fontSize: 10` | Below scale | + +### P1 — Hardcoded Line-Height Off-Scale + +| File | Line | Value | Notes | +|------|------|-------|-------| +| `web/styles/components/_input.scss` | 71 | `line-height: 22px` | Between `$line-height-sm` (20px) and `$line-height-lg` (24px) | +| `web/styles/components/_paging.scss` | 14 | `line-height: 34px` | Above `$line-height-xlg` (28px) | +| `web/styles/components/_tabs.scss` | 167 | `line-height: 15px` | Below `$line-height-xxsm` (16px) | +| `web/styles/project/_utils.scss` | 163 | `line-height: 14px` | Below scale | + +### P1 — Inline Font Weight 600 (no token exists) + +| File | Line | Value | +|------|------|-------| +| `web/components/pages/admin-dashboard/components/OrganisationUsageTable.tsx` | 21, 27 | `fontWeight: 600` | +| `web/components/pages/admin-dashboard/components/ReleasePipelineStatsTable.tsx` | 283 | `fontWeight: 600` | +| `web/components/navigation/AccountDropdown.tsx` | 83 | `fontWeight: 600` | + +### P2 — Hardcoded Font-Size On-Scale + +| File | Line | Value | Token | +|------|------|-------|-------| +| `web/styles/styles.scss` | 185 | `font-size: 30px` | `$h3-font-size` | +| `web/styles/styles.scss` | 191 | `font-size: 14px` | `$font-size-base` | +| `web/styles/styles.scss` | 193 | `font-size: 11px` | `$font-caption-xs` | +| `web/styles/components/_switch.scss` | 79 | `font-size: 14px` | `$font-size-base` | +| `web/styles/components/_metrics.scss` | 15 | `font-size: 16px` | `$h6-font-size` | +| `web/styles/project/_type.scss` | 104 | `font-size: 0.75rem` | `$font-caption-sm` (12px) | + +### P2 — Admin Dashboard Inline fontSize Hotspot + +The files under `web/components/pages/admin-dashboard/components/` contain over 50 inline `fontSize` overrides across `OrganisationUsageTable.tsx`, `ReleasePipelineStatsTable.tsx`, and `IntegrationAdoptionTable.tsx`. These bypass both the SCSS token system and dark mode theming. This is the single largest area of typography token misuse. + +--- + +## 3. Spacing + +### P1 — Off-Grid Spacing Values + +| File | Line | Value | Issue | +|------|------|-------|-------| +| `web/styles/project/_buttons.scss` | 149 | `padding: 19px 0 18px 0` | Both off-grid; asymmetric by 1px | +| `web/styles/styles.scss` | 173 | `padding: 15px` | Off-grid; should be 16px (`$spacer`) | +| `web/styles/styles.scss` | 206 | `padding: 10px 10px 5px 0` | 10px and 5px both off-grid | +| `web/styles/components/_chip.scss` | 42 | `padding: 5px 12px` | 5px off-grid (chip is heavily reused) | +| `web/styles/components/_chip.scss` | 84 | `padding: 3px 8px` | 3px off-grid | +| `web/styles/components/_chip.scss` | 85, 89, 101, 113 | `margin-right: 5px` | 5px off-grid (repeated 4 times) | +| `web/styles/project/_FeaturesPage.scss` | 11, 24 | `margin-top: 6px`, `margin-bottom: 6px` | 6px off-grid | +| `web/styles/components/_metrics.scss` | 11 | `margin-bottom: 6px` | 6px off-grid | +| `web/styles/project/_forms.scss` | 149 | `margin-bottom: 6px` | 6px off-grid | +| `web/styles/project/_alert.scss` | 4, 7, 14 | `margin-top: 60px, 130px, 70px` | Layout offsets coupled to fixed header height | + +### P1 — Inconsistent Spacing Across Similar Components + +**Chip margin-right inconsistency**: +- `.chip`: `margin-right: 0.5rem` (8px) — good +- `.chip--sm`: `margin-right: 5px` — off-grid +- `.chip--xs`: `margin-right: 5px` — off-grid +- `.chip .margin-right`: `12px` — different value entirely + +### P2 — On-Grid But Hardcoded + +| File | Line | Value | Notes | +|------|------|-------|-------| +| `web/styles/project/_panel.scss` | 43 | `padding: 20px` | Should use `$spacer * 1.25` | +| `web/styles/project/_modals.scss` | 106 | `padding: 20px` | Inconsistent with `$modal-body-padding-y` (24px) | +| `web/styles/project/_utils.scss` | 78, 82 | `margin-top: 20px`, `margin-bottom: 20px` | Custom utility duplicating Bootstrap `.mt-4`/`.mb-4` | +| `web/styles/project/_utils.scss` | 175 | `padding: 10px` | Off-grid | +| `web/styles/styles.scss` | 161 | `margin-left: 10px` | Off-grid | +| `web/styles/3rdParty/_hljs.scss` | 184, 186 | `padding: 7px 12px`, `margin-right: 5px` | Off-grid | +| `web/styles/components/_switch.scss` | 80 | `padding-left: 10px` | Off-grid | + +### Systemic Patterns + +- **5px is the most common off-grid offender** (10+ occurrences). Bulk replace to 4px would be highest-impact single change. +- **12px is an unofficial spacing token** used 5+ times for `margin-right`. Should be formalised as `$spacer * 0.75`. +- **6px appears as a small vertical spacer** in 3+ files. Standardise to 4px or 8px. + +--- + +## 4. Button Styles + +### P0 + +**4.1 `btn--transparent` hover — no dark mode override** +- `web/styles/project/_buttons.scss:155` — hover uses `rgba(0,0,0,0.1)`, nearly invisible on dark backgrounds + +**4.2 `btn-tertiary` — no dark mode override** +- `web/styles/project/_buttons.scss:113-127` — gold/yellow variant with `$primary900` text. Not overridden in `.dark` block (lines 432-563). May be unreadable on dark backgrounds. + +**4.3 `btn-danger` — dark hover resolves to primary colour** +- `web/styles/project/_buttons.scss:32-43` — `.dark` block does not include `btn-danger` override. Generic `.dark .btn:hover` (line 436) overwrites danger-specific hover with primary colour. + +### P1 + +**4.4 `btn--outline` hardcodes `background: white`** +- `web/styles/project/_buttons.scss:59` — should be `$bg-light100` or `$input-bg` + +**4.5 `btn-link .dark-link` — invisible in dark mode** +- `web/styles/project/_buttons.scss:379` — `.dark-link` sets `color: $body-color` (dark text), no dark override + +### P2 + +**4.6 `btn--remove` hover — orphan red `#d93939`** +- `web/styles/project/_buttons.scss:183` — not a design token + +**4.7 Excessive `!important` in button styles** +- `web/styles/project/_buttons.scss:60,86-87,100-101,104,109,114,201,209,228,238,252-257` +- At least 15 `!important` declarations creating specificity debt + +**4.8 Inline style overrides on Button components in TSX** +- Multiple TSX files apply `style={{...}}` directly on ` + + + ))} + + ), +} diff --git a/frontend/stories/ColourPalette.stories.tsx b/frontend/stories/ColourPalette.stories.tsx new file mode 100644 index 000000000000..53a197bc7392 --- /dev/null +++ b/frontend/stories/ColourPalette.stories.tsx @@ -0,0 +1,1175 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' + +const meta: Meta = { + parameters: { layout: 'padded' }, + title: 'Design System/Colours/Palette Audit', +} +export default meta + +// --------------------------------------------------------------------------- +// Shared primitives +// --------------------------------------------------------------------------- + +const fontStack = "'OpenSans', sans-serif" + +function getBadgeForLabel( + label: string, +): { colour: string; text: string } | undefined { + if (label === '$primary400') { + return { colour: '#ef4d56', text: 'LIGHTER than base' } + } + if (label === '$primary (base)') { + return { colour: '#27ab95', text: 'Base' } + } + return undefined +} + +function getBadgeForSlot( + label: string, +): { colour: string; text: string } | undefined { + if (label === 'primary-500') { + return { colour: '#27ab95', text: 'Existing $primary' } + } + if (label === 'primary-400') { + return { colour: '#6837fc', text: 'Correct 400 slot' } + } + return undefined +} + +const Swatch: React.FC<{ + hex: string + label: string + sublabel?: string + width?: number + height?: number + badge?: { text: string; colour: string } +}> = ({ badge, height = 56, hex, label, sublabel, width = 56 }) => ( +
+
+
+ {badge && ( +
+ {badge.text} +
+ )} +
+
+
+ {label} +
+ {sublabel && ( +
+ {sublabel} +
+ )} +
+
+) + +const SwatchRow: React.FC<{ swatches: React.ReactNode; label?: string }> = ({ + label, + swatches, +}) => ( +
+ {label && ( +
+ {label} +
+ )} +
+ {swatches} +
+
+) + +const IssueCallout: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => ( +
+ {children} +
+) + +const SectionHeading: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => ( +

+ {children} +

+) + +// --------------------------------------------------------------------------- +// Story 1: TonalScaleInconsistency +// --------------------------------------------------------------------------- + +export const TonalScaleInconsistency: StoryObj = { + render: () => { + const actualScale = [ + { hex: '#906af6', label: '$primary400', note: 'Lighter' }, + { hex: '#6837fc', label: '$primary (base)', note: 'Base' }, + { hex: '#4e25db', label: '$primary600', note: 'Darker' }, + { hex: '#3919b7', label: '$primary700', note: 'Darker' }, + { hex: '#2a2054', label: '$primary800', note: 'Darker' }, + { hex: '#1e0d26', label: '$primary900', note: 'Darkest' }, + ] + + // What a correct 50–900 scale would look like (illustrative, HSL-derived from #6837fc) + const correctScale = [ + { hex: '#f0ebff', label: 'primary-50', note: 'Lightest' }, + { hex: '#ddd0ff', label: 'primary-100', note: '' }, + { hex: '#c4aaff', label: 'primary-200', note: '' }, + { hex: '#a882ff', label: 'primary-300', note: '' }, + { + hex: '#8b5cff', + label: 'primary-400', + note: 'Should be darker than 300', + }, + { hex: '#6837fc', label: 'primary-500', note: 'Base (current $primary)' }, + { hex: '#4e25db', label: 'primary-600', note: 'Matches $primary600' }, + { hex: '#3919b7', label: 'primary-700', note: 'Matches $primary700' }, + { hex: '#2a2054', label: 'primary-800', note: 'Matches $primary800' }, + { hex: '#1e0d26', label: 'primary-900', note: 'Matches $primary900' }, + ] + + return ( +
+

+ Tonal Scale Inconsistency +

+

+ The convention for numbered colour scales is: higher numbers = darker + shades. The current primary scale violates this at{' '} + $primary400, which is lighter than the + unnumbered $primary base. There is also no{' '} + $primary100, $primary200, or{' '} + $primary300 — the scale jumps straight from 400 to the + base, with no low-numbered light stops defined at all. +

+ + + Issue: $primary400 (#906AF6) is{' '} + lighter than $primary (#6837FC). In a + conventional 50–900 scale, 400 should sit below the 500-equivalent + base, not above it. This makes the scale non-intuitive and + error-prone: a developer reaching for a lighter variant correctly + picks 400 and gets the wrong result. + + + + Current scale — as defined in _variables.scss + + ( + + ))} + /> + +
+ Note: $primary-faded (#e2d4fe) is referenced in component + code but is not defined in _variables.scss. It + appears as an orphan hex. It would naturally belong at the primary-100 + or primary-50 level. +
+ + + What a correct 50–900 scale would look like + +

+ Illustrative target — HSL-interpolated from the existing base + (#6837FC). The 600–900 range already matches existing variables; the + gap is the 50–400 range. +

+ ( + + ))} + /> +
+ ) + }, +} + +// --------------------------------------------------------------------------- +// Story 2: AlphaColourMismatches +// --------------------------------------------------------------------------- + +export const AlphaColourMismatches: StoryObj = { + render: () => { + type AlphaEntry = { + name: string + solidHex: string + solidRgb: string + alphaBaseRgb: string + alphaBaseHex: string + alphaVars: Array<{ label: string; actual: string; correct: string }> + } + + const entries: AlphaEntry[] = [ + { + alphaBaseHex: '#956cff', + alphaBaseRgb: '149, 108, 255', + alphaVars: [ + { + actual: 'rgba(149,108,255,0.08)', + correct: 'rgba(104,55,252,0.08)', + label: 'alfa-8', + }, + { + actual: 'rgba(149,108,255,0.16)', + correct: 'rgba(104,55,252,0.16)', + label: 'alfa-16', + }, + { + actual: 'rgba(149,108,255,0.24)', + correct: 'rgba(104,55,252,0.24)', + label: 'alfa-24', + }, + { + actual: 'rgba(149,108,255,0.32)', + correct: 'rgba(104,55,252,0.32)', + label: 'alfa-32', + }, + ], + name: 'Primary', + solidHex: '#6837fc', + solidRgb: '104, 55, 252', + }, + { + alphaBaseHex: '#ff424b', + alphaBaseRgb: '255, 66, 75', + alphaVars: [ + { + actual: 'rgba(255,66,75,0.08)', + correct: 'rgba(239,77,86,0.08)', + label: 'alfa-8', + }, + { + actual: 'rgba(255,66,75,0.16)', + correct: 'rgba(239,77,86,0.16)', + label: 'alfa-16', + }, + ], + name: 'Danger', + solidHex: '#ef4d56', + solidRgb: '239, 77, 86', + }, + { + alphaBaseHex: '#ff9f00', + alphaBaseRgb: '255, 159, 0', + alphaVars: [ + { + actual: 'rgba(255,159,0,0.08)', + correct: 'rgba(255,159,67,0.08)', + label: 'alfa-8', + }, + ], + name: 'Warning', + solidHex: '#ff9f43', + solidRgb: '255, 159, 67', + }, + ] + + return ( +
+

+ Alpha Colour Mismatches +

+

+ Alpha variants (e.g. $primary-alfa-8) should be derived + from the same RGB values as their solid counterpart. In this codebase, + all three checked colours use adifferent RGB base for their + alpha variants. This causes the alpha overlays to tint differently + from the solid colour they are meant to complement. +

+ + + Issue: $primary-alfa-* uses RGB (149, + 108, 255) — which corresponds to $primary400 (#956CFF), + not $primary (#6837FC / 104, 55, 252). The danger and + warning alphas similarly derive from undocumented RGB values that do + not match their solid variables. + + + {entries.map((entry) => ( +
+ {entry.name} + +
+
+
+ Solid token +
+ +
+ +
+
+ Alpha base actually used +
+ +
+
+ +
+ Alpha variants: actual (left) vs correct (right) +
+ +
+ {entry.alphaVars.map((v) => ( +
+
+ -{v.label} +
+
+
+
+
+ ACTUAL +
+
+
+ vs +
+
+
+
+ CORRECT +
+
+
+ + {v.actual} + +
+ ))} +
+
+ ))} +
+ ) + }, +} + +// --------------------------------------------------------------------------- +// Story 3: OrphanHexValues +// --------------------------------------------------------------------------- + +export const OrphanHexValues: StoryObj = { + render: () => { + type OrphanEntry = { + hex: string + uses: number + description: string + shouldMapTo: string | null + } + + const orphans: OrphanEntry[] = [ + { + description: 'Mid grey — most used orphan', + hex: '#9DA4AE', + shouldMapTo: + '$text-icon-light-grey (rgba(157,164,174,1) ≈ same colour)', + uses: 52, + }, + { + description: 'Red — not using $danger', + hex: '#e74c3c', + shouldMapTo: '$danger (#ef4d56)', + uses: 8, + }, + { + description: 'Green — not using $success', + hex: '#53af41', + shouldMapTo: '$success (#27ab95)', + uses: 5, + }, + { + description: 'Grey', + hex: '#767d85', + shouldMapTo: 'No exact match — nearest $text-icon-grey (#656d7b)', + uses: 7, + }, + { + description: 'Grey-blue', + hex: '#5D6D7E', + shouldMapTo: 'No exact match — nearest $text-icon-grey (#656d7b)', + uses: 4, + }, + { + description: 'Dark grey', + hex: '#343a40', + shouldMapTo: 'No exact match — nearest $body-color (#1a2634)', + uses: 6, + }, + { + description: 'GitHub purple', + hex: '#8957e5', + shouldMapTo: 'No variable — third-party brand colour', + uses: 3, + }, + { + description: 'GitHub green', + hex: '#238636', + shouldMapTo: 'No variable — third-party brand colour', + uses: 3, + }, + { + description: 'GitHub red', + hex: '#da3633', + shouldMapTo: 'No variable — third-party brand colour', + uses: 3, + }, + { + description: 'Navy', + hex: '#1c2840', + shouldMapTo: 'No exact match — nearest $bg-dark500 (#101628)', + uses: 2, + }, + ] + + const isThirdParty = (entry: OrphanEntry) => + entry.shouldMapTo?.startsWith('No variable — third-party') ?? false + const hasNearMatch = (entry: OrphanEntry) => + !isThirdParty(entry) && + (entry.shouldMapTo?.startsWith('No exact') ?? false) + const hasExactMap = (entry: OrphanEntry) => + !isThirdParty(entry) && !hasNearMatch(entry) + + const tagColour = (entry: OrphanEntry) => { + if (isThirdParty(entry)) return '#0aaddf' + if (hasNearMatch(entry)) return '#ff9f43' + return '#ef4d56' + } + + const tagLabel = (entry: OrphanEntry) => { + if (isThirdParty(entry)) return '3rd party' + if (hasNearMatch(entry)) return 'near match' + return 'has variable' + } + + return ( +
+

+ Orphan Hex Values +

+

+ These hex values appear directly in component files — not referencing + any SCSS variable or design token. They bypass the design system and + will not respond to theme changes or future token migrations. The + usage counts are approximate from a codebase grep. +

+ + + Issue: #9DA4AE appears in 52 places + across the codebase. It is visually equivalent to{' '} + $text-icon-light-grey (rgba(157,164,174,1)) but is + written as a raw hex. Any change to the grey text colour will miss + these 52 instances. Similarly, #e74c3c and{' '} + #53af41 duplicate existing semantic variables ( + $danger and $success) without referencing + them. + + +
+ Legend: + {[ + { colour: '#ef4d56', label: 'Has a variable — should be replaced' }, + { colour: '#ff9f43', label: 'Near match — needs review' }, + { colour: '#0aaddf', label: 'Third-party brand colour' }, + ].map(({ colour, label }) => ( + + + {label} + + ))} +
+ +
+ {orphans.map((entry) => ( +
+
+
+
+ + {entry.hex} + + + {tagLabel(entry)} + +
+
+ {entry.description} +
+
+ {entry.shouldMapTo ?? 'No match'} +
+
+ {entry.uses} uses in codebase +
+
+
+ ))} +
+
+ ) + }, +} + +// --------------------------------------------------------------------------- +// Story 4: GreyScaleGaps +// --------------------------------------------------------------------------- + +export const GreyScaleGaps: StoryObj = { + render: () => { + type GreyEntry = { + variable: string + hex: string + role: string + } + + const currentGreys: GreyEntry[] = [ + { hex: '#656d7b', role: 'Body / icon text', variable: '$text-icon-grey' }, + { + hex: 'rgba(157,164,174,1)', + role: 'Muted / placeholder text', + variable: '$text-icon-light-grey', + }, + { + hex: '#ffffff', + role: 'Page background (light)', + variable: '$bg-light100', + }, + { hex: '#fafafb', role: 'Panel background', variable: '$bg-light200' }, + { + hex: '#eff1f4', + role: 'Subtle surface / pills', + variable: '$bg-light300', + }, + { + hex: '#e0e3e9', + role: 'Border / divider surface', + variable: '$bg-light500', + }, + { + hex: '#e7e7e7', + role: 'Footer — no exact variable', + variable: '$footer-grey (approx)', + }, + { + hex: '#2d3443', + role: 'Dark surface (dark mode only)', + variable: '$bg-dark100', + }, + { + hex: '#202839', + role: 'Dark surface — code bg', + variable: '$bg-dark200', + }, + { hex: '#161d30', role: 'Dark panel secondary', variable: '$bg-dark300' }, + { hex: '#15192b', role: 'Dark panel / modal', variable: '$bg-dark400' }, + { hex: '#101628', role: 'Dark page background', variable: '$bg-dark500' }, + ] + + // A well-structured neutral 50–900 scale (illustrative target) + const idealGreys = [ + { hex: '#f9fafb', note: '≈ $bg-light200 (#fafafb)', step: 'neutral-50' }, + { hex: '#f3f4f6', note: '≈ $bg-light300 (#eff1f4)', step: 'neutral-100' }, + { hex: '#e5e7eb', note: '≈ $bg-light500 (#e0e3e9)', step: 'neutral-200' }, + { hex: '#d1d5db', note: 'Missing — no variable', step: 'neutral-300' }, + { + hex: '#9ca3af', + note: '≈ $text-icon-light-grey / #9DA4AE orphan', + step: 'neutral-400', + }, + { + hex: '#6b7280', + note: '≈ $text-icon-grey (#656d7b)', + step: 'neutral-500', + }, + { hex: '#4b5563', note: 'Missing — no variable', step: 'neutral-600' }, + { hex: '#374151', note: 'Missing — no variable', step: 'neutral-700' }, + { hex: '#1f2937', note: '≈ $bg-dark200 (#202839)', step: 'neutral-800' }, + { hex: '#111827', note: '≈ $bg-dark500 (#101628)', step: 'neutral-900' }, + ] + + return ( +
+

+ Grey Scale Gaps +

+

+ The current grey system has no systematic numbering. Greys are split + across two naming conventions ($bg-light* /{' '} + $bg-dark* for surfaces, and $text-icon-* for + foreground) with no unified neutral scale. Light and dark backgrounds + are defined in entirely separate variable sets with no tonal + relationship between them. Several mid-grey values (neutral-300 to + neutral-700) are completely absent, which is why orphan hex values + like #767d85 and #5D6D7E appear in + components. +

+ + + Issue: There is no $bg-light400 (it + skips from 300 to 500), the dark scale goes in the opposite direction + (100 is the lightest dark shade, 500 the darkest), and there is no + cross-referencing between the light and dark ends of the scale. A + developer cannot predictably choose a surface colour by number. + + + + Current greys — as defined in _variables.scss + +

+ Shown lightest to darkest. Note: there is no $bg-light400{' '} + step, and the dark-mode backgrounds form a completely separate set + rather than extending the same scale. +

+
+ {currentGreys.map((g) => ( +
+
+
+ {g.variable} +
+
+ {g.hex} +
+
+ {g.role} +
+
+ ))} +
+ + + What a proper neutral-50 through neutral-900 scale would look like + +

+ A unified scale unifies foreground and background colours into one + progressive sequence. Existing SCSS variables map closely to several + steps — shown in the annotations below. The gaps (300, 600, 700) are + where orphan hex values currently fill in ad hoc. +

+
+ {idealGreys.map((g) => { + const isGap = g.note.startsWith('Missing') + return ( +
+
+
+ {isGap && ( +
+ GAP +
+ )} +
+
+ {g.step} +
+
+ {g.hex} +
+
+ {g.note} +
+
+ ) + })} +
+ +
+ + Recommendation: + {' '} + Define a single $neutral-50 through{' '} + $neutral-900 scale, then alias the existing semantic + names ($text-icon-grey, $bg-light*,{' '} + $bg-dark*) as references to the appropriate steps. This + eliminates the need for orphan hex values and makes dark-mode surface + colours predictable by number. +
+
+ ) + }, +} diff --git a/frontend/stories/Colours.stories.tsx b/frontend/stories/Colours.stories.tsx new file mode 100644 index 000000000000..d4718303cbfe --- /dev/null +++ b/frontend/stories/Colours.stories.tsx @@ -0,0 +1,346 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import * as tokens from 'common/theme/tokens' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const Swatch: React.FC<{ name: string; value: string }> = ({ name, value }) => ( +
+
+
+
+ {name} +
+ + {value} + +
+
+) + +const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ + children, + title, +}) => ( +
+

+ {title} +

+
+ {children} +
+
+) + +// --------------------------------------------------------------------------- +// Token groups — extracted from the token file +// --------------------------------------------------------------------------- + +const tokenGroups: Record> = { + Brand: { + colorBrandPrimary: tokens.colorBrandPrimary, + colorBrandPrimaryActive: tokens.colorBrandPrimaryActive, + colorBrandPrimaryAlpha16: tokens.colorBrandPrimaryAlpha16, + colorBrandPrimaryAlpha24: tokens.colorBrandPrimaryAlpha24, + colorBrandPrimaryAlpha8: tokens.colorBrandPrimaryAlpha8, + colorBrandPrimaryHover: tokens.colorBrandPrimaryHover, + colorBrandSecondary: tokens.colorBrandSecondary, + colorBrandSecondaryHover: tokens.colorBrandSecondaryHover, + }, + Feedback: { + colorFeedbackDanger: tokens.colorFeedbackDanger, + colorFeedbackDangerLight: tokens.colorFeedbackDangerLight, + colorFeedbackDangerSurface: tokens.colorFeedbackDangerSurface, + colorFeedbackInfo: tokens.colorFeedbackInfo, + colorFeedbackInfoSurface: tokens.colorFeedbackInfoSurface, + colorFeedbackSuccess: tokens.colorFeedbackSuccess, + colorFeedbackSuccessLight: tokens.colorFeedbackSuccessLight, + colorFeedbackSuccessSurface: tokens.colorFeedbackSuccessSurface, + colorFeedbackWarning: tokens.colorFeedbackWarning, + colorFeedbackWarningSurface: tokens.colorFeedbackWarningSurface, + }, + Icon: { + colorIconInverse: tokens.colorIconInverse, + colorIconSecondary: tokens.colorIconSecondary, + colorIconStandard: tokens.colorIconStandard, + colorIconTertiary: tokens.colorIconTertiary, + }, + Interactive: { + colorInteractiveSecondary: tokens.colorInteractiveSecondary, + colorInteractiveSecondaryActive: tokens.colorInteractiveSecondaryActive, + colorInteractiveSecondaryHover: tokens.colorInteractiveSecondaryHover, + colorInteractiveSwitchOff: tokens.colorInteractiveSwitchOff, + colorInteractiveSwitchOffHover: tokens.colorInteractiveSwitchOffHover, + }, + Stroke: { + colorStrokeFocus: tokens.colorStrokeFocus, + colorStrokeInput: tokens.colorStrokeInput, + colorStrokeInputFocus: tokens.colorStrokeInputFocus, + colorStrokeInputHover: tokens.colorStrokeInputHover, + colorStrokeInverse: tokens.colorStrokeInverse, + colorStrokeSecondary: tokens.colorStrokeSecondary, + colorStrokeStandard: tokens.colorStrokeStandard, + }, + Surface: { + colorSurfaceInput: tokens.colorSurfaceInput, + colorSurfaceInverse: tokens.colorSurfaceInverse, + colorSurfaceModal: tokens.colorSurfaceModal, + colorSurfaceMuted: tokens.colorSurfaceMuted, + colorSurfacePanel: tokens.colorSurfacePanel, + colorSurfacePanelSecondary: tokens.colorSurfacePanelSecondary, + colorSurfaceSecondary: tokens.colorSurfaceSecondary, + colorSurfaceStandard: tokens.colorSurfaceStandard, + colorSurfaceTertiary: tokens.colorSurfaceTertiary, + }, + Text: { + colorTextHeading: tokens.colorTextHeading, + colorTextInverse: tokens.colorTextInverse, + colorTextOnBrand: tokens.colorTextOnBrand, + colorTextSecondary: tokens.colorTextSecondary, + colorTextStandard: tokens.colorTextStandard, + colorTextTertiary: tokens.colorTextTertiary, + }, +} + +// --------------------------------------------------------------------------- +// Current SCSS variables — documents what exists today (before token migration) +// --------------------------------------------------------------------------- + +const currentScssVars: Record< + string, + Record +> = { + 'Backgrounds': { + '$bg-dark100': { dark: '#2d3443', light: '(unused in light)' }, + '$bg-light100 / $bg-dark500': { dark: '#101628', light: '#ffffff' }, + '$bg-light200 / $bg-dark400': { dark: '#15192b', light: '#fafafb' }, + '$bg-light300 / $bg-dark300': { dark: '#161d30', light: '#eff1f4' }, + '$bg-light500 / $bg-dark200': { dark: '#202839', light: '#e0e3e9' }, + }, + 'Body / Text': { + '$body-color': { dark: 'white', light: '#1a2634' }, + '$header-color': { dark: '#ffffff', light: '#1e0d26' }, + '$text-icon-grey': { dark: '(no override)', light: '#656d7b' }, + '$text-icon-light-grey': { + dark: '(no override)', + light: 'rgba(157,164,174,1)', + }, + }, + 'Borders / Strokes': { + '$basic-alpha-16 / $white-alpha-16': { + dark: 'rgba(255,255,255,0.16)', + light: 'rgba(101,109,123,0.16)', + }, + '$input-border-color': { dark: '#15192b', light: 'rgba(101,109,123,0.16)' }, + '$panel-border-color': { + dark: 'rgba(255,255,255,0.16)', + light: 'rgba(101,109,123,0.16)', + }, + }, + 'Brand Palette': { + '$danger': { dark: '#ef4d56', light: '#ef4d56' }, + '$info': { dark: '#0aaddf', light: '#0aaddf' }, + '$primary': { dark: '#6837fc', light: '#6837fc' }, + '$primary400': { dark: '#906af6', light: '#906af6' }, + '$primary600': { dark: '#4e25db', light: '#4e25db' }, + '$success': { dark: '#27ab95', light: '#27ab95' }, + '$warning': { dark: '#ff9f43', light: '#ff9f43' }, + }, +} + +const ScssVarRow: React.FC<{ name: string; light: string; dark: string }> = ({ + dark, + light, + name, +}) => ( + + + {name} + + +
+
+ {light} +
+ + +
+
+ {dark} +
+ + +) + +// --------------------------------------------------------------------------- +// Stories +// --------------------------------------------------------------------------- + +const meta: Meta = { + parameters: { + layout: 'padded', + }, + title: 'Design System/Colours', +} +export default meta + +export const SemanticTokens: StoryObj = { + render: () => ( +
+

+ Semantic Colour Tokens +

+

+ These tokens automatically adapt to light/dark mode via CSS custom + properties. Toggle the theme in the toolbar above to preview both modes. +
+ Source: common/theme/tokens.ts +{' '} + web/styles/_tokens.scss +

+ {Object.entries(tokenGroups).map(([group, values]) => ( +
+ {Object.entries(values).map(([name, value]) => ( + + ))} +
+ ))} +
+ ), +} + +export const CurrentScssVariables: StoryObj = { + render: () => ( +
+

+ Current SCSS Variables +

+

+ The existing colour system in _variables.scss. Each + variable has a separate $*-dark counterpart used in{' '} + .dark selectors. This is what the token migration will + consolidate. +

+ {Object.entries(currentScssVars).map(([section, vars]) => ( +
+

+ {section} +

+ + + + + + + + + + {Object.entries(vars).map(([name, { dark, light }]) => ( + + ))} + +
+ Variable + + Light + + Dark +
+
+ ))} +
+ ), +} diff --git a/frontend/stories/DarkModeIssues.stories.tsx b/frontend/stories/DarkModeIssues.stories.tsx new file mode 100644 index 000000000000..f6362c5520e3 --- /dev/null +++ b/frontend/stories/DarkModeIssues.stories.tsx @@ -0,0 +1,679 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' + +// --------------------------------------------------------------------------- +// Meta +// --------------------------------------------------------------------------- + +const meta: Meta = { + parameters: { layout: 'padded' }, + title: 'Design System/Dark Mode Issues', +} +export default meta + +// --------------------------------------------------------------------------- +// Data +// --------------------------------------------------------------------------- + +const hardcodedColourRows = [ + { + code: "fill='#1A2634'", + colour: '#1A2634', + component: 'StatItem', + file: 'web/components/StatItem.tsx', + issue: 'Invisible in dark mode', + line: 43, + }, + { + code: "fill={checked ? '#656D7B' : '#1A2634'}", + colour: '#1A2634', + component: 'Switch', + file: 'web/components/Switch.tsx', + issue: '#1A2634 invisible in dark mode', + line: 57, + }, + { + code: "fill={isOpen ? '#1A2634' : '#9DA4AE'}", + colour: '#1A2634', + component: 'DateSelect', + file: 'web/components/DateSelect.tsx', + issue: '#1A2634 invisible in dark mode', + line: 136, + }, + { + code: "fill={'#1A2634'}", + colour: '#1A2634', + component: 'ScheduledChangesPage', + file: 'web/components/pages/ScheduledChangesPage.tsx', + issue: 'Invisible in dark mode', + line: 126, + }, + { + code: "tick={{ fill: '#1A2634' }}", + colour: '#1A2634', + component: 'OrganisationUsage', + file: 'web/components/organisation-settings/usage/OrganisationUsage.container.tsx', + issue: 'Chart labels invisible in dark mode', + line: 63, + }, + { + code: "tick={{ fill: '#1A2634' }}", + colour: '#1A2634', + component: 'SingleSDKLabelsChart', + file: 'web/components/organisation-settings/usage/components/SingleSDKLabelsChart.tsx', + issue: 'Chart labels invisible in dark mode', + line: 62, + }, +] + +// --------------------------------------------------------------------------- +// Shared styles +// --------------------------------------------------------------------------- + +const tableHeaderStyle: React.CSSProperties = { + borderBottom: '2px solid var(--colorStrokeStandard)', + color: 'var(--colorTextSecondary)', + fontSize: 13, + fontWeight: 600, + padding: '8px 12px', + textAlign: 'left', + whiteSpace: 'nowrap', +} + +const tableCellStyle: React.CSSProperties = { + borderBottom: '1px solid var(--colorStrokeStandard)', + color: 'var(--colorTextStandard)', + fontSize: 13, + padding: '10px 12px', + verticalAlign: 'middle', +} + +const codeStyle: React.CSSProperties = { + background: 'var(--colorSurfacePanel)', + borderRadius: 4, + color: 'var(--colorTextSecondary)', + fontFamily: "'Fira Code', 'Consolas', 'Courier New', monospace", + fontSize: 12, + padding: '2px 6px', + wordBreak: 'break-all', +} + +const badgeStyle: React.CSSProperties = { + background: 'rgba(239,77,86,0.12)', + borderRadius: 4, + color: '#EF4D56', + display: 'inline-block', + fontSize: 11, + fontWeight: 700, + padding: '2px 8px', + whiteSpace: 'nowrap', +} + +// --------------------------------------------------------------------------- +// Story 1: HardcodedColoursInComponents +// --------------------------------------------------------------------------- + +export const HardcodedColoursInComponents: StoryObj = { + render: () => ( +
+

+ Hardcoded Colours in Components +

+

+ These components pass #1A2634 as a + hardcoded fill to icons or chart tick props. In dark mode the background + is #101628, making the element + effectively invisible. Each value should be replaced with a CSS custom + property or currentColor. +

+ + + + + + + + + + + + + + + {hardcodedColourRows.map((row) => ( + + + + + + + + + + ))} + +
ComponentFileLineColourCodeIssueReplace with
+ {row.component} + + {row.file} + + {row.line} + +
+
+ {row.colour} +
+
+ {row.code} + + {row.issue} + + + {row.code.includes('tick') + ? "tick={{ fill: 'var(--colorTextStandard)' }}" + : "fill='currentColor'"} + +
+
+ ), +} + +// --------------------------------------------------------------------------- +// Story 2: DarkModeImplementationPatterns +// --------------------------------------------------------------------------- + +const patternBlockStyle: React.CSSProperties = { + background: 'var(--colorSurfacePanel)', + border: '1px solid var(--colorStrokeStandard)', + borderRadius: 8, + marginBottom: 24, + padding: 20, +} + +const patternTitleStyle: React.CSSProperties = { + color: 'var(--colorTextHeading)', + fontSize: 15, + fontWeight: 700, + marginBottom: 4, + marginTop: 0, +} + +const patternSubtitleStyle: React.CSSProperties = { + color: 'var(--colorTextSecondary)', + fontSize: 13, + marginBottom: 12, +} + +const preStyle = (borderColour: string): React.CSSProperties => ({ + background: '#0d1117', + borderLeft: `4px solid ${borderColour}`, + borderRadius: 6, + color: '#e6edf3', + fontFamily: "'Fira Code', 'Consolas', 'Courier New', monospace", + fontSize: 13, + lineHeight: 1.6, + margin: '0 0 12px 0', + overflow: 'auto', + padding: '14px 18px', +}) + +const problemPillStyle: React.CSSProperties = { + alignItems: 'center', + background: 'rgba(239,77,86,0.12)', + borderRadius: 20, + color: '#EF4D56', + display: 'inline-flex', + fontSize: 12, + fontWeight: 600, + gap: 6, + marginTop: 4, + padding: '4px 10px', +} + +const warningPillStyle: React.CSSProperties = { + alignItems: 'center', + background: 'rgba(255,159,67,0.12)', + borderRadius: 20, + color: '#FF9F43', + display: 'inline-flex', + fontSize: 12, + fontWeight: 600, + gap: 6, + marginTop: 4, + padding: '4px 10px', +} + +const infoPillStyle: React.CSSProperties = { + alignItems: 'center', + background: 'rgba(10,173,223,0.12)', + borderRadius: 20, + color: '#0AADDF', + display: 'inline-flex', + fontSize: 12, + fontWeight: 600, + gap: 6, + marginTop: 4, + padding: '4px 10px', +} + +export const DarkModeImplementationPatterns: StoryObj = { + render: () => ( +
+

+ Dark Mode Implementation Patterns +

+

+ Three parallel mechanisms handle dark mode today. Each solves a + different layer of the problem, but none covers everything — so + components fall through the cracks. +

+ + {/* Pattern 1 */} +
+

+ Pattern 1 — SCSS .dark selectors +

+

+ 48 rules across 29 files. Applied by toggling the .dark{' '} + class on document.body. +

+
+          {`.dark .panel {
+  background-color: $panel-bg-dark;
+}
+
+.dark .aside-nav {
+  background: $aside-nav-bg-dark;
+  border-right: 1px solid $nav-line-dark;
+}`}
+        
+ + Problem: compile-time only — cannot be used in inline styles or JS + expressions + +
+ + {/* Pattern 2 */} +
+

+ Pattern 2 — getDarkMode() runtime ternaries +

+

+ 13 components call getDarkMode() to branch on colour + values at render time. +

+
+          {`// From Switch.tsx (line 57)
+const color = getDarkMode() ? '#ffffff' : '#1A2634'
+
+// Or inline
+
+
+// From OrganisationUsage.container.tsx (line 63)
+tick={{ fill: getDarkMode() ? '#ffffff' : '#1A2634' }}`}
+        
+ + Problem: manual ternaries, easy to forget, requires re-render on theme + change + +
+ + {/* Pattern 3 */} +
+

+ Pattern 3 — Bootstrap data-bs-theme attribute +

+

+ Set on document.documentElement alongside the{' '} + .dark class. Intended to unlock Bootstrap's built-in + dark mode variables, but adoption is inconsistent. +

+
+          {`// Set in theme toggle handler
+document.documentElement.setAttribute('data-bs-theme', 'dark')
+document.body.classList.add('dark')
+
+// Bootstrap provides these automatically when data-bs-theme="dark":
+// --bs-body-bg, --bs-body-color, --bs-border-color, etc.
+// But our custom components don't use Bootstrap tokens consistently.`}
+        
+ + Problem: conflicts with .dark class approach; + inconsistent adoption across custom components + +
+ + {/* Proposed solution */} +
+

+ Proposed solution — CSS custom properties (design tokens) +

+

+ A single source of truth. The token value changes per theme; + components never branch on theme state. +

+
+          {`:root {
+  --colorTextStandard: #1A2634;
+  --colorIconStandard: #1A2634;
+}
+
+[data-bs-theme="dark"],
+.dark {
+  --colorTextStandard: #ffffff;
+  --colorIconStandard: #ffffff;
+}
+
+/* Component just uses the token — no ternary, no getDarkMode() */
+`}
+        
+ + Single mechanism, no runtime branching, works in SCSS and inline + styles + +
+
+ ), +} + +// --------------------------------------------------------------------------- +// Story 3: ThemeTokenComparison +// --------------------------------------------------------------------------- + +const comparisonCardStyle = (accent: string): React.CSSProperties => ({ + background: 'var(--colorSurfacePanel)', + border: `1px solid ${accent}`, + borderRadius: 8, + display: 'flex', + flex: 1, + flexDirection: 'column', + gap: 12, + minWidth: 0, + padding: 20, +}) + +const comparisonHeadingStyle = (colour: string): React.CSSProperties => ({ + borderBottom: `1px solid var(--colorStrokeStandard)`, + color: colour, + fontSize: 14, + fontWeight: 700, + margin: 0, + paddingBottom: 8, +}) + +export const ThemeTokenComparison: StoryObj = { + render: () => ( +
+

+ Theme Token Comparison +

+

+ Side-by-side view of the current approach versus token-based theming. + The goal is to remove all dark mode awareness from component code. +

+ + {/* Row 1: Icon fill */} +
+

+ Example 1 — Icon fill colour +

+
+
+

Before (current)

+
+              {`// Component has to know about dark mode
+const color = getDarkMode()
+  ? '#ffffff'
+  : '#1A2634'
+
+`}
+            
+
    +
  • Component coupled to theme state
  • +
  • Re-renders needed on theme change
  • +
  • Easy to forget one branch
  • +
  • Raw hex values scattered across codebase
  • +
+
+ +
+

After (with tokens)

+
+              {`// Component just uses semantic token
+import { colorIconStandard }
+  from 'common/theme'
+
+
+
+// Or directly via CSS variable
+`}
+            
+
    +
  • Component has zero theme knowledge
  • +
  • CSS updates the value — no re-render
  • +
  • One token covers both light and dark
  • +
  • Token name documents intent
  • +
+
+
+
+ + {/* Row 2: Chart tick */} +
+

+ Example 2 — Recharts tick colour +

+
+
+

Before (current)

+
+              {`// OrganisationUsage.container.tsx, line 63
+// Chart labels are invisible in dark mode
+
+
+`}
+            
+
+ +
+

After (with tokens)

+
+              {`// Reads computed token at render time
+const textColour = getComputedStyle(
+  document.documentElement
+).getPropertyValue('--colorTextStandard').trim()
+
+
+
+`}
+            
+
+
+
+ + {/* Token mapping table */} +
+

+ Proposed token mapping +

+ + + + + + + + + + + {[ + { + dark: '#ffffff', + light: '#1A2634', + replaces: "fill='#1A2634' (icons)", + token: '--colorIconStandard', + }, + { + dark: '#ffffff', + light: '#1A2634', + replaces: "tick={{ fill: '#1A2634' }} (charts)", + token: '--colorTextStandard', + }, + { + dark: '#9DA4AE', + light: '#9DA4AE', + replaces: "fill='#9DA4AE' (secondary icons)", + token: '--colorIconSubtle', + }, + { + dark: '#656D7B', + light: '#656D7B', + replaces: "fill='#656D7B' (sun icon in Switch)", + token: '--colorIconDisabled', + }, + ].map(({ dark, light, replaces, token }) => ( + + + + + + + ))} + +
Token nameLight valueDark valueReplaces
+ {token} + +
+
+ {light} +
+
+
+
+ {dark} +
+
+ + {replaces} + +
+
+
+ ), +} diff --git a/frontend/stories/Icons.stories.tsx b/frontend/stories/Icons.stories.tsx new file mode 100644 index 000000000000..bb5f86989b1e --- /dev/null +++ b/frontend/stories/Icons.stories.tsx @@ -0,0 +1,605 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import Icon from 'components/Icon' +import type { IconName } from 'components/Icon' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getBorderStyle(broken?: boolean, hardcoded?: boolean): string { + if (broken) return '2px solid #ef4d56' + if (hardcoded) return '2px solid #ff9f43' + return '1px solid var(--colorStrokeStandard)' +} + +// --------------------------------------------------------------------------- +// Data +// --------------------------------------------------------------------------- + +/** All icon names from the IconName union type in Icon.tsx */ +const allIconNames: IconName[] = [ + 'arrow-left', + 'arrow-right', + 'award', + 'bar-chart', + 'bell', + 'calendar', + 'checkmark', + 'checkmark-circle', + 'checkmark-square', + 'chevron-down', + 'chevron-left', + 'chevron-right', + 'chevron-up', + 'clock', + 'close-circle', + 'code', + 'copy', + 'copy-outlined', + 'dash', + 'diff', + 'edit', + 'edit-outlined', + 'email', + 'expand', + 'eye', + 'eye-off', + 'features', + 'file-text', + 'flash', + 'flask', + 'github', + 'google', + 'height', + 'info', + 'info-outlined', + 'issue-closed', + 'issue-linked', + 'layers', + 'layout', + 'link', + 'list', + 'lock', + 'minus-circle', + 'moon', + 'more-vertical', + 'nav-logo', + 'open-external-link', + 'options-2', + 'people', + 'person', + 'pie-chart', + 'plus', + 'pr-closed', + 'pr-draft', + 'pr-linked', + 'pr-merged', + 'radio', + 'refresh', + 'request', + 'required', + 'rocket', + 'search', + 'setting', + 'settings-2', + 'shield', + 'star', + 'sun', + 'timer', + 'trash-2', + 'warning', +] + +/** Icons that default to #1A2634 — invisible in dark mode */ +const darkModeBreakageIcons: IconName[] = [ + 'checkmark', + 'chevron-down', + 'chevron-left', + 'chevron-right', + 'chevron-up', + 'arrow-left', + 'arrow-right', + 'clock', + 'code', + 'copy', + 'copy-outlined', + 'dash', + 'diff', + 'edit', + 'edit-outlined', + 'email', + 'file-text', + 'flash', + 'flask', + 'height', + 'bell', + 'calendar', + 'layout', + 'layers', + 'list', + 'lock', + 'minus-circle', + 'more-vertical', + 'people', + 'person', + 'pie-chart', + 'refresh', + 'request', + 'setting', + 'settings-2', + 'shield', + 'star', + 'timer', + 'trash-2', + 'bar-chart', + 'award', + 'options-2', + 'open-external-link', + 'features', + 'rocket', + 'expand', + 'radio', +] + +/** Icons with hardcoded fills that can't be overridden */ +const hardcodedFillIcons: IconName[] = [ + 'github', + 'google', + 'link', + 'pr-merged', + 'pr-closed', + 'pr-linked', + 'pr-draft', + 'issue-closed', + 'issue-linked', +] + +/** Icons using specific semantic defaults (not #1A2634) */ +const semanticDefaultIcons: Record< + string, + { icons: IconName[]; colour: string } +> = { + 'Cyan (#0AADDF)': { colour: '#0AADDF', icons: ['info'] }, + 'Grey (#9DA4AE)': { + colour: '#9DA4AE', + icons: ['eye', 'eye-off', 'search', 'info-outlined'], + }, + 'Orange (#FF9F43)': { colour: '#FF9F43', icons: ['warning'] }, + 'Purple (#6837FC)': { colour: '#6837FC', icons: ['checkmark-square'] }, + 'Red (#EF4D56)': { colour: '#EF4D56', icons: ['close-circle'] }, + 'Sun/Moon grey (#656D7B)': { colour: '#656D7B', icons: ['sun', 'moon'] }, + 'White (#ffffff)': { colour: '#ffffff', icons: ['plus', 'nav-logo'] }, +} + +/** Separate SVG components outside of Icon.tsx */ +const separateSvgComponents = [ + { name: 'ArrowUpIcon', path: 'web/components/svg/ArrowUpIcon.tsx' }, + { name: 'AuditLogIcon', path: 'web/components/svg/AuditLogIcon.tsx' }, + { name: 'CaretDownIcon', path: 'web/components/svg/CaretDownIcon.tsx' }, + { name: 'CaretRightIcon', path: 'web/components/svg/CaretRightIcon.tsx' }, + { + name: 'DocumentationIcon', + path: 'web/components/svg/DocumentationIcon.tsx', + }, + { name: 'DropIcon', path: 'web/components/svg/DropIcon.tsx' }, + { + name: 'EnvironmentSettingsIcon', + path: 'web/components/svg/EnvironmentSettingsIcon.tsx', + }, + { name: 'FeaturesIcon', path: 'web/components/svg/FeaturesIcon.tsx' }, + { name: 'LogoutIcon', path: 'web/components/svg/LogoutIcon.tsx' }, + { name: 'NavIconSmall', path: 'web/components/svg/NavIconSmall.tsx' }, + { name: 'OrgSettingsIcon', path: 'web/components/svg/OrgSettingsIcon.tsx' }, + { name: 'PlayIcon', path: 'web/components/svg/PlayIcon.tsx' }, + { name: 'PlusIcon', path: 'web/components/svg/PlusIcon.tsx' }, + { + name: 'ProjectSettingsIcon', + path: 'web/components/svg/ProjectSettingsIcon.tsx', + }, + { name: 'SegmentsIcon', path: 'web/components/svg/SegmentsIcon.tsx' }, + { name: 'SparklesIcon', path: 'web/components/svg/SparklesIcon.tsx' }, + { name: 'UpgradeIcon', path: 'web/components/svg/UpgradeIcon.tsx' }, + { name: 'UserSettingsIcon', path: 'web/components/svg/UserSettingsIcon.tsx' }, + { name: 'UsersIcon', path: 'web/components/svg/UsersIcon.tsx' }, + { name: 'GithubIcon', path: 'web/components/base/icons/GithubIcon.tsx' }, + { name: 'GitlabIcon', path: 'web/components/base/icons/GitlabIcon.tsx' }, + { + name: 'IdentityOverridesIcon', + path: 'web/components/IdentityOverridesIcon.tsx', + }, + { + name: 'SegmentOverridesIcon', + path: 'web/components/SegmentOverridesIcon.tsx', + }, +] + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const IconCell: React.FC<{ + name: IconName + broken?: boolean + hardcoded?: boolean +}> = ({ broken, hardcoded, name }) => ( +
+
+ +
+ + {name} + + {broken && ( + + DARK MODE BROKEN + + )} + {hardcoded && ( + + HARDCODED FILL + + )} +
+) + +// --------------------------------------------------------------------------- +// Stories +// --------------------------------------------------------------------------- + +const meta: Meta = { + parameters: { layout: 'padded' }, + title: 'Design System/Icons', +} +export default meta + +export const AllIcons: StoryObj = { + render: () => ( +
+

+ All Icons ({allIconNames.length}) +

+

+ Source: Icon.tsx — 71 inline SVGs in a single switch + statement (1,543 lines). Toggle dark mode in the toolbar to see which + icons disappear. +

+
+ + + Defaults to #1A2634 — invisible in dark mode + + + + Hardcoded fill — not overridable via props + +
+
+ {allIconNames.map((name) => ( + + ))} +
+
+ ), +} + +export const DarkModeBroken: StoryObj = { + render: () => ( +
+

+ Dark Mode Broken Icons ({darkModeBreakageIcons.length}) +

+

+ These icons default to fill="#1A2634" (dark + navy). On the dark background (#101628), they are + invisible. +
+
+ Fix: Replace{' '} + + fill={'{'}fill || '#1A2634'{'}'} + {' '} + with{' '} + + fill={'{'}fill || 'currentColor'{'}'} + + . +

+
+ {darkModeBreakageIcons.map((name) => ( + + ))} +
+
+ ), +} + +export const HardcodedFills: StoryObj = { + render: () => ( +
+

+ Hardcoded Fill Icons ({hardcodedFillIcons.length}) +

+

+ These icons have fill colours baked into the SVG paths. The{' '} + fill prop has no effect. Some (like Google) intentionally + use brand colours, others (like + github and pr-draft) use #1A2634{' '} + which breaks in dark mode. +

+
+ {hardcodedFillIcons.map((name) => ( + + ))} +
+
+ ), +} + +export const SemanticDefaults: StoryObj = { + render: () => ( +
+

+ Icons with Semantic Defaults +

+

+ These icons use meaningful default fill colours (success, danger, info + etc.) rather than the generic #1A2634. +

+ {Object.entries(semanticDefaultIcons).map( + ([label, { colour, icons }]) => ( +
+

+ {label}{' '} + +

+
+ {icons.map((name) => ( + + ))} +
+
+ ), + )} +
+ ), +} + +export const SeparateSvgComponents: StoryObj = { + render: () => ( +
+

+ Separate SVG Components ({separateSvgComponents.length}) +

+

+ These icons exist as individual .tsx files outside of{' '} + Icon.tsx. They are imported directly by components, not via + the <Icon name="..." /> API. +
+
+ Consolidation opportunity: Either move these into the + Icon.tsx switch statement, or extract Icon.tsx's inline SVGs into + individual files and use a consistent pattern for all icons. +

+ + + + + + + + + {separateSvgComponents.map(({ name, path }) => ( + + + + + ))} + +
+ Component + + File Path +
+ {name} + + + {path} + +
+
+ ), +} + +export const IconSystemSummary: StoryObj = { + render: () => ( +
+

+ Icon System Summary +

+ + + {[ + ['Total icon names in Icon.tsx', '71'], + [ + 'Inline SVGs in switch statement', + '70 (paste declared but not implemented)', + ], + ['Separate SVG components', '23 files across 3 directories'], + ['Integration SVG files', '37 in /static/images/integrations/'], + [ + 'Icons defaulting to #1A2634', + `${darkModeBreakageIcons.length} — invisible in dark mode`, + ], + [ + 'Icons with hardcoded fills', + `${hardcodedFillIcons.length} — cannot be overridden via props`, + ], + ['Icons using currentColor', '0'], + ['Icon.tsx total lines', '1,543'], + ['Unused dependency: ionicons', 'v7.2.1 installed but not used'], + ].map(([label, value]) => ( + + + + + ))} + +
+ {label} + + {value} +
+
+ ), +} diff --git a/frontend/stories/Typography.stories.tsx b/frontend/stories/Typography.stories.tsx new file mode 100644 index 000000000000..766920b2733c --- /dev/null +++ b/frontend/stories/Typography.stories.tsx @@ -0,0 +1,1003 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' + +// --------------------------------------------------------------------------- +// Meta +// --------------------------------------------------------------------------- + +const meta: Meta = { + parameters: { layout: 'padded' }, + title: 'Design System/Typography', +} +export default meta + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function getA11yBackground(a11y: string): string { + if (a11y.startsWith('Accessible')) return 'rgba(39, 171, 149, 0.1)' + if (a11y.startsWith('Fails')) return 'rgba(239, 77, 86, 0.1)' + return 'rgba(255, 159, 67, 0.1)' +} + +function getA11yColour(a11y: string): string { + if (a11y.startsWith('Accessible')) return '#27ab95' + if (a11y.startsWith('Fails')) return '#ef4d56' + return '#ff9f43' +} + +const SectionHeading: React.FC<{ title: string; subtitle?: string }> = ({ + subtitle, + title, +}) => ( +
+

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+) + +const Divider: React.FC = () => ( +
+) + +const SpecBadge: React.FC<{ label: string }> = ({ label }) => ( + + {label} + +) + +// --------------------------------------------------------------------------- +// TypeScale story +// --------------------------------------------------------------------------- + +const headingLevels = [ + { lineHeight: 46, size: 42, tag: 'h1', weight: 700 }, + { lineHeight: 40, size: 34, tag: 'h2', weight: 700 }, + { lineHeight: 40, size: 30, tag: 'h3', weight: 700 }, + { lineHeight: 32, size: 24, tag: 'h4', weight: 700 }, + { lineHeight: 28, size: 18, tag: 'h5', weight: 700 }, + { lineHeight: 24, size: 16, tag: 'h6', weight: 700 }, +] as const + +const bodyLevels = [ + { label: 'Body', lineHeight: 20, scssVar: '$font-size-base', size: 14 }, + { label: 'Caption', lineHeight: 20, scssVar: '$font-caption', size: 13 }, + { + label: 'Caption SM', + lineHeight: 18, + scssVar: '$font-caption-sm', + size: 12, + }, + { + label: 'Caption XS', + lineHeight: 16, + scssVar: '$font-caption-xs', + size: 11, + }, +] as const + +export const TypeScale: StoryObj = { + render: () => ( +
+ + + {/* Headings */} +

+ Headings +

+ + {headingLevels.map(({ lineHeight, size, tag, weight }) => { + const Tag = tag as keyof JSX.IntrinsicElements + return ( +
+ {/* Tag label */} + + {tag} + + + {/* Spec badges */} +
+ + + +
+ + {/* Live element — picks up project CSS */} + + The quick brown fox + +
+ ) + })} + + + + {/* Body */} +

+ Body sizes +

+

+ There are no CSS classes for body sizes — they are set via SCSS + variables only. Rendered here with matching inline styles so you can see + the scale. +

+ + {bodyLevels.map(({ label, lineHeight, scssVar, size }) => ( +
+ {/* Label */} +
+
+ {label} +
+ + {scssVar} + +
+ + {/* Spec badges */} +
+ + +
+ + {/* Live sample */} + + The quick brown fox jumps over the lazy dog + +
+ ))} +
+ ), +} + +// --------------------------------------------------------------------------- +// HardcodedFontSizes story +// --------------------------------------------------------------------------- + +type HardcodedGroup = { + value: string | number + count: number + files: string[] +} + +const hardcodedGroups: HardcodedGroup[] = [ + { + count: 17, + files: [ + 'IntegrationAdoptionTable', + 'ReleasePipelineStatsTable', + 'various admin components', + ], + value: 13, + }, + { + count: 12, + files: ['Labels', 'captions', 'badges across many components'], + value: 12, + }, + { + count: 7, + files: ['TableFilter', 'TableFilterOptions', 'small labels'], + value: 11, + }, + { + count: 1, + files: ['Icon labels in stories'], + value: 10, + }, + { + count: 1, + files: ['IonIcon in CreateRole'], + value: 16, + }, + { + count: 1, + files: ['Scattered'], + value: '18px', + }, + { + count: 1, + files: ['Scattered'], + value: '14px', + }, + { + count: 3, + files: ['Scattered'], + value: '13px', + }, + { + count: 2, + files: ['Scattered'], + value: '12px', + }, + { + count: 1, + files: ['Scattered'], + value: '0.875rem', + }, +] + +const totalHardcoded = hardcodedGroups.reduce((acc, g) => acc + g.count, 0) + +const CountBadge: React.FC<{ count: number }> = ({ count }) => ( + + {count} + +) + +export const HardcodedFontSizes: StoryObj = { + render: () => ( +
+ + + {/* Summary callout */} +
+ +
+ + {totalHardcoded} inline fontSize values bypass the type scale. + + + These should use typography tokens instead. Each hardcoded value + creates drift between components and makes future type-scale changes + labour-intensive. + +
+
+ + {/* Column headers */} +
+ {['Value', 'Count', 'Sample', 'Files'].map((h) => ( + + {h} + + ))} +
+ + {hardcodedGroups.map((group) => { + const numericSize = + typeof group.value === 'number' + ? group.value + : parseFloat(group.value as string) * + (String(group.value).endsWith('rem') ? 16 : 1) + + return ( +
+ {/* Value */} + + {`fontSize: ${ + typeof group.value === 'string' + ? `'${group.value}'` + : group.value + }`} + + + {/* Count badge */} +
+ +
+ + {/* Sample — rendered at the actual size with audit border */} +
+ + Sample text at {group.value} + +
+ + {/* Files */} +
+ {group.files.map((f) => ( +
+ {f} +
+ ))} +
+
+ ) + })} + + {/* Legend */} +
+
+ + Red border = sample rendered at the hardcoded size + +
+
+ ), +} + +// --------------------------------------------------------------------------- +// WeightAndStyleUsage story +// --------------------------------------------------------------------------- + +const weightTiers = [ + { + classes: 'fw-bold, .bold-link', + label: 'Bold', + occurrences: '~14 in SCSS', + scssVar: '$btn-font-weight', + usage: 'Headings (h1–h6), buttons, badges', + value: 700, + }, + { + classes: 'fw-semibold', + label: 'Semibold', + occurrences: '~3 in SCSS, 3 inline in TSX', + scssVar: '$tab-btn-active-font-weight', + usage: 'Active tab states, admin dashboard tables', + value: 600, + }, + { + classes: '.font-weight-medium, fw-normal (sometimes)', + label: 'Medium', + occurrences: '~33 in SCSS, 1 inline in TSX', + scssVar: '$input-font-weight', + usage: 'Body text, inputs, alerts, tabs, links — most common weight', + value: 500, + }, + { + classes: 'fw-normal', + label: 'Regular', + occurrences: '~5 in SCSS, 1 inline in TSX', + scssVar: '(none)', + usage: 'Secondary/muted text, placeholders, dividers', + value: 400, + }, +] + +const mutedTextPatterns = [ + { + a11y: 'Accessible', + method: 'Colour-based', + pattern: '$text-muted / .text-muted', + value: '#656D7B', + }, + { + a11y: 'Accessible', + method: 'Colour + size', + pattern: '.faint', + value: '$text-muted + 0.8em', + }, + { + a11y: 'Risky — depends on bg', + method: 'Opacity', + pattern: 'opacity: 0.6', + value: '60% transparency', + }, + { + a11y: 'Risky — low contrast', + method: 'Opacity', + pattern: 'opacity: 0.5', + value: '50% transparency', + }, + { + a11y: 'Fails WCAG AA', + method: 'Opacity', + pattern: 'opacity: 0.4', + value: '40% transparency', + }, + { + a11y: 'Intentional (disabled)', + method: 'Opacity', + pattern: '$btn-disabled-opacity (0.32)', + value: '32% transparency', + }, +] + +const bugs = [ + { + code: "fontWeight: 'semi-bold'", + file: 'web/components/messages/SuccessMessage.tsx', + fix: 'fontWeight: 600', + issue: + 'Invalid CSS — "semi-bold" is not a valid fontWeight value. Browser ignores it.', + }, + { + code: "fontWeight: 'semi-bold'", + file: 'web/components/SuccessMessage.js', + fix: 'fontWeight: 600', + issue: 'Same bug, duplicate file (JS version of the above).', + }, +] + +export const WeightAndStyleUsage: StoryObj = { + render: () => ( +
+ + + {/* Weight tiers */} +

+ Weight Tiers in Use +

+ + + + {['Weight', 'Sample', 'SCSS variable', 'CSS classes', 'Usage'].map( + (h) => ( + + ), + )} + + + + {weightTiers.map((tier) => ( + + + + + + + + ))} + +
+ {h} +
+ + + {tier.label} + + + + The quick brown fox + + + + {tier.scssVar} + + + + {tier.classes} + + + {tier.usage} +
+ + {/* Inconsistency callout */} +
+ + Inconsistency: Two class systems in use + +

+ Custom class .font-weight-medium (500) competes with + Bootstrap utilities fw-bold (18 uses),{' '} + fw-semibold (16 uses), fw-normal (32 uses). + Components pick whichever they find first. Should standardise on one + system. +

+
+ + + + {/* Muted text patterns */} +

+ "Subtle" / Muted Text Patterns +

+

+ Text hierarchy is achieved through both colour and opacity. + Opacity-based patterns are an accessibility concern — the resulting + contrast depends on the background, making it unpredictable across + light/dark modes. +

+ + + + {['Pattern', 'Method', 'Value', 'Accessibility'].map((h) => ( + + ))} + + + + {mutedTextPatterns.map((row) => ( + + + + + + + ))} + +
+ {h} +
+ + {row.pattern} + + + {row.method} + + {row.value} + + + {row.a11y} + +
+ + + + {/* Bugs */} +

+ Bugs Found +

+ {bugs.map((bug) => ( +
+ + {bug.file} + +
+ {bug.code} +
+

+ {bug.issue} +

+

+ Fix: {bug.fix} +

+
+ ))} + + + + {/* Other styles */} +

+ Other Styles +

+ + + {[ + [ + 'Italic', + '2 occurrences (CodeReferenceItem, RuleConditionPropertySelect)', + 'Not a pattern — ad-hoc usage only', + ], + [ + 'Underline', + '4 declarations in SCSS (.link-style, a:hover, .btn-link:hover)', + 'Consistent — used for interactive text', + ], + [ + 'text-transform: uppercase', + '1 occurrence (.btn-project-letter)', + 'Rare — not systemic', + ], + [ + 'text-transform: capitalize', + '1 occurrence (.panel heading)', + 'Rare — not systemic', + ], + ['letter-spacing', '1 class (.letter-spacing: 1px)', 'Barely used'], + ].map(([style, usage, note]) => ( + + + + + + ))} + +
+ {style} + + {usage} + + {note} +
+
+ ), +} diff --git a/frontend/web/styles/_tokens.scss b/frontend/web/styles/_tokens.scss new file mode 100644 index 000000000000..91077a99239d --- /dev/null +++ b/frontend/web/styles/_tokens.scss @@ -0,0 +1,140 @@ +// ============================================================================= +// Design Tokens — CSS Custom Properties +// ============================================================================= +// +// This file defines the CSS custom properties consumed by common/theme/tokens.ts. +// Light mode values are defined on :root, dark mode overrides on [data-bs-theme='dark']. +// +// Migration: gradually replace $scss-variable references in component SCSS files +// with var(--tokenName). Once a $foo / $foo-dark pair is fully replaced, remove it +// from _variables.scss. +// +// ============================================================================= + +:root { + // ---- Text ---------------------------------------------------------------- + --colorTextStandard: #{$body-color}; // #1a2634 + --colorTextSecondary: #{$text-icon-grey}; // #656d7b + --colorTextTertiary: #{$text-icon-light-grey}; // rgba(157, 164, 174, 1) + --colorTextInverse: #ffffff; + --colorTextOnBrand: #ffffff; + --colorTextHeading: #{$header-color}; // #1e0d26 + + // ---- Surface ------------------------------------------------------------- + --colorSurfaceStandard: #{$bg-light100}; // #ffffff + --colorSurfaceSecondary: #{$bg-light200}; // #fafafb + --colorSurfaceTertiary: #{$bg-light300}; // #eff1f4 + --colorSurfaceMuted: #{$bg-light500}; // #e0e3e9 + --colorSurfaceInverse: #{$bg-dark500}; // #101628 + --colorSurfacePanel: #{$panel-bg}; // white + --colorSurfacePanelSecondary: #{$panel-grey-background}; // #fafafb + --colorSurfaceModal: #ffffff; + --colorSurfaceInput: #{$input-bg}; // #fff + + // ---- Icon ---------------------------------------------------------------- + --colorIconStandard: #{$body-color}; // #1a2634 + --colorIconSecondary: #{$text-icon-grey}; // #656d7b + --colorIconTertiary: #{$text-icon-light-grey}; // rgba(157, 164, 174, 1) + --colorIconInverse: #ffffff; + + // ---- Stroke -------------------------------------------------------------- + --colorStrokeStandard: #{$basic-alpha-16}; // rgba(101, 109, 123, 0.16) + --colorStrokeSecondary: #{$basic-alpha-24}; // rgba(101, 109, 123, 0.24) + --colorStrokeFocus: #{$primary}; // #6837fc + --colorStrokeInverse: #{$white-alpha-16}; // rgba(255, 255, 255, 0.16) + --colorStrokeInput: #{$input-border-color}; // rgba(101, 109, 123, 0.16) + --colorStrokeInputHover: #{$basic-alpha-24}; + --colorStrokeInputFocus: #{$primary}; + + // ---- Brand --------------------------------------------------------------- + --colorBrandPrimary: #{$primary}; // #6837fc + --colorBrandPrimaryHover: #{$primary600}; // #4e25db + --colorBrandPrimaryActive: #{$primary700}; // #3919b7 + --colorBrandSecondary: #{$secondary500}; // #F7D56E + --colorBrandSecondaryHover: #{$secondary600}; // #e5c55f + --colorBrandPrimaryAlpha8: #{$primary-alfa-8}; + --colorBrandPrimaryAlpha16: #{$primary-alfa-16}; + --colorBrandPrimaryAlpha24: #{$primary-alfa-24}; + + // ---- Feedback ------------------------------------------------------------ + --colorFeedbackSuccess: #{$success}; // #27ab95 + --colorFeedbackSuccessLight: #{$success400}; // #56ccad + --colorFeedbackSuccessSurface: #{$success-solid-alert}; + --colorFeedbackDanger: #{$danger}; // #ef4d56 + --colorFeedbackDangerLight: #{$danger400}; // #f57c78 + --colorFeedbackDangerSurface: #{$danger-solid-alert}; + --colorFeedbackWarning: #{$warning}; // #ff9f43 + --colorFeedbackWarningSurface: #{$warning-solid-alert}; + --colorFeedbackInfo: #{$info}; // #0aaddf + --colorFeedbackInfoSurface: #{$info-solid-alert}; + + // ---- Interactive --------------------------------------------------------- + --colorInteractiveSecondary: #{$basic-alpha-8}; + --colorInteractiveSecondaryHover: #{$basic-alpha-16}; + --colorInteractiveSecondaryActive: #{$basic-alpha-24}; + --colorInteractiveSwitchOff: #{$switch-bg}; // rgba(101, 109, 123, 0.24) + --colorInteractiveSwitchOffHover: #{$switch-hover-bg}; // rgba(101, 109, 123, 0.48) + + // ---- Misc ---------------------------------------------------------------- + --borderRadiusSm: #{$border-radius-sm}; // 4px + --borderRadiusStandard: #{$border-radius}; // 6px + --borderRadiusLg: #{$border-radius-lg}; // 8px + --borderRadiusXl: #{$border-radius-xlg}; // 10px +} + +// ============================================================================= +// Dark mode overrides +// ============================================================================= + +[data-bs-theme='dark'] { + // ---- Text ---------------------------------------------------------------- + --colorTextStandard: #{$body-color-dark}; // white + --colorTextSecondary: #{$text-icon-light-grey}; // rgba(157, 164, 174, 1) + --colorTextTertiary: #{$white-alpha-48}; + --colorTextInverse: #{$body-color}; // #1a2634 + --colorTextOnBrand: #ffffff; + --colorTextHeading: #{$header-color-dark}; // white + + // ---- Surface ------------------------------------------------------------- + --colorSurfaceStandard: #{$bg-dark500}; // #101628 + --colorSurfaceSecondary: #{$bg-dark400}; // #15192b + --colorSurfaceTertiary: #{$bg-dark300}; // #161d30 + --colorSurfaceMuted: #{$bg-dark200}; // #202839 + --colorSurfaceInverse: #{$bg-light100}; // #ffffff + --colorSurfacePanel: #{$panel-bg-dark}; // #15192b + --colorSurfacePanelSecondary: #{$panel-grey-background-dark}; // #161d30 + --colorSurfaceModal: #{$modal-bg-dark}; // #15192b + --colorSurfaceInput: #{$input-bg-dark}; // #161d30 + + // ---- Icon ---------------------------------------------------------------- + --colorIconStandard: #{$text-icon-light}; // #ffffff + --colorIconSecondary: #{$text-icon-light-grey}; // rgba(157, 164, 174, 1) + --colorIconTertiary: #{$white-alpha-48}; + --colorIconInverse: #{$body-color}; // #1a2634 + + // ---- Stroke -------------------------------------------------------------- + --colorStrokeStandard: #{$white-alpha-16}; + --colorStrokeSecondary: #{$white-alpha-24}; + --colorStrokeFocus: #{$primary400}; // #906af6 + --colorStrokeInverse: #{$basic-alpha-16}; + --colorStrokeInput: #{$input-border-color-dark}; // #15192b + --colorStrokeInputHover: #{$input-hover-border-color-dark}; // rgba(255, 255, 255, 0.08) + --colorStrokeInputFocus: #{$input-focus-border-color-dark}; // #6837fc + + // ---- Brand --------------------------------------------------------------- + --colorBrandPrimaryHover: #{$btn-hover-bg-dark}; // #906af6 + --colorBrandPrimaryActive: #{$primary}; // #6837fc + + // ---- Feedback (dark surfaces) -------------------------------------------- + --colorFeedbackSuccessSurface: #{$success-solid-dark-alert}; + --colorFeedbackDangerSurface: #{$danger-solid-dark-alert}; + --colorFeedbackWarningSurface: #{$warning-solid-dark-alert}; + --colorFeedbackInfoSurface: #{$info-solid-dark-alert}; + + // ---- Interactive --------------------------------------------------------- + --colorInteractiveSecondary: #{$white-alpha-8}; + --colorInteractiveSecondaryHover: #{$white-alpha-16}; + --colorInteractiveSecondaryActive: #{$white-alpha-24}; + --colorInteractiveSwitchOff: #{$switch-bg-dark}; // rgba(255, 255, 255, 0.24) + --colorInteractiveSwitchOffHover: #{$switch-hover-bg-dark}; // rgba(255, 255, 255, 0.48) +} diff --git a/frontend/web/styles/styles.scss b/frontend/web/styles/styles.scss index 674a2fcefaef..1380ea685d9e 100644 --- a/frontend/web/styles/styles.scss +++ b/frontend/web/styles/styles.scss @@ -1,4 +1,5 @@ @import "variables"; +@import "tokens"; @import "3rdParty/index"; @import "components/index"; @import "flexbox/index";