Files
Danny White a31ca2bad0 fix(ui): make brand text readable across themes (#49871)
## What kind of change does this PR introduce?

Bug fix and design-system update. Resolves DEPR-316.

Follow-up rename (`text-brand` → `text-primary`) is in a dedicated PR
(https://github.com/supabase/supabase/pull/50564) stacked on this one.

## What is the current behavior?

`text-brand` resolves to the canonical bright brand green in places that
need readable text, which fails WCAG AA on light surfaces. A separate
`text-brand-display` token adds another green for large type.

## What is the new behavior?

- `text-brand` maps to accessible `--primary` (light mode darkened to
meet ~4.5:1 AA)
- `--hue` / `--primary-hue` aligned to 157.5
- `text-brand-display` removed; former display callsites use
`text-brand`
- Bright fills/borders stay on `brand-default`
- Design-system colour and typography docs updated

| Before | After |
| --- | --- |
| <img width="514" height="112" alt="CleanShot 2026-09-02 at 11 13
09@2x"
src="https://github.com/user-attachments/assets/4e0138a9-a32d-4e4c-a426-90736706e1e7"
/> | <img width="512" height="138" alt="CleanShot 2026-09-21 at 11 42
05@2x"
src="https://github.com/user-attachments/assets/164cc5b1-a0c5-4e93-95f1-80016641a114"
/> |
| <img width="864" height="266" alt="CleanShot 2026-09-02 at 11 13
53@2x"
src="https://github.com/user-attachments/assets/3c1ca53f-bf9e-431e-bc15-816b4a275b8e"
/> | <img width="882" height="248" alt="CleanShot 2026-09-21 at 11 41
37@2x"
src="https://github.com/user-attachments/assets/24828e7b-ed6b-44cb-b9dc-becc3398bdfc"
/> |
| <img width="782" height="692" alt="CleanShot 2026-09-02 at 11 16
30@2x"
src="https://github.com/user-attachments/assets/fc871977-77bc-47fb-9e0e-9284e0ecd5cc"
/> | <img width="730" height="690" alt="CleanShot 2026-09-21 at 11 42
52@2x"
src="https://github.com/user-attachments/assets/bf479515-d5f9-471e-b82d-f097c0f4b56c"
/> |
| <img width="480" height="306" alt="CleanShot 2026-09-02 at 11 18
53@2x"
src="https://github.com/user-attachments/assets/03f341f4-f02e-44f8-a2b2-8c31670d0427"
/> | <img width="470" height="300" alt="CleanShot 2026-09-21 at 11 43
19@2x"
src="https://github.com/user-attachments/assets/9df18217-d5e6-48b8-ba0b-579d2664b94b"
/> |
| <img width="960" height="300" alt="CleanShot 2026-09-02 at 11 32
04@2x"
src="https://github.com/user-attachments/assets/6b1d9373-7a71-4247-81ff-26441604b09d"
/> | <img width="980" height="306" alt="CleanShot 2026-09-21 at 11 44
13@2x"
src="https://github.com/user-attachments/assets/41ad4784-02ec-4b29-b860-32af9fa79aa8"
/> |
| <img width="924" height="214" alt="CleanShot 2026-09-02 at 11 34
44@2x"
src="https://github.com/user-attachments/assets/1de661fe-c7b6-499b-a94f-e4737436ec79"
/> | <img width="752" height="162" alt="CleanShot 2026-09-21 at 11 44
56@2x"
src="https://github.com/user-attachments/assets/1811890f-0660-4445-84e9-447720954fa1"
/> |

## To test

Test each callsite **in light mode** (dark mode is largely unchanged).

### WWW

-
[Homepage](https://zone-www-dot-com-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/):
“Scale to millions” uses readable brand text (display token is gone)
-
[Careers](https://zone-www-dot-com-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/careers):
small “Careers” eyebrow readable; green dividers stay bright
`brand-default`
-
[Contact](https://zone-www-dot-com-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/contact-us):
email / policy links use readable brand text
-
[Regions](https://zone-www-dot-com-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/regions):
“Ask about early access to BYOC” readable

### Docs

- [Docs
homepage](https://docs-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/docs):
“DOCS” wordmark and resource links readable
- [Database
overview](https://docs-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/docs/guides/database/overview):
nav / footer brand links readable
- [JavaScript
reference](https://docs-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/docs/reference/javascript/introduction):
active sidebar treatment readable

### Design system

-
[Typography](https://design-system-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/docs/typography):
documents `text-brand` only (no display)
- [Colour
usage](https://design-system-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/docs/color-usage):
`text-brand` vs `bg-brand-default`
- [Design-system
homepage](https://design-system-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/):
brand text examples across themes

### Studio

- [Auth
providers](https://studio-staging-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/dashboard/project/_/auth/providers):
enabled provider badge text readable; status dot stays bright
- [Database
policies](https://studio-staging-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/dashboard/project/_/database/policies?new=true):
template hover text more legible
- [Database
connections](https://studio-staging-git-dnywh-depr-316-brand-text-tokens-supabase.vercel.app/dashboard/project/_/observability/connections):
“Live” status readable; animated dot stays bright green

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-09-22 10:07:28 +10:00

189 lines
5.5 KiB
TypeScript

import {
Activity,
Database,
GitMerge,
Hash,
Layers,
ListFilter,
SortAsc,
Zap,
type LucideIcon,
} from 'lucide-react'
// Get human-readable description for an operation
export function getOperationDescription(operation: string): string {
const op = operation.toLowerCase()
if (op.includes('seq scan')) {
return 'Reads entire table row by row'
}
if (op.includes('index only scan')) {
return 'Reads data directly from index (fastest)'
}
if (op.includes('bitmap index scan')) {
return 'Builds bitmap of matching rows from index'
}
if (op.includes('bitmap heap scan')) {
return 'Fetches rows using bitmap'
}
if (op.includes('index scan')) {
return 'Uses index to find matching rows'
}
if (op.includes('hash left join')) {
return 'Returns all left rows with matching right rows via hash'
}
if (op.includes('hash right join')) {
return 'Returns all right rows with matching left rows via hash'
}
if (op.includes('hash full join')) {
return 'Returns all rows from both tables via hash'
}
if (op.includes('hash anti join')) {
return 'Returns rows without matches via hash'
}
if (op.includes('hash semi join')) {
return 'Returns rows with at least one match via hash'
}
if (op.includes('hash join')) {
return 'Joins tables using hash lookup'
}
if (op.includes('merge left join')) {
return 'Returns all left rows with matching right rows via merge'
}
if (op.includes('merge right join')) {
return 'Returns all right rows with matching left rows via merge'
}
if (op.includes('merge full join')) {
return 'Returns all rows from both tables via merge'
}
if (op.includes('merge anti join')) {
return 'Returns rows without matches via merge'
}
if (op.includes('merge semi join')) {
return 'Returns rows with at least one match via merge'
}
if (op.includes('merge join')) {
return 'Joins pre-sorted tables'
}
if (op.includes('nested loop left join')) {
return 'Returns all left rows with matching right rows via loop'
}
if (op.includes('nested loop anti join')) {
return 'Returns rows without matches via loop'
}
if (op.includes('nested loop semi join')) {
return 'Returns rows with at least one match via loop'
}
if (op.includes('nested loop')) {
return 'Joins by looping through each row'
}
if (op === 'hash') {
return 'Builds hash table for fast lookups'
}
if (op.includes('sort')) {
return 'Sorts rows for output or join'
}
if (op.includes('aggregate') || op.includes('group')) {
return 'Groups rows and calculates aggregates'
}
if (op.includes('limit')) {
return 'Returns only first N rows'
}
if (op.includes('materialize')) {
return 'Stores results in memory for reuse'
}
if (op.includes('gather')) {
return 'Collects results from parallel workers'
}
return ''
}
// Get an icon for the operation type
export function getOperationIcon(operation: string): LucideIcon {
const op = operation.toLowerCase()
if (op === 'hash') return Hash
if (op.includes('hash join')) return GitMerge
if (op.includes('merge join')) return GitMerge
if (op.includes('nested loop')) return GitMerge
if (op.includes('join')) return Layers
if (op.includes('index')) return Zap
if (op.includes('seq scan')) return Database
if (op.includes('scan')) return Database
if (op.includes('filter')) return ListFilter
if (op.includes('sort')) return SortAsc
if (op.includes('aggregate') || op.includes('group')) return Activity
return Database
}
// Get a color class for the operation type
export function getOperationColor(operation: string): string {
const op = operation.toLowerCase()
if (op.includes('seq scan')) return 'text-warning'
if (op.includes('index')) return 'text-brand'
if (op.includes('join')) return 'text-foreground-light'
if (op.includes('sort') || op.includes('aggregate')) return 'text-foreground-light'
return 'text-foreground-light'
}
export function isExplainQuery(rows: readonly unknown[]): boolean {
if (rows.length === 0) return false
const firstRow = rows[0]
if (typeof firstRow !== 'object' || firstRow === null) return false
return 'QUERY PLAN' in firstRow && Object.keys(firstRow).length === 1
}
export function formatNodeDuration(ms: number | undefined): string {
if (ms === undefined) return '-'
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
if (ms >= 1) return `${ms.toFixed(2)}ms`
if (ms >= 0.01) return `${ms.toFixed(2)}ms`
if (ms >= 0.001) return `${ms.toFixed(3)}ms`
const us = ms * 1000
if (us >= 0.1) return `${us.toFixed(1)}µs`
return `${us.toFixed(2)}µs`
}
export function getScanBarColor(operation: string): string {
const op = operation.toLowerCase()
// Index scans are green
if (
op.includes('index scan') ||
op.includes('index only scan') ||
op.includes('bitmap index scan')
) {
return 'bg-brand-default/20'
}
// Sequential scans are yellow
if (op.includes('seq scan') || op.includes('sequential scan')) {
return 'bg-warning/20'
}
// Default neutral color for other operations
return 'bg-foreground/6'
}
export function getScanBorderColor(operation: string): string {
const op = operation.toLowerCase()
// Index scans are green
if (
op.includes('index scan') ||
op.includes('index only scan') ||
op.includes('bitmap index scan')
) {
return 'border-l-brand'
}
// Sequential scans are yellow
if (op.includes('seq scan') || op.includes('sequential scan')) {
return 'border-l-warning'
}
// Default neutral color for other operations
return 'border-l-border-muted'
}