@atmos.build/ui 0.1.1 → 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
@@ -65,8 +73,10 @@ export declare class NautAvatarEngine {
65
73
  /** An expression holds one eye shape forever; idle opens the eyes between beats. */
66
74
  private readonly eyeRest;
67
75
  private el;
76
+ private readonly rendered;
68
77
  constructor(opts?: NautAvatarOptions);
69
78
  attach(el: NautAvatarElements): void;
79
+ get needsResponsiveFrameRate(): boolean;
70
80
  setShape(shape: NautShape): void;
71
81
  setColor(color: NautColor): void;
72
82
  setExpression(key: NautExpression, instant?: boolean): void;
@@ -78,6 +88,8 @@ export declare class NautAvatarEngine {
78
88
  /** Eases the eyes back to their neutral open shape and back into the expression. */
79
89
  private restEyes;
80
90
  private render;
91
+ private writeAttribute;
92
+ private writeStyle;
81
93
  }
82
94
  /** Stable pseudo-random pick so the same id always gets the same appearance. */
83
95
  export declare function hashSeed(input: string): number;
@@ -646,6 +646,7 @@ export class NautAvatarEngine {
646
646
  /** An expression holds one eye shape forever; idle opens the eyes between beats. */
647
647
  eyeRest = { open: 0, cur: 0, next: HOLD_EXPRESSION_S };
648
648
  el = null;
649
+ rendered = new Map();
649
650
  constructor(opts = {}) {
650
651
  this.rand = rng(opts.seed ?? 1);
651
652
  this.shape = opts.shape ?? 'circle';
@@ -664,6 +665,17 @@ export class NautAvatarEngine {
664
665
  }
665
666
  attach(el) {
666
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));
667
679
  }
668
680
  setShape(shape) {
669
681
  this.shape = shape;
@@ -796,10 +808,10 @@ export class NautAvatarEngine {
796
808
  const el = this.el;
797
809
  if (!el)
798
810
  return;
799
- el.root.setAttribute('transform', `translate(${o.headX.toFixed(2)} ${o.headY.toFixed(2)}) scale(${o.sx.toFixed(3)} ${o.sy.toFixed(3)})`);
800
- el.body.setAttribute('d', nodePath(this.nodes));
801
- el.body.setAttribute('fill', NAUT_COLORS[this.color]);
802
- 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)})`);
803
815
  const yaw = clamp(o.gazeX, -1.3, 1.3) * 0.85 + o.spin;
804
816
  const pitch = -clamp(o.gazeY, -1.3, 1.3) * 0.7;
805
817
  const R = this.sphere;
@@ -811,7 +823,7 @@ export class NautAvatarEngine {
811
823
  const poly = eyePolygon(p, sgn);
812
824
  const eye = side === 'L' ? el.eyeL : el.eyeR;
813
825
  if (!poly.length) {
814
- eye.setAttribute('d', '');
826
+ this.writeAttribute(`eye${side}.d`, eye, 'd', '');
815
827
  continue;
816
828
  }
817
829
  const fr = eyeFrame(sgn * o.eyeSep, o.eyeLat, yaw, pitch);
@@ -823,12 +835,24 @@ export class NautAvatarEngine {
823
835
  const vx = lerp(0, fr.V[0], curve);
824
836
  const vy = lerp(1, fr.V[1], curve);
825
837
  const d = poly
826
- .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)}`)
827
839
  .join(' ');
828
- eye.setAttribute('d', d + ' Z');
829
- 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');
830
842
  }
831
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
+ }
832
856
  }
833
857
  /** Stable pseudo-random pick so the same id always gets the same appearance. */
834
858
  export function hashSeed(input) {
@@ -8,11 +8,15 @@ import { pointerGaze } from './naut-pointer-gaze.js';
8
8
  export { NAUT_COLORS, NAUT_COLOR_KEYS, NAUT_EXPRESSIONS, NAUT_GESTURES, NAUT_SHAPES, isNautColor, isNautShape, nautAppearanceForSeed, } from './naut-avatar-engine.js';
9
9
  /** A face can be carried across the page by something other than a mouse or a scroll. */
10
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;
11
14
  /** Where the mouse is, shared by every face so one listener serves the page. */
12
15
  const pointer = {
13
16
  x: 0,
14
17
  y: 0,
15
18
  active: false,
19
+ lastMovedAt: 0,
16
20
  /** Bumps whenever cached face rects could have gone stale. */
17
21
  moved: 0,
18
22
  onPointer(event) {
@@ -23,6 +27,7 @@ const pointer = {
23
27
  pointer.x = event.clientX;
24
28
  pointer.y = event.clientY;
25
29
  pointer.active = true;
30
+ pointer.lastMovedAt = performance.now();
26
31
  pointer.moved++;
27
32
  },
28
33
  onLeave() {
@@ -64,16 +69,21 @@ function aimAtPointer(entry, now) {
64
69
  const ticker = {
65
70
  entries: new Set(),
66
71
  frame: 0,
67
- last: 0,
68
72
  tick(now) {
69
- const dt = Math.min((now - ticker.last) / 1000, 0.05);
70
- ticker.last = now;
71
- for (const entry of ticker.entries)
72
- if (entry.visible && entry.follow)
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)
73
84
  aimAtPointer(entry, now);
74
- for (const entry of ticker.entries)
75
- if (entry.visible)
76
- entry.engine.update(dt);
85
+ entry.engine.update(dt);
86
+ }
77
87
  ticker.frame = ticker.entries.size ? requestAnimationFrame(ticker.tick) : 0;
78
88
  },
79
89
  add(entry) {
@@ -81,7 +91,6 @@ const ticker = {
81
91
  pointer.listen();
82
92
  ticker.entries.add(entry);
83
93
  if (!ticker.frame) {
84
- ticker.last = performance.now();
85
94
  ticker.frame = requestAnimationFrame(ticker.tick);
86
95
  }
87
96
  },
@@ -120,6 +129,7 @@ export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle
120
129
  rect: null,
121
130
  rectAt: -1,
122
131
  rectTime: 0,
132
+ lastUpdate: performance.now(),
123
133
  }));
124
134
  React.useImperativeHandle(forwardedRef, () => ({ play: (gesture) => engine.play(gesture) }), [
125
135
  engine,
@@ -150,7 +160,7 @@ export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle
150
160
  engine.update(0);
151
161
  }, [engine]);
152
162
  React.useEffect(() => {
153
- if (reducedMotion) {
163
+ if (!animated) {
154
164
  engine.settle();
155
165
  engine.update(0);
156
166
  return;
@@ -159,6 +169,7 @@ export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle
159
169
  entry.svg = svg;
160
170
  entry.rect = null;
161
171
  entry.rectAt = -1;
172
+ entry.lastUpdate = performance.now();
162
173
  ticker.add(entry);
163
174
  const observer = svg && typeof IntersectionObserver !== 'undefined'
164
175
  ? new IntersectionObserver((records) => {
@@ -173,12 +184,12 @@ export const NautAvatar = React.forwardRef(function NautAvatar({ shape = 'circle
173
184
  ticker.remove(entry);
174
185
  entry.svg = null;
175
186
  };
176
- }, [engine, entry, reducedMotion]);
187
+ }, [animated, engine, entry]);
177
188
  React.useEffect(() => {
178
- if (!reducedMotion)
189
+ if (animated)
179
190
  return;
180
191
  engine.settle();
181
192
  engine.update(0);
182
- }, [engine, reducedMotion, shape, color, expression]);
183
- 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))" })] })] }) }));
184
195
  });
@@ -129,8 +129,10 @@ export declare const NAUT_SCENES: {
129
129
  export type NautSceneName = keyof typeof NAUT_SCENES;
130
130
  export interface NautSceneProps {
131
131
  scene: NautSceneName;
132
+ /** Enables ambient drift and gestures. */
133
+ animated?: boolean;
132
134
  /** Sizes the scene; the faces lay themselves out inside whatever box this gives. */
133
135
  className?: string;
134
136
  }
135
137
  /** A small animated illustration built from Naut faces. Decorative — it carries no label. */
136
- export declare function NautScene({ scene, className }: NautSceneProps): React.ReactElement;
138
+ export declare function NautScene({ scene, animated, className, }: NautSceneProps): React.ReactElement;
@@ -126,12 +126,13 @@ const DRIFT_TILT = 4;
126
126
  /** Seconds between the starts of neighbouring drifts, so nothing moves in unison. */
127
127
  const DRIFT_OFFSET = 0.7;
128
128
  /** A small animated illustration built from Naut faces. Decorative — it carries no label. */
129
- export function NautScene({ scene, className }) {
129
+ export function NautScene({ scene, animated = true, className, }) {
130
130
  const reducedMotion = useReducedMotion();
131
+ const active = animated && !reducedMotion;
131
132
  const { faces, gesture, interval } = NAUT_SCENES[scene];
132
133
  const handles = React.useRef([]);
133
134
  React.useEffect(() => {
134
- if (reducedMotion)
135
+ if (!active)
135
136
  return;
136
137
  const pending = [];
137
138
  const run = () => {
@@ -145,13 +146,13 @@ export function NautScene({ scene, className }) {
145
146
  for (const timeout of pending)
146
147
  window.clearTimeout(timeout);
147
148
  };
148
- }, [gesture, interval, reducedMotion]);
149
+ }, [active, gesture, interval]);
149
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: {
150
151
  left: `${face.x * 100}%`,
151
152
  top: `${face.y * 100}%`,
152
153
  height: `${face.size * 100}%`,
153
154
  transform: 'translate(-50%, -50%)',
154
- }, children: _jsx(motion.span, { className: "block size-full", animate: reducedMotion
155
+ }, children: _jsx(motion.span, { className: "block size-full", animate: !active
155
156
  ? undefined
156
157
  : {
157
158
  y: [`-${DRIFT_TRAVEL}%`, `${DRIFT_TRAVEL}%`],
@@ -164,5 +165,5 @@ export function NautScene({ scene, className }) {
164
165
  ease: 'easeInOut',
165
166
  }, children: _jsx(NautAvatar, { ref: (handle) => {
166
167
  handles.current[index] = handle;
167
- }, shape: face.shape, color: face.color, expression: face.expression, seed: `${scene}-${index}` }) }) }, `${face.shape}-${face.color}-${index}`))) }) }));
168
+ }, shape: face.shape, color: face.color, expression: face.expression, idle: active, seed: `${scene}-${index}` }) }) }, `${face.shape}-${face.color}-${index}`))) }) }));
168
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';
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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atmos.build/ui",
3
- "version": "0.1.1",
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"