Compare commits
12 Commits
c83bd6a89b
...
v2.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dc9d612de | ||
|
|
e2be462ecb | ||
|
|
6d5c3027ac | ||
|
|
262be3e332 | ||
|
|
c502ccda48 | ||
|
|
a67ff9e403 | ||
|
|
3674811e40 | ||
|
|
ce8769d5a2 | ||
|
|
f52fb05f5e | ||
|
|
4535b03207 | ||
|
|
1df0a905a2 | ||
|
|
7a09b9f91c |
@@ -1,7 +1,7 @@
|
|||||||
# Floating Dross Chat — Design
|
# Floating Dross Chat — Design
|
||||||
|
|
||||||
**Date:** 2026-06-09
|
**Date:** 2026-06-09
|
||||||
**Status:** Approved (pending final spec sign-off)
|
**Status:** Phase 1 SHIPPED (v2.11.0, 2026-06-10) — global floating bubble + avatars + settings + persona. Phase 2 (voice) and the "Keep voice clips" retention setting are next.
|
||||||
**Goal:** Replace the docked, per-Space "Cradle Chat" with a global, movable floating-bubble Dross companion — mobile-first, with voice-clip input transcribed locally into instructions.
|
**Goal:** Replace the docked, per-Space "Cradle Chat" with a global, movable floating-bubble Dross companion — mobile-first, with voice-clip input transcribed locally into instructions.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -21,6 +21,7 @@ The chat mechanics are already factored into a reusable engine (`public/componen
|
|||||||
5. **Colour** — Dross is **violet** by default, but his accent is **tunable in Settings** (his own vars, independent of the UI theme).
|
5. **Colour** — Dross is **violet** by default, but his accent is **tunable in Settings** (his own vars, independent of the UI theme).
|
||||||
6. **Persona** — give him the real Cradle-Dross voice (dry, sardonic, impatient, brilliant, secretly loyal) via an **editable system prompt in Settings** (tunable).
|
6. **Persona** — give him the real Cradle-Dross voice (dry, sardonic, impatient, brilliant, secretly loyal) via an **editable system prompt in Settings** (tunable).
|
||||||
7. **Voice** — record a clip → transcribe with **local faster-whisper on the Ollama box (CT 102, GPU, CPU-fallback)** → transcript lands in the input for **review-and-send first (mode 1)**. A *voice-mode* setting allows graduating to **hands-free auto-send (mode 2)**, then **interpret-into-confirmable-action (mode 3)** later.
|
7. **Voice** — record a clip → transcribe with **local faster-whisper on the Ollama box (CT 102, GPU, CPU-fallback)** → transcript lands in the input for **review-and-send first (mode 1)**. A *voice-mode* setting allows graduating to **hands-free auto-send (mode 2)**, then **interpret-into-confirmable-action (mode 3)** later.
|
||||||
|
8. **Audio retention (Phase 2, added 2026-06-09)** — by default the clip is transcribed then **destroyed** (transient). Add a **Dross setting** "Keep voice clips" that, when on, **saves each audio clip paired with its transcript**, stored **safely and securely** (encrypted at rest / access-controlled; on a homelab dataset, owner-only — exact store TBD in P2: e.g. a `voice_clips` table + blob on a ZFS dataset, or object store). Off by default. This is a P2 deliverable, designed-for now.
|
||||||
|
|
||||||
## Non-goals (this iteration)
|
## Non-goals (this iteration)
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { router as storageRouter } from './routes/storage.js';
|
|||||||
import { router as backupsRouter } from './routes/backups.js';
|
import { router as backupsRouter } from './routes/backups.js';
|
||||||
import { router as kuttRouter } from './routes/kutt.js';
|
import { router as kuttRouter } from './routes/kutt.js';
|
||||||
import { router as themeRouter } from './routes/theme.js';
|
import { router as themeRouter } from './routes/theme.js';
|
||||||
|
import { router as drossRouter } from './routes/dross.js';
|
||||||
|
|
||||||
export function mountApi(app) {
|
export function mountApi(app) {
|
||||||
const api = Router();
|
const api = Router();
|
||||||
@@ -73,6 +74,7 @@ export function mountApi(app) {
|
|||||||
api.use('/links', linksRouter);
|
api.use('/links', linksRouter);
|
||||||
api.use('/kutt', kuttRouter);
|
api.use('/kutt', kuttRouter);
|
||||||
api.use('/theme', themeRouter);
|
api.use('/theme', themeRouter);
|
||||||
|
api.use('/dross', drossRouter);
|
||||||
api.use('/pending-changes', pendingChangesRouter);
|
api.use('/pending-changes', pendingChangesRouter);
|
||||||
api.use('/audit', auditRouter);
|
api.use('/audit', auditRouter);
|
||||||
api.use('/search', searchRouter);
|
api.use('/search', searchRouter);
|
||||||
|
|||||||
115
lib/api/routes/dross.js
Normal file
115
lib/api/routes/dross.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { validate } from '../validate.js';
|
||||||
|
import { asyncWrap } from '../errors.js';
|
||||||
|
import { requireOwner } from '../cap.js';
|
||||||
|
import * as settings from '../../db/repos/app_settings.js';
|
||||||
|
import * as conversations from '../../db/repos/conversations.js';
|
||||||
|
import * as messages from '../../db/repos/messages.js';
|
||||||
|
import * as agents from '../../db/repos/agents.js';
|
||||||
|
import { runAgentTurn } from '../../ai/agent/run_turn.js';
|
||||||
|
import { personaFor } from '../../ai/personas/index.js';
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||||
|
const COMPANION_SLUG = 'companion';
|
||||||
|
|
||||||
|
export const router = Router();
|
||||||
|
|
||||||
|
async function getCfg() { return { ...DEFAULT_SETTINGS, ...(await settings.get('dross', {})) }; }
|
||||||
|
|
||||||
|
router.get('/settings', asyncWrap(async (_req, res) => res.json(await getCfg())));
|
||||||
|
|
||||||
|
const settingsBody = z.object({
|
||||||
|
avatar: z.enum(['soft-eye', 'wisp', 'motes']),
|
||||||
|
accent: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||||
|
persona: z.string().max(8000),
|
||||||
|
voiceMode: z.enum(['review', 'handsfree', 'action'])
|
||||||
|
});
|
||||||
|
router.put('/settings', requireOwner, validate({ body: settingsBody }),
|
||||||
|
asyncWrap(async (req, res) => res.json(await settings.set('dross', req.body))));
|
||||||
|
|
||||||
|
async function resolve() {
|
||||||
|
const agent = await agents.getBySlug(COMPANION_SLUG);
|
||||||
|
const convo = await conversations.findOrCreateGlobal(agent.id, { kind: 'user', id: null });
|
||||||
|
return { agent, convo };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', asyncWrap(async (_req, res) => {
|
||||||
|
const { agent, convo } = await resolve();
|
||||||
|
const rows = await messages.listByConversation(convo.id);
|
||||||
|
res.json({
|
||||||
|
conversation_id: convo.id,
|
||||||
|
agent: { id: agent.id, slug: agent.slug, name: agent.name },
|
||||||
|
messages: rows
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
const turnSchema = z.object({
|
||||||
|
text: z.string().min(1),
|
||||||
|
view: z.object({ entityType: z.string(), entityId: z.string() }).partial().nullish()
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/turn', requireOwner, validate({ body: turnSchema }), asyncWrap(async (req, res) => {
|
||||||
|
const { agent, convo } = await resolve();
|
||||||
|
const { text, view } = req.body;
|
||||||
|
const cfg = await getCfg();
|
||||||
|
const persona = (cfg.persona && cfg.persona.trim()) ? cfg.persona : personaFor(COMPANION_SLUG);
|
||||||
|
|
||||||
|
const priorTurns = (await messages.listByConversation(convo.id)).length;
|
||||||
|
const resume = priorTurns > 0;
|
||||||
|
await messages.append(convo.id, { role: 'user', body: text });
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
||||||
|
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||||
|
const claudeExe = req.app.locals.claudeExe || process.env.CLAUDE_EXE || 'claude';
|
||||||
|
const companionTools = ['mcp__void__search', 'mcp__void__read', 'mcp__void__context', 'mcp__void__propose_change'];
|
||||||
|
const draftIds = [];
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await runAgentTurn({
|
||||||
|
agent, persona, registryName: undefined, toolNames: companionTools,
|
||||||
|
spaceId: null, view, sessionId: convo.id, resume, userText: text, claudeExe,
|
||||||
|
home: process.env.VOID_CLAUDE_HOME || undefined,
|
||||||
|
onEvent: (e) => {
|
||||||
|
if (e.type === 'delta') {
|
||||||
|
send('delta', { type: 'delta', text: e.text });
|
||||||
|
} else if (e.type === 'tool') {
|
||||||
|
send('tool', { type: 'tool', tool: e.tool, status: e.status });
|
||||||
|
} else if (e.type === 'tool_result') {
|
||||||
|
try {
|
||||||
|
let parsed = null;
|
||||||
|
const tryParse = (s) => { try { return JSON.parse(s); } catch { return null; } };
|
||||||
|
if (typeof e.result === 'string') {
|
||||||
|
parsed = tryParse(e.result);
|
||||||
|
} else if (e.result?.structuredContent?.pending_change_id) {
|
||||||
|
parsed = e.result.structuredContent;
|
||||||
|
} else if (Array.isArray(e.result)) {
|
||||||
|
for (const b of e.result) {
|
||||||
|
const c = b?.type === 'text' && b.text ? tryParse(b.text) : null;
|
||||||
|
if (c?.pending_change_id) { parsed = c; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed?.pending_change_id) {
|
||||||
|
draftIds.push(parsed.pending_change_id);
|
||||||
|
send('draft', { type: 'draft', pending_change_id: parsed.pending_change_id, summary: parsed.summary || 'a change' });
|
||||||
|
}
|
||||||
|
} catch { /* parsing failed — no draft to surface */ }
|
||||||
|
} else if (e.type === 'error') {
|
||||||
|
send('error', { type: 'error', message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
send('error', { message: String(e?.message || e) });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistant = await messages.append(convo.id, {
|
||||||
|
role: 'assistant', body: result.text, agent_id: agent.id,
|
||||||
|
metadata: { tool_trace: result.toolTrace, draft_ids: draftIds, usage: result.usage }
|
||||||
|
});
|
||||||
|
send('done', { assistant_message_id: assistant.id, draft_ids: draftIds, usage: result.usage });
|
||||||
|
res.end();
|
||||||
|
}));
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "void-server",
|
"name": "void-server",
|
||||||
"version": "2.10.0",
|
"version": "2.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { api } from './api.js';
|
|||||||
import { route, current, navigate } from './router.js';
|
import { route, current, navigate } from './router.js';
|
||||||
import { renderSidebar } from './components/sidebar.js';
|
import { renderSidebar } from './components/sidebar.js';
|
||||||
import { renderTopbar } from './components/topbar.js';
|
import { renderTopbar } from './components/topbar.js';
|
||||||
import { renderRightrail } from './components/rightrail.js';
|
import { renderDrossBubble } from './components/dross_bubble.js';
|
||||||
import { emit, state } from './state.js';
|
import { emit, state } from './state.js';
|
||||||
import { el, mount } from './dom.js';
|
import { el, mount } from './dom.js';
|
||||||
import { attachDropzone } from './components/dropzone.js';
|
import { attachDropzone } from './components/dropzone.js';
|
||||||
@@ -84,7 +84,7 @@ async function init() {
|
|||||||
await loadTheme(); // apply saved palette overrides before rendering chrome
|
await loadTheme(); // apply saved palette overrides before rendering chrome
|
||||||
renderTopbar(document.getElementById('topbar'));
|
renderTopbar(document.getElementById('topbar'));
|
||||||
renderSidebar(document.getElementById('sidebar'));
|
renderSidebar(document.getElementById('sidebar'));
|
||||||
renderRightrail(document.getElementById('rightrail'));
|
renderDrossBubble();
|
||||||
initChrome();
|
initChrome();
|
||||||
attachDropzone(document.getElementById('main'));
|
attachDropzone(document.getElementById('main'));
|
||||||
route(renderView);
|
route(renderView);
|
||||||
|
|||||||
20
public/components/dross_avatar.js
Normal file
20
public/components/dross_avatar.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// public/components/dross_avatar.js
|
||||||
|
import { el } from '../dom.js';
|
||||||
|
|
||||||
|
// Returns a .dross-orb element rendering the chosen avatar. Colours come from
|
||||||
|
// CSS vars (--dross*), set on the element by the caller for per-user accent.
|
||||||
|
export function drossAvatar(variant = 'soft-eye', size = 60) {
|
||||||
|
let inner;
|
||||||
|
if (variant === 'wisp') {
|
||||||
|
inner = [el('div', { class: 'b-core' }), el('div', { class: 'b-bright' })];
|
||||||
|
} else if (variant === 'motes') {
|
||||||
|
inner = [
|
||||||
|
el('div', { class: 'd-ring' }, el('div', { class: 'd-mote' })),
|
||||||
|
el('div', { class: 'd-ring r2' }, el('div', { class: 'd-mote' })),
|
||||||
|
el('div', { class: 'd-core' })
|
||||||
|
];
|
||||||
|
} else { // soft-eye (default)
|
||||||
|
inner = [el('div', { class: 'av-eye' }, el('div', { class: 'av-pupil' }))];
|
||||||
|
}
|
||||||
|
return el('div', { class: 'dross-orb', style: { width: size + 'px', height: size + 'px' } }, ...inner);
|
||||||
|
}
|
||||||
87
public/components/dross_bubble.js
Normal file
87
public/components/dross_bubble.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// public/components/dross_bubble.js
|
||||||
|
// Global floating Dross companion. Replaces the per-Space right rail.
|
||||||
|
import { el, mount } from '../dom.js';
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import { wireAgentChat } from './agent_chat.js';
|
||||||
|
import { drossAvatar } from './dross_avatar.js';
|
||||||
|
|
||||||
|
const TOOL_LABELS = { search: '🔍 searching', read: '📄 reading', context: '🧭 looking at this view', propose_change: '📝 drafting a change' };
|
||||||
|
let cfg = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||||
|
|
||||||
|
function applyAccent(node, hex) {
|
||||||
|
node.style.setProperty('--dross', hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderDrossBubble() {
|
||||||
|
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { /* defaults */ }
|
||||||
|
|
||||||
|
const fab = el('div', { class: 'dross-fab', title: 'Dross' },
|
||||||
|
el('div', { class: 'dross-ping', style: { display: 'none' } }, ''), drossAvatar(cfg.avatar, 60));
|
||||||
|
const log = el('div', { class: 'dross-log' });
|
||||||
|
const input = el('textarea', { rows: 1, placeholder: 'Ask Dross…' });
|
||||||
|
const sendBtn = el('button', { class: 'dross-send', title: 'Send' },
|
||||||
|
el('span', { html: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>' }));
|
||||||
|
const mic = el('button', { class: 'dross-mic', disabled: true, title: 'Voice arrives in Phase 2' },
|
||||||
|
el('span', { html: '<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg>' }), 'Hold to talk');
|
||||||
|
const closeBtn = el('button', { class: 'dross-x', title: 'Close' }, '⤬');
|
||||||
|
const header = el('div', { class: 'dross-hd' }, drossAvatar(cfg.avatar, 30),
|
||||||
|
el('div', { class: 'dross-who' }, 'Dross', el('small', {}, 'always here, regrettably')), closeBtn);
|
||||||
|
const collapse = el('div', { class: 'dross-collapse', title: 'Collapse' },
|
||||||
|
el('span', { class: 'grip' }), el('span', {}, '⌄ collapse'), el('span', { class: 'grip' }));
|
||||||
|
const panel = el('div', { class: 'dross-panel' }, header, log,
|
||||||
|
el('div', { class: 'dross-inwrap' }, input, el('div', { class: 'dross-btnrow' }, mic, sendBtn)), collapse);
|
||||||
|
|
||||||
|
document.getElementById('shell').append(fab, panel);
|
||||||
|
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||||||
|
|
||||||
|
const chat = wireAgentChat({
|
||||||
|
logEl: log, inputEl: input, sendBtnEl: sendBtn,
|
||||||
|
historyUrl: '/api/dross', turnUrl: '/api/dross/turn',
|
||||||
|
agentName: 'Dross', showDrafts: true, toolLabels: TOOL_LABELS,
|
||||||
|
turnBody: (text) => ({ text, view: state.view || null })
|
||||||
|
});
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
const r = fab.getBoundingClientRect();
|
||||||
|
panel.classList.add('open'); fab.style.display = 'none';
|
||||||
|
const pr = panel.getBoundingClientRect();
|
||||||
|
const left = Math.max(8, Math.min(r.right - pr.width, innerWidth - pr.width - 8));
|
||||||
|
const top = Math.max(8, Math.min(r.bottom - pr.height, innerHeight - pr.height - 8));
|
||||||
|
panel.style.right = 'auto'; panel.style.bottom = 'auto'; panel.style.left = left + 'px'; panel.style.top = top + 'px';
|
||||||
|
if (!loaded) { loaded = true; chat.load(); }
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
function closePanel() { panel.classList.remove('open'); fab.style.display = 'block'; }
|
||||||
|
fab.addEventListener('click', () => { if (fab._moved) { fab._moved = false; return; } openPanel(); });
|
||||||
|
closeBtn.addEventListener('click', closePanel);
|
||||||
|
collapse.addEventListener('click', closePanel);
|
||||||
|
// Topbar ◆ button (and any caller) can summon/dismiss Dross.
|
||||||
|
window.addEventListener('dross-toggle', () => panel.classList.contains('open') ? closePanel() : openPanel());
|
||||||
|
|
||||||
|
drag(fab, fab, true); drag(header, panel, false);
|
||||||
|
|
||||||
|
window.addEventListener('dross-settings-changed', async () => {
|
||||||
|
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { return; }
|
||||||
|
applyAccent(fab, cfg.accent); applyAccent(panel, cfg.accent);
|
||||||
|
mount(fab, el('div', { class: 'dross-ping', style: { display: 'none' } }), drossAvatar(cfg.avatar, 60));
|
||||||
|
header.replaceChild(drossAvatar(cfg.avatar, 30), header.firstChild);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drag(handle, target, isFab) {
|
||||||
|
handle.addEventListener('pointerdown', (e) => {
|
||||||
|
if (e.target.closest('.dross-x') || e.target.closest('.dross-mic') || e.target.closest('.dross-send')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const r = target.getBoundingClientRect(); const sx = e.clientX, sy = e.clientY; let moved = false;
|
||||||
|
target.style.right = 'auto'; target.style.bottom = 'auto'; target.style.left = r.left + 'px'; target.style.top = r.top + 'px';
|
||||||
|
const mv = (ev) => {
|
||||||
|
const dx = ev.clientX - sx, dy = ev.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 4) moved = true;
|
||||||
|
target.style.left = Math.max(4, Math.min(innerWidth - r.width - 4, r.left + dx)) + 'px';
|
||||||
|
target.style.top = Math.max(4, Math.min(innerHeight - r.height - 4, r.top + dy)) + 'px';
|
||||||
|
};
|
||||||
|
const up = () => { document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); if (isFab) target._moved = moved; };
|
||||||
|
document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import { el, mount, clear } from '../dom.js';
|
import { el, mount, clear } from '../dom.js';
|
||||||
import { navigate } from '../router.js';
|
import { navigate } from '../router.js';
|
||||||
import { on } from '../state.js';
|
import { on } from '../state.js';
|
||||||
import { toggleSidebar, toggleRail } from './chrome.js';
|
import { toggleSidebar } from './chrome.js';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
|
||||||
// Cluster health → topbar pill. Returns [status, label, title].
|
// Cluster health → topbar pill. Returns [status, label, title].
|
||||||
@@ -72,7 +72,7 @@ export function renderTopbar(root) {
|
|||||||
el('div', { class: 'topbar-spacer' }),
|
el('div', { class: 'topbar-spacer' }),
|
||||||
clusterPill,
|
clusterPill,
|
||||||
bell,
|
bell,
|
||||||
el('button', { class: 'chrome-toggle', title: 'Toggle companion chat', onclick: toggleRail }, '◆'),
|
el('button', { class: 'chrome-toggle', title: 'Summon Dross', onclick: () => window.dispatchEvent(new CustomEvent('dross-toggle')) }, '◆'),
|
||||||
el('button', { class: 'icon-btn', onclick: () => alert('Agent-switching ships post-Plan-2.') }, 'Owner')
|
el('button', { class: 'icon-btn', onclick: () => alert('Agent-switching ships post-Plan-2.') }, 'Owner')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
<header id="topbar"></header>
|
<header id="topbar"></header>
|
||||||
<aside id="sidebar"></aside>
|
<aside id="sidebar"></aside>
|
||||||
<main id="main"></main>
|
<main id="main"></main>
|
||||||
<aside id="rightrail"></aside>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="modal-root"></div>
|
<div id="modal-root"></div>
|
||||||
<script type="module" src="/app.js"></script>
|
<script type="module" src="/app.js"></script>
|
||||||
|
|||||||
@@ -29,20 +29,18 @@ html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color:
|
|||||||
|
|
||||||
#shell {
|
#shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--sidebar-w) 1fr var(--rail-w);
|
grid-template-columns: var(--sidebar-w) 1fr;
|
||||||
grid-template-rows: var(--topbar-h) 1fr;
|
grid-template-rows: var(--topbar-h) 1fr;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"topbar topbar topbar"
|
"topbar topbar"
|
||||||
"sidebar main rail";
|
"sidebar main";
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
}
|
}
|
||||||
#shell.rail-collapsed { grid-template-columns: var(--sidebar-w) 1fr var(--rail-w-min); }
|
|
||||||
|
|
||||||
#topbar { grid-area: topbar; border-bottom: 1px solid var(--border); background: var(--panel); display: flex; align-items: center; padding: 0 16px; gap: 12px; }
|
#topbar { grid-area: topbar; border-bottom: 1px solid var(--border); background: var(--panel); display: flex; align-items: center; padding: 0 16px; gap: 12px; }
|
||||||
#sidebar { grid-area: sidebar; border-right: 1px solid var(--border); background: var(--panel); overflow-y: auto; padding: 12px 0; }
|
#sidebar { grid-area: sidebar; border-right: 1px solid var(--border); background: var(--panel); overflow-y: auto; padding: 12px 0; }
|
||||||
#main { grid-area: main; overflow-y: auto; padding: 24px 32px; }
|
#main { grid-area: main; overflow-y: auto; padding: 24px 32px; }
|
||||||
#rightrail{ grid-area: rail; border-left: 1px solid var(--border); background: var(--panel); overflow: hidden; display: flex; flex-direction: column; }
|
|
||||||
|
|
||||||
/* topbar */
|
/* topbar */
|
||||||
.brand { font-family: var(--font-display); font-weight: 700; letter-spacing: 0.18em; font-size: 14px; color: var(--accent); text-transform: uppercase; padding: 0 6px; }
|
.brand { font-family: var(--font-display); font-weight: 700; letter-spacing: 0.18em; font-size: 14px; color: var(--accent); text-transform: uppercase; padding: 0 6px; }
|
||||||
@@ -483,15 +481,11 @@ ul.plain li:last-child { border-bottom: none; }
|
|||||||
/* ===== Collapsible chrome + responsive layout (Plan 6 polish) ===== */
|
/* ===== Collapsible chrome + responsive layout (Plan 6 polish) ===== */
|
||||||
:root { --sidebar-w-min: 0px; }
|
:root { --sidebar-w-min: 0px; }
|
||||||
#shell { transition: grid-template-columns .22s ease; }
|
#shell { transition: grid-template-columns .22s ease; }
|
||||||
#sidebar, #rightrail { transition: transform .22s ease; }
|
#sidebar { transition: transform .22s ease; }
|
||||||
|
|
||||||
/* Desktop collapse — shrink the grid columns */
|
/* Desktop collapse — shrink the sidebar column */
|
||||||
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w); }
|
#shell.sidebar-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr; }
|
||||||
#shell.sidebar-collapsed.rail-collapsed { grid-template-columns: var(--sidebar-w-min) 1fr var(--rail-w-min); }
|
|
||||||
#shell.rail-collapsed { grid-template-columns: var(--sidebar-w) 1fr var(--rail-w-min); }
|
|
||||||
#shell.sidebar-collapsed #sidebar { overflow: hidden; border-right: none; }
|
#shell.sidebar-collapsed #sidebar { overflow: hidden; border-right: none; }
|
||||||
/* Hide chat body when the rail is collapsed so the thin strip stays clean */
|
|
||||||
#shell.rail-collapsed .rail-chat { display: none; }
|
|
||||||
|
|
||||||
/* Topbar toggle buttons */
|
/* Topbar toggle buttons */
|
||||||
.chrome-toggle {
|
.chrome-toggle {
|
||||||
@@ -514,13 +508,11 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
|||||||
/* ---- Narrow / mobile / vertical: off-canvas drawers, single-column main ---- */
|
/* ---- Narrow / mobile / vertical: off-canvas drawers, single-column main ---- */
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
#shell,
|
#shell,
|
||||||
#shell.sidebar-collapsed,
|
#shell.sidebar-collapsed {
|
||||||
#shell.rail-collapsed,
|
|
||||||
#shell.sidebar-collapsed.rail-collapsed {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-areas: "topbar" "main";
|
grid-template-areas: "topbar" "main";
|
||||||
}
|
}
|
||||||
#sidebar, #rightrail {
|
#sidebar {
|
||||||
position: fixed; top: var(--topbar-h); bottom: 0; z-index: 50;
|
position: fixed; top: var(--topbar-h); bottom: 0; z-index: 50;
|
||||||
}
|
}
|
||||||
#sidebar { left: 0; width: min(82vw, 300px); transform: translateX(-100%); border-right: 1px solid var(--border); }
|
#sidebar { left: 0; width: min(82vw, 300px); transform: translateX(-100%); border-right: 1px solid var(--border); }
|
||||||
@@ -729,3 +721,56 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
|||||||
.ip-icon img { width: 28px; height: 28px; object-fit: contain; }
|
.ip-icon img { width: 28px; height: 28px; object-fit: contain; }
|
||||||
.ip-set-hd, .isp-hd { font-size: 12px; margin: 6px 0 3px; text-transform: capitalize; }
|
.ip-set-hd, .isp-hd { font-size: 12px; margin: 6px 0 3px; text-transform: capitalize; }
|
||||||
.isp-upload { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
.isp-upload { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ---- Dross floating chat ---- */
|
||||||
|
:root{ --dross:#a86adf; --dross-dim:#5a2e8a; --dross-soft:#1e1030; --dross-glow:#c79bff; }
|
||||||
|
.dross-orb{position:relative;border-radius:50%;display:grid;place-items:center;overflow:hidden;flex:none;
|
||||||
|
background:radial-gradient(circle at 38% 30%, #2a1640, #1a0f2a 70%, #120a1e);
|
||||||
|
box-shadow:0 0 0 1px #ffffff12, 0 6px 22px -6px #000, 0 0 26px -4px var(--dross-dim)}
|
||||||
|
.dross-fab{position:fixed;right:20px;bottom:20px;z-index:40;cursor:grab;touch-action:none;animation:dross-bob 5s ease-in-out infinite}
|
||||||
|
.dross-fab:active{cursor:grabbing}
|
||||||
|
@keyframes dross-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
|
||||||
|
.dross-fab .dross-orb{width:60px;height:60px}
|
||||||
|
.dross-ping{position:absolute;right:-2px;top:-2px;width:17px;height:17px;border-radius:50%;background:var(--accent);
|
||||||
|
color:#0a0a0e;font-size:10px;display:grid;place-items:center;box-shadow:0 0 0 2px var(--bg);z-index:2;font-family:var(--font-ui)}
|
||||||
|
.av-eye{width:54%;height:54%;border-radius:50%;background:radial-gradient(circle at 50% 40%, #2a1c3a, #140b20);display:grid;place-items:center;box-shadow:inset 0 0 10px #000}
|
||||||
|
.av-pupil{width:44%;height:44%;border-radius:50%;position:relative;background:radial-gradient(circle at 38% 32%, #fff, var(--dross-glow) 50%, var(--dross) 100%);box-shadow:0 0 10px var(--dross-glow);animation:dross-look 7s ease-in-out infinite}
|
||||||
|
.av-pupil::after{content:"";position:absolute;right:14%;bottom:18%;width:26%;height:26%;border-radius:50%;background:#fff;opacity:.8}
|
||||||
|
@keyframes dross-look{0%,45%{transform:translate(0,0)}58%{transform:translate(3px,-2px)}72%{transform:translate(-2px,1px)}88%,100%{transform:translate(0,0)}}
|
||||||
|
.b-core{position:absolute;inset:13%;border-radius:50%;filter:blur(3px);animation:dross-spin 7s linear infinite;
|
||||||
|
background:conic-gradient(from 0deg, var(--dross-dim), var(--dross-glow), var(--dross), var(--dross-soft), var(--dross-dim))}
|
||||||
|
.b-bright{position:absolute;inset:32%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,transparent 75%);animation:dross-pulse 3s ease-in-out infinite}
|
||||||
|
@keyframes dross-spin{to{transform:rotate(360deg)}}
|
||||||
|
@keyframes dross-pulse{0%,100%{opacity:.55;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
||||||
|
.d-core{width:22%;height:22%;border-radius:50%;background:radial-gradient(circle,#fff,var(--dross-glow) 60%,var(--dross));box-shadow:0 0 12px var(--dross-glow)}
|
||||||
|
.d-ring{position:absolute;inset:0;animation:dross-spin 5s linear infinite}
|
||||||
|
.d-ring.r2{animation-duration:8s;animation-direction:reverse}
|
||||||
|
.d-mote{position:absolute;top:11%;left:50%;width:11%;height:11%;margin-left:-5.5%;border-radius:50%;background:var(--dross-glow);box-shadow:0 0 8px var(--dross-glow)}
|
||||||
|
.d-ring.r2 .d-mote{top:auto;bottom:14%;background:var(--dross);width:8%;height:8%}
|
||||||
|
.dross-panel{position:fixed;right:20px;bottom:20px;width:340px;max-width:calc(100vw - 24px);height:480px;max-height:calc(100vh - 24px);
|
||||||
|
display:none;flex-direction:column;z-index:41;border:1px solid var(--dross-dim);border-radius:16px;overflow:hidden;
|
||||||
|
background:linear-gradient(180deg, rgba(30,16,48,.6), rgba(20,20,28,.96) 22%);
|
||||||
|
box-shadow:0 24px 70px -18px #000, 0 0 0 1px #00000060, 0 0 40px -16px var(--dross-dim);backdrop-filter:blur(6px)}
|
||||||
|
.dross-panel.open{display:flex}
|
||||||
|
.dross-hd{display:flex;align-items:center;gap:10px;padding:11px 12px;cursor:grab;touch-action:none;
|
||||||
|
background:linear-gradient(180deg, var(--dross-soft), transparent);border-bottom:1px solid var(--border)}
|
||||||
|
.dross-hd .dross-orb{width:30px;height:30px}
|
||||||
|
.dross-who{font-family:var(--font-display);letter-spacing:.16em;text-transform:uppercase;font-size:13px;color:#efe9f6;flex:1}
|
||||||
|
.dross-who small{display:block;font-family:var(--font-mono);letter-spacing:0;text-transform:none;font-size:10px;color:var(--dross-glow);opacity:.85}
|
||||||
|
.dross-x{background:none;border:0;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 6px}
|
||||||
|
.dross-x:hover{color:var(--text)}
|
||||||
|
.dross-log{flex:1;overflow:auto;padding:12px 12px;display:flex;flex-direction:column;gap:10px}
|
||||||
|
.dross-inwrap{padding:10px;border-top:1px solid var(--border);background:#0d0a12;display:flex;flex-direction:column;gap:9px}
|
||||||
|
.dross-inwrap textarea{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:10px;padding:10px 12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:none;height:46px;max-height:96px}
|
||||||
|
.dross-btnrow{display:flex;gap:10px}
|
||||||
|
.dross-mic{flex:1;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:var(--dross-soft);color:var(--dross-glow);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;font-family:var(--font-ui);font-size:13px}
|
||||||
|
.dross-mic[disabled]{opacity:.5;cursor:not-allowed}
|
||||||
|
.dross-send{width:64px;height:50px;border-radius:12px;border:1px solid var(--dross-dim);background:linear-gradient(180deg,var(--dross),var(--dross-dim));color:#fff;cursor:pointer;display:grid;place-items:center}
|
||||||
|
.dross-collapse{display:flex;align-items:center;justify-content:center;gap:8px;height:34px;cursor:pointer;color:var(--muted);
|
||||||
|
font-family:var(--font-ui);font-size:11px;letter-spacing:.12em;text-transform:uppercase;background:#0b0810;border-top:1px solid var(--border)}
|
||||||
|
.dross-collapse:hover{color:var(--dross-glow)}
|
||||||
|
.dross-collapse .grip{width:42px;height:4px;border-radius:3px;background:var(--border)}
|
||||||
|
.dross-pick{display:flex;gap:12px;margin-bottom:12px;flex-wrap:wrap}
|
||||||
|
.dross-avopt{display:flex;flex-direction:column;align-items:center;gap:6px;padding:10px 14px;border:1px solid var(--border);border-radius:12px;background:var(--panel);color:var(--muted);cursor:pointer;font-size:11px}
|
||||||
|
.dross-avopt.on{border-color:var(--dross);color:var(--dross-glow);background:var(--dross-soft)}
|
||||||
|
.dross-persona{width:100%;background:#0d0d13;border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);font-family:var(--font-mono);font-size:12px;resize:vertical;margin:10px 0}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { el, mount } from '../dom.js';
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { iconSetsPanel } from './icon_sets_panel.js';
|
import { iconSetsPanel } from './icon_sets_panel.js';
|
||||||
import { THEME_VARS, PRESETS, applyTheme, clearTheme, saveTheme, currentTheme, effectiveHex, toHex6 } from '../theme.js';
|
import { THEME_VARS, PRESETS, applyTheme, clearTheme, saveTheme, currentTheme, effectiveHex, toHex6 } from '../theme.js';
|
||||||
|
import { drossAvatar } from '../components/dross_avatar.js';
|
||||||
|
|
||||||
// Theming — colour pickers for the palette, live-preview on input, presets +
|
// Theming — colour pickers for the palette, live-preview on input, presets +
|
||||||
// reset. Persists to /api/theme (app_settings); applied app-wide on next boot.
|
// reset. Persists to /api/theme (app_settings); applied app-wide on next boot.
|
||||||
@@ -148,6 +149,44 @@ async function renderAgents(c) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drossBody() {
|
||||||
|
const out = el('span', { class: 'muted', style: { marginLeft: '8px' } });
|
||||||
|
let cur = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
|
||||||
|
const avatarRow = el('div', { class: 'dross-pick' });
|
||||||
|
const accent = el('input', { type: 'color', value: cur.accent });
|
||||||
|
const persona = el('textarea', { class: 'dross-persona', rows: 6, placeholder: "Dross's system prompt…" });
|
||||||
|
const mode = el('select', { class: 'pm-input', style: { maxWidth: '200px' } },
|
||||||
|
el('option', { value: 'review' }, 'Voice: review then send'),
|
||||||
|
el('option', { value: 'handsfree' }, 'Voice: hands-free (Phase 2)'),
|
||||||
|
el('option', { value: 'action' }, 'Voice: interpret to action (later)'));
|
||||||
|
|
||||||
|
function paintAvatars() {
|
||||||
|
mount(avatarRow, ['soft-eye', 'wisp', 'motes'].map(v => {
|
||||||
|
const card = el('button', { class: 'dross-avopt' + (cur.avatar === v ? ' on' : ''), title: v },
|
||||||
|
drossAvatar(v, 48), el('span', {}, v));
|
||||||
|
card.style.setProperty('--dross', cur.accent);
|
||||||
|
card.onclick = () => { cur.avatar = v; paintAvatars(); };
|
||||||
|
return card;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
try { cur = { ...cur, ...(await api.get('/api/dross/settings')) }; } catch {}
|
||||||
|
accent.value = cur.accent; persona.value = cur.persona; mode.value = cur.voiceMode; paintAvatars();
|
||||||
|
})();
|
||||||
|
accent.addEventListener('input', () => { cur.accent = accent.value; paintAvatars(); });
|
||||||
|
|
||||||
|
const save = el('button', { class: 'primary' }, 'Save');
|
||||||
|
save.onclick = async () => {
|
||||||
|
try {
|
||||||
|
await api.put('/api/dross/settings', { avatar: cur.avatar, accent: accent.value, persona: persona.value, voiceMode: mode.value });
|
||||||
|
window.dispatchEvent(new CustomEvent('dross-settings-changed'));
|
||||||
|
out.textContent = 'Saved.';
|
||||||
|
} catch { out.textContent = 'Save failed'; }
|
||||||
|
};
|
||||||
|
return el('div', { class: 'settings-body' }, avatarRow, el('label', { class: 'st-lbl' }, 'Accent', accent),
|
||||||
|
persona, el('div', { class: 'theme-actions' }, mode, save, out));
|
||||||
|
}
|
||||||
|
|
||||||
export async function render(main) {
|
export async function render(main) {
|
||||||
const tokensBody = el('div', { class: 'settings-body' });
|
const tokensBody = el('div', { class: 'settings-body' });
|
||||||
const agentsBody = el('div', { class: 'settings-body' });
|
const agentsBody = el('div', { class: 'settings-body' });
|
||||||
@@ -172,6 +211,7 @@ export async function render(main) {
|
|||||||
mount(main,
|
mount(main,
|
||||||
el('h1', { class: 'view-h1' }, '◆ Settings'),
|
el('h1', { class: 'view-h1' }, '◆ Settings'),
|
||||||
section('Theming', 'Recolour the interface. Pick a colour to preview it live, choose a preset, then Save to persist. Reset returns to the default Blackflame palette.', themingBody()),
|
section('Theming', 'Recolour the interface. Pick a colour to preview it live, choose a preset, then Save to persist. Reset returns to the default Blackflame palette.', themingBody()),
|
||||||
|
section('Dross', "Your companion's look and voice. Avatar, accent colour, his personality (system prompt), and how voice clips behave.", drossBody()),
|
||||||
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
|
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
|
||||||
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
|
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
|
||||||
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),
|
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe('dashboard layout api', () => {
|
|||||||
it('GET returns defaults', async () => {
|
it('GET returns defaults', async () => {
|
||||||
const res = await request(app).get('/api/dashboard/layout').set(ownerHeaders);
|
const res = await request(app).get('/api/dashboard/layout').set(ownerHeaders);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toEqual({ card_order: [], hidden: [], sizes: {} });
|
expect(res.body).toEqual({ card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PUT persists and GET reflects it', async () => {
|
it('PUT persists and GET reflects it', async () => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ beforeAll(async () => { await resetDb(); await migrateUp(); });
|
|||||||
describe('dashboard_layout repo', () => {
|
describe('dashboard_layout repo', () => {
|
||||||
it('returns defaults when unset', async () => {
|
it('returns defaults when unset', async () => {
|
||||||
const l = await repo.get();
|
const l = await repo.get();
|
||||||
expect(l).toEqual({ card_order: [], hidden: [], sizes: {} });
|
expect(l).toEqual({ card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('upserts and reads back', async () => {
|
it('upserts and reads back', async () => {
|
||||||
|
|||||||
55
tests/routes/dross.test.js
Normal file
55
tests/routes/dross.test.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createApp } from '../../server.js';
|
||||||
|
import { resetDb } from '../helpers/db.js';
|
||||||
|
import { migrateUp } from '../../lib/db/migrate.js';
|
||||||
|
|
||||||
|
let app;
|
||||||
|
const owner = { Authorization: 'Bearer test-token' };
|
||||||
|
beforeAll(async () => {
|
||||||
|
await resetDb(); await migrateUp();
|
||||||
|
process.env.OWNER_TOKEN = 'test-token';
|
||||||
|
app = createApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dross chat', () => {
|
||||||
|
it('GET /api/dross returns a global conversation + Dross agent', async () => {
|
||||||
|
const res = await request(app).get('/api/dross').set(owner);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.conversation_id).toBeTruthy();
|
||||||
|
expect(res.body.agent.slug).toBe('companion');
|
||||||
|
expect(Array.isArray(res.body.messages)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/dross/turn rejects empty text (400)', async () => {
|
||||||
|
const res = await request(app).post('/api/dross/turn').set(owner).send({ text: '' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/dross without token is 401', async () => {
|
||||||
|
const res = await request(app).get('/api/dross');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dross settings', () => {
|
||||||
|
it('GET /api/dross/settings returns defaults', async () => {
|
||||||
|
const res = await request(app).get('/api/dross/settings').set(owner);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/dross/settings persists and round-trips', async () => {
|
||||||
|
const body = { avatar: 'wisp', accent: '#aa66ff', persona: 'Be terse.', voiceMode: 'handsfree' };
|
||||||
|
const put = await request(app).put('/api/dross/settings').set(owner).send(body);
|
||||||
|
expect(put.status).toBe(200);
|
||||||
|
const get = await request(app).get('/api/dross/settings').set(owner);
|
||||||
|
expect(get.body).toMatchObject(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT rejects a bad avatar (400)', async () => {
|
||||||
|
const res = await request(app).put('/api/dross/settings').set(owner)
|
||||||
|
.send({ avatar: 'nope', accent: '#aa66ff', persona: '', voiceMode: 'review' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
tests/views/dross_avatar.test.js
Normal file
21
tests/views/dross_avatar.test.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { drossAvatar } from '../../public/components/dross_avatar.js';
|
||||||
|
|
||||||
|
describe('drossAvatar', () => {
|
||||||
|
it('renders the requested variant class', () => {
|
||||||
|
const eye = drossAvatar('soft-eye', 60);
|
||||||
|
expect(eye.classList.contains('dross-orb')).toBe(true);
|
||||||
|
expect(eye.querySelector('.av-eye')).toBeTruthy();
|
||||||
|
expect(drossAvatar('wisp', 30).querySelector('.b-core')).toBeTruthy();
|
||||||
|
expect(drossAvatar('motes', 30).querySelector('.d-core')).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('falls back to soft-eye for unknown variants', () => {
|
||||||
|
expect(drossAvatar('bogus', 60).querySelector('.av-eye')).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('sets the pixel size', () => {
|
||||||
|
const a = drossAvatar('wisp', 42);
|
||||||
|
expect(a.style.width).toBe('42px');
|
||||||
|
expect(a.style.height).toBe('42px');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user