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/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." },
@@ -247,6 +248,7 @@ function skillsSection(skills) {
247
248
  if (skills.canCreate) {
248
249
  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
250
  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.`);
251
+ 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
252
  }
251
253
  else if (skills.items.length) {
252
254
  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.");
package/dist/room.js CHANGED
@@ -1205,6 +1205,11 @@ export class Room extends EventEmitter {
1205
1205
  const skill = this.skills?.library.get(message.skill.name);
1206
1206
  if (!message.to.length)
1207
1207
  targets = targets.filter((id) => this.hasSkill(this.participants.get(id), message.skill.name));
1208
+ if (!targets.length && !message.to.length) {
1209
+ const alone = [...this.runtimes.keys()].filter(live);
1210
+ if (alone.length === 1)
1211
+ targets = alone;
1212
+ }
1208
1213
  if (!targets.length)
1209
1214
  this.notice(`Nobody in this room has the skill "${message.skill.name}"; attach it to an agent first, or address one with @.`, "warn");
1210
1215
  if (skill) {
@@ -1241,13 +1246,19 @@ export class Room extends EventEmitter {
1241
1246
  this.requestTurn(id, message.to.includes(id) || !!message.skill);
1242
1247
  }
1243
1248
  hasSkill(participant, name) {
1249
+ if (this.isBuiltinSkill(name))
1250
+ return true;
1244
1251
  if (!participant?.skills)
1245
1252
  return false;
1246
1253
  const lower = name.toLowerCase();
1247
1254
  return participant.skills.some((s) => s.toLowerCase() === lower);
1248
1255
  }
1256
+ isBuiltinSkill(name) {
1257
+ const skill = this.skills?.library.get(name);
1258
+ return !!skill && skill.author === BUILTIN_AUTHOR && !skill.draft && !skill.problems.length;
1259
+ }
1249
1260
  attachedSkills(participant) {
1250
- if (!this.skills || !participant.skills?.length)
1261
+ if (!this.skills)
1251
1262
  return [];
1252
1263
  return this.skills.library.list().filter((s) => !s.problems.length && !s.draft && this.hasSkill(participant, s.name));
1253
1264
  }
@@ -1443,6 +1454,116 @@ export class Room extends EventEmitter {
1443
1454
  warnings: proposal.warnings,
1444
1455
  };
1445
1456
  }
1457
+ async describeLooksForAgent(participantId) {
1458
+ const participant = this.agentInRoom(participantId);
1459
+ if (!this.skills?.looks || !this.skills.appearance)
1460
+ throw new Error("looks are not available in this hub");
1461
+ const described = await this.skills.looks.describe();
1462
+ return { you: participant.name, human: this.settings.humanName, appearance: this.skills.appearance.current(), ...described };
1463
+ }
1464
+ async lintLookForAgent(participantId, raw) {
1465
+ this.agentInRoom(participantId);
1466
+ if (!this.skills?.looks)
1467
+ throw new Error("looks are not available in this hub");
1468
+ try {
1469
+ const checked = await this.skills.looks.check(raw);
1470
+ return { ok: checked.lint.ok, id: checked.spec.id, errors: checked.lint.errors.map((e) => ({ key: e.key, message: e.message })), warnings: checked.lint.warnings.map((w) => ({ key: w.key, message: w.message })), report: checked.lint.report };
1471
+ }
1472
+ catch (error) {
1473
+ return { ok: false, errors: [{ key: "spec", message: error instanceof Error ? error.message : String(error) }], warnings: [], report: [] };
1474
+ }
1475
+ }
1476
+ async createLookForAgent(participantId, raw, replace) {
1477
+ const participant = this.agentInRoom(participantId);
1478
+ if (!this.skills?.looks)
1479
+ throw new Error("looks are not available in this hub");
1480
+ let saved;
1481
+ try {
1482
+ saved = await this.skills.looks.save(raw, { author: participant.name, replace });
1483
+ }
1484
+ catch (error) {
1485
+ throw new Error(`not saved: ${error instanceof Error ? error.message : String(error)}`);
1486
+ }
1487
+ const warnings = saved.lint.warnings.map((w) => w.message);
1488
+ this.postSystem(`${participant.name} saved the look "${saved.spec.label}" (Settings → Appearance).`);
1489
+ this.log.info(`${participant.name} saved look ${saved.spec.id}`);
1490
+ return {
1491
+ ok: true,
1492
+ message: `Saved the look "${saved.spec.label}" (id ${saved.spec.id}) among ${this.settings.humanName}'s own looks: it is in Settings → Appearance now, after the looks viberoom ships. Nothing is worn until ${this.settings.humanName} picks it; propose_look_changes with look "${saved.spec.id}" offers it as a card.${warnings.length ? ` Warnings: ${warnings.join("; ")}` : ""}`,
1493
+ id: saved.spec.id,
1494
+ warnings,
1495
+ };
1496
+ }
1497
+ async proposeLookChanges(participantId, why, changes) {
1498
+ const participant = this.agentInRoom(participantId);
1499
+ if (!this.skills?.appearance || !this.skills.looks)
1500
+ throw new Error("looks are not available in this hub");
1501
+ const current = this.skills.appearance.current();
1502
+ const patch = {};
1503
+ const rows = [];
1504
+ if (changes.look !== undefined) {
1505
+ const lookId = String(changes.look).trim();
1506
+ const known = await this.skills.appearance.ownAdjustments(lookId);
1507
+ if (!known)
1508
+ throw new Error(`not proposed: no look "${lookId}" (the looks viberoom ships, or one of ${this.settings.humanName}'s own by its id)`);
1509
+ patch.look = lookId;
1510
+ if (lookId !== current.look)
1511
+ rows.push({ key: "look", from: current.look, to: lookId });
1512
+ }
1513
+ if (changes.adjust !== undefined) {
1514
+ if (!changes.adjust || typeof changes.adjust !== "object" || Array.isArray(changes.adjust))
1515
+ throw new Error("not proposed: adjust is an object of adjustable keys and values");
1516
+ const lookId = String(changes.look ?? current.look);
1517
+ const own = await this.skills.appearance.ownAdjustments(lookId);
1518
+ if (!own)
1519
+ throw new Error(`not proposed: no look "${lookId}" to fine-tune`);
1520
+ const merged = { ...(current.custom?.[lookId] ?? {}), ...changes.adjust };
1521
+ patch.custom = { [lookId]: merged };
1522
+ const previewed = this.skills.appearance.preview({ custom: { [lookId]: merged } });
1523
+ for (const key of Object.keys(changes.adjust)) {
1524
+ const from = current.custom?.[lookId]?.[key] ?? own.values[key] ?? "";
1525
+ const to = previewed.custom[lookId]?.[key] ?? "";
1526
+ if (String(from).toLowerCase() !== String(to).toLowerCase())
1527
+ rows.push({ key: `${key} (${own.label})`, from, to });
1528
+ }
1529
+ }
1530
+ for (const key of ["chatFontSize", "font", "mono"]) {
1531
+ if (changes[key] === undefined)
1532
+ continue;
1533
+ patch[key] = changes[key];
1534
+ const previewed = this.skills.appearance.preview({ [key]: changes[key] });
1535
+ if (String(previewed[key]) !== String(current[key]))
1536
+ rows.push({ key: key === "chatFontSize" ? "text size" : key === "font" ? "font" : "code font", from: current[key], to: previewed[key] });
1537
+ }
1538
+ this.skills.appearance.preview(patch);
1539
+ if (!rows.length)
1540
+ throw new Error("not proposed: the change leaves the window as it is");
1541
+ const proposal = {
1542
+ key: randomUUID(),
1543
+ participantId,
1544
+ participantName: participant.name,
1545
+ ts: Date.now(),
1546
+ why: String(why ?? "").trim().slice(0, 600),
1547
+ settings: [],
1548
+ vibemates: [],
1549
+ appearance: rows,
1550
+ warnings: [],
1551
+ touchesOwn: false,
1552
+ status: "pending",
1553
+ };
1554
+ this.proposalPlans.set(proposal.key, { settings: [], vibemates: [], ops: [], ids: {}, appearance: patch });
1555
+ this.proposals.set(proposal.key, proposal);
1556
+ this.push({ type: "proposal", proposal });
1557
+ const what = rows.map((c) => c.key).join(", ");
1558
+ this.postSystem(`${participant.name} proposes a change to how the window looks (${what}); apply or reject it on the card.`, "human", false, { tone: "attention" });
1559
+ this.log.info(`look proposal ${proposal.key} from ${participant.name}: ${what}`);
1560
+ return {
1561
+ ok: true,
1562
+ message: `Proposal sent to ${this.settings.humanName} as a card in the room (${what}). It changes the whole window, not this room alone; nothing changes until they apply it, and you will see a room line with the outcome.`,
1563
+ key: proposal.key,
1564
+ warnings: [],
1565
+ };
1566
+ }
1446
1567
  async resolveProposal(key, accept) {
1447
1568
  const proposal = this.proposals.get(key);
1448
1569
  const plan = this.proposalPlans.get(key);
@@ -1450,7 +1571,7 @@ export class Room extends EventEmitter {
1450
1571
  throw new Error("no such pending proposal");
1451
1572
  if (proposal.status !== "pending")
1452
1573
  return proposal;
1453
- const what = [...proposal.settings.map((c) => c.key), ...proposal.vibemates.map((o) => `${o.op} ${o.name}`)].join(", ");
1574
+ const what = [...proposal.settings.map((c) => c.key), ...proposal.vibemates.map((o) => `${o.op} ${o.name}`), ...(proposal.appearance ?? []).map((c) => c.key)].join(", ");
1454
1575
  if (!accept) {
1455
1576
  proposal.status = "rejected";
1456
1577
  this.proposalPlans.delete(key);
@@ -1505,6 +1626,16 @@ export class Room extends EventEmitter {
1505
1626
  patch[c.key] = c.key === "language" ? c.to : c.to;
1506
1627
  this.updateSettings(patch);
1507
1628
  }
1629
+ if (plan.appearance) {
1630
+ try {
1631
+ if (!this.skills?.appearance)
1632
+ throw new Error("looks are not available in this hub");
1633
+ this.skills.appearance.apply(plan.appearance);
1634
+ }
1635
+ catch (error) {
1636
+ skipped.push(`appearance (${error instanceof Error ? error.message : String(error)})`);
1637
+ }
1638
+ }
1508
1639
  proposal.status = "applied";
1509
1640
  proposal.skipped = skipped;
1510
1641
  this.proposalPlans.delete(key);
@@ -1697,10 +1828,9 @@ export class Room extends EventEmitter {
1697
1828
  if (!this.skills)
1698
1829
  return { ok: false, reason: "skills are not available in this hub" };
1699
1830
  const skill = this.skills.library.get(name);
1700
- const builtin = !!skill && skill.author === BUILTIN_AUTHOR && !skill.draft;
1701
1831
  const mine = this.attachedSkills(participant).filter((s) => s.agentInvocable).map((s) => s.name);
1702
1832
  const list = mine.length ? `your skills: ${mine.join(", ")}` : "you have no skills";
1703
- if (!builtin && (!this.hasSkill(participant, name) || !mine.some((s) => s.toLowerCase() === name.toLowerCase()))) {
1833
+ if (!this.hasSkill(participant, name) || !mine.some((s) => s.toLowerCase() === name.toLowerCase())) {
1704
1834
  return { ok: false, reason: `"${name}" is not one of your skills (${list})` };
1705
1835
  }
1706
1836
  if (!skill || skill.problems.length)
package/dist/server.js CHANGED
@@ -137,6 +137,12 @@ export function startServer(hub, port, log, info, onShutdownRequest, onRestartRe
137
137
  }
138
138
  return;
139
139
  }
140
+ if (req.method === "GET" && path === "/looks-custom.css") {
141
+ const css = await hub.looks.css();
142
+ res.writeHead(200, { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "no-cache" });
143
+ res.end(css);
144
+ return;
145
+ }
140
146
  if (req.method === "GET" && STATIC_FILES[path]) {
141
147
  const entry = STATIC_FILES[path];
142
148
  const body = await readFile(staticPath(entry));
@@ -360,6 +366,15 @@ export function startServer(hub, port, log, info, onShutdownRequest, onRestartRe
360
366
  sendJson(res, 200, target.room.describeRoomForAgent(target.participantId));
361
367
  return;
362
368
  }
369
+ if (req.method === "GET" && path === "/api/mcp/looks") {
370
+ const target = hub.resolveMcpToken(url.searchParams.get("token") ?? "");
371
+ if (!target) {
372
+ sendJson(res, 403, { error: "unknown skills token (the session it belonged to is gone)" });
373
+ return;
374
+ }
375
+ sendJson(res, 200, await target.room.describeLooksForAgent(target.participantId));
376
+ return;
377
+ }
363
378
  if (req.method === "GET" && path === "/api/mcp/message") {
364
379
  const target = hub.resolveMcpToken(url.searchParams.get("token") ?? "");
365
380
  if (!target) {
@@ -410,6 +425,14 @@ export function startServer(hub, port, log, info, onShutdownRequest, onRestartRe
410
425
  sendJson(res, 200, { templates: hub.templates.list() });
411
426
  return;
412
427
  }
428
+ if (req.method === "GET" && path === "/api/looks") {
429
+ sendJson(res, 200, { looks: hub.looks.list() });
430
+ return;
431
+ }
432
+ if (req.method === "GET" && path === "/api/looks/describe") {
433
+ sendJson(res, 200, await hub.looks.describe());
434
+ return;
435
+ }
413
436
  if (req.method === "GET" && path === "/api/rooms") {
414
437
  sendJson(res, 200, [...hub.rooms.values()].map((r) => r.snapshot()));
415
438
  return;
@@ -423,6 +446,29 @@ export function startServer(hub, port, log, info, onShutdownRequest, onRestartRe
423
446
  sendJson(res, 200, { ok: true, settings: hub.updateSettings(body) });
424
447
  return;
425
448
  }
449
+ if (path === "/api/looks/check") {
450
+ const { checkLookSpec } = await import("./looks.js");
451
+ try {
452
+ const checked = await checkLookSpec(body.spec ?? body);
453
+ sendJson(res, 200, { ok: checked.lint.ok, id: checked.spec.id, errors: checked.lint.errors, warnings: checked.lint.warnings, report: checked.lint.report });
454
+ }
455
+ catch (error) {
456
+ sendJson(res, 200, { ok: false, errors: [{ level: "error", key: "spec", message: error instanceof Error ? error.message : String(error) }], warnings: [], report: [] });
457
+ }
458
+ return;
459
+ }
460
+ if (path === "/api/looks") {
461
+ const saved = await hub.saveLook(body.spec ?? body, { author: "human", replace: body.replace === true || body.replace === "true" });
462
+ sendJson(res, 200, { ok: true, look: saved.spec, warnings: saved.lint.warnings, report: saved.lint.report });
463
+ return;
464
+ }
465
+ if (path === "/api/looks/remove") {
466
+ const id = String(body.id ?? "");
467
+ if (!hub.removeLook(id))
468
+ throw new Error(`no look "${id}" of your own to remove`);
469
+ sendJson(res, 200, { ok: true });
470
+ return;
471
+ }
426
472
  if (path === "/api/fs/mkdir") {
427
473
  sendJson(res, 200, { ok: true, path: await createFolder(String(body.parent ?? ""), String(body.name ?? "")) });
428
474
  return;
@@ -498,6 +544,30 @@ export function startServer(hub, port, log, info, onShutdownRequest, onRestartRe
498
544
  sendJson(res, 200, target.room.createTemplateForAgent(target.participantId, design, body.replace === true || body.replace === "true"));
499
545
  return;
500
546
  }
547
+ if (path === "/api/mcp/looks/lint" || path === "/api/mcp/looks/create" || path === "/api/mcp/looks/propose") {
548
+ const target = hub.resolveMcpToken(String(body.token ?? ""));
549
+ if (!target) {
550
+ sendJson(res, 403, { error: "unknown skills token (the session it belonged to is gone)" });
551
+ return;
552
+ }
553
+ if (path === "/api/mcp/looks/lint") {
554
+ sendJson(res, 200, await target.room.lintLookForAgent(target.participantId, body.spec ?? body));
555
+ return;
556
+ }
557
+ if (path === "/api/mcp/looks/create") {
558
+ sendJson(res, 200, await target.room.createLookForAgent(target.participantId, body.spec ?? body, body.replace === true || body.replace === "true"));
559
+ return;
560
+ }
561
+ const num = (v) => (v === undefined || v === null || v === "" ? undefined : Number(v));
562
+ sendJson(res, 200, await target.room.proposeLookChanges(target.participantId, String(body.why ?? ""), {
563
+ look: typeof body.look === "string" ? body.look : undefined,
564
+ adjust: body.adjust && typeof body.adjust === "object" && !Array.isArray(body.adjust) ? body.adjust : undefined,
565
+ chatFontSize: num(body.chatFontSize),
566
+ font: typeof body.font === "string" ? body.font : undefined,
567
+ mono: typeof body.mono === "string" ? body.mono : undefined,
568
+ }));
569
+ return;
570
+ }
501
571
  if (path === "/api/mcp/propose") {
502
572
  const target = hub.resolveMcpToken(String(body.token ?? ""));
503
573
  if (!target) {
package/dist/skills.js CHANGED
@@ -4,7 +4,7 @@ import { join, resolve } from "node:path";
4
4
  import { isReservedSkillName, RESERVED_SKILL_NAMES } from "./commands.js";
5
5
  export const SKILL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
6
6
  export const SKILL_FILE = "SKILL.md";
7
- import { ROOM_DESIGNER_NAME } from "./persona.js";
7
+ import { LOOK_DESIGNER_NAME, ROOM_DESIGNER_NAME } from "./persona.js";
8
8
  export const BUILTIN_AUTHOR = "viberoom";
9
9
  export const HUMAN_AUTHOR = "human";
10
10
  const DESCRIPTION_MAX = 300;
@@ -175,7 +175,28 @@ export function renderSkillBody(body, args) {
175
175
  .replace(/\r\n/g, "\n")
176
176
  .trim();
177
177
  }
178
- export const BUILTIN_SKILLS = [SKILL_WRITER, ROOM_DESIGNER];
178
+ export const LOOK_DESIGNER = {
179
+ name: LOOK_DESIGNER_NAME,
180
+ description: "How to design a good viberoom look (how the window is drawn: colours, light, corners, fonts) as data that extends a shipped look. Load it before lint_look, create_look or propose_look_changes.",
181
+ argumentHint: "",
182
+ body: [
183
+ "A look is data, not code: which shipped look it extends, a few hues laid over that look's palette, and what it wants otherwise (corners, fonts, shadows, the chat's paper, a part of an element). Everything the window draws is derived from the palette, so a hue changed there reaches every place that wears it; start from the shipped look closest to what is asked and change as little as says it. The facts (the looks, every hue and element with what it means, the value syntax, the fonts, how the window is set now) come from describe_looks; never guess a name, a key the look does not have is refused.",
184
+ "",
185
+ "Name what a colour looks like, not what it is for: the palette says primary, ink, bg, white, lav; the elements say which part wears which hue (bubble.bg, btn.hoverInk). Prefer changing hues over changing elements: a new accent is palette.primary plus its primaryLight, primaryDeep and primaryDark steps and its tints lav and lav2; a new paper is bg with white (the panels) a shade apart from it, and soft / softer a step off white; a new ink is ink with ink2 for the words in a bubble and ink3, muted and faint growing quieter. Change an element only when a part must differ from what the palette gives it.",
186
+ "",
187
+ "You cannot see the colours; the lint can. Every look must read: the words on a bubble and on the paper at 7:1, the quiet words at 3:1 (4.5:1 on dark paper), every ink on its own paper at 3:1. Run lint_look before you save and read the report: it names every pair with its ratio. Fix the ink before the paper (a darker muted, an ink2 nearer black), keep the accent readable where it is written on (btn.onPrimary on primary at 3:1), and give a bubble that sits on paper of nearly its own shade a hairline (bubble.border) or another shade.",
188
+ "",
189
+ "Light comes from one place, top-left, and the shadows fall from it in the palette's shadowInk; a flat look sets the shadows to none, a look with volume raises a thing with a soft shadow below it and a bevel (a light gradient over its face) and sinks a pressed one with an inset shade. Say scheme dark when the paper is dark: diagrams, marks and the quiet-word floors follow it. One accent; the state colours (green ready, yellow thinking, red error, blue waiting, violet writing) keep their families, only their shades follow the paper. Fonts by id from describe_looks (they ship with viberoom and look the same on every machine); running text at lineHeight 1.45 or more; a terminal look wants the mono font everywhere and no curves (rScale 0), a soft look rounder corners (rScale 1.2-1.6) and pills for every control (rCtlMin 99px).",
190
+ "",
191
+ "When it reads: create_look saves it among the human's own looks (Settings → Appearance, after the shipped ones, with the human's name on it); one of the human's own may be replaced with replace: true, a shipped look never. Then propose_look_changes offers it as a card (look: its id), or fine-tunes any look with the adjustables describe_looks lists (a colour as #rrggbb, a scale 0-2) — the whole window changes, not this room, and only on the human's click. What a look cannot do: no CSS of its own, no layout, no icons, no fonts beyond the shipped ids; when the human asks for such a thing, say so instead of forcing a token to carry it.",
192
+ ].join("\n"),
193
+ userInvocable: true,
194
+ agentInvocable: true,
195
+ author: BUILTIN_AUTHOR,
196
+ reviewed: true,
197
+ draft: false,
198
+ };
199
+ export const BUILTIN_SKILLS = [SKILL_WRITER, ROOM_DESIGNER, LOOK_DESIGNER];
179
200
  export function isBuiltinSkill(name) {
180
201
  const lower = name.trim().toLowerCase();
181
202
  return BUILTIN_SKILLS.some((b) => b.name === lower);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberoom",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "viberoom: group chat rooms for a human and several coding agents over the Agent Client Protocol",
5
5
  "type": "module",
6
6
  "engines": {
package/ui/app.css CHANGED
@@ -30,7 +30,7 @@
30
30
  @supports not selector(::-webkit-scrollbar) { .rail-rooms { scrollbar-width: thin; } }
31
31
  .shell.rail-open .rail-rooms { align-items: stretch; }
32
32
  .rail-room { flex: none; }
33
- .rail-room [data-ui="room-mark"] { width: 40px; height: 40px; border-radius: calc(13px * var(--r-scale)); font-size: 16px; opacity: 0.72; transition: opacity var(--t-fast), transform var(--t-fast) var(--ease-out), box-shadow var(--t-fast); }
33
+ .rail-room [data-ui="room-mark"] { width: 40px; height: 40px; border-radius: var(--room-mark-radius-rail); font-size: 16px; opacity: 0.72; transition: opacity var(--t-fast), transform var(--t-fast) var(--ease-out), box-shadow var(--t-fast); }
34
34
  .rail-room [data-ui="room-mark"][data-kind="emoji"] { font-size: 21px; }
35
35
  .rail-room:hover [data-ui="room-mark"] { opacity: 1; transform: translateY(-1px); }
36
36
  .rail-room.active, .rail-room.active:hover { background: transparent; box-shadow: none; }
@@ -231,18 +231,18 @@
231
231
  .home-room .hr-time { color: var(--faint); font-size: calc(11px * var(--fs-scale)); font-weight: 700; flex: none; }
232
232
  .home-room.empty-room { cursor: default; }
233
233
  .feature-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
234
- .feature { border-radius: calc(18px * var(--r-scale)); padding: 18px 18px 16px; display: flex; flex-direction: column; gap: 8px; box-shadow: var(--edge); transition: transform var(--t-fast) var(--ease-out); }
234
+ .feature { border-radius: calc(18px * var(--r-scale)); padding: 18px 18px 16px; display: flex; flex-direction: column; gap: 8px; box-shadow: var(--feature-shadow); background-image: var(--bevel); transition: transform var(--t-fast) var(--ease-out); }
235
235
  .feature:hover { transform: translateY(-2px); }
236
236
  .feature .f-emoji { width: 46px; height: 46px; border-radius: calc(14px * var(--r-scale)); background: var(--feature-emoji-bg); display: grid; place-content: center; font-size: 24px; box-shadow: var(--shadow-tile); }
237
237
  .feature h3 { font-size: calc(15px * var(--fs-scale)); margin-top: 4px; }
238
238
  .feature p { margin: 0; font-size: calc(13px * var(--fs-scale)); font-weight: 600; line-height: 1.5; color: var(--ink-2); }
239
- .feature.lav { background: var(--lav); }
240
- .feature.mint { background: var(--mint); }
241
- .feature.warm { background: var(--warm); }
242
- .feature.peach { background: var(--feature-peach-bg); }
243
- .feature.rose { background: var(--rose); }
244
- .feature.sky { background: var(--sky); }
245
- .feature.orchid { background: var(--orchid); }
239
+ .feature.lav { background-color: var(--lav); }
240
+ .feature.mint { background-color: var(--mint); }
241
+ .feature.warm { background-color: var(--warm); }
242
+ .feature.peach { background-color: var(--feature-peach-bg); }
243
+ .feature.rose { background-color: var(--rose); }
244
+ .feature.sky { background-color: var(--sky); }
245
+ .feature.orchid { background-color: var(--orchid); }
246
246
  .home-vendors { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
247
247
  .steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
248
248
  .step { display: flex; gap: 14px; align-items: flex-start; background: var(--step-bg); border-radius: calc(18px * var(--r-scale)); padding: 16px 18px; box-shadow: var(--edge); }
@@ -362,6 +362,13 @@
362
362
  .look-cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
363
363
  .look-cards [data-ui="look-card"] { width: auto; }
364
364
  #look-dialog .look-cards { grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 6px 0 14px; }
365
+ .look-own { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 8px 0 2px; }
366
+ .look-own .own-only { display: inline-flex; gap: 6px; }
367
+ .prop-window { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; font-size: calc(12px * var(--fs-scale)); font-weight: 600; }
368
+ .prop-window .note { display: flex; align-items: center; gap: 6px; }
369
+ .prop-window .note > .i { width: 14px; height: 14px; flex: none; }
370
+ .prop-look { display: flex; align-items: center; gap: 10px; }
371
+ .prop-look [data-ui="look-card"] { width: 150px; cursor: default; }
365
372
  .look-adjust { margin-top: 4px; }
366
373
  .look-adjust .adjust-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
367
374
  .look-adjust .adjust-head .label { font-size: calc(12px * var(--fs-scale)); font-weight: 800; color: var(--ink-3); }
@@ -373,14 +380,10 @@
373
380
  .adjust-group.text .field.row [data-ui="number-field"] { width: 104px; flex: none; }
374
381
  .adjust-group.text .field.row select { flex: 0 1 58%; }
375
382
  .adjust-group.text #sp-chat-sample { margin-top: 4px; }
376
- .bubble { max-width: 100%; background: var(--bubble-bg); border-radius: calc(6px * var(--r-scale)) calc(18px * var(--r-scale)) calc(18px * var(--r-scale)) calc(18px * var(--r-scale)); padding: 12px 16px; min-width: 0; font-size: calc(14.5px * var(--fs-scale)); font-weight: 600; line-height: 1.55; color: var(--bubble-ink); box-shadow: inset 0 0 0 1px var(--bubble-border), var(--bubble-shadow); }
377
- .msg.mine .bubble { background: var(--bubble-mine-bg); color: var(--bubble-mine-ink); border-radius: calc(18px * var(--r-scale)) calc(6px * var(--r-scale)) calc(18px * var(--r-scale)) calc(18px * var(--r-scale)); box-shadow: inset 0 0 0 1px var(--bubble-border), var(--bubble-mine-shadow); padding: 11px 16px; }
383
+ .bubble { max-width: 100%; background-color: var(--bubble-bg); background-image: var(--bevel); border-radius: calc(6px * var(--r-scale)) calc(18px * var(--r-scale)) calc(18px * var(--r-scale)) calc(18px * var(--r-scale)); padding: 12px 16px; min-width: 0; font-size: calc(14.5px * var(--fs-scale)); font-weight: 600; line-height: 1.55; color: var(--bubble-ink); box-shadow: inset 0 0 0 1px var(--bubble-border), var(--bubble-shadow); }
384
+ .msg.mine .bubble { background-color: transparent; background-image: var(--bevel), var(--bubble-mine-bg); color: var(--bubble-mine-ink); border-radius: calc(18px * var(--r-scale)) calc(6px * var(--r-scale)) calc(18px * var(--r-scale)) calc(18px * var(--r-scale)); box-shadow: inset 0 0 0 1px var(--bubble-border), var(--bubble-mine-shadow); padding: 11px 16px; }
378
385
  .msg.mine .bubble .text { color: var(--bubble-mine-ink); }
379
386
  .msg.mine.waiting .bubble { background: var(--attention-grad); box-shadow: var(--attention-shadow); }
380
- html[data-look="plush"] .msg .bubble { background-image: var(--bevel); }
381
- html[data-look="plush"] .msg.mine .bubble { background-image: var(--bevel), var(--bubble-mine-bg); }
382
- html[data-look="plush"] .composer-box { box-shadow: var(--input-shadow); background: var(--softer); border-radius: calc(14px * var(--r-scale)); padding: 0 12px; }
383
- html[data-look="plush"] .feature { box-shadow: var(--shadow-card); background-image: var(--bevel); }
384
387
  .bubble .waiting { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--bubble-mine-rule); font-size: calc(13px * var(--fs-scale)); font-weight: 600; color: var(--bubble-mine-waiting-ink); }
385
388
  .bubble .waiting .i { width: 14px; height: 14px; vertical-align: -2px; }
386
389
  .bubble .waiting-text { flex: 1; min-width: 160px; }
@@ -543,7 +546,7 @@ table.csv tbody tr:nth-child(even) td { background: var(--table-stripe); }
543
546
  .composer .smile-btn { border: 0; background: transparent; color: var(--placeholder); padding: 0; display: grid; place-content: center; width: 28px; height: 28px; border-radius: calc(8px * var(--r-scale)); flex: none; }
544
547
  .composer .smile-btn .i { width: 20px; height: 20px; }
545
548
  .composer .smile-btn:hover { color: var(--primary); }
546
- .composer-box { flex: 1; min-width: 0; display: flex; align-items: center; }
549
+ .composer-box { flex: 1; min-width: 0; display: flex; align-items: center; box-shadow: var(--composer-shadow); background: var(--composer-bg); border-radius: var(--composer-radius); padding: 0 var(--composer-pad-x); }
547
550
  .composer-box:has(.shots-tray:not([hidden])) { flex-direction: column; align-items: stretch; }
548
551
  .composer-box:has(.shots-tray:not([hidden])) textarea { flex: none; }
549
552
  .composer.drop-target { box-shadow: 0 0 0 2px var(--primary); }
@@ -636,6 +639,7 @@ table.csv tbody tr:nth-child(even) td { background: var(--table-stripe); }
636
639
  #fv-tools label.search > input:not([type="checkbox"]) { flex: 1 1 auto; margin-top: 0; width: auto; height: 100%; padding: 0; border: 0; background: transparent; }
637
640
  .fv-tools .fv-hits, .fv-tools .fv-count { color: var(--faint); font-size: var(--fs-xs); font-weight: 700; }
638
641
  .fv-tools .fv-goto, .fv-tools .fv-wrap { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: var(--fs-xs); font-weight: 700; }
642
+ #fv-tools .fv-goto, #fv-tools .fv-wrap { margin: 0; }
639
643
  #fv-tools .fv-goto > input[type="number"] { flex: 0 0 76px; margin-top: 0; width: 76px; height: 30px; padding: 0 8px; }
640
644
  .fv-tools .fv-wrap input { accent-color: var(--primary); }
641
645
  .text [data-ui="file-card"] { margin: 8px 0 4px; }