viberoom 0.6.1 → 0.8.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 (115) hide show
  1. package/NOTICE +14 -0
  2. package/README.md +11 -0
  3. package/dist/hub.js +101 -14
  4. package/dist/look-lint.js +180 -0
  5. package/dist/looks.js +151 -0
  6. package/dist/mcp-skills-server.js +87 -0
  7. package/dist/persona.js +2 -0
  8. package/dist/room.js +134 -4
  9. package/dist/server.js +70 -0
  10. package/dist/skills.js +23 -2
  11. package/package.json +1 -1
  12. package/ui/app.css +142 -114
  13. package/ui/app.js +423 -102
  14. package/ui/avatars.js +7 -14
  15. package/ui/components.css +156 -80
  16. package/ui/components.js +207 -1
  17. package/ui/fonts/anonymous-pro-cyrillic-400.woff2 +0 -0
  18. package/ui/fonts/anonymous-pro-cyrillic-700.woff2 +0 -0
  19. package/ui/fonts/anonymous-pro-latin-400.woff2 +0 -0
  20. package/ui/fonts/anonymous-pro-latin-700.woff2 +0 -0
  21. package/ui/fonts/anonymous-pro-latin-ext-400.woff2 +0 -0
  22. package/ui/fonts/anonymous-pro-latin-ext-700.woff2 +0 -0
  23. package/ui/fonts/anonymous-pro.css +7 -0
  24. package/ui/fonts/cascadia-code-cyrillic-ext.woff2 +0 -0
  25. package/ui/fonts/cascadia-code-cyrillic.woff2 +0 -0
  26. package/ui/fonts/cascadia-code-latin-ext.woff2 +0 -0
  27. package/ui/fonts/cascadia-code-latin.woff2 +0 -0
  28. package/ui/fonts/cascadia-code.css +5 -0
  29. package/ui/fonts/comfortaa-cyrillic-ext.woff2 +0 -0
  30. package/ui/fonts/comfortaa-cyrillic.woff2 +0 -0
  31. package/ui/fonts/comfortaa-latin-ext.woff2 +0 -0
  32. package/ui/fonts/comfortaa-latin.woff2 +0 -0
  33. package/ui/fonts/comfortaa.css +5 -0
  34. package/ui/fonts/exo-2-cyrillic-ext.woff2 +0 -0
  35. package/ui/fonts/exo-2-cyrillic.woff2 +0 -0
  36. package/ui/fonts/exo-2-latin-ext.woff2 +0 -0
  37. package/ui/fonts/exo-2-latin.woff2 +0 -0
  38. package/ui/fonts/exo-2.css +5 -0
  39. package/ui/fonts/golos-text-cyrillic-ext.woff2 +0 -0
  40. package/ui/fonts/golos-text-cyrillic.woff2 +0 -0
  41. package/ui/fonts/golos-text-latin-ext.woff2 +0 -0
  42. package/ui/fonts/golos-text-latin.woff2 +0 -0
  43. package/ui/fonts/golos-text.css +5 -0
  44. package/ui/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  45. package/ui/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  46. package/ui/fonts/ibm-plex-mono-cyrillic-700.woff2 +0 -0
  47. package/ui/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  48. package/ui/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  49. package/ui/fonts/ibm-plex-mono-cyrillic-ext-700.woff2 +0 -0
  50. package/ui/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  51. package/ui/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  52. package/ui/fonts/ibm-plex-mono-latin-700.woff2 +0 -0
  53. package/ui/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  54. package/ui/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  55. package/ui/fonts/ibm-plex-mono-latin-ext-700.woff2 +0 -0
  56. package/ui/fonts/ibm-plex-mono.css +13 -0
  57. package/ui/fonts/ibm-plex-sans-cyrillic-400.woff2 +0 -0
  58. package/ui/fonts/ibm-plex-sans-cyrillic-600.woff2 +0 -0
  59. package/ui/fonts/ibm-plex-sans-cyrillic-700.woff2 +0 -0
  60. package/ui/fonts/ibm-plex-sans-cyrillic-ext-400.woff2 +0 -0
  61. package/ui/fonts/ibm-plex-sans-cyrillic-ext-600.woff2 +0 -0
  62. package/ui/fonts/ibm-plex-sans-cyrillic-ext-700.woff2 +0 -0
  63. package/ui/fonts/ibm-plex-sans-latin-400.woff2 +0 -0
  64. package/ui/fonts/ibm-plex-sans-latin-600.woff2 +0 -0
  65. package/ui/fonts/ibm-plex-sans-latin-700.woff2 +0 -0
  66. package/ui/fonts/ibm-plex-sans-latin-ext-400.woff2 +0 -0
  67. package/ui/fonts/ibm-plex-sans-latin-ext-600.woff2 +0 -0
  68. package/ui/fonts/ibm-plex-sans-latin-ext-700.woff2 +0 -0
  69. package/ui/fonts/ibm-plex-sans.css +13 -0
  70. package/ui/fonts/manrope-cyrillic-ext.woff2 +0 -0
  71. package/ui/fonts/manrope-cyrillic.woff2 +0 -0
  72. package/ui/fonts/manrope-latin-ext.woff2 +0 -0
  73. package/ui/fonts/manrope-latin.woff2 +0 -0
  74. package/ui/fonts/manrope.css +5 -0
  75. package/ui/fonts/montserrat-cyrillic-ext.woff2 +0 -0
  76. package/ui/fonts/montserrat-cyrillic.woff2 +0 -0
  77. package/ui/fonts/montserrat-latin-ext.woff2 +0 -0
  78. package/ui/fonts/montserrat-latin.woff2 +0 -0
  79. package/ui/fonts/montserrat.css +5 -0
  80. package/ui/fonts/open-sans-cyrillic-ext.woff2 +0 -0
  81. package/ui/fonts/open-sans-cyrillic.woff2 +0 -0
  82. package/ui/fonts/open-sans-latin-ext.woff2 +0 -0
  83. package/ui/fonts/open-sans-latin.woff2 +0 -0
  84. package/ui/fonts/open-sans.css +5 -0
  85. package/ui/fonts/pt-mono-cyrillic-ext.woff2 +0 -0
  86. package/ui/fonts/pt-mono-cyrillic.woff2 +0 -0
  87. package/ui/fonts/pt-mono-latin-ext.woff2 +0 -0
  88. package/ui/fonts/pt-mono-latin.woff2 +0 -0
  89. package/ui/fonts/pt-mono.css +5 -0
  90. package/ui/fonts/rubik-cyrillic-ext.woff2 +0 -0
  91. package/ui/fonts/rubik-cyrillic.woff2 +0 -0
  92. package/ui/fonts/rubik-latin-ext.woff2 +0 -0
  93. package/ui/fonts/rubik-latin.woff2 +0 -0
  94. package/ui/fonts/rubik.css +5 -0
  95. package/ui/fonts/source-sans-3-cyrillic-ext.woff2 +0 -0
  96. package/ui/fonts/source-sans-3-cyrillic.woff2 +0 -0
  97. package/ui/fonts/source-sans-3-latin-ext.woff2 +0 -0
  98. package/ui/fonts/source-sans-3-latin.woff2 +0 -0
  99. package/ui/fonts/source-sans-3.css +5 -0
  100. package/ui/fonts/ubuntu-sans-cyrillic-ext.woff2 +0 -0
  101. package/ui/fonts/ubuntu-sans-cyrillic.woff2 +0 -0
  102. package/ui/fonts/ubuntu-sans-latin-ext.woff2 +0 -0
  103. package/ui/fonts/ubuntu-sans-latin.woff2 +0 -0
  104. package/ui/fonts/ubuntu-sans.css +5 -0
  105. package/ui/fonts/victor-mono-cyrillic-ext.woff2 +0 -0
  106. package/ui/fonts/victor-mono-cyrillic.woff2 +0 -0
  107. package/ui/fonts/victor-mono-latin-ext.woff2 +0 -0
  108. package/ui/fonts/victor-mono-latin.woff2 +0 -0
  109. package/ui/fonts/victor-mono.css +5 -0
  110. package/ui/index.html +16 -6
  111. package/ui/looks.css +2831 -0
  112. package/ui/manifest.json +3 -3
  113. package/ui/theme.css +203 -96
  114. package/ui/tokens.js +800 -45
  115. package/ui/ui.js +2 -0
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,26 +162,12 @@
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
- arial: { label: "Arial / Helvetica (system)", stack: 'Arial, Helvetica, "Liberation Sans", sans-serif' },
170
- system: { label: "System UI font", stack: 'system-ui, -apple-system, "Segoe UI", Roboto, Cantarell, sans-serif' },
171
- },
172
- mono: {
173
- "jetbrains-mono": { label: "JetBrains Mono (default)", stack: '"JetBrains Mono", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
174
- "fira-code": { label: "Fira Code", stack: '"Fira Code", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
175
- "source-code-pro": { label: "Source Code Pro", stack: '"Source Code Pro", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
176
- system: { label: "System monospace", stack: 'ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace' },
177
- },
178
- };
165
+ const FONTS = globalThis.VIBEROOM_TOKENS.fonts;
179
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" };
180
167
  const STATUS_TONE = { idle: "ready", queued: "waiting", starting: "waiting", thinking: "thinking", writing: "writing", error: "error", offline: "asleep", left: "asleep", unstaffed: "attention" };
181
168
  const TOOL_STATUSES = new Set(["pending", "in_progress", "completed", "failed"]);
182
169
  const TOKENS = globalThis.VIBEROOM_TOKENS;
183
- const FALLBACK_COLOR = TOKENS.current.elements.participant.fallback;
170
+ const FALLBACK_COLOR = () => TOKENS.active().elements.face.fallback;
184
171
  const WORKING_SVG = '<svg class="working" viewBox="0 0 44 35" aria-hidden="true" title="working…">'
185
172
  + '<g class="body">'
186
173
  + '<circle cx="20" cy="6.5" r="5.6" fill="currentColor"/>'
@@ -825,9 +812,13 @@
825
812
  function diagramSettings(d) {
826
813
  return d || (state.settings && state.settings.diagrams) || {};
827
814
  }
815
+ function diagramPreset(d) {
816
+ const look = TOKENS.active();
817
+ return TOKENS.diagrams.forScheme(DIAGRAM_PRESETS[d.preset] || DIAGRAM_PRESETS.pop, look.scheme, look.elements.diagram.nodeInk);
818
+ }
828
819
  function mermaidThemeVariables(d) {
829
820
  d = diagramSettings(d);
830
- const preset = DIAGRAM_PRESETS[d.preset] || DIAGRAM_PRESETS.pop;
821
+ const preset = diagramPreset(d);
831
822
  const vars = Object.assign({}, preset, { fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--font").trim() || "Nunito, sans-serif", fontSize: "13px" });
832
823
  delete vars.label;
833
824
  delete vars.palette;
@@ -841,19 +832,19 @@
841
832
  }
842
833
  function diagramPalette(d) {
843
834
  d = diagramSettings(d);
844
- const preset = DIAGRAM_PRESETS[d.preset] || DIAGRAM_PRESETS.pop;
835
+ const preset = diagramPreset(d);
845
836
  return preset.palette && !d.primary ? preset.palette : null;
846
837
  }
847
- const MERMAID_CSS = [
838
+ const mermaidCss = () => [
848
839
  ".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}); }`,
840
+ `.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
841
  ".edgePath .path, .flowchart-link, .messageLine0, .messageLine1, .transition, .relation { stroke-width: 2px; }",
851
842
  ".edgeLabel, .edgeLabel p { font-weight: 700; }",
852
843
  ".cluster rect { stroke-dasharray: 4 3; stroke-width: 1.5px; }",
853
844
  ".cluster-label, .cluster-label p { font-weight: 800; }",
854
845
  ].join(" ");
855
846
  function paintDiagram(root, palette) {
856
- const ink = TOKENS.current.elements.diagram.nodeInk;
847
+ const ink = TOKENS.active().elements.diagram.nodeInk;
857
848
  const byKey = new Map();
858
849
  const pick = (key) => {
859
850
  if (!byKey.has(key)) byKey.set(key, palette[byKey.size % palette.length]);
@@ -911,7 +902,7 @@
911
902
  theme: "base",
912
903
  securityLevel: "strict",
913
904
  themeVariables: mermaidThemeVariables(d),
914
- themeCSS: MERMAID_CSS,
905
+ themeCSS: mermaidCss(),
915
906
  flowchart: { curve: "basis", padding: 14, nodeSpacing: 44, rankSpacing: 52 },
916
907
  });
917
908
  for (const block of blocks) {
@@ -993,7 +984,7 @@
993
984
  }
994
985
  function meAvatarData() {
995
986
  const s = state.settings || {};
996
- return { name: s.humanName || "You", color: TOKENS.current.elements.participant.humanInk, avatar: s.humanAvatar, kind: "human" };
987
+ return { name: s.humanName || "You", color: TOKENS.active().elements.face.humanInk, avatar: s.humanAvatar, kind: "human" };
997
988
  }
998
989
  function copyableHtml(el) {
999
990
  const clone = (el.querySelector(".text > .words") || el.querySelector(".text")).cloneNode(true);
@@ -1044,11 +1035,26 @@
1044
1035
  t.hidden = !t.hidden;
1045
1036
  b.classList.toggle("on", !t.hidden);
1046
1037
  });
1038
+ document.addEventListener("click", (e) => {
1039
+ const step = e.target.closest && e.target.closest('[data-ui="number-field"] > .steps > button');
1040
+ if (!step) return;
1041
+ e.preventDefault();
1042
+ const input = step.closest('[data-ui="number-field"]').querySelector("input");
1043
+ if (!input || input.disabled) return;
1044
+ try {
1045
+ if (step.dataset.act === "up") input.stepUp();
1046
+ else input.stepDown();
1047
+ } catch {
1048
+ return;
1049
+ }
1050
+ input.dispatchEvent(new Event("input", { bubbles: true }));
1051
+ input.dispatchEvent(new Event("change", { bubbles: true }));
1052
+ });
1047
1053
  function field(label, inputHtml, hint, geekText) {
1048
1054
  return `<label class="field"><span class="label">${label}${geekText ? geekTip(geekText) : ""}</span>${inputHtml}${hint ? `<span class="hint">${hint}</span>` : ""}</label>`;
1049
1055
  }
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>`;
1056
+ function vendorLogo(r, size) {
1057
+ return UI.html("logo-tile", { icon: r.icon || "", letter: r.vendor[0], size: size || "md", title: r.vendor });
1052
1058
  }
1053
1059
  function slowTasksHtml() {
1054
1060
  if (!slowTasks.length) return '<p class="hint">Nothing over 8 ms so far in this window.</p>';
@@ -1146,11 +1152,18 @@
1146
1152
  return h % 360;
1147
1153
  }
1148
1154
  function roomMark(room, lg) {
1149
- const hue = roomHue(room);
1150
1155
  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>`;
1156
+ return UI.html("room-mark", { hue: roomHue(room), emoji: emoji || undefined, letter: emoji ? undefined : room.name.trim()[0] || "?", size: lg ? "lg" : "md" });
1157
+ }
1158
+ function lookSampleHtml() {
1159
+ const withIcon = state.recipes.filter((r) => r.icon);
1160
+ const lead = withIcon[0] || state.recipes[0];
1161
+ const sam = { name: "Sam", kind: "agent", color: TOKENS.active().elements.face.avatarDefault, avatar: "🦊", agentType: lead ? lead.id : "", agentLabel: lead ? lead.vendor : "" };
1162
+ const marks = (withIcon.length ? withIcon : state.recipes).slice(0, 3).map((r) => vendorLogo(r, "sm")).join("");
1163
+ 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>
1164
+ <div class="msg mine"><div class="bubble-col"><div class="bubble">Your message.</div></div></div>
1165
+ <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>
1166
+ <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
1167
  }
1155
1168
  function roomTitle(room) {
1156
1169
  const emoji = (room.settings && room.settings.emoji) || "";
@@ -1168,10 +1181,11 @@
1168
1181
 
1169
1182
  function attachScrollHints(box) {
1170
1183
  if (!box || box.dataset.hinted) return;
1184
+ const inside = box.tagName === "DIALOG";
1171
1185
  const frame = box.parentElement;
1172
- if (!frame) return;
1186
+ if (!frame && !inside) return;
1173
1187
  box.dataset.hinted = "1";
1174
- if (getComputedStyle(frame).position === "static") frame.style.position = "relative";
1188
+ if (!inside && getComputedStyle(frame).position === "static") frame.style.position = "relative";
1175
1189
  const make = (dir) => {
1176
1190
  const b = document.createElement("button");
1177
1191
  b.type = "button";
@@ -1180,7 +1194,13 @@
1180
1194
  b.innerHTML = ic(dir === "up" ? "chevrons-up" : "chevrons-down");
1181
1195
  b.hidden = true;
1182
1196
  b.addEventListener("click", () => box.scrollBy({ top: (dir === "up" ? -1 : 1) * Math.max(120, box.clientHeight * 0.85), behavior: "smooth" }));
1183
- frame.appendChild(b);
1197
+ if (inside) {
1198
+ const slot = document.createElement("div");
1199
+ slot.className = `scroll-hint-slot ${dir}`;
1200
+ slot.appendChild(b);
1201
+ if (dir === "up") box.prepend(slot);
1202
+ else box.append(slot);
1203
+ } else frame.appendChild(b);
1184
1204
  return b;
1185
1205
  };
1186
1206
  const up = make("up");
@@ -1427,7 +1447,7 @@
1427
1447
  renderRailRooms();
1428
1448
  const s = state.settings;
1429
1449
  if (s) {
1430
- els.railMeAvatar.innerHTML = avatar(meAvatarData(), 44, {});
1450
+ els.railMeAvatar.innerHTML = avatar(meAvatarData(), 44, { kind: "bare" });
1431
1451
  els.railMeLabel.textContent = s.humanName;
1432
1452
  }
1433
1453
  const open = els.app.classList.contains("rail-open");
@@ -1510,7 +1530,7 @@
1510
1530
  const q = state.roomSearch.toLowerCase();
1511
1531
  const all = document.createElement("li");
1512
1532
  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>`;
1533
+ 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
1534
  all.addEventListener("click", () => {
1515
1535
  state.skillsRoom = null;
1516
1536
  renderSideSkills();
@@ -1568,6 +1588,7 @@
1568
1588
  { 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
1589
  { 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
1590
  { 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." },
1591
+ { 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
1592
  ];
1572
1593
  const STEPS = [
1573
1594
  { 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 +1600,7 @@
1579
1600
  const name = s.humanName || "there";
1580
1601
  const rooms = sortedRooms().slice(0, 4);
1581
1602
  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("");
1603
+ 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
1604
  els.homeView.innerHTML = `
1584
1605
  <div class="home-inner">
1585
1606
  <section class="hero">
@@ -1589,6 +1610,7 @@
1589
1610
  <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
1611
  <div class="hero-actions">
1591
1612
  ${UI.html("button", { label: "Open room", icon: "rooms", size: "cta", id: "home-open-room", hook: "hero-cta" })}
1613
+ ${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
1614
  </div>
1593
1615
  </div>
1594
1616
  <div class="hero-art" aria-hidden="true">
@@ -1608,7 +1630,7 @@
1608
1630
  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
1631
  })
1610
1632
  .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>`}
1633
+ ${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
1634
  </div>
1613
1635
  </section>
1614
1636
  <section class="home-section">
@@ -1640,6 +1662,15 @@
1640
1662
  setView("rooms");
1641
1663
  remember("view", "rooms");
1642
1664
  });
1665
+ const changeLook = $("#home-change-look");
1666
+ if (changeLook) changeLook.addEventListener("click", () => {
1667
+ setView("settings");
1668
+ remember("view", "settings");
1669
+ requestAnimationFrame(() => {
1670
+ const section = $("#sp-appearance");
1671
+ if (section) section.scrollIntoView({ behavior: "smooth", block: "start" });
1672
+ });
1673
+ });
1643
1674
  const all = $("#home-all-rooms");
1644
1675
  if (all) all.addEventListener("click", () => setView("rooms"));
1645
1676
  els.homeView.querySelectorAll(".home-room[data-room]").forEach((b) => b.addEventListener("click", () => selectRoom(b.dataset.room)));
@@ -1666,7 +1697,7 @@
1666
1697
  const st = roomStats(room);
1667
1698
  const card = document.createElement("div");
1668
1699
  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("");
1700
+ const faces = st.agents.slice(0, 5).map((p) => avatar(p, 26, { vendor: true, ring: true })).join("");
1670
1701
  card.innerHTML = `
1671
1702
  <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
1703
  <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 +1737,8 @@
1706
1737
  : asleep
1707
1738
  ? `<span class="zzz" title="${esc(STATUS_LABEL[p.status] || p.status)}">zzz</span>`
1708
1739
  : 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")}` : "";
1740
+ const avatarHtml = avatar(p.kind === "human" ? meAvatarData() : p, 44, { vendor: true, muted: p.muted, me: p.kind === "human", dim: unstaffed ? "unstaffed" : asleep ? "asleep" : undefined });
1741
+ const statusValue = p.kind === "agent" ? shown || "idle" : "";
1711
1742
  const bodyHtml = `<div class="p-body">
1712
1743
  <div class="p-name"><span>${esc(p.name)}</span>${p.muted ? UI.html("badge", { label: "muted", tone: "muted" }) : ""}${status}</div>
1713
1744
  <div class="p-sub">${esc(sub)}</div>
@@ -1721,16 +1752,16 @@
1721
1752
  li.dataset.id = p.id;
1722
1753
  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
1754
  li.dataset.avatar = avatarHtml;
1724
- if (statusClass) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="${statusClass}"></span>`);
1755
+ if (statusValue) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="status" data-status="${esc(statusValue)}"></span>`);
1725
1756
  li.dataset.body = bodyHtml;
1726
1757
  } else {
1727
1758
  if (li.dataset.avatar !== avatarHtml) {
1728
1759
  li.querySelector(".avatar").outerHTML = avatarHtml;
1729
1760
  li.dataset.avatar = avatarHtml;
1730
1761
  }
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>`);
1762
+ const dot = li.querySelector(".avatar .status");
1763
+ if (statusValue && dot && dot.dataset.status !== statusValue) dot.dataset.status = statusValue;
1764
+ else if (statusValue && !dot) li.querySelector(".avatar").insertAdjacentHTML("beforeend", `<span class="status" data-status="${esc(statusValue)}"></span>`);
1734
1765
  if (li.dataset.body !== bodyHtml) {
1735
1766
  li.querySelectorAll(".p-body, .p-actions").forEach((el) => el.remove());
1736
1767
  li.insertAdjacentHTML("beforeend", bodyHtml);
@@ -1851,12 +1882,12 @@
1851
1882
  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
1883
  return el;
1853
1884
  }
1854
- const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR, kind: m.from === "human" ? "human" : "agent" };
1885
+ const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR(), kind: m.from === "human" ? "human" : "agent" };
1855
1886
  const mine = m.from === "human";
1856
1887
  el.className = "msg " + (mine ? "mine" : "agent");
1857
1888
  el.innerHTML = `
1858
1889
  <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>
1890
+ <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
1891
  <div class="bubble">
1861
1892
  ${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
1893
  <div class="edit-box" hidden></div>
@@ -2090,6 +2121,7 @@
2090
2121
  const card = choice.closest('[data-ui="ask-card"]');
2091
2122
  if (choice.dataset.act === "permit") post(roomApi(`/permissions/${encodeURIComponent(card.dataset.key)}`), { optionId: choice.dataset.option || null }).catch(showError);
2092
2123
  else if (choice.dataset.act === "decide") post(roomApi(`/proposals/${encodeURIComponent(card.dataset.key)}`), { accept: choice.dataset.answer === "apply" }).catch(showError);
2124
+ else if (choice.dataset.act === "try-look") tryLook(choice.dataset.look);
2093
2125
  return;
2094
2126
  }
2095
2127
  const chip = e.target.closest('[data-ui="tool-call"] > [data-ui="chip"]');
@@ -2106,8 +2138,17 @@
2106
2138
  el.classList.toggle("hidden-by-search", !messageMatches(m));
2107
2139
  const wasPinned = el.classList.contains("pinned");
2108
2140
  el.classList.toggle("pinned", !!m.pinned);
2109
- const pinBtn = el.querySelector('[data-act="pin"]');
2141
+ const pinBtn = el.querySelector('.head [data-act="pin"]');
2110
2142
  if (pinBtn) pinBtn.title = m.pinned ? "Unpin this message" : "Pin this message";
2143
+ const bubble = el.querySelector(".bubble");
2144
+ const mark = bubble && bubble.querySelector(":scope > .pin-mark");
2145
+ if (m.pinned && bubble && !mark) {
2146
+ bubble.insertAdjacentHTML("beforeend", UI.html("icon-button", { icon: "pin", title: "Pinned: click to unpin", kind: "primary", size: "xs", act: "unpin", hook: "pin-mark" }));
2147
+ bubble.querySelector(":scope > .pin-mark").addEventListener("click", (e) => {
2148
+ e.stopPropagation();
2149
+ if (pinBtn) pinBtn.click();
2150
+ });
2151
+ } else if (!m.pinned && mark) mark.remove();
2111
2152
  if (wasPinned !== !!m.pinned) renderTimeline();
2112
2153
  const editedEl = el.querySelector(".edited");
2113
2154
  if (editedEl) {
@@ -2289,12 +2330,9 @@
2289
2330
  return [...visibilityMarkers(room).entries()].map(([seq, ps]) => `${seq}:${ps.map((p) => p.id).join(",")}`).sort().join("|");
2290
2331
  }
2291
2332
  function dividerElement(agents) {
2292
- const el = document.createElement("div");
2293
- el.className = "visibility-divider";
2294
2333
  const names = agents.map((p) => p.name);
2295
2334
  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;
2335
+ 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
2336
  }
2299
2337
 
2300
2338
  const PAGE_SIZE = 50;
@@ -2565,6 +2603,7 @@
2565
2603
  rows.push(`<div class="prop-row"><b>Room rules</b>${lines.join("")}</div>`);
2566
2604
  } 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>`);
2567
2605
  }
2606
+ 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>`);
2568
2607
  for (const v of p.vibemates || []) {
2569
2608
  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>`);
2570
2609
  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>`);
@@ -2573,9 +2612,15 @@
2573
2612
  }
2574
2613
  function renderProposal(room, p) {
2575
2614
  const existing = els.messages.querySelector(`[data-ui="ask-card"][data-kind="proposal"][data-key="${CSS.escape(p.key)}"]`);
2615
+ const lookRow = (p.appearance || []).find((c) => c.key === "look");
2616
+ const lookTarget = lookRow && TOKENS.looks[lookRow.to];
2617
+ const windowNote = p.appearance && p.appearance.length
2618
+ ? `<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>`
2619
+ : "";
2576
2620
  const body =
2577
2621
  (p.why ? `<div class="prop-why">${esc(p.why)}</div>` : "") +
2578
2622
  `<div class="prop-diff">${proposalDiffHtml(p)}</div>` +
2623
+ windowNote +
2579
2624
  (p.warnings && p.warnings.length ? `<ul class="prop-warn">${p.warnings.map((w) => `<li>${esc(w)}</li>`).join("")}</ul>` : "") +
2580
2625
  (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>` : "");
2581
2626
  const outcome = p.status === "pending" ? undefined : p.status === "applied" ? `applied${p.skipped && p.skipped.length ? ` · not applied: ${p.skipped.join("; ")}` : ""}` : "rejected";
@@ -2706,12 +2751,12 @@
2706
2751
  </div>
2707
2752
  <div class="section" id="pp-timing">
2708
2753
  ${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.")}
2754
+ ${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
2755
  </div>
2711
2756
  <div class="section danger">
2712
2757
  ${sectionTitle("bolt", "Respawn")}
2713
2758
  <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>
2759
+ <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
2760
  <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
2761
  </div>
2717
2762
  <div class="section">
@@ -2814,7 +2859,7 @@
2814
2859
  els.detailsInner.innerHTML = `
2815
2860
  ${panelTitle("Your vibe", "how the vibemates know you")}
2816
2861
  <div class="profile">
2817
- ${avatar(meAvatarData(), 84, {})}
2862
+ ${avatar(meAvatarData(), 84, { kind: "card" })}
2818
2863
  <h3>${esc(s.humanName || "")}</h3>
2819
2864
  <div class="tagline">${esc(s.humanDescription || "no vibe line yet")}</div>
2820
2865
  </div>
@@ -2868,7 +2913,7 @@
2868
2913
  <div class="section">
2869
2914
  ${sectionTitle("user", "Turn taking")}
2870
2915
  ${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.")}
2916
+ ${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
2917
  <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
2918
  <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
2919
  </div>
@@ -2890,8 +2935,8 @@
2890
2935
  <div class="section">
2891
2936
  ${sectionTitle("chat", "Conversation")}
2892
2937
  ${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}">`)}
2938
+ ${field("Max sentences per reply", `${UI.html("number-field", { id: "rp-maxlen", value: String(rs.maxSentences ?? ""), min: 1, max: 100, placeholder: "no limit" })}`)}
2939
+ ${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
2940
  </div>
2896
2941
  <div class="section">
2897
2942
  ${sectionTitle("eye", "Referee")}
@@ -2899,12 +2944,12 @@
2899
2944
  </div>
2900
2945
  <div class="section">
2901
2946
  ${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}">`)}
2947
+ ${field("Full brief every N vibemate turns", `${UI.html("number-field", { id: "rp-brief-turns", value: String(rs.fullBriefEveryTurns), min: 1, max: 10000 })}`)}
2948
+ ${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
2949
  <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
2950
  <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.")}
2951
+ ${field("Replay last N chat messages after a reconnect", `${UI.html("number-field", { id: "rp-replay", value: String(rs.replayAfterRestart), min: 0, max: 200 })}`)}
2952
+ ${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
2953
  </div>`,
2909
2954
  "tools, hops, referee, briefs",
2910
2955
  )}
@@ -2976,7 +3021,7 @@
2976
3021
  .map((r) => {
2977
3022
  const v = (s.vendorPresets || {})[r.id] || {};
2978
3023
  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>
3024
+ <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
3025
  <div class="vc-fields">
2981
3026
  ${field("Model", `<input type="text" data-vendor="${r.id}" data-key="model" value="${esc(v.model || "")}" placeholder="${esc(r.defaultModel || "vibemate default")}">`)}
2982
3027
  ${field("Effort", `<input type="text" data-vendor="${r.id}" data-key="effort" value="${esc(v.effort || "")}" placeholder="${esc(r.defaultEffort || "vibemate default")}">`)}
@@ -2985,7 +3030,7 @@
2985
3030
  </div>`;
2986
3031
  })
2987
3032
  .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>`;
3033
+ const logo = (r) => vendorLogo(r);
2989
3034
  const machine =
2990
3035
  installed
2991
3036
  .map((r) => {
@@ -3000,6 +3045,31 @@
3000
3045
  <div id="sp-form">
3001
3046
  <div class="page-cols">
3002
3047
  <div>
3048
+ <div class="section" id="sp-appearance">
3049
+ ${sectionTitle("eye", "Appearance")}
3050
+ ${(() => {
3051
+ const a = s.appearance || {};
3052
+ const lookId = TOKENS.looks[a.look] ? a.look : TOKENS.current.id;
3053
+ const look = TOKENS.looks[lookId];
3054
+ const custom = (a.custom || {})[lookId] || {};
3055
+ 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("");
3056
+ const groups = [...new Set(TOKENS.adjustables.map((f) => f.group))];
3057
+ 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("");
3058
+ return `<div class="look-preview" id="sp-look-preview">${lookSampleHtml()}</div>
3059
+ ${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.")}
3060
+ <div class="look-adjust" id="sp-look-adjust">
3061
+ <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>
3062
+ <p class="hint">Kept for this look alone. The sample follows as you pick; the window follows on save.</p>
3063
+ ${groups.map((g) => `<div class="adjust-group"><h5>${esc(g)}</h5>${rows(g)}</div>`).join("")}
3064
+ </div>`;
3065
+ })()}
3066
+ <div class="adjust-group text"><h5>Text</h5>
3067
+ <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>
3068
+ <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>
3069
+ <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>
3070
+ <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>
3071
+ </div>
3072
+ </div>
3003
3073
  <div class="section">
3004
3074
  ${sectionTitle("lock", "Permissions")}
3005
3075
  <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 +3077,13 @@
3007
3077
  <div class="section">
3008
3078
  ${sectionTitle("bolt", "Pace")}
3009
3079
  ${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>
3080
+ ${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
3081
  </div>
3019
3082
  <div class="section" id="sp-editor">
3020
3083
  ${sectionTitle("pencil", "Open files at a line")}
3021
3084
  <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
3085
  <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>') })}
3086
+ ${UI.html("choice", { label: "Auto", on: !["custom", "default-app"].includes((s.editor || {}).mode), data: { mode: "auto" } })}
3024
3087
  ${UI.html("choice", { label: "The default app", on: (s.editor || {}).mode === "default-app", data: { mode: "default-app" } })}
3025
3088
  ${UI.html("choice", { label: "My own command", on: (s.editor || {}).mode === "custom", data: { mode: "custom" } })}
3026
3089
  </div>
@@ -3065,9 +3128,9 @@
3065
3128
  <div class="section">
3066
3129
  ${sectionTitle("rooms", "Defaults for new rooms")}
3067
3130
  <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.")}
3131
+ ${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.")}
3132
+ ${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.")}
3133
+ ${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
3134
  <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
3135
  ${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
3136
  </div>
@@ -3106,12 +3169,6 @@
3106
3169
  showEditorCmd();
3107
3170
  }),
3108
3171
  );
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
3172
  const diagramSection = $("#sp-diagrams");
3116
3173
  const previewTheme = () => ({ preset: $("#sp-diagram-preset").value, primary: $("#sp-diagram-custom").checked ? $("#sp-diagram-color").value : null });
3117
3174
  const redrawPreview = () => {
@@ -3141,6 +3198,135 @@
3141
3198
  if (px >= 12 && px <= 24) sample.style.fontSize = `${px}px`;
3142
3199
  });
3143
3200
  $("#sp-font").addEventListener("change", () => (sample.style.fontFamily = FONTS.text[$("#sp-font").value].stack));
3201
+ const looksBox = $("#sp-appearance");
3202
+ const pickedLook = () => TOKENS.looks[$("#sp-look").value] || TOKENS.current;
3203
+ const rowOf = (key) => looksBox.querySelector(`[data-ui="adjust-row"][data-key="${key}"]`);
3204
+ const inputOf = (key) => {
3205
+ const row = rowOf(key);
3206
+ return row ? row.querySelector("input") : null;
3207
+ };
3208
+ const pendingAdjust = () => {
3209
+ const look = pickedLook();
3210
+ const out = {};
3211
+ for (const f of TOKENS.adjustables) {
3212
+ const input = inputOf(f.key);
3213
+ const value = String(input ? input.value : "").toLowerCase();
3214
+ if (value && value !== String(f.of(look)).toLowerCase()) out[f.key] = value;
3215
+ }
3216
+ return out;
3217
+ };
3218
+ const paintRows = () => {
3219
+ const look = pickedLook();
3220
+ for (const f of TOKENS.adjustables) {
3221
+ const row = rowOf(f.key);
3222
+ const input = inputOf(f.key);
3223
+ if (!row || !input) continue;
3224
+ const own = String(f.of(look));
3225
+ UI.setState(row, input.value.toLowerCase() !== own.toLowerCase() ? "changed" : null);
3226
+ row.querySelector(".value").textContent = input.value;
3227
+ row.querySelector(".back").title = `Back to the look's own, ${own}`;
3228
+ }
3229
+ };
3230
+ const paintLookPreview = () => {
3231
+ const preview = $("#sp-look-preview");
3232
+ if (!preview) return;
3233
+ const look = pickedLook();
3234
+ preview.dataset.look = look.id;
3235
+ for (const [name, value] of Object.entries(TOKENS.cssVars(look))) preview.style.setProperty(name, value);
3236
+ applyCustomVars(preview, pendingAdjust());
3237
+ paintRows();
3238
+ };
3239
+ const fillRows = (lookId) => {
3240
+ const look = TOKENS.looks[lookId] || TOKENS.current;
3241
+ const custom = ((state.settings.appearance || {}).custom || {})[look.id] || {};
3242
+ for (const f of TOKENS.adjustables) {
3243
+ const input = inputOf(f.key);
3244
+ if (input) input.value = custom[f.key] || String(f.of(look));
3245
+ }
3246
+ $("#sp-adj-name").textContent = look.label;
3247
+ };
3248
+ looksBox.querySelectorAll('.look-cards [data-ui="look-card"]').forEach((b) =>
3249
+ b.addEventListener("click", () => {
3250
+ looksBox.querySelectorAll('.look-cards [data-ui="look-card"]').forEach((x) => {
3251
+ UI.setState(x, x === b ? "on" : null);
3252
+ x.setAttribute("aria-pressed", x === b ? "true" : "false");
3253
+ });
3254
+ const input = $("#sp-look");
3255
+ input.value = b.dataset.look;
3256
+ fillRows(b.dataset.look);
3257
+ paintLookPreview();
3258
+ paintOwnRow();
3259
+ input.dispatchEvent(new Event("change", { bubbles: true }));
3260
+ }),
3261
+ );
3262
+ const paintOwnRow = () => {
3263
+ const own = looksBox.querySelector("#sp-look-own .own-only");
3264
+ if (own) own.hidden = !pickedLook().custom;
3265
+ };
3266
+ looksBox.querySelector(".sp-look-import").addEventListener("click", () => $("#sp-look-file").click());
3267
+ $("#sp-look-file").addEventListener("change", async () => {
3268
+ const file = $("#sp-look-file").files && $("#sp-look-file").files[0];
3269
+ $("#sp-look-file").value = "";
3270
+ if (!file) return;
3271
+ try {
3272
+ const spec = JSON.parse(await file.text());
3273
+ const checked = await post("/api/looks/check", { spec });
3274
+ if (!checked.ok) throw new Error(`${file.name} is not a look viberoom can wear: ${(checked.errors || []).map((x) => x.message).join("; ")}`);
3275
+ const taken = TOKENS.looks[checked.id];
3276
+ if (taken && !taken.custom) throw new Error(`"${checked.id}" is the id of a look viberoom ships; change the id in the file`);
3277
+ 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;
3278
+ const saved = await post("/api/looks", { spec, replace: !!taken });
3279
+ 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");
3280
+ } catch (error) {
3281
+ showError(error);
3282
+ }
3283
+ });
3284
+ looksBox.querySelector(".sp-look-export").addEventListener("click", () => {
3285
+ const look = pickedLook();
3286
+ const spec = state.looks.find((l) => l.id === look.id);
3287
+ if (!spec) return;
3288
+ const a = document.createElement("a");
3289
+ a.href = URL.createObjectURL(new Blob([`${JSON.stringify(spec, null, 2)}\n`], { type: "application/json" }));
3290
+ a.download = `${look.id}.json`;
3291
+ document.body.appendChild(a);
3292
+ a.click();
3293
+ a.remove();
3294
+ setTimeout(() => URL.revokeObjectURL(a.href), 2000);
3295
+ });
3296
+ looksBox.querySelector(".sp-look-delete").addEventListener("click", async () => {
3297
+ const look = pickedLook();
3298
+ if (!look.custom) return;
3299
+ 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 });
3300
+ if (!ok) return;
3301
+ try {
3302
+ await post("/api/looks/remove", { id: look.id });
3303
+ toast(`Look "${look.label}" deleted.`, "success");
3304
+ } catch (error) {
3305
+ showError(error);
3306
+ }
3307
+ });
3308
+ looksBox.querySelectorAll('[data-ui="adjust-row"] input').forEach((i) => i.addEventListener("input", paintLookPreview));
3309
+ looksBox.addEventListener("click", (e) => {
3310
+ const back = e.target.closest && e.target.closest('[data-ui="adjust-row"] .back');
3311
+ if (!back) return;
3312
+ const row = back.closest('[data-ui="adjust-row"]');
3313
+ const f = TOKENS.adjustables.find((x) => x.key === row.dataset.key);
3314
+ const input = row.querySelector("input");
3315
+ if (!f || !input) return;
3316
+ input.value = String(f.of(pickedLook()));
3317
+ paintLookPreview();
3318
+ input.dispatchEvent(new Event("change", { bubbles: true }));
3319
+ });
3320
+ looksBox.querySelector(".sp-adj-reset").addEventListener("click", () => {
3321
+ const look = pickedLook();
3322
+ for (const f of TOKENS.adjustables) {
3323
+ const input = inputOf(f.key);
3324
+ if (input) input.value = String(f.of(look));
3325
+ }
3326
+ paintLookPreview();
3327
+ $("#sp-look").dispatchEvent(new Event("change", { bubbles: true }));
3328
+ });
3329
+ paintLookPreview();
3144
3330
  $("#sp-mono").addEventListener("change", () => sample.querySelectorAll("code").forEach((c) => (c.style.fontFamily = FONTS.mono[$("#sp-mono").value].stack)));
3145
3331
  $("#sp-slow-copy").addEventListener("click", async () => {
3146
3332
  const b = $("#sp-slow-copy");
@@ -3193,7 +3379,7 @@
3193
3379
  checkForUpdates: $("#sp-updates").checked,
3194
3380
  diagrams: { preset: $("#sp-diagram-preset").value, primary: $("#sp-diagram-custom").checked ? $("#sp-diagram-color").value : null },
3195
3381
  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 },
3382
+ 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
3383
  roomDefaults: {
3198
3384
  turnTaking: $("#sp-turns").value,
3199
3385
  replyDelay: Number($("#sp-delay").value),
@@ -3580,7 +3766,7 @@
3580
3766
  els.invAgents.innerHTML = state.recipes
3581
3767
  .map(
3582
3768
  (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>`,
3769
+ `<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
3770
  )
3585
3771
  .join("");
3586
3772
  els.invAgents.querySelectorAll(".agent-tile:not(.off)").forEach((b) =>
@@ -3735,6 +3921,7 @@
3735
3921
  if (!room) return closeDialog(els.rcDialog);
3736
3922
  const globalMode = els.rcForm.querySelector('input[name="rc-mode"]:checked').value;
3737
3923
  const replay = Number(els.rcReplay.value);
3924
+ if (Number.isFinite(replay) && replay !== (room.settings.replayAfterRestart ?? 10)) post(roomApi("/settings"), { replayAfterRestart: replay }).catch((error) => toast(error.message, "error"));
3738
3925
  const rows = [...els.rcTable.querySelectorAll("tr")].map((tr) => ({ id: tr.dataset.id, choice: tr.querySelector(".rc-per").value || globalMode }));
3739
3926
  els.rcSubmit.disabled = true;
3740
3927
  UI.setState(els.rcSubmit, "loading");
@@ -3787,7 +3974,7 @@
3787
3974
  tplEls.list.innerHTML = tpl.items
3788
3975
  .map((t) => {
3789
3976
  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("");
3977
+ const faces = t.vibemates.slice(0, 4).map((v) => avatar({ name: v.name, avatar: v.avatar, color: FALLBACK_COLOR() }, 20, { ring: true })).join("");
3791
3978
  return `<button type="button" class="tpl-item${on ? " on" : ""}" data-id="${esc(t.id)}" role="radio" aria-checked="${on ? "true" : "false"}">
3792
3979
  <span class="tpl-emoji">${esc(t.emoji || "🧩")}</span>
3793
3980
  <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 +4082,22 @@
3895
4082
  ${stpField("Language", `<input type="text" id="stp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
3896
4083
  ${stpField("Vibemates' own tools", stpSelect("stp-tools", st.tools || "on-request", [["on-request", "Only when someone explicitly asks"], ["never", "Never (chat only)"]]))}
3897
4084
  ${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))}">`)}
4085
+ ${stpField("Reply delay, seconds", `${UI.html("number-field", { id: "stp-delay", value: String(st.replyDelay ?? 4), min: 0, max: 120, step: 0.5 })}`)}
3899
4086
  ${stpSwitch("Vibemates wake each other", "stp-wake", st.agentsWakeEachOther !== false)}
3900
4087
  ${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">`)}
4088
+ ${stpField("Hop limit", `${UI.html("number-field", { id: "stp-hops", value: String(st.hopLimit ?? 100), min: 0, max: 10000 })}`)}
4089
+ ${stpField("Max sentences per reply", `${UI.html("number-field", { id: "stp-maxlen", value: String(st.maxSentences ?? ""), min: 1, max: 100, placeholder: "no limit" })}`)}
3903
4090
  ${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
4091
  ${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))}">`)}
4092
+ ${stpField("Full brief every N turns", `${UI.html("number-field", { id: "stp-brief-turns", value: String(st.fullBriefEveryTurns ?? 8), min: 1, max: 10000 })}`)}
4093
+ ${stpField("…or every N tokens", `${UI.html("number-field", { id: "stp-brief-tokens", value: String(st.fullBriefEveryTokens ?? 20000), min: 1000, max: 10000000, step: 1000 })}`)}
4094
+ ${stpField("Replay after a reconnect", `${UI.html("number-field", { id: "stp-replay", value: String(st.replayAfterRestart ?? 10), min: 0, max: 200 })}`)}
4095
+ ${stpField("Missed messages read at most", `${UI.html("number-field", { id: "stp-backlog", value: String(st.backlogCap ?? 50), min: 1, max: 1000 })}`)}
3909
4096
  ${stpSwitch("Core rules in every header", "stp-header-rules", st.headerRules !== false)}
3910
4097
  ${stpSwitch("Show vendor and model to other vibemates", "stp-vendor", !!st.showVendorInRoster)}
3911
4098
  </div>`;
3912
4099
  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>
4100
+ 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
4101
  <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
4102
  <div class="stp-vm-fields">
3916
4103
  ${stpField("Vibename", `<input type="text" data-k="name" maxlength="40" value="${esc(v.name)}" required>`)}
@@ -3921,7 +4108,7 @@
3921
4108
  ${stpField("Model", `<input type="text" data-k="model" value="${esc(v.model || "")}" placeholder="the agent's default">`)}
3922
4109
  ${stpField("Effort", `<input type="text" data-k="effort" value="${esc(v.effort || "")}" placeholder="default">`)}
3923
4110
  ${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">`)}
4111
+ ${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
4112
  ${stpField("Skills, comma-separated", `<input type="text" data-k="skills" value="${esc((v.skills || []).join(", "))}">`, true)}
3926
4113
  </div>
3927
4114
  </div></div>`).join("");
@@ -4030,12 +4217,35 @@
4030
4217
  closeDialog(els.pfDialog);
4031
4218
  if (state.view === "home") renderHome();
4032
4219
  toast(`Welcome, ${els.pfName.value.trim()}. Open a room and summon a vibemate.`, "info");
4220
+ if (state.freshVibe) offerLook();
4033
4221
  } catch (error) {
4034
4222
  els.pfError.textContent = error.message;
4035
4223
  els.pfError.hidden = false;
4036
4224
  }
4037
4225
  });
4038
4226
  els.pfDialog.addEventListener("cancel", (event) => event.preventDefault());
4227
+ function offerLook() {
4228
+ const current = (state.settings.appearance || {}).look || TOKENS.current.id;
4229
+ $("#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("");
4230
+ openDialog($("#look-dialog"));
4231
+ }
4232
+ $("#look-dialog-cards").addEventListener("click", async (e) => {
4233
+ const card = e.target.closest('[data-ui="look-card"]');
4234
+ if (!card) return;
4235
+ $("#look-dialog-cards").querySelectorAll('[data-ui="look-card"]').forEach((x) => {
4236
+ UI.setState(x, x === card ? "on" : null);
4237
+ x.setAttribute("aria-pressed", x === card ? "true" : "false");
4238
+ });
4239
+ try {
4240
+ await post("/api/settings", { appearance: { look: card.dataset.look } });
4241
+ } catch (error) {
4242
+ toast(error.message, "error");
4243
+ }
4244
+ });
4245
+ $("#look-form").addEventListener("submit", (e) => {
4246
+ e.preventDefault();
4247
+ closeDialog($("#look-dialog"));
4248
+ });
4039
4249
 
4040
4250
  function openEraseDialog() {
4041
4251
  els.eraseWord.value = "";
@@ -4081,12 +4291,75 @@
4081
4291
  }
4082
4292
 
4083
4293
 
4294
+ const CUSTOM_VARS = [...new Set(TOKENS.adjustables.flatMap((f) => Object.keys(f.vars(f.kind === "scale" ? "1" : TOKENS.current.palette.black))))];
4295
+ function customVars(v) {
4296
+ const out = {};
4297
+ for (const f of TOKENS.adjustables) if (v[f.key]) Object.assign(out, f.vars(v[f.key]));
4298
+ return out;
4299
+ }
4300
+ function applyCustomVars(el, values) {
4301
+ for (const name of CUSTOM_VARS) el.style.removeProperty(name);
4302
+ for (const [name, value] of Object.entries(customVars(values || {}))) el.style.setProperty(name, value);
4303
+ }
4304
+ function registerLooks(specs) {
4305
+ for (const id of Object.keys(TOKENS.looks)) if (TOKENS.looks[id].custom) delete TOKENS.looks[id];
4306
+ for (const spec of specs || []) {
4307
+ try {
4308
+ TOKENS.looks[spec.id] = TOKENS.make(spec);
4309
+ } catch (error) {
4310
+ console.warn(`look ${spec.id} could not be built: ${error.message}`);
4311
+ }
4312
+ }
4313
+ }
4314
+ function refreshLooksCss() {
4315
+ const link = $("#looks-custom");
4316
+ if (link) link.href = `/looks-custom.css?v=${Date.now()}`;
4317
+ }
4318
+ function lookTag(look) {
4319
+ return look.custom ? `${(state.settings && state.settings.humanName) || "Your"}'s look` : undefined;
4320
+ }
4321
+ function tryLook(id) {
4322
+ const root = document.documentElement;
4323
+ if (root.dataset.tried === id || !TOKENS.looks[id]) {
4324
+ applyAppearance();
4325
+ return;
4326
+ }
4327
+ if (id === TOKENS.current.id) delete root.dataset.look;
4328
+ else root.dataset.look = id;
4329
+ applyCustomVars(root, (((state.settings || {}).appearance || {}).custom || {})[id] || {});
4330
+ root.dataset.tried = id;
4331
+ refreshTryButtons();
4332
+ }
4333
+ function refreshTryButtons() {
4334
+ const tried = document.documentElement.dataset.tried || "";
4335
+ document.querySelectorAll('[data-ui="ask-card"] [data-act="try-look"]').forEach((b) => {
4336
+ const label = b.querySelector(".label") || b;
4337
+ label.textContent = tried === b.dataset.look ? "Back" : "Try it on";
4338
+ });
4339
+ }
4340
+ let appliedScheme = null;
4084
4341
  function applyAppearance() {
4085
4342
  const a = (state.settings || {}).appearance || {};
4086
4343
  const root = document.documentElement;
4344
+ delete root.dataset.tried;
4087
4345
  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);
4346
+ const look = TOKENS.looks[a.look] ? a.look : TOKENS.current.id;
4347
+ if (look === TOKENS.current.id) delete root.dataset.look;
4348
+ else root.dataset.look = look;
4349
+ const scheme = TOKENS.looks[look].scheme;
4350
+ if (appliedScheme && appliedScheme !== scheme) {
4351
+ document.querySelectorAll(".mermaid-block[data-rendered]").forEach((b) => delete b.dataset.rendered);
4352
+ renderDiagrams(document);
4353
+ }
4354
+ appliedScheme = scheme;
4355
+ if (look !== TOKENS.current.id && (!a.font || a.font === "nunito")) root.style.removeProperty("--font");
4356
+ else root.style.setProperty("--font", (FONTS.text[a.font] || FONTS.text.nunito).stack);
4089
4357
  root.style.setProperty("--mono", (FONTS.mono[a.mono] || FONTS.mono["jetbrains-mono"]).stack);
4358
+ const frame = document.querySelector('meta[name="theme-color"]');
4359
+ if (frame) frame.content = TOKENS.looks[look].elements.browser.themeColor;
4360
+ applyCustomVars(root, (a.custom || {})[look] || {});
4361
+ document.querySelectorAll("#participants .avatar .life").forEach((ring) => setLifePath(ring, ring.parentElement));
4362
+ refreshTryButtons();
4090
4363
  }
4091
4364
 
4092
4365
  let hubIdentity = null;
@@ -4099,6 +4372,8 @@
4099
4372
  }
4100
4373
  if (identity) hubIdentity = identity;
4101
4374
  state.settings = snapshot.settings;
4375
+ state.looks = snapshot.looks || [];
4376
+ registerLooks(state.looks);
4102
4377
  applyAppearance();
4103
4378
  state.update = snapshot.update || null;
4104
4379
  renderUpdatePop();
@@ -4277,6 +4552,13 @@
4277
4552
  if (state.view === "skills" && !editingInDetails()) renderSkillsPage();
4278
4553
  if (state.detailsOpen && !editingInDetails()) renderDetails();
4279
4554
  },
4555
+ looks: (m) => {
4556
+ state.looks = m.looks || [];
4557
+ registerLooks(state.looks);
4558
+ refreshLooksCss();
4559
+ applyAppearance();
4560
+ if (state.view === "settings" && !editingInDetails()) renderSettingsPage();
4561
+ },
4280
4562
  settings: (m) => {
4281
4563
  state.settings = m.settings;
4282
4564
  applyAppearance();
@@ -4700,7 +4982,7 @@
4700
4982
  const waiting = room ? room.participants.filter((p) => p.kind === "agent" && p.status === "unstaffed") : [];
4701
4983
  castBanner.hidden = waiting.length === 0;
4702
4984
  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>`
4985
+ ? `<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
4986
  : "";
4705
4987
  els.input.disabled = waiting.length > 0;
4706
4988
  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 +5498,9 @@
5216
5498
  if (!nodes.length) return;
5217
5499
  const total = els.messages.scrollHeight || 1;
5218
5500
  const h = Math.max(0, t.ticks.clientHeight - TICK_H);
5219
- const tops = nodes.map(topInList);
5501
+ const fits = els.messages.scrollHeight <= els.messages.clientHeight + 1;
5502
+ const stripTop = fits ? t.ticks.getBoundingClientRect().top : 0;
5503
+ const tops = fits ? nodes.map((el) => el.getBoundingClientRect().top - stripTop) : nodes.map(topInList);
5220
5504
  const frag = document.createDocumentFragment();
5221
5505
  nodes.forEach((el, i) => {
5222
5506
  const tick = document.createElement("div");
@@ -5224,11 +5508,18 @@
5224
5508
  tick.className = `tl-tick${pinned ? " pinned i i-pin" : ""}`;
5225
5509
  tick.dataset.i = i;
5226
5510
  if (opts.colorOf) tick.style.setProperty("--tick", opts.colorOf(room, el));
5227
- tick.style.top = `${Math.round((tops[i] / total) * h)}px`;
5511
+ tick.style.top = `${Math.round(fits ? Math.max(0, Math.min(h, tops[i] + 6)) : (tops[i] / total) * h)}px`;
5228
5512
  frag.appendChild(tick);
5229
5513
  });
5230
5514
  t.ticks.replaceChildren(frag);
5231
5515
  updateView();
5516
+ if (fits && !t.following) {
5517
+ t.following = true;
5518
+ requestAnimationFrame(() => requestAnimationFrame(() => {
5519
+ render();
5520
+ t.following = false;
5521
+ }));
5522
+ }
5232
5523
  }
5233
5524
  function updateView() {
5234
5525
  if (t.el.hidden) return;
@@ -5346,7 +5637,7 @@
5346
5637
  if (room.id !== state.currentRoomId || state.view !== "room") return;
5347
5638
  requestAnimationFrame(() => {
5348
5639
  if (bubbleInView(m.id)) return;
5349
- const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR, kind: "agent" };
5640
+ const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR(), kind: "agent" };
5350
5641
  pushNote({ id: m.id, kind: "done", p, text: `${p.name} finished`, sub: `started ${time(m.ts)}${m.durationMs ? ` · ${spanText(m.durationMs)}` : ""}` });
5351
5642
  });
5352
5643
  }
@@ -5354,7 +5645,7 @@
5354
5645
  if (room.id !== state.currentRoomId || state.view !== "room") return;
5355
5646
  requestAnimationFrame(() => {
5356
5647
  if (bubbleInView(m.id)) return;
5357
- const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR, kind: "agent" };
5648
+ const p = findById(room, m.from) || { name: m.fromName, color: FALLBACK_COLOR(), kind: "agent" };
5358
5649
  pushNote({ id: m.id, kind: "new", p, text: `${p.name} wrote below`, sub: time(m.ts) });
5359
5650
  });
5360
5651
  }
@@ -5391,7 +5682,7 @@
5391
5682
  const timelines = [
5392
5683
  createTimeline($("#timeline"), () => [...els.messages.querySelectorAll(".msg.mine:not(.hidden-by-search)")], {}),
5393
5684
  createTimeline($("#timeline-left"), () => [...els.messages.querySelectorAll(".msg.agent:not(.hidden-by-search)")], {
5394
- colorOf: (room, el) => (authorOf(room, el) || {}).color || FALLBACK_COLOR,
5685
+ colorOf: (room, el) => (authorOf(room, el) || {}).color || FALLBACK_COLOR(),
5395
5686
  avatarOf: (room, el) => { const p = authorOf(room, el); return p ? avatar(p, 16, {}) : ""; },
5396
5687
  }),
5397
5688
  ];
@@ -5404,6 +5695,7 @@
5404
5695
  function updateTimelineView() { for (const t of timelines) t.updateView(); }
5405
5696
  attachScrollHints(els.pageInner);
5406
5697
  attachScrollHints(els.detailsInner);
5698
+ document.querySelectorAll("dialog.dialog").forEach((d) => attachScrollHints(d));
5407
5699
  const composerFollowers = [$("#timeline"), $("#timeline-left"), els.mentionMenu, els.emojiMenu, els.jumpLatest];
5408
5700
  new ResizeObserver(() => {
5409
5701
  const h = `${els.composer.offsetHeight}px`;
@@ -5411,6 +5703,15 @@
5411
5703
  renderTimeline();
5412
5704
  }).observe(els.composer);
5413
5705
  new ResizeObserver(() => renderTimeline()).observe(els.messages);
5706
+ document.fonts.ready.then(() => renderTimeline());
5707
+ let timelineTimer = 0;
5708
+ new MutationObserver(() => {
5709
+ if (timelineTimer) return;
5710
+ timelineTimer = setTimeout(() => {
5711
+ timelineTimer = 0;
5712
+ renderTimeline();
5713
+ }, 300);
5714
+ }).observe(els.messages, { childList: true, subtree: true });
5414
5715
 
5415
5716
  const workingNow = $("#working-now");
5416
5717
  function renderWorkingNow() {
@@ -5453,8 +5754,27 @@
5453
5754
  const left = Math.max(0, Math.min(100, 100 - Math.round((100 * (p.contextUsed || 0)) / p.contextSize)));
5454
5755
  return { left, tone: left <= 20 ? "hot" : left <= 50 ? "warm" : "ok" };
5455
5756
  }
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>`;
5757
+ function lifePathFor(r) {
5758
+ const s = 2.5, e = 49.5, mid = 26;
5759
+ r = Math.max(0, Math.min(23.5, r));
5760
+ const n = (v) => String(Math.round(v * 10) / 10);
5761
+ if (r < 0.5) return `M${mid} ${s}H${e}V${e}H${s}V${s}H${mid}`;
5762
+ const a = (x, y) => `A${n(r)} ${n(r)} 0 0 1 ${n(x)} ${n(y)}`;
5763
+ 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}`;
5764
+ }
5765
+ function lifeRadius(av) {
5766
+ const cs = getComputedStyle(av.isConnected ? av : document.documentElement);
5767
+ const scale = parseFloat(cs.getPropertyValue("--r-scale"));
5768
+ const corner = parseFloat(cs.getPropertyValue("--face-corner"));
5769
+ return ((Number.isFinite(corner) ? corner : 0.32) * 44 + 4) * (Number.isFinite(scale) ? scale : 1);
5770
+ }
5771
+ function setLifePath(ring, av) {
5772
+ const d = lifePathFor(lifeRadius(av));
5773
+ if (ring.dataset.d === d) return;
5774
+ ring.querySelectorAll("path").forEach((path) => path.setAttribute("d", d));
5775
+ ring.dataset.d = d;
5776
+ }
5777
+ 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
5778
  function patchLifeRing(li, p) {
5459
5779
  const av = li.querySelector(".avatar");
5460
5780
  if (!av) return;
@@ -5464,6 +5784,7 @@
5464
5784
  ring = av.querySelector(".life");
5465
5785
  av.classList.add("has-life");
5466
5786
  }
5787
+ setLifePath(ring, av);
5467
5788
  const { left, tone } = lifeOf(p);
5468
5789
  const cls = `life life-${tone}`;
5469
5790
  if (ring.getAttribute("class") !== cls) ring.setAttribute("class", cls);
@@ -5553,7 +5874,7 @@
5553
5874
  ${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
5875
  <div class="lp-respawn">
5555
5876
  ${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>
5877
+ <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
5878
  </div>
5558
5879
  </div>
5559
5880
  <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>`;