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

@@ -1,48 +1,8 @@
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 a = await getCurrentWebviewWindow().theme();
return a ?? getCSSAppearance();
}
/**
* Subscribe to appearance (dark/light) changes. Note, we use Tauri Window appearance instead of
* CSS appearance because CSS won't fire the way we handle window theme management.
*/
export function subscribeToWindowAppearanceChange(
cb: (appearance: Appearance) => void,
): () => void {
const container = {
unsubscribe: () => {},
};
getCurrentWebviewWindow()
.onThemeChanged((t) => {
cb(t.payload);
})
.then((l) => {
container.unsubscribe = l;
});
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: (a: Appearance) => void) {
cb(getCSSAppearance());
getWindowAppearance().then(cb);
subscribeToWindowAppearanceChange(cb);
}
export type { Appearance } from "@yaakapp-internal/theme";
export {
getCSSAppearance,
getWindowAppearance,
resolveAppearance,
subscribeToPreferredAppearance,
subscribeToWindowAppearanceChange,
} from "@yaakapp-internal/theme";

View File

@@ -1,14 +1,21 @@
import type { GetThemesResponse } from '@yaakapp-internal/plugins';
import { invokeCmd } from '../tauri';
import type { Appearance } from './appearance';
import { resolveAppearance } from './appearance';
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
import { invokeCmd } from "../tauri";
import type { Appearance } from "./appearance";
import { resolveAppearance } from "./appearance";
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
export async function getThemes() {
const themes = (await invokeCmd<GetThemesResponse[]>('cmd_get_themes')).flatMap((t) => t.themes);
const themes = (
await invokeCmd<GetThemesResponse[]>("cmd_get_themes")
).flatMap((t) => t.themes);
themes.sort((a, b) => a.label.localeCompare(b.label));
// Remove duplicates, in case multiple plugins provide the same theme
const uniqueThemes = Array.from(new Map(themes.map((t) => [t.id, t])).values());
return { themes: [yaakDark, yaakLight, ...uniqueThemes] };
const uniqueThemes = Array.from(
new Map(themes.map((t) => [t.id, t])).values(),
);
return { themes: [defaultDarkTheme, defaultLightTheme, ...uniqueThemes] };
}
export async function getResolvedTheme(
@@ -23,88 +30,16 @@ export async function getResolvedTheme(
const darkThemes = themes.filter((t) => t.dark);
const lightThemes = themes.filter((t) => !t.dark);
const dark = darkThemes.find((t) => t.id === themeDark) ?? darkThemes[0] ?? yaakDark;
const light = lightThemes.find((t) => t.id === themeLight) ?? lightThemes[0] ?? yaakLight;
const dark =
darkThemes.find((t) => t.id === themeDark) ??
darkThemes[0] ??
defaultDarkTheme;
const light =
lightThemes.find((t) => t.id === themeLight) ??
lightThemes[0] ??
defaultLightTheme;
const active = appearance === 'dark' ? dark : light;
const active = appearance === "dark" ? dark : light;
return { dark, light, active };
}
const yaakDark = {
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%)',
},
},
};
const yaakLight = {
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%)',
},
},
};
export const defaultDarkTheme = yaakDark;
export const defaultLightTheme = yaakLight;

View File

@@ -1,386 +1,13 @@
import type { Theme, ThemeComponentColors } from '@yaakapp-internal/plugins';
import { defaultDarkTheme, defaultLightTheme } from './themes';
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;
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 c = (s: string | undefined) => yc(theme, s);
const vars: CSSVariables = {
surface: cmp.surface,
surfaceHighlight: cmp.surfaceHighlight ?? c(cmp.surface)?.lift(0.06).css(),
surfaceActive: cmp.surfaceActive ?? c(cmp.primary)?.lower(0.2).translucify(0.8).css(),
backdrop: cmp.backdrop ?? c(cmp.surface)?.lower(0.2).translucify(0.2).css(),
selection: cmp.selection ?? c(cmp.primary)?.lower(0.1).translucify(0.7).css(),
border: cmp.border ?? c(cmp.surface)?.lift(0.11)?.css(),
borderSubtle: cmp.borderSubtle ?? c(cmp.border)?.lower(0.06)?.css(),
borderFocus: c(cmp.info)?.translucify(0.5)?.css(),
text: cmp.text,
textSubtle: cmp.textSubtle ?? c(cmp.text)?.lower(0.2)?.css(),
textSubtlest: cmp.textSubtlest ?? c(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,
};
// Extend with base
for (const [k, v] of Object.entries(vars)) {
if (!v && base?.[k as YaakColorKey]) {
vars[k as YaakColorKey] = base[k 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 inputCSS(color: YaakColor | null): Partial<CSSVariables> {
if (color == null) return {};
const theme: Partial<ThemeComponentColors> = {
border: color.css(),
};
return theme;
}
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; // Inherit from parent
theme.surface = undefined; // Inherit from parent
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;
}
const themeVars = themeVariables(theme, component);
return variablesToCSS(`.x-theme-${component}`, themeVars);
}
function buttonCSS(
theme: Theme,
color: YaakColorKey,
colors?: ThemeComponentColors,
): string | null {
const yaakColor = yc(theme, colors?.[color]);
if (yaakColor == null) {
return null;
}
return [
variablesToCSS(`.x-theme-button--solid--${color}`, buttonSolidColorVariables(yaakColor)),
variablesToCSS(`.x-theme-button--border--${color}`, buttonBorderColorVariables(yaakColor)),
].join('\n\n');
}
function bannerCSS(
theme: Theme,
color: YaakColorKey,
colors?: ThemeComponentColors,
): string | null {
const yaakColor = yc(theme, colors?.[color]);
if (yaakColor == null) {
return null;
}
return [variablesToCSS(`.x-theme-banner--${color}`, bannerColorVariables(yaakColor))].join(
'\n\n',
);
}
function toastCSS(theme: Theme, color: YaakColorKey, colors?: ThemeComponentColors): string | null {
const yaakColor = yc(theme, colors?.[color]);
if (yaakColor == null) {
return null;
}
return [variablesToCSS(`.x-theme-toast--${color}`, toastColorVariables(yaakColor))].join('\n\n');
}
function templateTagCSS(
theme: Theme,
color: YaakColorKey,
colors?: ThemeComponentColors,
): string | null {
const yaakColor = yc(theme, colors?.[color]);
if (yaakColor == null) {
return null;
}
return [
variablesToCSS(`.x-theme-templateTag--${color}`, templateTagColorVariables(yaakColor)),
].join('\n\n');
}
export function getThemeCSS(theme: Theme): string {
theme.components = theme.components ?? {};
// Toast defaults to menu styles
theme.components.toast = theme.components.toast ?? theme.components.menu ?? {};
const { components, id, label } = theme;
const colors = Object.keys(theme.base).reduce((prev, key) => {
// biome-ignore lint/performance/noAccumulatingSpread: none
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 indent(text: string, space = ' '): string {
return text
.split('\n')
.map((line) => space + line)
.join('\n');
}
function yc<T extends string | null | undefined>(
theme: Theme,
s: T,
): T extends string ? YaakColor : null {
if (s == null) return null as never;
return new YaakColor(s, theme.dark ? 'dark' : 'light') as never;
}
export function completeTheme(theme: Theme): Theme {
const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;
const c = (s: string | null | undefined) => yc(theme, s);
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 ??= c(theme.base.surface)?.lift(0.06)?.css();
theme.base.surfaceActive ??= c(theme.base.primary)?.lower(0.2).translucify(0.8).css();
theme.base.border ??= c(theme.base.surface)?.lift(0.12)?.css();
theme.base.borderSubtle ??= c(theme.base.border)?.lower(0.08)?.css();
theme.base.text ??= fallback.text;
theme.base.textSubtle ??= c(theme.base.text)?.lower(0.3)?.css();
theme.base.textSubtlest ??= c(theme.base.text)?.lower(0.5)?.css();
return theme;
}
export type {
YaakColorKey,
YaakColors,
YaakTheme,
} from "@yaakapp-internal/theme";
export {
addThemeStylesToDocument,
applyThemeToDocument,
completeTheme,
getThemeCSS,
indent,
setThemeOnDocument,
} from "@yaakapp-internal/theme";

View File

@@ -1,158 +1 @@
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 c = this.clone();
if (c.lightness < n) {
c.lightness = n;
}
return c;
}
isDark(): boolean {
return this.lightness < 50;
}
translucify(mod: number): YaakColor {
const c = this.clone();
c.alpha = c.alpha - c.alpha * mod;
return c;
}
opacify(mod: number): YaakColor {
const c = this.clone();
c.alpha = this.alpha + (100 - this.alpha) * mod;
return c;
}
desaturate(mod: number): YaakColor {
const c = this.clone();
c.saturation = c.saturation - c.saturation * mod;
return c;
}
saturate(mod: number): YaakColor {
const c = this.clone();
c.saturation = this.saturation + (100 - this.saturation) * mod;
return c;
}
lighterThan(c: YaakColor): boolean {
return this.lightness > c.lightness;
}
css(): string {
const h = this.hue;
const s = this.saturation;
const l = this.lightness;
const a = this.alpha;
const [r, g, b] = parseColor(`hsl(${h},${s}%,${l}%)`).rgb;
return rgbaToHex(r, g, b, a);
}
hexNoAlpha(): string {
const h = this.hue;
const s = this.saturation;
const l = this.lightness;
const [r, g, b] = parseColor(`hsl(${h},${s}%,${l}%)`).rgb;
return rgbaToHexNoAlpha(r, g, b);
}
private _lighten(mod: number): YaakColor {
const c = this.clone();
c.lightness = this.lightness + (100 - this.lightness) * mod;
return c;
}
private _darken(mod: number): YaakColor {
const c = this.clone();
c.lightness = this.lightness - this.lightness * mod;
return c;
}
}
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 = (h: string): number => {
if (h === '') return 255;
return Number(`0x${h}`);
};
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];
}
export { YaakColor } from "@yaakapp-internal/theme";

View File

@@ -1,29 +1,33 @@
import './main.css';
import { RouterProvider } from '@tanstack/react-router';
import { type } from '@tauri-apps/plugin-os';
import { changeModelStoreWorkspace, initModelStore } from '@yaakapp-internal/models';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { initSync } from './init/sync';
import { initGlobalListeners } from './lib/initGlobalListeners';
import { jotaiStore } from './lib/jotai';
import { router } from './lib/router';
import "./main.css";
import { RouterProvider } from "@tanstack/react-router";
import { type } from "@tauri-apps/plugin-os";
import {
changeModelStoreWorkspace,
initModelStore,
} from "@yaakapp-internal/models";
import { setPlatformOnDocument } from "@yaakapp-internal/theme";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { initSync } from "./init/sync";
import { initGlobalListeners } from "./lib/initGlobalListeners";
import { jotaiStore } from "./lib/jotai";
import { router } from "./lib/router";
const osType = type();
document.documentElement.setAttribute('data-platform', osType);
setPlatformOnDocument(osType);
window.addEventListener('keydown', (e) => {
window.addEventListener("keydown", (e) => {
const rx = /input|select|textarea/i;
const target = e.target;
if (e.key !== 'Backspace') return;
if (e.key !== "Backspace") return;
if (!(target instanceof Element)) return;
if (target.getAttribute('contenteditable') !== null) return;
if (target.getAttribute("contenteditable") !== null) return;
if (
!rx.test(target.tagName) ||
('disabled' in target && target.disabled) ||
('readOnly' in target && target.readOnly)
("disabled" in target && target.disabled) ||
("readOnly" in target && target.readOnly)
) {
e.preventDefault();
}
@@ -35,8 +39,8 @@ initModelStore(jotaiStore);
initGlobalListeners();
await changeModelStoreWorkspace(null); // Load global models
console.log('Creating React root');
createRoot(document.getElementById('root') as HTMLElement).render(
console.log("Creating React root");
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,

View File

@@ -87,6 +87,8 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@yaakapp-internal/theme": "^1.0.0",
"@yaakapp-internal/ui": "^1.0.0",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.21",
"decompress": "^4.2.1",

View File

@@ -1,146 +1,16 @@
const plugin = require('tailwindcss/plugin');
const sizes = {
'2xs': '1.4rem',
xs: '1.8rem',
sm: '2.0rem',
md: '2.3rem',
lg: '2.6rem',
};
const sharedConfig = require("@yaakapp-internal/tailwind-config");
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class', '[data-resolved-appearance="dark"]'],
...sharedConfig,
content: [
'./*.{html,ts,tsx}',
'./commands/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./hooks/**/*.{ts,tsx}',
'./init/**/*.{ts,tsx}',
'./lib/**/*.{ts,tsx}',
'./routes/**/*.{ts,tsx}',
],
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']);
}),
"./*.{html,ts,tsx}",
"./commands/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./hooks/**/*.{ts,tsx}",
"./init/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}",
"./routes/**/*.{ts,tsx}",
"../../packages/ui/src/**/*.{ts,tsx}",
],
};

View File

@@ -1,12 +1,15 @@
import { listen } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { setWindowTheme } from '@yaakapp-internal/mac-window';
import type { ModelPayload } from '@yaakapp-internal/models';
import { getSettings } from './lib/settings';
import type { Appearance } from './lib/theme/appearance';
import { getCSSAppearance, subscribeToPreferredAppearance } from './lib/theme/appearance';
import { getResolvedTheme } from './lib/theme/themes';
import { addThemeStylesToDocument, setThemeOnDocument } from './lib/theme/window';
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { setWindowTheme } from "@yaakapp-internal/mac-window";
import type { ModelPayload } from "@yaakapp-internal/models";
import { getSettings } from "./lib/settings";
import type { Appearance } from "./lib/theme/appearance";
import {
getCSSAppearance,
subscribeToPreferredAppearance,
} from "./lib/theme/appearance";
import { getResolvedTheme } from "./lib/theme/themes";
import { applyThemeToDocument } from "@yaakapp-internal/theme";
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
// a good appearance guess so we're not waiting too long
@@ -22,15 +25,15 @@ configureTheme().then(
// need to show it here, after configuring the theme for the first time.
await getCurrentWebviewWindow().show();
},
(err) => console.log('Failed to configure theme', err),
(err) => console.log("Failed to configure theme", err),
);
// Listen for settings changes, the re-compute theme
listen<ModelPayload>('model_write', async (event) => {
if (event.payload.change.type !== 'upsert') return;
listen<ModelPayload>("model_write", async (event) => {
if (event.payload.change.type !== "upsert") return;
const model = event.payload.model.model;
if (model !== 'settings' && model !== 'plugin') return;
if (model !== "settings" && model !== "plugin") return;
await configureTheme();
}).catch(console.error);
@@ -42,8 +45,7 @@ async function configureTheme() {
settings.themeLight,
settings.themeDark,
);
addThemeStylesToDocument(theme.active);
setThemeOnDocument(theme.active);
applyThemeToDocument(theme.active);
if (theme.active.base.surface != null) {
setWindowTheme(theme.active.base.surface);
}

View File

@@ -15,9 +15,16 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@yaakapp-internal/theme": ["../../packages/theme/src/index.ts"],
"@yaakapp-internal/theme/*": ["../../packages/theme/src/*"],
"@yaakapp-internal/ui": ["../../packages/ui/src/index.ts"],
"@yaakapp-internal/ui/*": ["../../packages/ui/src/*"],
},
},
"include": ["."],
"exclude": ["vite.config.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
"references": [{ "path": "./tsconfig.node.json" }],
}

View File

@@ -1,12 +1,27 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak Proxy</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak Proxy</title>
<style>
html,
body {
background-color: white;
}
@media (prefers-color-scheme: dark) {
html,
body {
background-color: #1b1a29;
}
}
</style>
</head>
<body class="text-base">
<div id="root"></div>
<script type="module" src="/theme.ts"></script>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@@ -1,95 +1,92 @@
:root {
color: #f4efe7;
background:
radial-gradient(circle at top, rgba(217, 119, 6, 0.35), transparent 45%),
linear-gradient(180deg, #18212b 0%, #0f141a 100%);
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
@layer base {
html,
body,
#root {
@apply w-full h-full overflow-hidden text-text bg-surface;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
:root {
--font-family-interface: "";
--font-family-editor: "";
}
body {
min-height: 100vh;
}
:root {
font-variant-ligatures: none;
}
.app-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
html[data-platform="linux"] {
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.hero-card {
width: min(680px, 100%);
padding: 40px;
border: 1px solid rgba(244, 239, 231, 0.12);
border-radius: 28px;
background: rgba(9, 12, 16, 0.7);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(18px);
}
::selection {
@apply bg-selection;
}
.eyebrow {
margin: 0 0 12px;
font-size: 12px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: #f6ad55;
}
:not(a),
:not(input):not(textarea),
:not(input):not(textarea)::after,
:not(input):not(textarea)::before {
@apply select-none cursor-default;
}
h1 {
margin: 0;
font-size: clamp(44px, 8vw, 84px);
line-height: 0.95;
}
input,
textarea {
&::placeholder {
@apply text-placeholder;
}
}
.lede {
margin: 20px 0 0;
max-width: 48ch;
font-size: 18px;
line-height: 1.6;
color: rgba(244, 239, 231, 0.78);
}
a,
a[href] * {
@apply cursor-pointer !important;
}
.controls {
margin-top: 28px;
display: flex;
gap: 12px;
}
table th {
@apply text-left;
}
.btn {
border: 0;
border-radius: 10px;
padding: 10px 14px;
background: #f6ad55;
color: #111;
font-weight: 700;
cursor: pointer;
}
:not(iframe) {
&::-webkit-scrollbar,
&::-webkit-scrollbar-corner {
@apply w-[8px] h-[8px] bg-transparent;
}
.btn.ghost {
background: rgba(255, 255, 255, 0.12);
color: #f4efe7;
}
&::-webkit-scrollbar-track {
@apply bg-transparent;
}
.btn:disabled {
opacity: 0.6;
cursor: default;
}
&::-webkit-scrollbar-thumb {
@apply bg-text-subtlest rounded-[4px] opacity-20;
}
.status {
margin-top: 14px;
display: flex;
gap: 16px;
color: rgba(244, 239, 231, 0.88);
font-size: 14px;
&::-webkit-scrollbar-thumb:hover {
@apply opacity-40 !important;
}
}
.hide-scrollbars {
&::-webkit-scrollbar-corner,
&::-webkit-scrollbar {
@apply hidden !important;
}
}
.rtl {
direction: rtl;
}
:root {
color-scheme: light dark;
--transition-duration: 100ms ease-in-out;
--color-white: 255 100% 100%;
--color-black: 255 0% 0%;
}
}

View File

@@ -1,4 +1,5 @@
import "./main.css";
import { Button } from "@yaakapp-internal/ui";
import { invoke } from "@tauri-apps/api/core";
import { StrictMode } from "react";
import { useState } from "react";
@@ -45,25 +46,38 @@ function App() {
}
return (
<main className="app-shell">
<section className="hero-card">
<p className="eyebrow">Monorepo Smoke Test</p>
<h1>Yaak Proxy</h1>
<p className="lede">
This is a minimal proxy app stub running on the new `apps/yaak-proxy`
and `crates-tauri/yaak-app-proxy` structure.
</p>
<div className="controls">
<button className="btn" disabled={busy} onClick={startProxy}>
Start Proxy
</button>
<button className="btn ghost" disabled={busy} onClick={stopProxy}>
Stop Proxy
</button>
</div>
<div className="status">
<span>Status: {status}</span>
{port != null ? <span>Port: {port}</span> : null}
<main className="h-full w-full overflow-auto p-6">
<section className="flex items-start">
<div className="flex w-full max-w-xl flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold text-text">Yaak Proxy</h1>
<p className="mt-2 text-sm text-text-subtle">Status: {status}</p>
<p className="mt-1 text-sm text-text-subtle">
Port: {port ?? "Not running"}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button
disabled={busy}
onClick={startProxy}
size="sm"
tone="primary"
>
Start Proxy
</Button>
<Button
disabled={busy}
onClick={stopProxy}
size="sm"
variant="border"
>
Stop Proxy
</Button>
<Button size="sm" type="button">
Shared Button
</Button>
</div>
</div>
</section>
</main>

View File

@@ -9,6 +9,8 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@yaakapp-internal/theme": "^1.0.0",
"@yaakapp-internal/ui": "^1.0.0",
"@tauri-apps/api": "^2.9.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"

View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: [
require("@tailwindcss/nesting")(require("postcss-nesting")),
require("tailwindcss"),
require("autoprefixer"),
],
};

View File

@@ -0,0 +1,7 @@
const sharedConfig = require("@yaakapp-internal/tailwind-config");
/** @type {import('tailwindcss').Config} */
module.exports = {
...sharedConfig,
content: ["./*.{html,ts,tsx}", "../../packages/ui/src/**/*.{ts,tsx}"],
};

9
apps/yaak-proxy/theme.ts Normal file
View File

@@ -0,0 +1,9 @@
import {
applyThemeToDocument,
defaultDarkTheme,
platformFromUserAgent,
setPlatformOnDocument,
} from "@yaakapp-internal/theme";
setPlatformOnDocument(platformFromUserAgent(navigator.userAgent));
applyThemeToDocument(defaultDarkTheme);

View File

@@ -14,9 +14,16 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@yaakapp-internal/theme": ["../../packages/theme/src/index.ts"],
"@yaakapp-internal/theme/*": ["../../packages/theme/src/*"],
"@yaakapp-internal/ui": ["../../packages/ui/src/index.ts"],
"@yaakapp-internal/ui/*": ["../../packages/ui/src/*"],
},
},
"include": ["."],
"exclude": ["vite.config.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
"references": [{ "path": "./tsconfig.node.json" }],
}