mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-27 17:52:14 +00:00
# Which Problems Are Solved This PR introduces a comprehensive theme customization system for the login application with responsive behavior and enhanced visual options. <img width="1122" height="578" alt="Screenshot 2025-08-19 at 09 55 24" src="https://github.com/user-attachments/assets/cdcc8948-533d-4e13-bf45-fdcc24acfb2b" /> # How the Problems Are Solved ## ✨ Features Added - **🔄 Responsive Layout System**: Automatic switching between side-by-side and top-to-bottom layouts based on screen size - **🖼️ Background Image Support**: Custom background images configurable via environment variables - **⚙️ Theme Configuration**: Complete theme system with roundness, spacing, appearance, and layout options - **📱 Mobile-First Design**: Intelligent layout adaptation for different screen sizes - **🎯 Enhanced Typography**: Improved visual hierarchy with larger titles in side-by-side mode ## 🏗️ Architecture - **Server-Safe Theme Functions**: Theme configuration accessible on both server and client - **SSR-Safe Hooks**: Proper hydration handling for responsive layouts - **Component Separation**: Clear boundaries between server and client components - **Two-Section Layout**: Consistent content structure across all login pages ## 🔧 Configuration Options All theme options are configurable via environment variables: - `NEXT_PUBLIC_THEME_ROUNDNESS`: `edgy` | `mid` | `full` - `NEXT_PUBLIC_THEME_LAYOUT`: `side-by-side` | `top-to-bottom` - `NEXT_PUBLIC_THEME_APPEARANCE`: `flat` | `material` - `NEXT_PUBLIC_THEME_SPACING`: `regular` | `compact` - `NEXT_PUBLIC_THEME_BACKGROUND_IMAGE`: Custom background image URL ## 📄 Pages Updated Updated all major login pages to use the new two-section responsive layout: - Login name entry - Password verification - MFA verification - User registration - Account selection - Device authorization - Logout confirmation ## 📚 Documentation - **THEME_ARCHITECTURE.md**: Complete technical documentation of the theme system - **THEME_CUSTOMIZATION.md**: User-friendly guide with examples and troubleshooting ## 🚀 Benefits - **Better UX**: Responsive design that works seamlessly across all devices - **Brand Flexibility**: Easy customization to match any brand identity - **Maintainable Code**: Clean separation of concerns and well-documented architecture - **Future-Proof**: Extensible system for additional theme options <img width="580" height="680" alt="Screenshot 2025-08-19 at 09 22 23" src="https://github.com/user-attachments/assets/9de8da37-6d56-4fe9-b337-5d8ad2a3ba59" /> <img width="599" height="689" alt="Screenshot 2025-08-19 at 09 23 45" src="https://github.com/user-attachments/assets/26a30cc7-4017-4f4b-8b87-a49466c42b94" /> <img width="595" height="681" alt="Screenshot 2025-08-19 at 09 23 17" src="https://github.com/user-attachments/assets/a3d31088-4545-4f36-aafe-1aae1253d677" />
454 lines
16 KiB
TypeScript
454 lines
16 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
ThemeRoundness,
|
|
ThemeLayout,
|
|
ThemeAppearance,
|
|
ThemeSpacing,
|
|
ComponentRoundnessConfig,
|
|
DEFAULT_COMPONENT_ROUNDNESS,
|
|
DEFAULT_THEME,
|
|
getThemeConfig,
|
|
ROUNDNESS_CLASSES,
|
|
getComponentRoundness,
|
|
SPACING_STYLES,
|
|
APPEARANCE_STYLES,
|
|
} from "./theme";
|
|
|
|
describe("Theme Configuration", () => {
|
|
const originalEnv = process.env;
|
|
|
|
beforeEach(() => {
|
|
// Reset environment variables before each test
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Restore original environment variables
|
|
process.env = originalEnv;
|
|
});
|
|
|
|
describe("DEFAULT_COMPONENT_ROUNDNESS", () => {
|
|
it("should have correct default values for all components", () => {
|
|
expect(DEFAULT_COMPONENT_ROUNDNESS).toEqual({
|
|
card: "mid",
|
|
button: "mid",
|
|
input: "mid",
|
|
image: "mid",
|
|
avatar: "full",
|
|
avatarContainer: "full",
|
|
themeSwitch: "full",
|
|
});
|
|
});
|
|
|
|
it("should have all required component types", () => {
|
|
const requiredComponents: (keyof ComponentRoundnessConfig)[] = [
|
|
"card",
|
|
"button",
|
|
"input",
|
|
"image",
|
|
"avatar",
|
|
"avatarContainer",
|
|
"themeSwitch",
|
|
];
|
|
|
|
requiredComponents.forEach((component) => {
|
|
expect(DEFAULT_COMPONENT_ROUNDNESS).toHaveProperty(component);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("DEFAULT_THEME", () => {
|
|
it("should have all required properties", () => {
|
|
expect(DEFAULT_THEME).toEqual({
|
|
roundness: "mid",
|
|
componentRoundness: DEFAULT_COMPONENT_ROUNDNESS,
|
|
layout: "top-to-bottom",
|
|
appearance: "flat",
|
|
spacing: "regular",
|
|
});
|
|
});
|
|
|
|
it("should have valid default values", () => {
|
|
expect(DEFAULT_THEME.roundness).toBe("mid");
|
|
expect(DEFAULT_THEME.layout).toBe("top-to-bottom");
|
|
expect(DEFAULT_THEME.appearance).toBe("flat");
|
|
expect(DEFAULT_THEME.spacing).toBe("regular");
|
|
});
|
|
});
|
|
|
|
describe("getThemeConfig", () => {
|
|
it("should return default theme when no environment variables are set", () => {
|
|
const config = getThemeConfig();
|
|
|
|
expect(config.roundness).toBe(DEFAULT_THEME.roundness);
|
|
expect(config.layout).toBe(DEFAULT_THEME.layout);
|
|
expect(config.appearance).toBe(DEFAULT_THEME.appearance);
|
|
expect(config.spacing).toBe(DEFAULT_THEME.spacing);
|
|
expect(config.componentRoundness).toEqual(DEFAULT_COMPONENT_ROUNDNESS);
|
|
});
|
|
|
|
it("should use global roundness from environment variable", () => {
|
|
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
|
|
|
|
const config = getThemeConfig();
|
|
|
|
expect(config.roundness).toBe("full");
|
|
});
|
|
|
|
it("should apply global roundness to all components when env var is set", () => {
|
|
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "edgy";
|
|
|
|
const config = getThemeConfig();
|
|
|
|
expect(config.componentRoundness?.card).toBe("edgy");
|
|
expect(config.componentRoundness?.button).toBe("edgy");
|
|
expect(config.componentRoundness?.input).toBe("edgy");
|
|
expect(config.componentRoundness?.image).toBe("edgy");
|
|
expect(config.componentRoundness?.avatar).toBe("edgy");
|
|
expect(config.componentRoundness?.avatarContainer).toBe("edgy");
|
|
expect(config.componentRoundness?.themeSwitch).toBe("edgy");
|
|
});
|
|
|
|
it("should use component-specific defaults when global roundness is not set", () => {
|
|
const config = getThemeConfig();
|
|
|
|
expect(config.componentRoundness).toEqual(DEFAULT_COMPONENT_ROUNDNESS);
|
|
expect(config.componentRoundness?.avatar).toBe("full");
|
|
expect(config.componentRoundness?.avatarContainer).toBe("full");
|
|
expect(config.componentRoundness?.card).toBe("mid");
|
|
});
|
|
|
|
it("should use layout from environment variable", () => {
|
|
process.env.NEXT_PUBLIC_THEME_LAYOUT = "side-by-side";
|
|
|
|
const config = getThemeConfig();
|
|
|
|
expect(config.layout).toBe("side-by-side");
|
|
});
|
|
|
|
it("should use appearance from environment variable", () => {
|
|
process.env.NEXT_PUBLIC_THEME_APPEARANCE = "material";
|
|
|
|
const config = getThemeConfig();
|
|
|
|
expect(config.appearance).toBe("material");
|
|
});
|
|
|
|
it("should use spacing from environment variable", () => {
|
|
process.env.NEXT_PUBLIC_THEME_SPACING = "compact";
|
|
|
|
const config = getThemeConfig();
|
|
|
|
expect(config.spacing).toBe("compact");
|
|
});
|
|
|
|
it("should use background image from environment variable", () => {
|
|
const backgroundUrl = "https://example.com/image.jpg";
|
|
process.env.NEXT_PUBLIC_THEME_BACKGROUND_IMAGE = backgroundUrl;
|
|
|
|
const config = getThemeConfig();
|
|
|
|
expect(config.backgroundImage).toBe(backgroundUrl);
|
|
});
|
|
|
|
it("should have undefined background image when not set", () => {
|
|
const config = getThemeConfig();
|
|
|
|
expect(config.backgroundImage).toBeUndefined();
|
|
});
|
|
|
|
it("should combine multiple environment variables correctly", () => {
|
|
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
|
|
process.env.NEXT_PUBLIC_THEME_LAYOUT = "side-by-side";
|
|
process.env.NEXT_PUBLIC_THEME_APPEARANCE = "glass";
|
|
process.env.NEXT_PUBLIC_THEME_SPACING = "compact";
|
|
process.env.NEXT_PUBLIC_THEME_BACKGROUND_IMAGE = "https://example.com/bg.png";
|
|
|
|
const config = getThemeConfig();
|
|
|
|
expect(config.roundness).toBe("full");
|
|
expect(config.layout).toBe("side-by-side");
|
|
expect(config.appearance).toBe("glass");
|
|
expect(config.spacing).toBe("compact");
|
|
expect(config.backgroundImage).toBe("https://example.com/bg.png");
|
|
});
|
|
});
|
|
|
|
describe("ROUNDNESS_CLASSES", () => {
|
|
it("should have classes for all roundness levels", () => {
|
|
expect(ROUNDNESS_CLASSES).toHaveProperty("edgy");
|
|
expect(ROUNDNESS_CLASSES).toHaveProperty("mid");
|
|
expect(ROUNDNESS_CLASSES).toHaveProperty("full");
|
|
});
|
|
|
|
it("should have classes for all component types in each roundness level", () => {
|
|
const components: (keyof ComponentRoundnessConfig)[] = [
|
|
"card",
|
|
"button",
|
|
"input",
|
|
"image",
|
|
"avatar",
|
|
"avatarContainer",
|
|
"themeSwitch",
|
|
];
|
|
|
|
const roundnessLevels: ThemeRoundness[] = ["edgy", "mid", "full"];
|
|
|
|
roundnessLevels.forEach((level) => {
|
|
components.forEach((component) => {
|
|
expect(ROUNDNESS_CLASSES[level]).toHaveProperty(component);
|
|
expect(typeof ROUNDNESS_CLASSES[level][component]).toBe("string");
|
|
expect(ROUNDNESS_CLASSES[level][component].length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
it("should have distinct classes for different roundness levels", () => {
|
|
const components: (keyof ComponentRoundnessConfig)[] = ["card", "button", "input"];
|
|
|
|
components.forEach((component) => {
|
|
// Each roundness level should have different classes for the same component
|
|
expect(ROUNDNESS_CLASSES.edgy[component]).not.toBe(ROUNDNESS_CLASSES.mid[component]);
|
|
expect(ROUNDNESS_CLASSES.mid[component]).not.toBe(ROUNDNESS_CLASSES.full[component]);
|
|
expect(ROUNDNESS_CLASSES.edgy[component]).not.toBe(ROUNDNESS_CLASSES.full[component]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getComponentRoundness", () => {
|
|
it("should return a valid CSS class string for any component", () => {
|
|
const cardClass = getComponentRoundness("card");
|
|
expect(typeof cardClass).toBe("string");
|
|
expect(cardClass.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should return different classes for different components with default config", () => {
|
|
const cardClass = getComponentRoundness("card");
|
|
const avatarClass = getComponentRoundness("avatar");
|
|
|
|
// Avatar defaults to full roundness, card to mid - they should be different
|
|
expect(cardClass).not.toBe(avatarClass);
|
|
});
|
|
|
|
it("should change output when global roundness environment variable changes", () => {
|
|
// Get default
|
|
const defaultCardClass = getComponentRoundness("card");
|
|
|
|
// Set to different roundness
|
|
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "edgy";
|
|
const edgyCardClass = getComponentRoundness("card");
|
|
|
|
// Should be different
|
|
expect(edgyCardClass).not.toBe(defaultCardClass);
|
|
|
|
// Try another roundness level
|
|
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
|
|
const fullCardClass = getComponentRoundness("card");
|
|
|
|
expect(fullCardClass).not.toBe(defaultCardClass);
|
|
expect(fullCardClass).not.toBe(edgyCardClass);
|
|
});
|
|
|
|
it("should return classes for all component types", () => {
|
|
const components: (keyof ComponentRoundnessConfig)[] = [
|
|
"card",
|
|
"button",
|
|
"input",
|
|
"image",
|
|
"avatar",
|
|
"avatarContainer",
|
|
"themeSwitch",
|
|
];
|
|
|
|
components.forEach((component) => {
|
|
const cssClass = getComponentRoundness(component);
|
|
expect(typeof cssClass).toBe("string");
|
|
expect(cssClass.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
it("should apply global roundness to all components when set", () => {
|
|
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
|
|
|
|
const cardClass = getComponentRoundness("card");
|
|
const buttonClass = getComponentRoundness("button");
|
|
const avatarClass = getComponentRoundness("avatar");
|
|
|
|
// All should get classes from the "full" roundness level
|
|
expect(cardClass).toBe(ROUNDNESS_CLASSES.full.card);
|
|
expect(buttonClass).toBe(ROUNDNESS_CLASSES.full.button);
|
|
expect(avatarClass).toBe(ROUNDNESS_CLASSES.full.avatar);
|
|
});
|
|
|
|
it("should respect component-specific defaults when no global roundness is set", () => {
|
|
delete process.env.NEXT_PUBLIC_THEME_ROUNDNESS;
|
|
|
|
const avatarClass = getComponentRoundness("avatar");
|
|
const cardClass = getComponentRoundness("card");
|
|
|
|
// Avatar should use its default (full), card should use its default (mid)
|
|
expect(avatarClass).toBe(ROUNDNESS_CLASSES.full.avatar);
|
|
expect(cardClass).toBe(ROUNDNESS_CLASSES.mid.card);
|
|
});
|
|
});
|
|
|
|
describe("SPACING_STYLES", () => {
|
|
it("should have regular and compact spacing options", () => {
|
|
expect(SPACING_STYLES).toHaveProperty("regular");
|
|
expect(SPACING_STYLES).toHaveProperty("compact");
|
|
});
|
|
|
|
it("should have spacing and padding properties for each option", () => {
|
|
expect(SPACING_STYLES.regular).toHaveProperty("spacing");
|
|
expect(SPACING_STYLES.regular).toHaveProperty("padding");
|
|
expect(SPACING_STYLES.compact).toHaveProperty("spacing");
|
|
expect(SPACING_STYLES.compact).toHaveProperty("padding");
|
|
});
|
|
|
|
it("should have non-empty string values for all properties", () => {
|
|
expect(typeof SPACING_STYLES.regular.spacing).toBe("string");
|
|
expect(SPACING_STYLES.regular.spacing.length).toBeGreaterThan(0);
|
|
expect(typeof SPACING_STYLES.regular.padding).toBe("string");
|
|
expect(SPACING_STYLES.regular.padding.length).toBeGreaterThan(0);
|
|
|
|
expect(typeof SPACING_STYLES.compact.spacing).toBe("string");
|
|
expect(SPACING_STYLES.compact.spacing.length).toBeGreaterThan(0);
|
|
expect(typeof SPACING_STYLES.compact.padding).toBe("string");
|
|
expect(SPACING_STYLES.compact.padding.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should have different values between regular and compact", () => {
|
|
expect(SPACING_STYLES.regular.spacing).not.toBe(SPACING_STYLES.compact.spacing);
|
|
expect(SPACING_STYLES.regular.padding).not.toBe(SPACING_STYLES.compact.padding);
|
|
});
|
|
});
|
|
|
|
describe("APPEARANCE_STYLES", () => {
|
|
it("should have flat, material, and glass appearance options", () => {
|
|
expect(APPEARANCE_STYLES).toHaveProperty("flat");
|
|
expect(APPEARANCE_STYLES).toHaveProperty("material");
|
|
expect(APPEARANCE_STYLES).toHaveProperty("glass");
|
|
});
|
|
|
|
it("should have required properties for each appearance", () => {
|
|
const requiredProperties = ["card", "button", "idp-button", "typography", "background"];
|
|
|
|
Object.values(APPEARANCE_STYLES).forEach((style) => {
|
|
requiredProperties.forEach((prop) => {
|
|
expect(style).toHaveProperty(prop);
|
|
// @ts-ignore - dynamic property access
|
|
expect(typeof style[prop]).toBe("string");
|
|
// @ts-ignore - dynamic property access
|
|
expect(style[prop].length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
it("should have different styles for different appearances", () => {
|
|
// Each appearance should have distinct card styles
|
|
expect(APPEARANCE_STYLES.flat.card).not.toBe(APPEARANCE_STYLES.material.card);
|
|
expect(APPEARANCE_STYLES.material.card).not.toBe(APPEARANCE_STYLES.glass.card);
|
|
expect(APPEARANCE_STYLES.flat.card).not.toBe(APPEARANCE_STYLES.glass.card);
|
|
|
|
// Each appearance should have distinct button styles
|
|
expect(APPEARANCE_STYLES.flat.button).not.toBe(APPEARANCE_STYLES.material.button);
|
|
expect(APPEARANCE_STYLES.material.button).not.toBe(APPEARANCE_STYLES.glass.button);
|
|
expect(APPEARANCE_STYLES.flat.button).not.toBe(APPEARANCE_STYLES.glass.button);
|
|
});
|
|
|
|
it("should have idp-button styles for all appearances", () => {
|
|
expect(APPEARANCE_STYLES.flat["idp-button"]).toBeDefined();
|
|
expect(typeof APPEARANCE_STYLES.flat["idp-button"]).toBe("string");
|
|
|
|
expect(APPEARANCE_STYLES.material["idp-button"]).toBeDefined();
|
|
expect(typeof APPEARANCE_STYLES.material["idp-button"]).toBe("string");
|
|
|
|
expect(APPEARANCE_STYLES.glass["idp-button"]).toBeDefined();
|
|
expect(typeof APPEARANCE_STYLES.glass["idp-button"]).toBe("string");
|
|
});
|
|
});
|
|
|
|
describe("Type Safety", () => {
|
|
it("should accept valid ThemeRoundness values", () => {
|
|
const validValues: ThemeRoundness[] = ["edgy", "mid", "full"];
|
|
validValues.forEach((value) => {
|
|
expect(["edgy", "mid", "full"]).toContain(value);
|
|
});
|
|
});
|
|
|
|
it("should accept valid ThemeLayout values", () => {
|
|
const validValues: ThemeLayout[] = ["side-by-side", "top-to-bottom"];
|
|
validValues.forEach((value) => {
|
|
expect(["side-by-side", "top-to-bottom"]).toContain(value);
|
|
});
|
|
});
|
|
|
|
it("should accept valid ThemeAppearance values", () => {
|
|
const validValues: ThemeAppearance[] = ["flat", "material", "glass"];
|
|
validValues.forEach((value) => {
|
|
expect(["flat", "material", "glass"]).toContain(value);
|
|
});
|
|
});
|
|
|
|
it("should accept valid ThemeSpacing values", () => {
|
|
const validValues: ThemeSpacing[] = ["regular", "compact"];
|
|
validValues.forEach((value) => {
|
|
expect(["regular", "compact"]).toContain(value);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Integration Tests", () => {
|
|
it("should work correctly when switching between different roundness levels", () => {
|
|
// Get classes for different roundness levels
|
|
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "edgy";
|
|
const edgyClass = getComponentRoundness("card");
|
|
|
|
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "mid";
|
|
const midClass = getComponentRoundness("card");
|
|
|
|
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
|
|
const fullClass = getComponentRoundness("card");
|
|
|
|
// All should be different
|
|
expect(edgyClass).not.toBe(midClass);
|
|
expect(midClass).not.toBe(fullClass);
|
|
expect(edgyClass).not.toBe(fullClass);
|
|
|
|
// All should be valid strings
|
|
expect(typeof edgyClass).toBe("string");
|
|
expect(typeof midClass).toBe("string");
|
|
expect(typeof fullClass).toBe("string");
|
|
});
|
|
|
|
it("should maintain consistency across all theme properties", () => {
|
|
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "full";
|
|
process.env.NEXT_PUBLIC_THEME_LAYOUT = "side-by-side";
|
|
process.env.NEXT_PUBLIC_THEME_APPEARANCE = "material";
|
|
process.env.NEXT_PUBLIC_THEME_SPACING = "compact";
|
|
|
|
const config = getThemeConfig();
|
|
|
|
// Verify all properties are set correctly
|
|
expect(config.roundness).toBe("full");
|
|
expect(config.layout).toBe("side-by-side");
|
|
expect(config.appearance).toBe("material");
|
|
expect(config.spacing).toBe("compact");
|
|
|
|
// Verify component roundness is applied globally
|
|
expect(config.componentRoundness?.card).toBe("full");
|
|
expect(config.componentRoundness?.button).toBe("full");
|
|
});
|
|
|
|
it("should handle empty string environment variables by using defaults", () => {
|
|
process.env.NEXT_PUBLIC_THEME_ROUNDNESS = "";
|
|
process.env.NEXT_PUBLIC_THEME_LAYOUT = "";
|
|
|
|
const config = getThemeConfig();
|
|
|
|
expect(config.roundness).toBe(DEFAULT_THEME.roundness);
|
|
expect(config.layout).toBe(DEFAULT_THEME.layout);
|
|
});
|
|
});
|
|
});
|