@usenavii/core 0.2.0 → 0.3.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/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # @usenavii/core
2
+
3
+ **Deterministic mascot avatars from a seed.** Pure TypeScript engine. Same seed in → byte-identical SVG out, every time. No state, no uploads, no network.
4
+
5
+ <p align="center">
6
+ <img src="https://navii-api.uxderrick.com/group?seeds=aria,milo,nova,kai,sage,eden,luna,rio,pip,wren,zane,iris&size=72&overlap=0.32&ring=%230a0a0b&tileBg=%23ffffff" alt="Navii cast" />
7
+ </p>
8
+
9
+ - [Live demo](https://navii.uxderrick.com) — interactive playground + cast
10
+ - [Docs](https://navii.uxderrick.com/docs)
11
+ - [GitHub](https://github.com/uxderrick/navii)
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ npm add @usenavii/core
17
+ pnpm add @usenavii/core
18
+ yarn add @usenavii/core
19
+ bun add @usenavii/core
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ```ts
25
+ import { createAvatar } from '@usenavii/core';
26
+
27
+ const svg = createAvatar(user.id, { size: 96 });
28
+ document.body.insertAdjacentHTML('beforeend', svg);
29
+ ```
30
+
31
+ Or use the namespace bundle:
32
+
33
+ ```ts
34
+ import { Navii } from '@usenavii/core';
35
+
36
+ Navii.create(user.id);
37
+ Navii.seed({ id: user.id, email: user.email, name: user.name });
38
+ Navii.build({ body: 'tall', eyes: 'star', palette: 'violet' });
39
+ Navii.group([user1.id, user2.id, user3.id]);
40
+ ```
41
+
42
+ ## The seed: read this once
43
+
44
+ Same seed always produces the same avatar — that's the contract.
45
+
46
+ | Pass | Result |
47
+ | -------------------------- | ------------------------------------------------------- |
48
+ | `user.id` / UUID | ✅ Best. Stable and globally unique. |
49
+ | `user.email` | ✅ Good. Stable, unique per user. |
50
+ | `user.name` alone | ⚠️ Names collide. Two "Alice"s get the same avatar. |
51
+ | `${name}-${createdAt}` | ✅ Fine fallback if no ID exists. Bake at signup. |
52
+ | `Date.now()` at render | ❌ **Don't.** Breaks determinism — changes on reload. |
53
+
54
+ If your shape is uncertain, use the helper:
55
+
56
+ ```ts
57
+ const s = Navii.seed({ id: user.id, email: user.email, name: user.name, createdAt: user.createdAt });
58
+ const svg = Navii.create(s);
59
+ ```
60
+
61
+ It picks the most unique field automatically: `id` → `email` → `name + createdAt` → `name`.
62
+
63
+ ## API
64
+
65
+ ```ts
66
+ createAvatar(seed: string, options?: AvatarOptions): string
67
+ selectAvatar(seed: string, options?: AvatarOptions): AvatarSpec
68
+ renderAvatar(spec: AvatarSpec, options?: AvatarOptions): string
69
+ renderGroup(seeds: string[], options?: GroupOptions): string
70
+
71
+ seed(fields: SeedFields): string // pick most-unique field
72
+ build(spec?: BuildSpec, opts?): string // manual mix-and-match (no seed)
73
+
74
+ Navii.{ create, render, select, group, seed, build }
75
+ ```
76
+
77
+ ### `AvatarOptions`
78
+
79
+ | Option | Type | Default |
80
+ | ------------ | ----------------------------------------------------- | ------------ |
81
+ | `size` | `number` (px) | `96` |
82
+ | `paletteId` | known palette id (e.g. `'mint'`) | seed-derived |
83
+ | `background` | `'none' \| 'solid' \| 'ring'` or `{ color }` | seed-derived |
84
+ | `tileBg` | CSS color or `'auto'` (palette accent) | none |
85
+ | `title` | accessible label (sets `<title>` + `aria-label`) | none |
86
+ | `animated` | `boolean` — idle float / blink / sway / pulse / twinkle | `false` |
87
+
88
+ ### `build()` — direct construction without a seed
89
+
90
+ Use for brand mascots, logo marks, designer-curated avatars:
91
+
92
+ ```ts
93
+ const svg = Navii.build({
94
+ body: 'tall', eyes: 'star', mouth: 'grin',
95
+ palette: 'violet', topper: 'crown',
96
+ }, { size: 192, animated: true });
97
+ ```
98
+
99
+ Any field omitted falls back to the first variant.
100
+
101
+ ## Determinism guarantee
102
+
103
+ `createAvatar(seed)` is a pure function. Same seed + same options → byte-identical SVG.
104
+
105
+ - PRNG: `sfc32` seeded from a `cyrb53` hash of the seed string.
106
+ - Part picks happen in a fixed order. New parts are appended to the end of the stream, so adding variants in future releases never shifts existing seeds.
107
+ - No `Date.now()`, no `Math.random()`, no module-level state, no environment lookups.
108
+
109
+ Render the same avatar in Node, in the browser, on the edge — all byte-identical.
110
+
111
+ ## Cast (output space)
112
+
113
+ 22 palettes × 8 bodies × 10 eyes × 10 mouths × 5 antennae × 7 accessories × 3 backgrounds × 12 toppers = **22,176,000** discrete combinations. Plus continuous tweaks (hue rotation ±30°, body scale ±8%, eye gap ±2, mouth curvature ±15%, antenna tilt ±8°) → effectively unbounded.
114
+
115
+ ## License
116
+
117
+ MIT. See [LICENSE](https://github.com/uxderrick/navii/blob/main/LICENSE).
package/dist/index.cjs CHANGED
@@ -559,6 +559,76 @@ function renderTopper(id, anchor, palette) {
559
559
  }
560
560
  }
561
561
 
562
+ // src/parts/outfit.ts
563
+ function renderOutfit(id, anchor, palette) {
564
+ if (id === "none") return "";
565
+ const cx = anchor.cx;
566
+ const cy = anchor.mouthY + (anchor.groundY - anchor.mouthY) * 0.55;
567
+ const ink = palette.ink;
568
+ const accent = palette.accent;
569
+ switch (id) {
570
+ case "collar":
571
+ return [
572
+ `<path d="M${cx - 9} ${cy} L${cx - 2} ${cy - 4} L${cx - 2} ${cy + 5} Z" fill="${accent}" stroke="${ink}" stroke-width="0.7" />`,
573
+ `<path d="M${cx + 9} ${cy} L${cx + 2} ${cy - 4} L${cx + 2} ${cy + 5} Z" fill="${accent}" stroke="${ink}" stroke-width="0.7" />`,
574
+ // tiny button at center
575
+ `<circle cx="${cx}" cy="${cy + 4}" r="0.9" fill="${ink}" />`
576
+ ].join("");
577
+ case "scarf":
578
+ return [
579
+ // wrap band
580
+ `<path d="M${cx - 14} ${cy - 2} Q${cx} ${cy + 3} ${cx + 14} ${cy - 2} L${cx + 14} ${cy + 3} Q${cx} ${cy + 8} ${cx - 14} ${cy + 3} Z" fill="${palette.bodyTo}" stroke="${ink}" stroke-width="0.6" />`,
581
+ // tail 1 (left)
582
+ `<path d="M${cx - 6} ${cy + 5} L${cx - 9} ${cy + 12} L${cx - 4} ${cy + 12} L${cx - 2} ${cy + 5} Z" fill="${palette.bodyTo}" stroke="${ink}" stroke-width="0.5" />`,
583
+ // tail 2 (slightly right)
584
+ `<path d="M${cx + 1} ${cy + 6} L${cx + 4} ${cy + 13} L${cx - 1} ${cy + 13} L${cx - 2} ${cy + 6} Z" fill="${palette.bodyFrom}" stroke="${ink}" stroke-width="0.5" />`
585
+ ].join("");
586
+ case "bowtie":
587
+ return [
588
+ // left wing
589
+ `<path d="M${cx - 1} ${cy} L${cx - 9} ${cy - 4} L${cx - 9} ${cy + 4} Z" fill="${palette.bodyTo}" stroke="${ink}" stroke-width="0.7" />`,
590
+ // right wing
591
+ `<path d="M${cx + 1} ${cy} L${cx + 9} ${cy - 4} L${cx + 9} ${cy + 4} Z" fill="${palette.bodyTo}" stroke="${ink}" stroke-width="0.7" />`,
592
+ // center knot
593
+ `<rect x="${cx - 1.4}" y="${cy - 2.4}" width="2.8" height="4.8" rx="0.8" fill="${palette.bodyFrom}" stroke="${ink}" stroke-width="0.5" />`
594
+ ].join("");
595
+ case "sunflower": {
596
+ const fx = cx - 8;
597
+ const fy = cy + 2;
598
+ const petals = [];
599
+ for (let i = 0; i < 8; i++) {
600
+ const a = i / 8 * Math.PI * 2;
601
+ const px = fx + Math.cos(a) * 3.2;
602
+ const py = fy + Math.sin(a) * 3.2;
603
+ petals.push(
604
+ `<ellipse cx="${px.toFixed(2)}" cy="${py.toFixed(2)}" rx="2.4" ry="1.3" fill="#FACC15" stroke="${ink}" stroke-width="0.35" transform="rotate(${(a * 180 / Math.PI).toFixed(1)} ${px.toFixed(2)} ${py.toFixed(2)})" />`
605
+ );
606
+ }
607
+ return [
608
+ // stem (tucked behind)
609
+ `<path d="M${fx + 2} ${fy + 2} Q${fx + 4} ${fy + 6} ${fx + 1} ${fy + 10}" stroke="#16A34A" stroke-width="1.1" fill="none" stroke-linecap="round" />`,
610
+ // leaf
611
+ `<path d="M${fx + 3} ${fy + 6} Q${fx + 7} ${fy + 4} ${fx + 6} ${fy + 8} Q${fx + 4} ${fy + 8} ${fx + 3} ${fy + 6} Z" fill="#22C55E" stroke="${ink}" stroke-width="0.35" />`,
612
+ ...petals,
613
+ // center disc
614
+ `<circle cx="${fx}" cy="${fy}" r="2" fill="#92400E" stroke="${ink}" stroke-width="0.4" />`,
615
+ // texture dots on disc
616
+ `<circle cx="${fx - 0.6}" cy="${fy - 0.5}" r="0.4" fill="#451A03" />`,
617
+ `<circle cx="${fx + 0.7}" cy="${fy + 0.3}" r="0.4" fill="#451A03" />`,
618
+ `<circle cx="${fx - 0.4}" cy="${fy + 0.8}" r="0.4" fill="#451A03" />`
619
+ ].join("");
620
+ }
621
+ case "necklace":
622
+ return [
623
+ // chain (curve from collarbone left → drop → right)
624
+ `<path d="M${cx - 10} ${cy} Q${cx} ${cy + 8} ${cx + 10} ${cy}" stroke="${accent}" stroke-width="0.8" fill="none" stroke-linecap="round" />`,
625
+ // pendant
626
+ `<circle cx="${cx}" cy="${cy + 7}" r="1.6" fill="${accent}" stroke="${ink}" stroke-width="0.5" />`,
627
+ `<circle cx="${cx}" cy="${cy + 7}" r="0.7" fill="${palette.blush}" />`
628
+ ].join("");
629
+ }
630
+ }
631
+
562
632
  // src/parts/index.ts
563
633
  var BODY_IDS = [
564
634
  "orb",
@@ -657,6 +727,7 @@ function selectAvatar(seed2, options = {}) {
657
727
  accessory,
658
728
  background,
659
729
  topper,
730
+ outfit: "none",
660
731
  hueShift,
661
732
  bodyScale,
662
733
  eyeGapShift,
@@ -740,10 +811,13 @@ function renderAvatarInner(spec, options = {}) {
740
811
  const antennaWrapped = antennaSvg ? `<g${transformAntenna(spec.antennaTilt ?? 0, anchor)}><g class="antenna">${antennaSvg}</g></g>` : "";
741
812
  const tileBg = resolveTileBg(options.tileBg, spec.palette);
742
813
  const tileCircle = tileBg ? `<circle cx="50" cy="50" r="50" fill="${tileBg}" />` : "";
814
+ const outfitSvg = renderOutfit(spec.outfit ?? "none", anchor, spec.palette);
743
815
  const parts = [
744
816
  tileCircle,
745
817
  renderBackground(spec.background, spec.palette, bgOverride),
746
818
  bodyWrapped,
819
+ // outfit sits on the body but below the face, so face features stay readable
820
+ outfitSvg,
747
821
  renderTopper(spec.topper, anchor, spec.palette),
748
822
  wrap("eyes", renderEyes(spec.eyes, spec.palette, anchor)),
749
823
  renderMouth(spec.mouth, spec.palette, anchor, spec.mouthCurveScale ?? 1),
@@ -859,6 +933,7 @@ function build(spec = {}, options = {}) {
859
933
  accessory: spec.accessory ?? "none",
860
934
  background: spec.background ?? "none",
861
935
  topper: spec.topper ?? "none",
936
+ outfit: spec.outfit ?? "none",
862
937
  hueShift: spec.hueShift ?? 0,
863
938
  bodyScale: spec.bodyScale ?? 1,
864
939
  eyeGapShift: spec.eyeGapShift ?? 0,