# 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 `