@illlustrations/avatars 1.0.0 → 1.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.
package/AI.md CHANGED
@@ -46,7 +46,10 @@ fromJSON(croods, saved).toString();
46
46
  `` `avatar-${i}-${id.replace(/[^A-Za-z0-9_-]/g, '')}` ``. Seed with the raw ID.
47
47
  `<Avatar>` and `toDataUri()` handle IDs themselves.
48
48
  6. Colors: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `transparent`, or
49
- `original` for color roles. Named CSS colors throw. `size` must be above 0 and at most 8192.
49
+ `original` for color roles. Named CSS colors throw.
50
+ To recolor something that isn't a role (bag, mouth, drool, accents), use
51
+ `palette` with a key from `croods.palette` or `avatar.sourceColors()`; never
52
+ guess artwork hex values. Roles win over the palette. `size` must be above 0 and at most 8192.
50
53
  7. `<Avatar>` is a client component (`'use client'`). In a React Server
51
54
  Component, render it as-is or use `createAvatar(...).toDataUri()` in an `<img>`.
52
55
  8. The artwork is CC BY 4.0: credit "Croods by illlustrations" once per app or
@@ -59,9 +62,10 @@ fromJSON(croods, saved).toString();
59
62
  | --- | --- |
60
63
  | `seed` | Any string. Omit for the default character. |
61
64
  | `selections` | Partial `{ head, face, upperBody, facialHair, accessories }` |
62
- | `colors` | Partial `{ hair, skin, clothing, stroke }` |
65
+ | `colors` | Partial `{ hair, skin, clothing, stroke }`; each one color or (1.1+) a list the seed picks from |
63
66
  | `theme` | `'ink'` (blue and white) or `'neutral'` (black and white) |
64
- | `background` | Hex or `'transparent'` (default) |
67
+ | `palette` | 1.1+: `{ '#FF4B33': '#254CE9' }` swaps any artwork color; keys from `croods.palette` |
68
+ | `background` | Hex or `'transparent'` (default); 1.1+ also a list the seed picks from |
65
69
  | `shape` | `'square'` (default), `'rounded'`, `'circle'` |
66
70
  | `size` | Pixels. Core default 600, React default 64 |
67
71
  | `seedPool` | `'v1'` (default). Only for pinning seed results across versions |
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ - `palette` option: swap any artwork color, matching the website builder, e.g.
6
+ `{ '#FF4B33': '#254CE9' }`. Roles still win; the palette wins over theme
7
+ accents; mask and clip coverage never change. Works in `createAvatar`,
8
+ `<Avatar>` and saved state.
9
+ - `style.palette` lists every source color in a style; `avatar.sourceColors()`
10
+ lists the ones in the chosen parts.
11
+ - Color lists: any role in `colors` and `background` accept a list, e.g.
12
+ `colors: { clothing: ['#254CE9', '#F76549'] }`; the seed picks one entry per
13
+ option. Saved state stores the resolved color.
14
+ - Output without a palette or lists is byte-identical to 1.0.0.
15
+
3
16
  ## 1.0.0
4
17
 
5
18
  First public release of `@illlustrations/avatars`.
package/README.md CHANGED
@@ -104,11 +104,12 @@ Seeded avatars never pick facial hair; pass `selections.facialHair` to opt in.
104
104
  | `seed` | String, including empty string. Omit for the default character. |
105
105
  | `seedPool` | Versioned selection pool, initially `v1`. |
106
106
  | `selections` | Partial `head`, `face`, `upperBody`, `facialHair`, `accessories` overrides. Face, facial hair and accessories support `none`. |
107
- | `colors` | Partial `hair`, `skin`, `clothing`, `stroke` overrides. Croods defaults to `original` for each role. |
108
- | `background` | Transparent by default. |
107
+ | `colors` | Partial `hair`, `skin`, `clothing`, `stroke` overrides: one color or a list the seed picks from (1.1+). Croods defaults to `original` for each role. |
108
+ | `background` | Transparent by default. One color or a list the seed picks from (1.1+). |
109
109
  | `size` | Positive number up to 8192; core defaults to 600, React to 64. |
110
110
  | `shape` | `square` (default), `rounded`, or `circle`. |
111
111
  | `theme` | Omit for original artwork. `ink` uses `#0040FC` and white; `neutral` uses black and white. Explicit `colors` override theme roles. |
112
+ | `palette` | Since 1.1. Swap any artwork color, like the website builder: `{ '#FF4B33': '#254CE9' }`. Keys come from `croods.palette`. |
112
113
 
113
114
  Colors accept 3/4/6/8-digit hex, `transparent`, or `original` for color roles.
114
115
  Themes also recolor fixed accents, while preserving mask coverage and keeping
@@ -126,6 +127,47 @@ Inline React rendering uses `--avatar-hair`, `--avatar-skin`,
126
127
  literals. CSS overrides are presentation-only; use `colors` for a palette that
127
128
  also appears in saved state and downloads.
128
129
 
130
+ ## Color lists
131
+
132
+ Any color role and `background` also take a list. The seed picks one entry, so
133
+ each person keeps the same color and a team gets your brand mix:
134
+
135
+ ```tsx
136
+ <Avatar assets={croods} seed={user.id}
137
+ colors={{ clothing: ['#254CE9', '#F76549', '#77E87B'], hair: ['original', '#272727'] }}
138
+ background={['#DDE5FF', '#FFE6D2', '#D4EDE2']} />
139
+ ```
140
+
141
+ Each option picks independently (hash of seed + option name, mixed with
142
+ MurmurHash3 fmix32; frozen from 1.1). Lists never change which parts a seed
143
+ picks. Saved state stores the resolved color. Every entry is validated; lists
144
+ hold 1 to 64 colors.
145
+
146
+ ## Palette (any artwork color)
147
+
148
+ Roles cover hair, skin, clothing and outlines. `palette` reaches every other
149
+ color too, such as the bag, mouth and drool accents:
150
+
151
+ ```ts
152
+ croods.palette; // ['#000000', '#FF0000', '#FFFFFF', …] every source color
153
+ avatar.sourceColors(); // the source colors in this avatar's parts
154
+
155
+ createAvatar(croods, {
156
+ selections: { upperBody: 't-shirt-bag' },
157
+ palette: { '#FF4B33': '#254CE9', '#FF0000': '#1B1B1B' },
158
+ });
159
+ ```
160
+
161
+ - Keys are source colors from `style.palette` (`#RGB` or `#RRGGBB`, any case);
162
+ unknown colors throw. Values are hex or `transparent`.
163
+ - A palette entry changes every use of that color. `#FFFFFF` is both skin and
164
+ eye whites, so use `colors.skin` to change skin alone.
165
+ - Explicit `colors` roles win over the palette; the palette wins over theme
166
+ accents. Mask and clip coverage never change.
167
+ - Saved state includes the palette (canonical `#RRGGBB` keys) only when it is
168
+ non-empty, so states without one are unchanged from 1.0. Versions before
169
+ 1.1 reject states that contain a palette.
170
+
129
171
  ## Stable state and geometry
130
172
 
131
173
  Every part carries its exact database avatar rectangle and effective layer
@@ -1,4 +1,4 @@
1
1
  import type { AvatarOptions, AvatarResult, AvatarStyle } from './types.js';
2
- export type { AvatarOptions, AvatarResult, AvatarState, AvatarStyle, ColorRole, Colors, RenderOptions, Selections, Shape, Slot, Theme } from './types.js';
2
+ export type { AvatarOptions, AvatarResult, AvatarState, AvatarStyle, ColorChoice, ColorRole, Colors, Palette, RenderOptions, Selections, Shape, Slot, Theme } from './types.js';
3
3
  export declare function createAvatar(style: AvatarStyle, options?: AvatarOptions): AvatarResult;
4
4
  export declare function fromJSON(style: AvatarStyle, value: unknown): AvatarResult;
@@ -1,5 +1,5 @@
1
- import { renderDocument } from '../internal/render.js';
2
- import { assertStyle, isColor, isRoleColor, validateSelections } from '../internal/style.js';
1
+ import { renderDocument, usedSourceColors } from '../internal/render.js';
2
+ import { assertStyle, isColor, isRoleColor, normalizePalette, validateSelections } from '../internal/style.js';
3
3
  import { colorRoles, slots } from './types.js';
4
4
  const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
5
5
  function record(value, label) {
@@ -20,11 +20,13 @@ function hash(seed) {
20
20
  }
21
21
  function validateState(style, state) {
22
22
  record(state, 'avatar state');
23
- keys(state, ['schemaVersion', 'style', 'styleVersion', 'selections', 'colors', 'background', 'size', 'shape', 'theme'], 'state field');
23
+ keys(state, ['schemaVersion', 'style', 'styleVersion', 'selections', 'colors', 'palette', 'background', 'size', 'shape', 'theme'], 'state field');
24
24
  if (state.schemaVersion !== 1 || state.style !== style.id || state.styleVersion !== style.version)
25
25
  throw new Error('Avatar state schema or style version does not match');
26
26
  if (state.theme !== undefined && !['ink', 'neutral'].includes(state.theme))
27
27
  throw new Error('Unknown avatar theme');
28
+ if (state.palette !== undefined && Object.keys(normalizePalette(style, state.palette, true)).length === 0)
29
+ throw new Error('Saved palette must not be empty');
28
30
  record(state.selections, 'selections');
29
31
  keys(state.selections, slots, 'slot');
30
32
  validateSelections(style, state.selections);
@@ -40,10 +42,37 @@ function validateState(style, state) {
40
42
  if (!['square', 'rounded', 'circle'].includes(state.shape))
41
43
  throw new Error('Unknown avatar shape');
42
44
  }
45
+ /**
46
+ * MurmurHash3 fmix32. FNV-1a's low bits depend only on the input's low bits, so
47
+ * small moduli would tie differently salted picks together. Frozen from 1.1.
48
+ * The v1 seed pool keeps plain FNV-1a so existing seeds never change.
49
+ */
50
+ function mix(value) {
51
+ value ^= value >>> 16;
52
+ value = Math.imul(value, 0x85ebca6b);
53
+ value ^= value >>> 13;
54
+ value = Math.imul(value, 0xc2b2ae35);
55
+ return (value ^ (value >>> 16)) >>> 0;
56
+ }
57
+ /**
58
+ * A color option is one color or a list to choose from. The seed picks from a
59
+ * list (salted per option, so choices are independent); every entry is
60
+ * validated so a typo fails even when it is not picked.
61
+ */
62
+ function pickColor(value, seed, salt, valid, label) {
63
+ if (!Array.isArray(value))
64
+ return value;
65
+ if (value.length === 0 || value.length > 64)
66
+ throw new Error(`${label} list must have 1 to 64 colors`);
67
+ for (const color of value)
68
+ if (!valid(color))
69
+ throw new Error(`Invalid ${label} in list: use a hex color, transparent${label === 'background' ? '' : ' or original'}`);
70
+ return value[mix(hash(`${seed}\u0000${salt}`)) % value.length];
71
+ }
43
72
  export function createAvatar(style, options = {}) {
44
73
  assertStyle(style);
45
74
  record(options, 'options');
46
- keys(options, ['seed', 'seedPool', 'selections', 'colors', 'background', 'size', 'shape', 'theme'], 'option');
75
+ keys(options, ['seed', 'seedPool', 'selections', 'colors', 'palette', 'background', 'size', 'shape', 'theme'], 'option');
47
76
  let selections = style.defaults.selections;
48
77
  if (options.seed !== undefined && typeof options.seed !== 'string')
49
78
  throw new Error('Seed must be a string');
@@ -62,12 +91,18 @@ export function createAvatar(style, options = {}) {
62
91
  record(options.colors, 'colors');
63
92
  keys(options.colors, colorRoles, 'color role');
64
93
  }
94
+ const seed = options.seed ?? '';
95
+ const colors = Object.fromEntries(Object.entries(options.colors ?? {}).map(([role, value]) => [role, pickColor(value, seed, `colors.${role}`, isRoleColor, `${role} color`)]));
96
+ const background = pickColor(options.background, seed, 'background', isColor, 'background');
97
+ const palette = options.palette === undefined ? {} : normalizePalette(style, options.palette);
65
98
  const state = {
66
99
  schemaVersion: 1, style: style.id, styleVersion: style.version,
67
100
  selections: { ...selections, ...options.selections },
68
- colors: { ...style.defaults.colors, ...(options.theme ? { hair: options.theme === 'ink' ? '#0040FC' : '#000000', stroke: options.theme === 'ink' ? '#0040FC' : '#000000', skin: '#FFFFFF', clothing: '#FFFFFF' } : {}), ...options.colors },
101
+ colors: { ...style.defaults.colors, ...(options.theme ? { hair: options.theme === 'ink' ? '#0040FC' : '#000000', stroke: options.theme === 'ink' ? '#0040FC' : '#000000', skin: '#FFFFFF', clothing: '#FFFFFF' } : {}), ...colors },
69
102
  ...(options.theme === undefined ? {} : { theme: options.theme }),
70
- background: options.background ?? 'transparent', size: options.size ?? 600, shape: options.shape ?? 'square',
103
+ // Omitted when empty, so states without a palette stay identical to 1.0.
104
+ ...(Object.keys(palette).length ? { palette } : {}),
105
+ background: background ?? 'transparent', size: options.size ?? 600, shape: options.shape ?? 'square',
71
106
  };
72
107
  validateState(style, state);
73
108
  return result(style, state);
@@ -84,5 +119,6 @@ function result(style, state) {
84
119
  toJSON: () => JSON.parse(JSON.stringify(state)),
85
120
  toString: (options = {}) => renderDocument(style, state, options),
86
121
  toDataUri: (options = {}) => `data:image/svg+xml,${encodeURIComponent(renderDocument(style, state, options))}`,
122
+ sourceColors: () => usedSourceColors(style, state),
87
123
  });
88
124
  }
@@ -6,18 +6,30 @@ export type Colors = Record<ColorRole, string>;
6
6
  export type Selections = Record<Slot, string>;
7
7
  export type Theme = 'ink' | 'neutral';
8
8
  export type Shape = 'square' | 'rounded' | 'circle';
9
+ /**
10
+ * Source artwork color → replacement, like the website builder's palette.
11
+ * Keys are colors from `style.palette` (`#RGB` or `#RRGGBB`, any case); values
12
+ * are hex or `transparent`. Every use of a source color changes, including
13
+ * role paints left as `original`. Explicit `colors` roles win over the palette.
14
+ */
15
+ export type Palette = Record<string, string>;
16
+ /** One color, or a list the seed picks from (the same seed always gets the same entry). */
17
+ export type ColorChoice = string | readonly string[];
9
18
  export interface AvatarOptions {
10
19
  theme?: Theme;
20
+ palette?: Palette;
11
21
  seed?: string;
12
22
  seedPool?: string;
13
23
  selections?: Partial<Selections>;
14
- colors?: Partial<Colors>;
15
- background?: string;
24
+ colors?: Partial<Record<ColorRole, ColorChoice>>;
25
+ background?: ColorChoice;
16
26
  size?: number;
17
27
  shape?: Shape;
18
28
  }
19
29
  export interface AvatarState {
20
30
  theme?: Theme;
31
+ /** Present only when non-empty. Keys are canonical `#RRGGBB`. */
32
+ palette?: Palette;
21
33
  schemaVersion: 1;
22
34
  style: string;
23
35
  styleVersion: string;
@@ -36,6 +48,8 @@ export interface AvatarResult {
36
48
  toString(options?: RenderOptions): string;
37
49
  toDataUri(options?: Omit<RenderOptions, 'idPrefix'>): string;
38
50
  toJSON(): AvatarState;
51
+ /** Source colors in this avatar's parts, as canonical `#RRGGBB`: the keys `palette` can change. */
52
+ sourceColors(): string[];
39
53
  }
40
54
  export interface Part {
41
55
  id: string;
@@ -79,5 +93,7 @@ declare const compiledStyle: unique symbol;
79
93
  /** Only use style objects shipped by the package's curated style entry points. */
80
94
  export interface AvatarStyle extends Readonly<StyleDefinition> {
81
95
  readonly [compiledStyle]: true;
96
+ /** Every source color in the style's artwork (canonical `#RRGGBB`), most used first. */
97
+ readonly palette: readonly string[];
82
98
  }
83
99
  export {};
@@ -0,0 +1,9 @@
1
+ /** `#RGB`, `#RRGGBB`, `black` or `white` → uppercase `#RRGGBB`; anything else → undefined. */
2
+ export declare function canonicalHex(value: string | undefined): string | undefined;
3
+ /**
4
+ * Rewrite literal fill/stroke/stop-color paints outside mask and clip coverage.
5
+ * Role variables, url() references, none and transparent are never passed on.
6
+ */
7
+ export declare function mapPaints(svg: string, map: (paint: string) => string): string;
8
+ /** Canonical source colors in one compiled part, with how often each is painted. */
9
+ export declare function sourceColorCounts(svg: string, counts?: Map<string, number>): Map<string, number>;
@@ -0,0 +1,48 @@
1
+ /** `#RGB`, `#RRGGBB`, `black` or `white` → uppercase `#RRGGBB`; anything else → undefined. */
2
+ export function canonicalHex(value) {
3
+ if (value === undefined)
4
+ return undefined;
5
+ let color = value.trim().toUpperCase();
6
+ if (color === 'BLACK')
7
+ return '#000000';
8
+ if (color === 'WHITE')
9
+ return '#FFFFFF';
10
+ if (/^#[0-9A-F]{3}$/.test(color))
11
+ color = '#' + [...color.slice(1)].map(char => char + char).join('');
12
+ return /^#[0-9A-F]{6}$/.test(color) ? color : undefined;
13
+ }
14
+ /**
15
+ * Rewrite literal fill/stroke/stop-color paints outside mask and clip coverage.
16
+ * Role variables, url() references, none and transparent are never passed on.
17
+ */
18
+ export function mapPaints(svg, map) {
19
+ let coverage = 0;
20
+ return svg.replace(/<[^>]+>/g, tag => {
21
+ if (/^<\/(?:mask|clipPath)\b/.test(tag)) {
22
+ coverage--;
23
+ return tag;
24
+ }
25
+ const protectedTag = /^<(?:mask|clipPath)\b/.test(tag);
26
+ if (protectedTag && !/\/>$/.test(tag))
27
+ coverage++;
28
+ if (coverage || protectedTag)
29
+ return tag;
30
+ return tag.replace(/(\s(?:fill|stroke|stop-color)=")([^"<>]*)(")/g, (match, start, paint, end) => {
31
+ if (/^(?:none|transparent|var\(|url\()/i.test(paint))
32
+ return match;
33
+ return `${start}${map(paint)}${end}`;
34
+ });
35
+ });
36
+ }
37
+ /** Canonical source colors in one compiled part, with how often each is painted. */
38
+ export function sourceColorCounts(svg, counts = new Map()) {
39
+ const add = (paint) => {
40
+ const color = canonicalHex(paint);
41
+ if (color)
42
+ counts.set(color, (counts.get(color) ?? 0) + 1);
43
+ return paint;
44
+ };
45
+ // Role paints keep their original color as the variable fallback.
46
+ mapPaints(svg.replace(/(\s(?:fill|stroke|stop-color)=")var\(--avatar-(?:hair|skin|clothing|stroke),\s*([^)]*)\)(")/g, (_, start, original, end) => `${start}${original}${end}`), add);
47
+ return counts;
48
+ }
@@ -1,5 +1,7 @@
1
1
  import type { AvatarStyle, AvatarState, RenderOptions } from '../core/types.js';
2
2
  export declare const escapeXml: (value: string) => string;
3
+ /** Canonical source colors used by the avatar's selected parts. */
4
+ export declare function usedSourceColors(style: AvatarStyle, state: AvatarState): string[];
3
5
  /** Internal React renderer; the package export map only exposes the supported public API. */
4
6
  export declare function renderContents(style: AvatarStyle, state: AvatarState, idPrefix: string, cssVariables?: boolean): string;
5
7
  export declare function renderDocument(style: AvatarStyle, state: AvatarState, options: RenderOptions): string;
@@ -1,4 +1,5 @@
1
1
  import { slots } from '../core/types.js';
2
+ import { canonicalHex, mapPaints, sourceColorCounts } from './paint.js';
2
3
  export const escapeXml = (value) => value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[char]);
3
4
  function record(value, label) {
4
5
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -14,31 +15,38 @@ function prefix(value) {
14
15
  throw new Error('idPrefix must start with a letter and contain only letters, digits, underscores or hyphens');
15
16
  return value;
16
17
  }
17
- /** Recolor fixed accents while preserving mask/clip coverage and role variables. */
18
- function themeAccents(svg, ink) {
19
- let coverage = 0;
20
- return svg.replace(/<[^>]+>/g, tag => {
21
- if (/^<\/(?:mask|clipPath)\b/.test(tag)) {
22
- coverage--;
23
- return tag;
24
- }
25
- const protectedTag = /^<(?:mask|clipPath)\b/.test(tag);
26
- if (protectedTag && !/\/>$/.test(tag))
27
- coverage++;
28
- if (coverage || protectedTag)
29
- return tag;
30
- return tag.replace(/(\s(?:fill|stroke|stop-color)=")([^"<>]*)(")/g, (match, start, paint, end) => {
31
- if (/^(?:none|transparent|var\(|url\()/i.test(paint))
32
- return match;
33
- const canonical = paint.toUpperCase();
34
- // Dark neutral accents are linework; other fixed accents become paper.
35
- const dark = canonical === 'BLACK' || /^#(?:000|000000|424242)$/.test(canonical);
36
- if (!/^(?:#[0-9A-F]{3,8}|BLACK|WHITE)$/.test(canonical))
37
- return match;
38
- return `${start}${dark ? ink : '#FFFFFF'}${end}`;
39
- });
18
+ /**
19
+ * Recolor fixed paints: palette entries first, then theme accents. Mask/clip
20
+ * coverage and role variables are untouched (see mapPaints).
21
+ */
22
+ function recolorFixed(svg, theme, palette) {
23
+ if (!theme && !palette)
24
+ return svg;
25
+ const ink = theme === 'ink' ? '#0040FC' : '#000000';
26
+ return mapPaints(svg, paint => {
27
+ const swap = palette?.[canonicalHex(paint) ?? ''];
28
+ if (swap)
29
+ return swap;
30
+ if (!theme)
31
+ return paint;
32
+ const canonical = paint.toUpperCase();
33
+ if (!/^(?:#[0-9A-F]{3,8}|BLACK|WHITE)$/.test(canonical))
34
+ return paint;
35
+ // Dark neutral accents are linework; other fixed accents become paper.
36
+ const dark = canonical === 'BLACK' || /^#(?:000|000000|424242)$/.test(canonical);
37
+ return dark ? ink : '#FFFFFF';
40
38
  });
41
39
  }
40
+ /** Canonical source colors used by the avatar's selected parts. */
41
+ export function usedSourceColors(style, state) {
42
+ const counts = new Map();
43
+ for (const slot of slots) {
44
+ const part = style.parts[slot].find(item => item.id === state.selections[slot]);
45
+ if (part)
46
+ sourceColorCounts(part.svg, counts);
47
+ }
48
+ return style.palette.filter(color => counts.has(color));
49
+ }
42
50
  /** Internal React renderer; the package export map only exposes the supported public API. */
43
51
  export function renderContents(style, state, idPrefix, cssVariables = false) {
44
52
  const id = prefix(idPrefix);
@@ -48,9 +56,12 @@ export function renderContents(style, state, idPrefix, cssVariables = false) {
48
56
  const part = style.parts[slot].find(item => item.id === state.selections[slot]);
49
57
  return part ? [{ slot, part }] : [];
50
58
  }).sort((a, b) => a.part.zIndex - b.part.zIndex).map(({ slot, part }) => {
51
- const source = state.theme ? themeAccents(part.svg, state.theme === 'ink' ? '#0040FC' : '#000000') : part.svg;
59
+ const source = recolorFixed(part.svg, state.theme, state.palette);
52
60
  const svg = source.replaceAll('__AVATAR_ID__', `${id}-${slot}-`).replace(/var\(--avatar-(hair|skin|clothing|stroke)(?:,\s*([^)]*))?\)/g, (_, role, original) => {
53
- const color = state.colors[role] === 'original' ? (original ?? '#000000') : state.colors[role];
61
+ // Explicit role colors win; a role left as original follows the palette.
62
+ const color = state.colors[role] === 'original'
63
+ ? (state.palette?.[canonicalHex(original) ?? ''] ?? original ?? '#000000')
64
+ : state.colors[role];
54
65
  return cssVariables ? `var(--avatar-${role}, ${color})` : color;
55
66
  });
56
67
  const p = part.position;
@@ -1,7 +1,9 @@
1
- import type { AvatarStyle, Selections, StyleDefinition } from '../core/types.js';
1
+ import type { AvatarStyle, Palette, Selections, StyleDefinition } from '../core/types.js';
2
2
  export declare const isColor: (value: unknown) => value is string;
3
3
  export declare const isRoleColor: (value: unknown) => value is string;
4
4
  export declare function validateSelections(style: StyleDefinition, selections: Selections): void;
5
+ /** Validate palette input and return it with canonical `#RRGGBB` keys. */
6
+ export declare function normalizePalette(style: AvatarStyle, palette: unknown, strict?: boolean): Palette;
5
7
  /** Internal build output loader. Not a public arbitrary-SVG authoring API. */
6
8
  export declare function defineStyle(definition: StyleDefinition): AvatarStyle;
7
9
  export declare function assertStyle(style: AvatarStyle): void;
@@ -1,4 +1,5 @@
1
1
  import { colorRoles, slots } from '../core/types.js';
2
+ import { canonicalHex, sourceColorCounts } from './paint.js';
2
3
  const compiled = new WeakSet();
3
4
  const identifier = /^[a-z][a-z0-9-]*$/;
4
5
  export const isColor = (value) => typeof value === 'string' && /^(#[\da-f]{3}|#[\da-f]{4}|#[\da-f]{6}|#[\da-f]{8}|transparent)$/i.test(value);
@@ -18,6 +19,25 @@ export function validateSelections(style, selections) {
18
19
  }
19
20
  }
20
21
  }
22
+ /** Validate palette input and return it with canonical `#RRGGBB` keys. */
23
+ export function normalizePalette(style, palette, strict = false) {
24
+ if (!palette || typeof palette !== 'object' || Array.isArray(palette))
25
+ throw new Error('Invalid palette');
26
+ const result = {};
27
+ for (const [key, value] of Object.entries(palette)) {
28
+ const color = canonicalHex(key);
29
+ if (!color || (strict && key !== color))
30
+ throw new Error(`Invalid palette color: ${key}`);
31
+ if (!style.palette.includes(color))
32
+ throw new Error(`Unknown palette color: ${key} is not in the ${style.id} artwork`);
33
+ if (own(result, color))
34
+ throw new Error(`Duplicate palette color: ${color}`);
35
+ if (!isColor(value))
36
+ throw new Error(`Invalid palette value for ${color}: use a hex color or transparent`);
37
+ result[color] = value;
38
+ }
39
+ return result;
40
+ }
21
41
  function freeze(value) {
22
42
  Object.freeze(value);
23
43
  for (const child of Object.values(value))
@@ -61,6 +81,12 @@ export function defineStyle(definition) {
61
81
  for (const selections of pool)
62
82
  validateSelections(style, selections);
63
83
  }
84
+ // Every source color, most used first: the keys a palette may change.
85
+ const counts = new Map();
86
+ for (const slot of slots)
87
+ for (const part of style.parts[slot])
88
+ sourceColorCounts(part.svg, counts);
89
+ style.palette = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([color]) => color);
64
90
  freeze(style);
65
91
  compiled.add(style);
66
92
  return style;
@@ -1,8 +1,8 @@
1
1
  import type { SVGProps } from 'react';
2
2
  import type { AvatarOptions, AvatarStyle } from '../core/types.js';
3
- export type { AvatarOptions, AvatarStyle } from '../core/types.js';
3
+ export type { AvatarOptions, AvatarStyle, Palette } from '../core/types.js';
4
4
  export type AvatarProps = AvatarOptions & Omit<SVGProps<SVGSVGElement>, keyof AvatarOptions | 'children' | 'dangerouslySetInnerHTML' | 'title' | 'viewBox' | 'width' | 'height'> & {
5
5
  assets: AvatarStyle;
6
6
  title?: string;
7
7
  };
8
- export declare function Avatar({ assets, theme, seed, seedPool, selections, colors, background, size, shape, title, style, ...props }: AvatarProps): import("react").JSX.Element;
8
+ export declare function Avatar({ assets, theme, seed, seedPool, selections, colors, palette, background, size, shape, title, style, ...props }: AvatarProps): import("react").JSX.Element;
@@ -3,11 +3,11 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useId } from 'react';
4
4
  import { createAvatar } from '../core/index.js';
5
5
  import { escapeXml, renderContents } from '../internal/render.js';
6
- export function Avatar({ assets, theme, seed, seedPool, selections, colors, background, size = 64, shape, title, style, ...props }) {
6
+ export function Avatar({ assets, theme, seed, seedPool, selections, colors, palette, background, size = 64, shape, title, style, ...props }) {
7
7
  const reactId = useId();
8
8
  // Hex encoding is injective and works with both React 18 and 19's useId formats.
9
9
  const prefix = `avatar-${Array.from(reactId, char => char.codePointAt(0).toString(16)).join('-')}`;
10
- const state = createAvatar(assets, { theme, seed, seedPool, selections, colors, background, size, shape }).toJSON();
10
+ const state = createAvatar(assets, { theme, seed, seedPool, selections, colors, palette, background, size, shape }).toJSON();
11
11
  const labelled = Boolean(props['aria-label'] || props['aria-labelledby'] || title);
12
12
  const titleMarkup = title ? `<title id="${prefix}-title">${escapeXml(title)}</title>` : '';
13
13
  const variables = Object.fromEntries(Object.entries(state.colors).filter(([, value]) => value !== 'original').map(([role, value]) => [`--avatar-${role}`, value]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@illlustrations/avatars",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Composable, seeded SVG avatars for JavaScript and React. Croods is the first style.",
5
5
  "homepage": "https://illlustrations.co/docs",
6
6
  "repository": {