@dryui/theme-wizard 1.0.0

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.
Files changed (47) hide show
  1. package/dist/actions.d.ts +4 -0
  2. package/dist/actions.js +9 -0
  3. package/dist/components/AlphaSlider.svelte +13 -0
  4. package/dist/components/AlphaSlider.svelte.d.ts +9 -0
  5. package/dist/components/ContrastBadge.svelte +22 -0
  6. package/dist/components/ContrastBadge.svelte.d.ts +8 -0
  7. package/dist/components/HsbPicker.svelte +304 -0
  8. package/dist/components/HsbPicker.svelte.d.ts +9 -0
  9. package/dist/components/StepIndicator.svelte +87 -0
  10. package/dist/components/StepIndicator.svelte.d.ts +7 -0
  11. package/dist/components/TokenPreview.svelte +55 -0
  12. package/dist/components/TokenPreview.svelte.d.ts +8 -0
  13. package/dist/components/WizardShell.svelte +140 -0
  14. package/dist/components/WizardShell.svelte.d.ts +15 -0
  15. package/dist/engine/derivation.d.ts +282 -0
  16. package/dist/engine/derivation.js +1445 -0
  17. package/dist/engine/derivation.test.d.ts +1 -0
  18. package/dist/engine/derivation.test.js +956 -0
  19. package/dist/engine/export-css.d.ts +32 -0
  20. package/dist/engine/export-css.js +90 -0
  21. package/dist/engine/export-css.test.d.ts +1 -0
  22. package/dist/engine/export-css.test.js +78 -0
  23. package/dist/engine/index.d.ts +10 -0
  24. package/dist/engine/index.js +6 -0
  25. package/dist/engine/palette.d.ts +16 -0
  26. package/dist/engine/palette.js +44 -0
  27. package/dist/engine/presets.d.ts +6 -0
  28. package/dist/engine/presets.js +34 -0
  29. package/dist/engine/url-codec.d.ts +53 -0
  30. package/dist/engine/url-codec.js +243 -0
  31. package/dist/engine/url-codec.test.d.ts +1 -0
  32. package/dist/engine/url-codec.test.js +137 -0
  33. package/dist/index.d.ts +14 -0
  34. package/dist/index.js +17 -0
  35. package/dist/state.svelte.d.ts +104 -0
  36. package/dist/state.svelte.js +555 -0
  37. package/dist/steps/BrandColor.svelte +218 -0
  38. package/dist/steps/BrandColor.svelte.d.ts +6 -0
  39. package/dist/steps/Personality.svelte +319 -0
  40. package/dist/steps/Personality.svelte.d.ts +3 -0
  41. package/dist/steps/PreviewExport.svelte +113 -0
  42. package/dist/steps/PreviewExport.svelte.d.ts +9 -0
  43. package/dist/steps/Shape.svelte +121 -0
  44. package/dist/steps/Shape.svelte.d.ts +18 -0
  45. package/dist/steps/Typography.svelte +115 -0
  46. package/dist/steps/Typography.svelte.d.ts +18 -0
  47. package/package.json +56 -0
@@ -0,0 +1,32 @@
1
+ import type { ThemeTokens } from './derivation.js';
2
+ export interface GenerateCssOptions {
3
+ personalityTokens?: Record<string, string>;
4
+ shapeTokens?: Record<string, string>;
5
+ shadowTokens?: {
6
+ light: Record<string, string>;
7
+ dark: Record<string, string>;
8
+ };
9
+ }
10
+ /**
11
+ * Generate CSS files from a ThemeTokens object.
12
+ *
13
+ * Returns:
14
+ * defaultCss — `:root { ... }` block with all light tokens.
15
+ * darkCss — `[data-theme="dark"] { ... }` block plus `.theme-auto` media query.
16
+ */
17
+ export declare function generateCss(theme: ThemeTokens, options?: GenerateCssOptions): {
18
+ defaultCss: string;
19
+ darkCss: string;
20
+ };
21
+ /**
22
+ * Trigger a browser download of the combined CSS as a single file.
23
+ */
24
+ export declare function downloadCss(theme: ThemeTokens, filename?: string, options?: GenerateCssOptions): void;
25
+ /**
26
+ * Copy the combined CSS to the clipboard.
27
+ */
28
+ export declare function copyCss(theme: ThemeTokens, options?: GenerateCssOptions): Promise<void>;
29
+ /**
30
+ * Return a JSON string containing the light and dark token maps.
31
+ */
32
+ export declare function exportJson(theme: ThemeTokens): string;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Generate CSS files from a ThemeTokens object.
3
+ *
4
+ * Returns:
5
+ * defaultCss — `:root { ... }` block with all light tokens.
6
+ * darkCss — `[data-theme="dark"] { ... }` block plus `.theme-auto` media query.
7
+ */
8
+ export function generateCss(theme, options) {
9
+ // Build default.css (:root block)
10
+ const defaultLines = [':root {'];
11
+ for (const [token, value] of Object.entries(theme.light)) {
12
+ defaultLines.push(` ${token}: ${value};`);
13
+ }
14
+ // Shadow tokens (light mode)
15
+ if (options?.shadowTokens) {
16
+ for (const [token, value] of Object.entries(options.shadowTokens.light)) {
17
+ defaultLines.push(` ${token}: ${value};`);
18
+ }
19
+ }
20
+ // Shape tokens (mode-independent, go in :root)
21
+ if (options?.shapeTokens) {
22
+ for (const [token, value] of Object.entries(options.shapeTokens)) {
23
+ defaultLines.push(` ${token}: ${value};`);
24
+ }
25
+ }
26
+ // Personality tokens (use semantic refs, mode-independent, go in :root)
27
+ if (options?.personalityTokens) {
28
+ for (const [token, value] of Object.entries(options.personalityTokens)) {
29
+ defaultLines.push(` ${token}: ${value};`);
30
+ }
31
+ }
32
+ defaultLines.push('}');
33
+ const defaultCss = defaultLines.join('\n');
34
+ // Build dark.css ([data-theme="dark"] + .theme-auto media query)
35
+ const darkEntries = Object.entries(theme.dark);
36
+ const darkShadowEntries = options?.shadowTokens ? Object.entries(options.shadowTokens.dark) : [];
37
+ const darkLines = ['[data-theme="dark"] {'];
38
+ for (const [token, value] of darkEntries) {
39
+ darkLines.push(` ${token}: ${value};`);
40
+ }
41
+ for (const [token, value] of darkShadowEntries) {
42
+ darkLines.push(` ${token}: ${value};`);
43
+ }
44
+ darkLines.push('}');
45
+ darkLines.push('');
46
+ darkLines.push('.theme-auto {');
47
+ darkLines.push(' @media (prefers-color-scheme: dark) {');
48
+ darkLines.push(' & {');
49
+ for (const [token, value] of darkEntries) {
50
+ darkLines.push(` ${token}: ${value};`);
51
+ }
52
+ for (const [token, value] of darkShadowEntries) {
53
+ darkLines.push(` ${token}: ${value};`);
54
+ }
55
+ darkLines.push(' }');
56
+ darkLines.push(' }');
57
+ darkLines.push('}');
58
+ const darkCss = darkLines.join('\n');
59
+ return { defaultCss, darkCss };
60
+ }
61
+ /**
62
+ * Trigger a browser download of the combined CSS as a single file.
63
+ */
64
+ export function downloadCss(theme, filename = 'dryui-theme.css', options) {
65
+ const { defaultCss, darkCss } = generateCss(theme, options);
66
+ const combined = `${defaultCss}\n\n${darkCss}`;
67
+ const blob = new Blob([combined], { type: 'text/css' });
68
+ const url = URL.createObjectURL(blob);
69
+ const a = document.createElement('a');
70
+ a.href = url;
71
+ a.download = filename;
72
+ document.body.appendChild(a);
73
+ a.click();
74
+ document.body.removeChild(a);
75
+ URL.revokeObjectURL(url);
76
+ }
77
+ /**
78
+ * Copy the combined CSS to the clipboard.
79
+ */
80
+ export async function copyCss(theme, options) {
81
+ const { defaultCss, darkCss } = generateCss(theme, options);
82
+ const combined = `${defaultCss}\n\n${darkCss}`;
83
+ await navigator.clipboard.writeText(combined);
84
+ }
85
+ /**
86
+ * Return a JSON string containing the light and dark token maps.
87
+ */
88
+ export function exportJson(theme) {
89
+ return JSON.stringify({ light: theme.light, dark: theme.dark }, null, 2);
90
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,78 @@
1
+ // apps/docs/src/lib/theme-wizard/export-css.test.ts
2
+ import { describe, test, expect } from 'bun:test';
3
+ import { generateCss, exportJson } from './export-css.js';
4
+ import { generateTheme } from './derivation.js';
5
+ const defaultBrand = generateTheme({ h: 230, s: 65, b: 85 });
6
+ describe('generateCss', () => {
7
+ test('defaultCss contains a :root block', () => {
8
+ const { defaultCss } = generateCss(defaultBrand);
9
+ expect(defaultCss).toContain(':root {');
10
+ });
11
+ test('defaultCss contains all light tokens', () => {
12
+ const { defaultCss } = generateCss(defaultBrand);
13
+ for (const [token] of Object.entries(defaultBrand.light)) {
14
+ expect(defaultCss).toContain(token);
15
+ }
16
+ });
17
+ test('defaultCss light token values are present', () => {
18
+ const { defaultCss } = generateCss(defaultBrand);
19
+ for (const [token, value] of Object.entries(defaultBrand.light)) {
20
+ expect(defaultCss).toContain(`${token}: ${value}`);
21
+ }
22
+ });
23
+ test('darkCss contains [data-theme="dark"] block', () => {
24
+ const { darkCss } = generateCss(defaultBrand);
25
+ expect(darkCss).toContain('[data-theme="dark"] {');
26
+ });
27
+ test('darkCss contains all dark tokens', () => {
28
+ const { darkCss } = generateCss(defaultBrand);
29
+ for (const [token] of Object.entries(defaultBrand.dark)) {
30
+ expect(darkCss).toContain(token);
31
+ }
32
+ });
33
+ test('darkCss contains .theme-auto media query', () => {
34
+ const { darkCss } = generateCss(defaultBrand);
35
+ expect(darkCss).toContain('.theme-auto {');
36
+ expect(darkCss).toContain('@media (prefers-color-scheme: dark)');
37
+ });
38
+ test('darkCss .theme-auto block uses & selector', () => {
39
+ const { darkCss } = generateCss(defaultBrand);
40
+ expect(darkCss).toContain('& {');
41
+ });
42
+ test('darkCss includes dark token values inside .theme-auto', () => {
43
+ const { darkCss } = generateCss(defaultBrand);
44
+ // Pick an arbitrary dark token and verify it appears twice (in both blocks)
45
+ const firstToken = Object.keys(defaultBrand.dark)[0] ?? '--dry-color-text-strong';
46
+ const occurrences = darkCss.split(firstToken).length - 1;
47
+ expect(occurrences).toBeGreaterThanOrEqual(2);
48
+ });
49
+ test('different brand colors produce different CSS', () => {
50
+ const oceanTheme = generateTheme({ h: 200, s: 80, b: 70 });
51
+ const { defaultCss: defaultDefault } = generateCss(defaultBrand);
52
+ const { defaultCss: defaultOcean } = generateCss(oceanTheme);
53
+ expect(defaultDefault).not.toBe(defaultOcean);
54
+ });
55
+ });
56
+ describe('exportJson', () => {
57
+ test('returns valid JSON', () => {
58
+ const json = exportJson(defaultBrand);
59
+ expect(() => JSON.parse(json)).not.toThrow();
60
+ });
61
+ test('JSON contains light and dark keys', () => {
62
+ const parsed = JSON.parse(exportJson(defaultBrand));
63
+ expect(parsed).toHaveProperty('light');
64
+ expect(parsed).toHaveProperty('dark');
65
+ });
66
+ test('JSON light tokens match theme', () => {
67
+ const parsed = JSON.parse(exportJson(defaultBrand));
68
+ for (const [token, value] of Object.entries(defaultBrand.light)) {
69
+ expect(parsed.light[token]).toBe(value);
70
+ }
71
+ });
72
+ test('JSON dark tokens match theme', () => {
73
+ const parsed = JSON.parse(exportJson(defaultBrand));
74
+ for (const [token, value] of Object.entries(defaultBrand.dark)) {
75
+ expect(parsed.dark[token]).toBe(value);
76
+ }
77
+ });
78
+ });
@@ -0,0 +1,10 @@
1
+ export { hsbToHsl, hslToHsb, hslToRgb, hslToHex, hexToHsl, cssColorToRgb, relativeLuminance, contrastRatio, meetsContrast, luminanceFromHsl, contrastBetweenCssColors, measureForegroundOnSurface, compareForegroundAcrossSurfaces, apcaSrgbToY, apcaContrast, apcaContrastBetweenCssColors, meetsApca, generateThemeModel, generateTheme } from './derivation.js';
2
+ export type { HSL, HSB, BrandInput, ThemeOptions, ThemeTokens, SystemTone, ThemeReference, ThemeModeLadder, TransparentNeutralLadder, TransparentBrandLadder, TransparentToneLadder, TransparentPrimitiveLadders, LiteralTransparentNeutralLadder, LiteralTransparentToneLadder, LiteralTransparentPrimitiveLadders, SolidSurfaceSteps, SolidSurfaceRoles, SolidSurfacePalette, SolidPrimitiveLadders, InteractiveStateRecipe, InteractionStateRecipes, BrandCandidateAssessment, BrandPolicy, ForegroundSurfaceThresholds, ForegroundSurfaceAssessment, ForegroundSurfaceComparison, ThemeAuditCheck, ThemeAudit, PhotoTemperatureGuidance, ThemeModelLayer, ThemeModel } from './derivation.js';
3
+ export { generateCss, downloadCss, copyCss, exportJson } from './export-css.js';
4
+ export type { GenerateCssOptions } from './export-css.js';
5
+ export { encodeTheme, decodeTheme, encodeRecipe, decodeRecipe } from './url-codec.js';
6
+ export type { DecodedTheme, WizardRecipe } from './url-codec.js';
7
+ export { generatePalette, textToBrand } from './palette.js';
8
+ export type { PaletteResult } from './palette.js';
9
+ export { PRESETS } from './presets.js';
10
+ export type { Preset } from './presets.js';
@@ -0,0 +1,6 @@
1
+ // Engine — pure functions, zero Svelte/rune dependencies. Safe for server-side use.
2
+ export { hsbToHsl, hslToHsb, hslToRgb, hslToHex, hexToHsl, cssColorToRgb, relativeLuminance, contrastRatio, meetsContrast, luminanceFromHsl, contrastBetweenCssColors, measureForegroundOnSurface, compareForegroundAcrossSurfaces, apcaSrgbToY, apcaContrast, apcaContrastBetweenCssColors, meetsApca, generateThemeModel, generateTheme } from './derivation.js';
3
+ export { generateCss, downloadCss, copyCss, exportJson } from './export-css.js';
4
+ export { encodeTheme, decodeTheme, encodeRecipe, decodeRecipe } from './url-codec.js';
5
+ export { generatePalette, textToBrand } from './palette.js';
6
+ export { PRESETS } from './presets.js';
@@ -0,0 +1,16 @@
1
+ import { type BrandInput } from './derivation.js';
2
+ export interface PaletteResult {
3
+ swatches: string[];
4
+ bg: string;
5
+ accent: string;
6
+ }
7
+ /**
8
+ * Generate a 4-color display palette from a brand HSB input.
9
+ * Sweeps from a deep complement through the brand to a light accent.
10
+ */
11
+ export declare function generatePalette(brand: BrandInput): PaletteResult;
12
+ /**
13
+ * Deterministically map arbitrary text to a BrandInput.
14
+ * Same text always produces the same color.
15
+ */
16
+ export declare function textToBrand(text: string): BrandInput;
@@ -0,0 +1,44 @@
1
+ import { hsbToHsl, hslToHex } from './derivation.js';
2
+ /**
3
+ * Generate a 4-color display palette from a brand HSB input.
4
+ * Sweeps from a deep complement through the brand to a light accent.
5
+ */
6
+ export function generatePalette(brand) {
7
+ const h = brand.h;
8
+ const specs = [
9
+ { hOff: -105, s: 58, b: 45 },
10
+ { hOff: -50, s: 72, b: 58 },
11
+ { hOff: -18, s: 80, b: 72 },
12
+ { hOff: 25, s: 65, b: 82 }
13
+ ];
14
+ const swatches = specs.map(({ hOff, s, b }) => {
15
+ const hue = (((h + hOff) % 360) + 360) % 360;
16
+ const hsl = hsbToHsl(hue, s / 100, b / 100);
17
+ return hslToHex(hsl.h, hsl.s, hsl.l);
18
+ });
19
+ // Keep background in the dark blue-indigo range, with subtle brand influence
20
+ const bgHue = 230 + Math.sin((h / 360) * Math.PI * 2) * 25;
21
+ const bgHsl = hsbToHsl(bgHue, 0.5, 0.14);
22
+ const bg = hslToHex(bgHsl.h, bgHsl.s, bgHsl.l);
23
+ const accent = swatches[3];
24
+ return { swatches, bg, accent };
25
+ }
26
+ /**
27
+ * Deterministically map arbitrary text to a BrandInput.
28
+ * Same text always produces the same color.
29
+ */
30
+ export function textToBrand(text) {
31
+ const normalized = text.trim().toLowerCase();
32
+ if (!normalized)
33
+ return { h: 230, s: 65, b: 85 };
34
+ let hash = 0;
35
+ for (let i = 0; i < normalized.length; i++) {
36
+ hash = ((hash << 5) - hash + normalized.charCodeAt(i)) | 0;
37
+ }
38
+ hash = Math.abs(hash);
39
+ return {
40
+ h: hash % 360,
41
+ s: 55 + (hash % 30),
42
+ b: 60 + ((hash >> 8) % 25)
43
+ };
44
+ }
@@ -0,0 +1,6 @@
1
+ import type { BrandInput } from './derivation.js';
2
+ export interface Preset {
3
+ name: string;
4
+ brandInput: BrandInput;
5
+ }
6
+ export declare const PRESETS: Preset[];
@@ -0,0 +1,34 @@
1
+ export const PRESETS = [
2
+ {
3
+ name: 'Default',
4
+ brandInput: { h: 230, s: 65, b: 85 }
5
+ },
6
+ {
7
+ name: 'Ocean',
8
+ brandInput: { h: 200, s: 80, b: 70 }
9
+ },
10
+ {
11
+ name: 'Forest',
12
+ brandInput: { h: 145, s: 60, b: 55 }
13
+ },
14
+ {
15
+ name: 'Sunset',
16
+ brandInput: { h: 25, s: 80, b: 90 }
17
+ },
18
+ {
19
+ name: 'Rose',
20
+ brandInput: { h: 340, s: 65, b: 85 }
21
+ },
22
+ {
23
+ name: 'Lavender',
24
+ brandInput: { h: 270, s: 45, b: 80 }
25
+ },
26
+ {
27
+ name: 'Midnight',
28
+ brandInput: { h: 240, s: 80, b: 35 }
29
+ },
30
+ {
31
+ name: 'Ember',
32
+ brandInput: { h: 10, s: 85, b: 75 }
33
+ }
34
+ ];
@@ -0,0 +1,53 @@
1
+ import type { BrandInput, ThemeOptions } from './derivation.js';
2
+ import type { NeutralMode, Personality, RadiusPreset, Density, ShadowPreset, TypeScale, FontPreset } from '../state.svelte.js';
3
+ export interface DecodedTheme {
4
+ brand: BrandInput;
5
+ options?: ThemeOptions;
6
+ }
7
+ export interface WizardRecipe {
8
+ brand: BrandInput;
9
+ personality?: Personality;
10
+ neutralMode?: NeutralMode;
11
+ statusHues?: {
12
+ error?: number;
13
+ warning?: number;
14
+ success?: number;
15
+ info?: number;
16
+ };
17
+ typography?: {
18
+ fontPreset: FontPreset;
19
+ scale: TypeScale;
20
+ };
21
+ shape?: {
22
+ radiusPreset: RadiusPreset;
23
+ radiusScale: number;
24
+ density: Density;
25
+ };
26
+ shadows?: {
27
+ preset: ShadowPreset;
28
+ intensity: number;
29
+ tintBrand: boolean;
30
+ };
31
+ }
32
+ /**
33
+ * Encode wizard input state into a compact URL-safe string.
34
+ *
35
+ * Format: `h{hue}s{sat}b{bri}` with optional `-n` neutral-mode flag and
36
+ * `-e{error}w{warning}s{success}i{info}` status suffix.
37
+ *
38
+ * Examples:
39
+ * Default brand only: `h230s65b85`
40
+ * Brand + status hue overrides: `h230s65b85-e350w45`
41
+ */
42
+ export declare function encodeTheme(input: {
43
+ brand: BrandInput;
44
+ options?: ThemeOptions;
45
+ }): string;
46
+ /**
47
+ * Decode a compact theme string back into brand input and options.
48
+ *
49
+ * Throws if the string is not a valid encoded theme.
50
+ */
51
+ export declare function decodeTheme(encoded: string): DecodedTheme;
52
+ export declare function encodeRecipe(recipe: WizardRecipe): string;
53
+ export declare function decodeRecipe(encoded: string): WizardRecipe;
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Encode wizard input state into a compact URL-safe string.
3
+ *
4
+ * Format: `h{hue}s{sat}b{bri}` with optional `-n` neutral-mode flag and
5
+ * `-e{error}w{warning}s{success}i{info}` status suffix.
6
+ *
7
+ * Examples:
8
+ * Default brand only: `h230s65b85`
9
+ * Brand + status hue overrides: `h230s65b85-e350w45`
10
+ */
11
+ export function encodeTheme(input) {
12
+ const { brand, options } = input;
13
+ const segments = [
14
+ `h${Math.round(brand.h)}s${Math.round(brand.s)}b${Math.round(brand.b)}`
15
+ ];
16
+ if (options?.neutralMode === 'neutral') {
17
+ segments.push('n');
18
+ }
19
+ const sh = options?.statusHues;
20
+ if (sh && Object.keys(sh).length > 0) {
21
+ const parts = [];
22
+ if (sh.error !== undefined)
23
+ parts.push(`e${Math.round(sh.error)}`);
24
+ if (sh.warning !== undefined)
25
+ parts.push(`w${Math.round(sh.warning)}`);
26
+ if (sh.success !== undefined)
27
+ parts.push(`s${Math.round(sh.success)}`);
28
+ if (sh.info !== undefined)
29
+ parts.push(`i${Math.round(sh.info)}`);
30
+ if (parts.length > 0) {
31
+ segments.push(parts.join(''));
32
+ }
33
+ }
34
+ return segments.join('-');
35
+ }
36
+ /**
37
+ * Decode a compact theme string back into brand input and options.
38
+ *
39
+ * Throws if the string is not a valid encoded theme.
40
+ */
41
+ export function decodeTheme(encoded) {
42
+ if (!encoded) {
43
+ throw new Error('Empty encoded theme string');
44
+ }
45
+ // Parse brand: h{hue}s{sat}b{bri}
46
+ const [brandPart, ...segments] = encoded.split('-');
47
+ if (brandPart === undefined) {
48
+ throw new Error('Missing brand segment');
49
+ }
50
+ const brandMatch = brandPart.match(/^h(\d+(?:\.\d+)?)s(\d+(?:\.\d+)?)b(\d+(?:\.\d+)?)$/);
51
+ if (!brandMatch ||
52
+ brandMatch[1] === undefined ||
53
+ brandMatch[2] === undefined ||
54
+ brandMatch[3] === undefined) {
55
+ throw new Error(`Invalid brand segment: "${brandPart}"`);
56
+ }
57
+ const brand = {
58
+ h: parseFloat(brandMatch[1]),
59
+ s: parseFloat(brandMatch[2]),
60
+ b: parseFloat(brandMatch[3])
61
+ };
62
+ if (segments.length === 0) {
63
+ return { brand };
64
+ }
65
+ const options = {};
66
+ const statusHues = {};
67
+ let hasStatusHues = false;
68
+ for (const segment of segments) {
69
+ if (segment === 'n') {
70
+ options.neutralMode = 'neutral';
71
+ continue;
72
+ }
73
+ // Parse status hues: sequences of letter+digits, e.g. e350w45s145i210
74
+ const statusRegex = /([ewsi])(\d+(?:\.\d+)?)/g;
75
+ let match;
76
+ while ((match = statusRegex.exec(segment)) !== null) {
77
+ const key = match[1];
78
+ const rawHue = match[2];
79
+ if (key === undefined || rawHue === undefined)
80
+ continue;
81
+ const hue = parseFloat(rawHue);
82
+ if (key === 'e')
83
+ statusHues.error = hue;
84
+ else if (key === 'w')
85
+ statusHues.warning = hue;
86
+ else if (key === 's')
87
+ statusHues.success = hue;
88
+ else if (key === 'i')
89
+ statusHues.info = hue;
90
+ hasStatusHues = true;
91
+ }
92
+ }
93
+ if (hasStatusHues) {
94
+ options.statusHues = statusHues;
95
+ }
96
+ if (Object.keys(options).length > 0) {
97
+ return { brand, options };
98
+ }
99
+ return { brand };
100
+ }
101
+ // ─── Full wizard recipe codec ────────────────────────────────────────────────
102
+ // Encodes ALL wizard inputs into a single compact hash.
103
+ // Format: h{h}s{s}b{b}[-n][-e{h}w{h}s{h}i{h}][-l{0-3}][-f{0-5}c{0-2}][-p{0-3}d{0-2}k{scale*100}][-x{0-3}y{int*100}z{0|1}]
104
+ // Example: h230s65b85-e0w40s145i210-l2-f0c1-p1d1k100-x2y100z1
105
+ const PERSONALITY_NAMES = ['minimal', 'clean', 'structured', 'rich'];
106
+ const PERSONALITY_INDEX = Object.fromEntries(PERSONALITY_NAMES.map((n, i) => [n, i]));
107
+ const FONT_NAMES = ['System', 'Humanist', 'Geometric', 'Classical', 'Serif', 'Mono'];
108
+ const FONT_INDEX = Object.fromEntries(FONT_NAMES.map((n, i) => [n, i]));
109
+ const SCALE_NAMES = ['compact', 'default', 'spacious'];
110
+ const SCALE_INDEX = Object.fromEntries(SCALE_NAMES.map((n, i) => [n, i]));
111
+ const RADIUS_NAMES = ['sharp', 'soft', 'rounded', 'pill'];
112
+ const RADIUS_INDEX = Object.fromEntries(RADIUS_NAMES.map((n, i) => [n, i]));
113
+ const SHADOW_NAMES = ['flat', 'subtle', 'elevated', 'deep'];
114
+ const SHADOW_INDEX = Object.fromEntries(SHADOW_NAMES.map((n, i) => [n, i]));
115
+ export function encodeRecipe(recipe) {
116
+ const { brand } = recipe;
117
+ const parts = [`h${Math.round(brand.h)}s${Math.round(brand.s)}b${Math.round(brand.b)}`];
118
+ if (recipe.neutralMode === 'neutral')
119
+ parts.push('n');
120
+ const sh = recipe.statusHues;
121
+ if (sh) {
122
+ const sp = [];
123
+ if (sh.error !== undefined)
124
+ sp.push(`e${Math.round(sh.error)}`);
125
+ if (sh.warning !== undefined)
126
+ sp.push(`w${Math.round(sh.warning)}`);
127
+ if (sh.success !== undefined)
128
+ sp.push(`s${Math.round(sh.success)}`);
129
+ if (sh.info !== undefined)
130
+ sp.push(`i${Math.round(sh.info)}`);
131
+ if (sp.length > 0)
132
+ parts.push(sp.join(''));
133
+ }
134
+ if (recipe.personality) {
135
+ const li = PERSONALITY_INDEX[recipe.personality] ?? 2;
136
+ parts.push(`l${li}`);
137
+ }
138
+ if (recipe.typography) {
139
+ const fi = FONT_INDEX[recipe.typography.fontPreset] ?? 0;
140
+ const si = SCALE_INDEX[recipe.typography.scale] ?? 1;
141
+ parts.push(`f${fi}c${si}`);
142
+ }
143
+ if (recipe.shape) {
144
+ const ri = RADIUS_INDEX[recipe.shape.radiusPreset] ?? 1;
145
+ const di = SCALE_INDEX[recipe.shape.density] ?? 1;
146
+ const ks = Math.round(recipe.shape.radiusScale * 100);
147
+ parts.push(`p${ri}d${di}k${ks}`);
148
+ }
149
+ if (recipe.shadows) {
150
+ const xi = SHADOW_INDEX[recipe.shadows.preset] ?? 2;
151
+ const yi = Math.round(recipe.shadows.intensity * 100);
152
+ const zi = recipe.shadows.tintBrand ? 1 : 0;
153
+ parts.push(`x${xi}y${yi}z${zi}`);
154
+ }
155
+ return parts.join('-');
156
+ }
157
+ export function decodeRecipe(encoded) {
158
+ if (!encoded)
159
+ throw new Error('Empty recipe string');
160
+ const [brandPart, ...segments] = encoded.split('-');
161
+ if (!brandPart)
162
+ throw new Error('Missing brand segment');
163
+ const brandMatch = brandPart.match(/^h(\d+(?:\.\d+)?)s(\d+(?:\.\d+)?)b(\d+(?:\.\d+)?)$/);
164
+ if (!brandMatch || !brandMatch[1] || !brandMatch[2] || !brandMatch[3]) {
165
+ throw new Error(`Invalid brand segment: "${brandPart}"`);
166
+ }
167
+ const recipe = {
168
+ brand: {
169
+ h: parseFloat(brandMatch[1]),
170
+ s: parseFloat(brandMatch[2]),
171
+ b: parseFloat(brandMatch[3])
172
+ }
173
+ };
174
+ const statusHues = {};
175
+ let hasStatus = false;
176
+ for (const seg of segments) {
177
+ if (seg === 'n') {
178
+ recipe.neutralMode = 'neutral';
179
+ continue;
180
+ }
181
+ // Personality: l{0-3}
182
+ const personalityMatch = seg.match(/^l([0-3])$/);
183
+ if (personalityMatch && personalityMatch[1] !== undefined) {
184
+ recipe.personality = (PERSONALITY_NAMES[parseInt(personalityMatch[1])] ??
185
+ 'structured');
186
+ continue;
187
+ }
188
+ // Typography: f{idx}c{idx}
189
+ const typoMatch = seg.match(/^f(\d)c(\d)$/);
190
+ if (typoMatch && typoMatch[1] !== undefined && typoMatch[2] !== undefined) {
191
+ recipe.typography = {
192
+ fontPreset: FONT_NAMES[parseInt(typoMatch[1])] ?? 'System',
193
+ scale: (SCALE_NAMES[parseInt(typoMatch[2])] ?? 'default')
194
+ };
195
+ continue;
196
+ }
197
+ // Shape: p{idx}d{idx}k{scale*100}
198
+ const shapeMatch = seg.match(/^p(\d)d(\d)k(\d+)$/);
199
+ if (shapeMatch &&
200
+ shapeMatch[1] !== undefined &&
201
+ shapeMatch[2] !== undefined &&
202
+ shapeMatch[3] !== undefined) {
203
+ recipe.shape = {
204
+ radiusPreset: (RADIUS_NAMES[parseInt(shapeMatch[1])] ?? 'soft'),
205
+ density: (SCALE_NAMES[parseInt(shapeMatch[2])] ?? 'default'),
206
+ radiusScale: parseInt(shapeMatch[3]) / 100
207
+ };
208
+ continue;
209
+ }
210
+ // Shadows: x{idx}y{int*100}z{0|1}
211
+ const shadowMatch = seg.match(/^x(\d)y(\d+)z([01])$/);
212
+ if (shadowMatch &&
213
+ shadowMatch[1] !== undefined &&
214
+ shadowMatch[2] !== undefined &&
215
+ shadowMatch[3] !== undefined) {
216
+ recipe.shadows = {
217
+ preset: (SHADOW_NAMES[parseInt(shadowMatch[1])] ?? 'elevated'),
218
+ intensity: parseInt(shadowMatch[2]) / 100,
219
+ tintBrand: shadowMatch[3] === '1'
220
+ };
221
+ continue;
222
+ }
223
+ // Status hues: e{h}w{h}s{h}i{h}
224
+ const statusRegex = /([ewsi])(\d+(?:\.\d+)?)/g;
225
+ let match;
226
+ while ((match = statusRegex.exec(seg)) !== null) {
227
+ const key = match[1];
228
+ const hue = parseFloat(match[2]);
229
+ if (key === 'e')
230
+ statusHues.error = hue;
231
+ else if (key === 'w')
232
+ statusHues.warning = hue;
233
+ else if (key === 's')
234
+ statusHues.success = hue;
235
+ else if (key === 'i')
236
+ statusHues.info = hue;
237
+ hasStatus = true;
238
+ }
239
+ }
240
+ if (hasStatus)
241
+ recipe.statusHues = statusHues;
242
+ return recipe;
243
+ }
@@ -0,0 +1 @@
1
+ export {};