@atmos.build/ui 0.1.0 → 0.1.1

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.
@@ -62,6 +62,8 @@ export declare class NautAvatarEngine {
62
62
  private readonly face;
63
63
  private targetFace;
64
64
  private readonly idleState;
65
+ /** An expression holds one eye shape forever; idle opens the eyes between beats. */
66
+ private readonly eyeRest;
65
67
  private el;
66
68
  constructor(opts?: NautAvatarOptions);
67
69
  attach(el: NautAvatarElements): void;
@@ -73,6 +75,8 @@ export declare class NautAvatarEngine {
73
75
  settle(): void;
74
76
  update(dt: number): void;
75
77
  private updateIdle;
78
+ /** Eases the eyes back to their neutral open shape and back into the expression. */
79
+ private restEyes;
76
80
  private render;
77
81
  }
78
82
  /** Stable pseudo-random pick so the same id always gets the same appearance. */
@@ -148,6 +148,7 @@ const NEUTRAL_HEAD = {
148
148
  sy: 1,
149
149
  };
150
150
  const eyeKey = (param, side) => `${param}${side}`;
151
+ const EYE_KEYS = EYE_PARAMS.flatMap((param) => [eyeKey(param, 'L'), eyeKey(param, 'R')]);
151
152
  function expandFace(spec) {
152
153
  const out = {};
153
154
  for (const [k, v] of Object.entries(spec)) {
@@ -223,6 +224,7 @@ const EXPRESSIONS = {
223
224
  },
224
225
  },
225
226
  sleepy: {
227
+ fixedEyes: true,
226
228
  face: { topCut: 0.55, botCut: 0, round: 0.6, gazeY: 0.25, headY: 3 },
227
229
  loop: (t, o) => {
228
230
  const s = Math.max(0, Math.sin(t * 1.2));
@@ -240,6 +242,7 @@ const EXPRESSIONS = {
240
242
  },
241
243
  },
242
244
  error: {
245
+ fixedEyes: true,
243
246
  face: { cross: 1, w: 15, h: 15, pill: 0, round: 0.2 },
244
247
  loop: (t, o) => {
245
248
  o.headX += Math.sin(t * 90) * Math.exp(-(t % 2.4) * 3) * 3;
@@ -252,7 +255,10 @@ const EXPRESSIONS = {
252
255
  o.roll += Math.sin(t * 0.7) * 3;
253
256
  },
254
257
  },
255
- wink: { face: { topCutR: 0.5, botCutR: 0.44, smileL: 0.35, round: 0.6, roll: -3 } },
258
+ wink: {
259
+ fixedEyes: true,
260
+ face: { topCutR: 0.5, botCutR: 0.44, smileL: 0.35, round: 0.6, roll: -3 },
261
+ },
256
262
  focused: {
257
263
  face: { topCut: 0.28, botCut: 0.22, round: 0.25, w: 8.5, h: 17 },
258
264
  loop: (t, o) => {
@@ -276,6 +282,7 @@ const EXPRESSIONS = {
276
282
  },
277
283
  smug: { face: { topCut: 0.42, topSlant: -8, round: 0.3, gazeX: 0.45, roll: -4, smile: 0.25 } },
278
284
  talking: {
285
+ fixedEyes: true,
279
286
  face: {},
280
287
  loop: (t, o) => {
281
288
  const s = Math.abs(Math.sin(t * 9)) * 0.55 + Math.abs(Math.sin(t * 5.3)) * 0.45;
@@ -286,6 +293,7 @@ const EXPRESSIONS = {
286
293
  },
287
294
  },
288
295
  loading: {
296
+ fixedEyes: true,
289
297
  face: { w: 7, h: 7, round: 1 },
290
298
  loop: (t, o) => {
291
299
  o.gazeX += Math.cos(t * 3) * 0.75;
@@ -599,6 +607,11 @@ function nodePath(nodes) {
599
607
  }
600
608
  const cloneNodes = (nodes) => nodes.map((n) => [n[0], n[1], n[2], n[3], n[4], n[5]]);
601
609
  export const NAUT_VIEWBOX = '-126 -126 252 252';
610
+ /** How long a mood wears its own eye shape before the face rests, and how long it rests. */
611
+ const HOLD_EXPRESSION_S = 3.4;
612
+ const HOLD_EXPRESSION_JITTER_S = 2.6;
613
+ const OPEN_EYES_S = 1.8;
614
+ const OPEN_EYES_JITTER_S = 1.4;
602
615
  /**
603
616
  * Framework-agnostic renderer: owns the animation state and writes attributes onto the
604
617
  * SVG elements it is given. Call `update(dt)` once per frame.
@@ -630,6 +643,8 @@ export class NautAvatarEngine {
630
643
  curGazeY: 0,
631
644
  curRoll: 0,
632
645
  };
646
+ /** An expression holds one eye shape forever; idle opens the eyes between beats. */
647
+ eyeRest = { open: 0, cur: 0, next: HOLD_EXPRESSION_S };
633
648
  el = null;
634
649
  constructor(opts = {}) {
635
650
  this.rand = rng(opts.seed ?? 1);
@@ -639,6 +654,7 @@ export class NautAvatarEngine {
639
654
  this.idle = opts.idle ?? true;
640
655
  this.time = this.rand() * 100;
641
656
  this.idleState.nextBlink = 2 + this.rand() * 3;
657
+ this.eyeRest.next = HOLD_EXPRESSION_S + this.rand() * HOLD_EXPRESSION_JITTER_S;
642
658
  this.nodes = cloneNodes(SHAPES[this.shape].nodes);
643
659
  this.sphere = SHAPES[this.shape].sphere;
644
660
  this.eyeYOff = SHAPES[this.shape].eyeY;
@@ -662,8 +678,14 @@ export class NautAvatarEngine {
662
678
  if (changed || instant)
663
679
  this.emotionTime = 0;
664
680
  this.targetFace = { ...NEUTRAL, ...expandFace(def.face) };
665
- if (instant)
681
+ if (changed || instant) {
682
+ this.eyeRest.open = 0;
683
+ this.eyeRest.next = HOLD_EXPRESSION_S + this.rand() * HOLD_EXPRESSION_JITTER_S;
684
+ }
685
+ if (instant) {
666
686
  Object.assign(this.face, this.targetFace);
687
+ this.eyeRest.cur = 0;
688
+ }
667
689
  if (def.enter && !instant && changed)
668
690
  this.play(def.enter);
669
691
  }
@@ -673,6 +695,8 @@ export class NautAvatarEngine {
673
695
  /** Snap every tween to its target (used for static renders / reduced motion). */
674
696
  settle() {
675
697
  Object.assign(this.face, this.targetFace);
698
+ this.eyeRest.open = 0;
699
+ this.eyeRest.cur = 0;
676
700
  this.nodes = cloneNodes(SHAPES[this.shape].nodes);
677
701
  this.sphere = SHAPES[this.shape].sphere;
678
702
  this.eyeYOff = SHAPES[this.shape].eyeY;
@@ -697,6 +721,7 @@ export class NautAvatarEngine {
697
721
  if (def.loop)
698
722
  def.loop(this.emotionTime, o);
699
723
  this.updateIdle(dt, o);
724
+ this.restEyes(dt, o, def);
700
725
  for (const g of this.gestures) {
701
726
  g.t += dt / GESTURES[g.key].dur;
702
727
  GESTURES[g.key].fn(clamp(g.t, 0, 1), o);
@@ -747,6 +772,26 @@ export class NautAvatarEngine {
747
772
  o.gazeY += s.curGazeY;
748
773
  o.roll += s.curRoll;
749
774
  }
775
+ /** Eases the eyes back to their neutral open shape and back into the expression. */
776
+ restEyes(dt, o, def) {
777
+ const rest = this.eyeRest;
778
+ if (this.idle && !def.fixedEyes) {
779
+ rest.next -= dt;
780
+ if (rest.next <= 0) {
781
+ rest.open = rest.open > 0.5 ? 0 : 1;
782
+ rest.next = rest.open
783
+ ? OPEN_EYES_S + this.rand() * OPEN_EYES_JITTER_S
784
+ : HOLD_EXPRESSION_S + this.rand() * HOLD_EXPRESSION_JITTER_S;
785
+ }
786
+ }
787
+ else
788
+ rest.open = 0;
789
+ rest.cur = approach(rest.cur, rest.open, 3, dt);
790
+ if (rest.cur < 0.002)
791
+ return;
792
+ for (const k of EYE_KEYS)
793
+ o[k] = lerp(o[k], NEUTRAL[k], rest.cur);
794
+ }
750
795
  render(o) {
751
796
  const el = this.el;
752
797
  if (!el)
@@ -17,6 +17,8 @@ export interface NautAvatarProps extends Omit<React.SVGProps<SVGSVGElement>, 're
17
17
  x: number;
18
18
  y: number;
19
19
  } | null;
20
+ /** Glance at a mouse that comes near the face. Ignored once `gaze` is given. */
21
+ followPointer?: boolean;
20
22
  /** Any stable id — keeps a list of Nauts from blinking in unison. */
21
23
  seed?: string | number;
22
24
  }
@@ -4,7 +4,62 @@ import * as React from 'react';
4
4
  import { useReducedMotion } from 'motion/react';
5
5
  import { cn } from '../lib/cn.js';
6
6
  import { NAUT_VIEWBOX, NautAvatarEngine, hashSeed, } from './naut-avatar-engine.js';
7
+ import { pointerGaze } from './naut-pointer-gaze.js';
7
8
  export { NAUT_COLORS, NAUT_COLOR_KEYS, NAUT_EXPRESSIONS, NAUT_GESTURES, NAUT_SHAPES, isNautColor, isNautShape, nautAppearanceForSeed, } from './naut-avatar-engine.js';
9
+ /** A face can be carried across the page by something other than a mouse or a scroll. */
10
+ const RECT_MAX_AGE_MS = 500;
11
+ /** Where the mouse is, shared by every face so one listener serves the page. */
12
+ const pointer = {
13
+ x: 0,
14
+ y: 0,
15
+ active: false,
16
+ /** Bumps whenever cached face rects could have gone stale. */
17
+ moved: 0,
18
+ onPointer(event) {
19
+ if (event.pointerType === 'touch') {
20
+ pointer.active = false;
21
+ return;
22
+ }
23
+ pointer.x = event.clientX;
24
+ pointer.y = event.clientY;
25
+ pointer.active = true;
26
+ pointer.moved++;
27
+ },
28
+ onLeave() {
29
+ pointer.active = false;
30
+ },
31
+ onLayout() {
32
+ pointer.moved++;
33
+ },
34
+ listen() {
35
+ window.addEventListener('pointermove', pointer.onPointer, { passive: true });
36
+ window.addEventListener('pointerdown', pointer.onPointer, { passive: true });
37
+ document.addEventListener('mouseleave', pointer.onLeave);
38
+ window.addEventListener('scroll', pointer.onLayout, { passive: true, capture: true });
39
+ window.addEventListener('resize', pointer.onLayout, { passive: true });
40
+ },
41
+ stop() {
42
+ window.removeEventListener('pointermove', pointer.onPointer);
43
+ window.removeEventListener('pointerdown', pointer.onPointer);
44
+ document.removeEventListener('mouseleave', pointer.onLeave);
45
+ window.removeEventListener('scroll', pointer.onLayout, { capture: true });
46
+ window.removeEventListener('resize', pointer.onLayout);
47
+ pointer.active = false;
48
+ },
49
+ };
50
+ function aimAtPointer(entry, now) {
51
+ const svg = entry.svg;
52
+ if (!pointer.active || !svg) {
53
+ entry.engine.gazeTarget = null;
54
+ return;
55
+ }
56
+ if (entry.rectAt !== pointer.moved || now - entry.rectTime > RECT_MAX_AGE_MS) {
57
+ entry.rect = svg.getBoundingClientRect();
58
+ entry.rectAt = pointer.moved;
59
+ entry.rectTime = now;
60
+ }
61
+ entry.engine.gazeTarget = entry.rect ? pointerGaze(entry.rect, pointer.x, pointer.y) : null;
62
+ }
8
63
  /** One animation frame loop drives every mounted avatar; offscreen ones sit out. */
9
64
  const ticker = {
10
65
  entries: new Set(),
@@ -13,12 +68,17 @@ const ticker = {
13
68
  tick(now) {
14
69
  const dt = Math.min((now - ticker.last) / 1000, 0.05);
15
70
  ticker.last = now;
71
+ for (const entry of ticker.entries)
72
+ if (entry.visible && entry.follow)
73
+ aimAtPointer(entry, now);
16
74
  for (const entry of ticker.entries)
17
75
  if (entry.visible)
18
76
  entry.engine.update(dt);
19
77
  ticker.frame = ticker.entries.size ? requestAnimationFrame(ticker.tick) : 0;
20
78
  },
21
79
  add(entry) {
80
+ if (!ticker.entries.size)
81
+ pointer.listen();
22
82
  ticker.entries.add(entry);
23
83
  if (!ticker.frame) {
24
84
  ticker.last = performance.now();
@@ -27,9 +87,12 @@ const ticker = {
27
87
  },
28
88
  remove(entry) {
29
89
  ticker.entries.delete(entry);
30
- if (!ticker.entries.size && ticker.frame) {
31
- cancelAnimationFrame(ticker.frame);
32
- ticker.frame = 0;
90
+ if (!ticker.entries.size) {
91
+ pointer.stop();
92
+ if (ticker.frame) {
93
+ cancelAnimationFrame(ticker.frame);
94
+ ticker.frame = 0;
95
+ }
33
96
  }
34
97
  },
35
98
  };
@@ -39,7 +102,7 @@ const seedNumber = (seed) => typeof seed === 'number' ? seed : hashSeed(seed ??
39
102
  * look around. The eyes are holes, so set `--naut-avatar-eye` to the surface the
40
103
  * avatar sits on (defaults to `--background`).
41
104
  */
42
- export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle', color = 'ink', expression = 'neutral', idle = true, gaze = null, seed, className, style, ...props }, forwardedRef) {
105
+ export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle', color = 'ink', expression = 'neutral', idle = true, gaze = null, followPointer = true, seed, className, style, ...props }, forwardedRef) {
43
106
  const reducedMotion = useReducedMotion();
44
107
  const animated = idle && !reducedMotion;
45
108
  const svgRef = React.useRef(null);
@@ -49,6 +112,15 @@ export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle
49
112
  const eyeLRef = React.useRef(null);
50
113
  const eyeRRef = React.useRef(null);
51
114
  const [engine] = React.useState(() => new NautAvatarEngine({ shape, color, expression, seed: seedNumber(seed), idle: animated }));
115
+ const [entry] = React.useState(() => ({
116
+ engine,
117
+ svg: null,
118
+ visible: true,
119
+ follow: false,
120
+ rect: null,
121
+ rectAt: -1,
122
+ rectTime: 0,
123
+ }));
52
124
  React.useImperativeHandle(forwardedRef, () => ({ play: (gesture) => engine.play(gesture) }), [
53
125
  engine,
54
126
  ]);
@@ -58,7 +130,8 @@ export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle
58
130
  engine.setExpression(expression);
59
131
  engine.idle = animated;
60
132
  engine.gazeTarget = gaze;
61
- }, [engine, shape, color, expression, animated, gaze]);
133
+ entry.follow = followPointer && !gaze && animated;
134
+ }, [engine, entry, shape, color, expression, animated, gaze, followPointer]);
62
135
  React.useLayoutEffect(() => {
63
136
  if (!rootRef.current ||
64
137
  !bodyRef.current ||
@@ -82,9 +155,11 @@ export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle
82
155
  engine.update(0);
83
156
  return;
84
157
  }
85
- const entry = { engine, visible: true };
86
- ticker.add(entry);
87
158
  const svg = svgRef.current;
159
+ entry.svg = svg;
160
+ entry.rect = null;
161
+ entry.rectAt = -1;
162
+ ticker.add(entry);
88
163
  const observer = svg && typeof IntersectionObserver !== 'undefined'
89
164
  ? new IntersectionObserver((records) => {
90
165
  for (const record of records)
@@ -96,8 +171,9 @@ export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle
96
171
  return () => {
97
172
  observer?.disconnect();
98
173
  ticker.remove(entry);
174
+ entry.svg = null;
99
175
  };
100
- }, [engine, reducedMotion]);
176
+ }, [engine, entry, reducedMotion]);
101
177
  React.useEffect(() => {
102
178
  if (!reducedMotion)
103
179
  return;
@@ -0,0 +1,16 @@
1
+ export interface FaceBox {
2
+ left: number;
3
+ top: number;
4
+ width: number;
5
+ height: number;
6
+ }
7
+ /**
8
+ * Where a face should look to notice a mouse at (x, y), in the same [-1, 1] gaze the
9
+ * avatar takes elsewhere. Null once the pointer is out of reach, which hands the eyes
10
+ * back to their idle wander. Reach scales with the face, so a 16px Naut in a list only
11
+ * looks up for a cursor beside it while a hero-sized one watches across a column.
12
+ */
13
+ export declare function pointerGaze(box: FaceBox, x: number, y: number): {
14
+ x: number;
15
+ y: number;
16
+ } | null;
@@ -0,0 +1,31 @@
1
+ /** How far from a face a mouse still counts as near, in face radii. */
2
+ const REACH = 6;
3
+ /** Inside this share of the reach the face gives the pointer its full attention. */
4
+ const FULL = 0.45;
5
+ /** Pointer offset, in face radii, that deflects the eyes all the way. */
6
+ const SPAN = 3;
7
+ /** A glance, not a lock: the eyes stop short of their extreme. */
8
+ const WEIGHT = 0.85;
9
+ const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
10
+ const smoothstep = (v) => v * v * (3 - 2 * v);
11
+ const deflect = (offset, span) => Math.max(-1, Math.min(1, offset / span));
12
+ /**
13
+ * Where a face should look to notice a mouse at (x, y), in the same [-1, 1] gaze the
14
+ * avatar takes elsewhere. Null once the pointer is out of reach, which hands the eyes
15
+ * back to their idle wander. Reach scales with the face, so a 16px Naut in a list only
16
+ * looks up for a cursor beside it while a hero-sized one watches across a column.
17
+ */
18
+ export function pointerGaze(box, x, y) {
19
+ if (!box.width || !box.height)
20
+ return null;
21
+ const radius = Math.max(box.width, box.height) / 2;
22
+ const dx = x - (box.left + box.width / 2);
23
+ const dy = y - (box.top + box.height / 2);
24
+ const reach = radius * REACH;
25
+ const distance = Math.hypot(dx, dy);
26
+ if (distance > reach)
27
+ return null;
28
+ const attention = WEIGHT * smoothstep(clamp01((reach - distance) / (reach * (1 - FULL))));
29
+ const span = radius * SPAN;
30
+ return { x: deflect(dx, span) * attention, y: deflect(dy, span) * attention };
31
+ }
@@ -0,0 +1,136 @@
1
+ import * as React from 'react';
2
+ import type { NautColor, NautExpression, NautGesture, NautShape } from './naut-avatar-engine.js';
3
+ export interface NautSceneFace {
4
+ shape: NautShape;
5
+ color: NautColor;
6
+ expression: NautExpression;
7
+ /** Share of the scene's height the face stands as; smaller reads as further off. */
8
+ size: number;
9
+ /** Where the face floats, as a share of the scene box from its top left. */
10
+ x: number;
11
+ y: number;
12
+ /** Seconds one drift takes; slower reads as further off. */
13
+ drift: number;
14
+ }
15
+ export interface NautSceneDef {
16
+ faces: readonly NautSceneFace[];
17
+ /** Run across the whole scene on a loop, one face after the next. */
18
+ gesture: NautGesture;
19
+ /** Seconds between one run of the gesture and the next. */
20
+ interval: number;
21
+ }
22
+ /**
23
+ * The illustrations a page reaches for, drawn from the same faces the product
24
+ * uses everywhere else — so an empty state, a 404 and a chat header are all
25
+ * recognisably the same Nauts rather than a separate cast of stock art.
26
+ */
27
+ export declare const NAUT_SCENES: {
28
+ readonly adrift: {
29
+ readonly faces: readonly [{
30
+ readonly shape: "circle";
31
+ readonly color: "sun";
32
+ readonly expression: "confused";
33
+ readonly size: 0.62;
34
+ readonly x: 0.4;
35
+ readonly y: 0.4;
36
+ readonly drift: 7;
37
+ }, {
38
+ readonly shape: "triangle";
39
+ readonly color: "mint";
40
+ readonly expression: "surprised";
41
+ readonly size: 0.3;
42
+ readonly x: 0.76;
43
+ readonly y: 0.68;
44
+ readonly drift: 9;
45
+ }, {
46
+ readonly shape: "diamond";
47
+ readonly color: "lavender";
48
+ readonly expression: "sleepy";
49
+ readonly size: 0.2;
50
+ readonly x: 0.105;
51
+ readonly y: 0.72;
52
+ readonly drift: 11;
53
+ }];
54
+ readonly gesture: "lookAround";
55
+ readonly interval: 6;
56
+ };
57
+ readonly greeting: {
58
+ readonly faces: readonly [{
59
+ readonly shape: "circle";
60
+ readonly color: "sun";
61
+ readonly expression: "happy";
62
+ readonly size: 0.56;
63
+ readonly x: 0.4;
64
+ readonly y: 0.42;
65
+ readonly drift: 6;
66
+ }, {
67
+ readonly shape: "hexagon";
68
+ readonly color: "sky";
69
+ readonly expression: "excited";
70
+ readonly size: 0.34;
71
+ readonly x: 0.735;
72
+ readonly y: 0.56;
73
+ readonly drift: 7.5;
74
+ }, {
75
+ readonly shape: "squircle";
76
+ readonly color: "coral";
77
+ readonly expression: "happy";
78
+ readonly size: 0.22;
79
+ readonly x: 0.1;
80
+ readonly y: 0.62;
81
+ readonly drift: 9;
82
+ }];
83
+ readonly gesture: "bounce";
84
+ readonly interval: 5;
85
+ };
86
+ readonly crew: {
87
+ readonly faces: readonly [{
88
+ readonly shape: "squircle";
89
+ readonly color: "coral";
90
+ readonly expression: "happy";
91
+ readonly size: 0.38;
92
+ readonly x: 0.17;
93
+ readonly y: 0.56;
94
+ readonly drift: 8;
95
+ }, {
96
+ readonly shape: "circle";
97
+ readonly color: "sun";
98
+ readonly expression: "focused";
99
+ readonly size: 0.52;
100
+ readonly x: 0.5;
101
+ readonly y: 0.46;
102
+ readonly drift: 6.5;
103
+ }, {
104
+ readonly shape: "hexagon";
105
+ readonly color: "sky";
106
+ readonly expression: "thinking";
107
+ readonly size: 0.38;
108
+ readonly x: 0.83;
109
+ readonly y: 0.56;
110
+ readonly drift: 9.5;
111
+ }];
112
+ readonly gesture: "nod";
113
+ readonly interval: 6;
114
+ };
115
+ readonly alone: {
116
+ readonly faces: readonly [{
117
+ readonly shape: "circle";
118
+ readonly color: "sun";
119
+ readonly expression: "neutral";
120
+ readonly size: 0.86;
121
+ readonly x: 0.5;
122
+ readonly y: 0.5;
123
+ readonly drift: 7;
124
+ }];
125
+ readonly gesture: "blink";
126
+ readonly interval: 7;
127
+ };
128
+ };
129
+ export type NautSceneName = keyof typeof NAUT_SCENES;
130
+ export interface NautSceneProps {
131
+ scene: NautSceneName;
132
+ /** Sizes the scene; the faces lay themselves out inside whatever box this gives. */
133
+ className?: string;
134
+ }
135
+ /** A small animated illustration built from Naut faces. Decorative — it carries no label. */
136
+ export declare function NautScene({ scene, className }: NautSceneProps): React.ReactElement;
@@ -0,0 +1,168 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import * as React from 'react';
4
+ import { motion, useReducedMotion } from 'motion/react';
5
+ import { cn } from '../lib/cn.js';
6
+ import { NautAvatar } from './naut-avatar.js';
7
+ /**
8
+ * The illustrations a page reaches for, drawn from the same faces the product
9
+ * uses everywhere else — so an empty state, a 404 and a chat header are all
10
+ * recognisably the same Nauts rather than a separate cast of stock art.
11
+ */
12
+ export const NAUT_SCENES = {
13
+ adrift: {
14
+ faces: [
15
+ {
16
+ shape: 'circle',
17
+ color: 'sun',
18
+ expression: 'confused',
19
+ size: 0.62,
20
+ x: 0.4,
21
+ y: 0.4,
22
+ drift: 7,
23
+ },
24
+ {
25
+ shape: 'triangle',
26
+ color: 'mint',
27
+ expression: 'surprised',
28
+ size: 0.3,
29
+ x: 0.76,
30
+ y: 0.68,
31
+ drift: 9,
32
+ },
33
+ {
34
+ shape: 'diamond',
35
+ color: 'lavender',
36
+ expression: 'sleepy',
37
+ size: 0.2,
38
+ x: 0.105,
39
+ y: 0.72,
40
+ drift: 11,
41
+ },
42
+ ],
43
+ gesture: 'lookAround',
44
+ interval: 6,
45
+ },
46
+ greeting: {
47
+ faces: [
48
+ { shape: 'circle', color: 'sun', expression: 'happy', size: 0.56, x: 0.4, y: 0.42, drift: 6 },
49
+ {
50
+ shape: 'hexagon',
51
+ color: 'sky',
52
+ expression: 'excited',
53
+ size: 0.34,
54
+ x: 0.735,
55
+ y: 0.56,
56
+ drift: 7.5,
57
+ },
58
+ {
59
+ shape: 'squircle',
60
+ color: 'coral',
61
+ expression: 'happy',
62
+ size: 0.22,
63
+ x: 0.1,
64
+ y: 0.62,
65
+ drift: 9,
66
+ },
67
+ ],
68
+ gesture: 'bounce',
69
+ interval: 5,
70
+ },
71
+ crew: {
72
+ faces: [
73
+ {
74
+ shape: 'squircle',
75
+ color: 'coral',
76
+ expression: 'happy',
77
+ size: 0.38,
78
+ x: 0.17,
79
+ y: 0.56,
80
+ drift: 8,
81
+ },
82
+ {
83
+ shape: 'circle',
84
+ color: 'sun',
85
+ expression: 'focused',
86
+ size: 0.52,
87
+ x: 0.5,
88
+ y: 0.46,
89
+ drift: 6.5,
90
+ },
91
+ {
92
+ shape: 'hexagon',
93
+ color: 'sky',
94
+ expression: 'thinking',
95
+ size: 0.38,
96
+ x: 0.83,
97
+ y: 0.56,
98
+ drift: 9.5,
99
+ },
100
+ ],
101
+ gesture: 'nod',
102
+ interval: 6,
103
+ },
104
+ alone: {
105
+ faces: [
106
+ {
107
+ shape: 'circle',
108
+ color: 'sun',
109
+ expression: 'neutral',
110
+ size: 0.86,
111
+ x: 0.5,
112
+ y: 0.5,
113
+ drift: 7,
114
+ },
115
+ ],
116
+ gesture: 'blink',
117
+ interval: 7,
118
+ },
119
+ };
120
+ /** Milliseconds between one face taking up the gesture and the next. */
121
+ const GESTURE_STAGGER_MS = 180;
122
+ /** Share of its own height a face wanders up and down. */
123
+ const DRIFT_TRAVEL = 9;
124
+ /** Degrees a face tips as it drifts. */
125
+ const DRIFT_TILT = 4;
126
+ /** Seconds between the starts of neighbouring drifts, so nothing moves in unison. */
127
+ const DRIFT_OFFSET = 0.7;
128
+ /** A small animated illustration built from Naut faces. Decorative — it carries no label. */
129
+ export function NautScene({ scene, className }) {
130
+ const reducedMotion = useReducedMotion();
131
+ const { faces, gesture, interval } = NAUT_SCENES[scene];
132
+ const handles = React.useRef([]);
133
+ React.useEffect(() => {
134
+ if (reducedMotion)
135
+ return;
136
+ const pending = [];
137
+ const run = () => {
138
+ handles.current.forEach((handle, index) => {
139
+ pending.push(window.setTimeout(() => handle?.play(gesture), index * GESTURE_STAGGER_MS));
140
+ });
141
+ };
142
+ const id = window.setInterval(run, interval * 1000);
143
+ return () => {
144
+ window.clearInterval(id);
145
+ for (const timeout of pending)
146
+ window.clearTimeout(timeout);
147
+ };
148
+ }, [gesture, interval, reducedMotion]);
149
+ return (_jsx("div", { "aria-hidden": true, className: cn('flex items-center justify-center', className), children: _jsx("div", { className: "relative aspect-3/2 h-full", children: faces.map((face, index) => (_jsx("span", { className: "absolute block aspect-square", style: {
150
+ left: `${face.x * 100}%`,
151
+ top: `${face.y * 100}%`,
152
+ height: `${face.size * 100}%`,
153
+ transform: 'translate(-50%, -50%)',
154
+ }, children: _jsx(motion.span, { className: "block size-full", animate: reducedMotion
155
+ ? undefined
156
+ : {
157
+ y: [`-${DRIFT_TRAVEL}%`, `${DRIFT_TRAVEL}%`],
158
+ rotate: [-DRIFT_TILT, DRIFT_TILT],
159
+ }, transition: {
160
+ duration: face.drift,
161
+ delay: index * DRIFT_OFFSET,
162
+ repeat: Infinity,
163
+ repeatType: 'mirror',
164
+ ease: 'easeInOut',
165
+ }, children: _jsx(NautAvatar, { ref: (handle) => {
166
+ handles.current[index] = handle;
167
+ }, shape: face.shape, color: face.color, expression: face.expression, seed: `${scene}-${index}` }) }) }, `${face.shape}-${face.color}-${index}`))) }) }));
168
+ }
package/dist/index.d.ts CHANGED
@@ -67,6 +67,7 @@ export { SortableList, SortableItem, SortableRow, DragHandle, DragPreview, useSo
67
67
  export { VoiceLine, type VoiceLineProps, type VoiceLineState } from './components/voice-line.js';
68
68
  export { ShimmerText, type ShimmerTextProps } from './components/shimmer-text.js';
69
69
  export { NautAvatar, NAUT_COLORS, NAUT_COLOR_KEYS, NAUT_EXPRESSIONS, NAUT_GESTURES, NAUT_SHAPES, isNautColor, isNautShape, nautAppearanceForSeed, type NautAppearance, type NautAvatarHandle, type NautAvatarProps, type NautColor, type NautExpression, type NautGesture, type NautShape, } from './components/naut-avatar.js';
70
+ export { NautScene, NAUT_SCENES, type NautSceneDef, type NautSceneFace, type NautSceneName, type NautSceneProps, } from './components/naut-scene.js';
70
71
  export { TranscriptionOverlay, type TranscriptionOverlayLabels, type TranscriptionOverlayProps, type TranscriptionOverlaySelector, type TranscriptionOverlayState, } from './components/transcription-overlay.js';
71
72
  export { TimestampedTranscript, type TimestampedTranscriptLabels, type TimestampedTranscriptProps, type TimestampedTranscriptUtterance, type TimestampedTranscriptWord, } from './components/timestamped-transcript.js';
72
73
  export { MicrophonePriority, type MicrophonePriorityItem, type MicrophonePriorityLabels, type MicrophonePriorityProps, } from './components/microphone-priority.js';
package/dist/index.js CHANGED
@@ -67,6 +67,7 @@ export { SortableList, SortableItem, SortableRow, DragHandle, DragPreview, useSo
67
67
  export { VoiceLine } from './components/voice-line.js';
68
68
  export { ShimmerText } from './components/shimmer-text.js';
69
69
  export { NautAvatar, NAUT_COLORS, NAUT_COLOR_KEYS, NAUT_EXPRESSIONS, NAUT_GESTURES, NAUT_SHAPES, isNautColor, isNautShape, nautAppearanceForSeed, } from './components/naut-avatar.js';
70
+ export { NautScene, NAUT_SCENES, } from './components/naut-scene.js';
70
71
  export { TranscriptionOverlay, } from './components/transcription-overlay.js';
71
72
  export { TimestampedTranscript, } from './components/timestamped-transcript.js';
72
73
  export { MicrophonePriority, } from './components/microphone-priority.js';
@@ -1,7 +1,9 @@
1
- import { type UserConfig } from 'vite';
1
+ import { type Plugin, type UserConfig } from 'vite';
2
2
  export interface AtmosMcpAppViteOptions {
3
3
  root: string;
4
4
  entry?: string;
5
5
  outDir?: string;
6
6
  }
7
7
  export declare function atmosMcpAppViteConfig({ root, entry, outDir, }: AtmosMcpAppViteOptions): UserConfig;
8
+ export declare function atmosMcpAppSourceMetadata(root: string): Plugin;
9
+ export declare function annotateMcpAppReactComponents(code: string, filename: string, sourcePath: string): string;
@@ -1,12 +1,16 @@
1
1
  import tailwindcss from '@tailwindcss/postcss';
2
2
  import react from '@vitejs/plugin-react';
3
3
  import path from 'node:path';
4
+ import ts from 'typescript';
4
5
  import { defineConfig } from 'vite';
6
+ const MCP_APP_SOURCE_METADATA_PROPERTY = '__atmosMcpSource';
7
+ const REACT_COMPONENT_FILE = /\.[jt]sx$/i;
8
+ const NON_PRODUCTION_COMPONENT_FILE = /\.(?:stories|test|spec)\.[cm]?[jt]sx?$/i;
5
9
  export function atmosMcpAppViteConfig({ root, entry = 'src/main.tsx', outDir = '../dist', }) {
6
10
  return defineConfig({
7
11
  root,
8
12
  publicDir: false,
9
- plugins: [react()],
13
+ plugins: [atmosMcpAppSourceMetadata(root), react()],
10
14
  define: { 'process.env.NODE_ENV': JSON.stringify('production') },
11
15
  css: { postcss: { plugins: [tailwindcss()] } },
12
16
  resolve: { dedupe: ['react', 'react-dom', 'react/jsx-runtime'] },
@@ -28,3 +32,104 @@ export function atmosMcpAppViteConfig({ root, entry = 'src/main.tsx', outDir = '
28
32
  },
29
33
  });
30
34
  }
35
+ export function atmosMcpAppSourceMetadata(root) {
36
+ const appRoot = normalizePath(path.resolve(root));
37
+ return {
38
+ name: 'atmos-mcp-app-source-metadata',
39
+ enforce: 'pre',
40
+ transform(code, id) {
41
+ const file = normalizePath(id.split('?')[0] ?? id);
42
+ if (!shouldAnnotateComponentFile(file, appRoot))
43
+ return null;
44
+ const sourcePath = normalizePath(path.relative(appRoot, file));
45
+ if (!isSafeSourcePath(sourcePath))
46
+ return null;
47
+ const transformed = annotateMcpAppReactComponents(code, file, sourcePath);
48
+ return transformed === code ? null : { code: transformed, map: null };
49
+ },
50
+ };
51
+ }
52
+ export function annotateMcpAppReactComponents(code, filename, sourcePath) {
53
+ const sourceFile = ts.createSourceFile(filename, code, ts.ScriptTarget.Latest, true, filename.toLowerCase().endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.JSX);
54
+ const componentNames = topLevelComponentNames(sourceFile);
55
+ if (componentNames.length === 0)
56
+ return code;
57
+ const assignments = componentNames.map((componentName) => sourceMetadataAssignment(componentName, sourcePath));
58
+ return `${code.trimEnd()}\n\n${assignments.join('\n')}\n`;
59
+ }
60
+ function topLevelComponentNames(sourceFile) {
61
+ const names = [];
62
+ const seen = new Set();
63
+ for (const statement of sourceFile.statements) {
64
+ if (ts.isFunctionDeclaration(statement) && statement.name) {
65
+ addComponentName(names, seen, statement.name.text);
66
+ continue;
67
+ }
68
+ if (!ts.isVariableStatement(statement))
69
+ continue;
70
+ for (const declaration of statement.declarationList.declarations) {
71
+ if (ts.isIdentifier(declaration.name) &&
72
+ declaration.initializer &&
73
+ isReactComponentInitializer(declaration.initializer)) {
74
+ addComponentName(names, seen, declaration.name.text);
75
+ }
76
+ }
77
+ }
78
+ return names;
79
+ }
80
+ function addComponentName(names, seen, name) {
81
+ if (!/^[A-Z][A-Za-z0-9_]*$/.test(name) || seen.has(name))
82
+ return;
83
+ seen.add(name);
84
+ names.push(name);
85
+ }
86
+ function isReactComponentInitializer(expression) {
87
+ const value = unwrapExpression(expression);
88
+ if (ts.isArrowFunction(value) || ts.isFunctionExpression(value))
89
+ return true;
90
+ if (!ts.isCallExpression(value))
91
+ return false;
92
+ const callee = dottedName(value.expression);
93
+ return (callee === 'memo' ||
94
+ callee === 'React.memo' ||
95
+ callee === 'forwardRef' ||
96
+ callee === 'React.forwardRef');
97
+ }
98
+ function unwrapExpression(expression) {
99
+ let value = expression;
100
+ while (ts.isParenthesizedExpression(value) ||
101
+ ts.isAsExpression(value) ||
102
+ ts.isTypeAssertionExpression(value) ||
103
+ ts.isSatisfiesExpression(value) ||
104
+ ts.isNonNullExpression(value)) {
105
+ value = value.expression;
106
+ }
107
+ return value;
108
+ }
109
+ function dottedName(expression) {
110
+ if (ts.isIdentifier(expression))
111
+ return expression.text;
112
+ if (!ts.isPropertyAccessExpression(expression))
113
+ return undefined;
114
+ const parent = dottedName(expression.expression);
115
+ return parent ? `${parent}.${expression.name.text}` : expression.name.text;
116
+ }
117
+ function sourceMetadataAssignment(componentName, sourcePath) {
118
+ const metadata = JSON.stringify({ sourcePath, componentName });
119
+ return `Object.defineProperty(${componentName}, ${JSON.stringify(MCP_APP_SOURCE_METADATA_PROPERTY)}, { value: ${metadata} });`;
120
+ }
121
+ function shouldAnnotateComponentFile(file, appRoot) {
122
+ return (file.startsWith(`${appRoot}/`) &&
123
+ REACT_COMPONENT_FILE.test(file) &&
124
+ !NON_PRODUCTION_COMPONENT_FILE.test(file) &&
125
+ !file.includes('/node_modules/'));
126
+ }
127
+ function isSafeSourcePath(value) {
128
+ return (Boolean(value) &&
129
+ value.length <= 512 &&
130
+ !value.startsWith('/') &&
131
+ !value.split('/').includes('..'));
132
+ }
133
+ function normalizePath(value) {
134
+ return value.replace(/\\/g, '/');
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atmos.build/ui",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The complete atmOS React component system and MCP App authoring helpers.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -83,6 +83,7 @@
83
83
  "tailwind-merge": "^3.6.0",
84
84
  "tailwindcss": "^4.3.2",
85
85
  "tw-animate-css": "^1.4.0",
86
+ "typescript": "^5.7.0",
86
87
  "use-intl": "^4.13.2",
87
88
  "vite": "8.1.4",
88
89
  "yjs": "^13.6.31"