@nadicodeai/ui 4.0.5 → 5.2.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.
- package/AGENTS.md +4 -0
- package/README.md +4 -0
- package/dist/activity.d.ts +2 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +2 -0
- package/dist/components/activity-orb.d.ts +21 -0
- package/dist/components/activity-orb.d.ts.map +1 -0
- package/dist/components/activity-orb.js +26 -0
- package/dist/components/threshold/canvas-budget.d.ts +10 -0
- package/dist/components/threshold/canvas-budget.d.ts.map +1 -0
- package/dist/components/threshold/canvas-budget.js +54 -0
- package/dist/components/threshold/index.d.ts +6 -0
- package/dist/components/threshold/index.d.ts.map +1 -0
- package/dist/components/threshold/index.js +5 -0
- package/dist/components/threshold/orb.d.ts +14 -0
- package/dist/components/threshold/orb.d.ts.map +1 -0
- package/dist/components/threshold/orb.js +17 -0
- package/dist/components/threshold/presence.d.ts +27 -0
- package/dist/components/threshold/presence.d.ts.map +1 -0
- package/dist/components/threshold/presence.js +159 -0
- package/dist/components/threshold/room.d.ts +33 -0
- package/dist/components/threshold/room.d.ts.map +1 -0
- package/dist/components/threshold/room.js +168 -0
- package/dist/components/threshold/threshold.css +228 -0
- package/dist/components/threshold/typed-line.d.ts +8 -0
- package/dist/components/threshold/typed-line.d.ts.map +1 -0
- package/dist/components/threshold/typed-line.js +43 -0
- package/dist/components/threshold/use-reduced-motion.d.ts +10 -0
- package/dist/components/threshold/use-reduced-motion.d.ts.map +1 -0
- package/dist/components/threshold/use-reduced-motion.js +32 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/internal/activity-orb.d.ts +27 -0
- package/dist/internal/activity-orb.d.ts.map +1 -0
- package/dist/internal/activity-orb.js +150 -0
- package/dist/internal/agent-visual/color.d.ts.map +1 -1
- package/dist/internal/agent-visual/color.js +13 -7
- package/dist/threshold.d.ts +2 -0
- package/dist/threshold.d.ts.map +1 -0
- package/dist/threshold.js +13 -0
- package/docs/activity.md +126 -0
- package/docs/contract.md +8 -2
- package/docs/threshold.md +221 -0
- package/package.json +13 -3
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 @@
|
|
|
1
|
+
{"version":3,"file":"activity.d.ts","sourceRoot":"","sources":["../src/activity.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,KAAK,YAAY,EAAE,KAAK,eAAe,EAAE,MAAM,yBAAyB,CAAC"}
|
package/dist/activity.js
ADDED
|
@@ -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,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
|
+
}
|