@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 +7 -3
- package/CHANGELOG.md +13 -0
- package/README.md +44 -2
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +42 -6
- package/dist/core/types.d.ts +18 -2
- package/dist/internal/paint.d.ts +9 -0
- package/dist/internal/paint.js +48 -0
- package/dist/internal/render.d.ts +2 -0
- package/dist/internal/render.js +36 -25
- package/dist/internal/style.d.ts +3 -1
- package/dist/internal/style.js +26 -0
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.js +2 -2
- package/package.json +1 -1
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.
|
|
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
|
-
| `
|
|
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
|
package/dist/core/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/index.js
CHANGED
|
@@ -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' } : {}), ...
|
|
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
|
-
|
|
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
|
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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<
|
|
15
|
-
background?:
|
|
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;
|
package/dist/internal/render.js
CHANGED
|
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 =
|
|
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
|
-
|
|
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;
|
package/dist/internal/style.d.ts
CHANGED
|
@@ -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;
|
package/dist/internal/style.js
CHANGED
|
@@ -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;
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/react/index.js
CHANGED
|
@@ -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