viberoom 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/app.js CHANGED
@@ -7,6 +7,7 @@
7
7
  recipes: [],
8
8
  roomDefaults: null,
9
9
  skills: [],
10
+ looks: [],
10
11
  version: null,
11
12
  rooms: new Map(),
12
13
  currentRoomId: null,
@@ -161,36 +162,7 @@
161
162
  } catch {
162
163
  }
163
164
 
164
- const FONTS = {
165
- text: {
166
- nunito: { label: "Nunito (default)", stack: '"Nunito", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
167
- inter: { label: "Inter", stack: '"Inter", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
168
- "noto-sans": { label: "Noto Sans", stack: '"Noto Sans", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
169
- "open-sans": { label: "Open Sans", stack: '"Open Sans", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
170
- "source-sans-3": { label: "Source Sans 3", stack: '"Source Sans 3", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
171
- "ibm-plex-sans": { label: "IBM Plex Sans", stack: '"IBM Plex Sans", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
172
- "manrope": { label: "Manrope", stack: '"Manrope", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
173
- "rubik": { label: "Rubik", stack: '"Rubik", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
174
- "montserrat": { label: "Montserrat", stack: '"Montserrat", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
175
- "golos-text": { label: "Golos Text", stack: '"Golos Text", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
176
- "exo-2": { label: "Exo 2", stack: '"Exo 2", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
177
- "comfortaa": { label: "Comfortaa", stack: '"Comfortaa", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
178
- "ubuntu-sans": { label: "Ubuntu Sans", stack: '"Ubuntu Sans", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
179
- arial: { label: "Arial / Helvetica (system)", stack: 'Arial, Helvetica, "Liberation Sans", sans-serif' },
180
- system: { label: "System UI font", stack: 'system-ui, -apple-system, "Segoe UI", Roboto, Cantarell, sans-serif' },
181
- },
182
- mono: {
183
- "jetbrains-mono": { label: "JetBrains Mono (default)", stack: '"JetBrains Mono", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
184
- "fira-code": { label: "Fira Code", stack: '"Fira Code", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
185
- "source-code-pro": { label: "Source Code Pro", stack: '"Source Code Pro", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
186
- "ibm-plex-mono": { label: "IBM Plex Mono", stack: '"IBM Plex Mono", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
187
- "pt-mono": { label: "PT Mono", stack: '"PT Mono", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
188
- "victor-mono": { label: "Victor Mono", stack: '"Victor Mono", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
189
- "anonymous-pro": { label: "Anonymous Pro", stack: '"Anonymous Pro", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
190
- "cascadia-code": { label: "Cascadia Code", stack: '"Cascadia Code", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
191
- system: { label: "System monospace", stack: 'ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace' },
192
- },
193
- };
165
+ const FONTS = globalThis.VIBEROOM_TOKENS.fonts;
194
166
  const STATUS_LABEL = { unstaffed: "needs a coding agent", starting: "starting…", idle: "ready", queued: "waiting…", thinking: "thinking…", writing: "writing…", error: "error", offline: "offline", left: "left" };
195
167
  const STATUS_TONE = { idle: "ready", queued: "waiting", starting: "waiting", thinking: "thinking", writing: "writing", error: "error", offline: "asleep", left: "asleep", unstaffed: "attention" };
196
168
  const TOOL_STATUSES = new Set(["pending", "in_progress", "completed", "failed"]);
@@ -713,28 +685,34 @@
713
685
  }
714
686
  if (!found.size) return;
715
687
  const paths = await Promise.all([...found.keys()].map((token) => resolveInRoom(room.id, token)));
716
- let linked = false;
688
+ const resolved = new Map();
717
689
  [...found.keys()].forEach((token, i) => {
718
690
  const full = paths[i] && paths[i].path;
719
- if (!full) return;
720
- const byNode = new Map();
721
- for (const hit of found.get(token)) byNode.set(hit.node, [...(byNode.get(hit.node) || []), hit]);
722
- for (const [node, hits] of byNode) {
723
- if (!node.isConnected) continue;
724
- for (const hit of hits.sort((a, b) => b.index - a.index)) {
725
- const after = node.splitText(hit.index);
726
- after.nodeValue = after.nodeValue.slice(hit.length);
727
- const link = document.createElement("a");
728
- link.className = "open-link";
729
- link.href = "#";
730
- link.dataset.open = `${full}${hit.line}`;
731
- link.title = `${full} (relative to the room's folder)`;
732
- link.textContent = `${token}${hit.line}`;
733
- node.parentNode.insertBefore(link, after);
734
- linked = true;
735
- }
736
- }
691
+ if (full) resolved.set(token, full);
737
692
  });
693
+ if (!resolved.size) return;
694
+ const byNode = new Map();
695
+ for (const [token, hits] of found) {
696
+ if (!resolved.has(token)) continue;
697
+ for (const hit of hits) byNode.set(hit.node, [...(byNode.get(hit.node) || []), { ...hit, token }]);
698
+ }
699
+ let linked = false;
700
+ for (const [node, hits] of byNode) {
701
+ if (!node.isConnected) continue;
702
+ for (const hit of hits.sort((a, b) => b.index - a.index)) {
703
+ const full = resolved.get(hit.token);
704
+ const after = node.splitText(hit.index);
705
+ after.nodeValue = after.nodeValue.slice(hit.length);
706
+ const link = document.createElement("a");
707
+ link.className = "open-link";
708
+ link.href = "#";
709
+ link.dataset.open = `${full}${hit.line}`;
710
+ link.title = `${full} (relative to the room's folder)`;
711
+ link.textContent = `${hit.token}${hit.line}`;
712
+ node.parentNode.insertBefore(link, after);
713
+ linked = true;
714
+ }
715
+ }
738
716
  if (linked) renderPreviews(textEl, m);
739
717
  }
740
718
  function csvTable(rows) {
@@ -1134,8 +1112,8 @@
1134
1112
  const field = lastField;
1135
1113
  lastField = null;
1136
1114
  try {
1137
- await onSave();
1138
- if (field) {
1115
+ const outcome = await onSave();
1116
+ if (field && outcome !== false) {
1139
1117
  recentlySaved.set(field, Date.now());
1140
1118
  markSaved(field);
1141
1119
  }
@@ -1337,24 +1315,60 @@
1337
1315
  }, 190);
1338
1316
  }
1339
1317
  function confirmDialog(text, opts) {
1318
+ return choiceDialog(text, opts).then((choice) => choice === "ok");
1319
+ }
1320
+ function choiceDialog(text, opts) {
1340
1321
  const o = opts || {};
1341
1322
  const dialog = $("#confirm-dialog");
1342
1323
  $("#cf-title").textContent = o.title || "Are you sure?";
1343
1324
  $("#cf-text").textContent = text;
1344
- const ok = $("#cf-ok");
1345
- ok.textContent = o.okLabel || "OK";
1346
- ok.className = `btn ${o.danger ? "danger solid" : "primary"}`;
1325
+ const buttons = { ok: $("#cf-ok"), alt: $("#cf-alt"), cancel: $("#cf-cancel") };
1326
+ buttons.ok.textContent = o.okLabel || "OK";
1327
+ buttons.cancel.textContent = o.cancelLabel || "Cancel";
1328
+ buttons.alt.textContent = o.altLabel || "";
1329
+ buttons.alt.hidden = !o.altLabel;
1330
+ dialog.classList.toggle("three-way", !!o.altLabel);
1331
+ const primary = o.primary || "ok";
1332
+ for (const [name, button] of Object.entries(buttons)) button.dataset.kind = name === primary ? (o.danger ? "danger" : "primary") : "ghost";
1347
1333
  return new Promise((resolve) => {
1348
1334
  const done = () => {
1349
1335
  dialog.removeEventListener("close", done);
1350
- resolve(dialog.returnValue === "ok");
1336
+ resolve(dialog.returnValue === "ok" ? "ok" : dialog.returnValue === "alt" ? "alt" : "cancel");
1351
1337
  };
1352
1338
  dialog.addEventListener("close", done);
1353
1339
  dialog.returnValue = "";
1354
1340
  openDialog(dialog);
1355
- ok.focus();
1341
+ buttons[primary].focus();
1356
1342
  });
1357
1343
  }
1344
+
1345
+ const BRIEF_TEXT_MAX = 32000;
1346
+ function briefTextLimit(room) {
1347
+ const r = room || currentRoom();
1348
+ return (r && r.settings && Number(r.settings.briefTextLimit)) || 8000;
1349
+ }
1350
+ function bindCount(el, countEl, limitOf) {
1351
+ if (!el || !countEl) return;
1352
+ const tick = () => {
1353
+ const n = (el.isContentEditable ? rulesText(el) : el.value).length;
1354
+ const limit = limitOf();
1355
+ countEl.textContent = `${n} / ${limit}`;
1356
+ countEl.classList.toggle("over", n > limit);
1357
+ };
1358
+ el.addEventListener("input", tick);
1359
+ tick();
1360
+ }
1361
+ async function fitBriefText(text, what, limit, limitInput) {
1362
+ if (text.length <= limit) return text;
1363
+ const needed = Math.min(BRIEF_TEXT_MAX, Math.ceil(text.length / 500) * 500);
1364
+ const canRaise = text.length <= BRIEF_TEXT_MAX;
1365
+ const choice = await choiceDialog(`${what} is ${text.length} characters; this room's limit is ${limit}. Cut it at the limit, or raise the limit for this room?${canRaise ? "" : ` ${BRIEF_TEXT_MAX} is the most a room can allow.`}`, { title: "Over the room's limit", okLabel: `Cut at ${limit}`, altLabel: canRaise ? `Raise the limit to ${needed}` : "", cancelLabel: "Go back", primary: "cancel" });
1366
+ if (choice === "ok") return text.slice(0, limit);
1367
+ if (choice !== "alt") return null;
1368
+ if (limitInput) limitInput.value = String(needed);
1369
+ else await post(roomApi("/settings"), { briefTextLimit: needed });
1370
+ return text;
1371
+ }
1358
1372
  function remember(key, value) {
1359
1373
  try {
1360
1374
  localStorage.setItem(`viberoom.${key}`, String(value));
@@ -2149,6 +2163,7 @@
2149
2163
  const card = choice.closest('[data-ui="ask-card"]');
2150
2164
  if (choice.dataset.act === "permit") post(roomApi(`/permissions/${encodeURIComponent(card.dataset.key)}`), { optionId: choice.dataset.option || null }).catch(showError);
2151
2165
  else if (choice.dataset.act === "decide") post(roomApi(`/proposals/${encodeURIComponent(card.dataset.key)}`), { accept: choice.dataset.answer === "apply" }).catch(showError);
2166
+ else if (choice.dataset.act === "try-look") tryLook(choice.dataset.look);
2152
2167
  return;
2153
2168
  }
2154
2169
  const chip = e.target.closest('[data-ui="tool-call"] > [data-ui="chip"]');
@@ -2630,6 +2645,7 @@
2630
2645
  rows.push(`<div class="prop-row"><b>Room rules</b>${lines.join("")}</div>`);
2631
2646
  } else rows.push(`<div class="prop-row"><b>${esc(c.key)}</b> <span class="prop-from">${esc(proposalValue(c.from))}</span> → <span class="prop-to">${esc(proposalValue(c.to))}</span></div>`);
2632
2647
  }
2648
+ for (const c of p.appearance || []) rows.push(`<div class="prop-row"><b>${esc(c.key)}</b> <span class="prop-from">${esc(proposalValue(c.from))}</span> → <span class="prop-to">${esc(proposalValue(c.to))}</span></div>`);
2633
2649
  for (const v of p.vibemates || []) {
2634
2650
  if (v.op === "update") rows.push(`<div class="prop-row"><b>${esc(v.name)}</b>${(v.fields || []).map((f) => `<div class="prop-line">${esc(f.field)}: <span class="prop-from">${esc(f.from || "(empty)")}</span> → <span class="prop-to">${esc(f.to || "(empty)")}</span></div>`).join("")}</div>`);
2635
2651
  else rows.push(`<div class="prop-row"><b>${v.op === "add" ? "New vibemate" : "Remove"}</b> ${esc(v.name)}${v.op === "add" ? ' <span class="hint">(you pick its coding agent)</span>' : ""}</div>`);
@@ -2638,9 +2654,15 @@
2638
2654
  }
2639
2655
  function renderProposal(room, p) {
2640
2656
  const existing = els.messages.querySelector(`[data-ui="ask-card"][data-kind="proposal"][data-key="${CSS.escape(p.key)}"]`);
2657
+ const lookRow = (p.appearance || []).find((c) => c.key === "look");
2658
+ const lookTarget = lookRow && TOKENS.looks[lookRow.to];
2659
+ const windowNote = p.appearance && p.appearance.length
2660
+ ? `<div class="prop-window"><span class="note">${ic("eye")} This changes the whole window, not this room alone.</span>${lookTarget ? `<div class="prop-look">${UI.html("look-card", { look: lookTarget, tag: lookTag(lookTarget), title: lookTarget.label })}${p.status === "pending" ? UI.html("button", { label: document.documentElement.dataset.tried === lookTarget.id ? "Back" : "Try it on", kind: "soft", size: "xs", act: "try-look", data: { look: lookTarget.id }, title: "This window only, until you apply or reject" }) : ""}</div>` : ""}</div>`
2661
+ : "";
2641
2662
  const body =
2642
2663
  (p.why ? `<div class="prop-why">${esc(p.why)}</div>` : "") +
2643
2664
  `<div class="prop-diff">${proposalDiffHtml(p)}</div>` +
2665
+ windowNote +
2644
2666
  (p.warnings && p.warnings.length ? `<ul class="prop-warn">${p.warnings.map((w) => `<li>${esc(w)}</li>`).join("")}</ul>` : "") +
2645
2667
  (p.touchesOwn ? `<div class="prop-own">${ic("info")} This changes the rules or ${esc(p.participantName)}'s own persona: it decides how ${esc(p.participantName)} itself will behave.</div>` : "");
2646
2668
  const outcome = p.status === "pending" ? undefined : p.status === "applied" ? `applied${p.skipped && p.skipped.length ? ` · not applied: ${p.skipped.join("; ")}` : ""}` : "rejected";
@@ -2753,7 +2775,7 @@
2753
2775
  ${field("Vibename", `<input type="text" id="pp-name" maxlength="24" value="${esc(p.name)}">`)}
2754
2776
  ${field("Vibersona", `<input type="text" id="pp-tagline" maxlength="80" value="${esc(p.tagline || "")}" placeholder="a few words under the vibename">`, "Shown under the vibename.", "Everyone in the room sees it: you, and the other vibemates in their roster.")}
2755
2777
  ${field("Vibeface", `<div id="pp-avatar-picker"></div><input type="text" id="pp-avatar" maxlength="8" value="${esc(p.avatar || "")}" placeholder="custom emoji (optional)">`)}
2756
- ${field("Vibio", `<textarea id="pp-role" rows="5" maxlength="4000" placeholder="who it is, how it speaks, what it cares about">${esc(p.role || "")}</textarea>`, "Only this vibemate reads it.", "Reaches the vibemate as refreshed instructions in its brief on its next turn; its memory is kept. The other participants never see it.")}
2778
+ ${field("Vibio", `<textarea id="pp-role" rows="5" placeholder="who it is, how it speaks, what it cares about">${esc(p.role || "")}</textarea>`, `Only this vibemate reads it.<span class="count" id="pp-role-count"></span>`, "Reaches the vibemate as refreshed instructions in its brief on its next turn; its memory is kept. The other participants never see it. A vibio and the room rules go into every brief, so the room has a limit for them (the room's settings, for geeks); text over it is never cut in silence.")}
2757
2779
  </div>
2758
2780
  ${p.trouble ? `<div class="trouble"><b>${esc(p.trouble.what)}</b><span>${esc(p.trouble.advice)}</span></div>` : ""}
2759
2781
  ${p.statusDetail && (p.status === "offline" || p.status === "error" || p.failedTurns) ? `<p class="hint" style="color:var(--danger);margin:0 4px 10px">${esc(p.statusDetail)}</p>` : ""}
@@ -2826,7 +2848,13 @@
2826
2848
  renderSkillChecks($("#pp-skills"), p.skills || []);
2827
2849
  bindSave($("#pp-skills-section"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { skills: checkedSkills($("#pp-skills")) }));
2828
2850
  bindSave($("#pp-timing"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { replyDelay: $("#pp-delay").value === "" ? null : Number($("#pp-delay").value) }));
2829
- bindSave($("#pp-persona"), () => post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { name: $("#pp-name").value, tagline: $("#pp-tagline").value, role: $("#pp-role").value, avatar: $("#pp-avatar").value }));
2851
+ bindCount($("#pp-role"), $("#pp-role-count"), () => briefTextLimit(currentRoom()));
2852
+ bindSave($("#pp-persona"), async () => {
2853
+ const role = await fitBriefText($("#pp-role").value, `${p.name}'s vibio`, briefTextLimit(currentRoom()));
2854
+ if (role === null) return false;
2855
+ await post(roomApi(`/participants/${encodeURIComponent(p.id)}/persona`), { name: $("#pp-name").value, tagline: $("#pp-tagline").value, role, avatar: $("#pp-avatar").value });
2856
+ return true;
2857
+ });
2830
2858
  renderConfig($("#pp-config"), p, offline);
2831
2859
  }
2832
2860
 
@@ -2927,7 +2955,7 @@
2927
2955
  ${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.")}
2928
2956
  ${field("Topic", `<input type="text" id="rp-topic" maxlength="2000" value="${esc(rs.topic || "")}" placeholder="what this room is about (optional)">`)}
2929
2957
  ${field("Folder", `<span class="dir-row"><input type="text" id="rp-dir" maxlength="1000" value="${esc(room.dir)}" spellcheck="false">${UI.html("button", { label: "Browse", icon: "folder", kind: "ghost", id: "rp-dir-browse", title: "Choose a folder", hook: "browse-btn" })}</span>`, "Where the vibemates read and write. Changing it restarts them in the new folder; they replay the last messages.")}
2930
- <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>
2958
+ <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 class="count" id="rp-rules-count"></span></span><div class="mention-menu inline" id="rp-rules-menu" hidden></div></div>
2931
2959
  ${field("Language", `<input type="text" id="rp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
2932
2960
  </div>
2933
2961
  <div class="section">
@@ -2970,6 +2998,7 @@
2970
2998
  <label class="switch"><span class="label">Show vendor and model to other vibemates</span><input type="checkbox" id="rp-vendor" ${rs.showVendorInRoster ? "checked" : ""}></label>
2971
2999
  ${field("Replay last N chat messages after a reconnect", `${UI.html("number-field", { id: "rp-replay", value: String(rs.replayAfterRestart), min: 0, max: 200 })}`)}
2972
3000
  ${field("Missed messages a vibemate reads at most on its next turn", `${UI.html("number-field", { id: "rp-backlog", value: String(rs.backlogCap), min: 1, max: 1000 })}`, "Everything posted since its last turn counts, including while it was muted; older messages are dropped with a note in its prompt.")}
3001
+ ${field("Most characters in a vibio or in the room rules", `${UI.html("number-field", { id: "rp-text-limit", value: String(rs.briefTextLimit ?? 8000), min: 500, max: 32000, step: 500 })}`, "Both go into every brief. Text over the limit is refused with the numbers, never cut.")}
2973
3002
  </div>`,
2974
3003
  "tools, hops, referee, briefs",
2975
3004
  )}
@@ -2983,6 +3012,8 @@
2983
3012
  wireDetailsClose();
2984
3013
  rulesToNodes($("#rp-rules"), room.customRulesText != null ? room.customRulesText : rs.customRules || "", room);
2985
3014
  attachRichMentions($("#rp-rules"), $("#rp-rules-menu"));
3015
+ bindCount($("#rp-rules"), $("#rp-rules-count"), () => Number($("#rp-text-limit").value) || briefTextLimit(room));
3016
+ $("#rp-text-limit").addEventListener("input", () => $("#rp-rules").dispatchEvent(new Event("input")));
2986
3017
  $("#rp-emoji-picker").appendChild(
2987
3018
  emojiGrid(ROOM_EMOJI, rs.emoji || "", (emoji) => {
2988
3019
  $("#rp-emoji").value = emoji;
@@ -2999,10 +3030,13 @@
2999
3030
  if (name.trim() !== room.name) await post(roomApi("/rename"), { name });
3000
3031
  const dir = $("#rp-dir").value.trim();
3001
3032
  if (dir && dir !== room.dir) await post(roomApi("/dir"), { dir });
3033
+ const rules = await fitBriefText(rulesText($("#rp-rules")), "The room rules text", Number($("#rp-text-limit").value) || briefTextLimit(room), $("#rp-text-limit"));
3034
+ if (rules === null) return false;
3002
3035
  await post(roomApi("/settings"), {
3003
3036
  emoji: $("#rp-emoji").value,
3004
3037
  topic: $("#rp-topic").value,
3005
- customRules: rulesText($("#rp-rules")).slice(0, 4000),
3038
+ customRules: rules,
3039
+ briefTextLimit: Number($("#rp-text-limit").value),
3006
3040
  language: $("#rp-lang").value.trim() || "follow-human",
3007
3041
  tools: $("#rp-tools").value,
3008
3042
  maxSentences: $("#rp-maxlen").value === "" ? null : Number($("#rp-maxlen").value),
@@ -3072,11 +3106,11 @@
3072
3106
  const lookId = TOKENS.looks[a.look] ? a.look : TOKENS.current.id;
3073
3107
  const look = TOKENS.looks[lookId];
3074
3108
  const custom = (a.custom || {})[lookId] || {};
3075
- const cards = Object.values(TOKENS.looks).map((l) => UI.html("look-card", { look: l, on: lookId === l.id, data: { look: l.id } })).join("");
3109
+ const cards = Object.values(TOKENS.looks).map((l) => UI.html("look-card", { look: l, tag: lookTag(l), on: lookId === l.id, data: { look: l.id } })).join("");
3076
3110
  const groups = [...new Set(TOKENS.adjustables.map((f) => f.group))];
3077
3111
  const rows = (group) => TOKENS.adjustables.filter((f) => f.group === group).map((f) => UI.html("adjust-row", { key: f.key, label: f.label, hint: f.hint, kind: f.kind, value: custom[f.key] || String(f.of(look)), own: String(f.of(look)), min: f.min, max: f.max, step: f.step })).join("");
3078
3112
  return `<div class="look-preview" id="sp-look-preview">${lookSampleHtml()}</div>
3079
- ${field("Look", `<input type="hidden" id="sp-look" value="${esc(lookId)}"><div class="look-cards">${cards}</div>`, "How the room is drawn. Every element keeps what it does; only its look changes. The sample above wears the look you pick; the window changes on save.")}
3113
+ ${field("Look", `<input type="hidden" id="sp-look" value="${esc(lookId)}"><div class="look-cards">${cards}</div><div class="look-own" id="sp-look-own">${UI.html("button", { label: "Import a look…", kind: "ghost", size: "xs", hook: "sp-look-import", title: "A look file (.json) saved from viberoom, yours or someone else's" })}<span class="own-only"${look.custom ? "" : " hidden"}>${UI.html("button", { label: "Export", kind: "ghost", size: "xs", hook: "sp-look-export", title: "Save this look as a file, to share or keep" })}${UI.html("button", { label: "Delete", kind: "danger", size: "xs", hook: "sp-look-delete", title: "Remove this look from your own looks" })}</span><input type="file" id="sp-look-file" accept=".json,application/json" hidden></div>`, "How the room is drawn. Every element keeps what it does; only its look changes. The sample above wears the look you pick; the window changes on save. Your own looks (a vibemate's, or a file you import) come after the ones viberoom ships.")}
3080
3114
  <div class="look-adjust" id="sp-look-adjust">
3081
3115
  <div class="adjust-head"><span class="label">Fine-tune <span id="sp-adj-name">${esc(look.label)}</span></span>${UI.html("button", { label: "Reset all", kind: "ghost", size: "xs", hook: "sp-adj-reset", title: "Back to the look as designed" })}</div>
3082
3116
  <p class="hint">Kept for this look alone. The sample follows as you pick; the window follows on save.</p>
@@ -3151,6 +3185,7 @@
3151
3185
  ${field("Hop limit", `${UI.html("number-field", { id: "sp-hops", value: String(d.hopLimit), min: 0, max: 10000 })}`, "How many vibemate-to-vibemate replies may follow one message of yours before the room waits for you again.")}
3152
3186
  ${field("Full brief every N turns", `${UI.html("number-field", { id: "sp-brief-turns", value: String(d.fullBriefEveryTurns), min: 1, max: 10000 })}`, "How often a vibemate gets the whole room brief again instead of the short header.")}
3153
3187
  ${field("Full brief every N tokens", `${UI.html("number-field", { id: "sp-brief-tokens", value: String(d.fullBriefEveryTokens), min: 1000, max: 10000000, step: 1000 })}`, "…or after this much new context since its last full brief, whichever comes first.")}
3188
+ ${field("Most characters in a vibio or in the room rules", `${UI.html("number-field", { id: "sp-text-limit", value: String(d.briefTextLimit ?? 8000), min: 500, max: 32000, step: 500 })}`, "Both go into every brief. Over the limit, the text is refused with the numbers, never cut; each room can raise or lower its own.")}
3154
3189
  <label class="switch"><span class="label">Repeat core rules in every header<span class="hint">The short header before each turn repeats the room's core rules (who is here, how to address, how long to write).</span></span><input type="checkbox" id="sp-header-rules" ${d.headerRules ? "checked" : ""}></label>
3155
3190
  ${field("Tools", `<select id="sp-tools"><option value="on-request"${d.tools === "on-request" ? " selected" : ""}>Only when asked</option><option value="never"${d.tools === "never" ? " selected" : ""}>Never</option></select>`, "Whether vibemates may use their own tools (files, shell, web) without being asked to.")}
3156
3191
  </div>
@@ -3275,9 +3310,56 @@
3275
3310
  input.value = b.dataset.look;
3276
3311
  fillRows(b.dataset.look);
3277
3312
  paintLookPreview();
3313
+ paintOwnRow();
3278
3314
  input.dispatchEvent(new Event("change", { bubbles: true }));
3279
3315
  }),
3280
3316
  );
3317
+ const paintOwnRow = () => {
3318
+ const own = looksBox.querySelector("#sp-look-own .own-only");
3319
+ if (own) own.hidden = !pickedLook().custom;
3320
+ };
3321
+ looksBox.querySelector(".sp-look-import").addEventListener("click", () => $("#sp-look-file").click());
3322
+ $("#sp-look-file").addEventListener("change", async () => {
3323
+ const file = $("#sp-look-file").files && $("#sp-look-file").files[0];
3324
+ $("#sp-look-file").value = "";
3325
+ if (!file) return;
3326
+ try {
3327
+ const spec = JSON.parse(await file.text());
3328
+ const checked = await post("/api/looks/check", { spec });
3329
+ if (!checked.ok) throw new Error(`${file.name} is not a look viberoom can wear: ${(checked.errors || []).map((x) => x.message).join("; ")}`);
3330
+ const taken = TOKENS.looks[checked.id];
3331
+ if (taken && !taken.custom) throw new Error(`"${checked.id}" is the id of a look viberoom ships; change the id in the file`);
3332
+ if (taken && !(await confirmDialog(`You have a look "${taken.label}" with this id already. Replace it with the one from ${file.name}?`, { title: "Replace the look?", okLabel: "Replace" }))) return;
3333
+ const saved = await post("/api/looks", { spec, replace: !!taken });
3334
+ toast(`Look "${saved.look.label}" ${taken ? "replaced" : "added"}: it is in the list now.${(saved.warnings || []).length ? ` ${saved.warnings.map((w) => w.message).join(" ")}` : ""}`, "success");
3335
+ } catch (error) {
3336
+ showError(error);
3337
+ }
3338
+ });
3339
+ looksBox.querySelector(".sp-look-export").addEventListener("click", () => {
3340
+ const look = pickedLook();
3341
+ const spec = state.looks.find((l) => l.id === look.id);
3342
+ if (!spec) return;
3343
+ const a = document.createElement("a");
3344
+ a.href = URL.createObjectURL(new Blob([`${JSON.stringify(spec, null, 2)}\n`], { type: "application/json" }));
3345
+ a.download = `${look.id}.json`;
3346
+ document.body.appendChild(a);
3347
+ a.click();
3348
+ a.remove();
3349
+ setTimeout(() => URL.revokeObjectURL(a.href), 2000);
3350
+ });
3351
+ looksBox.querySelector(".sp-look-delete").addEventListener("click", async () => {
3352
+ const look = pickedLook();
3353
+ if (!look.custom) return;
3354
+ const ok = await confirmDialog(`"${look.label}" goes from your looks; a window wearing it falls back to VibeClassic. The file is gone too (export it first to keep it).`, { title: "Delete the look?", okLabel: "Delete", danger: true });
3355
+ if (!ok) return;
3356
+ try {
3357
+ await post("/api/looks/remove", { id: look.id });
3358
+ toast(`Look "${look.label}" deleted.`, "success");
3359
+ } catch (error) {
3360
+ showError(error);
3361
+ }
3362
+ });
3281
3363
  looksBox.querySelectorAll('[data-ui="adjust-row"] input').forEach((i) => i.addEventListener("input", paintLookPreview));
3282
3364
  looksBox.addEventListener("click", (e) => {
3283
3365
  const back = e.target.closest && e.target.closest('[data-ui="adjust-row"] .back');
@@ -3359,6 +3441,7 @@
3359
3441
  hopLimit: Number($("#sp-hops").value),
3360
3442
  fullBriefEveryTurns: Number($("#sp-brief-turns").value),
3361
3443
  fullBriefEveryTokens: Number($("#sp-brief-tokens").value),
3444
+ briefTextLimit: Number($("#sp-text-limit").value),
3362
3445
  headerRules: $("#sp-header-rules").checked,
3363
3446
  tools: $("#sp-tools").value,
3364
3447
  },
@@ -3695,7 +3778,7 @@
3695
3778
  }
3696
3779
  els.invModelCustom.hidden = !info.modelAtLaunch;
3697
3780
  const who = info.agentInfo && info.agentInfo.name ? `${info.agentInfo.name} ${info.agentInfo.version || ""}`.trim() : recipe.vendor;
3698
- els.invStatus.textContent = parts.length ? `Options from ${who} (${parts.join(", ")}; ${(info.durationMs / 1000).toFixed(1)} s)` : `${who} exposes no config options over ACP${info.modelAtLaunch ? "; the model is a launch flag (built-in list, or type one)" : ""}.`;
3781
+ els.invStatus.textContent = parts.length ? `Options from ${who} (${parts.join(", ")}; ${(info.durationMs / 1000).toFixed(1)} s)` : `${who} exposes no config options over ACP${info.modelAtLaunch ? "; the model is a launch flag (built-in list, or type one)" : ""}${info.modeAtLaunch ? "; the mode is a launch flag (a change restarts the session)" : ""}.`;
3699
3782
  } catch (error) {
3700
3783
  if (requestId !== optionsRequest) return;
3701
3784
  els.invStatus.textContent = `Could not read the vibemate's options (${error.message}); showing the built-in list.`;
@@ -4066,6 +4149,7 @@
4066
4149
  ${stpField("…or every N tokens", `${UI.html("number-field", { id: "stp-brief-tokens", value: String(st.fullBriefEveryTokens ?? 20000), min: 1000, max: 10000000, step: 1000 })}`)}
4067
4150
  ${stpField("Replay after a reconnect", `${UI.html("number-field", { id: "stp-replay", value: String(st.replayAfterRestart ?? 10), min: 0, max: 200 })}`)}
4068
4151
  ${stpField("Missed messages read at most", `${UI.html("number-field", { id: "stp-backlog", value: String(st.backlogCap ?? 50), min: 1, max: 1000 })}`)}
4152
+ ${stpField("Most characters in a vibio or the rules", `${UI.html("number-field", { id: "stp-text-limit", value: String(st.briefTextLimit ?? 8000), min: 500, max: 32000, step: 500 })}`)}
4069
4153
  ${stpSwitch("Core rules in every header", "stp-header-rules", st.headerRules !== false)}
4070
4154
  ${stpSwitch("Show vendor and model to other vibemates", "stp-vendor", !!st.showVendorInRoster)}
4071
4155
  </div>`;
@@ -4087,7 +4171,7 @@
4087
4171
  </div></div>`).join("");
4088
4172
  return `
4089
4173
  <div class="stp-section"><h5>Room</h5>${room}</div>
4090
- <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>
4174
+ <div class="stp-section"><h5>Room rules</h5><textarea id="stp-rules" rows="5" placeholder="one rule per line">${esc(st.customRules || "")}</textarea></div>
4091
4175
  <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">${UI.html("button", { label: "Browse", icon: "folder", kind: "ghost", hook: "browse-btn", data: { stpBrowse: true } })}</span></div>
4092
4176
  <div class="stp-section"><h5>Vibemates · <span id="stp-vm-count">${(t.vibemates || []).length}</span></h5>${vms || '<span class="hint">none</span>'}</div>`;
4093
4177
  }
@@ -4112,7 +4196,8 @@
4112
4196
  backlogCap: num("#stp-backlog"),
4113
4197
  headerRules: $("#stp-header-rules").checked,
4114
4198
  showVendorInRoster: $("#stp-vendor").checked,
4115
- customRules: $("#stp-rules").value.slice(0, 4000),
4199
+ customRules: $("#stp-rules").value,
4200
+ briefTextLimit: num("#stp-text-limit"),
4116
4201
  };
4117
4202
  const vibemates = [...stpEls.preview.querySelectorAll(".stp-vm")].map((row) => {
4118
4203
  const v = {};
@@ -4199,7 +4284,7 @@
4199
4284
  els.pfDialog.addEventListener("cancel", (event) => event.preventDefault());
4200
4285
  function offerLook() {
4201
4286
  const current = (state.settings.appearance || {}).look || TOKENS.current.id;
4202
- $("#look-dialog-cards").innerHTML = Object.values(TOKENS.looks).map((l) => UI.html("look-card", { look: l, on: l.id === current, data: { look: l.id } })).join("");
4287
+ $("#look-dialog-cards").innerHTML = Object.values(TOKENS.looks).map((l) => UI.html("look-card", { look: l, tag: lookTag(l), on: l.id === current, data: { look: l.id } })).join("");
4203
4288
  openDialog($("#look-dialog"));
4204
4289
  }
4205
4290
  $("#look-dialog-cards").addEventListener("click", async (e) => {
@@ -4274,10 +4359,47 @@
4274
4359
  for (const name of CUSTOM_VARS) el.style.removeProperty(name);
4275
4360
  for (const [name, value] of Object.entries(customVars(values || {}))) el.style.setProperty(name, value);
4276
4361
  }
4362
+ function registerLooks(specs) {
4363
+ for (const id of Object.keys(TOKENS.looks)) if (TOKENS.looks[id].custom) delete TOKENS.looks[id];
4364
+ for (const spec of specs || []) {
4365
+ try {
4366
+ TOKENS.looks[spec.id] = TOKENS.make(spec);
4367
+ } catch (error) {
4368
+ console.warn(`look ${spec.id} could not be built: ${error.message}`);
4369
+ }
4370
+ }
4371
+ }
4372
+ function refreshLooksCss() {
4373
+ const link = $("#looks-custom");
4374
+ if (link) link.href = `/looks-custom.css?v=${Date.now()}`;
4375
+ }
4376
+ function lookTag(look) {
4377
+ return look.custom ? `${(state.settings && state.settings.humanName) || "Your"}'s look` : undefined;
4378
+ }
4379
+ function tryLook(id) {
4380
+ const root = document.documentElement;
4381
+ if (root.dataset.tried === id || !TOKENS.looks[id]) {
4382
+ applyAppearance();
4383
+ return;
4384
+ }
4385
+ if (id === TOKENS.current.id) delete root.dataset.look;
4386
+ else root.dataset.look = id;
4387
+ applyCustomVars(root, (((state.settings || {}).appearance || {}).custom || {})[id] || {});
4388
+ root.dataset.tried = id;
4389
+ refreshTryButtons();
4390
+ }
4391
+ function refreshTryButtons() {
4392
+ const tried = document.documentElement.dataset.tried || "";
4393
+ document.querySelectorAll('[data-ui="ask-card"] [data-act="try-look"]').forEach((b) => {
4394
+ const label = b.querySelector(".label") || b;
4395
+ label.textContent = tried === b.dataset.look ? "Back" : "Try it on";
4396
+ });
4397
+ }
4277
4398
  let appliedScheme = null;
4278
4399
  function applyAppearance() {
4279
4400
  const a = (state.settings || {}).appearance || {};
4280
4401
  const root = document.documentElement;
4402
+ delete root.dataset.tried;
4281
4403
  root.style.setProperty("--fs-scale", String((a.chatFontSize || 14.5) / 14.5));
4282
4404
  const look = TOKENS.looks[a.look] ? a.look : TOKENS.current.id;
4283
4405
  if (look === TOKENS.current.id) delete root.dataset.look;
@@ -4295,6 +4417,7 @@
4295
4417
  if (frame) frame.content = TOKENS.looks[look].elements.browser.themeColor;
4296
4418
  applyCustomVars(root, (a.custom || {})[look] || {});
4297
4419
  document.querySelectorAll("#participants .avatar .life").forEach((ring) => setLifePath(ring, ring.parentElement));
4420
+ refreshTryButtons();
4298
4421
  }
4299
4422
 
4300
4423
  let hubIdentity = null;
@@ -4307,6 +4430,8 @@
4307
4430
  }
4308
4431
  if (identity) hubIdentity = identity;
4309
4432
  state.settings = snapshot.settings;
4433
+ state.looks = snapshot.looks || [];
4434
+ registerLooks(state.looks);
4310
4435
  applyAppearance();
4311
4436
  state.update = snapshot.update || null;
4312
4437
  renderUpdatePop();
@@ -4485,6 +4610,13 @@
4485
4610
  if (state.view === "skills" && !editingInDetails()) renderSkillsPage();
4486
4611
  if (state.detailsOpen && !editingInDetails()) renderDetails();
4487
4612
  },
4613
+ looks: (m) => {
4614
+ state.looks = m.looks || [];
4615
+ registerLooks(state.looks);
4616
+ refreshLooksCss();
4617
+ applyAppearance();
4618
+ if (state.view === "settings" && !editingInDetails()) renderSettingsPage();
4619
+ },
4488
4620
  settings: (m) => {
4489
4621
  state.settings = m.settings;
4490
4622
  applyAppearance();