Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions apps/api/src/custom-theme/1710000000000-CreateThemesTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';

export class CreateThemesTable1710000000000 implements MigrationInterface {
name = 'CreateThemesTable1710000000000';

async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TYPE "theme_scope_enum" AS ENUM ('global', 'organization', 'user')`);

await queryRunner.createTable(
new Table({
name: 'themes',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'uuid_generate_v4()',
},
{ name: 'name', type: 'varchar', length: '100', isNullable: false },
{ name: 'description', type: 'varchar', length: '500', isNullable: true },
{
name: 'scope',
type: 'enum',
enumName: 'theme_scope_enum',
default: "'global'",
},
{ name: 'scope_owner_id', type: 'uuid', isNullable: true },
{ name: 'config', type: 'jsonb', isNullable: false },
{ name: 'is_default', type: 'boolean', default: false },
{ name: 'is_active', type: 'boolean', default: true },
{ name: 'is_read_only', type: 'boolean', default: false },
{ name: 'parent_theme_id', type: 'uuid', isNullable: true },
{ name: 'created_by', type: 'uuid', isNullable: true },
{ name: 'updated_by', type: 'uuid', isNullable: true },
{
name: 'created_at',
type: 'timestamp with time zone',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updated_at',
type: 'timestamp with time zone',
default: 'CURRENT_TIMESTAMP',
},
{ name: 'deleted_at', type: 'timestamp with time zone', isNullable: true },
],
}),
true,
);

await queryRunner.createIndex(
'themes',
new TableIndex({
name: 'IDX_themes_scope_is_default',
columnNames: ['scope', 'is_default'],
}),
);

await queryRunner.createIndex(
'themes',
new TableIndex({
name: 'UQ_themes_name_scope',
columnNames: ['name', 'scope'],
isUnique: true,
where: 'deleted_at IS NULL',
}),
);

await queryRunner.createIndex(
'themes',
new TableIndex({
name: 'IDX_themes_is_active_deleted',
columnNames: ['is_active', 'deleted_at'],
}),
);
}

async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('themes', true, true, true);
await queryRunner.query(`DROP TYPE IF EXISTS "theme_scope_enum"`);
}
}
176 changes: 176 additions & 0 deletions apps/api/src/custom-theme/css-generator.util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import {
generateCssVariables,
generateDarkModeVariables,
generateFullCssBundle,
variablesToCssString,
} from '../utils/css-generator.util';
import { DEFAULT_THEME_CONFIG, ThemeConfig } from '../types/theme-config.types';

describe('CssGeneratorUtil', () => {
describe('generateCssVariables', () => {
it('should generate CSS variables from the default config', () => {
const vars = generateCssVariables(DEFAULT_THEME_CONFIG);

expect(vars).toBeDefined();
expect(typeof vars).toBe('object');
});

it('should map primary color to --color-primary', () => {
const vars = generateCssVariables(DEFAULT_THEME_CONFIG);
expect(vars['--color-primary']).toBe('#6366F1');
});

it('should map font family to --font-font-family-heading', () => {
const vars = generateCssVariables(DEFAULT_THEME_CONFIG);
expect(vars['--font-font-family-heading']).toContain('Inter');
});

it('should map spacing unit to --spacing-unit', () => {
const vars = generateCssVariables(DEFAULT_THEME_CONFIG);
expect(vars['--spacing-unit']).toBe('4');
});

it('should map border radius to --radius-base', () => {
const vars = generateCssVariables(DEFAULT_THEME_CONFIG);
expect(vars['--radius-base']).toBe('4px');
});

it('should map shadow to --shadow-base', () => {
const vars = generateCssVariables(DEFAULT_THEME_CONFIG);
expect(vars['--shadow-base']).toBeDefined();
});

it('should prefix custom CSS variables with --custom-', () => {
const config: ThemeConfig = {
...DEFAULT_THEME_CONFIG,
customCssVariables: { ribbonColor: '#FF0000' },
};
const vars = generateCssVariables(config);
expect(vars['--custom-ribbon-color']).toBe('#FF0000');
});

it('should preserve custom variables that already start with --', () => {
const config: ThemeConfig = {
...DEFAULT_THEME_CONFIG,
customCssVariables: { '--my-var': 'blue' },
};
const vars = generateCssVariables(config);
expect(vars['--my-var']).toBe('blue');
});

it('should produce all keys as strings starting with --', () => {
const vars = generateCssVariables(DEFAULT_THEME_CONFIG);
for (const key of Object.keys(vars)) {
expect(key.startsWith('--')).toBe(true);
}
});

it('should not include null/undefined values', () => {
const vars = generateCssVariables(DEFAULT_THEME_CONFIG);
for (const val of Object.values(vars)) {
expect(val).not.toBeNull();
expect(val).not.toBeUndefined();
expect(val).not.toBe('null');
expect(val).not.toBe('undefined');
}
});
});

describe('generateDarkModeVariables', () => {
it('should return undefined when dark mode is disabled', () => {
const result = generateDarkModeVariables({
...DEFAULT_THEME_CONFIG,
darkModeEnabled: false,
});
expect(result).toBeUndefined();
});

it('should return undefined when no darkModeColors provided', () => {
const result = generateDarkModeVariables({
...DEFAULT_THEME_CONFIG,
darkModeEnabled: true,
darkModeColors: undefined,
});
expect(result).toBeUndefined();
});

it('should generate dark mode variables when enabled and colors provided', () => {
const config: ThemeConfig = {
...DEFAULT_THEME_CONFIG,
darkModeEnabled: true,
darkModeColors: {
background: '#1A1A2E',
surface: '#16213E',
textPrimary: '#E0E0E0',
},
};
const vars = generateDarkModeVariables(config);

expect(vars).toBeDefined();
expect(vars!['--color-background']).toBe('#1A1A2E');
expect(vars!['--color-surface']).toBe('#16213E');
expect(vars!['--color-text-primary']).toBe('#E0E0E0');
});

it('should skip undefined values in darkModeColors', () => {
const config: ThemeConfig = {
...DEFAULT_THEME_CONFIG,
darkModeEnabled: true,
darkModeColors: { background: '#000', primary: undefined as any },
};
const vars = generateDarkModeVariables(config);
expect(vars!['--color-background']).toBe('#000');
expect(vars!['--color-primary']).toBeUndefined();
});
});

describe('variablesToCssString', () => {
it('should wrap variables in :root by default', () => {
const css = variablesToCssString({ '--color-primary': '#6366F1' });
expect(css).toContain(':root {');
expect(css).toContain('--color-primary: #6366F1;');
expect(css).toContain('}');
});

it('should use custom selector when provided', () => {
const css = variablesToCssString({ '--x': 'y' }, '.my-scope');
expect(css).toContain('.my-scope {');
});

it('should produce one declaration per variable', () => {
const vars = { '--a': '1', '--b': '2', '--c': '3' };
const css = variablesToCssString(vars);
expect((css.match(/;/g) ?? []).length).toBe(3);
});
});

describe('generateFullCssBundle', () => {
it('should return a non-empty string', () => {
const css = generateFullCssBundle(DEFAULT_THEME_CONFIG);
expect(typeof css).toBe('string');
expect(css.length).toBeGreaterThan(0);
});

it('should contain :root block', () => {
const css = generateFullCssBundle(DEFAULT_THEME_CONFIG);
expect(css).toContain(':root {');
});

it('should not include dark mode block when disabled', () => {
const css = generateFullCssBundle({
...DEFAULT_THEME_CONFIG,
darkModeEnabled: false,
});
expect(css).not.toContain('@media (prefers-color-scheme: dark)');
});

it('should include dark mode media query when enabled', () => {
const css = generateFullCssBundle({
...DEFAULT_THEME_CONFIG,
darkModeEnabled: true,
darkModeColors: { background: '#000' },
});
expect(css).toContain('@media (prefers-color-scheme: dark)');
});
});
});
98 changes: 98 additions & 0 deletions apps/api/src/custom-theme/css-generator.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { ThemeConfig } from '../types/theme-config.types';

/**
* Converts a camelCase key to a CSS custom property name.
* e.g. "primaryHover" → "--color-primary-hover"
*/
function camelToKebab(str: string): string {
return str.replace(/([A-Z])/g, (m) => `-${m.toLowerCase()}`);
}

function sectionPrefix(section: string): string {
const map: Record<string, string> = {
colors: '--color',
typography: '--font',
spacing: '--spacing',
borderRadius: '--radius',
shadows: '--shadow',
transitions: '--transition',
breakpoints: '--bp',
zIndex: '--z',
};
return map[section] ?? `--${camelToKebab(section)}`;
}

export function generateCssVariables(
config: ThemeConfig,
): Record<string, string> {
const vars: Record<string, string> = {};

const sections: Array<keyof ThemeConfig> = [
'colors',
'typography',
'spacing',
'borderRadius',
'shadows',
'transitions',
'breakpoints',
'zIndex',
];

for (const section of sections) {
const sectionData = config[section] as Record<string, unknown>;
if (!sectionData || typeof sectionData !== 'object') continue;

const prefix = sectionPrefix(section);

for (const [key, value] of Object.entries(sectionData)) {
if (value === undefined || value === null) continue;
const varName = `${prefix}-${camelToKebab(key)}`;
vars[varName] = String(value);
}
}

// Custom CSS variables pass-through
if (config.customCssVariables) {
for (const [key, value] of Object.entries(config.customCssVariables)) {
const cssKey = key.startsWith('--') ? key : `--custom-${camelToKebab(key)}`;
vars[cssKey] = value;
}
}

return vars;
}

export function generateDarkModeVariables(
config: ThemeConfig,
): Record<string, string> | undefined {
if (!config.darkModeEnabled || !config.darkModeColors) return undefined;

const vars: Record<string, string> = {};
for (const [key, value] of Object.entries(config.darkModeColors)) {
if (value === undefined || value === null) continue;
vars[`--color-${camelToKebab(key)}`] = String(value);
}
return vars;
}

export function variablesToCssString(
vars: Record<string, string>,
selector = ':root',
): string {
const declarations = Object.entries(vars)
.map(([k, v]) => ` ${k}: ${v};`)
.join('\n');
return `${selector} {\n${declarations}\n}`;
}

export function generateFullCssBundle(config: ThemeConfig): string {
const rootVars = generateCssVariables(config);
const rootCss = variablesToCssString(rootVars, ':root');

const darkVars = generateDarkModeVariables(config);
const darkCss = darkVars
? `\n\n@media (prefers-color-scheme: dark) {\n${variablesToCssString(darkVars, ' :root')}\n}`
: '';

return rootCss + darkCss;
}
Loading
Loading