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.
- package/NOTICE +8 -2
- package/README.md +1 -1
- package/dist/hub.js +46 -2
- package/dist/room.js +48 -6
- package/dist/server.js +18 -1
- package/dist/templates.js +27 -3
- package/package.json +1 -1
- package/templates/wren-and-quinn/template.json +28 -0
- package/ui/app.css +67 -19
- package/ui/app.js +259 -25
- package/ui/fonts/fira-code-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/fira-code-cyrillic.woff2 +0 -0
- package/ui/fonts/fira-code-latin-ext.woff2 +0 -0
- package/ui/fonts/fira-code-latin.woff2 +0 -0
- package/ui/fonts/fira-code.css +5 -0
- package/ui/fonts/inter-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/inter-cyrillic.woff2 +0 -0
- package/ui/fonts/inter-latin-ext.woff2 +0 -0
- package/ui/fonts/inter-latin.woff2 +0 -0
- package/ui/fonts/inter.css +5 -0
- package/ui/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
- package/ui/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- package/ui/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/ui/fonts/jetbrains-mono.css +5 -0
- package/ui/fonts/noto-sans-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/noto-sans-cyrillic.woff2 +0 -0
- package/ui/fonts/noto-sans-latin-ext.woff2 +0 -0
- package/ui/fonts/noto-sans-latin.woff2 +0 -0
- package/ui/fonts/noto-sans.css +5 -0
- package/ui/fonts/source-code-pro-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/source-code-pro-cyrillic.woff2 +0 -0
- package/ui/fonts/source-code-pro-latin-ext.woff2 +0 -0
- package/ui/fonts/source-code-pro-latin.woff2 +0 -0
- package/ui/fonts/source-code-pro.css +5 -0
- package/ui/index.html +11 -0
- package/ui/theme.css +44 -18
- 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
|
|
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: "
|
|
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-${
|
|
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(
|
|
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:
|
|
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 ?
|
|
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"
|
|
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
|
-
|
|
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 (
|
|
1435
|
-
|
|
1436
|
-
return `<span class="ticks"
|
|
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
|
|
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
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|
|
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
|
-
<
|
|
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" : "
|
|
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",
|
|
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
|
|
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: "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
|
|
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; }
|
|
Binary file
|
|
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: "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
|
|
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: "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; }
|
|
Binary file
|
|
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: "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>
|