@nadicodeai/ui 6.0.0 → 6.0.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.
@@ -0,0 +1,211 @@
1
+ import { computeFrame } from "./frame.js";
2
+ import { createMotion, needsMotion, settled, stepPose, updateMotion, } from "./motion.js";
3
+ const attributeName = (prop) => prop.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
4
+ function write(element, attributes) {
5
+ for (const prop in attributes) {
6
+ const value = attributes[prop] ?? "";
7
+ if (prop === "display") {
8
+ element.style.display = value;
9
+ continue;
10
+ }
11
+ const name = attributeName(prop);
12
+ if (element.getAttribute(name) !== value)
13
+ element.setAttribute(name, value);
14
+ }
15
+ }
16
+ const pools = new WeakMap();
17
+ class Pool {
18
+ document;
19
+ entries = new Map();
20
+ observer;
21
+ reduced = typeof matchMedia === "function"
22
+ ? matchMedia("(prefers-reduced-motion: reduce)")
23
+ : { matches: false, addEventListener() { }, removeEventListener() { } };
24
+ pointer = { x: NaN, y: NaN };
25
+ frame;
26
+ lastFrame = -Infinity;
27
+ constructor(document) {
28
+ this.document = document;
29
+ this.observer =
30
+ typeof IntersectionObserver === "undefined"
31
+ ? undefined
32
+ : new IntersectionObserver((records) => {
33
+ for (const record of records) {
34
+ const entry = this.entries.get(record.target);
35
+ if (!entry)
36
+ continue;
37
+ if (record.isIntersecting && !entry.visible)
38
+ entry.motion.snap = true;
39
+ entry.visible = record.isIntersecting;
40
+ entry.dirty = true;
41
+ }
42
+ this.request();
43
+ }, { rootMargin: "80px" });
44
+ document.addEventListener("visibilitychange", this.onWake);
45
+ document.addEventListener("pointermove", this.onPointer, { passive: true });
46
+ document.addEventListener("pointerleave", this.onPointerLeave);
47
+ this.reduced.addEventListener("change", this.onWake);
48
+ window.addEventListener("resize", this.onWake);
49
+ }
50
+ onWake = () => {
51
+ for (const entry of this.entries.values()) {
52
+ entry.dirty = true;
53
+ entry.motion.snap = true;
54
+ entry.rect = null;
55
+ }
56
+ if (this.document.hidden && this.frame !== undefined) {
57
+ cancelAnimationFrame(this.frame);
58
+ this.frame = undefined;
59
+ }
60
+ this.request();
61
+ };
62
+ onPointer = (event) => {
63
+ if (event.pointerType === "touch")
64
+ return;
65
+ this.pointer.x = event.clientX;
66
+ this.pointer.y = event.clientY;
67
+ this.request();
68
+ };
69
+ onPointerLeave = () => {
70
+ this.pointer.x = NaN;
71
+ this.pointer.y = NaN;
72
+ this.request();
73
+ };
74
+ request() {
75
+ if (this.frame === undefined &&
76
+ !this.document.hidden &&
77
+ this.entries.size > 0 &&
78
+ typeof requestAnimationFrame === "function")
79
+ this.frame = requestAnimationFrame(this.tick);
80
+ }
81
+ aim(entry, now) {
82
+ const { motion } = entry;
83
+ if (motion.still || motion.view !== "front" || Number.isNaN(this.pointer.x)) {
84
+ motion.pointer.x = 0;
85
+ motion.pointer.y = 0;
86
+ return;
87
+ }
88
+ if (!entry.rect || now - entry.rectAt > 500) {
89
+ entry.rect = entry.svg.getBoundingClientRect();
90
+ entry.rectAt = now;
91
+ }
92
+ const rect = entry.rect;
93
+ if (!rect.width)
94
+ return;
95
+ const cx = rect.left + rect.width / 2;
96
+ const cy = rect.top + rect.height / 2;
97
+ const reach = Math.max(rect.width, 32) * 2.5;
98
+ const dx = this.pointer.x - cx;
99
+ const dy = this.pointer.y - cy;
100
+ const distance = Math.hypot(dx, dy);
101
+ const k = Math.min(1, distance / reach) / (distance || 1);
102
+ motion.pointer.x = Math.max(-1, Math.min(1, dx * k));
103
+ motion.pointer.y = Math.max(-1, Math.min(1, dy * k));
104
+ }
105
+ tick = (now) => {
106
+ this.frame = undefined;
107
+ if (this.document.hidden)
108
+ return;
109
+ if (now - this.lastFrame < 1000 / 30) {
110
+ this.request();
111
+ return;
112
+ }
113
+ const dt = Math.min(0.05, Math.max(0, (now - this.lastFrame) / 1000));
114
+ this.lastFrame = now;
115
+ let again = false;
116
+ for (const entry of this.entries.values()) {
117
+ if (!entry.visible)
118
+ continue;
119
+ const { motion } = entry;
120
+ motion.still = motion.paused || this.reduced.matches;
121
+ this.aim(entry, now);
122
+ const moving = needsMotion(motion, now);
123
+ if (entry.dirty || moving || entry.terminalFrameDue) {
124
+ const pose = stepPose(motion, now, entry.dirty ? 0 : dt);
125
+ this.paint(entry, pose, now);
126
+ this.setMode(entry, motion.still ? "still" : "live");
127
+ entry.dirty = false;
128
+ entry.terminalFrameDue = moving;
129
+ }
130
+ again ||= needsMotion(motion, now) && !settled(motion, now);
131
+ if (Number.isFinite(this.pointer.x) && motion.state === "idle" && !motion.still)
132
+ again = true;
133
+ }
134
+ if (again)
135
+ this.request();
136
+ };
137
+ paint(entry, pose, now) {
138
+ const frame = computeFrame(entry.motion, pose, now);
139
+ for (const part in frame) {
140
+ const element = entry.parts.get(part);
141
+ if (element)
142
+ write(element, frame[part]);
143
+ }
144
+ entry.svg.dataset.state = entry.motion.state;
145
+ }
146
+ setMode(entry, mode) {
147
+ if (entry.svg.dataset.renderMode === mode)
148
+ return;
149
+ entry.svg.dataset.renderMode = mode;
150
+ entry.callbacks.onModeChange?.(mode);
151
+ }
152
+ release(svg) {
153
+ this.observer?.unobserve(svg);
154
+ this.entries.delete(svg);
155
+ if (this.entries.size !== 0)
156
+ return;
157
+ if (this.frame !== undefined)
158
+ cancelAnimationFrame(this.frame);
159
+ this.observer?.disconnect();
160
+ this.document.removeEventListener("visibilitychange", this.onWake);
161
+ this.document.removeEventListener("pointermove", this.onPointer);
162
+ this.document.removeEventListener("pointerleave", this.onPointerLeave);
163
+ this.reduced.removeEventListener("change", this.onWake);
164
+ window.removeEventListener("resize", this.onWake);
165
+ pools.delete(this.document);
166
+ }
167
+ }
168
+ export function mountForma(svg, options, callbacks = {}) {
169
+ const doc = svg.ownerDocument;
170
+ let pool = pools.get(doc);
171
+ if (!pool) {
172
+ pool = new Pool(doc);
173
+ pools.set(doc, pool);
174
+ }
175
+ if (pool.entries.has(svg))
176
+ throw new Error("This element already has a Forma avatar");
177
+ const parts = new Map();
178
+ for (const element of svg.querySelectorAll("[data-part]"))
179
+ parts.set(element.getAttribute("data-part"), element);
180
+ const entry = {
181
+ svg,
182
+ parts,
183
+ motion: createMotion(options, performance.now()),
184
+ visible: !pool.observer,
185
+ dirty: true,
186
+ terminalFrameDue: false,
187
+ callbacks,
188
+ rect: null,
189
+ rectAt: -Infinity,
190
+ };
191
+ const activePool = pool;
192
+ pool.entries.set(svg, entry);
193
+ pool.observer?.observe(svg);
194
+ pool.request();
195
+ let disposed = false;
196
+ return {
197
+ update(next) {
198
+ if (disposed)
199
+ return;
200
+ updateMotion(entry.motion, next, performance.now());
201
+ entry.dirty = true;
202
+ activePool.request();
203
+ },
204
+ dispose() {
205
+ if (disposed)
206
+ return;
207
+ disposed = true;
208
+ activePool.release(svg);
209
+ },
210
+ };
211
+ }
@@ -0,0 +1,4 @@
1
+ import { type Frame } from "./frame.js";
2
+ import type { ResolvedFormaOptions } from "./model.js";
3
+ export declare function stillFrame(options: ResolvedFormaOptions): Frame;
4
+ //# sourceMappingURL=still.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"still.d.ts","sourceRoot":"","sources":["../../../src/internal/forma/still.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,KAAK,EAAgB,MAAM,SAAS,CAAC;AACnD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAGpD,wBAAgB,UAAU,CAAC,OAAO,EAAE,oBAAoB,GAAG,KAAK,CAK/D"}
@@ -0,0 +1,8 @@
1
+ import { computeFrame } from "./frame.js";
2
+ import { createMotion, stepPose } from "./motion.js";
3
+ export function stillFrame(options) {
4
+ const motion = createMotion({ ...options, paused: true }, 0);
5
+ motion.still = true;
6
+ const pose = stepPose(motion, 0, 0);
7
+ return computeFrame(motion, pose, 0);
8
+ }
package/docs/contract.md CHANGED
@@ -107,7 +107,7 @@ this same renderer.
107
107
 
108
108
  ## Admission Rules
109
109
 
110
- Assistant conversation and runtime APIs enter through `@nadicodeai/ui/agents`; Agent identity enters through `@nadicodeai/ui/components/agent-card` and `@nadicodeai/ui/components/agent-visual`. The consuming application still constructs the runtime and owns transport, persistence, permissions, tools, approvals, and failure policy. Shared non-Agent product components (a shell, a settings surface, a generic section, a diff, or a file preview) are admitted only with a named consuming surface landing in the same change. App-bespoke UI and behavior (window chrome, PTY/xterm embeds, queues, approvals, and persistence) stay in the consuming app; they never become package abstractions. `@nadicodeai/ui/threshold` is the plan-approved package surface for the room, Nadia's presence, the status orb and the typed line used by the Nadia desktop app. The screen, its copy and its state machine stay in the consuming app, and the entry ships no screen. Its contract is [`threshold.md`](threshold.md). A component left unconsumed after its target migration ships gets removed.
110
+ Assistant conversation and runtime APIs enter through `@nadicodeai/ui/agents`; Agent identity enters through `@nadicodeai/ui/components/agent-card`, `@nadicodeai/ui/components/agent-visual`, and the Forma avatars in `@nadicodeai/ui/components/forma-avatar` (owned by [`forma-avatar.md`](forma-avatar.md)). The consuming application still constructs the runtime and owns transport, persistence, permissions, tools, approvals, and failure policy. Shared non-Agent product components (a shell, a settings surface, a generic section, a diff, or a file preview) are admitted only with a named consuming surface landing in the same change. App-bespoke UI and behavior (window chrome, PTY/xterm embeds, queues, approvals, and persistence) stay in the consuming app; they never become package abstractions. `@nadicodeai/ui/threshold` is the plan-approved package surface for the room, Nadia's presence, the status orb and the typed line used by the Nadia desktop app. The screen, its copy and its state machine stay in the consuming app, and the entry ships no screen. Its contract is [`threshold.md`](threshold.md). A component left unconsumed after its target migration ships gets removed.
111
111
 
112
112
  ### Agent identity presentation
113
113
 
@@ -0,0 +1,92 @@
1
+ # Forma Avatars
2
+
3
+ Forma is the avatar family for the Agents a company operates inside Nadia,
4
+ and for Nadia herself. It is a character: a coloured shell with a visor and
5
+ two eyes, alive through the approved lifecycle gestures. `@nadicodeai/ui`
6
+ owns its rendering as one SVG per avatar; consuming products own the stored
7
+ appearance, the Agent data, and every fact about status or work.
8
+
9
+ This system is distinct from Nadia's portrait, which stays the one canonical
10
+ identity in `brand/AGENTS.md` "Nadia's identity". Nadia's avatar here is her
11
+ approved Forma shell: the tricolour, the signature eyes and lash flicks, and
12
+ the Italy hallmark on her right flank (Natural Earth 110m Italy from
13
+ AshKyd/geojson-regions, projected once with d3-geo 3.1.1 into a 512-unit box).
14
+
15
+ ## Forms and colours
16
+
17
+ A customer picks a form and a colour. Forms are shapes, never roles: choosing
18
+ Goccia grants no tools and changes no instructions, and the product names the
19
+ job. The closed set is Nadia plus seven forms:
20
+
21
+ | Form | Silhouette | Default shell | Visor | Eyes |
22
+ | --- | --- | --- | --- | --- |
23
+ | `nadia` | The approved shell, tricolour, fixed | verde | black | signature mint |
24
+ | `goccia` | Drop | rosso | black | white |
25
+ | `nuvola` | Cloud | cobalto | ivory | ink |
26
+ | `triangolo` | Rounded triangle | verde | ivory | ink |
27
+ | `esagono` | Hexagon | giallo | black | white |
28
+ | `quadrato` | Squircle | arancio | ivory | ink |
29
+ | `pillola` | Stadium | verde | black | white |
30
+ | `uovo` | Egg | cobalto | black | white |
31
+
32
+ Shell colours are the Agent identity palette (`identity-verde`, `-cobalto`,
33
+ `-rosso`, `-arancio`, `-giallo`) plus `forma-avorio`; the visor, bezel,
34
+ Nadia's eye light and the blocked sand are the `forma-*` tokens. All of them are owned by
35
+ `packages/design-system/DESIGN.md` and read through their CSS custom
36
+ properties, so an avatar carries no colour of its own. `uovo` is the form an
37
+ Agent wears until a customer chooses; it is never inferred from a name, an
38
+ id, a role label, or a position in a list.
39
+
40
+ ## Construction
41
+
42
+ Every form carries a core sphere inside its silhouette. The one approved
43
+ visor and the form's eyes ride on that sphere, seen through the engine's
44
+ camera (perspective from 5.2 radii), and the silhouette clips them: the face
45
+ is the same size and curvature on every form, and turning the head moves it
46
+ across the shell the same way, foreshortening at the rim.
47
+
48
+ The lighting reproduces the engine's studio, number for number where a
49
+ gradient can: the shell is enamel deepened by its own pigment toward the
50
+ rim (a multiply layer), shaded by the hemisphere light toward the floor,
51
+ lit by the wide top-left key card, a hot spot, and the right rim card; the
52
+ visor is black glass with one crisp reflection at its top-left corner, a
53
+ soft haze across the top, the right-edge reflection, and a thin bezel lit
54
+ only where the key reaches it; the eyes carry their halo and a glint, and
55
+ Nadia's Italy hallmark is the engine's ivory engraving at longitude 1.24 on
56
+ her right flank, visible from the side. Blocked morphs every form into the same torus and drains
57
+ its pigment 28% toward `forma-sabbia`, as the approved renders do.
58
+
59
+ ## States and motion
60
+
61
+ Six states: `idle`, `working`, `thinking`, `waiting`, `blocked`, `done`. The
62
+ choreography is the approved engine's, number for number: the idle sway and
63
+ pointer-following gaze; working's acknowledging nod, scan, beat, three pulsing
64
+ ticks and two orbiting bead trails; thinking's tilt and three thought beads;
65
+ waiting's pause bars; blocked's morph into the ring and bar; done's wind-up,
66
+ full turn, hop, happy eyes and the three-ribbon celebration. A completion
67
+ celebrates once per real transition or `motionKey` change; an initially
68
+ completed avatar holds its finished pose. Waiting and blocked always face
69
+ forward; `view="side"` shows the flank otherwise.
70
+
71
+ One loop per document drives every mounted avatar at up to 30 frames a
72
+ second. Offscreen and hidden avatars do not render, settled states stop the
73
+ loop, and the pointer turns the heads nearest to it. `paused` and reduced
74
+ motion hold the state's deterministic resting frame, which is also what the
75
+ server renders, effects included (a working still carries its orbit beads, a
76
+ thinking still its thought beads), so no surface ever shows an empty slot and
77
+ no still image is shipped.
78
+
79
+ ## Package shape
80
+
81
+ `FormaAvatar` (`@nadicodeai/ui/components/forma-avatar`) takes `form`,
82
+ `color`, `state`, `size` (12–512 CSS pixels), `paused`, `view`, and
83
+ `motionKey`. It is decorative by default; a visual that is itself the
84
+ choice, as in a picker, opts into `decorative={false}` with a `label`. It
85
+ exposes `data-forma-avatar`, `data-state`, `data-color` on its host and
86
+ `data-render-mode` (`still` or `live`) on the SVG. The model's closed lists
87
+ (`formaForms`, `formaChoices`, `formaColors`, `formaStates`, `formDetails`,
88
+ `defaultForm`) are exported beside it for pickers and stored-appearance
89
+ mapping; the geometry, rig and frame computation are internal.
90
+
91
+ Additions to the family go through this document and the design-system
92
+ token contract, never through an ad-hoc visual.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nadicodeai/ui",
3
- "version": "6.0.0",
3
+ "version": "6.0.1",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -70,7 +70,7 @@
70
70
  "@assistant-ui/react": "0.15.18",
71
71
  "@assistant-ui/react-markdown": "0.14.14",
72
72
  "@base-ui/react": "^1.8.0",
73
- "@nadicodeai/design-system": "6.0.0",
73
+ "@nadicodeai/design-system": "6.0.1",
74
74
  "@paper-design/shaders": "0.0.80",
75
75
  "class-variance-authority": "^0.7.1",
76
76
  "clsx": "^2.1.1",