Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,159 @@
export interface ThemeColors {
// Sidebar specific
sidebar?: string; // --sidebar
sidebarBackground?: string; // --sidebar-background
sidebarForeground?: string; // --sidebar-foreground
sidebarPrimary?: string; // --sidebar-primary
sidebarPrimaryForeground?: string; // --sidebar-primary-foreground
sidebarAccent?: string; // --sidebar-accent
sidebarAccentForeground?: string; // --sidebar-accent-foreground
sidebarBorder?: string; // --sidebar-border
sidebarRing?: string; // --sidebar-ring
// General theme colors
background?: string; // --background
foreground?: string; // --foreground
muted?: string; // --muted
mutedForeground?: string; // --muted-foreground
popover?: string; // --popover
popoverForeground?: string; // --popover-foreground
card?: string; // --card
cardForeground?: string; // --card-foreground
border?: string; // --border
input?: string; // --input
primary?: string; // --primary
primaryForeground?: string; // --primary-foreground
secondary?: string; // --secondary
secondaryForeground?: string; // --secondary-foreground
accent?: string; // --accent
accentForeground?: string; // --accent-foreground
destructive?: string; // --destructive
destructiveForeground?: string; // --destructive-foreground
ring?: string; // --ring
}
export interface ThemeConfig {
// Sidebar settings
sidebarVariant?: "inset" | "sidebar";
sidebarDarkMode?: boolean; // Force dark mode for sidebar even in light theme
// Theme colors (CSS variables)
colors?: ThemeColors;
// Dark mode colors (CSS variables)
darkColors?: ThemeColors;
// Other theme settings
radius?: string; // Border radius (e.g., "0.5rem")
fontFamily?: string; // Font family (e.g., "ui-sans-serif, system-ui, -apple-system...")
}
export const defaultThemeConfig: ThemeConfig = {
sidebarVariant: "inset",
sidebarDarkMode: false,
colors: {},
radius: "0.5rem",
};
export function parseThemeConfig(config: string | null | undefined): ThemeConfig {
if (!config) return defaultThemeConfig;
try {
return JSON.parse(config) as ThemeConfig;
} catch {
return defaultThemeConfig;
}
}
export function stringifyThemeConfig(config: ThemeConfig): string {
return JSON.stringify(config);
}
export function getThemeStyle({ themeConfig, isDarkMode }: { themeConfig: ThemeConfig; isDarkMode: boolean }): Record<string, string> {
// Prepare style object with theme CSS variables
const themeStyle: Record<string, string> = {};
// Apply light mode colors
if (themeConfig?.colors) {
const colors = themeConfig.colors;
// General theme colors
if (colors.background) themeStyle["--background"] = colors.background;
if (colors.foreground) themeStyle["--foreground"] = colors.foreground;
if (colors.muted) themeStyle["--muted"] = colors.muted;
if (colors.mutedForeground) themeStyle["--muted-foreground"] = colors.mutedForeground;
if (colors.popover) themeStyle["--popover"] = colors.popover;
if (colors.popoverForeground) themeStyle["--popover-foreground"] = colors.popoverForeground;
if (colors.card) themeStyle["--card"] = colors.card;
if (colors.cardForeground) themeStyle["--card-foreground"] = colors.cardForeground;
if (colors.border) themeStyle["--border"] = colors.border;
if (colors.input) themeStyle["--input"] = colors.input;
if (colors.primary) themeStyle["--primary"] = colors.primary;
if (colors.primaryForeground) themeStyle["--primary-foreground"] = colors.primaryForeground;
if (colors.secondary) themeStyle["--secondary"] = colors.secondary;
if (colors.secondaryForeground) themeStyle["--secondary-foreground"] = colors.secondaryForeground;
if (colors.accent) themeStyle["--accent"] = colors.accent;
if (colors.accentForeground) themeStyle["--accent-foreground"] = colors.accentForeground;
if (colors.destructive) themeStyle["--destructive"] = colors.destructive;
if (colors.destructiveForeground) themeStyle["--destructive-foreground"] = colors.destructiveForeground;
if (colors.ring) themeStyle["--ring"] = colors.ring;
// Sidebar colors (applied at root level for global access)
if (colors.sidebar) themeStyle["--sidebar"] = colors.sidebar;
if (colors.sidebarBackground) themeStyle["--sidebar-background"] = colors.sidebarBackground;
if (colors.sidebarForeground) themeStyle["--sidebar-foreground"] = colors.sidebarForeground;
if (colors.sidebarPrimary) themeStyle["--sidebar-primary"] = colors.sidebarPrimary;
if (colors.sidebarPrimaryForeground) themeStyle["--sidebar-primary-foreground"] = colors.sidebarPrimaryForeground;
if (colors.sidebarAccent) themeStyle["--sidebar-accent"] = colors.sidebarAccent;
if (colors.sidebarAccentForeground) themeStyle["--sidebar-accent-foreground"] = colors.sidebarAccentForeground;
if (colors.sidebarBorder) themeStyle["--sidebar-border"] = colors.sidebarBorder;
if (colors.sidebarRing) themeStyle["--sidebar-ring"] = colors.sidebarRing;
}
if (themeConfig?.radius) {
themeStyle["--radius"] = themeConfig.radius;
}
if (themeConfig?.fontFamily) {
themeStyle["font-family"] = themeConfig.fontFamily;
}
if (isDarkMode && themeConfig?.darkColors) {
const darkColors = themeConfig.darkColors;
if (darkColors.background) themeStyle["--background"] = darkColors.background;
if (darkColors.foreground) themeStyle["--foreground"] = darkColors.foreground;
if (darkColors.muted) themeStyle["--muted"] = darkColors.muted;
if (darkColors.mutedForeground) themeStyle["--muted-foreground"] = darkColors.mutedForeground;
if (darkColors.popover) themeStyle["--popover"] = darkColors.popover;
if (darkColors.popoverForeground) themeStyle["--popover-foreground"] = darkColors.popoverForeground;
if (darkColors.card) themeStyle["--card"] = darkColors.card;
if (darkColors.cardForeground) themeStyle["--card-foreground"] = darkColors.cardForeground;
if (darkColors.border) themeStyle["--border"] = darkColors.border;
if (darkColors.input) themeStyle["--input"] = darkColors.input;
if (darkColors.primary) themeStyle["--primary"] = darkColors.primary;
if (darkColors.primaryForeground) themeStyle["--primary-foreground"] = darkColors.primaryForeground;
if (darkColors.secondary) themeStyle["--secondary"] = darkColors.secondary;
if (darkColors.secondaryForeground) themeStyle["--secondary-foreground"] = darkColors.secondaryForeground;
if (darkColors.accent) themeStyle["--accent"] = darkColors.accent;
if (darkColors.accentForeground) themeStyle["--accent-foreground"] = darkColors.accentForeground;
if (darkColors.destructive) themeStyle["--destructive"] = darkColors.destructive;
if (darkColors.destructiveForeground) themeStyle["--destructive-foreground"] = darkColors.destructiveForeground;
if (darkColors.ring) themeStyle["--ring"] = darkColors.ring;
if (darkColors.sidebar) themeStyle["--sidebar"] = darkColors.sidebar;
if (darkColors.sidebarBackground) themeStyle["--sidebar-background"] = darkColors.sidebarBackground;
if (darkColors.sidebarForeground) themeStyle["--sidebar-foreground"] = darkColors.sidebarForeground;
if (darkColors.sidebarPrimary) themeStyle["--sidebar-primary"] = darkColors.sidebarPrimary;
if (darkColors.sidebarPrimaryForeground) themeStyle["--sidebar-primary-foreground"] = darkColors.sidebarPrimaryForeground;
if (darkColors.sidebarAccent) themeStyle["--sidebar-accent"] = darkColors.sidebarAccent;
if (darkColors.sidebarAccentForeground) themeStyle["--sidebar-accent-foreground"] = darkColors.sidebarAccentForeground;
if (darkColors.sidebarBorder) themeStyle["--sidebar-border"] = darkColors.sidebarBorder;
if (darkColors.sidebarRing) themeStyle["--sidebar-ring"] = darkColors.sidebarRing;
}
return themeStyle;
}
@@ -0,0 +1,100 @@
import { ThemeConfig, ThemeColors } from "./ThemeConfig";
export function exportConfigToCss(config: ThemeConfig): string {
let css = "";
// Export light mode colors
if (config.colors && Object.keys(config.colors).length > 0) {
css += ":root {\n";
// Add font family if present
if (config.fontFamily) {
css += ` --font-sans: ${config.fontFamily};\n`;
}
// Add radius if present
if (config.radius) {
css += ` --radius: ${config.radius};\n`;
}
css += exportColors(config.colors);
css += "}\n";
}
// Export dark mode colors
if (config.darkColors && Object.keys(config.darkColors).length > 0) {
if (css) css += "\n";
css += ".dark {\n";
if (config.fontFamily) {
css += ` --font-sans: ${config.fontFamily};\n`;
}
// Add radius if different from light mode
if (config.radius && !config.colors) {
css += ` --radius: ${config.radius};\n`;
}
css += exportColors(config.darkColors);
css += "}\n";
}
// If no colors but has other config, export to :root
if (!css && (config.radius || config.fontFamily)) {
css += ":root {\n";
if (config.fontFamily) {
css += ` --font-sans: ${config.fontFamily};\n`;
}
if (config.radius) {
css += ` --radius: ${config.radius};\n`;
}
css += "}\n";
}
return css || "/* No custom theme configuration */";
}
function exportColors(colors: ThemeColors): string {
let css = "";
const colorMap: Record<keyof ThemeColors, string> = {
background: "--background",
foreground: "--foreground",
card: "--card",
cardForeground: "--card-foreground",
popover: "--popover",
popoverForeground: "--popover-foreground",
primary: "--primary",
primaryForeground: "--primary-foreground",
secondary: "--secondary",
secondaryForeground: "--secondary-foreground",
muted: "--muted",
mutedForeground: "--muted-foreground",
accent: "--accent",
accentForeground: "--accent-foreground",
destructive: "--destructive",
destructiveForeground: "--destructive-foreground",
border: "--border",
input: "--input",
ring: "--ring",
sidebar: "--sidebar",
sidebarBackground: "--sidebar-background",
sidebarForeground: "--sidebar-foreground",
sidebarPrimary: "--sidebar-primary",
sidebarPrimaryForeground: "--sidebar-primary-foreground",
sidebarAccent: "--sidebar-accent",
sidebarAccentForeground: "--sidebar-accent-foreground",
sidebarBorder: "--sidebar-border",
sidebarRing: "--sidebar-ring",
};
for (const [key, cssVar] of Object.entries(colorMap)) {
const value = colors[key as keyof ThemeColors];
if (value) {
css += ` ${cssVar}: ${value};\n`;
}
}
return css;
}
@@ -0,0 +1,119 @@
import { ThemeConfig, ThemeColors } from "./ThemeConfig";
// Maps CSS variable names to our ThemeConfig keys
const cssVarMap: Record<string, keyof ThemeColors> = {
"--background": "background",
"--foreground": "foreground",
"--card": "card",
"--card-foreground": "cardForeground",
"--popover": "popover",
"--popover-foreground": "popoverForeground",
"--primary": "primary",
"--primary-foreground": "primaryForeground",
"--secondary": "secondary",
"--secondary-foreground": "secondaryForeground",
"--muted": "muted",
"--muted-foreground": "mutedForeground",
"--accent": "accent",
"--accent-foreground": "accentForeground",
"--destructive": "destructive",
"--destructive-foreground": "destructiveForeground",
"--border": "border",
"--input": "input",
"--ring": "ring",
"--sidebar": "sidebar",
"--sidebar-background": "sidebarBackground",
"--sidebar-foreground": "sidebarForeground",
"--sidebar-primary": "sidebarPrimary",
"--sidebar-primary-foreground": "sidebarPrimaryForeground",
"--sidebar-accent": "sidebarAccent",
"--sidebar-accent-foreground": "sidebarAccentForeground",
"--sidebar-border": "sidebarBorder",
"--sidebar-ring": "sidebarRing",
};
export function parseCssConfig(cssText: string): Partial<ThemeConfig> {
const config: Partial<ThemeConfig> = {
colors: {},
darkColors: {},
};
// Parse :root section for light mode colors
const rootMatch = cssText.match(/:root\s*{([^}]*)}/);
if (rootMatch) {
const rootContent = rootMatch[1];
config.colors = parseColorSection(rootContent);
// Parse radius
const radiusMatch = rootContent.match(/--radius:\s*([^;]+);/);
if (radiusMatch) {
const radiusValue = radiusMatch[1].trim();
// Convert px to rem (assuming 16px = 1rem)
if (radiusValue.endsWith("px")) {
const px = parseFloat(radiusValue);
config.radius = `${px / 16}rem`;
} else if (radiusValue.endsWith("rem")) {
config.radius = radiusValue;
} else {
config.radius = radiusValue;
}
}
// Parse font-sans (use as default font family)
const fontMatch = rootContent.match(/--font-sans:\s*([^;]+);/);
if (fontMatch) {
config.fontFamily = fontMatch[1].trim();
}
}
// Parse .dark section for dark mode colors
const darkMatch = cssText.match(/\.dark\s*{([^}]*)}/);
if (darkMatch) {
const darkContent = darkMatch[1];
config.darkColors = parseColorSection(darkContent);
}
return config;
}
function parseColorSection(content: string): ThemeColors {
const colors: ThemeColors = {};
// Extract all CSS variables
const varRegex = /(--[a-zA-Z-]+):\s*([^;]+);/g;
let match;
while ((match = varRegex.exec(content)) !== null) {
const cssVar = match[1];
const value = match[2].trim();
// Check if this CSS variable maps to one of our theme colors
const configKey = cssVarMap[cssVar];
if (configKey) {
colors[configKey] = value;
}
}
return colors;
}
export function formatCssValue(value: string): string {
// If it's already a valid CSS color format, return as is
if (
value.startsWith("hsl(") ||
value.startsWith("hsla(") ||
value.startsWith("oklch(") ||
value.startsWith("rgb(") ||
value.startsWith("rgba(") ||
value.startsWith("#")
) {
return value;
}
// Otherwise, assume it's a hex color without the #
if (/^[0-9A-Fa-f]{6}$/.test(value)) {
return `#${value}`;
}
return value;
}
@@ -0,0 +1,26 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { defaultThemes } from "./defaultThemes";
const repoRoot = process.cwd();
function readSettings(locale: "de" | "en") {
return JSON.parse(fs.readFileSync(path.join(repoRoot, `apps/web/public/locales/${locale}/settings.json`), "utf8")) as Record<string, string>;
}
describe("default theme preset i18n", () => {
it("keeps every visible preset label backed by English and German settings keys", () => {
const en = readSettings("en");
const de = readSettings("de");
for (const theme of defaultThemes) {
expect(theme.labelKey).toBe(`settings:tenant.theme.presets.${theme.value}`);
const localeKey = theme.labelKey.replace("settings:", "");
expect(en[localeKey], `${theme.value} English label`).toBeTruthy();
expect(de[localeKey], `${theme.value} German label`).toBeTruthy();
}
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,58 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/utils/db/users.db.server", () => ({
updateUserProfile: vi.fn(),
}));
const { normalizeLightOrDarkMode, nextLightOrDarkMode, persistUserLightOrDarkMode } = await import("./userThemePreference.server");
const { updateUserProfile } = await import("~/utils/db/users.db.server");
describe("user theme preference helpers", () => {
beforeEach(() => {
vi.mocked(updateUserProfile).mockReset();
});
it("accepts only light and dark values", () => {
expect(normalizeLightOrDarkMode("light")).toBe("light");
expect(normalizeLightOrDarkMode("dark")).toBe("dark");
});
it("normalizes missing and unknown values to light", () => {
expect(normalizeLightOrDarkMode(undefined)).toBe("light");
expect(normalizeLightOrDarkMode(null)).toBe("light");
expect(normalizeLightOrDarkMode("")).toBe("light");
expect(normalizeLightOrDarkMode("system")).toBe("light");
});
it("toggles from normalized current values", () => {
expect(nextLightOrDarkMode("light")).toBe("dark");
expect(nextLightOrDarkMode("dark")).toBe("light");
expect(nextLightOrDarkMode("unknown")).toBe("dark");
});
it("does not write for anonymous users", async () => {
await expect(persistUserLightOrDarkMode(undefined, "dark")).resolves.toBeNull();
await expect(persistUserLightOrDarkMode("", "dark")).resolves.toBeNull();
expect(updateUserProfile).not.toHaveBeenCalled();
});
it("persists a normalized value for authenticated users", async () => {
const user = { id: "user-1" } as Awaited<ReturnType<typeof updateUserProfile>>;
vi.mocked(updateUserProfile).mockResolvedValueOnce(user);
await expect(persistUserLightOrDarkMode("user-1", "dark")).resolves.toEqual(user);
expect(updateUserProfile).toHaveBeenCalledTimes(1);
expect(updateUserProfile).toHaveBeenCalledWith({ lightOrDarkMode: "dark" }, "user-1");
});
it("normalizes unknown values before persisting", async () => {
vi.mocked(updateUserProfile).mockResolvedValueOnce({ id: "user-1" } as Awaited<ReturnType<typeof updateUserProfile>>);
await persistUserLightOrDarkMode("user-1", "system");
expect(updateUserProfile).toHaveBeenCalledWith({ lightOrDarkMode: "light" }, "user-1");
});
});
@@ -0,0 +1,21 @@
import { updateUserProfile } from "~/utils/db/users.db.server";
export const LIGHT_OR_DARK_MODES = ["light", "dark"] as const;
export type LightOrDarkMode = (typeof LIGHT_OR_DARK_MODES)[number];
export function normalizeLightOrDarkMode(value: unknown): LightOrDarkMode {
return value === "dark" ? "dark" : "light";
}
export function nextLightOrDarkMode(current: unknown): LightOrDarkMode {
return normalizeLightOrDarkMode(current) === "dark" ? "light" : "dark";
}
export async function persistUserLightOrDarkMode(userId: string | null | undefined, lightOrDarkMode: unknown) {
if (!userId) {
return null;
}
return await updateUserProfile({ lightOrDarkMode: normalizeLightOrDarkMode(lightOrDarkMode) }, userId);
}