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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+82
View File
@@ -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')
})
})
+74
View File
@@ -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'
}
+48
View File
@@ -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)')
})
})
+100
View File
@@ -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)})`
}
+2
View File
@@ -0,0 +1,2 @@
export { getContrastTextColor, isDarkColor, isLightColor, perceivedBrightness } from './brightness'
export { hexToRgb, hslToRgb, rgbToHex, rgbToHsl, toCssColor } from './convert'