Extract shared UI and theme packages

This commit is contained in:
Gregory Schier
2026-03-06 10:30:31 -08:00
parent 6915778c06
commit fd100330a6
33 changed files with 1388 additions and 1021 deletions

View File

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

View File

@@ -0,0 +1,44 @@
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
export type Appearance = "light" | "dark";
export function getCSSAppearance(): Appearance {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export async function getWindowAppearance(): Promise<Appearance> {
const appearance = await getCurrentWebviewWindow().theme();
return appearance ?? getCSSAppearance();
}
export function subscribeToWindowAppearanceChange(
cb: (appearance: Appearance) => void,
): () => void {
const container = {
unsubscribe: () => {},
};
getCurrentWebviewWindow()
.onThemeChanged((theme) => {
cb(theme.payload);
})
.then((listener) => {
container.unsubscribe = listener;
});
return () => container.unsubscribe();
}
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) {
cb(getCSSAppearance());
getWindowAppearance().then(cb);
subscribeToWindowAppearanceChange(cb);
}

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%,87%)",
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%,90%)",
},
},
};

View File

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

View File

@@ -0,0 +1,430 @@
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<YaakTheme["components"]>;
type CSSVariables = Record<YaakColorKey, string | undefined>;
function themeVariables(
theme: Theme,
component?: ComponentName,
base?: CSSVariables,
): CSSVariables | null {
const cmp =
component == null
? theme.base
: (theme.components?.[component] ?? ({} as ThemeComponentColors));
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 ?? color(cmp.surface)?.lift(0.11)?.css(),
borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06)?.css(),
borderFocus: color(cmp.info)?.translucify(0.5)?.css(),
text: cmp.text,
textSubtle: cmp.textSubtle ?? color(cmp.text)?.lower(0.2)?.css(),
textSubtlest: cmp.textSubtlest ?? color(cmp.text)?.lower(0.3)?.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,
};
for (const [key, value] of Object.entries(vars)) {
if (!value && base?.[key as YaakColorKey]) {
vars[key as YaakColorKey] = base[key as YaakColorKey];
}
}
return vars;
}
function templateTagColorVariables(
color: YaakColor | null,
): Partial<CSSVariables> {
if (color == null) return {};
return {
text: color.lift(0.7).css(),
textSubtle: color.lift(0.4).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(color: YaakColor | null): Partial<CSSVariables> {
if (color == null) return {};
return {
text: color.lift(0.8).css(),
textSubtle: color.lift(0.8).translucify(0.3).css(),
surface: color.translucify(0.9).css(),
surfaceHighlight: color.translucify(0.8).css(),
border: color.lift(0.3).translucify(0.6).css(),
};
}
function bannerColorVariables(color: YaakColor | null): Partial<CSSVariables> {
if (color == null) return {};
return {
text: color.lift(0.8).css(),
textSubtle: color.translucify(0.3).css(),
textSubtlest: color.translucify(0.6).css(),
surface: color.translucify(0.95).css(),
border: color.lift(0.3).translucify(0.8).css(),
};
}
function buttonSolidColorVariables(
color: YaakColor | null,
isDefault = false,
): Partial<CSSVariables> {
if (color == null) return {};
const theme: Partial<ThemeComponentColors> = {
text: "white",
surface: color.lower(0.3).css(),
surfaceHighlight: color.lower(0.1).css(),
border: color.css(),
};
if (isDefault) {
theme.text = undefined;
theme.surface = undefined;
theme.surfaceHighlight = color.lift(0.08).css();
}
return theme;
}
function buttonBorderColorVariables(
color: YaakColor | null,
isDefault = false,
): Partial<CSSVariables> {
if (color == null) return {};
const vars: Partial<CSSVariables> = {
text: color.lift(0.8).css(),
textSubtle: color.lift(0.55).css(),
textSubtlest: color.lift(0.4).translucify(0.6).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 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(theme: Theme, component: ComponentName): string | null {
if (theme.components == null) return null;
return variablesToCSS(
`.x-theme-${component}`,
themeVariables(theme, component),
);
}
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(color),
),
variablesToCSS(
`.x-theme-button--border--${colorKey}`,
buttonBorderColorVariables(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(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(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(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, themeVariables(theme));
themeCSS = [
baseCss,
...Object.keys(components).map((key) =>
componentCSS(theme, key as ComponentName),
),
variablesToCSS(
".x-theme-button--solid--default",
buttonSolidColorVariables(yc(theme, theme.base.surface), true),
),
variablesToCSS(
".x-theme-button--border--default",
buttonBorderColorVariables(yc(theme, theme.base.surface), 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;
const color = (value: string | null | undefined) => yc(theme, value);
theme.base.primary ??= fallback.primary;
theme.base.secondary ??= fallback.secondary;
theme.base.info ??= fallback.info;
theme.base.success ??= fallback.success;
theme.base.notice ??= fallback.notice;
theme.base.warning ??= fallback.warning;
theme.base.danger ??= fallback.danger;
theme.base.surface ??= fallback.surface;
theme.base.surfaceHighlight ??= color(theme.base.surface)?.lift(0.06)?.css();
theme.base.surfaceActive ??= color(theme.base.primary)
?.lower(0.2)
.translucify(0.8)
.css();
theme.base.border ??= color(theme.base.surface)?.lift(0.12)?.css();
theme.base.borderSubtle ??= color(theme.base.border)?.lower(0.08)?.css();
theme.base.text ??= fallback.text;
theme.base.textSubtle ??= color(theme.base.text)?.lower(0.3)?.css();
theme.base.textSubtlest ??= color(theme.base.text)?.lower(0.5)?.css();
return theme;
}

View File

@@ -0,0 +1,153 @@
import parseColor from "parse-color";
export class YaakColor {
private readonly appearance: "dark" | "light" = "light";
private hue = 0;
private saturation = 0;
private lightness = 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(1);
}
static black(): YaakColor {
return new YaakColor("rgb(0,0,0)", "light").lift(1);
}
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 { hsla } = parseColor(fixedCssColor);
this.hue = hsla[0];
this.saturation = hsla[1];
this.lightness = hsla[2];
this.alpha = hsla[3] ?? 1;
return this;
}
clone(): YaakColor {
return new YaakColor(this.css(), 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);
}
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 = color.alpha - color.alpha * mod;
return color;
}
opacify(mod: number): YaakColor {
const color = this.clone();
color.alpha = this.alpha + (100 - this.alpha) * mod;
return color;
}
desaturate(mod: number): YaakColor {
const color = this.clone();
color.saturation = color.saturation - color.saturation * mod;
return color;
}
saturate(mod: number): YaakColor {
const color = this.clone();
color.saturation = this.saturation + (100 - this.saturation) * mod;
return color;
}
lighterThan(color: YaakColor): boolean {
return this.lightness > color.lightness;
}
css(): string {
const [r, g, b] = parseColor(
`hsl(${this.hue},${this.saturation}%,${this.lightness}%)`,
).rgb;
return rgbaToHex(r, g, b, this.alpha);
}
hexNoAlpha(): string {
const [r, g, b] = parseColor(
`hsl(${this.hue},${this.saturation}%,${this.lightness}%)`,
).rgb;
return rgbaToHexNoAlpha(r, g, b);
}
private _lighten(mod: number): YaakColor {
const color = this.clone();
color.lightness = this.lightness + (100 - this.lightness) * mod;
return color;
}
private _darken(mod: number): YaakColor {
const color = this.clone();
color.lightness = this.lightness - this.lightness * mod;
return color;
}
}
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": "Node",
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src"]
}