Files
Void-Homelab/docs/superpowers/plans/2026-06-09-floating-dross-chat-phase1.md
2026-06-09 23:46:14 +10:00

655 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Floating Dross Chat — Phase 1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the per-Space right-rail companion with a global, draggable floating "Dross" bubble (orb → chat panel) plus a Settings panel for his avatar, colour, persona, and voice-mode. No voice yet (Phase 2).
**Architecture:** A new backend router `/api/dross` resolves a single space-less ("global") conversation for the existing `companion` agent (Dross) via the already-existing `conversations.findOrCreateGlobal`, streams turns over SSE exactly like `companion.js`, and stores per-user preferences in the generic `app_settings` store (key `dross`). The frontend gets a self-contained `dross_bubble.js` component that reuses the existing `wireAgentChat` engine and mounts globally (replacing `renderRightrail`), with avatars rendered by `dross_avatar.js`.
**Tech Stack:** Node/Express, Postgres (`app_settings`, `conversations`, `messages`), vanilla-JS frontend, vitest + supertest for backend tests, headless Playwright (already on CT 300) for UI verification.
**Spec:** `docs/superpowers/specs/2026-06-09-floating-dross-chat-design.md`
---
### Task 1: Dross settings endpoint (`/api/dross/settings`)
Stores `{avatar, accent, persona, voiceMode}` in `app_settings` key `dross`. Reuses `lib/db/repos/app_settings.js` (get/set already exist).
**Files:**
- Create: `lib/api/routes/dross.js`
- Modify: `lib/api/index.js` (import + mount)
- Test: `tests/routes/dross.test.js`
- [ ] **Step 1: Write the failing test**
```js
// tests/routes/dross.test.js
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 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);
});
});
```
- [ ] **Step 2: Run it — expect FAIL** (`route not found` / 404, then 401 once mounted)
Run: `npx vitest run tests/routes/dross.test.js`
Expected: FAIL (router doesn't exist yet).
- [ ] **Step 3: Create the router with the settings half**
```js
// lib/api/routes/dross.js
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 conversations from '../../db/repos/conversations.js';
import * as messages from '../../db/repos/messages.js';
import * as agents from '../../db/repos/agents.js';
import * as settings from '../../db/repos/app_settings.js';
import { runAgentTurn } from '../../ai/agent/run_turn.js';
import { personaFor } from '../../ai/personas/index.js';
const COMPANION_SLUG = 'companion';
const DEFAULT_SETTINGS = { avatar: 'soft-eye', accent: '#a86adf', persona: '', voiceMode: 'review' };
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))));
```
- [ ] **Step 4: Mount it**
In `lib/api/index.js`, add the import after the kutt/theme imports:
```js
import { router as drossRouter } from './routes/dross.js';
```
and the mount alongside the others (e.g. after `api.use('/theme', themeRouter);`):
```js
api.use('/dross', drossRouter);
```
- [ ] **Step 5: Run tests — expect the 3 settings tests PASS**
Run: `npx vitest run tests/routes/dross.test.js`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add lib/api/routes/dross.js lib/api/index.js tests/routes/dross.test.js
git commit -m "feat(dross): settings endpoint (avatar/accent/persona/voiceMode)"
```
---
### Task 2: Global Dross chat route (`GET /api/dross`, `POST /api/dross/turn`)
A space-less conversation for the `companion` agent, streamed over SSE. Mirrors `lib/api/routes/companion.js` but uses `findOrCreateGlobal`, `spaceId: null`, and the persona from settings (falling back to `personaFor('companion')`).
**Files:**
- Modify: `lib/api/routes/dross.js`
- Test: `tests/routes/dross.test.js` (add cases)
- [ ] **Step 1: Add failing tests for history + validation**
Append inside the `describe('dross settings'…` file a new block:
```js
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);
});
});
```
> Note: a full turn shells out to the `claude` CLI, so we don't unit-test the SSE happy-path here (it's covered by the live smoke test in Task 7). We test resolution, validation, and auth.
- [ ] **Step 2: Run — expect FAIL** (GET `/api/dross` is 404 until added)
Run: `npx vitest run tests/routes/dross.test.js`
Expected: FAIL on the chat block.
- [ ] **Step 3: Add the history + turn handlers to `dross.js`**
Append to `lib/api/routes/dross.js`:
```js
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') {
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' });
}
} 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();
}));
```
- [ ] **Step 4: Run tests — expect PASS**
Run: `npx vitest run tests/routes/dross.test.js`
Expected: PASS (all settings + chat cases).
- [ ] **Step 5: Commit**
```bash
git add lib/api/routes/dross.js tests/routes/dross.test.js
git commit -m "feat(dross): global (space-less) Dross conversation + SSE turn"
```
---
### Task 3: Dross avatar component
Pure render of the three violet avatars at any size. Reused by the orb, the panel header, and the Settings preview.
**Files:**
- Create: `public/components/dross_avatar.js`
- Test: `tests/views/dross_avatar.test.js`
- [ ] **Step 1: Write the failing test (jsdom)**
```js
// tests/views/dross_avatar.test.js
// @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');
});
});
```
- [ ] **Step 2: Run — expect FAIL** (module missing)
Run: `npx vitest run tests/views/dross_avatar.test.js`
Expected: FAIL.
- [ ] **Step 3: Implement `dross_avatar.js`** (markup ported from `docs/mockups/dross-chat.html`)
```js
// 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);
}
```
- [ ] **Step 4: Run — expect PASS**
Run: `npx vitest run tests/views/dross_avatar.test.js`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add public/components/dross_avatar.js tests/views/dross_avatar.test.js
git commit -m "feat(dross): avatar component (soft-eye / wisp / motes)"
```
---
### Task 4: Bubble CSS
Port the orb/panel/avatar/mic/collapse styles from the mockup into `style.css` under `dross-*` class names, driven by `--dross*` vars.
**Files:**
- Modify: `public/style.css` (append a `/* ---- Dross floating chat ---- */` block)
- [ ] **Step 1: Append the CSS block**
Append to `public/style.css` (values lifted verbatim from `docs/mockups/dross-chat.html`; replace the mock's `.orb`/`.panel`/etc. selectors with `.dross-orb`/`.dross-panel`/etc.):
```css
/* ---- 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)}
/* soft eye */
.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)}}
/* wisp */
.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)}}
/* motes */
.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%}
/* panel */
.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)}
/* reuse existing .turn/.msg/.tools/.chip chat styles from the rail */
```
- [ ] **Step 2: Commit**
```bash
git add public/style.css
git commit -m "feat(dross): floating bubble + avatar styles"
```
---
### Task 5: Bubble component (`dross_bubble.js`) + mount globally
Self-contained component: a fixed FAB orb that opens a draggable, anchored panel with the chat (via `wireAgentChat`), a top-right ✕ and a bottom "⌄ collapse", both minimising to the orb. Reads `dross` settings for avatar/accent and applies `--dross*` accent. (Mic button is rendered but **disabled** with title "Voice arrives in Phase 2".)
**Files:**
- Create: `public/components/dross_bubble.js`
- Modify: `public/app.js` (replace `renderRightrail` with `renderDrossBubble`)
- Modify: `public/index.html` (remove the now-unused `<aside id="rightrail">`)
- [ ] **Step 1: Implement `dross_bubble.js`**
```js
// 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) {
// derive dim/soft/glow from the chosen accent so the whole orb stays coherent
node.style.setProperty('--dross', hex);
}
export async function renderDrossBubble(rootIgnored) {
try { cfg = { ...cfg, ...(await api.get('/api/dross/settings')) }; } catch { /* defaults */ }
const host = el('div', { class: 'dross-host' });
document.getElementById('shell').appendChild(host);
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);
host.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);
drag(fab, fab, true); drag(header, panel, false);
// re-apply settings live when the Settings panel saves
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);
});
}
```
- [ ] **Step 2: Swap the mount in `public/app.js`**
Replace the import:
```js
import { renderRightrail } from './components/rightrail.js';
```
with:
```js
import { renderDrossBubble } from './components/dross_bubble.js';
```
and replace the call in `init()`:
```js
renderRightrail(document.getElementById('rightrail'));
```
with:
```js
renderDrossBubble();
```
- [ ] **Step 3: Remove the dead rail element** in `public/index.html` — delete the line:
```html
<aside id="rightrail"></aside>
```
- [ ] **Step 4: Headless-verify** (the bubble can't be unit-tested for drag; use Playwright per the headless-ui-check skill). Deploy to a scratch run or use the live deploy in Task 7; assert: `.dross-fab` exists; clicking it shows `.dross-panel.open`; the bottom `.dross-collapse` closes it; no console errors.
- [ ] **Step 5: Commit**
```bash
git add public/components/dross_bubble.js public/app.js public/index.html
git commit -m "feat(dross): global floating bubble; retire the right rail"
```
---
### Task 6: Settings → Dross section
Avatar picker (3 buttons using `drossAvatar` previews), accent colour input, persona textarea, voice-mode select. Saves to `/api/dross/settings` and dispatches `dross-settings-changed` so the live bubble updates.
**Files:**
- Modify: `public/views/settings.js`
- [ ] **Step 1: Add the Dross section body builder**
In `public/views/settings.js`, add the import:
```js
import { drossAvatar } from '../components/dross_avatar.js';
```
and a builder:
```js
function drossBody() {
const wrap = el('div', { class: 'settings-body' });
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));
}
```
and register it in `render()` (after the Theming section):
```js
section('Dross', "Your companion's look and voice. Avatar, accent colour, his personality (system prompt), and how voice clips behave.", drossBody()),
```
- [ ] **Step 2: Add minimal CSS** (append to `public/style.css`):
```css
.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}
```
- [ ] **Step 3: Headless-verify** in Task 7: the Settings page shows three avatar options, colour input, persona textarea, voice-mode select; saving updates the live bubble's orb without a reload.
- [ ] **Step 4: Commit**
```bash
git add public/views/settings.js public/style.css
git commit -m "feat(dross): Settings panel — avatar, accent, persona, voice-mode"
```
---
### Task 7: Deploy, verify end-to-end, document
**Files:**
- Modify: `package.json` (version bump), `CHANGELOG`/wiki as per repo convention
- [ ] **Step 1: Full test run**`npx vitest run` → expect all green (new dross + avatar tests included).
- [ ] **Step 2: Bump version**`npm version 2.11.0 --no-git-tag-version`.
- [ ] **Step 3: Deploy**`./deploy/push.sh` (health-gated; runs migrate — no new migration this phase, app_settings already exists).
- [ ] **Step 4: Live smoke (headless, token-injected, per headless-ui-check):**
- Load `#/sacred-valley`: `.dross-fab` present, no console errors.
- Click fab → `.dross-panel.open`; type a message + send → an assistant turn streams in (live `claude` turn — confirms global Dross works without a Space open).
- Bottom `.dross-collapse` closes it; drag the fab; reload → still there.
- `#/settings`: change avatar → Save → the live fab orb changes without reload.
- [ ] **Step 5: Document** (standing rule — wiki + git): update the spec's status to "Phase 1 shipped", add a Void wiki page "Floating Dross chat — Phase 1 (2.11.0)", update memory `project_cradle_chat_floating` and `project_void2_alpha27_and_git`. Tag `v2.11.0`, push to Gitea.
---
## Self-Review
**Spec coverage:** Global Dross (Task 2) ✓ · floating draggable orb+panel (Task 5) ✓ · close ✕ + bottom collapse (Task 5) ✓ · 3 avatars + default soft-eye (Tasks 3,6) ✓ · violet + tunable accent (Tasks 4,5,6) ✓ · tunable persona (Tasks 1,2,6) ✓ · voice-mode setting present, mic disabled (Tasks 1,5,6) ✓ · retire right rail (Task 5) ✓. Voice transcription itself is Phase 2 (out of scope here, per spec) — mic is intentionally disabled.
**Placeholder scan:** No TBD/TODO; every code step has complete code; tests have real assertions.
**Type/name consistency:** `drossAvatar(variant,size)` used identically in Tasks 3/5/6; settings keys `{avatar,accent,persona,voiceMode}` consistent across route (Task 1), bubble (Task 5), settings (Task 6); event name `dross-settings-changed` matches between Task 5 listener and Task 6 dispatcher; route paths `/api/dross`, `/api/dross/turn`, `/api/dross/settings` consistent.