docs(dross): Phase 1 implementation plan (bubble + global Dross + settings)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
654
docs/superpowers/plans/2026-06-09-floating-dross-chat-phase1.md
Normal file
654
docs/superpowers/plans/2026-06-09-floating-dross-chat-phase1.md
Normal file
@@ -0,0 +1,654 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user