fix(ui): align primary colours across text, buttons, and controls (#50697)

## Problem

Primary colour serves readable text and selected controls, but those
uses need different shades. Light mode needs darker text, while dark
mode needs a deeper button fill. Fixed brand green on interactive chrome
also prevents a custom primary hue from carrying through the interface.
Some slider tracks and selected text are hard to read.

## Solution

- Keep `--primary` for accessible text and small selected indicators.
Use `--primary-solid` for button fills, which need a deeper shade in
dark mode.
- Add `--primary-bright` for focus rings, selected control chrome, chart
accents, and other interactive highlights. It follows `--primary-hue`;
`brand-*` stays fixed for Supabase identity.
- Make slider troughs clearer and text selection translucent with theme
foreground text.
- Document the split in the design-system colour guide.

| Before | After |
| --- | --- |
| <img width="980" height="244" alt="Before: light mode primary
controls"
src="https://github.com/user-attachments/assets/dfae325d-0dfe-4231-8bcd-3f89c4b9d793"
/> | <img width="982" height="204" alt="After: light mode primary
controls"
src="https://github.com/user-attachments/assets/5fdcb531-a6e3-4549-8a13-9d9a5ebe6e20"
/> |
| <img width="610" height="120" alt="Before: slider track"
src="https://github.com/user-attachments/assets/04f768e0-51e8-4d06-9b97-c52f4a34f122"
/> | <img width="622" height="126" alt="After: slider track"
src="https://github.com/user-attachments/assets/95127f4e-13dc-4f0f-b63c-cf5d70a28b42"
/> |
| <img width="652" height="512" alt="Before: dark mode controls"
src="https://github.com/user-attachments/assets/3f88de66-90cc-40ee-8cf1-b5f4eb87b09a"
/> | <img width="658" height="498" alt="After: dark mode controls"
src="https://github.com/user-attachments/assets/906bec30-6ca1-4614-9fb3-6cf5e5feec22"
/> |

## Review instructions

1. Compare light and dark mode in the [colour usage
guide](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/color-usage#primary-and-brand-colors).
Check primary ink, primary-solid, primary-bright, and fixed brand
swatches.
2. In Studio, open the ‘new table’ sheet in [Table
Editor](https://studio-staging-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/dashboard/project/_/editor).
Tab through the new table sheet's fields and toggles. Check the focus
rings, selected controls, and the sheet's edges in both themes. You do
not need to save a table.
3. Select text in Studio in both themes, including a link or
primary-coloured label. The selection and text should remain legible.
4. Check the
[Field](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/components/field)
Price Range slider: the unused track should remain visible in both
themes. The selected field card border should follow primary-bright.
5. Check the
[Button](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/components/button)
and [Radio
Group](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/components/radio-group)
previews. In dark mode, `primary` button fill should be deeper than
primary
[text](https://design-system-git-dnywh-fix-bright-brand-chrome-supabase.vercel.app/design-system/docs/color-usage#text);
selected radios should remain readable.
This commit is contained in:
Danny White authored and GitHub committed 2026-09-24 09:56:31 +10:00
1 parent 76c4f2b739
commit d067e81a69
68 files changed
+276 -189

No files matched your search

+6 -1
View File
@@ -14,7 +14,11 @@
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-hover: var(--primary-hover);
--color-primary-bright: var(--primary-bright);
--color-primary-foreground: var(--primary-foreground);
--color-primary-solid: var(--primary-solid);
--color-primary-solid-foreground: var(--primary-solid-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-tertiary: var(--tertiary);
@@ -74,7 +78,7 @@
--color-brand-400: hsl(var(--brand-400));
--color-brand-500: hsl(var(--brand-500));
--color-brand-600: hsl(var(--brand-600));
/* Alias leftover bg-brand / border-brand to the bright fill. Prefer brand-default / primary. */
/* Brand remains the fixed Supabase palette. Functional chrome uses primary. */
--color-brand: hsl(var(--brand-default));
--color-brand-default: hsl(var(--brand-default));
--color-_secondary-200: hsl(var(--secondary-200));
@@ -334,6 +338,7 @@
--color-border-warning: var(--border-warning);
--color-border-info: var(--border-info);
--color-border-brand-default: var(--border-brand-default);
--color-border-primary-bright: var(--border-primary-bright);
/*
* This will generate colors only for border utilities
* i.e. bg-muted and they'll only have border-color: var(--border-muted) rule
@@ -20,8 +20,8 @@ const DIMMED_FILL_OPACITY = 0.35
const CHART_COLORS = {
TICK: 'var(--background-overlay-hover)',
AXIS: 'var(--background-overlay-hover)',
BRAND: 'hsl(var(--brand-default))',
BRAND_HOVER: 'hsl(var(--brand-500))',
PRIMARY: 'var(--primary-bright)',
PRIMARY_HOVER: 'var(--primary-bright-hover)',
}
export type ChartBarTick =
@@ -97,8 +97,8 @@ export const ChartBar = ({
DateTimeFormat = 'MMM D, YYYY, hh:mma',
isFullHeight = false,
className,
color = CHART_COLORS.BRAND,
hoverColor = CHART_COLORS.BRAND_HOVER,
color = CHART_COLORS.PRIMARY,
hoverColor = CHART_COLORS.PRIMARY_HOVER,
chartHighlight,
syncId,
showHighlightArea = true,
@@ -18,8 +18,8 @@ import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, cn } fr
const CHART_COLORS = {
TICK: 'var(--background-overlay-hover)',
AXIS: 'var(--background-overlay-hover)',
BRAND: 'hsl(var(--brand-default))',
BRAND_HOVER: 'hsl(var(--brand-500))',
PRIMARY: 'var(--primary-bright)',
PRIMARY_HOVER: 'var(--primary-bright-hover)',
}
export type ChartLineTick =
@@ -104,7 +104,7 @@ export const ChartLine = ({
DateTimeFormat = 'MMM D, YYYY, hh:mma',
isFullHeight = false,
className,
color = CHART_COLORS.BRAND,
color = CHART_COLORS.PRIMARY,
chartHighlight,
syncId,
showHighlightArea = true,
+5 -5
View File
@@ -448,7 +448,7 @@ const ChartDisabledState = ({ icon, label, description, actions }: ChartDisabled
{
demo: {
label: 'Demo',
color: 'hsl(var(--brand-default))',
color: 'var(--primary-bright)',
},
} satisfies ChartConfig
}
@@ -457,7 +457,7 @@ const ChartDisabledState = ({ icon, label, description, actions }: ChartDisabled
<BarChart data={demoData}>
<XAxis dataKey="period_start" hide />
<YAxis hide />
<Bar dataKey="demo" fill="hsl(var(--brand-default))" />
<Bar dataKey="demo" fill="var(--primary-bright)" />
</BarChart>
</ChartContainer>
</div>
@@ -584,8 +584,8 @@ const ChartSparkline = React.forwardRef<HTMLDivElement, ChartSparklineProps>(
<AreaChart data={data} margin={{ top: 5, left: 0, right: 0, bottom: 0 }}>
<defs>
<linearGradient id="sparklineGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--brand-default))" stopOpacity={0.8} />
<stop offset="95%" stopColor="hsl(var(--brand-default))" stopOpacity={0} />
<stop offset="5%" stopColor="var(--primary-bright)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--primary-bright)" stopOpacity={0} />
</linearGradient>
</defs>
<RechartsTooltip content={<ChartSparklineTooltip />} />
@@ -594,7 +594,7 @@ const ChartSparkline = React.forwardRef<HTMLDivElement, ChartSparklineProps>(
dataKey={dataKey || 'value'}
fill="url(#sparklineGradient)"
fillOpacity={0.1}
stroke="hsl(var(--brand-default))"
stroke="var(--primary-bright)"
strokeWidth={1.5}
/>
</AreaChart>
@@ -265,7 +265,7 @@ export function FilterCondition({
className={cn(
'flex items-stretch px-0 h-[26px] bg-muted group shrink-0',
variant === 'pill' ? 'rounded-sm border' : 'border-r',
isHighlighted && 'ring-2 ring-primary'
isHighlighted && 'ring-2 ring-primary-bright'
)}
data-testid={`filter-condition-${property.name}`}
data-highlighted={isHighlighted}
@@ -263,8 +263,8 @@ const MetricCardSparkline = React.forwardRef<HTMLDivElement, MetricCardSparkline
<AreaChart data={data} margin={{ top: 5, left: 0, right: 0, bottom: 0 }}>
<defs>
<linearGradient id="sparklineGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--brand-default))" stopOpacity={0.8} />
<stop offset="95%" stopColor="hsl(var(--brand-default))" stopOpacity={0} />
<stop offset="5%" stopColor="var(--primary-bright)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--primary-bright)" stopOpacity={0} />
</linearGradient>
</defs>
<RechartsTooltip content={<SparklineTooltip />} />
@@ -273,7 +273,7 @@ const MetricCardSparkline = React.forwardRef<HTMLDivElement, MetricCardSparkline
dataKey={dataKey || 'value'}
fill="url(#sparklineGradient)"
fillOpacity={0.1}
stroke="hsl(var(--brand-default))"
stroke="var(--primary-bright)"
strokeWidth={1.5}
/>
</AreaChart>
+36 -6
View File
@@ -13,7 +13,7 @@
* and the brand, so the two could never drift apart. They are now split so a
* theme can run, say, a cool-gray surface against the green brand:
* --surface-hue tints backgrounds, text and borders (the neutral ramp)
* --primary-hue is the brand hue that --primary is built from
* --primary-hue is the spot hue that functional primary colors use
* Both default to --hue (Supabase green ≈157.5° in OKLCH), so a theme that only
* sets --hue keeps surfaces and brand locked together as before; override
* either one to let them diverge. */
@@ -150,7 +150,7 @@
);
/* Inverse of --foreground: canvas-coloured text for use on inverted fills
(bg-foreground, bg-black / dark:bg-white, etc). Not the same as
--primary-foreground, which stays light for the dark primary fill. */
--primary-solid-foreground, which stays light on the deep button plate. */
--foreground-contrast: oklch(var(--surface) calc(var(--chroma) * 0.55) var(--surface-hue));
--card: oklch(from var(--background) calc(l + var(--elevation-step) * var(--elevation-1)) c h);
@@ -159,14 +159,22 @@
--popover-foreground: var(--foreground);
/*
* Primary is the accessible functional green (text, controls). Bright
* expressive fills use brand-default. Light theme overrides --primary for AA text.
* Primary ink vs primary solid fill:
* --primary Readable branded text / small indicators. Root
* values mirror dark theme (bright). Light theme
* overrides for AA text on light surfaces.
* --primary-solid Deeper button plate (dark mode). Light theme
* aliases these to --primary so ink and buttons
* share one green there.
* --primary-bright keeps rings and selected chrome vivid while sharing the
* hue of --primary. It is brighter than readable ink in light mode.
*
* Hover shifts lightness only (preserves hue/chroma). Mixing with --foreground
* pulls light-mode surface-hue (warm) into the green and reads as yellow.
* Sign of --tone-span: darken in light, lighten in dark.
*/
--primary: oklch(0.76 0.15 var(--primary-hue));
--primary-bright: oklch(from var(--primary) max(l, 0.7) max(c, 0.15) h);
--button-fill-hover-delta: 0.04;
--primary-hover: oklch(
from var(--primary)
@@ -176,10 +184,29 @@
)
c h
);
--primary-bright-hover: oklch(
from var(--primary-bright)
calc(
l + var(--button-fill-hover-delta) * var(--tone-span) /
max(var(--tone-span), calc(var(--tone-span) * -1))
)
c h
);
--primary-foreground: oklch(
min(var(--surface), var(--foreground-lightness)) calc(var(--chroma) * 0.45) var(--primary-hue)
);
--primary-solid: oklch(0.43627 0.11 var(--primary-hue));
--primary-solid-hover: oklch(
from var(--primary-solid)
calc(
l + var(--button-fill-hover-delta) * var(--tone-span) /
max(var(--tone-span), calc(var(--tone-span) * -1))
)
c h
);
--primary-solid-foreground: oklch(0.95 0.00275 var(--primary-hue));
--secondary: oklch(
from var(--background) calc(l + var(--elevation-step) * var(--elevation-3)) c h
);
@@ -274,7 +301,9 @@
--border-destructive: oklch(from var(--destructive) l c h / 30%);
--border-warning: oklch(from var(--warning) l c h / 30%);
--border-info: oklch(from var(--info) l c h / 30%);
--border-brand-default: oklch(from var(--primary) l c h / 30%);
/* Keep the brand border for explicitly Supabase-branded surfaces. */
--border-brand-default: oklch(from hsl(var(--brand-default)) l c h / 30%);
--border-primary-bright: oklch(from var(--primary-bright) l c h / 30%);
--border: oklch(
from var(--foreground) l calc(c * 0.54) h / calc(2% + 20% * var(--contrast-border))
@@ -290,5 +319,6 @@
--border-control-hover: oklch(
from var(--foreground) l calc(c * 0.5) h / calc(12% + 58% * var(--contrast-border))
);
--ring: oklch(from var(--primary) l c h / 55%);
/* Focus chrome follows the theme's primary hue without muddy light-mode ink. */
--ring: oklch(from var(--primary-bright) l c h / 55%);
}
+6 -1
View File
@@ -16,9 +16,14 @@
--foreground-lightness: 0.1;
--muted-foreground-level: 0.65;
--tertiary-foreground-level: 0.5;
/* Accessible primary for text/controls (~4.5:1+ on light surfaces). */
/* Accessible primary for text and small indicators on light surfaces (~4.5:1+).
Solid button plates alias to the same green in light mode. Focus rings and
bright interactive chrome uses --primary-bright. */
--primary: oklch(0.525 0.12 var(--primary-hue));
--primary-foreground: oklch(0.99 0.00275 var(--primary-hue));
--primary-solid: var(--primary);
--primary-solid-foreground: var(--primary-foreground);
--primary-solid-hover: var(--primary-hover);
/*
* Expressive fills run darker than the dark-theme anchors so they hold contrast
* against the near-white surface (a light amber on white disappears). Lightness
@@ -32,7 +32,7 @@ describe('#Button', () => {
const button = screen.getByRole('button', { name: 'Neutral' })
expect(button.className).toContain('bg-card')
expect(button.className).toContain('hover:bg-muted')
expect(button.className).not.toContain('bg-primary')
expect(button.className).not.toContain('bg-primary-solid')
})
it('should apply tiny size classes when buttonVariants omits size', () => {
@@ -45,7 +45,7 @@ describe('#Button', () => {
render(<Button variant="primary">Primary</Button>)
const button = screen.getByRole('button', { name: 'Primary' })
expect(button.className).toContain('bg-primary')
expect(button.className).toContain('bg-primary-solid')
})
it('should render different text', () => {
+11 -11
View File
@@ -38,12 +38,12 @@ const buttonVariants = cva(
variant: {
primary: `
border-0
bg-primary
bg-primary-solid
bg-[linear-gradient(to_bottom,hsl(var(--colors-white)/0.015),hsl(var(--colors-black)/0.01))]
text-primary-foreground
text-primary-solid-foreground
shadow-[var(--button-shadow-raised)]
hover:bg-[var(--primary-hover)]
data-[state=open]:bg-[var(--primary-hover)]
hover:bg-[var(--primary-solid-hover)]
data-[state=open]:bg-[var(--primary-solid-hover)]
`,
default: `
text-foreground
@@ -76,12 +76,12 @@ const buttonVariants = cva(
data-[state=open]:border-control-hover
`,
link: `
text-brand-600
text-primary
border
border-transparent/0
hover:bg-brand-400
hover:bg-primary-bright/15
shadow-none
data-[state=open]:bg-brand-400
data-[state=open]:bg-primary-bright/15
`,
text: `
text-foreground
@@ -155,13 +155,13 @@ const IconContainerVariants = cva('inline-flex items-center justify-center shrin
xxxlarge: '[&_svg]:h-[42px] [&_svg]:w-[42px]',
},
variant: {
primary: 'text-primary-foreground/50',
primary: 'text-primary-solid-foreground/50',
default: 'text-foreground-lighter',
secondary: 'text-background',
alternative: 'text-foreground-lighter',
outline: 'text-foreground-lighter',
dashed: 'text-foreground-lighter',
link: 'text-brand-600',
link: 'text-primary',
text: 'text-foreground-lighter',
danger: 'text-destructive-foreground/50',
warning: 'text-warning-foreground/50',
@@ -173,13 +173,13 @@ export type LoadingVariantProps = VariantProps<typeof loadingVariants>
const loadingVariants = cva('', {
variants: {
variant: {
primary: 'text-primary-foreground/50',
primary: 'text-primary-solid-foreground/50',
default: 'text-foreground-lighter',
secondary: 'text-background',
alternative: 'text-foreground-lighter',
outline: 'text-foreground-lighter',
dashed: 'text-foreground-lighter',
link: 'text-brand-600',
link: 'text-primary',
text: 'text-foreground-muted',
danger: 'text-destructive-foreground/50',
warning: 'text-warning-foreground/50',
+1 -1
View File
@@ -75,7 +75,7 @@ export const menuItemVariants = cva(
type: 'border',
active: true,
className:
'text-foreground-muted border-l border-brand-default group-hover:border-brand-default',
'text-foreground-muted border-l border-primary-bright group-hover:border-primary-bright',
},
{
type: 'border',
@@ -12,7 +12,8 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-[var(--primary-hover)]',
default:
'bg-primary-solid text-primary-solid-foreground hover:bg-[var(--primary-solid-hover)]',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-control bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
@@ -83,7 +83,8 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
day_button
),
selected: cn(
!fullDateRangeSelected && 'bg-brand-400! dark:bg-brand-500! text-foreground rounded-md',
!fullDateRangeSelected &&
'bg-primary-bright/70! dark:bg-primary-bright/25! text-foreground rounded-md',
selected
),
// Plain accent — range/selected fills use ! so they still win when today is in the selection
@@ -94,15 +95,17 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
),
disabled: cn('text-foreground-muted opacity-50', disabled),
range_start: cn(
fullDateRangeSelected && 'bg-brand-400! dark:bg-brand-500! text-foreground rounded-l-md',
fullDateRangeSelected &&
'bg-primary-bright/70! dark:bg-primary-bright/25! text-foreground rounded-l-md',
range_start
),
range_middle: cn(
'bg-brand-200! dark:bg-brand-400! text-foreground rounded-none',
'bg-primary-bright/25! dark:bg-primary-bright/12! text-foreground rounded-none',
range_middle
),
range_end: cn(
fullDateRangeSelected && 'bg-brand-400! dark:bg-brand-500! text-foreground rounded-r-md',
fullDateRangeSelected &&
'bg-primary-bright/70! dark:bg-primary-bright/25! text-foreground rounded-r-md',
range_end
),
hidden: cn('invisible', hidden),
@@ -106,7 +106,7 @@ function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>)
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
'has-data-[state=checked]:bg-primary-bright/5 has-data-[state=checked]:border-primary-bright dark:has-data-[state=checked]:bg-primary-bright/10',
className
)}
{...props}
@@ -30,7 +30,7 @@ function Slider({
>
<SliderPrimitive.Track
data-slot="slider-track"
className="relative h-1 w-full grow overflow-hidden rounded-full bg-surface-300"
className="relative h-1 w-full grow overflow-hidden rounded-full bg-border-stronger"
>
<SliderPrimitive.Range
data-slot="slider-range"
@@ -7,7 +7,7 @@ import * as React from 'react'
import { cn } from '../../../lib/utils/cn'
const switchRootVariants = cva(
'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border transition-colors focus-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-brand-default data-[state=checked]:hover:bg-brand-600/90 data-[state=unchecked]:bg-control data-[state=unchecked]:hover:bg-border',
'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border transition-colors focus-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary-bright data-[state=checked]:hover:bg-[var(--primary-bright-hover)] data-[state=unchecked]:bg-control data-[state=unchecked]:hover:bg-border',
{
variants: {
size: {
@@ -23,7 +23,7 @@ const switchRootVariants = cva(
)
const switchThumbVariants = cva(
'pointer-events-none block rounded-full bg-foreground-lighter data-[state=checked]:bg-white shadow-lg ring-0 transition-transform',
'pointer-events-none block rounded-full bg-foreground-lighter data-[state=checked]:bg-black shadow-lg ring-0 transition-transform',
{
variants: {
size: {
@@ -60,6 +60,7 @@ export default {
'bg-brand-500',
'bg-brand-600',
'bg-brand-default',
'bg-primary-bright',
'bg-_secondary-200',
'bg-_secondary-400',
'bg-_secondary',