viberoom 0.5.1 → 0.5.3

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.
Files changed (38) hide show
  1. package/NOTICE +8 -2
  2. package/README.md +1 -1
  3. package/dist/hub.js +46 -2
  4. package/dist/room.js +48 -6
  5. package/dist/server.js +18 -1
  6. package/dist/templates.js +27 -3
  7. package/package.json +1 -1
  8. package/templates/wren-and-quinn/template.json +28 -0
  9. package/ui/app.css +67 -19
  10. package/ui/app.js +259 -25
  11. package/ui/fonts/fira-code-cyrillic-ext.woff2 +0 -0
  12. package/ui/fonts/fira-code-cyrillic.woff2 +0 -0
  13. package/ui/fonts/fira-code-latin-ext.woff2 +0 -0
  14. package/ui/fonts/fira-code-latin.woff2 +0 -0
  15. package/ui/fonts/fira-code.css +5 -0
  16. package/ui/fonts/inter-cyrillic-ext.woff2 +0 -0
  17. package/ui/fonts/inter-cyrillic.woff2 +0 -0
  18. package/ui/fonts/inter-latin-ext.woff2 +0 -0
  19. package/ui/fonts/inter-latin.woff2 +0 -0
  20. package/ui/fonts/inter.css +5 -0
  21. package/ui/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
  22. package/ui/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
  23. package/ui/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  24. package/ui/fonts/jetbrains-mono-latin.woff2 +0 -0
  25. package/ui/fonts/jetbrains-mono.css +5 -0
  26. package/ui/fonts/noto-sans-cyrillic-ext.woff2 +0 -0
  27. package/ui/fonts/noto-sans-cyrillic.woff2 +0 -0
  28. package/ui/fonts/noto-sans-latin-ext.woff2 +0 -0
  29. package/ui/fonts/noto-sans-latin.woff2 +0 -0
  30. package/ui/fonts/noto-sans.css +5 -0
  31. package/ui/fonts/source-code-pro-cyrillic-ext.woff2 +0 -0
  32. package/ui/fonts/source-code-pro-cyrillic.woff2 +0 -0
  33. package/ui/fonts/source-code-pro-latin-ext.woff2 +0 -0
  34. package/ui/fonts/source-code-pro-latin.woff2 +0 -0
  35. package/ui/fonts/source-code-pro.css +5 -0
  36. package/ui/index.html +11 -0
  37. package/ui/theme.css +44 -18
  38. package/templates/forge-and-lumen/template.json +0 -28
package/ui/app.js CHANGED
@@ -128,7 +128,39 @@
128
128
  eraseSubmit: $("#erase-submit"),
129
129
  };
130
130
 
131
- const STATUS_LABEL = { unstaffed: "needs a coding agent", starting: "starting…", idle: "ready", queued: "waiting…", thinking: "thinking…", error: "error", offline: "offline", left: "left" };
131
+ const FONTS = {
132
+ text: {
133
+ nunito: { label: "Nunito (default)", stack: '"Nunito", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
134
+ inter: { label: "Inter", stack: '"Inter", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
135
+ "noto-sans": { label: "Noto Sans", stack: '"Noto Sans", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
136
+ arial: { label: "Arial / Helvetica (system)", stack: 'Arial, Helvetica, "Liberation Sans", sans-serif' },
137
+ system: { label: "System UI font", stack: 'system-ui, -apple-system, "Segoe UI", Roboto, Cantarell, sans-serif' },
138
+ },
139
+ mono: {
140
+ "jetbrains-mono": { label: "JetBrains Mono (default)", stack: '"JetBrains Mono", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
141
+ "fira-code": { label: "Fira Code", stack: '"Fira Code", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
142
+ "source-code-pro": { label: "Source Code Pro", stack: '"Source Code Pro", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
143
+ system: { label: "System monospace", stack: 'ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace' },
144
+ },
145
+ };
146
+ const STATUS_LABEL = { unstaffed: "needs a coding agent", starting: "starting…", idle: "ready", queued: "waiting…", thinking: "thinking…", writing: "writing…", error: "error", offline: "offline", left: "left" };
147
+ const FALLBACK_COLOR = "#9ca3af";
148
+ const WORKING_SVG = '<svg class="working" viewBox="0 0 44 35" aria-hidden="true" title="working…">'
149
+ + '<g class="body">'
150
+ + '<circle cx="20" cy="6.5" r="5.6" fill="currentColor"/>'
151
+ + '<path d="M6 35L13.7 16.5a4 4 0 0 1 8 0L14 35z" fill="currentColor"/>'
152
+ + '</g>'
153
+ + '<path d="M19 19.5L23 27" fill="none" stroke="currentColor" stroke-width="4.4" stroke-linecap="round"/>'
154
+ + '<g class="forearm"><path d="M23 27h10.5" fill="none" stroke="currentColor" stroke-width="4.4" stroke-linecap="round"/></g>'
155
+ + '<rect x="26" y="30.2" width="15.5" height="3" rx="1" fill="currentColor"/>'
156
+ + '<path d="M35.9 30.2L41.1 14h2.4l-5.1 16.2z" fill="currentColor"/>'
157
+ + '<rect x="18" y="33.2" width="25" height="1.3" rx=".6" fill="currentColor"/>'
158
+ + '</svg>';
159
+
160
+ function shownStatus(room, p) {
161
+ if (p.status !== "thinking") return p.status;
162
+ return room.messages.some((m) => m.streaming && m.from === p.id) ? "writing" : "thinking";
163
+ }
132
164
  const CHAT_EMOJI = ["😀", "😄", "😂", "🙂", "😉", "😍", "🤔", "😎", "🥳", "😅", "😢", "😡", "👍", "👎", "👋", "🙏", "👏", "💪", "🔥", "✨", "🎉", "❤️", "💜", "✅", "❌", "⚠️", "💡", "🚀", "🐛", "🤖", "🤫", "☕"];
133
165
  const ROOM_EMOJI = ["🎭", "🚀", "🧪", "🛠️", "🎨", "📚", "🧠", "💬", "🔬", "🎯", "🐙", "☕", "🌈", "🏗️", "🎮", "🔥", "🧩", "📈", "🗺️", "🎧", "🌱", "🏠", "🛸", "🧭"];
134
166
  function emojiGrid(list, current, onPick) {
@@ -391,7 +423,7 @@
391
423
  function mermaidThemeVariables(d) {
392
424
  d = diagramSettings(d);
393
425
  const preset = DIAGRAM_PRESETS[d.preset] || DIAGRAM_PRESETS.pop;
394
- const vars = Object.assign({}, preset, { fontFamily: "Nunito, Segoe UI, system-ui, -apple-system, Roboto, sans-serif", fontSize: "13px" });
426
+ const vars = Object.assign({}, preset, { fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--font").trim() || "Nunito, sans-serif", fontSize: "13px" });
395
427
  delete vars.label;
396
428
  delete vars.palette;
397
429
  if (preset.palette) preset.palette.forEach((c, i) => (vars[`pie${i + 1}`] = c.fill));
@@ -1070,6 +1102,7 @@
1070
1102
  const selected = (state.selection.kind === "participant" && state.selection.id === p.id) || (p.kind === "human" && state.selection.kind === "me" && state.detailsOpen);
1071
1103
  const asleep = p.kind === "agent" && (p.status === "offline" || p.status === "left");
1072
1104
  const unstaffed = p.kind === "agent" && p.status === "unstaffed";
1105
+ const shown = shownStatus(room, p);
1073
1106
  const className = (p.kind === "human" ? "me" : "") + (selected ? " selected" : "") + (asleep ? " offline" : "") + (unstaffed ? " unstaffed" : "");
1074
1107
  const sub = p.kind === "human" ? "you, the human" : [p.tagline ? `"${p.tagline}"` : "", p.agentVendor || p.agentLabel, p.model].filter(Boolean).join(" · ");
1075
1108
  const warn = p.statusDetail && (p.status === "offline" || p.status === "error" || p.failedTurns) ? `<div class="p-warn" title="${esc(p.statusDetail)}">${esc(p.statusDetail)}</div>` : "";
@@ -1077,9 +1110,9 @@
1077
1110
  ? `<span class="badge status-unstaffed" title="Click to summon this vibemate: pick the coding agent that runs it">summon</span>`
1078
1111
  : asleep
1079
1112
  ? `<span class="zzz" title="${esc(STATUS_LABEL[p.status] || p.status)}">zzz</span>`
1080
- : p.kind === "agent" && p.status !== "idle" ? `<span class="badge status-${p.status}">${p.status === "thinking" ? '<span class="dot"></span>' : ""}${STATUS_LABEL[p.status] || p.status}</span>` : "";
1113
+ : p.kind === "agent" && p.status !== "idle" ? `<span class="badge status-${shown}">${p.status === "thinking" ? '<span class="dot"></span>' : ""}${STATUS_LABEL[shown] || shown}</span>` : "";
1081
1114
  const avatarHtml = avatar(p.kind === "human" ? meAvatarData() : p, 44, { vendor: true });
1082
- const statusClass = p.kind === "agent" ? `avatar-status status-${esc(p.status || "idle")}` : "";
1115
+ const statusClass = p.kind === "agent" ? `avatar-status status-${esc(shown || "idle")}` : "";
1083
1116
  const bodyHtml = `<div class="p-body">
1084
1117
  <div class="p-name"><span>${esc(p.name)}</span>${p.muted ? '<span class="badge muted">muted</span>' : ""}${status}</div>
1085
1118
  <div class="p-sub">${esc(sub)}</div>
@@ -1228,7 +1261,7 @@
1228
1261
  : `<div class="sys${warn ? " warn" : ""}" title="${esc(fullTime(m.ts))}">${esc(m.text)}</div>`;
1229
1262
  return el;
1230
1263
  }
1231
- const p = findById(room, m.from) || { name: m.fromName, color: "#9ca3af", kind: m.from === "human" ? "human" : "agent" };
1264
+ const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR, kind: m.from === "human" ? "human" : "agent" };
1232
1265
  const mine = m.from === "human";
1233
1266
  el.className = "msg " + (mine ? "mine" : "agent");
1234
1267
  el.innerHTML = `
@@ -1377,9 +1410,9 @@
1377
1410
  const more = el.querySelector(".more");
1378
1411
  const long = !m.streaming && m.text.length > CLAMP_CHARS;
1379
1412
  const expanded = state.expanded.has(m.id);
1380
- text.innerHTML = renderText(room, m.text, m.images) + (m.streaming ? '<span class="caret"></span>' : "");
1413
+ text.innerHTML = renderText(room, m.text, m.images) + (m.streaming ? WORKING_SVG : "");
1381
1414
  if (!m.streaming) renderDiagrams(text);
1382
- if (m.streaming && !m.text) text.innerHTML = '<span class="pending">…</span>';
1415
+ if (m.streaming && !m.text) text.innerHTML = '<span class="pending" title="thinking…"><i></i><i></i><i></i></span>';
1383
1416
  text.classList.toggle("clamped", long && !expanded);
1384
1417
  more.hidden = !long;
1385
1418
  more.textContent = expanded ? "Show less" : "Show more";
@@ -1417,7 +1450,8 @@
1417
1450
  if (m.durationMs) parts.push(`<span title="how long the reply took">${ic("clock")} ${(m.durationMs / 1000).toFixed(1)} s</span>`);
1418
1451
  if (m.usage) parts.push(`<span title="tokens in">${ic("arrow-down")} ${fmtTokens(m.usage.inputTokens)}</span><span title="tokens out">${ic("arrow-up")} ${fmtTokens(m.usage.outputTokens)}</span>${m.usage.cachedWriteTokens ? `<span title="tokens written to the cache">${ic("database")} ${fmtTokens(m.usage.cachedWriteTokens)}</span>` : ""}`);
1419
1452
  meta.innerHTML = parts.join("<span class=\"sep\">·</span>");
1420
- } else if (m.from === "human") refreshSeen(room);
1453
+ fillSeen(el, room, m);
1454
+ } else if (m.from === "human") fillSeen(el, room, m);
1421
1455
  else meta.textContent = "";
1422
1456
  }
1423
1457
 
@@ -1429,23 +1463,48 @@
1429
1463
  return null;
1430
1464
  }
1431
1465
  function seenHtml(room, m) {
1432
- const present = room.participants.filter((p) => p.kind === "agent" && p.status !== "left" && p.status !== "offline");
1466
+ const present = room.participants.filter((p) => p.kind === "agent" && p.status !== "left" && p.status !== "offline" && p.id !== m.from);
1467
+ if (!present.length) return "";
1433
1468
  const seen = present.filter((p) => p.lastSeenSeq != null && p.lastSeenSeq >= m.seq);
1434
- if (!seen.length) return `<span class="ticks">✓</span> sent`;
1435
- if (present.length > 1 && seen.length === present.length) return `<span class="ticks">✓✓</span> seen by all`;
1436
- return `<span class="ticks">✓✓</span> seen by ${esc(seen.map((p) => p.name).join(", "))}`;
1469
+ if (seen.length === present.length) return "";
1470
+ const you = m.from !== "human";
1471
+ if (!seen.length) return `<span class="ticks">✓</span> sent${you ? " · seen by you" : ""}`;
1472
+ return `<span class="ticks">✓✓</span> seen by ${esc([...(you ? ["you"] : []), ...seen.map((p) => p.name)].join(", "))}`;
1473
+ }
1474
+ function fillSeen(el, room, m) {
1475
+ if (m.kind !== "chat" || m.streaming) return;
1476
+ const html = seenHtml(room, m);
1477
+ if (m.from === "human") {
1478
+ const meta = el.querySelector(".meta");
1479
+ if (!meta) return;
1480
+ meta.innerHTML = html;
1481
+ meta.classList.toggle("seen", !!html);
1482
+ } else {
1483
+ const meta = el.querySelector(".meta");
1484
+ let span = meta && meta.querySelector(".seen-by");
1485
+ if (!span && meta && html) {
1486
+ span = document.createElement("span");
1487
+ span.className = "seen-by";
1488
+ meta.appendChild(span);
1489
+ }
1490
+ if (span) {
1491
+ span.innerHTML = html;
1492
+ span.hidden = !html;
1493
+ }
1494
+ }
1437
1495
  }
1438
1496
  function refreshSeen(room) {
1439
- const last = lastHumanMessage(room);
1497
+ const present = room.participants.filter((p) => p.kind === "agent" && p.status !== "left" && p.status !== "offline");
1498
+ const floor = present.length ? Math.min(...present.map((p) => (p.lastSeenSeq == null ? 0 : p.lastSeenSeq))) : Infinity;
1440
1499
  const byId = new Map(room.messages.map((m) => [m.id, m]));
1441
- for (const el of els.messages.querySelectorAll(".msg.mine")) {
1442
- const meta = el.querySelector(".meta");
1443
- if (!meta) continue;
1444
- const isLast = last && el.dataset.id === last.id;
1445
- meta.innerHTML = isLast ? seenHtml(room, last) : "";
1446
- meta.classList.toggle("seen", !!isLast);
1500
+ for (const el of els.messages.querySelectorAll(".msg.mine, .msg.agent")) {
1501
+ const seq = Number(el.dataset.seq);
1502
+ const hasLabel = el.querySelector(".meta.seen, .seen-by:not([hidden])");
1503
+ if (seq <= floor && !hasLabel) continue;
1447
1504
  const m = byId.get(el.dataset.id);
1448
- if (m && (isLast || el.classList.contains("waiting"))) renderWaiting(el, room, m);
1505
+ if (!m) continue;
1506
+ fillSeen(el, room, m);
1507
+ if (m.from === "human" && (seq > floor || el.classList.contains("waiting"))) renderWaiting(el, room, m);
1449
1508
  }
1450
1509
  }
1451
1510
 
@@ -1536,6 +1595,7 @@
1536
1595
  if (empty) empty.remove();
1537
1596
  els.messages.appendChild(messageElement(room, m));
1538
1597
  if (m.from === "human") refreshSeen(room);
1598
+ if (m.streaming && m.from !== "human") renderSideRoom();
1539
1599
  else if (!stick && m.kind === "chat") noteNew(room, m);
1540
1600
  }
1541
1601
  if (stick) scrollToBottom();
@@ -1972,7 +2032,7 @@
1972
2032
  ${field("Emoji", `<div id="rp-emoji-picker"></div><input type="text" id="rp-emoji" maxlength="8" value="${esc(rs.emoji || "")}" placeholder="custom emoji (optional)">`, "A face for the room, next to its name.")}
1973
2033
  ${field("Topic", `<input type="text" id="rp-topic" maxlength="2000" value="${esc(rs.topic || "")}" placeholder="what this room is about (optional)">`)}
1974
2034
  ${field("Folder", `<span class="dir-row"><input type="text" id="rp-dir" maxlength="1000" value="${esc(room.dir)}" spellcheck="false"><button type="button" class="btn ghost browse-btn" id="rp-dir-browse" title="Choose a folder">${ic("folder")}Browse</button></span>`, "Where the vibemates read and write. Changing it restarts them in the new folder; they replay the last messages.")}
1975
- <label class="field mention-host"><span class="label">Room rules${geekTip("References follow renames and note when a participant has left. Rules go into every vibemate's brief as instructions, not as routing.")}</span><div id="rp-rules" class="rules-editor" contenteditable="true" spellcheck="true" data-placeholder="e.g. Everyone listens to @Pesho, he is the manager. Keep answers under 3 sentences."></div><span class="hint">One rule per line; type @ to reference a participant.</span><div class="mention-menu inline" id="rp-rules-menu" hidden></div></label>
2035
+ <div class="field mention-host"><span class="label">Room rules${geekTip("References follow renames and note when a participant has left. Rules go into every vibemate's brief as instructions, not as routing.")}</span><div id="rp-rules" class="rules-editor" contenteditable="true" spellcheck="true" data-placeholder="e.g. Everyone listens to @Pesho, he is the manager. Keep answers under 3 sentences."></div><span class="hint">One rule per line; type @ to reference a participant.</span><div class="mention-menu inline" id="rp-rules-menu" hidden></div></div>
1976
2036
  ${field("Language", `<input type="text" id="rp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
1977
2037
  </div>
1978
2038
  <div class="section">
@@ -1982,6 +2042,11 @@
1982
2042
  <label class="switch"><span class="label">Vibemates wake each other<span class="hint">A reply without @ wakes every other vibemate, as yours does; each may answer or stay silent. Off: only @Name wakes a vibemate. The hop limit applies either way.</span></span><input type="checkbox" id="rp-wake" ${rs.agentsWakeEachOther !== false ? "checked" : ""}></label>
1983
2043
  <label class="switch"><span class="label">Wait while you are typing<span class="hint">A vibemate about to start holds back while you type (a few seconds after your last keystroke). A reply already under way is not interrupted.</span></span><input type="checkbox" id="rp-wait-typing" ${rs.waitWhileHumanTypes !== false ? "checked" : ""}></label>
1984
2044
  </div>
2045
+ <div class="section template">
2046
+ ${sectionTitle("rooms", "Turn this room into a template")}
2047
+ <p class="field-note">Its settings, rules, folder and vibemates (with the coding agent each runs on) become one of your templates, listed first under "Start from a template". You see everything it will contain, and can change any of it, before you create it.</p>
2048
+ <div class="row-btns start stp-row"><button type="button" class="btn sm primary" id="rp-template">${ic("rooms")}Preview and create template</button></div>
2049
+ </div>
1985
2050
  ${geek(
1986
2051
  "rp-geek",
1987
2052
  `<div class="section">
@@ -2009,6 +2074,7 @@
2009
2074
  <label class="switch"><span class="label">Repeat core rules in every header</span><input type="checkbox" id="rp-header-rules" ${rs.headerRules ? "checked" : ""}></label>
2010
2075
  <label class="switch"><span class="label">Show vendor and model to other vibemates</span><input type="checkbox" id="rp-vendor" ${rs.showVendorInRoster ? "checked" : ""}></label>
2011
2076
  ${field("Replay last N chat messages after a reconnect", `<input type="number" id="rp-replay" min="0" max="200" value="${rs.replayAfterRestart}">`)}
2077
+ ${field("Missed messages a vibemate reads at most on its next turn", `<input type="number" id="rp-backlog" min="1" max="1000" value="${rs.backlogCap}">`, "Everything posted since its last turn counts, including while it was muted; older messages are dropped with a note in its prompt.")}
2012
2078
  </div>`,
2013
2079
  "tools, hops, referee, briefs",
2014
2080
  )}
@@ -2032,6 +2098,7 @@
2032
2098
  $("#rp-dir").value = dir;
2033
2099
  $("#rp-dir").dispatchEvent(new Event("change", { bubbles: true }));
2034
2100
  }));
2101
+ $("#rp-template").addEventListener("click", () => openSaveTemplateDialog(room));
2035
2102
  bindSave($("#rp-form"), $("#rp-save"), async () => {
2036
2103
  const name = $("#rp-name").value;
2037
2104
  if (name.trim() !== room.name) await post(roomApi("/rename"), { name });
@@ -2050,6 +2117,7 @@
2050
2117
  headerRules: $("#rp-header-rules").checked,
2051
2118
  showVendorInRoster: $("#rp-vendor").checked,
2052
2119
  replayAfterRestart: Number($("#rp-replay").value),
2120
+ backlogCap: Number($("#rp-backlog").value),
2053
2121
  refereeAction: $("#rp-referee").value,
2054
2122
  turnTaking: $("#rp-turns").value,
2055
2123
  waitWhileHumanTypes: $("#rp-wait-typing").checked,
@@ -2105,6 +2173,13 @@
2105
2173
  ${field("Turn taking in new rooms", `<select id="sp-turns"><option value="one-at-a-time"${d.turnTaking !== "parallel" ? " selected" : ""}>One vibemate at a time</option><option value="parallel"${d.turnTaking === "parallel" ? " selected" : ""}>All addressed vibemates at once</option></select>`)}
2106
2174
  ${field("Reply delay in new rooms, seconds", `<input type="number" id="sp-delay" min="0" max="120" step="0.5" value="${d.replyDelay ?? 4}">`, "Used when two or more vibemates share a room; each room can change it; a vibemate can override it in its own panel.", "Before each turn a vibemate waits a random 0–N seconds, so replies cross less often. Messages that arrive meanwhile land in its backlog. A vibemate alone answers at once unless it has its own delay.")}
2107
2175
  </div>
2176
+ <div class="section" id="sp-appearance">
2177
+ ${sectionTitle("eye", "Appearance")}
2178
+ ${field("Text size, px", `<input type="number" id="sp-chat-fs" min="12" max="24" step="0.5" value="${esc(String((s.appearance || {}).chatFontSize || 14.5))}">`, "The size of the chat text; 14.5 is the default. Everything else in the window scales with it.")}
2179
+ ${field("Font", `<select id="sp-font">${Object.entries(FONTS.text).map(([id, f]) => `<option value="${id}"${((s.appearance || {}).font || "nunito") === id ? " selected" : ""}>${esc(f.label)}</option>`).join("")}</select>`, "Nunito, Inter and Noto Sans come with viberoom and look the same on every OS; the system entries use what this machine has.")}
2180
+ ${field("Code font", `<select id="sp-mono">${Object.entries(FONTS.mono).map(([id, f]) => `<option value="${id}"${((s.appearance || {}).mono || "jetbrains-mono") === id ? " selected" : ""}>${esc(f.label)}</option>`).join("")}</select>`, "For code blocks, paths and tool output.")}
2181
+ <div class="bubble" id="sp-chat-sample" style="display:inline-block;font-size:${((s.appearance || {}).chatFontSize || 14.5) / zoomFactor()}px">Messages will read like this, with <code>code</code> a step smaller.</div>
2182
+ </div>
2108
2183
  <div class="section" id="sp-editor">
2109
2184
  ${sectionTitle("pencil", "Open files at a line")}
2110
2185
  <label class="field"><span class="label">A click on a path like main.ts:375 opens the file in${geekTip("Only an editor can jump to a line; the OS default app just opens the file. Auto looks for VS Code, Cursor, Windsurf, Zed, Sublime Text, Notepad++ and the JetBrains IDEs, in that order, on PATH and in their usual folders. Custom: a command with {file}, {line} and {column} placeholders, e.g. code --goto {file}:{line}.")}</span>
@@ -2206,6 +2281,13 @@
2206
2281
  });
2207
2282
  $("#sp-diagram-color").addEventListener("input", redrawPreview);
2208
2283
  renderDiagrams(diagramSection, previewTheme());
2284
+ const sample = $("#sp-chat-sample");
2285
+ $("#sp-chat-fs").addEventListener("input", () => {
2286
+ const px = Number($("#sp-chat-fs").value);
2287
+ if (px >= 12 && px <= 24) sample.style.fontSize = `${px / zoomFactor()}px`;
2288
+ });
2289
+ $("#sp-font").addEventListener("change", () => (sample.style.fontFamily = FONTS.text[$("#sp-font").value].stack));
2290
+ $("#sp-mono").addEventListener("change", () => sample.querySelectorAll("code").forEach((c) => (c.style.fontFamily = FONTS.mono[$("#sp-mono").value].stack)));
2209
2291
  bindSave($("#sp-form"), $("#sp-save"), async () => {
2210
2292
  const vendorPresets = {};
2211
2293
  els.pageInner.querySelectorAll("input[data-vendor]").forEach((inp) => {
@@ -2217,6 +2299,7 @@
2217
2299
  agentSkillsNeedApproval: $("#sp-skill-approval").checked,
2218
2300
  diagrams: { preset: $("#sp-diagram-preset").value, primary: $("#sp-diagram-custom").checked ? $("#sp-diagram-color").value : null },
2219
2301
  editor: { mode: $("#sp-editor-mode").value, command: $("#sp-editor-cmd").value },
2302
+ appearance: { chatFontSize: Number($("#sp-chat-fs").value), font: $("#sp-font").value, mono: $("#sp-mono").value },
2220
2303
  roomDefaults: {
2221
2304
  turnTaking: $("#sp-turns").value,
2222
2305
  replyDelay: Number($("#sp-delay").value),
@@ -2777,7 +2860,7 @@
2777
2860
  const faces = t.vibemates.slice(0, 4).map((v) => avatar({ name: v.name, avatar: v.avatar, color: "#9ca3af" }, 20, {})).join("");
2778
2861
  return `<button type="button" class="tpl-item${on ? " on" : ""}" data-id="${esc(t.id)}" role="radio" aria-checked="${on ? "true" : "false"}">
2779
2862
  <span class="tpl-emoji">${esc(t.emoji || "🧩")}</span>
2780
- <span class="tpl-body"><b>${esc(t.name)}${t.recommended ? '<span class="badge tpl-rec">recommended</span>' : ""}</b><span class="tpl-meta">${t.vibemates.length} vibemate${t.vibemates.length === 1 ? "" : "s"}${t.builtin ? " · built in" : " · yours"}</span><span class="avatar-stack">${faces}</span></span>
2863
+ <span class="tpl-body"><b>${esc(t.name)}${t.builtin ? "" : '<span class="badge tpl-mine">your template</span>'}${t.recommended ? '<span class="badge tpl-rec">recommended</span>' : ""}</b><span class="tpl-meta">${t.vibemates.length} vibemate${t.vibemates.length === 1 ? "" : "s"}${t.builtin ? " · built in" : ""}</span><span class="avatar-stack">${faces}</span></span>
2781
2864
  <span class="tpl-check">${ic("check")}</span>
2782
2865
  </button>`;
2783
2866
  })
@@ -2803,10 +2886,14 @@
2803
2886
  return `<div class="tpl-vm" data-i="${i}">
2804
2887
  <div class="tpl-vm-head"><b>${esc(v.name)}</b>${v.tagline ? `<span class="hint">"${esc(v.tagline)}"</span>` : ""}</div>
2805
2888
  ${v.role ? `<div class="tpl-vm-role">${esc(v.role)}</div>` : ""}
2889
+ ${runsOn(v)}
2806
2890
  </div>`;
2807
2891
  })
2808
2892
  .join("")}`;
2809
- tplEls.detail.insertAdjacentHTML("beforeend", '<p class="hint">You pick the coding agent for each vibemate in the room, right after it opens.</p>');
2893
+ if (t.dir) tplEls.detail.insertAdjacentHTML("beforeend", `<p class="hint">Folder: <code>${esc(t.dir)}</code> (you can change it below)</p>`);
2894
+ tplEls.detail.insertAdjacentHTML("beforeend", t.vibemates.some((v) => v.agentType)
2895
+ ? '<p class="hint">A vibemate whose coding agent is not installed here waits in the roster until you pick another.</p>'
2896
+ : '<p class="hint">You pick the coding agent for each vibemate in the room, right after it opens.</p>');
2810
2897
  }
2811
2898
  $("#tpl-dir-browse").addEventListener("click", () => openFolderPicker(tplEls.dir.value, (dir) => (tplEls.dir.value = dir)));
2812
2899
  tplEls.form.addEventListener("submit", async (event) => {
@@ -2833,6 +2920,142 @@
2833
2920
  }
2834
2921
  });
2835
2922
 
2923
+ function runsOn(v) {
2924
+ if (!v.agentType) return "";
2925
+ const rec = state.recipes.find((r) => r.id === v.agentType);
2926
+ const parts = [rec ? rec.vendor : v.agentType, v.model, v.effort, v.mode].filter(Boolean);
2927
+ return `<div class="chips tpl-runs">${parts.map((x) => `<span class="chip">${esc(x)}</span>`).join("")}${rec && rec.unavailableReason ? '<span class="badge status-offline">not installed here</span>' : ""}</div>`;
2928
+ }
2929
+
2930
+ const stpEls = { dialog: $("#save-template-dialog"), form: $("#save-template-form"), name: $("#stp-name"), desc: $("#stp-desc"), preview: $("#stp-preview"), error: $("#stp-error"), create: $("#stp-create") };
2931
+ const stp = { room: null, template: null };
2932
+ async function openSaveTemplateDialog(room) {
2933
+ stp.room = room;
2934
+ stpEls.error.hidden = true;
2935
+ stpEls.name.value = room.name;
2936
+ stpEls.desc.value = "";
2937
+ stpEls.preview.innerHTML = '<span class="hint">loading…</span>';
2938
+ openDialog(stpEls.dialog);
2939
+ try {
2940
+ const t = (await post(roomApi("/template-preview"), {})).template;
2941
+ stp.template = t;
2942
+ stpEls.preview.innerHTML = renderTemplateForm(t);
2943
+ stpEls.preview.querySelectorAll("[data-stp-browse]").forEach((b) => b.addEventListener("click", () => {
2944
+ const input = $("#stp-dir");
2945
+ openFolderPicker(input.value, (dir) => (input.value = dir));
2946
+ }));
2947
+ stpEls.preview.querySelectorAll("[data-stp-remove]").forEach((b) => b.addEventListener("click", () => {
2948
+ b.closest(".stp-vm").remove();
2949
+ $("#stp-vm-count").textContent = String(stpEls.preview.querySelectorAll(".stp-vm").length);
2950
+ }));
2951
+ } catch (error) {
2952
+ stpEls.error.textContent = error.message;
2953
+ stpEls.error.hidden = false;
2954
+ }
2955
+ }
2956
+ const stpField = (label, html, wide) => `<label class="field${wide ? " wide" : ""}"><span class="label">${label}</span>${html}</label>`;
2957
+ const stpSwitch = (label, id, on) => `<label class="switch"><span class="label">${label}</span><input type="checkbox" id="${id}"${on ? " checked" : ""}></label>`;
2958
+ const stpSelect = (id, value, options) => `<select id="${id}">${options.map(([v, l]) => `<option value="${esc(v)}"${String(value) === v ? " selected" : ""}>${esc(l)}</option>`).join("")}</select>`;
2959
+ function renderTemplateForm(t) {
2960
+ const st = t.settings || {};
2961
+ const lang = st.language && st.language.mode === "fixed" ? st.language.language : "";
2962
+ const room = `<div class="stp-grid">
2963
+ ${stpField("Emoji", `<input type="text" id="stp-emoji" maxlength="8" value="${esc(t.emoji || "")}" placeholder="none">`)}
2964
+ ${stpField("Topic", `<input type="text" id="stp-topic" maxlength="2000" value="${esc(st.topic || "")}" placeholder="what the room is about">`)}
2965
+ ${stpField("Language", `<input type="text" id="stp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
2966
+ ${stpField("Vibemates' own tools", stpSelect("stp-tools", st.tools || "on-request", [["on-request", "Only when someone explicitly asks"], ["never", "Never (chat only)"]]))}
2967
+ ${stpField("Who may speak", stpSelect("stp-turns", st.turnTaking || "parallel", [["parallel", "All addressed vibemates at once"], ["one-at-a-time", "One vibemate at a time"]]))}
2968
+ ${stpField("Reply delay, seconds", `<input type="number" id="stp-delay" min="0" max="120" step="0.5" value="${esc(String(st.replyDelay ?? 4))}">`)}
2969
+ ${stpSwitch("Vibemates wake each other", "stp-wake", st.agentsWakeEachOther !== false)}
2970
+ ${stpSwitch("Wait while you are typing", "stp-wait", st.waitWhileHumanTypes !== false)}
2971
+ ${stpField("Hop limit", `<input type="number" id="stp-hops" min="0" max="10000" value="${esc(String(st.hopLimit ?? 100))}">`)}
2972
+ ${stpField("Max sentences per reply", `<input type="number" id="stp-maxlen" min="1" max="100" value="${st.maxSentences ?? ""}" placeholder="no limit">`)}
2973
+ ${stpField("Referee", stpSelect("stp-referee", st.refereeAction || "next-header", [["next-header", "Post it; remind in the next header"], ["retry-hidden", "Hold it; retry in a hidden turn"]]))}
2974
+ ${stpField("About you", stpSelect("stp-about", st.humanDescriptionMode || "inherit", [["inherit", "Program-wide description"], ["append", "Program-wide + this room's"], ["override", "Only this room's"], ["none", "Nothing about me"]]))}
2975
+ ${stpField("Full brief every N turns", `<input type="number" id="stp-brief-turns" min="1" max="10000" value="${esc(String(st.fullBriefEveryTurns ?? 8))}">`)}
2976
+ ${stpField("…or every N tokens", `<input type="number" id="stp-brief-tokens" min="1000" max="10000000" step="1000" value="${esc(String(st.fullBriefEveryTokens ?? 20000))}">`)}
2977
+ ${stpField("Replay after a reconnect", `<input type="number" id="stp-replay" min="0" max="200" value="${esc(String(st.replayAfterRestart ?? 10))}">`)}
2978
+ ${stpField("Missed messages read at most", `<input type="number" id="stp-backlog" min="1" max="1000" value="${esc(String(st.backlogCap ?? 50))}">`)}
2979
+ ${stpSwitch("Core rules in every header", "stp-header-rules", st.headerRules !== false)}
2980
+ ${stpSwitch("Show vendor and model to other vibemates", "stp-vendor", !!st.showVendorInRoster)}
2981
+ </div>`;
2982
+ const agentOptions = [["", "none yet: cast when the room opens"], ...state.recipes.map((r) => [r.id, r.vendor + (r.unavailableReason ? " (not installed here)" : "")])];
2983
+ const vms = (t.vibemates || []).map((v, i) => `<div class="stp-vm" data-i="${i}">${avatar({ name: v.name, avatar: v.avatar, color: "#9ca3af" }, 36, {})}<div>
2984
+ <div class="stp-vm-top"><b>${esc(v.name)}</b><button type="button" class="icon-btn sm ghost" title="Leave this vibemate out of the template" data-stp-remove>${ic("close")}</button></div>
2985
+ <div class="stp-vm-fields">
2986
+ ${stpField("Vibename", `<input type="text" data-k="name" maxlength="40" value="${esc(v.name)}" required>`)}
2987
+ ${stpField("Vibersona", `<input type="text" data-k="tagline" maxlength="80" value="${esc(v.tagline || "")}">`)}
2988
+ ${stpField("Vibio", `<textarea data-k="role" rows="3" maxlength="4000">${esc(v.role || "")}</textarea>`, true)}
2989
+ ${stpField("Vibeface", `<input type="text" data-k="avatar" maxlength="8" value="${esc(v.avatar || "")}" placeholder="initials">`)}
2990
+ ${stpField("Coding agent", stpSelect("", v.agentType || "", agentOptions).replace('id=""', 'data-k="agentType"'))}
2991
+ ${stpField("Model", `<input type="text" data-k="model" value="${esc(v.model || "")}" placeholder="the agent's default">`)}
2992
+ ${stpField("Effort", `<input type="text" data-k="effort" value="${esc(v.effort || "")}" placeholder="default">`)}
2993
+ ${stpField("Mode", `<input type="text" data-k="mode" value="${esc(v.mode || "")}" placeholder="default">`)}
2994
+ ${stpField("Reply delay override, s", `<input type="number" data-k="replyDelay" min="0" max="120" step="0.5" value="${v.replyDelay ?? ""}" placeholder="the room's">`)}
2995
+ ${stpField("Skills, comma-separated", `<input type="text" data-k="skills" value="${esc((v.skills || []).join(", "))}">`, true)}
2996
+ </div>
2997
+ </div></div>`).join("");
2998
+ return `
2999
+ <div class="stp-section"><h5>Room</h5>${room}</div>
3000
+ <div class="stp-section"><h5>Room rules</h5><textarea id="stp-rules" rows="5" maxlength="4000" placeholder="one rule per line">${esc(st.customRules || "")}</textarea></div>
3001
+ <div class="stp-section"><h5>Folder</h5><span class="dir-row"><input type="text" id="stp-dir" maxlength="1000" value="${esc(t.dir || "")}" spellcheck="false"><button type="button" class="btn ghost browse-btn" data-stp-browse>${ic("folder")}Browse</button></span></div>
3002
+ <div class="stp-section"><h5>Vibemates · <span id="stp-vm-count">${(t.vibemates || []).length}</span></h5>${vms || '<span class="hint">none</span>'}</div>`;
3003
+ }
3004
+ function readTemplateForm() {
3005
+ const num = (id) => Number($(id).value);
3006
+ const langText = $("#stp-lang").value.trim();
3007
+ const settings = {
3008
+ topic: $("#stp-topic").value,
3009
+ language: langText ? { mode: "fixed", language: langText } : { mode: "follow-human" },
3010
+ tools: $("#stp-tools").value,
3011
+ turnTaking: $("#stp-turns").value,
3012
+ replyDelay: num("#stp-delay"),
3013
+ agentsWakeEachOther: $("#stp-wake").checked,
3014
+ waitWhileHumanTypes: $("#stp-wait").checked,
3015
+ hopLimit: num("#stp-hops"),
3016
+ maxSentences: $("#stp-maxlen").value === "" ? null : num("#stp-maxlen"),
3017
+ refereeAction: $("#stp-referee").value,
3018
+ humanDescriptionMode: $("#stp-about").value,
3019
+ fullBriefEveryTurns: num("#stp-brief-turns"),
3020
+ fullBriefEveryTokens: num("#stp-brief-tokens"),
3021
+ replayAfterRestart: num("#stp-replay"),
3022
+ backlogCap: num("#stp-backlog"),
3023
+ headerRules: $("#stp-header-rules").checked,
3024
+ showVendorInRoster: $("#stp-vendor").checked,
3025
+ customRules: $("#stp-rules").value.slice(0, 4000),
3026
+ };
3027
+ const vibemates = [...stpEls.preview.querySelectorAll(".stp-vm")].map((row) => {
3028
+ const v = {};
3029
+ row.querySelectorAll("[data-k]").forEach((el) => {
3030
+ const k = el.dataset.k;
3031
+ const val = el.value.trim();
3032
+ if (k === "skills") v.skills = val.split(",").map((x) => x.trim()).filter(Boolean);
3033
+ else if (k === "replyDelay") { if (val !== "") v.replyDelay = Number(val); }
3034
+ else if (val) v[k] = val;
3035
+ });
3036
+ return v;
3037
+ });
3038
+ return { emoji: $("#stp-emoji").value.trim(), dir: $("#stp-dir").value.trim(), settings, vibemates };
3039
+ }
3040
+ stpEls.form.addEventListener("submit", async (event) => {
3041
+ event.preventDefault();
3042
+ if (!stp.room) return;
3043
+ stpEls.create.disabled = true;
3044
+ stpEls.create.textContent = "Creating…";
3045
+ try {
3046
+ const edited = readTemplateForm();
3047
+ const res = await post(`/api/rooms/${encodeURIComponent(stp.room.id)}/save-template`, { name: stpEls.name.value, description: stpEls.desc.value, emoji: edited.emoji, template: { dir: edited.dir, settings: edited.settings, vibemates: edited.vibemates } });
3048
+ closeDialog(stpEls.dialog);
3049
+ toast(`Template "${res.template.name}" created. It is first under "Start from a template".`, "success");
3050
+ } catch (error) {
3051
+ stpEls.error.textContent = error.message;
3052
+ stpEls.error.hidden = false;
3053
+ } finally {
3054
+ stpEls.create.disabled = false;
3055
+ stpEls.create.textContent = "Create the template";
3056
+ }
3057
+ });
3058
+
2836
3059
  function openRoomDialog() {
2837
3060
  els.roomError.hidden = true;
2838
3061
  els.roomName.value = "";
@@ -2928,8 +3151,18 @@
2928
3151
  }
2929
3152
 
2930
3153
 
3154
+ function applyAppearance() {
3155
+ const a = (state.settings || {}).appearance || {};
3156
+ const root = document.documentElement;
3157
+ root.style.zoom = String((a.chatFontSize || 14.5) / 14.5);
3158
+ root.style.setProperty("--font", (FONTS.text[a.font] || FONTS.text.nunito).stack);
3159
+ root.style.setProperty("--mono", (FONTS.mono[a.mono] || FONTS.mono["jetbrains-mono"]).stack);
3160
+ }
3161
+ const zoomFactor = () => Number(document.documentElement.style.zoom) || 1;
3162
+
2931
3163
  function loadSnapshot(snapshot) {
2932
3164
  state.settings = snapshot.settings;
3165
+ applyAppearance();
2933
3166
  state.version = snapshot.version || null;
2934
3167
  state.skills = snapshot.skills || [];
2935
3168
  state.recipes = snapshot.recipes || [];
@@ -3106,6 +3339,7 @@
3106
3339
  });
3107
3340
  es.addEventListener("settings", (e) => {
3108
3341
  state.settings = JSON.parse(e.data).settings;
3342
+ applyAppearance();
3109
3343
  rerenderDiagrams();
3110
3344
  renderRail();
3111
3345
  if (state.view === "settings" && !editingInDetails()) renderSettingsPage();
@@ -3153,14 +3387,14 @@
3153
3387
  let drag = null;
3154
3388
  grip.addEventListener("pointerdown", (e) => {
3155
3389
  if (e.button !== 0) return;
3156
- drag = { y: e.clientY, h: els.input.offsetHeight };
3390
+ drag = { y: e.clientY / zoomFactor(), h: els.input.offsetHeight };
3157
3391
  grip.setPointerCapture(e.pointerId);
3158
3392
  els.composer.classList.add("resizing");
3159
3393
  e.preventDefault();
3160
3394
  });
3161
3395
  grip.addEventListener("pointermove", (e) => {
3162
3396
  if (!drag) return;
3163
- composerMin = Math.round(Math.min(composerCeiling(), Math.max(36, drag.h + drag.y - e.clientY)));
3397
+ composerMin = Math.round(Math.min(composerCeiling(), Math.max(36, drag.h + drag.y - e.clientY / zoomFactor())));
3164
3398
  autosize();
3165
3399
  });
3166
3400
  const stop = () => {
Binary file
@@ -0,0 +1,5 @@
1
+ /* viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE */
2
+ @font-face { font-family: "Fira Code"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("/fonts/fira-code-cyrillic-ext.woff2") format("woff2"); unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; }
3
+ @font-face { font-family: "Fira Code"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("/fonts/fira-code-cyrillic.woff2") format("woff2"); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
4
+ @font-face { font-family: "Fira Code"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("/fonts/fira-code-latin-ext.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
5
+ @font-face { font-family: "Fira Code"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("/fonts/fira-code-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
Binary file
Binary file
Binary file
@@ -0,0 +1,5 @@
1
+ /* viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE */
2
+ @font-face { font-family: "Inter"; font-style: normal; font-weight: 500 800; font-display: swap; src: url("/fonts/inter-cyrillic-ext.woff2") format("woff2"); unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; }
3
+ @font-face { font-family: "Inter"; font-style: normal; font-weight: 500 800; font-display: swap; src: url("/fonts/inter-cyrillic.woff2") format("woff2"); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
4
+ @font-face { font-family: "Inter"; font-style: normal; font-weight: 500 800; font-display: swap; src: url("/fonts/inter-latin-ext.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
5
+ @font-face { font-family: "Inter"; font-style: normal; font-weight: 500 800; font-display: swap; src: url("/fonts/inter-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@@ -0,0 +1,5 @@
1
+ /* viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE */
2
+ @font-face { font-family: "JetBrains Mono"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("/fonts/jetbrains-mono-cyrillic-ext.woff2") format("woff2"); unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; }
3
+ @font-face { font-family: "JetBrains Mono"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("/fonts/jetbrains-mono-cyrillic.woff2") format("woff2"); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
4
+ @font-face { font-family: "JetBrains Mono"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("/fonts/jetbrains-mono-latin-ext.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
5
+ @font-face { font-family: "JetBrains Mono"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("/fonts/jetbrains-mono-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
Binary file
@@ -0,0 +1,5 @@
1
+ /* viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE */
2
+ @font-face { font-family: "Noto Sans"; font-style: normal; font-weight: 500 800; font-display: swap; src: url("/fonts/noto-sans-cyrillic-ext.woff2") format("woff2"); unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; }
3
+ @font-face { font-family: "Noto Sans"; font-style: normal; font-weight: 500 800; font-display: swap; src: url("/fonts/noto-sans-cyrillic.woff2") format("woff2"); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
4
+ @font-face { font-family: "Noto Sans"; font-style: normal; font-weight: 500 800; font-display: swap; src: url("/fonts/noto-sans-latin-ext.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
5
+ @font-face { font-family: "Noto Sans"; font-style: normal; font-weight: 500 800; font-display: swap; src: url("/fonts/noto-sans-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@@ -0,0 +1,5 @@
1
+ /* viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE */
2
+ @font-face { font-family: "Source Code Pro"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("/fonts/source-code-pro-cyrillic-ext.woff2") format("woff2"); unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; }
3
+ @font-face { font-family: "Source Code Pro"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("/fonts/source-code-pro-cyrillic.woff2") format("woff2"); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; }
4
+ @font-face { font-family: "Source Code Pro"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("/fonts/source-code-pro-latin-ext.woff2") format("woff2"); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
5
+ @font-face { font-family: "Source Code Pro"; font-style: normal; font-weight: 400 700; font-display: swap; src: url("/fonts/source-code-pro-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
package/ui/index.html CHANGED
@@ -164,6 +164,17 @@
164
164
  <div class="actions"><button type="button" class="btn ghost" data-close>Cancel</button><button type="submit" class="btn primary" id="tpl-create">Create the room</button></div>
165
165
  </form>
166
166
  </dialog>
167
+ <dialog id="save-template-dialog" class="dialog wide">
168
+ <form id="save-template-form" method="dialog">
169
+ <h3><span class="h-ico" data-icon="rooms"></span>Turn this room into a template</h3>
170
+ <p class="lead">Everything below goes into the template: the room's settings and rules, its folder, and each vibemate with the coding agent it runs on. Check it, name it, create it.</p>
171
+ <label>Template name<input id="stp-name" type="text" maxlength="60" required placeholder="e.g. Payments refactor"></label>
172
+ <label>Description <span class="hint">(optional)</span><textarea id="stp-desc" rows="2" maxlength="400" placeholder="what this setup is good for"></textarea></label>
173
+ <div class="stp-preview" id="stp-preview"></div>
174
+ <p class="error" id="stp-error" hidden></p>
175
+ <div class="actions"><button type="button" class="btn ghost" data-close>Cancel</button><button type="submit" class="btn primary" id="stp-create">Create the template</button></div>
176
+ </form>
177
+ </dialog>
167
178
  <dialog id="invite-dialog" class="dialog wide">
168
179
  <form id="invite-form" method="dialog">
169
180
  <h3><span class="h-ico" data-icon="spark"></span>Summon a Vibemate</h3>