viberoom 0.5.2 → 0.5.4

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 (40) hide show
  1. package/NOTICE +8 -2
  2. package/README.md +1 -1
  3. package/dist/hub.js +55 -2
  4. package/dist/main.js +10 -0
  5. package/dist/room.js +43 -3
  6. package/dist/server.js +41 -1
  7. package/dist/templates.js +27 -3
  8. package/dist/update.js +95 -0
  9. package/package.json +1 -1
  10. package/templates/wren-and-quinn/template.json +28 -0
  11. package/ui/app.css +61 -19
  12. package/ui/app.js +306 -14
  13. package/ui/fonts/fira-code-cyrillic-ext.woff2 +0 -0
  14. package/ui/fonts/fira-code-cyrillic.woff2 +0 -0
  15. package/ui/fonts/fira-code-latin-ext.woff2 +0 -0
  16. package/ui/fonts/fira-code-latin.woff2 +0 -0
  17. package/ui/fonts/fira-code.css +5 -0
  18. package/ui/fonts/inter-cyrillic-ext.woff2 +0 -0
  19. package/ui/fonts/inter-cyrillic.woff2 +0 -0
  20. package/ui/fonts/inter-latin-ext.woff2 +0 -0
  21. package/ui/fonts/inter-latin.woff2 +0 -0
  22. package/ui/fonts/inter.css +5 -0
  23. package/ui/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
  24. package/ui/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
  25. package/ui/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  26. package/ui/fonts/jetbrains-mono-latin.woff2 +0 -0
  27. package/ui/fonts/jetbrains-mono.css +5 -0
  28. package/ui/fonts/noto-sans-cyrillic-ext.woff2 +0 -0
  29. package/ui/fonts/noto-sans-cyrillic.woff2 +0 -0
  30. package/ui/fonts/noto-sans-latin-ext.woff2 +0 -0
  31. package/ui/fonts/noto-sans-latin.woff2 +0 -0
  32. package/ui/fonts/noto-sans.css +5 -0
  33. package/ui/fonts/source-code-pro-cyrillic-ext.woff2 +0 -0
  34. package/ui/fonts/source-code-pro-cyrillic.woff2 +0 -0
  35. package/ui/fonts/source-code-pro-latin-ext.woff2 +0 -0
  36. package/ui/fonts/source-code-pro-latin.woff2 +0 -0
  37. package/ui/fonts/source-code-pro.css +5 -0
  38. package/ui/index.html +11 -0
  39. package/ui/theme.css +44 -18
  40. 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));
@@ -791,6 +823,51 @@
791
823
  maybeOfferReconnect();
792
824
  }
793
825
 
826
+ function renderUpdatePop() {
827
+ const old = $("#update-pop");
828
+ const u = state.update;
829
+ const show = u && u.available && u.latest && recall("updateDismissed") !== u.latest;
830
+ if (!show) {
831
+ if (old && !old.dataset.busy) old.remove();
832
+ return;
833
+ }
834
+ if (old && old.dataset.version === u.latest) return;
835
+ if (old) old.remove();
836
+ const pop = document.createElement("div");
837
+ pop.id = "update-pop";
838
+ pop.className = "update-pop";
839
+ pop.dataset.version = u.latest;
840
+ pop.innerHTML = `<div class="up-main"><div class="up-text"><b>viberoom ${esc(u.latest)}</b> is out. You have ${esc(u.current)}.</div><button type="button" class="btn sm primary up-go">Update now and restart</button></div>
841
+ <div class="up-side"><button type="button" class="icon-btn sm up-x" title="Not now">${ic("close")}</button><button type="button" class="icon-btn sm up-settings" title="Update settings">${ic("settings")}</button></div>`;
842
+ pop.querySelector(".up-x").addEventListener("click", () => {
843
+ remember("updateDismissed", u.latest);
844
+ pop.remove();
845
+ });
846
+ pop.querySelector(".up-settings").addEventListener("click", () => setView("settings"));
847
+ pop.querySelector(".up-go").addEventListener("click", () => installUpdate(pop, u.latest));
848
+ els.rail.querySelector(".rail-foot").appendChild(pop);
849
+ }
850
+ async function installUpdate(pop, version) {
851
+ const go = pop.querySelector(".up-go");
852
+ const text = pop.querySelector(".up-text");
853
+ pop.dataset.busy = "1";
854
+ go.disabled = true;
855
+ go.classList.add("loading");
856
+ text.innerHTML = `Installing <b>viberoom ${esc(version)}</b>… this takes a moment.`;
857
+ try {
858
+ await post("/api/update/install", {});
859
+ go.classList.remove("loading");
860
+ text.innerHTML = `<b>viberoom ${esc(version)}</b> is installed. Restarting…`;
861
+ go.hidden = true;
862
+ } catch (e) {
863
+ delete pop.dataset.busy;
864
+ go.classList.remove("loading");
865
+ go.disabled = false;
866
+ go.textContent = "Try again";
867
+ text.innerHTML = `<span class="error">${esc(e.message || String(e))}</span>`;
868
+ }
869
+ }
870
+
794
871
  function renderRail() {
795
872
  els.rail.querySelectorAll(".rail-item[data-nav]").forEach((b) => {
796
873
  const nav = b.dataset.nav;
@@ -1070,6 +1147,7 @@
1070
1147
  const selected = (state.selection.kind === "participant" && state.selection.id === p.id) || (p.kind === "human" && state.selection.kind === "me" && state.detailsOpen);
1071
1148
  const asleep = p.kind === "agent" && (p.status === "offline" || p.status === "left");
1072
1149
  const unstaffed = p.kind === "agent" && p.status === "unstaffed";
1150
+ const shown = shownStatus(room, p);
1073
1151
  const className = (p.kind === "human" ? "me" : "") + (selected ? " selected" : "") + (asleep ? " offline" : "") + (unstaffed ? " unstaffed" : "");
1074
1152
  const sub = p.kind === "human" ? "you, the human" : [p.tagline ? `"${p.tagline}"` : "", p.agentVendor || p.agentLabel, p.model].filter(Boolean).join(" · ");
1075
1153
  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 +1155,9 @@
1077
1155
  ? `<span class="badge status-unstaffed" title="Click to summon this vibemate: pick the coding agent that runs it">summon</span>`
1078
1156
  : asleep
1079
1157
  ? `<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>` : "";
1158
+ : p.kind === "agent" && p.status !== "idle" ? `<span class="badge status-${shown}">${p.status === "thinking" ? '<span class="dot"></span>' : ""}${STATUS_LABEL[shown] || shown}</span>` : "";
1081
1159
  const avatarHtml = avatar(p.kind === "human" ? meAvatarData() : p, 44, { vendor: true });
1082
- const statusClass = p.kind === "agent" ? `avatar-status status-${esc(p.status || "idle")}` : "";
1160
+ const statusClass = p.kind === "agent" ? `avatar-status status-${esc(shown || "idle")}` : "";
1083
1161
  const bodyHtml = `<div class="p-body">
1084
1162
  <div class="p-name"><span>${esc(p.name)}</span>${p.muted ? '<span class="badge muted">muted</span>' : ""}${status}</div>
1085
1163
  <div class="p-sub">${esc(sub)}</div>
@@ -1228,7 +1306,7 @@
1228
1306
  : `<div class="sys${warn ? " warn" : ""}" title="${esc(fullTime(m.ts))}">${esc(m.text)}</div>`;
1229
1307
  return el;
1230
1308
  }
1231
- const p = findById(room, m.from) || { name: m.fromName, color: "#9ca3af", kind: m.from === "human" ? "human" : "agent" };
1309
+ const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR, kind: m.from === "human" ? "human" : "agent" };
1232
1310
  const mine = m.from === "human";
1233
1311
  el.className = "msg " + (mine ? "mine" : "agent");
1234
1312
  el.innerHTML = `
@@ -1377,9 +1455,9 @@
1377
1455
  const more = el.querySelector(".more");
1378
1456
  const long = !m.streaming && m.text.length > CLAMP_CHARS;
1379
1457
  const expanded = state.expanded.has(m.id);
1380
- text.innerHTML = renderText(room, m.text, m.images) + (m.streaming ? '<span class="caret"></span>' : "");
1458
+ text.innerHTML = renderText(room, m.text, m.images) + (m.streaming ? WORKING_SVG : "");
1381
1459
  if (!m.streaming) renderDiagrams(text);
1382
- if (m.streaming && !m.text) text.innerHTML = '<span class="pending">…</span>';
1460
+ if (m.streaming && !m.text) text.innerHTML = '<span class="pending" title="thinking…"><i></i><i></i><i></i></span>';
1383
1461
  text.classList.toggle("clamped", long && !expanded);
1384
1462
  more.hidden = !long;
1385
1463
  more.textContent = expanded ? "Show less" : "Show more";
@@ -1434,8 +1512,9 @@
1434
1512
  if (!present.length) return "";
1435
1513
  const seen = present.filter((p) => p.lastSeenSeq != null && p.lastSeenSeq >= m.seq);
1436
1514
  if (seen.length === present.length) return "";
1437
- if (!seen.length) return `<span class="ticks">✓</span> sent`;
1438
- return `<span class="ticks">✓✓</span> seen by ${esc(seen.map((p) => p.name).join(", "))}`;
1515
+ const you = m.from !== "human";
1516
+ if (!seen.length) return `<span class="ticks">✓</span> sent${you ? " · seen by you" : ""}`;
1517
+ return `<span class="ticks">✓✓</span> seen by ${esc([...(you ? ["you"] : []), ...seen.map((p) => p.name)].join(", "))}`;
1439
1518
  }
1440
1519
  function fillSeen(el, room, m) {
1441
1520
  if (m.kind !== "chat" || m.streaming) return;
@@ -1561,6 +1640,7 @@
1561
1640
  if (empty) empty.remove();
1562
1641
  els.messages.appendChild(messageElement(room, m));
1563
1642
  if (m.from === "human") refreshSeen(room);
1643
+ if (m.streaming && m.from !== "human") renderSideRoom();
1564
1644
  else if (!stick && m.kind === "chat") noteNew(room, m);
1565
1645
  }
1566
1646
  if (stick) scrollToBottom();
@@ -1997,7 +2077,7 @@
1997
2077
  ${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.")}
1998
2078
  ${field("Topic", `<input type="text" id="rp-topic" maxlength="2000" value="${esc(rs.topic || "")}" placeholder="what this room is about (optional)">`)}
1999
2079
  ${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.")}
2000
- <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>
2080
+ <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>
2001
2081
  ${field("Language", `<input type="text" id="rp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
2002
2082
  </div>
2003
2083
  <div class="section">
@@ -2007,6 +2087,11 @@
2007
2087
  <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>
2008
2088
  <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>
2009
2089
  </div>
2090
+ <div class="section template">
2091
+ ${sectionTitle("rooms", "Turn this room into a template")}
2092
+ <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>
2093
+ <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>
2094
+ </div>
2010
2095
  ${geek(
2011
2096
  "rp-geek",
2012
2097
  `<div class="section">
@@ -2034,6 +2119,7 @@
2034
2119
  <label class="switch"><span class="label">Repeat core rules in every header</span><input type="checkbox" id="rp-header-rules" ${rs.headerRules ? "checked" : ""}></label>
2035
2120
  <label class="switch"><span class="label">Show vendor and model to other vibemates</span><input type="checkbox" id="rp-vendor" ${rs.showVendorInRoster ? "checked" : ""}></label>
2036
2121
  ${field("Replay last N chat messages after a reconnect", `<input type="number" id="rp-replay" min="0" max="200" value="${rs.replayAfterRestart}">`)}
2122
+ ${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.")}
2037
2123
  </div>`,
2038
2124
  "tools, hops, referee, briefs",
2039
2125
  )}
@@ -2057,6 +2143,7 @@
2057
2143
  $("#rp-dir").value = dir;
2058
2144
  $("#rp-dir").dispatchEvent(new Event("change", { bubbles: true }));
2059
2145
  }));
2146
+ $("#rp-template").addEventListener("click", () => openSaveTemplateDialog(room));
2060
2147
  bindSave($("#rp-form"), $("#rp-save"), async () => {
2061
2148
  const name = $("#rp-name").value;
2062
2149
  if (name.trim() !== room.name) await post(roomApi("/rename"), { name });
@@ -2075,6 +2162,7 @@
2075
2162
  headerRules: $("#rp-header-rules").checked,
2076
2163
  showVendorInRoster: $("#rp-vendor").checked,
2077
2164
  replayAfterRestart: Number($("#rp-replay").value),
2165
+ backlogCap: Number($("#rp-backlog").value),
2078
2166
  refereeAction: $("#rp-referee").value,
2079
2167
  turnTaking: $("#rp-turns").value,
2080
2168
  waitWhileHumanTypes: $("#rp-wait-typing").checked,
@@ -2130,6 +2218,13 @@
2130
2218
  ${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>`)}
2131
2219
  ${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.")}
2132
2220
  </div>
2221
+ <div class="section" id="sp-appearance">
2222
+ ${sectionTitle("eye", "Appearance")}
2223
+ ${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.")}
2224
+ ${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.")}
2225
+ ${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.")}
2226
+ <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>
2227
+ </div>
2133
2228
  <div class="section" id="sp-editor">
2134
2229
  ${sectionTitle("pencil", "Open files at a line")}
2135
2230
  <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>
@@ -2154,6 +2249,12 @@
2154
2249
  </div>
2155
2250
  </div>
2156
2251
  <div>
2252
+ <div class="section" id="sp-update">
2253
+ ${sectionTitle("refresh", "Updates")}
2254
+ <label class="switch"><span class="label">Check for updates once a day<span class="hint">At start, one request to the npm registry for the latest viberoom version; nothing else leaves this machine. A newer version shows as a bubble over your avatar.</span></span><input type="checkbox" id="sp-updates" ${s.checkForUpdates !== false ? "checked" : ""}></label>
2255
+ <p class="hint" id="sp-update-status">${updateStatusText()}</p>
2256
+ <button type="button" class="btn sm" id="sp-update-check">Check now</button>
2257
+ </div>
2157
2258
  <div class="section">
2158
2259
  ${sectionTitle("spark", "Vibemates on this machine")}
2159
2260
  ${machine || '<p class="hint">No supported vibemate is installed yet.</p>'}
@@ -2231,6 +2332,27 @@
2231
2332
  });
2232
2333
  $("#sp-diagram-color").addEventListener("input", redrawPreview);
2233
2334
  renderDiagrams(diagramSection, previewTheme());
2335
+ const sample = $("#sp-chat-sample");
2336
+ $("#sp-chat-fs").addEventListener("input", () => {
2337
+ const px = Number($("#sp-chat-fs").value);
2338
+ if (px >= 12 && px <= 24) sample.style.fontSize = `${px / zoomFactor()}px`;
2339
+ });
2340
+ $("#sp-font").addEventListener("change", () => (sample.style.fontFamily = FONTS.text[$("#sp-font").value].stack));
2341
+ $("#sp-mono").addEventListener("change", () => sample.querySelectorAll("code").forEach((c) => (c.style.fontFamily = FONTS.mono[$("#sp-mono").value].stack)));
2342
+ $("#sp-update-check").addEventListener("click", async () => {
2343
+ const b = $("#sp-update-check");
2344
+ b.disabled = true;
2345
+ b.classList.add("loading");
2346
+ try {
2347
+ state.update = await get("/api/update?check=1");
2348
+ $("#sp-update-status").textContent = updateStatusText();
2349
+ renderUpdatePop();
2350
+ } catch (e) {
2351
+ showError(e);
2352
+ }
2353
+ b.disabled = false;
2354
+ b.classList.remove("loading");
2355
+ });
2234
2356
  bindSave($("#sp-form"), $("#sp-save"), async () => {
2235
2357
  const vendorPresets = {};
2236
2358
  els.pageInner.querySelectorAll("input[data-vendor]").forEach((inp) => {
@@ -2240,8 +2362,10 @@
2240
2362
  await post("/api/settings", {
2241
2363
  bypassPermissionsByDefault: $("#sp-bypass").checked,
2242
2364
  agentSkillsNeedApproval: $("#sp-skill-approval").checked,
2365
+ checkForUpdates: $("#sp-updates").checked,
2243
2366
  diagrams: { preset: $("#sp-diagram-preset").value, primary: $("#sp-diagram-custom").checked ? $("#sp-diagram-color").value : null },
2244
2367
  editor: { mode: $("#sp-editor-mode").value, command: $("#sp-editor-cmd").value },
2368
+ appearance: { chatFontSize: Number($("#sp-chat-fs").value), font: $("#sp-font").value, mono: $("#sp-mono").value },
2245
2369
  roomDefaults: {
2246
2370
  turnTaking: $("#sp-turns").value,
2247
2371
  replyDelay: Number($("#sp-delay").value),
@@ -2256,6 +2380,16 @@
2256
2380
  });
2257
2381
  }
2258
2382
 
2383
+ function updateStatusText() {
2384
+ const u = state.update;
2385
+ const v = state.version ? state.version.version : "?";
2386
+ if (!u || !u.checkedAt) return `This is viberoom ${v}; not checked yet.`;
2387
+ const when = new Date(u.checkedAt).toLocaleString();
2388
+ if (u.available) return `viberoom ${u.latest} is available (this is ${u.current}); checked ${when}.`;
2389
+ if (u.error) return `Could not reach the registry (${u.error}); checked ${when}.`;
2390
+ return `This is viberoom ${u.current}, the latest; checked ${when}.`;
2391
+ }
2392
+
2259
2393
  function skillBadges(sk) {
2260
2394
  const out = [];
2261
2395
  if (sk.userInvocable === false) out.push('<span class="badge">vibemate only</span>');
@@ -2802,7 +2936,7 @@
2802
2936
  const faces = t.vibemates.slice(0, 4).map((v) => avatar({ name: v.name, avatar: v.avatar, color: "#9ca3af" }, 20, {})).join("");
2803
2937
  return `<button type="button" class="tpl-item${on ? " on" : ""}" data-id="${esc(t.id)}" role="radio" aria-checked="${on ? "true" : "false"}">
2804
2938
  <span class="tpl-emoji">${esc(t.emoji || "🧩")}</span>
2805
- <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>
2939
+ <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>
2806
2940
  <span class="tpl-check">${ic("check")}</span>
2807
2941
  </button>`;
2808
2942
  })
@@ -2828,10 +2962,14 @@
2828
2962
  return `<div class="tpl-vm" data-i="${i}">
2829
2963
  <div class="tpl-vm-head"><b>${esc(v.name)}</b>${v.tagline ? `<span class="hint">"${esc(v.tagline)}"</span>` : ""}</div>
2830
2964
  ${v.role ? `<div class="tpl-vm-role">${esc(v.role)}</div>` : ""}
2965
+ ${runsOn(v)}
2831
2966
  </div>`;
2832
2967
  })
2833
2968
  .join("")}`;
2834
- tplEls.detail.insertAdjacentHTML("beforeend", '<p class="hint">You pick the coding agent for each vibemate in the room, right after it opens.</p>');
2969
+ if (t.dir) tplEls.detail.insertAdjacentHTML("beforeend", `<p class="hint">Folder: <code>${esc(t.dir)}</code> (you can change it below)</p>`);
2970
+ tplEls.detail.insertAdjacentHTML("beforeend", t.vibemates.some((v) => v.agentType)
2971
+ ? '<p class="hint">A vibemate whose coding agent is not installed here waits in the roster until you pick another.</p>'
2972
+ : '<p class="hint">You pick the coding agent for each vibemate in the room, right after it opens.</p>');
2835
2973
  }
2836
2974
  $("#tpl-dir-browse").addEventListener("click", () => openFolderPicker(tplEls.dir.value, (dir) => (tplEls.dir.value = dir)));
2837
2975
  tplEls.form.addEventListener("submit", async (event) => {
@@ -2858,6 +2996,142 @@
2858
2996
  }
2859
2997
  });
2860
2998
 
2999
+ function runsOn(v) {
3000
+ if (!v.agentType) return "";
3001
+ const rec = state.recipes.find((r) => r.id === v.agentType);
3002
+ const parts = [rec ? rec.vendor : v.agentType, v.model, v.effort, v.mode].filter(Boolean);
3003
+ 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>`;
3004
+ }
3005
+
3006
+ 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") };
3007
+ const stp = { room: null, template: null };
3008
+ async function openSaveTemplateDialog(room) {
3009
+ stp.room = room;
3010
+ stpEls.error.hidden = true;
3011
+ stpEls.name.value = room.name;
3012
+ stpEls.desc.value = "";
3013
+ stpEls.preview.innerHTML = '<span class="hint">loading…</span>';
3014
+ openDialog(stpEls.dialog);
3015
+ try {
3016
+ const t = (await post(roomApi("/template-preview"), {})).template;
3017
+ stp.template = t;
3018
+ stpEls.preview.innerHTML = renderTemplateForm(t);
3019
+ stpEls.preview.querySelectorAll("[data-stp-browse]").forEach((b) => b.addEventListener("click", () => {
3020
+ const input = $("#stp-dir");
3021
+ openFolderPicker(input.value, (dir) => (input.value = dir));
3022
+ }));
3023
+ stpEls.preview.querySelectorAll("[data-stp-remove]").forEach((b) => b.addEventListener("click", () => {
3024
+ b.closest(".stp-vm").remove();
3025
+ $("#stp-vm-count").textContent = String(stpEls.preview.querySelectorAll(".stp-vm").length);
3026
+ }));
3027
+ } catch (error) {
3028
+ stpEls.error.textContent = error.message;
3029
+ stpEls.error.hidden = false;
3030
+ }
3031
+ }
3032
+ const stpField = (label, html, wide) => `<label class="field${wide ? " wide" : ""}"><span class="label">${label}</span>${html}</label>`;
3033
+ const stpSwitch = (label, id, on) => `<label class="switch"><span class="label">${label}</span><input type="checkbox" id="${id}"${on ? " checked" : ""}></label>`;
3034
+ 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>`;
3035
+ function renderTemplateForm(t) {
3036
+ const st = t.settings || {};
3037
+ const lang = st.language && st.language.mode === "fixed" ? st.language.language : "";
3038
+ const room = `<div class="stp-grid">
3039
+ ${stpField("Emoji", `<input type="text" id="stp-emoji" maxlength="8" value="${esc(t.emoji || "")}" placeholder="none">`)}
3040
+ ${stpField("Topic", `<input type="text" id="stp-topic" maxlength="2000" value="${esc(st.topic || "")}" placeholder="what the room is about">`)}
3041
+ ${stpField("Language", `<input type="text" id="stp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
3042
+ ${stpField("Vibemates' own tools", stpSelect("stp-tools", st.tools || "on-request", [["on-request", "Only when someone explicitly asks"], ["never", "Never (chat only)"]]))}
3043
+ ${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"]]))}
3044
+ ${stpField("Reply delay, seconds", `<input type="number" id="stp-delay" min="0" max="120" step="0.5" value="${esc(String(st.replyDelay ?? 4))}">`)}
3045
+ ${stpSwitch("Vibemates wake each other", "stp-wake", st.agentsWakeEachOther !== false)}
3046
+ ${stpSwitch("Wait while you are typing", "stp-wait", st.waitWhileHumanTypes !== false)}
3047
+ ${stpField("Hop limit", `<input type="number" id="stp-hops" min="0" max="10000" value="${esc(String(st.hopLimit ?? 100))}">`)}
3048
+ ${stpField("Max sentences per reply", `<input type="number" id="stp-maxlen" min="1" max="100" value="${st.maxSentences ?? ""}" placeholder="no limit">`)}
3049
+ ${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"]]))}
3050
+ ${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"]]))}
3051
+ ${stpField("Full brief every N turns", `<input type="number" id="stp-brief-turns" min="1" max="10000" value="${esc(String(st.fullBriefEveryTurns ?? 8))}">`)}
3052
+ ${stpField("…or every N tokens", `<input type="number" id="stp-brief-tokens" min="1000" max="10000000" step="1000" value="${esc(String(st.fullBriefEveryTokens ?? 20000))}">`)}
3053
+ ${stpField("Replay after a reconnect", `<input type="number" id="stp-replay" min="0" max="200" value="${esc(String(st.replayAfterRestart ?? 10))}">`)}
3054
+ ${stpField("Missed messages read at most", `<input type="number" id="stp-backlog" min="1" max="1000" value="${esc(String(st.backlogCap ?? 50))}">`)}
3055
+ ${stpSwitch("Core rules in every header", "stp-header-rules", st.headerRules !== false)}
3056
+ ${stpSwitch("Show vendor and model to other vibemates", "stp-vendor", !!st.showVendorInRoster)}
3057
+ </div>`;
3058
+ const agentOptions = [["", "none yet: cast when the room opens"], ...state.recipes.map((r) => [r.id, r.vendor + (r.unavailableReason ? " (not installed here)" : "")])];
3059
+ 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>
3060
+ <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>
3061
+ <div class="stp-vm-fields">
3062
+ ${stpField("Vibename", `<input type="text" data-k="name" maxlength="40" value="${esc(v.name)}" required>`)}
3063
+ ${stpField("Vibersona", `<input type="text" data-k="tagline" maxlength="80" value="${esc(v.tagline || "")}">`)}
3064
+ ${stpField("Vibio", `<textarea data-k="role" rows="3" maxlength="4000">${esc(v.role || "")}</textarea>`, true)}
3065
+ ${stpField("Vibeface", `<input type="text" data-k="avatar" maxlength="8" value="${esc(v.avatar || "")}" placeholder="initials">`)}
3066
+ ${stpField("Coding agent", stpSelect("", v.agentType || "", agentOptions).replace('id=""', 'data-k="agentType"'))}
3067
+ ${stpField("Model", `<input type="text" data-k="model" value="${esc(v.model || "")}" placeholder="the agent's default">`)}
3068
+ ${stpField("Effort", `<input type="text" data-k="effort" value="${esc(v.effort || "")}" placeholder="default">`)}
3069
+ ${stpField("Mode", `<input type="text" data-k="mode" value="${esc(v.mode || "")}" placeholder="default">`)}
3070
+ ${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">`)}
3071
+ ${stpField("Skills, comma-separated", `<input type="text" data-k="skills" value="${esc((v.skills || []).join(", "))}">`, true)}
3072
+ </div>
3073
+ </div></div>`).join("");
3074
+ return `
3075
+ <div class="stp-section"><h5>Room</h5>${room}</div>
3076
+ <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>
3077
+ <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>
3078
+ <div class="stp-section"><h5>Vibemates · <span id="stp-vm-count">${(t.vibemates || []).length}</span></h5>${vms || '<span class="hint">none</span>'}</div>`;
3079
+ }
3080
+ function readTemplateForm() {
3081
+ const num = (id) => Number($(id).value);
3082
+ const langText = $("#stp-lang").value.trim();
3083
+ const settings = {
3084
+ topic: $("#stp-topic").value,
3085
+ language: langText ? { mode: "fixed", language: langText } : { mode: "follow-human" },
3086
+ tools: $("#stp-tools").value,
3087
+ turnTaking: $("#stp-turns").value,
3088
+ replyDelay: num("#stp-delay"),
3089
+ agentsWakeEachOther: $("#stp-wake").checked,
3090
+ waitWhileHumanTypes: $("#stp-wait").checked,
3091
+ hopLimit: num("#stp-hops"),
3092
+ maxSentences: $("#stp-maxlen").value === "" ? null : num("#stp-maxlen"),
3093
+ refereeAction: $("#stp-referee").value,
3094
+ humanDescriptionMode: $("#stp-about").value,
3095
+ fullBriefEveryTurns: num("#stp-brief-turns"),
3096
+ fullBriefEveryTokens: num("#stp-brief-tokens"),
3097
+ replayAfterRestart: num("#stp-replay"),
3098
+ backlogCap: num("#stp-backlog"),
3099
+ headerRules: $("#stp-header-rules").checked,
3100
+ showVendorInRoster: $("#stp-vendor").checked,
3101
+ customRules: $("#stp-rules").value.slice(0, 4000),
3102
+ };
3103
+ const vibemates = [...stpEls.preview.querySelectorAll(".stp-vm")].map((row) => {
3104
+ const v = {};
3105
+ row.querySelectorAll("[data-k]").forEach((el) => {
3106
+ const k = el.dataset.k;
3107
+ const val = el.value.trim();
3108
+ if (k === "skills") v.skills = val.split(",").map((x) => x.trim()).filter(Boolean);
3109
+ else if (k === "replyDelay") { if (val !== "") v.replyDelay = Number(val); }
3110
+ else if (val) v[k] = val;
3111
+ });
3112
+ return v;
3113
+ });
3114
+ return { emoji: $("#stp-emoji").value.trim(), dir: $("#stp-dir").value.trim(), settings, vibemates };
3115
+ }
3116
+ stpEls.form.addEventListener("submit", async (event) => {
3117
+ event.preventDefault();
3118
+ if (!stp.room) return;
3119
+ stpEls.create.disabled = true;
3120
+ stpEls.create.textContent = "Creating…";
3121
+ try {
3122
+ const edited = readTemplateForm();
3123
+ 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 } });
3124
+ closeDialog(stpEls.dialog);
3125
+ toast(`Template "${res.template.name}" created. It is first under "Start from a template".`, "success");
3126
+ } catch (error) {
3127
+ stpEls.error.textContent = error.message;
3128
+ stpEls.error.hidden = false;
3129
+ } finally {
3130
+ stpEls.create.disabled = false;
3131
+ stpEls.create.textContent = "Create the template";
3132
+ }
3133
+ });
3134
+
2861
3135
  function openRoomDialog() {
2862
3136
  els.roomError.hidden = true;
2863
3137
  els.roomName.value = "";
@@ -2953,8 +3227,20 @@
2953
3227
  }
2954
3228
 
2955
3229
 
3230
+ function applyAppearance() {
3231
+ const a = (state.settings || {}).appearance || {};
3232
+ const root = document.documentElement;
3233
+ root.style.zoom = String((a.chatFontSize || 14.5) / 14.5);
3234
+ root.style.setProperty("--font", (FONTS.text[a.font] || FONTS.text.nunito).stack);
3235
+ root.style.setProperty("--mono", (FONTS.mono[a.mono] || FONTS.mono["jetbrains-mono"]).stack);
3236
+ }
3237
+ const zoomFactor = () => Number(document.documentElement.style.zoom) || 1;
3238
+
2956
3239
  function loadSnapshot(snapshot) {
2957
3240
  state.settings = snapshot.settings;
3241
+ applyAppearance();
3242
+ state.update = snapshot.update || null;
3243
+ renderUpdatePop();
2958
3244
  state.version = snapshot.version || null;
2959
3245
  state.skills = snapshot.skills || [];
2960
3246
  state.recipes = snapshot.recipes || [];
@@ -3131,12 +3417,18 @@
3131
3417
  });
3132
3418
  es.addEventListener("settings", (e) => {
3133
3419
  state.settings = JSON.parse(e.data).settings;
3420
+ applyAppearance();
3134
3421
  rerenderDiagrams();
3135
3422
  renderRail();
3136
3423
  if (state.view === "settings" && !editingInDetails()) renderSettingsPage();
3137
3424
  if (state.detailsOpen && state.selection.kind === "me" && !editingInDetails()) renderDetails();
3138
3425
  if (state.view === "room") renderSideRoom();
3139
3426
  });
3427
+ es.addEventListener("update", (e) => {
3428
+ state.update = JSON.parse(e.data).update;
3429
+ renderUpdatePop();
3430
+ if (state.view === "settings" && !editingInDetails()) renderSettingsPage();
3431
+ });
3140
3432
  es.addEventListener("reset", () => location.href = "/");
3141
3433
  }
3142
3434
  function releaseStream() {
@@ -3178,14 +3470,14 @@
3178
3470
  let drag = null;
3179
3471
  grip.addEventListener("pointerdown", (e) => {
3180
3472
  if (e.button !== 0) return;
3181
- drag = { y: e.clientY, h: els.input.offsetHeight };
3473
+ drag = { y: e.clientY / zoomFactor(), h: els.input.offsetHeight };
3182
3474
  grip.setPointerCapture(e.pointerId);
3183
3475
  els.composer.classList.add("resizing");
3184
3476
  e.preventDefault();
3185
3477
  });
3186
3478
  grip.addEventListener("pointermove", (e) => {
3187
3479
  if (!drag) return;
3188
- composerMin = Math.round(Math.min(composerCeiling(), Math.max(36, drag.h + drag.y - e.clientY)));
3480
+ composerMin = Math.round(Math.min(composerCeiling(), Math.max(36, drag.h + drag.y - e.clientY / zoomFactor())));
3189
3481
  autosize();
3190
3482
  });
3191
3483
  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; }