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

258 lines
8.6 KiB
TypeScript

'use client'
import Image from 'next/image'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { CSSProperties, PointerEvent } from 'react'
import { Button, cn } from 'ui'
import { useDevToolbar } from './DevToolbarContext'
import { getEventCountBadge } from './utils'
// Duplicated for tree-shaking — bundler must see literal process.env reference.
// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbar.tsx, feature-flags.tsx
const env = process.env.NEXT_PUBLIC_ENVIRONMENT
const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging'
const POSITION_STORAGE_KEY = 'dev-telemetry-toolbar-position'
const DRAG_THRESHOLD = 4
const MARGIN = 24
const BUTTON_SIZE = 40 // h-10 w-10
// Spring easing: slight overshoot then settle
const SNAP_TRANSITION =
'top 380ms cubic-bezier(0.34, 1.56, 0.64, 1), left 380ms cubic-bezier(0.34, 1.56, 0.64, 1)'
type SnapPosition =
| 'top-left'
| 'top-center'
| 'top-right'
| 'middle-left'
| 'middle-center'
| 'middle-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right'
// All positions expressed as pixel top+left so transitions interpolate cleanly
function getSnapCoords(
position: SnapPosition,
vw: number,
vh: number
): { top: number; left: number } {
const [row, col] = position.split('-')
const top =
row === 'top'
? MARGIN
: row === 'bottom'
? vh - MARGIN - BUTTON_SIZE
: Math.round(vh / 2 - BUTTON_SIZE / 2)
const left =
col === 'left'
? MARGIN
: col === 'right'
? vw - MARGIN - BUTTON_SIZE
: Math.round(vw / 2 - BUTTON_SIZE / 2)
return { top, left }
}
function getNearestSnapPosition(cx: number, cy: number): SnapPosition {
const vw = window.innerWidth
const vh = window.innerHeight
const row = cy < vh / 3 ? 'top' : cy > (2 * vh) / 3 ? 'bottom' : 'middle'
const col = cx < vw / 3 ? 'left' : cx > (2 * vw) / 3 ? 'right' : 'center'
return `${row}-${col}` as SnapPosition
}
function readStoredPosition(): SnapPosition {
if (typeof window === 'undefined') return 'bottom-right'
return (localStorage.getItem(POSITION_STORAGE_KEY) as SnapPosition) ?? 'bottom-right'
}
export function DevToolbarTrigger() {
const { isEnabled, isOpen, setIsOpen, events } = useDevToolbar()
const [snapPosition, setSnapPosition] = useState<SnapPosition>('bottom-right')
const [hasHydrated, setHasHydrated] = useState(false)
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null)
// Holds the last drag pixel position for one RAF to prime the CSS transition
const [releasedAt, setReleasedAt] = useState<{ x: number; y: number } | null>(null)
const [viewport, setViewport] = useState(() => ({
w: typeof window !== 'undefined' ? window.innerWidth : 1920,
h: typeof window !== 'undefined' ? window.innerHeight : 1080,
}))
const dragRef = useRef<{
startPointerX: number
startPointerY: number
startButtonX: number
startButtonY: number
hasDragged: boolean
} | null>(null)
const wasDraggingRef = useRef(false)
// Restore persisted position after mount to avoid SSR hydration mismatch.
// hasHydrated is set via RAF so the correct position is painted before transitions are enabled,
// preventing the spring animation from firing on initial load.
useEffect(() => {
const stored = readStoredPosition()
if (stored !== 'bottom-right') setSnapPosition(stored)
const id = requestAnimationFrame(() => setHasHydrated(true))
return () => cancelAnimationFrame(id)
}, [])
// Keep snap coords accurate on resize
useEffect(() => {
const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight })
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
// Two-phase snap: hold last drag position for one frame (primes the transition),
// then clear it so the spring fires from that position to the snap target
useEffect(() => {
if (releasedAt === null) return
const id = requestAnimationFrame(() => setReleasedAt(null))
return () => cancelAnimationFrame(id)
}, [releasedAt])
const handlePointerDown = useCallback((e: PointerEvent<HTMLButtonElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
dragRef.current = {
startPointerX: e.clientX,
startPointerY: e.clientY,
startButtonX: rect.left,
startButtonY: rect.top,
hasDragged: false,
}
wasDraggingRef.current = false
e.currentTarget.setPointerCapture(e.pointerId)
}, [])
const handlePointerMove = useCallback((e: PointerEvent<HTMLButtonElement>) => {
if (!dragRef.current) return
const dx = e.clientX - dragRef.current.startPointerX
const dy = e.clientY - dragRef.current.startPointerY
if (!dragRef.current.hasDragged && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
dragRef.current.hasDragged = true
setDragPos({
x: dragRef.current.startButtonX + dx,
y: dragRef.current.startButtonY + dy,
})
}, [])
const handlePointerUp = useCallback((e: PointerEvent<HTMLButtonElement>) => {
if (!dragRef.current) return
const { hasDragged } = dragRef.current
dragRef.current = null
wasDraggingRef.current = hasDragged
if (!hasDragged) return
const rect = e.currentTarget.getBoundingClientRect()
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const newPosition = getNearestSnapPosition(cx, cy)
setSnapPosition(newPosition)
localStorage.setItem(POSITION_STORAGE_KEY, newPosition)
// Phase 1: park at last drag position with transition primed
setReleasedAt({ x: rect.left, y: rect.top })
setDragPos(null)
}, [])
const handlePointerCancel = useCallback(() => {
dragRef.current = null
wasDraggingRef.current = false
setDragPos(null)
setReleasedAt(null)
}, [])
if (!IS_TOOLBAR_ENABLED || !isEnabled) return null
const eventCountBadge = getEventCountBadge(events.length)
const isDragging = dragPos !== null
const snapCoords = getSnapCoords(snapPosition, viewport.w, viewport.h)
const FULL_TRANSITION = `${SNAP_TRANSITION}, opacity 200ms ease`
const containerStyle: CSSProperties =
dragPos !== null
? {
position: 'fixed',
zIndex: 50,
left: dragPos.x,
top: dragPos.y,
transition: 'none',
opacity: 1,
}
: releasedAt !== null
? // Phase 1: same pixel position as drag end, transition now defined
{
position: 'fixed',
zIndex: 50,
left: releasedAt.x,
top: releasedAt.y,
transition: FULL_TRANSITION,
opacity: isOpen ? 0 : 1,
pointerEvents: isOpen ? 'none' : undefined,
}
: // Phase 2: spring fires from releasedAt → snapCoords
{
position: 'fixed',
zIndex: 50,
...snapCoords,
transition: hasHydrated ? FULL_TRANSITION : 'none',
opacity: isOpen ? 0 : 1,
pointerEvents: isOpen ? 'none' : undefined,
}
const handleClick = () => {
if (wasDraggingRef.current) {
wasDraggingRef.current = false
return
}
setIsOpen(true)
}
return (
<div style={containerStyle}>
<Button
rounded
className={cn(
'relative h-10 w-10 p-0 shadow-md',
'focus-visible:outline-0 focus-visible:outline-transparent focus-visible:outline-offset-0',
'select-none touch-none',
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
)}
aria-label="Open dev toolbar"
onClick={handleClick}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
title="Dev Toolbar"
>
<Image
src="/img/logo-pixel-small-light.png"
alt="Dev Toolbar"
width={16}
height={16}
style={{
filter:
'brightness(0) saturate(100%) invert(72%) sepia(57%) saturate(431%) hue-rotate(108deg) brightness(95%) contrast(91%)',
}}
aria-hidden="true"
className="pointer-events-none"
/>
{eventCountBadge && (
<span
className={cn(
'absolute -top-1 -right-1',
'inline-flex items-center justify-center',
'rounded-full bg-brand-default text-black',
'text-[9px] font-medium leading-none tracking-tighter tabular-nums',
eventCountBadge.sizeClass
)}
>
{eventCountBadge.label}
</span>
)}
</Button>
</div>
)
}