snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
13
packages/theme/package.json
Normal file
13
packages/theme/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
85
packages/theme/src/appearance.ts
Normal file
85
packages/theme/src/appearance.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
76
packages/theme/src/defaultThemes.ts
Normal file
76
packages/theme/src/defaultThemes.ts
Normal 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%)",
|
||||
},
|
||||
},
|
||||
};
|
||||
28
packages/theme/src/index.ts
Normal file
28
packages/theme/src/index.ts
Normal 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";
|
||||
413
packages/theme/src/window.ts
Normal file
413
packages/theme/src/window.ts
Normal 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;
|
||||
}
|
||||
386
packages/theme/src/yaakColor.ts
Normal file
386
packages/theme/src/yaakColor.ts
Normal 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];
|
||||
}
|
||||
12
packages/theme/tsconfig.json
Normal file
12
packages/theme/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user