chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getContrastTextColor,
|
||||
isDarkColor,
|
||||
isLightColor,
|
||||
perceivedBrightness,
|
||||
} from '@/lib/colors/brightness'
|
||||
|
||||
describe('perceivedBrightness', () => {
|
||||
it('returns 1 for white and 0 for black (hex and keywords)', () => {
|
||||
expect(perceivedBrightness('#ffffff')).toBe(1)
|
||||
expect(perceivedBrightness('#000000')).toBe(0)
|
||||
expect(perceivedBrightness('white')).toBe(1)
|
||||
expect(perceivedBrightness('black')).toBe(0)
|
||||
})
|
||||
|
||||
it('parses 3-digit hex, optional # and quotes, case-insensitively', () => {
|
||||
expect(perceivedBrightness('#FFF')).toBe(1)
|
||||
expect(perceivedBrightness('fff')).toBe(1)
|
||||
expect(perceivedBrightness("'#FFFFFF'")).toBe(1)
|
||||
})
|
||||
|
||||
it('returns null for non-color values', () => {
|
||||
expect(perceivedBrightness('currentColor')).toBeNull()
|
||||
expect(perceivedBrightness('linear-gradient(45deg, #000, #fff)')).toBeNull()
|
||||
expect(perceivedBrightness('rebeccapurple')).toBeNull()
|
||||
expect(perceivedBrightness('#12')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads saturated brand colors perceptually (bright yellow is light)', () => {
|
||||
expect((perceivedBrightness('#EAB308') as number) > 0.6).toBe(true)
|
||||
expect((perceivedBrightness('#3B82F6') as number) < 0.6).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLightColor', () => {
|
||||
it('classifies light vs dark tiles at the default threshold', () => {
|
||||
expect(isLightColor('#FFFFFF')).toBe(true)
|
||||
expect(isLightColor('#FFE01B')).toBe(true)
|
||||
expect(isLightColor('#EAB308')).toBe(true)
|
||||
expect(isLightColor('#171717')).toBe(false)
|
||||
expect(isLightColor('#3B82F6')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats non-color values (gradients) as dark', () => {
|
||||
expect(isLightColor('linear-gradient(45deg, #fff, #000)')).toBe(false)
|
||||
expect(isLightColor('currentColor')).toBe(false)
|
||||
})
|
||||
|
||||
it('respects a custom threshold', () => {
|
||||
expect(isLightColor('#808080', 0.9)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDarkColor', () => {
|
||||
it('classifies dark vs light at the 0.5 midpoint', () => {
|
||||
expect(isDarkColor('#000000')).toBe(true)
|
||||
expect(isDarkColor('#3B82F6')).toBe(true)
|
||||
expect(isDarkColor('#ffffff')).toBe(false)
|
||||
expect(isDarkColor('#FFE01B')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats unparseable values as not dark', () => {
|
||||
expect(isDarkColor('currentColor')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getContrastTextColor', () => {
|
||||
it('picks black on light colors and white on dark colors', () => {
|
||||
expect(getContrastTextColor('#ffffff')).toBe('#000000')
|
||||
expect(getContrastTextColor('#FFE01B')).toBe('#000000')
|
||||
expect(getContrastTextColor('#000000')).toBe('#ffffff')
|
||||
expect(getContrastTextColor('#3B82F6')).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('treats unparseable colors as light (black text), matching legacy behavior', () => {
|
||||
expect(getContrastTextColor('currentColor')).toBe('#000000')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Perceived brightness (0 = black, 1 = white) of a CSS color, using the ITU-R
|
||||
* BT.601 (YIQ) luma weights `0.299 R + 0.587 G + 0.114 B`.
|
||||
*
|
||||
* This is the perceptual "is it light or dark" measure the app uses for
|
||||
* foreground/background contrast decisions. It tracks human brightness
|
||||
* perception better than gamma-corrected relative luminance for the saturated
|
||||
* brand colors used as tile backgrounds (e.g. it correctly reads bright yellows
|
||||
* as light), which is why every contrast helper in the app builds on it.
|
||||
*
|
||||
* Accepts `#rgb`/`#rrggbb` hex (with or without `#`, optionally quoted) and the
|
||||
* `white`/`black` keywords. Returns `null` for anything else (named colors,
|
||||
* gradients, `currentColor`, malformed input) so callers can treat unknown
|
||||
* values explicitly instead of guessing.
|
||||
*/
|
||||
export function perceivedBrightness(color: string): number | null {
|
||||
const value = color.trim().replace(/['"]/g, '').toLowerCase()
|
||||
if (value === 'white') return 1
|
||||
if (value === 'black') return 0
|
||||
const hex = value.replace('#', '')
|
||||
let r: number
|
||||
let g: number
|
||||
let b: number
|
||||
if (/^[0-9a-f]{3}$/.test(hex)) {
|
||||
r = Number.parseInt(hex[0] + hex[0], 16)
|
||||
g = Number.parseInt(hex[1] + hex[1], 16)
|
||||
b = Number.parseInt(hex[2] + hex[2], 16)
|
||||
} else if (/^[0-9a-f]{6}$/.test(hex)) {
|
||||
r = Number.parseInt(hex.slice(0, 2), 16)
|
||||
g = Number.parseInt(hex.slice(2, 4), 16)
|
||||
b = Number.parseInt(hex.slice(4, 6), 16)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `color` is light enough that a white foreground would wash out.
|
||||
* Non-color values (gradients, `currentColor`, unknown) are treated as not
|
||||
* light. `threshold` is the perceived-brightness cutoff (default 0.6, tuned so
|
||||
* only clearly light tiles flip to a dark foreground).
|
||||
*/
|
||||
export function isLightColor(color: string, threshold = 0.6): boolean {
|
||||
const brightness = perceivedBrightness(color)
|
||||
return brightness !== null && brightness > threshold
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `color` is dark enough to warrant a light foreground. Non-color
|
||||
* values (gradients, `currentColor`, unknown) are treated as not dark.
|
||||
* `threshold` is the perceived-brightness cutoff (default 0.5, the conventional
|
||||
* midpoint for binary text contrast).
|
||||
*/
|
||||
export function isDarkColor(color: string, threshold = 0.5): boolean {
|
||||
const brightness = perceivedBrightness(color)
|
||||
return brightness !== null && brightness < threshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Black or white — whichever reads on top of `color`. Dark colors get white
|
||||
* text; light colors (and unparseable values) get black. Uses the 0.5 midpoint
|
||||
* ({@link isDarkColor}'s default), the conventional binary text-contrast cutoff.
|
||||
*
|
||||
* Note this is intentionally a different cutoff than the brand-tile *icon*
|
||||
* decision ({@link isLightColor}'s 0.6 default, raised to 0.75 for tiles), which
|
||||
* biases toward white more aggressively: a colored tile reads better with a
|
||||
* white icon until it is clearly light, whereas plain text wants the
|
||||
* mathematically closer of black/white. The two helpers therefore answer
|
||||
* "what foreground reads here" differently by design, per surface.
|
||||
*/
|
||||
export function getContrastTextColor(color: string): '#000000' | '#ffffff' {
|
||||
return isDarkColor(color) ? '#ffffff' : '#000000'
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hexToRgb, hslToRgb, rgbToHex, rgbToHsl, toCssColor } from '@/lib/colors/convert'
|
||||
|
||||
describe('hexToRgb', () => {
|
||||
it('parses 6- and 3-digit hex, with or without #', () => {
|
||||
expect(hexToRgb('#ff8800')).toEqual({ r: 255, g: 136, b: 0 })
|
||||
expect(hexToRgb('f80')).toEqual({ r: 255, g: 136, b: 0 })
|
||||
})
|
||||
|
||||
it('returns black for malformed input', () => {
|
||||
expect(hexToRgb('nope')).toEqual({ r: 0, g: 0, b: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rgbToHex', () => {
|
||||
it('clamps and pads to #rrggbb', () => {
|
||||
expect(rgbToHex(255, 136, 0)).toBe('#ff8800')
|
||||
expect(rgbToHex(-10, 300, 5)).toBe('#00ff05')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rgbToHsl / hslToRgb round-trip', () => {
|
||||
it('round-trips a saturated color within rounding', () => {
|
||||
const { h, s, l } = rgbToHsl(59, 130, 246)
|
||||
const { r, g, b } = hslToRgb(h, s, l)
|
||||
expect(Math.abs(r - 59)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(g - 130)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(b - 246)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('handles grays (zero saturation)', () => {
|
||||
expect(hslToRgb(0, 0, 0.5)).toEqual({ r: 128, g: 128, b: 128 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('toCssColor', () => {
|
||||
it('returns bare hex when fully opaque', () => {
|
||||
expect(toCssColor('#3b82f6', 1)).toBe('#3b82f6')
|
||||
expect(toCssColor('3b82f6', 1)).toBe('#3b82f6')
|
||||
})
|
||||
|
||||
it('returns rgba with 3-decimal alpha when translucent', () => {
|
||||
expect(toCssColor('#3b82f6', 0.5)).toBe('rgba(59,130,246,0.500)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Generic color-space conversions shared across the app (brand tiles, presence
|
||||
* avatars, the PPTX renderer, …). Pure and dependency-free.
|
||||
*/
|
||||
|
||||
/** Parse a hex color string (with or without `#`) into RGB components. */
|
||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const cleaned = hex.replace(/^#/, '')
|
||||
if (cleaned.length !== 6 && cleaned.length !== 3) {
|
||||
return { r: 0, g: 0, b: 0 }
|
||||
}
|
||||
const full =
|
||||
cleaned.length === 3
|
||||
? cleaned[0] + cleaned[0] + cleaned[1] + cleaned[1] + cleaned[2] + cleaned[2]
|
||||
: cleaned
|
||||
const num = Number.parseInt(full, 16)
|
||||
return {
|
||||
r: (num >> 16) & 0xff,
|
||||
g: (num >> 8) & 0xff,
|
||||
b: num & 0xff,
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert RGB components (0-255 each) to a 6-digit `#rrggbb` hex string. */
|
||||
export function rgbToHex(r: number, g: number, b: number): string {
|
||||
const clamp = (v: number): number => Math.max(0, Math.min(255, Math.round(v)))
|
||||
return `#${[clamp(r), clamp(g), clamp(b)].map((c) => c.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
/** Convert RGB (0-255) to HSL (h: 0-360, s: 0-1, l: 0-1). */
|
||||
export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
|
||||
const rn = r / 255
|
||||
const gn = g / 255
|
||||
const bn = b / 255
|
||||
const max = Math.max(rn, gn, bn)
|
||||
const min = Math.min(rn, gn, bn)
|
||||
const l = (max + min) / 2
|
||||
let h = 0
|
||||
let s = 0
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case rn:
|
||||
h = ((gn - bn) / d + (gn < bn ? 6 : 0)) * 60
|
||||
break
|
||||
case gn:
|
||||
h = ((bn - rn) / d + 2) * 60
|
||||
break
|
||||
case bn:
|
||||
h = ((rn - gn) / d + 4) * 60
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { h, s, l }
|
||||
}
|
||||
|
||||
/** Convert HSL (h: 0-360, s: 0-1, l: 0-1) to RGB (0-255). */
|
||||
export function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
||||
h = ((h % 360) + 360) % 360
|
||||
s = Math.max(0, Math.min(1, s))
|
||||
l = Math.max(0, Math.min(1, l))
|
||||
|
||||
if (s === 0) {
|
||||
const v = Math.round(l * 255)
|
||||
return { r: v, g: v, b: v }
|
||||
}
|
||||
|
||||
const hueToRgb = (p: number, q: number, t: number): number => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t
|
||||
if (t < 1 / 2) return q
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
|
||||
return p
|
||||
}
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
const hNorm = h / 360
|
||||
|
||||
return {
|
||||
r: Math.round(hueToRgb(p, q, hNorm + 1 / 3) * 255),
|
||||
g: Math.round(hueToRgb(p, q, hNorm) * 255),
|
||||
b: Math.round(hueToRgb(p, q, hNorm - 1 / 3) * 255),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a resolved color + alpha as a CSS color string: the bare hex when
|
||||
* fully opaque, otherwise `rgba(r,g,b,a)`. Accepts hex with or without `#`.
|
||||
*/
|
||||
export function toCssColor(color: string, alpha: number): string {
|
||||
const hex = color.startsWith('#') ? color : `#${color}`
|
||||
if (alpha >= 1) return hex
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
return `rgba(${r},${g},${b},${alpha.toFixed(3)})`
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { getContrastTextColor, isDarkColor, isLightColor, perceivedBrightness } from './brightness'
|
||||
export { hexToRgb, hslToRgb, rgbToHex, rgbToHsl, toCssColor } from './convert'
|
||||
Reference in New Issue
Block a user