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/NOTICE CHANGED
@@ -11,6 +11,20 @@ This product bundles the following third-party material:
11
11
  - JetBrains Mono: Copyright 2020 The JetBrains Mono Project Authors.
12
12
  - Fira Code: Copyright 2014-2020 The Fira Code Project Authors.
13
13
  - Source Code Pro: Copyright 2010-2019 Adobe (http://www.adobe.com/).
14
+ - Open Sans: Copyright 2020 The Open Sans Project Authors.
15
+ - Source Sans 3: Copyright 2010-2020 Adobe (http://www.adobe.com/).
16
+ - IBM Plex Sans, IBM Plex Mono: Copyright 2017 IBM Corp.
17
+ - Manrope: Copyright 2018 The Manrope Project Authors.
18
+ - Rubik: Copyright 2015 The Rubik Project Authors.
19
+ - Montserrat: Copyright 2011 The Montserrat Project Authors.
20
+ - Golos Text: Copyright 2023 The Golos Text Project Authors.
21
+ - Exo 2: Copyright 2013 The Exo 2 Project Authors.
22
+ - Comfortaa: Copyright 2011 The Comfortaa Project Authors.
23
+ - Ubuntu Sans: Copyright 2022 The Ubuntu Sans Project Authors.
24
+ - PT Mono: Copyright 2011 ParaType Ltd.
25
+ - Victor Mono: Copyright 2019 The Victor Mono Project Authors.
26
+ - Anonymous Pro: Copyright 2009 The Anonymous Pro Project Authors.
27
+ - Cascadia Code: Copyright 2019 Microsoft Corporation.
14
28
 
15
29
  - Vendor icons (assets/vendors/): the logos of Claude, Codex, Gemini, Cursor,
16
30
  OpenCode and GitHub Copilot, as published in the Agent Client Protocol
package/README.md CHANGED
@@ -88,6 +88,17 @@ brief the others would receive, save a template for you to pick under New room,
88
88
  the room you are in. A proposal is a card in the chat with the diff; nothing changes until you click
89
89
  Apply, and the room is told what you decided.
90
90
 
91
+ ## Let them design the look
92
+
93
+ A look (how the window is drawn: colours, light, corners, fonts) is data, not code: which of the shipped
94
+ looks it starts from, a few hues, and what it wants otherwise. Ask a vibemate for "a warm paper look for
95
+ long evenings" and it has a built-in skill on what makes a look good and four tools: read every token
96
+ with what it means, check a draft (the hub measures whether the words read on their paper and reports
97
+ the numbers, since a vibemate cannot see colours), save the look among your own (Settings → Appearance,
98
+ after the shipped ones, with your name on it), or propose wearing it as a card you can try on first. Your
99
+ own looks are files under your data folder: export one to share it, import one someone sent you, delete
100
+ one you are done with. A shipped look can only be fine-tuned, never rewritten.
101
+
91
102
  <br>
92
103
 
93
104
  ## Talk to all, or to one
package/dist/hub.js CHANGED
@@ -11,17 +11,72 @@ import { DEFAULT_ROOM_SETTINGS } from "./persona.js";
11
11
  import { Room } from "./room.js";
12
12
  import { SkillLibrary } from "./skills.js";
13
13
  import { TemplateLibrary, roomSettingsFromTemplate } from "./templates.js";
14
- export const TEXT_FONTS = ["nunito", "inter", "noto-sans", "arial", "system"];
15
- export const MONO_FONTS = ["jetbrains-mono", "fira-code", "source-code-pro", "system"];
16
- export const DEFAULT_APPEARANCE = { chatFontSize: 14.5, font: "nunito", mono: "jetbrains-mono" };
14
+ import { LookLibrary, checkLookSpec, loadTokens } from "./looks.js";
15
+ export const ADJUSTABLE = {
16
+ canvas: "colour", panel: "colour", bubble: "colour", mine: "colour", ring: "colour", ink: "colour", muted: "colour", accent: "colour",
17
+ face: "colour", logo: "colour", logoInk: "colour", corners: "scale",
18
+ };
19
+ export const TEXT_FONTS = ["nunito", "inter", "noto-sans", "open-sans", "source-sans-3", "ibm-plex-sans", "manrope", "rubik", "montserrat", "golos-text", "exo-2", "comfortaa", "ubuntu-sans", "arial", "system"];
20
+ export const MONO_FONTS = ["jetbrains-mono", "fira-code", "source-code-pro", "ibm-plex-mono", "pt-mono", "victor-mono", "anonymous-pro", "cascadia-code", "system"];
21
+ export const DEFAULT_APPEARANCE = { chatFontSize: 14.5, font: "nunito", mono: "jetbrains-mono", look: "classic", custom: {} };
17
22
  export const DIAGRAM_PRESETS = ["pop", "lavender", "mint", "sunset", "slate"];
18
23
  const ROOM_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
19
24
  const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules"];
25
+ export function nextAppearance(current, a) {
26
+ const chatFontSize = Number(a.chatFontSize ?? current.chatFontSize ?? DEFAULT_APPEARANCE.chatFontSize);
27
+ if (!Number.isFinite(chatFontSize) || chatFontSize < 12 || chatFontSize > 24)
28
+ throw new Error("appearance.chatFontSize must be between 12 and 24");
29
+ const font = String(a.font ?? current.font ?? DEFAULT_APPEARANCE.font);
30
+ if (!TEXT_FONTS.includes(font))
31
+ throw new Error(`appearance.font must be one of ${TEXT_FONTS.join(", ")}`);
32
+ const mono = String(a.mono ?? current.mono ?? DEFAULT_APPEARANCE.mono);
33
+ if (!MONO_FONTS.includes(mono))
34
+ throw new Error(`appearance.mono must be one of ${MONO_FONTS.join(", ")}`);
35
+ const look = String(a.look ?? current.look ?? DEFAULT_APPEARANCE.look);
36
+ if (!/^[a-z][a-z0-9-]{0,30}$/.test(look))
37
+ throw new Error("appearance.look must be a short lower-case id");
38
+ const custom = { ...(current.custom ?? {}) };
39
+ if (a.custom !== undefined && typeof a.custom === "object" && a.custom) {
40
+ for (const [lookId, values] of Object.entries(a.custom)) {
41
+ if (!/^[a-z][a-z0-9-]{0,30}$/.test(lookId))
42
+ throw new Error("appearance.custom: a look id is a short lower-case id");
43
+ if (values === null) {
44
+ delete custom[lookId];
45
+ continue;
46
+ }
47
+ if (typeof values !== "object")
48
+ throw new Error("appearance.custom: each look takes an object of adjustments");
49
+ const clean = {};
50
+ for (const [key, value] of Object.entries(values)) {
51
+ const kind = ADJUSTABLE[key];
52
+ if (!kind)
53
+ throw new Error(`appearance.custom: "${key}" is not adjustable (${Object.keys(ADJUSTABLE).join(", ")})`);
54
+ if (kind === "colour") {
55
+ if (typeof value !== "string" || !/^#[0-9a-fA-F]{6}$/.test(value))
56
+ throw new Error(`appearance.custom.${key} must be a colour like #1a2b3c`);
57
+ clean[key] = value.toLowerCase();
58
+ }
59
+ else {
60
+ const n = typeof value === "string" || typeof value === "number" ? Number(value) : NaN;
61
+ if (!Number.isFinite(n) || n < 0 || n > 2)
62
+ throw new Error(`appearance.custom.${key} must be a number between 0 and 2`);
63
+ clean[key] = String(Math.round(n * 100) / 100);
64
+ }
65
+ }
66
+ if (Object.keys(clean).length)
67
+ custom[lookId] = clean;
68
+ else
69
+ delete custom[lookId];
70
+ }
71
+ }
72
+ return { chatFontSize: Math.round(chatFontSize * 2) / 2, font, mono, look, custom };
73
+ }
20
74
  export class Hub extends EventEmitter {
21
75
  dataDir;
22
76
  rooms = new Map();
23
77
  skills;
24
78
  templates;
79
+ looks;
25
80
  settings;
26
81
  log;
27
82
  optionCache = new Map();
@@ -35,6 +90,7 @@ export class Hub extends EventEmitter {
35
90
  mkdirSync(join(this.dataDir, "rooms"), { recursive: true });
36
91
  this.skills = new SkillLibrary(join(this.dataDir, "skills"), log.child("skills"));
37
92
  this.templates = new TemplateLibrary(join(this.dataDir, "templates"), log.child("templates"));
93
+ this.looks = new LookLibrary(join(this.dataDir, "looks"), log.child("looks"));
38
94
  try {
39
95
  this.skills.seedBuiltins();
40
96
  }
@@ -47,6 +103,30 @@ export class Hub extends EventEmitter {
47
103
  hubUrl: () => this.hubUrl,
48
104
  templates: this.templates,
49
105
  templatesChanged: () => this.emit("event", { type: "templates" }),
106
+ looks: {
107
+ list: () => this.looks.list(),
108
+ describe: () => this.looks.describe(),
109
+ check: (raw) => checkLookSpec(raw),
110
+ save: (raw, options) => this.saveLook(raw, options),
111
+ },
112
+ appearance: {
113
+ current: () => ({ ...this.settings.appearance, custom: { ...(this.settings.appearance.custom ?? {}) } }),
114
+ preview: (patch) => nextAppearance(this.settings.appearance, patch),
115
+ apply: (patch) => {
116
+ this.updateSettings({ appearance: patch });
117
+ },
118
+ ownAdjustments: async (lookId) => {
119
+ const tokens = await loadTokens();
120
+ let look = tokens.looks[lookId];
121
+ if (!look) {
122
+ const spec = this.looks.get(lookId);
123
+ if (!spec)
124
+ return null;
125
+ look = tokens.make(spec);
126
+ }
127
+ return { label: look.label, values: Object.fromEntries(tokens.adjustables.map((f) => [f.key, String(f.of(look))])) };
128
+ },
129
+ },
50
130
  needApproval: () => this.settings.agentSkillsNeedApproval === true,
51
131
  save: (draft) => {
52
132
  const { body: _b, ...meta } = this.saveSkillInternal(draft);
@@ -205,17 +285,7 @@ export class Hub extends EventEmitter {
205
285
  next.editor = { mode, command };
206
286
  }
207
287
  if (patch.appearance !== undefined && typeof patch.appearance === "object" && patch.appearance) {
208
- const a = patch.appearance;
209
- const chatFontSize = Number(a.chatFontSize ?? next.appearance?.chatFontSize ?? DEFAULT_APPEARANCE.chatFontSize);
210
- if (!Number.isFinite(chatFontSize) || chatFontSize < 12 || chatFontSize > 24)
211
- throw new Error("appearance.chatFontSize must be between 12 and 24");
212
- const font = String(a.font ?? next.appearance?.font ?? DEFAULT_APPEARANCE.font);
213
- if (!TEXT_FONTS.includes(font))
214
- throw new Error(`appearance.font must be one of ${TEXT_FONTS.join(", ")}`);
215
- const mono = String(a.mono ?? next.appearance?.mono ?? DEFAULT_APPEARANCE.mono);
216
- if (!MONO_FONTS.includes(mono))
217
- throw new Error(`appearance.mono must be one of ${MONO_FONTS.join(", ")}`);
218
- next.appearance = { chatFontSize: Math.round(chatFontSize * 2) / 2, font, mono };
288
+ next.appearance = nextAppearance(next.appearance, patch.appearance);
219
289
  }
220
290
  next.appearance = { ...DEFAULT_APPEARANCE, ...(next.appearance ?? {}) };
221
291
  if (!next.editor)
@@ -451,12 +521,28 @@ export class Hub extends EventEmitter {
451
521
  this.update = update;
452
522
  this.emit("event", { type: "update", update });
453
523
  }
524
+ async saveLook(raw, options) {
525
+ const saved = await this.looks.save(raw, options);
526
+ this.emit("event", { type: "looks", looks: this.looks.list() });
527
+ return saved;
528
+ }
529
+ removeLook(id) {
530
+ const removed = this.looks.remove(id);
531
+ if (!removed)
532
+ return false;
533
+ if (this.settings.appearance?.look === id || this.settings.appearance?.custom?.[id]) {
534
+ this.updateSettings({ appearance: { look: this.settings.appearance.look === id ? DEFAULT_APPEARANCE.look : this.settings.appearance.look, custom: { [id]: null } } });
535
+ }
536
+ this.emit("event", { type: "looks", looks: this.looks.list() });
537
+ return true;
538
+ }
454
539
  snapshot() {
455
540
  return {
456
541
  settings: this.settings,
457
542
  update: this.update,
458
543
  recipes: listRecipes().map(({ build: _b, ...r }) => r),
459
544
  skills: this.skills.list(),
545
+ looks: this.looks.list(),
460
546
  roomDefaults: { ...DEFAULT_ROOM_SETTINGS, ...this.settings.roomDefaults },
461
547
  rooms: [...this.rooms.values()].map((room) => room.snapshot()),
462
548
  openRooms: [...this.openRooms],
@@ -477,6 +563,7 @@ export class Hub extends EventEmitter {
477
563
  rmSync(join(this.dataDir, "rooms"), { recursive: true, force: true });
478
564
  rmSync(this.roomsPath(), { force: true });
479
565
  rmSync(this.skills.dir, { recursive: true, force: true });
566
+ rmSync(this.looks.dir, { recursive: true, force: true });
480
567
  mkdirSync(join(this.dataDir, "rooms"), { recursive: true });
481
568
  mkdirSync(this.skills.dir, { recursive: true });
482
569
  this.skills.list();
@@ -0,0 +1,180 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ const outsideCalls = (value) => {
3
+ let out = "";
4
+ let depth = 0;
5
+ for (const ch of String(value)) {
6
+ if (ch === "(") {
7
+ if (depth === 0)
8
+ out += "(";
9
+ depth++;
10
+ }
11
+ else if (ch === ")") {
12
+ depth = Math.max(0, depth - 1);
13
+ if (depth === 0)
14
+ out += ")";
15
+ }
16
+ else if (depth === 0)
17
+ out += ch;
18
+ }
19
+ return out.replace(/[\w-]+\(\)/g, "x");
20
+ };
21
+ export function lintKinds(look, issues) {
22
+ const check = (path, key, value, section) => {
23
+ const v = String(value).trim();
24
+ const isRadiusKey = section === "shape" ? key !== "rScale" && key !== "rCtlMin" : /[Rr]adius/.test(key);
25
+ if (isRadiusKey) {
26
+ const bare = outsideCalls(v);
27
+ if (/\//.test(bare) || bare.trim().split(/\s+/).filter(Boolean).length > 1)
28
+ issues.errors.push({ level: "error", key: path, message: `${path} is one length or calc(), not "${v.slice(0, 60)}": the named radii are used inside calc() and max(), so a multi-corner value leaves every corner square` });
29
+ return;
30
+ }
31
+ if ((key === "border" || /Border$/.test(key)) && !/^#|^rgba?\(|^transparent$|^currentColor$/i.test(v)) {
32
+ if (/^-?\d*\.?\d+(px|em|rem|pt)?$/.test(v) && v !== "0" && v !== "0px")
33
+ issues.warnings.push({ level: "warning", key: path, message: `${path} "${v}" is a width alone, which draws no border: write width, style and colour ("2px solid $ink")` });
34
+ return;
35
+ }
36
+ if (/Lift$/.test(key)) {
37
+ const n = parseFloat(v);
38
+ if (Number.isFinite(n) && n > 0)
39
+ issues.warnings.push({ level: "warning", key: path, message: `${path} "${v}" pushes the thing down on hover; a lift is negative ("-2px")` });
40
+ return;
41
+ }
42
+ if (/Drop$/.test(key)) {
43
+ const n = parseFloat(v);
44
+ if (Number.isFinite(n) && n < 0)
45
+ issues.warnings.push({ level: "warning", key: path, message: `${path} "${v}" lifts the thing when pressed; a drop is positive ("1px")` });
46
+ }
47
+ };
48
+ for (const [key, value] of Object.entries(look.shape ?? {}))
49
+ check(`shape.${key}`, key, value, "shape");
50
+ for (const [group, parts] of Object.entries(look.elements))
51
+ for (const [key, value] of Object.entries(parts))
52
+ check(`elements.${group}.${key}`, key, value, "elements");
53
+ }
54
+ export function contrast(a, b) {
55
+ const hex = (s) => {
56
+ const m = /^#([0-9a-f]{6})$/i.exec(String(s).trim());
57
+ return m ? [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16)) : null;
58
+ };
59
+ const lum = (c) => {
60
+ const f = (v) => {
61
+ v /= 255;
62
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
63
+ };
64
+ return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]);
65
+ };
66
+ const x = hex(a);
67
+ const y = hex(b);
68
+ if (!x || !y)
69
+ return null;
70
+ const [l1, l2] = [lum(x), lum(y)];
71
+ return +((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)).toFixed(2);
72
+ }
73
+ export function luminance(colour) {
74
+ const c = contrast(colour, "#000000");
75
+ return c === null ? null : (c * 0.05 - 0.05);
76
+ }
77
+ export function hslHex(h, s, l) {
78
+ s /= 100;
79
+ l /= 100;
80
+ const k = (n) => (n + h / 30) % 12;
81
+ const a = s * Math.min(l, 1 - l);
82
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
83
+ return `#${[f(0), f(8), f(4)].map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("")}`;
84
+ }
85
+ export function flatten(value, paper) {
86
+ const v = String(value).trim();
87
+ if (/^#[0-9a-f]{6}$/i.test(v))
88
+ return v.toLowerCase();
89
+ const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i.exec(v);
90
+ const p = /^#([0-9a-f]{6})$/i.exec(String(paper).trim());
91
+ if (!m || !p)
92
+ return null;
93
+ const a = m[4] === undefined ? 1 : Math.max(0, Math.min(1, parseFloat(m[4])));
94
+ const base = [0, 2, 4].map((i) => parseInt(p[1].slice(i, i + 2), 16));
95
+ const top = [m[1], m[2], m[3]].map((x) => parseInt(x, 10));
96
+ return `#${top.map((c, i) => Math.round(c * a + base[i] * (1 - a)).toString(16).padStart(2, "0")).join("")}`;
97
+ }
98
+ const NOT_A_PAIR = new Set(["meaning.attentionInk"]);
99
+ export function lintLook(look) {
100
+ const errors = [];
101
+ const warnings = [];
102
+ const report = [];
103
+ const dark = look.scheme === "dark";
104
+ const at = (inkKey, ink, bgKey, bg, floor) => {
105
+ const c = contrast(ink, bg);
106
+ if (c === null)
107
+ return;
108
+ report.push(`${inkKey} on ${bgKey}: ${c}:1 (floor ${floor})`);
109
+ if (c < floor)
110
+ errors.push({ level: "error", key: inkKey, message: `${inkKey} ${ink} on ${bgKey} ${bg} reads ${c}:1, under ${floor}:1` });
111
+ };
112
+ const e = look.elements;
113
+ const p = look.palette;
114
+ const bubble = e.bubble?.bg ?? "";
115
+ at("elements.bubble.ink", e.bubble?.ink ?? "", "elements.bubble.bg", bubble, 7);
116
+ at("palette.muted", p.muted, "elements.bubble.bg", bubble, dark ? 4.5 : 3);
117
+ at("palette.faint", p.faint, "elements.bubble.bg", bubble, dark ? 3 : 2);
118
+ at("palette.ink", p.ink, "palette.bg", p.bg, 7);
119
+ at("elements.logoTile.ink", e.logoTile?.ink ?? "", "elements.logoTile.bg", e.logoTile?.bg ?? "", 3);
120
+ at("elements.logoTile.badgeInk", e.logoTile?.badgeInk ?? "", "elements.logoTile.badgeBg", e.logoTile?.badgeBg ?? "", 3);
121
+ at("elements.btn.onPrimary", e.btn?.onPrimary ?? "", "palette.primary", p.primary, 3);
122
+ at("elements.face.humanInk", e.face?.humanInk ?? "", "elements.face.paper", e.face?.paper ?? "", 4.5);
123
+ if (dark && e.roomMark?.gradFrom) {
124
+ const [s, l] = e.roomMark.gradFrom.split(" ").map((v) => parseFloat(v));
125
+ if (Number.isFinite(s) && Number.isFinite(l)) {
126
+ for (const hue of [0, 60, 120, 180, 240, 300])
127
+ at(`elements.roomMark.ink (hue ${hue})`, e.roomMark.ink, "the mark's paper", hslHex(hue, s, l), 3);
128
+ }
129
+ }
130
+ const codeBg = e.code?.bg ?? "";
131
+ for (const [part, colour] of Object.entries(e.syntax ?? {}))
132
+ at(`elements.syntax.${part}`, colour, "elements.code.bg", codeBg, 3);
133
+ at("elements.code.ink", e.code?.ink ?? "", "elements.code.bg", codeBg, 4.5);
134
+ at("elements.code.gutterInk", e.code?.gutterInk ?? "", "elements.code.gutterBg", e.code?.gutterBg ?? "", 3);
135
+ const headBtnBg = flatten(e.code?.headBtnBg ?? "", codeBg);
136
+ if (headBtnBg)
137
+ at("elements.code.headBtnInk", e.code?.headBtnInk ?? "", "elements.code.headBtnBg over the block", headBtnBg, 3);
138
+ const cardBg = e.fileCard?.bg ?? "";
139
+ const cardHead = flatten(e.fileCard?.headBg ?? "", cardBg);
140
+ if (cardHead)
141
+ at("elements.fileCard.headInk", e.fileCard?.headInk ?? "", "elements.fileCard.headBg over the card", cardHead, 3);
142
+ const darkBtn = flatten(e.btn?.darkBg ?? "", cardBg);
143
+ if (darkBtn)
144
+ at("elements.btn.darkInk", e.btn?.darkInk ?? "", "elements.btn.darkBg over the file card", darkBtn, 3);
145
+ const groups = { ...e, meaning: look.meaning ?? {} };
146
+ for (const [group, parts] of Object.entries(groups)) {
147
+ for (const [key, value] of Object.entries(parts)) {
148
+ if (!(key === "ink" || /Ink$/.test(key)) || NOT_A_PAIR.has(`${group}.${key}`))
149
+ continue;
150
+ const stem = key === "ink" ? "" : key.slice(0, -3);
151
+ const bgKey = group === "meaning" ? stem : stem ? `${stem}Bg` : "bg";
152
+ if (!(bgKey in parts))
153
+ continue;
154
+ const where = group === "meaning" ? "meaning" : `elements.${group}`;
155
+ at(`${where}.${key}`, value, `${where}.${bgKey}`, parts[bgKey], 3);
156
+ }
157
+ }
158
+ const off = contrast(bubble, p.bg);
159
+ if (off !== null) {
160
+ report.push(`elements.bubble.bg off palette.bg: ${off}:1 (1.1 or a ring)`);
161
+ const ring = e.bubble?.border ?? "transparent";
162
+ if (off < 1.1 && /^transparent$/i.test(ring))
163
+ warnings.push({ level: "warning", key: "elements.bubble.bg", message: `the bubble ${bubble} barely stands off the paper ${p.bg} (${off}:1) and wears no ring: give elements.bubble.border a hairline or the bubble another shade` });
164
+ }
165
+ const paperLum = luminance(p.bg);
166
+ if (paperLum !== null) {
167
+ if (dark && paperLum > 0.4)
168
+ warnings.push({ level: "warning", key: "scheme", message: `scheme is dark but the paper ${p.bg} is light: diagrams and the marks are drawn for dark paper` });
169
+ if (!dark && paperLum < 0.2)
170
+ warnings.push({ level: "warning", key: "scheme", message: `scheme is light but the paper ${p.bg} is dark: say scheme "dark", so diagrams and the marks are drawn for it` });
171
+ }
172
+ lintKinds(look, { errors, warnings });
173
+ const onPanel = contrast(p.muted, look.aliases?.panel ?? "");
174
+ if (onPanel !== null) {
175
+ report.push(`palette.muted on aliases.panel: ${onPanel}:1 (3 wanted)`);
176
+ if (onPanel < 3)
177
+ warnings.push({ level: "warning", key: "palette.muted", message: `the quiet words ${p.muted} on a panel ${look.aliases.panel} read ${onPanel}:1; times and hints will be hard to read` });
178
+ }
179
+ return { ok: errors.length === 0, errors, warnings, report };
180
+ }
package/dist/looks.js ADDED
@@ -0,0 +1,151 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { writeFileAtomic } from "./atomic.js";
6
+ import { lintLook } from "./look-lint.js";
7
+ export const LOOK_ID = /^[a-z][a-z0-9-]{0,30}$/;
8
+ export const DEFAULT_BASE = "classic";
9
+ const SPEC_SECTIONS = ["palette", "shape", "type", "motion", "elevation", "canvas"];
10
+ let tokensPromise = null;
11
+ export function loadTokens() {
12
+ if (!tokensPromise) {
13
+ const file = fileURLToPath(new URL("../ui/tokens.js", import.meta.url));
14
+ tokensPromise = import(pathToFileURL(file).href).then(() => {
15
+ const tokens = globalThis.VIBEROOM_TOKENS;
16
+ if (!tokens)
17
+ throw new Error("ui/tokens.js did not register VIBEROOM_TOKENS");
18
+ return tokens;
19
+ });
20
+ }
21
+ return tokensPromise;
22
+ }
23
+ export function cleanLookSpec(raw) {
24
+ const r = (raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {});
25
+ const out = { id: String(r.id ?? "").trim().toLowerCase(), label: String(r.label ?? "").trim() };
26
+ if (typeof r.scheme === "string" && r.scheme.trim())
27
+ out.scheme = r.scheme.trim();
28
+ if (typeof r.extends === "string" && r.extends.trim())
29
+ out.extends = r.extends.trim();
30
+ if (typeof r.author === "string" && r.author.trim())
31
+ out.author = r.author.trim().slice(0, 40);
32
+ if (typeof r.created === "string" && r.created.trim())
33
+ out.created = r.created.trim();
34
+ if (typeof r.updatedAt === "string" && r.updatedAt.trim())
35
+ out.updatedAt = r.updatedAt.trim();
36
+ const flat = (v) => {
37
+ if (!v || typeof v !== "object" || Array.isArray(v))
38
+ return undefined;
39
+ const o = {};
40
+ for (const [k, x] of Object.entries(v))
41
+ if (typeof x === "string")
42
+ o[k] = x;
43
+ return Object.keys(o).length ? o : undefined;
44
+ };
45
+ for (const section of SPEC_SECTIONS) {
46
+ const v = flat(r[section]);
47
+ if (v)
48
+ out[section] = v;
49
+ }
50
+ if (r.elements && typeof r.elements === "object" && !Array.isArray(r.elements)) {
51
+ const groups = {};
52
+ for (const [group, parts] of Object.entries(r.elements)) {
53
+ const v = flat(parts);
54
+ if (v)
55
+ groups[group] = v;
56
+ }
57
+ if (Object.keys(groups).length)
58
+ out.elements = groups;
59
+ }
60
+ return out;
61
+ }
62
+ export async function checkLookSpec(raw) {
63
+ const tokens = await loadTokens();
64
+ const spec = cleanLookSpec(raw);
65
+ const look = tokens.make(spec);
66
+ return { spec, look, lint: lintLook(look) };
67
+ }
68
+ export class LookLibrary {
69
+ dir;
70
+ log;
71
+ constructor(dir, log) {
72
+ this.dir = dir;
73
+ this.log = log;
74
+ }
75
+ list() {
76
+ const out = [];
77
+ if (!existsSync(this.dir))
78
+ return out;
79
+ for (const entry of readdirSync(this.dir, { withFileTypes: true })) {
80
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
81
+ continue;
82
+ const id = entry.name.slice(0, -5);
83
+ if (!LOOK_ID.test(id))
84
+ continue;
85
+ try {
86
+ const spec = cleanLookSpec(JSON.parse(readFileSync(join(this.dir, entry.name), "utf8")));
87
+ if (spec.id !== id)
88
+ throw new Error(`the file says id "${spec.id}"`);
89
+ out.push(spec);
90
+ }
91
+ catch (error) {
92
+ this.log.warn(`look ${id} skipped: ${error instanceof Error ? error.message : String(error)}`);
93
+ }
94
+ }
95
+ return out.sort((a, b) => (a.created ?? "").localeCompare(b.created ?? "") || a.label.localeCompare(b.label));
96
+ }
97
+ get(id) {
98
+ return this.list().find((l) => l.id === id);
99
+ }
100
+ async save(raw, options) {
101
+ const checked = await checkLookSpec({ ...raw, author: options.author });
102
+ if (!checked.lint.ok)
103
+ throw new Error(`the look does not read: ${checked.lint.errors.map((e) => e.message).join("; ")}`);
104
+ const existing = this.get(checked.spec.id);
105
+ if (existing && !options.replace)
106
+ throw new Error(`a look "${checked.spec.id}" exists already; say replace to overwrite it`);
107
+ const now = new Date().toISOString();
108
+ const spec = { ...checked.spec, author: options.author, created: existing?.created ?? now, updatedAt: now };
109
+ mkdirSync(this.dir, { recursive: true });
110
+ writeFileAtomic(join(this.dir, `${spec.id}.json`), `${JSON.stringify(spec, null, 2)}\n`);
111
+ this.log.info(`${existing ? "replaced" : "saved"} look "${spec.label}" (${spec.id}) by ${options.author}`);
112
+ return { ...checked, spec };
113
+ }
114
+ remove(id) {
115
+ if (!LOOK_ID.test(id))
116
+ return false;
117
+ const file = join(this.dir, `${id}.json`);
118
+ if (!existsSync(file))
119
+ return false;
120
+ rmSync(file, { force: true });
121
+ this.log.info(`removed look ${id}`);
122
+ return true;
123
+ }
124
+ async css() {
125
+ const tokens = await loadTokens();
126
+ const lines = ["/* the looks of this hub's human (<dataDir>/looks/*.json), generated by the hub; a window picks one with data-look on <html> */"];
127
+ for (const spec of this.list()) {
128
+ let look;
129
+ try {
130
+ look = tokens.make(spec);
131
+ }
132
+ catch (error) {
133
+ this.log.warn(`look ${spec.id} does not build: ${error instanceof Error ? error.message : String(error)}`);
134
+ continue;
135
+ }
136
+ lines.push(`:root[data-look="${look.id}"] {`);
137
+ for (const group of tokens.cssGroups(look)) {
138
+ lines.push(` /* ${group.title} */`);
139
+ for (const [name, value] of group.entries)
140
+ lines.push(` ${name}: ${value};`);
141
+ }
142
+ lines.push("}");
143
+ }
144
+ return `${lines.join("\n")}\n`;
145
+ }
146
+ async describe() {
147
+ const tokens = await loadTokens();
148
+ const own = this.list().map((l) => ({ id: l.id, label: l.label, scheme: l.scheme ?? null, extends: l.extends ?? DEFAULT_BASE, author: l.author ?? "human", updatedAt: l.updatedAt ?? null }));
149
+ return { ...tokens.describe(), ownLooks: own };
150
+ }
151
+ }
@@ -40,6 +40,19 @@ const DESIGN_FIELDS = {
40
40
  },
41
41
  },
42
42
  };
43
+ const LOOK_SPEC_FIELDS = {
44
+ id: { type: "string", description: "short lower-case id (letters, digits, hyphens; 1-31 characters, starting with a letter): the file's name; never the id of a look viberoom ships" },
45
+ label: { type: "string", description: "the name the picker shows (1-40 characters)" },
46
+ extends: { type: "string", description: "the shipped look it starts from (describe_looks lists them: classic is VibeClassic, light; classic-dark; clay; comfort; plush is 3D; terminal); classic when omitted" },
47
+ scheme: { type: "string", enum: ["light", "dark"], description: "light or dark paper (diagrams and marks are drawn for it); the base's when omitted" },
48
+ palette: { type: "object", description: "hues laid over the base's palette, by name, each a flat colour #rrggbb: { primary: \"#b5533c\", bg: \"#f6f1e7\" }; describe_looks tells what every hue is for", additionalProperties: { type: "string" } },
49
+ shape: { type: "object", description: "corners: rScale (0 square … 1.6 very round), rCtlMin (0px keeps each control's own corner, 99px makes every control a pill)", additionalProperties: { type: "string" } },
50
+ type: { type: "object", description: "text: font and mono (an id from describe_looks fonts, or a family stack), lineHeight, fsScale", additionalProperties: { type: "string" } },
51
+ motion: { type: "object", description: "tFast, tBase, tSlow (durations, 0ms for none), easeOut, easePop", additionalProperties: { type: "string" } },
52
+ elevation: { type: "object", description: "shadows and light, as CSS: a shadow, none, or for bevel a gradient; $name, alpha($name, 0.2) and mix($a, $b, 0.5) may stand inside", additionalProperties: { type: "string" } },
53
+ canvas: { type: "object", description: "the chat's paper: gradCanvas (a colour or gradients), canvasPattern (none, or a pattern), canvasPatternSize, gradPage, the scrollbar", additionalProperties: { type: "string" } },
54
+ elements: { type: "object", description: "per element group, the parts to change: { bubble: { bg: \"$white\", border: \"alpha($ink, 0.12)\" }, btn: { shadow: \"none\" } }; describe_looks lists every group and key with its VibeClassic value", additionalProperties: { type: "object", additionalProperties: { type: "string" } } },
55
+ };
43
56
  const TOOLS = [
44
57
  {
45
58
  name: TOOL_NAME,
@@ -124,6 +137,46 @@ const TOOLS = [
124
137
  required: ["why"],
125
138
  },
126
139
  },
140
+ {
141
+ name: "describe_looks",
142
+ description: "Everything about the looks before you design one: the looks that exist (the ones viberoom ships and the human's own), how the window is set now (the look worn, its fine-tuning, the fonts, the text size), every token a look may set with what it means and its VibeClassic value, how a value is written ($name, alpha(), mix()), the fonts by id, and what may be fine-tuned on any look without a spec. Read-only. Load the built-in skill \"look-designer\" for what makes a look good.",
143
+ inputSchema: { type: "object", properties: {} },
144
+ annotations: { readOnlyHint: true },
145
+ },
146
+ {
147
+ name: "lint_look",
148
+ description: "Check a look spec without saving anything: whether every key exists and every value is of the right kind, and whether the words read on their paper (the contrast floors every look must pass), with the ratio of every pair measured, so you see the numbers you cannot see as colours. Errors must go before create_look takes it; warnings are advice.",
149
+ inputSchema: { type: "object", properties: LOOK_SPEC_FIELDS, required: ["id", "label"] },
150
+ annotations: { readOnlyHint: true },
151
+ },
152
+ {
153
+ name: "create_look",
154
+ description: "Save a look among the human's own looks: a file the human picks under Settings → Appearance, listed after the looks viberoom ships with the human's name on it. The spec extends a shipped look and changes only what it gives. The hub checks it first (an error stops the save, warnings come back with it). Nothing is worn until the human picks it (or applies a propose_look_changes card). A taken id needs replace: true (one of the human's own looks may be replaced; a look viberoom ships never).",
155
+ inputSchema: {
156
+ type: "object",
157
+ properties: {
158
+ ...LOOK_SPEC_FIELDS,
159
+ replace: { type: "boolean", description: "optional: overwrite the human's look with this id instead of refusing" },
160
+ },
161
+ required: ["id", "label"],
162
+ },
163
+ },
164
+ {
165
+ name: "propose_look_changes",
166
+ description: "Propose a change to how the human's window looks, as a card the human applies or rejects: which look to wear (a shipped one, or one of the human's own by its id, e.g. one you just saved), the fine-tuning of a look (the adjustables describe_looks lists: a colour as #rrggbb, a scale as a number 0-2), the fonts, the text size. This is the whole window, not this room alone; nothing changes until the human clicks Apply, and the room gets a line with the outcome. Say in why what it improves.",
167
+ inputSchema: {
168
+ type: "object",
169
+ properties: {
170
+ why: { type: "string", description: "one or two sentences: what this change improves; shown on the card" },
171
+ look: { type: "string", description: "optional: the id of the look to wear" },
172
+ adjust: { type: "object", description: "optional: the fine-tuning of the look named in look (or of the one worn now): { accent: \"#b5533c\", corners: 0.5 }; describe_looks lists the keys", additionalProperties: {} },
173
+ chatFontSize: { type: "number", description: "optional: the text size in px, 12-24" },
174
+ font: { type: "string", description: "optional: a text font id (describe_looks fonts.text)" },
175
+ mono: { type: "string", description: "optional: a code font id (describe_looks fonts.mono)" },
176
+ },
177
+ required: ["why"],
178
+ },
179
+ },
127
180
  {
128
181
  name: "read_message",
129
182
  description: "One message of this room by its number: the whole of a message that was quoted to you as \"> Name (#N, time): …\", or any message whose #N you have seen. Returns who wrote it, to whom, when, its text, its images as file paths and, with around > 0, up to that many messages before and after it. Read-only; the human sees the call like any other tool call.",
@@ -276,6 +329,40 @@ async function handle(message) {
276
329
  reply(id, { content: [{ type: "text", text: String(res.body.message ?? "proposed") }] });
277
330
  return;
278
331
  }
332
+ if (name === "describe_looks") {
333
+ const res = await hub(`/api/mcp/looks?token=${encodeURIComponent(TOKEN)}`);
334
+ if (!res.ok)
335
+ return errorResult("the looks could not be described", res);
336
+ reply(id, { content: [{ type: "text", text: JSON.stringify(res.body, null, 2) }] });
337
+ return;
338
+ }
339
+ if (name === "lint_look" || name === "create_look") {
340
+ const { replace, ...spec } = args;
341
+ const res = await hub(name === "lint_look" ? "/api/mcp/looks/lint" : "/api/mcp/looks/create", {
342
+ method: "POST",
343
+ headers: { "content-type": "application/json" },
344
+ body: JSON.stringify({ token: TOKEN, spec, replace }),
345
+ });
346
+ if (!res.ok)
347
+ return errorResult(name === "lint_look" ? "the look could not be checked" : "the look could not be saved", res);
348
+ if (name === "lint_look") {
349
+ reply(id, { content: [{ type: "text", text: JSON.stringify(res.body, null, 2) }], isError: res.body.ok === false ? true : undefined });
350
+ return;
351
+ }
352
+ reply(id, { content: [{ type: "text", text: String(res.body.message ?? "saved") }] });
353
+ return;
354
+ }
355
+ if (name === "propose_look_changes") {
356
+ const res = await hub("/api/mcp/looks/propose", {
357
+ method: "POST",
358
+ headers: { "content-type": "application/json" },
359
+ body: JSON.stringify({ token: TOKEN, ...args }),
360
+ });
361
+ if (!res.ok)
362
+ return errorResult("the proposal could not be made", res);
363
+ reply(id, { content: [{ type: "text", text: String(res.body.message ?? "proposed") }] });
364
+ return;
365
+ }
279
366
  if (name === "read_message") {
280
367
  const seq = Number(args.seq);
281
368
  if (!Number.isInteger(seq))