@atmos.build/ui 0.1.0 → 0.1.2

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.
@@ -0,0 +1,9 @@
1
+ /**
2
+ * How a diff's line counts are written down.
3
+ *
4
+ * Exact below a thousand and abbreviated above it: a reviewer wants to know a hunk touched
5
+ * seven lines, and only that a rewrite touched roughly sixteen hundred. Abbreviation goes
6
+ * through `Intl.NumberFormat` rather than a hardcoded `k`, so a five-figure refactor stays the
7
+ * same width as a one-line fix in every locale.
8
+ */
9
+ export declare function compactFormatter(locale?: string): (value: number) => string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * How a diff's line counts are written down.
3
+ *
4
+ * Exact below a thousand and abbreviated above it: a reviewer wants to know a hunk touched
5
+ * seven lines, and only that a rewrite touched roughly sixteen hundred. Abbreviation goes
6
+ * through `Intl.NumberFormat` rather than a hardcoded `k`, so a five-figure refactor stays the
7
+ * same width as a one-line fix in every locale.
8
+ */
9
+ export function compactFormatter(locale) {
10
+ const exact = new Intl.NumberFormat(locale);
11
+ const compact = new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1 });
12
+ return (value) => (value < 1000 ? exact.format(value) : compact.format(value));
13
+ }
@@ -0,0 +1,27 @@
1
+ import * as React from 'react';
2
+ export interface DiffStatLabelProps extends Omit<React.ComponentProps<'span'>, 'children'> {
3
+ additions: number;
4
+ deletions: number;
5
+ /**
6
+ * The whole label as one string, for readers who get it announced rather than scanned.
7
+ * Required because the visible form is punctuation and digits, which is not a sentence in
8
+ * any language — the caller is the only place that can translate it.
9
+ */
10
+ label: string;
11
+ /** Formatting locale. Left to the runtime's own when the caller has no opinion. */
12
+ locale?: string;
13
+ size?: 'sm' | 'md';
14
+ }
15
+ /**
16
+ * How much a diff changed, in the two numbers a reviewer actually scans for.
17
+ *
18
+ * The colours are the diff surface's own `--diff-added` / `--diff-removed` rather than the
19
+ * status palette's success and danger: this label sits next to the lines it counts, and a
20
+ * green borrowed from "operation succeeded" reads as a different kind of statement.
21
+ *
22
+ * Counts go through `Intl.NumberFormat` rather than a hand-rolled `k`, which means the width
23
+ * is the locale's decision and not ours — German short notation does not abbreviate thousands
24
+ * at all, so a German reader sees `24.019` where an English one sees `24K`. Showing them an
25
+ * English abbreviation instead would be narrower and wrong.
26
+ */
27
+ export declare function DiffStatLabel({ additions, deletions, label, locale, size, className, ...props }: DiffStatLabelProps): React.JSX.Element;
@@ -0,0 +1,24 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import * as React from 'react';
4
+ import { cn } from '../lib/cn.js';
5
+ import { compactFormatter } from './diff-stat-format.js';
6
+ const SIZES = { sm: 'gap-1 text-[11px]', md: 'gap-1.5 text-xs' };
7
+ /**
8
+ * How much a diff changed, in the two numbers a reviewer actually scans for.
9
+ *
10
+ * The colours are the diff surface's own `--diff-added` / `--diff-removed` rather than the
11
+ * status palette's success and danger: this label sits next to the lines it counts, and a
12
+ * green borrowed from "operation succeeded" reads as a different kind of statement.
13
+ *
14
+ * Counts go through `Intl.NumberFormat` rather than a hand-rolled `k`, which means the width
15
+ * is the locale's decision and not ours — German short notation does not abbreviate thousands
16
+ * at all, so a German reader sees `24.019` where an English one sees `24K`. Showing them an
17
+ * English abbreviation instead would be narrower and wrong.
18
+ */
19
+ export function DiffStatLabel({ additions, deletions, label, locale, size = 'sm', className, ...props }) {
20
+ const format = React.useMemo(() => compactFormatter(locale), [locale]);
21
+ const added = Math.max(0, Math.trunc(additions));
22
+ const removed = Math.max(0, Math.trunc(deletions));
23
+ return (_jsx("span", { "data-slot": "diff-stat-label", className: cn('inline-flex shrink-0 items-center font-mono tabular-nums', SIZES[size], className), "aria-label": label, ...props, children: added === 0 && removed === 0 ? (_jsx("span", { className: "text-muted-foreground", "aria-hidden": true, children: "\u2014" })) : (_jsxs(_Fragment, { children: [added > 0 ? (_jsxs("span", { className: "text-diff-added", "aria-hidden": true, children: ["+", format(added)] })) : null, removed > 0 ? (_jsxs("span", { className: "text-diff-removed", "aria-hidden": true, children: ["\u2212", format(removed)] })) : null] })) }));
24
+ }
@@ -31,12 +31,20 @@ export interface NautAvatarOptions {
31
31
  seed?: number;
32
32
  idle?: boolean;
33
33
  }
34
+ export interface NautAvatarAttributeElement {
35
+ setAttribute(name: string, value: string): void;
36
+ }
37
+ export interface NautAvatarEyeElement extends NautAvatarAttributeElement {
38
+ style: {
39
+ opacity: string;
40
+ };
41
+ }
34
42
  export interface NautAvatarElements {
35
- root: SVGGElement;
36
- body: SVGPathElement;
37
- eyes: SVGGElement;
38
- eyeL: SVGPathElement;
39
- eyeR: SVGPathElement;
43
+ root: NautAvatarAttributeElement;
44
+ body: NautAvatarAttributeElement;
45
+ eyes: NautAvatarAttributeElement;
46
+ eyeL: NautAvatarEyeElement;
47
+ eyeR: NautAvatarEyeElement;
40
48
  }
41
49
  /**
42
50
  * Framework-agnostic renderer: owns the animation state and writes attributes onto the
@@ -62,9 +70,13 @@ export declare class NautAvatarEngine {
62
70
  private readonly face;
63
71
  private targetFace;
64
72
  private readonly idleState;
73
+ /** An expression holds one eye shape forever; idle opens the eyes between beats. */
74
+ private readonly eyeRest;
65
75
  private el;
76
+ private readonly rendered;
66
77
  constructor(opts?: NautAvatarOptions);
67
78
  attach(el: NautAvatarElements): void;
79
+ get needsResponsiveFrameRate(): boolean;
68
80
  setShape(shape: NautShape): void;
69
81
  setColor(color: NautColor): void;
70
82
  setExpression(key: NautExpression, instant?: boolean): void;
@@ -73,7 +85,11 @@ export declare class NautAvatarEngine {
73
85
  settle(): void;
74
86
  update(dt: number): void;
75
87
  private updateIdle;
88
+ /** Eases the eyes back to their neutral open shape and back into the expression. */
89
+ private restEyes;
76
90
  private render;
91
+ private writeAttribute;
92
+ private writeStyle;
77
93
  }
78
94
  /** Stable pseudo-random pick so the same id always gets the same appearance. */
79
95
  export declare function hashSeed(input: string): number;
@@ -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,7 +643,10 @@ 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;
649
+ rendered = new Map();
634
650
  constructor(opts = {}) {
635
651
  this.rand = rng(opts.seed ?? 1);
636
652
  this.shape = opts.shape ?? 'circle';
@@ -639,6 +655,7 @@ export class NautAvatarEngine {
639
655
  this.idle = opts.idle ?? true;
640
656
  this.time = this.rand() * 100;
641
657
  this.idleState.nextBlink = 2 + this.rand() * 3;
658
+ this.eyeRest.next = HOLD_EXPRESSION_S + this.rand() * HOLD_EXPRESSION_JITTER_S;
642
659
  this.nodes = cloneNodes(SHAPES[this.shape].nodes);
643
660
  this.sphere = SHAPES[this.shape].sphere;
644
661
  this.eyeYOff = SHAPES[this.shape].eyeY;
@@ -648,6 +665,17 @@ export class NautAvatarEngine {
648
665
  }
649
666
  attach(el) {
650
667
  this.el = el;
668
+ this.rendered.clear();
669
+ }
670
+ get needsResponsiveFrameRate() {
671
+ const targetGazeX = this.gazeTarget?.x ?? this.idleState.gazeX;
672
+ const targetGazeY = this.gazeTarget?.y ?? this.idleState.gazeY;
673
+ return (this.gestures.length > 0 ||
674
+ Math.abs(this.idleState.curGazeX - targetGazeX) > 0.01 ||
675
+ Math.abs(this.idleState.curGazeY - targetGazeY) > 0.01 ||
676
+ Math.abs(this.idleState.curRoll - this.idleState.roll) > 0.02 ||
677
+ Math.abs(this.eyeRest.cur - this.eyeRest.open) > 0.01 ||
678
+ FACE_KEYS.some((key) => Math.abs(this.face[key] - this.targetFace[key]) > 0.002));
651
679
  }
652
680
  setShape(shape) {
653
681
  this.shape = shape;
@@ -662,8 +690,14 @@ export class NautAvatarEngine {
662
690
  if (changed || instant)
663
691
  this.emotionTime = 0;
664
692
  this.targetFace = { ...NEUTRAL, ...expandFace(def.face) };
665
- if (instant)
693
+ if (changed || instant) {
694
+ this.eyeRest.open = 0;
695
+ this.eyeRest.next = HOLD_EXPRESSION_S + this.rand() * HOLD_EXPRESSION_JITTER_S;
696
+ }
697
+ if (instant) {
666
698
  Object.assign(this.face, this.targetFace);
699
+ this.eyeRest.cur = 0;
700
+ }
667
701
  if (def.enter && !instant && changed)
668
702
  this.play(def.enter);
669
703
  }
@@ -673,6 +707,8 @@ export class NautAvatarEngine {
673
707
  /** Snap every tween to its target (used for static renders / reduced motion). */
674
708
  settle() {
675
709
  Object.assign(this.face, this.targetFace);
710
+ this.eyeRest.open = 0;
711
+ this.eyeRest.cur = 0;
676
712
  this.nodes = cloneNodes(SHAPES[this.shape].nodes);
677
713
  this.sphere = SHAPES[this.shape].sphere;
678
714
  this.eyeYOff = SHAPES[this.shape].eyeY;
@@ -697,6 +733,7 @@ export class NautAvatarEngine {
697
733
  if (def.loop)
698
734
  def.loop(this.emotionTime, o);
699
735
  this.updateIdle(dt, o);
736
+ this.restEyes(dt, o, def);
700
737
  for (const g of this.gestures) {
701
738
  g.t += dt / GESTURES[g.key].dur;
702
739
  GESTURES[g.key].fn(clamp(g.t, 0, 1), o);
@@ -747,14 +784,34 @@ export class NautAvatarEngine {
747
784
  o.gazeY += s.curGazeY;
748
785
  o.roll += s.curRoll;
749
786
  }
787
+ /** Eases the eyes back to their neutral open shape and back into the expression. */
788
+ restEyes(dt, o, def) {
789
+ const rest = this.eyeRest;
790
+ if (this.idle && !def.fixedEyes) {
791
+ rest.next -= dt;
792
+ if (rest.next <= 0) {
793
+ rest.open = rest.open > 0.5 ? 0 : 1;
794
+ rest.next = rest.open
795
+ ? OPEN_EYES_S + this.rand() * OPEN_EYES_JITTER_S
796
+ : HOLD_EXPRESSION_S + this.rand() * HOLD_EXPRESSION_JITTER_S;
797
+ }
798
+ }
799
+ else
800
+ rest.open = 0;
801
+ rest.cur = approach(rest.cur, rest.open, 3, dt);
802
+ if (rest.cur < 0.002)
803
+ return;
804
+ for (const k of EYE_KEYS)
805
+ o[k] = lerp(o[k], NEUTRAL[k], rest.cur);
806
+ }
750
807
  render(o) {
751
808
  const el = this.el;
752
809
  if (!el)
753
810
  return;
754
- el.root.setAttribute('transform', `translate(${o.headX.toFixed(2)} ${o.headY.toFixed(2)}) scale(${o.sx.toFixed(3)} ${o.sy.toFixed(3)})`);
755
- el.body.setAttribute('d', nodePath(this.nodes));
756
- el.body.setAttribute('fill', NAUT_COLORS[this.color]);
757
- el.eyes.setAttribute('transform', `rotate(${o.roll.toFixed(2)})`);
811
+ this.writeAttribute('root.transform', el.root, 'transform', `translate(${o.headX.toFixed(1)} ${o.headY.toFixed(1)}) scale(${o.sx.toFixed(3)} ${o.sy.toFixed(3)})`);
812
+ this.writeAttribute('body.d', el.body, 'd', nodePath(this.nodes));
813
+ this.writeAttribute('body.fill', el.body, 'fill', NAUT_COLORS[this.color]);
814
+ this.writeAttribute('eyes.transform', el.eyes, 'transform', `rotate(${o.roll.toFixed(1)})`);
758
815
  const yaw = clamp(o.gazeX, -1.3, 1.3) * 0.85 + o.spin;
759
816
  const pitch = -clamp(o.gazeY, -1.3, 1.3) * 0.7;
760
817
  const R = this.sphere;
@@ -766,7 +823,7 @@ export class NautAvatarEngine {
766
823
  const poly = eyePolygon(p, sgn);
767
824
  const eye = side === 'L' ? el.eyeL : el.eyeR;
768
825
  if (!poly.length) {
769
- eye.setAttribute('d', '');
826
+ this.writeAttribute(`eye${side}.d`, eye, 'd', '');
770
827
  continue;
771
828
  }
772
829
  const fr = eyeFrame(sgn * o.eyeSep, o.eyeLat, yaw, pitch);
@@ -778,12 +835,24 @@ export class NautAvatarEngine {
778
835
  const vx = lerp(0, fr.V[0], curve);
779
836
  const vy = lerp(1, fr.V[1], curve);
780
837
  const d = poly
781
- .map(([lx, ly], i) => `${i ? 'L' : 'M'} ${(cx + ux * lx + vx * ly).toFixed(2)} ${(cy + uy * lx + vy * ly).toFixed(2)}`)
838
+ .map(([lx, ly], i) => `${i ? 'L' : 'M'} ${(cx + ux * lx + vx * ly).toFixed(1)} ${(cy + uy * lx + vy * ly).toFixed(1)}`)
782
839
  .join(' ');
783
- eye.setAttribute('d', d + ' Z');
784
- eye.style.opacity = fr.P[2] > 0 ? '1' : '0';
840
+ this.writeAttribute(`eye${side}.d`, eye, 'd', d + ' Z');
841
+ this.writeStyle(`eye${side}.opacity`, eye.style, 'opacity', fr.P[2] > 0 ? '1' : '0');
785
842
  }
786
843
  }
844
+ writeAttribute(key, element, name, value) {
845
+ if (this.rendered.get(key) === value)
846
+ return;
847
+ this.rendered.set(key, value);
848
+ element.setAttribute(name, value);
849
+ }
850
+ writeStyle(key, style, name, value) {
851
+ if (this.rendered.get(key) === value)
852
+ return;
853
+ this.rendered.set(key, value);
854
+ style[name] = value;
855
+ }
787
856
  }
788
857
  /** Stable pseudo-random pick so the same id always gets the same appearance. */
789
858
  export function hashSeed(input) {
@@ -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,32 +4,104 @@ 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
+ const IDLE_FRAME_MS = 1000 / 3;
12
+ const RESPONSIVE_FRAME_MS = 1000 / 20;
13
+ const POINTER_SETTLE_MS = 350;
14
+ /** Where the mouse is, shared by every face so one listener serves the page. */
15
+ const pointer = {
16
+ x: 0,
17
+ y: 0,
18
+ active: false,
19
+ lastMovedAt: 0,
20
+ /** Bumps whenever cached face rects could have gone stale. */
21
+ moved: 0,
22
+ onPointer(event) {
23
+ if (event.pointerType === 'touch') {
24
+ pointer.active = false;
25
+ return;
26
+ }
27
+ pointer.x = event.clientX;
28
+ pointer.y = event.clientY;
29
+ pointer.active = true;
30
+ pointer.lastMovedAt = performance.now();
31
+ pointer.moved++;
32
+ },
33
+ onLeave() {
34
+ pointer.active = false;
35
+ },
36
+ onLayout() {
37
+ pointer.moved++;
38
+ },
39
+ listen() {
40
+ window.addEventListener('pointermove', pointer.onPointer, { passive: true });
41
+ window.addEventListener('pointerdown', pointer.onPointer, { passive: true });
42
+ document.addEventListener('mouseleave', pointer.onLeave);
43
+ window.addEventListener('scroll', pointer.onLayout, { passive: true, capture: true });
44
+ window.addEventListener('resize', pointer.onLayout, { passive: true });
45
+ },
46
+ stop() {
47
+ window.removeEventListener('pointermove', pointer.onPointer);
48
+ window.removeEventListener('pointerdown', pointer.onPointer);
49
+ document.removeEventListener('mouseleave', pointer.onLeave);
50
+ window.removeEventListener('scroll', pointer.onLayout, { capture: true });
51
+ window.removeEventListener('resize', pointer.onLayout);
52
+ pointer.active = false;
53
+ },
54
+ };
55
+ function aimAtPointer(entry, now) {
56
+ const svg = entry.svg;
57
+ if (!pointer.active || !svg) {
58
+ entry.engine.gazeTarget = null;
59
+ return;
60
+ }
61
+ if (entry.rectAt !== pointer.moved || now - entry.rectTime > RECT_MAX_AGE_MS) {
62
+ entry.rect = svg.getBoundingClientRect();
63
+ entry.rectAt = pointer.moved;
64
+ entry.rectTime = now;
65
+ }
66
+ entry.engine.gazeTarget = entry.rect ? pointerGaze(entry.rect, pointer.x, pointer.y) : null;
67
+ }
8
68
  /** One animation frame loop drives every mounted avatar; offscreen ones sit out. */
9
69
  const ticker = {
10
70
  entries: new Set(),
11
71
  frame: 0,
12
- last: 0,
13
72
  tick(now) {
14
- const dt = Math.min((now - ticker.last) / 1000, 0.05);
15
- ticker.last = now;
16
- for (const entry of ticker.entries)
17
- if (entry.visible)
18
- entry.engine.update(dt);
73
+ const pointerResponsive = pointer.active && now - pointer.lastMovedAt < POINTER_SETTLE_MS;
74
+ for (const entry of ticker.entries) {
75
+ if (!entry.visible)
76
+ continue;
77
+ const responsive = (entry.follow && pointerResponsive) || entry.engine.needsResponsiveFrameRate;
78
+ const frameMs = responsive ? RESPONSIVE_FRAME_MS : IDLE_FRAME_MS;
79
+ if (now - entry.lastUpdate < frameMs)
80
+ continue;
81
+ const dt = Math.min((now - entry.lastUpdate) / 1000, 0.1);
82
+ entry.lastUpdate = now;
83
+ if (entry.follow)
84
+ aimAtPointer(entry, now);
85
+ entry.engine.update(dt);
86
+ }
19
87
  ticker.frame = ticker.entries.size ? requestAnimationFrame(ticker.tick) : 0;
20
88
  },
21
89
  add(entry) {
90
+ if (!ticker.entries.size)
91
+ pointer.listen();
22
92
  ticker.entries.add(entry);
23
93
  if (!ticker.frame) {
24
- ticker.last = performance.now();
25
94
  ticker.frame = requestAnimationFrame(ticker.tick);
26
95
  }
27
96
  },
28
97
  remove(entry) {
29
98
  ticker.entries.delete(entry);
30
- if (!ticker.entries.size && ticker.frame) {
31
- cancelAnimationFrame(ticker.frame);
32
- ticker.frame = 0;
99
+ if (!ticker.entries.size) {
100
+ pointer.stop();
101
+ if (ticker.frame) {
102
+ cancelAnimationFrame(ticker.frame);
103
+ ticker.frame = 0;
104
+ }
33
105
  }
34
106
  },
35
107
  };
@@ -39,7 +111,7 @@ const seedNumber = (seed) => typeof seed === 'number' ? seed : hashSeed(seed ??
39
111
  * look around. The eyes are holes, so set `--naut-avatar-eye` to the surface the
40
112
  * avatar sits on (defaults to `--background`).
41
113
  */
42
- export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle', color = 'ink', expression = 'neutral', idle = true, gaze = null, seed, className, style, ...props }, forwardedRef) {
114
+ 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
115
  const reducedMotion = useReducedMotion();
44
116
  const animated = idle && !reducedMotion;
45
117
  const svgRef = React.useRef(null);
@@ -49,6 +121,16 @@ export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle
49
121
  const eyeLRef = React.useRef(null);
50
122
  const eyeRRef = React.useRef(null);
51
123
  const [engine] = React.useState(() => new NautAvatarEngine({ shape, color, expression, seed: seedNumber(seed), idle: animated }));
124
+ const [entry] = React.useState(() => ({
125
+ engine,
126
+ svg: null,
127
+ visible: true,
128
+ follow: false,
129
+ rect: null,
130
+ rectAt: -1,
131
+ rectTime: 0,
132
+ lastUpdate: performance.now(),
133
+ }));
52
134
  React.useImperativeHandle(forwardedRef, () => ({ play: (gesture) => engine.play(gesture) }), [
53
135
  engine,
54
136
  ]);
@@ -58,7 +140,8 @@ export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle
58
140
  engine.setExpression(expression);
59
141
  engine.idle = animated;
60
142
  engine.gazeTarget = gaze;
61
- }, [engine, shape, color, expression, animated, gaze]);
143
+ entry.follow = followPointer && !gaze && animated;
144
+ }, [engine, entry, shape, color, expression, animated, gaze, followPointer]);
62
145
  React.useLayoutEffect(() => {
63
146
  if (!rootRef.current ||
64
147
  !bodyRef.current ||
@@ -77,14 +160,17 @@ export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle
77
160
  engine.update(0);
78
161
  }, [engine]);
79
162
  React.useEffect(() => {
80
- if (reducedMotion) {
163
+ if (!animated) {
81
164
  engine.settle();
82
165
  engine.update(0);
83
166
  return;
84
167
  }
85
- const entry = { engine, visible: true };
86
- ticker.add(entry);
87
168
  const svg = svgRef.current;
169
+ entry.svg = svg;
170
+ entry.rect = null;
171
+ entry.rectAt = -1;
172
+ entry.lastUpdate = performance.now();
173
+ ticker.add(entry);
88
174
  const observer = svg && typeof IntersectionObserver !== 'undefined'
89
175
  ? new IntersectionObserver((records) => {
90
176
  for (const record of records)
@@ -96,13 +182,14 @@ export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle
96
182
  return () => {
97
183
  observer?.disconnect();
98
184
  ticker.remove(entry);
185
+ entry.svg = null;
99
186
  };
100
- }, [engine, reducedMotion]);
187
+ }, [animated, engine, entry]);
101
188
  React.useEffect(() => {
102
- if (!reducedMotion)
189
+ if (animated)
103
190
  return;
104
191
  engine.settle();
105
192
  engine.update(0);
106
- }, [engine, reducedMotion, shape, color, expression]);
107
- return (_jsx("svg", { ref: svgRef, viewBox: NAUT_VIEWBOX, "data-slot": "naut-avatar", "data-shape": shape, "data-color": color, "data-expression": expression, className: cn('block size-full overflow-visible select-none', className), style: style, "aria-hidden": props['aria-label'] ? undefined : true, ...props, children: _jsxs("g", { ref: rootRef, children: [_jsx("path", { ref: bodyRef }), _jsxs("g", { ref: eyesRef, children: [_jsx("path", { ref: eyeLRef, fill: "var(--naut-avatar-eye, var(--background))" }), _jsx("path", { ref: eyeRRef, fill: "var(--naut-avatar-eye, var(--background))" })] })] }) }));
193
+ }, [animated, engine, shape, color, expression]);
194
+ return (_jsx("svg", { ref: svgRef, viewBox: NAUT_VIEWBOX, "data-slot": "naut-avatar", "data-shape": shape, "data-color": color, "data-expression": expression, "data-atmos-motion": animated ? 'running' : undefined, "data-atmos-motion-property": animated ? 'svg-attributes' : undefined, className: cn('block size-full overflow-visible select-none', className), style: style, "aria-hidden": props['aria-label'] ? undefined : true, ...props, children: _jsxs("g", { ref: rootRef, children: [_jsx("path", { ref: bodyRef }), _jsxs("g", { ref: eyesRef, children: [_jsx("path", { ref: eyeLRef, fill: "var(--naut-avatar-eye, var(--background))" }), _jsx("path", { ref: eyeRRef, fill: "var(--naut-avatar-eye, var(--background))" })] })] }) }));
108
195
  });
@@ -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,138 @@
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
+ /** Enables ambient drift and gestures. */
133
+ animated?: boolean;
134
+ /** Sizes the scene; the faces lay themselves out inside whatever box this gives. */
135
+ className?: string;
136
+ }
137
+ /** A small animated illustration built from Naut faces. Decorative — it carries no label. */
138
+ export declare function NautScene({ scene, animated, className, }: NautSceneProps): React.ReactElement;
@@ -0,0 +1,169 @@
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, animated = true, className, }) {
130
+ const reducedMotion = useReducedMotion();
131
+ const active = animated && !reducedMotion;
132
+ const { faces, gesture, interval } = NAUT_SCENES[scene];
133
+ const handles = React.useRef([]);
134
+ React.useEffect(() => {
135
+ if (!active)
136
+ return;
137
+ const pending = [];
138
+ const run = () => {
139
+ handles.current.forEach((handle, index) => {
140
+ pending.push(window.setTimeout(() => handle?.play(gesture), index * GESTURE_STAGGER_MS));
141
+ });
142
+ };
143
+ const id = window.setInterval(run, interval * 1000);
144
+ return () => {
145
+ window.clearInterval(id);
146
+ for (const timeout of pending)
147
+ window.clearTimeout(timeout);
148
+ };
149
+ }, [active, gesture, interval]);
150
+ 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: {
151
+ left: `${face.x * 100}%`,
152
+ top: `${face.y * 100}%`,
153
+ height: `${face.size * 100}%`,
154
+ transform: 'translate(-50%, -50%)',
155
+ }, children: _jsx(motion.span, { className: "block size-full", animate: !active
156
+ ? undefined
157
+ : {
158
+ y: [`-${DRIFT_TRAVEL}%`, `${DRIFT_TRAVEL}%`],
159
+ rotate: [-DRIFT_TILT, DRIFT_TILT],
160
+ }, transition: {
161
+ duration: face.drift,
162
+ delay: index * DRIFT_OFFSET,
163
+ repeat: Infinity,
164
+ repeatType: 'mirror',
165
+ ease: 'easeInOut',
166
+ }, children: _jsx(NautAvatar, { ref: (handle) => {
167
+ handles.current[index] = handle;
168
+ }, shape: face.shape, color: face.color, expression: face.expression, idle: active, seed: `${scene}-${index}` }) }) }, `${face.shape}-${face.color}-${index}`))) }) }));
169
+ }
@@ -1,8 +1,8 @@
1
1
  import * as React from 'react';
2
- import { type HTMLMotionProps } from 'motion/react';
3
- export interface ShimmerTextProps extends Omit<HTMLMotionProps<'span'>, 'children'> {
2
+ export interface ShimmerTextProps extends Omit<React.ComponentPropsWithoutRef<'span'>, 'children'> {
4
3
  children: string | number;
4
+ animate?: boolean;
5
5
  duration?: number;
6
6
  spread?: number;
7
7
  }
8
- export declare const ShimmerText: React.NamedExoticComponent<Omit<ShimmerTextProps, "ref"> & React.RefAttributes<HTMLSpanElement>>;
8
+ export declare const ShimmerText: React.NamedExoticComponent<ShimmerTextProps & React.RefAttributes<HTMLSpanElement>>;
@@ -1,23 +1,59 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import * as React from 'react';
4
- import { motion, useReducedMotion } from 'motion/react';
4
+ import { useMediaQuery } from '../hooks/use-media-query.js';
5
5
  import { cn } from '../lib/cn.js';
6
- const ShimmerTextComponent = React.forwardRef(function ShimmerText({ children, className, duration = 2, spread = 2, style, ...props }, forwardedRef) {
7
- const reducedMotion = useReducedMotion();
6
+ const SHIMMER_FRAME_MS = 1000 / 8;
7
+ const shimmerEntries = new Set();
8
+ let shimmerTimer = 0;
9
+ function paintShimmers() {
10
+ const now = performance.now();
11
+ for (const entry of shimmerEntries) {
12
+ const progress = ((now - entry.startedAt) % entry.durationMs) / entry.durationMs;
13
+ entry.element.style.backgroundPosition = `${100 - progress * 100}% center, 0 center`;
14
+ }
15
+ }
16
+ function startShimmer(entry) {
17
+ shimmerEntries.add(entry);
18
+ paintShimmers();
19
+ if (!shimmerTimer)
20
+ shimmerTimer = window.setInterval(paintShimmers, SHIMMER_FRAME_MS);
21
+ }
22
+ function stopShimmer(entry) {
23
+ shimmerEntries.delete(entry);
24
+ if (shimmerEntries.size || !shimmerTimer)
25
+ return;
26
+ window.clearInterval(shimmerTimer);
27
+ shimmerTimer = 0;
28
+ }
29
+ const ShimmerTextComponent = React.forwardRef(function ShimmerText({ children, className, duration = 2, spread = 2, style, animate = true, ...props }, forwardedRef) {
30
+ const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
31
+ const active = animate !== false && !reducedMotion;
32
+ const localRef = React.useRef(null);
33
+ const setRef = React.useCallback((node) => {
34
+ localRef.current = node;
35
+ if (typeof forwardedRef === 'function')
36
+ forwardedRef(node);
37
+ else if (forwardedRef)
38
+ forwardedRef.current = node;
39
+ }, [forwardedRef]);
8
40
  const dynamicSpread = React.useMemo(() => `${String(children).length * spread}px`, [children, spread]);
9
- const backgroundImage = reducedMotion
10
- ? 'linear-gradient(var(--base-color), var(--base-color))'
11
- : 'var(--bg), linear-gradient(var(--base-color), var(--base-color))';
12
- return (_jsx(motion.span, { ref: forwardedRef, "data-slot": "shimmer-text", className: cn('relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent', '[--base-color:color-mix(in_oklch,var(--foreground)_30%,transparent)] [--base-gradient-color:var(--foreground)]', '[background-repeat:no-repeat,padding-box]', '[--bg:linear-gradient(90deg,transparent_calc(50%-var(--spread)),var(--base-gradient-color),transparent_calc(50%+var(--spread)))]', className),
13
- // Keyframes, not an initial→animate delta: nested under an AnimatePresence
14
- // the presence context suppresses `initial`, which would freeze the sweep
15
- // at its end position and leave the text flat.
16
- animate: reducedMotion ? undefined : { backgroundPosition: ['100% center', '0% center'] }, transition: {
17
- repeat: Number.POSITIVE_INFINITY,
18
- duration,
19
- ease: 'linear',
20
- }, style: {
41
+ const backgroundImage = active
42
+ ? 'var(--bg), linear-gradient(var(--base-color), var(--base-color))'
43
+ : 'linear-gradient(var(--base-color), var(--base-color))';
44
+ React.useEffect(() => {
45
+ const element = localRef.current;
46
+ if (!active || !element)
47
+ return;
48
+ const entry = {
49
+ element,
50
+ durationMs: Math.max(duration * 1000, SHIMMER_FRAME_MS),
51
+ startedAt: performance.now(),
52
+ };
53
+ startShimmer(entry);
54
+ return () => stopShimmer(entry);
55
+ }, [active, duration, children]);
56
+ return (_jsx("span", { ref: setRef, "data-slot": "shimmer-text", "data-atmos-motion": active ? 'running' : undefined, "data-atmos-motion-property": active ? 'background-position' : undefined, className: cn('relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent contain-paint', '[--base-color:color-mix(in_oklch,var(--foreground)_30%,transparent)] [--base-gradient-color:var(--foreground)]', '[background-repeat:no-repeat,padding-box]', '[--bg:linear-gradient(90deg,transparent_calc(50%-var(--spread)),var(--base-gradient-color),transparent_calc(50%+var(--spread)))]', className), style: {
21
57
  '--spread': dynamicSpread,
22
58
  backgroundImage,
23
59
  ...style,
package/dist/index.d.ts CHANGED
@@ -24,6 +24,8 @@ export { TemplateChipInput, type TemplateChipInputProps, type TemplateChipInputH
24
24
  export { RunStatusBadge, type RunStatus, type RunStatusBadgeProps, } from './components/run-status-badge.js';
25
25
  export { ConfidenceMeter, confidenceLevel, type ConfidenceLevel, type ConfidenceMeterProps, } from './components/confidence-meter.js';
26
26
  export { DateTile, type DateTileProps } from './components/date-tile.js';
27
+ export { DiffStatLabel, type DiffStatLabelProps } from './components/diff-stat-label.js';
28
+ export { compactFormatter } from './components/diff-stat-format.js';
27
29
  export { MetricBreakdown, MetricGrid, MetricPanel, MetricSectionHeading, MetricTile, type MetricBreakdownItem, type MetricBreakdownProps, type MetricGridProps, type MetricPanelProps, type MetricSectionHeadingProps, type MetricTileProps, type MetricTone, } from './components/metric-grid.js';
28
30
  export { resolveDateTimeFormat, type FormatDateTime } from './lib/date-time-format.js';
29
31
  export { ProvenanceChip, type ProvenanceChipProps, type ProvenanceMethod, } from './components/provenance-chip.js';
@@ -67,6 +69,7 @@ export { SortableList, SortableItem, SortableRow, DragHandle, DragPreview, useSo
67
69
  export { VoiceLine, type VoiceLineProps, type VoiceLineState } from './components/voice-line.js';
68
70
  export { ShimmerText, type ShimmerTextProps } from './components/shimmer-text.js';
69
71
  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';
72
+ export { NautScene, NAUT_SCENES, type NautSceneDef, type NautSceneFace, type NautSceneName, type NautSceneProps, } from './components/naut-scene.js';
70
73
  export { TranscriptionOverlay, type TranscriptionOverlayLabels, type TranscriptionOverlayProps, type TranscriptionOverlaySelector, type TranscriptionOverlayState, } from './components/transcription-overlay.js';
71
74
  export { TimestampedTranscript, type TimestampedTranscriptLabels, type TimestampedTranscriptProps, type TimestampedTranscriptUtterance, type TimestampedTranscriptWord, } from './components/timestamped-transcript.js';
72
75
  export { MicrophonePriority, type MicrophonePriorityItem, type MicrophonePriorityLabels, type MicrophonePriorityProps, } from './components/microphone-priority.js';
package/dist/index.js CHANGED
@@ -24,6 +24,8 @@ export { TemplateChipInput, } from './components/template-chip-input.js';
24
24
  export { RunStatusBadge, } from './components/run-status-badge.js';
25
25
  export { ConfidenceMeter, confidenceLevel, } from './components/confidence-meter.js';
26
26
  export { DateTile } from './components/date-tile.js';
27
+ export { DiffStatLabel } from './components/diff-stat-label.js';
28
+ export { compactFormatter } from './components/diff-stat-format.js';
27
29
  export { MetricBreakdown, MetricGrid, MetricPanel, MetricSectionHeading, MetricTile, } from './components/metric-grid.js';
28
30
  export { resolveDateTimeFormat } from './lib/date-time-format.js';
29
31
  export { ProvenanceChip, } from './components/provenance-chip.js';
@@ -67,6 +69,7 @@ export { SortableList, SortableItem, SortableRow, DragHandle, DragPreview, useSo
67
69
  export { VoiceLine } from './components/voice-line.js';
68
70
  export { ShimmerText } from './components/shimmer-text.js';
69
71
  export { NautAvatar, NAUT_COLORS, NAUT_COLOR_KEYS, NAUT_EXPRESSIONS, NAUT_GESTURES, NAUT_SHAPES, isNautColor, isNautShape, nautAppearanceForSeed, } from './components/naut-avatar.js';
72
+ export { NautScene, NAUT_SCENES, } from './components/naut-scene.js';
70
73
  export { TranscriptionOverlay, } from './components/transcription-overlay.js';
71
74
  export { TimestampedTranscript, } from './components/timestamped-transcript.js';
72
75
  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.2",
4
4
  "description": "The complete atmOS React component system and MCP App authoring helpers.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -32,6 +32,10 @@
32
32
  "types": "./dist/lib/cn.d.ts",
33
33
  "import": "./dist/lib/cn.js"
34
34
  },
35
+ "./naut-avatar-engine": {
36
+ "types": "./dist/components/naut-avatar-engine.d.ts",
37
+ "import": "./dist/components/naut-avatar-engine.js"
38
+ },
35
39
  "./view-query": {
36
40
  "types": "./dist/lib/view-query/index.d.ts",
37
41
  "import": "./dist/lib/view-query/index.js"
@@ -83,6 +87,7 @@
83
87
  "tailwind-merge": "^3.6.0",
84
88
  "tailwindcss": "^4.3.2",
85
89
  "tw-animate-css": "^1.4.0",
90
+ "typescript": "^5.7.0",
86
91
  "use-intl": "^4.13.2",
87
92
  "vite": "8.1.4",
88
93
  "yjs": "^13.6.31"