yana-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PUBLISH.md ADDED
@@ -0,0 +1,26 @@
1
+ # Publish to npm
2
+
3
+ GitHub repo đã lên: https://github.com/phamlongh230-lgtm/yana-web
4
+
5
+ ## Còn 2 bước npm — anh chạy khi về:
6
+
7
+ ### 1. Publish yamtam-core (dependency)
8
+ ```bash
9
+ cd ~/yamtam-engine/packages/yamtam-core
10
+ npm login
11
+ npm publish --access public
12
+ ```
13
+
14
+ ### 2. Publish yana-web
15
+ ```bash
16
+ cd ~/yana-web
17
+ npm publish --access public
18
+ ```
19
+
20
+ ## Sau khi publish, người dùng chạy:
21
+ ```bash
22
+ npx yana
23
+ ```
24
+
25
+ ## Nếu muốn scoped package (@phamlongh230/yana):
26
+ Sửa name trong package.json thành "@phamlongh230/yana" trước khi publish.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # Yana
2
+
3
+ Self-hosted AI workspace. One command to run.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ npx yana
9
+ ```
10
+
11
+ Opens at `http://localhost:8081`. Add your API key in **System → Providers**, start chatting.
12
+
13
+ ## Features
14
+
15
+ - **Multi-provider** — Claude, OpenAI, Gemini, Groq, DeepSeek, OpenRouter, Ollama
16
+ - **Encrypted keys** — API keys stored with AES-256-GCM, never sent anywhere except the provider you choose
17
+ - **Memory** — Yana remembers context across conversations
18
+ - **Missions** — break goals into tracked tasks
19
+ - **Mobile + Desktop UI** — auto-detects device, separate optimised shells
20
+ - **Self-hosted** — runs on your machine, your data stays local
21
+
22
+ ## Install globally
23
+
24
+ ```bash
25
+ npm install -g yana-web
26
+ yana
27
+ ```
28
+
29
+ ## Environment variables
30
+
31
+ | Variable | Default | Description |
32
+ |---|---|---|
33
+ | `PORT` | `8081` | Server port |
34
+ | `HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for Docker/remote) |
35
+ | `YANA_DATA_DIR` | `~/.yana` | Where auth, memory and missions are stored |
36
+ | `YANA_ROOT` | `process.cwd()` | Optional: path to a YAMTAM engine checkout for skill routing |
37
+
38
+ ## Docker
39
+
40
+ ```bash
41
+ docker run -p 8081:8081 -e HOST=0.0.0.0 -v ~/.yana:/root/.yana ghcr.io/phamlongh230-lgtm/yana-web
42
+ ```
43
+
44
+ ## License
45
+
46
+ MIT
package/auth.js ADDED
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+ // Yana Auth — single-user password gate for the local web UI.
3
+ //
4
+ // Password: scrypt hash (random salt, N=16384) in .yana/auth.json — never
5
+ // plaintext, never in env, never in a URL (rule 66 / api-security-gate API2).
6
+ // Sessions: random 256-bit tokens in an HttpOnly SameSite=Lax cookie,
7
+ // persisted to .yana/sessions.json so a server restart keeps you signed in.
8
+ // Login attempts are rate-limited per IP (5 per 15 min) — OWASP API6.
9
+
10
+ const crypto = require('crypto');
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ // Persistent data dir. Default: dot-dir next to the server (static server never
15
+ // serves it). Override with YANA_DATA_DIR to point at a mounted volume
16
+ // (e.g. /data on Railway) so accounts survive redeploys.
17
+ const DATA_DIR = process.env.YANA_DATA_DIR || path.join(require('os').homedir(), '.yana');
18
+ const AUTH_FILE = path.join(DATA_DIR, 'auth.json');
19
+ const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
20
+ const COOKIE = 'yana_sid';
21
+ const SESSION_TTL = 7 * 24 * 3600 * 1000; // 7 days (default)
22
+ const REMEMBER_TTL = 30 * 24 * 3600 * 1000; // 30 days ("remember me")
23
+ const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 64 };
24
+
25
+ const LOGIN_RATE = { windowMs: 15 * 60_000, max: 5, hits: new Map() };
26
+
27
+ let sessions = loadJson(SESSIONS_FILE) || {};
28
+
29
+ function loadJson(file) {
30
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; }
31
+ }
32
+
33
+ function saveJson(file, data) {
34
+ fs.mkdirSync(DATA_DIR, { recursive: true });
35
+ fs.writeFileSync(file, JSON.stringify(data), { mode: 0o600 });
36
+ }
37
+
38
+ function hashPassword(password) {
39
+ const salt = crypto.randomBytes(16);
40
+ const hash = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
41
+ return { salt: salt.toString('hex'), hash: hash.toString('hex') };
42
+ }
43
+
44
+ function verifyPassword(password, rec) {
45
+ const expected = Buffer.from(rec.hash, 'hex');
46
+ const actual = crypto.scryptSync(password, Buffer.from(rec.salt, 'hex'), expected.length, SCRYPT);
47
+ return crypto.timingSafeEqual(actual, expected);
48
+ }
49
+
50
+ function isSetUp() {
51
+ const rec = loadJson(AUTH_FILE);
52
+ return !!(rec && rec.salt && rec.hash);
53
+ }
54
+
55
+ // ── Sessions ──────────────────────────────────────────────────────────────────
56
+ function createSession(remember) {
57
+ const token = crypto.randomBytes(32).toString('hex');
58
+ sessions[token] = { created: Date.now(), ttl: remember ? REMEMBER_TTL : SESSION_TTL };
59
+ pruneSessions();
60
+ saveJson(SESSIONS_FILE, sessions);
61
+ return token;
62
+ }
63
+
64
+ function pruneSessions() {
65
+ const now = Date.now();
66
+ for (const [t, s] of Object.entries(sessions)) {
67
+ if (now - s.created > (s.ttl || SESSION_TTL)) delete sessions[t];
68
+ }
69
+ }
70
+
71
+ function sessionToken(req) {
72
+ const header = req.headers.cookie || '';
73
+ for (const part of header.split(';')) {
74
+ const [k, v] = part.trim().split('=');
75
+ if (k === COOKIE && v) return v;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ function isAuthed(req) {
81
+ const token = sessionToken(req);
82
+ if (!token || !sessions[token]) return false;
83
+ const s = sessions[token];
84
+ if (Date.now() - s.created > (s.ttl || SESSION_TTL)) {
85
+ delete sessions[token];
86
+ saveJson(SESSIONS_FILE, sessions);
87
+ return false;
88
+ }
89
+ return true;
90
+ }
91
+
92
+ // req.secure is resolved by server.js (X-Forwarded-Proto behind a trusted
93
+ // proxy) — the Secure flag keeps the session cookie off plain-HTTP hops.
94
+ function setCookie(req, res, token) {
95
+ const ttl = (sessions[token] && sessions[token].ttl) || SESSION_TTL;
96
+ const secure = req.secure ? '; Secure' : '';
97
+ res.setHeader('Set-Cookie',
98
+ `${COOKIE}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${ttl / 1000}${secure}`);
99
+ }
100
+
101
+ function clearCookie(req, res) {
102
+ const secure = req.secure ? '; Secure' : '';
103
+ res.setHeader('Set-Cookie', `${COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secure}`);
104
+ }
105
+
106
+ // ── Rate limit (login only — stricter than the global POST limiter) ──────────
107
+ function loginRateLimited(req) {
108
+ // req.clientIp is the proxy-aware address resolved by server.js — without it
109
+ // every visitor behind Railway's proxy would share one rate-limit bucket
110
+ const ip = req.clientIp || req.socket.remoteAddress || 'unknown';
111
+ const now = Date.now();
112
+ let rec = LOGIN_RATE.hits.get(ip);
113
+ if (!rec || now - rec.start > LOGIN_RATE.windowMs) rec = { count: 0, start: now };
114
+ rec.count++;
115
+ LOGIN_RATE.hits.set(ip, rec);
116
+ return rec.count > LOGIN_RATE.max;
117
+ }
118
+
119
+ // ── Handlers ──────────────────────────────────────────────────────────────────
120
+ function json(res, status, obj) {
121
+ res.writeHead(status, { 'Content-Type': 'application/json' });
122
+ res.end(JSON.stringify(obj));
123
+ }
124
+
125
+ function handleStatus(req, res) {
126
+ const rec = loadJson(AUTH_FILE);
127
+ json(res, 200, {
128
+ setup: isSetUp(),
129
+ authed: isAuthed(req),
130
+ // Account name is shown on the login screen (single-user local app) —
131
+ // it is display data, not a secret.
132
+ username: (rec && rec.username) || null,
133
+ });
134
+ }
135
+
136
+ // Account names are compared NFC-normalized and case-insensitive so that
137
+ // Vietnamese IME composition differences never lock the owner out.
138
+ function normalizeUsername(name) {
139
+ return String(name).normalize('NFC').trim();
140
+ }
141
+
142
+ function validUsername(name) {
143
+ if (typeof name !== 'string') return false;
144
+ const n = normalizeUsername(name);
145
+ // 2–32 visible chars, no control characters
146
+ return n.length >= 2 && n.length <= 32 && !/[\u0000-\u001f\u007f]/.test(n);
147
+ }
148
+
149
+ // First run only: create the account (username + password), then sign in.
150
+ function handleSetup(req, res, body) {
151
+ if (isSetUp()) { json(res, 409, { error: 'Already set up' }); return; }
152
+ const username = body && body.username;
153
+ const password = body && body.password;
154
+ if (!validUsername(username)) {
155
+ json(res, 400, { error: 'Username must be 2-32 characters' }); return;
156
+ }
157
+ if (typeof password !== 'string' || password.length < 6) {
158
+ json(res, 400, { error: 'Password must be at least 6 characters' }); return;
159
+ }
160
+ saveJson(AUTH_FILE, {
161
+ ...hashPassword(password),
162
+ username: normalizeUsername(username),
163
+ created: new Date().toISOString(),
164
+ });
165
+ setCookie(req, res, createSession(!!body.remember));
166
+ json(res, 200, { ok: true });
167
+ }
168
+
169
+ function handleLogin(req, res, body) {
170
+ if (loginRateLimited(req)) {
171
+ res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '900' });
172
+ res.end(JSON.stringify({ error: 'Too many attempts — wait 15 minutes' }));
173
+ return;
174
+ }
175
+ const rec = loadJson(AUTH_FILE);
176
+ if (!rec) { json(res, 409, { error: 'Not set up yet' }); return; }
177
+ const password = body && body.password;
178
+ // Accounts created before usernames existed have no rec.username — skip the
179
+ // name check for them so the owner is never locked out by this upgrade.
180
+ if (rec.username) {
181
+ const given = body && body.username;
182
+ if (typeof given !== 'string' ||
183
+ normalizeUsername(given).toLowerCase() !== rec.username.toLowerCase()) {
184
+ json(res, 401, { error: 'Wrong username or password' }); return;
185
+ }
186
+ }
187
+ if (typeof password !== 'string' || !verifyPassword(password, rec)) {
188
+ json(res, 401, { error: 'Wrong username or password' }); return;
189
+ }
190
+ setCookie(req, res, createSession(!!body.remember));
191
+ json(res, 200, { ok: true });
192
+ }
193
+
194
+ function handleLogout(req, res) {
195
+ const token = sessionToken(req);
196
+ if (token && sessions[token]) {
197
+ delete sessions[token];
198
+ saveJson(SESSIONS_FILE, sessions);
199
+ }
200
+ clearCookie(req, res);
201
+ json(res, 200, { ok: true });
202
+ }
203
+
204
+ module.exports = { isAuthed, isSetUp, handleStatus, handleSetup, handleLogin, handleLogout };
package/bin/yana.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Open browser automatically after server starts
4
+ const { execSync } = require('child_process');
5
+ const http = require('http');
6
+
7
+ // Forward to server.js — it exports the http.Server instance
8
+ const server = require('../server.js');
9
+
10
+ // Wait for server to be listening, then open browser
11
+ server.on('listening', () => {
12
+ const { port } = server.address();
13
+ const url = `http://localhost:${port}`;
14
+ try {
15
+ const cmd = process.platform === 'darwin' ? `open ${url}`
16
+ : process.platform === 'win32' ? `start ${url}`
17
+ : `xdg-open ${url}`;
18
+ execSync(cmd, { stdio: 'ignore' });
19
+ } catch (_) {}
20
+ });
@@ -0,0 +1,240 @@
1
+ // Yana AI — app shell, routing, tweaks wiring
2
+
3
+ /* ---------- Memory Garden — real L1 atomic facts via /api/memories ---------- */
4
+ function MemoryGarden() {
5
+ const [data, setData] = React.useState(null);
6
+ const [filter, setFilter] = React.useState("all");
7
+
8
+ React.useEffect(() => {
9
+ fetch("/api/memories")
10
+ .then((r) => (r.ok ? r.json() : null))
11
+ .then((d) => { if (d) setData(d); })
12
+ .catch(() => {});
13
+ }, []);
14
+
15
+ const memories = data ? data.memories : [];
16
+ const kinds = ["all", ...Array.from(new Set(memories.map((m) => m.kind)))];
17
+ const visible = filter === "all" ? memories : memories.filter((m) => m.kind === filter);
18
+
19
+ return (
20
+ <div data-screen-label="Memory Garden">
21
+ <PageHeader
22
+ title={L("Memory Garden", "Vườn ký ức")}
23
+ sub={data
24
+ ? data.total + L(" L1 atomic facts · persisted in memory/L1_atomic", " fact L1 · lưu tại memory/L1_atomic")
25
+ : L("Loading memories…", "Đang tải ký ức…")}>
26
+ <div style={{ display: "flex", gap: 6 }}>
27
+ {kinds.map((k) => (
28
+ <button key={k} onClick={() => setFilter(k)} style={{
29
+ padding: "5px 13px", borderRadius: 99, border: "none", cursor: "pointer", fontSize: 12.5,
30
+ fontWeight: filter === k ? 500 : 400,
31
+ background: filter === k ? "var(--primary)" : "rgba(var(--shadow-rgb), .08)",
32
+ color: filter === k ? "white" : "var(--ink-2)",
33
+ transition: "background .15s",
34
+ }}>{k === "all" ? L("All", "Tất cả") : k}</button>
35
+ ))}
36
+ </div>
37
+ </PageHeader>
38
+ <div style={{ display: "flex", flexDirection: "column", gap: "var(--gap)", maxWidth: 800 }}>
39
+ {data && visible.length === 0 && (
40
+ <div style={{ color: "var(--ink-3)", fontSize: 13 }}>{L("No memories yet.", "Chưa có ký ức nào.")}</div>
41
+ )}
42
+ {visible.map((m) => (
43
+ <div key={m.id} className="glass" style={{ borderRadius: "var(--r-lg)", padding: "var(--pad-card)", display: "flex", gap: 14 }}>
44
+ <div style={{ flex: "none", paddingTop: 2 }}>
45
+ <span style={{ color: "var(--pink)" }}>{Icons.memory(16)}</span>
46
+ </div>
47
+ <div style={{ flex: 1, minWidth: 0 }}>
48
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
49
+ <span className="chip neutral" style={{ fontSize: 11 }}>{m.kind}</span>
50
+ {m.confidence && <span className="chip gold" style={{ fontSize: 10.5 }}>{m.confidence}</span>}
51
+ {m.fresh && <span className="chip" style={{ fontSize: 10.5, color: "var(--good)" }}>{L("Fresh", "Mới")}</span>}
52
+ </div>
53
+ <p style={{ margin: 0, fontSize: 13.5, lineHeight: 1.55, color: "var(--ink)" }}>{m.text}</p>
54
+ {m.source && <div style={{ fontSize: 12, color: "var(--ink-3)", marginTop: 7 }}>{m.source}</div>}
55
+ </div>
56
+ </div>
57
+ ))}
58
+ </div>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ /* ---------- Skills — real counts via /api/skills (core/skills on disk) ---------- */
64
+ function Skills() {
65
+ const [data, setData] = React.useState(null);
66
+
67
+ React.useEffect(() => {
68
+ fetch("/api/skills")
69
+ .then((r) => (r.ok ? r.json() : null))
70
+ .then((d) => { if (d) setData(d); })
71
+ .catch(() => {});
72
+ }, []);
73
+
74
+ const groups = data
75
+ ? [{ name: L("core (standalone)", "lõi (độc lập)"), count: data.standalone }, ...data.packs]
76
+ : [];
77
+
78
+ return (
79
+ <div data-screen-label="Skills">
80
+ <PageHeader
81
+ title={L("Skills", "Kỹ năng")}
82
+ sub={data
83
+ ? data.total.toLocaleString() + L(" skills on disk · " + data.pack_count + " imported packs", " kỹ năng trên đĩa · " + data.pack_count + " gói đã nhập")
84
+ : L("Counting skills…", "Đang đếm kỹ năng…")} />
85
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "var(--gap)" }}>
86
+ {groups.map((c) => (
87
+ <div key={c.name} className="glass" style={{ borderRadius: "var(--r-lg)", padding: "var(--pad-card)", display: "flex", flexDirection: "column", gap: 10 }}>
88
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
89
+ <span style={{ fontSize: 14.5, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{c.name}</span>
90
+ <span className="chip neutral" style={{ fontSize: 11.5, flex: "none" }}>{c.count.toLocaleString()}</span>
91
+ </div>
92
+ <div className="bar" style={{ height: 4 }}>
93
+ <i style={{ width: Math.round(c.count / data.total * 100) + "%" }}></i>
94
+ </div>
95
+ <div style={{ fontSize: 12, color: "var(--ink-3)" }}>
96
+ {Math.round(c.count / data.total * 100)}% {L("of catalog", "danh mục")}
97
+ </div>
98
+ </div>
99
+ ))}
100
+ </div>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ /* ---------- App shell ---------- */
106
+ const TWEAK_DEFAULTS = {
107
+ "theme": "Jade Lake 🌿",
108
+ "language": "English",
109
+ "blur": 70,
110
+ "transparency": 60,
111
+ "reflection": 70,
112
+ "depth": 55,
113
+ "layout": "Regular",
114
+ "showAgents": true,
115
+ "showMissions": true,
116
+ "showMemory": true,
117
+ "showSystem": true,
118
+ "accent": ""
119
+ };
120
+
121
+ const THEME_MAP = {
122
+ "Lotus Dawn 🌸": "dawn",
123
+ "Jade Lake 🌿": "jade",
124
+ "Morning Mist ☁️": "mist",
125
+ "Glass Silver ✨": "silver",
126
+ };
127
+ const DENSITY = { "Compact": 0.85, "Regular": 1, "Spacious": 1.18 };
128
+
129
+ function applyTweaks(t) {
130
+ const root = document.documentElement;
131
+ root.setAttribute("data-theme", THEME_MAP[t.theme] || "jade");
132
+ root.style.setProperty("--blur", t.blur / 100);
133
+ root.style.setProperty("--alpha", t.transparency / 100);
134
+ root.style.setProperty("--reflect", t.reflection / 100);
135
+ root.style.setProperty("--depth", t.depth / 100);
136
+ root.style.setProperty("--sp", DENSITY[t.layout] || 1);
137
+ if (t.accent) {
138
+ root.style.setProperty("--primary", t.accent);
139
+ root.style.setProperty("--primary-soft", "color-mix(in oklab, " + t.accent + " 10%, transparent)");
140
+ } else {
141
+ root.style.removeProperty("--primary");
142
+ root.style.removeProperty("--primary-soft");
143
+ }
144
+ }
145
+
146
+ function App() {
147
+ const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
148
+ const [page, setPage] = React.useState(() => localStorage.getItem("yana.page") || "dashboard");
149
+ window.YANA_LANG = t.language === "Tiếng Việt" ? "vi" : "en";
150
+
151
+ React.useEffect(() => applyTweaks(t), [t]);
152
+ React.useEffect(() => localStorage.setItem("yana.page", page), [page]);
153
+
154
+ const Page = {
155
+ dashboard: () => <Dashboard t={t} onNav={setPage} />,
156
+ chat: () => <Chat t={t} />,
157
+ agents: () => <AgentSpace />,
158
+ missions: () => <MissionCenter />,
159
+ memory: () => <MemoryGarden />,
160
+ skills: () => <Skills />,
161
+ providers: () => <Providers />,
162
+ settings: () => <Settings t={t} setTweak={setTweak} />,
163
+ }[page] || (() => <Dashboard t={t} onNav={setPage} />);
164
+
165
+ return (
166
+ <div key={t.language} className="yana-app" style={{ position: "relative", zIndex: 1, height: "100%", display: "flex", gap: "var(--gap)" }}>
167
+ <Sidebar page={page} onNav={setPage} />
168
+ <main className="yana-main" style={{ flex: 1, minWidth: 0, minHeight: 0, overflowY: page === "chat" ? "hidden" : "auto", display: "flex", flexDirection: "column" }}>
169
+ <div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
170
+ <Page />
171
+ </div>
172
+ </main>
173
+
174
+ <TweaksPanel>
175
+ <TweakSection label="Theme" />
176
+ <TweakSelect label="Direction" value={t.theme}
177
+ options={Object.keys(THEME_MAP)}
178
+ onChange={(v) => setTweak("theme", v)} />
179
+ <TweakRadio label="Language" value={t.language}
180
+ options={["English", "Tiếng Việt"]}
181
+ onChange={(v) => setTweak("language", v)} />
182
+
183
+ <TweakSection label="Glass" />
184
+ <TweakSlider label="Blur strength" value={t.blur} min={0} max={100} unit="%" onChange={(v) => setTweak("blur", v)} />
185
+ <TweakSlider label="Transparency" value={t.transparency} min={0} max={100} unit="%" onChange={(v) => setTweak("transparency", v)} />
186
+ <TweakSlider label="Reflection" value={t.reflection} min={0} max={100} unit="%" onChange={(v) => setTweak("reflection", v)} />
187
+ <TweakSlider label="Depth" value={t.depth} min={0} max={100} unit="%" onChange={(v) => setTweak("depth", v)} />
188
+
189
+ <TweakSection label="Layout" />
190
+ <TweakRadio label="Density" value={t.layout} options={["Compact", "Regular", "Spacious"]} onChange={(v) => setTweak("layout", v)} />
191
+
192
+ <TweakSection label="AI Control Center" />
193
+ <TweakToggle label="Show agents" value={t.showAgents} onChange={(v) => setTweak("showAgents", v)} />
194
+ <TweakToggle label="Show missions" value={t.showMissions} onChange={(v) => setTweak("showMissions", v)} />
195
+ <TweakToggle label="Show Memory Garden" value={t.showMemory} onChange={(v) => setTweak("showMemory", v)} />
196
+ <TweakToggle label="Show system status" value={t.showSystem} onChange={(v) => setTweak("showSystem", v)} />
197
+
198
+ <TweakSection label="Visual style" />
199
+ <TweakColor label="Accent" value={t.accent || "#2f7e6e"}
200
+ options={["#2f7e6e", "#56949f", "#3a7ca5", "#7d6aa8", "#b96b80", "#b07a4f", "#b78f3d", "#6f8f5a", "#5b7282"]}
201
+ onChange={(v) => setTweak("accent", v)} />
202
+ <TweakButton label="Use theme accent" onClick={() => setTweak("accent", "")} />
203
+ </TweaksPanel>
204
+ </div>
205
+ );
206
+ }
207
+
208
+ /* ---------- Undercurrent: slow drifting motes ---------- */
209
+ const MOTES = [
210
+ { left: "12%", top: "78%", dur: "64s", delay: "0s", dx: "60px", dy: "-50px", peak: 0.20 },
211
+ { left: "26%", top: "88%", dur: "82s", delay: "-20s", dx: "-45px", dy: "-70px", peak: 0.16 },
212
+ { left: "44%", top: "72%", dur: "71s", delay: "-40s", dx: "50px", dy: "-40px", peak: 0.18 },
213
+ { left: "58%", top: "84%", dur: "90s", delay: "-10s", dx: "-60px", dy: "-55px", peak: 0.22 },
214
+ { left: "72%", top: "76%", dur: "76s", delay: "-55s", dx: "40px", dy: "-65px", peak: 0.16 },
215
+ { left: "84%", top: "90%", dur: "68s", delay: "-30s", dx: "-50px", dy: "-45px", peak: 0.20 },
216
+ { left: "35%", top: "94%", dur: "86s", delay: "-65s", dx: "55px", dy: "-60px", peak: 0.14 },
217
+ { left: "92%", top: "68%", dur: "79s", delay: "-48s", dx: "-35px", dy: "-50px", peak: 0.14 },
218
+ ];
219
+
220
+ function Undercurrent() {
221
+ return (
222
+ <div className="scene">
223
+ {MOTES.map((m, i) => (
224
+ <span key={i} className="mote" style={{
225
+ left: m.left, top: m.top,
226
+ "--dur": m.dur, "--delay": m.delay, "--dx": m.dx, "--dy": m.dy, "--peak": m.peak,
227
+ }}></span>
228
+ ))}
229
+ </div>
230
+ );
231
+ }
232
+
233
+ // Render only after the key vault has decrypted into its in-memory cache —
234
+ // otherwise ProviderCard/Chat would see an empty vault on first paint.
235
+ YanaVault.ready.then(() => ReactDOM.createRoot(document.getElementById("root")).render(
236
+ <>
237
+ <Undercurrent />
238
+ <App />
239
+ </>
240
+ ));