@sixbell-telco/sdk
Version:
A collection of reusable components designed for use in Sixbell Telco Angular projects
469 lines (367 loc) • 11.7 kB
Markdown
# Theme Utility
Runtime theme system for Angular 19 with config-driven loading, JSON configs, assets, and signal-based state.
This doc covers:
- Runtime theme config + JSON schema
- Loading flow and caching behavior
- Theme/asset switching behavior
- How to add or extend themes safely
- Tailwind `@theme` registration for new variables
---
## Quick Start
```typescript
import { provideRuntimeTheme } from '@sixbell-telco/sdk/utils/theme';
bootstrapApplication(AppComponent, {
providers: [provideRuntimeTheme('/assets/themes/themes.json')],
});
```
Switch theme or scheme:
```typescript
import { Component, inject } from '@angular/core';
import { ThemeService } from '@sixbell-telco/sdk/utils/theme';
@Component({
selector: 'app-theme-switcher',
template: `
<select (change)="onThemeChange($event)">
@for (theme of themeService.getAvailableThemes(); track theme) {
<option [value]="theme">{{ theme }}</option>
}
</select>
<button (click)="toggleScheme()">Current: {{ themeService.resolvedScheme() }}</button>
`,
})
export class ThemeSwitcherComponent {
protected readonly themeService = inject(ThemeService);
onThemeChange(event: Event): void {
const theme = (event.target as HTMLSelectElement).value;
this.themeService.setTheme(theme);
}
toggleScheme(): void {
const current = this.themeService.selectedScheme();
const next = current === 'dark' ? 'light' : current === 'light' ? 'dark' : 'system';
this.themeService.setScheme(next);
}
}
```
---
## Runtime Model Overview
The theme system is config-based:
1. App loads a config (`themes.json`) containing a list of theme paths.
2. The selected theme file is loaded blocking (fonts + assets are ready before paint).
3. Remaining themes are prefetched non-blocking so switching is instant later.
### Mermaid: Boot Flow
```mermaid
sequenceDiagram
participant App
participant Provider as provideRuntimeTheme
participant Loader as RuntimeConfigLoader
participant Dexie as RuntimeConfigStore
participant ThemeSvc as ThemeService
App->>Provider: bootstrap
Provider->>Loader: loadLatest(theme-config)
Loader->>Dexie: get(cacheKey)
alt cached & schema ok
Dexie-->>Loader: cached
Loader-->>Provider: theme-config (source=cache)
else cache miss / schema mismatch
Loader->>Provider: fetch(theme-config)
Provider->>Loader: parse + store
Loader->>Dexie: set(cacheKey)
end
Provider->>ThemeSvc: applyRuntimeConfig()
Provider->>Provider: resolve selectedTheme
Provider->>Loader: loadLatest(theme file)
Provider->>ThemeSvc: configureRuntime(theme, blocking=true)
Provider->>Loader: prefetch remaining theme files (non-blocking)
Provider->>ThemeSvc: configureRuntime(theme, blocking=false)
```
---
## Files and Schemas
### 1) Theme Config (`themes.json`)
```json
{
"meta": {
"name": "themes",
"updatedAt": "2026-02-02T00:00:00Z",
"hash": "showcase-themes-v2",
"schemaVersion": "2"
},
"themes": ["/assets/themes/catalog/sixbell_telco.json", "/assets/themes/catalog/wom.json"],
"defaultTheme": "sixbell_telco",
"defaultScheme": "system",
"excludeThemes": []
}
```
Config rules:
- `themes` is required.
- Each item is a theme JSON path.
- Theme name comes from `meta.name` inside the theme file.
- `excludeThemes` hides themes at runtime without deleting files.
- `meta.schemaVersion` is required and used to invalidate old cache entries.
- `meta.hash` is required and used for update detection.
### 2) Per-Theme Runtime Config (`<theme>.json`)
```json
{
"meta": {
"name": "sixbell_telco",
"updatedAt": "2026-02-02T00:00:00Z",
"hash": "showcase-theme-sixbell-telco-v1",
"schemaVersion": "2"
},
"fonts": [
{
"family": "Poppins",
"faces": [{ "weight": 400, "style": "normal", "src": "./assets/fonts/Poppins-Regular.ttf" }]
}
],
"themes": {
"light": {
"variables": {
"colors": {
"--color-primary": "oklch(65.64% 0.1155 219.3)"
},
"radius": {},
"sizes": {},
"effects": {},
"others": {
"--font-body": "Poppins, sans-serif"
}
}
},
"dark": {
"variables": {
"colors": {
"--color-primary": "oklch(65.64% 0.1155 219.3)"
},
"radius": {},
"sizes": {},
"effects": {},
"others": {
"--font-body": "Poppins, sans-serif"
}
}
}
},
"assets": [
{
"name": "logo",
"light": "/assets/logos/sixbell-logo-light-mode.svg",
"dark": "/assets/logos/sixbell-logo-dark-mode.svg"
}
]
}
```
Theme file rules:
- File should contain `themes.light` and `themes.dark`.
- Both `light` and `dark` variants are required.
- `meta.name`, `meta.hash`, `meta.schemaVersion`, and `meta.updatedAt` are required.
- `variables` are grouped by category and injected as CSS custom properties with a `data-theme` selector.
- `others` is required and intended for app-specific tokens not provided by the SDK.
- `assets` are optional but should have both light and dark variants for each asset.
---
## ThemeService API (Runtime)
### Signals
- `selectedTheme()` - current theme key
- `selectedScheme()` - `system | light | dark`
- `resolvedScheme()` - resolved to `light | dark`
- `isDarkTheme()` - boolean
- `finalTheme()` - string like `sixbell_telco__dark`
### Common methods
```typescript
themeService.setTheme('wom');
themeService.setScheme('dark');
themeService.getAvailableThemes();
themeService.getDefaultTheme();
```
---
## How the Flow Works (Detailed)
### Mermaid: Runtime Theme Selection
```mermaid
flowchart TD
A[User sets theme/scheme] --> B[ThemeService updates signals]
B --> C[Resolved scheme computed]
C --> D[finalTheme computed]
D --> E[ThemeDomService sets data-theme]
E --> F[ThemeStorageService persists preferences]
D --> G[ThemeDomService injects CSS vars]
```
### Config + Theme Loading (Boot)
1. `provideRuntimeTheme()` runs at app init.
2. It loads the config via `RuntimeConfigLoader`.
3. Available themes are computed with `excludeThemes`.
4. The saved theme is restored (if still available).
5. Selected theme config is loaded blocking (fonts + assets).
6. Remaining themes are loaded non-blocking.
### Asset Resolution
- Assets are resolved by theme name + asset key + scheme.
- If an asset is missing in the current config, the last working config is used.
- Asset cache is in-memory by theme.
```typescript
const url = assetsService.getAssetUrl(themeService.selectedTheme(), 'logo', themeService.resolvedScheme());
```
---
## Cache and Schema Versioning
Runtime configs are cached in IndexedDB (Dexie) as last-known-good.
When JSON schema changes, bump `THEME_SCHEMA_VERSION` and update `meta.schemaVersion` in JSON.
Old cached entries will be discarded automatically.
Hash requirements:
- `meta.hash` is required for the config and each theme file.
- Config hash drives update detection.
```ts
// projects/sdk/utils/theme/src/constants.ts
export const THEME_SCHEMA_VERSION = '2' as const;
```
```ts
// note: THEME_SCHEMA_VERSION is exported from the theme public API
// import from @sixbell-telco/sdk/utils/theme
```
---
## Tailwind Token Registration (Required for New Variables)
Runtime variables are injected at runtime, but Tailwind utility classes are compiled at build time. If you add new variables and want Tailwind classes for them, you must register the tokens in your global CSS:
```css
@import 'tailwindcss';
@theme {
--color-counters-input: oklch(65.5% 0.0126 255.51);
--color-counters-done: oklch(69.5% 0.2115 142.5);
--color-counters-busy: oklch(75.89% 0.181 99.99);
}
```
When you add custom tokens under `variables.others`, register those tokens in your app `@theme` block so Tailwind utilities can use them.
---
## Extending Themes (Add New Theme)
### Step 1: Add a theme JSON
Create `/assets/themes/catalog/acme.json`:
```json
{
"meta": {
"name": "acme",
"updatedAt": "2026-02-02T00:00:00Z",
"hash": "acme-theme-v1",
"schemaVersion": "2"
},
"fonts": [
{
"family": "Acme Sans",
"faces": [{ "weight": 400, "style": "normal", "src": "/assets/fonts/AcmeSans-Regular.woff2" }]
}
],
"themes": {
"light": {
"variables": {
"colors": {
"--color-primary": "oklch(70% 0.1 220)"
},
"radius": {},
"sizes": {},
"effects": {},
"others": {
"--font-body": "Acme Sans, sans-serif"
}
}
},
"dark": {
"variables": {
"colors": {
"--color-primary": "oklch(65% 0.1 220)"
},
"radius": {},
"sizes": {},
"effects": {},
"others": {
"--font-body": "Acme Sans, sans-serif"
}
}
}
},
"assets": [
{
"name": "logo",
"light": "/assets/logos/acme_light.svg",
"dark": "/assets/logos/acme_dark.svg"
}
]
}
```
### Step 2: Register it in the config
```json
{
"themes": ["/assets/themes/catalog/sixbell_telco.json", "/assets/themes/catalog/wom.json", "/assets/themes/catalog/acme.json"]
}
```
### Step 3: Register tokens in `@theme` (if new variables)
Add to `projects/sdk/src/_index.css` (or your app CSS):
```css
@theme {
--color-acme-highlight: oklch(72% 0.12 30);
--color-acme-surface: oklch(98% 0.02 95);
}
```
### Step 4: Use it
```typescript
themeService.setTheme('acme');
themeService.setScheme('dark');
```
---
## Examples
### Minimal Bootstrap
```ts
bootstrapApplication(AppComponent, {
providers: [provideRuntimeTheme('/assets/themes/themes.json')],
});
```
### App with SSE Update Support
```ts
bootstrapApplication(AppComponent, {
providers: [
provideRuntimeTheme('/assets/themes/themes.json', {
appId: 'host-app',
sse: { url: 'http://localhost:4000/api/runtime/updates?resource=theme', eventType: 'message' },
}),
],
});
```
### React to Update Availability
```ts
const themeService = inject(ThemeService);
effect(() => {
if (themeService.updateAvailable()) {
// prompt user and call refreshTheme()
}
});
```
### Manual Refresh
```ts
await themeService.refreshTheme();
```
### Using the local runtime API server
```ts
bootstrapApplication(AppComponent, {
providers: [
provideRuntimeTheme('http://localhost:4000/api/runtime/theme/config', {
sse: { url: 'http://localhost:4000/api/runtime/updates?resource=theme' },
}),
],
});
```
## Hash and SchemaVersion Ownership
In production, the backend or build pipeline that publishes runtime JSON should always set:
- `meta.hash`: update this on any content change (colors, assets, fonts, paths).
- `meta.schemaVersion`: update only when the JSON structure changes.
Default mode bootstrap validation:
- Cached configs are validated on boot against the latest `meta.hash`.
- If the hash changed, the selected theme reloads before render.
- If the network check fails, the cached config is used to keep the app available.
When editing theme files manually, update the file `meta.hash` and the top-level `meta.hash` so clients detect the change.
---
## Troubleshooting
### Theme not applied on load
- Check the config path is correct.
- Check the theme JSON has both `light` and `dark` variants.
- Check that `meta.name` matches the config `name`.
### New variables not usable in Tailwind utilities
- Add the token to `@theme` in global CSS.
### Old config stuck in cache
- Bump `THEME_SCHEMA_VERSION` and add `meta.schemaVersion` in JSON.
---
## Related Utilities
- `@sixbell-telco/sdk/utils/runtime-config` - Loader + cache
- `@sixbell-telco/sdk/utils/translation` - i18n runtime config