viberoom 0.6.0 → 0.7.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.
Files changed (110) hide show
  1. package/NOTICE +14 -0
  2. package/dist/hub.js +45 -4
  3. package/dist/main.js +1 -1
  4. package/dist/recipes.js +2 -1
  5. package/dist/shortcuts.js +1 -1
  6. package/package.json +1 -1
  7. package/ui/app.css +131 -107
  8. package/ui/app.js +334 -87
  9. package/ui/avatars.js +7 -14
  10. package/ui/components.css +116 -21
  11. package/ui/components.js +202 -1
  12. package/ui/fonts/anonymous-pro-cyrillic-400.woff2 +0 -0
  13. package/ui/fonts/anonymous-pro-cyrillic-700.woff2 +0 -0
  14. package/ui/fonts/anonymous-pro-latin-400.woff2 +0 -0
  15. package/ui/fonts/anonymous-pro-latin-700.woff2 +0 -0
  16. package/ui/fonts/anonymous-pro-latin-ext-400.woff2 +0 -0
  17. package/ui/fonts/anonymous-pro-latin-ext-700.woff2 +0 -0
  18. package/ui/fonts/anonymous-pro.css +7 -0
  19. package/ui/fonts/cascadia-code-cyrillic-ext.woff2 +0 -0
  20. package/ui/fonts/cascadia-code-cyrillic.woff2 +0 -0
  21. package/ui/fonts/cascadia-code-latin-ext.woff2 +0 -0
  22. package/ui/fonts/cascadia-code-latin.woff2 +0 -0
  23. package/ui/fonts/cascadia-code.css +5 -0
  24. package/ui/fonts/comfortaa-cyrillic-ext.woff2 +0 -0
  25. package/ui/fonts/comfortaa-cyrillic.woff2 +0 -0
  26. package/ui/fonts/comfortaa-latin-ext.woff2 +0 -0
  27. package/ui/fonts/comfortaa-latin.woff2 +0 -0
  28. package/ui/fonts/comfortaa.css +5 -0
  29. package/ui/fonts/exo-2-cyrillic-ext.woff2 +0 -0
  30. package/ui/fonts/exo-2-cyrillic.woff2 +0 -0
  31. package/ui/fonts/exo-2-latin-ext.woff2 +0 -0
  32. package/ui/fonts/exo-2-latin.woff2 +0 -0
  33. package/ui/fonts/exo-2.css +5 -0
  34. package/ui/fonts/golos-text-cyrillic-ext.woff2 +0 -0
  35. package/ui/fonts/golos-text-cyrillic.woff2 +0 -0
  36. package/ui/fonts/golos-text-latin-ext.woff2 +0 -0
  37. package/ui/fonts/golos-text-latin.woff2 +0 -0
  38. package/ui/fonts/golos-text.css +5 -0
  39. package/ui/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  40. package/ui/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  41. package/ui/fonts/ibm-plex-mono-cyrillic-700.woff2 +0 -0
  42. package/ui/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  43. package/ui/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  44. package/ui/fonts/ibm-plex-mono-cyrillic-ext-700.woff2 +0 -0
  45. package/ui/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  46. package/ui/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  47. package/ui/fonts/ibm-plex-mono-latin-700.woff2 +0 -0
  48. package/ui/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  49. package/ui/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  50. package/ui/fonts/ibm-plex-mono-latin-ext-700.woff2 +0 -0
  51. package/ui/fonts/ibm-plex-mono.css +13 -0
  52. package/ui/fonts/ibm-plex-sans-cyrillic-400.woff2 +0 -0
  53. package/ui/fonts/ibm-plex-sans-cyrillic-600.woff2 +0 -0
  54. package/ui/fonts/ibm-plex-sans-cyrillic-700.woff2 +0 -0
  55. package/ui/fonts/ibm-plex-sans-cyrillic-ext-400.woff2 +0 -0
  56. package/ui/fonts/ibm-plex-sans-cyrillic-ext-600.woff2 +0 -0
  57. package/ui/fonts/ibm-plex-sans-cyrillic-ext-700.woff2 +0 -0
  58. package/ui/fonts/ibm-plex-sans-latin-400.woff2 +0 -0
  59. package/ui/fonts/ibm-plex-sans-latin-600.woff2 +0 -0
  60. package/ui/fonts/ibm-plex-sans-latin-700.woff2 +0 -0
  61. package/ui/fonts/ibm-plex-sans-latin-ext-400.woff2 +0 -0
  62. package/ui/fonts/ibm-plex-sans-latin-ext-600.woff2 +0 -0
  63. package/ui/fonts/ibm-plex-sans-latin-ext-700.woff2 +0 -0
  64. package/ui/fonts/ibm-plex-sans.css +13 -0
  65. package/ui/fonts/manrope-cyrillic-ext.woff2 +0 -0
  66. package/ui/fonts/manrope-cyrillic.woff2 +0 -0
  67. package/ui/fonts/manrope-latin-ext.woff2 +0 -0
  68. package/ui/fonts/manrope-latin.woff2 +0 -0
  69. package/ui/fonts/manrope.css +5 -0
  70. package/ui/fonts/montserrat-cyrillic-ext.woff2 +0 -0
  71. package/ui/fonts/montserrat-cyrillic.woff2 +0 -0
  72. package/ui/fonts/montserrat-latin-ext.woff2 +0 -0
  73. package/ui/fonts/montserrat-latin.woff2 +0 -0
  74. package/ui/fonts/montserrat.css +5 -0
  75. package/ui/fonts/open-sans-cyrillic-ext.woff2 +0 -0
  76. package/ui/fonts/open-sans-cyrillic.woff2 +0 -0
  77. package/ui/fonts/open-sans-latin-ext.woff2 +0 -0
  78. package/ui/fonts/open-sans-latin.woff2 +0 -0
  79. package/ui/fonts/open-sans.css +5 -0
  80. package/ui/fonts/pt-mono-cyrillic-ext.woff2 +0 -0
  81. package/ui/fonts/pt-mono-cyrillic.woff2 +0 -0
  82. package/ui/fonts/pt-mono-latin-ext.woff2 +0 -0
  83. package/ui/fonts/pt-mono-latin.woff2 +0 -0
  84. package/ui/fonts/pt-mono.css +5 -0
  85. package/ui/fonts/rubik-cyrillic-ext.woff2 +0 -0
  86. package/ui/fonts/rubik-cyrillic.woff2 +0 -0
  87. package/ui/fonts/rubik-latin-ext.woff2 +0 -0
  88. package/ui/fonts/rubik-latin.woff2 +0 -0
  89. package/ui/fonts/rubik.css +5 -0
  90. package/ui/fonts/source-sans-3-cyrillic-ext.woff2 +0 -0
  91. package/ui/fonts/source-sans-3-cyrillic.woff2 +0 -0
  92. package/ui/fonts/source-sans-3-latin-ext.woff2 +0 -0
  93. package/ui/fonts/source-sans-3-latin.woff2 +0 -0
  94. package/ui/fonts/source-sans-3.css +5 -0
  95. package/ui/fonts/ubuntu-sans-cyrillic-ext.woff2 +0 -0
  96. package/ui/fonts/ubuntu-sans-cyrillic.woff2 +0 -0
  97. package/ui/fonts/ubuntu-sans-latin-ext.woff2 +0 -0
  98. package/ui/fonts/ubuntu-sans-latin.woff2 +0 -0
  99. package/ui/fonts/ubuntu-sans.css +5 -0
  100. package/ui/fonts/victor-mono-cyrillic-ext.woff2 +0 -0
  101. package/ui/fonts/victor-mono-cyrillic.woff2 +0 -0
  102. package/ui/fonts/victor-mono-latin-ext.woff2 +0 -0
  103. package/ui/fonts/victor-mono-latin.woff2 +0 -0
  104. package/ui/fonts/victor-mono.css +5 -0
  105. package/ui/index.html +15 -6
  106. package/ui/looks.css +2446 -0
  107. package/ui/manifest.json +3 -3
  108. package/ui/theme.css +114 -84
  109. package/ui/tokens.js +315 -29
  110. package/ui/ui.js +2 -0
package/ui/app.js CHANGED
@@ -166,6 +166,16 @@
166
166
  nunito: { label: "Nunito (default)", stack: '"Nunito", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
167
167
  inter: { label: "Inter", stack: '"Inter", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
168
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' },
169
179
  arial: { label: "Arial / Helvetica (system)", stack: 'Arial, Helvetica, "Liberation Sans", sans-serif' },
170
180
  system: { label: "System UI font", stack: 'system-ui, -apple-system, "Segoe UI", Roboto, Cantarell, sans-serif' },
171
181
  },
@@ -173,6 +183,11 @@
173
183
  "jetbrains-mono": { label: "JetBrains Mono (default)", stack: '"JetBrains Mono", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
174
184
  "fira-code": { label: "Fira Code", stack: '"Fira Code", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
175
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' },
176
191
  system: { label: "System monospace", stack: 'ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace' },
177
192
  },
178
193
  };
@@ -180,7 +195,7 @@
180
195
  const STATUS_TONE = { idle: "ready", queued: "waiting", starting: "waiting", thinking: "thinking", writing: "writing", error: "error", offline: "asleep", left: "asleep", unstaffed: "attention" };
181
196
  const TOOL_STATUSES = new Set(["pending", "in_progress", "completed", "failed"]);
182
197
  const TOKENS = globalThis.VIBEROOM_TOKENS;
183
- const FALLBACK_COLOR = TOKENS.current.elements.participant.fallback;
198
+ const FALLBACK_COLOR = () => TOKENS.active().elements.face.fallback;
184
199
  const WORKING_SVG = '<svg class="working" viewBox="0 0 44 35" aria-hidden="true" title="working…">'
185
200
  + '<g class="body">'
186
201
  + '<circle cx="20" cy="6.5" r="5.6" fill="currentColor"/>'
@@ -825,9 +840,13 @@
825
840
  function diagramSettings(d) {
826
841
  return d || (state.settings && state.settings.diagrams) || {};
827
842
  }
843
+ function diagramPreset(d) {
844
+ const look = TOKENS.active();
845
+ return TOKENS.diagrams.forScheme(DIAGRAM_PRESETS[d.preset] || DIAGRAM_PRESETS.pop, look.scheme, look.elements.diagram.nodeInk);
846
+ }
828
847
  function mermaidThemeVariables(d) {
829
848
  d = diagramSettings(d);
830
- const preset = DIAGRAM_PRESETS[d.preset] || DIAGRAM_PRESETS.pop;
849
+ const preset = diagramPreset(d);
831
850
  const vars = Object.assign({}, preset, { fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--font").trim() || "Nunito, sans-serif", fontSize: "13px" });
832
851
  delete vars.label;
833
852
  delete vars.palette;
@@ -841,19 +860,19 @@
841
860
  }
842
861
  function diagramPalette(d) {
843
862
  d = diagramSettings(d);
844
- const preset = DIAGRAM_PRESETS[d.preset] || DIAGRAM_PRESETS.pop;
863
+ const preset = diagramPreset(d);
845
864
  return preset.palette && !d.primary ? preset.palette : null;
846
865
  }
847
- const MERMAID_CSS = [
866
+ const mermaidCss = () => [
848
867
  ".node rect, .node .label-container, .node .basic, .cluster rect, rect.actor { rx: 12px; ry: 12px; }",
849
- `.node .label-container, .node .basic, .node rect, .node circle, .node ellipse, rect.actor { stroke-width: 1.8px; filter: drop-shadow(0 2px 0 ${TOKENS.current.elements.diagram.nodeShadow}); }`,
868
+ `.node .label-container, .node .basic, .node rect, .node circle, .node ellipse, rect.actor { stroke-width: 1.8px; filter: drop-shadow(0 2px 0 ${TOKENS.active().elements.diagram.nodeShadow}); }`,
850
869
  ".edgePath .path, .flowchart-link, .messageLine0, .messageLine1, .transition, .relation { stroke-width: 2px; }",
851
870
  ".edgeLabel, .edgeLabel p { font-weight: 700; }",
852
871
  ".cluster rect { stroke-dasharray: 4 3; stroke-width: 1.5px; }",
853
872
  ".cluster-label, .cluster-label p { font-weight: 800; }",
854
873
  ].join(" ");
855
874
  function paintDiagram(root, palette) {
856
- const ink = TOKENS.current.elements.diagram.nodeInk;
875
+ const ink = TOKENS.active().elements.diagram.nodeInk;
857
876
  const byKey = new Map();
858
877
  const pick = (key) => {
859
878
  if (!byKey.has(key)) byKey.set(key, palette[byKey.size % palette.length]);
@@ -911,7 +930,7 @@
911
930
  theme: "base",
912
931
  securityLevel: "strict",
913
932
  themeVariables: mermaidThemeVariables(d),
914
- themeCSS: MERMAID_CSS,
933
+ themeCSS: mermaidCss(),
915
934
  flowchart: { curve: "basis", padding: 14, nodeSpacing: 44, rankSpacing: 52 },
916
935
  });
917
936
  for (const block of blocks) {
@@ -993,7 +1012,7 @@
993
1012
  }
994
1013
  function meAvatarData() {
995
1014
  const s = state.settings || {};
996
- return { name: s.humanName || "You", color: TOKENS.current.elements.participant.humanInk, avatar: s.humanAvatar, kind: "human" };
1015
+ return { name: s.humanName || "You", color: TOKENS.active().elements.face.humanInk, avatar: s.humanAvatar, kind: "human" };
997
1016
  }
998
1017
  function copyableHtml(el) {
999
1018
  const clone = (el.querySelector(".text > .words") || el.querySelector(".text")).cloneNode(true);
@@ -1044,11 +1063,26 @@
1044
1063
  t.hidden = !t.hidden;
1045
1064
  b.classList.toggle("on", !t.hidden);
1046
1065
  });
1066
+ document.addEventListener("click", (e) => {
1067
+ const step = e.target.closest && e.target.closest('[data-ui="number-field"] > .steps > button');
1068
+ if (!step) return;
1069
+ e.preventDefault();
1070
+ const input = step.closest('[data-ui="number-field"]').querySelector("input");
1071
+ if (!input || input.disabled) return;
1072
+ try {
1073
+ if (step.dataset.act === "up") input.stepUp();
1074
+ else input.stepDown();
1075
+ } catch {
1076
+ return;
1077
+ }
1078
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1079
+ input.dispatchEvent(new Event("change", { bubbles: true }));
1080
+ });
1047
1081
  function field(label, inputHtml, hint, geekText) {
1048
1082
  return `<label class="field"><span class="label">${label}${geekText ? geekTip(geekText) : ""}</span>${inputHtml}${hint ? `<span class="hint">${hint}</span>` : ""}</label>`;
1049
1083
  }
1050
- function vendorLogo(r) {
1051
- return `<span class="vc-logo">${r.icon ? `<img src="${esc(r.icon)}" alt="" onerror="this.replaceWith(document.createTextNode('${esc(r.vendor[0])}'))">` : esc(r.vendor[0])}</span>`;
1084
+ function vendorLogo(r, size) {
1085
+ return UI.html("logo-tile", { icon: r.icon || "", letter: r.vendor[0], size: size || "md", title: r.vendor });
1052
1086
  }
1053
1087
  function slowTasksHtml() {
1054
1088
  if (!slowTasks.length) return '<p class="hint">Nothing over 8 ms so far in this window.</p>';
@@ -1146,11 +1180,18 @@
1146
1180
  return h % 360;
1147
1181
  }
1148
1182
  function roomMark(room, lg) {
1149
- const hue = roomHue(room);
1150
1183
  const emoji = (room.settings && room.settings.emoji) || "";
1151
- if (emoji) return `<span class="room-mark emoji${lg ? " lg" : ""}" style="--room-hue:${hue}">${esc(emoji)}</span>`;
1152
- const letter = (room.name.trim()[0] || "?").toUpperCase();
1153
- return `<span class="room-mark${lg ? " lg" : ""}" style="--room-hue:${hue};--room-hue-2:${(hue + 30) % 360}">${esc(letter)}</span>`;
1184
+ return UI.html("room-mark", { hue: roomHue(room), emoji: emoji || undefined, letter: emoji ? undefined : room.name.trim()[0] || "?", size: lg ? "lg" : "md" });
1185
+ }
1186
+ function lookSampleHtml() {
1187
+ const withIcon = state.recipes.filter((r) => r.icon);
1188
+ const lead = withIcon[0] || state.recipes[0];
1189
+ const sam = { name: "Sam", kind: "agent", color: TOKENS.active().elements.face.avatarDefault, avatar: "🦊", agentType: lead ? lead.id : "", agentLabel: lead ? lead.vendor : "" };
1190
+ const marks = (withIcon.length ? withIcon : state.recipes).slice(0, 3).map((r) => vendorLogo(r, "sm")).join("");
1191
+ return `<div class="msg agent"><div class="bubble-col"><div class="head"><span class="head-av">${avatar(sam, 32, { vendor: true })}</span><span class="name" style="color:${sam.color}">Sam</span><span class="time">16:21</span></div><div class="bubble">A reply, on this look's paper, in its ink, with <code>code</code> a step smaller.</div><div class="meta"><span>seen</span></div></div></div>
1192
+ <div class="msg mine"><div class="bubble-col"><div class="bubble">Your message.</div></div></div>
1193
+ <div class="sample-row">${UI.html("hub-row", { text: "Sam finished the reply started at 16:21 · 2m 30s", ref: "sample", title: "a row the hub writes", face: UI.raw(avatar(sam, 20, {})) })}</div>
1194
+ <div class="sample-row">${UI.html("room-mark", { hue: 200, letter: "V", title: "a room" })}${UI.html("room-mark", { hue: 40, emoji: "🎭", title: "a room with an emoji" })}${marks}${UI.html("button", { label: "Send", kind: "primary", size: "sm" })}</div>`;
1154
1195
  }
1155
1196
  function roomTitle(room) {
1156
1197
  const emoji = (room.settings && room.settings.emoji) || "";
@@ -1168,10 +1209,11 @@
1168
1209
 
1169
1210
  function attachScrollHints(box) {
1170
1211
  if (!box || box.dataset.hinted) return;
1212
+ const inside = box.tagName === "DIALOG";
1171
1213
  const frame = box.parentElement;
1172
- if (!frame) return;
1214
+ if (!frame && !inside) return;
1173
1215
  box.dataset.hinted = "1";
1174
- if (getComputedStyle(frame).position === "static") frame.style.position = "relative";
1216
+ if (!inside && getComputedStyle(frame).position === "static") frame.style.position = "relative";
1175
1217
  const make = (dir) => {
1176
1218
  const b = document.createElement("button");
1177
1219
  b.type = "button";
@@ -1180,7 +1222,13 @@
1180
1222
  b.innerHTML = ic(dir === "up" ? "chevrons-up" : "chevrons-down");
1181
1223
  b.hidden = true;
1182
1224
  b.addEventListener("click", () => box.scrollBy({ top: (dir === "up" ? -1 : 1) * Math.max(120, box.clientHeight * 0.85), behavior: "smooth" }));
1183
- frame.appendChild(b);
1225
+ if (inside) {
1226
+ const slot = document.createElement("div");
1227
+ slot.className = `scroll-hint-slot ${dir}`;
1228
+ slot.appendChild(b);
1229
+ if (dir === "up") box.prepend(slot);
1230
+ else box.append(slot);
1231
+ } else frame.appendChild(b);
1184
1232
  return b;
1185
1233
  };
1186
1234
  const up = make("up");
@@ -1427,7 +1475,7 @@
1427
1475
  renderRailRooms();
1428
1476
  const s = state.settings;
1429
1477
  if (s) {
1430
- els.railMeAvatar.innerHTML = avatar(meAvatarData(), 44, {});
1478
+ els.railMeAvatar.innerHTML = avatar(meAvatarData(), 44, { kind: "bare" });
1431
1479
  els.railMeLabel.textContent = s.humanName;
1432
1480
  }
1433
1481
  const open = els.app.classList.contains("rail-open");
@@ -1510,7 +1558,7 @@
1510
1558
  const q = state.roomSearch.toLowerCase();
1511
1559
  const all = document.createElement("li");
1512
1560
  all.className = state.skillsRoom ? "" : "selected";
1513
- all.innerHTML = `<span class="room-mark" style="background:var(--grad-primary)">${ic("skills")}</span><div class="p-body"><div class="p-name"><span>All skills</span></div><div class="p-preview">${(state.skills || []).length} in the library</div></div>`;
1561
+ all.innerHTML = `${UI.html("room-mark", { icon: "skills", title: "All skills" })}<div class="p-body"><div class="p-name"><span>All skills</span></div><div class="p-preview">${(state.skills || []).length} in the library</div></div>`;
1514
1562
  all.addEventListener("click", () => {
1515
1563
  state.skillsRoom = null;
1516
1564
  renderSideSkills();
@@ -1568,6 +1616,7 @@
1568
1616
  { tone: "peach", emoji: "🧩", title: "Skills", text: "Reusable instructions in your library. Attach them to vibemates, invoke one with /name, or let a vibemate write its own." },
1569
1617
  { tone: "rose", emoji: "🤫", title: "Hush", text: "Too much at once? One click stops every running reply. The vibemates stay quiet until you write again." },
1570
1618
  { tone: "sky", emoji: "📊", title: "Links and diagrams", text: "Links and file paths open on your machine with one click. A ```mermaid block in a reply turns into a diagram." },
1619
+ { tone: "orchid", emoji: "🎨", title: "Looks", text: "VibeClassic and its dark twin, Terminal, Clay, Eye Comfort (the look the research on tired eyes asks for) or 3D clayful (soft clay objects, with weight): pick one with the button above or under Settings → Appearance, and fine-tune its paper, bubbles, ink and corners." },
1571
1620
  ];
1572
1621
  const STEPS = [
1573
1622
  { n: 1, title: "Open a room", text: "Give it a name and a folder. The folder is where the vibemates read and write." },
@@ -1579,7 +1628,7 @@
1579
1628
  const name = s.humanName || "there";
1580
1629
  const rooms = sortedRooms().slice(0, 4);
1581
1630
  const vendors = state.recipes.filter((r) => !r.unavailableReason);
1582
- const vendorLogos = (vendors.length ? vendors : state.recipes).map((r) => `<span class="home-vendor" title="${esc(r.vendor)}${r.unavailableReason ? " (not installed)" : ""}"${r.unavailableReason ? ' style="opacity:.45"' : ""}>${vendorLogo(r)}</span>`).join("");
1631
+ const vendorLogos = (vendors.length ? vendors : state.recipes).map((r) => `<span class="home-vendor" title="${esc(r.vendor)}${r.unavailableReason ? " (not installed)" : ""}"${r.unavailableReason ? ' style="opacity:.45"' : ""}>${vendorLogo(r, "sm")}</span>`).join("");
1583
1632
  els.homeView.innerHTML = `
1584
1633
  <div class="home-inner">
1585
1634
  <section class="hero">
@@ -1589,6 +1638,7 @@
1589
1638
  <p>One chat, many coding agents. Summon your vibemates into a room, talk to all of them at once, and let them talk to each other.</p>
1590
1639
  <div class="hero-actions">
1591
1640
  ${UI.html("button", { label: "Open room", icon: "rooms", size: "cta", id: "home-open-room", hook: "hero-cta" })}
1641
+ ${UI.html("button", { label: "Change the look", icon: "eye", size: "cta", id: "home-change-look", hook: "hero-cta", title: "Settings → Appearance: the looks and their fine-tuning" })}
1592
1642
  </div>
1593
1643
  </div>
1594
1644
  <div class="hero-art" aria-hidden="true">
@@ -1608,7 +1658,7 @@
1608
1658
  return `<button class="home-room" data-room="${esc(room.id)}">${roomMark(room)}<span class="hr-body"><span class="hr-name">${esc(room.name)}</span><span class="hr-sub">${esc(last ? `${last.fromName}: ${String(last.text).replace(/\s+/g, " ").slice(0, 70)}` : room.settings.topic || `${st.agents.length} vibemate${st.agents.length === 1 ? "" : "s"}`)}</span></span>${st.unread ? `<span class="count-pill">${st.unread}</span>` : `<span class="hr-time">${esc(relTime(st.last))}</span>`}</button>`;
1609
1659
  })
1610
1660
  .join("")}
1611
- ${rooms.length ? "" : `<div class="home-room empty-room"><span class="room-mark" style="background:var(--grad-primary)">${ic("plus")}</span><span class="hr-body"><span class="hr-name">No rooms yet</span><span class="hr-sub">Open one, summon a vibemate, say hello.</span></span></div>`}
1661
+ ${rooms.length ? "" : `<div class="home-room empty-room">${UI.html("room-mark", { icon: "plus" })}<span class="hr-body"><span class="hr-name">No rooms yet</span><span class="hr-sub">Open one, summon a vibemate, say hello.</span></span></div>`}
1612
1662
  </div>
1613
1663
  </section>
1614
1664
  <section class="home-section">
@@ -1640,6 +1690,15 @@
1640
1690
  setView("rooms");
1641
1691
  remember("view", "rooms");
1642
1692
  });
1693
+ const changeLook = $("#home-change-look");
1694
+ if (changeLook) changeLook.addEventListener("click", () => {
1695
+ setView("settings");
1696
+ remember("view", "settings");
1697
+ requestAnimationFrame(() => {
1698
+ const section = $("#sp-appearance");
1699
+ if (section) section.scrollIntoView({ behavior: "smooth", block: "start" });
1700
+ });
1701
+ });
1643
1702
  const all = $("#home-all-rooms");
1644
1703
  if (all) all.addEventListener("click", () => setView("rooms"));
1645
1704
  els.homeView.querySelectorAll(".home-room[data-room]").forEach((b) => b.addEventListener("click", () => selectRoom(b.dataset.room)));
@@ -1666,7 +1725,7 @@
1666
1725
  const st = roomStats(room);
1667
1726
  const card = document.createElement("div");
1668
1727
  card.className = `card hover room-card${room.id === state.currentRoomId ? " current" : ""}`;
1669
- const faces = st.agents.slice(0, 5).map((p) => avatar(p, 26, { vendor: true })).join("");
1728
+ const faces = st.agents.slice(0, 5).map((p) => avatar(p, 26, { vendor: true, ring: true })).join("");
1670
1729
  card.innerHTML = `
1671
1730
  <div class="rc-head"><div class="rc-title">${roomMark(room)}<h3>${esc(room.name)}</h3></div>${st.unread ? `<span class="count-pill">${st.unread}</span>` : st.thinking ? '<span class="live-dot" title="a vibemate is replying"></span>' : ""}</div>
1672
1731
  <div class="rc-topic">${esc(room.settings.topic || (st.chats.length ? String(st.chats[st.chats.length - 1].text).slice(0, 140) : "Nothing said yet."))}</div>
@@ -1706,8 +1765,8 @@
1706
1765
  : asleep
1707
1766
  ? `<span class="zzz" title="${esc(STATUS_LABEL[p.status] || p.status)}">zzz</span>`
1708
1767
  : p.kind === "agent" && p.status !== "idle" ? UI.html("badge", { label: STATUS_LABEL[shown] || shown, tone: STATUS_TONE[shown] || "plain", dot: p.status === "thinking" }) : "";
1709
- const avatarHtml = avatar(p.kind === "human" ? meAvatarData() : p, 44, { vendor: true, muted: p.muted });
1710
- const statusClass = p.kind === "agent" ? `avatar-status status-${esc(shown || "idle")}` : "";
1768
+ const avatarHtml = avatar(p.kind === "human" ? meAvatarData() : p, 44, { vendor: true, muted: p.muted, me: p.kind === "human", dim: unstaffed ? "unstaffed" : asleep ? "asleep" : undefined });
1769
+ const statusValue = p.kind === "agent" ? shown || "idle" : "";
1711
1770
  const bodyHtml = `<div class="p-body">
1712
1771
  <div class="p-name"><span>${esc(p.name)}</span>${p.muted ? UI.html("badge", { label: "muted", tone: "muted" }) : ""}${status}</div>
1713
1772
  <div class="p-sub">${esc(sub)}</div>
@@ -1721,16 +1780,16 @@
1721
1780
  li.dataset.id = p.id;
1722
1781
  li.innerHTML = avatarHtml + bodyHtml + (p.kind === "agent" ? UI.html("row-button", { icon: "settings", title: `Open ${p.name}'s panel`, act: "panel" }) + UI.html("row-button", { icon: "last-reply", title: `Go to ${p.name}'s last reply`, act: "last-reply" }) : "");
1723
1782
  li.dataset.avatar = avatarHtml;
1724
- if (statusClass) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="${statusClass}"></span>`);
1783
+ if (statusValue) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="status" data-status="${esc(statusValue)}"></span>`);
1725
1784
  li.dataset.body = bodyHtml;
1726
1785
  } else {
1727
1786
  if (li.dataset.avatar !== avatarHtml) {
1728
1787
  li.querySelector(".avatar").outerHTML = avatarHtml;
1729
1788
  li.dataset.avatar = avatarHtml;
1730
1789
  }
1731
- const dot = li.querySelector(".avatar-status");
1732
- if (statusClass && dot && dot.className !== statusClass) dot.className = statusClass;
1733
- else if (statusClass && !dot) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="${statusClass}"></span>`);
1790
+ const dot = li.querySelector(".avatar .status");
1791
+ if (statusValue && dot && dot.dataset.status !== statusValue) dot.dataset.status = statusValue;
1792
+ else if (statusValue && !dot) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="status" data-status="${esc(statusValue)}"></span>`);
1734
1793
  if (li.dataset.body !== bodyHtml) {
1735
1794
  li.querySelectorAll(".p-body, .p-actions").forEach((el) => el.remove());
1736
1795
  li.insertAdjacentHTML("beforeend", bodyHtml);
@@ -1851,12 +1910,12 @@
1851
1910
  el.innerHTML = UI.html("hub-row", { text: m.text, tone, ref: details.refId || undefined, face: face ? UI.raw(face) : undefined, title: details.refId ? `${fullTime(m.ts)} · go to the reply` : fullTime(m.ts) });
1852
1911
  return el;
1853
1912
  }
1854
- const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR, kind: m.from === "human" ? "human" : "agent" };
1913
+ const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR(), kind: m.from === "human" ? "human" : "agent" };
1855
1914
  const mine = m.from === "human";
1856
1915
  el.className = "msg " + (mine ? "mine" : "agent");
1857
1916
  el.innerHTML = `
1858
1917
  <div class="bubble-col">
1859
- <div class="head"><span class="head-av">${avatar(mine ? Object.assign(meAvatarData(), { color: p.color }) : p, 32, { vendor: true })}</span><span class="name" style="color:${p.color}">${esc(m.fromName)}</span><span class="edited" hidden></span>${mine ? UI.html("icon-button", { icon: "pencil", title: "Edit this message", kind: "ghost", size: "xs", act: "edit" }) : ""}${UI.html("icon-button", { icon: "quote", title: "Quote this message in your next one", kind: "ghost", size: "xs", act: "quote" })}${UI.html("icon-button", { icon: "pin", title: "Pin this message", kind: "ghost", size: "xs", act: "pin" })}${UI.html("icon-button", { icon: "copy", title: "Copy this message, formatted; the tool calls stay here", kind: "ghost", size: "xs", act: "copy" })}<span class="time" title="${esc(fullTime(m.ts))}">${time(m.ts)}</span></div>
1918
+ <div class="head"><span class="head-av">${avatar(mine ? Object.assign(meAvatarData(), { color: p.color }) : p, 32, { vendor: true, me: mine })}</span><span class="name" style="color:${p.color}">${esc(m.fromName)}</span><span class="edited" hidden></span>${mine ? UI.html("icon-button", { icon: "pencil", title: "Edit this message", kind: "ghost", size: "xs", act: "edit" }) : ""}${UI.html("icon-button", { icon: "quote", title: "Quote this message in your next one", kind: "ghost", size: "xs", act: "quote" })}${UI.html("icon-button", { icon: "pin", title: "Pin this message", kind: "ghost", size: "xs", act: "pin" })}${UI.html("icon-button", { icon: "copy", title: "Copy this message, formatted; the tool calls stay here", kind: "ghost", size: "xs", act: "copy" })}<span class="time" title="${esc(fullTime(m.ts))}">${time(m.ts)}</span></div>
1860
1919
  <div class="bubble">
1861
1920
  ${m.skill ? `<div class="skill-invoke" title="skill invocation: the vibemates that have this skill got its instructions with this message">${ic("skills")} skill <b>${esc(m.skill.name)}</b></div>` : ""}
1862
1921
  <div class="edit-box" hidden></div>
@@ -2106,8 +2165,17 @@
2106
2165
  el.classList.toggle("hidden-by-search", !messageMatches(m));
2107
2166
  const wasPinned = el.classList.contains("pinned");
2108
2167
  el.classList.toggle("pinned", !!m.pinned);
2109
- const pinBtn = el.querySelector('[data-act="pin"]');
2168
+ const pinBtn = el.querySelector('.head [data-act="pin"]');
2110
2169
  if (pinBtn) pinBtn.title = m.pinned ? "Unpin this message" : "Pin this message";
2170
+ const bubble = el.querySelector(".bubble");
2171
+ const mark = bubble && bubble.querySelector(":scope > .pin-mark");
2172
+ if (m.pinned && bubble && !mark) {
2173
+ bubble.insertAdjacentHTML("beforeend", UI.html("icon-button", { icon: "pin", title: "Pinned: click to unpin", kind: "primary", size: "xs", act: "unpin", hook: "pin-mark" }));
2174
+ bubble.querySelector(":scope > .pin-mark").addEventListener("click", (e) => {
2175
+ e.stopPropagation();
2176
+ if (pinBtn) pinBtn.click();
2177
+ });
2178
+ } else if (!m.pinned && mark) mark.remove();
2111
2179
  if (wasPinned !== !!m.pinned) renderTimeline();
2112
2180
  const editedEl = el.querySelector(".edited");
2113
2181
  if (editedEl) {
@@ -2289,12 +2357,9 @@
2289
2357
  return [...visibilityMarkers(room).entries()].map(([seq, ps]) => `${seq}:${ps.map((p) => p.id).join(",")}`).sort().join("|");
2290
2358
  }
2291
2359
  function dividerElement(agents) {
2292
- const el = document.createElement("div");
2293
- el.className = "visibility-divider";
2294
2360
  const names = agents.map((p) => p.name);
2295
2361
  const label = names.length === 1 ? `${names[0]} has not seen anything above this line` : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]} have not seen anything above this line`;
2296
- el.innerHTML = `<span class="vd-line"></span><span class="vd-label" title="Vibemates know the room only from their own starting point: the join, a replay of the last N messages, or a restored session.">↑ ${esc(label)}</span><span class="vd-line"></span>`;
2297
- return el;
2362
+ return UI.el("unseen-line", { label, title: "Vibemates know the room only from their own starting point: the join, a replay of the last N messages, or a restored session." });
2298
2363
  }
2299
2364
 
2300
2365
  const PAGE_SIZE = 50;
@@ -2706,12 +2771,12 @@
2706
2771
  </div>
2707
2772
  <div class="section" id="pp-timing">
2708
2773
  ${sectionTitle("bolt", "Timing")}
2709
- ${field("Reply delay override, seconds", `<input type="number" id="pp-delay" min="0" max="120" step="0.5" value="${p.replyDelay ?? ""}" placeholder="the room's: ${room.settings.replyDelay ?? 4} s">`, `Overrides the room's delay (${room.settings.replyDelay ?? 4} s, used only when two or more vibemates are in) for this vibemate only, even when it is alone. Empty: it follows the room.`, "Before each turn the vibemate waits a random 0–N seconds, so replies cross less often. Messages that arrive during the wait land in its backlog, so it can react to them or stay silent.")}
2774
+ ${field("Reply delay override, seconds", `${UI.html("number-field", { id: "pp-delay", value: String(p.replyDelay ?? ""), min: 0, max: 120, step: 0.5, placeholder: `the room's: ${room.settings.replyDelay ?? 4} s` })}`, `Overrides the room's delay (${room.settings.replyDelay ?? 4} s, used only when two or more vibemates are in) for this vibemate only, even when it is alone. Empty: it follows the room.`, "Before each turn the vibemate waits a random 0–N seconds, so replies cross less often. Messages that arrive during the wait land in its backlog, so it can react to them or stay silent.")}
2710
2775
  </div>
2711
2776
  <div class="section danger">
2712
2777
  ${sectionTitle("bolt", "Respawn")}
2713
2778
  <p class="hint">${esc(p.name)} comes back with an empty head: it forgets this conversation entirely. The room's history stays and you still see everything.${geekTip("A session's context cannot be erased, so the vibemate's process and session are closed and it starts a new one with no replay. Its stored session is dropped too, or a later reconnect would bring the old context back. Same thing as typing /respawn @Name in the composer.")}</p>
2714
- <div class="row-btns start">${UI.html("button", { label: `Respawn ${p.name}`, icon: "bolt", kind: "danger", size: "sm", act: "respawn" })}<label class="lp-with">${UI.html("button", { label: "With the last", size: "sm", act: "respawn-mem" })}<input type="number" id="pp-respawn-n" min="0" max="500" value="${room.settings.replayAfterRestart ?? 10}"> messages</label></div>
2779
+ <div class="row-btns start">${UI.html("button", { label: `Respawn ${p.name}`, icon: "bolt", kind: "danger", size: "sm", act: "respawn" })}<label class="lp-with">${UI.html("button", { label: "With the last", size: "sm", act: "respawn-mem" })}${UI.html("number-field", { id: "pp-respawn-n", value: String(room.settings.replayAfterRestart ?? 10), min: 0, max: 500 })} messages</label></div>
2715
2780
  <p class="hint">With memory: a new session that gets only ${p.notes ? "its own notes and " : ""}the last N messages of this room; the rest is gone.</p>
2716
2781
  </div>
2717
2782
  <div class="section">
@@ -2814,7 +2879,7 @@
2814
2879
  els.detailsInner.innerHTML = `
2815
2880
  ${panelTitle("Your vibe", "how the vibemates know you")}
2816
2881
  <div class="profile">
2817
- ${avatar(meAvatarData(), 84, {})}
2882
+ ${avatar(meAvatarData(), 84, { kind: "card" })}
2818
2883
  <h3>${esc(s.humanName || "")}</h3>
2819
2884
  <div class="tagline">${esc(s.humanDescription || "no vibe line yet")}</div>
2820
2885
  </div>
@@ -2868,7 +2933,7 @@
2868
2933
  <div class="section">
2869
2934
  ${sectionTitle("user", "Turn taking")}
2870
2935
  ${field("Who may speak", `<select id="rp-turns"><option value="one-at-a-time"${rs.turnTaking !== "parallel" ? " selected" : ""}>One vibemate at a time</option><option value="parallel"${rs.turnTaking === "parallel" ? " selected" : ""}>All addressed vibemates at once</option></select>`, null, "One at a time: the others queue and see the earlier replies before they answer; the addressed vibemates go first. All at once: fastest, but replies may cross.")}
2871
- ${field("Reply delay, seconds", `<input type="number" id="rp-delay" min="0" max="120" step="0.5" value="${rs.replyDelay ?? 4}">`, "With two or more vibemates, each waits a random 0–N seconds before it answers, so replies cross less often. A vibemate alone answers at once. A vibemate's own delay (in its panel) always applies.")}
2936
+ ${field("Reply delay, seconds", `${UI.html("number-field", { id: "rp-delay", value: String(rs.replyDelay ?? 4), min: 0, max: 120, step: 0.5 })}`, "With two or more vibemates, each waits a random 0–N seconds before it answers, so replies cross less often. A vibemate alone answers at once. A vibemate's own delay (in its panel) always applies.")}
2872
2937
  <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>
2873
2938
  <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>
2874
2939
  </div>
@@ -2890,8 +2955,8 @@
2890
2955
  <div class="section">
2891
2956
  ${sectionTitle("chat", "Conversation")}
2892
2957
  ${field("Vibemates' own tools (files, shell, web)", `<select id="rp-tools"><option value="on-request"${rs.tools === "on-request" ? " selected" : ""}>Only when someone explicitly asks</option><option value="never"${rs.tools === "never" ? " selected" : ""}>Never (chat only)</option></select>`, null, "An instruction in every vibemate's brief; the vibemate's mode is the real limit.")}
2893
- ${field("Max sentences per reply", `<input type="number" id="rp-maxlen" min="1" max="100" value="${rs.maxSentences ?? ""}" placeholder="no limit">`)}
2894
- ${field("Hop limit (vibemate-to-vibemate replies per human message)", `<input type="number" id="rp-hops" min="0" max="10000" value="${rs.hopLimit}">`)}
2958
+ ${field("Max sentences per reply", `${UI.html("number-field", { id: "rp-maxlen", value: String(rs.maxSentences ?? ""), min: 1, max: 100, placeholder: "no limit" })}`)}
2959
+ ${field("Hop limit (vibemate-to-vibemate replies per human message)", `${UI.html("number-field", { id: "rp-hops", value: String(rs.hopLimit), min: 0, max: 10000 })}`)}
2895
2960
  </div>
2896
2961
  <div class="section">
2897
2962
  ${sectionTitle("eye", "Referee")}
@@ -2899,12 +2964,12 @@
2899
2964
  </div>
2900
2965
  <div class="section">
2901
2966
  ${sectionTitle("save", "Instruction delivery")}
2902
- ${field("Full brief every N vibemate turns", `<input type="number" id="rp-brief-turns" min="1" max="10000" value="${rs.fullBriefEveryTurns}">`)}
2903
- ${field("…or every N new context tokens", `<input type="number" id="rp-brief-tokens" min="1000" max="10000000" step="1000" value="${rs.fullBriefEveryTokens}">`)}
2967
+ ${field("Full brief every N vibemate turns", `${UI.html("number-field", { id: "rp-brief-turns", value: String(rs.fullBriefEveryTurns), min: 1, max: 10000 })}`)}
2968
+ ${field("…or every N new context tokens", `${UI.html("number-field", { id: "rp-brief-tokens", value: String(rs.fullBriefEveryTokens), min: 1000, max: 10000000, step: 1000 })}`)}
2904
2969
  <label class="switch"><span class="label">Repeat core rules in every header</span><input type="checkbox" id="rp-header-rules" ${rs.headerRules ? "checked" : ""}></label>
2905
2970
  <label class="switch"><span class="label">Show vendor and model to other vibemates</span><input type="checkbox" id="rp-vendor" ${rs.showVendorInRoster ? "checked" : ""}></label>
2906
- ${field("Replay last N chat messages after a reconnect", `<input type="number" id="rp-replay" min="0" max="200" value="${rs.replayAfterRestart}">`)}
2907
- ${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.")}
2971
+ ${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
+ ${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.")}
2908
2973
  </div>`,
2909
2974
  "tools, hops, referee, briefs",
2910
2975
  )}
@@ -2976,7 +3041,7 @@
2976
3041
  .map((r) => {
2977
3042
  const v = (s.vendorPresets || {})[r.id] || {};
2978
3043
  return `<div class="vendor-card">
2979
- <div class="vc-head"><span class="vc-logo">${r.icon ? `<img src="${esc(r.icon)}" alt="" onerror="this.replaceWith(document.createTextNode('${esc(r.vendor[0])}'))">` : esc(r.vendor[0])}</span><span class="vc-name">${esc(r.vendor)}</span>${UI.html("badge", { label: "installed", tone: "ready", dot: true })}</div>
3044
+ <div class="vc-head">${vendorLogo(r)}<span class="vc-name">${esc(r.vendor)}</span>${UI.html("badge", { label: "installed", tone: "ready", dot: true })}</div>
2980
3045
  <div class="vc-fields">
2981
3046
  ${field("Model", `<input type="text" data-vendor="${r.id}" data-key="model" value="${esc(v.model || "")}" placeholder="${esc(r.defaultModel || "vibemate default")}">`)}
2982
3047
  ${field("Effort", `<input type="text" data-vendor="${r.id}" data-key="effort" value="${esc(v.effort || "")}" placeholder="${esc(r.defaultEffort || "vibemate default")}">`)}
@@ -2985,7 +3050,7 @@
2985
3050
  </div>`;
2986
3051
  })
2987
3052
  .join("");
2988
- const logo = (r) => `<span class="vc-logo">${r.icon ? `<img src="${esc(r.icon)}" alt="" onerror="this.replaceWith(document.createTextNode('${esc(r.vendor[0])}'))">` : esc(r.vendor[0])}</span>`;
3053
+ const logo = (r) => vendorLogo(r);
2989
3054
  const machine =
2990
3055
  installed
2991
3056
  .map((r) => {
@@ -3000,6 +3065,31 @@
3000
3065
  <div id="sp-form">
3001
3066
  <div class="page-cols">
3002
3067
  <div>
3068
+ <div class="section" id="sp-appearance">
3069
+ ${sectionTitle("eye", "Appearance")}
3070
+ ${(() => {
3071
+ const a = s.appearance || {};
3072
+ const lookId = TOKENS.looks[a.look] ? a.look : TOKENS.current.id;
3073
+ const look = TOKENS.looks[lookId];
3074
+ 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("");
3076
+ const groups = [...new Set(TOKENS.adjustables.map((f) => f.group))];
3077
+ 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
+ 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.")}
3080
+ <div class="look-adjust" id="sp-look-adjust">
3081
+ <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
+ <p class="hint">Kept for this look alone. The sample follows as you pick; the window follows on save.</p>
3083
+ ${groups.map((g) => `<div class="adjust-group"><h5>${esc(g)}</h5>${rows(g)}</div>`).join("")}
3084
+ </div>`;
3085
+ })()}
3086
+ <div class="adjust-group text"><h5>Text</h5>
3087
+ <div class="field row"><span class="label">Size, px<span class="hint">Only the text changes size; the boxes, buttons and panels stay. 14.5 is the default.</span></span>${UI.html("number-field", { id: "sp-chat-fs", value: String((s.appearance || {}).chatFontSize || 14.5), min: 12, max: 24, step: 0.5 })}</div>
3088
+ <div class="field row"><span class="label">Font${geekTip("The named fonts come with viberoom and look the same on every OS (all free, with Cyrillic); the system entries use what this machine has. A look with a font of its own (Terminal, Clay) keeps it while this stays at the default.")}</span><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></div>
3089
+ <div class="field row"><span class="label">Code font<span class="hint">For code blocks, paths and tool output.</span></span><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></div>
3090
+ <div class="bubble" id="sp-chat-sample" style="display:inline-block;font-size:${(s.appearance || {}).chatFontSize || 14.5}px">Messages will read like this, with <code>code</code> a step smaller.</div>
3091
+ </div>
3092
+ </div>
3003
3093
  <div class="section">
3004
3094
  ${sectionTitle("lock", "Permissions")}
3005
3095
  <label class="switch"><span class="label">Vibemates act without asking<span class="hint">Off: they ask you before editing files or running commands.</span>${geekTip('New vibemates start in their vendor\'s "act without asking" mode (Claude bypassPermissions, Codex agent-full-access, Gemini yolo, Cursor agent, OpenCode build, Copilot agent + allow_all). Change it per vibemate when summoning one, or later in its panel.')}</span><input type="checkbox" id="sp-bypass" ${s.bypassPermissionsByDefault !== false ? "checked" : ""}></label>
@@ -3007,20 +3097,13 @@
3007
3097
  <div class="section">
3008
3098
  ${sectionTitle("bolt", "Pace")}
3009
3099
  ${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>`)}
3010
- ${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.")}
3011
- </div>
3012
- <div class="section" id="sp-appearance">
3013
- ${sectionTitle("eye", "Appearance")}
3014
- ${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. Only the text changes size with it: the boxes, buttons and panels stay as they are.")}
3015
- ${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.")}
3016
- ${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.")}
3017
- <div class="bubble" id="sp-chat-sample" style="display:inline-block;font-size:${(s.appearance || {}).chatFontSize || 14.5}px">Messages will read like this, with <code>code</code> a step smaller.</div>
3100
+ ${field("Reply delay in new rooms, seconds", `${UI.html("number-field", { id: "sp-delay", value: String(d.replyDelay ?? 4), min: 0, max: 120, step: 0.5 })}`, "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.")}
3018
3101
  </div>
3019
3102
  <div class="section" id="sp-editor">
3020
3103
  ${sectionTitle("pencil", "Open files at a line")}
3021
3104
  <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>
3022
3105
  <div class="chips editor-modes">
3023
- ${UI.html("choice", { label: "Auto", on: !["custom", "default-app"].includes((s.editor || {}).mode), data: { mode: "auto" }, trail: UI.raw('<span class="hint" id="sp-editor-auto">…</span>') })}
3106
+ ${UI.html("choice", { label: "Auto", on: !["custom", "default-app"].includes((s.editor || {}).mode), data: { mode: "auto" } })}
3024
3107
  ${UI.html("choice", { label: "The default app", on: (s.editor || {}).mode === "default-app", data: { mode: "default-app" } })}
3025
3108
  ${UI.html("choice", { label: "My own command", on: (s.editor || {}).mode === "custom", data: { mode: "custom" } })}
3026
3109
  </div>
@@ -3065,9 +3148,9 @@
3065
3148
  <div class="section">
3066
3149
  ${sectionTitle("rooms", "Defaults for new rooms")}
3067
3150
  <p class="field-note">Every new room starts with these; each room can change them in its own settings.</p>
3068
- ${field("Hop limit", `<input type="number" id="sp-hops" min="0" max="10000" value="${d.hopLimit}">`, "How many vibemate-to-vibemate replies may follow one message of yours before the room waits for you again.")}
3069
- ${field("Full brief every N turns", `<input type="number" id="sp-brief-turns" min="1" max="10000" value="${d.fullBriefEveryTurns}">`, "How often a vibemate gets the whole room brief again instead of the short header.")}
3070
- ${field("Full brief every N tokens", `<input type="number" id="sp-brief-tokens" min="1000" max="10000000" step="1000" value="${d.fullBriefEveryTokens}">`, "…or after this much new context since its last full brief, whichever comes first.")}
3151
+ ${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
+ ${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
+ ${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.")}
3071
3154
  <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>
3072
3155
  ${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.")}
3073
3156
  </div>
@@ -3106,12 +3189,6 @@
3106
3189
  showEditorCmd();
3107
3190
  }),
3108
3191
  );
3109
- get("/api/editor")
3110
- .then((r) => {
3111
- const auto = $("#sp-editor-auto");
3112
- if (auto) auto.textContent = r.editor ? `(${r.editor.label})` : "(none found)";
3113
- })
3114
- .catch(() => undefined);
3115
3192
  const diagramSection = $("#sp-diagrams");
3116
3193
  const previewTheme = () => ({ preset: $("#sp-diagram-preset").value, primary: $("#sp-diagram-custom").checked ? $("#sp-diagram-color").value : null });
3117
3194
  const redrawPreview = () => {
@@ -3141,6 +3218,88 @@
3141
3218
  if (px >= 12 && px <= 24) sample.style.fontSize = `${px}px`;
3142
3219
  });
3143
3220
  $("#sp-font").addEventListener("change", () => (sample.style.fontFamily = FONTS.text[$("#sp-font").value].stack));
3221
+ const looksBox = $("#sp-appearance");
3222
+ const pickedLook = () => TOKENS.looks[$("#sp-look").value] || TOKENS.current;
3223
+ const rowOf = (key) => looksBox.querySelector(`[data-ui="adjust-row"][data-key="${key}"]`);
3224
+ const inputOf = (key) => {
3225
+ const row = rowOf(key);
3226
+ return row ? row.querySelector("input") : null;
3227
+ };
3228
+ const pendingAdjust = () => {
3229
+ const look = pickedLook();
3230
+ const out = {};
3231
+ for (const f of TOKENS.adjustables) {
3232
+ const input = inputOf(f.key);
3233
+ const value = String(input ? input.value : "").toLowerCase();
3234
+ if (value && value !== String(f.of(look)).toLowerCase()) out[f.key] = value;
3235
+ }
3236
+ return out;
3237
+ };
3238
+ const paintRows = () => {
3239
+ const look = pickedLook();
3240
+ for (const f of TOKENS.adjustables) {
3241
+ const row = rowOf(f.key);
3242
+ const input = inputOf(f.key);
3243
+ if (!row || !input) continue;
3244
+ const own = String(f.of(look));
3245
+ UI.setState(row, input.value.toLowerCase() !== own.toLowerCase() ? "changed" : null);
3246
+ row.querySelector(".value").textContent = input.value;
3247
+ row.querySelector(".back").title = `Back to the look's own, ${own}`;
3248
+ }
3249
+ };
3250
+ const paintLookPreview = () => {
3251
+ const preview = $("#sp-look-preview");
3252
+ if (!preview) return;
3253
+ const look = pickedLook();
3254
+ preview.dataset.look = look.id;
3255
+ for (const [name, value] of Object.entries(TOKENS.cssVars(look))) preview.style.setProperty(name, value);
3256
+ applyCustomVars(preview, pendingAdjust());
3257
+ paintRows();
3258
+ };
3259
+ const fillRows = (lookId) => {
3260
+ const look = TOKENS.looks[lookId] || TOKENS.current;
3261
+ const custom = ((state.settings.appearance || {}).custom || {})[look.id] || {};
3262
+ for (const f of TOKENS.adjustables) {
3263
+ const input = inputOf(f.key);
3264
+ if (input) input.value = custom[f.key] || String(f.of(look));
3265
+ }
3266
+ $("#sp-adj-name").textContent = look.label;
3267
+ };
3268
+ looksBox.querySelectorAll('.look-cards [data-ui="look-card"]').forEach((b) =>
3269
+ b.addEventListener("click", () => {
3270
+ looksBox.querySelectorAll('.look-cards [data-ui="look-card"]').forEach((x) => {
3271
+ UI.setState(x, x === b ? "on" : null);
3272
+ x.setAttribute("aria-pressed", x === b ? "true" : "false");
3273
+ });
3274
+ const input = $("#sp-look");
3275
+ input.value = b.dataset.look;
3276
+ fillRows(b.dataset.look);
3277
+ paintLookPreview();
3278
+ input.dispatchEvent(new Event("change", { bubbles: true }));
3279
+ }),
3280
+ );
3281
+ looksBox.querySelectorAll('[data-ui="adjust-row"] input').forEach((i) => i.addEventListener("input", paintLookPreview));
3282
+ looksBox.addEventListener("click", (e) => {
3283
+ const back = e.target.closest && e.target.closest('[data-ui="adjust-row"] .back');
3284
+ if (!back) return;
3285
+ const row = back.closest('[data-ui="adjust-row"]');
3286
+ const f = TOKENS.adjustables.find((x) => x.key === row.dataset.key);
3287
+ const input = row.querySelector("input");
3288
+ if (!f || !input) return;
3289
+ input.value = String(f.of(pickedLook()));
3290
+ paintLookPreview();
3291
+ input.dispatchEvent(new Event("change", { bubbles: true }));
3292
+ });
3293
+ looksBox.querySelector(".sp-adj-reset").addEventListener("click", () => {
3294
+ const look = pickedLook();
3295
+ for (const f of TOKENS.adjustables) {
3296
+ const input = inputOf(f.key);
3297
+ if (input) input.value = String(f.of(look));
3298
+ }
3299
+ paintLookPreview();
3300
+ $("#sp-look").dispatchEvent(new Event("change", { bubbles: true }));
3301
+ });
3302
+ paintLookPreview();
3144
3303
  $("#sp-mono").addEventListener("change", () => sample.querySelectorAll("code").forEach((c) => (c.style.fontFamily = FONTS.mono[$("#sp-mono").value].stack)));
3145
3304
  $("#sp-slow-copy").addEventListener("click", async () => {
3146
3305
  const b = $("#sp-slow-copy");
@@ -3193,7 +3352,7 @@
3193
3352
  checkForUpdates: $("#sp-updates").checked,
3194
3353
  diagrams: { preset: $("#sp-diagram-preset").value, primary: $("#sp-diagram-custom").checked ? $("#sp-diagram-color").value : null },
3195
3354
  editor: { mode: $("#sp-editor-mode").value, command: $("#sp-editor-cmd").value },
3196
- appearance: { chatFontSize: Number($("#sp-chat-fs").value), font: $("#sp-font").value, mono: $("#sp-mono").value },
3355
+ appearance: { chatFontSize: Number($("#sp-chat-fs").value), font: $("#sp-font").value, mono: $("#sp-mono").value, look: $("#sp-look").value, custom: { [$("#sp-look").value]: Object.keys(pendingAdjust()).length ? pendingAdjust() : null } },
3197
3356
  roomDefaults: {
3198
3357
  turnTaking: $("#sp-turns").value,
3199
3358
  replyDelay: Number($("#sp-delay").value),
@@ -3580,7 +3739,7 @@
3580
3739
  els.invAgents.innerHTML = state.recipes
3581
3740
  .map(
3582
3741
  (r) =>
3583
- `<button type="button" class="agent-tile${r.unavailableReason ? " off" : ""}" data-agent="${esc(r.id)}" title="${esc(r.unavailableReason ? `Not installed on this machine. ${r.installHint || ""}` : r.loginState === "missing" ? `Found at ${r.installedAt || "bundled"}, but not logged in: run ${r.loginCommand}` : `Found at ${r.installedAt || "bundled"}`)}"><span class="at-logo">${vendorLogo(r)}</span><span class="at-name">${esc(r.vendor)}</span><span class="at-sub">${r.unavailableReason ? "not installed" : r.loginState === "missing" ? "no login" : "installed"}</span></button>`,
3742
+ `<button type="button" class="agent-tile${r.unavailableReason ? " off" : ""}" data-agent="${esc(r.id)}" title="${esc(r.unavailableReason ? `Not installed on this machine. ${r.installHint || ""}` : r.loginState === "missing" ? `Found at ${r.installedAt || "bundled"}, but not logged in: run ${r.loginCommand}` : `Found at ${r.installedAt || "bundled"}`)}">${vendorLogo(r, "lg")}<span class="at-name">${esc(r.vendor)}</span><span class="at-sub">${r.unavailableReason ? "not installed" : r.loginState === "missing" ? "no login" : "installed"}</span></button>`,
3584
3743
  )
3585
3744
  .join("");
3586
3745
  els.invAgents.querySelectorAll(".agent-tile:not(.off)").forEach((b) =>
@@ -3735,6 +3894,7 @@
3735
3894
  if (!room) return closeDialog(els.rcDialog);
3736
3895
  const globalMode = els.rcForm.querySelector('input[name="rc-mode"]:checked').value;
3737
3896
  const replay = Number(els.rcReplay.value);
3897
+ if (Number.isFinite(replay) && replay !== (room.settings.replayAfterRestart ?? 10)) post(roomApi("/settings"), { replayAfterRestart: replay }).catch((error) => toast(error.message, "error"));
3738
3898
  const rows = [...els.rcTable.querySelectorAll("tr")].map((tr) => ({ id: tr.dataset.id, choice: tr.querySelector(".rc-per").value || globalMode }));
3739
3899
  els.rcSubmit.disabled = true;
3740
3900
  UI.setState(els.rcSubmit, "loading");
@@ -3787,7 +3947,7 @@
3787
3947
  tplEls.list.innerHTML = tpl.items
3788
3948
  .map((t) => {
3789
3949
  const on = tpl.current && t.id === tpl.current.id;
3790
- const faces = t.vibemates.slice(0, 4).map((v) => avatar({ name: v.name, avatar: v.avatar, color: FALLBACK_COLOR }, 20, {})).join("");
3950
+ const faces = t.vibemates.slice(0, 4).map((v) => avatar({ name: v.name, avatar: v.avatar, color: FALLBACK_COLOR() }, 20, { ring: true })).join("");
3791
3951
  return `<button type="button" class="tpl-item${on ? " on" : ""}" data-id="${esc(t.id)}" role="radio" aria-checked="${on ? "true" : "false"}">
3792
3952
  <span class="tpl-emoji">${esc(t.emoji || "🧩")}</span>
3793
3953
  <span class="tpl-body"><b>${esc(t.name)}${t.builtin ? "" : UI.html("badge", { label: "your template", size: "xs" })}${t.recommended ? UI.html("badge", { label: "recommended", tone: "attention", size: "xs" }) : ""}</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>
@@ -3895,22 +4055,22 @@
3895
4055
  ${stpField("Language", `<input type="text" id="stp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
3896
4056
  ${stpField("Vibemates' own tools", stpSelect("stp-tools", st.tools || "on-request", [["on-request", "Only when someone explicitly asks"], ["never", "Never (chat only)"]]))}
3897
4057
  ${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"]]))}
3898
- ${stpField("Reply delay, seconds", `<input type="number" id="stp-delay" min="0" max="120" step="0.5" value="${esc(String(st.replyDelay ?? 4))}">`)}
4058
+ ${stpField("Reply delay, seconds", `${UI.html("number-field", { id: "stp-delay", value: String(st.replyDelay ?? 4), min: 0, max: 120, step: 0.5 })}`)}
3899
4059
  ${stpSwitch("Vibemates wake each other", "stp-wake", st.agentsWakeEachOther !== false)}
3900
4060
  ${stpSwitch("Wait while you are typing", "stp-wait", st.waitWhileHumanTypes !== false)}
3901
- ${stpField("Hop limit", `<input type="number" id="stp-hops" min="0" max="10000" value="${esc(String(st.hopLimit ?? 100))}">`)}
3902
- ${stpField("Max sentences per reply", `<input type="number" id="stp-maxlen" min="1" max="100" value="${st.maxSentences ?? ""}" placeholder="no limit">`)}
4061
+ ${stpField("Hop limit", `${UI.html("number-field", { id: "stp-hops", value: String(st.hopLimit ?? 100), min: 0, max: 10000 })}`)}
4062
+ ${stpField("Max sentences per reply", `${UI.html("number-field", { id: "stp-maxlen", value: String(st.maxSentences ?? ""), min: 1, max: 100, placeholder: "no limit" })}`)}
3903
4063
  ${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"]]))}
3904
4064
  ${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"]]))}
3905
- ${stpField("Full brief every N turns", `<input type="number" id="stp-brief-turns" min="1" max="10000" value="${esc(String(st.fullBriefEveryTurns ?? 8))}">`)}
3906
- ${stpField("…or every N tokens", `<input type="number" id="stp-brief-tokens" min="1000" max="10000000" step="1000" value="${esc(String(st.fullBriefEveryTokens ?? 20000))}">`)}
3907
- ${stpField("Replay after a reconnect", `<input type="number" id="stp-replay" min="0" max="200" value="${esc(String(st.replayAfterRestart ?? 10))}">`)}
3908
- ${stpField("Missed messages read at most", `<input type="number" id="stp-backlog" min="1" max="1000" value="${esc(String(st.backlogCap ?? 50))}">`)}
4065
+ ${stpField("Full brief every N turns", `${UI.html("number-field", { id: "stp-brief-turns", value: String(st.fullBriefEveryTurns ?? 8), min: 1, max: 10000 })}`)}
4066
+ ${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
+ ${stpField("Replay after a reconnect", `${UI.html("number-field", { id: "stp-replay", value: String(st.replayAfterRestart ?? 10), min: 0, max: 200 })}`)}
4068
+ ${stpField("Missed messages read at most", `${UI.html("number-field", { id: "stp-backlog", value: String(st.backlogCap ?? 50), min: 1, max: 1000 })}`)}
3909
4069
  ${stpSwitch("Core rules in every header", "stp-header-rules", st.headerRules !== false)}
3910
4070
  ${stpSwitch("Show vendor and model to other vibemates", "stp-vendor", !!st.showVendorInRoster)}
3911
4071
  </div>`;
3912
4072
  const agentOptions = [["", "none yet: cast when the room opens"], ...state.recipes.map((r) => [r.id, r.vendor + (r.unavailableReason ? " (not installed here)" : "")])];
3913
- const vms = (t.vibemates || []).map((v, i) => `<div class="stp-vm" data-i="${i}">${avatar({ name: v.name, avatar: v.avatar, color: FALLBACK_COLOR }, 36, {})}<div>
4073
+ const vms = (t.vibemates || []).map((v, i) => `<div class="stp-vm" data-i="${i}">${avatar({ name: v.name, avatar: v.avatar, color: FALLBACK_COLOR() }, 36, {})}<div>
3914
4074
  <div class="stp-vm-top"><b>${esc(v.name)}</b>${UI.html("icon-button", { icon: "close", title: "Leave this vibemate out of the template", kind: "ghost", size: "sm", data: { stpRemove: true } })}</div>
3915
4075
  <div class="stp-vm-fields">
3916
4076
  ${stpField("Vibename", `<input type="text" data-k="name" maxlength="40" value="${esc(v.name)}" required>`)}
@@ -3921,7 +4081,7 @@
3921
4081
  ${stpField("Model", `<input type="text" data-k="model" value="${esc(v.model || "")}" placeholder="the agent's default">`)}
3922
4082
  ${stpField("Effort", `<input type="text" data-k="effort" value="${esc(v.effort || "")}" placeholder="default">`)}
3923
4083
  ${stpField("Mode", `<input type="text" data-k="mode" value="${esc(v.mode || "")}" placeholder="default">`)}
3924
- ${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">`)}
4084
+ ${stpField("Reply delay override, s", `${UI.html("number-field", { value: String(v.replyDelay ?? ""), min: 0, max: 120, step: 0.5, placeholder: "the room's", data: { k: "replyDelay" } })}`)}
3925
4085
  ${stpField("Skills, comma-separated", `<input type="text" data-k="skills" value="${esc((v.skills || []).join(", "))}">`, true)}
3926
4086
  </div>
3927
4087
  </div></div>`).join("");
@@ -4030,12 +4190,35 @@
4030
4190
  closeDialog(els.pfDialog);
4031
4191
  if (state.view === "home") renderHome();
4032
4192
  toast(`Welcome, ${els.pfName.value.trim()}. Open a room and summon a vibemate.`, "info");
4193
+ if (state.freshVibe) offerLook();
4033
4194
  } catch (error) {
4034
4195
  els.pfError.textContent = error.message;
4035
4196
  els.pfError.hidden = false;
4036
4197
  }
4037
4198
  });
4038
4199
  els.pfDialog.addEventListener("cancel", (event) => event.preventDefault());
4200
+ function offerLook() {
4201
+ 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("");
4203
+ openDialog($("#look-dialog"));
4204
+ }
4205
+ $("#look-dialog-cards").addEventListener("click", async (e) => {
4206
+ const card = e.target.closest('[data-ui="look-card"]');
4207
+ if (!card) return;
4208
+ $("#look-dialog-cards").querySelectorAll('[data-ui="look-card"]').forEach((x) => {
4209
+ UI.setState(x, x === card ? "on" : null);
4210
+ x.setAttribute("aria-pressed", x === card ? "true" : "false");
4211
+ });
4212
+ try {
4213
+ await post("/api/settings", { appearance: { look: card.dataset.look } });
4214
+ } catch (error) {
4215
+ toast(error.message, "error");
4216
+ }
4217
+ });
4218
+ $("#look-form").addEventListener("submit", (e) => {
4219
+ e.preventDefault();
4220
+ closeDialog($("#look-dialog"));
4221
+ });
4039
4222
 
4040
4223
  function openEraseDialog() {
4041
4224
  els.eraseWord.value = "";
@@ -4081,12 +4264,37 @@
4081
4264
  }
4082
4265
 
4083
4266
 
4267
+ const CUSTOM_VARS = [...new Set(TOKENS.adjustables.flatMap((f) => Object.keys(f.vars(f.kind === "scale" ? "1" : TOKENS.current.palette.black))))];
4268
+ function customVars(v) {
4269
+ const out = {};
4270
+ for (const f of TOKENS.adjustables) if (v[f.key]) Object.assign(out, f.vars(v[f.key]));
4271
+ return out;
4272
+ }
4273
+ function applyCustomVars(el, values) {
4274
+ for (const name of CUSTOM_VARS) el.style.removeProperty(name);
4275
+ for (const [name, value] of Object.entries(customVars(values || {}))) el.style.setProperty(name, value);
4276
+ }
4277
+ let appliedScheme = null;
4084
4278
  function applyAppearance() {
4085
4279
  const a = (state.settings || {}).appearance || {};
4086
4280
  const root = document.documentElement;
4087
4281
  root.style.setProperty("--fs-scale", String((a.chatFontSize || 14.5) / 14.5));
4088
- root.style.setProperty("--font", (FONTS.text[a.font] || FONTS.text.nunito).stack);
4282
+ const look = TOKENS.looks[a.look] ? a.look : TOKENS.current.id;
4283
+ if (look === TOKENS.current.id) delete root.dataset.look;
4284
+ else root.dataset.look = look;
4285
+ const scheme = TOKENS.looks[look].scheme;
4286
+ if (appliedScheme && appliedScheme !== scheme) {
4287
+ document.querySelectorAll(".mermaid-block[data-rendered]").forEach((b) => delete b.dataset.rendered);
4288
+ renderDiagrams(document);
4289
+ }
4290
+ appliedScheme = scheme;
4291
+ if (look !== TOKENS.current.id && (!a.font || a.font === "nunito")) root.style.removeProperty("--font");
4292
+ else root.style.setProperty("--font", (FONTS.text[a.font] || FONTS.text.nunito).stack);
4089
4293
  root.style.setProperty("--mono", (FONTS.mono[a.mono] || FONTS.mono["jetbrains-mono"]).stack);
4294
+ const frame = document.querySelector('meta[name="theme-color"]');
4295
+ if (frame) frame.content = TOKENS.looks[look].elements.browser.themeColor;
4296
+ applyCustomVars(root, (a.custom || {})[look] || {});
4297
+ document.querySelectorAll("#participants .avatar .life").forEach((ring) => setLifePath(ring, ring.parentElement));
4090
4298
  }
4091
4299
 
4092
4300
  let hubIdentity = null;
@@ -4700,7 +4908,7 @@
4700
4908
  const waiting = room ? room.participants.filter((p) => p.kind === "agent" && p.status === "unstaffed") : [];
4701
4909
  castBanner.hidden = waiting.length === 0;
4702
4910
  castBanner.innerHTML = waiting.length
4703
- ? `<div class="cast-lead"><strong>Summon ${waiting.map((p) => esc(p.name)).join(" and ")} to begin.</strong> ${waiting.length === 1 ? "It comes" : "They come"} from the template with the character set; pick the coding agent that runs ${waiting.length === 1 ? "it" : "each"}.</div><div class="cast-list">${waiting.map((p) => `<button type="button" class="cast-card" data-id="${esc(p.id)}">${avatar(p, 44, {})}<b>${esc(p.name)}</b>${p.tagline ? `<span>"${esc(p.tagline)}"</span>` : ""}<em>Summon ${esc(p.name)}</em></button>`).join("")}</div>`
4911
+ ? `<div class="cast-lead"><strong>Summon ${waiting.map((p) => esc(p.name)).join(" and ")} to begin.</strong> ${waiting.length === 1 ? "It comes" : "They come"} from the template with the character set; pick the coding agent that runs ${waiting.length === 1 ? "it" : "each"}.</div><div class="cast-list">${waiting.map((p) => `<button type="button" class="cast-card" data-id="${esc(p.id)}">${avatar(p, 44, { dim: "unstaffed" })}<b>${esc(p.name)}</b>${p.tagline ? `<span>"${esc(p.tagline)}"</span>` : ""}<em>Summon ${esc(p.name)}</em></button>`).join("")}</div>`
4704
4912
  : "";
4705
4913
  els.input.disabled = waiting.length > 0;
4706
4914
  els.input.placeholder = waiting.length ? `Summon ${waiting.map((p) => p.name).join(" and ")} to start the conversation` : "Message the room… @Name or /skill";
@@ -5216,7 +5424,9 @@
5216
5424
  if (!nodes.length) return;
5217
5425
  const total = els.messages.scrollHeight || 1;
5218
5426
  const h = Math.max(0, t.ticks.clientHeight - TICK_H);
5219
- const tops = nodes.map(topInList);
5427
+ const fits = els.messages.scrollHeight <= els.messages.clientHeight + 1;
5428
+ const stripTop = fits ? t.ticks.getBoundingClientRect().top : 0;
5429
+ const tops = fits ? nodes.map((el) => el.getBoundingClientRect().top - stripTop) : nodes.map(topInList);
5220
5430
  const frag = document.createDocumentFragment();
5221
5431
  nodes.forEach((el, i) => {
5222
5432
  const tick = document.createElement("div");
@@ -5224,11 +5434,18 @@
5224
5434
  tick.className = `tl-tick${pinned ? " pinned i i-pin" : ""}`;
5225
5435
  tick.dataset.i = i;
5226
5436
  if (opts.colorOf) tick.style.setProperty("--tick", opts.colorOf(room, el));
5227
- tick.style.top = `${Math.round((tops[i] / total) * h)}px`;
5437
+ tick.style.top = `${Math.round(fits ? Math.max(0, Math.min(h, tops[i] + 6)) : (tops[i] / total) * h)}px`;
5228
5438
  frag.appendChild(tick);
5229
5439
  });
5230
5440
  t.ticks.replaceChildren(frag);
5231
5441
  updateView();
5442
+ if (fits && !t.following) {
5443
+ t.following = true;
5444
+ requestAnimationFrame(() => requestAnimationFrame(() => {
5445
+ render();
5446
+ t.following = false;
5447
+ }));
5448
+ }
5232
5449
  }
5233
5450
  function updateView() {
5234
5451
  if (t.el.hidden) return;
@@ -5346,7 +5563,7 @@
5346
5563
  if (room.id !== state.currentRoomId || state.view !== "room") return;
5347
5564
  requestAnimationFrame(() => {
5348
5565
  if (bubbleInView(m.id)) return;
5349
- const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR, kind: "agent" };
5566
+ const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR(), kind: "agent" };
5350
5567
  pushNote({ id: m.id, kind: "done", p, text: `${p.name} finished`, sub: `started ${time(m.ts)}${m.durationMs ? ` · ${spanText(m.durationMs)}` : ""}` });
5351
5568
  });
5352
5569
  }
@@ -5354,7 +5571,7 @@
5354
5571
  if (room.id !== state.currentRoomId || state.view !== "room") return;
5355
5572
  requestAnimationFrame(() => {
5356
5573
  if (bubbleInView(m.id)) return;
5357
- const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR, kind: "agent" };
5574
+ const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR(), kind: "agent" };
5358
5575
  pushNote({ id: m.id, kind: "new", p, text: `${p.name} wrote below`, sub: time(m.ts) });
5359
5576
  });
5360
5577
  }
@@ -5391,7 +5608,7 @@
5391
5608
  const timelines = [
5392
5609
  createTimeline($("#timeline"), () => [...els.messages.querySelectorAll(".msg.mine:not(.hidden-by-search)")], {}),
5393
5610
  createTimeline($("#timeline-left"), () => [...els.messages.querySelectorAll(".msg.agent:not(.hidden-by-search)")], {
5394
- colorOf: (room, el) => (authorOf(room, el) || {}).color || FALLBACK_COLOR,
5611
+ colorOf: (room, el) => (authorOf(room, el) || {}).color || FALLBACK_COLOR(),
5395
5612
  avatarOf: (room, el) => { const p = authorOf(room, el); return p ? avatar(p, 16, {}) : ""; },
5396
5613
  }),
5397
5614
  ];
@@ -5404,6 +5621,7 @@
5404
5621
  function updateTimelineView() { for (const t of timelines) t.updateView(); }
5405
5622
  attachScrollHints(els.pageInner);
5406
5623
  attachScrollHints(els.detailsInner);
5624
+ document.querySelectorAll("dialog.dialog").forEach((d) => attachScrollHints(d));
5407
5625
  const composerFollowers = [$("#timeline"), $("#timeline-left"), els.mentionMenu, els.emojiMenu, els.jumpLatest];
5408
5626
  new ResizeObserver(() => {
5409
5627
  const h = `${els.composer.offsetHeight}px`;
@@ -5411,6 +5629,15 @@
5411
5629
  renderTimeline();
5412
5630
  }).observe(els.composer);
5413
5631
  new ResizeObserver(() => renderTimeline()).observe(els.messages);
5632
+ document.fonts.ready.then(() => renderTimeline());
5633
+ let timelineTimer = 0;
5634
+ new MutationObserver(() => {
5635
+ if (timelineTimer) return;
5636
+ timelineTimer = setTimeout(() => {
5637
+ timelineTimer = 0;
5638
+ renderTimeline();
5639
+ }, 300);
5640
+ }).observe(els.messages, { childList: true, subtree: true });
5414
5641
 
5415
5642
  const workingNow = $("#working-now");
5416
5643
  function renderWorkingNow() {
@@ -5453,8 +5680,27 @@
5453
5680
  const left = Math.max(0, Math.min(100, 100 - Math.round((100 * (p.contextUsed || 0)) / p.contextSize)));
5454
5681
  return { left, tone: left <= 20 ? "hot" : left <= 50 ? "warm" : "ok" };
5455
5682
  }
5456
- const LIFE_PATH = "M26 2.5H32.5A17 17 0 0 1 49.5 19.5V32.5A17 17 0 0 1 32.5 49.5H19.5A17 17 0 0 1 2.5 32.5V19.5A17 17 0 0 1 19.5 2.5H26";
5457
- const LIFE_RING = `<svg class="life" viewBox="0 0 52 52" aria-hidden="true"><path class="life-track" d="${LIFE_PATH}" pathLength="100"/><path class="life-arc" d="${LIFE_PATH}" pathLength="100"/></svg>`;
5683
+ function lifePathFor(r) {
5684
+ const s = 2.5, e = 49.5, mid = 26;
5685
+ r = Math.max(0, Math.min(23.5, r));
5686
+ const n = (v) => String(Math.round(v * 10) / 10);
5687
+ if (r < 0.5) return `M${mid} ${s}H${e}V${e}H${s}V${s}H${mid}`;
5688
+ const a = (x, y) => `A${n(r)} ${n(r)} 0 0 1 ${n(x)} ${n(y)}`;
5689
+ return `M${mid} ${s}H${n(e - r)}${a(e, s + r)}V${n(e - r)}${a(e - r, e)}H${n(s + r)}${a(s, e - r)}V${n(s + r)}${a(s + r, s)}H${mid}`;
5690
+ }
5691
+ function lifeRadius(av) {
5692
+ const cs = getComputedStyle(av.isConnected ? av : document.documentElement);
5693
+ const scale = parseFloat(cs.getPropertyValue("--r-scale"));
5694
+ const corner = parseFloat(cs.getPropertyValue("--face-corner"));
5695
+ return ((Number.isFinite(corner) ? corner : 0.32) * 44 + 4) * (Number.isFinite(scale) ? scale : 1);
5696
+ }
5697
+ function setLifePath(ring, av) {
5698
+ const d = lifePathFor(lifeRadius(av));
5699
+ if (ring.dataset.d === d) return;
5700
+ ring.querySelectorAll("path").forEach((path) => path.setAttribute("d", d));
5701
+ ring.dataset.d = d;
5702
+ }
5703
+ const LIFE_RING = `<svg class="life" viewBox="0 0 52 52" aria-hidden="true"><path class="life-track" pathLength="100"/><path class="life-arc" pathLength="100"/></svg>`;
5458
5704
  function patchLifeRing(li, p) {
5459
5705
  const av = li.querySelector(".avatar");
5460
5706
  if (!av) return;
@@ -5464,6 +5710,7 @@
5464
5710
  ring = av.querySelector(".life");
5465
5711
  av.classList.add("has-life");
5466
5712
  }
5713
+ setLifePath(ring, av);
5467
5714
  const { left, tone } = lifeOf(p);
5468
5715
  const cls = `life life-${tone}`;
5469
5716
  if (ring.getAttribute("class") !== cls) ring.setAttribute("class", cls);
@@ -5553,7 +5800,7 @@
5553
5800
  ${p.notes ? `<pre class="lp-notes">${esc(p.notes)}</pre><div class="lp-editor" hidden><textarea class="lp-notes-area" rows="6" maxlength="4000">${esc(p.notes)}</textarea><div class="row-btns">${UI.html("button", { label: "Clear", kind: "ghost", size: "sm", hook: "lp-notes-clear" })}${UI.html("button", { label: "Save", kind: "primary", size: "sm", hook: "lp-notes-save" })}</div></div>` : ""}
5554
5801
  <div class="lp-respawn">
5555
5802
  ${UI.html("button", { label: "Respawn, empty head", icon: "bolt", kind: "danger", size: "sm", hook: "lp-empty", title: "A new session that knows nothing of this conversation" })}
5556
- <label class="lp-with">${UI.html("button", { label: "Respawn with the last", size: "sm", hook: "lp-mem", title: `A new session that re-reads only the last N messages${p.notes ? " and its own notes" : ""}` })}<input type="number" class="lp-n" min="0" max="500" value="${n}"> messages</label>
5803
+ <label class="lp-with">${UI.html("button", { label: "Respawn with the last", size: "sm", hook: "lp-mem", title: `A new session that re-reads only the last N messages${p.notes ? " and its own notes" : ""}` })}${UI.html("number-field", { value: String(n), min: 0, max: 500, hook: "lp-n" })} messages</label>
5557
5804
  </div>
5558
5805
  </div>
5559
5806
  <div class="lp-side">${UI.html("icon-button", { icon: "close", title: "Close", size: "sm", hook: "lp-x" })}${UI.html("icon-button", { icon: "settings", title: "Open this vibemate's panel", size: "sm", hook: "lp-more" })}</div>`;