docs: Add comprehensive implementation plans for all todo items

Created detailed markdown plans for all items in todo.md:

1. 01-playwright-scaffolding.md - Base Playwright infrastructure
2. 02-magnitude-tests-comprehensive.md - Complete test coverage
3. 03-stream-ai-to-deepgram-tts.md - TTS latency optimization
4. 04-fix-galaxy-node-clicking.md - Galaxy navigation bugs
5. 05-dark-light-mode-theme.md - Dark/light mode with dynamic favicons
6. 06-fix-double-border-desktop.md - UI polish
7. 07-delete-backup-files.md - Code cleanup
8. 08-ai-transition-to-edit.md - Intelligent node creation flow
9. 09-umap-minimum-nodes-analysis.md - Technical analysis

Each plan includes:
- Detailed problem analysis
- Proposed solutions with code examples
- Manual Playwright MCP testing strategy
- Magnitude test specifications
- Implementation steps
- Success criteria

Ready to implement in sequence.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-09 21:07:42 +00:00
parent 346326e31f
commit b96159ec02
9 changed files with 2994 additions and 0 deletions

View File

@@ -0,0 +1,404 @@
# Plan: Add Playwright Scaffolding Files
**Priority:** CRITICAL - Must be done first
**Dependencies:** None
**Affects:** All future testing work
## Overview
Create base Playwright scaffolding files to improve efficiency of both manual Playwright MCP testing and Magnitude test automation. This infrastructure will make it easier to write, run, and maintain browser-based tests.
## Current State
- No Playwright configuration file exists
- No test helpers or utilities for common operations
- No fixtures for authenticated states
- Manual testing with Playwright MCP is inefficient
- Magnitude tests will lack reusable components
## Proposed Solution
Create a complete Playwright testing infrastructure with:
### 1. Core Configuration Files
#### `playwright.config.ts`
```typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/playwright',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
// Setup authentication
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
// Desktop browsers
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'tests/playwright/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'tests/playwright/.auth/user.json',
},
dependencies: ['setup'],
},
// Mobile browsers
{
name: 'mobile-chrome',
use: {
...devices['Pixel 5'],
storageState: 'tests/playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
```
#### `.gitignore` updates
```
# Playwright
tests/playwright/.auth/
test-results/
playwright-report/
playwright/.cache/
```
### 2. Authentication Setup
#### `tests/playwright/auth.setup.ts`
```typescript
import { test as setup, expect } from '@playwright/test';
const authFile = 'tests/playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
// Check if auth already exists and is valid
const authCookie = process.env.PLAYWRIGHT_AUTH_COOKIE;
if (authCookie) {
// Use existing auth from environment
await page.context().addCookies([{
name: 'ponderants-auth',
value: authCookie,
domain: 'localhost',
path: '/',
}]);
} else {
// Perform OAuth login
await page.goto('/');
await page.click('text=Log in with Bluesky');
// Wait for OAuth redirect
await page.waitForURL(/bsky\.social/);
// Fill in credentials
await page.fill('input[name="identifier"]', process.env.TEST_USER_HANDLE!);
await page.fill('input[name="password"]', process.env.TEST_USER_PASSWORD!);
await page.click('button[type="submit"]');
// Wait for redirect back to app
await page.waitForURL(/localhost:3000/);
// Verify we're authenticated
await expect(page.getByRole('button', { name: /profile/i })).toBeVisible();
}
// Save signed-in state
await page.context().storageState({ path: authFile });
});
```
### 3. Test Fixtures and Helpers
#### `tests/playwright/fixtures.ts`
```typescript
import { test as base, Page } from '@playwright/test';
type Fixtures = {
authenticatedPage: Page;
chatPage: Page;
galaxyPage: Page;
};
export const test = base.extend<Fixtures>({
authenticatedPage: async ({ page }, use) => {
// Already authenticated via setup
await page.goto('/');
await use(page);
},
chatPage: async ({ page }, use) => {
await page.goto('/chat');
await use(page);
},
galaxyPage: async ({ page }, use) => {
await page.goto('/galaxy');
await use(page);
},
});
export { expect } from '@playwright/test';
```
#### `tests/playwright/helpers/chat.ts`
```typescript
import { Page, expect } from '@playwright/test';
export class ChatHelper {
constructor(private page: Page) {}
async sendMessage(message: string) {
await this.page.fill('textarea[placeholder*="Type"]', message);
await this.page.press('textarea[placeholder*="Type"]', 'Enter');
}
async waitForAIResponse() {
// Wait for typing indicator to disappear
await this.page.waitForSelector('[data-testid="typing-indicator"]', {
state: 'hidden',
timeout: 30000,
});
}
async createNode() {
await this.page.click('button:has-text("Create Node")');
}
async getLastMessage() {
const messages = this.page.locator('[data-testid="chat-message"]');
const count = await messages.count();
return messages.nth(count - 1);
}
}
```
#### `tests/playwright/helpers/galaxy.ts`
```typescript
import { Page, expect } from '@playwright/test';
export class GalaxyHelper {
constructor(private page: Page) {}
async waitForGalaxyLoad() {
// Wait for canvas to be visible
await this.page.waitForSelector('canvas', { timeout: 10000 });
// Wait for nodes to be loaded (check for at least one node)
await this.page.waitForFunction(() => {
const canvas = document.querySelector('canvas');
return canvas && canvas.getContext('2d') !== null;
});
}
async clickNode(nodeId: string) {
// Find and click on a specific node
await this.page.evaluate((id) => {
const event = new CustomEvent('node-click', { detail: { nodeId: id } });
window.dispatchEvent(event);
}, nodeId);
}
async getNodeCount() {
return await this.page.evaluate(() => {
// Access R3F scene data
return window.__galaxyNodes?.length || 0;
});
}
}
```
#### `tests/playwright/helpers/node.ts`
```typescript
import { Page, expect } from '@playwright/test';
export class NodeHelper {
constructor(private page: Page) {}
async createNode(title: string, body: string) {
// Navigate to chat
await this.page.goto('/chat');
// Trigger node creation
await this.page.fill('input[placeholder*="Title"]', title);
await this.page.fill('textarea[placeholder*="Body"]', body);
await this.page.click('button:has-text("Publish")');
// Wait for success
await expect(this.page.getByText(/published/i)).toBeVisible();
}
async waitForUMAPCalculation() {
// Poll /api/galaxy until nodes have coords_3d
await this.page.waitForFunction(async () => {
const response = await fetch('/api/galaxy');
const data = await response.json();
return data.nodes.every((n: any) => n.coords_3d !== null);
}, { timeout: 60000 });
}
}
```
### 4. Example Tests
#### `tests/playwright/smoke.spec.ts`
```typescript
import { test, expect } from './fixtures';
test.describe('Smoke Tests', () => {
test('homepage loads', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Ponderants/);
});
test('authenticated user can access chat', async ({ chatPage }) => {
await expect(chatPage.getByRole('textbox')).toBeVisible();
});
test('authenticated user can access galaxy', async ({ galaxyPage }) => {
await expect(galaxyPage.locator('canvas')).toBeVisible();
});
});
```
### 5. Environment Variables
#### `.env.test` (template)
```bash
# Test user credentials
TEST_USER_HANDLE=your-test-handle.bsky.social
TEST_USER_PASSWORD=your-test-password
# Optional: Pre-authenticated cookie for faster tests
PLAYWRIGHT_AUTH_COOKIE=
# Playwright settings
PLAYWRIGHT_BASE_URL=http://localhost:3000
```
## Implementation Steps
1. **Create directory structure**
```bash
mkdir -p tests/playwright/.auth
mkdir -p tests/playwright/helpers
```
2. **Install dependencies**
```bash
pnpm add -D @playwright/test
```
3. **Create configuration files**
- playwright.config.ts
- .gitignore updates
- .env.test template
4. **Create authentication setup**
- auth.setup.ts
5. **Create fixtures and helpers**
- fixtures.ts
- helpers/chat.ts
- helpers/galaxy.ts
- helpers/node.ts
6. **Create example tests**
- smoke.spec.ts
7. **Update package.json scripts**
```json
{
"scripts": {
"test:playwright": "playwright test",
"test:playwright:ui": "playwright test --ui",
"test:playwright:debug": "playwright test --debug"
}
}
```
## Testing Plan
### Manual Playwright MCP Testing
1. Open Playwright MCP browser
2. Navigate to http://localhost:3000
3. Test authentication flow
4. Test chat interaction
5. Test node creation
6. Test galaxy visualization
7. Test node detail modal
### Magnitude Test Integration
After manual testing succeeds, create magnitude tests that use the same patterns:
```typescript
import { test } from 'magnitude-test';
test('User can create and view nodes in galaxy', async (agent) => {
await agent.open('http://localhost:3000');
await agent.act('Log in with test credentials');
await agent.check('User is authenticated');
await agent.act('Navigate to chat');
await agent.act('Create a new node with title "Test Node" and body "Test content"');
await agent.check('Node is published to Bluesky');
await agent.act('Navigate to galaxy');
await agent.check('Can see the new node in 3D visualization');
});
```
## Success Criteria
- ✅ Playwright configuration file exists
- ✅ Authentication setup works (both OAuth and cookie-based)
- ✅ Helper classes for chat, galaxy, and node operations exist
- ✅ Example smoke tests pass
- ✅ Can run `pnpm test:playwright` successfully
- ✅ Manual Playwright MCP testing is efficient with helpers
- ✅ Foundation exists for Magnitude test creation
## Files to Create
1. `playwright.config.ts` - Main configuration
2. `tests/playwright/auth.setup.ts` - Authentication setup
3. `tests/playwright/fixtures.ts` - Custom fixtures
4. `tests/playwright/helpers/chat.ts` - Chat helper functions
5. `tests/playwright/helpers/galaxy.ts` - Galaxy helper functions
6. `tests/playwright/helpers/node.ts` - Node helper functions
7. `tests/playwright/smoke.spec.ts` - Example smoke tests
8. `.env.test` - Environment variables template
## Files to Update
1. `.gitignore` - Add Playwright artifacts
2. `package.json` - Add test scripts
3. `README.md` - Document testing approach (optional)

View File

@@ -0,0 +1,569 @@
# Plan: Add Comprehensive Magnitude Tests for All Features
**Priority:** CRITICAL - Must be done second (after Playwright scaffolding)
**Dependencies:** 01-playwright-scaffolding.md
**Affects:** Code quality, regression prevention, production confidence
## Overview
Create comprehensive Magnitude test coverage for ALL existing and new features. Every user flow must be tested fully, including both happy paths and unhappy paths.
## Current State
- No Magnitude tests exist
- No test coverage for critical flows (auth, chat, node creation, galaxy)
- No regression testing
- Production deployments lack confidence
## Test Coverage Required
### 1. Authentication Flow Tests
#### `tests/magnitude/auth.mag.ts`
**Happy Paths:**
- User can log in with valid Bluesky credentials
- User can log out successfully
- User session persists across page refreshes
- User can access protected routes after authentication
**Unhappy Paths:**
- Login fails with invalid credentials
- Login fails with non-existent handle
- Unauthenticated user redirected from protected routes
- Session expiry handled gracefully
```typescript
import { test } from 'magnitude-test';
test('User can log in with valid credentials', async (agent) => {
await agent.open('http://localhost:3000');
await agent.check('Login page is visible');
await agent.act('Click "Log in with Bluesky" button')
.data({
handle: process.env.TEST_USER_HANDLE,
password: process.env.TEST_USER_PASSWORD
});
await agent.check('User is redirected to Bluesky OAuth');
await agent.check('User is redirected back to Ponderants');
await agent.check('User sees authenticated interface');
await agent.check('Profile menu is visible');
});
test('Login fails with invalid credentials', async (agent) => {
await agent.open('http://localhost:3000');
await agent.act('Click "Log in with Bluesky" button')
.data({
handle: 'invalid-user.bsky.social',
password: 'wrongpassword'
});
await agent.check('Error message is displayed');
await agent.check('User remains on login page');
});
test('User session persists across refresh', async (agent) => {
// First login
await agent.open('http://localhost:3000');
await agent.act('Log in with valid credentials');
await agent.check('User is authenticated');
// Refresh page
await agent.act('Refresh the page');
await agent.check('User is still authenticated');
await agent.check('Profile menu is still visible');
});
test('User can log out', async (agent) => {
await agent.open('http://localhost:3000');
await agent.act('Log in with valid credentials');
await agent.check('User is authenticated');
await agent.act('Click profile menu');
await agent.act('Click "Log out" button');
await agent.check('User is redirected to login page');
await agent.check('Session is cleared');
});
```
### 2. Chat Interface Tests
#### `tests/magnitude/chat.mag.ts`
**Happy Paths:**
- User can send text message and receive AI response
- User can use voice input and receive AI response
- User can see typing indicator while AI is thinking
- User can create a node from conversation
- User can select AI persona
- Chat history persists
**Unhappy Paths:**
- Empty message submission is prevented
- Voice input fails gracefully without microphone permission
- AI error displays user-friendly message
- Network error handled with retry option
```typescript
import { test } from 'magnitude-test';
test('User can send message and receive AI response', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in if needed');
await agent.act('Type "What is the meaning of life?" into chat input');
await agent.act('Press Enter to send');
await agent.check('Message appears in chat history');
await agent.check('Typing indicator is visible');
await agent.check('AI response appears after typing indicator disappears');
await agent.check('Response contains thoughtful content');
});
test('User can use voice input', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in if needed');
await agent.act('Click microphone button');
await agent.check('Recording indicator is visible');
await agent.act('Speak "Hello, how are you?"');
await agent.act('Click microphone button to stop recording');
await agent.check('Transcribed text appears in chat');
await agent.check('AI response is generated');
});
test('User can create node from conversation', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in if needed');
await agent.act('Have a conversation about "quantum computing"');
await agent.check('At least 3 messages exchanged');
await agent.act('Click "Create Node" button');
await agent.check('Node editor opens with AI-generated draft');
await agent.check('Title is populated');
await agent.check('Body contains conversation insights');
});
test('Empty message submission is prevented', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in if needed');
await agent.act('Press Enter without typing anything');
await agent.check('No message is sent');
await agent.check('Send button remains disabled');
});
test('AI error displays user-friendly message', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in if needed');
// Simulate network error by blocking API calls
await agent.act('Send a message while API is blocked');
await agent.check('Error notification appears');
await agent.check('Error message is user-friendly');
await agent.check('Retry option is available');
});
```
### 3. Node Creation and Publishing Tests
#### `tests/magnitude/nodes.mag.ts`
**Happy Paths:**
- User can create a simple node (title + body)
- Node is published to Bluesky as a post
- Node is published to Bluesky as a thread (long content)
- Node appears in local cache (SurrealDB)
- Embedding is generated for node
- Node can link to other existing nodes
- AI suggests relevant node links
- Node appears in galaxy after creation
**Unhappy Paths:**
- Node creation fails without title
- Node creation fails without body
- Bluesky API error handled gracefully
- Embedding generation failure doesn't block node creation
- Link suggestion failure doesn't block node creation
```typescript
import { test } from 'magnitude-test';
test('User can create a simple node', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in if needed');
await agent.act('Click "New Node" button');
await agent.check('Node editor opens');
await agent.act('Type "My First Thought" into title field');
await agent.act('Type "This is my first ponderant about philosophy" into body field');
await agent.act('Click "Publish" button');
await agent.check('Success notification appears');
await agent.check('Node is published to Bluesky');
await agent.check('Node appears in cache');
});
test('Long node is published as thread', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in if needed');
const longBody = 'This is a very long thought that exceeds 300 graphemes. '.repeat(20);
await agent.act('Create new node');
await agent.act(`Type "Long Thought" into title`);
await agent.act(`Type long content into body`).data({ body: longBody });
await agent.act('Click "Publish" button');
await agent.check('Node is published as Bluesky thread');
await agent.check('Thread contains multiple posts');
await agent.check('First post contains link to detail page');
await agent.check('Thread indicators show (2/3), (3/3), etc.');
});
test('Node with emojis handles grapheme counting correctly', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in if needed');
const emojiContent = '🎉 '.repeat(100) + 'Hello world! 👋 ';
await agent.act('Create new node');
await agent.act('Type "Emoji Test" into title');
await agent.act('Type emoji content into body').data({ body: emojiContent });
await agent.act('Click "Publish" button');
await agent.check('Node is published without exceeding 300 grapheme limit');
await agent.check('Emojis are counted as 1 grapheme each');
});
test('Node creation fails without title', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in if needed');
await agent.act('Create new node');
await agent.act('Type "Some body content" into body field');
await agent.act('Click "Publish" button without entering title');
await agent.check('Validation error appears');
await agent.check('Error message says "Title is required"');
await agent.check('Node is not published');
});
test('Node links to existing nodes', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in if needed');
// Create first node
await agent.act('Create node titled "Philosophy Basics"');
await agent.check('First node is published');
// Create second node that links to first
await agent.act('Create new node titled "Advanced Philosophy"');
await agent.check('AI suggests linking to "Philosophy Basics"');
await agent.act('Accept suggested link');
await agent.act('Publish node');
await agent.check('Node is published with link');
await agent.check('Graph relationship exists in database');
});
```
### 4. Galaxy Visualization Tests
#### `tests/magnitude/galaxy.mag.ts`
**Happy Paths:**
- Galaxy loads and displays nodes with 3D coordinates
- User can rotate/zoom/pan the galaxy
- User can click on a node to view details
- Node detail modal displays correct information
- User can navigate from modal to chat
- User can navigate from modal to node detail page
- Empty state shows when no nodes exist
- UMAP calculation triggers automatically after 3rd node
**Unhappy Paths:**
- Galaxy handles zero nodes gracefully
- Galaxy handles nodes without coordinates
- WebGL not available fallback
- Node click on non-existent node handled
- Modal close doesn't break navigation
```typescript
import { test } from 'magnitude-test';
test('Galaxy loads and displays nodes', async (agent) => {
await agent.open('http://localhost:3000/galaxy');
await agent.act('Log in if needed');
// Ensure user has at least 3 nodes with embeddings
await agent.check('Galaxy canvas is visible');
await agent.check('3D nodes are rendered');
await agent.check('Can rotate the galaxy by dragging');
await agent.check('Can zoom with scroll wheel');
});
test('User can click on node to view details', async (agent) => {
await agent.open('http://localhost:3000/galaxy');
await agent.act('Log in if needed');
await agent.check('At least one node is visible');
await agent.act('Click on a node sphere');
await agent.check('Node detail modal opens');
await agent.check('Modal shows node title');
await agent.check('Modal shows node body');
await agent.check('Modal shows Bluesky post link');
});
test('Node detail modal navigation works', async (agent) => {
await agent.open('http://localhost:3000/galaxy');
await agent.act('Log in if needed');
await agent.act('Click on a node');
await agent.check('Modal is open');
await agent.act('Click "View Full Detail" button');
await agent.check('Navigated to /galaxy/[node-id] page');
await agent.check('URL contains node ID');
await agent.check('Page shows full node content');
});
test('Empty galaxy shows helpful message', async (agent) => {
// Use a new test user with no nodes
await agent.open('http://localhost:3000/galaxy');
await agent.act('Log in with empty account');
await agent.check('Empty state message is visible');
await agent.check('Message says "No thoughts yet"');
await agent.check('Button to create first node is visible');
});
test('UMAP calculation triggers after 3rd node', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in with empty account');
// Create 3 nodes
await agent.act('Create node 1: "First Thought"');
await agent.act('Create node 2: "Second Thought"');
await agent.act('Create node 3: "Third Thought"');
await agent.check('UMAP calculation started automatically');
await agent.act('Navigate to /galaxy');
await agent.check('All 3 nodes have 3D coordinates');
await agent.check('Nodes are positioned in 3D space');
});
test('Galaxy handles WebGL unavailable', async (agent) => {
await agent.open('http://localhost:3000/galaxy');
await agent.act('Log in if needed');
await agent.act('Disable WebGL in browser');
await agent.check('Fallback message is displayed');
await agent.check('Message explains WebGL requirement');
await agent.check('Link to enable WebGL provided');
});
```
### 5. Voice Feature Tests
#### `tests/magnitude/voice.mag.ts`
**Happy Paths:**
- User can start voice recording
- User can stop voice recording
- Voice is transcribed to text
- Transcribed text is sent to AI
- TTS plays AI response
- User can toggle TTS on/off
**Unhappy Paths:**
- Microphone permission denied
- Deepgram API error
- Network error during transcription
- TTS fails gracefully
```typescript
import { test } from 'magnitude-test';
test('User can record and transcribe voice', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in if needed');
await agent.act('Click microphone button');
await agent.check('Recording indicator shows');
await agent.check('Microphone permission granted');
await agent.act('Speak "What is artificial intelligence?"');
await agent.wait(2000);
await agent.act('Click microphone button to stop');
await agent.check('Recording stops');
await agent.check('Text is transcribed');
await agent.check('Transcribed text appears in chat input');
});
test('Microphone permission denied handled gracefully', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in if needed');
await agent.act('Deny microphone permission');
await agent.act('Click microphone button');
await agent.check('Error notification appears');
await agent.check('Error explains permission is needed');
await agent.check('Link to browser settings provided');
});
test('TTS plays AI response', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Log in if needed');
await agent.act('Enable TTS in settings');
await agent.act('Send message "Hello"');
await agent.check('AI responds with text');
await agent.check('TTS audio plays automatically');
await agent.check('Audio player controls are visible');
});
```
### 6. Integration Tests
#### `tests/magnitude/integration.mag.ts`
**Full user journeys:**
- Complete flow: Login → Chat → Create Node → View in Galaxy
- Complete flow: Login → Galaxy → View Node → Edit Node
- Complete flow: Login → Create 3 Nodes → UMAP → Explore Galaxy
```typescript
import { test } from 'magnitude-test';
test('Complete user journey: Login to Galaxy visualization', async (agent) => {
// 1. Login
await agent.open('http://localhost:3000');
await agent.act('Log in with valid credentials');
await agent.check('User is authenticated');
// 2. Chat with AI
await agent.act('Navigate to /chat');
await agent.act('Send message "Tell me about philosophy"');
await agent.check('AI responds');
// 3. Create node from conversation
await agent.act('Click "Create Node" button');
await agent.check('Node editor opens with AI draft');
await agent.act('Review and edit node content');
await agent.act('Click "Publish"');
await agent.check('Node is published to Bluesky');
await agent.check('Success notification appears');
// 4. View in galaxy
await agent.act('Navigate to /galaxy');
await agent.check('Galaxy canvas loads');
await agent.check('New node appears in galaxy');
await agent.act('Click on the new node');
await agent.check('Node details modal opens');
await agent.check('Content matches published node');
});
test('User creates 3 nodes and explores galaxy', async (agent) => {
await agent.open('http://localhost:3000');
await agent.act('Log in with empty account');
// Create 3 nodes
for (let i = 1; i <= 3; i++) {
await agent.act(`Create node titled "Thought ${i}"`);
await agent.check(`Node ${i} published successfully`);
}
// Check UMAP triggered
await agent.check('UMAP calculation started');
await agent.wait(5000); // Wait for UMAP to complete
// Explore galaxy
await agent.act('Navigate to /galaxy');
await agent.check('All 3 nodes have coordinates');
await agent.check('Nodes are spatially distributed');
await agent.act('Rotate galaxy to different angle');
await agent.act('Zoom in on cluster of nodes');
await agent.act('Click on each node to view details');
await agent.check('All node details accessible');
});
```
## Implementation Steps
1. **Create test directory structure**
```bash
mkdir -p tests/magnitude
```
2. **Create test files in order**
- `tests/magnitude/auth.mag.ts` - Authentication tests
- `tests/magnitude/chat.mag.ts` - Chat interface tests
- `tests/magnitude/nodes.mag.ts` - Node creation tests
- `tests/magnitude/galaxy.mag.ts` - Galaxy visualization tests
- `tests/magnitude/voice.mag.ts` - Voice feature tests
- `tests/magnitude/integration.mag.ts` - Full user journeys
3. **Update package.json**
```json
{
"scripts": {
"test": "npx magnitude",
"test:watch": "npx magnitude --watch"
}
}
```
4. **Create test data helpers**
```typescript
// tests/magnitude/helpers/data.ts
export const createTestNode = (index: number) => ({
title: `Test Node ${index}`,
body: `This is test content for node ${index}. It discusses various topics.`
});
```
5. **Run tests and fix issues**
- Run each test file individually first
- Fix failing tests
- Run full suite
- Ensure all tests pass
## Success Criteria
- ✅ All authentication flows tested (happy + unhappy paths)
- ✅ All chat interactions tested (text, voice, AI response)
- ✅ All node creation scenarios tested (simple, thread, emojis, links)
- ✅ All galaxy visualization features tested (load, click, navigate)
- ✅ All voice features tested (record, transcribe, TTS)
- ✅ Integration tests cover complete user journeys
- ✅ All tests pass reliably
- ✅ Test suite runs in CI/CD
- ✅ New features cannot be merged without tests
## Files to Create
1. `tests/magnitude/auth.mag.ts`
2. `tests/magnitude/chat.mag.ts`
3. `tests/magnitude/nodes.mag.ts`
4. `tests/magnitude/galaxy.mag.ts`
5. `tests/magnitude/voice.mag.ts`
6. `tests/magnitude/integration.mag.ts`
7. `tests/magnitude/helpers/data.ts`
## Files to Update
1. `package.json` - Add test scripts
2. `.github/workflows/test.yml` - Add CI test workflow (if exists)

View File

@@ -0,0 +1,382 @@
# Plan: Stream AI Output to Deepgram for Faster TTS Synthesis
**Priority:** MEDIUM
**Dependencies:** None
**Affects:** Voice interaction latency, user experience
## Overview
Currently, the app waits for the complete AI response before sending it to Deepgram for TTS. This creates a laggy experience. By streaming the AI output directly to Deepgram as it's generated, we can start playing audio much faster and create a more responsive voice interaction.
## Current Implementation
### Current Flow (SLOW)
```
User speaks → Deepgram transcribe → Send to AI
Wait for full response (3-10s)
Send complete text to Deepgram TTS
Wait for audio generation (1-3s)
Play audio
```
**Total latency:** 4-13 seconds before first audio plays
## Proposed Implementation
### New Flow (FAST)
```
User speaks → Deepgram transcribe → Stream to AI
Stream chunks to Deepgram TTS
↓ (chunks arrive)
Play audio chunks immediately
```
**Total latency:** 1-2 seconds before first audio plays
## Technical Approach
### 1. Modify AI SDK Integration
Currently using `useChat` from Vercel AI SDK with async completion:
```typescript
// Current (app/api/chat/route.ts)
const result = await streamText({
model: google('gemini-2.0-flash-exp'),
messages,
system: systemPrompt,
});
return result.toDataStreamResponse();
```
Need to add TTS streaming:
```typescript
// New approach
const result = streamText({
model: google('gemini-2.0-flash-exp'),
messages,
system: systemPrompt,
async onChunk({ chunk }) {
// Stream each chunk to Deepgram TTS
if (chunk.type === 'text-delta') {
await streamToDeepgram(chunk.textDelta);
}
},
});
return result.toDataStreamResponse();
```
### 2. Create Deepgram TTS Streaming Service
#### `lib/deepgram-tts-stream.ts`
```typescript
import { createClient, LiveClient } from '@deepgram/sdk';
export class DeepgramTTSStream {
private client: LiveClient;
private audioQueue: Uint8Array[] = [];
private isPlaying = false;
constructor(apiKey: string) {
const deepgram = createClient(apiKey);
this.client = deepgram.speak.live({
model: 'aura-asteria-en',
encoding: 'linear16',
sample_rate: 24000,
});
this.client.on('data', (data: Buffer) => {
this.audioQueue.push(new Uint8Array(data));
this.playNextChunk();
});
}
async streamText(text: string) {
// Send text chunk to Deepgram for synthesis
this.client.send(text);
}
async flush() {
// Signal end of text stream
this.client.close();
}
private async playNextChunk() {
if (this.isPlaying || this.audioQueue.length === 0) return;
this.isPlaying = true;
const chunk = this.audioQueue.shift()!;
// Play audio chunk using Web Audio API
await this.playAudioChunk(chunk);
this.isPlaying = false;
this.playNextChunk(); // Play next if available
}
private async playAudioChunk(chunk: Uint8Array) {
const audioContext = new AudioContext({ sampleRate: 24000 });
const audioBuffer = audioContext.createBuffer(
1, // mono
chunk.length / 2, // 16-bit samples
24000
);
const channelData = audioBuffer.getChannelData(0);
for (let i = 0; i < chunk.length / 2; i++) {
// Convert 16-bit PCM to float32
const sample = (chunk[i * 2] | (chunk[i * 2 + 1] << 8));
channelData[i] = sample / 32768.0;
}
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
return new Promise((resolve) => {
source.onended = resolve;
source.start();
});
}
}
```
### 3. Create Server-Sent Events (SSE) Endpoint for TTS
#### `app/api/chat-with-tts/route.ts`
```typescript
import { DeepgramTTSStream } from '@/lib/deepgram-tts-stream';
import { streamText } from 'ai';
import { google } from '@ai-sdk/google';
export async function POST(request: Request) {
const { messages } = await request.json();
// Create a TransformStream for SSE
const stream = new TransformStream();
const writer = stream.writable.getWriter();
const encoder = new TextEncoder();
// Start streaming AI response
(async () => {
const ttsStream = new DeepgramTTSStream(process.env.DEEPGRAM_API_KEY!);
try {
const result = streamText({
model: google('gemini-2.0-flash-exp'),
messages,
async onChunk({ chunk }) {
if (chunk.type === 'text-delta') {
// Send text to client
await writer.write(
encoder.encode(`data: ${JSON.stringify({ text: chunk.textDelta })}\n\n`)
);
// Stream to Deepgram TTS
await ttsStream.streamText(chunk.textDelta);
}
},
});
await result.text; // Wait for completion
await ttsStream.flush();
await writer.write(encoder.encode('data: [DONE]\n\n'));
} catch (error) {
await writer.write(
encoder.encode(`data: ${JSON.stringify({ error: error.message })}\n\n`)
);
} finally {
await writer.close();
}
})();
return new Response(stream.readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
```
### 4. Update Client to Consume SSE with TTS
#### `components/ChatInterface.tsx`
```typescript
const [isTTSEnabled, setIsTTSEnabled] = useState(false);
const ttsStreamRef = useRef<DeepgramTTSStream | null>(null);
async function sendMessageWithTTS(message: string) {
const response = await fetch('/api/chat-with-tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [...messages, { role: 'user', content: message }] }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
// Initialize TTS stream
if (isTTSEnabled) {
ttsStreamRef.current = new DeepgramTTSStream();
}
let fullText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
if (ttsStreamRef.current) {
await ttsStreamRef.current.flush();
}
break;
}
try {
const parsed = JSON.parse(data);
if (parsed.text) {
fullText += parsed.text;
// Update UI with incremental text
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant') {
return [...prev.slice(0, -1), { ...last, content: fullText }];
}
return [...prev, { role: 'assistant', content: fullText }];
});
// Stream to TTS
if (ttsStreamRef.current) {
await ttsStreamRef.current.streamText(parsed.text);
}
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
}
}
}
```
## Alternative: Use Deepgram's Native Streaming TTS
Deepgram has a WebSocket-based streaming TTS API that's even more efficient:
```typescript
const deepgram = createClient(process.env.DEEPGRAM_API_KEY);
const connection = deepgram.speak.live({
model: 'aura-asteria-en',
encoding: 'linear16',
sample_rate: 24000,
});
connection.on('open', () => {
console.log('TTS connection established');
});
connection.on('data', (audioData: Buffer) => {
// Play audio chunk immediately
playAudioBuffer(audioData);
});
// As AI chunks arrive, send to Deepgram
aiStream.on('text-delta', (text) => {
connection.send(text);
});
// When AI completes
aiStream.on('finish', () => {
connection.close();
});
```
## Implementation Steps
1. **Research Deepgram TTS Streaming API**
- Review docs: https://developers.deepgram.com/docs/tts-streaming
- Test WebSocket connection manually
- Understand audio format and buffering
2. **Create TTS streaming service**
- `lib/deepgram-tts-stream.ts`
- Implement audio queue and playback
- Handle reconnection and errors
3. **Modify API route for streaming**
- Create `/api/chat-with-tts` route
- Implement SSE response
- Connect AI stream to TTS stream
4. **Update client components**
- Add TTS toggle in UI
- Implement SSE consumption
- Connect to audio playback
5. **Test with Playwright MCP**
- Enable TTS
- Send message
- Verify audio starts playing quickly (< 2s)
- Verify audio quality
- Test error handling (network drop, TTS failure)
6. **Add Magnitude test**
```typescript
test('TTS streams audio with low latency', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Enable TTS in settings');
await agent.act('Send message "Hello"');
await agent.check('Audio starts playing within 2 seconds');
await agent.check('Audio continues as AI generates response');
await agent.check('Audio completes without gaps');
});
```
## Performance Targets
- **Time to first audio:** < 2 seconds (vs current 4-13s)
- **Perceived latency:** Near real-time streaming
- **Audio quality:** No degradation from current implementation
- **Reliability:** Graceful fallback if streaming fails
## Success Criteria
- ✅ TTS audio starts playing within 2 seconds of AI response beginning
- ✅ Audio streams continuously as AI generates text
- ✅ No perceptible gaps or stuttering in audio playback
- ✅ Graceful fallback to batch TTS if streaming fails
- ✅ Playwright MCP manual test passes
- ✅ Magnitude test passes
- ✅ No regression in audio quality
## Files to Create
1. `lib/deepgram-tts-stream.ts` - TTS streaming service
2. `app/api/chat-with-tts/route.ts` - SSE endpoint for TTS
3. `tests/playwright/tts-streaming.spec.ts` - Manual Playwright test
4. `tests/magnitude/tts-streaming.mag.ts` - Magnitude test
## Files to Update
1. `components/ChatInterface.tsx` - Add TTS streaming consumption
2. `app/theme.ts` - Add TTS toggle styling if needed

View File

@@ -0,0 +1,383 @@
# Plan: Fix Galaxy Node Clicking and Navigation
**Priority:** HIGH - Critical user experience issue
**Dependencies:** None
**Affects:** Galaxy visualization navigation, user frustration
## Overview
There are two critical bugs with galaxy node interaction:
1. When going directly to a node ID link (`/galaxy/node:xxx`), it redirects to `/chat`
2. When clicking on a node in `/galaxy` (either general or on a specific node ID URL), the modal closes automatically
Both issues severely impact the galaxy user experience and need immediate fixing.
## Current Broken Behavior
### Issue 1: Direct Node URL Redirects to Chat
```
User clicks: /galaxy/node:abc123
Expected: Shows node detail page with full content
Actual: Redirects to /chat
```
### Issue 2: Modal Auto-Closes on Node Click
```
User action: Click node sphere in galaxy visualization
Expected: Modal stays open showing node details
Actual: Modal opens briefly then closes immediately
```
## Root Cause Analysis
Let me investigate the current implementation:
### 1. Check `/app/galaxy/[node-id]/page.tsx`
Likely issues:
- Missing authentication check causing redirect
- Incorrect route parameter parsing
- Navigation logic in wrong place
### 2. Check Galaxy Modal Component
Likely issues:
- Event handler conflict (click bubbling to parent)
- State management issue (modal state reset)
- React Three Fiber event handling bug
## Proposed Fixes
### Fix 1: Node Detail Page Route
#### Current (broken):
```typescript
// app/galaxy/[node-id]/page.tsx
export default async function NodeDetailPage({ params }: { params: { 'node-id': string } }) {
// Redirects to /chat if not authenticated?
}
```
#### Fixed:
```typescript
// app/galaxy/[node-id]/page.tsx
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { verifySurrealJwt } from '@/lib/auth/jwt';
import { connectToDB } from '@/lib/db';
export default async function NodeDetailPage({
params,
}: {
params: { 'node-id': string };
}) {
// 1. Verify authentication
const cookieStore = await cookies();
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
if (!surrealJwt) {
// Redirect to login, preserving the intended destination
redirect(`/login?redirect=/galaxy/${params['node-id']}`);
}
const userSession = verifySurrealJwt(surrealJwt);
if (!userSession) {
redirect(`/login?redirect=/galaxy/${params['node-id']}`);
}
// 2. Fetch node data
const db = await connectToDB();
const nodeId = params['node-id'];
const nodes = await db.select<NodeData>(nodeId);
const node = nodes[0];
if (!node) {
// Node doesn't exist or user doesn't have access
redirect('/galaxy?error=node-not-found');
}
// 3. Verify user owns this node
if (node.user_did !== userSession.did) {
redirect('/galaxy?error=unauthorized');
}
// 4. Render node detail page
return (
<Stack p="xl">
<Title order={1}>{node.title}</Title>
<Text>{node.body}</Text>
{node.atp_uri && (
<Anchor href={node.atp_uri} target="_blank">
View on Bluesky
</Anchor>
)}
<Button component={Link} href="/galaxy">
Back to Galaxy
</Button>
</Stack>
);
}
```
### Fix 2: Galaxy Modal Component
#### Problem: Click event bubbling
When user clicks node in R3F, the click event might be:
- Bubbling to parent canvas
- Triggering canvas click handler that closes modal
- Or: React state update race condition
#### Solution: Stop event propagation
```typescript
// components/galaxy/NodeSphere.tsx
import { useRef } from 'react';
import { ThreeEvent } from '@react-three/fiber';
function NodeSphere({ node, onClick }: { node: NodeData; onClick: (node: NodeData) => void }) {
const meshRef = useRef();
const handleClick = (event: ThreeEvent<MouseEvent>) => {
// CRITICAL: Stop propagation to prevent canvas click handler
event.stopPropagation();
// Call parent onClick
onClick(node);
};
return (
<mesh
ref={meshRef}
position={node.coords_3d}
onClick={handleClick}
>
<sphereGeometry args={[0.1, 16, 16]} />
<meshStandardMaterial color="#fff" />
</mesh>
);
}
```
#### Problem: Modal state management
Modal might be controlled by both:
- Galaxy component state
- URL query params
- Leading to race condition
#### Solution: Single source of truth
```typescript
// app/galaxy/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
export default function GalaxyPage() {
const searchParams = useSearchParams();
const router = useRouter();
// Modal state controlled ONLY by URL query param
const selectedNodeId = searchParams.get('node');
const handleNodeClick = (nodeId: string) => {
// Update URL to open modal
router.push(`/galaxy?node=${encodeURIComponent(nodeId)}`, { scroll: false });
};
const handleModalClose = () => {
// Remove query param to close modal
router.push('/galaxy', { scroll: false });
};
return (
<>
<ThoughtGalaxy
nodes={nodes}
links={links}
onNodeClick={handleNodeClick}
/>
<Modal
opened={!!selectedNodeId}
onClose={handleModalClose}
size="lg"
>
{selectedNodeId && <NodeDetailModal nodeId={selectedNodeId} />}
</Modal>
</>
);
}
```
### Fix 3: Canvas Click Handler
If canvas has a click handler that closes modal, remove it or add condition:
```typescript
// components/galaxy/ThoughtGalaxy.tsx
<Canvas
onClick={(e) => {
// Only close modal if clicking on background (not a node)
if (e.target === e.currentTarget) {
onBackgroundClick?.();
}
}}
>
{/* ... */}
</Canvas>
```
## Testing Strategy
### Manual Playwright MCP Testing
#### Test 1: Direct Node URL
```typescript
test('Direct node URL loads correctly', async ({ page }) => {
// Create a node first
const nodeId = await createTestNode(page);
// Navigate directly to node detail page
await page.goto(`/galaxy/${nodeId}`);
// Verify we're on the correct page
await expect(page).toHaveURL(/\/galaxy\/node:/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByText(/View on Bluesky/)).toBeVisible();
});
```
#### Test 2: Modal Stays Open
```typescript
test('Galaxy modal stays open when clicking node', async ({ page }) => {
await page.goto('/galaxy');
// Click on a node sphere
await page.evaluate(() => {
const event = new CustomEvent('node-click', { detail: { nodeId: 'node:123' } });
window.dispatchEvent(event);
});
// Modal should open
await expect(page.getByRole('dialog')).toBeVisible();
// Wait 2 seconds
await page.waitForTimeout(2000);
// Modal should still be open
await expect(page.getByRole('dialog')).toBeVisible();
});
```
#### Test 3: Modal Navigation
```typescript
test('Can navigate from galaxy to node detail page', async ({ page }) => {
await page.goto('/galaxy');
// Click node to open modal
await clickNode(page, 'node:123');
// Modal opens
await expect(page.getByRole('dialog')).toBeVisible();
// Click "View Full Detail"
await page.click('button:has-text("View Full Detail")');
// Navigate to detail page
await expect(page).toHaveURL(/\/galaxy\/node:123/);
await expect(page.getByRole('dialog')).not.toBeVisible();
});
```
### Magnitude Tests
```typescript
import { test } from 'magnitude-test';
test('User can navigate to node via direct URL', async (agent) => {
// Create a node first
await agent.open('http://localhost:3000/chat');
await agent.act('Create a test node');
const nodeUrl = await agent.getData('node-url');
// Navigate directly to node URL
await agent.open(nodeUrl);
await agent.check('Node detail page loads');
await agent.check('Node title is visible');
await agent.check('Node body is visible');
await agent.check('Bluesky link is visible');
});
test('Galaxy modal stays open when clicking nodes', async (agent) => {
await agent.open('http://localhost:3000/galaxy');
await agent.act('Click on a node sphere');
await agent.check('Modal opens');
await agent.wait(2000);
await agent.check('Modal is still open');
await agent.check('Node details are visible');
});
test('Can navigate from galaxy modal to node detail page', async (agent) => {
await agent.open('http://localhost:3000/galaxy');
await agent.act('Click on a node');
await agent.check('Modal opens');
await agent.act('Click "View Full Detail" button');
await agent.check('Navigated to node detail page');
await agent.check('URL contains node ID');
await agent.check('Full node content is visible');
});
```
## Implementation Steps
1. **Fix node detail page route**
- Add proper authentication check
- Add redirect with return URL
- Add node ownership verification
- Add error handling for missing nodes
2. **Fix modal event handling**
- Add `event.stopPropagation()` to node click handler
- Remove or condition any canvas-level click handlers
- Ensure modal state is controlled by URL query param
3. **Test with Playwright MCP**
- Test direct URL navigation
- Test modal stays open
- Test modal to detail page navigation
- Fix any issues found
4. **Add Magnitude tests**
- Write tests covering all 3 scenarios
- Ensure tests pass
5. **Deploy and verify in production**
## Success Criteria
- ✅ Direct node URLs (`/galaxy/node:xxx`) load correctly
- ✅ No redirect to `/chat` when accessing node URLs
- ✅ Galaxy modal stays open after clicking node
- ✅ Can navigate from modal to detail page
- ✅ Can navigate from detail page back to galaxy
- ✅ All Playwright MCP tests pass
- ✅ All Magnitude tests pass
- ✅ No console errors during navigation
## Files to Update
1. `app/galaxy/[node-id]/page.tsx` - Fix authentication and routing
2. `app/galaxy/page.tsx` - Fix modal state management
3. `components/galaxy/ThoughtGalaxy.tsx` - Fix canvas click handling
4. `components/galaxy/NodeSphere.tsx` - Fix node click event propagation
## Files to Create
1. `tests/playwright/galaxy-navigation.spec.ts` - Manual tests
2. `tests/magnitude/galaxy-navigation.mag.ts` - Magnitude tests

View File

@@ -0,0 +1,463 @@
# Plan: Add Dark/Light Mode with Dynamic Favicons
**Priority:** MEDIUM
**Dependencies:** None
**Affects:** User experience, accessibility, branding
## Overview
Add full dark mode / light mode support throughout the app, including:
- Dynamic theme switching
- Persistent user preference
- System preference detection
- Mode-specific favicons
- Smooth transitions
## Current State
- App uses a minimal grayscale theme
- No dark mode support
- Single favicon for all modes
- No theme toggle UI
## Proposed Implementation
### 1. Mantine Color Scheme Support
Mantine has built-in dark mode support. We'll leverage `@mantine/core` ColorSchemeScript and hooks.
#### Update `app/layout.tsx`
```typescript
import {
ColorSchemeScript,
MantineProvider,
} from '@mantine/core';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
<ColorSchemeScript defaultColorScheme="auto" />
{/* Dynamic favicon based on theme */}
<link
rel="icon"
href="/favicon-light.svg"
media="(prefers-color-scheme: light)"
/>
<link
rel="icon"
href="/favicon-dark.svg"
media="(prefers-color-scheme: dark)"
/>
</head>
<body>
<MantineProvider defaultColorScheme="auto">
{children}
</MantineProvider>
</body>
</html>
);
}
```
### 2. Update Theme Configuration
#### `app/theme.ts`
```typescript
import { createTheme, MantineColorsTuple } from '@mantine/core';
const gray: MantineColorsTuple = [
'#f8f9fa',
'#f1f3f5',
'#e9ecef',
'#dee2e6',
'#ced4da',
'#adb5bd',
'#868e96',
'#495057',
'#343a40',
'#212529',
];
export const theme = createTheme({
fontFamily: 'Inter, system-ui, sans-serif',
primaryColor: 'gray',
colors: {
gray,
},
// Dark mode specific colors
black: '#0a0a0a',
white: '#ffffff',
// Component-specific dark mode overrides
components: {
AppShell: {
styles: (theme) => ({
main: {
backgroundColor: theme.colorScheme === 'dark'
? theme.colors.dark[8]
: theme.white,
},
}),
},
Paper: {
styles: (theme) => ({
root: {
backgroundColor: theme.colorScheme === 'dark'
? theme.colors.dark[7]
: theme.white,
borderColor: theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[3],
},
}),
},
Modal: {
styles: (theme) => ({
content: {
backgroundColor: theme.colorScheme === 'dark'
? theme.colors.dark[7]
: theme.white,
},
header: {
backgroundColor: theme.colorScheme === 'dark'
? theme.colors.dark[6]
: theme.colors.gray[0],
},
}),
},
},
});
```
### 3. Theme Toggle Component
#### `components/ThemeToggle.tsx`
```typescript
'use client';
import { ActionIcon, useMantineColorScheme, useComputedColorScheme } from '@mantine/core';
import { IconSun, IconMoon } from '@tabler/icons-react';
export function ThemeToggle() {
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme('light');
return (
<ActionIcon
onClick={() => setColorScheme(computedColorScheme === 'dark' ? 'light' : 'dark')}
variant="subtle"
size="lg"
aria-label="Toggle color scheme"
>
{computedColorScheme === 'dark' ? (
<IconSun size={20} />
) : (
<IconMoon size={20} />
)}
</ActionIcon>
);
}
```
### 4. Add Toggle to Desktop Navigation
#### `components/DesktopNav.tsx`
```typescript
import { ThemeToggle } from './ThemeToggle';
export function DesktopNav() {
return (
<AppShell.Navbar p="md">
<Stack justify="space-between" h="100%">
<Stack>
{/* Navigation items */}
</Stack>
<Stack>
<ThemeToggle />
{/* Profile menu */}
</Stack>
</Stack>
</AppShell.Navbar>
);
}
```
### 5. Create Mode-Specific Favicons
#### Design Requirements
- **Light mode favicon:** Dark icon on transparent background
- **Dark mode favicon:** Light icon on transparent background
- Both should be SVG for scalability
- Simple, recognizable icon representing "thoughts" or "ponderants"
#### `public/favicon-light.svg`
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Dark icon for light mode -->
<circle cx="50" cy="50" r="40" fill="#212529" />
<circle cx="35" cy="45" r="5" fill="#ffffff" />
<circle cx="65" cy="45" r="5" fill="#ffffff" />
<path d="M 30 65 Q 50 75 70 65" stroke="#ffffff" stroke-width="3" fill="none" />
</svg>
```
#### `public/favicon-dark.svg`
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Light icon for dark mode -->
<circle cx="50" cy="50" r="40" fill="#f8f9fa" />
<circle cx="35" cy="45" r="5" fill="#212529" />
<circle cx="65" cy="45" r="5" fill="#212529" />
<path d="M 30 65 Q 50 75 70 65" stroke="#212529" stroke-width="3" fill="none" />
</svg>
```
### 6. Update Galaxy Visualization for Dark Mode
The 3D galaxy needs to look good in both modes:
#### `components/galaxy/ThoughtGalaxy.tsx`
```typescript
'use client';
import { useComputedColorScheme } from '@mantine/core';
export function ThoughtGalaxy() {
const colorScheme = useComputedColorScheme('light');
const isDark = colorScheme === 'dark';
return (
<Canvas
style={{
background: isDark
? 'linear-gradient(to bottom, #0a0a0a, #1a1a1a)'
: 'linear-gradient(to bottom, #f8f9fa, #e9ecef)',
}}
>
<ambientLight intensity={isDark ? 0.3 : 0.5} />
<pointLight position={[10, 10, 10]} intensity={isDark ? 0.8 : 1} />
{/* Node spheres - adjust color based on theme */}
<NodeSphere color={isDark ? '#f8f9fa' : '#212529'} />
{/* Link lines */}
<LinkLine color={isDark ? 'rgba(248, 249, 250, 0.3)' : 'rgba(33, 37, 41, 0.3)'} />
</Canvas>
);
}
```
### 7. Persist User Preference
Mantine automatically stores the preference in localStorage with the key `mantine-color-scheme-value`.
For server-side rendering, we can add a cookie fallback:
#### `middleware.ts`
```typescript
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Set color scheme cookie if not present
if (!request.cookies.has('mantine-color-scheme')) {
response.cookies.set('mantine-color-scheme', 'auto');
}
return response;
}
```
## Testing Strategy
### Manual Playwright MCP Testing
#### Test 1: Theme Toggle
```typescript
test('User can toggle between light and dark mode', async ({ page }) => {
await page.goto('/');
// Check initial theme
const initialTheme = await page.evaluate(() => {
return document.documentElement.getAttribute('data-mantine-color-scheme');
});
// Click theme toggle
await page.click('[aria-label="Toggle color scheme"]');
// Check theme changed
const newTheme = await page.evaluate(() => {
return document.documentElement.getAttribute('data-mantine-color-scheme');
});
expect(newTheme).not.toBe(initialTheme);
});
```
#### Test 2: Theme Persistence
```typescript
test('Theme preference persists across page refreshes', async ({ page }) => {
await page.goto('/');
// Set to dark mode
await page.click('[aria-label="Toggle color scheme"]');
await page.waitForTimeout(100);
const darkTheme = await page.evaluate(() => {
return document.documentElement.getAttribute('data-mantine-color-scheme');
});
// Refresh page
await page.reload();
// Check theme persisted
const persistedTheme = await page.evaluate(() => {
return document.documentElement.getAttribute('data-mantine-color-scheme');
});
expect(persistedTheme).toBe(darkTheme);
});
```
#### Test 3: Favicon Changes
```typescript
test('Favicon changes with theme', async ({ page }) => {
await page.goto('/');
// Get favicon in light mode
const lightFavicon = await page.evaluate(() => {
const link = document.querySelector('link[rel="icon"]');
return link?.getAttribute('href');
});
// Switch to dark mode
await page.click('[aria-label="Toggle color scheme"]');
await page.waitForTimeout(100);
// Get favicon in dark mode
const darkFavicon = await page.evaluate(() => {
const link = document.querySelector('link[rel="icon"]');
return link?.getAttribute('href');
});
expect(lightFavicon).toContain('light');
expect(darkFavicon).toContain('dark');
});
```
### Magnitude Tests
```typescript
import { test } from 'magnitude-test';
test('User can switch between light and dark mode', async (agent) => {
await agent.open('http://localhost:3000');
await agent.check('Page loads in default theme');
await agent.act('Click theme toggle button');
await agent.check('Theme switches to opposite mode');
await agent.check('All UI elements are visible in new theme');
});
test('Dark mode looks good throughout app', async (agent) => {
await agent.open('http://localhost:3000');
await agent.act('Switch to dark mode');
await agent.act('Navigate to /chat');
await agent.check('Chat interface is readable in dark mode');
await agent.act('Navigate to /galaxy');
await agent.check('Galaxy visualization looks good in dark mode');
await agent.act('Navigate to /edit');
await agent.check('Editor is readable in dark mode');
});
test('Theme preference persists', async (agent) => {
await agent.open('http://localhost:3000');
await agent.act('Switch to dark mode');
await agent.check('App is in dark mode');
await agent.act('Refresh page');
await agent.check('App remains in dark mode');
});
```
## Implementation Steps
1. **Update Mantine provider and layout**
- Add ColorSchemeScript to head
- Add dynamic favicon links
- Update MantineProvider with defaultColorScheme
2. **Update theme configuration**
- Add dark mode color overrides
- Update component styles for both modes
3. **Create ThemeToggle component**
- Add sun/moon icon toggle
- Wire up to Mantine color scheme hooks
4. **Add toggle to navigation**
- Add to DesktopNav
- Add to mobile nav if applicable
5. **Create favicons**
- Design light mode favicon (dark icon)
- Design dark mode favicon (light icon)
- Export as SVG
- Add to public folder
6. **Update galaxy visualization**
- Add theme-aware colors
- Test in both modes
7. **Test with Playwright MCP**
- Test toggle functionality
- Test persistence
- Test favicon changes
- Test all pages in both modes
8. **Add Magnitude tests**
- Write comprehensive theme tests
- Ensure all pass
9. **Commit and push (NO DEPLOY)**
## Success Criteria
- ✅ User can toggle between light and dark mode
- ✅ Theme preference persists across sessions
- ✅ Favicon changes based on active theme
- ✅ All UI elements are readable in both modes
- ✅ Galaxy visualization looks good in both modes
- ✅ System preference is detected and respected
- ✅ Smooth transitions between modes
- ✅ All Playwright MCP tests pass
- ✅ All Magnitude tests pass
## Files to Create
1. `components/ThemeToggle.tsx` - Toggle component
2. `public/favicon-light.svg` - Light mode favicon
3. `public/favicon-dark.svg` - Dark mode favicon
4. `tests/playwright/theme.spec.ts` - Manual tests
5. `tests/magnitude/theme.mag.ts` - Magnitude tests
## Files to Update
1. `app/layout.tsx` - Add ColorSchemeScript and dynamic favicons
2. `app/theme.ts` - Add dark mode color overrides
3. `components/DesktopNav.tsx` - Add ThemeToggle
4. `components/galaxy/ThoughtGalaxy.tsx` - Add theme-aware colors

View File

@@ -0,0 +1,171 @@
# Plan: Fix Double Border on Desktop Between Sidebar and Conversation
**Priority:** LOW - Visual polish
**Dependencies:** None
**Affects:** Desktop UI aesthetics
## Overview
There's a double border appearing on desktop between the sidebar and the main conversation area. This creates a visual inconsistency and needs to be fixed for a polished look.
## Current Issue
```
+----------+||+-------------+
| Sidebar ||| Main |
| ||| Content |
| ||| |
+----------+||+-------------+
^^
Double border
```
## Root Cause
Likely causes:
1. Both AppShell.Navbar and AppShell.Main have borders
2. Border-box sizing causing borders to stack
3. Mantine default styles adding extra borders
## Investigation Steps
1. **Inspect current implementation**
```typescript
// Check components/DesktopNav.tsx
// Check app/layout.tsx AppShell usage
// Check theme.ts AppShell styles
```
2. **Identify which elements have borders**
- AppShell.Navbar
- AppShell.Main
- Any wrapper components
## Proposed Fixes
### Option 1: Remove Border from One Side
```typescript
// app/theme.ts
export const theme = createTheme({
components: {
AppShell: {
styles: {
navbar: {
borderRight: 'none', // Remove right border from navbar
},
},
},
},
});
```
### Option 2: Use Single Shared Border
```typescript
// app/theme.ts
export const theme = createTheme({
components: {
AppShell: {
styles: (theme) => ({
navbar: {
borderRight: `1px solid ${theme.colors.gray[3]}`,
},
main: {
borderLeft: 'none', // Remove left border from main
},
}),
},
},
});
```
### Option 3: Use Divider Component
```typescript
// components/DesktopNav.tsx
<Group h="100%" gap={0} wrap="nowrap">
<AppShell.Navbar>{/* ... */}</AppShell.Navbar>
<Divider orientation="vertical" />
<AppShell.Main>{/* ... */}</AppShell.Main>
</Group>
```
## Testing
### Manual Playwright MCP Test
```typescript
test('No double border between sidebar and main content', async ({ page }) => {
await page.goto('/chat');
await page.setViewportSize({ width: 1920, height: 1080 });
// Take screenshot of border area
const borderElement = page.locator('.mantine-AppShell-navbar');
await borderElement.screenshot({ path: 'border-before.png' });
// Check computed styles
const navbarBorder = await page.evaluate(() => {
const navbar = document.querySelector('.mantine-AppShell-navbar');
return window.getComputedStyle(navbar!).borderRight;
});
const mainBorder = await page.evaluate(() => {
const main = document.querySelector('.mantine-AppShell-main');
return window.getComputedStyle(main!).borderLeft;
});
// Only one should have a border
const hasDouble = navbarBorder !== 'none' && mainBorder !== 'none';
expect(hasDouble).toBe(false);
});
```
### Magnitude Test
```typescript
import { test } from 'magnitude-test';
test('Desktop sidebar has clean border', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Resize window to desktop size (1920x1080)');
await agent.check('Sidebar is visible');
await agent.check('No double border between sidebar and content');
await agent.check('Border is clean and single-width');
});
```
## Implementation Steps
1. **Identify current border setup**
- Inspect DesktopNav component
- Check AppShell configuration
- Check theme.ts
2. **Choose fix approach**
- Decide which element keeps the border
- Remove border from other element
3. **Implement fix**
- Update theme.ts or component styles
4. **Test with Playwright MCP**
- Visual inspection
- Computed styles check
5. **Add Magnitude test**
6. **Commit and push**
## Success Criteria
- ✅ Only one border between sidebar and main content
- ✅ Border is clean and single-width
- ✅ Consistent across all pages
- ✅ Works in both light and dark mode
- ✅ Playwright MCP test passes
- ✅ Magnitude test passes
## Files to Update
1. `app/theme.ts` - Update AppShell border styles
2. `tests/playwright/desktop-border.spec.ts` - Manual test
3. `tests/magnitude/desktop-border.mag.ts` - Magnitude test

View File

@@ -0,0 +1,78 @@
# Plan: Delete Backup/Old Page Files
**Priority:** LOW - Code cleanup
**Dependencies:** None
**Affects:** Code maintenance, repository cleanliness
## Overview
Remove all backup and old page.tsx files that are no longer needed. These files clutter the codebase and can cause confusion.
## Files to Delete
Need to search for:
- `*.backup.*` files
- `*.old.*` files
- `*-old.*` files
- Files with "backup" in the name
- Commented-out old implementations
## Search Strategy
```bash
# Find all backup files
find . -name "*.backup.*" -o -name "*.old.*" -o -name "*-old.*"
# Find files with "backup" in name
find . -iname "*backup*"
# Check git status for renamed files
git status | grep -i backup
```
## Implementation Steps
1. **Search for backup files**
```bash
find app -name "*.backup*" -o -name "*.old*"
find components -name "*.backup*" -o -name "*.old*"
find lib -name "*.backup*" -o -name "*.old*"
```
2. **Verify files are not imported anywhere**
```bash
# For each found file, check if it's imported
grep -r "import.*from.*filename" .
```
3. **Delete files**
```bash
git rm app/chat/page.tsx.backup
# ... repeat for each file
```
4. **Check for commented-out code**
- Review recent commits for large commented blocks
- Remove if no longer needed
5. **Commit deletion**
```bash
git commit -m "chore: Remove backup and old files"
git push origin development
```
## Success Criteria
- ✅ No `.backup` files in codebase
- ✅ No `.old` files in codebase
- ✅ No files with "backup" in name
- ✅ Code still builds successfully
- ✅ All tests still pass
## Files to Delete
(Will be determined during search step)
Example:
1. `app/chat/page.tsx.backup`
2. Any other discovered backup files

View File

@@ -0,0 +1,290 @@
# Plan: Allow AI to Transition to Edit Mode in Chat
**Priority:** MEDIUM
**Dependencies:** None
**Affects:** User experience, conversation flow
## Overview
Currently, users must manually click "Create Node" to transition from chat to the node editor. We should allow the AI to intelligently suggest and trigger this transition when appropriate during conversation.
## Current Flow
```
User: "I want to write about quantum computing"
AI: "That's interesting! Tell me more..."
[User continues conversation]
User: *clicks "Create Node" manually*
→ Editor opens with AI-generated draft
```
## Proposed Flow
```
User: "I want to write about quantum computing"
AI: "That's interesting! Tell me more..."
[Conversation develops]
AI: "It sounds like you have a solid idea forming. Would you like me to help you draft a node about this?"
User: "Yes" / Clicks button
AI: *automatically transitions to edit mode*
→ Editor opens with AI-generated draft
```
## Implementation Approach
### 1. Add Tool Use to AI System Prompt
Update the AI system prompt to include a `createNode` tool:
#### `app/api/chat/route.ts`
```typescript
import { tool } from 'ai';
import { z } from 'zod';
const tools = {
createNode: tool({
description: 'Create a thought node when the user has developed a coherent idea worth capturing. Use this when the conversation has explored a topic deeply enough to write about it.',
parameters: z.object({
reason: z.string().describe('Why you think this conversation is ready to become a node'),
}),
execute: async ({ reason }) => {
return { shouldTransition: true, reason };
},
}),
};
export async function POST(request: Request) {
const { messages } = await request.json();
const result = streamText({
model: google('gemini-2.0-flash-exp'),
messages,
system: `You are a thoughtful AI assistant helping users explore and capture their ideas...
When a conversation reaches a natural conclusion or the user has developed a coherent thought, you can suggest creating a node to capture it. Use the createNode tool when:
- The conversation has explored a specific topic in depth
- The user has articulated a complete idea or insight
- There's enough substance for a meaningful blog post
- The user expresses readiness to capture their thoughts
Be conversational and natural when suggesting this - don't force it if the conversation is still developing.`,
tools,
maxSteps: 5,
});
return result.toDataStreamResponse();
}
```
### 2. Handle Tool Calls in Client
Update the chat interface to handle the createNode tool call:
#### `components/ChatInterface.tsx`
```typescript
'use client';
import { useChat } from 'ai/react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export function ChatInterface() {
const router = useRouter();
const { messages, input, handleInputChange, handleSubmit } = useChat({
api: '/api/chat',
onToolCall: async ({ toolCall }) => {
if (toolCall.toolName === 'createNode') {
// Show confirmation UI
setShowNodeCreationPrompt(true);
setNodeCreationReason(toolCall.args.reason);
}
},
});
const [showNodeCreationPrompt, setShowNodeCreationPrompt] = useState(false);
const [nodeCreationReason, setNodeCreationReason] = useState('');
const handleAcceptNodeCreation = async () => {
// Generate node draft
const response = await fetch('/api/generate-node-draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversation: messages,
}),
});
const { title, body } = await response.json();
// Transition to edit mode
router.push(`/edit?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`);
};
return (
<Stack h="100%">
{/* Chat messages */}
{/* Node creation prompt */}
{showNodeCreationPrompt && (
<Paper p="md" withBorder>
<Stack>
<Text size="sm" c="dimmed">{nodeCreationReason}</Text>
<Group>
<Button onClick={handleAcceptNodeCreation}>
Create Node
</Button>
<Button variant="subtle" onClick={() => setShowNodeCreationPrompt(false)}>
Continue Conversation
</Button>
</Group>
</Stack>
</Paper>
)}
</Stack>
);
}
```
### 3. Alternative: Button-Based Approach (Simpler)
Instead of using tool calls, simply add a button that appears contextually:
```typescript
export function ChatInterface() {
const { messages } = useChat();
const [showCreateButton, setShowCreateButton] = useState(false);
// Show "Create Node" button after 5+ messages
useEffect(() => {
if (messages.length >= 5) {
setShowCreateButton(true);
}
}, [messages.length]);
return (
<Stack>
{/* Messages */}
{showCreateButton && (
<Paper p="md" withBorder bg="gray.0">
<Group justify="space-between">
<Stack gap="xs">
<Text fw={500}>Ready to capture this idea?</Text>
<Text size="sm" c="dimmed">
You've explored this topic well. Would you like to create a node?
</Text>
</Stack>
<Button onClick={handleCreateNode}>
Create Node
</Button>
</Group>
</Paper>
)}
</Stack>
);
}
```
## Testing Strategy
### Manual Playwright MCP Test
```typescript
test('AI suggests creating node after deep conversation', async ({ page }) => {
await page.goto('/chat');
// Have a conversation
await sendMessage(page, 'I want to write about philosophy');
await waitForAIResponse(page);
await sendMessage(page, 'Specifically about existentialism');
await waitForAIResponse(page);
await sendMessage(page, 'And how it relates to modern life');
await waitForAIResponse(page);
// Check if AI suggests creating node
await expect(page.getByText(/create a node/i)).toBeVisible({ timeout: 30000 });
// Click accept
await page.click('button:has-text("Create Node")');
// Should navigate to editor
await expect(page).toHaveURL(/\/edit/);
await expect(page.getByRole('textbox', { name: /title/i })).toBeVisible();
});
```
### Magnitude Test
```typescript
import { test } from 'magnitude-test';
test('AI intelligently suggests creating node', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Have a conversation about quantum computing');
await agent.act('Discuss multiple aspects over 5+ messages');
await agent.check('AI suggests creating a node');
await agent.check('Suggestion includes reasoning');
await agent.act('Click "Create Node" button');
await agent.check('Transitions to edit mode');
await agent.check('Editor has AI-generated draft');
});
test('User can decline node creation and continue chatting', async (agent) => {
await agent.open('http://localhost:3000/chat');
await agent.act('Have a conversation that triggers node suggestion');
await agent.check('AI suggests creating a node');
await agent.act('Click "Continue Conversation"');
await agent.check('Suggestion dismisses');
await agent.check('Can continue chatting');
});
```
## Implementation Steps
1. **Choose approach**
- Tool-based (more intelligent, AI decides)
- Button-based (simpler, always available after N messages)
- Hybrid (both)
2. **Implement AI tool/prompt**
- Add createNode tool definition
- Update system prompt
- Handle tool calls
3. **Update ChatInterface**
- Add suggestion UI
- Handle accept/decline
- Transition to edit mode
4. **Test with Playwright MCP**
- Test suggestion appears
- Test accept flow
- Test decline flow
5. **Add Magnitude tests**
6. **Commit and push**
## Success Criteria
- ✅ AI suggests node creation at appropriate times
- ✅ User can accept and transition to edit mode
- ✅ User can decline and continue conversation
- ✅ Transition is smooth and intuitive
- ✅ Works well in conversation flow
- ✅ Playwright MCP tests pass
- ✅ Magnitude tests pass
## Files to Update
1. `app/api/chat/route.ts` - Add tool or enhanced prompt
2. `components/ChatInterface.tsx` - Add suggestion UI and handling
3. `tests/playwright/ai-to-edit.spec.ts` - Manual tests
4. `tests/magnitude/ai-to-edit.mag.ts` - Magnitude tests

View File

@@ -0,0 +1,254 @@
# Plan: Analysis - Why Wait for Three Nodes Before UMAP?
**Priority:** LOW - Analysis and potential optimization
**Dependencies:** None
**Affects:** User experience for early galaxy usage
## Question
Why do we wait until the user has created 3 nodes before running UMAP to calculate 3D coordinates? Is this an arbitrary choice or is there a technical reason?
## Current Implementation
```typescript
// app/api/nodes/route.ts (lines 277-305)
const totalNodes = countResult[0]?.[0]?.total || 0;
if (totalNodes >= 3) {
// Trigger UMAP calculation
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/calculate-graph`, {
method: 'POST',
headers: { 'Cookie': cookieHeader },
});
}
```
## UMAP Technical Requirements
### Minimum Data Points
UMAP (Uniform Manifold Approximation and Projection) is a dimensionality reduction algorithm. Let's investigate its minimum requirements:
1. **Mathematical minimum**: UMAP needs at least `nNeighbors + 1` data points
- Our config: `nNeighbors: Math.min(15, nodes.length - 1)`
- So technically we need minimum 2 points
2. **Practical minimum**: With 1-2 points, the projection is trivial:
- 1 point: Just sits at origin [0, 0, 0]
- 2 points: Linear projection on single axis
- 3+ points: Meaningful 3D spatial distribution
3. **Meaningful visualization**: For interesting galaxy visualization:
- 1 node: Just a single sphere (boring)
- 2 nodes: Two spheres in a line (boring)
- 3 nodes: Triangle configuration (starting to be interesting)
- 4+ nodes: Complex 3D structure (compelling)
## Options to Consider
### Option 1: Keep Current (3-node minimum) ✅ RECOMMENDED
**Pros:**
- Ensures meaningful visualization
- UMAP produces better results with more data
- Avoids wasted computation on trivial cases
- User has enough content to make galaxy worth exploring
**Cons:**
- User can't see galaxy until 3rd node
- Feels like arbitrary limitation
### Option 2: Allow 1+ nodes (Calculate always)
**Pros:**
- Galaxy available immediately
- No waiting for 3rd node
- Simpler logic
**Cons:**
- 1-2 nodes produce boring visualization (single point, line)
- Wasted UMAP computation on trivial cases
- Poor user experience showing "empty" galaxy
### Option 3: Fallback Layout for 1-2 Nodes
**Pros:**
- Galaxy available immediately
- 1-2 nodes get simple predetermined positions
- UMAP kicks in at 3+ for interesting layout
- Best of both worlds
**Cons:**
- More complex implementation
- Potential confusion when layout suddenly changes at 3rd node
```typescript
function calculateNodePositions(nodes: NodeData[]): NodeData[] {
if (nodes.length === 1) {
// Single node at origin
return [{
...nodes[0],
coords_3d: [0, 0, 0],
}];
}
if (nodes.length === 2) {
// Two nodes on X axis
return [
{ ...nodes[0], coords_3d: [-1, 0, 0] },
{ ...nodes[1], coords_3d: [1, 0, 0] },
];
}
// 3+ nodes: Use UMAP
return runUMAP(nodes);
}
```
### Option 4: Show Empty State with Onboarding
**Pros:**
- Clear communication about galaxy feature
- Educational for new users
- No computation wasted
- Encourages node creation
**Cons:**
- More UI work
- Doesn't solve the "when to calculate" question
```typescript
// app/galaxy/page.tsx
if (nodes.length === 0) {
return <EmptyState message="Create your first node to start your galaxy!" />;
}
if (nodes.length < 3) {
return <PartialState message={`Create ${3 - nodes.length} more nodes to see your galaxy visualization!`} />;
}
```
## Recommendation
**Keep the 3-node minimum** for the following reasons:
1. **User Experience**
- 1-2 nodes produce boring visualizations that don't showcase the galaxy feature
- Better to show compelling visualization from the start
- Empty state can explain "create 3 nodes to unlock galaxy"
2. **Technical Quality**
- UMAP produces better results with more data points
- 3 points is mathematical minimum for interesting 3D distribution
- Avoids wasted computation on trivial cases
3. **Product Story**
- Forces users to create meaningful content before "unlocking" visualization
- Makes galaxy feel like a reward for engagement
- Aligns with the product vision of "network of thoughts"
## Potential Enhancements
### 1. Better Onboarding
```typescript
// Show progress toward galaxy unlock
<Progress value={(nodes.length / 3) * 100} label={`${nodes.length}/3 nodes created`} />
```
### 2. Preview Mode
```typescript
// Show static preview of galaxy with 1-2 nodes
<GalaxyPreview nodes={nodes} message="Create 1 more node to unlock 3D visualization!" />
```
### 3. Configurable Threshold
```typescript
// Allow power users to adjust in settings
const UMAP_MINIMUM_NODES = userSettings.umapMinimum || 3;
```
## Implementation (If Changing)
If we decide to implement Option 3 (fallback layout):
1. **Update calculate-graph logic**
```typescript
if (nodes.length < 3) {
return simpleLayout(nodes);
}
return umapLayout(nodes);
```
2. **Add simple layout function**
```typescript
function simpleLayout(nodes: NodeData[]): NodeData[] {
// Predetermined positions for 1-2 nodes
}
```
3. **Update API response**
```typescript
return NextResponse.json({
nodes_mapped: nodes.length,
layout_method: nodes.length < 3 ? 'simple' : 'umap',
});
```
## Testing
If implementing changes:
### Playwright MCP Test
```typescript
test('Galaxy works with 1-2 nodes', async ({ page }) => {
// Create 1 node
await createNode(page, 'First Node');
await page.goto('/galaxy');
await expect(page.locator('canvas')).toBeVisible();
// Create 2nd node
await createNode(page, 'Second Node');
await page.goto('/galaxy');
await expect(page.locator('canvas')).toBeVisible();
// Should see 2 nodes
const nodeCount = await getNodeCount(page);
expect(nodeCount).toBe(2);
});
```
### Magnitude Test
```typescript
test('User understands galaxy requirement', async (agent) => {
await agent.open('http://localhost:3000/galaxy');
// With 0 nodes
await agent.check('Sees message about creating nodes');
await agent.check('Message says "3 nodes" or similar');
// After creating 1 node
await agent.act('Create first node');
await agent.open('http://localhost:3000/galaxy');
await agent.check('Sees progress toward 3 nodes');
});
```
## Success Criteria
- ✅ Clear documentation of why 3-node minimum exists
- ✅ User understands requirement through UI/messaging
- ✅ If changed: Works correctly with 1-2 nodes
- ✅ If changed: Smooth transition to UMAP at 3+ nodes
- ✅ Tests pass
## Files to Update (if implementing changes)
1. `app/api/calculate-graph/route.ts` - Add fallback layout logic
2. `app/galaxy/page.tsx` - Add better onboarding messaging
3. `docs/architecture.md` - Document decision (create if needed)
## Files to Create
1. `docs/decisions/umap-minimum-nodes.md` - Document the decision
2. `tests/playwright/galaxy-onboarding.spec.ts` - If implementing changes
3. `tests/magnitude/galaxy-onboarding.mag.ts` - If implementing changes