viberoom 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
package/dist/main.js CHANGED
@@ -19,6 +19,7 @@ function parseArgs(argv) {
19
19
  port: 4810,
20
20
  portGiven: false,
21
21
  dataDir: process.env.VIBEROOM_DATA_DIR ? resolve(process.env.VIBEROOM_DATA_DIR) : resolve(homedir(), ".viberoom"),
22
+ dataDirGiven: !!process.env.VIBEROOM_DATA_DIR,
22
23
  name: undefined,
23
24
  open: true,
24
25
  browser: false,
@@ -43,6 +44,7 @@ function parseArgs(argv) {
43
44
  break;
44
45
  case "--data-dir":
45
46
  options.dataDir = resolve(next());
47
+ options.dataDirGiven = true;
46
48
  break;
47
49
  case "--open":
48
50
  options.open = true;
@@ -271,7 +273,8 @@ async function runHub(options, log, info) {
271
273
  }
272
274
  }
273
275
  }
274
- migrateLegacyData(options.dataDir, log);
276
+ if (!options.dataDirGiven)
277
+ migrateLegacyData(options.dataDir, log);
275
278
  const hub = new Hub(options.dataDir, log, options.name);
276
279
  if (options.name && hub.settings.humanName !== options.name)
277
280
  hub.updateSettings({ humanName: options.name });
@@ -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))
package/dist/persona.js CHANGED
@@ -10,6 +10,7 @@ export function skillPull(reply) {
10
10
  }
11
11
  export const SKILL_WRITER_NAME = "skill-writer";
12
12
  export const ROOM_DESIGNER_NAME = "room-designer";
13
+ export const LOOK_DESIGNER_NAME = "look-designer";
13
14
  export const NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}_-]{0,23}$/u;
14
15
  export const ROOM_SETTINGS_SPEC = {
15
16
  name: { kind: "own-path", brief: true, agent: false, doc: "The room's name; changed with rename." },
@@ -28,7 +29,8 @@ export const ROOM_SETTINGS_SPEC = {
28
29
  replayAfterRestart: { kind: "integer", min: 0, max: 200, default: 10, brief: false, agent: true, doc: "Messages replayed to a vibemate whose session restarts." },
29
30
  backlogCap: { kind: "integer", min: 1, max: 1000, default: 50, brief: false, agent: true, doc: "Most missed messages a vibemate reads on its next turn; older ones are dropped with a note." },
30
31
  showVendorInRoster: { kind: "boolean", default: false, brief: true, agent: true, doc: "The roster in the brief names each vibemate's vendor (Claude, Codex, ...)." },
31
- customRules: { kind: "text", max: 4000, default: "", brief: true, agent: true, doc: "The room rules, one per line; every vibemate gets them under 'Room rules (set by the human)'. @Name inside a rule is a live reference." },
32
+ briefTextLimit: { kind: "integer", min: 500, max: 32_000, default: 8000, brief: false, agent: true, doc: "Most characters a vibio (a vibemate's role) or the room rules may have; both go into every brief. Text over it is refused with the numbers, never cut." },
33
+ customRules: { kind: "text", max: 32_000, default: "", brief: true, agent: true, doc: "The room rules, one per line, at most briefTextLimit characters; every vibemate gets them under 'Room rules (set by the human)'. @Name inside a rule is a live reference." },
32
34
  refereeAction: { kind: "enum", values: ["next-header", "retry-hidden"], default: "next-header", brief: false, agent: true, doc: "On a mechanical violation (wrong language, too long): remind in the next header, or hold the reply and ask for a corrected one in a hidden turn." },
33
35
  turnTaking: { kind: "enum", values: ["parallel", "one-at-a-time"], default: "parallel", brief: false, agent: true, doc: "parallel: every addressed vibemate answers at once; one-at-a-time: one speaks, the others queue and see the earlier replies first." },
34
36
  waitWhileHumanTypes: { kind: "boolean", default: true, brief: false, agent: true, doc: "A vibemate about to start a turn waits while the human is typing." },
@@ -76,8 +78,12 @@ export function coerceSetting(key, raw) {
76
78
  throw new Error(`${key} must be ${spec.values.join(" or ")}`);
77
79
  return value;
78
80
  }
79
- case "text":
80
- return String(raw).slice(0, spec.max);
81
+ case "text": {
82
+ const value = String(raw);
83
+ if (value.length > spec.max)
84
+ throw new Error(`${key} is ${value.length} characters; at most ${spec.max}`);
85
+ return value;
86
+ }
81
87
  case "language": {
82
88
  if (raw && typeof raw === "object") {
83
89
  const o = raw;
@@ -247,6 +253,7 @@ function skillsSection(skills) {
247
253
  if (skills.canCreate) {
248
254
  lines.push(`You may also create skills for the shared library when a procedure is worth reusing (by you later, or by other agents): first load the built-in skill "${SKILL_WRITER_NAME}" with the viberoom ${SKILL_TOOL_NAME} tool for the rules of a good skill, then call the viberoom tools create_skill (name, description, instructions) and attach_skill to give it to yourself or to other agents. These are MCP tools of the "viberoom" server, not your own skill commands. The human sees every new skill in Settings.`);
249
255
  lines.push(`You may also design rooms: load the built-in skill "${ROOM_DESIGNER_NAME}" first, then describe_room for the facts, lint_room_design to check a draft (it previews the brief the vibemates would read), create_template to save a template the human can pick under New room, and propose_room_changes to suggest a change to this room: it becomes a card the human applies or rejects, so nothing here changes without their click.`);
256
+ lines.push(`You may also design looks (how the human's window is drawn: colours, shadows, corners, fonts): load the built-in skill "${LOOK_DESIGNER_NAME}" first, then describe_looks for the facts, lint_look to check a draft (it measures whether the words read), create_look to save a look the human can pick under Settings, and propose_look_changes to suggest wearing a look or fine-tuning one: a card the human applies or rejects.`);
250
257
  }
251
258
  else if (skills.items.length) {
252
259
  lines.push("Skills are created by the human or by agents that have the hub's tools; if you want a new one, describe it in the room.");