snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Lint and Test / Lint/Test (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 17:39:41 +05:00
commit 906fb231cc
1544 changed files with 164131 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
{
"name": "@yaakapp-internal/theme",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {
"@tauri-apps/api": "^2.11.0",
"@yaakapp-internal/plugins": "^1.0.0",
"parse-color": "^1.0.0"
}
}

View File

@@ -0,0 +1,85 @@
import { platform } from "@yaakapp-internal/platform";
export type Appearance = "light" | "dark";
const SYSTEM_APPEARANCE_CHANGE_EVENT = "system_appearance_change";
declare global {
interface Window {
__YAAK_INITIAL_APPEARANCE__?: Appearance;
}
}
// NOTE: On macOS and Linux the backend detects the appearance the OS prefers and injects
// it here, later emitting change events. This is required because applying a theme forces
// the window appearance, which poisons what the webview reports (CSS media queries and
// window theme events follow the override).
export function getSystemAppearance(): Appearance | null {
const appearance = window.__YAAK_INITIAL_APPEARANCE__;
return appearance === "dark" || appearance === "light" ? appearance : null;
}
export function getCSSAppearance(): Appearance {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export async function getWindowAppearance(): Promise<Appearance> {
const appearance = await platform.window.theme();
return appearance ?? getCSSAppearance();
}
export function subscribeToCSSAppearanceChange(cb: (appearance: Appearance) => void): () => void {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const listener = () => cb(media.matches ? "dark" : "light");
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}
export function subscribeToWindowAppearanceChange(
cb: (appearance: Appearance) => void,
): () => void {
return platform.window.onThemeChanged(cb);
}
export function subscribeToSystemAppearanceChange(
cb: (appearance: Appearance) => void,
): () => void {
return platform.listen<Appearance>(SYSTEM_APPEARANCE_CHANGE_EVENT, cb);
}
export function resolveAppearance(
preferredAppearance: Appearance,
appearanceSetting: string,
): Appearance {
const appearance = appearanceSetting === "system" ? preferredAppearance : appearanceSetting;
return appearance === "dark" ? "dark" : "light";
}
export function subscribeToPreferredAppearance(cb: (appearance: Appearance) => void) {
const systemAppearance = getSystemAppearance();
if (systemAppearance != null) {
cb(systemAppearance);
return subscribeToSystemAppearanceChange(cb);
}
cb(getCSSAppearance());
void getWindowAppearance().then(cb);
return subscribeToPreferredAppearanceChange(cb);
}
export function subscribeToPreferredAppearanceChange(cb: (appearance: Appearance) => void) {
if (getSystemAppearance() != null) {
// The CSS and window sources are poisoned by forced window appearances, so only
// listen to the backend's system appearance detection when it's available
return subscribeToSystemAppearanceChange(cb);
}
const unsubscribeCSS = subscribeToCSSAppearanceChange(cb);
const unsubscribeWindow = subscribeToWindowAppearanceChange(cb);
const unsubscribeSystem = subscribeToSystemAppearanceChange(cb);
return () => {
unsubscribeCSS();
unsubscribeWindow();
unsubscribeSystem();
};
}

View File

@@ -0,0 +1,76 @@
import type { Theme } from "@yaakapp-internal/plugins";
export const defaultDarkTheme: Theme = {
id: "yaak-dark",
label: "Yaak",
dark: true,
base: {
surface: "hsl(244,23%,14%)",
surfaceHighlight: "hsl(244,23%,20%)",
text: "hsl(245,23%,85%)",
textSubtle: "hsl(245,18%,58%)",
textSubtlest: "hsl(245,18%,45%)",
border: "hsl(244,23%,25%)",
primary: "hsl(266,100%,79%)",
secondary: "hsl(245,23%,60%)",
info: "hsl(206,100%,63%)",
success: "hsl(150,99%,44%)",
notice: "hsl(48,80%,63%)",
warning: "hsl(28,100%,61%)",
danger: "hsl(342,90%,68%)",
},
components: {
button: {
primary: "hsl(266,100%,71.1%)",
secondary: "hsl(244,23%,54%)",
info: "hsl(206,100%,56.7%)",
success: "hsl(150,99%,37.4%)",
notice: "hsl(48,80%,50.4%)",
warning: "hsl(28,100%,54.9%)",
danger: "hsl(342,90%,61.2%)",
},
dialog: {
border: "hsl(244,23%,24%)",
},
sidebar: {
surface: "hsl(243,23%,16%)",
border: "hsl(244,23%,22%)",
},
responsePane: {
surface: "hsl(243,23%,16%)",
border: "hsl(246,23%,23%)",
},
appHeader: {
surface: "hsl(244,23%,12%)",
border: "hsl(244,23%,21%)",
},
},
};
export const defaultLightTheme: Theme = {
id: "yaak-light",
label: "Yaak",
dark: false,
base: {
surface: "hsl(0,0%,100%)",
surfaceHighlight: "hsl(218,24%,92%)",
text: "hsl(217,24%,10%)",
textSubtle: "hsl(217,24%,40%)",
textSubtlest: "hsl(217,24%,58%)",
border: "hsl(217,22%,90%)",
primary: "hsl(266,100%,60%)",
secondary: "hsl(220,24%,50%)",
info: "hsl(206,100%,40%)",
success: "hsl(139,66%,34%)",
notice: "hsl(45,100%,34%)",
warning: "hsl(30,100%,36%)",
danger: "hsl(335,75%,48%)",
},
components: {
sidebar: {
surface: "hsl(220,20%,98%)",
border: "hsl(217,22%,88%)",
surfaceHighlight: "hsl(217,25%,94%)",
},
},
};

View File

@@ -0,0 +1,28 @@
export type { Appearance } from "./appearance";
export {
subscribeToCSSAppearanceChange,
getCSSAppearance,
getSystemAppearance,
getWindowAppearance,
resolveAppearance,
subscribeToPreferredAppearance,
subscribeToPreferredAppearanceChange,
subscribeToSystemAppearanceChange,
subscribeToWindowAppearanceChange,
} from "./appearance";
export { defaultDarkTheme, defaultLightTheme } from "./defaultThemes";
export { YaakColor } from "./yaakColor";
export type { DocumentPlatform, YaakColorKey, YaakColors, YaakTheme } from "./window";
export {
addThemeStylesToDocument,
applyThemeToDocument,
completeColorVariables,
completeFullColorVariables,
completePartialColorVariables,
completeTheme,
getThemeCSS,
indent,
platformFromUserAgent,
setThemeOnDocument,
setPlatformOnDocument,
} from "./window";

View File

@@ -0,0 +1,413 @@
import type { Theme, ThemeComponentColors } from "@yaakapp-internal/plugins";
import { defaultDarkTheme, defaultLightTheme } from "./defaultThemes";
import { YaakColor } from "./yaakColor";
export type YaakColors = {
surface: YaakColor;
surfaceHighlight?: YaakColor;
surfaceActive?: YaakColor;
text: YaakColor;
textSubtle?: YaakColor;
textSubtlest?: YaakColor;
border?: YaakColor;
borderSubtle?: YaakColor;
borderFocus?: YaakColor;
shadow?: YaakColor;
backdrop?: YaakColor;
selection?: YaakColor;
primary?: YaakColor;
secondary?: YaakColor;
info?: YaakColor;
success?: YaakColor;
notice?: YaakColor;
warning?: YaakColor;
danger?: YaakColor;
};
export type YaakTheme = {
id: string;
name: string;
base: YaakColors;
components?: Partial<{
dialog: Partial<YaakColors>;
menu: Partial<YaakColors>;
toast: Partial<YaakColors>;
sidebar: Partial<YaakColors>;
responsePane: Partial<YaakColors>;
appHeader: Partial<YaakColors>;
button: Partial<YaakColors>;
banner: Partial<YaakColors>;
templateTag: Partial<YaakColors>;
urlBar: Partial<YaakColors>;
editor: Partial<YaakColors>;
input: Partial<YaakColors>;
}>;
};
export type YaakColorKey = keyof ThemeComponentColors;
export type DocumentPlatform = "linux" | "macos" | "windows" | "unknown";
type ComponentName = keyof NonNullable<Theme["components"]>;
type CSSVariables = Record<YaakColorKey, string | undefined>;
export function completeFullColorVariables(theme: Theme, cmp: Partial<CSSVariables>): CSSVariables {
const color = (value: string | undefined) => yc(theme, value);
const vars: CSSVariables = {
surface: cmp.surface,
surfaceHighlight: cmp.surfaceHighlight ?? color(cmp.surface)?.lift(0.06).css(),
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(),
backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(),
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
border: cmp.border,
borderSubtle: cmp.borderSubtle,
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5)?.css(),
text: cmp.text,
textSubtle: cmp.textSubtle,
textSubtlest: cmp.textSubtlest,
shadow:
cmp.shadow ??
YaakColor.black()
.translucify(theme.dark ? 0.7 : 0.93)
.css(),
primary: cmp.primary,
secondary: cmp.secondary,
info: cmp.info,
success: cmp.success,
notice: cmp.notice,
warning: cmp.warning,
danger: cmp.danger,
};
const themeColor = (value: string) => new YaakColor(value, theme.dark ? "dark" : "light");
const themeSurface = themeColor(theme.dark ? "oklch(23% 0 0)" : "oklch(100% 0 0)");
const surface = themeColor(vars.surface ?? themeSurface.css());
const reference = surface.compositeOver(themeSurface);
const seed = themeColor(vars.surface ?? vars.surfaceHighlight ?? vars.border ?? surface.css());
const textBase = seed.desaturate(0.6).opacify(1);
const borderBase = seed.opacify(1);
const text = vars.text ?? textBase.withContrast(reference, 11).css();
const textColor = themeColor(text);
return normalizeColorVariables(theme, {
...vars,
text,
textSubtle: vars.textSubtle ?? textColor.lower(0.2).css(),
textSubtlest: vars.textSubtlest ?? textColor.lower(0.4).css(),
border: vars.border ?? borderBase.desaturate(0.2).withContrast(reference, 3).css(),
borderSubtle:
vars.borderSubtle ?? borderBase.desaturate(0.2).withContrast(reference, 1.2).css(),
});
}
export function completePartialColorVariables(
theme: Theme,
cmp: Partial<CSSVariables>,
): CSSVariables {
const color = (value: string | undefined) => yc(theme, value);
const text = color(cmp.text);
return normalizeColorVariables(theme, {
surface: cmp.surface,
surfaceHighlight: cmp.surfaceHighlight ?? color(cmp.surface)?.lift(0.06).css(),
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(),
backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(),
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
border: cmp.border ?? color(cmp.surface)?.lift(0.11).css(),
borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06).css(),
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5).css(),
text: cmp.text,
textSubtle: cmp.textSubtle ?? text?.lower(0.3).css(),
textSubtlest: cmp.textSubtlest ?? text?.lower(0.5).css(),
shadow:
cmp.shadow ??
YaakColor.black()
.translucify(theme.dark ? 0.7 : 0.93)
.css(),
primary: cmp.primary,
secondary: cmp.secondary,
info: cmp.info,
success: cmp.success,
notice: cmp.notice,
warning: cmp.warning,
danger: cmp.danger,
});
}
export const completeColorVariables = completeFullColorVariables;
function normalizeColorVariables(theme: Theme, vars: CSSVariables): CSSVariables {
const normalized: CSSVariables = {} as CSSVariables;
for (const [key, value] of Object.entries(vars)) {
normalized[key as YaakColorKey] = value == null ? undefined : yc(theme, value).css();
}
return normalized;
}
function templateTagColorVariables(theme: Theme, color: YaakColor): CSSVariables {
return completeFullColorVariables(theme, {
// Lift toward the foreground, but not all the way. liftMax() pushes lightness to an
// extreme, and the two extremes don't behave alike: lightening past the sRGB gamut clips
// to a still-tinted color, while darkening to zero lightness collapses every hue to black.
// That's why tags kept their color in dark mode and turned black in light. Stopping short
// keeps the hue in both, and still clears 6:1 against the tag's own surface.
text: color.lift(0.6).css(),
textSubtle: color.lift(0.5).css(),
textSubtlest: color.css(),
surface: color.lower(0.2).translucify(0.8).css(),
border: color.translucify(0.6).css(),
borderSubtle: color.translucify(0.8).css(),
surfaceHighlight: color.lower(0.1).translucify(0.7).css(),
});
}
function toastColorVariables(theme: Theme, color: YaakColor): CSSVariables {
return completeFullColorVariables(theme, {
surface: color.translucify(0.9).css(),
surfaceHighlight: color.translucify(0.8).css(),
});
}
function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
return completeFullColorVariables(theme, {
text: color.desaturate(0.5).lift(0.12).css(),
textSubtle: color.desaturate(0.58).lift(0.04).translucify(0.04).css(),
textSubtlest: color.desaturate(0.65).translucify(0.18).css(),
surface: color.translucify(0.95).css(),
surfaceHighlight: color.translucify(0.85).css(),
border: color.lift(0.3).translucify(0.8).css(),
});
}
function buttonSolidColorVariables(
theme: Theme,
color: YaakColor,
isDefault = false,
): CSSVariables {
const vars: Partial<CSSVariables> = {
surface: color.lower(0.3).css(),
surfaceHighlight: color.lower(0.1).css(),
};
if (isDefault) {
vars.surface = undefined;
vars.surfaceHighlight = color.lift(0.08).css();
}
return completeFullColorVariables(theme, vars);
}
function buttonBorderColorVariables(
theme: Theme,
color: YaakColor,
isDefault = false,
): CSSVariables {
const vars: Partial<CSSVariables> = {
text: color.desaturate(0.4).lift(1).css(),
textSubtle: color.desaturate(0.4).lift(0.55).css(),
surfaceHighlight: color.translucify(0.8).css(),
borderSubtle: color.translucify(0.5).css(),
border: color.translucify(0.3).css(),
};
if (isDefault) {
vars.borderSubtle = color.lift(0.28).css();
vars.border = color.lift(0.5).css();
}
return completeFullColorVariables(theme, vars);
}
function variablesToCSS(
selector: string | null,
vars: Partial<CSSVariables> | null,
): string | null {
if (vars == null) return null;
const css = Object.entries(vars)
.filter(([, value]) => value)
.map(([name, value]) => `--${name}: ${value};`)
.join("\n");
return selector == null ? css : `${selector} {\n${indent(css)}\n}`;
}
function componentCSS(component: ComponentName, vars: CSSVariables): string | null {
return variablesToCSS(`.x-theme-${component}`, vars);
}
function buttonCSS(
theme: Theme,
colorKey: YaakColorKey,
colors?: ThemeComponentColors,
): string | null {
const color = yc(theme, colors?.[colorKey]);
if (color == null) return null;
return [
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(theme, color)),
variablesToCSS(
`.x-theme-button--border--${colorKey}`,
buttonBorderColorVariables(theme, color),
),
].join("\n\n");
}
function bannerCSS(
theme: Theme,
colorKey: YaakColorKey,
colors?: ThemeComponentColors,
): string | null {
const color = yc(theme, colors?.[colorKey]);
if (color == null) return null;
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(theme, color));
}
function toastCSS(
theme: Theme,
colorKey: YaakColorKey,
colors?: ThemeComponentColors,
): string | null {
const color = yc(theme, colors?.[colorKey]);
if (color == null) return null;
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(theme, color));
}
function templateTagCSS(
theme: Theme,
colorKey: YaakColorKey,
colors?: ThemeComponentColors,
): string | null {
const color = yc(theme, colors?.[colorKey]);
if (color == null) return null;
return variablesToCSS(
`.x-theme-templateTag--${colorKey}`,
templateTagColorVariables(theme, color),
);
}
export function getThemeCSS(theme: Theme): string {
theme.components = theme.components ?? {};
theme.components.toast = theme.components.toast ?? theme.components.menu ?? {};
const { components, id, label } = theme;
const colors = Object.keys(theme.base).reduce((prev, key) => {
return { ...prev, [key]: theme.base[key as YaakColorKey] };
}, {} as ThemeComponentColors);
let themeCSS = "";
try {
const baseCss = variablesToCSS(null, completeFullColorVariables(theme, theme.base));
const baseSurface = yc(theme, theme.base.surface);
themeCSS = [
baseCss,
...Object.entries(components).map(([key, value]) =>
componentCSS(key as ComponentName, completePartialColorVariables(theme, value ?? {})),
),
baseSurface == null
? null
: variablesToCSS(
".x-theme-button--solid--default",
buttonSolidColorVariables(theme, baseSurface, true),
),
baseSurface == null
? null
: variablesToCSS(
".x-theme-button--border--default",
buttonBorderColorVariables(theme, baseSurface, true),
),
...Object.keys(colors).map((key) =>
buttonCSS(theme, key as YaakColorKey, theme.components?.button ?? colors),
),
...Object.keys(colors).map((key) =>
bannerCSS(theme, key as YaakColorKey, theme.components?.banner ?? colors),
),
...Object.keys(colors).map((key) =>
toastCSS(theme, key as YaakColorKey, theme.components?.banner ?? colors),
),
...Object.keys(colors).map((key) =>
templateTagCSS(theme, key as YaakColorKey, theme.components?.templateTag ?? colors),
),
].join("\n\n");
} catch (err) {
console.error("Failed to generate CSS", err);
}
return [`/* ${label} */`, `[data-theme="${id}"] {`, indent(themeCSS), "}"].join("\n");
}
export function addThemeStylesToDocument(rawTheme: Theme | null) {
if (rawTheme == null) {
console.error("Failed to add theme styles: theme is null");
return;
}
const theme = completeTheme(rawTheme);
let styleEl = document.head.querySelector("style[data-theme]");
if (!styleEl) {
styleEl = document.createElement("style");
document.head.appendChild(styleEl);
}
styleEl.setAttribute("data-theme", theme.id);
styleEl.setAttribute("data-updated-at", new Date().toISOString());
styleEl.textContent = getThemeCSS(theme);
}
export function setThemeOnDocument(theme: Theme | null) {
if (theme == null) {
console.error("Failed to set theme: theme is null");
return;
}
document.documentElement.setAttribute("data-theme", theme.id);
}
export function applyThemeToDocument(theme: Theme | null) {
addThemeStylesToDocument(theme);
setThemeOnDocument(theme);
}
export function platformFromUserAgent(userAgent: string): DocumentPlatform {
const normalized = userAgent.toLowerCase();
if (normalized.includes("linux")) return "linux";
if (normalized.includes("mac os") || normalized.includes("macintosh")) return "macos";
if (normalized.includes("win")) return "windows";
return "unknown";
}
export function setPlatformOnDocument(platform: string | null | undefined) {
const normalized =
platform === "linux" || platform === "macos" || platform === "windows" ? platform : "unknown";
document.documentElement.setAttribute("data-platform", normalized);
}
export function indent(text: string, space = " "): string {
return text
.split("\n")
.map((line) => space + line)
.join("\n");
}
function yc<T extends string | null | undefined>(
theme: Theme,
value: T,
): T extends string ? YaakColor : null {
if (value == null) return null as never;
return new YaakColor(value, theme.dark ? "dark" : "light") as never;
}
export function completeTheme(theme: Theme): Theme {
const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;
for (const [key, value] of Object.entries(fallback)) {
theme.base[key as YaakColorKey] ??= value;
}
return theme;
}

View File

@@ -0,0 +1,386 @@
import parseColor from "parse-color";
export class YaakColor {
private readonly appearance: "dark" | "light" = "light";
private lightness = 0;
private chroma = 0;
private hue = 0;
private alpha = 1;
constructor(cssColor: string, appearance: "dark" | "light" = "light") {
try {
this.set(cssColor);
this.appearance = appearance;
} catch (err) {
console.log("Failed to parse CSS color", cssColor, err);
}
}
static transparent(): YaakColor {
return new YaakColor("rgb(0,0,0)", "light").translucify(1);
}
static white(): YaakColor {
return new YaakColor("rgb(0,0,0)", "light").lower(999);
}
static black(): YaakColor {
return new YaakColor("rgb(0,0,0)", "light").lift(999);
}
set(cssColor: string): YaakColor {
let fixedCssColor = cssColor;
if (cssColor.startsWith("#") && cssColor.length === 9) {
const [r, g, b, a] = hexToRgba(cssColor);
fixedCssColor = `rgba(${r},${g},${b},${a})`;
}
const oklch = parseOklch(fixedCssColor);
if (oklch != null) {
this.lightness = oklch.lightness;
this.chroma = oklch.chroma;
this.hue = oklch.hue;
this.alpha = oklch.alpha;
return this;
}
const { rgba } = parseColor(fixedCssColor);
const [lightness, chroma, hue] = rgbToOklch(rgba[0], rgba[1], rgba[2]);
this.lightness = lightness;
this.chroma = chroma;
this.hue = hue;
this.alpha = rgba[3] ?? 1;
return this;
}
clone(): YaakColor {
return new YaakColor(this.css(), this.appearance);
}
themeColor(cssColor: string): YaakColor {
return new YaakColor(cssColor, this.appearance);
}
lower(mod: number): YaakColor {
return this.appearance === "dark" ? this._darken(mod) : this._lighten(mod);
}
lift(mod: number): YaakColor {
return this.appearance === "dark" ? this._lighten(mod) : this._darken(mod);
}
liftMax(): YaakColor {
return this.lift(999);
}
lowerMax(): YaakColor {
return this.lower(999);
}
themeSurface(): YaakColor {
return new YaakColor(
this.appearance === "dark" ? "oklch(23% 0 0)" : "oklch(100% 0 0)",
this.appearance,
);
}
minLightness(n: number): YaakColor {
const color = this.clone();
if (color.lightness < n) {
color.lightness = n;
}
return color;
}
isDark(): boolean {
return this.lightness < 50;
}
translucify(mod: number): YaakColor {
const color = this.clone();
color.alpha = clamp(color.alpha - color.alpha * mod, 0, 1);
return color;
}
opacify(mod: number): YaakColor {
const color = this.clone();
color.alpha = clamp(this.alpha + (1 - this.alpha) * mod, 0, 1);
return color;
}
desaturate(mod: number): YaakColor {
const color = this.clone();
color.chroma = color.chroma - color.chroma * mod;
return color;
}
saturate(mod: number): YaakColor {
const color = this.clone();
color.chroma = this.chroma + this.chroma * mod;
return color;
}
lighterThan(color: YaakColor): boolean {
return this.lightness > color.lightness;
}
contrastRatio(background: YaakColor): number {
const foreground = this.alpha < 1 ? this.compositeOver(background) : this;
const foregroundLuminance = foreground.relativeLuminance();
const backgroundLuminance = background.relativeLuminance();
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
const darker = Math.min(foregroundLuminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
withContrast(background: YaakColor, minContrast: number): YaakColor {
const darker = this.clone();
darker.lightness = 0;
darker.chroma = 0;
darker.hue = 0;
const lighter = this.clone();
lighter.lightness = 100;
lighter.chroma = 0;
lighter.hue = 0;
const darkerContrast = darker.contrastRatio(background);
const lighterContrast = lighter.contrastRatio(background);
let useLighterColor = lighterContrast >= darkerContrast;
// Saturated accent surfaces often read better with white text even when
// black has the higher numeric contrast. Keep yellow-ish light accents dark
// by requiring white to clear a modest contrast floor first.
if (minContrast >= 3 && lighterContrast >= 2.5) {
useLighterColor = true;
}
const selectedContrast = useLighterColor ? lighterContrast : darkerContrast;
if (selectedContrast < minContrast) {
return useLighterColor ? lighter : darker;
}
let minLightness = 0;
let maxLightness = 100;
const color = this.clone();
for (let i = 0; i < 24; i += 1) {
color.lightness = (minLightness + maxLightness) / 2;
const contrast = color.contrastRatio(background);
if (useLighterColor) {
if (contrast >= minContrast) {
maxLightness = color.lightness;
} else {
minLightness = color.lightness;
}
} else if (contrast >= minContrast) {
minLightness = color.lightness;
} else {
maxLightness = color.lightness;
}
}
color.lightness = useLighterColor ? maxLightness : minLightness;
return color;
}
compositeOver(background: YaakColor): YaakColor {
const [fgR, fgG, fgB] = this.rgb();
const [bgR, bgG, bgB] = background.rgb();
const alpha = this.alpha + background.alpha * (1 - this.alpha);
if (alpha <= 0) {
return YaakColor.transparent();
}
const r = (fgR * this.alpha + bgR * background.alpha * (1 - this.alpha)) / alpha;
const g = (fgG * this.alpha + bgG * background.alpha * (1 - this.alpha)) / alpha;
const b = (fgB * this.alpha + bgB * background.alpha * (1 - this.alpha)) / alpha;
return new YaakColor(`rgba(${r},${g},${b},${alpha})`, this.appearance);
}
css(): string {
const [r, g, b] = this.rgb();
return rgbaToHex(r, g, b, this.alpha);
}
hexNoAlpha(): string {
const [r, g, b] = this.rgb();
return rgbaToHexNoAlpha(r, g, b);
}
private relativeLuminance(): number {
const [r, g, b] = this.rgb();
const red = srgbToLinear(r / 255);
const green = srgbToLinear(g / 255);
const blue = srgbToLinear(b / 255);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}
private rgb(): [number, number, number] {
return oklchToRgb(this.lightness, this.chroma, this.hue);
}
private _lighten(mod: number): YaakColor {
const color = this.clone();
color.lightness = clamp(this.lightness + (100 - this.lightness) * mod, 0, 100);
return color;
}
private _darken(mod: number): YaakColor {
const color = this.clone();
color.lightness = clamp(this.lightness - this.lightness * mod, 0, 100);
return color;
}
}
function parseOklch(
cssColor: string,
): { lightness: number; chroma: number; hue: number; alpha: number } | null {
const match = cssColor
.trim()
.match(
/^oklch\(\s*([^\s,]+)(?:\s+|,\s*)([^\s,]+)(?:\s+|,\s*)([^\s,/]+)(?:\s*\/\s*([^)]+)|(?:\s*,\s*([^)]*))?)\s*\)$/i,
);
if (match == null) return null;
const [, lightnessValue, chromaValue, hueValue, slashAlpha, commaAlpha] = match;
if (lightnessValue == null || chromaValue == null || hueValue == null) return null;
const lightness = parseOklchLightness(lightnessValue);
const chroma = parseCssNumber(chromaValue, 1);
const hue = normalizeHue(parseCssNumber(hueValue.replace(/deg$/i, ""), 1));
const alpha = parseCssNumber(slashAlpha ?? commaAlpha ?? "1", 1);
if (
!Number.isFinite(lightness) ||
!Number.isFinite(chroma) ||
!Number.isFinite(hue) ||
!Number.isFinite(alpha)
) {
return null;
}
return {
lightness: clamp(lightness, 0, 100),
chroma: Math.max(0, chroma),
hue,
alpha: clamp(alpha, 0, 1),
};
}
function parseCssNumber(value: string, percentScale: number): number {
const normalized = value.trim();
if (normalized.endsWith("%")) {
return (Number.parseFloat(normalized) / 100) * percentScale;
}
return Number.parseFloat(normalized);
}
function parseOklchLightness(value: string): number {
const parsed = parseCssNumber(value, 100);
return value.trim().endsWith("%") || parsed > 1 ? parsed : parsed * 100;
}
function rgbToOklch(r: number, g: number, b: number): [number, number, number] {
const red = srgbToLinear(r / 255);
const green = srgbToLinear(g / 255);
const blue = srgbToLinear(b / 255);
const l = 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue;
const m = 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue;
const s = 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue;
const lRoot = Math.cbrt(l);
const mRoot = Math.cbrt(m);
const sRoot = Math.cbrt(s);
const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot;
const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot;
const okb = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot;
return [
lightness * 100,
Math.sqrt(a * a + okb * okb),
normalizeHue(radToDeg(Math.atan2(okb, a))),
];
}
function oklchToRgb(lightness: number, chroma: number, hue: number): [number, number, number] {
const l = clamp(lightness, 0, 100) / 100;
const a = Math.cos(degToRad(hue)) * chroma;
const b = Math.sin(degToRad(hue)) * chroma;
const lRoot = l + 0.3963377774 * a + 0.2158037573 * b;
const mRoot = l - 0.1055613458 * a - 0.0638541728 * b;
const sRoot = l - 0.0894841775 * a - 1.291485548 * b;
const lCube = lRoot * lRoot * lRoot;
const mCube = mRoot * mRoot * mRoot;
const sCube = sRoot * sRoot * sRoot;
const red = 4.0767416621 * lCube - 3.3077115913 * mCube + 0.2309699292 * sCube;
const green = -1.2684380046 * lCube + 2.6097574011 * mCube - 0.3413193965 * sCube;
const blue = -0.0041960863 * lCube - 0.7034186147 * mCube + 1.707614701 * sCube;
return [linearToSrgb(red) * 255, linearToSrgb(green) * 255, linearToSrgb(blue) * 255];
}
function srgbToLinear(value: number): number {
return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
}
function linearToSrgb(value: number): number {
const srgb = value <= 0.0031308 ? value * 12.92 : 1.055 * Math.pow(value, 1 / 2.4) - 0.055;
return clamp(srgb, 0, 1);
}
function normalizeHue(value: number): number {
const hue = value % 360;
return hue < 0 ? hue + 360 : hue;
}
function degToRad(value: number): number {
return (value * Math.PI) / 180;
}
function radToDeg(value: number): number {
return (value * 180) / Math.PI;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function rgbaToHex(r: number, g: number, b: number, a: number): string {
const toHex = (n: number): string => {
const hex = Number(Math.round(n)).toString(16);
return hex.length === 1 ? `0${hex}` : hex;
};
return `#${[toHex(r), toHex(g), toHex(b), toHex(a * 255)].join("").toUpperCase()}`;
}
function rgbaToHexNoAlpha(r: number, g: number, b: number): string {
const toHex = (n: number): string => {
const hex = Number(Math.round(n)).toString(16);
return hex.length === 1 ? `0${hex}` : hex;
};
return `#${[toHex(r), toHex(g), toHex(b)].join("").toUpperCase()}`;
}
function hexToRgba(hex: string): [number, number, number, number] {
const fromHex = (value: string): number => {
if (value === "") return 255;
return Number(`0x${value}`);
};
const r = fromHex(hex.slice(1, 3));
const g = fromHex(hex.slice(3, 5));
const b = fromHex(hex.slice(5, 7));
const a = fromHex(hex.slice(7, 9));
return [r, g, b, a / 255];
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2021",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"strict": true,
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src"]
}