mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-28 20:31:47 +01:00
Extract shared UI and theme packages
This commit is contained in:
136
packages/tailwind-config/index.cjs
Normal file
136
packages/tailwind-config/index.cjs
Normal file
@@ -0,0 +1,136 @@
|
||||
const plugin = require("tailwindcss/plugin");
|
||||
|
||||
const sizes = {
|
||||
"2xs": "1.4rem",
|
||||
xs: "1.8rem",
|
||||
sm: "2.0rem",
|
||||
md: "2.3rem",
|
||||
lg: "2.6rem",
|
||||
};
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class", '[data-resolved-appearance="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
keyframes: {
|
||||
blinkRing: {
|
||||
"0%, 49%": { "--tw-ring-color": "var(--primary)" },
|
||||
"50%, 99%": { "--tw-ring-color": "transparent" },
|
||||
"100%": { "--tw-ring-color": "var(--primary)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
blinkRing: "blinkRing 150ms step-start 400ms infinite",
|
||||
},
|
||||
opacity: {
|
||||
disabled: "0.3",
|
||||
},
|
||||
fontSize: {
|
||||
xs: "0.8rem",
|
||||
},
|
||||
height: sizes,
|
||||
width: sizes,
|
||||
minHeight: sizes,
|
||||
minWidth: sizes,
|
||||
lineHeight: {
|
||||
// HACK: Minus 2 to account for borders inside inputs
|
||||
xs: "calc(1.75rem - 2px)",
|
||||
sm: "calc(2.0rem - 2px)",
|
||||
md: "calc(2.5rem - 2px)",
|
||||
},
|
||||
transitionProperty: {
|
||||
grid: "grid",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
mono: [
|
||||
"var(--font-family-editor)",
|
||||
"JetBrains Mono",
|
||||
"ui-monospace",
|
||||
"SFMono-Regular",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Fira Code",
|
||||
"Ubuntu Mono",
|
||||
"Consolas",
|
||||
"Liberation Mono",
|
||||
"Courier New",
|
||||
"DejaVu Sans Mono",
|
||||
"Hack",
|
||||
"monospace",
|
||||
],
|
||||
sans: [
|
||||
"var(--font-family-interface)",
|
||||
"Inter UI",
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
"Roboto",
|
||||
"Oxygen-Sans",
|
||||
"Ubuntu",
|
||||
"Cantarell",
|
||||
"Helvetica Neue",
|
||||
"sans-serif",
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol",
|
||||
],
|
||||
},
|
||||
fontSize: {
|
||||
"4xs": "0.6rem",
|
||||
"3xs": "0.675rem",
|
||||
"2xs": "0.75rem",
|
||||
xs: "0.8rem",
|
||||
sm: "0.9rem",
|
||||
base: "1rem",
|
||||
lg: "1.12rem",
|
||||
xl: "1.25rem",
|
||||
"2xl": "1.5rem",
|
||||
"3xl": "2rem",
|
||||
"4xl": "2.5rem",
|
||||
"5xl": "3rem",
|
||||
editor: "var(--editor-font-size)",
|
||||
shrink: "0.8em",
|
||||
},
|
||||
boxShadow: {
|
||||
DEFAULT: "0 1px 3px 0 var(--shadow)",
|
||||
lg: "0 10px 15px -3px var(--shadow)",
|
||||
},
|
||||
colors: {
|
||||
transparent: "transparent",
|
||||
placeholder: "var(--textSubtlest)",
|
||||
shadow: "var(--shadow)",
|
||||
backdrop: "var(--backdrop)",
|
||||
selection: "var(--selection)",
|
||||
|
||||
// New theme values
|
||||
surface: "var(--surface)",
|
||||
"surface-highlight": "var(--surfaceHighlight)",
|
||||
"surface-active": "var(--surfaceActive)",
|
||||
text: "var(--text)",
|
||||
"text-subtle": "var(--textSubtle)",
|
||||
"text-subtlest": "var(--textSubtlest)",
|
||||
border: "var(--border)",
|
||||
"border-subtle": "var(--borderSubtle)",
|
||||
"border-focus": "var(--borderFocus)",
|
||||
primary: "var(--primary)",
|
||||
danger: "var(--danger)",
|
||||
secondary: "var(--secondary)",
|
||||
success: "var(--success)",
|
||||
info: "var(--info)",
|
||||
notice: "var(--notice)",
|
||||
warning: "var(--warning)",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/container-queries"),
|
||||
plugin(function ({ addVariant }) {
|
||||
addVariant("hocus", ["&:hover", "&:focus-visible", "&.focus:focus"]);
|
||||
addVariant("focus-visible-or-class", [
|
||||
"&:focus-visible",
|
||||
"&.focus:focus",
|
||||
]);
|
||||
}),
|
||||
],
|
||||
};
|
||||
5
packages/tailwind-config/index.d.ts
vendored
Normal file
5
packages/tailwind-config/index.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
declare const config: Config;
|
||||
|
||||
export = config;
|
||||
11
packages/tailwind-config/package.json
Normal file
11
packages/tailwind-config/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/tailwind-config",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"main": "index.cjs",
|
||||
"types": "index.d.ts",
|
||||
"dependencies": {
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
13
packages/theme/package.json
Normal file
13
packages/theme/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
44
packages/theme/src/appearance.ts
Normal file
44
packages/theme/src/appearance.ts
Normal 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);
|
||||
}
|
||||
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%,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%)",
|
||||
},
|
||||
},
|
||||
};
|
||||
26
packages/theme/src/index.ts
Normal file
26
packages/theme/src/index.ts
Normal 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";
|
||||
430
packages/theme/src/window.ts
Normal file
430
packages/theme/src/window.ts
Normal 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;
|
||||
}
|
||||
153
packages/theme/src/yaakColor.ts
Normal file
153
packages/theme/src/yaakColor.ts
Normal 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];
|
||||
}
|
||||
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": "Node",
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
16
packages/ui/package.json
Normal file
16
packages/ui/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/ui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
}
|
||||
}
|
||||
123
packages/ui/src/components/Button.tsx
Normal file
123
packages/ui/src/components/Button.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
type ButtonColor =
|
||||
| "default"
|
||||
| "custom"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "info"
|
||||
| "success"
|
||||
| "notice"
|
||||
| "warning"
|
||||
| "danger";
|
||||
|
||||
type ButtonVariant = "border" | "solid";
|
||||
type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto";
|
||||
|
||||
export type ButtonProps = Omit<
|
||||
HTMLAttributes<HTMLButtonElement>,
|
||||
"color" | "onChange"
|
||||
> & {
|
||||
innerClassName?: string;
|
||||
color?: ButtonColor;
|
||||
tone?: Exclude<ButtonColor, "custom">;
|
||||
variant?: ButtonVariant;
|
||||
isLoading?: boolean;
|
||||
size?: ButtonSize;
|
||||
justify?: "start" | "center";
|
||||
type?: "button" | "submit";
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
};
|
||||
|
||||
function cx(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function Button({
|
||||
isLoading,
|
||||
className,
|
||||
innerClassName,
|
||||
children,
|
||||
color,
|
||||
tone,
|
||||
type = "button",
|
||||
justify = "center",
|
||||
size = "md",
|
||||
variant = "solid",
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
disabled,
|
||||
title,
|
||||
onClick,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const resolvedColor = color ?? tone ?? "default";
|
||||
const isDisabled = disabled || isLoading;
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={cx(
|
||||
className,
|
||||
"x-theme-button",
|
||||
`x-theme-button--${variant}`,
|
||||
`x-theme-button--${variant}--${resolvedColor}`,
|
||||
"border",
|
||||
"max-w-full min-w-0",
|
||||
"hocus:opacity-100",
|
||||
"whitespace-nowrap outline-none",
|
||||
"flex-shrink-0 flex items-center",
|
||||
"outline-0",
|
||||
isDisabled ? "pointer-events-none opacity-disabled" : "pointer-events-auto",
|
||||
justify === "start" && "justify-start",
|
||||
justify === "center" && "justify-center",
|
||||
size === "md" && "h-md px-3 rounded-md",
|
||||
size === "sm" && "h-sm px-2.5 rounded-md",
|
||||
size === "xs" && "h-xs px-2 text-sm rounded-md",
|
||||
size === "2xs" && "h-2xs px-2 text-xs rounded",
|
||||
size === "auto" && "px-3 py-2 rounded-md",
|
||||
variant === "solid" && "border-transparent",
|
||||
variant === "solid" &&
|
||||
resolvedColor === "custom" &&
|
||||
"focus-visible:outline-2 outline-border-focus",
|
||||
variant === "solid" &&
|
||||
resolvedColor !== "custom" &&
|
||||
"text-text enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle",
|
||||
variant === "solid" &&
|
||||
resolvedColor !== "custom" &&
|
||||
resolvedColor !== "default" &&
|
||||
"bg-surface",
|
||||
variant === "border" && "border",
|
||||
variant === "border" &&
|
||||
resolvedColor !== "custom" &&
|
||||
"border-border-subtle text-text-subtle enabled:hocus:border-border enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler",
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
title={title}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="mr-1">...</div>
|
||||
) : leftSlot ? (
|
||||
<div className="mr-2">{leftSlot}</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cx(
|
||||
"truncate w-full",
|
||||
justify === "start" ? "text-left" : "text-center",
|
||||
innerClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{rightSlot ? <div className="ml-1">{rightSlot}</div> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
2
packages/ui/src/index.ts
Normal file
2
packages/ui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Button } from "./components/Button";
|
||||
export type { ButtonProps } from "./components/Button";
|
||||
12
packages/ui/tsconfig.json
Normal file
12
packages/ui/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user