viberoom 0.7.0 → 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.
package/ui/app.js CHANGED
@@ -7,6 +7,7 @@
7
7
  recipes: [],
8
8
  roomDefaults: null,
9
9
  skills: [],
10
+ looks: [],
10
11
  version: null,
11
12
  rooms: new Map(),
12
13
  currentRoomId: null,
@@ -161,36 +162,7 @@
161
162
  } catch {
162
163
  }
163
164
 
164
- const FONTS = {
165
- text: {
166
- nunito: { label: "Nunito (default)", stack: '"Nunito", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
167
- inter: { label: "Inter", stack: '"Inter", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
168
- "noto-sans": { label: "Noto Sans", stack: '"Noto Sans", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
169
- "open-sans": { label: "Open Sans", stack: '"Open Sans", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
170
- "source-sans-3": { label: "Source Sans 3", stack: '"Source Sans 3", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
171
- "ibm-plex-sans": { label: "IBM Plex Sans", stack: '"IBM Plex Sans", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
172
- "manrope": { label: "Manrope", stack: '"Manrope", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
173
- "rubik": { label: "Rubik", stack: '"Rubik", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
174
- "montserrat": { label: "Montserrat", stack: '"Montserrat", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
175
- "golos-text": { label: "Golos Text", stack: '"Golos Text", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
176
- "exo-2": { label: "Exo 2", stack: '"Exo 2", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
177
- "comfortaa": { label: "Comfortaa", stack: '"Comfortaa", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
178
- "ubuntu-sans": { label: "Ubuntu Sans", stack: '"Ubuntu Sans", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif' },
179
- arial: { label: "Arial / Helvetica (system)", stack: 'Arial, Helvetica, "Liberation Sans", sans-serif' },
180
- system: { label: "System UI font", stack: 'system-ui, -apple-system, "Segoe UI", Roboto, Cantarell, sans-serif' },
181
- },
182
- mono: {
183
- "jetbrains-mono": { label: "JetBrains Mono (default)", stack: '"JetBrains Mono", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
184
- "fira-code": { label: "Fira Code", stack: '"Fira Code", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
185
- "source-code-pro": { label: "Source Code Pro", stack: '"Source Code Pro", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
186
- "ibm-plex-mono": { label: "IBM Plex Mono", stack: '"IBM Plex Mono", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
187
- "pt-mono": { label: "PT Mono", stack: '"PT Mono", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
188
- "victor-mono": { label: "Victor Mono", stack: '"Victor Mono", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
189
- "anonymous-pro": { label: "Anonymous Pro", stack: '"Anonymous Pro", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
190
- "cascadia-code": { label: "Cascadia Code", stack: '"Cascadia Code", ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", monospace' },
191
- system: { label: "System monospace", stack: 'ui-monospace, Consolas, Menlo, "DejaVu Sans Mono", "Courier New", monospace' },
192
- },
193
- };
165
+ const FONTS = globalThis.VIBEROOM_TOKENS.fonts;
194
166
  const STATUS_LABEL = { unstaffed: "needs a coding agent", starting: "starting…", idle: "ready", queued: "waiting…", thinking: "thinking…", writing: "writing…", error: "error", offline: "offline", left: "left" };
195
167
  const STATUS_TONE = { idle: "ready", queued: "waiting", starting: "waiting", thinking: "thinking", writing: "writing", error: "error", offline: "asleep", left: "asleep", unstaffed: "attention" };
196
168
  const TOOL_STATUSES = new Set(["pending", "in_progress", "completed", "failed"]);
@@ -2149,6 +2121,7 @@
2149
2121
  const card = choice.closest('[data-ui="ask-card"]');
2150
2122
  if (choice.dataset.act === "permit") post(roomApi(`/permissions/${encodeURIComponent(card.dataset.key)}`), { optionId: choice.dataset.option || null }).catch(showError);
2151
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);
2152
2125
  return;
2153
2126
  }
2154
2127
  const chip = e.target.closest('[data-ui="tool-call"] > [data-ui="chip"]');
@@ -2630,6 +2603,7 @@
2630
2603
  rows.push(`<div class="prop-row"><b>Room rules</b>${lines.join("")}</div>`);
2631
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>`);
2632
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>`);
2633
2607
  for (const v of p.vibemates || []) {
2634
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>`);
2635
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>`);
@@ -2638,9 +2612,15 @@
2638
2612
  }
2639
2613
  function renderProposal(room, p) {
2640
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
+ : "";
2641
2620
  const body =
2642
2621
  (p.why ? `<div class="prop-why">${esc(p.why)}</div>` : "") +
2643
2622
  `<div class="prop-diff">${proposalDiffHtml(p)}</div>` +
2623
+ windowNote +
2644
2624
  (p.warnings && p.warnings.length ? `<ul class="prop-warn">${p.warnings.map((w) => `<li>${esc(w)}</li>`).join("")}</ul>` : "") +
2645
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>` : "");
2646
2626
  const outcome = p.status === "pending" ? undefined : p.status === "applied" ? `applied${p.skipped && p.skipped.length ? ` · not applied: ${p.skipped.join("; ")}` : ""}` : "rejected";
@@ -3072,11 +3052,11 @@
3072
3052
  const lookId = TOKENS.looks[a.look] ? a.look : TOKENS.current.id;
3073
3053
  const look = TOKENS.looks[lookId];
3074
3054
  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("");
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("");
3076
3056
  const groups = [...new Set(TOKENS.adjustables.map((f) => f.group))];
3077
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("");
3078
3058
  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.")}
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.")}
3080
3060
  <div class="look-adjust" id="sp-look-adjust">
3081
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>
3082
3062
  <p class="hint">Kept for this look alone. The sample follows as you pick; the window follows on save.</p>
@@ -3275,9 +3255,56 @@
3275
3255
  input.value = b.dataset.look;
3276
3256
  fillRows(b.dataset.look);
3277
3257
  paintLookPreview();
3258
+ paintOwnRow();
3278
3259
  input.dispatchEvent(new Event("change", { bubbles: true }));
3279
3260
  }),
3280
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
+ });
3281
3308
  looksBox.querySelectorAll('[data-ui="adjust-row"] input').forEach((i) => i.addEventListener("input", paintLookPreview));
3282
3309
  looksBox.addEventListener("click", (e) => {
3283
3310
  const back = e.target.closest && e.target.closest('[data-ui="adjust-row"] .back');
@@ -4199,7 +4226,7 @@
4199
4226
  els.pfDialog.addEventListener("cancel", (event) => event.preventDefault());
4200
4227
  function offerLook() {
4201
4228
  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("");
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("");
4203
4230
  openDialog($("#look-dialog"));
4204
4231
  }
4205
4232
  $("#look-dialog-cards").addEventListener("click", async (e) => {
@@ -4274,10 +4301,47 @@
4274
4301
  for (const name of CUSTOM_VARS) el.style.removeProperty(name);
4275
4302
  for (const [name, value] of Object.entries(customVars(values || {}))) el.style.setProperty(name, value);
4276
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
+ }
4277
4340
  let appliedScheme = null;
4278
4341
  function applyAppearance() {
4279
4342
  const a = (state.settings || {}).appearance || {};
4280
4343
  const root = document.documentElement;
4344
+ delete root.dataset.tried;
4281
4345
  root.style.setProperty("--fs-scale", String((a.chatFontSize || 14.5) / 14.5));
4282
4346
  const look = TOKENS.looks[a.look] ? a.look : TOKENS.current.id;
4283
4347
  if (look === TOKENS.current.id) delete root.dataset.look;
@@ -4295,6 +4359,7 @@
4295
4359
  if (frame) frame.content = TOKENS.looks[look].elements.browser.themeColor;
4296
4360
  applyCustomVars(root, (a.custom || {})[look] || {});
4297
4361
  document.querySelectorAll("#participants .avatar .life").forEach((ring) => setLifePath(ring, ring.parentElement));
4362
+ refreshTryButtons();
4298
4363
  }
4299
4364
 
4300
4365
  let hubIdentity = null;
@@ -4307,6 +4372,8 @@
4307
4372
  }
4308
4373
  if (identity) hubIdentity = identity;
4309
4374
  state.settings = snapshot.settings;
4375
+ state.looks = snapshot.looks || [];
4376
+ registerLooks(state.looks);
4310
4377
  applyAppearance();
4311
4378
  state.update = snapshot.update || null;
4312
4379
  renderUpdatePop();
@@ -4485,6 +4552,13 @@
4485
4552
  if (state.view === "skills" && !editingInDetails()) renderSkillsPage();
4486
4553
  if (state.detailsOpen && !editingInDetails()) renderDetails();
4487
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
+ },
4488
4562
  settings: (m) => {
4489
4563
  state.settings = m.settings;
4490
4564
  applyAppearance();