diff --git a/frontend/common/services/useFeatureAnalytics.ts b/frontend/common/services/useFeatureAnalytics.ts index 3f192eff56fa..cab1eb34f015 100644 --- a/frontend/common/services/useFeatureAnalytics.ts +++ b/frontend/common/services/useFeatureAnalytics.ts @@ -60,19 +60,27 @@ export const featureAnalyticsService = service preBuiltData.push(dayObj) } + // Collect raw entries with labels intact for label-based grouping. + // chartData aggregates by environment (existing behaviour), + // rawEntries preserves per-SDK labels for stacked charts (#6067). + const rawEntries: Res['environmentAnalytics'] = [] + responses.forEach((response, i) => { const environment_id = query.environment_ids[i] response.data?.forEach((entry) => { + rawEntries.push(entry) const date = moment(entry.day).format('Do MMM') const dayEntry = preBuiltData.find((d) => d.day === date) if (dayEntry) { - dayEntry[environment_id] = entry.count // Set count for specific environment ID + dayEntry[environment_id] = entry.count } }) }) return { - data: error ? [] : preBuiltData, + data: error + ? { chartData: [], rawEntries: [] } + : { chartData: preBuiltData, rawEntries }, error, } }, diff --git a/frontend/common/theme/index.ts b/frontend/common/theme/index.ts deleted file mode 100644 index 69e4acfaebc0..000000000000 --- a/frontend/common/theme/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { tokens, radius, shadow, duration, easing } from './tokens' -export type { - TokenCategory, - TokenEntry, - TokenName, - RadiusScale, - ShadowScale, -} from './tokens' diff --git a/frontend/common/theme/tokens.json b/frontend/common/theme/tokens.json index 672535b68241..acd40cad8317 100644 --- a/frontend/common/theme/tokens.json +++ b/frontend/common/theme/tokens.json @@ -131,6 +131,18 @@ "info": { "cssVar": "--color-icon-info", "light": "#0aaddf", "dark": "#0aaddf" } } }, + "chart": { + "1": { "cssVar": "--color-chart-1", "light": "#0aaddf", "dark": "#45bce0", "description": "First series in charts. Blue." }, + "2": { "cssVar": "--color-chart-2", "light": "#ef4d56", "dark": "#f57c78", "description": "Second series. Red." }, + "3": { "cssVar": "--color-chart-3", "light": "#27ab95", "dark": "#56ccad", "description": "Third series. Green." }, + "4": { "cssVar": "--color-chart-4", "light": "#ff9f43", "dark": "#ffc08a", "description": "Fourth series. Orange." }, + "5": { "cssVar": "--color-chart-5", "light": "#7a4dfc", "dark": "#906af6", "description": "Fifth series. Purple." }, + "6": { "cssVar": "--color-chart-6", "light": "#0b8bb2", "dark": "#7ecde2", "description": "Sixth series. Blue dark." }, + "7": { "cssVar": "--color-chart-7", "light": "#e61b26", "dark": "#f5a5a2", "description": "Seventh series. Red dark." }, + "8": { "cssVar": "--color-chart-8", "light": "#13787b", "dark": "#87d4c4", "description": "Eighth series. Green dark." }, + "9": { "cssVar": "--color-chart-9", "light": "#fa810c", "dark": "#ffd7b5", "description": "Ninth series. Orange dark." }, + "10": { "cssVar": "--color-chart-10", "light": "#6837fc", "dark": "#b794ff", "description": "Tenth series. Purple dark." } + }, "radius": { "none": { "cssVar": "--radius-none", "value": "0px", "description": "Sharp corners. Tables, dividers." }, "xs": { "cssVar": "--radius-xs", "value": "2px", "description": "Barely rounded. Badges, tags." }, diff --git a/frontend/common/theme/tokens.ts b/frontend/common/theme/tokens.ts index eb361c183eaf..67159199be92 100644 --- a/frontend/common/theme/tokens.ts +++ b/frontend/common/theme/tokens.ts @@ -3,57 +3,6 @@ // Do not edit manually. Run: npm run generate:tokens // ============================================================================= -export const tokens = { - border: { - action: 'var(--color-border-action, #6837fc)', - danger: 'var(--color-border-danger, #ef4d56)', - default: 'var(--color-border-default)', - disabled: 'var(--color-border-disabled)', - info: 'var(--color-border-info, #0aaddf)', - strong: 'var(--color-border-strong)', - success: 'var(--color-border-success, #27ab95)', - warning: 'var(--color-border-warning, #ff9f43)', - }, - icon: { - action: 'var(--color-icon-action, #6837fc)', - danger: 'var(--color-icon-danger, #ef4d56)', - default: 'var(--color-icon-default, #1a2634)', - disabled: 'var(--color-icon-disabled, #9da4ae)', - info: 'var(--color-icon-info, #0aaddf)', - secondary: 'var(--color-icon-secondary, #656d7b)', - success: 'var(--color-icon-success, #27ab95)', - warning: 'var(--color-icon-warning, #ff9f43)', - }, - surface: { - action: 'var(--color-surface-action, #6837fc)', - actionActive: 'var(--color-surface-action-active, #3919b7)', - actionHover: 'var(--color-surface-action-hover, #4e25db)', - actionMuted: 'var(--color-surface-action-muted)', - actionSubtle: 'var(--color-surface-action-subtle)', - active: 'var(--color-surface-active)', - danger: 'var(--color-surface-danger)', - default: 'var(--color-surface-default, #ffffff)', - emphasis: 'var(--color-surface-emphasis, #e0e3e9)', - hover: 'var(--color-surface-hover)', - info: 'var(--color-surface-info)', - muted: 'var(--color-surface-muted, #eff1f4)', - subtle: 'var(--color-surface-subtle, #fafafb)', - success: 'var(--color-surface-success)', - warning: 'var(--color-surface-warning)', - }, - text: { - action: 'var(--color-text-action, #6837fc)', - danger: 'var(--color-text-danger, #ef4d56)', - default: 'var(--color-text-default, #1a2634)', - disabled: 'var(--color-text-disabled, #9da4ae)', - info: 'var(--color-text-info, #0aaddf)', - secondary: 'var(--color-text-secondary, #656d7b)', - success: 'var(--color-text-success, #27ab95)', - tertiary: 'var(--color-text-tertiary, #9da4ae)', - warning: 'var(--color-text-warning, #ff9f43)', - }, -} as const - export type TokenEntry = { value: string description: string @@ -157,7 +106,130 @@ export const easing: Record = { }, } -export type TokenCategory = keyof typeof tokens -export type TokenName = keyof (typeof tokens)[C] -export type RadiusScale = keyof typeof radius -export type ShadowScale = keyof typeof shadow +// ============================================================================= +// Flat token constants — semantic tokens as CSS value strings. +// Use directly in any context that accepts a CSS value: +// (recharts prop) +// style={{ color: colorTextSecondary }} (inline style) +// border: `1px solid ${colorBorderDefault}` (template strings) +// var() resolves at render; theme toggle updates colours via CSS cascade. +// ============================================================================= + +// Border +export const colorBorderAction = 'var(--color-border-action, #6837fc)' +export const colorBorderDanger = 'var(--color-border-danger, #ef4d56)' +export const colorBorderDefault = + 'var(--color-border-default, rgba(101, 109, 123, 0.16))' +export const colorBorderDisabled = + 'var(--color-border-disabled, rgba(101, 109, 123, 0.08))' +export const colorBorderInfo = 'var(--color-border-info, #0aaddf)' +export const colorBorderStrong = + 'var(--color-border-strong, rgba(101, 109, 123, 0.24))' +export const colorBorderSuccess = 'var(--color-border-success, #27ab95)' +export const colorBorderWarning = 'var(--color-border-warning, #ff9f43)' + +// Icon +export const colorIconAction = 'var(--color-icon-action, #6837fc)' +export const colorIconDanger = 'var(--color-icon-danger, #ef4d56)' +export const colorIconDefault = 'var(--color-icon-default, #1a2634)' +export const colorIconDisabled = 'var(--color-icon-disabled, #9da4ae)' +export const colorIconInfo = 'var(--color-icon-info, #0aaddf)' +export const colorIconSecondary = 'var(--color-icon-secondary, #656d7b)' +export const colorIconSuccess = 'var(--color-icon-success, #27ab95)' +export const colorIconWarning = 'var(--color-icon-warning, #ff9f43)' + +// Surface +export const colorSurfaceAction = 'var(--color-surface-action, #6837fc)' +export const colorSurfaceActionActive = + 'var(--color-surface-action-active, #3919b7)' +export const colorSurfaceActionHover = + 'var(--color-surface-action-hover, #4e25db)' +export const colorSurfaceActionMuted = + 'var(--color-surface-action-muted, rgba(104, 55, 252, 0.16))' +export const colorSurfaceActionSubtle = + 'var(--color-surface-action-subtle, rgba(104, 55, 252, 0.08))' +export const colorSurfaceActive = + 'var(--color-surface-active, rgba(0, 0, 0, 0.16))' +export const colorSurfaceDanger = + 'var(--color-surface-danger, rgba(239, 77, 86, 0.08))' +export const colorSurfaceDefault = 'var(--color-surface-default, #ffffff)' +export const colorSurfaceEmphasis = 'var(--color-surface-emphasis, #e0e3e9)' +export const colorSurfaceHover = + 'var(--color-surface-hover, rgba(0, 0, 0, 0.08))' +export const colorSurfaceInfo = + 'var(--color-surface-info, rgba(10, 173, 223, 0.08))' +export const colorSurfaceMuted = 'var(--color-surface-muted, #eff1f4)' +export const colorSurfaceSubtle = 'var(--color-surface-subtle, #fafafb)' +export const colorSurfaceSuccess = + 'var(--color-surface-success, rgba(39, 171, 149, 0.08))' +export const colorSurfaceWarning = + 'var(--color-surface-warning, rgba(255, 159, 67, 0.08))' + +// Text +export const colorTextAction = 'var(--color-text-action, #6837fc)' +export const colorTextDanger = 'var(--color-text-danger, #ef4d56)' +export const colorTextDefault = 'var(--color-text-default, #1a2634)' +export const colorTextDisabled = 'var(--color-text-disabled, #9da4ae)' +export const colorTextInfo = 'var(--color-text-info, #0aaddf)' +export const colorTextSecondary = 'var(--color-text-secondary, #656d7b)' +export const colorTextSuccess = 'var(--color-text-success, #27ab95)' +export const colorTextTertiary = 'var(--color-text-tertiary, #9da4ae)' +export const colorTextWarning = 'var(--color-text-warning, #ff9f43)' + +// Chart +export const colorChart1 = 'var(--color-chart-1, #0aaddf)' +export const colorChart2 = 'var(--color-chart-2, #ef4d56)' +export const colorChart3 = 'var(--color-chart-3, #27ab95)' +export const colorChart4 = 'var(--color-chart-4, #ff9f43)' +export const colorChart5 = 'var(--color-chart-5, #7a4dfc)' +export const colorChart6 = 'var(--color-chart-6, #0b8bb2)' +export const colorChart7 = 'var(--color-chart-7, #e61b26)' +export const colorChart8 = 'var(--color-chart-8, #13787b)' +export const colorChart9 = 'var(--color-chart-9, #fa810c)' +export const colorChart10 = 'var(--color-chart-10, #6837fc)' + +// Chart palette — indexed access for building colour maps. +export const CHART_COLOURS = [ + colorChart1, + colorChart2, + colorChart3, + colorChart4, + colorChart5, + colorChart6, + colorChart7, + colorChart8, + colorChart9, + colorChart10, +] as const + +// Radius +export const radius2xl = 'var(--radius-2xl, 18px)' +export const radiusFull = 'var(--radius-full, 9999px)' +export const radiusLg = 'var(--radius-lg, 8px)' +export const radiusMd = 'var(--radius-md, 6px)' +export const radiusNone = 'var(--radius-none, 0px)' +export const radiusSm = 'var(--radius-sm, 4px)' +export const radiusXl = 'var(--radius-xl, 10px)' +export const radiusXs = 'var(--radius-xs, 2px)' + +// Shadow +export const shadowLg = + 'var(--shadow-lg, 0px 8px 16px oklch(from var(--slate-1000) l c h / 0.15))' +export const shadowMd = + 'var(--shadow-md, 0px 4px 8px oklch(from var(--slate-1000) l c h / 0.12))' +export const shadowSm = + 'var(--shadow-sm, 0px 1px 2px oklch(from var(--slate-1000) l c h / 0.05))' +export const shadowXl = + 'var(--shadow-xl, 0px 12px 24px oklch(from var(--slate-1000) l c h / 0.20))' + +// Duration +export const durationFast = 'var(--duration-fast, 100ms)' +export const durationNormal = 'var(--duration-normal, 200ms)' +export const durationSlow = 'var(--duration-slow, 300ms)' + +// Easing +export const easingEntrance = + 'var(--easing-entrance, cubic-bezier(0.0, 0, 0.38, 0.9))' +export const easingExit = 'var(--easing-exit, cubic-bezier(0.2, 0, 1, 0.9))' +export const easingStandard = + 'var(--easing-standard, cubic-bezier(0.2, 0, 0.38, 0.9))' diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 85d05a85e86b..3bf44f8e93d0 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -1211,14 +1211,22 @@ export type Res = { releasePipeline: SingleReleasePipeline pipelineStages: PagedResponse featureCodeReferences: FeatureCodeReferences[] - featureAnalytics: ({ - day: string - } & { - [environmentId: string]: number - })[] + featureAnalytics: { + chartData: ({ + day: string + } & { + [environmentId: string]: number + })[] + rawEntries: Res['environmentAnalytics'] + } environmentAnalytics: { day: string count: number + labels?: { + user_agent?: string | null + client_application_name?: string | null + client_application_version?: string | null + } | null }[] featureList: { results: ProjectFlag[] diff --git a/frontend/documentation/DecisionFramework.mdx b/frontend/documentation/DecisionFramework.mdx index 7629a604ad27..3f37a011c48d 100644 --- a/frontend/documentation/DecisionFramework.mdx +++ b/frontend/documentation/DecisionFramework.mdx @@ -120,9 +120,9 @@ Run `git commit` — the pre-commit hook generates `_tokens.scss` and `tokens.ts // Icons reference the icon token -// Or via TS exports -import { tokens } from 'common/theme/tokens' - +// Or via TS exports — flat camelCase constants +import { colorIconDanger } from 'common/theme/tokens' + ``` Dark mode works automatically — no extra overrides needed. diff --git a/frontend/documentation/TokenReference.generated.stories.tsx b/frontend/documentation/TokenReference.generated.stories.tsx index f85d80ec4671..6c5809bb7dc3 100644 --- a/frontend/documentation/TokenReference.generated.stories.tsx +++ b/frontend/documentation/TokenReference.generated.stories.tsx @@ -384,6 +384,108 @@ export const AllTokens: StoryObj = { +

Chart colours

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TokenValueUsage
+ --color-chart-1 + + #0aaddf + First series in charts. Blue.
+ --color-chart-2 + + #ef4d56 + Second series. Red.
+ --color-chart-3 + + #27ab95 + Third series. Green.
+ --color-chart-4 + + #ff9f43 + Fourth series. Orange.
+ --color-chart-5 + + #7a4dfc + Fifth series. Purple.
+ --color-chart-6 + + #0b8bb2 + Sixth series. Blue dark.
+ --color-chart-7 + + #e61b26 + Seventh series. Red dark.
+ --color-chart-8 + + #13787b + Eighth series. Green dark.
+ --color-chart-9 + + #fa810c + Ninth series. Orange dark.
+ --color-chart-10 + + #6837fc + Tenth series. Purple dark.

Radius

diff --git a/frontend/documentation/components/BarChart.stories.tsx b/frontend/documentation/components/BarChart.stories.tsx new file mode 100644 index 000000000000..8c256add8538 --- /dev/null +++ b/frontend/documentation/components/BarChart.stories.tsx @@ -0,0 +1,153 @@ +import React, { useMemo, useState } from 'react' +import type { Meta, StoryObj } from 'storybook' +import BarChart, { ChartDataPoint } from 'components/charts/BarChart' +import { MultiSelect } from 'components/base/select/multi-select' +import { buildChartColorMap } from 'components/charts/buildChartColorMap' + +// ============================================================================ +// Fake data +// ============================================================================ + +const SDKS = [ + 'flagsmith-js-sdk', + 'flagsmith-python-sdk', + 'flagsmith-java-sdk', + 'flagsmith-go-sdk', + 'flagsmith-ruby-sdk', +] + +function generateFakeData(days: number, labels: string[]): ChartDataPoint[] { + const data: ChartDataPoint[] = [] + const now = new Date() + + const baseMap: Record = { + Development: 1200, + Production: 5000, + Staging: 2400, + 'flagsmith-js-sdk': 4500, + 'flagsmith-python-sdk': 2200, + } + + for (let i = days - 1; i >= 0; i--) { + const date = new Date(now) + date.setDate(date.getDate() - i) + const dayStr = date.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + }) + + const point: ChartDataPoint = { day: dayStr } + labels.forEach((label) => { + const base = baseMap[label] || 800 + const variance = Math.floor(Math.random() * base * 0.4) + const weekday = (now.getDay() - i + 7) % 7 + const weekendDip = weekday === 0 || weekday === 6 ? 0.4 : 1 + point[label] = Math.floor((base + variance) * weekendDip) + }) + data.push(point) + } + return data +} + +// ============================================================================ +// Stories +// ============================================================================ + +const meta: Meta = { + component: BarChart, + tags: ['autodocs'], + title: 'Components/BarChart', +} +export default meta + +type Story = StoryObj + +export const WithLabelledBuckets: Story = { + decorators: [ + () => { + const labels = useMemo(() => SDKS.slice(0, 5), []) + const data = useMemo(() => generateFakeData(30, labels), [labels]) + const colorMap = useMemo(() => buildChartColorMap(labels), [labels]) + const [selectedLabels, setSelectedLabels] = useState([]) + + const filteredLabels = + selectedLabels.length > 0 + ? labels.filter((l) => selectedLabels.includes(l)) + : labels + + const labelOptions = labels.map((l) => ({ label: l, value: l })) + + return ( +
+

+ Stacked by SDK label — each color represents a different SDK sending + evaluations. +

+
+ +
+ +
+ ) + }, + ], +} + +export const WithoutLabels: Story = { + decorators: [ + () => { + const labels = useMemo(() => ['Production', 'Staging', 'Development'], []) + const data = useMemo(() => generateFakeData(30, labels), [labels]) + const colorMap = useMemo(() => buildChartColorMap(labels), [labels]) + + return ( +
+

+ No labels — grouped by environment (current behaviour). +

+ +
+ ) + }, + ], +} + +export const SingleSeries: Story = { + decorators: [ + () => { + const labels = useMemo(() => ['flagsmith-js-sdk'], []) + const data = useMemo(() => generateFakeData(30, labels), [labels]) + const colorMap = useMemo(() => buildChartColorMap(labels), [labels]) + + return ( +
+

+ Single SDK — no filter needed when there's only one series. +

+ +
+ ) + }, + ], +} diff --git a/frontend/documentation/components/MultiSelect.stories.tsx b/frontend/documentation/components/MultiSelect.stories.tsx new file mode 100644 index 000000000000..545461ace37a --- /dev/null +++ b/frontend/documentation/components/MultiSelect.stories.tsx @@ -0,0 +1,153 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' +import { MultiSelect } from 'components/base/select/multi-select' + +const SDK_OPTIONS = [ + { label: 'flagsmith-js-sdk', value: 'js' }, + { label: 'flagsmith-python-sdk', value: 'python' }, + { label: 'flagsmith-java-sdk', value: 'java' }, + { label: 'flagsmith-go-sdk', value: 'go' }, + { label: 'flagsmith-ruby-sdk', value: 'ruby' }, +] + +const COLOUR_MAP = new Map([ + ['js', '#0aaddf'], + ['python', '#7a4dfc'], + ['java', '#ef4d56'], + ['go', '#27ab95'], + ['ruby', '#ff9f43'], +]) + +const meta: Meta = { + component: MultiSelect, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + title: 'Components/MultiSelect', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + decorators: [ + () => { + const [selected, setSelected] = useState([]) + return ( + + ) + }, + ], +} + +export const WithLabel: Story = { + decorators: [ + () => { + const [selected, setSelected] = useState([]) + return ( + + ) + }, + ], +} + +export const WithColors: Story = { + decorators: [ + () => { + const [selected, setSelected] = useState(['js', 'python']) + return ( + + ) + }, + ], +} + +export const PreSelected: Story = { + decorators: [ + () => { + const [selected, setSelected] = useState([ + 'js', + 'python', + 'java', + ]) + return ( + + ) + }, + ], +} + +export const Disabled: Story = { + decorators: [ + () => ( + {}} + disabled + /> + ), + ], +} + +export const Inline: Story = { + decorators: [ + () => { + const [selected, setSelected] = useState(['js', 'python']) + return ( + + ) + }, + ], +} + +export const Small: Story = { + decorators: [ + () => { + const [selected, setSelected] = useState([]) + return ( + + ) + }, + ], +} diff --git a/frontend/scripts/generate-tokens.mjs b/frontend/scripts/generate-tokens.mjs index b45be36ad549..bfe5ce88601a 100644 --- a/frontend/scripts/generate-tokens.mjs +++ b/frontend/scripts/generate-tokens.mjs @@ -31,7 +31,7 @@ const kebabToCamel = (s) => s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()) const sorted = (obj) => - Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)) + Object.entries(obj).sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true })) const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") const lightVal = (e) => e.light ?? e.value @@ -39,6 +39,8 @@ const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1) const NON_COLOUR = ['radius', 'shadow', 'duration', 'easing'] const DESCRIBED = ['radius', 'shadow', 'duration', 'easing'] +// Chart colours are like colour tokens (light/dark) but not under "color" +const CHART_CATEGORY = 'chart' // Build reverse lookups for primitives const hexToPrimitive = new Map() @@ -133,6 +135,18 @@ function buildScssLines() { rootLines.push('') } + // Chart colour tokens + if (json[CHART_CATEGORY]) { + rootLines.push(' // Chart') + for (const [, e] of sorted(json[CHART_CATEGORY])) { + rootLines.push(` ${e.cssVar}: ${toPrimitiveRef(e.light)};`) + if (e.dark && e.dark !== e.light) { + darkLines.push(` ${e.cssVar}: ${toPrimitiveRef(e.dark)};`) + } + } + rootLines.push('') + } + // Non-colour tokens for (const cat of NON_COLOUR) { if (!json[cat]) continue @@ -150,19 +164,6 @@ function buildScssLines() { return { rootLines, darkLines } } -function buildTsColourLines() { - const lines = [] - for (const [category, entries] of sorted(json.color)) { - lines.push(` ${category}: {`) - for (const [key, e] of sorted(entries)) { - const v = esc(makeCssVar(e.cssVar, e.light)) - lines.push(` ${kebabToCamel(key)}: '${v}',`) - } - lines.push(' },') - } - return lines -} - function buildTsDescribedLines() { const blocks = [] for (const cat of DESCRIBED) { @@ -184,6 +185,87 @@ function buildTsDescribedLines() { return blocks } +/** + * Convert a CSS custom property name to a camelCase JS identifier. + * '--color-text-secondary' → 'colorTextSecondary' + * '--radius-md' → 'radiusMd' + * '--shadow-sm' → 'shadowSm' + */ +function cssVarToConstName(cssVar) { + return kebabToCamel(cssVar.replace(/^--/, '')) +} + +/** + * Build flat camelCase constants for every semantic token in tokens.json. + * + * Each constant is a CSS value string: `var(--token-name, fallback)`. Pass + * directly to SVG attributes, inline styles, or any prop that accepts a CSS + * value. var() resolves at render; theme toggle updates colours via the CSS + * cascade (no hook / DOM read needed). + * + * Primitives (e.g. --blue-500) are deliberately excluded — they're an + * implementation detail of the semantic layer and shouldn't be consumed + * directly by app code. + */ +function buildFlatConstants() { + const lines = [] + + lines.push('// =============================================================================') + lines.push('// Flat token constants — semantic tokens as CSS value strings.') + lines.push('// Use directly in any context that accepts a CSS value:') + lines.push('// (recharts prop)') + lines.push('// style={{ color: colorTextSecondary }} (inline style)') + lines.push('// border: `1px solid ${colorBorderDefault}` (template strings)') + lines.push('// var() resolves at render; theme toggle updates colours via CSS cascade.') + lines.push('// =============================================================================') + lines.push('') + + // Colour tokens under json.color (border, icon, surface, text) + for (const [category, entries] of sorted(json.color)) { + lines.push(`// ${cap(category)}`) + for (const [, e] of sorted(entries)) { + const constName = cssVarToConstName(e.cssVar) + const fallback = lightVal(e) + lines.push(`export const ${constName} = 'var(${e.cssVar}, ${fallback})'`) + } + lines.push('') + } + + // Chart colour tokens live at json.chart (not under json.color) — emit + // their flat constants here, then a CHART_COLOURS palette array for + // buildChartColorMap() consumers. + if (json[CHART_CATEGORY]) { + lines.push('// Chart') + for (const [, e] of sorted(json[CHART_CATEGORY])) { + const constName = cssVarToConstName(e.cssVar) + const fallback = lightVal(e) + lines.push(`export const ${constName} = 'var(${e.cssVar}, ${fallback})'`) + } + lines.push('') + lines.push('// Chart palette — indexed access for building colour maps.') + lines.push('export const CHART_COLOURS = [') + for (const [key] of sorted(json[CHART_CATEGORY])) { + lines.push(` colorChart${key},`) + } + lines.push('] as const') + lines.push('') + } + + // Non-colour tokens — radius, shadow, duration, easing + for (const cat of NON_COLOUR) { + if (!json[cat]) continue + lines.push(`// ${cap(cat)}`) + for (const [, e] of sorted(json[cat])) { + const constName = cssVarToConstName(e.cssVar) + const fallback = lightVal(e) + lines.push(`export const ${constName} = 'var(${e.cssVar}, ${fallback})'`) + } + lines.push('') + } + + return lines +} + function buildTableRows(title, entries, opts = {}) { const rows = [] rows.push(`

${title}

`) @@ -236,8 +318,8 @@ function generateScss() { } function generateTs() { - const colourLines = buildTsColourLines() const describedBlocks = buildTsDescribedLines() + const flatLines = buildFlatConstants() const output = [ '// =============================================================================', @@ -245,10 +327,6 @@ function generateTs() { '// Do not edit manually. Run: npm run generate:tokens', '// =============================================================================', '', - 'export const tokens = {', - ...colourLines, - '} as const', - '', 'export type TokenEntry = {', ' value: string', ' description: string', @@ -256,11 +334,7 @@ function generateTs() { '', ...describedBlocks, '', - 'export type TokenCategory = keyof typeof tokens', - 'export type TokenName = keyof (typeof tokens)[C]', - 'export type RadiusScale = keyof typeof radius', - 'export type ShadowScale = keyof typeof shadow', - '', + ...flatLines, ] return output.join('\n') @@ -278,6 +352,16 @@ function generateMcpStory() { tables.push(...buildTableRows(`Colour: ${cat}`, data)) } + // Chart colours + if (json[CHART_CATEGORY]) { + const chartData = Object.values(json[CHART_CATEGORY]).map((e) => ({ + cssVar: e.cssVar, + value: e.light, + description: e.description || '', + })) + tables.push(...buildTableRows('Chart colours', chartData, { showDescription: true })) + } + for (const cat of DESCRIBED) { if (!json[cat]) continue const data = Object.values(json[cat]).map((e) => ({ diff --git a/frontend/web/components/ColorSwatch.tsx b/frontend/web/components/ColorSwatch.tsx new file mode 100644 index 000000000000..9e4ece554ce0 --- /dev/null +++ b/frontend/web/components/ColorSwatch.tsx @@ -0,0 +1,35 @@ +import React, { FC } from 'react' +import classNames from 'classnames' + +type ColorSwatchSize = 'sm' | 'md' | 'lg' + +type ColorSwatchProps = { + color: string + size?: ColorSwatchSize + className?: string +} + +const SIZE_MAP: Record = { + lg: 16, + md: 12, + sm: 8, +} + +const ColorSwatch: FC = ({ + className, + color, + size = 'md', +}) => ( +