@awc-ui/theme 1.0.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@awc-ui/theme",
3
+ "version": "1.0.0-beta",
4
+ "description": "MD3 theme generation and application utilities for AWC UI",
5
+ "type": "module",
6
+ "main": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs"
12
+ },
13
+ "./worker": {
14
+ "types": "./dist/worker.d.ts",
15
+ "import": "./dist/worker.mjs",
16
+ "default": "./dist/worker.mjs"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "LICENSE"
23
+ ],
24
+ "devDependencies": {
25
+ "@material/material-color-utilities": "^0.4.0",
26
+ "esbuild": "^0.25.12",
27
+ "typescript": "^5.4.0"
28
+ },
29
+ "keywords": [
30
+ "material-design",
31
+ "md3",
32
+ "theme",
33
+ "color"
34
+ ],
35
+ "license": "MIT",
36
+ "author": "AWC UI",
37
+ "homepage": "https://awc-ui.dev",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/awc-ui/core.git",
41
+ "directory": "packages/theme"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/awc-ui/core/issues"
45
+ },
46
+ "module": "./dist/index.mjs",
47
+ "sideEffects": false,
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "scripts": {
52
+ "build": "rm -rf dist && tsc -p tsconfig.json --emitDeclarationOnly && esbuild src/index.ts --bundle --format=esm --target=es2020 --sourcemap --outfile=dist/index.mjs && esbuild src/worker.ts --bundle --format=esm --target=es2020 --sourcemap --outfile=dist/worker.mjs",
53
+ "lint": "tsc --noEmit"
54
+ }
55
+ }
@@ -0,0 +1,47 @@
1
+ import { TYPESCALE_SLOTS } from './types';
2
+
3
+ const GOOGLE_FONT_FAMILIES = new Set([
4
+ 'Roboto',
5
+ 'Inter',
6
+ 'Open Sans',
7
+ 'Lato',
8
+ 'Poppins',
9
+ 'Nunito',
10
+ 'Montserrat',
11
+ 'Source Sans 3',
12
+ ]);
13
+
14
+ function fontStack(fontFamily: string): string {
15
+ const trimmed = fontFamily.trim();
16
+ if (!trimmed) return 'Roboto, sans-serif';
17
+ return trimmed.includes(',') ? trimmed : `${trimmed}, sans-serif`;
18
+ }
19
+
20
+ /** Set all 15 --md-sys-typescale-*-font-family tokens on an element. */
21
+ export function applyFontFamily(element: HTMLElement, fontFamily: string): void {
22
+ const stack = fontStack(fontFamily);
23
+ for (const slot of TYPESCALE_SLOTS) {
24
+ element.style.setProperty(`--md-sys-typescale-${slot}-font-family`, stack);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Inject or update a Google Fonts stylesheet for common font choices.
30
+ * Custom font family strings are applied via CSS vars only (no network fetch).
31
+ */
32
+ export function loadGoogleFont(fontFamily: string): void {
33
+ const primary = fontFamily.split(',')[0].trim();
34
+ if (!GOOGLE_FONT_FAMILIES.has(primary)) return;
35
+
36
+ const id = 'awc-ui-dynamic-font';
37
+ let link = document.getElementById(id) as HTMLLinkElement | null;
38
+ if (!link) {
39
+ link = document.createElement('link');
40
+ link.id = id;
41
+ link.rel = 'stylesheet';
42
+ document.head.appendChild(link);
43
+ }
44
+
45
+ const family = primary.replace(/ /g, '+');
46
+ link.href = `https://fonts.googleapis.com/css2?family=${family}:wght@300;400;500;600;700&display=swap`;
47
+ }
@@ -0,0 +1,31 @@
1
+ import { generateCss } from './generate-css';
2
+ import { tokenName } from './token-name';
3
+ import { ROLE_KEYS, type RoleMap, type ThemeComputeResult } from './types';
4
+
5
+ /** Apply generated MD3 color role tokens as inline CSS custom properties on an element. */
6
+ export function applyThemeRoles(element: HTMLElement, roles: RoleMap): void {
7
+ for (const [role, hex] of Object.entries(roles)) {
8
+ element.style.setProperty(`--md-sys-color-${tokenName(role)}`, hex);
9
+ }
10
+ }
11
+
12
+ /** Remove inline MD3 color role overrides so stylesheet / data-theme tokens can apply. */
13
+ export function clearThemeRoles(element: HTMLElement): void {
14
+ for (const role of ROLE_KEYS) {
15
+ element.style.removeProperty(`--md-sys-color-${tokenName(role)}`);
16
+ }
17
+ }
18
+
19
+ /** Inject or update a stylesheet with light (:root) and dark ([data-theme="dark"]) role tokens. */
20
+ export function applyThemeStylesheet(
21
+ theme: ThemeComputeResult,
22
+ styleId = 'awc-ui-dynamic-theme',
23
+ ): void {
24
+ let styleEl = document.getElementById(styleId) as HTMLStyleElement | null;
25
+ if (!styleEl) {
26
+ styleEl = document.createElement('style');
27
+ styleEl.id = styleId;
28
+ document.head.appendChild(styleEl);
29
+ }
30
+ styleEl.textContent = generateCss(theme);
31
+ }
@@ -0,0 +1,102 @@
1
+ import {
2
+ argbFromHex,
3
+ hexFromArgb,
4
+ Hct,
5
+ TonalPalette,
6
+ DynamicScheme,
7
+ Variant,
8
+ } from '@material/material-color-utilities';
9
+ import {
10
+ ROLE_KEYS,
11
+ type PaletteKey,
12
+ type RoleKey,
13
+ type RoleMap,
14
+ type ThemeComputeRequest,
15
+ type ThemeComputeResult,
16
+ } from './types';
17
+
18
+ const TONES = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 99, 100] as const;
19
+ const NEUTRAL_CHROMA = 4;
20
+ const NEUTRAL_VARIANT_CHROMA = 8;
21
+
22
+ function paletteToTones(palette: TonalPalette): Record<number, string> {
23
+ const out: Record<number, string> = {};
24
+ for (const tone of TONES) {
25
+ out[tone] = hexFromArgb(palette.tone(tone)).toUpperCase();
26
+ }
27
+ return out;
28
+ }
29
+
30
+ function rolesFromScheme(scheme: DynamicScheme): RoleMap {
31
+ const out = {} as RoleMap;
32
+ for (const key of ROLE_KEYS) {
33
+ const argb = (scheme as unknown as Record<string, number>)[key];
34
+ out[key] = hexFromArgb(argb).toUpperCase();
35
+ }
36
+ return out;
37
+ }
38
+
39
+ /** Generate MD3 tonal palettes and role-mapped tokens for light and dark mode. */
40
+ export function computeTheme(req: ThemeComputeRequest): ThemeComputeResult {
41
+ const start = performance.now();
42
+
43
+ const primaryArgb = argbFromHex(req.primaryHex);
44
+ const primaryHct = Hct.fromInt(primaryArgb);
45
+ const primary = TonalPalette.fromHct(primaryHct);
46
+
47
+ const secondary = req.secondaryHex
48
+ ? TonalPalette.fromHct(Hct.fromInt(argbFromHex(req.secondaryHex)))
49
+ : TonalPalette.fromHueAndChroma(primaryHct.hue, 16);
50
+
51
+ const tertiary = req.tertiaryHex
52
+ ? TonalPalette.fromHct(Hct.fromInt(argbFromHex(req.tertiaryHex)))
53
+ : TonalPalette.fromHueAndChroma(primaryHct.hue + 60, 24);
54
+
55
+ const neutral = TonalPalette.fromHueAndChroma(primaryHct.hue, NEUTRAL_CHROMA);
56
+ const neutralVariant = TonalPalette.fromHueAndChroma(
57
+ primaryHct.hue,
58
+ NEUTRAL_VARIANT_CHROMA,
59
+ );
60
+
61
+ const tones: Record<PaletteKey, Record<number, string>> = {
62
+ primary: paletteToTones(primary),
63
+ secondary: paletteToTones(secondary),
64
+ tertiary: paletteToTones(tertiary),
65
+ neutral: paletteToTones(neutral),
66
+ neutralVariant: paletteToTones(neutralVariant),
67
+ };
68
+
69
+ const requestedLevel =
70
+ typeof req.contrastLevel === 'number' && Number.isFinite(req.contrastLevel)
71
+ ? req.contrastLevel
72
+ : 0;
73
+ const contrastLevel = Math.max(-1, Math.min(1, requestedLevel));
74
+
75
+ const buildScheme = (isDark: boolean) =>
76
+ new DynamicScheme({
77
+ sourceColorHct: primaryHct,
78
+ variant: Variant.TONAL_SPOT,
79
+ contrastLevel,
80
+ isDark,
81
+ primaryPalette: primary,
82
+ secondaryPalette: secondary,
83
+ tertiaryPalette: tertiary,
84
+ neutralPalette: neutral,
85
+ neutralVariantPalette: neutralVariant,
86
+ });
87
+
88
+ const light = rolesFromScheme(buildScheme(false));
89
+ const dark = rolesFromScheme(buildScheme(true));
90
+
91
+ return {
92
+ tones,
93
+ roles: { light, dark },
94
+ sources: {
95
+ primary: req.primaryHex.toUpperCase(),
96
+ secondary: req.secondaryHex?.toUpperCase() ?? '',
97
+ tertiary: req.tertiaryHex?.toUpperCase() ?? '',
98
+ },
99
+ contrastLevel,
100
+ durationMs: Math.round((performance.now() - start) * 100) / 100,
101
+ };
102
+ }
@@ -0,0 +1,36 @@
1
+ import { tokenName } from './token-name';
2
+ import type { ThemeComputeResult } from './types';
3
+
4
+ /** Format a computed theme as a drop-in CSS file overriding @awc-ui/tokens defaults. */
5
+ export function generateCss(result: ThemeComputeResult): string {
6
+ const now = new Date().toISOString();
7
+ const { sources, roles } = result;
8
+ const lines: string[] = [
9
+ '/*!',
10
+ ' * AWC UI — Material Design 3 theme',
11
+ ` * Generated: ${now}`,
12
+ ' * Source colors:',
13
+ ` primary: ${sources.primary}`,
14
+ ` secondary: ${sources.secondary}`,
15
+ ` tertiary: ${sources.tertiary}`,
16
+ ' *',
17
+ ' * Drop this file after @awc-ui/tokens to override the default theme.',
18
+ ' * Toggle dark mode by setting [data-theme="dark"] on <html> or any ancestor.',
19
+ ' */',
20
+ '',
21
+ ':root {',
22
+ ];
23
+
24
+ for (const [role, hex] of Object.entries(roles.light)) {
25
+ lines.push(` --md-sys-color-${tokenName(role)}: ${hex};`);
26
+ }
27
+
28
+ lines.push('}', '', '[data-theme="dark"] {');
29
+
30
+ for (const [role, hex] of Object.entries(roles.dark)) {
31
+ lines.push(` --md-sys-color-${tokenName(role)}: ${hex};`);
32
+ }
33
+
34
+ lines.push('}', '');
35
+ return lines.join('\n');
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { computeTheme } from './compute-theme';
2
+ export { applyThemeRoles, applyThemeStylesheet, clearThemeRoles } from './apply-theme';
3
+ export { applyFontFamily, loadGoogleFont } from './apply-font';
4
+ export { generateCss } from './generate-css';
5
+ export { tokenName } from './token-name';
6
+ export {
7
+ ROLE_KEYS,
8
+ DEFAULT_SEED_COLORS,
9
+ DEFAULT_FONT_FAMILY,
10
+ TYPESCALE_SLOTS,
11
+ } from './types';
12
+ export type {
13
+ PaletteKey,
14
+ RoleKey,
15
+ RoleMap,
16
+ ThemeComputeRequest,
17
+ ThemeComputeResult,
18
+ ThemeWorkerRequest,
19
+ ThemeWorkerResponse,
20
+ } from './types';
@@ -0,0 +1,4 @@
1
+ /** Convert camelCase MD3 role names to kebab-case CSS token suffixes. */
2
+ export function tokenName(role: string): string {
3
+ return role.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
4
+ }
package/src/types.ts ADDED
@@ -0,0 +1,102 @@
1
+ export type PaletteKey =
2
+ | 'primary'
3
+ | 'secondary'
4
+ | 'tertiary'
5
+ | 'neutral'
6
+ | 'neutralVariant';
7
+
8
+ /** MD3 role names — matches --md-sys-color-* tokens and DynamicScheme getters. */
9
+ export const ROLE_KEYS = [
10
+ 'primary',
11
+ 'onPrimary',
12
+ 'primaryContainer',
13
+ 'onPrimaryContainer',
14
+ 'inversePrimary',
15
+ 'secondary',
16
+ 'onSecondary',
17
+ 'secondaryContainer',
18
+ 'onSecondaryContainer',
19
+ 'tertiary',
20
+ 'onTertiary',
21
+ 'tertiaryContainer',
22
+ 'onTertiaryContainer',
23
+ 'error',
24
+ 'onError',
25
+ 'errorContainer',
26
+ 'onErrorContainer',
27
+ 'background',
28
+ 'onBackground',
29
+ 'surface',
30
+ 'onSurface',
31
+ 'surfaceVariant',
32
+ 'onSurfaceVariant',
33
+ 'surfaceContainerLowest',
34
+ 'surfaceContainerLow',
35
+ 'surfaceContainer',
36
+ 'surfaceContainerHigh',
37
+ 'surfaceContainerHighest',
38
+ 'surfaceDim',
39
+ 'surfaceBright',
40
+ 'outline',
41
+ 'outlineVariant',
42
+ 'inverseSurface',
43
+ 'inverseOnSurface',
44
+ 'shadow',
45
+ 'scrim',
46
+ 'surfaceTint',
47
+ ] as const;
48
+
49
+ export type RoleKey = (typeof ROLE_KEYS)[number];
50
+ export type RoleMap = Record<RoleKey, string>;
51
+
52
+ export type ThemeComputeRequest = {
53
+ primaryHex: string;
54
+ secondaryHex?: string;
55
+ tertiaryHex?: string;
56
+ /**
57
+ * MD3 contrast level passed to DynamicScheme.
58
+ * -1 reduced, 0 standard (default), 0.5 medium, 1 high contrast.
59
+ */
60
+ contrastLevel?: number;
61
+ };
62
+
63
+ export type ThemeComputeResult = {
64
+ tones: Record<PaletteKey, Record<number, string>>;
65
+ roles: { light: RoleMap; dark: RoleMap };
66
+ sources: { primary: string; secondary: string; tertiary: string };
67
+ contrastLevel: number;
68
+ durationMs: number;
69
+ };
70
+
71
+ export type ThemeWorkerRequest = ThemeComputeRequest & { type: 'compute' };
72
+
73
+ export type ThemeWorkerResponse =
74
+ | ({ type: 'palette' } & ThemeComputeResult)
75
+ | { type: 'error'; message: string };
76
+
77
+ export const DEFAULT_SEED_COLORS = {
78
+ primary: '#6750A4',
79
+ secondary: '#625B71',
80
+ tertiary: '#7D5260',
81
+ } as const;
82
+
83
+ export const DEFAULT_FONT_FAMILY = 'Roboto';
84
+
85
+ /** All 15 MD3 typescale slots that expose a font-family token. */
86
+ export const TYPESCALE_SLOTS = [
87
+ 'display-large',
88
+ 'display-medium',
89
+ 'display-small',
90
+ 'headline-large',
91
+ 'headline-medium',
92
+ 'headline-small',
93
+ 'title-large',
94
+ 'title-medium',
95
+ 'title-small',
96
+ 'label-large',
97
+ 'label-medium',
98
+ 'label-small',
99
+ 'body-large',
100
+ 'body-medium',
101
+ 'body-small',
102
+ ] as const;
package/src/worker.ts ADDED
@@ -0,0 +1,38 @@
1
+ /// <reference lib="webworker" />
2
+ /**
3
+ * Theme Generator Web Worker
4
+ *
5
+ * Off-main-thread HCT computation for responsive color-picker drag.
6
+ * Messages in: { type: 'compute', primaryHex, secondaryHex?, tertiaryHex? }
7
+ * Messages out: { type: 'palette', tones, roles, sources, durationMs }
8
+ * { type: 'error', message }
9
+ */
10
+ import { computeTheme } from './compute-theme';
11
+ import type { ThemeWorkerRequest, ThemeWorkerResponse } from './types';
12
+
13
+ self.addEventListener('message', (e: MessageEvent<ThemeWorkerRequest>) => {
14
+ try {
15
+ if (!e.data || e.data.type !== 'compute') {
16
+ const response: ThemeWorkerResponse = {
17
+ type: 'error',
18
+ message: `Unexpected message type: ${(e.data as { type?: string })?.type ?? 'undefined'}`,
19
+ };
20
+ (self as unknown as Worker).postMessage(response);
21
+ return;
22
+ }
23
+
24
+ const { type: _type, ...request } = e.data;
25
+ const result = computeTheme(request);
26
+ const response: ThemeWorkerResponse = {
27
+ type: 'palette',
28
+ ...result,
29
+ };
30
+ (self as unknown as Worker).postMessage(response);
31
+ } catch (err) {
32
+ const response: ThemeWorkerResponse = {
33
+ type: 'error',
34
+ message: err instanceof Error ? err.message : 'Unknown error',
35
+ };
36
+ (self as unknown as Worker).postMessage(response);
37
+ }
38
+ });