@cana-ai/walkie-talkie 0.1.0 β†’ 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,21 @@
1
- # πŸ“» Cana Walkie-Talkie
1
+ <p align="center">
2
+ <a href="https://cana.build">
3
+ <img src="https://raw.githubusercontent.com/Colate-Ltd/cana-walkie-talkie/main/public/cana-logo.png" alt="Cana" width="72" />
4
+ </a>
5
+ </p>
2
6
 
3
- **A real-time message bus for human↔agent and agent↔agent coordination.**
7
+ <h1 align="center">Cana Walkie-Talkie</h1>
8
+
9
+ <p align="center"><b>A real-time message bus for human↔agent and agent↔agent coordination.</b></p>
10
+
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/@cana-ai/walkie-talkie"><img src="https://img.shields.io/npm/v/@cana-ai/walkie-talkie?color=6366f1&label=npm" alt="npm version" /></a>
13
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-6366f1" alt="License: MIT" /></a>
14
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%E2%89%A522.5-43853d" alt="Node β‰₯ 22.5" /></a>
15
+ &nbsp;Β·&nbsp;
16
+ <a href="https://cana.build">cana.build</a> Β·
17
+ <a href="https://cana.build/docs">Docs</a>
18
+ </p>
4
19
 
5
20
  Spin up a WebSocket bus where people and AI agents (Claude Code, your own
6
21
  scripts, anything that speaks WebSocket) join shared **channels**, exchange
@@ -43,6 +58,28 @@ push, and deep agent-platform integration (see the table below).
43
58
 
44
59
  Requires **Node β‰₯ 22.5** (for built-in `node:sqlite`).
45
60
 
61
+ ### Run instantly with `npx` β€” no clone, no install
62
+
63
+ ```bash
64
+ npx @cana-ai/walkie-talkie
65
+ ```
66
+
67
+ That boots the bus **and** the bundled dashboard on **http://localhost:8787** and
68
+ prints a generated **admin token** on first run. Configure it inline with env vars:
69
+
70
+ ```bash
71
+ PORT=9000 ADMIN_TOKEN=$(openssl rand -hex 32) npx @cana-ai/walkie-talkie
72
+ ```
73
+
74
+ Pin a version with `npx @cana-ai/walkie-talkie@latest`, or install it globally:
75
+
76
+ ```bash
77
+ npm i -g @cana-ai/walkie-talkie
78
+ cana-walkie-talkie # same binary, now on your PATH
79
+ ```
80
+
81
+ ### Or clone for development
82
+
46
83
  ```bash
47
84
  git clone https://github.com/Colate-Ltd/cana-walkie-talkie.git
48
85
  cd cana-walkie-talkie
package/dist/server.js CHANGED
@@ -18,7 +18,33 @@ app.use("/api/bus", rest);
18
18
  app.use("/", express.static(path.join(__dirname, "..", "public")));
19
19
  const server = http.createServer(app);
20
20
  attachWebSocket(server);
21
+ // Brand banner β€” Cana by Colate (https://cana.build)
22
+ const c = {
23
+ link: "\x1b[38;5;81m", // cyan link
24
+ bold: "\x1b[1m",
25
+ dim: "\x1b[2m",
26
+ reset: "\x1b[0m",
27
+ };
28
+ // "CANA.BUILD" wordmark with a top→bottom indigo→violet gradient.
29
+ const grad = ["\x1b[38;5;189m", "\x1b[38;5;147m", "\x1b[38;5;141m", "\x1b[38;5;135m", "\x1b[38;5;99m", "\x1b[38;5;99m"];
30
+ const art = [
31
+ " β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— ",
32
+ "β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•— β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—",
33
+ "β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘",
34
+ "β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘",
35
+ "β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•",
36
+ " β•šβ•β•β•β•β•β•β•šβ•β• β•šβ•β•β•šβ•β• β•šβ•β•β•β•β•šβ•β• β•šβ•β•β•šβ•β•β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β•β•šβ•β•β•β•β•β•β•β•šβ•β•β•β•β•β• ",
37
+ ];
38
+ const banner = [
39
+ "",
40
+ ...art.map((line, i) => ` ${grad[i]}${c.bold}${line}${c.reset}`),
41
+ "",
42
+ ` ${c.bold}Walkie-Talkie${c.reset} ${c.dim}Β· a shared workspace for teams and AI agents${c.reset}`,
43
+ ` ${c.link}${c.bold}https://cana.build${c.reset} ${c.dim}β€” by Colate${c.reset}`,
44
+ "",
45
+ ].join("\n");
21
46
  server.listen(config.port, config.host, () => {
47
+ console.log(banner);
22
48
  const url = `http://${config.host === "0.0.0.0" ? "localhost" : config.host}:${config.port}`;
23
49
  console.log(`\n Cana Walkie-Talkie (open-source core)`);
24
50
  console.log(` ──────────────────────────────────────`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cana-ai/walkie-talkie",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/public/app.js CHANGED
@@ -10,14 +10,42 @@ const api = (path, opts = {}) =>
10
10
  headers: { "content-type": "application/json", authorization: `Bearer ${adminToken}`, ...(opts.headers || {}) },
11
11
  });
12
12
 
13
+ // Snapshot the auth-aware empty states so we can restore them later.
14
+ const emptyHTML = $("messages").innerHTML;
15
+
16
+ // ── Auth state ───────────────────────────────────────────────────────
17
+ function setAuthState() {
18
+ document.body.dataset.auth = adminToken ? "in" : "out";
19
+ }
20
+
21
+ function resetViewer() {
22
+ closeWs();
23
+ activeChannel = null;
24
+ $("viewerHead").classList.add("hidden");
25
+ $("composer").classList.add("hidden");
26
+ $("messages").innerHTML = emptyHTML;
27
+ }
28
+
13
29
  // ── Admin token ──────────────────────────────────────────────────────
14
30
  $("adminToken").value = adminToken;
15
31
  $("saveToken").onclick = () => {
16
32
  adminToken = $("adminToken").value.trim();
33
+ if (!adminToken) return;
17
34
  localStorage.setItem("wt-admin-token", adminToken);
35
+ setAuthState();
36
+ resetViewer();
18
37
  loadChannels();
19
38
  };
20
39
 
40
+ $("logout").onclick = () => {
41
+ adminToken = "";
42
+ localStorage.removeItem("wt-admin-token");
43
+ $("adminToken").value = "";
44
+ setAuthState();
45
+ renderChannels([]);
46
+ resetViewer();
47
+ };
48
+
21
49
  // ── Channels ─────────────────────────────────────────────────────────
22
50
  async function loadChannels() {
23
51
  const res = await api("/channels");
@@ -50,11 +78,7 @@ $("newChannel").onclick = async () => {
50
78
  $("killChannel").onclick = async () => {
51
79
  if (!activeChannel || !confirm(`Close #${activeChannel.name}?`)) return;
52
80
  await api(`/channels/${activeChannel.id}/kill`, { method: "POST" });
53
- closeWs();
54
- activeChannel = null;
55
- $("viewerHead").classList.add("hidden");
56
- $("composer").classList.add("hidden");
57
- $("messages").innerHTML = `<div class="empty">Channel closed.</div>`;
81
+ resetViewer();
58
82
  loadChannels();
59
83
  };
60
84
 
@@ -140,5 +164,6 @@ $("tkCreate").onclick = async () => {
140
164
 
141
165
  function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); }
142
166
 
167
+ setAuthState();
143
168
  if (adminToken) loadChannels();
144
169
  setInterval(() => { if (adminToken) loadChannels(); }, 10000);
Binary file
Binary file
Binary file
Binary file
package/public/index.html CHANGED
@@ -4,15 +4,41 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Cana Walkie-Talkie</title>
7
+ <link rel="icon" href="/favicon.ico" sizes="any" />
8
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
9
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
7
10
  <link rel="stylesheet" href="/styles.css" />
8
11
  </head>
9
- <body>
12
+ <body data-auth="out">
10
13
  <header class="topbar">
11
- <div class="brand">πŸ“» Cana Walkie-Talkie <span class="tag">open-source core</span></div>
12
- <div class="admin">
13
- <input id="adminToken" type="password" placeholder="Admin token…" autocomplete="off" />
14
- <button id="saveToken">Connect</button>
14
+ <div class="brand">
15
+ <a class="logo" href="https://cana.build" target="_blank" rel="noopener" title="cana.build">
16
+ <img class="logo-mark" src="/cana-logo.png" alt="Cana" width="22" height="22" />
17
+ <span class="logo-text"><b>cana</b><span>.build</span></span>
18
+ </a>
19
+ <span class="brand-sep"></span>
20
+ <span class="product">Walkie-Talkie</span>
21
+ <span class="tag">open-source core</span>
15
22
  </div>
23
+
24
+ <nav class="topnav">
25
+ <a class="navlink" href="https://cana.build/docs" target="_blank" rel="noopener">Docs β†—</a>
26
+ <a class="navlink" href="https://cana.build" target="_blank" rel="noopener">cana.build β†—</a>
27
+ <a class="navlink" href="https://github.com/Colate-Ltd/cana-walkie-talkie" target="_blank" rel="noopener">GitHub β†—</a>
28
+
29
+ <!-- Logged out: token entry -->
30
+ <div class="admin auth-out">
31
+ <input id="adminToken" type="password" placeholder="Admin token…" autocomplete="off" />
32
+ <button id="saveToken">Connect</button>
33
+ </div>
34
+
35
+ <!-- Logged in: status + logout -->
36
+ <div class="admin auth-in">
37
+ <span class="status-dot" title="Authenticated">●</span>
38
+ <span class="status-text">Connected</span>
39
+ <button id="logout" class="ghost">Log out</button>
40
+ </div>
41
+ </nav>
16
42
  </header>
17
43
 
18
44
  <main class="layout">
@@ -22,6 +48,11 @@
22
48
  <button id="newChannel" class="ghost">+ New</button>
23
49
  </div>
24
50
  <ul id="channelList" class="channel-list"></ul>
51
+ <div class="sidebar-foot">
52
+ <a href="https://cana.build/docs" target="_blank" rel="noopener">Documentation</a>
53
+ <span>Β·</span>
54
+ <a href="https://cana.build" target="_blank" rel="noopener">Powered by cana.build</a>
55
+ </div>
25
56
  </aside>
26
57
 
27
58
  <section class="viewer">
@@ -37,12 +68,26 @@
37
68
  </div>
38
69
 
39
70
  <div id="messages" class="messages">
40
- <div class="empty">Enter your admin token, then pick or create a channel.</div>
71
+ <!-- Logged out -->
72
+ <div class="empty auth-out">
73
+ <div class="empty-card">
74
+ <div class="empty-logo"><img src="/cana-logo.png" alt="Cana" width="40" height="40" /><span class="empty-word"><b>cana</b><span>.build</span></span></div>
75
+ <h3>Welcome to Walkie-Talkie</h3>
76
+ <p>A real-time message bus for human↔agent and agent↔agent coordination.</p>
77
+ <p class="muted">Enter your admin token above to get started.</p>
78
+ <div class="empty-links">
79
+ <a href="https://cana.build/docs" target="_blank" rel="noopener">Read the docs β†—</a>
80
+ <a href="https://github.com/Colate-Ltd/cana-walkie-talkie" target="_blank" rel="noopener">View on GitHub β†—</a>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ <!-- Logged in, no channel selected -->
85
+ <div class="empty auth-in">Pick a channel, or create one to get started.</div>
41
86
  </div>
42
87
 
43
88
  <form id="composer" class="composer hidden">
44
89
  <input id="composeTo" class="to" placeholder="@handle (optional)" />
45
- <input id="composeText" class="text" placeholder="Message…" autocomplete="off" />
90
+ <input id="composeText" class="text" placeholder="Message the channel…" autocomplete="off" />
46
91
  <label class="priv"><input id="composePrivate" type="checkbox" /> private</label>
47
92
  <button type="submit">Send</button>
48
93
  </form>
package/public/styles.css CHANGED
@@ -1,52 +1,169 @@
1
1
  :root {
2
- --bg: #0f1117; --panel: #171a23; --panel2: #1e222d; --line: #2a2f3c;
3
- --text: #e6e8ee; --muted: #8b93a7; --accent: #6366f1; --danger: #ef4444;
2
+ --bg: #f7f8fc;
3
+ --panel: #ffffff;
4
+ --panel2: #f3f4f9;
5
+ --line: #e6e8f0;
6
+ --line2: #eef0f6;
7
+ --text: #1a1d29;
8
+ --muted: #6b7280;
9
+ --accent: #6366f1;
10
+ --accent-weak: #eef0fe;
11
+ --accent-grad: linear-gradient(135deg, #818cf8 0%, #6366f1 55%, #7c3aed 100%);
12
+ --danger: #ef4444;
13
+ --shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 1px 3px rgba(16, 24, 40, .06);
14
+ --radius: 10px;
4
15
  }
5
16
  * { box-sizing: border-box; }
6
- body { margin: 0; font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }
7
- .topbar { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; background: var(--panel); border-bottom: 1px solid var(--line); }
8
- .brand { font-weight: 600; }
9
- .tag { font-size: 11px; color: var(--muted); border: 1px solid var(--line); padding: 1px 6px; border-radius: 999px; margin-left: 6px; }
10
- .admin { display: flex; gap: 8px; }
17
+ body {
18
+ margin: 0;
19
+ font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
20
+ background: var(--bg);
21
+ color: var(--text);
22
+ -webkit-font-smoothing: antialiased;
23
+ overflow-x: hidden;
24
+ }
25
+ a { color: var(--accent); text-decoration: none; }
26
+ a:hover { text-decoration: underline; }
27
+
28
+ /* ── Topbar ─────────────────────────────────────────────── */
29
+ .topbar {
30
+ display: flex; align-items: center; justify-content: space-between;
31
+ height: 56px; padding: 0 20px;
32
+ background: var(--panel); border-bottom: 1px solid var(--line);
33
+ }
34
+ .brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
35
+ .logo { display: inline-flex; align-items: center; gap: 8px; color: var(--text); }
36
+ .logo:hover { text-decoration: none; }
37
+ .logo-mark { width: 22px; height: 22px; object-fit: contain; display: block; }
38
+ .logo-text { font-size: 17px; letter-spacing: -.01em; }
39
+ .logo-text b { font-weight: 700; }
40
+ .logo-text span { color: var(--accent); font-weight: 600; }
41
+ .brand-sep { width: 1px; height: 22px; background: var(--line); }
42
+ .product { font-weight: 600; font-size: 15px; }
43
+ .tag {
44
+ font-size: 11px; color: var(--accent); background: var(--accent-weak);
45
+ border: 1px solid #dfe1fd; padding: 2px 8px; border-radius: 999px; font-weight: 600;
46
+ }
47
+
48
+ .topnav { display: flex; align-items: center; gap: 6px; }
49
+ .navlink { color: var(--muted); font-size: 13px; padding: 6px 10px; border-radius: 8px; font-weight: 500; }
50
+ .navlink:hover { color: var(--text); background: var(--panel2); text-decoration: none; }
51
+
52
+ .admin { display: flex; align-items: center; gap: 8px; margin-left: 6px; padding-left: 10px; border-left: 1px solid var(--line); }
53
+ .status-dot { color: #22c55e; font-size: 10px; }
54
+ .status-text { font-size: 13px; color: var(--muted); font-weight: 500; }
55
+
56
+ /* Auth visibility β€” toggled by body[data-auth] */
57
+ body[data-auth="out"] .auth-in { display: none !important; }
58
+ body[data-auth="in"] .auth-out { display: none !important; }
59
+
60
+ /* ── Controls ───────────────────────────────────────────── */
11
61
  input, select, button { font: inherit; }
12
- input, select { background: var(--panel2); border: 1px solid var(--line); color: var(--text); padding: 7px 10px; border-radius: 8px; }
13
- button { background: var(--accent); color: white; border: none; padding: 7px 12px; border-radius: 8px; cursor: pointer; }
14
- button.ghost { background: transparent; border: 1px solid var(--line); color: var(--text); }
15
- button.danger { color: var(--danger); border-color: var(--danger); }
16
- .layout { display: grid; grid-template-columns: 280px 1fr; height: calc(100vh - 53px); }
17
- .sidebar { border-right: 1px solid var(--line); background: var(--panel); overflow-y: auto; }
18
- .sidebar-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; }
19
- .sidebar-head h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 0; }
20
- .channel-list { list-style: none; margin: 0; padding: 0 8px; }
21
- .channel-list li { padding: 10px 12px; border-radius: 8px; cursor: pointer; }
62
+ input, select {
63
+ background: var(--panel); border: 1px solid var(--line); color: var(--text);
64
+ padding: 8px 11px; border-radius: 9px; outline: none; transition: border-color .12s, box-shadow .12s;
65
+ }
66
+ input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
67
+ input::placeholder { color: #9aa1b1; }
68
+ button {
69
+ background: var(--accent); color: #fff; border: none; padding: 8px 14px;
70
+ border-radius: 9px; cursor: pointer; font-weight: 600; transition: filter .12s, background .12s;
71
+ }
72
+ button:hover { filter: brightness(1.06); }
73
+ button.ghost { background: var(--panel); border: 1px solid var(--line); color: var(--text); font-weight: 500; }
74
+ button.ghost:hover { background: var(--panel2); filter: none; }
75
+ button.danger { color: var(--danger); border-color: #f3c4c4; background: var(--panel); }
76
+ button.danger:hover { background: #fef2f2; }
77
+
78
+ /* ── Layout ─────────────────────────────────────────────── */
79
+ .layout { display: grid; grid-template-columns: 280px 1fr; height: calc(100vh - 56px); }
80
+ .sidebar { display: flex; flex-direction: column; border-right: 1px solid var(--line); background: var(--panel); }
81
+ .sidebar-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 16px 10px; }
82
+ .sidebar-head h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 0; font-weight: 700; }
83
+ .sidebar-head .ghost { padding: 5px 10px; font-size: 13px; }
84
+ .channel-list { list-style: none; margin: 0; padding: 0 8px; flex: 1; overflow-y: auto; }
85
+ .channel-list li { padding: 10px 12px; border-radius: 9px; cursor: pointer; border: 1px solid transparent; transition: background .1s; }
22
86
  .channel-list li:hover { background: var(--panel2); }
23
- .channel-list li.active { background: var(--panel2); outline: 1px solid var(--accent); }
87
+ .channel-list li.active { background: var(--accent-weak); border-color: #dfe1fd; }
24
88
  .channel-list .cname { font-weight: 600; }
25
- .channel-list .cmeta { font-size: 12px; color: var(--muted); }
26
- .viewer { display: flex; flex-direction: column; min-width: 0; }
27
- .viewer-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--line); }
89
+ .channel-list .cmeta { font-size: 12px; color: var(--muted); margin-top: 2px; }
90
+ .sidebar-foot { padding: 12px 16px; border-top: 1px solid var(--line2); font-size: 12px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
91
+ .sidebar-foot a { color: var(--muted); }
92
+ .sidebar-foot a:hover { color: var(--accent); }
93
+
94
+ /* ── Viewer ─────────────────────────────────────────────── */
95
+ .viewer { display: flex; flex-direction: column; min-width: 0; background: var(--bg); }
96
+ .viewer-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid var(--line); background: var(--panel); }
28
97
  .viewer-head h2 { margin: 0; font-size: 16px; }
29
98
  .viewer-actions { display: flex; gap: 8px; }
30
- .messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
31
- .empty { color: var(--muted); margin: auto; }
32
- .msg { padding: 8px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; max-width: 75%; }
33
- .msg.me { align-self: flex-end; border-color: var(--accent); }
34
- .msg.system { align-self: center; background: transparent; border: none; color: var(--muted); font-size: 12px; }
35
- .msg .from { font-size: 12px; color: var(--accent); font-weight: 600; }
36
- .msg .pill { font-size: 10px; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0 5px; margin-left: 6px; }
37
- .composer { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--line); }
38
- .composer .to { width: 160px; }
99
+ .messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 10px; }
100
+ .empty { color: var(--muted); margin: auto; text-align: center; }
101
+
102
+ /* Welcome card (logged out) */
103
+ .empty-card { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 32px 36px; max-width: 440px; box-shadow: var(--shadow); }
104
+ .empty-logo { font-size: 22px; margin-bottom: 14px; display: flex; align-items: center; justify-content: center; gap: 8px; }
105
+ .empty-logo img { width: 40px; height: 40px; object-fit: contain; }
106
+ .empty-word b { font-weight: 700; color: var(--text); }
107
+ .empty-word > span { color: var(--accent); font-weight: 600; }
108
+ .empty-card h3 { margin: 0 0 8px; font-size: 18px; color: var(--text); }
109
+ .empty-card p { margin: 6px 0; }
110
+ .empty-links { margin-top: 18px; display: flex; gap: 16px; justify-content: center; }
111
+
112
+ /* Messages */
113
+ .msg { padding: 9px 13px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; max-width: 72%; box-shadow: var(--shadow); }
114
+ .msg.me { align-self: flex-end; background: var(--accent); color: #fff; border-color: var(--accent); }
115
+ .msg.me .from { color: #e0e1ff; }
116
+ .msg.system { align-self: center; background: transparent; border: none; box-shadow: none; color: var(--muted); font-size: 12px; }
117
+ .msg .from { font-size: 12px; color: var(--accent); font-weight: 600; margin-bottom: 2px; }
118
+ .msg .pill { font-size: 10px; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 1px 6px; margin-left: 6px; }
119
+ .msg.me .pill { color: #e0e1ff; border-color: rgba(255,255,255,.4); }
120
+
121
+ /* Composer */
122
+ .composer { display: flex; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--line); background: var(--panel); }
123
+ .composer .to { width: 170px; }
39
124
  .composer .text { flex: 1; }
40
- .composer .priv { display: flex; align-items: center; gap: 4px; color: var(--muted); font-size: 12px; }
41
- .modal { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; }
42
- .modal-card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 20px; width: 420px; max-width: 92vw; }
43
- .modal-card h3 { margin: 0 0 12px; }
44
- .modal-card label { display: block; margin: 10px 0; }
45
- .modal-card input[type=text], .modal-card input:not([type]), .modal-card select { width: 100%; margin-top: 4px; }
46
- .modal-card fieldset { border: 1px solid var(--line); border-radius: 8px; }
47
- .modal-card fieldset label { display: inline-flex; align-items: center; gap: 5px; margin-right: 14px; }
48
- .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
49
- .tk-result { background: var(--panel2); border-radius: 10px; padding: 12px; margin-top: 12px; }
50
- .tk-result code { display: block; word-break: break-all; background: #0b0d12; padding: 8px; border-radius: 6px; margin: 6px 0; font-size: 12px; }
125
+ .composer .priv { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 12px; white-space: nowrap; }
126
+
127
+ /* ── Modal ──────────────────────────────────────────────── */
128
+ .modal { position: fixed; inset: 0; background: rgba(16, 24, 40, .45); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); }
129
+ .modal-card { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 24px; width: 440px; max-width: 92vw; box-shadow: 0 20px 50px rgba(16, 24, 40, .2); }
130
+ .modal-card h3 { margin: 0 0 14px; }
131
+ .modal-card label { display: block; margin: 12px 0; font-size: 13px; color: var(--muted); }
132
+ .modal-card input[type=text], .modal-card input:not([type]), .modal-card select { width: 100%; margin-top: 5px; color: var(--text); }
133
+ .modal-card fieldset { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
134
+ .modal-card legend { font-size: 12px; color: var(--muted); padding: 0 6px; }
135
+ .modal-card fieldset label { display: inline-flex; align-items: center; gap: 6px; margin: 0 16px 0 0; color: var(--text); }
136
+ .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
137
+ .tk-result { background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: 14px; margin-top: 14px; }
138
+ .tk-result code { display: block; word-break: break-all; background: #0f1117; color: #e6e8ee; padding: 9px 11px; border-radius: 8px; margin: 6px 0; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
139
+
51
140
  .muted { color: var(--muted); font-size: 12px; }
52
141
  .hidden { display: none !important; }
142
+
143
+ /* ── Responsive ─────────────────────────────────────────── */
144
+ @media (max-width: 900px) {
145
+ .topbar { height: auto; flex-wrap: wrap; gap: 10px; padding: 10px 14px; }
146
+ .brand { flex: 1 1 auto; }
147
+ .topnav { flex: 1 1 100%; flex-wrap: wrap; justify-content: flex-start; gap: 4px; }
148
+ .admin { margin-left: auto; }
149
+ .admin.auth-out { flex: 1 1 100%; margin-left: 0; padding-left: 0; border-left: none; }
150
+ .admin.auth-out #adminToken { flex: 1 1 0; width: 0; min-width: 0; }
151
+ #saveToken { flex: 0 0 auto; }
152
+ .layout { grid-template-columns: 1fr; height: auto; min-height: calc(100vh - 56px); }
153
+ .sidebar { border-right: none; border-bottom: 1px solid var(--line); max-height: 38vh; }
154
+ .channel-list { max-height: 26vh; }
155
+ .viewer { min-height: 60vh; }
156
+ .msg { max-width: 88%; }
157
+ .empty { width: 100%; margin: auto 0; }
158
+ .empty-card { padding: 24px 20px; margin: 0; width: 100%; max-width: 100%; }
159
+ .empty-card p { overflow-wrap: anywhere; }
160
+ .empty-links { flex-wrap: wrap; }
161
+ }
162
+
163
+ @media (max-width: 560px) {
164
+ .product, .tag { display: none; }
165
+ .navlink { padding: 6px 8px; font-size: 12px; }
166
+ .composer { flex-wrap: wrap; }
167
+ .composer .to { width: 100%; }
168
+ .composer .text { flex: 1 1 100%; }
169
+ }