@nadicodeai/ui 4.0.5 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +4 -0
  3. package/dist/components/activity-orb.d.ts +21 -0
  4. package/dist/components/activity-orb.d.ts.map +1 -0
  5. package/dist/components/activity-orb.js +26 -0
  6. package/dist/components/threshold/canvas-budget.d.ts +10 -0
  7. package/dist/components/threshold/canvas-budget.d.ts.map +1 -0
  8. package/dist/components/threshold/canvas-budget.js +54 -0
  9. package/dist/components/threshold/index.d.ts +6 -0
  10. package/dist/components/threshold/index.d.ts.map +1 -0
  11. package/dist/components/threshold/index.js +5 -0
  12. package/dist/components/threshold/orb.d.ts +14 -0
  13. package/dist/components/threshold/orb.d.ts.map +1 -0
  14. package/dist/components/threshold/orb.js +17 -0
  15. package/dist/components/threshold/presence.d.ts +27 -0
  16. package/dist/components/threshold/presence.d.ts.map +1 -0
  17. package/dist/components/threshold/presence.js +159 -0
  18. package/dist/components/threshold/room.d.ts +33 -0
  19. package/dist/components/threshold/room.d.ts.map +1 -0
  20. package/dist/components/threshold/room.js +168 -0
  21. package/dist/components/threshold/threshold.css +228 -0
  22. package/dist/components/threshold/typed-line.d.ts +8 -0
  23. package/dist/components/threshold/typed-line.d.ts.map +1 -0
  24. package/dist/components/threshold/typed-line.js +43 -0
  25. package/dist/components/threshold/use-reduced-motion.d.ts +10 -0
  26. package/dist/components/threshold/use-reduced-motion.d.ts.map +1 -0
  27. package/dist/components/threshold/use-reduced-motion.js +32 -0
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -0
  31. package/dist/internal/activity-orb.d.ts +27 -0
  32. package/dist/internal/activity-orb.d.ts.map +1 -0
  33. package/dist/internal/activity-orb.js +150 -0
  34. package/dist/internal/agent-visual/color.d.ts.map +1 -1
  35. package/dist/internal/agent-visual/color.js +13 -7
  36. package/dist/threshold.d.ts +2 -0
  37. package/dist/threshold.d.ts.map +1 -0
  38. package/dist/threshold.js +13 -0
  39. package/docs/activity.md +120 -0
  40. package/docs/contract.md +8 -2
  41. package/docs/threshold.md +221 -0
  42. package/package.json +8 -2
package/AGENTS.md CHANGED
@@ -8,6 +8,10 @@ exports.
8
8
  - Consumer setup: `docs/consuming-cross-repo.md`.
9
9
  - Tokens, CSS, motion, and assets: `../design-system/DESIGN.md`.
10
10
  - Agent visuals: `docs/agent-visual.md`.
11
+ - System activity indicators: `docs/activity.md`.
12
+ - First run, sign-in, and repair surfaces: `docs/threshold.md`; the
13
+ primitives are `@nadicodeai/ui/threshold`, with
14
+ `@nadicodeai/ui/threshold.css` beside them.
11
15
  - Package releases: `../../docs/agents/publish.md`.
12
16
 
13
17
  `npm run dev -w @nadicodeai/ui` watches source and refreshes `dist/`;
package/README.md CHANGED
@@ -89,6 +89,10 @@ from source and package exports, not from prose. assistant-ui runtime ownership
89
89
  and migration readiness live in the linked package docs; this README does not
90
90
  summarize them.
91
91
 
92
+ System activity uses the shared `ActivityOrb` component. See
93
+ [`docs/activity.md`](docs/activity.md) for the activity vocabulary, selected
94
+ motions, brand treatment, sizes, accessibility and usage examples.
95
+
92
96
  ## Layout primitives
93
97
 
94
98
  `SeamGrid` is a supported low-level layout primitive: the single owner of the
@@ -0,0 +1,21 @@
1
+ import { type CSSProperties } from "react";
2
+ import { type ActivityKind, type ActivityOrbSize } from "../internal/activity-orb.js";
3
+ export type { ActivityKind, ActivityOrbSize } from "../internal/activity-orb.js";
4
+ export type ActivityOrbProps = {
5
+ activity: ActivityKind;
6
+ className?: string;
7
+ style?: CSSProperties;
8
+ /** Freeze the animation for a preview or a user-controlled motion pause. */
9
+ paused?: boolean;
10
+ /** 20 inline, 64 within a region, 88 at an application threshold. */
11
+ size?: ActivityOrbSize;
12
+ } & ({
13
+ decorative: true;
14
+ label?: never;
15
+ } | {
16
+ decorative?: false;
17
+ label: string;
18
+ });
19
+ /** The shared system-activity family. The consumer owns the actual operation. */
20
+ export declare function ActivityOrb({ activity, className, decorative, label, paused, size, style, ...props }: ActivityOrbProps): import("react").JSX.Element;
21
+ //# sourceMappingURL=activity-orb.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"activity-orb.d.ts","sourceRoot":"","sources":["../../src/components/activity-orb.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,aAAa,EAAqB,MAAM,OAAO,CAAC;AAE9D,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,eAAe,EAAoB,MAAM,yBAAyB,CAAC;AAGpG,YAAY,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAE7E,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,EAAE,YAAY,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,qEAAqE;IACrE,IAAI,CAAC,EAAE,eAAe,CAAC;CACxB,GAAG,CAAC;IAAE,UAAU,EAAE,IAAI,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GAAG;IAAE,UAAU,CAAC,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAElF,iFAAiF;AACjF,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,SAAS,EACT,UAAkB,EAClB,KAAK,EACL,MAAc,EACd,IAAS,EACT,KAAK,EACL,GAAG,KAAK,EACT,EAAE,gBAAgB,+BAmClB"}
@@ -0,0 +1,26 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useRef } from "react";
4
+ import { mountActivityOrb } from "../internal/activity-orb.js";
5
+ import { cn } from "../lib/utils.js";
6
+ /** The shared system-activity family. The consumer owns the actual operation. */
7
+ export function ActivityOrb({ activity, className, decorative = false, label, paused = false, size = 20, style, ...props }) {
8
+ const canvas = useRef(null);
9
+ const renderer = useRef(null);
10
+ const initiallyPaused = useRef(paused);
11
+ useEffect(() => {
12
+ if (!canvas.current)
13
+ return;
14
+ const mounted = mountActivityOrb(canvas.current, activity, size, initiallyPaused.current);
15
+ renderer.current = mounted;
16
+ return () => {
17
+ mounted.destroy();
18
+ renderer.current = null;
19
+ };
20
+ }, [activity, size]);
21
+ useEffect(() => {
22
+ initiallyPaused.current = paused;
23
+ renderer.current?.setPaused(paused);
24
+ }, [activity, paused, size]);
25
+ return (_jsx("canvas", { ...props, "aria-hidden": decorative || undefined, "aria-label": decorative ? undefined : label, className: cn("inline-block shrink-0 align-middle", className), "data-activity": activity, "data-slot": "activity-orb", height: size, ref: canvas, role: decorative ? undefined : "img", style: { ...style, height: size, width: size }, width: size, children: decorative ? null : label }));
26
+ }
@@ -0,0 +1,10 @@
1
+ /** True while a primitive that outranks the room owns the window's canvas. */
2
+ export declare function canvasHeld(): boolean;
3
+ /**
4
+ * Take the window's canvas. Returns the release, which is idempotent: a
5
+ * teardown that runs twice releases once.
6
+ */
7
+ export declare function holdCanvas(): () => void;
8
+ /** Whether the room must yield its canvas right now. Live. */
9
+ export declare function useCanvasHeld(): boolean;
10
+ //# sourceMappingURL=canvas-budget.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canvas-budget.d.ts","sourceRoot":"","sources":["../../../src/components/threshold/canvas-budget.ts"],"names":[],"mappings":"AA0BA,8EAA8E;AAC9E,wBAAgB,UAAU,IAAI,OAAO,CAEpC;AAED;;;GAGG;AACH,wBAAgB,UAAU,IAAI,MAAM,IAAI,CAWvC;AAWD,8DAA8D;AAC9D,wBAAgB,aAAa,IAAI,OAAO,CAEvC"}
@@ -0,0 +1,54 @@
1
+ "use client";
2
+ import { useSyncExternalStore } from "react";
3
+ /*
4
+ * One shader canvas per window: the living-elements register's rule, held
5
+ * here so no screen can break it by composition.
6
+ *
7
+ * Two threshold primitives can paint with a shader, the room and the arriving
8
+ * presence, and a threshold mounts both. The presence's water outranks the
9
+ * room: while Nadia is arriving her water is the window's one live canvas and
10
+ * the room shows its still field; when she stands clear the water leaves and
11
+ * the room's canvas is the one that paints. The presence takes a hold for as
12
+ * long as its water is wanted, and the room yields while any hold is out.
13
+ *
14
+ * The budget is per window, so it is module state rather than a context: a
15
+ * provider would let a surface forget to wrap both primitives, and the rule
16
+ * would then hold only where someone remembered it.
17
+ */
18
+ let holds = 0;
19
+ const listeners = new Set();
20
+ const notify = () => {
21
+ for (const listener of listeners)
22
+ listener();
23
+ };
24
+ /** True while a primitive that outranks the room owns the window's canvas. */
25
+ export function canvasHeld() {
26
+ return holds > 0;
27
+ }
28
+ /**
29
+ * Take the window's canvas. Returns the release, which is idempotent: a
30
+ * teardown that runs twice releases once.
31
+ */
32
+ export function holdCanvas() {
33
+ holds += 1;
34
+ notify();
35
+ let released = false;
36
+ return () => {
37
+ if (released)
38
+ return;
39
+ released = true;
40
+ holds -= 1;
41
+ notify();
42
+ };
43
+ }
44
+ function subscribe(listener) {
45
+ listeners.add(listener);
46
+ return () => {
47
+ listeners.delete(listener);
48
+ };
49
+ }
50
+ const serverSnapshot = () => false;
51
+ /** Whether the room must yield its canvas right now. Live. */
52
+ export function useCanvasHeld() {
53
+ return useSyncExternalStore(subscribe, canvasHeld, serverSnapshot);
54
+ }
@@ -0,0 +1,6 @@
1
+ export { Orb, type OrbProps, type OrbSize, type OrbState, type OrbTheme } from "./orb.js";
2
+ export { arrivalOpacity, NadiaPresence, type NadiaPresenceProps, type PresenceState, } from "./presence.js";
3
+ export { ThresholdRoom, type ThresholdRoomProps } from "./room.js";
4
+ export { TypedLine, type TypedLineProps } from "./typed-line.js";
5
+ export { useReducedMotion } from "./use-reduced-motion.js";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/threshold/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,KAAK,QAAQ,EAAE,KAAK,OAAO,EAAE,KAAK,QAAQ,EAAE,KAAK,QAAQ,EAAE,MAAM,OAAO,CAAC;AACvF,OAAO,EACL,cAAc,EACd,aAAa,EACb,KAAK,kBAAkB,EACvB,KAAK,aAAa,GACnB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,QAAQ,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9D,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC"}
@@ -0,0 +1,5 @@
1
+ export { Orb } from "./orb.js";
2
+ export { arrivalOpacity, NadiaPresence, } from "./presence.js";
3
+ export { ThresholdRoom } from "./room.js";
4
+ export { TypedLine } from "./typed-line.js";
5
+ export { useReducedMotion } from "./use-reduced-motion.js";
@@ -0,0 +1,14 @@
1
+ import { type ActivityOrbSize } from "../activity-orb.js";
2
+ export type OrbState = "working" | "connecting" | "absent";
3
+ export type OrbTheme = "auto" | "dark" | "light";
4
+ export type OrbSize = ActivityOrbSize;
5
+ export type OrbProps = {
6
+ className?: string;
7
+ label: string;
8
+ size?: OrbSize;
9
+ state: OrbState;
10
+ theme?: OrbTheme;
11
+ };
12
+ /** Preserve the threshold API while using the one shared activity renderer. */
13
+ export declare function Orb({ className, label, size, state, theme }: OrbProps): import("react").JSX.Element | null;
14
+ //# sourceMappingURL=orb.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orb.d.ts","sourceRoot":"","sources":["../../../src/components/threshold/orb.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAe,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAG9E,MAAM,MAAM,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,QAAQ,CAAC;AAC3D,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AACjD,MAAM,MAAM,OAAO,GAAG,eAAe,CAAC;AAEtC,MAAM,MAAM,QAAQ,GAAG;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,QAAQ,CAAC;IAChB,KAAK,CAAC,EAAE,QAAQ,CAAC;CAClB,CAAC;AAUF,+EAA+E;AAC/E,wBAAgB,GAAG,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,IAAS,EAAE,KAAK,EAAE,KAAc,EAAE,EAAE,QAAQ,sCAYnF"}
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { ActivityOrb } from "../activity-orb.js";
4
+ import { cn } from "../../lib/utils.js";
5
+ // A threshold deliberately fixes its appearance. Normal product surfaces
6
+ // inherit their ink; the shared renderer needs no knowledge of app themes.
7
+ const THEME_INK = {
8
+ auto: "inherit",
9
+ dark: "var(--nc-dark-ink)",
10
+ light: "var(--nc-ink)",
11
+ };
12
+ /** Preserve the threshold API while using the one shared activity renderer. */
13
+ export function Orb({ className, label, size = 88, state, theme = "dark" }) {
14
+ if (state === "absent")
15
+ return null;
16
+ return (_jsx(ActivityOrb, { activity: state === "working" ? "processing" : "connecting", className: cn("nc-threshold-orb", className), "data-state": state, label: label, size: size, style: { color: THEME_INK[theme] } }));
17
+ }
@@ -0,0 +1,27 @@
1
+ export type PresenceState = "arriving" | "present" | "ready" | "error";
2
+ export type NadiaPresenceProps = {
3
+ className?: string;
4
+ /** The accessible name of the arrival, read by the hidden progress bar. */
5
+ label: string;
6
+ /** 0 while she is behind the field, 1 when she stands clear. */
7
+ reveal: number;
8
+ /**
9
+ * The portrait master. The package imports no binary asset of its own, so
10
+ * the consuming surface resolves `@nadicodeai/design-system/assets/nadia-solid-surface`
11
+ * with its own bundler and passes the URL here.
12
+ */
13
+ src: string;
14
+ state: PresenceState;
15
+ };
16
+ /** The arrival curve: a trace at the start, not a face already half-built. */
17
+ export declare function arrivalOpacity(reveal: number): number;
18
+ /**
19
+ * Nadia at a threshold.
20
+ *
21
+ * `reveal` drives one opacity, so nothing re-renders per frame and the value
22
+ * a Customer sees is the value assistive technology is told. The lower edge
23
+ * dissolves into the room instead of ending on a rectangle. At `state="ready"`
24
+ * she takes one short verde vivo beat, her presence accent, and settles.
25
+ */
26
+ export declare function NadiaPresence({ className, label, reveal, src, state }: NadiaPresenceProps): import("react").JSX.Element;
27
+ //# sourceMappingURL=presence.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presence.d.ts","sourceRoot":"","sources":["../../../src/components/threshold/presence.tsx"],"names":[],"mappings":"AA+CA,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;AAEvE,MAAM,MAAM,kBAAkB,GAAG;IAC/B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,aAAa,CAAC;CACtB,CAAC;AAIF,8EAA8E;AAC9E,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAErD;AAgBD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,kBAAkB,+BAsIzF"}
@@ -0,0 +1,159 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { defaultObjectSizing, ShaderFitOptions, ShaderMount, waterFragmentShader, } from "@paper-design/shaders";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { resolveIdentityColor } from "../../internal/agent-visual/color.js";
6
+ import { cn } from "../../lib/utils.js";
7
+ import { holdCanvas } from "./canvas-budget.js";
8
+ import { useReducedMotion } from "./use-reduced-motion.js";
9
+ /*
10
+ * Nadia's presence: the portrait master itself, arriving through the room. No
11
+ * medallion, no ring, no bar. How much of her you can see is the progress.
12
+ *
13
+ * While she is arriving the portrait carries Paper Shaders' Water at a
14
+ * restrained intensity, so the field she is coming through moves across her
15
+ * without taking her geometry apart. Every amplitude is scaled by how much of
16
+ * the arrival is left, so the water is gone by the time she stands clear. At
17
+ * reveal 1, and in every other state, this is the plain portrait.
18
+ *
19
+ * The water is the window's one shader canvas while it is wanted. The presence
20
+ * holds the canvas budget from the moment the water is asked for until it is
21
+ * torn down, and the room keeps its still field for exactly that long
22
+ * (`canvas-budget.ts`).
23
+ */
24
+ const ARRIVAL_FLOOR = 0.025;
25
+ const ARRIVAL_CURVE = 1.25;
26
+ const WATER_CAUSTIC = 0.35;
27
+ const WATER_EDGES = 0.4;
28
+ const WATER_WAVES = 0.3;
29
+ const WATER_LAYERING = 0.4;
30
+ const WATER_HIGHLIGHTS = 0.12;
31
+ const WATER_SIZE = 1;
32
+ const WATER_SPEED = 1;
33
+ const WATER_FRAME = 0;
34
+ const WATER_MIN_PIXEL_RATIO = 1;
35
+ const WATER_MAX_PIXEL_COUNT = 1_024_000;
36
+ const PRESENCE_FIELD_TOKEN = "--nc-identity-verde";
37
+ const TRANSPARENT = [0, 0, 0, 0];
38
+ const clampUnit = (value) => Math.min(Math.max(value, 0), 1);
39
+ /** The arrival curve: a trace at the start, not a face already half-built. */
40
+ export function arrivalOpacity(reveal) {
41
+ return ARRIVAL_FLOOR + (1 - ARRIVAL_FLOOR) * clampUnit(reveal) ** ARRIVAL_CURVE;
42
+ }
43
+ function waterUniforms(reveal, field) {
44
+ const remaining = 1 - clampUnit(reveal);
45
+ return {
46
+ u_caustic: WATER_CAUSTIC * remaining,
47
+ u_colorBack: TRANSPARENT,
48
+ u_colorHighlight: field,
49
+ u_edges: WATER_EDGES * remaining,
50
+ u_highlights: WATER_HIGHLIGHTS,
51
+ u_layering: WATER_LAYERING,
52
+ u_size: WATER_SIZE,
53
+ u_waves: WATER_WAVES * remaining,
54
+ };
55
+ }
56
+ /**
57
+ * Nadia at a threshold.
58
+ *
59
+ * `reveal` drives one opacity, so nothing re-renders per frame and the value
60
+ * a Customer sees is the value assistive technology is told. The lower edge
61
+ * dissolves into the room instead of ending on a rectangle. At `state="ready"`
62
+ * she takes one short verde vivo beat, her presence accent, and settles.
63
+ */
64
+ export function NadiaPresence({ className, label, reveal, src, state }) {
65
+ const still = useReducedMotion();
66
+ const hostRef = useRef(null);
67
+ const mountRef = useRef(null);
68
+ const [surface, setSurface] = useState("portrait");
69
+ const value = clampUnit(reveal);
70
+ const arriving = state === "arriving";
71
+ const wet = arriving && value < 1 && !still;
72
+ useEffect(() => {
73
+ const host = hostRef.current;
74
+ if (!host || !wet)
75
+ return;
76
+ const field = resolveIdentityColor(host, PRESENCE_FIELD_TOKEN);
77
+ if (!field)
78
+ return;
79
+ let cancelled = false;
80
+ let canvas = null;
81
+ // Taken before the image decodes: the room stays still while her water is
82
+ // on its way, not only once it paints.
83
+ const releaseCanvas = holdCanvas();
84
+ const clearCanvases = () => {
85
+ for (const element of host.querySelectorAll("canvas"))
86
+ element.remove();
87
+ host.removeAttribute("data-paper-shader");
88
+ };
89
+ const teardown = () => {
90
+ canvas?.removeEventListener("webglcontextlost", handleContextLost);
91
+ canvas = null;
92
+ try {
93
+ mountRef.current?.dispose();
94
+ }
95
+ catch {
96
+ // The plain portrait is what stays; vendor cleanup failing is not
97
+ // allowed to remove Nadia from the screen.
98
+ }
99
+ mountRef.current = null;
100
+ clearCanvases();
101
+ // After disposal, so the room's canvas is never alive beside this one.
102
+ releaseCanvas();
103
+ };
104
+ function handleContextLost(event) {
105
+ event.preventDefault();
106
+ teardown();
107
+ setSurface("portrait");
108
+ }
109
+ const image = new Image();
110
+ image.decoding = "async";
111
+ image.src = src;
112
+ const mount = async () => {
113
+ try {
114
+ await image.decode();
115
+ if (cancelled)
116
+ return;
117
+ mountRef.current = new ShaderMount(host, waterFragmentShader, {
118
+ ...waterUniforms(value, field),
119
+ u_fit: ShaderFitOptions.contain,
120
+ u_image: image,
121
+ u_offsetX: defaultObjectSizing.offsetX,
122
+ u_offsetY: defaultObjectSizing.offsetY,
123
+ u_originX: defaultObjectSizing.originX,
124
+ u_originY: defaultObjectSizing.originY,
125
+ u_rotation: defaultObjectSizing.rotation,
126
+ u_scale: defaultObjectSizing.scale,
127
+ u_worldHeight: defaultObjectSizing.worldHeight,
128
+ u_worldWidth: defaultObjectSizing.worldWidth,
129
+ }, undefined, WATER_SPEED, WATER_FRAME, WATER_MIN_PIXEL_RATIO, WATER_MAX_PIXEL_COUNT, ["u_image"]);
130
+ canvas = mountRef.current.canvasElement;
131
+ canvas.addEventListener("webglcontextlost", handleContextLost);
132
+ setSurface("water");
133
+ }
134
+ catch {
135
+ // No WebGL, no palette, or an image that will not decode: she arrives
136
+ // as the plain portrait, which is the whole design at reveal 1 anyway.
137
+ teardown();
138
+ setSurface("portrait");
139
+ }
140
+ };
141
+ void mount();
142
+ return () => {
143
+ cancelled = true;
144
+ teardown();
145
+ setSurface("portrait");
146
+ };
147
+ }, [src, wet]);
148
+ useEffect(() => {
149
+ const host = hostRef.current;
150
+ const mount = mountRef.current;
151
+ if (!host || !mount)
152
+ return;
153
+ const field = resolveIdentityColor(host, PRESENCE_FIELD_TOKEN);
154
+ if (!field)
155
+ return;
156
+ mount.setUniforms(waterUniforms(value, field));
157
+ }, [value]);
158
+ return (_jsxs("div", { className: cn("nc-threshold-presence", className), "data-state": state, "data-still": still || undefined, style: { "--threshold-portrait": `url("${src}")` }, children: [_jsx("div", { "aria-hidden": "true", className: "nc-threshold-presence-figure", "data-surface": surface, ref: hostRef, style: { opacity: arriving ? arrivalOpacity(value) : 1 }, children: _jsx("img", { alt: "", className: "nc-threshold-presence-portrait", draggable: false, src: src }) }), _jsx("div", { "aria-label": label, "aria-valuemax": 100, "aria-valuemin": 0, "aria-valuenow": Math.floor(value * 100), className: "nc-threshold-sr", role: "progressbar" })] }));
159
+ }
@@ -0,0 +1,33 @@
1
+ export type ThresholdRoomProps = {
2
+ className?: string;
3
+ /** Repair and error states quiet the room instead of hiding it. */
4
+ dimmed?: boolean;
5
+ /**
6
+ * How much of the room's pace is running, 0 to 1. The captured recipe is
7
+ * energy 1; the first-run screen holds it there while Nadia is still
8
+ * arriving and drops it to 0 when she has, which stills the field. Sign-in,
9
+ * success and every repair state pass 0.
10
+ */
11
+ energy?: number;
12
+ /** Success: the field's colour becomes verde vivo, Nadia's presence accent. */
13
+ ready?: boolean;
14
+ };
15
+ /**
16
+ * The threshold's background field.
17
+ *
18
+ * It paints absolutely inside its own relative, isolated parent and never
19
+ * against the viewport, so a Portal panel can hold one without covering the
20
+ * page. It takes no pointer events and carries no words.
21
+ *
22
+ * When WebGL is unavailable, when the design-system tokens are not in the
23
+ * cascade above it, or when the context is lost after mounting, the element
24
+ * keeps its still field: a token-built radial gradient that needs no GPU. The
25
+ * room is never blank.
26
+ *
27
+ * The window carries one shader canvas. While Nadia's water is painting her
28
+ * arrival, the room holds this same still field and mounts nothing; the
29
+ * moment she stands clear the water leaves and the room's canvas is the one
30
+ * that paints (`canvas-budget.ts`).
31
+ */
32
+ export declare function ThresholdRoom({ className, dimmed, energy, ready, }: ThresholdRoomProps): import("react").JSX.Element;
33
+ //# sourceMappingURL=room.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"room.d.ts","sourceRoot":"","sources":["../../../src/components/threshold/room.tsx"],"names":[],"mappings":"AAwCA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+EAA+E;IAC/E,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAIF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAAC,EAC5B,SAAS,EACT,MAAc,EACd,MAAU,EACV,KAAa,GACd,EAAE,kBAAkB,+BA+IpB"}
@@ -0,0 +1,168 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { defaultObjectSizing, meshGradientFragmentShader, ShaderFitOptions, ShaderMount, } from "@paper-design/shaders";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { resolveIdentityColor } from "../../internal/agent-visual/color.js";
6
+ import { cn } from "../../lib/utils.js";
7
+ import { useCanvasHeld } from "./canvas-budget.js";
8
+ import { useReducedMotion } from "./use-reduced-motion.js";
9
+ /*
10
+ * The room is the place a threshold happens in: one field of verde ink on true
11
+ * black, behind Nadia and behind the words. It is Paper Shaders' published
12
+ * Mesh Gradient "Ink" preset, adapted to two colours and slowed, mounted
13
+ * through the core `ShaderMount` the package already depends on. There is no
14
+ * React wrapper and no second shader engine.
15
+ *
16
+ * Every number below was selected from rendered captures (the study is
17
+ * `apps/nadia/build/design/threshold/`), so this module is the one place they
18
+ * are written.
19
+ */
20
+ const ROOM_SPEED = 0.22;
21
+ const ROOM_FRAME = 12_000;
22
+ const ROOM_MIN_PIXEL_RATIO = 1;
23
+ const ROOM_MAX_PIXEL_COUNT = 1_024_000;
24
+ const ROOM_DISTORTION = 1;
25
+ const ROOM_SWIRL = 0.2;
26
+ const ROOM_ROTATION = 90;
27
+ /** The field's ink, and the two colours it carries. Uniforms, never CSS. */
28
+ const ROOM_INK_TOKEN = "--nc-identity-ink";
29
+ const ROOM_FIELD_TOKEN = "--nc-identity-verde";
30
+ const ROOM_READY_TOKEN = "--nc-dark-verde-vivo";
31
+ const clampUnit = (value) => Math.min(Math.max(value, 0), 1);
32
+ /**
33
+ * The threshold's background field.
34
+ *
35
+ * It paints absolutely inside its own relative, isolated parent and never
36
+ * against the viewport, so a Portal panel can hold one without covering the
37
+ * page. It takes no pointer events and carries no words.
38
+ *
39
+ * When WebGL is unavailable, when the design-system tokens are not in the
40
+ * cascade above it, or when the context is lost after mounting, the element
41
+ * keeps its still field: a token-built radial gradient that needs no GPU. The
42
+ * room is never blank.
43
+ *
44
+ * The window carries one shader canvas. While Nadia's water is painting her
45
+ * arrival, the room holds this same still field and mounts nothing; the
46
+ * moment she stands clear the water leaves and the room's canvas is the one
47
+ * that paints (`canvas-budget.ts`).
48
+ */
49
+ export function ThresholdRoom({ className, dimmed = false, energy = 0, ready = false, }) {
50
+ const still = useReducedMotion();
51
+ const hostRef = useRef(null);
52
+ const mountRef = useRef(null);
53
+ const [surface, setSurface] = useState("still");
54
+ const held = useCanvasHeld();
55
+ const speed = still ? 0 : ROOM_SPEED * clampUnit(energy);
56
+ // The deferred mount reads the values current when it runs, not the ones of
57
+ // the render that scheduled it.
58
+ const speedRef = useRef(speed);
59
+ const readyRef = useRef(ready);
60
+ useEffect(() => {
61
+ speedRef.current = speed;
62
+ readyRef.current = ready;
63
+ });
64
+ // The window's one canvas. While Nadia's water holds it the room keeps its
65
+ // still field; when the hold is released the room mounts. Every other prop
66
+ // change is carried into the live mount by the two effects below, so a prop
67
+ // change never rebuilds a WebGL context.
68
+ //
69
+ // The mount is deferred by one microtask. Effects run in tree order, so a
70
+ // presence mounted after the room takes its hold after this effect has run;
71
+ // deferring lets that hold cancel a mount that has not happened yet, and
72
+ // the window never carries two live contexts, not even for one tick.
73
+ useEffect(() => {
74
+ const host = hostRef.current;
75
+ if (!host || held)
76
+ return;
77
+ let cancelled = false;
78
+ let canvas = null;
79
+ const clearCanvases = () => {
80
+ for (const element of host.querySelectorAll("canvas"))
81
+ element.remove();
82
+ host.removeAttribute("data-paper-shader");
83
+ };
84
+ const handleContextLost = (event) => {
85
+ event.preventDefault();
86
+ canvas?.removeEventListener("webglcontextlost", handleContextLost);
87
+ canvas = null;
88
+ try {
89
+ mountRef.current?.dispose();
90
+ }
91
+ catch {
92
+ // The still field is already behind the canvas; vendor cleanup failing
93
+ // must not take the room down with it.
94
+ }
95
+ mountRef.current = null;
96
+ clearCanvases();
97
+ setSurface("still");
98
+ };
99
+ const mount = () => {
100
+ if (cancelled)
101
+ return;
102
+ const ink = resolveIdentityColor(host, ROOM_INK_TOKEN);
103
+ const field = resolveIdentityColor(host, readyRef.current ? ROOM_READY_TOKEN : ROOM_FIELD_TOKEN);
104
+ // Without the design-system cascade there is no palette to paint with, and
105
+ // a guessed colour would be a second source for a token value.
106
+ if (!ink || !field)
107
+ return;
108
+ try {
109
+ mountRef.current = new ShaderMount(host, meshGradientFragmentShader, {
110
+ u_colors: [field, ink],
111
+ u_colorsCount: 2,
112
+ u_distortion: ROOM_DISTORTION,
113
+ u_fit: ShaderFitOptions.contain,
114
+ u_grainMixer: 0,
115
+ u_grainOverlay: 0,
116
+ u_offsetX: defaultObjectSizing.offsetX,
117
+ u_offsetY: defaultObjectSizing.offsetY,
118
+ u_originX: defaultObjectSizing.originX,
119
+ u_originY: defaultObjectSizing.originY,
120
+ u_rotation: ROOM_ROTATION,
121
+ u_scale: defaultObjectSizing.scale,
122
+ u_swirl: ROOM_SWIRL,
123
+ u_worldHeight: defaultObjectSizing.worldHeight,
124
+ u_worldWidth: defaultObjectSizing.worldWidth,
125
+ }, undefined, speedRef.current, ROOM_FRAME, ROOM_MIN_PIXEL_RATIO, ROOM_MAX_PIXEL_COUNT);
126
+ canvas = mountRef.current.canvasElement;
127
+ canvas.addEventListener("webglcontextlost", handleContextLost);
128
+ setSurface("shader");
129
+ }
130
+ catch {
131
+ // The constructor prepends its canvas before it reaches the context, so
132
+ // a failed mount leaves one behind that would cover the still field.
133
+ mountRef.current = null;
134
+ clearCanvases();
135
+ setSurface("still");
136
+ }
137
+ };
138
+ queueMicrotask(mount);
139
+ return () => {
140
+ cancelled = true;
141
+ canvas?.removeEventListener("webglcontextlost", handleContextLost);
142
+ try {
143
+ mountRef.current?.dispose();
144
+ }
145
+ catch {
146
+ // Same reason as above: disposal is best effort.
147
+ }
148
+ mountRef.current = null;
149
+ clearCanvases();
150
+ setSurface("still");
151
+ };
152
+ }, [held]);
153
+ useEffect(() => {
154
+ mountRef.current?.setSpeed(speed);
155
+ }, [speed]);
156
+ useEffect(() => {
157
+ const host = hostRef.current;
158
+ const mount = mountRef.current;
159
+ if (!host || !mount)
160
+ return;
161
+ const ink = resolveIdentityColor(host, ROOM_INK_TOKEN);
162
+ const field = resolveIdentityColor(host, ready ? ROOM_READY_TOKEN : ROOM_FIELD_TOKEN);
163
+ if (!ink || !field)
164
+ return;
165
+ mount.setUniforms({ u_colors: [field, ink] });
166
+ }, [ready]);
167
+ return (_jsx("div", { "aria-hidden": "true", className: cn("nc-threshold-room", className), "data-dimmed": dimmed || undefined, "data-ready": ready || undefined, "data-surface": surface, ref: hostRef }));
168
+ }