Upload 5 files
Browse files- README.md +59 -9
- app.js +879 -0
- data.json +0 -0
- index.html +222 -0
- styles.css +630 -0
README.md
CHANGED
|
@@ -1,13 +1,63 @@
|
|
| 1 |
---
|
| 2 |
-
title: Message Router
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
sdk_version: 6.22.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 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 |
+
}
|