@crossworks/share-ui 0.230.43
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/LICENSE.md +135 -0
- package/package.json +68 -0
- package/src/app-bridge-protocol.ts +115 -0
- package/src/app-presenter.tsx +25 -0
- package/src/app-sandbox.tsx +552 -0
- package/src/appearance.ts +192 -0
- package/src/avatar.test.ts +229 -0
- package/src/avatar.ts +731 -0
- package/src/backgrounds.test.ts +119 -0
- package/src/backgrounds.ts +118 -0
- package/src/draw-presenter.tsx +39 -0
- package/src/event-presenter.tsx +62 -0
- package/src/file-presenter.tsx +76 -0
- package/src/formula-calculator.tsx +209 -0
- package/src/formula-presenter.test.ts +128 -0
- package/src/formula-presenter.tsx +301 -0
- package/src/help-topics.ts +104 -0
- package/src/lib/ink-audit.test.ts +314 -0
- package/src/lib/theme-css-blocks.ts +26 -0
- package/src/lib/theme-generator.test.ts +179 -0
- package/src/lib/theme-registry.gen.ts +352 -0
- package/src/lib/themes.test.ts +308 -0
- package/src/lib/themes.ts +75 -0
- package/src/lib/utils.ts +6 -0
- package/src/nav-items.ts +225 -0
- package/src/note-presenter.tsx +14 -0
- package/src/page-outline.tsx +127 -0
- package/src/table-presenter.tsx +226 -0
- package/src/task-presenter.tsx +60 -0
- package/src/ui/button.tsx +50 -0
- package/src/ui/input.tsx +18 -0
- package/src/ui/label.tsx +20 -0
- package/src/view-payload.ts +82 -0
- package/styles/app.css +1098 -0
- package/styles/themes.css +6198 -0
- package/themes/generate.d.mts +11 -0
- package/themes/generate.mjs +618 -0
- package/themes/model.d.mts +24 -0
- package/themes/model.mjs +213 -0
- package/themes/preview.html +145 -0
- package/themes/seeds.d.mts +16 -0
- package/themes/seeds.mjs +3694 -0
- package/tsconfig.json +15 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { DEFAULT_COLOR_THEME } from './lib/themes';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_AVATAR_STYLE,
|
|
4
|
+
DEFAULT_AVATAR_TINT,
|
|
5
|
+
resolveAvatarStyle,
|
|
6
|
+
resolveAvatarTint,
|
|
7
|
+
} from './avatar';
|
|
8
|
+
import { decodeBackgrounds, encodeBackgrounds } from './backgrounds';
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_FONT_SIZE,
|
|
11
|
+
DEFAULT_LOGO_FONT,
|
|
12
|
+
DEFAULT_PROSE_FONT,
|
|
13
|
+
DEFAULT_TITLE_FONT,
|
|
14
|
+
DEFAULT_UI_FONT,
|
|
15
|
+
fontByKey,
|
|
16
|
+
resolveFontSize,
|
|
17
|
+
resolveFontVars,
|
|
18
|
+
type ResolvedFontVars,
|
|
19
|
+
} from '@mantle/client-types/display-fonts';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The brain's system-wide appearance — colour theme, the four fonts and their
|
|
23
|
+
* four sizes, the avatar style and tint, the generated backgrounds — as it
|
|
24
|
+
* travels from the anchor owner's profile row to a rendered document.
|
|
25
|
+
*
|
|
26
|
+
* There is ONE delivery mechanism: the values are rendered into the `<html>`
|
|
27
|
+
* tag as attributes (`data-color-theme`, `data-font-logo`, `data-font-title`,
|
|
28
|
+
* `data-font-ui`, `data-font-prose`, the four size attributes,
|
|
29
|
+
* `data-avatar-style`, `data-avatar-tint`) plus the font-family CSS vars as
|
|
30
|
+
* inline style, server-side, on every surface — the client app's root layout
|
|
31
|
+
* (fed by the public GET /api/appearance) and the server-rendered share/print
|
|
32
|
+
* pages (read straight from the DB). No before-paint scripts, no localStorage
|
|
33
|
+
* cache, no coordination flags: the HTML is simply correct when it arrives, and
|
|
34
|
+
* the client providers read the attributes back on mount as their initial
|
|
35
|
+
* state.
|
|
36
|
+
*
|
|
37
|
+
* SIZES travel as attributes only, never as resolved numbers, because app.css
|
|
38
|
+
* owns the multipliers — one place holds the scale, so a TS copy cannot drift
|
|
39
|
+
* from the CSS that actually paints.
|
|
40
|
+
*
|
|
41
|
+
* `resolveAppearanceAttrs` is that projection: defaults and unknown keys
|
|
42
|
+
* resolve to NOTHING (attribute absent, var unset) so the CSS fallbacks win —
|
|
43
|
+
* "default" is the absence of the attribute, never a value.
|
|
44
|
+
*/
|
|
45
|
+
export type BrainAppearance = {
|
|
46
|
+
colorTheme: string | null;
|
|
47
|
+
fontLogo: string | null;
|
|
48
|
+
fontTitle: string | null;
|
|
49
|
+
fontUi: string | null;
|
|
50
|
+
fontProse: string | null;
|
|
51
|
+
fontSize: string | null;
|
|
52
|
+
fontLogoSize: string | null;
|
|
53
|
+
fontTitleSize: string | null;
|
|
54
|
+
fontProseSize: string | null;
|
|
55
|
+
avatarStyle: string | null;
|
|
56
|
+
avatarTint: string | null;
|
|
57
|
+
backgrounds: string | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type AppearanceAttrs = {
|
|
61
|
+
/** Non-default colour theme id, or undefined (attribute omitted). */
|
|
62
|
+
colorTheme?: string;
|
|
63
|
+
/** Non-default, registry-known font keys — the client provider's initial
|
|
64
|
+
* state, one per slot. */
|
|
65
|
+
fontLogo?: string;
|
|
66
|
+
fontTitle?: string;
|
|
67
|
+
fontUi?: string;
|
|
68
|
+
fontProse?: string;
|
|
69
|
+
/** Non-default sizes. 'medium' is the absence of the attribute; app.css turns
|
|
70
|
+
* the interface one into a root font-size and the other three into local
|
|
71
|
+
* multipliers. */
|
|
72
|
+
fontSize?: string;
|
|
73
|
+
fontLogoSize?: string;
|
|
74
|
+
fontTitleSize?: string;
|
|
75
|
+
fontProseSize?: string;
|
|
76
|
+
/** Non-default avatar style id — the client provider's initial state. */
|
|
77
|
+
avatarStyle?: string;
|
|
78
|
+
/** Non-default avatar tint — same contract. */
|
|
79
|
+
avatarTint?: string;
|
|
80
|
+
/** Non-default per-area generated backgrounds, as `menu=waves,header=off`.
|
|
81
|
+
* Areas on their default are omitted, so the attribute is absent entirely on
|
|
82
|
+
* a brain that has never chosen. */
|
|
83
|
+
backgrounds?: string;
|
|
84
|
+
/** Resolved font-family values for the four CSS vars (inline style on <html>). */
|
|
85
|
+
fontVars: ResolvedFontVars;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Attribute name ↔ default key, for the four selectable faces. Only keys that
|
|
89
|
+
* actually RESOLVED travel: an unknown key must not become provider state a
|
|
90
|
+
* modal would then display as the current choice. */
|
|
91
|
+
const FONT_SLOTS = [
|
|
92
|
+
['fontLogo', 'wordmark', DEFAULT_LOGO_FONT],
|
|
93
|
+
['fontTitle', 'pageTitle', DEFAULT_TITLE_FONT],
|
|
94
|
+
['fontUi', 'ui', DEFAULT_UI_FONT],
|
|
95
|
+
['fontProse', 'prose', DEFAULT_PROSE_FONT],
|
|
96
|
+
] as const;
|
|
97
|
+
|
|
98
|
+
const SIZE_FIELDS = ['fontSize', 'fontLogoSize', 'fontTitleSize', 'fontProseSize'] as const;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The `<html>` data-attribute name for every font field, and the CSS var name
|
|
102
|
+
* for every resolved font-family — THE canonical tables. Two renderers stamp an
|
|
103
|
+
* appearance onto a document (the client root layout and the server htmlPage),
|
|
104
|
+
* and the original bug this feature fixed was exactly those two drifting: the
|
|
105
|
+
* server copy missed `--font-sans` for months and every share rendered in
|
|
106
|
+
* Inter. Both renderers now consume these projections, so an attribute can no
|
|
107
|
+
* longer exist in one document and not the other; display-fonts.test.ts holds
|
|
108
|
+
* the tables complete against AppearanceAttrs itself.
|
|
109
|
+
*/
|
|
110
|
+
const FONT_ATTR_NAMES = [
|
|
111
|
+
['data-font-logo', 'fontLogo'],
|
|
112
|
+
['data-font-title', 'fontTitle'],
|
|
113
|
+
['data-font-ui', 'fontUi'],
|
|
114
|
+
['data-font-prose', 'fontProse'],
|
|
115
|
+
['data-font-size', 'fontSize'],
|
|
116
|
+
['data-logo-size', 'fontLogoSize'],
|
|
117
|
+
['data-title-size', 'fontTitleSize'],
|
|
118
|
+
['data-prose-size', 'fontProseSize'],
|
|
119
|
+
] as const;
|
|
120
|
+
|
|
121
|
+
const FONT_VAR_NAMES = [
|
|
122
|
+
['--font-wordmark', 'wordmark'],
|
|
123
|
+
['--font-page-title', 'pageTitle'],
|
|
124
|
+
['--font-sans', 'ui'],
|
|
125
|
+
['--font-prose', 'prose'],
|
|
126
|
+
] as const;
|
|
127
|
+
|
|
128
|
+
/** The font data-* attributes of a resolved appearance, ready to spread onto
|
|
129
|
+
* `<html>` (client) or serialize into the tag (server). Defaults are already
|
|
130
|
+
* absent from AppearanceAttrs, so absence needs no handling here. */
|
|
131
|
+
export function appearanceFontAttrs(a: AppearanceAttrs): Record<string, string> {
|
|
132
|
+
const out: Record<string, string> = {};
|
|
133
|
+
for (const [attr, field] of FONT_ATTR_NAMES) {
|
|
134
|
+
const value = a[field];
|
|
135
|
+
if (value) out[attr] = value;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The font-family CSS vars of a resolved appearance, for the `<html>` inline
|
|
141
|
+
* style. Same single-source contract as appearanceFontAttrs. */
|
|
142
|
+
export function appearanceFontVars(a: AppearanceAttrs): Record<string, string> {
|
|
143
|
+
const out: Record<string, string> = {};
|
|
144
|
+
for (const [name, key] of FONT_VAR_NAMES) {
|
|
145
|
+
const value = a.fontVars[key];
|
|
146
|
+
if (value) out[name] = value;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function resolveAppearanceAttrs(a: BrainAppearance | null | undefined): AppearanceAttrs {
|
|
152
|
+
const out: AppearanceAttrs = { fontVars: {} };
|
|
153
|
+
if (!a) return out;
|
|
154
|
+
if (a.colorTheme && a.colorTheme !== DEFAULT_COLOR_THEME) out.colorTheme = a.colorTheme;
|
|
155
|
+
|
|
156
|
+
out.fontVars = resolveFontVars(a.fontLogo, a.fontTitle, a.fontUi, a.fontProse);
|
|
157
|
+
for (const [field, varName, defaultKey] of FONT_SLOTS) {
|
|
158
|
+
const stored = a[field];
|
|
159
|
+
if (!out.fontVars[varName] || !stored) continue;
|
|
160
|
+
// Resolve through the registry so a legacy alias travels as the key the
|
|
161
|
+
// modal can actually show, and a dropped key travels as nothing at all.
|
|
162
|
+
const face = fontByKey(stored);
|
|
163
|
+
if (face && face.key !== defaultKey) out[field] = face.key;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const field of SIZE_FIELDS) {
|
|
167
|
+
const stored = a[field];
|
|
168
|
+
if (!stored) continue;
|
|
169
|
+
const resolved = resolveFontSize(stored);
|
|
170
|
+
if (resolved !== DEFAULT_FONT_SIZE) out[field] = resolved;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Same contract: only a known, non-default style travels. A legacy
|
|
174
|
+
// boring-avatars id stored before the DiceBear move resolves to a shipped
|
|
175
|
+
// style here, so the attribute is always something the picker can show.
|
|
176
|
+
if (a.avatarStyle) {
|
|
177
|
+
const resolved = resolveAvatarStyle(a.avatarStyle);
|
|
178
|
+
if (resolved !== DEFAULT_AVATAR_STYLE) out.avatarStyle = resolved;
|
|
179
|
+
}
|
|
180
|
+
if (a.avatarTint) {
|
|
181
|
+
const resolved = resolveAvatarTint(a.avatarTint);
|
|
182
|
+
if (resolved !== DEFAULT_AVATAR_TINT) out.avatarTint = resolved;
|
|
183
|
+
}
|
|
184
|
+
// Round-tripped through decode/encode rather than passed along: that drops
|
|
185
|
+
// unknown areas and unknown styles, and re-omits anything sitting on its
|
|
186
|
+
// default. What reaches the document is always something the picker can show.
|
|
187
|
+
if (a.backgrounds) {
|
|
188
|
+
const encoded = encodeBackgrounds(decodeBackgrounds(a.backgrounds));
|
|
189
|
+
if (encoded) out.backgrounds = encoded;
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import {
|
|
5
|
+
AVATAR_CATEGORIES,
|
|
6
|
+
AVATAR_STYLES,
|
|
7
|
+
DEFAULT_AVATAR_STYLE,
|
|
8
|
+
DEFAULT_AVATAR_TINT,
|
|
9
|
+
resolveAvatarTint,
|
|
10
|
+
avatarStyleMeta,
|
|
11
|
+
loadAvatarStyle,
|
|
12
|
+
renderAvatarSvg,
|
|
13
|
+
requiresAttribution,
|
|
14
|
+
resolveAvatarStyle,
|
|
15
|
+
} from './avatar';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The avatar generator runs in a client component AND in a route handler, so
|
|
19
|
+
* these assert the properties both tiers depend on: it never throws on stored
|
|
20
|
+
* input, the theme ramp actually reaches the SVG, and the same seed is the
|
|
21
|
+
* same bytes (the mobile companion caches on that).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Clean Slate --chart-1..5, light. Hex, straight out of themes.css. */
|
|
25
|
+
const RAMP = ['#666ed1', '#ae467f', '#ad5700', '#4b830f', '#00889b'];
|
|
26
|
+
|
|
27
|
+
const styleJson = (id: string) =>
|
|
28
|
+
JSON.parse(
|
|
29
|
+
readFileSync(
|
|
30
|
+
fileURLToPath(
|
|
31
|
+
new URL(`../node_modules/@dicebear/styles/dist/${id}.min.json`, import.meta.url),
|
|
32
|
+
),
|
|
33
|
+
'utf8',
|
|
34
|
+
),
|
|
35
|
+
) as { meta: { source: { name: string }; creator: { name: string }; license: { name: string } } };
|
|
36
|
+
|
|
37
|
+
describe('the style registry', () => {
|
|
38
|
+
it('renders every shipped style', async () => {
|
|
39
|
+
for (const s of AVATAR_STYLES) {
|
|
40
|
+
const svg = await renderAvatarSvg({ style: s.id, seed: 'Saskia', size: 48 });
|
|
41
|
+
expect(svg.startsWith('<svg'), s.id).toBe(true);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('has unique ids, a known category each, and a default that exists', () => {
|
|
46
|
+
const ids = AVATAR_STYLES.map((s) => s.id);
|
|
47
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
48
|
+
expect(ids).toContain(DEFAULT_AVATAR_STYLE);
|
|
49
|
+
const known = new Set(AVATAR_CATEGORIES.map((c) => c.id));
|
|
50
|
+
for (const s of AVATAR_STYLES) expect(known, s.id).toContain(s.category);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Labels are OURS, not DiceBear's. Its `meta.source.name` is unusable as a
|
|
54
|
+
// picker label: `bottts` and `bottts-neutral` are both "Bottts", `big-ears`
|
|
55
|
+
// is "Face Generator", and `croodles` is "Croodles - Doodle your face". Ten
|
|
56
|
+
// styles would have been indistinguishable in the list.
|
|
57
|
+
it('gives every style a distinct, non-empty label', () => {
|
|
58
|
+
const labels = AVATAR_STYLES.map((s) => s.label);
|
|
59
|
+
for (const l of labels) expect(l.trim().length).toBeGreaterThan(0);
|
|
60
|
+
expect(new Set(labels).size, 'duplicate labels').toBe(labels.length);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// The repo is public and 14 of these styles are CC BY 4.0, which REQUIRES
|
|
64
|
+
// attribution. If a DiceBear bump relicensed a style or renamed its designer,
|
|
65
|
+
// our credit would quietly become wrong — so the table is checked against the
|
|
66
|
+
// bytes actually installed rather than trusted.
|
|
67
|
+
it('states each style’s real creator and licence', () => {
|
|
68
|
+
for (const s of AVATAR_STYLES) {
|
|
69
|
+
const meta = styleJson(s.id).meta;
|
|
70
|
+
expect(s.creator, `${s.id} creator`).toBe(meta.creator.name);
|
|
71
|
+
// The package writes a couple of licences with a trailing full stop.
|
|
72
|
+
expect(s.license, `${s.id} licence`).toBe(meta.license.name.replace(/\.$/, ''));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('flags exactly the styles that oblige us to credit someone', () => {
|
|
77
|
+
for (const s of AVATAR_STYLES) {
|
|
78
|
+
const free = s.license === 'CC0 1.0' || s.license === 'MIT';
|
|
79
|
+
expect(requiresAttribution(s), `${s.id} (${s.license})`).toBe(!free);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('resolveAvatarStyle', () => {
|
|
85
|
+
it('passes current ids through', () => {
|
|
86
|
+
expect(resolveAvatarStyle('rings')).toBe('rings');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Stored avatars still carry boring-avatars ids; they must keep rendering
|
|
90
|
+
// something sensible without a data migration.
|
|
91
|
+
it.each([
|
|
92
|
+
['beam', 'thumbs'],
|
|
93
|
+
['bauhaus', 'shapes'],
|
|
94
|
+
['marble', 'glass'],
|
|
95
|
+
['sunset', 'glass'],
|
|
96
|
+
['pixel', 'identicon'],
|
|
97
|
+
['ring', 'rings'],
|
|
98
|
+
])('maps legacy %s → %s', (legacy, expected) => {
|
|
99
|
+
expect(resolveAvatarStyle(legacy)).toBe(expected);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('falls back for unknown, empty and nullish ids', () => {
|
|
103
|
+
for (const v of ['nope', '', null, undefined]) {
|
|
104
|
+
expect(resolveAvatarStyle(v)).toBe(DEFAULT_AVATAR_STYLE);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('always yields metadata, even for junk', () => {
|
|
109
|
+
expect(avatarStyleMeta('nope').id).toBe(DEFAULT_AVATAR_STYLE);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('lazy loading', () => {
|
|
114
|
+
// The sync path exists for React, which cannot await mid-render. It must
|
|
115
|
+
// report "not yet" rather than throw or render a broken avatar.
|
|
116
|
+
//
|
|
117
|
+
// The style cache is module-level and every test above populates it, so a
|
|
118
|
+
// cold style has to come from a FRESH module instance — otherwise this
|
|
119
|
+
// silently tests the warm path and would still pass if laziness broke.
|
|
120
|
+
it('renders nothing synchronously until the style is loaded', async () => {
|
|
121
|
+
vi.resetModules();
|
|
122
|
+
const cold = await import('./avatar');
|
|
123
|
+
expect(cold.isAvatarStyleReady('constellation')).toBe(false);
|
|
124
|
+
expect(cold.renderAvatarSvgSync({ style: 'constellation', seed: 'x' })).toBeNull();
|
|
125
|
+
await cold.loadAvatarStyle('constellation');
|
|
126
|
+
expect(cold.isAvatarStyleReady('constellation')).toBe(true);
|
|
127
|
+
expect(cold.renderAvatarSvgSync({ style: 'constellation', seed: 'x' })).toContain('<svg');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('shares one load between concurrent callers', async () => {
|
|
131
|
+
const [a, b] = await Promise.all([loadAvatarStyle('planets'), loadAvatarStyle('planets')]);
|
|
132
|
+
expect(a).toBe(b);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('theming', () => {
|
|
137
|
+
it('puts the theme ramp in the SVG', async () => {
|
|
138
|
+
const svg = await renderAvatarSvg({ style: 'shapes', seed: 'Saskia', ramp: RAMP });
|
|
139
|
+
expect(RAMP.some((c) => svg.includes(c))).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// backgroundColor is a CORE option, so it lands even on styles that declare
|
|
143
|
+
// no `background` colour group — the minimalist ones that expose only a
|
|
144
|
+
// shape colour, and the character styles, which expose none at all. Without
|
|
145
|
+
// that, "theme the background" would be a silent no-op for most of the set.
|
|
146
|
+
it.each(['rings', 'identicon', 'notionists', 'bottts', 'lorelei'])(
|
|
147
|
+
'themes %s, which declares no background group',
|
|
148
|
+
async (style) => {
|
|
149
|
+
const svg = await renderAvatarSvg({ style, seed: 'Saskia', ramp: RAMP });
|
|
150
|
+
expect(RAMP.some((c) => svg.includes(c))).toBe(true);
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// DiceBear validates colours as hex and throws on anything else. An avatar
|
|
155
|
+
// must never be the thing that takes a page down.
|
|
156
|
+
it('drops non-hex colours instead of throwing', async () => {
|
|
157
|
+
await expect(
|
|
158
|
+
renderAvatarSvg({ style: 'rings', seed: 'x', ramp: ['oklch(0.5 0.1 200)'] }),
|
|
159
|
+
).resolves.toContain('<svg');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('tint', () => {
|
|
164
|
+
it('defaults to mixed and rejects junk', () => {
|
|
165
|
+
expect(DEFAULT_AVATAR_TINT).toBe('mixed');
|
|
166
|
+
for (const v of ['nope', '', null, undefined]) expect(resolveAvatarTint(v)).toBe('mixed');
|
|
167
|
+
for (const v of ['native', 'mixed', 'theme']) expect(resolveAvatarTint(v)).toBe(v);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('native leaves the ramp out entirely', async () => {
|
|
171
|
+
const svg = await renderAvatarSvg({
|
|
172
|
+
style: 'shapes',
|
|
173
|
+
seed: 'Saskia',
|
|
174
|
+
ramp: RAMP,
|
|
175
|
+
tint: 'native',
|
|
176
|
+
});
|
|
177
|
+
expect(RAMP.some((c) => svg.includes(c))).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// `theme` repaints the style's own colour groups too, so a style with several
|
|
181
|
+
// of them lands MORE ramp colours in the SVG than `mixed` does.
|
|
182
|
+
it('theme paints more of the artwork than mixed', async () => {
|
|
183
|
+
const count = (svg: string) => RAMP.filter((c) => svg.includes(c)).length;
|
|
184
|
+
const mixed = await renderAvatarSvg({
|
|
185
|
+
style: 'shapes',
|
|
186
|
+
seed: 'Saskia',
|
|
187
|
+
ramp: RAMP,
|
|
188
|
+
tint: 'mixed',
|
|
189
|
+
});
|
|
190
|
+
const themed = await renderAvatarSvg({
|
|
191
|
+
style: 'shapes',
|
|
192
|
+
seed: 'Saskia',
|
|
193
|
+
ramp: RAMP,
|
|
194
|
+
tint: 'theme',
|
|
195
|
+
});
|
|
196
|
+
expect(count(themed)).toBeGreaterThan(count(mixed));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// The guard that stops `theme` producing an invisible avatar: groups declared
|
|
200
|
+
// `contrastTo` another group are the legible part drawn ON it (initials text,
|
|
201
|
+
// the icons glyph, thumbs' eyes and mouth) and DiceBear solves them to black
|
|
202
|
+
// or white. Painting them from the same ramp as the surface behind them is a
|
|
203
|
+
// coin-flip on whether the avatar still has a face.
|
|
204
|
+
it('never repaints a contrastTo group, even on theme', async () => {
|
|
205
|
+
for (const style of ['initials', 'icons', 'thumbs']) {
|
|
206
|
+
const svg = await renderAvatarSvg({ style, seed: 'Saskia', ramp: RAMP, tint: 'theme' });
|
|
207
|
+
const solved = /#000000|#ffffff/i.test(svg);
|
|
208
|
+
expect(solved, `${style} lost its contrast-solved colour`).toBe(true);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('determinism', () => {
|
|
214
|
+
it('same seed → same bytes', async () => {
|
|
215
|
+
const a = await renderAvatarSvg({ style: 'loops', seed: 'Remy', size: 40, ramp: RAMP });
|
|
216
|
+
const b = await renderAvatarSvg({ style: 'loops', seed: 'Remy', size: 40, ramp: RAMP });
|
|
217
|
+
expect(a).toBe(b);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('different seeds → different avatars', async () => {
|
|
221
|
+
const a = await renderAvatarSvg({ style: 'loops', seed: 'Remy' });
|
|
222
|
+
const b = await renderAvatarSvg({ style: 'loops', seed: 'Saskia' });
|
|
223
|
+
expect(a).not.toBe(b);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('honours size', async () => {
|
|
227
|
+
expect(await renderAvatarSvg({ style: 'shapes', seed: 'x', size: 96 })).toContain('width="96"');
|
|
228
|
+
});
|
|
229
|
+
});
|