nishtha711 commited on
Commit
17caec4
·
verified ·
1 Parent(s): 446ae08

Upload 5 files

Browse files
Files changed (5) hide show
  1. README.md +59 -9
  2. app.js +879 -0
  3. data.json +0 -0
  4. index.html +222 -0
  5. styles.css +630 -0
README.md CHANGED
@@ -1,13 +1,63 @@
1
  ---
2
- title: Message Router Hackerrank
3
- emoji: 👁
4
- colorFrom: red
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 6.22.0
8
- python_version: '3.12'
9
- app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Message Router — An Inbox That Reasons
3
+ emoji: 🧭
4
+ colorFrom: green
5
+ colorTo: pink
6
+ sdk: static
 
 
 
7
  pinned: false
8
+ short_description: Interactive WhatsApp UI for a real AI notification router
9
  ---
10
 
11
+ # Message Router an inbox that reasons
12
+
13
+ A WhatsApp-accurate, fully interactive front end for the notification-routing
14
+ pipeline documented in the parent repo's `README.md`. Every decision shown
15
+ here is a real, precomputed output of the actual run — nothing is invented
16
+ for the demo, and no API key is needed to view it.
17
+
18
+ - Pick any of the 32 real inboxes from the dropdown and browse their chats.
19
+ - Tap a message with a dashed outline to open the animated 5-phase routing
20
+ trace: bundle → media → rules → router → normalize, including hard-override
21
+ behavior, evidence, and the semantic-consistency check.
22
+ - **Router HQ** (pinned chat, pulses on load) narrates the architecture
23
+ inside a chat thread, with tappable quick replies and deep links into real
24
+ examples.
25
+ - **Try It Yourself** (pinned chat) runs a small client-side heuristic so you
26
+ can type your own message and get an instant (clearly-labelled toy) verdict.
27
+ - Search, Unread/Groups filters, a ⋮ menu, and the static architecture
28
+ diagram below the phone (animated on scroll) are all live, not decorative.
29
+ - Light/dark theme toggle, default light.
30
+
31
+ This folder is exactly what gets deployed — nothing here needs a build step.
32
+
33
+ ## Local preview
34
+
35
+ ```bash
36
+ cd hf
37
+ python -m http.server 8000
38
+ # open http://localhost:8000
39
+ ```
40
+
41
+ ## Regenerating data.json
42
+
43
+ `data.json` is generated once from the parent repo's real outputs and
44
+ committed here. The generator and its source data (`runs/`, `dataset/`) live
45
+ one level up — see `../webapp/build_data.py`:
46
+
47
+ ```bash
48
+ python ../webapp/build_data.py # reads ../dataset, ../runs
49
+ # writes ./data.json
50
+ ```
51
+
52
+ ## Deploying this folder to a Hugging Face Space
53
+
54
+ This folder *is* the Space — `sdk: static` in the frontmatter above means
55
+ Spaces serves `index.html`, `styles.css`, `app.js` and `data.json` directly,
56
+ no runtime, no secrets, no `GEMINI_API_KEY`.
57
+
58
+ ```bash
59
+ huggingface-cli repo create message-router --type space --space_sdk static
60
+ git clone https://huggingface.co/spaces/<you>/message-router
61
+ cp hf/*.md hf/*.html hf/*.css hf/*.js hf/*.json message-router/
62
+ cd message-router && git add -A && git commit -m "deploy" && git push
63
+ ```
app.js ADDED
@@ -0,0 +1,879 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =================================================================
2
+ Message Router — demo shell logic
3
+ Loads APP_DATA (inline <script id="app-data"> for the single-file
4
+ preview build, or fetch('data.json') for the deployed HF Space),
5
+ then renders an interactive WhatsApp-accurate phone on top of it.
6
+ Every decision shown is a real, precomputed output of the actual
7
+ Python pipeline in src/ — nothing here re-derives a verdict.
8
+ ================================================================= */
9
+ (function () {
10
+ "use strict";
11
+
12
+ const BANDS = { notify: [0.85, 0.91], mute: [0.81, 0.87], digest: [0.78, 0.84] };
13
+ const ACTION_META = {
14
+ notify: { label: "Notify", color: "#e0592a", icon: "🔔" },
15
+ digest: { label: "Digest", color: "#2f86c9", icon: "🕓" },
16
+ mute: { label: "Mute", color: "#847a90", icon: "🔇" },
17
+ };
18
+ const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
19
+
20
+ const state = { data: null, userId: null, convKey: null, view: "list", search: "", filter: "all", navTab: "chats" };
21
+
22
+ function toast(msg, ms) {
23
+ const stack = $("#toastStack");
24
+ if (!stack) return;
25
+ const t = el("div", "toast", esc(msg));
26
+ stack.appendChild(t);
27
+ setTimeout(() => {
28
+ t.classList.add("out");
29
+ setTimeout(() => t.remove(), 240);
30
+ }, ms || 3200);
31
+ }
32
+
33
+ // ------------------------------------------------------------- helpers
34
+ const $ = (sel, root) => (root || document).querySelector(sel);
35
+ const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
36
+ const el = (tag, cls, html) => {
37
+ const n = document.createElement(tag);
38
+ if (cls) n.className = cls;
39
+ if (html !== undefined) n.innerHTML = html;
40
+ return n;
41
+ };
42
+ const esc = (s) => (s || "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
43
+ const titleize = (s) => (s || "").replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
44
+ const clamp01 = (v) => Math.max(0, Math.min(1, v));
45
+
46
+ function nowClock() {
47
+ const d = new Date();
48
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
49
+ }
50
+
51
+ function currentUser() { return state.data.users[state.userId]; }
52
+ function currentConv() {
53
+ const u = currentUser();
54
+ if (!u) return null;
55
+ return u.conversations.find((c) => c.conv_key === state.convKey) || null;
56
+ }
57
+
58
+ // -------------------------------------------------------------- theme
59
+ function applyTheme(mode) {
60
+ document.documentElement.setAttribute("data-theme", mode);
61
+ localStorage.setItem("router-theme", mode);
62
+ $("#themeToggle").innerHTML = mode === "dark" ? ICON_SUN : ICON_MOON;
63
+ }
64
+ const ICON_SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4.5"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/></svg>';
65
+ const ICON_MOON = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.8 14.6A8.6 8.6 0 1 1 9.4 3.2a7 7 0 0 0 11.4 11.4Z"/></svg>';
66
+
67
+ function initTheme() {
68
+ const saved = localStorage.getItem("router-theme") || "light";
69
+ applyTheme(saved);
70
+ $("#themeToggle").addEventListener("click", () => {
71
+ const next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark";
72
+ applyTheme(next);
73
+ });
74
+ }
75
+
76
+ // ---------------------------------------------------------- boot / data
77
+ function boot(data) {
78
+ state.data = data;
79
+ state.userId = data.meta.hero_user_id;
80
+ hydrateStats(data.meta);
81
+ populateUserSwitch(data);
82
+ renderChatList();
83
+ showView("list");
84
+ $("#phone").addEventListener("mouseenter", () => $("#phone").classList.add("settled"), { once: true });
85
+ document.querySelectorAll(".reveal").forEach((n, i) => (n.style.animationDelay = i * 90 + "ms"));
86
+ initSearchAndFilters();
87
+ initMenu();
88
+ initNav();
89
+ initContactInfo();
90
+ initArchDiagram();
91
+ maybeSpotlightRouterHQ();
92
+ }
93
+
94
+ function hydrateStats(meta) {
95
+ $("#statAccuracy").textContent = (meta.action_accuracy * 100).toFixed(1) + "%";
96
+ $("#statCalls").textContent = meta.routing_calls + " / " + meta.raw_message_count;
97
+ $("#statCost").textContent = "$" + meta.cost_usd;
98
+ $("#statCaught").textContent = meta.semantic_contradictions_caught;
99
+ $("#statRank").textContent = meta.rank + " / " + meta.total_participants.toLocaleString();
100
+ }
101
+
102
+ function populateUserSwitch(data) {
103
+ const sel = $("#userSwitch");
104
+ sel.innerHTML = "";
105
+ Object.values(data.users)
106
+ .sort((a, b) => a.name.localeCompare(b.name))
107
+ .forEach((u) => {
108
+ const opt = el("option");
109
+ opt.value = u.user_id;
110
+ opt.textContent = `${u.name} — ${u.stats.n_new} new msg${u.stats.n_new === 1 ? "" : "s"}`;
111
+ sel.appendChild(opt);
112
+ });
113
+ sel.value = state.userId;
114
+ sel.addEventListener("change", () => {
115
+ state.userId = sel.value;
116
+ renderChatList();
117
+ showView("list");
118
+ });
119
+ }
120
+
121
+ // ------------------------------------------------- search & filter row
122
+ function initSearchAndFilters() {
123
+ const input = $("#searchInput");
124
+ const bar = $("#searchBar");
125
+ const clear = $("#searchClear");
126
+ input.addEventListener("input", () => {
127
+ state.search = input.value;
128
+ bar.classList.toggle("has-value", !!input.value);
129
+ renderChatList();
130
+ });
131
+ clear.addEventListener("click", () => {
132
+ input.value = "";
133
+ state.search = "";
134
+ bar.classList.remove("has-value");
135
+ renderChatList();
136
+ input.focus();
137
+ });
138
+ $$(".filter-pill", $("#filterRow")).forEach((pill) => {
139
+ pill.addEventListener("click", () => {
140
+ $$(".filter-pill", $("#filterRow")).forEach((p) => p.classList.remove("active"));
141
+ pill.classList.add("active");
142
+ state.filter = pill.dataset.filter;
143
+ renderChatList();
144
+ });
145
+ });
146
+ $("#searchIconBtn").addEventListener("click", () => input.focus());
147
+ }
148
+
149
+ // ------------------------------------------------------------- 3-dot menu
150
+ function initMenu() {
151
+ const btn = $("#menuBtn");
152
+ const dd = $("#menuDropdown");
153
+ btn.addEventListener("click", (e) => {
154
+ e.stopPropagation();
155
+ dd.classList.toggle("show");
156
+ });
157
+ document.addEventListener("click", () => dd.classList.remove("show"));
158
+ dd.addEventListener("click", (e) => e.stopPropagation());
159
+ dd.addEventListener("click", (e) => {
160
+ const action = e.target.closest("button")?.dataset.action;
161
+ if (!action) return;
162
+ dd.classList.remove("show");
163
+ if (action === "theme") {
164
+ const next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark";
165
+ applyTheme(next);
166
+ toast(`Switched to ${next} mode`);
167
+ } else if (action === "shuffle") {
168
+ const ids = Object.keys(state.data.users).filter((id) => id !== state.userId);
169
+ const pick = ids[Math.floor(Math.random() * ids.length)];
170
+ state.userId = pick;
171
+ $("#userSwitch").value = pick;
172
+ renderChatList();
173
+ toast(`Now viewing ${state.data.users[pick].name}'s inbox`);
174
+ } else if (action === "router") {
175
+ openRouterHQ();
176
+ } else if (action === "diagram") {
177
+ document.getElementById("archCard").scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "center" });
178
+ }
179
+ });
180
+ }
181
+
182
+ // ------------------------------------------------------------- bottom nav
183
+ const NAV_STUBS = {
184
+ updates: { icon: "🔄", text: "Status updates aren't part of a notification router — this demo lives entirely in Chats." },
185
+ communities: { icon: "👥", text: "Communities are a WhatsApp feature this demo doesn't need — every routing decision already lives in one inbox at a time." },
186
+ calls: { icon: "📞", text: "No calling here — this pipeline decides whether a message interrupts you, it doesn't place calls." },
187
+ };
188
+ function initNav() {
189
+ $$(".nav-item").forEach((item) => {
190
+ item.addEventListener("click", () => {
191
+ $$(".nav-item").forEach((x) => x.classList.remove("active"));
192
+ item.classList.add("active");
193
+ const tab = item.dataset.tab;
194
+ state.navTab = tab;
195
+ const stub = $("#navStub");
196
+ const isChats = tab === "chats";
197
+ $("#chatList").classList.toggle("hidden", !isChats);
198
+ $("#filterRow").style.display = isChats ? "" : "none";
199
+ $("#searchBar").style.display = isChats ? "" : "none";
200
+ stub.classList.toggle("hidden", isChats);
201
+ if (!isChats) {
202
+ const s = NAV_STUBS[tab];
203
+ stub.innerHTML = `<span class="ns-icon">${s.icon}</span><p>${esc(s.text)}</p><button id="navBack">Back to Chats</button>`;
204
+ $("#navBack").addEventListener("click", () => $('.nav-item[data-tab="chats"]').click());
205
+ }
206
+ });
207
+ });
208
+ }
209
+
210
+ // -------------------------------------------------------- contact info
211
+ function initContactInfo() {
212
+ const pop = $("#infoPop");
213
+ const open = () => {
214
+ pop.innerHTML = contactInfoHTML();
215
+ pop.classList.add("show");
216
+ };
217
+ $("#threadAvatar").addEventListener("click", (e) => { e.stopPropagation(); open(); });
218
+ $("#threadTitleBtn").addEventListener("click", (e) => { e.stopPropagation(); open(); });
219
+ document.addEventListener("click", () => pop.classList.remove("show"));
220
+ pop.addEventListener("click", (e) => e.stopPropagation());
221
+ $("#videoBtn").addEventListener("click", () => toast("📹 No video calls — this demo only decides whether a message interrupts you."));
222
+ $("#callBtn").addEventListener("click", () => toast("📞 No voice calls either — routing, not ringing."));
223
+ }
224
+
225
+ function contactInfoHTML() {
226
+ if (state.convKey === "meta:router") {
227
+ return `<div class="ip-avatar" style="background:linear-gradient(135deg,var(--brand-teal),var(--accent-lavender))">🧭</div>
228
+ <h4>Router HQ</h4><div class="ip-sub">The pipeline, narrating itself inside a chat thread.</div>`;
229
+ }
230
+ if (state.convKey === "meta:sandbox") {
231
+ return `<div class="ip-avatar" style="background:linear-gradient(135deg,var(--accent-mint),var(--accent-sun))">✍️</div>
232
+ <h4>Try It Yourself</h4><div class="ip-sub">A simplified live demo — not the real 30-signal pipeline.</div>`;
233
+ }
234
+ const conv = currentConv();
235
+ if (!conv) return "";
236
+ const rows = [];
237
+ if (conv.kind === "group") rows.push(["type", "group chat"]);
238
+ if (conv.kind === "business") { rows.push(["verified", conv.verified ? "yes ✔️" : "no"]); }
239
+ if (conv.subtitle) rows.push(["details", conv.subtitle]);
240
+ rows.push(["messages here", String(conv.messages.length)]);
241
+ rows.push(["routed by AI", String(conv.messages.filter((m) => m.decision).length)]);
242
+ return `<div class="ip-avatar" style="background:${conv.color}">${esc(conv.initials)}</div>
243
+ <h4>${esc(conv.name)}</h4><div class="ip-sub">${esc(conv.subtitle || (conv.kind === "personal" ? "direct message" : ""))}</div>
244
+ ${rows.map(([k, v]) => `<div class="ip-row"><span>${esc(k)}</span><b>${esc(v)}</b></div>`).join("")}`;
245
+ }
246
+
247
+ // --------------------------------------------------- architecture diagram
248
+ const ARCH_SEQUENCE = [
249
+ { nodes: ["an-source"], edges: [] },
250
+ { nodes: ["an-bundle"], edges: ["ae-source-bundle"] },
251
+ { nodes: ["an-media"], edges: ["ae-bundle-media"] },
252
+ { nodes: ["an-rules"], edges: ["ae-media-rules"] },
253
+ { nodes: ["an-override"], edges: ["ae-override-stub"] },
254
+ { nodes: ["an-router"], edges: ["ae-rules-router"] },
255
+ { nodes: ["an-normalize"], edges: ["ae-router-normalize"] },
256
+ { nodes: ["an-output"], edges: ["ae-normalize-output"] },
257
+ { nodes: ["an-evidence-note"], edges: ["ae-evidence"] },
258
+ ];
259
+ function initArchDiagram() {
260
+ const card = $("#archCard");
261
+ if (!card) return;
262
+ const edges = $$(".arch-edge", card);
263
+ edges.forEach((e) => {
264
+ const len = e.getTotalLength ? e.getTotalLength() : 120;
265
+ e.style.strokeDasharray = String(len);
266
+ e.style.strokeDashoffset = String(len);
267
+ });
268
+ let played = false;
269
+ function playArch() {
270
+ card.classList.add("play");
271
+ $$(".arch-node, .arch-edge", card).forEach((n) => n.classList.remove("on"));
272
+ edges.forEach((e) => (e.style.strokeDashoffset = e.style.strokeDasharray));
273
+ let i = 0;
274
+ const stepDelay = reduceMotion ? 0 : 260;
275
+ function tick() {
276
+ if (i >= ARCH_SEQUENCE.length) return;
277
+ const step = ARCH_SEQUENCE[i];
278
+ step.nodes.forEach((id) => $("#" + id)?.classList.add("on"));
279
+ step.edges.forEach((id) => {
280
+ const line = $("#" + id);
281
+ if (line) { line.classList.add("on"); line.style.strokeDashoffset = "0"; }
282
+ });
283
+ i++;
284
+ if (i < ARCH_SEQUENCE.length) setTimeout(tick, stepDelay);
285
+ }
286
+ tick();
287
+ }
288
+ if ("IntersectionObserver" in window) {
289
+ const io = new IntersectionObserver((entries) => {
290
+ entries.forEach((entry) => {
291
+ if (entry.isIntersecting && !played) { played = true; playArch(); }
292
+ });
293
+ }, { threshold: 0.35 });
294
+ io.observe(card);
295
+ } else {
296
+ playArch();
297
+ }
298
+ $("#diagramReplay").addEventListener("click", playArch);
299
+ }
300
+
301
+ // ---------------------------------------------------------- navigation
302
+ function showView(which) {
303
+ state.view = which;
304
+ $("#viewChatList").classList.toggle("hidden", which !== "list");
305
+ $("#viewThread").classList.toggle("hidden", which !== "thread");
306
+ }
307
+
308
+ // ------------------------------------------------------- chat list ----
309
+ function lastPreview(conv) {
310
+ const m = conv.messages[conv.messages.length - 1];
311
+ if (!m) return { text: "", time: "" };
312
+ let text;
313
+ if (m.media) text = m.media.type === "image" ? "📷 Photo" : "🎤 Voice message";
314
+ else text = (m.text || "").replace(/\s+/g, " ").slice(0, 46);
315
+ return { text, time: m.time, action: m.decision ? m.decision.action : null };
316
+ }
317
+
318
+ function renderChatList() {
319
+ const u = currentUser();
320
+ const list = $("#chatList");
321
+ list.innerHTML = "";
322
+
323
+ const q = (state.search || "").trim().toLowerCase();
324
+ if (!q || "router hq".includes(q)) {
325
+ const hqRow = buildMetaRow("router", "🧭", "Router HQ", "Tap to see how I actually think →", true);
326
+ hqRow.id = "hqRow";
327
+ hqRow.classList.add("pinned-hq");
328
+ list.appendChild(hqRow);
329
+ }
330
+ if (!q || "try it yourself".includes(q)) {
331
+ list.appendChild(buildMetaRow("sandbox", "✍️", "Try It Yourself", "Write your own message, get a live verdict", false));
332
+ }
333
+
334
+ const filtered = u.conversations.filter((conv) => {
335
+ if (q && !conv.name.toLowerCase().includes(q)) return false;
336
+ if (state.filter === "unread" && !conv.messages.some((m) => m.is_new)) return false;
337
+ if (state.filter === "group" && conv.kind !== "group") return false;
338
+ return true;
339
+ });
340
+
341
+ if (!filtered.length) {
342
+ const empty = el("div", "empty-row", `<span class="ee">🔍</span>No chats match${q ? ` “${esc(q)}”` : " this filter"}.`);
343
+ list.appendChild(empty);
344
+ return;
345
+ }
346
+
347
+ filtered.forEach((conv) => {
348
+ const prev = lastPreview(conv);
349
+ const unreadCount = conv.messages.filter((m) => m.is_new).length;
350
+ const row = el("div", "chat-row");
351
+ row.innerHTML = `
352
+ <div class="avatar ${conv.kind === "group" ? "group" : ""}" style="background:${conv.color}">${esc(conv.initials)}</div>
353
+ <div class="chat-row-body">
354
+ <div class="chat-row-top">
355
+ <span class="chat-row-name">${esc(conv.name)} ${conv.verified ? '<span class="verified-badge" title="Verified business">✔️</span>' : ""}</span>
356
+ <span class="chat-row-time ${unreadCount ? "unread" : ""}">${esc(prev.time)}</span>
357
+ </div>
358
+ <div class="chat-row-bottom">
359
+ <span class="chat-row-preview">${prev.action ? `<span class="action-dot" style="background:${ACTION_META[prev.action].color}"></span>` : ""}${esc(prev.text)}</span>
360
+ ${unreadCount ? `<span class="chat-row-badge">${unreadCount}</span>` : ""}
361
+ </div>
362
+ </div>`;
363
+ row.addEventListener("click", () => openThread(conv.conv_key));
364
+ list.appendChild(row);
365
+ });
366
+ }
367
+
368
+ function buildMetaRow(key, emoji, name, subtitle, pinned) {
369
+ const row = el("div", "chat-row");
370
+ row.innerHTML = `
371
+ <div class="avatar bot">${emoji}</div>
372
+ <div class="chat-row-body">
373
+ <div class="chat-row-top">
374
+ <span class="chat-row-name">${pinned ? "📌 " : ""}${name}${pinned ? '<span class="start-badge">start here</span>' : ""}</span>
375
+ <span class="chat-row-time"></span>
376
+ </div>
377
+ <div class="chat-row-bottom"><span class="chat-row-preview">${subtitle}</span></div>
378
+ </div>`;
379
+ row.addEventListener("click", () => (key === "router" ? openRouterHQ() : openSandbox()));
380
+ return row;
381
+ }
382
+
383
+ // ------------------------------------------------- surface Router HQ --
384
+ function maybeSpotlightRouterHQ() {
385
+ if (sessionStorage.getItem("hq_spotlight_seen")) return;
386
+ sessionStorage.setItem("hq_spotlight_seen", "1");
387
+ const row = $("#hqRow");
388
+ if (!row) return;
389
+ const arrow = el("div", "spotlight-arrow", "👆 Start here — I explain myself");
390
+ row.appendChild(arrow);
391
+ setTimeout(() => {
392
+ arrow.classList.add("out");
393
+ setTimeout(() => arrow.remove(), 320);
394
+ }, 4200);
395
+ }
396
+
397
+ // ---------------------------------------------------------- thread ----
398
+ function openThread(convKey) {
399
+ state.convKey = convKey;
400
+ const conv = currentConv();
401
+ $("#threadAvatar").style.background = conv.color;
402
+ $("#threadAvatar").textContent = conv.initials;
403
+ $("#threadAvatar").className = "avatar " + (conv.kind === "group" ? "group" : "");
404
+ $("#threadName").innerHTML = esc(conv.name) + (conv.verified ? ' <span class="verified-badge">✔️</span>' : "");
405
+ $("#threadSubtitle").textContent = conv.subtitle || (conv.kind === "personal" ? subtitleFor(conv.id) : "");
406
+ renderFakeComposer();
407
+ renderThreadMessages(conv);
408
+ showView("thread");
409
+ }
410
+
411
+ function renderFakeComposer() {
412
+ $("#composer").innerHTML = `
413
+ <div class="composer-fake">
414
+ <div class="cbox">
415
+ <button class="icon-btn" data-quip="😊 Emoji picker isn't wired up — this inbox is read-only by design.">😊</button>
416
+ <span class="ph">This inbox is read-only — try “Try It Yourself”</span>
417
+ <button class="icon-btn" data-quip="📎 Nothing to attach — every message here already happened, in the real run.">📎</button>
418
+ </div>
419
+ <button class="send-btn fake" data-quip="🎤 No mic input — type a message in “Try It Yourself” instead." aria-label="Voice message">
420
+ <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 15a3 3 0 003-3V6a3 3 0 10-6 0v6a3 3 0 003 3z"/><path d="M19 11a1 1 0 00-2 0 5 5 0 01-10 0 1 1 0 00-2 0 7 7 0 006 6.93V20H9a1 1 0 000 2h6a1 1 0 000-2h-2v-2.07A7 7 0 0019 11z"/></svg>
421
+ </button>
422
+ </div>`;
423
+ $$("[data-quip]", $("#composer")).forEach((btn) => btn.addEventListener("click", () => toast(btn.dataset.quip)));
424
+ }
425
+
426
+ function subtitleFor(id) {
427
+ const seed = id.split("_")[1] ? parseInt(id.split("_")[1], 10) : 0;
428
+ const opts = ["online", "last seen today at " + nowClock(), "last seen recently"];
429
+ return opts[seed % opts.length];
430
+ }
431
+
432
+ function renderThreadMessages(conv) {
433
+ const box = $("#threadMessages");
434
+ box.innerHTML = "";
435
+ let lastDate = "";
436
+ conv.messages.forEach((m) => {
437
+ if (m.date && m.date !== lastDate) {
438
+ box.appendChild(el("div", "date-chip", esc(m.date)));
439
+ lastDate = m.date;
440
+ }
441
+ box.appendChild(renderBubble(m, conv));
442
+ });
443
+ box.scrollTop = box.scrollHeight;
444
+ }
445
+
446
+ function mediaCardHTML(media) {
447
+ if (!media) return "";
448
+ if (media.type === "image") {
449
+ return `<div class="media-card">
450
+ <div class="media-visual"><span class="media-icon">🖼️</span><div style="font-size:.72rem;line-height:1.35"><b>${esc(media.visual_type || "image")}</b><br>${esc((media.summary || "").slice(0, 70))}</div></div>
451
+ <div class="media-cap">OCR: “${esc((media.ocr_text || "").slice(0, 90))}${(media.ocr_text || "").length > 90 ? "…" : ""}”</div>
452
+ </div>`;
453
+ }
454
+ const bars = Array.from({ length: 26 }, (_, i) => `<span style="height:${6 + Math.round(Math.abs(Math.sin(i * 1.7)) * 16)}px"></span>`).join("");
455
+ return `<div class="media-card voice">
456
+ <div class="media-visual"><span class="media-icon">🎤</span><div class="waveform">${bars}</div><span style="font-size:.72rem">0:0${Math.max(3, (media.transcript || "").length % 6 + 3)}</span></div>
457
+ <div class="media-cap">“${esc((media.transcript || "").slice(0, 90))}${(media.transcript || "").length > 90 ? "…" : ""}”</div>
458
+ </div>`;
459
+ }
460
+
461
+ function renderBubble(m, conv, opts) {
462
+ opts = opts || {};
463
+ const out = !!opts.out;
464
+ const row = el("div", "bubble-row " + (out ? "out" : "in"));
465
+ const b = el("div", "bubble " + (out ? "out" : "in") + (m.decision ? " decisioned" : ""));
466
+ if (m.decision) b.style.setProperty("--action-color", ACTION_META[m.decision.action].color);
467
+ b.dataset.msgId = m.message_id;
468
+ let senderHTML = "";
469
+ if (!out && conv && conv.kind === "group" && m.sender_name) {
470
+ senderHTML = `<span class="sender-name" style="color:${m.senderColor || conv.color}">${esc(m.sender_name)}</span>`;
471
+ }
472
+ const ticksHTML = out ? tickSVG(m.tickState || "read") : "";
473
+ b.innerHTML = `
474
+ ${senderHTML}
475
+ ${mediaCardHTML(m.media)}
476
+ <span class="btext">${esc(m.text || (m.media ? "" : ""))}</span>
477
+ <span class="bmeta"><span class="btime">${esc(m.time)}</span>${ticksHTML}</span>
478
+ ${m.decision ? decisionTagHTML(m.decision) : ""}
479
+ `;
480
+ if (m.decision) {
481
+ b.addEventListener("click", () => openPipelineSheet(m, conv));
482
+ }
483
+ row.appendChild(b);
484
+ return row;
485
+ }
486
+
487
+ function decisionTagHTML(d) {
488
+ const meta = ACTION_META[d.action];
489
+ return `<div class="decision-tag" style="--action-color:${meta.color}"><span class="dot"></span>${meta.icon} ${meta.label} · tap for the trace</div>`;
490
+ }
491
+
492
+ function tickSVG(tickState) {
493
+ const cls = tickState === "read" ? "tick read" : "tick";
494
+ return `<svg class="${cls}" viewBox="0 0 16 11"><path class="stroke" d="M1 6l3 3 7-8"/>${tickState !== "sent" ? '<path class="stroke" d="M5 6l3 3 7-8"/>' : ""}</svg>`;
495
+ }
496
+
497
+ // ---------------------------------------------------- pipeline sheet --
498
+ function openPipelineSheet(m, conv) {
499
+ const d = m.decision;
500
+ const meta = ACTION_META[d.action];
501
+ const overlay = $("#sheetOverlay");
502
+ const sheet = $("#pipelineSheet");
503
+ sheet.style.setProperty("--action-color", meta.color);
504
+ $("#sheetTitle").textContent = "How this got routed";
505
+ $("#sheetMsgPreview").textContent = "“" + (m.text || (m.media ? m.media.summary : "")).slice(0, 90) + "”";
506
+ $("#actionBadge").style.setProperty("--action-color", meta.color);
507
+ $("#actionBadge").innerHTML = `${meta.icon} ${meta.label} <span style="opacity:.6">· ${esc(titleize(d.message_type))}</span>`;
508
+
509
+ const band = BANDS[d.action];
510
+ const pct = ((d.confidence - band[0]) / (band[1] - band[0])) * 100;
511
+ $("#confGauge").innerHTML = `
512
+ <div class="cg-label"><span>confidence band ${band[0].toFixed(2)}–${band[1].toFixed(2)}</span><span>${d.confidence.toFixed(2)}</span></div>
513
+ <div class="cg-track"><div class="cg-band" style="left:0;right:0"></div><div class="cg-dot" style="left:${clamp01(pct / 100) * 100}%"></div></div>`;
514
+
515
+ const steps = buildFlowSteps(m, d, conv);
516
+ $("#flowRail").innerHTML = steps.map((s, i) => `
517
+ <div class="flow-node ${s.overrideHere ? "override" : ""}" data-i="${i}">${s.icon}</div>
518
+ ${i < steps.length - 1 ? `<div class="flow-line ${s.overrideHere ? "dashed" : ""}" data-i="${i}"><div class="fill"></div></div>` : ""}
519
+ `).join("");
520
+ const railNodes = $$(".flow-node", $("#flowRail"));
521
+ railNodes[railNodes.length - 1].classList.add("final");
522
+
523
+ $("#flowSteps").innerHTML = steps.map((s, i) => `
524
+ <div class="flow-step" data-i="${i}"><h4>${s.icon} ${s.title}</h4><div class="fs-body">${s.body}</div></div>`).join("");
525
+
526
+ overlay.classList.remove("hidden"); requestAnimationFrame(() => overlay.classList.add("show"));
527
+ sheet.classList.remove("hidden"); requestAnimationFrame(() => sheet.classList.add("show"));
528
+
529
+ animateFlow(steps.length);
530
+
531
+ // wire evidence chips after render
532
+ $$(".evid-chip", $("#flowSteps")).forEach((chip) => {
533
+ chip.addEventListener("click", () => {
534
+ const prev = chip.nextElementSibling;
535
+ if (prev && prev.classList.contains("evid-preview")) prev.classList.toggle("show");
536
+ });
537
+ });
538
+ }
539
+
540
+ function animateFlow(n) {
541
+ const nodes = $$(".flow-node", $("#flowRail"));
542
+ const lines = $$(".flow-line", $("#flowRail"));
543
+ const steps = $$(".flow-step", $("#flowSteps"));
544
+ const stepDelay = reduceMotion ? 0 : 420;
545
+ nodes.forEach((x) => x.classList.remove("active"));
546
+ steps.forEach((x) => x.classList.remove("active"));
547
+ lines.forEach((x) => x.querySelector(".fill").style.height = "0%");
548
+ let i = 0;
549
+ function tick() {
550
+ if (i >= n) return;
551
+ nodes[i].classList.add("active");
552
+ steps[i].classList.add("active");
553
+ if (lines[i]) requestAnimationFrame(() => (lines[i].querySelector(".fill").style.height = "100%"));
554
+ i++;
555
+ if (i < n) setTimeout(tick, stepDelay);
556
+ }
557
+ setTimeout(tick, reduceMotion ? 0 : 120);
558
+ }
559
+
560
+ function buildFlowSteps(m, d, conv) {
561
+ const u = currentUser();
562
+ const steps = [];
563
+ const histCount = u.conversations.reduce((s, c) => s + c.messages.filter((x) => !x.is_new).length, 0);
564
+ steps.push({
565
+ icon: "📦", title: "Bundle — one context block per user",
566
+ body: `Built once for <strong>${esc(u.name)}</strong>: ${histCount} past messages, quiet hours <strong>${esc(u.profile.do_not_disturb_window)}</strong>, dismissal rate ${(u.profile.dismissal_rate * 100).toFixed(0)}%. This whole batch shares one prompt.`,
567
+ });
568
+ if (m.media) {
569
+ const mm = m.media;
570
+ const flags = [
571
+ mm.contains_qr && "QR code", mm.contains_payment_request && "payment request",
572
+ mm.contains_urgency_language && "urgency language", mm.contains_otp_or_credential_request && "OTP/credential ask",
573
+ ].filter(Boolean);
574
+ steps.push({
575
+ icon: mm.type === "image" ? "🖼️" : "🎤", title: mm.type === "image" ? "Media — OCR" : "Media — transcription",
576
+ body: `${mm.type === "image" ? "OCR" : "Whisper-style transcript"} read: “${esc((mm.ocr_text || mm.transcript || "").slice(0, 110))}”. ${flags.length ? `Flags: <strong>${flags.join(", ")}</strong>.` : "No risk flags in the media itself."}`,
577
+ });
578
+ }
579
+ const sigs = d.signals_relied_on || [];
580
+ steps.push({
581
+ icon: "🛡️", title: "Rules — deterministic, no LLM",
582
+ body: `${sigs.length ? `Signals this decision leaned on: <div class="chip-row">${sigs.map((s) => `<span class="sig-chip ${d.override_rule && s.includes(d.override_rule.split("_")[0]) ? "hot" : ""}">${esc(s)}</span>`).join("")}</div>` : "No individual signal dominated — a blend of context."}${d.override_rule ? `<div class="override-banner">⚡ HARD OVERRIDE: <code>${esc(d.override_rule)}</code> — this bypassed the model. Four rules like this exist specifically because a fluent, confident message can itself be the attack.</div>` : ""}`,
583
+ overrideHere: !!d.override_rule,
584
+ });
585
+ steps.push({
586
+ icon: "🧠", title: d.source === "rule_override" ? "Router — model bypassed" : "Router — gemini-3.5-flash-lite",
587
+ body: d.source === "rule_override"
588
+ ? `The rule decided the <em>action</em>, but the model's evidence selection was kept regardless — an override constrains the verdict, not the reasoning behind it.`
589
+ : `Reasoned in the same call as the rest of ${esc(u.name)}'s batch, temperature 0, structured JSON out.${d.adjudicated ? ` Escalated to the adjudicator model over a rule/model disagreement: “${esc(d.adjudicator_note || "")}”` : ""}`,
590
+ });
591
+ const evid = d.evidence_message_ids || [];
592
+ const evidHTML = evid.length && evid[0] !== "none"
593
+ ? evid.map((eid) => {
594
+ const e = state.data.evidence[eid];
595
+ if (!e) return "";
596
+ return `<span class="evid-chip" data-eid="${eid}">📎 ${esc(eid)}</span><div class="evid-preview">“${esc(e.text.slice(0, 140))}” — ${esc(e.sender_name)}, ${esc(e.date)}<div class="ep-meta">opened=${e.event.opened} replied=${e.event.replied} dismissed=${e.event.dismissed} reported=${e.event.reported}</div></div>`;
597
+ }).join("")
598
+ : `<span class="sig-chip">no historical evidence — write it as "none", not a guess</span>`;
599
+ steps.push({
600
+ icon: "✅", title: "Normalize — semantic consistency check",
601
+ body: `Confidence clamped into the <strong>${d.action}</strong> band, enum validated, every phrase in the reason checked against the row's real signals.${evidHTML}
602
+ <div class="reason-final">“${esc(d.reason)}”<span class="rf-check">✓ consistency check passed</span></div>`,
603
+ });
604
+ return steps;
605
+ }
606
+
607
+ function closeSheet() {
608
+ $("#sheetOverlay").classList.remove("show");
609
+ $("#pipelineSheet").classList.remove("show");
610
+ setTimeout(() => { $("#sheetOverlay").classList.add("hidden"); $("#pipelineSheet").classList.add("hidden"); }, 380);
611
+ }
612
+
613
+ function initSheet() {
614
+ $("#sheetOverlay").addEventListener("click", closeSheet);
615
+ $("#sheetClose").addEventListener("click", closeSheet);
616
+ }
617
+
618
+ // ---------------------------------------------------------- deep link
619
+ function deepLink(userId, convKey, messageId) {
620
+ state.userId = userId;
621
+ $("#userSwitch").value = userId;
622
+ renderChatList();
623
+ openThread(convKey);
624
+ showView("thread");
625
+ setTimeout(() => {
626
+ const bubble = $(`.bubble[data-msg-id="${messageId}"]`);
627
+ if (bubble) {
628
+ bubble.scrollIntoView({ block: "center", behavior: reduceMotion ? "auto" : "smooth" });
629
+ bubble.style.transition = "box-shadow .3s ease";
630
+ bubble.style.boxShadow = "0 0 0 3px var(--brand-teal)";
631
+ setTimeout(() => (bubble.style.boxShadow = ""), 1400);
632
+ setTimeout(() => bubble.click(), reduceMotion ? 0 : 550);
633
+ }
634
+ }, reduceMotion ? 0 : 340);
635
+ }
636
+
637
+ // ============================================================
638
+ // Router HQ — a quirky pipeline explainer that lives IN a chat
639
+ // ============================================================
640
+ const HQ_INTRO = [
641
+ "Hey — I'm the router. Yes, the thing that just decided whether your last WhatsApp message deserved your attention. I live inside 5 phases and a few hard rules. Want the tour?",
642
+ "Quick numbers before we start: 110 messages, 32 people, 32 API calls — one per person, not per message. $0 spent. 93.3% action accuracy on the held-out samples.",
643
+ "Pick a thread below, or open any message bubble in a real chat above — the little dashed outline means I have a full trace for it.",
644
+ ];
645
+
646
+ const HQ_REPLIES = [
647
+ {
648
+ chip: "🛡️ Show me a scam you caught",
649
+ user: "Show me a scam you caught",
650
+ bot: [
651
+ "This one's my favourite. A message asking to \"verify OTP now or your profile will be limited\" landed in Vihaan's Myntra group thread.",
652
+ "I didn't even ask the model. `otp_or_credential_request` is one of 4 hard overrides — the kind of message that's dangerous *because* it sounds urgent and official. Confident text talking a model into the wrong answer is exactly the failure mode a rule has to catch before generation happens.",
653
+ ],
654
+ link: { label: "Open the trace →", userId: "u_004", convKey: "group:group_005", messageId: "msg_044" },
655
+ },
656
+ {
657
+ chip: "🧮 Why only 32 calls for 110 messages?",
658
+ user: "Why only 32 calls for 110 messages?",
659
+ bot: [
660
+ "Free-tier quota was the real constraint, not latency. I batch by *person*, not by message — one prompt per user with their whole profile, quiet hours, history and business relationships already inside it.",
661
+ "That's the 32 people in the dropdown above, by the way — every single one is a real routing call from the actual run, not a mock.",
662
+ "Bonus: batching means near-duplicate messages land in the same context, so I can tell a genuine admin notice apart from a copycat asking for a screenshot via a sketchy link — same batch, side by side.",
663
+ ],
664
+ },
665
+ {
666
+ chip: "🤔 Tell me about a mistake",
667
+ user: "Tell me about a mistake you made",
668
+ bot: [
669
+ "Early on I auto-muted anyone with a bad cross-user reputation, no matter what they said. Sounded safe.",
670
+ "The labelled data disagreed: the worst-reputation sender in the dataset sent both a scam *and* a completely harmless message to different people — and only the scam was meant to be muted.",
671
+ "So reputation got demoted to a ceiling, not a verdict: it can cap a decision at 'digest', but it can never mute by itself anymore. Accuracy went from 90.0% back up to 93.3% on that one fix.",
672
+ ],
673
+ },
674
+ {
675
+ chip: "🎬 Play my full trace live",
676
+ user: "Play my full trace live",
677
+ bot: ["Loading the OTP-scam trace — watch the left rail light up phase by phase.", "That dashed red line means the router rule fired and skipped the model for the *verdict* — but kept its evidence, because evidence is scored output, not a safety call."],
678
+ link: { label: "▶ Watch it route →", userId: "u_004", convKey: "group:group_005", messageId: "msg_044" },
679
+ },
680
+ {
681
+ chip: "🔍 A false claim you caught yourself",
682
+ user: "Did you ever catch yourself lying?",
683
+ bot: [
684
+ "Fair question. Every generated reason gets checked phrase-by-phrase against the row's real signals before it ships — not just \"is this valid JSON\", but \"is this true\".",
685
+ "Across the full run it rejected 15 reasons that were well-formed but false — including calling an unverified pharmacy \"a verified business\", and calling a one-to-one message about holding a jacket \"marketing the user opted out of\".",
686
+ "One targeted re-ask, then a deterministic fallback that can't contradict the row. No hallucinated justification ships.",
687
+ ],
688
+ },
689
+ ];
690
+
691
+ function openRouterHQ() {
692
+ state.convKey = "meta:router";
693
+ $("#threadAvatar").className = "avatar bot";
694
+ $("#threadAvatar").style.background = "";
695
+ $("#threadAvatar").textContent = "🧭";
696
+ $("#threadName").textContent = "Router HQ";
697
+ $("#threadSubtitle").textContent = "always online · replies instantly";
698
+ const box = $("#threadMessages");
699
+ box.innerHTML = "";
700
+ $("#composer").innerHTML = `<div class="locked-note">Tap a suggestion below — this bot only speaks in facts from the real run</div>`;
701
+ showView("thread");
702
+ playHQIntro(box);
703
+ }
704
+
705
+ function botBubble(text) {
706
+ const row = el("div", "bubble-row in");
707
+ const b = el("div", "bubble in");
708
+ b.innerHTML = `<span class="btext">${text}</span><span class="bmeta"><span class="btime">${nowClock()}</span></span>`;
709
+ row.appendChild(b);
710
+ return row;
711
+ }
712
+ function userBubble(text) {
713
+ const row = el("div", "bubble-row out");
714
+ const b = el("div", "bubble out");
715
+ b.innerHTML = `<span class="btext">${esc(text)}</span><span class="bmeta"><span class="btime">${nowClock()}</span>${tickSVG("sent")}</span>`;
716
+ row.appendChild(b);
717
+ setTimeout(() => { b.querySelector(".tick").outerHTML = tickSVG("delivered"); }, 350);
718
+ setTimeout(() => { b.querySelector(".tick").outerHTML = tickSVG("read"); }, 850);
719
+ return row;
720
+ }
721
+ function typingIndicator() {
722
+ const row = el("div", "typing-row");
723
+ row.innerHTML = `<div class="typing-bubble"><span></span><span></span><span></span></div>`;
724
+ return row;
725
+ }
726
+
727
+ function playHQIntro(box) {
728
+ let i = 0;
729
+ function next() {
730
+ if (i >= HQ_INTRO.length) { renderHQChips(box); return; }
731
+ const t = typingIndicator();
732
+ box.appendChild(t); box.scrollTop = box.scrollHeight;
733
+ setTimeout(() => {
734
+ t.remove();
735
+ box.appendChild(botBubble(HQ_INTRO[i]));
736
+ box.scrollTop = box.scrollHeight;
737
+ i++;
738
+ setTimeout(next, reduceMotion ? 30 : 480);
739
+ }, reduceMotion ? 30 : 620);
740
+ }
741
+ next();
742
+ }
743
+
744
+ function renderHQChips(box) {
745
+ const wrap = el("div", "quick-replies");
746
+ HQ_REPLIES.forEach((r) => {
747
+ const chip = el("button", "qr-chip", r.chip);
748
+ chip.addEventListener("click", () => {
749
+ wrap.querySelectorAll(".qr-chip").forEach((c) => (c.disabled = true));
750
+ box.appendChild(userBubble(r.user));
751
+ box.scrollTop = box.scrollHeight;
752
+ const t = typingIndicator();
753
+ setTimeout(() => { box.appendChild(t); box.scrollTop = box.scrollHeight; }, 300);
754
+ setTimeout(() => {
755
+ t.remove();
756
+ let j = 0;
757
+ function nextLine() {
758
+ if (j >= r.bot.length) {
759
+ if (r.link) {
760
+ const row = el("div", "bubble-row in");
761
+ const b = el("div", "bubble in");
762
+ const btn = el("button", "deep-link-btn", r.link.label);
763
+ btn.addEventListener("click", () => deepLink(r.link.userId, r.link.convKey, r.link.messageId));
764
+ b.innerHTML = `<span class="btext">One tap away:</span>`;
765
+ b.appendChild(btn);
766
+ row.appendChild(b);
767
+ box.appendChild(row);
768
+ }
769
+ wrap.querySelectorAll(".qr-chip").forEach((c) => (c.disabled = false));
770
+ box.appendChild(wrap);
771
+ box.scrollTop = box.scrollHeight;
772
+ return;
773
+ }
774
+ box.appendChild(botBubble(r.bot[j]));
775
+ box.scrollTop = box.scrollHeight;
776
+ j++;
777
+ setTimeout(nextLine, reduceMotion ? 20 : 520);
778
+ }
779
+ nextLine();
780
+ }, reduceMotion ? 50 : 900);
781
+ });
782
+ wrap.appendChild(chip);
783
+ });
784
+ box.appendChild(wrap);
785
+ box.scrollTop = box.scrollHeight;
786
+ }
787
+
788
+ // ============================================================
789
+ // Try It Yourself — live sandbox with an honestly-labelled
790
+ // simplified heuristic (not the real 30-signal pipeline)
791
+ // ============================================================
792
+ function openSandbox() {
793
+ state.convKey = "meta:sandbox";
794
+ $("#threadAvatar").className = "avatar bot";
795
+ $("#threadAvatar").style.background = "";
796
+ $("#threadAvatar").textContent = "✍️";
797
+ $("#threadName").textContent = "Try It Yourself";
798
+ $("#threadSubtitle").textContent = "simplified live demo";
799
+ const box = $("#threadMessages");
800
+ box.innerHTML = "";
801
+ box.appendChild(botBubble("Type any WhatsApp-style message and I'll route it live — right here, in your browser, no server call."));
802
+ box.appendChild(botBubble("Heads up: this is a ~15-line keyword heuristic for the demo, not the real 5-phase pipeline. For the real thing, tap any dashed message bubble in an actual chat above."));
803
+ box.scrollTop = box.scrollHeight;
804
+ $("#composer").innerHTML = `
805
+ <div class="cbox"><input id="sandboxInput" placeholder="e.g. Pay ₹500 now or lose your seat, scan this QR" maxlength="180" /></div>
806
+ <button class="send-btn" id="sandboxSend">${SEND_ICON}</button>`;
807
+ const input = $("#sandboxInput");
808
+ const send = () => {
809
+ const val = input.value.trim();
810
+ if (!val) return;
811
+ box.appendChild(userBubble(val));
812
+ box.scrollTop = box.scrollHeight;
813
+ input.value = "";
814
+ const t = typingIndicator();
815
+ setTimeout(() => { box.appendChild(t); box.scrollTop = box.scrollHeight; }, 200);
816
+ setTimeout(() => {
817
+ t.remove();
818
+ const r = simulateRoute(val);
819
+ const row = el("div", "bubble-row in");
820
+ const b = el("div", "bubble in");
821
+ b.style.setProperty("--action-color", ACTION_META[r.action].color);
822
+ b.innerHTML = `<span class="btext">${r.icon} <strong>${ACTION_META[r.action].label}</strong> · ${titleize(r.message_type)}<br><span style="font-size:.86rem;color:var(--wa-list-subtitle)">${esc(r.reason)}</span></span>
823
+ <span class="bmeta"><span class="btime">${nowClock()}</span></span>
824
+ <div class="decision-tag" style="--action-color:${ACTION_META[r.action].color}"><span class="dot"></span>confidence ${r.confidence.toFixed(2)} · toy heuristic</div>`;
825
+ row.appendChild(b);
826
+ box.appendChild(row);
827
+ box.scrollTop = box.scrollHeight;
828
+ }, reduceMotion ? 30 : 700);
829
+ };
830
+ $("#sandboxSend").addEventListener("click", send);
831
+ input.addEventListener("keydown", (e) => { if (e.key === "Enter") send(); });
832
+ showView("thread");
833
+ setTimeout(() => input.focus(), 300);
834
+ }
835
+
836
+ const SEND_ICON = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 20l18-8L3 4v6l12 2-12 2z"/></svg>';
837
+
838
+ function simulateRoute(text) {
839
+ const t = text.toLowerCase();
840
+ const has = (arr) => arr.some((w) => t.includes(w));
841
+ if (has(["otp", "verify your account", "bank detail", "card number", "cvv", "pin number", "click this link and login"])) {
842
+ return { action: "mute", message_type: "scam", confidence: 0.83, icon: "🔇", reason: "Requests a credential or OTP through an unverified flow — mirrors the OTP hard override." };
843
+ }
844
+ if (has(["forward this", "send to", "people you care about", "10 people", "share this", "chain"])) {
845
+ return { action: "mute", message_type: "forward", confidence: 0.85, icon: "🔇", reason: "Reads like a chain-forward asking to reshare — the pattern usually gets ignored anyway." };
846
+ }
847
+ if (has(["% off", "sale", "discount", "buy now", "offer", "limited stock", "flat "])) {
848
+ return { action: "digest", message_type: "promotion", confidence: 0.8, icon: "🕓", reason: "Promotional content — plausibly useful, but nothing time-critical here." };
849
+ }
850
+ if (has(["urgent", "immediately", "asap", "right now", "emergency", "in 10 min", "in 20 min", "call now"])) {
851
+ return { action: "notify", message_type: "urgent", confidence: 0.88, icon: "🔔", reason: "Deadline or urgency language pushes this to interrupt-now." };
852
+ }
853
+ if (has(["good morning", "good night", "gm", "happy diwali", "happy new year", "god bless"])) {
854
+ return { action: "mute", message_type: "greeting", confidence: 0.84, icon: "🔇", reason: "Low-content greeting text — the kind that's routinely dismissed." };
855
+ }
856
+ if (has(["payment", "due", "pay by", "invoice", "fee", "penalty"])) {
857
+ return { action: "notify", message_type: "payment", confidence: 0.87, icon: "🔔", reason: "Mentions a concrete payment obligation — worth surfacing even without extra urgency words." };
858
+ }
859
+ return { action: "digest", message_type: "personal", confidence: 0.79, icon: "🕓", reason: "No strong signal matched this toy heuristic — the real pipeline would pull in sender history, DND state and cross-user reputation here." };
860
+ }
861
+
862
+ // ---------------------------------------------------------------- init
863
+ function init() {
864
+ initTheme();
865
+ initSheet();
866
+ $("#backBtn").addEventListener("click", () => showView("list"));
867
+ const el2 = document.getElementById("app-data");
868
+ if (el2) {
869
+ boot(JSON.parse(el2.textContent));
870
+ } else {
871
+ fetch("data.json").then((r) => r.json()).then(boot).catch((err) => {
872
+ $("#chatList").innerHTML = `<div style="padding:30px;color:var(--wa-list-subtitle);font-size:.85rem">Could not load data.json (${esc(String(err))}). Run webapp/build_data.py and serve this folder over HTTP.</div>`;
873
+ });
874
+ }
875
+ }
876
+
877
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
878
+ else init();
879
+ })();
data.json ADDED
The diff for this file is too large to render. See raw diff
 
index.html ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Message Router — an inbox that reasons</title>
7
+ <meta name="description" content="An interactive WhatsApp replica showing a real AI notification-routing pipeline: 93.3% accuracy, 32 API calls for 110 messages, $0 cost. Ranked 18/1983 at HackerRank Orchestrate." />
8
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧭</text></svg>" />
9
+ <link rel="stylesheet" href="styles.css" />
10
+ </head>
11
+ <body>
12
+
13
+ <div class="blobs" aria-hidden="true">
14
+ <div class="blob b1"></div><div class="blob b2"></div><div class="blob b3"></div><div class="blob b4"></div>
15
+ </div>
16
+
17
+ <header class="topbar">
18
+ <div class="brand"><span class="mark">🧭</span><span>Message&nbsp;Router</span></div>
19
+ <button id="themeToggle" class="theme-toggle" aria-label="Toggle dark mode"></button>
20
+ </header>
21
+
22
+ <section class="hero">
23
+ <div class="hero-copy reveal">
24
+ <p class="eyebrow">HackerRank Orchestrate · Ranked <span id="statRank">18 / 1,983</span></p>
25
+ <h1>Every WhatsApp message,<br>routed like <em>a person</em> would.</h1>
26
+ <p class="lede">An AI pipeline that decides what interrupts you now, what waits for later, and what gets muted — reasoning over text, image posters and voice notes, with hard safety rules a language model can't be talked out of. This isn't a mockup: every verdict below is a real, precomputed output of the actual routing run. Tap in.</p>
27
+ <div class="stat-row">
28
+ <div class="stat-chip" style="--rot:-4deg"><b id="statAccuracy">93.3%</b><span>action accuracy</span></div>
29
+ <div class="stat-chip" style="--rot:3deg"><b id="statCalls">32 / 110</b><span>API calls for messages</span></div>
30
+ <div class="stat-chip" style="--rot:-2deg"><b id="statCost">$0</b><span>cost, free tier only</span></div>
31
+ <div class="stat-chip" style="--rot:5deg"><b id="statCaught">15</b><span>false claims self-caught</span></div>
32
+ </div>
33
+ </div>
34
+ <div class="hero-phone-wrap reveal">
35
+ <div class="callout c1">Tap any dashed message → see the full 5-phase trace<span class="arrow">↳ real signals, not vibes</span></div>
36
+ <div class="callout c2">Switch inboxes — same sender, different verdict, on purpose<span class="arrow">↳ personalization is the whole point</span></div>
37
+ <div class="phone-frame-outer">
38
+ <div class="stage-controls">
39
+ <label for="userSwitch">Viewing inbox as</label>
40
+ <select id="userSwitch" aria-label="Switch device owner"></select>
41
+ </div>
42
+
43
+ <div class="phone" id="phone">
44
+ <div class="notch"></div>
45
+ <div class="screen">
46
+ <div class="statusbar">
47
+ <span id="clock">9:41</span>
48
+ <span class="icons">📶 🔋</span>
49
+ </div>
50
+
51
+ <div id="viewChatList" class="view chat-list-view">
52
+ <div class="wa-header list-header">
53
+ <span class="wa-title">Chats</span>
54
+ <div class="header-icons">
55
+ <button id="searchIconBtn" aria-label="Search">
56
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
57
+ </button>
58
+ <button id="menuBtn" aria-label="Menu">
59
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
60
+ </button>
61
+ <div class="dropdown-menu" id="menuDropdown">
62
+ <button data-action="theme">🌓 <span>Toggle theme</span></button>
63
+ <button data-action="shuffle">🔀 <span>Shuffle inbox</span></button>
64
+ <button data-action="router">🧭 <span>Open Router HQ</span></button>
65
+ <div class="dm-divider"></div>
66
+ <button data-action="diagram">📐 <span>View architecture</span></button>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ <div class="filter-row" id="filterRow">
71
+ <button class="filter-pill active" data-filter="all">All</button><button class="filter-pill" data-filter="unread">Unread</button><button class="filter-pill" data-filter="group">Groups</button>
72
+ </div>
73
+ <div class="search-bar" id="searchBar">
74
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
75
+ <input id="searchInput" type="text" placeholder="Search chats" autocomplete="off" />
76
+ <button class="search-clear" id="searchClear" aria-label="Clear search">✕</button>
77
+ </div>
78
+ <div class="chat-list" id="chatList"></div>
79
+ <div class="nav-stub hidden" id="navStub"></div>
80
+ <div class="bottom-nav">
81
+ <button class="nav-item active" data-tab="chats"><span class="nav-ico">💬</span>Chats</button>
82
+ <button class="nav-item" data-tab="updates"><span class="nav-ico">🔄</span>Updates</button>
83
+ <button class="nav-item" data-tab="communities"><span class="nav-ico">👥</span>Communities</button>
84
+ <button class="nav-item" data-tab="calls"><span class="nav-ico">📞</span>Calls</button>
85
+ </div>
86
+ </div>
87
+
88
+ <div id="viewThread" class="view thread-view hidden">
89
+ <div class="wa-header thread-header">
90
+ <button class="back-btn" id="backBtn" aria-label="Back">‹</button>
91
+ <button class="avatar" id="threadAvatar" aria-label="Contact info" style="border:none;cursor:pointer;padding:0"></button>
92
+ <button class="thread-title" id="threadTitleBtn" aria-label="Contact info" style="background:none;border:none;text-align:left;cursor:pointer;padding:0;color:inherit">
93
+ <span class="t-name" id="threadName"></span>
94
+ <span class="t-sub" id="threadSubtitle"></span>
95
+ </button>
96
+ <div class="header-icons">
97
+ <button id="videoBtn" aria-label="Video call"><svg viewBox="0 0 24 24"><path d="M17 10.5V7a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h12a1 1 0 001-1v-3.5l4 4v-11l-4 4z"/></svg></button>
98
+ <button id="callBtn" aria-label="Voice call"><svg viewBox="0 0 24 24"><path d="M6.6 10.8c1.4 2.7 3.6 4.9 6.3 6.3l2.1-2.1c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.5.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.4 21 3 13.6 3 4.5c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.2.2 2.4.6 3.5.1.4 0 .8-.2 1L6.6 10.8z"/></svg></button>
99
+ </div>
100
+ <div class="info-pop" id="infoPop"></div>
101
+ </div>
102
+ <div class="thread-messages" id="threadMessages"></div>
103
+ <div class="composer" id="composer"></div>
104
+ </div>
105
+ <div class="toast-stack" id="toastStack"></div>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ </section>
111
+
112
+ <section class="pipeline-overview">
113
+ <h2>How a message actually gets routed</h2>
114
+ <p class="sub">Five phases, most of them deterministic. The model only speaks once it's the last honest option.</p>
115
+ <figure class="diagram-card" id="archCard">
116
+ <svg viewBox="0 0 1100 170" role="img" aria-label="Pipeline flow: dataset feeds a per-user bundle, then media OCR/transcription, then rules. Rules run deterministically; an override branches above the rules-to-router step and can force the verdict, while a separate short path shows the router still receives the override's evidence. The router calls the LLM once per user, then normalize validates and semantically checks the output before it becomes a CSV row." font-family="ui-monospace, Consolas, monospace" font-size="11.5">
117
+ <defs>
118
+ <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
119
+ <path d="M0 0L10 5L0 10z" fill="currentColor" />
120
+ </marker>
121
+ </defs>
122
+
123
+ <g class="arch-node" id="an-source" fill="currentColor">
124
+ <text x="55" y="108" text-anchor="middle" font-size="18">📄</text>
125
+ <text x="55" y="130" text-anchor="middle" font-size="9.5" opacity="0.7">dataset/*.csv</text>
126
+ </g>
127
+ <line class="arch-edge" id="ae-source-bundle" x1="95" y1="109" x2="130" y2="109" stroke="currentColor" stroke-width="1.6" marker-end="url(#arrow)" fill="none" />
128
+
129
+ <g class="arch-node" id="an-bundle" fill="currentColor">
130
+ <rect x="130" y="88" width="150" height="42" rx="10" fill="none" stroke="currentColor" opacity="0.8"/>
131
+ <text x="205" y="106" text-anchor="middle" font-weight="700">1 · bundle.py</text>
132
+ <text x="205" y="120" text-anchor="middle" opacity="0.6">one block per user</text>
133
+ </g>
134
+ <line class="arch-edge" id="ae-bundle-media" x1="280" y1="109" x2="314" y2="109" stroke="currentColor" stroke-width="1.6" marker-end="url(#arrow)" fill="none" />
135
+
136
+ <g class="arch-node" id="an-media" fill="currentColor">
137
+ <rect x="314" y="88" width="150" height="42" rx="10" fill="none" stroke="currentColor" opacity="0.8"/>
138
+ <text x="389" y="106" text-anchor="middle" font-weight="700">2 · media.py</text>
139
+ <text x="389" y="120" text-anchor="middle" opacity="0.6">OCR + transcript, cached</text>
140
+ </g>
141
+ <line class="arch-edge" id="ae-media-rules" x1="464" y1="109" x2="498" y2="109" stroke="currentColor" stroke-width="1.6" marker-end="url(#arrow)" fill="none" />
142
+
143
+ <g class="arch-node" id="an-rules" fill="currentColor">
144
+ <rect x="498" y="88" width="150" height="42" rx="10" fill="none" stroke="currentColor" opacity="0.8"/>
145
+ <text x="573" y="106" text-anchor="middle" font-weight="700">3 · rules.py</text>
146
+ <text x="573" y="120" text-anchor="middle" opacity="0.6">4 hard overrides</text>
147
+ </g>
148
+
149
+ <!-- override branches above the rules→router step; two short paths fan out from it -->
150
+ <line class="arch-edge" id="ae-override-stub" x1="665" y1="50" x2="665" y2="85" stroke="currentColor" stroke-width="1.6" stroke-dasharray="3 3" marker-end="url(#arrow)" fill="none" />
151
+ <g class="arch-node hot" id="an-override" fill="currentColor">
152
+ <rect x="580" y="8" width="170" height="42" rx="10" fill="none" stroke="var(--danger, currentColor)" stroke-width="2"/>
153
+ <text x="665" y="26" text-anchor="middle" font-weight="700">override fires?</text>
154
+ <text x="665" y="40" text-anchor="middle" opacity="0.6">verdict skips the LLM</text>
155
+ </g>
156
+
157
+ <line class="arch-edge" id="ae-rules-router" x1="648" y1="109" x2="682" y2="109" stroke="currentColor" stroke-width="1.6" marker-end="url(#arrow)" fill="none" />
158
+
159
+ <g class="arch-node" id="an-router" fill="currentColor">
160
+ <rect x="682" y="88" width="150" height="42" rx="10" fill="none" stroke="currentColor" opacity="0.8"/>
161
+ <text x="757" y="106" text-anchor="middle" font-weight="700">4 · router.py</text>
162
+ <text x="757" y="120" text-anchor="middle" opacity="0.6">1 call / user, T=0</text>
163
+ </g>
164
+ <line class="arch-edge" id="ae-router-normalize" x1="832" y1="109" x2="866" y2="109" stroke="currentColor" stroke-width="1.6" marker-end="url(#arrow)" fill="none" />
165
+
166
+ <g class="arch-node" id="an-normalize" fill="currentColor">
167
+ <rect x="866" y="88" width="150" height="42" rx="10" fill="none" stroke="currentColor" opacity="0.8"/>
168
+ <text x="941" y="106" text-anchor="middle" font-weight="700">5 · normalize.py</text>
169
+ <text x="941" y="120" text-anchor="middle" opacity="0.6">semantic check</text>
170
+ </g>
171
+ <line class="arch-edge" id="ae-normalize-output" x1="1016" y1="109" x2="1050" y2="109" stroke="currentColor" stroke-width="1.6" marker-end="url(#arrow)" fill="none" />
172
+
173
+ <g class="arch-node" id="an-output" fill="currentColor">
174
+ <text x="1063" y="108" text-anchor="middle" font-size="18">📤</text>
175
+ <text x="1063" y="130" text-anchor="middle" font-size="9.5" opacity="0.7">output.csv</text>
176
+ </g>
177
+
178
+ <path class="arch-edge" id="ae-evidence" d="M720 50 Q745 68 757 87" stroke="currentColor" stroke-width="1.6" stroke-dasharray="4 4" marker-end="url(#arrow)" fill="none" />
179
+ <g class="arch-node" id="an-evidence-note" fill="currentColor">
180
+ <text x="855" y="20" text-anchor="middle" font-size="10" opacity="0.75">override's own evidence</text>
181
+ <text x="855" y="33" text-anchor="middle" font-size="10" opacity="0.75">still reaches router.py</text>
182
+ </g>
183
+ </svg>
184
+ <figcaption>Rules can force the verdict on 4 hard-override conditions (OTP/credential asks, lookalike-domain payments, router-injection attempts, chain forwards) — but the router's evidence selection survives the override, since evidence is scored output, not a safety decision.</figcaption>
185
+ <button class="diagram-replay" id="diagramReplay">🔁 Replay</button>
186
+ </figure>
187
+ </section>
188
+
189
+ <footer class="site-footer">
190
+ <p>Built for HackerRank Orchestrate, August 2026 · ranked 18 / 1,983 · every number on this page comes from the committed run, nothing is fabricated for the demo.</p>
191
+ </footer>
192
+
193
+ <div id="sheetOverlay" class="sheet-overlay hidden"></div>
194
+ <div id="pipelineSheet" class="pipeline-sheet hidden" role="dialog" aria-modal="true" aria-label="Routing trace">
195
+ <div class="sheet-grab"></div>
196
+ <div class="sheet-head">
197
+ <div class="sh-top">
198
+ <div>
199
+ <h3 id="sheetTitle">How this got routed</h3>
200
+ <div class="sh-msg" id="sheetMsgPreview"></div>
201
+ </div>
202
+ <button class="sheet-close" id="sheetClose" aria-label="Close">✕</button>
203
+ </div>
204
+ <div class="action-badge" id="actionBadge"></div>
205
+ <div class="conf-gauge" id="confGauge"></div>
206
+ </div>
207
+ <div class="sheet-body">
208
+ <div class="flow">
209
+ <div class="flow-rail" id="flowRail"></div>
210
+ <div class="flow-steps" id="flowSteps"></div>
211
+ </div>
212
+ </div>
213
+ </div>
214
+
215
+ <script src="app.js"></script>
216
+ <script>
217
+ var _clk = document.getElementById("clock");
218
+ function _tick(){ if(_clk){ _clk.textContent = new Date().toLocaleTimeString([], {hour:"2-digit", minute:"2-digit", hour12:false}); } }
219
+ _tick(); setInterval(_tick, 30000);
220
+ </script>
221
+ </body>
222
+ </html>
styles.css ADDED
@@ -0,0 +1,630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =================================================================
2
+ Message Router — demo shell
3
+ Two visual worlds on purpose: the phone reproduces WhatsApp exactly
4
+ (its own tokens, --wa-*), the page around it is the studio's own
5
+ design (--page-*, --ink-*, --accent-*). They share one theme switch.
6
+ ================================================================= */
7
+
8
+ /* ---------- font stacks (system, no webfont fetch — kept honest) ---------- */
9
+ :root {
10
+ --font-display: Georgia, "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
11
+ --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
12
+ --font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Consolas", monospace;
13
+ --font-wa: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
14
+ }
15
+
16
+ /* ---------- LIGHT (default — explicit, not system-derived) ---------- */
17
+ :root,
18
+ :root[data-theme="light"] {
19
+ --page-bg: #fbf6ef;
20
+ --page-bg-2: #f5ede1;
21
+ --ink: #241f2e;
22
+ --ink-soft: #6b6478;
23
+ --ink-faint: #9c93ab;
24
+ --paper: #ffffff;
25
+ --accent-blush: #f3b6c6;
26
+ --accent-lavender: #c9b6f3;
27
+ --accent-mint: #aee6c4;
28
+ --accent-sun: #f6d186;
29
+ --brand-teal: #0a8f74;
30
+ --brand-teal-ink: #ffffff;
31
+ --glass-bg: rgba(255, 255, 255, 0.6);
32
+ --glass-border: rgba(255, 255, 255, 0.9);
33
+ --glass-shadow: rgba(60, 40, 30, 0.16);
34
+ --ring: rgba(36, 31, 46, 0.08);
35
+ --danger: #c14953;
36
+ --warn: #b8790f;
37
+ --good: #1f8a5f;
38
+
39
+ --wa-header: #008069;
40
+ --wa-header-ink: #ffffff;
41
+ --wa-list-bg: #ffffff;
42
+ --wa-list-search-bg: #f0f2f5;
43
+ --wa-list-ink: #111b21;
44
+ --wa-list-subtitle: #667781;
45
+ --wa-divider: #e9edef;
46
+ --wa-chat-bg: #efeae2;
47
+ --wa-bubble-in: #ffffff;
48
+ --wa-bubble-in-ink: #111b21;
49
+ --wa-bubble-out: #d9fdd3;
50
+ --wa-bubble-out-ink: #111b21;
51
+ --wa-bubble-shadow: rgba(0, 0, 0, 0.08);
52
+ --wa-timestamp: #667781;
53
+ --wa-tick-grey: #8696a0;
54
+ --wa-tick-blue: #53bdee;
55
+ --wa-fab: #00a884;
56
+ --wa-fab-ink: #ffffff;
57
+ --wa-online: #00a884;
58
+ --wa-panel-ink: #111b21;
59
+ --wa-icon: #54656f;
60
+ --wa-badge: #25d366;
61
+ --wa-badge-ink: #073b2c;
62
+ --wa-doodle: radial-gradient(circle at 1px 1px, rgba(0, 0, 0, 0.05) 1px, transparent 0);
63
+ }
64
+
65
+ /* ---------- DARK, system-preferred (only when no explicit choice) ---------- */
66
+ @media (prefers-color-scheme: dark) {
67
+ :root:not([data-theme="light"]) {
68
+ --page-bg: #15121c;
69
+ --page-bg-2: #1c1826;
70
+ --ink: #f1ecf7;
71
+ --ink-soft: #b8afc7;
72
+ --ink-faint: #79708c;
73
+ --paper: #1e1a28;
74
+ --accent-blush: #e38fa6;
75
+ --accent-lavender: #a98ce0;
76
+ --accent-mint: #7fcba0;
77
+ --accent-sun: #e0b563;
78
+ --brand-teal: #24c39a;
79
+ --brand-teal-ink: #0b1f1a;
80
+ --glass-bg: rgba(30, 26, 40, 0.55);
81
+ --glass-border: rgba(255, 255, 255, 0.08);
82
+ --glass-shadow: rgba(0, 0, 0, 0.5);
83
+ --ring: rgba(255, 255, 255, 0.08);
84
+ --danger: #e58089;
85
+ --warn: #e0b563;
86
+ --good: #6bd9a6;
87
+
88
+ --wa-header: #202c33;
89
+ --wa-header-ink: #e9edef;
90
+ --wa-list-bg: #111b21;
91
+ --wa-list-search-bg: #202c33;
92
+ --wa-list-ink: #e9edef;
93
+ --wa-list-subtitle: #8696a0;
94
+ --wa-divider: #222e35;
95
+ --wa-chat-bg: #0b141a;
96
+ --wa-bubble-in: #202c33;
97
+ --wa-bubble-in-ink: #e9edef;
98
+ --wa-bubble-out: #005c4b;
99
+ --wa-bubble-out-ink: #e9edef;
100
+ --wa-bubble-shadow: rgba(0, 0, 0, 0.3);
101
+ --wa-timestamp: #8696a0;
102
+ --wa-tick-grey: #8696a0;
103
+ --wa-tick-blue: #53bdee;
104
+ --wa-fab: #00a884;
105
+ --wa-fab-ink: #062f26;
106
+ --wa-online: #00d9a3;
107
+ --wa-panel-ink: #e9edef;
108
+ --wa-icon: #aebac1;
109
+ --wa-badge: #00a884;
110
+ --wa-badge-ink: #04241c;
111
+ --wa-doodle: radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.05) 1px, transparent 0);
112
+ }
113
+ }
114
+
115
+ /* ---------- DARK, explicit toggle (always wins) ---------- */
116
+ :root[data-theme="dark"] {
117
+ --page-bg: #15121c;
118
+ --page-bg-2: #1c1826;
119
+ --ink: #f1ecf7;
120
+ --ink-soft: #b8afc7;
121
+ --ink-faint: #79708c;
122
+ --paper: #1e1a28;
123
+ --accent-blush: #e38fa6;
124
+ --accent-lavender: #a98ce0;
125
+ --accent-mint: #7fcba0;
126
+ --accent-sun: #e0b563;
127
+ --brand-teal: #24c39a;
128
+ --brand-teal-ink: #0b1f1a;
129
+ --glass-bg: rgba(30, 26, 40, 0.55);
130
+ --glass-border: rgba(255, 255, 255, 0.08);
131
+ --glass-shadow: rgba(0, 0, 0, 0.5);
132
+ --ring: rgba(255, 255, 255, 0.08);
133
+ --danger: #e58089;
134
+ --warn: #e0b563;
135
+ --good: #6bd9a6;
136
+
137
+ --wa-header: #202c33;
138
+ --wa-header-ink: #e9edef;
139
+ --wa-list-bg: #111b21;
140
+ --wa-list-search-bg: #202c33;
141
+ --wa-list-ink: #e9edef;
142
+ --wa-list-subtitle: #8696a0;
143
+ --wa-divider: #222e35;
144
+ --wa-chat-bg: #0b141a;
145
+ --wa-bubble-in: #202c33;
146
+ --wa-bubble-in-ink: #e9edef;
147
+ --wa-bubble-out: #005c4b;
148
+ --wa-bubble-out-ink: #e9edef;
149
+ --wa-bubble-shadow: rgba(0, 0, 0, 0.3);
150
+ --wa-timestamp: #8696a0;
151
+ --wa-tick-grey: #8696a0;
152
+ --wa-tick-blue: #53bdee;
153
+ --wa-fab: #00a884;
154
+ --wa-fab-ink: #062f26;
155
+ --wa-online: #00d9a3;
156
+ --wa-panel-ink: #e9edef;
157
+ --wa-icon: #aebac1;
158
+ --wa-badge: #00a884;
159
+ --wa-badge-ink: #04241c;
160
+ }
161
+
162
+ /* ------------------------------------------------------------- reset ---- */
163
+ *, *::before, *::after { box-sizing: border-box; }
164
+ html { color-scheme: light; scroll-behavior: smooth; }
165
+ html[data-theme="dark"] { color-scheme: dark; }
166
+ body {
167
+ margin: 0;
168
+ background: var(--page-bg);
169
+ color: var(--ink);
170
+ font-family: var(--font-ui);
171
+ -webkit-font-smoothing: antialiased;
172
+ overflow-x: hidden;
173
+ }
174
+ img { max-width: 100%; display: block; }
175
+ button { font: inherit; }
176
+ ::selection { background: var(--accent-mint); color: #0b1f1a; }
177
+
178
+ @media (prefers-reduced-motion: reduce) {
179
+ *, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
180
+ }
181
+
182
+ /* ------------------------------------------------------------ blobs ----- */
183
+ .blobs { position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; }
184
+ .blob {
185
+ position: absolute; border-radius: 50%; filter: blur(60px); opacity: 0.55;
186
+ animation: drift 26s ease-in-out infinite alternate;
187
+ }
188
+ .blob.b1 { width: 46vw; height: 46vw; top: -14vw; left: -12vw; background: var(--accent-blush); animation-delay: 0s; }
189
+ .blob.b2 { width: 38vw; height: 38vw; top: 8vw; right: -14vw; background: var(--accent-lavender); animation-delay: -8s; }
190
+ .blob.b3 { width: 34vw; height: 34vw; bottom: -12vw; left: 18vw; background: var(--accent-mint); animation-delay: -16s; }
191
+ .blob.b4 { width: 24vw; height: 24vw; bottom: 6vw; right: 10vw; background: var(--accent-sun); animation-delay: -4s; }
192
+ @keyframes drift {
193
+ from { transform: translate(0, 0) scale(1); }
194
+ to { transform: translate(3vw, -2vw) scale(1.08); }
195
+ }
196
+
197
+ /* ------------------------------------------------------------ layout --- */
198
+ .wrap { position: relative; z-index: 1; max-width: 1180px; margin: 0 auto; padding: 0 28px; }
199
+
200
+ .topbar {
201
+ position: sticky; top: 0; z-index: 40;
202
+ display: flex; align-items: center; justify-content: space-between;
203
+ padding: 16px 28px;
204
+ backdrop-filter: blur(14px) saturate(1.3);
205
+ background: linear-gradient(to bottom, var(--page-bg) 55%, transparent);
206
+ }
207
+ .brand { display: flex; align-items: center; gap: 10px; font-family: var(--font-display); font-size: 1.15rem; letter-spacing: 0.01em; }
208
+ .brand .mark {
209
+ width: 32px; height: 32px; border-radius: 10px; display: grid; place-items: center;
210
+ background: linear-gradient(135deg, var(--brand-teal), var(--accent-mint)); color: #06231c; font-size: 1rem;
211
+ box-shadow: 0 4px 14px var(--glass-shadow);
212
+ }
213
+ .theme-toggle {
214
+ border: 1px solid var(--ring); background: var(--glass-bg); backdrop-filter: blur(10px);
215
+ width: 40px; height: 40px; border-radius: 50%; cursor: pointer; display: grid; place-items: center;
216
+ color: var(--ink); transition: transform 0.25s ease, box-shadow 0.25s ease;
217
+ box-shadow: 0 2px 10px var(--glass-shadow);
218
+ }
219
+ .theme-toggle:hover { transform: translateY(-2px); }
220
+ .theme-toggle svg { width: 18px; height: 18px; }
221
+
222
+ /* ------------------------------------------------------------- hero ---- */
223
+ .hero {
224
+ position: relative; z-index: 1;
225
+ display: grid; grid-template-columns: 1.05fr 0.95fr; gap: 40px; align-items: center;
226
+ max-width: 1180px; margin: 0 auto; padding: 28px 28px 60px;
227
+ }
228
+ .eyebrow {
229
+ display: inline-flex; align-items: center; gap: 8px;
230
+ font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase;
231
+ color: var(--brand-teal); background: color-mix(in srgb, var(--brand-teal) 12%, transparent);
232
+ border: 1px solid color-mix(in srgb, var(--brand-teal) 30%, transparent);
233
+ padding: 6px 12px; border-radius: 100px; margin-bottom: 18px;
234
+ }
235
+ .hero h1 {
236
+ font-family: var(--font-display); font-weight: 600; text-wrap: balance;
237
+ font-size: clamp(2.1rem, 3.6vw, 3.1rem); line-height: 1.08; margin: 0 0 20px; letter-spacing: -0.01em;
238
+ }
239
+ .hero h1 em { font-style: italic; color: var(--brand-teal); }
240
+ .lede { font-size: 1.04rem; line-height: 1.65; color: var(--ink-soft); max-width: 46ch; margin: 0 0 30px; }
241
+
242
+ .stat-row { display: flex; flex-wrap: wrap; gap: 14px; }
243
+ .stat-chip {
244
+ --rot: 0deg;
245
+ background: var(--glass-bg); border: 1px solid var(--glass-border);
246
+ backdrop-filter: blur(10px); border-radius: 18px; padding: 12px 16px;
247
+ box-shadow: 0 8px 20px var(--glass-shadow);
248
+ transform: rotate(var(--rot));
249
+ transition: transform 0.25s ease, box-shadow 0.25s ease;
250
+ min-width: 108px;
251
+ }
252
+ .stat-chip:hover { transform: rotate(0deg) translateY(-3px); box-shadow: 0 14px 28px var(--glass-shadow); }
253
+ .stat-chip b { display: block; font-family: var(--font-display); font-size: 1.5rem; line-height: 1.1; }
254
+ .stat-chip span { display: block; font-size: 0.72rem; color: var(--ink-soft); margin-top: 2px; }
255
+
256
+ .hero-phone-wrap { position: relative; display: flex; justify-content: center; }
257
+ .callout {
258
+ position: absolute; z-index: 5; max-width: 168px; padding: 10px 13px; border-radius: 14px;
259
+ background: var(--glass-bg); border: 1px solid var(--glass-border); backdrop-filter: blur(10px);
260
+ font-family: var(--font-display); font-style: italic; font-size: 0.86rem; line-height: 1.3;
261
+ box-shadow: 0 10px 24px var(--glass-shadow); color: var(--ink);
262
+ animation: floaty 6s ease-in-out infinite;
263
+ }
264
+ .callout.c1 { top: 4%; left: -8%; animation-delay: -1s; }
265
+ .callout.c2 { bottom: 10%; right: -12%; animation-delay: -3s; }
266
+ .callout .arrow { display: block; font-family: var(--font-mono); font-size: 0.7rem; color: var(--brand-teal); margin-top: 4px; font-style: normal; }
267
+ @keyframes floaty { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
268
+
269
+ /* --------------------------------------------------------- phone stage - */
270
+ .phone-stage { position: relative; z-index: 1; max-width: 1180px; margin: 0 auto; padding: 10px 28px 90px; display: flex; flex-direction: column; align-items: center; }
271
+ .stage-controls {
272
+ display: flex; align-items: center; gap: 12px; margin-bottom: 26px; flex-wrap: wrap; justify-content: center;
273
+ }
274
+ .stage-controls label { font-size: 0.78rem; color: var(--ink-soft); font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; }
275
+ #userSwitch {
276
+ border: 1px solid var(--ring); background: var(--glass-bg); backdrop-filter: blur(8px);
277
+ color: var(--ink); padding: 9px 14px; border-radius: 100px; font-family: var(--font-ui); font-size: 0.92rem;
278
+ cursor: pointer; box-shadow: 0 4px 14px var(--glass-shadow);
279
+ }
280
+ .hint-pill {
281
+ font-size: 0.76rem; color: var(--ink-soft); background: var(--glass-bg); border: 1px solid var(--glass-border);
282
+ padding: 7px 13px; border-radius: 100px; display: inline-flex; align-items: center; gap: 6px;
283
+ }
284
+
285
+ .phone-frame-outer { position: relative; }
286
+ .phone {
287
+ width: 380px; max-width: 92vw; height: 780px; max-height: 82vh;
288
+ border-radius: 46px; padding: 12px; background: linear-gradient(160deg, #2c2c34, #131316);
289
+ box-shadow: 0 30px 60px -20px var(--glass-shadow), 0 0 0 1px rgba(0,0,0,0.4), inset 0 0 0 2px rgba(255,255,255,0.06);
290
+ transform: perspective(1400px) rotateY(-4deg) rotateX(1.5deg);
291
+ transition: transform 0.6s cubic-bezier(.2,.8,.2,1);
292
+ }
293
+ .phone:hover, .phone.settled { transform: perspective(1400px) rotateY(0deg) rotateX(0deg); }
294
+ .screen {
295
+ position: relative; width: 100%; height: 100%; border-radius: 34px; overflow: hidden;
296
+ background: var(--wa-chat-bg); display: flex; flex-direction: column;
297
+ box-shadow: inset 0 0 0 1px rgba(0,0,0,0.35);
298
+ }
299
+ .notch {
300
+ position: absolute; top: 10px; left: 50%; transform: translateX(-50%); width: 110px; height: 22px;
301
+ background: #131316; border-radius: 14px; z-index: 50;
302
+ }
303
+ .statusbar {
304
+ height: 30px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px;
305
+ background: var(--wa-header); color: var(--wa-header-ink); font-size: 0.72rem; font-family: var(--font-wa);
306
+ font-variant-numeric: tabular-nums; flex-shrink: 0; position: relative; z-index: 2;
307
+ }
308
+ .statusbar .icons { display: flex; gap: 4px; align-items: center; }
309
+
310
+ /* --------------------------------------------------------- WA screens -- */
311
+ .view { position: absolute; inset: 0; top: 30px; display: flex; flex-direction: column; background: var(--wa-list-bg); transition: transform 0.32s cubic-bezier(.2,.8,.2,1), opacity 0.32s ease; }
312
+ .view.hidden { transform: translateX(100%); opacity: 0; pointer-events: none; }
313
+ .view.chat-list-view.hidden { transform: translateX(-30%); }
314
+
315
+ .wa-header { background: var(--wa-header); color: var(--wa-header-ink); flex-shrink: 0; }
316
+ .list-header { padding: 14px 16px 8px; display: flex; align-items: center; justify-content: space-between; }
317
+ .list-header .wa-title { font-size: 1.3rem; font-weight: 700; font-family: var(--font-wa); }
318
+ .header-icons { display: flex; gap: 20px; align-items: center; }
319
+ .header-icons svg { width: 20px; height: 20px; fill: currentColor; }
320
+ .filter-row { display: flex; gap: 8px; padding: 10px 16px 12px; }
321
+ .filter-pill { padding: 6px 14px; border-radius: 100px; font-size: 0.82rem; background: rgba(255,255,255,0.16); }
322
+ .filter-pill.active { background: #ffffff; color: var(--wa-header); font-weight: 600; }
323
+
324
+ .search-bar { margin: 10px 12px; background: var(--wa-list-search-bg); border-radius: 10px; padding: 9px 12px; display: flex; align-items: center; gap: 10px; color: var(--wa-list-subtitle); font-size: 0.9rem; flex-shrink: 0; }
325
+ .search-bar svg { width: 16px; height: 16px; }
326
+
327
+ .chat-list { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; }
328
+ .chat-list.hidden { display: none; }
329
+ .nav-stub.hidden { display: none; }
330
+ .chat-row { position: relative; display: flex; gap: 12px; padding: 10px 16px; align-items: center; cursor: pointer; border-bottom: 1px solid transparent; }
331
+ .chat-row:active { background: var(--wa-list-search-bg); }
332
+ .avatar {
333
+ width: 49px; height: 49px; border-radius: 50%; flex-shrink: 0; display: grid; place-items: center;
334
+ font-family: var(--font-wa); font-weight: 600; color: #ffffff; font-size: 1rem; position: relative;
335
+ }
336
+ .avatar.group::after { content: "👥"; position: absolute; bottom: -2px; right: -2px; font-size: 0.7rem; background: var(--wa-list-bg); border-radius: 50%; padding: 1px; }
337
+ .avatar.bot { background: linear-gradient(135deg, var(--brand-teal), var(--accent-lavender)) !important; }
338
+ .chat-row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
339
+ .chat-row-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
340
+ .chat-row-name { font-size: 0.98rem; color: var(--wa-list-ink); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 4px; }
341
+ .verified-badge { color: var(--brand-teal); flex-shrink: 0; }
342
+ .chat-row-time { font-size: 0.74rem; color: var(--wa-list-subtitle); flex-shrink: 0; }
343
+ .chat-row-time.unread { color: var(--wa-badge); font-weight: 600; }
344
+ .chat-row-bottom { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
345
+ .chat-row-preview { font-size: 0.86rem; color: var(--wa-list-subtitle); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 4px; }
346
+ .action-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
347
+ .chat-row-badge { min-width: 19px; height: 19px; border-radius: 10px; background: var(--wa-badge); color: var(--wa-badge-ink); font-size: 0.7rem; font-weight: 700; display: grid; place-items: center; padding: 0 5px; flex-shrink: 0; }
348
+
349
+ .bottom-nav { display: flex; border-top: 1px solid var(--wa-divider); background: var(--wa-list-bg); flex-shrink: 0; }
350
+ .bottom-nav .nav-item { flex: 1; text-align: center; padding: 8px 0 10px; font-size: 0.68rem; color: var(--wa-list-subtitle); }
351
+ .bottom-nav .nav-item .nav-ico { font-size: 1.05rem; display: block; margin-bottom: 2px; }
352
+ .bottom-nav .nav-item.active { color: var(--brand-teal); }
353
+
354
+ /* --------------------------------------------------------- thread ------ */
355
+ .thread-header { position: relative; display: flex; align-items: center; gap: 4px; padding: 8px 10px; }
356
+ .back-btn { background: none; border: none; color: var(--wa-header-ink); font-size: 1.5rem; cursor: pointer; padding: 4px 6px; line-height: 1; }
357
+ .thread-title { flex: 1; min-width: 0; display: flex; flex-direction: column; margin-left: 4px; }
358
+ .thread-title .t-name { font-size: 1rem; font-weight: 600; font-family: var(--font-wa); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 5px; }
359
+ .thread-title .t-sub { font-size: 0.72rem; opacity: 0.85; }
360
+
361
+ .thread-messages { flex: 1; overflow-y: auto; padding: 14px 10px; background-color: var(--wa-chat-bg); background-image: var(--wa-doodle); background-size: 16px 16px; display: flex; flex-direction: column; gap: 2px; }
362
+ .date-chip { align-self: center; background: rgba(255,255,255,0.75); color: #54656f; font-size: 0.72rem; padding: 5px 11px; border-radius: 8px; margin: 10px 0; box-shadow: 0 1px 1px rgba(0,0,0,0.08); }
363
+ :root[data-theme="dark"] .date-chip { background: #182229; color: #8696a0; }
364
+
365
+ .bubble-row { display: flex; margin: 2px 0; max-width: 100%; }
366
+ .bubble-row.in { justify-content: flex-start; }
367
+ .bubble-row.out { justify-content: flex-end; }
368
+ .bubble {
369
+ position: relative; max-width: 78%; padding: 6px 8px 7px 9px; border-radius: 8px;
370
+ box-shadow: 0 1px 1px var(--wa-bubble-shadow); font-size: 0.89rem; line-height: 1.35; cursor: default;
371
+ animation: pop-in 0.28s cubic-bezier(.2,.8,.2,1) both;
372
+ }
373
+ @keyframes pop-in { from { opacity: 0; transform: translateY(6px) scale(0.98); } to { opacity: 1; transform: none; } }
374
+ .bubble.in { background: var(--wa-bubble-in); color: var(--wa-bubble-in-ink); border-top-left-radius: 2px; }
375
+ .bubble.out { background: var(--wa-bubble-out); color: var(--wa-bubble-out-ink); border-top-right-radius: 2px; }
376
+ .bubble .sender-name { display: block; font-size: 0.8rem; font-weight: 700; margin-bottom: 2px; }
377
+ .bubble .btext { white-space: pre-wrap; word-wrap: break-word; }
378
+ .bubble .btext:empty { display: none; }
379
+ .bubble .bmeta { display: flex; justify-content: flex-end; align-items: center; gap: 3px; float: right; margin: 3px -2px -2px 8px; }
380
+ .bubble .btime { font-size: 0.68rem; color: var(--wa-timestamp); }
381
+ .bubble.out .btime { color: rgba(17,27,33,0.5); }
382
+ .tick { width: 15px; height: 10px; }
383
+ .tick .stroke { fill: none; stroke: var(--wa-tick-grey); stroke-width: 1.6; }
384
+ .tick.read .stroke { stroke: var(--wa-tick-blue); }
385
+
386
+ .bubble.decisioned { cursor: pointer; }
387
+ .bubble.decisioned::after { content: ""; position: absolute; inset: -3px; border-radius: 11px; border: 1.5px dashed color-mix(in srgb, var(--action-color, var(--brand-teal)) 55%, transparent); transition: border-color 0.2s, transform 0.15s; pointer-events: none; }
388
+ .bubble.decisioned:hover::after, .bubble.decisioned:active::after { border-color: var(--action-color, var(--brand-teal)); }
389
+ .bubble.decisioned:active { transform: scale(0.985); }
390
+ .decision-tag {
391
+ clear: both; display: inline-flex; align-items: center; gap: 4px; margin-top: 6px; padding: 3px 8px 3px 6px; border-radius: 100px;
392
+ font-size: 0.68rem; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase;
393
+ background: color-mix(in srgb, var(--action-color) 16%, transparent); color: var(--action-color);
394
+ border: 1px solid color-mix(in srgb, var(--action-color) 40%, transparent);
395
+ }
396
+ .decision-tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--action-color); }
397
+
398
+ .media-card { border-radius: 6px; overflow: hidden; margin-bottom: 5px; background: rgba(0,0,0,0.04); }
399
+ .media-card .media-visual { display: flex; align-items: center; gap: 10px; padding: 22px 12px; background: linear-gradient(135deg, var(--accent-lavender), var(--accent-blush)); color: #2b2233; }
400
+ .media-card.voice .media-visual { background: linear-gradient(135deg, var(--accent-mint), var(--accent-sun)); }
401
+ .media-card .media-icon { font-size: 1.5rem; }
402
+ .media-card .media-cap { font-size: 0.78rem; padding: 6px 9px 2px; color: var(--wa-list-subtitle); font-style: italic; }
403
+ .waveform { flex: 1; display: flex; align-items: center; gap: 2px; height: 22px; }
404
+ .waveform span { width: 3px; border-radius: 2px; background: rgba(43,34,51,0.55); }
405
+
406
+ .typing-row { display: flex; margin: 4px 0; }
407
+ .typing-bubble { background: var(--wa-bubble-in); border-radius: 8px; border-top-left-radius: 2px; padding: 10px 14px; display: flex; gap: 4px; box-shadow: 0 1px 1px var(--wa-bubble-shadow); }
408
+ .typing-bubble span { width: 6px; height: 6px; border-radius: 50%; background: var(--wa-tick-grey); animation: bounce 1.1s infinite ease-in-out; }
409
+ .typing-bubble span:nth-child(2) { animation-delay: 0.15s; }
410
+ .typing-bubble span:nth-child(3) { animation-delay: 0.3s; }
411
+ @keyframes bounce { 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; } 30% { transform: translateY(-4px); opacity: 1; } }
412
+
413
+ .quick-replies { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 6px 4px; }
414
+ .qr-chip {
415
+ border: 1px solid var(--brand-teal); color: var(--brand-teal); background: color-mix(in srgb, var(--brand-teal) 8%, var(--wa-chat-bg));
416
+ padding: 8px 13px; border-radius: 100px; font-size: 0.82rem; cursor: pointer; font-family: var(--font-wa);
417
+ transition: transform 0.15s ease, background 0.15s ease;
418
+ }
419
+ .qr-chip:hover { background: color-mix(in srgb, var(--brand-teal) 16%, var(--wa-chat-bg)); transform: translateY(-1px); }
420
+ .qr-chip:disabled { opacity: 0.4; cursor: default; transform: none; }
421
+
422
+ .deep-link-btn {
423
+ display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; background: var(--brand-teal); color: var(--brand-teal-ink);
424
+ border: none; padding: 8px 12px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; cursor: pointer; font-family: var(--font-wa);
425
+ }
426
+
427
+ .composer { display: flex; align-items: flex-end; gap: 8px; padding: 8px 8px; background: transparent; flex-shrink: 0; }
428
+ .composer .cbox { flex: 1; background: var(--wa-bubble-in); border-radius: 24px; padding: 9px 14px; display: flex; align-items: center; gap: 8px; box-shadow: 0 1px 2px var(--wa-bubble-shadow); }
429
+ .composer input { flex: 1; border: none; outline: none; background: none; color: var(--wa-list-ink); font-size: 0.92rem; font-family: var(--font-wa); }
430
+ .composer input::placeholder { color: var(--wa-list-subtitle); }
431
+ .send-btn { width: 42px; height: 42px; border-radius: 50%; background: var(--wa-fab); color: var(--wa-fab-ink); border: none; display: grid; place-items: center; cursor: pointer; flex-shrink: 0; box-shadow: 0 3px 8px var(--wa-bubble-shadow); }
432
+ .send-btn svg { width: 19px; height: 19px; margin-left: 2px; }
433
+ .send-btn:disabled { opacity: 0.5; cursor: default; }
434
+
435
+ .locked-note { text-align: center; font-size: 0.72rem; color: var(--wa-list-subtitle); padding: 6px 20px 10px; }
436
+
437
+ /* --------------------------------------------------------- sheet ------- */
438
+ .sheet-overlay { position: fixed; inset: 0; background: rgba(10, 8, 14, 0.55); backdrop-filter: blur(3px); z-index: 90; opacity: 0; transition: opacity 0.25s ease; }
439
+ .sheet-overlay.hidden { opacity: 0; pointer-events: none; }
440
+ .sheet-overlay.show { opacity: 1; }
441
+
442
+ .pipeline-sheet {
443
+ position: fixed; left: 50%; bottom: 0; transform: translate(-50%, 100%); width: min(420px, 94vw); max-height: 86vh;
444
+ background: var(--paper); border-radius: 24px 24px 0 0; z-index: 91; box-shadow: 0 -20px 60px rgba(0,0,0,0.35);
445
+ transition: transform 0.38s cubic-bezier(.2,.85,.25,1); display: flex; flex-direction: column; overflow: hidden;
446
+ }
447
+ .pipeline-sheet.show { transform: translate(-50%, 0); }
448
+ .sheet-grab { width: 40px; height: 4px; background: var(--ring); border-radius: 4px; margin: 10px auto 4px; flex-shrink: 0; }
449
+ .sheet-head { padding: 6px 22px 14px; flex-shrink: 0; border-bottom: 1px solid var(--ring); }
450
+ .sheet-head .sh-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; }
451
+ .sheet-head h3 { font-family: var(--font-display); font-size: 1.2rem; margin: 0 0 4px; }
452
+ .sheet-head .sh-msg { font-size: 0.86rem; color: var(--ink-soft); font-style: italic; max-width: 30ch; }
453
+ .sheet-close { border: none; background: var(--ring); width: 30px; height: 30px; border-radius: 50%; cursor: pointer; color: var(--ink); flex-shrink: 0; font-size: 1rem; }
454
+ .action-badge { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 100px; font-weight: 700; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.03em; background: color-mix(in srgb, var(--action-color) 18%, transparent); color: var(--action-color); border: 1px solid color-mix(in srgb, var(--action-color) 45%, transparent); margin-top: 8px; }
455
+
456
+ .conf-gauge { margin-top: 12px; }
457
+ .conf-gauge .cg-label { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 0.68rem; color: var(--ink-faint); margin-bottom: 4px; }
458
+ .conf-gauge .cg-track { position: relative; height: 6px; border-radius: 4px; background: var(--ring); overflow: visible; }
459
+ .conf-gauge .cg-band { position: absolute; top: 0; bottom: 0; border-radius: 4px; background: color-mix(in srgb, var(--action-color) 30%, transparent); }
460
+ .conf-gauge .cg-dot { position: absolute; top: 50%; width: 12px; height: 12px; border-radius: 50%; background: var(--action-color); border: 2px solid var(--paper); transform: translate(-50%, -50%); box-shadow: 0 0 0 3px color-mix(in srgb, var(--action-color) 30%, transparent); }
461
+
462
+ .sheet-body { flex: 1; overflow-y: auto; padding: 18px 22px 28px; }
463
+
464
+ .flow { display: flex; gap: 14px; }
465
+ .flow-rail { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; width: 26px; }
466
+ .flow-node { width: 26px; height: 26px; border-radius: 50%; background: var(--ring); border: 2px solid var(--ring); display: grid; place-items: center; font-size: 0.82rem; flex-shrink: 0; opacity: 0.35; transform: scale(0.8); transition: all 0.4s cubic-bezier(.3,1.4,.4,1); }
467
+ .flow-node.active { opacity: 1; transform: scale(1); border-color: var(--brand-teal); background: color-mix(in srgb, var(--brand-teal) 16%, var(--paper)); }
468
+ .flow-node.override { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 16%, var(--paper)); }
469
+ .flow-node.final.active { border-color: var(--action-color); background: color-mix(in srgb, var(--action-color) 20%, var(--paper)); animation: node-pop 0.5s cubic-bezier(.3,1.6,.4,1); }
470
+ @keyframes node-pop { 0% { transform: scale(0.6); } 60% { transform: scale(1.18); } 100% { transform: scale(1); } }
471
+ .flow-line { width: 2px; flex: 1; min-height: 22px; background: var(--ring); position: relative; overflow: hidden; }
472
+ .flow-line .fill { position: absolute; inset: auto 0 0 0; height: 0%; background: var(--brand-teal); transition: height 0.5s ease; }
473
+ .flow-line.dashed .fill { background: repeating-linear-gradient(to bottom, var(--danger) 0 4px, transparent 4px 8px); }
474
+ .flow-steps { flex: 1; display: flex; flex-direction: column; gap: 22px; min-width: 0; }
475
+ .flow-step { opacity: 0.25; transform: translateY(6px); transition: all 0.4s ease; min-height: 26px; }
476
+ .flow-step.active { opacity: 1; transform: none; }
477
+ .flow-step h4 { margin: 0 0 4px; font-size: 0.86rem; font-family: var(--font-ui); font-weight: 700; }
478
+ .flow-step .fs-body { font-size: 0.83rem; color: var(--ink-soft); line-height: 1.5; }
479
+ .flow-step .fs-body strong { color: var(--ink); }
480
+
481
+ .chip-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
482
+ .sig-chip { font-family: var(--font-mono); font-size: 0.68rem; background: var(--ring); color: var(--ink-soft); padding: 3px 8px; border-radius: 6px; }
483
+ .sig-chip.hot { background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--danger); }
484
+ .evid-chip { font-family: var(--font-ui); font-size: 0.76rem; background: color-mix(in srgb, var(--brand-teal) 10%, transparent); color: var(--brand-teal); padding: 5px 10px; border-radius: 8px; cursor: pointer; border: 1px solid color-mix(in srgb, var(--brand-teal) 30%, transparent); margin-top: 6px; display: inline-block; }
485
+ .evid-preview { margin-top: 8px; background: var(--ring); border-radius: 10px; padding: 10px 12px; font-size: 0.8rem; display: none; }
486
+ .evid-preview.show { display: block; }
487
+ .evid-preview .ep-meta { font-family: var(--font-mono); font-size: 0.68rem; color: var(--ink-faint); margin-top: 4px; }
488
+
489
+ .override-banner { margin-top: 10px; background: color-mix(in srgb, var(--danger) 12%, transparent); border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent); color: var(--danger); border-radius: 10px; padding: 9px 11px; font-size: 0.8rem; font-weight: 600; }
490
+ .reason-final { margin-top: 10px; padding: 12px 14px; background: var(--ring); border-radius: 12px; font-size: 0.87rem; line-height: 1.5; }
491
+ .reason-final .rf-check { color: var(--good); font-size: 0.76rem; font-weight: 700; margin-top: 6px; display: block; }
492
+
493
+ /* --------------------------------------------------------- pipeline ---- */
494
+ .pipeline-overview { position: relative; z-index: 1; max-width: 900px; margin: 40px auto 0; padding: 0 28px 90px; }
495
+ .pipeline-overview h2 { font-family: var(--font-display); font-size: 1.9rem; text-align: center; margin-bottom: 8px; text-wrap: balance; }
496
+ .pipeline-overview .sub { text-align: center; color: var(--ink-soft); margin-bottom: 34px; }
497
+ .diagram-card { background: var(--glass-bg); border: 1px solid var(--glass-border); backdrop-filter: blur(10px); border-radius: 24px; padding: 26px 20px; box-shadow: 0 14px 40px var(--glass-shadow); overflow-x: auto; }
498
+ .diagram-card svg { width: 100%; height: auto; display: block; min-width: 640px; }
499
+ .diagram-card figcaption { text-align: center; font-size: 0.84rem; color: var(--ink-soft); margin-top: 14px; }
500
+
501
+ /* ------------------------------------------------------------ footer --- */
502
+ .site-footer { position: relative; z-index: 1; text-align: center; padding: 30px 28px 60px; color: var(--ink-faint); font-size: 0.84rem; }
503
+ .site-footer a { color: var(--brand-teal); text-decoration: none; border-bottom: 1px dotted; }
504
+
505
+ /* ------------------------------------------------------------ misc ----- */
506
+ .reveal { animation: rise 0.7s cubic-bezier(.2,.8,.2,1) both; }
507
+ @keyframes rise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: none; } }
508
+
509
+ /* --------------------------------------------------- operational chrome */
510
+ .search-bar { position: relative; }
511
+ .search-bar input {
512
+ flex: 1; border: none; outline: none; background: none; color: var(--wa-list-ink);
513
+ font-family: var(--font-wa); font-size: 0.9rem;
514
+ }
515
+ .search-bar input::placeholder { color: var(--wa-list-subtitle); }
516
+ .search-clear { border: none; background: none; color: var(--wa-list-subtitle); cursor: pointer; font-size: 0.9rem; padding: 2px; display: none; }
517
+ .search-bar.has-value .search-clear { display: block; }
518
+
519
+ .filter-pill { cursor: pointer; user-select: none; transition: background 0.15s, color 0.15s, transform 0.1s; border: none; font: inherit; }
520
+ .filter-pill:active { transform: scale(0.96); }
521
+
522
+ .empty-row { padding: 46px 24px; text-align: center; color: var(--wa-list-subtitle); font-size: 0.86rem; line-height: 1.5; }
523
+ .empty-row .ee { font-size: 1.8rem; display: block; margin-bottom: 10px; }
524
+
525
+ .header-icons button { background: none; border: none; color: currentColor; padding: 4px; cursor: pointer; display: flex; min-width: 32px; min-height: 32px; align-items: center; justify-content: center; border-radius: 50%; transition: background 0.15s; }
526
+ .header-icons button:active { background: rgba(255, 255, 255, 0.18); }
527
+ .header-icons { position: relative; }
528
+
529
+ .dropdown-menu {
530
+ position: absolute; top: 44px; right: 0; z-index: 30; min-width: 208px;
531
+ background: var(--paper); color: var(--ink); border-radius: 14px; overflow: hidden;
532
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); border: 1px solid var(--ring);
533
+ opacity: 0; transform: translateY(-6px) scale(0.97); pointer-events: none; transition: all 0.16s ease;
534
+ }
535
+ .dropdown-menu.show { opacity: 1; transform: none; pointer-events: auto; }
536
+ .dropdown-menu button {
537
+ display: flex; align-items: center; gap: 10px; width: 100%; border: none; background: none; color: var(--ink);
538
+ font: inherit; font-size: 0.86rem; text-align: left; padding: 12px 15px; cursor: pointer; transition: background 0.12s;
539
+ }
540
+ .dropdown-menu button:hover, .dropdown-menu button:active { background: var(--ring); }
541
+ .dropdown-menu .dm-divider { height: 1px; background: var(--ring); margin: 2px 0; }
542
+
543
+ /* pinned Router HQ — impossible to miss on first look */
544
+ .chat-row.pinned-hq { position: relative; background: color-mix(in srgb, var(--brand-teal) 7%, transparent); }
545
+ .chat-row.pinned-hq .avatar { box-shadow: 0 0 0 0 color-mix(in srgb, var(--brand-teal) 55%, transparent); animation: hq-pulse 2.1s ease-out 3; }
546
+ @keyframes hq-pulse {
547
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--brand-teal) 55%, transparent); }
548
+ 70% { box-shadow: 0 0 0 12px color-mix(in srgb, var(--brand-teal) 0%, transparent); }
549
+ 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--brand-teal) 0%, transparent); }
550
+ }
551
+ .start-badge {
552
+ font-size: 0.62rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
553
+ background: var(--brand-teal); color: var(--brand-teal-ink); padding: 2px 7px; border-radius: 100px; margin-left: 6px;
554
+ }
555
+ .spotlight-arrow {
556
+ position: absolute; left: 66px; top: -6px; z-index: 25; display: flex; align-items: center; gap: 6px;
557
+ background: var(--ink); color: var(--page-bg); font-family: var(--font-ui); font-size: 0.76rem; font-weight: 600;
558
+ padding: 7px 12px; border-radius: 100px; box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
559
+ animation: spotlight-in 0.4s cubic-bezier(.2, .8, .2, 1) both, spotlight-bob 1.6s ease-in-out 0.4s infinite;
560
+ pointer-events: none; white-space: nowrap;
561
+ }
562
+ @keyframes spotlight-in { from { opacity: 0; transform: translateY(6px) scale(0.9); } to { opacity: 1; transform: none; } }
563
+ @keyframes spotlight-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
564
+ .spotlight-arrow.out { animation: spotlight-out 0.3s ease forwards; }
565
+ @keyframes spotlight-out { to { opacity: 0; transform: translateY(-6px) scale(0.92); } }
566
+
567
+ /* nav stub screen */
568
+ .nav-item { cursor: pointer; background: none; border: none; font: inherit; color: var(--wa-list-subtitle); transition: color 0.15s; }
569
+ .nav-stub { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 30px; text-align: center; background: var(--wa-list-bg); }
570
+ .nav-stub .ns-icon { font-size: 2.4rem; }
571
+ .nav-stub p { color: var(--wa-list-subtitle); font-size: 0.86rem; line-height: 1.5; max-width: 26ch; margin: 0; }
572
+ .nav-stub button { background: var(--brand-teal); color: var(--brand-teal-ink); border: none; padding: 10px 18px; border-radius: 100px; font-size: 0.84rem; font-weight: 600; cursor: pointer; }
573
+
574
+ /* fake-but-tappable composer row for read-only threads */
575
+ .composer-fake { display: flex; align-items: center; gap: 6px; padding: 8px 8px; flex-shrink: 0; }
576
+ .composer-fake .cbox { flex: 1; background: var(--wa-bubble-in); border-radius: 24px; padding: 9px 8px 9px 14px; display: flex; align-items: center; gap: 8px; box-shadow: 0 1px 2px var(--wa-bubble-shadow); opacity: 0.85; }
577
+ .composer-fake .cbox span.ph { flex: 1; color: var(--wa-list-subtitle); font-size: 0.88rem; }
578
+ .icon-btn { background: none; border: none; font-size: 1.05rem; color: var(--wa-icon); cursor: pointer; padding: 4px; line-height: 1; border-radius: 50%; min-width: 30px; min-height: 30px; }
579
+ .icon-btn:active { background: rgba(127, 127, 127, 0.18); }
580
+ .send-btn.fake { opacity: 0.9; }
581
+
582
+ /* toast */
583
+ .toast-stack { position: absolute; left: 50%; bottom: 78px; transform: translateX(-50%); z-index: 60; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; width: 92%; }
584
+ .toast {
585
+ background: var(--ink); color: var(--page-bg); font-family: var(--font-ui); font-size: 0.8rem; line-height: 1.4;
586
+ padding: 10px 15px; border-radius: 12px; box-shadow: 0 10px 26px rgba(0, 0, 0, 0.3); max-width: 100%;
587
+ animation: toast-in 0.28s cubic-bezier(.2, .8, .2, 1) both;
588
+ }
589
+ .toast.out { animation: toast-out 0.22s ease forwards; }
590
+ @keyframes toast-in { from { opacity: 0; transform: translateY(10px) scale(0.96); } to { opacity: 1; transform: none; } }
591
+ @keyframes toast-out { to { opacity: 0; transform: translateY(6px) scale(0.97); } }
592
+
593
+ /* contact-info popover */
594
+ .info-pop {
595
+ position: absolute; top: 46px; left: 8px; right: 8px; z-index: 30; background: var(--paper); color: var(--ink);
596
+ border-radius: 16px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.3); border: 1px solid var(--ring);
597
+ padding: 18px 18px 16px; opacity: 0; transform: translateY(-8px) scale(0.97); pointer-events: none; transition: all 0.18s ease;
598
+ }
599
+ .info-pop.show { opacity: 1; transform: none; pointer-events: auto; }
600
+ .info-pop .ip-avatar { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; color: #fff; font-family: var(--font-wa); font-size: 1.2rem; margin-bottom: 10px; }
601
+ .info-pop h4 { margin: 0 0 3px; font-family: var(--font-display); font-size: 1.05rem; }
602
+ .info-pop .ip-sub { font-size: 0.8rem; color: var(--ink-soft); margin-bottom: 10px; }
603
+ .info-pop .ip-row { display: flex; justify-content: space-between; font-size: 0.8rem; padding: 6px 0; border-top: 1px solid var(--ring); }
604
+ .info-pop .ip-row b { font-family: var(--font-mono); font-weight: 600; }
605
+
606
+ /* ---------------------------------------------------- diagram animation */
607
+ .diagram-card .arch-node { opacity: 0.18; transform: translateY(6px); transition: opacity 0.4s ease, transform 0.4s ease; transform-box: fill-box; transform-origin: center; }
608
+ .diagram-card.play .arch-node.on { opacity: 1; transform: translateY(0); }
609
+ .diagram-card .arch-edge { transition: stroke-dashoffset 0.5s ease, opacity 0.3s ease; opacity: 0.3; }
610
+ .diagram-card.play .arch-edge.on { opacity: 0.9; }
611
+ .diagram-card .arch-node rect { transition: stroke 0.3s ease, fill 0.3s ease; }
612
+ .diagram-card.play .arch-node.on.hot rect { stroke: var(--danger); stroke-width: 2.2; }
613
+ .diagram-replay {
614
+ display: flex; align-items: center; gap: 6px; margin: 16px auto 0; background: var(--glass-bg); border: 1px solid var(--glass-border);
615
+ color: var(--ink); font-family: var(--font-ui); font-size: 0.8rem; font-weight: 600; padding: 8px 16px; border-radius: 100px;
616
+ cursor: pointer; backdrop-filter: blur(8px);
617
+ }
618
+
619
+ /* ------------------------------------------------------------ responsive */
620
+ @media (max-width: 880px) {
621
+ .hero { grid-template-columns: 1fr; padding-top: 8px; }
622
+ .hero-phone-wrap { order: -1; margin-bottom: 10px; transform: scale(0.86); }
623
+ .callout { display: none; }
624
+ .lede { max-width: 60ch; }
625
+ }
626
+ @media (max-width: 460px) {
627
+ .wrap, .topbar, .phone-stage, .pipeline-overview { padding-left: 16px; padding-right: 16px; }
628
+ .phone { width: 100%; height: 640px; border-radius: 32px; }
629
+ .screen { border-radius: 22px; }
630
+ }