@leftium/logo 0.0.2 → 0.1.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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * OKLCH-based color transforms for icon color modes.
3
+ *
4
+ * Uses culori for perceptually uniform color manipulation.
5
+ * All transforms operate on individual CSS color strings.
6
+ */
7
+ import { parse, converter, formatHex, clampChroma, displayable } from 'culori';
8
+ const toOklch = converter('oklch');
9
+ /**
10
+ * Parse a CSS color string to OKLCH components.
11
+ * Returns null if the color can't be parsed.
12
+ */
13
+ function parseToOklch(color) {
14
+ const parsed = parse(color);
15
+ if (!parsed)
16
+ return null;
17
+ const oklch = toOklch(parsed);
18
+ return {
19
+ l: oklch.l ?? 0,
20
+ c: oklch.c ?? 0,
21
+ h: oklch.h ?? 0,
22
+ alpha: oklch.alpha
23
+ };
24
+ }
25
+ /**
26
+ * Convert OKLCH components back to a hex color string.
27
+ * Clamps to sRGB gamut.
28
+ */
29
+ function oklchToHex(l, c, h, alpha) {
30
+ let color = { mode: 'oklch', l, c, h, alpha };
31
+ // Clamp to displayable sRGB gamut
32
+ if (!displayable(color)) {
33
+ color = clampChroma(color, 'oklch');
34
+ }
35
+ return formatHex(color);
36
+ }
37
+ /**
38
+ * Transform a single CSS color to grayscale using OKLCH.
39
+ * Sets chroma to 0, preserving perceptual lightness.
40
+ */
41
+ export function toGrayscale(color) {
42
+ const oklch = parseToOklch(color);
43
+ if (!oklch)
44
+ return color;
45
+ return oklchToHex(oklch.l, 0, 0, oklch.alpha);
46
+ }
47
+ /**
48
+ * Transform a single CSS color to grayscale, then tint toward a target color.
49
+ *
50
+ * The tint color's hue and chroma influence the result, while the original
51
+ * color's lightness is preserved. This unifies a palette while keeping contrast.
52
+ */
53
+ export function toGrayscaleTint(color, tintColor) {
54
+ const oklch = parseToOklch(color);
55
+ if (!oklch)
56
+ return color;
57
+ const tint = parseToOklch(tintColor);
58
+ if (!tint)
59
+ return color;
60
+ // Use original lightness, tint's hue, and a fraction of tint's chroma
61
+ // Scale chroma by the ratio of original chroma to max, to preserve some variation
62
+ const tintChroma = tint.c * 0.7; // 70% of tint chroma for a softer effect
63
+ return oklchToHex(oklch.l, tintChroma, tint.h, oklch.alpha);
64
+ }
65
+ /**
66
+ * Remap a single CSS color to a target hue, optionally overriding saturation.
67
+ *
68
+ * Preserves the original lightness. If saturation is provided, it's used as
69
+ * a percentage of the maximum chroma; otherwise, the original chroma is preserved.
70
+ */
71
+ export function remapHue(color, targetHue, targetSaturation) {
72
+ const oklch = parseToOklch(color);
73
+ if (!oklch)
74
+ return color;
75
+ const c = targetSaturation !== undefined
76
+ ? (targetSaturation / 100) * 0.4 // Map 0-100 to 0-0.4 OKLCH chroma range
77
+ : oklch.c;
78
+ return oklchToHex(oklch.l, c, targetHue, oklch.alpha);
79
+ }
80
+ /**
81
+ * Apply a full IconColorMode transformation to SVG content.
82
+ *
83
+ * Handles all modes: 'auto', 'original', 'monochrome', 'grayscale',
84
+ * 'grayscale-tint', and { hue, saturation? }.
85
+ *
86
+ * Color transforms operate on fill/stroke attribute values and style properties,
87
+ * replacing each color with its transformed equivalent.
88
+ */
89
+ export function applyColorMode(svgContent, isMonochrome, colorMode, iconColor) {
90
+ // Handle simple string modes first
91
+ if (colorMode === 'original') {
92
+ return svgContent;
93
+ }
94
+ if (colorMode === 'auto') {
95
+ if (isMonochrome) {
96
+ return svgContent.replace(/currentColor/g, iconColor);
97
+ }
98
+ return svgContent;
99
+ }
100
+ if (colorMode === 'monochrome') {
101
+ return svgContent
102
+ .replace(/(fill=["'])(?!none)([^"']+)(["'])/gi, `$1${iconColor}$3`)
103
+ .replace(/(stroke=["'])(?!none)([^"']+)(["'])/gi, `$1${iconColor}$3`)
104
+ .replace(/currentColor/g, iconColor);
105
+ }
106
+ // For grayscale, grayscale-tint, and hue remap: transform each color value
107
+ const transformColor = (color) => {
108
+ if (color === 'none' || color === 'inherit' || color === 'transparent') {
109
+ return color;
110
+ }
111
+ // Replace currentColor with iconColor before transforming
112
+ const resolvedColor = color === 'currentColor' ? iconColor : color;
113
+ if (colorMode === 'grayscale') {
114
+ return toGrayscale(resolvedColor);
115
+ }
116
+ if (colorMode === 'grayscale-tint') {
117
+ return toGrayscaleTint(resolvedColor, iconColor);
118
+ }
119
+ // Object mode: { hue, saturation? }
120
+ if (typeof colorMode === 'object') {
121
+ return remapHue(resolvedColor, colorMode.hue, colorMode.saturation);
122
+ }
123
+ return color;
124
+ };
125
+ // Transform fill/stroke attribute values
126
+ let result = svgContent.replace(/((?:fill|stroke)=["'])([^"']+)(["'])/gi, (_match, prefix, value, suffix) => {
127
+ return `${prefix}${transformColor(value.trim())}${suffix}`;
128
+ });
129
+ // Transform fill/stroke in style attributes
130
+ result = result.replace(/((?:fill|stroke)\s*:\s*)([^;"']+)/gi, (_match, prefix, value) => {
131
+ return `${prefix}${transformColor(value.trim())}`;
132
+ });
133
+ // Also replace any remaining currentColor references
134
+ result = result.replace(/currentColor/g, transformColor('currentColor'));
135
+ return result;
136
+ }
@@ -0,0 +1,16 @@
1
+ import type { GradientConfig, AppLogoProps, IconSourceType } from './types.js';
2
+ /** The Leftium brand gradient: 45deg diagonal, dark blue -> bright blue -> dark blue */
3
+ export declare const LEFTIUM_GRADIENT: GradientConfig;
4
+ /** Default values for all AppLogo props */
5
+ export declare const APP_LOGO_DEFAULTS: Required<Pick<AppLogoProps, 'icon' | 'iconColor' | 'iconColorMode' | 'iconSize' | 'iconOffsetX' | 'iconOffsetY' | 'iconRotation' | 'cornerRadius' | 'cornerShape' | 'size'>> & {
6
+ background: GradientConfig;
7
+ };
8
+ /**
9
+ * Detect icon source type from the icon prop string.
10
+ *
11
+ * - Starts with `<svg` or `<SVG` -> inline SVG
12
+ * - Starts with `data:` -> data URL
13
+ * - Contains `:` (but not `data:`) -> Iconify ID
14
+ * - Otherwise -> Unicode/emoji text
15
+ */
16
+ export declare function detectIconSource(icon: string): IconSourceType;
@@ -0,0 +1,38 @@
1
+ /** The Leftium brand gradient: 45deg diagonal, dark blue -> bright blue -> dark blue */
2
+ export const LEFTIUM_GRADIENT = {
3
+ colors: ['#0029c1', '#3973ff', '#0029c1'],
4
+ stops: [0, 0.29, 1],
5
+ angle: 45 // bottom-left -> top-right
6
+ };
7
+ /** Default values for all AppLogo props */
8
+ export const APP_LOGO_DEFAULTS = {
9
+ icon: 'fxemoji:rocket',
10
+ iconColor: '#ffffff',
11
+ iconColorMode: 'auto',
12
+ iconSize: 60,
13
+ iconOffsetX: 0,
14
+ iconOffsetY: 0,
15
+ iconRotation: 0,
16
+ cornerRadius: 0,
17
+ cornerShape: 'round',
18
+ background: LEFTIUM_GRADIENT,
19
+ size: 512
20
+ };
21
+ /**
22
+ * Detect icon source type from the icon prop string.
23
+ *
24
+ * - Starts with `<svg` or `<SVG` -> inline SVG
25
+ * - Starts with `data:` -> data URL
26
+ * - Contains `:` (but not `data:`) -> Iconify ID
27
+ * - Otherwise -> Unicode/emoji text
28
+ */
29
+ export function detectIconSource(icon) {
30
+ const trimmed = icon.trim();
31
+ if (trimmed.startsWith('<svg') || trimmed.startsWith('<SVG'))
32
+ return 'svg';
33
+ if (trimmed.startsWith('data:'))
34
+ return 'data-url';
35
+ if (trimmed.includes(':'))
36
+ return 'iconify';
37
+ return 'emoji';
38
+ }
@@ -0,0 +1,44 @@
1
+ import type { AppLogoConfig } from './types.js';
2
+ export interface FaviconSetResult {
3
+ svg: string;
4
+ ico: Blob;
5
+ appleTouchIcon: Blob;
6
+ icon192: Blob;
7
+ icon512: Blob;
8
+ }
9
+ export interface AppInfo {
10
+ name?: string;
11
+ shortName?: string;
12
+ }
13
+ /**
14
+ * Generate the full favicon file set from an AppLogo configuration.
15
+ *
16
+ * Returns SVG, ICO, and PNG blobs for all standard favicon sizes.
17
+ * Browser-only (requires canvas).
18
+ */
19
+ export declare function generateFaviconSet(config: AppLogoConfig): Promise<FaviconSetResult>;
20
+ /**
21
+ * Generate a manifest.webmanifest JSON string.
22
+ */
23
+ export declare function generateManifest(appInfo: AppInfo): string;
24
+ /**
25
+ * Generate the HTML snippet for pasting into app.html.
26
+ */
27
+ export declare function generateFaviconHtml(appInfo: AppInfo): string;
28
+ /**
29
+ * Build the full zip kit as a Blob.
30
+ *
31
+ * Zip structure mirrors a SvelteKit project root:
32
+ * static/favicon.ico
33
+ * static/icon.svg
34
+ * static/apple-touch-icon.png
35
+ * static/icon-192.png
36
+ * static/icon-512.png
37
+ * static/logo.png
38
+ * static/logo.webp
39
+ * static/logo.svg
40
+ * static/manifest.webmanifest
41
+ * static/_app-logo/config.json
42
+ * _snippets/favicon-html.html
43
+ */
44
+ export declare function generateZipKit(config: AppLogoConfig, appInfo: AppInfo): Promise<Blob>;
@@ -0,0 +1,97 @@
1
+ import JSZip from 'jszip';
2
+ import { generateAppLogoSvg } from './generate-svg.js';
3
+ import { generateAppLogoPng } from './generate-png.js';
4
+ import { pngToIco } from './generate-ico.js';
5
+ /**
6
+ * Generate the full favicon file set from an AppLogo configuration.
7
+ *
8
+ * Returns SVG, ICO, and PNG blobs for all standard favicon sizes.
9
+ * Browser-only (requires canvas).
10
+ */
11
+ export async function generateFaviconSet(config) {
12
+ const [svg, png32, appleTouchIcon, icon192, icon512] = await Promise.all([
13
+ generateAppLogoSvg(config, 'favicon'),
14
+ generateAppLogoPng(config, { variant: 'favicon', size: 32 }),
15
+ generateAppLogoPng(config, { variant: 'favicon', size: 180 }),
16
+ generateAppLogoPng(config, { variant: 'favicon', size: 192 }),
17
+ generateAppLogoPng(config, { variant: 'favicon', size: 512 })
18
+ ]);
19
+ const ico = await pngToIco(png32);
20
+ return { svg, ico, appleTouchIcon, icon192, icon512 };
21
+ }
22
+ /**
23
+ * Generate a manifest.webmanifest JSON string.
24
+ */
25
+ export function generateManifest(appInfo) {
26
+ const manifest = {
27
+ name: appInfo.name || 'My App',
28
+ short_name: appInfo.shortName || appInfo.name || 'App',
29
+ icons: [
30
+ { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
31
+ { src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
32
+ { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml' }
33
+ ],
34
+ theme_color: '#ffffff',
35
+ background_color: '#ffffff',
36
+ display: 'standalone'
37
+ };
38
+ return JSON.stringify(manifest, null, '\t');
39
+ }
40
+ /**
41
+ * Generate the HTML snippet for pasting into app.html.
42
+ */
43
+ export function generateFaviconHtml(appInfo) {
44
+ const name = appInfo.name || 'My App';
45
+ return [
46
+ `\t\t<title>${name}</title>`,
47
+ `\t\t<link rel="icon" href="/favicon.ico" sizes="32x32">`,
48
+ `\t\t<link rel="icon" href="/icon.svg" type="image/svg+xml">`,
49
+ `\t\t<link rel="apple-touch-icon" href="/apple-touch-icon.png">`,
50
+ `\t\t<link rel="manifest" href="/manifest.webmanifest">`
51
+ ].join('\n');
52
+ }
53
+ /**
54
+ * Build the full zip kit as a Blob.
55
+ *
56
+ * Zip structure mirrors a SvelteKit project root:
57
+ * static/favicon.ico
58
+ * static/icon.svg
59
+ * static/apple-touch-icon.png
60
+ * static/icon-192.png
61
+ * static/icon-512.png
62
+ * static/logo.png
63
+ * static/logo.webp
64
+ * static/logo.svg
65
+ * static/manifest.webmanifest
66
+ * static/_app-logo/config.json
67
+ * _snippets/favicon-html.html
68
+ */
69
+ export async function generateZipKit(config, appInfo) {
70
+ const [faviconSet, logoPng, logoWebp, logoSvg] = await Promise.all([
71
+ generateFaviconSet(config),
72
+ generateAppLogoPng(config, { variant: 'logo', size: 512 }),
73
+ generateAppLogoPng(config, { variant: 'logo', size: 512, format: 'webp' }),
74
+ generateAppLogoSvg(config, 'logo')
75
+ ]);
76
+ const zip = new JSZip();
77
+ const staticDir = zip.folder('static');
78
+ const appLogoDir = staticDir.folder('_app-logo');
79
+ const snippetsDir = zip.folder('_snippets');
80
+ // Favicon files
81
+ staticDir.file('favicon.ico', faviconSet.ico);
82
+ staticDir.file('icon.svg', faviconSet.svg);
83
+ staticDir.file('apple-touch-icon.png', faviconSet.appleTouchIcon);
84
+ staticDir.file('icon-192.png', faviconSet.icon192);
85
+ staticDir.file('icon-512.png', faviconSet.icon512);
86
+ // Logo files
87
+ staticDir.file('logo.png', logoPng);
88
+ staticDir.file('logo.webp', logoWebp);
89
+ staticDir.file('logo.svg', logoSvg);
90
+ // Manifest
91
+ staticDir.file('manifest.webmanifest', generateManifest(appInfo));
92
+ // Config for regeneration
93
+ appLogoDir.file('config.json', JSON.stringify(config, null, '\t'));
94
+ // HTML snippet
95
+ snippetsDir.file('favicon-html.html', generateFaviconHtml(appInfo));
96
+ return zip.generateAsync({ type: 'blob' });
97
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * ICO binary container generator.
3
+ *
4
+ * Wraps a single PNG blob in the ICO container format.
5
+ * The ICO format is a simple container; modern browsers accept a single 32x32 PNG inside.
6
+ *
7
+ * ICO file structure (single image):
8
+ * ICONDIR header (6 bytes)
9
+ * ICONDIRENTRY (16 bytes)
10
+ * PNG image data (variable)
11
+ */
12
+ /**
13
+ * Wrap a PNG Blob in an ICO container.
14
+ *
15
+ * @param pngBlob - A PNG image blob (should be 32x32 for favicon.ico)
16
+ * @returns ICO file as a Blob
17
+ */
18
+ export declare function pngToIco(pngBlob: Blob): Promise<Blob>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * ICO binary container generator.
3
+ *
4
+ * Wraps a single PNG blob in the ICO container format.
5
+ * The ICO format is a simple container; modern browsers accept a single 32x32 PNG inside.
6
+ *
7
+ * ICO file structure (single image):
8
+ * ICONDIR header (6 bytes)
9
+ * ICONDIRENTRY (16 bytes)
10
+ * PNG image data (variable)
11
+ */
12
+ /**
13
+ * Wrap a PNG Blob in an ICO container.
14
+ *
15
+ * @param pngBlob - A PNG image blob (should be 32x32 for favicon.ico)
16
+ * @returns ICO file as a Blob
17
+ */
18
+ export async function pngToIco(pngBlob) {
19
+ const pngBytes = new Uint8Array(await pngBlob.arrayBuffer());
20
+ const pngSize = pngBytes.byteLength;
21
+ // ICO header: 6 bytes
22
+ // reserved (2) = 0
23
+ // type (2) = 1 (icon)
24
+ // count (2) = 1 (number of images)
25
+ const HEADER_SIZE = 6;
26
+ const ENTRY_SIZE = 16;
27
+ const imageOffset = HEADER_SIZE + ENTRY_SIZE;
28
+ const buffer = new ArrayBuffer(imageOffset + pngSize);
29
+ const view = new DataView(buffer);
30
+ const bytes = new Uint8Array(buffer);
31
+ // ICONDIR header
32
+ view.setUint16(0, 0, true); // reserved
33
+ view.setUint16(2, 1, true); // type: 1 = ICO
34
+ view.setUint16(4, 1, true); // image count: 1
35
+ // ICONDIRENTRY (16 bytes at offset 6)
36
+ // width (1 byte): 0 means 256; use actual size for <=255
37
+ // height (1 byte): same
38
+ // We read actual dimensions from the PNG IHDR chunk (bytes 16-23)
39
+ const width = readPngDimension(pngBytes, 16);
40
+ const height = readPngDimension(pngBytes, 20);
41
+ bytes[6] = width >= 256 ? 0 : width; // width (0 = 256)
42
+ bytes[7] = height >= 256 ? 0 : height; // height (0 = 256)
43
+ bytes[8] = 0; // color count (0 = no palette)
44
+ bytes[9] = 0; // reserved
45
+ view.setUint16(10, 1, true); // color planes
46
+ view.setUint16(12, 32, true); // bits per pixel
47
+ view.setUint32(14, pngSize, true); // size of image data
48
+ view.setUint32(18, imageOffset, true); // offset of image data
49
+ // PNG image data
50
+ bytes.set(pngBytes, imageOffset);
51
+ return new Blob([buffer], { type: 'image/x-icon' });
52
+ }
53
+ /**
54
+ * Read a 4-byte big-endian uint32 from a Uint8Array at the given offset.
55
+ * Used to extract width/height from the PNG IHDR chunk.
56
+ */
57
+ function readPngDimension(bytes, offset) {
58
+ return (((bytes[offset] << 24) |
59
+ (bytes[offset + 1] << 16) |
60
+ (bytes[offset + 2] << 8) |
61
+ bytes[offset + 3]) >>>
62
+ 0);
63
+ }
@@ -0,0 +1,16 @@
1
+ import type { AppLogoConfig } from './types.js';
2
+ /**
3
+ * Generate a PNG or WebP Blob from an AppLogo configuration.
4
+ *
5
+ * Renders the SVG onto an offscreen canvas and exports as PNG or WebP.
6
+ * Browser-only (requires canvas and Image).
7
+ *
8
+ * @param config - The AppLogo configuration
9
+ * @param options - Export options
10
+ * @returns PNG or WebP Blob
11
+ */
12
+ export declare function generateAppLogoPng(config: AppLogoConfig, options?: {
13
+ variant?: 'logo' | 'favicon';
14
+ size?: number;
15
+ format?: 'png' | 'webp';
16
+ }): Promise<Blob>;
@@ -0,0 +1,60 @@
1
+ import { generateAppLogoSvg } from './generate-svg.js';
2
+ /**
3
+ * Generate a PNG or WebP Blob from an AppLogo configuration.
4
+ *
5
+ * Renders the SVG onto an offscreen canvas and exports as PNG or WebP.
6
+ * Browser-only (requires canvas and Image).
7
+ *
8
+ * @param config - The AppLogo configuration
9
+ * @param options - Export options
10
+ * @returns PNG or WebP Blob
11
+ */
12
+ export async function generateAppLogoPng(config, options) {
13
+ const size = options?.size ?? 512;
14
+ const mimeType = options?.format === 'webp' ? 'image/webp' : 'image/png';
15
+ // Override size in config for SVG generation
16
+ const sizedConfig = {
17
+ ...config,
18
+ [options?.variant === 'favicon' ? 'favicon' : 'logo']: {
19
+ ...(options?.variant === 'favicon' ? config.favicon : config.logo),
20
+ size
21
+ }
22
+ };
23
+ const svg = await generateAppLogoSvg(sizedConfig, options?.variant);
24
+ return new Promise((resolve, reject) => {
25
+ const img = new Image();
26
+ const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
27
+ const url = URL.createObjectURL(blob);
28
+ img.onload = () => {
29
+ try {
30
+ const canvas = document.createElement('canvas');
31
+ canvas.width = size;
32
+ canvas.height = size;
33
+ const ctx = canvas.getContext('2d');
34
+ if (!ctx) {
35
+ reject(new Error('Failed to get canvas 2d context'));
36
+ return;
37
+ }
38
+ ctx.drawImage(img, 0, 0, size, size);
39
+ canvas.toBlob((outBlob) => {
40
+ URL.revokeObjectURL(url);
41
+ if (outBlob) {
42
+ resolve(outBlob);
43
+ }
44
+ else {
45
+ reject(new Error('Canvas toBlob returned null'));
46
+ }
47
+ }, mimeType);
48
+ }
49
+ catch (err) {
50
+ URL.revokeObjectURL(url);
51
+ reject(err);
52
+ }
53
+ };
54
+ img.onerror = () => {
55
+ URL.revokeObjectURL(url);
56
+ reject(new Error('Failed to load SVG into Image element'));
57
+ };
58
+ img.src = url;
59
+ });
60
+ }
@@ -0,0 +1,9 @@
1
+ import type { AppLogoConfig } from './types.js';
2
+ /**
3
+ * Generate a complete SVG string for an AppLogo configuration.
4
+ *
5
+ * @param config - The AppLogo configuration
6
+ * @param variant - 'logo' or 'favicon' to use per-output overrides
7
+ * @returns Complete SVG string
8
+ */
9
+ export declare function generateAppLogoSvg(config: AppLogoConfig, variant?: 'logo' | 'favicon'): Promise<string>;
@@ -0,0 +1,158 @@
1
+ import { APP_LOGO_DEFAULTS } from './defaults.js';
2
+ import { resolveIcon } from './iconify.js';
3
+ import { applyColorMode } from './color-transform.js';
4
+ import { generateCornerPath } from './squircle.js';
5
+ /**
6
+ * Resolve merged props from an AppLogoConfig + variant.
7
+ */
8
+ function resolveProps(config, variant) {
9
+ const overrides = variant === 'favicon' ? config.favicon : config.logo;
10
+ return {
11
+ icon: overrides?.icon ?? config.icon ?? APP_LOGO_DEFAULTS.icon,
12
+ iconColor: overrides?.iconColor ?? config.iconColor ?? APP_LOGO_DEFAULTS.iconColor,
13
+ iconColorMode: overrides?.iconColorMode ?? config.iconColorMode ?? APP_LOGO_DEFAULTS.iconColorMode,
14
+ iconSize: overrides?.iconSize ?? APP_LOGO_DEFAULTS.iconSize,
15
+ iconOffsetX: overrides?.iconOffsetX ?? APP_LOGO_DEFAULTS.iconOffsetX,
16
+ iconOffsetY: overrides?.iconOffsetY ?? APP_LOGO_DEFAULTS.iconOffsetY,
17
+ iconRotation: overrides?.iconRotation ?? APP_LOGO_DEFAULTS.iconRotation,
18
+ cornerRadius: overrides?.cornerRadius ?? config.cornerRadius ?? APP_LOGO_DEFAULTS.cornerRadius,
19
+ cornerShape: overrides?.cornerShape ?? config.cornerShape ?? APP_LOGO_DEFAULTS.cornerShape,
20
+ background: overrides?.background ?? config.background ?? APP_LOGO_DEFAULTS.background,
21
+ size: overrides?.size ?? APP_LOGO_DEFAULTS.size
22
+ };
23
+ }
24
+ /**
25
+ * Convert a CSS gradient angle (degrees, clockwise from top) to SVG linearGradient coordinates.
26
+ *
27
+ * CSS: 0deg = bottom-to-top, 90deg = left-to-right, 180deg = top-to-bottom
28
+ * SVG: x1,y1 = start point, x2,y2 = end point (as percentages)
29
+ *
30
+ * @param angleDeg - CSS gradient angle in degrees
31
+ * @param position - Shift gradient along its axis as % (-100 to 100), default 0
32
+ * @param scale - Scale gradient length (1 = full span, 0.5 = half, 2 = double), default 1
33
+ */
34
+ function angleToGradientCoords(angleDeg, position = 0, scale = 1) {
35
+ // CSS gradient angles: 0deg = to top, 90deg = to right, clockwise.
36
+ // The gradient line runs FROM the opposite side TO the angle direction.
37
+ // In SVG coords (Y-axis down): convert CSS angle to SVG start/end points.
38
+ const rad = (angleDeg * Math.PI) / 180;
39
+ // Direction vector: CSS 0deg = up (dx=0, dy=-1 in SVG), rotates clockwise
40
+ const dx = Math.sin(rad);
41
+ const dy = -Math.cos(rad);
42
+ // Half-length of the gradient vector, scaled
43
+ const halfLen = 50 * scale;
44
+ // Position shift along the gradient axis (% of full span)
45
+ const shiftX = dx * (position / 100) * 50;
46
+ const shiftY = dy * (position / 100) * 50;
47
+ // Center point, shifted by position
48
+ const cx = 50 + shiftX;
49
+ const cy = 50 + shiftY;
50
+ // Start and end points
51
+ const x1 = Math.round(cx - dx * halfLen);
52
+ const y1 = Math.round(cy - dy * halfLen);
53
+ const x2 = Math.round(cx + dx * halfLen);
54
+ const y2 = Math.round(cy + dy * halfLen);
55
+ return { x1: `${x1}%`, y1: `${y1}%`, x2: `${x2}%`, y2: `${y2}%` };
56
+ }
57
+ /**
58
+ * Build a <linearGradient> element for the gradient background.
59
+ * Returns the element without wrapping <defs>.
60
+ */
61
+ function buildGradientElement(gradient, id) {
62
+ const angle = gradient.angle ?? 45;
63
+ const position = gradient.position ?? 0;
64
+ const scale = gradient.scale ?? 1;
65
+ const coords = angleToGradientCoords(angle, position, scale);
66
+ const stops = gradient.colors
67
+ .map((color, i) => {
68
+ const offset = gradient.stops ? gradient.stops[i] : i / (gradient.colors.length - 1);
69
+ return `<stop offset="${(offset * 100).toFixed(1)}%" stop-color="${color}"/>`;
70
+ })
71
+ .join('\n ');
72
+ return `<linearGradient id="${id}" x1="${coords.x1}" y1="${coords.y1}" x2="${coords.x2}" y2="${coords.y2}">
73
+ ${stops}
74
+ </linearGradient>`;
75
+ }
76
+ /**
77
+ * Build the background element (solid color or gradient fill).
78
+ *
79
+ * For 'round' corners, uses a standard <rect> with rx/ry.
80
+ * For other corner shapes, uses a <path> with the superellipse curve.
81
+ */
82
+ function buildBackground(background, size, cornerRadius, cornerShape, gradientId) {
83
+ const fill = typeof background === 'string' ? background : `url(#${gradientId})`;
84
+ // For 'round' shape, use standard rect with rx/ry (cleaner SVG)
85
+ if (cornerShape === 'round') {
86
+ const rx = cornerRadius > 0
87
+ ? ` rx="${(cornerRadius / 100) * size}" ry="${(cornerRadius / 100) * size}"`
88
+ : '';
89
+ return {
90
+ defs: '',
91
+ element: `<rect width="${size}" height="${size}"${rx} fill="${fill}"/>`
92
+ };
93
+ }
94
+ // For other shapes, generate the superellipse path
95
+ const pathD = generateCornerPath(size, cornerRadius, cornerShape);
96
+ const clipId = 'app-logo-clip';
97
+ return {
98
+ defs: `<clipPath id="${clipId}"><path d="${pathD}"/></clipPath>`,
99
+ element: `<rect width="${size}" height="${size}" fill="${fill}" clip-path="url(#${clipId})"/>`
100
+ };
101
+ }
102
+ /**
103
+ * Build the icon layer as an SVG group, positioned and scaled within the square.
104
+ */
105
+ function buildIconLayer(svgContent, viewBox, size, iconSize, iconOffsetX, iconOffsetY, iconRotation) {
106
+ // Parse viewBox
107
+ const [vbX, vbY, vbW, vbH] = viewBox.split(' ').map(Number);
108
+ // Icon pixel size
109
+ const iconPx = (iconSize / 100) * size;
110
+ // Scale factor to fit icon into iconPx
111
+ const scale = iconPx / Math.max(vbW, vbH);
112
+ // Actual rendered dimensions (preserving aspect ratio)
113
+ const renderedW = vbW * scale;
114
+ const renderedH = vbH * scale;
115
+ // Center position with offset
116
+ const offsetXPx = (iconOffsetX / 100) * size;
117
+ const offsetYPx = (iconOffsetY / 100) * size;
118
+ const tx = (size - renderedW) / 2 + offsetXPx;
119
+ const ty = (size - renderedH) / 2 + offsetYPx;
120
+ // Rotation around the icon's center
121
+ const rotateAttr = iconRotation !== 0
122
+ ? ` rotate(${iconRotation}, ${(renderedW / 2).toFixed(2)}, ${(renderedH / 2).toFixed(2)})`
123
+ : '';
124
+ return `<g transform="translate(${tx.toFixed(2)}, ${ty.toFixed(2)})${rotateAttr} scale(${scale.toFixed(4)}) translate(${-vbX}, ${-vbY})">
125
+ ${svgContent}
126
+ </g>`;
127
+ }
128
+ /**
129
+ * Generate a complete SVG string for an AppLogo configuration.
130
+ *
131
+ * @param config - The AppLogo configuration
132
+ * @param variant - 'logo' or 'favicon' to use per-output overrides
133
+ * @returns Complete SVG string
134
+ */
135
+ export async function generateAppLogoSvg(config, variant) {
136
+ const props = resolveProps(config, variant);
137
+ const size = props.size;
138
+ const gradientId = 'app-logo-bg';
139
+ // Resolve icon
140
+ const resolved = await resolveIcon(props.icon);
141
+ // Apply color mode (supports all IconColorMode values)
142
+ const coloredContent = applyColorMode(resolved.svgContent, resolved.isMonochrome, props.iconColorMode, props.iconColor);
143
+ // Build SVG parts
144
+ const isGradient = typeof props.background !== 'string';
145
+ const gradientEl = isGradient
146
+ ? buildGradientElement(props.background, gradientId)
147
+ : '';
148
+ const bg = buildBackground(props.background, size, props.cornerRadius, props.cornerShape, gradientId);
149
+ const iconLayer = buildIconLayer(coloredContent, resolved.viewBox, size, props.iconSize, props.iconOffsetX, props.iconOffsetY, props.iconRotation);
150
+ // Combine all defs (gradient + clip path)
151
+ const allDefs = [gradientEl, bg.defs].filter(Boolean).join('\n ');
152
+ const defsBlock = allDefs ? `<defs>\n ${allDefs}\n </defs>` : '';
153
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
154
+ ${defsBlock}
155
+ ${bg.element}
156
+ ${iconLayer}
157
+ </svg>`;
158
+ }