shadimage
atroui
libadaptive-themesource: ok · hit

Adaptive Theme

Contrast helpers that lift muted type to WCAG AA against the canvas.

npx shadcn add "https://www.atroui.com/r/adaptive-theme.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files1 · source cached 10/14/2026, 2:33:27 PM

registry/default/lib/adaptive-theme.ts
/** Contrast + OKLCH companion palettes for ThemeAdapt. */

export type Rgb = [number, number, number]

export type Oklch = { l: number; c: number; h: number }

export type ThemeSwatch = {
  background: Rgb
  foreground: Rgb
  muted: Rgb
  brand: Rgb
}

const TARGET_AA = 4.5

export const COMPANION_VAR_KEYS = [
  "--background",
  "--foreground",
  "--muted-foreground",
  "--muted",
  "--card",
  "--card-foreground",
  "--popover",
  "--popover-foreground",
  "--border",
  "--border-subtle",
  "--brand",
  "--primary",
  "--input",
  "--ring",
  "--surface",
] as const

export function srgbToLinear(c: number): number {
  const x = c / 255
  return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
}

export function linearToSrgb(c: number): number {
  const x = c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055
  return Math.max(0, Math.min(255, Math.round(x * 255)))
}

export function relativeLuminance(rgb: Rgb): number {
  const [r, g, b] = rgb
  return (
    0.2126 * srgbToLinear(r) +
    0.7152 * srgbToLinear(g) +
    0.0722 * srgbToLinear(b)
  )
}

export function contrastRatio(a: Rgb, b: Rgb): number {
  const l1 = relativeLuminance(a)
  const l2 = relativeLuminance(b)
  const lighter = Math.max(l1, l2)
  const darker = Math.min(l1, l2)
  return (lighter + 0.05) / (darker + 0.05)
}

export function parseRgb(css: string): Rgb | null {
  const t = css.trim()
  const m = t.match(
    /^rgba?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)\s*[, ]\s*([0-9.]+)/i
  )
  if (!m?.[1] || !m[2] || !m[3]) return null
  const r = Number(m[1])
  const g = Number(m[2])
  const b = Number(m[3])
  if (![r, g, b].every((n) => Number.isFinite(n))) return null
  return [r, g, b]
}

export function parseHex(hex: string): Rgb | null {
  const m = hex.trim().match(/^#([0-9a-fA-F]{6})$/)
  if (!m?.[1]) return null
  const n = Number.parseInt(m[1], 16)
  if (!Number.isFinite(n)) return null
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}

export function clampContrast(minRatio: number): number {
  if (!Number.isFinite(minRatio)) return 4.5
  return Math.min(21, Math.max(1, minRatio))
}

export function rgbToCss(rgb: Rgb): string {
  const r = Math.max(0, Math.min(255, Math.round(rgb[0])))
  const g = Math.max(0, Math.min(255, Math.round(rgb[1])))
  const b = Math.max(0, Math.min(255, Math.round(rgb[2])))
  return `rgb(${r} ${g} ${b})`
}

export function mixRgb(from: Rgb, to: Rgb, t: number): Rgb {
  return [
    from[0] + (to[0] - from[0]) * t,
    from[1] + (to[1] - from[1]) * t,
    from[2] + (to[2] - from[2]) * t,
  ]
}

export function rgbToOklch(rgb: Rgb): Oklch {
  const r = srgbToLinear(rgb[0])
  const g = srgbToLinear(rgb[1])
  const b = srgbToLinear(rgb[2])
  const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
  const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
  const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
  const l_ = Math.cbrt(l)
  const m_ = Math.cbrt(m)
  const s_ = Math.cbrt(s)
  const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_
  const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_
  const b2 = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_
  const C = Math.hypot(a, b2)
  let h = (Math.atan2(b2, a) * 180) / Math.PI
  if (h < 0) h += 360
  return { l: L, c: C, h }
}

export function oklchToRgb({ l: L, c: C, h }: Oklch): Rgb {
  const hr = (h * Math.PI) / 180
  const a = Math.cos(hr) * C
  const b = Math.sin(hr) * C
  const l_ = L + 0.3963377774 * a + 0.2158037573 * b
  const m_ = L - 0.1055613458 * a - 0.0638541728 * b
  const s_ = L - 0.0894841775 * a - 1.291485548 * b
  const l = l_ * l_ * l_
  const m = m_ * m_ * m_
  const s = s_ * s_ * s_
  const r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
  const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
  const bl = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
  return [linearToSrgb(r), linearToSrgb(g), linearToSrgb(bl)]
}

export function hueDelta(a: number, b: number): number {
  const d = Math.abs(a - b) % 360
  return d > 180 ? 360 - d : d
}

function withHue(color: Oklch, fallback: Oklch): Oklch {
  if (color.c >= 0.012) return color
  return { l: color.l, c: Math.max(color.c, fallback.c * 0.3), h: fallback.h }
}

/** Raise or lower OKLCH lightness only until contrast passes. Keeps C and H. */
export function ensureContrastOklch(
  foreground: Rgb,
  background: Rgb,
  minRatio = TARGET_AA
): { rgb: Rgb; repaired: boolean; ratio: number } {
  const start = contrastRatio(foreground, background)
  if (start >= minRatio) {
    return { rgb: foreground, repaired: false, ratio: start }
  }
  const fg = rgbToOklch(foreground)
  const towardLight = relativeLuminance(foreground) >= relativeLuminance(background)
  let lo = towardLight ? fg.l : 0
  let hi = towardLight ? 1 : fg.l
  let best = foreground
  for (let i = 0; i < 20; i++) {
    const mid = (lo + hi) / 2
    const cand = oklchToRgb({ l: mid, c: fg.c, h: fg.h })
    if (contrastRatio(cand, background) >= minRatio) {
      best = cand
      if (towardLight) hi = mid
      else lo = mid
    } else if (towardLight) {
      lo = mid
    } else {
      hi = mid
    }
  }
  return { rgb: best, repaired: true, ratio: contrastRatio(best, background) }
}

export function ensureContrast(
  foreground: Rgb,
  background: Rgb,
  minRatio = TARGET_AA
): { rgb: Rgb; repaired: boolean; ratio: number } {
  return ensureContrastOklch(foreground, background, minRatio)
}

function roundRgb(rgb: Rgb): Rgb {
  return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2])]
}

/**
 * Dark companion for a designed light palette.
 * naive: crush + grey brand (the usual damage).
 * adaptive: keep hue, drop L on the canvas, lift type to AA in OKLCH.
 */
export function companionDark(
  light: ThemeSwatch,
  kind: "naive" | "adaptive",
  minRatio = TARGET_AA
): ThemeSwatch {
  const floor = clampContrast(minRatio)
  if (kind === "naive") {
    const background = roundRgb(mixRgb(light.background, [10, 10, 12], 0.9))
    return {
      background,
      foreground: roundRgb(mixRgb(light.foreground, [228, 228, 226], 0.5)),
      muted: roundRgb(mixRgb(light.muted, background, 0.72)),
      brand: roundRgb(mixRgb(light.brand, [118, 118, 118], 0.5)),
    }
  }

  const paper = rgbToOklch(light.background)
  const ink = withHue(rgbToOklch(light.foreground), paper)
  const quiet = withHue(rgbToOklch(light.muted), paper)
  const mark = withHue(rgbToOklch(light.brand), paper)

  const background = oklchToRgb({
    l: 0.17,
    c: paper.c * 0.5,
    h: paper.h,
  })
  const foreground = ensureContrastOklch(
    oklchToRgb({ l: 0.93, c: Math.min(ink.c, 0.04), h: ink.h }),
    background,
    floor
  ).rgb
  const muted = ensureContrastOklch(
    oklchToRgb({ l: 0.72, c: Math.max(quiet.c * 0.8, 0.02), h: quiet.h }),
    background,
    floor
  ).rgb
  const brand = oklchToRgb({
    l: Math.max(mark.l, 0.62),
    c: mark.c,
    h: mark.h,
  })

  return { background, foreground, muted, brand }
}

export function companionToCssVars(swatch: ThemeSwatch): Record<string, string> {
  const paper = rgbToOklch(swatch.background)
  const card = oklchToRgb({
    l: Math.min(0.24, paper.l + 0.05),
    c: paper.c * 0.85,
    h: paper.h,
  })
  const border = oklchToRgb({
    l: Math.min(0.34, paper.l + 0.12),
    c: paper.c * 0.45,
    h: paper.h,
  })
  const mutedBg = oklchToRgb({
    l: Math.min(0.22, paper.l + 0.04),
    c: paper.c * 0.65,
    h: paper.h,
  })
  return {
    "--background": rgbToCss(swatch.background),
    "--foreground": rgbToCss(swatch.foreground),
    "--muted-foreground": rgbToCss(swatch.muted),
    "--muted": rgbToCss(mutedBg),
    "--card": rgbToCss(card),
    "--card-foreground": rgbToCss(swatch.foreground),
    "--popover": rgbToCss(card),
    "--popover-foreground": rgbToCss(swatch.foreground),
    "--border": rgbToCss(border),
    "--border-subtle": rgbToCss(border),
    "--brand": rgbToCss(swatch.brand),
    "--primary": rgbToCss(swatch.brand),
    "--input": rgbToCss(border),
    "--ring": rgbToCss(swatch.brand),
    "--surface": rgbToCss(card),
  }
}

export function applyCompanionPalette(root: HTMLElement, swatch: ThemeSwatch) {
  const vars = companionToCssVars(swatch)
  for (const key of COMPANION_VAR_KEYS) {
    const value = vars[key]
    if (value) root.style.setProperty(key, value)
  }
  root.setAttribute("data-theme-adapt", "companion")
}

export function clearCompanionPalette(root: HTMLElement) {
  for (const key of COMPANION_VAR_KEYS) {
    root.style.removeProperty(key)
  }
  root.removeAttribute("data-theme-adapt")
}

function isDarkSelector(selector: string): boolean {
  return /(?:^|[\s,.])dark\b|\[data-theme=["']dark["']\]/.test(selector)
}

function isRootSelector(selector: string): boolean {
  return /(^|,\s*)(:root|html)(\s|,|$|:)/.test(selector) && !isDarkSelector(selector)
}

function collectFromRules(
  rules: CSSRuleList,
  out: Record<string, string>
) {
  for (const rule of rules) {
    if (
      rule instanceof CSSMediaRule ||
      rule instanceof CSSSupportsRule ||
      rule instanceof CSSLayerBlockRule ||
      (typeof CSSGroupingRule !== "undefined" && rule instanceof CSSGroupingRule)
    ) {
      if (
        rule instanceof CSSMediaRule &&
        /prefers-color-scheme:\s*dark/.test(rule.conditionText)
      ) {
        continue
      }
      collectFromRules(rule.cssRules, out)
      continue
    }
    if (!(rule instanceof CSSStyleRule)) continue
    if (!isRootSelector(rule.selectorText)) continue
    for (let i = 0; i < rule.style.length; i++) {
      const name = rule.style.item(i)
      if (name.startsWith("--")) {
        out[name] = rule.style.getPropertyValue(name).trim()
      }
    }
  }
}

export function collectLightRootProps(
  sheets: ArrayLike<CSSStyleSheet>
): Record<string, string> {
  const out: Record<string, string> = {}
  for (let i = 0; i < sheets.length; i++) {
    const sheet = sheets[i]
    if (!sheet) continue
    let rules: CSSRuleList
    try {
      rules = sheet.cssRules
    } catch {
      continue
    }
    collectFromRules(rules, out)
  }
  return out
}

function readVarRgb(probe: HTMLElement, token: string): Rgb | null {
  probe.style.backgroundColor = `var(${token})`
  return parseRgb(getComputedStyle(probe).backgroundColor)
}

let lightCache: ThemeSwatch | null = null

function swatchFromProbe(
  probe: HTMLElement
): ThemeSwatch | null {
  const background = readVarRgb(probe, "--background")
  const foreground = readVarRgb(probe, "--foreground")
  const muted =
    readVarRgb(probe, "--muted-foreground") ?? readVarRgb(probe, "--muted")
  const brand = readVarRgb(probe, "--brand") ?? readVarRgb(probe, "--primary")
  if (!background || !foreground || !muted || !brand) return null
  return { background, foreground, muted, brand }
}

/** Snapshot the authored light :root palette, even if html currently has .dark. */
export function sampleLightSwatch(): ThemeSwatch | null {
  if (typeof document === "undefined") return null
  const props = collectLightRootProps(document.styleSheets)
  if (Object.keys(props).length > 0) {
    const probe = document.createElement("div")
    probe.setAttribute("aria-hidden", "true")
    probe.style.cssText =
      "position:fixed;left:-9999px;top:0;width:2px;height:2px;pointer-events:none"
    for (const [key, value] of Object.entries(props)) {
      probe.style.setProperty(key, value)
    }
    document.body.appendChild(probe)
    try {
      const swatch = swatchFromProbe(probe)
      if (swatch) lightCache = swatch
      return swatch ?? lightCache
    } finally {
      probe.remove()
    }
  }
  if (!document.documentElement.classList.contains("dark")) {
    const live = swatchFromProbe(document.documentElement)
    if (live) lightCache = live
    return live
  }
  return lightCache
}

More from atroui

Browse all components in this library →