viberoom 0.5.1 → 0.5.3
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/NOTICE +8 -2
- package/README.md +1 -1
- package/dist/hub.js +46 -2
- package/dist/room.js +48 -6
- package/dist/server.js +18 -1
- package/dist/templates.js +27 -3
- package/package.json +1 -1
- package/templates/wren-and-quinn/template.json +28 -0
- package/ui/app.css +67 -19
- package/ui/app.js +259 -25
- package/ui/fonts/fira-code-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/fira-code-cyrillic.woff2 +0 -0
- package/ui/fonts/fira-code-latin-ext.woff2 +0 -0
- package/ui/fonts/fira-code-latin.woff2 +0 -0
- package/ui/fonts/fira-code.css +5 -0
- package/ui/fonts/inter-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/inter-cyrillic.woff2 +0 -0
- package/ui/fonts/inter-latin-ext.woff2 +0 -0
- package/ui/fonts/inter-latin.woff2 +0 -0
- package/ui/fonts/inter.css +5 -0
- package/ui/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
- package/ui/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- package/ui/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/ui/fonts/jetbrains-mono.css +5 -0
- package/ui/fonts/noto-sans-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/noto-sans-cyrillic.woff2 +0 -0
- package/ui/fonts/noto-sans-latin-ext.woff2 +0 -0
- package/ui/fonts/noto-sans-latin.woff2 +0 -0
- package/ui/fonts/noto-sans.css +5 -0
- package/ui/fonts/source-code-pro-cyrillic-ext.woff2 +0 -0
- package/ui/fonts/source-code-pro-cyrillic.woff2 +0 -0
- package/ui/fonts/source-code-pro-latin-ext.woff2 +0 -0
- package/ui/fonts/source-code-pro-latin.woff2 +0 -0
- package/ui/fonts/source-code-pro.css +5 -0
- package/ui/index.html +11 -0
- package/ui/theme.css +44 -18
- package/templates/forge-and-lumen/template.json +0 -28
package/NOTICE
CHANGED
|
@@ -3,8 +3,14 @@ Copyright (c) 2026 Todor Rusev
|
|
|
3
3
|
|
|
4
4
|
This product bundles the following third-party material:
|
|
5
5
|
|
|
6
|
-
-
|
|
7
|
-
|
|
6
|
+
- Fonts (ui/fonts/), each licensed under the SIL Open Font License, Version 1.1
|
|
7
|
+
(ui/fonts/OFL.txt), fetched from Google Fonts by scripts/fetch-fonts.mjs:
|
|
8
|
+
- Nunito: Copyright 2014 The Nunito Project Authors.
|
|
9
|
+
- Inter: Copyright 2016 The Inter Project Authors.
|
|
10
|
+
- Noto Sans: Copyright 2022 The Noto Project Authors.
|
|
11
|
+
- JetBrains Mono: Copyright 2020 The JetBrains Mono Project Authors.
|
|
12
|
+
- Fira Code: Copyright 2014-2020 The Fira Code Project Authors.
|
|
13
|
+
- Source Code Pro: Copyright 2010-2019 Adobe (http://www.adobe.com/).
|
|
8
14
|
|
|
9
15
|
- Vendor icons (assets/vendors/): the logos of Claude, Codex, Gemini, Cursor,
|
|
10
16
|
OpenCode and GitHub Copilot, as published in the Agent Client Protocol
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Open a room, summon the agents you already have, give each one a role, and let t
|
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
16
|
<p align="center">
|
|
17
|
-
<img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/conversation.png" width="960" alt="A
|
|
17
|
+
<img src="https://raw.githubusercontent.com/todor-rusev/viberoom/main/docs/screenshots/conversation.png" width="960" alt="A Wren & Quinn room: Wren explains a git command from a screenshot, Quinn adds the part that changes whose problem it is">
|
|
18
18
|
</p>
|
|
19
19
|
|
|
20
20
|
<p align="center">
|
package/dist/hub.js
CHANGED
|
@@ -11,6 +11,9 @@ 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 } 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
17
|
export const DIAGRAM_PRESETS = ["pop", "lavender", "mint", "sunset", "slate"];
|
|
15
18
|
const ROOM_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
16
19
|
const INSTRUCTION_FILES = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules"];
|
|
@@ -109,6 +112,7 @@ export class Hub extends EventEmitter {
|
|
|
109
112
|
agentSkillsNeedApproval: false,
|
|
110
113
|
diagrams: { preset: "pop", primary: null },
|
|
111
114
|
editor: { ...DEFAULT_EDITOR_SETTINGS },
|
|
115
|
+
appearance: { ...DEFAULT_APPEARANCE },
|
|
112
116
|
roomDefaults: {},
|
|
113
117
|
vendorPresets: {},
|
|
114
118
|
};
|
|
@@ -186,6 +190,20 @@ export class Hub extends EventEmitter {
|
|
|
186
190
|
throw new Error("editor.command must mention {file} (and usually {line})");
|
|
187
191
|
next.editor = { mode, command };
|
|
188
192
|
}
|
|
193
|
+
if (patch.appearance !== undefined && typeof patch.appearance === "object" && patch.appearance) {
|
|
194
|
+
const a = patch.appearance;
|
|
195
|
+
const chatFontSize = Number(a.chatFontSize ?? next.appearance?.chatFontSize ?? DEFAULT_APPEARANCE.chatFontSize);
|
|
196
|
+
if (!Number.isFinite(chatFontSize) || chatFontSize < 12 || chatFontSize > 24)
|
|
197
|
+
throw new Error("appearance.chatFontSize must be between 12 and 24");
|
|
198
|
+
const font = String(a.font ?? next.appearance?.font ?? DEFAULT_APPEARANCE.font);
|
|
199
|
+
if (!TEXT_FONTS.includes(font))
|
|
200
|
+
throw new Error(`appearance.font must be one of ${TEXT_FONTS.join(", ")}`);
|
|
201
|
+
const mono = String(a.mono ?? next.appearance?.mono ?? DEFAULT_APPEARANCE.mono);
|
|
202
|
+
if (!MONO_FONTS.includes(mono))
|
|
203
|
+
throw new Error(`appearance.mono must be one of ${MONO_FONTS.join(", ")}`);
|
|
204
|
+
next.appearance = { chatFontSize: Math.round(chatFontSize * 2) / 2, font, mono };
|
|
205
|
+
}
|
|
206
|
+
next.appearance = { ...DEFAULT_APPEARANCE, ...(next.appearance ?? {}) };
|
|
189
207
|
if (!next.editor)
|
|
190
208
|
next.editor = { ...DEFAULT_EDITOR_SETTINGS };
|
|
191
209
|
if (patch.roomDefaults !== undefined && typeof patch.roomDefaults === "object" && patch.roomDefaults) {
|
|
@@ -311,9 +329,16 @@ export class Hub extends EventEmitter {
|
|
|
311
329
|
const template = this.templates.get(input.templateId);
|
|
312
330
|
if (!template)
|
|
313
331
|
throw new Error(`no such template: ${input.templateId}`);
|
|
314
|
-
const { room, notices } = this.createRoom({ name: input.name, dir: input.dir, settings: template.settings });
|
|
332
|
+
const { room, notices } = this.createRoom({ name: input.name, dir: input.dir || template.dir || null, settings: template.settings });
|
|
333
|
+
const installed = new Set(listRecipes().filter((r) => !r.unavailableReason).map((r) => r.id));
|
|
315
334
|
for (const [i, tv] of template.vibemates.entries()) {
|
|
316
|
-
const choice = input.vibemates[i] ?? { name: tv.name, agentType: "" };
|
|
335
|
+
const choice = { ...(input.vibemates[i] ?? { name: tv.name, agentType: "" }) };
|
|
336
|
+
if (!choice.agentType && tv.agentType) {
|
|
337
|
+
if (installed.has(tv.agentType))
|
|
338
|
+
choice.agentType = tv.agentType;
|
|
339
|
+
else
|
|
340
|
+
notices.push(`${choice.name || tv.name}: the template runs it on ${tv.agentType}, which is not installed here; pick another agent in the roster.`);
|
|
341
|
+
}
|
|
317
342
|
const skills = (tv.skills ?? []).filter((name) => {
|
|
318
343
|
const ok = !!this.skills.get(name);
|
|
319
344
|
if (!ok)
|
|
@@ -337,6 +362,7 @@ export class Hub extends EventEmitter {
|
|
|
337
362
|
role: tv.role,
|
|
338
363
|
avatar: tv.avatar,
|
|
339
364
|
skills,
|
|
365
|
+
replyDelay: tv.replyDelay,
|
|
340
366
|
model: choice.model ?? tv.model,
|
|
341
367
|
effort: choice.effort ?? tv.effort,
|
|
342
368
|
mode: choice.mode ?? tv.mode,
|
|
@@ -349,6 +375,24 @@ export class Hub extends EventEmitter {
|
|
|
349
375
|
this.saveRooms();
|
|
350
376
|
return { room, notices };
|
|
351
377
|
}
|
|
378
|
+
saveRoomAsTemplate(roomId, input) {
|
|
379
|
+
const name = input.name.trim();
|
|
380
|
+
if (!name)
|
|
381
|
+
throw new Error("the template needs a name");
|
|
382
|
+
const room = this.getRoom(roomId);
|
|
383
|
+
const base = room.templateOf();
|
|
384
|
+
const edited = input.template ?? {};
|
|
385
|
+
const saved = this.templates.save({
|
|
386
|
+
name,
|
|
387
|
+
description: input.description.trim(),
|
|
388
|
+
emoji: (input.emoji ?? room.settings.emoji) || undefined,
|
|
389
|
+
dir: edited.dir?.trim() || base.dir,
|
|
390
|
+
settings: { ...base.settings, ...(edited.settings ?? {}) },
|
|
391
|
+
vibemates: edited.vibemates ?? base.vibemates,
|
|
392
|
+
});
|
|
393
|
+
this.emit("event", { type: "templates" });
|
|
394
|
+
return saved;
|
|
395
|
+
}
|
|
352
396
|
getRoom(id) {
|
|
353
397
|
const room = this.rooms.get(id);
|
|
354
398
|
if (!room)
|
package/dist/room.js
CHANGED
|
@@ -133,6 +133,40 @@ export class Room extends EventEmitter {
|
|
|
133
133
|
this.colorIndex++;
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
+
templateOf() {
|
|
137
|
+
const { name: _n, humanName: _h, ...rest } = this.settings;
|
|
138
|
+
const settings = { ...rest, customRules: this.renderRuleReferences(this.settings.customRules) };
|
|
139
|
+
const vibemates = [];
|
|
140
|
+
for (const p of this.participants.values()) {
|
|
141
|
+
if (p.kind !== "agent" || p.status === "left")
|
|
142
|
+
continue;
|
|
143
|
+
const v = { name: p.name };
|
|
144
|
+
if (p.tagline)
|
|
145
|
+
v.tagline = p.tagline;
|
|
146
|
+
if (p.role)
|
|
147
|
+
v.role = p.role;
|
|
148
|
+
if (p.avatar)
|
|
149
|
+
v.avatar = p.avatar;
|
|
150
|
+
if (p.skills?.length)
|
|
151
|
+
v.skills = [...p.skills];
|
|
152
|
+
if (p.replyDelay !== undefined)
|
|
153
|
+
v.replyDelay = p.replyDelay;
|
|
154
|
+
if (p.agentType) {
|
|
155
|
+
v.agentType = p.agentType;
|
|
156
|
+
const model = p.launch?.model ?? p.model;
|
|
157
|
+
const effort = p.launch?.effort ?? p.effort;
|
|
158
|
+
const mode = p.launch?.mode ?? p.mode;
|
|
159
|
+
if (model)
|
|
160
|
+
v.model = model;
|
|
161
|
+
if (effort)
|
|
162
|
+
v.effort = effort;
|
|
163
|
+
if (mode)
|
|
164
|
+
v.mode = mode;
|
|
165
|
+
}
|
|
166
|
+
vibemates.push(v);
|
|
167
|
+
}
|
|
168
|
+
return { dir: this.dir, settings, vibemates };
|
|
169
|
+
}
|
|
136
170
|
toStored() {
|
|
137
171
|
const { name: _n, humanName: _h, ...settings } = this.settings;
|
|
138
172
|
return {
|
|
@@ -931,6 +965,7 @@ export class Room extends EventEmitter {
|
|
|
931
965
|
turnActive: false,
|
|
932
966
|
pendingTurn: false,
|
|
933
967
|
turn: null,
|
|
968
|
+
strayMessageId: null,
|
|
934
969
|
turnsSinceBrief: 0,
|
|
935
970
|
usedAtBrief: 0,
|
|
936
971
|
briefSentThisTurn: false,
|
|
@@ -1660,6 +1695,7 @@ export class Room extends EventEmitter {
|
|
|
1660
1695
|
}
|
|
1661
1696
|
const startedAt = runtime.turn.startedAt;
|
|
1662
1697
|
const published = runtime.turn.published;
|
|
1698
|
+
const publishedAt = runtime.turn.publishedAt ?? null;
|
|
1663
1699
|
runtime.turn = null;
|
|
1664
1700
|
runtime.turnActive = false;
|
|
1665
1701
|
this.drafts.delete(draft.id);
|
|
@@ -1689,9 +1725,9 @@ export class Room extends EventEmitter {
|
|
|
1689
1725
|
this.closeRetry(retry, "the correction turn failed; nothing was posted");
|
|
1690
1726
|
return null;
|
|
1691
1727
|
}
|
|
1692
|
-
return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry, published);
|
|
1728
|
+
return this.finalizeTurn(participant, runtime, draft, result, Date.now() - startedAt, retry, published, publishedAt);
|
|
1693
1729
|
}
|
|
1694
|
-
finalizeTurn(participant, runtime, draft, result, durationMs, retry, published) {
|
|
1730
|
+
finalizeTurn(participant, runtime, draft, result, durationMs, retry, published, publishedAt = null) {
|
|
1695
1731
|
participant.status = "idle";
|
|
1696
1732
|
this.push({ type: "participant", participant });
|
|
1697
1733
|
const text = draft.text.trim();
|
|
@@ -1787,7 +1823,7 @@ export class Room extends EventEmitter {
|
|
|
1787
1823
|
durationMs,
|
|
1788
1824
|
};
|
|
1789
1825
|
this.commit(message);
|
|
1790
|
-
if (this.messages.some((x) => x.kind === "chat" && x.id !== message.id && x.ts >
|
|
1826
|
+
if (publishedAt !== null && this.messages.some((x) => x.kind === "chat" && x.id !== message.id && x.ts > publishedAt)) {
|
|
1791
1827
|
const at = new Date(draft.ts);
|
|
1792
1828
|
const hhmm = `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
|
|
1793
1829
|
this.postSystem(`${participant.name} finished the reply started at ${hhmm} · ${Math.round(durationMs / 1000)} s`, "human", false, { refId: message.id, agentId: participant.id });
|
|
@@ -1874,6 +1910,7 @@ export class Room extends EventEmitter {
|
|
|
1874
1910
|
if (turn.published)
|
|
1875
1911
|
return;
|
|
1876
1912
|
turn.published = true;
|
|
1913
|
+
turn.publishedAt = Date.now();
|
|
1877
1914
|
this.push({ type: "message", message: turn.message });
|
|
1878
1915
|
}
|
|
1879
1916
|
onSessionUpdate(id, update) {
|
|
@@ -1884,11 +1921,16 @@ export class Room extends EventEmitter {
|
|
|
1884
1921
|
const turn = runtime.turn;
|
|
1885
1922
|
switch (update.sessionUpdate) {
|
|
1886
1923
|
case "agent_message_chunk": {
|
|
1887
|
-
if (!turn)
|
|
1888
|
-
return;
|
|
1889
1924
|
const u = update;
|
|
1890
|
-
let text = contentText(u.content);
|
|
1891
1925
|
const messageId = u.messageId ?? null;
|
|
1926
|
+
if (!turn) {
|
|
1927
|
+
if (messageId)
|
|
1928
|
+
runtime.strayMessageId = messageId;
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
if (messageId && messageId === runtime.strayMessageId)
|
|
1932
|
+
return;
|
|
1933
|
+
let text = contentText(u.content);
|
|
1892
1934
|
if (messageId && !turn.sawMessageId && turn.message.text) {
|
|
1893
1935
|
const notices = (turn.message.notices ??= []);
|
|
1894
1936
|
notices.push(turn.message.text.trim());
|
package/dist/server.js
CHANGED
|
@@ -369,7 +369,7 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
369
369
|
sendJson(res, 200, { ok: true, room: room.snapshot(), notices });
|
|
370
370
|
return;
|
|
371
371
|
}
|
|
372
|
-
const roomAction = path.match(/^\/api\/rooms\/([^/]+)\/(send|typing|invite|settings|focus|rename|dir|delete|open)$/);
|
|
372
|
+
const roomAction = path.match(/^\/api\/rooms\/([^/]+)\/(send|typing|invite|settings|focus|rename|dir|delete|open|template-preview|save-template)$/);
|
|
373
373
|
if (roomAction) {
|
|
374
374
|
const room = hub.getRoom(decodeURIComponent(roomAction[1]));
|
|
375
375
|
const action = roomAction[2];
|
|
@@ -392,6 +392,23 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
|
|
|
392
392
|
const message = room.postHumanMessage(text, imageList(body.images));
|
|
393
393
|
sendJson(res, 200, { ok: true, id: message.id });
|
|
394
394
|
}
|
|
395
|
+
else if (action === "template-preview") {
|
|
396
|
+
sendJson(res, 200, { template: { name: room.name, emoji: room.settings.emoji || "", ...room.templateOf() } });
|
|
397
|
+
}
|
|
398
|
+
else if (action === "save-template") {
|
|
399
|
+
const edited = body.template && typeof body.template === "object" ? body.template : undefined;
|
|
400
|
+
const template = hub.saveRoomAsTemplate(room.id, {
|
|
401
|
+
name: String(body.name ?? ""),
|
|
402
|
+
description: String(body.description ?? ""),
|
|
403
|
+
emoji: body.emoji === undefined ? undefined : String(body.emoji),
|
|
404
|
+
template: edited && {
|
|
405
|
+
dir: optionalString(edited.dir) ?? undefined,
|
|
406
|
+
settings: edited.settings && typeof edited.settings === "object" ? edited.settings : undefined,
|
|
407
|
+
vibemates: Array.isArray(edited.vibemates) ? edited.vibemates : undefined,
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
sendJson(res, 200, { ok: true, template });
|
|
411
|
+
}
|
|
395
412
|
else if (action === "typing") {
|
|
396
413
|
room.humanTyping();
|
|
397
414
|
sendJson(res, 200, { ok: true });
|
package/dist/templates.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
|
|
2
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
6
7
|
export const SHIPPED_TEMPLATES_DIR = fileURLToPath(new URL("../templates/", import.meta.url));
|
|
@@ -22,6 +23,8 @@ export function cleanTemplate(raw, id) {
|
|
|
22
23
|
}
|
|
23
24
|
if (Array.isArray(o.skills))
|
|
24
25
|
out.skills = o.skills.map((s) => String(s).trim()).filter(Boolean);
|
|
26
|
+
if (typeof o.replyDelay === "number" && Number.isFinite(o.replyDelay))
|
|
27
|
+
out.replyDelay = o.replyDelay;
|
|
25
28
|
return out;
|
|
26
29
|
});
|
|
27
30
|
const settings = (t.settings && typeof t.settings === "object" ? t.settings : {});
|
|
@@ -32,8 +35,16 @@ export function cleanTemplate(raw, id) {
|
|
|
32
35
|
out.emoji = t.emoji.trim().slice(0, 8);
|
|
33
36
|
if (t.recommended === true)
|
|
34
37
|
out.recommended = true;
|
|
38
|
+
if (typeof t.dir === "string" && t.dir.trim())
|
|
39
|
+
out.dir = t.dir.trim();
|
|
40
|
+
if (typeof t.created === "string" && t.created.trim())
|
|
41
|
+
out.created = t.created.trim();
|
|
35
42
|
return out;
|
|
36
43
|
}
|
|
44
|
+
export function templateId(name) {
|
|
45
|
+
const id = name.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
46
|
+
return ID_PATTERN.test(id) ? id : "template";
|
|
47
|
+
}
|
|
37
48
|
function readTemplates(dir, log, builtin) {
|
|
38
49
|
const out = [];
|
|
39
50
|
if (!existsSync(dir))
|
|
@@ -54,7 +65,7 @@ function readTemplates(dir, log, builtin) {
|
|
|
54
65
|
log.warn(`template ${entry.name} in ${dir} skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
66
|
}
|
|
56
67
|
}
|
|
57
|
-
return out.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.name.localeCompare(b.name));
|
|
68
|
+
return out.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || (b.created ?? "").localeCompare(a.created ?? "") || a.name.localeCompare(b.name));
|
|
58
69
|
}
|
|
59
70
|
export class TemplateLibrary {
|
|
60
71
|
dir;
|
|
@@ -68,9 +79,22 @@ export class TemplateLibrary {
|
|
|
68
79
|
list() {
|
|
69
80
|
const own = readTemplates(this.dir, this.log, false);
|
|
70
81
|
const taken = new Set(own.map((t) => t.id));
|
|
71
|
-
return [...readTemplates(this.shippedDir, this.log, true).filter((t) => !taken.has(t.id))
|
|
82
|
+
return [...own, ...readTemplates(this.shippedDir, this.log, true).filter((t) => !taken.has(t.id))];
|
|
72
83
|
}
|
|
73
84
|
get(id) {
|
|
74
85
|
return this.list().find((t) => t.id === id);
|
|
75
86
|
}
|
|
87
|
+
save(template) {
|
|
88
|
+
const taken = new Set(this.list().map((t) => t.id));
|
|
89
|
+
const base = templateId(template.name);
|
|
90
|
+
let id = base;
|
|
91
|
+
for (let n = 2; taken.has(id); n++)
|
|
92
|
+
id = `${base.slice(0, 40 - String(n).length - 1)}-${n}`;
|
|
93
|
+
const clean = cleanTemplate({ ...template, created: new Date().toISOString() }, id);
|
|
94
|
+
const folder = join(this.dir, id);
|
|
95
|
+
mkdirSync(folder, { recursive: true });
|
|
96
|
+
writeFileAtomic(join(folder, "template.json"), JSON.stringify(clean, null, 2) + "\n");
|
|
97
|
+
this.log.info(`saved template "${clean.name}" (${id})`);
|
|
98
|
+
return clean;
|
|
99
|
+
}
|
|
76
100
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Wren & Quinn",
|
|
3
|
+
"order": 1,
|
|
4
|
+
"emoji": "⚒️",
|
|
5
|
+
"recommended": true,
|
|
6
|
+
"description": "Two equals around one codebase. Wren builds by default, Quinn explains by default; either does either when asked, and either reviews the other's work when you ask for it. The way viberoom itself was built.",
|
|
7
|
+
"settings": {
|
|
8
|
+
"language": {
|
|
9
|
+
"mode": "follow-human"
|
|
10
|
+
},
|
|
11
|
+
"turnTaking": "parallel",
|
|
12
|
+
"agentsWakeEachOther": true,
|
|
13
|
+
"hopLimit": 24,
|
|
14
|
+
"customRules": "Both build, both explain, both review; @ decides who does what. Without @, a task goes to the one who has worked on that part the most, or most recently; if neither has touched it, to Wren. A question without @ goes to Quinn.\nThe human decides when work is reviewed: ask the other one to review it. Unasked, the other answers questions and otherwise stays silent; it does not review, comment on or extend work it was not asked about. When asked to review, first say in one line what you will check, then check it by that criterion: a number, a live check, the risky case. The builder reports to the human, never to the other vibemate. A build is reported as: what changed (the files, and the commit if the project uses version control), how to verify it (a command, a script, a screenshot), what was not verified, what is left.\nMeasure before diagnosing; say no with a reason and a cheaper alternative; keep replies short.\nWork only on your own task, never on the other's; do not touch what the other is working on.\nReply only when addressed. On a message to everyone, only the one it concerns answers; the other stays silent. Never add to, correct or comment on the other's reply unless the human asks.\nFollow the conventions of the project you work in (its instructions, notes, tests). Where it keeps decision notes, write the decision there before reporting it here.\nRead every proposal and analyse it; agree or reject only with a real argument. Never concede to be agreeable, never object to seem rigorous.\nReply in the human's language. Code identifiers, commands, file paths, protocol and tool names stay exactly as in the code; established technical terms (kernel, thread, commit, pull request, layout) stay in English."
|
|
15
|
+
},
|
|
16
|
+
"vibemates": [
|
|
17
|
+
{
|
|
18
|
+
"name": "Wren",
|
|
19
|
+
"tagline": "builds by default; explains and reviews when asked",
|
|
20
|
+
"role": "You are Wren, one of two equal vibemates, Wren and Quinn. You lean to building: a task without an address is yours when it touches what you have built, or when neither of you has; before you change anything, read what is already there, the notes, the docs and the code, and keep each change small. Everything else is in the room rules."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "Quinn",
|
|
24
|
+
"tagline": "explains by default; builds and reviews when asked",
|
|
25
|
+
"role": "You are Quinn, one of two equal vibemates, Wren and Quinn. You lean to explaining: a question without an address is yours; when you review, it is by a criterion, not an impression. Everything else is in the room rules."
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
package/ui/app.css
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
.rail-room:hover .room-mark { opacity: 1; transform: translateY(-1px); }
|
|
36
36
|
.rail-room.active, .rail-room.active:hover { background: transparent; box-shadow: none; }
|
|
37
37
|
.rail-room.active .room-mark { opacity: 1; box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--primary); }
|
|
38
|
-
.rail-room .rail-count { background:
|
|
38
|
+
.rail-room .rail-count { background: var(--unread-grad); min-width: 18px; text-align: center; z-index: 2; }
|
|
39
39
|
.rail-room .rail-count.bump { animation: badge-pop 360ms var(--ease-pop); }
|
|
40
40
|
@keyframes badge-pop { 0% { transform: scale(0.6); } 55% { transform: scale(1.3); } 100% { transform: scale(1); } }
|
|
41
41
|
.shell.rail-open .rail-items { align-items: stretch; padding: 0 14px; }
|
|
@@ -214,9 +214,9 @@
|
|
|
214
214
|
.timeline { position: absolute; right: 6px; top: 78px; bottom: calc(var(--composer-h, 60px) + 36px); width: 18px; z-index: 4; }
|
|
215
215
|
.tl-ticks { position: absolute; inset: 0; }
|
|
216
216
|
.tl-view { position: absolute; left: 2px; right: 2px; border-radius: 6px; background: rgba(91, 91, 240, 0.07); pointer-events: none; transition: top 80ms linear, height 80ms linear; }
|
|
217
|
-
.tl-tick { position: absolute; left: 3px; width: 12px; height: 5px; border-radius: 3px; background:
|
|
217
|
+
.tl-tick { position: absolute; left: 3px; width: 12px; height: 5px; border-radius: 3px; background: var(--tick-mine); cursor: pointer; transition: background var(--t-fast), transform var(--t-fast); }
|
|
218
218
|
.tl-tick:hover, .tl-tick.active { background: var(--primary); transform: scaleX(1.25); }
|
|
219
|
-
.tl-tick.in-view { background:
|
|
219
|
+
.tl-tick.in-view { background: var(--tick-mine-view); }
|
|
220
220
|
.tl-tick.pinned { background: var(--primary); }
|
|
221
221
|
.tl-row.current.pinned { background: var(--lav-2); }
|
|
222
222
|
.tl-row.pinned { opacity: 1; color: var(--ink); }
|
|
@@ -224,9 +224,9 @@
|
|
|
224
224
|
.tl-row .tl-pin .i { width: 14px; height: 14px; }
|
|
225
225
|
.timeline.left { left: 6px; right: auto; }
|
|
226
226
|
.timeline.left .tl-view { background: rgba(28, 27, 51, 0.05); }
|
|
227
|
-
.timeline.left .tl-tick { background: color-mix(in srgb, var(--tick,
|
|
228
|
-
.timeline.left .tl-tick.in-view { background: color-mix(in srgb, var(--tick,
|
|
229
|
-
.timeline.left .tl-tick.pinned, .timeline.left .tl-tick:hover, .timeline.left .tl-tick.active { background: var(--tick,
|
|
227
|
+
.timeline.left .tl-tick { background: color-mix(in srgb, var(--tick, var(--tick-fallback)) 35%, #fff); }
|
|
228
|
+
.timeline.left .tl-tick.in-view { background: color-mix(in srgb, var(--tick, var(--tick-fallback)) 60%, #fff); }
|
|
229
|
+
.timeline.left .tl-tick.pinned, .timeline.left .tl-tick:hover, .timeline.left .tl-tick.active { background: var(--tick, var(--tick-fallback)); }
|
|
230
230
|
.timeline.left .tl-pop { left: 26px; right: auto; }
|
|
231
231
|
.tl-pop::before { content: ""; position: absolute; top: -6px; bottom: -6px; right: -6px; width: 6px; }
|
|
232
232
|
.timeline.left .tl-pop::before { right: auto; left: -6px; }
|
|
@@ -265,25 +265,25 @@
|
|
|
265
265
|
.jump-latest:hover { background: var(--soft); }
|
|
266
266
|
.done-notes { position: absolute; left: 30px; bottom: 96px; z-index: 5; display: flex; flex-direction: column-reverse; gap: 6px; }
|
|
267
267
|
.done-note { display: inline-flex; align-items: center; gap: 8px; max-width: 230px; padding: 6px 12px 6px 8px; border: 0; border-radius: 14px; background: var(--warm); color: var(--warm-ink); font: inherit; text-align: left; cursor: pointer; box-shadow: var(--shadow-pop); animation: rise var(--t-base) var(--ease-out); overflow: hidden; }
|
|
268
|
-
.done-note:hover { background:
|
|
268
|
+
.done-note:hover { background: var(--note-hover); }
|
|
269
269
|
.done-note .avatar { flex: none; }
|
|
270
270
|
.done-go { display: inline-flex; align-items: center; gap: 8px; min-width: 0; padding: 6px 4px 6px 8px; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
|
271
|
-
.done-go:hover { background:
|
|
271
|
+
.done-go:hover { background: var(--note-hover); }
|
|
272
272
|
.done-go .avatar { flex: none; }
|
|
273
273
|
.done-text { display: flex; flex-direction: column; min-width: 0; line-height: 1.25; }
|
|
274
274
|
.done-text b { font-size: 12px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
275
275
|
.done-text small { font-size: 11px; font-weight: 600; opacity: 0.8; white-space: nowrap; }
|
|
276
276
|
.done-x { border: 0; background: transparent; color: inherit; opacity: 0.6; font-size: 15px; line-height: 1; padding: 0 9px; cursor: pointer; }
|
|
277
|
-
.done-x:hover { opacity: 1; background:
|
|
277
|
+
.done-x:hover { opacity: 1; background: var(--note-hover); }
|
|
278
278
|
.jump-latest .i { width: 16px; height: 16px; }
|
|
279
279
|
.bubble { max-width: 100%; background: #fff; border-radius: 6px 18px 18px 18px; padding: 12px 16px; min-width: 0; font-size: 14.5px; font-weight: 600; line-height: 1.55; color: var(--ink-2); box-shadow: 0 2px 0 rgba(28, 27, 51, 0.06), 0 1px 3px rgba(28, 27, 51, 0.04); }
|
|
280
280
|
.msg.mine .bubble { background: var(--grad-primary); color: #fff; border-radius: 18px 18px 6px 18px; box-shadow: 0 8px 18px -10px rgba(91, 91, 240, 0.7); padding: 11px 16px; }
|
|
281
281
|
.msg.mine .bubble .text { color: #fff; }
|
|
282
|
-
.msg.mine.waiting .bubble { background:
|
|
282
|
+
.msg.mine.waiting .bubble { background: var(--attention-grad); box-shadow: var(--attention-shadow); }
|
|
283
283
|
.bubble .waiting { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px; padding-top: 8px; border-top: 1px solid rgba(255, 255, 255, 0.35); font-size: 13px; font-weight: 600; color: rgba(255, 255, 255, 0.92); }
|
|
284
284
|
.bubble .waiting .i { width: 14px; height: 14px; vertical-align: -2px; }
|
|
285
285
|
.bubble .waiting-text { flex: 1; min-width: 160px; }
|
|
286
|
-
.bubble .waiting-stop { border: 0; border-radius: 10px; padding: 6px 12px; background: rgba(255, 255, 255, 0.92); color:
|
|
286
|
+
.bubble .waiting-stop { border: 0; border-radius: 10px; padding: 6px 12px; background: rgba(255, 255, 255, 0.92); color: var(--attention-ink); font-weight: 800; font-size: 12.5px; cursor: pointer; }
|
|
287
287
|
.bubble .waiting-stop:hover:not(:disabled) { background: #fff; }
|
|
288
288
|
.bubble .waiting-stop:disabled { opacity: 0.7; cursor: default; }
|
|
289
289
|
.msg.mine .mention { background: rgba(255, 255, 255, 0.22); color: #fff !important; padding: 1px 7px; border-radius: 6px; font-weight: 800; }
|
|
@@ -361,10 +361,17 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
|
|
|
361
361
|
.bubble .text.clamped { max-height: 9.2em; overflow: hidden; -webkit-mask-image: linear-gradient(180deg, #000 70%, transparent); mask-image: linear-gradient(180deg, #000 70%, transparent); }
|
|
362
362
|
.bubble .more { background: none; border: 0; color: var(--primary); font-weight: 800; padding: 2px 0; font-size: 13px; }
|
|
363
363
|
.msg.mine .bubble .more { color: #fff; }
|
|
364
|
-
.bubble .pending {
|
|
364
|
+
.bubble .pending { display: inline-flex; align-items: center; gap: 4px; height: 1.55em; vertical-align: text-bottom; }
|
|
365
|
+
.bubble .pending i { width: 5px; height: 5px; border-radius: 50%; background: var(--muted); opacity: 0.25; animation: think 1.4s ease-in-out infinite; }
|
|
366
|
+
.bubble .pending i:nth-child(2) { animation-delay: 0.2s; }
|
|
367
|
+
.bubble .pending i:nth-child(3) { animation-delay: 0.4s; }
|
|
368
|
+
@keyframes think { 0%, 60%, 100% { opacity: 0.25; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-2px); } }
|
|
365
369
|
.mention { font-weight: 800; }
|
|
366
|
-
.
|
|
367
|
-
|
|
370
|
+
.working { display: inline-block; width: 19px; height: 15px; color: var(--faint); margin-left: 5px; vertical-align: -3px; overflow: visible; }
|
|
371
|
+
.working .forearm { animation: type 0.14s ease-in-out infinite alternate; transform-box: view-box; transform-origin: 23px 27px; }
|
|
372
|
+
.working .body { animation: bob 0.28s ease-in-out infinite alternate; }
|
|
373
|
+
@keyframes type { from { transform: rotate(-16deg); } to { transform: rotate(4deg); } }
|
|
374
|
+
@keyframes bob { from { transform: translateY(0); } to { transform: translateY(-0.8px); } }
|
|
368
375
|
.thought { margin: 2px 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); }
|
|
369
376
|
.thought summary { cursor: pointer; }
|
|
370
377
|
.thought-text { white-space: pre-wrap; padding: 4px 0 0 8px; border-left: 2px solid var(--lav-2); margin-top: 4px; }
|
|
@@ -374,7 +381,20 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
|
|
|
374
381
|
.tool { display: inline-flex; flex-direction: column; max-width: 100%; min-width: 0; }
|
|
375
382
|
.tool.open { flex-basis: 100%; }
|
|
376
383
|
.tool > .chip { font: inherit; font-size: 11px; font-weight: 800; cursor: pointer; text-align: left; }
|
|
377
|
-
.tool-
|
|
384
|
+
.tool > .chip-completed { --tool-bg: var(--mint); --tool-ink: var(--mint-ink); }
|
|
385
|
+
.tool > .chip-failed { --tool-bg: var(--rose); --tool-ink: var(--rose-ink); }
|
|
386
|
+
.tool > .chip-in_progress { --tool-bg: var(--warm); --tool-ink: var(--warm-ink); }
|
|
387
|
+
.tool > .chip-pending { --tool-bg: var(--lav); --tool-ink: var(--primary); }
|
|
388
|
+
.tool > .chip::after { content: "▾"; margin-left: auto; padding-left: 8px; opacity: 0.55; transition: transform var(--t-fast); }
|
|
389
|
+
.tool.open > .chip { width: 100%; border-radius: 12px 12px 0 0; padding: 6px 12px; white-space: normal; }
|
|
390
|
+
.tool.open > .chip::after { transform: rotate(180deg); }
|
|
391
|
+
.tool.open { --tool-bg: var(--lav); --tool-ink: var(--primary); }
|
|
392
|
+
.tool.open > .chip-completed ~ .tool-body, .tool.open > .chip-completed + .tool-body { --tool-bg: var(--mint); --tool-ink: var(--mint-ink); }
|
|
393
|
+
.tool.open > .chip-failed + .tool-body { --tool-bg: var(--rose); --tool-ink: var(--rose-ink); }
|
|
394
|
+
.tool.open > .chip-in_progress + .tool-body { --tool-bg: var(--warm); --tool-ink: var(--warm-ink); }
|
|
395
|
+
.tool-body { margin: 0; padding: 8px 10px 10px; background: var(--card); border: 2px solid var(--tool-bg, var(--lav)); border-top: 0; border-radius: 0 0 12px 12px; font-size: 12px; max-height: 340px; overflow: auto; }
|
|
396
|
+
.tool-body pre { background: var(--soft); color: var(--ink); padding: 6px 8px; border-radius: 8px; }
|
|
397
|
+
.tool-sec b { color: var(--tool-ink, var(--muted)); }
|
|
378
398
|
.tool-sec + .tool-sec { margin-top: 8px; }
|
|
379
399
|
.tool-sec b { display: block; font-size: 10.5px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
|
|
380
400
|
.tool-sec.muted { color: var(--muted); font-style: italic; }
|
|
@@ -390,8 +410,8 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
|
|
|
390
410
|
.head .edit-btn .i, .head .pin-btn .i { width: 14px; height: 14px; }
|
|
391
411
|
.msg:hover .head .edit-btn, .head .edit-btn:focus, .msg:hover .head .pin-btn, .head .pin-btn:focus { opacity: 1; }
|
|
392
412
|
.head .edit-btn:hover { color: var(--primary); }
|
|
393
|
-
.head .pin-btn:hover { color:
|
|
394
|
-
.msg.pinned .head .pin-btn { opacity: 1; color:
|
|
413
|
+
.head .pin-btn:hover { color: var(--attention); }
|
|
414
|
+
.msg.pinned .head .pin-btn { opacity: 1; color: var(--attention); }
|
|
395
415
|
.edit-box { margin: 4px 0 6px; }
|
|
396
416
|
.msg .bubble-col:has(> .bubble > .edit-box:not([hidden])) { width: 100%; }
|
|
397
417
|
.msg .bubble:has(> .edit-box:not([hidden])) { width: min(760px, 100%); }
|
|
@@ -415,7 +435,7 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
|
|
|
415
435
|
.perm-result { color: var(--muted); font-size: 12px; }
|
|
416
436
|
.visibility-divider { display: flex; align-items: center; gap: 10px; margin: 4px 0; }
|
|
417
437
|
.visibility-divider .vd-line { flex: 1; height: 8px; background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='8' viewBox='0 0 16 8'><path d='M0 4 Q4 0 8 4 T16 4' fill='none' stroke='%23f472b6' stroke-width='1.6'/></svg>") repeat-x center / 16px 8px; opacity: 0.7; }
|
|
418
|
-
.visibility-divider .vd-label { color:
|
|
438
|
+
.visibility-divider .vd-label { color: var(--unseen-ink); background: var(--unseen-bg); border-radius: var(--r-pill); padding: 4px 12px; font-size: 11px; font-weight: 800; white-space: nowrap; max-width: 70%; overflow: hidden; text-overflow: ellipsis; }
|
|
419
439
|
.msg.hidden { justify-content: center; }
|
|
420
440
|
.hidden-turn { max-width: 720px; width: 100%; background: var(--soft); border-radius: 12px; padding: 6px 12px; font-size: 12px; font-weight: 600; color: var(--muted); }
|
|
421
441
|
.hidden-turn summary { cursor: pointer; list-style: none; }
|
|
@@ -549,6 +569,31 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
|
|
|
549
569
|
.tpl-meta { color: var(--muted); font-size: 12px; font-weight: 600; }
|
|
550
570
|
.tpl-item .avatar-stack { margin-top: 2px; }
|
|
551
571
|
.tpl-rec { background: var(--warm); color: var(--warm-ink); font-size: 10px; padding: 1px 7px; }
|
|
572
|
+
.tpl-mine { background: var(--lav); color: var(--primary); font-size: 10px; padding: 1px 7px; }
|
|
573
|
+
.tpl-runs { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
574
|
+
.stp-preview { display: flex; flex-direction: column; gap: 10px; max-height: 48vh; overflow: auto; padding-right: 4px; margin: 4px 0 10px; }
|
|
575
|
+
.stp-section { background: var(--soft); border-radius: 14px; padding: 10px 12px; }
|
|
576
|
+
.section.template { background: var(--lav); margin: 22px 0 14px; padding: 16px 18px 18px; box-sizing: border-box; width: 100%; overflow: hidden; }
|
|
577
|
+
.section.template > h4 { color: var(--primary); }
|
|
578
|
+
.stp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; }
|
|
579
|
+
.stp-grid .field { margin-bottom: 8px; }
|
|
580
|
+
.stp-grid .field.wide { grid-column: 1 / -1; }
|
|
581
|
+
.stp-grid .switch { margin: 2px 0 8px; }
|
|
582
|
+
.stp-vm { display: grid; grid-template-columns: 36px 1fr; gap: 10px; align-items: start; padding: 10px 0 4px; border-top: 1px solid var(--lav-2); }
|
|
583
|
+
.stp-vm-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 0 10px; }
|
|
584
|
+
.stp-vm-fields .field { margin-bottom: 8px; }
|
|
585
|
+
.stp-vm-fields .field.wide { grid-column: 1 / -1; }
|
|
586
|
+
.stp-vm-fields .field textarea { min-height: 54px; }
|
|
587
|
+
.stp-vm-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
|
588
|
+
.stp-vm-top b { flex: 1; }
|
|
589
|
+
.stp-section h5 { margin: 0 0 6px; font-size: 11px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--faint); }
|
|
590
|
+
.stp-section .kv { font-size: 12.5px; }
|
|
591
|
+
.stp-section ul { margin: 0; padding-left: 18px; color: var(--ink-2); font-size: 13px; font-weight: 600; }
|
|
592
|
+
.stp-vm { display: grid; grid-template-columns: 36px 1fr; gap: 10px; align-items: start; padding: 8px 0; border-top: 1px solid var(--lav-2); }
|
|
593
|
+
.stp-vm:first-of-type { border-top: 0; padding-top: 2px; }
|
|
594
|
+
.stp-vm-head { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
|
|
595
|
+
.stp-vm-role { color: var(--muted); font-size: 12.5px; font-weight: 600; line-height: 1.45; margin-top: 3px; }
|
|
596
|
+
.stp-vm .chips { margin-top: 5px; }
|
|
552
597
|
.tpl-check { display: grid; place-items: center; width: 18px; height: 18px; border-radius: 50%; background: var(--primary); color: #fff; opacity: 0; transition: opacity var(--t-fast); }
|
|
553
598
|
.tpl-check .i { width: 12px; height: 12px; }
|
|
554
599
|
.tpl-item.on .tpl-check { opacity: 1; }
|
|
@@ -578,7 +623,10 @@ table.csv tbody tr:nth-child(even) td { background: rgba(91, 91, 240, 0.03); }
|
|
|
578
623
|
.check-list.locked { pointer-events: none; opacity: 0.6; }
|
|
579
624
|
.msg.system.done { justify-content: center; }
|
|
580
625
|
.done-row { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px 6px 8px; border: 0; border-radius: var(--r-pill); background: var(--warm); color: var(--warm-ink); font: inherit; font-size: 12px; font-weight: 800; cursor: pointer; box-shadow: var(--edge); }
|
|
581
|
-
.done-row:hover { background:
|
|
626
|
+
.done-row:hover { background: var(--note-hover); }
|
|
582
627
|
.done-notes { flex-direction: column; }
|
|
583
628
|
.done-note.new { background: var(--card); color: var(--ink-2); }
|
|
584
629
|
.done-note.new:hover { background: var(--soft); }
|
|
630
|
+
.meta .seen-by { color: var(--primary); font-weight: 800; margin-left: 6px; }
|
|
631
|
+
.section.template .stp-row { justify-content: center; margin: 6px 0 0; }
|
|
632
|
+
.section.template .field-note { margin: -2px 0 12px; }
|