@crossworks/share-ui 0.232.74 → 0.232.81

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossworks/share-ui",
3
- "version": "0.232.74",
3
+ "version": "0.232.81",
4
4
  "description": "The server-rendered share surface — the /s/<token> presenters, view-payload contract, mini-app sandbox, and the few primitives they need. Lives in MANTLE (the server renders these), published for jackdaw to consume; may depend only on @mantle/{client-types,content-core} (the jackdaw-repo-split boundary).",
5
5
  "exports": {
6
6
  "./app-presenter": "./src/app-presenter.tsx",
@@ -36,8 +36,8 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@firecms/neat": "1.0.2",
39
- "@mantle/client-types": "npm:@crossworks/client-types@0.232.74",
40
- "@mantle/content-core": "npm:@crossworks/content-core@0.232.74",
39
+ "@mantle/client-types": "npm:@crossworks/client-types@0.232.81",
40
+ "@mantle/content-core": "npm:@crossworks/content-core@0.232.81",
41
41
  "@radix-ui/react-label": "^2.1.12",
42
42
  "@radix-ui/react-slot": "^1.3.0",
43
43
  "class-variance-authority": "^0.7.1",
@@ -227,3 +227,65 @@ describe('determinism', () => {
227
227
  expect(await renderAvatarSvg({ style: 'shapes', seed: 'x', size: 96 })).toContain('width="96"');
228
228
  });
229
229
  });
230
+
231
+ describe('parts (avatar builder choices)', () => {
232
+ it('exposes each style’s components, variants, and optional set', async () => {
233
+ const loaded = await loadAvatarStyle('adventurer');
234
+ expect(Object.keys(loaded.variants).length).toBeGreaterThan(0);
235
+ for (const names of Object.values(loaded.variants)) {
236
+ expect(names.length).toBeGreaterThan(0);
237
+ }
238
+ // Every optional component is a real component.
239
+ for (const c of loaded.optional) expect(loaded.variants[c]).toBeDefined();
240
+ });
241
+
242
+ it('a pinned variant is deterministic and actually changes the avatar', async () => {
243
+ const loaded = await loadAvatarStyle('adventurer');
244
+ const [component, names] =
245
+ Object.entries(loaded.variants).find(([, v]) => v.length >= 2) ?? [];
246
+ if (!component || !names) throw new Error('adventurer lost its multi-variant components');
247
+ const base = { style: 'adventurer', seed: 'Remy', size: 40 };
248
+ const a1 = await renderAvatarSvg({ ...base, parts: { [component]: names[0]! } });
249
+ const a2 = await renderAvatarSvg({ ...base, parts: { [component]: names[0]! } });
250
+ const b = await renderAvatarSvg({ ...base, parts: { [component]: names[1]! } });
251
+ expect(a1).toBe(a2);
252
+ expect(a1).not.toBe(b);
253
+ });
254
+
255
+ it('drops unknown components and variants instead of throwing', async () => {
256
+ const loaded = await loadAvatarStyle('adventurer');
257
+ const component = Object.keys(loaded.variants)[0]!;
258
+ const plain = await renderAvatarSvg({ style: 'adventurer', seed: 'Remy' });
259
+ const junk = await renderAvatarSvg({
260
+ style: 'adventurer',
261
+ seed: 'Remy',
262
+ parts: { noSuchComponent: 'x', [component]: 'noSuchVariant' },
263
+ });
264
+ expect(junk).toBe(plain);
265
+ });
266
+
267
+ it('null hides an optional component; a pin force-shows it', async () => {
268
+ const loaded = await loadAvatarStyle('adventurer');
269
+ const component = loaded.optional[0];
270
+ if (!component) throw new Error('adventurer lost its optional components');
271
+ const variant = loaded.variants[component]![0]!;
272
+ const base = { style: 'adventurer', seed: 'Remy', size: 40 };
273
+ const shown = await renderAvatarSvg({ ...base, parts: { [component]: variant } });
274
+ const hidden = await renderAvatarSvg({ ...base, parts: { [component]: null } });
275
+ expect(shown).not.toBe(hidden);
276
+ });
277
+
278
+ it('stays deterministic with a themed ramp on top', async () => {
279
+ const loaded = await loadAvatarStyle('adventurer');
280
+ const component = Object.keys(loaded.variants)[0]!;
281
+ const variant = loaded.variants[component]![0]!;
282
+ const opts = {
283
+ style: 'adventurer',
284
+ seed: 'Remy',
285
+ ramp: RAMP,
286
+ tint: 'theme' as const,
287
+ parts: { [component]: variant },
288
+ };
289
+ expect(await renderAvatarSvg(opts)).toBe(await renderAvatarSvg(opts));
290
+ });
291
+ });
package/src/avatar.ts CHANGED
@@ -571,6 +571,9 @@ export type Loaded = {
571
571
  /** component name → the variant names it offers, e.g. `rotation` →
572
572
  * `['quarter', 'none', 'free']`. Empty for styles that declare none. */
573
573
  variants: Record<string, string[]>;
574
+ /** Components the style may leave out (declared probability < 100). Only
575
+ * these accept `null` ("hide it") in {@link AvatarParts}. */
576
+ optional: string[];
574
577
  };
575
578
 
576
579
  /**
@@ -606,15 +609,20 @@ function tintableGroups(json: unknown): string[] {
606
609
  * has a `rotation` component. Reading the declaration is the only way to know;
607
610
  * guessing by style id would break the moment a style is added or renamed.
608
611
  */
609
- function componentVariants(json: unknown): Record<string, string[]> {
612
+ function componentVariants(json: unknown): Pick<Loaded, 'variants' | 'optional'> {
610
613
  const components = (json as { components?: Record<string, unknown> } | null)?.components;
611
- if (!components) return {};
612
- const out: Record<string, string[]> = {};
614
+ if (!components) return { variants: {}, optional: [] };
615
+ const variants: Record<string, string[]> = {};
616
+ const optional: string[] = [];
613
617
  for (const [name, spec] of Object.entries(components)) {
614
- const variants = (spec as { variants?: Record<string, unknown> } | null)?.variants;
615
- if (variants) out[name] = Object.keys(variants);
618
+ // Alias components (`extends`) declare no variants of their own — skipped,
619
+ // like every other component without a variants map.
620
+ const s = spec as { variants?: Record<string, unknown>; probability?: number } | null;
621
+ if (!s?.variants) continue;
622
+ variants[name] = Object.keys(s.variants);
623
+ if (typeof s.probability === 'number' && s.probability < 100) optional.push(name);
616
624
  }
617
- return out;
625
+ return { variants, optional };
618
626
  }
619
627
 
620
628
  // `Style` parses and validates the JSON once; building one per render would
@@ -655,7 +663,7 @@ export function loadAvatarStyle(id: string | null | undefined): Promise<Loaded>
655
663
  const loaded: Loaded = {
656
664
  style: new Style(json as ConstructorParameters<typeof Style>[0]),
657
665
  tintGroups: tintableGroups(json),
658
- variants: componentVariants(json),
666
+ ...componentVariants(json),
659
667
  };
660
668
  STYLES.set(key, loaded);
661
669
  INFLIGHT.delete(key);
@@ -681,11 +689,23 @@ function hexOnly(colors: readonly string[] | undefined): string[] | undefined {
681
689
  return ok.length ? ok : undefined;
682
690
  }
683
691
 
692
+ /**
693
+ * Explicit per-component choices layered over the seed: component name → the
694
+ * variant to pin, or `null` to hide an OPTIONAL component. Components the map
695
+ * does not name keep their seed-picked look, so a stored choice set stays
696
+ * valid as the user re-rolls the seed underneath it. Unknown components and
697
+ * variants are DROPPED, not rejected — a choice saved under one brain style
698
+ * must never make an avatar throw after the brain switches styles.
699
+ */
700
+ export type AvatarParts = Record<string, string | null>;
701
+
684
702
  export type RenderAvatarOptions = {
685
703
  /** Stored style id; legacy and unknown ids are resolved, not rejected. */
686
704
  style?: string | null;
687
705
  /** Stable per-entity seed — the same seed always yields the same avatar. */
688
706
  seed: string;
707
+ /** Explicit component choices layered over the seed; see {@link AvatarParts}. */
708
+ parts?: AvatarParts | null;
689
709
  /** Rendered px. Sets the root svg width/height; the viewBox scales. */
690
710
  size?: number;
691
711
  /** The theme's chart ramp, as hex. Ignored when tint is `native`. */
@@ -694,7 +714,7 @@ export type RenderAvatarOptions = {
694
714
  tint?: AvatarTint;
695
715
  };
696
716
 
697
- function draw(loaded: Loaded, { seed, size = 40, ramp, tint }: RenderAvatarOptions): string {
717
+ function draw(loaded: Loaded, { seed, size = 40, ramp, tint, parts }: RenderAvatarOptions): string {
698
718
  const colors = hexOnly(ramp);
699
719
  const opts: Record<string, unknown> = { seed: seed || 'mantle', size };
700
720
  const mode = resolveAvatarTint(tint);
@@ -707,6 +727,25 @@ function draw(loaded: Loaded, { seed, size = 40, ramp, tint }: RenderAvatarOptio
707
727
  for (const g of loaded.tintGroups) opts[`${g}Color`] = colors;
708
728
  }
709
729
  }
730
+ if (parts) {
731
+ for (const [component, variant] of Object.entries(parts)) {
732
+ // DiceBear throws on options for components the style does not declare,
733
+ // so only choices the loaded style recognises may pass (see the note on
734
+ // componentVariants). Everything else is a stale choice — ignore it.
735
+ const known = loaded.variants[component];
736
+ if (!known) continue;
737
+ if (variant === null) {
738
+ // "Hide it" — only meaningful for components the style may omit.
739
+ if (loaded.optional.includes(component)) opts[`${component}Probability`] = 0;
740
+ continue;
741
+ }
742
+ if (!known.includes(variant)) continue;
743
+ opts[`${component}Variant`] = variant;
744
+ // A pinned variant must actually show: probability rolls independently
745
+ // of variant choice, so an optional component needs the 100 as well.
746
+ if (loaded.optional.includes(component)) opts[`${component}Probability`] = 100;
747
+ }
748
+ }
710
749
  return new Avatar(loaded.style, opts).toString();
711
750
  }
712
751