From e16182375326b4b8200a97423ed096e7a858acd3 Mon Sep 17 00:00:00 2001 From: Tyler G Date: Sun, 23 Aug 2026 15:45:45 -0700 Subject: [PATCH] feat: Implement FeatureLens visual feedback and AI task staging overlay --- main.ts | 2 + src/db/connection.ts | 42 +++ src/db/notes.ts | 100 ++++++ src/routes/notes.ts | 71 +++++ viewer/featurelens.js | 710 ++++++++++++++++++++++++++++++++++++++++++ viewer/index.html | 4 + 6 files changed, 929 insertions(+) create mode 100644 src/db/notes.ts create mode 100644 src/routes/notes.ts create mode 100644 viewer/featurelens.js diff --git a/main.ts b/main.ts index 5892462..7aae4ad 100644 --- a/main.ts +++ b/main.ts @@ -7,6 +7,7 @@ import { printFarmRoutes } from "./src/routes/printfarm.ts"; import { travelerRoutes } from "./src/routes/traveler.ts"; import { hilRoutes } from "./src/routes/hil.ts"; import { configExportRoutes } from "./src/routes/config_export.ts"; +import { noteRoutes } from "./src/routes/notes.ts"; const app = new Hono(); @@ -35,6 +36,7 @@ app.route("/api/print-farm", printFarmRoutes); app.route("/api/travelers", travelerRoutes); app.route("/api/hil", hilRoutes); app.route("/api/config", configExportRoutes); +app.route("/api/notes", noteRoutes); // Serve the 3D Viewer frontend app.use("/*", serveStatic({ root: "./" })); diff --git a/src/db/connection.ts b/src/db/connection.ts index 2fdc06a..ba3a76d 100644 --- a/src/db/connection.ts +++ b/src/db/connection.ts @@ -154,6 +154,48 @@ export async function initDb(retries = 5, delayMs = 2000) { `; } + // 6. FeatureLens Visual Feedback & AI Task Staging Notes + await sql` + CREATE TABLE IF NOT EXISTS feature_notes ( + id VARCHAR(50) PRIMARY KEY, + title VARCHAR(200) NOT NULL, + description TEXT NOT NULL, + category VARCHAR(30) NOT NULL DEFAULT 'feature', + priority VARCHAR(20) NOT NULL DEFAULT 'medium', + status VARCHAR(20) NOT NULL DEFAULT 'open', + route VARCHAR(100) NOT NULL, + target_type VARCHAR(20) NOT NULL DEFAULT 'dom_element', + target_selector TEXT, + part_id VARCHAR(50), + coordinates_3d JSONB, + context_snapshot JSONB DEFAULT '{}'::jsonb, + suggested_files TEXT[] DEFAULT ARRAY[]::TEXT[], + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `; + + const fnCount = await sql`SELECT count(*) FROM feature_notes`; + if (fnCount[0].count === "0") { + await sql` + INSERT INTO feature_notes ( + id, title, description, category, priority, status, route, target_type, target_selector, part_id, suggested_files + ) VALUES ( + 'fn-init-001', + 'Add Live WebSockets for RP2040 Telemetry Stream', + 'Connect the WebSerial supervisor tab to a real-time WebSocket backend so fan curves and supercap voltages update dynamically without manual polling.', + 'feature', + 'high', + 'open', + '/viewer/index.html#diagnostics', + 'dom_element', + '#diagnostics .card', + 'rp2040', + ARRAY['src/routes/hil.ts', 'viewer/hil.js', 'main.ts'] + ) + `; + } + console.log("✅ All database schemas and seed data initialized successfully"); return; } catch (err: any) { diff --git a/src/db/notes.ts b/src/db/notes.ts new file mode 100644 index 0000000..1e08ea1 --- /dev/null +++ b/src/db/notes.ts @@ -0,0 +1,100 @@ +import { sql } from "./connection.ts"; + +export interface FeatureNote { + id: string; + title: string; + description: string; + category: "feature" | "bug" | "ui_ux" | "architecture"; + priority: "low" | "medium" | "high" | "critical"; + status: "open" | "in_progress" | "completed" | "archived"; + route: string; + target_type: "dom_element" | "3d_mesh" | "viewport_area" | "global"; + target_selector?: string; + part_id?: string; + coordinates_3d?: { x: number; y: number; z: number }; + context_snapshot?: Record; + suggested_files?: string[]; + created_at?: string; + updated_at?: string; +} + +export const getFeatureNotes = async (category?: string, status?: string): Promise => { + if (category && status) { + return await sql` + SELECT * FROM feature_notes + WHERE category = ${category} AND status = ${status} + ORDER BY created_at DESC + `; + } + if (category) { + return await sql` + SELECT * FROM feature_notes + WHERE category = ${category} + ORDER BY created_at DESC + `; + } + if (status) { + return await sql` + SELECT * FROM feature_notes + WHERE status = ${status} + ORDER BY created_at DESC + `; + } + return await sql` + SELECT * FROM feature_notes ORDER BY created_at DESC + `; +}; + +export const getFeatureNoteById = async (id: string): Promise => { + const result = await sql` + SELECT * FROM feature_notes WHERE id = ${id} + `; + return result[0] || null; +}; + +export const createFeatureNote = async (note: Partial): Promise => { + const id = note.id || `fn-${Date.now()}-${Math.floor(Math.random() * 1000)}`; + const coordinatesJson = note.coordinates_3d ? JSON.stringify(note.coordinates_3d) : null; + const contextJson = note.context_snapshot ? JSON.stringify(note.context_snapshot) : "{}"; + const filesArray = note.suggested_files && note.suggested_files.length > 0 ? note.suggested_files : []; + + const result = await sql` + INSERT INTO feature_notes ( + id, title, description, category, priority, status, route, target_type, + target_selector, part_id, coordinates_3d, context_snapshot, suggested_files + ) VALUES ( + ${id}, + ${note.title || "Untitled Note"}, + ${note.description || ""}, + ${note.category || "feature"}, + ${note.priority || "medium"}, + ${note.status || "open"}, + ${note.route || "/viewer/index.html"}, + ${note.target_type || "dom_element"}, + ${note.target_selector || null}, + ${note.part_id || null}, + ${coordinatesJson}::jsonb, + ${contextJson}::jsonb, + ${filesArray} + ) + RETURNING * + `; + return result[0]; +}; + +export const updateFeatureNoteStatus = async (id: string, status: string): Promise => { + return await sql` + UPDATE feature_notes + SET status = ${status}, updated_at = CURRENT_TIMESTAMP + WHERE id = ${id} + RETURNING * + `; +}; + +export const deleteFeatureNote = async (id: string): Promise => { + const result = await sql` + DELETE FROM feature_notes WHERE id = ${id} + RETURNING id + `; + return result.length > 0; +}; diff --git a/src/routes/notes.ts b/src/routes/notes.ts new file mode 100644 index 0000000..6b8f7ea --- /dev/null +++ b/src/routes/notes.ts @@ -0,0 +1,71 @@ +import { Hono } from "hono"; +import { + getFeatureNotes, + getFeatureNoteById, + createFeatureNote, + updateFeatureNoteStatus, + deleteFeatureNote +} from "../db/notes.ts"; + +export const noteRoutes = new Hono(); + +noteRoutes.get("/", async (c) => { + try { + const category = c.req.query("category"); + const status = c.req.query("status"); + const notes = await getFeatureNotes(category, status); + return c.json(notes); + } catch (error: any) { + return c.json({ error: error.message }, 500); + } +}); + +noteRoutes.get("/:id", async (c) => { + const id = c.req.param("id"); + try { + const note = await getFeatureNoteById(id); + if (!note) return c.json({ error: "Note not found" }, 404); + return c.json(note); + } catch (error: any) { + return c.json({ error: error.message }, 500); + } +}); + +noteRoutes.post("/", async (c) => { + try { + const body = await c.req.json(); + if (!body.title) { + return c.json({ error: "Title is required" }, 400); + } + const created = await createFeatureNote(body); + return c.json(created, 201); + } catch (error: any) { + return c.json({ error: error.message }, 500); + } +}); + +noteRoutes.put("/:id/status", async (c) => { + const id = c.req.param("id"); + try { + const body = await c.req.json(); + if (!body.status) { + return c.json({ error: "Status is required" }, 400); + } + const updated = await updateFeatureNoteStatus(id, body.status); + if (updated.length === 0) return c.json({ error: "Note not found" }, 404); + return c.json(updated[0]); + } catch (error: any) { + return c.json({ error: error.message }, 500); + } +}); + +noteRoutes.delete("/:id", async (c) => { + const id = c.req.param("id"); + try { + const deleted = await deleteFeatureNote(id); + if (!deleted) return c.json({ error: "Note not found" }, 404); + return c.json({ success: true, id }); + } catch (error: any) { + return c.json({ error: error.message }, 500); + } +}); diff --git a/viewer/featurelens.js b/viewer/featurelens.js new file mode 100644 index 0000000..dd78709 --- /dev/null +++ b/viewer/featurelens.js @@ -0,0 +1,710 @@ +/** + * FeatureLens — Visual Feedback & AI Task Staging Overlay + * Zero-dependency Web Component with Shadow DOM Isolation + */ +class FeatureLensElement extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.isOpen = false; + this.isInspectMode = false; + this.notes = []; + this.currentTarget = null; + this.hoverOverlay = null; + this.raycaster = null; + this.mouse = null; + } + + connectedCallback() { + this.render(); + this.initInspector(); + this.loadNotes(); + this.setupGlobalEvents(); + } + + setupGlobalEvents() { + // Listen for custom open event + window.addEventListener('featurelens:toggle', () => this.toggleDrawer()); + } + + async loadNotes() { + try { + const res = await fetch('/api/notes'); + if (res.ok) { + this.notes = await res.json(); + this.updateBadge(); + this.renderNotesList(); + } + } catch (err) { + console.warn('[FeatureLens] Could not fetch notes from backend, using memory cache:', err); + } + } + + updateBadge() { + const badge = this.shadowRoot.querySelector('#fl-badge'); + if (badge) { + const openCount = this.notes.filter(n => n.status === 'open').length; + badge.textContent = openCount; + badge.style.display = openCount > 0 ? 'inline-block' : 'none'; + } + } + + toggleDrawer(forceState) { + this.isOpen = typeof forceState === 'boolean' ? forceState : !this.isOpen; + const drawer = this.shadowRoot.querySelector('#fl-drawer'); + const activator = this.shadowRoot.querySelector('#fl-activator'); + if (drawer) { + if (this.isOpen) { + drawer.classList.add('open'); + activator.classList.add('active'); + this.loadNotes(); + } else { + drawer.classList.remove('open'); + activator.classList.remove('active'); + this.stopInspectMode(); + } + } + } + + // ------------------------------------------------------------- + // Visual Inspector (2D DOM & 3D Three.js Raycaster) + // ------------------------------------------------------------- + initInspector() { + this.hoverOverlay = document.createElement('div'); + this.hoverOverlay.id = 'fl-hover-highlight'; + this.hoverOverlay.style.cssText = ` + position: absolute; + pointer-events: none; + border: 2px solid #06b6d4; + background: rgba(6, 182, 212, 0.15); + z-index: 999998; + display: none; + transition: all 0.05s ease-out; + border-radius: 4px; + box-shadow: 0 0 12px rgba(6, 182, 212, 0.5); + `; + document.body.appendChild(this.hoverOverlay); + + if (window.THREE) { + this.raycaster = new THREE.Raycaster(); + this.mouse = new THREE.Vector2(); + } + } + + startInspectMode() { + this.isInspectMode = true; + this.toggleDrawer(false); + + const banner = document.createElement('div'); + banner.id = 'fl-inspect-banner'; + banner.style.cssText = ` + position: fixed; + top: 1rem; + left: 50%; + transform: translateX(-50%); + background: #0f172a; + border: 1px solid #06b6d4; + color: #38bdf8; + padding: 0.5rem 1rem; + border-radius: 9999px; + font-family: 'JetBrains Mono', monospace; + font-size: 0.8rem; + font-weight: 700; + box-shadow: 0 10px 25px rgba(0,0,0,0.8); + z-index: 999999; + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + `; + banner.innerHTML = `🎯 Click any 2D UI element or 3D CAD part to attach note [ESC to cancel]`; + banner.onclick = () => this.stopInspectMode(); + document.body.appendChild(banner); + + this._onMouseMove = this.handlePointerMove.bind(this); + this._onClick = this.handlePointerClick.bind(this); + this._onKeyDown = (e) => { if (e.key === 'Escape') this.stopInspectMode(); }; + + window.addEventListener('mousemove', this._onMouseMove, true); + window.addEventListener('click', this._onClick, true); + window.addEventListener('keydown', this._onKeyDown, true); + } + + stopInspectMode() { + this.isInspectMode = false; + if (this.hoverOverlay) this.hoverOverlay.style.display = 'none'; + const banner = document.getElementById('fl-inspect-banner'); + if (banner) banner.remove(); + + window.removeEventListener('mousemove', this._onMouseMove, true); + window.removeEventListener('click', this._onClick, true); + window.removeEventListener('keydown', this._onKeyDown, true); + } + + handlePointerMove(e) { + if (!this.isInspectMode) return; + + // Check if hovering 3D canvas + if (window.scene && window.camera && e.target.tagName === 'CANVAS' && this.raycaster) { + const rect = e.target.getBoundingClientRect(); + this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + this.raycaster.setFromCamera(this.mouse, window.camera); + + const intersects = this.raycaster.intersectObjects(window.scene.children, true); + if (intersects.length > 0) { + const hit = intersects[0]; + let mesh = hit.object; + let partId = mesh.userData?.partId || mesh.name || '3d-mesh'; + + // Highlight 3D part + this.hoverOverlay.style.display = 'none'; + return; + } + } + + // 2D Element highlight + if (e.target && e.target !== this.hoverOverlay && !e.target.closest('feature-lens')) { + const rect = e.target.getBoundingClientRect(); + this.hoverOverlay.style.display = 'block'; + this.hoverOverlay.style.top = `${rect.top + window.scrollY}px`; + this.hoverOverlay.style.left = `${rect.left + window.scrollX}px`; + this.hoverOverlay.style.width = `${rect.width}px`; + this.hoverOverlay.style.height = `${rect.height}px`; + } + } + + handlePointerClick(e) { + if (!this.isInspectMode) return; + e.preventDefault(); + e.stopPropagation(); + + let targetType = 'dom_element'; + let targetSelector = ''; + let partId = null; + let coords3d = null; + + // 3D Intersect check + if (window.scene && window.camera && e.target.tagName === 'CANVAS' && this.raycaster) { + const rect = e.target.getBoundingClientRect(); + this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + this.raycaster.setFromCamera(this.mouse, window.camera); + + const intersects = this.raycaster.intersectObjects(window.scene.children, true); + if (intersects.length > 0) { + const hit = intersects[0]; + targetType = '3d_mesh'; + partId = hit.object.userData?.partId || hit.object.name || 'chassis-part'; + coords3d = { + x: parseFloat(hit.point.x.toFixed(2)), + y: parseFloat(hit.point.y.toFixed(2)), + z: parseFloat(hit.point.z.toFixed(2)) + }; + } + } + + if (targetType === 'dom_element') { + targetSelector = this.computeSelectorPath(e.target); + } + + this.currentTarget = { + type: targetType, + selector: targetSelector, + partId: partId, + coords3d: coords3d, + route: window.location.pathname + window.location.hash + }; + + this.stopInspectMode(); + this.toggleDrawer(true); + this.openCreateForm(); + } + + computeSelectorPath(el) { + if (!el || el === document.body || el === document.documentElement) return 'body'; + if (el.id) return `#${el.id}`; + if (el.className && typeof el.className === 'string') { + const classes = el.className.trim().split(/\s+/).slice(0, 2).join('.'); + if (classes) return `${el.tagName.toLowerCase()}.${classes}`; + } + return `${el.tagName.toLowerCase()}`; + } + + // ------------------------------------------------------------- + // AI Prompt Formatter + // ------------------------------------------------------------- + generateAiPrompt(note) { + const suggestedFiles = note.suggested_files && note.suggested_files.length > 0 + ? note.suggested_files.join(', ') + : 'viewer/index.html, src/routes/notes.ts'; + + let targetDesc = `DOM Element: \`${note.target_selector || 'Global View'}\``; + if (note.target_type === '3d_mesh') { + targetDesc = `3D Mesh Part: \`${note.part_id}\` (Coords: X:${note.coordinates_3d?.x || 0}, Y:${note.coordinates_3d?.y || 0}, Z:${note.coordinates_3d?.z || 0})`; + } + + return ` +Feature Target: [${note.title}] +Category: ${note.category?.toUpperCase()} | Priority: ${note.priority?.toUpperCase()} | Status: ${note.status?.toUpperCase()} +Target Element: ${targetDesc} +Route: ${note.route || window.location.pathname} + +Task / Improvement Specification: +${note.description} + +Technical Context & Candidate Source Files: +- Location / Route: ${note.route} +- Target Type: ${note.target_type} +- Suggested Relevant Files: ${suggestedFiles} +- Environment: 6U Tri-Mode Storage Array (Deno/Hono/Postgres + Three.js WebGL) +`; + } + + async copyAiPrompt(noteId) { + const note = this.notes.find(n => n.id === noteId); + if (!note) return; + const prompt = this.generateAiPrompt(note); + try { + await navigator.clipboard.writeText(prompt); + alert('✅ AI Task Prompt copied to clipboard! Paste it directly into your AI assistant.'); + } catch (err) { + console.warn('Clipboard write failed, showing text box:', err); + prompt('Copy your AI task prompt:', prompt); + } + } + + // ------------------------------------------------------------- + // Note CRUD Actions + // ------------------------------------------------------------- + openCreateForm() { + const formContainer = this.shadowRoot.querySelector('#fl-form-container'); + const listContainer = this.shadowRoot.querySelector('#fl-list-container'); + if (formContainer && listContainer) { + formContainer.style.display = 'block'; + listContainer.style.display = 'none'; + + const targetLabel = this.shadowRoot.querySelector('#fl-target-label'); + if (targetLabel) { + if (this.currentTarget?.type === '3d_mesh') { + targetLabel.innerHTML = `🎯 3D Mesh: ${this.currentTarget.partId} (X:${this.currentTarget.coords3d.x}, Y:${this.currentTarget.coords3d.y}, Z:${this.currentTarget.coords3d.z})`; + } else if (this.currentTarget?.selector) { + targetLabel.innerHTML = `🎯 2D Element: ${this.currentTarget.selector}`; + } else { + targetLabel.innerHTML = `🌐 Global App Scope`; + } + } + } + } + + closeCreateForm() { + const formContainer = this.shadowRoot.querySelector('#fl-form-container'); + const listContainer = this.shadowRoot.querySelector('#fl-list-container'); + if (formContainer && listContainer) { + formContainer.style.display = 'none'; + listContainer.style.display = 'block'; + this.currentTarget = null; + } + } + + async saveNote() { + const titleInput = this.shadowRoot.querySelector('#fl-title-input'); + const descInput = this.shadowRoot.querySelector('#fl-desc-input'); + const catSelect = this.shadowRoot.querySelector('#fl-cat-select'); + const prioSelect = this.shadowRoot.querySelector('#fl-prio-select'); + + if (!titleInput || !titleInput.value.trim()) { + alert('Please enter a note title.'); + return; + } + + // Auto deduce candidate files + let suggestedFiles = ['viewer/index.html']; + if (this.currentTarget?.type === '3d_mesh') { + suggestedFiles.push('viewer/index.html#threejs-cad', 'mechanical/'); + } else if (this.currentTarget?.selector?.includes('bom')) { + suggestedFiles.push('src/routes/bom.ts', 'src/db/bom.ts', 'viewer/bom.js'); + } else if (this.currentTarget?.selector?.includes('traveler')) { + suggestedFiles.push('src/routes/traveler.ts', 'src/db/traveler.ts', 'viewer/traveler.js'); + } else if (this.currentTarget?.selector?.includes('hil') || this.currentTarget?.selector?.includes('diagnostics')) { + suggestedFiles.push('src/routes/hil.ts', 'src/db/hil.ts', 'viewer/hil.js'); + } + + const payload = { + title: titleInput.value.trim(), + description: descInput ? descInput.value.trim() : '', + category: catSelect ? catSelect.value : 'feature', + priority: prioSelect ? prioSelect.value : 'medium', + status: 'open', + route: window.location.pathname + window.location.hash, + target_type: this.currentTarget?.type || 'global', + target_selector: this.currentTarget?.selector || null, + part_id: this.currentTarget?.partId || null, + coordinates_3d: this.currentTarget?.coords3d || null, + context_snapshot: { + viewport: `${window.innerWidth}x${window.innerHeight}`, + userAgent: navigator.userAgent, + timestamp: new Date().toISOString() + }, + suggested_files: suggestedFiles + }; + + try { + const res = await fetch('/api/notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (res.ok) { + titleInput.value = ''; + if (descInput) descInput.value = ''; + this.closeCreateForm(); + await this.loadNotes(); + } + } catch (err) { + console.error('Error saving note:', err); + } + } + + async updateStatus(id, newStatus) { + try { + await fetch(`/api/notes/${id}/status`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: newStatus }) + }); + await this.loadNotes(); + } catch (err) { + console.error('Error updating status:', err); + } + } + + async deleteNote(id) { + if (!confirm('Delete this feature note?')) return; + try { + await fetch(`/api/notes/${id}`, { method: 'DELETE' }); + await this.loadNotes(); + } catch (err) { + console.error('Error deleting note:', err); + } + } + + // ------------------------------------------------------------- + // Render Shadow DOM Template + // ------------------------------------------------------------- + render() { + this.shadowRoot.innerHTML = ` + + + +
+ 🔍 FeatureLens + +
+ + +
+
+
+ FeatureLens Studio + (OSHW-AI) +
+ +
+ +
+
+ + +
+ + + + + +
+
Loading notes...
+
+
+
+ `; + } + + renderNotesList() { + const container = this.shadowRoot.querySelector('#fl-list-container'); + if (!container) return; + container.innerHTML = ''; + + if (this.notes.length === 0) { + container.innerHTML = `
No active feature notes. Click Inspect & Pin to create one!
`; + return; + } + + this.notes.forEach(note => { + const card = document.createElement('div'); + card.className = 'fl-card'; + + let targetInfo = note.target_selector ? `${note.target_selector}` : 'Global Scope'; + if (note.target_type === '3d_mesh') { + targetInfo = `3D Part: ${note.part_id}`; + } + + card.innerHTML = ` +
+ ${note.category} + ${note.status} +
+
${note.title}
+
${targetInfo}
+
${note.description || 'No description provided.'}
+ +
+ + + +
+ `; + container.appendChild(card); + }); + } +} + +// Register Custom Web Component +if (!customElements.get('feature-lens')) { + customElements.define('feature-lens', FeatureLensElement); +} diff --git a/viewer/index.html b/viewer/index.html index ce47437..5a5672b 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -994,6 +994,7 @@ + +