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

246 lines
7.3 KiB
TypeScript

'use client'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'
import { cn } from 'ui'
import { useBreakpoint } from 'common'
import { AnimatedGridBackground } from '../AnimatedGridBackground'
const stats = [
{ label: 'GitHub stars', value: '+10,000' },
{ label: 'Databases created', value: '15M+' },
{ label: 'Realtime messages', value: '280B+' },
{ label: 'Edge Functions invoked', value: '49B+' },
{ label: 'Petabytes stored', value: '64 PB' },
{ label: 'Peak connections', value: '193K' },
{ label: 'Projects created', value: '12M+' },
{ label: 'Images transformed', value: '64M+' },
]
// Animation timing configuration
const BUBBLE_CONFIG = {
// Lifetime
lifetimeMin: 5000, // minimum bubble lifetime in ms
lifetimeMax: 10000, // maximum bubble lifetime in ms
// Spawn timing
spawnIntervalMin: 2000, // minimum time between spawns in ms
spawnIntervalMax: 3500, // maximum time between spawns in ms
initialDelays: [1200, 1300, 1100, 1400], // staggered initial spawn delays (3-4 bubbles)
maxBubbles: 4, // maximum bubbles visible at once
// Positioning
minDistance: 18, // minimum distance between bubble centers (percentage)
}
type FloatingStatBubble = {
id: number
stat: (typeof stats)[number]
top: number
left: number
}
function getDistance(
box1: { top: number; left: number },
box2: { top: number; left: number }
): number {
return Math.sqrt(Math.pow(box1.top - box2.top, 2) + Math.pow(box1.left - box2.left, 2))
}
function generateNonOverlappingPosition(
existingBubbles: FloatingStatBubble[],
isMobile: boolean
): { top: number; left: number } | null {
const maxAttempts = 20
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const top = isMobile
? Math.random() * 50 + 10 // 10-60% on mobile
: Math.random() * 70 + 10 // 10-80% on desktop
// On mobile, use a narrower, more centered range to prevent overflow
const left = isMobile
? Math.random() * 20 + 20 // 20-40% on mobile
: Math.random() * 70 + 10 // 10-80% on desktop
const hasOverlap = existingBubbles.some(
(bubble) =>
getDistance({ top, left }, { top: bubble.top, left: bubble.left }) <
BUBBLE_CONFIG.minDistance
)
if (!hasOverlap) {
return { top, left }
}
}
return null
}
function generateRandomBubble(
id: number,
existingBubbles: FloatingStatBubble[],
usedStatIndices: Set<number>,
isMobile: boolean
): FloatingStatBubble | null {
const position = generateNonOverlappingPosition(existingBubbles, isMobile)
if (!position) {
return null
}
// Find an unused stat, or pick a random one if all have been used
let statIndex: number
const availableIndices = stats.map((_, i) => i).filter((i) => !usedStatIndices.has(i))
if (availableIndices.length > 0) {
statIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)]
} else {
statIndex = Math.floor(Math.random() * stats.length)
}
return {
id,
stat: stats[statIndex],
top: position.top,
left: position.left,
}
}
function FloatingStatBubbles() {
const [bubbles, setBubbles] = useState<FloatingStatBubble[]>([])
const bubbleIdRef = useRef(0)
const usedStatIndicesRef = useRef<Set<number>>(new Set())
const lifetimeTimersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
const isMobile = useBreakpoint(768)
useEffect(() => {
const spawnBubble = () => {
setBubbles((prev) => {
// Limit max bubbles on screen
if (prev.length >= BUBBLE_CONFIG.maxBubbles) {
return prev
}
const id = bubbleIdRef.current++
const newBubble = generateRandomBubble(id, prev, usedStatIndicesRef.current, isMobile)
if (!newBubble) {
return prev
}
// Track which stat index was used
const statIndex = stats.indexOf(newBubble.stat)
usedStatIndicesRef.current.add(statIndex)
const lifetime =
Math.random() * (BUBBLE_CONFIG.lifetimeMax - BUBBLE_CONFIG.lifetimeMin) +
BUBBLE_CONFIG.lifetimeMin
const timerId = setTimeout(() => {
setBubbles((current) => current.filter((bubble) => bubble.id !== id))
usedStatIndicesRef.current.delete(statIndex)
lifetimeTimersRef.current.delete(timerId)
}, lifetime)
lifetimeTimersRef.current.add(timerId)
return [...prev, newBubble]
})
}
const initialTimers = BUBBLE_CONFIG.initialDelays.map((delay) => setTimeout(spawnBubble, delay))
let intervalId: ReturnType<typeof setTimeout>
const scheduleNextSpawn = () => {
intervalId = setTimeout(
() => {
spawnBubble()
scheduleNextSpawn()
},
BUBBLE_CONFIG.spawnIntervalMin +
Math.random() * (BUBBLE_CONFIG.spawnIntervalMax - BUBBLE_CONFIG.spawnIntervalMin)
)
}
scheduleNextSpawn()
return () => {
initialTimers.forEach(clearTimeout)
clearTimeout(intervalId)
lifetimeTimersRef.current.forEach(clearTimeout)
lifetimeTimersRef.current.clear()
}
}, [isMobile])
return (
<AnimatePresence>
{bubbles.map((bubble) => (
<motion.div
key={bubble.id}
className={cn(
'absolute pointer-events-none px-3 py-2 bg-surface-100 border rounded-lg shadow-xs'
)}
style={{
top: `${bubble.top}%`,
left: `${bubble.left}%`,
}}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 0.98, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{
opacity: { duration: 0.75, type: 'spring' },
scale: {
type: 'spring',
duration: 0.7,
bounce: 0.15,
},
}}
>
<p className="text-sm font-mono font-medium text-brand whitespace-nowrap">
{bubble.stat.value}
</p>
<p className="text-xs text-foreground-light whitespace-nowrap">{bubble.stat.label}</p>
</motion.div>
))}
</AnimatePresence>
)
}
export function Home() {
return (
<div className="h-[calc(100dvh-64px)] flex flex-col max-w-240 mx-auto w-[95%] md:w-full">
<section className="relative border-x border-b h-full">
{/* Grid background */}
<AnimatedGridBackground
cols={5}
rows={{
desktop: 4,
mobile: 8,
}}
tiles={[
{ cell: 1, type: 'dots' },
{ cell: 6, type: 'dots' },
{ cell: 7, type: 'stripes' },
{ cell: 8, type: 'stripes' },
{ cell: 13, type: 'dots' },
{ cell: 18, type: 'dots' },
]}
/>
{/* Floating stat bubbles */}
<FloatingStatBubbles />
{/* Content */}
<div className="flex flex-col justify-end h-full px-4 lg:px-8 py-0 relative">
<h1 className="font-medium tracking-tighter text-6xl md:text-7xl lg:text-[5.6rem] translate-y-2 lg:translate-y-[10px]">
Supabase Wrapped
</h1>
</div>
</section>
<div className="relative w-full border-x border-b px-6 lg:px-10 py-12">
<article className="text-lg">
<p>You created more Supabase databases in 2025 than in all previous years combined.</p>
</article>
</div>
</div>
)
}