diff --git a/plans/01-playwright-scaffolding.md b/plans/01-playwright-scaffolding.md new file mode 100644 index 0000000..060a11f --- /dev/null +++ b/plans/01-playwright-scaffolding.md @@ -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({ + 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) diff --git a/plans/02-magnitude-tests-comprehensive.md b/plans/02-magnitude-tests-comprehensive.md new file mode 100644 index 0000000..9c4d20a --- /dev/null +++ b/plans/02-magnitude-tests-comprehensive.md @@ -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) diff --git a/plans/03-stream-ai-to-deepgram-tts.md b/plans/03-stream-ai-to-deepgram-tts.md new file mode 100644 index 0000000..2ae38e1 --- /dev/null +++ b/plans/03-stream-ai-to-deepgram-tts.md @@ -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(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 diff --git a/plans/04-fix-galaxy-node-clicking.md b/plans/04-fix-galaxy-node-clicking.md new file mode 100644 index 0000000..5f9f982 --- /dev/null +++ b/plans/04-fix-galaxy-node-clicking.md @@ -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(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 ( + + {node.title} + {node.body} + {node.atp_uri && ( + + View on Bluesky + + )} + + + ); +} +``` + +### 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) => { + // CRITICAL: Stop propagation to prevent canvas click handler + event.stopPropagation(); + + // Call parent onClick + onClick(node); + }; + + return ( + + + + + ); +} +``` + +#### 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 ( + <> + + + + {selectedNodeId && } + + + ); +} +``` + +### Fix 3: Canvas Click Handler + +If canvas has a click handler that closes modal, remove it or add condition: + +```typescript +// components/galaxy/ThoughtGalaxy.tsx + { + // Only close modal if clicking on background (not a node) + if (e.target === e.currentTarget) { + onBackgroundClick?.(); + } + }} +> + {/* ... */} + +``` + +## 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 diff --git a/plans/05-dark-light-mode-theme.md b/plans/05-dark-light-mode-theme.md new file mode 100644 index 0000000..3e40e8b --- /dev/null +++ b/plans/05-dark-light-mode-theme.md @@ -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 ( + + + + {/* Dynamic favicon based on theme */} + + + + + + {children} + + + + ); +} +``` + +### 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 ( + setColorScheme(computedColorScheme === 'dark' ? 'light' : 'dark')} + variant="subtle" + size="lg" + aria-label="Toggle color scheme" + > + {computedColorScheme === 'dark' ? ( + + ) : ( + + )} + + ); +} +``` + +### 4. Add Toggle to Desktop Navigation + +#### `components/DesktopNav.tsx` +```typescript +import { ThemeToggle } from './ThemeToggle'; + +export function DesktopNav() { + return ( + + + + {/* Navigation items */} + + + + + {/* Profile menu */} + + + + ); +} +``` + +### 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 + + + + + + + +``` + +#### `public/favicon-dark.svg` +```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 ( + + + + + {/* Node spheres - adjust color based on theme */} + + + {/* Link lines */} + + + ); +} +``` + +### 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 diff --git a/plans/06-fix-double-border-desktop.md b/plans/06-fix-double-border-desktop.md new file mode 100644 index 0000000..93ffc61 --- /dev/null +++ b/plans/06-fix-double-border-desktop.md @@ -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 + + {/* ... */} + + {/* ... */} + +``` + +## 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 diff --git a/plans/07-delete-backup-files.md b/plans/07-delete-backup-files.md new file mode 100644 index 0000000..783dd91 --- /dev/null +++ b/plans/07-delete-backup-files.md @@ -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 diff --git a/plans/08-ai-transition-to-edit.md b/plans/08-ai-transition-to-edit.md new file mode 100644 index 0000000..351d909 --- /dev/null +++ b/plans/08-ai-transition-to-edit.md @@ -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 ( + + {/* Chat messages */} + + {/* Node creation prompt */} + {showNodeCreationPrompt && ( + + + {nodeCreationReason} + + + + + + + )} + + ); +} +``` + +### 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 ( + + {/* Messages */} + + {showCreateButton && ( + + + + Ready to capture this idea? + + You've explored this topic well. Would you like to create a node? + + + + + + )} + + ); +} +``` + +## 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 diff --git a/plans/09-umap-minimum-nodes-analysis.md b/plans/09-umap-minimum-nodes-analysis.md new file mode 100644 index 0000000..5f0f6b7 --- /dev/null +++ b/plans/09-umap-minimum-nodes-analysis.md @@ -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 ; +} + +if (nodes.length < 3) { + return ; +} +``` + +## 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 + +``` + +### 2. Preview Mode +```typescript +// Show static preview of galaxy with 1-2 nodes + +``` + +### 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