@nadicodeai/design-system 0.21.0 → 1.0.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.
- package/AGENTS.md +8 -9
- package/CONTEXT.md +6 -0
- package/DESIGN.md +69 -72
- package/README.md +1 -22
- package/dist/css/index.css +15 -738
- package/dist/tailwind/nadicode.tailwind.json +4 -0
- package/dist/tokens/nadicode.dtcg.json +44 -0
- package/package.json +3 -2
- package/dist/css/chat-autoplay.js +0 -537
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"canvas": "#ffffff",
|
|
15
15
|
"canvas-soft": "#fafafa",
|
|
16
16
|
"canvas-soft-2": "#f5f5f5",
|
|
17
|
+
"material-workspace": "#f7f7f7",
|
|
18
|
+
"material-solid": "#ffffff",
|
|
17
19
|
"line": "#e5e5e5",
|
|
18
20
|
"input": "#757575",
|
|
19
21
|
"seam": "#d4d4d4",
|
|
@@ -73,6 +75,8 @@
|
|
|
73
75
|
"dark-canvas": "#000000",
|
|
74
76
|
"dark-canvas-soft": "#111111",
|
|
75
77
|
"dark-canvas-soft-2": "#1a1a1a",
|
|
78
|
+
"dark-material-workspace": "#000000",
|
|
79
|
+
"dark-material-solid": "#000000",
|
|
76
80
|
"dark-ink": "#ededed",
|
|
77
81
|
"dark-body": "#c7c7c7",
|
|
78
82
|
"dark-muted": "#8f8f8f",
|
|
@@ -136,6 +136,28 @@
|
|
|
136
136
|
"hex": "#f5f5f5"
|
|
137
137
|
}
|
|
138
138
|
},
|
|
139
|
+
"material-workspace": {
|
|
140
|
+
"$value": {
|
|
141
|
+
"colorSpace": "srgb",
|
|
142
|
+
"components": [
|
|
143
|
+
0.969,
|
|
144
|
+
0.969,
|
|
145
|
+
0.969
|
|
146
|
+
],
|
|
147
|
+
"hex": "#f7f7f7"
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
"material-solid": {
|
|
151
|
+
"$value": {
|
|
152
|
+
"colorSpace": "srgb",
|
|
153
|
+
"components": [
|
|
154
|
+
1,
|
|
155
|
+
1,
|
|
156
|
+
1
|
|
157
|
+
],
|
|
158
|
+
"hex": "#ffffff"
|
|
159
|
+
}
|
|
160
|
+
},
|
|
139
161
|
"line": {
|
|
140
162
|
"$value": {
|
|
141
163
|
"colorSpace": "srgb",
|
|
@@ -785,6 +807,28 @@
|
|
|
785
807
|
"hex": "#1a1a1a"
|
|
786
808
|
}
|
|
787
809
|
},
|
|
810
|
+
"dark-material-workspace": {
|
|
811
|
+
"$value": {
|
|
812
|
+
"colorSpace": "srgb",
|
|
813
|
+
"components": [
|
|
814
|
+
0,
|
|
815
|
+
0,
|
|
816
|
+
0
|
|
817
|
+
],
|
|
818
|
+
"hex": "#000000"
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
"dark-material-solid": {
|
|
822
|
+
"$value": {
|
|
823
|
+
"colorSpace": "srgb",
|
|
824
|
+
"components": [
|
|
825
|
+
0,
|
|
826
|
+
0,
|
|
827
|
+
0
|
|
828
|
+
],
|
|
829
|
+
"hex": "#000000"
|
|
830
|
+
}
|
|
831
|
+
},
|
|
788
832
|
"dark-ink": {
|
|
789
833
|
"$value": {
|
|
790
834
|
"colorSpace": "srgb",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nadicodeai/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
"./tokens/dtcg": "./dist/tokens/nadicode.dtcg.json",
|
|
13
13
|
"./tailwind": "./dist/tailwind/nadicode.tailwind.json",
|
|
14
14
|
"./css": "./dist/css/index.css",
|
|
15
|
-
"./css/chat-autoplay.js": "./dist/css/chat-autoplay.js",
|
|
16
15
|
"./icons/*": "./dist/icons/*",
|
|
17
16
|
"./favicon/*": "./dist/favicon/*",
|
|
18
17
|
"./assets/logo-mark": {
|
|
@@ -155,6 +154,8 @@
|
|
|
155
154
|
"lint:design": "design.md lint DESIGN.md",
|
|
156
155
|
"lint": "eslint --max-warnings=0",
|
|
157
156
|
"typecheck": "tsc -p tsconfig.json",
|
|
157
|
+
"check": "npm run lint:design && npm run lint && npm run typecheck",
|
|
158
|
+
"check:static": "npm run check",
|
|
158
159
|
"test": "vitest run",
|
|
159
160
|
"test:visual": "playwright test --config playwright.visual.config.ts",
|
|
160
161
|
"prepublishOnly": "npm run build"
|
|
@@ -1,537 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* chat-autoplay.js — the single optional vanilla enhancer for NadicodeAI chat
|
|
3
|
-
* threads. The ONLY JavaScript in @nadicodeai/design-system.
|
|
4
|
-
*
|
|
5
|
-
* It is pure progressive enhancement, framework-free, dependency-free, with no
|
|
6
|
-
* module-loader statements and no third-party runtime. It turns an
|
|
7
|
-
* already-static `.nc-chat-thread` into an auto-playing, looping agent
|
|
8
|
-
* conversation.
|
|
9
|
-
*
|
|
10
|
-
* Runtime behavior:
|
|
11
|
-
*
|
|
12
|
-
* (a) Motion on: messages are hidden, then revealed in scripted source order by
|
|
13
|
-
* toggling the `.nc-anim-msg-in` hook (the rise-in). A typing indicator
|
|
14
|
-
* (`.nc-typing-dots`) shows between turns, the thread body auto-scrolls to
|
|
15
|
-
* the newest message, the `.nc-usage-meter-fill` is driven via `--nc-usage`,
|
|
16
|
-
* and after `LOOP_HOLD` the cycle restarts.
|
|
17
|
-
*
|
|
18
|
-
* (b) `prefers-reduced-motion: reduce`: the FULL thread is shown immediately,
|
|
19
|
-
* every message visible, NO loop, no typing animation. (We also stop if the
|
|
20
|
-
* preference changes to reduce mid-play.)
|
|
21
|
-
*
|
|
22
|
-
* (c) Script absent: the conversation renders FULLY and statically from CSS
|
|
23
|
-
* alone. The atoms set `animation-name: nc-msg-in` but, without the
|
|
24
|
-
* `.nc-anim-msg-in` hook supplying a duration, that is inert and every
|
|
25
|
-
* message is visible. This file is the ONLY thing that ever hides a message
|
|
26
|
-
* (via the per-element inline `display` it sets while playing). No hidden
|
|
27
|
-
* class is ever present in static markup, so no-JS == full static thread.
|
|
28
|
-
*
|
|
29
|
-
* (d) Playback starts on scroll-into-view via `IntersectionObserver`; it pauses
|
|
30
|
-
* (resets to the static full thread and re-arms) when scrolled fully out.
|
|
31
|
-
*
|
|
32
|
-
* Timing parity with `src/css/motion.css`: the loop reads the duration token
|
|
33
|
-
* (`--nc-duration-stagger`) from the thread's computed style and uses the
|
|
34
|
-
* per-role `defaultTiming()` rhythm below. The stagger unit comes from CSS, not
|
|
35
|
-
* a re-typed magic number; this module owns the sequencing cadence because it
|
|
36
|
-
* is runtime behavior rather than a reusable visual token.
|
|
37
|
-
*
|
|
38
|
-
* Opt-in: only `.nc-chat-thread[data-nc-autoplay]` elements are enhanced. A
|
|
39
|
-
* thread without the attribute is left fully static.
|
|
40
|
-
*
|
|
41
|
-
* Usage (no bundler assumed):
|
|
42
|
-
* <script src="…/css/chat-autoplay.js"></script>
|
|
43
|
-
* It self-initializes on DOMContentLoaded and also exposes
|
|
44
|
-
* `window.ncChatAutoplay.init(root?)` for manual / dynamic init.
|
|
45
|
-
*/
|
|
46
|
-
(function () {
|
|
47
|
-
"use strict";
|
|
48
|
-
|
|
49
|
-
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
50
|
-
|
|
51
|
-
// The CSS token owns visual stagger. These values own runtime sequencing.
|
|
52
|
-
var LOOP_HOLD = 6000; // Keep the completed state visible for most of the loop.
|
|
53
|
-
var START_DELAY = 240;
|
|
54
|
-
|
|
55
|
-
function defaultTiming(role, textLen) {
|
|
56
|
-
var len = textLen || 0;
|
|
57
|
-
switch (role) {
|
|
58
|
-
case "user":
|
|
59
|
-
return { typingMs: 0, holdMs: 1100 + len * 8 };
|
|
60
|
-
case "tool":
|
|
61
|
-
return { typingMs: 380, holdMs: 700 };
|
|
62
|
-
case "state":
|
|
63
|
-
return { typingMs: 220, holdMs: 700 };
|
|
64
|
-
case "memory":
|
|
65
|
-
return { typingMs: 380, holdMs: 900 };
|
|
66
|
-
case "handoff":
|
|
67
|
-
return { typingMs: 420, holdMs: 1300 };
|
|
68
|
-
case "approval":
|
|
69
|
-
return { typingMs: 460, holdMs: 1600 };
|
|
70
|
-
case "artifact":
|
|
71
|
-
return { typingMs: 600, holdMs: 1800 };
|
|
72
|
-
case "agent":
|
|
73
|
-
default:
|
|
74
|
-
return { typingMs: 680, holdMs: 900 + len * 12 };
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Infer a message's role from its NadicodeAI atom class so authors don't have
|
|
79
|
-
// to duplicate the role in a data attribute. An explicit `data-nc-role`
|
|
80
|
-
// overrides. Falls back to "agent" (the slowest, safest cadence).
|
|
81
|
-
var ROLE_BY_CLASS = [
|
|
82
|
-
["nc-chat-bubble-user", "user"],
|
|
83
|
-
["nc-chat-bubble-agent", "agent"],
|
|
84
|
-
["nc-tool-call-trace", "tool"],
|
|
85
|
-
["nc-memory-item", "memory"],
|
|
86
|
-
["nc-handoff-banner", "handoff"],
|
|
87
|
-
["nc-approval-gate", "approval"],
|
|
88
|
-
["nc-artifact-preview", "artifact"],
|
|
89
|
-
["nc-state-row", "state"],
|
|
90
|
-
];
|
|
91
|
-
|
|
92
|
-
function roleOf(el) {
|
|
93
|
-
var explicit = el.getAttribute("data-nc-role");
|
|
94
|
-
if (explicit) return explicit;
|
|
95
|
-
for (var i = 0; i < ROLE_BY_CLASS.length; i++) {
|
|
96
|
-
if (el.classList.contains(ROLE_BY_CLASS[i][0])) return ROLE_BY_CLASS[i][1];
|
|
97
|
-
}
|
|
98
|
-
return "agent";
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function reducedMotion() {
|
|
102
|
-
return (
|
|
103
|
-
typeof window.matchMedia === "function" &&
|
|
104
|
-
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Read a duration token (e.g. "--nc-duration-stagger") off a live element and
|
|
109
|
-
// return milliseconds. Falls back to a documented default that equals the
|
|
110
|
-
// motion.css value if the bundle is not loaded (graceful, never throws).
|
|
111
|
-
function durationToken(el, name, fallbackMs) {
|
|
112
|
-
var raw = getComputedStyle(el).getPropertyValue(name).trim();
|
|
113
|
-
if (!raw) return fallbackMs;
|
|
114
|
-
if (raw.slice(-2) === "ms") return parseFloat(raw) || fallbackMs;
|
|
115
|
-
if (raw.slice(-1) === "s") return (parseFloat(raw) || 0) * 1000 || fallbackMs;
|
|
116
|
-
var n = parseFloat(raw);
|
|
117
|
-
return isNaN(n) ? fallbackMs : n;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// ── One thread's player ───────────────────────────────────────────────────
|
|
121
|
-
function ChatThreadPlayer(thread) {
|
|
122
|
-
this.thread = thread;
|
|
123
|
-
this.body = thread.querySelector(".nc-chat-thread-body");
|
|
124
|
-
// Stable, source-ordered list of message atoms inside the body. The typing
|
|
125
|
-
// indicator and any element flagged to ignore are NOT messages.
|
|
126
|
-
this.messages = this.body
|
|
127
|
-
? Array.prototype.filter.call(this.body.children, function (el) {
|
|
128
|
-
return (
|
|
129
|
-
el.nodeType === 1 &&
|
|
130
|
-
!el.classList.contains("nc-typing-dots") &&
|
|
131
|
-
el.getAttribute("data-nc-autoplay-ignore") === null
|
|
132
|
-
);
|
|
133
|
-
})
|
|
134
|
-
: [];
|
|
135
|
-
this.typing = this.body ? this.body.querySelector(".nc-typing-dots") : null;
|
|
136
|
-
this.meterFill = thread.querySelector(".nc-usage-meter-fill");
|
|
137
|
-
this.staggerMs = 160; // overwritten from the CSS token at enhance()
|
|
138
|
-
this.timers = [];
|
|
139
|
-
this.running = false;
|
|
140
|
-
this.cancelled = false;
|
|
141
|
-
// Monotonic loop epoch. `_runLoop` captures the value live at start and every
|
|
142
|
-
// guard checks it. `stop()`/`rearm()` bump it so a stale continuation — a
|
|
143
|
-
// `_sleep` timer that already fired but whose `.then` is still queued when the
|
|
144
|
-
// player is stopped and immediately re-armed on scroll-in — sees a mismatched
|
|
145
|
-
// epoch and dies permanently, instead of resuming once `cancelled` flips back
|
|
146
|
-
// to false and racing the fresh loop. Single, uninterrupted playback never
|
|
147
|
-
// changes the epoch mid-run, so the observed sequence is unchanged.
|
|
148
|
-
this.generation = 0;
|
|
149
|
-
// WCAG 2.2.2 user-pause latch. Set true when the user presses the pause button;
|
|
150
|
-
// cleared when the user presses resume. play() and the in-view observer branch
|
|
151
|
-
// both check it; a paused thread stays paused across scroll-out/in.
|
|
152
|
-
this.userPaused = false;
|
|
153
|
-
// Set to true once the sequence has reached its end at least once. Used by
|
|
154
|
-
// the IntersectionObserver entry branch: a thread that has completed is shown
|
|
155
|
-
// in its final visible state on re-entry rather than replayed from blank, so
|
|
156
|
-
// a reader who scrolls back sees the payoff frame instead of an empty/typing state.
|
|
157
|
-
this.completedOnce = false;
|
|
158
|
-
// Accent for an auto-created typing bubble, inferred from the first agent
|
|
159
|
-
// bubble's data-accent (default green, matching the prototype).
|
|
160
|
-
this.accent = "green";
|
|
161
|
-
var firstAgent = thread.querySelector(".nc-chat-bubble-agent[data-accent]");
|
|
162
|
-
if (firstAgent) this.accent = firstAgent.getAttribute("data-accent") || "green";
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
ChatThreadPlayer.prototype._clearTimers = function () {
|
|
166
|
-
for (var i = 0; i < this.timers.length; i++) clearTimeout(this.timers[i]);
|
|
167
|
-
this.timers = [];
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
ChatThreadPlayer.prototype._sleep = function (ms) {
|
|
171
|
-
var timers = this.timers;
|
|
172
|
-
return new Promise(function (resolve) {
|
|
173
|
-
var t = setTimeout(resolve, ms);
|
|
174
|
-
timers.push(t);
|
|
175
|
-
});
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
// Fully static, fully visible end-state. Used for no-loop (reduced-motion),
|
|
179
|
-
// for pause-out-of-view, and as the baseline each loop resets from. Removes
|
|
180
|
-
// every trace the player added so the DOM matches the no-JS static thread.
|
|
181
|
-
ChatThreadPlayer.prototype._showAll = function () {
|
|
182
|
-
for (var i = 0; i < this.messages.length; i++) {
|
|
183
|
-
var m = this.messages[i];
|
|
184
|
-
m.style.display = "";
|
|
185
|
-
m.classList.remove("nc-anim-msg-in");
|
|
186
|
-
}
|
|
187
|
-
this._hideTyping();
|
|
188
|
-
this.thread.removeAttribute("data-nc-playing");
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
ChatThreadPlayer.prototype._hideAllMessages = function () {
|
|
192
|
-
for (var i = 0; i < this.messages.length; i++) {
|
|
193
|
-
this.messages[i].style.display = "none";
|
|
194
|
-
this.messages[i].classList.remove("nc-anim-msg-in");
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
ChatThreadPlayer.prototype._scrollToBottom = function () {
|
|
199
|
-
var body = this.body;
|
|
200
|
-
if (!body) return;
|
|
201
|
-
requestAnimationFrame(function () {
|
|
202
|
-
body.scrollTop = body.scrollHeight;
|
|
203
|
-
});
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
// Ensure a typing indicator exists; create one if the markup has none so the
|
|
207
|
-
// "typing between turns" behaviour works on any thread. Created element is
|
|
208
|
-
// tagged so _showAll can drop it.
|
|
209
|
-
ChatThreadPlayer.prototype._ensureTyping = function () {
|
|
210
|
-
if (this.typing) return this.typing;
|
|
211
|
-
if (!this.body) return null;
|
|
212
|
-
var el = document.createElement("div");
|
|
213
|
-
el.className = "nc-typing-dots";
|
|
214
|
-
el.setAttribute("data-accent", this.accent);
|
|
215
|
-
el.setAttribute("aria-hidden", "true");
|
|
216
|
-
el.setAttribute("data-nc-autoplay-created", "");
|
|
217
|
-
for (var i = 0; i < 3; i++) {
|
|
218
|
-
var dot = document.createElement("span");
|
|
219
|
-
dot.className = "nc-typing-dots-dot nc-anim-typing";
|
|
220
|
-
dot.style.setProperty("--nc-anim-delay", i * this.staggerMs + "ms");
|
|
221
|
-
el.appendChild(dot);
|
|
222
|
-
}
|
|
223
|
-
el.style.display = "none";
|
|
224
|
-
this.body.appendChild(el);
|
|
225
|
-
this.typing = el;
|
|
226
|
-
return el;
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
ChatThreadPlayer.prototype._showTyping = function () {
|
|
230
|
-
var el = this._ensureTyping();
|
|
231
|
-
if (!el) return;
|
|
232
|
-
// Move the indicator to the end of the body so it sits below revealed turns.
|
|
233
|
-
this.body.appendChild(el);
|
|
234
|
-
el.style.display = "";
|
|
235
|
-
this._scrollToBottom();
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
ChatThreadPlayer.prototype._hideTyping = function () {
|
|
239
|
-
if (this.typing) this.typing.style.display = "none";
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
ChatThreadPlayer.prototype._setMeter = function (fraction) {
|
|
243
|
-
if (!this.meterFill) return;
|
|
244
|
-
var pct = Math.max(0, Math.min(1, fraction)) * 100;
|
|
245
|
-
this.meterFill.style.setProperty("--nc-usage", pct + "%");
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
ChatThreadPlayer.prototype._reveal = function (msg, index) {
|
|
249
|
-
msg.style.display = "";
|
|
250
|
-
// Re-trigger the rise-in: the hook supplies duration/easing; the atom
|
|
251
|
-
// already carries `animation-name: nc-msg-in`. Force a reflow so re-adding
|
|
252
|
-
// the class restarts the animation on a re-loop.
|
|
253
|
-
msg.style.setProperty("--nc-anim-delay", "0ms");
|
|
254
|
-
msg.classList.remove("nc-anim-msg-in");
|
|
255
|
-
void msg.offsetWidth;
|
|
256
|
-
msg.classList.add("nc-anim-msg-in");
|
|
257
|
-
this._setMeter((index + 1) / this.messages.length);
|
|
258
|
-
this._scrollToBottom();
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
// A loop is "stale" once it is cancelled OR a newer epoch has started; either
|
|
262
|
-
// way it must stop touching the DOM. `myGen` is captured once at start so a queued
|
|
263
|
-
// continuation can detect that the player was stopped-and-re-armed underneath
|
|
264
|
-
// it (see the `generation` note in the constructor).
|
|
265
|
-
ChatThreadPlayer.prototype._runLoop = function () {
|
|
266
|
-
var myGen = this.generation;
|
|
267
|
-
function stale(player) {
|
|
268
|
-
return player.cancelled || player.generation !== myGen;
|
|
269
|
-
}
|
|
270
|
-
function loop(player) {
|
|
271
|
-
if (stale(player)) return Promise.resolve();
|
|
272
|
-
player._hideAllMessages();
|
|
273
|
-
player._hideTyping();
|
|
274
|
-
player._setMeter(0);
|
|
275
|
-
return player
|
|
276
|
-
._sleep(START_DELAY)
|
|
277
|
-
.then(function () {
|
|
278
|
-
var chain = Promise.resolve();
|
|
279
|
-
player.messages.forEach(function (msg, i) {
|
|
280
|
-
chain = chain.then(function () {
|
|
281
|
-
if (stale(player)) return;
|
|
282
|
-
var role = roleOf(msg);
|
|
283
|
-
var t = defaultTiming(role, (msg.textContent || "").trim().length);
|
|
284
|
-
var step = function () {
|
|
285
|
-
if (stale(player)) return;
|
|
286
|
-
player._hideTyping();
|
|
287
|
-
player._reveal(msg, i);
|
|
288
|
-
return player._sleep(t.holdMs);
|
|
289
|
-
};
|
|
290
|
-
if (t.typingMs > 0) {
|
|
291
|
-
player._showTyping();
|
|
292
|
-
return player._sleep(t.typingMs).then(step);
|
|
293
|
-
}
|
|
294
|
-
return step();
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
return chain;
|
|
298
|
-
})
|
|
299
|
-
.then(function () {
|
|
300
|
-
if (stale(player)) return;
|
|
301
|
-
// Mark completion before the hold so the observer entry branch can
|
|
302
|
-
// show the final state on re-entry without replaying from blank.
|
|
303
|
-
player.completedOnce = true;
|
|
304
|
-
return player._sleep(LOOP_HOLD);
|
|
305
|
-
})
|
|
306
|
-
.then(function () {
|
|
307
|
-
if (stale(player)) return;
|
|
308
|
-
return loop(player);
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
return loop(this);
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
ChatThreadPlayer.prototype.play = function () {
|
|
315
|
-
if (this.running || this.cancelled) return;
|
|
316
|
-
// WCAG 2.2.2: user-pressed pause wins over every automatic restart.
|
|
317
|
-
if (this.userPaused) return;
|
|
318
|
-
// (b) Reduced motion: show the full thread immediately, no loop, no typing.
|
|
319
|
-
if (reducedMotion()) {
|
|
320
|
-
this._showAll();
|
|
321
|
-
this._setMeter(1);
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
if (!this.messages.length) return;
|
|
325
|
-
this.running = true;
|
|
326
|
-
this.thread.setAttribute("data-nc-playing", "");
|
|
327
|
-
this._runLoop();
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
// Stop and reset to the fully-visible static thread (re-armable).
|
|
331
|
-
ChatThreadPlayer.prototype.stop = function () {
|
|
332
|
-
this.cancelled = true;
|
|
333
|
-
this.generation++; // retire the running loop's queued continuations
|
|
334
|
-
this._clearTimers();
|
|
335
|
-
this.running = false;
|
|
336
|
-
this._showAll();
|
|
337
|
-
this._setMeter(1);
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
// Re-arm a stopped player (used when the thread scrolls back into view). Bump
|
|
341
|
-
// the epoch again so a still-queued continuation from the just-stopped loop can
|
|
342
|
-
// never resume now that `cancelled` is cleared — only the loop `play()` starts
|
|
343
|
-
// next will match the current epoch.
|
|
344
|
-
ChatThreadPlayer.prototype.rearm = function () {
|
|
345
|
-
this.cancelled = false;
|
|
346
|
-
this.generation++;
|
|
347
|
-
this.running = false;
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
// Inject the enhancer-owned pause/resume button into the thread card.
|
|
351
|
-
// One button per thread, positioned top-right above the body. Uses
|
|
352
|
-
// data-nc-autoplay-name for an accessible per-thread subject, e.g.
|
|
353
|
-
// "Pause <value>"/"Resume <value>"; falls back to "Pause demonstration".
|
|
354
|
-
// Never called in reduced-motion mode (contract (b): no button for a path
|
|
355
|
-
// that plays nothing).
|
|
356
|
-
ChatThreadPlayer.prototype._injectPauseButton = function () {
|
|
357
|
-
var thread = this.thread;
|
|
358
|
-
var subject = thread.getAttribute("data-nc-autoplay-name") || "demonstration";
|
|
359
|
-
var btn = document.createElement("button");
|
|
360
|
-
btn.type = "button";
|
|
361
|
-
btn.className = "nc-chat-pause";
|
|
362
|
-
btn.setAttribute("aria-pressed", "false");
|
|
363
|
-
btn.textContent = "Pause " + subject;
|
|
364
|
-
btn.addEventListener(
|
|
365
|
-
"click",
|
|
366
|
-
function () {
|
|
367
|
-
if (this.userPaused) {
|
|
368
|
-
// Resume: clear the latch, rearm and play.
|
|
369
|
-
this.userPaused = false;
|
|
370
|
-
btn.setAttribute("aria-pressed", "false");
|
|
371
|
-
btn.textContent = "Pause " + subject;
|
|
372
|
-
this.rearm();
|
|
373
|
-
this.play();
|
|
374
|
-
} else {
|
|
375
|
-
// Pause: set the latch, stop and reveal full content.
|
|
376
|
-
this.userPaused = true;
|
|
377
|
-
btn.setAttribute("aria-pressed", "true");
|
|
378
|
-
btn.textContent = "Resume " + subject;
|
|
379
|
-
this.stop();
|
|
380
|
-
}
|
|
381
|
-
}.bind(this),
|
|
382
|
-
);
|
|
383
|
-
// Insert before the body so the button sits above the scrolling content.
|
|
384
|
-
var body = thread.querySelector(".nc-chat-thread-body");
|
|
385
|
-
if (body) {
|
|
386
|
-
thread.insertBefore(btn, body);
|
|
387
|
-
} else {
|
|
388
|
-
thread.appendChild(btn);
|
|
389
|
-
}
|
|
390
|
-
this.pauseBtn = btn;
|
|
391
|
-
};
|
|
392
|
-
|
|
393
|
-
// ── Initialisation ──────────────────────────────────────────────────────
|
|
394
|
-
var players = [];
|
|
395
|
-
var observer = null;
|
|
396
|
-
|
|
397
|
-
function enhance(thread) {
|
|
398
|
-
if (thread.__ncAutoplayPlayer) return thread.__ncAutoplayPlayer;
|
|
399
|
-
var player = new ChatThreadPlayer(thread);
|
|
400
|
-
// Read the stagger token off the live thread (parity with motion.css);
|
|
401
|
-
// fallback equals the motion.css default (60ms) if the bundle is absent.
|
|
402
|
-
player.staggerMs = durationToken(thread, "--nc-duration-stagger", 60);
|
|
403
|
-
thread.__ncAutoplayPlayer = player;
|
|
404
|
-
players.push(player);
|
|
405
|
-
// Inject the pause/resume button unless reduced-motion is on (that path
|
|
406
|
-
// plays nothing and must inject no button — WCAG 2.2.2 scope follows the
|
|
407
|
-
// animation).
|
|
408
|
-
if (!reducedMotion()) {
|
|
409
|
-
player._injectPauseButton();
|
|
410
|
-
}
|
|
411
|
-
return player;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function startObserving() {
|
|
415
|
-
if (typeof window.IntersectionObserver !== "function") {
|
|
416
|
-
// No IntersectionObserver: degrade by playing immediately (still respects
|
|
417
|
-
// reduced motion inside play()).
|
|
418
|
-
for (var i = 0; i < players.length; i++) players[i].play();
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
// Manual re-init (the documented `window.ncChatAutoplay.init(root)` path for
|
|
422
|
-
// dynamic content) calls this again; tear down the prior observer first so a
|
|
423
|
-
// repeat init replaces — rather than stacks — observers. A single self-init
|
|
424
|
-
// (the common path) hits this once and `observer` is null, so behavior is
|
|
425
|
-
// unchanged; only repeated manual init benefits (no leaked observers, no
|
|
426
|
-
// duplicate intersection callbacks per thread).
|
|
427
|
-
if (observer) observer.disconnect();
|
|
428
|
-
observer = new IntersectionObserver(
|
|
429
|
-
function (entries) {
|
|
430
|
-
for (var i = 0; i < entries.length; i++) {
|
|
431
|
-
var entry = entries[i];
|
|
432
|
-
var player = entry.target.__ncAutoplayPlayer;
|
|
433
|
-
if (!player) continue;
|
|
434
|
-
if (entry.isIntersecting) {
|
|
435
|
-
// (d) Start when scrolled into view — but only if the player is not
|
|
436
|
-
// already running its loop. Re-arming a mid-loop player kills and
|
|
437
|
-
// restarts it from blank (the long right thread reads as dead under
|
|
438
|
-
// scroll crossings). A player is "running" once play() sets the flag
|
|
439
|
-
// and until stop() clears it; the loop recursion holds this state.
|
|
440
|
-
// WCAG 2.2.2: do not override a user-pressed pause on scroll-in.
|
|
441
|
-
if (player.userPaused) continue;
|
|
442
|
-
if (!player.running) {
|
|
443
|
-
if (player.completedOnce) {
|
|
444
|
-
// The sequence finished at least once. Show the full final state
|
|
445
|
-
// immediately so the reader sees the payoff frame, not a blank/
|
|
446
|
-
// typing restart. The loop resumes from this state on the next
|
|
447
|
-
// natural cycle if the thread stays in view.
|
|
448
|
-
player._showAll();
|
|
449
|
-
player._setMeter(1);
|
|
450
|
-
} else {
|
|
451
|
-
player.rearm();
|
|
452
|
-
player.play();
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
} else if (entry.intersectionRatio === 0) {
|
|
456
|
-
// Stop only when the thread is FULLY out of view (ratio exactly 0).
|
|
457
|
-
// At partial visibility (e.g. ratio 0.228) do nothing — collapsing
|
|
458
|
-
// to the finished card while the thread is still on screen is the
|
|
459
|
-
// bug this guard fixes.
|
|
460
|
-
if (player.running) {
|
|
461
|
-
player.stop();
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
},
|
|
466
|
-
{ threshold: [0, 0.25] },
|
|
467
|
-
);
|
|
468
|
-
for (var j = 0; j < players.length; j++) observer.observe(players[j].thread);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// ── Enhancer-armed, run-once scroll entrance ─────────────────────────────
|
|
472
|
-
// Arms .nc-settle elements that are fully below the viewport at init.
|
|
473
|
-
// On first IntersectionObserver entry, switches data-nc-settle from "armed"
|
|
474
|
-
// to "in" (triggers the CSS nc-msg-in animation) and immediately unobserves
|
|
475
|
-
// so the rise-in fires once and is never reversed on scroll-back. Elements
|
|
476
|
-
// already visible at load are left unarmed (no flash; hero region untouched).
|
|
477
|
-
// prefers-reduced-motion: skip entirely — page fully visible without JS.
|
|
478
|
-
function initSettle(scope) {
|
|
479
|
-
if (reducedMotion()) return;
|
|
480
|
-
if (typeof window.IntersectionObserver !== "function") return;
|
|
481
|
-
var settleElements = (scope || document).querySelectorAll(".nc-settle");
|
|
482
|
-
if (!settleElements.length) return;
|
|
483
|
-
var settleObserver = new IntersectionObserver(
|
|
484
|
-
function (entries, obs) {
|
|
485
|
-
for (var i = 0; i < entries.length; i++) {
|
|
486
|
-
if (entries[i].isIntersecting) {
|
|
487
|
-
entries[i].target.setAttribute("data-nc-settle", "in");
|
|
488
|
-
obs.unobserve(entries[i].target);
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
},
|
|
492
|
-
{ threshold: 0.1 },
|
|
493
|
-
);
|
|
494
|
-
for (var i = 0; i < settleElements.length; i++) {
|
|
495
|
-
var el = settleElements[i];
|
|
496
|
-
var rect = el.getBoundingClientRect();
|
|
497
|
-
// Arm only elements whose top edge is fully below the viewport.
|
|
498
|
-
if (rect.top > window.innerHeight) {
|
|
499
|
-
el.setAttribute("data-nc-settle", "armed");
|
|
500
|
-
settleObserver.observe(el);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
function init(root) {
|
|
506
|
-
var scope = root || document;
|
|
507
|
-
var threads = scope.querySelectorAll(".nc-chat-thread[data-nc-autoplay]");
|
|
508
|
-
for (var i = 0; i < threads.length; i++) enhance(threads[i]);
|
|
509
|
-
initSettle(scope);
|
|
510
|
-
startObserving();
|
|
511
|
-
return players;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// Respond to a runtime change of the reduced-motion preference: collapse any
|
|
515
|
-
// running player to the static full thread.
|
|
516
|
-
if (typeof window.matchMedia === "function") {
|
|
517
|
-
var mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
518
|
-
var onChange = function () {
|
|
519
|
-
if (!mq.matches) return;
|
|
520
|
-
for (var i = 0; i < players.length; i++) {
|
|
521
|
-
if (players[i].running) players[i].stop();
|
|
522
|
-
}
|
|
523
|
-
};
|
|
524
|
-
if (typeof mq.addEventListener === "function") mq.addEventListener("change", onChange);
|
|
525
|
-
else if (typeof mq.addListener === "function") mq.addListener(onChange);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
window.ncChatAutoplay = { init: init, players: players };
|
|
529
|
-
|
|
530
|
-
if (document.readyState === "loading") {
|
|
531
|
-
document.addEventListener("DOMContentLoaded", function () {
|
|
532
|
-
init();
|
|
533
|
-
});
|
|
534
|
-
} else {
|
|
535
|
-
init();
|
|
536
|
-
}
|
|
537
|
-
})();
|