feat: Implement FeatureLens visual feedback and AI task staging overlay

This commit is contained in:
Tyler G 2026-08-23 15:45:45 -07:00
parent 87e79f42ef
commit e161823753
6 changed files with 929 additions and 0 deletions

View File

@ -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: "./" }));

View File

@ -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) {

100
src/db/notes.ts Normal file
View File

@ -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<string, any>;
suggested_files?: string[];
created_at?: string;
updated_at?: string;
}
export const getFeatureNotes = async (category?: string, status?: string): Promise<FeatureNote[]> => {
if (category && status) {
return await sql<FeatureNote[]>`
SELECT * FROM feature_notes
WHERE category = ${category} AND status = ${status}
ORDER BY created_at DESC
`;
}
if (category) {
return await sql<FeatureNote[]>`
SELECT * FROM feature_notes
WHERE category = ${category}
ORDER BY created_at DESC
`;
}
if (status) {
return await sql<FeatureNote[]>`
SELECT * FROM feature_notes
WHERE status = ${status}
ORDER BY created_at DESC
`;
}
return await sql<FeatureNote[]>`
SELECT * FROM feature_notes ORDER BY created_at DESC
`;
};
export const getFeatureNoteById = async (id: string): Promise<FeatureNote | null> => {
const result = await sql<FeatureNote[]>`
SELECT * FROM feature_notes WHERE id = ${id}
`;
return result[0] || null;
};
export const createFeatureNote = async (note: Partial<FeatureNote>): Promise<FeatureNote> => {
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<FeatureNote[]>`
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<FeatureNote[]> => {
return await sql<FeatureNote[]>`
UPDATE feature_notes
SET status = ${status}, updated_at = CURRENT_TIMESTAMP
WHERE id = ${id}
RETURNING *
`;
};
export const deleteFeatureNote = async (id: string): Promise<boolean> => {
const result = await sql`
DELETE FROM feature_notes WHERE id = ${id}
RETURNING id
`;
return result.length > 0;
};

71
src/routes/notes.ts Normal file
View File

@ -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);
}
});

710
viewer/featurelens.js Normal file
View File

@ -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 = `<span>🎯 Click any 2D UI element or 3D CAD part to attach note</span> <span style="color:#ef4444; margin-left:0.5rem;">[ESC to cancel]</span>`;
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 `<USER_REQUEST>
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)
</USER_REQUEST>`;
}
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: <strong>${this.currentTarget.partId}</strong> (X:${this.currentTarget.coords3d.x}, Y:${this.currentTarget.coords3d.y}, Z:${this.currentTarget.coords3d.z})`;
} else if (this.currentTarget?.selector) {
targetLabel.innerHTML = `🎯 2D Element: <code>${this.currentTarget.selector}</code>`;
} 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 = `
<style>
:host {
--fl-bg: #0b0f17;
--fl-card-bg: #0f172a;
--fl-border: #1e293b;
--fl-accent: #06b6d4;
--fl-accent-hover: #0891b2;
--fl-emerald: #10b981;
--fl-amber: #f59e0b;
--fl-rose: #ef4444;
--fl-text: #f8fafc;
--fl-text-muted: #94a3b8;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
z-index: 999990;
position: relative;
}
#fl-activator {
position: fixed;
bottom: 1.25rem;
right: 1.25rem;
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
border: 1px solid var(--fl-accent);
color: var(--fl-text);
padding: 0.6rem 1rem;
border-radius: 9999px;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
font-weight: 700;
box-shadow: 0 10px 25px -5px rgba(6, 182, 212, 0.4);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 999991;
user-select: none;
}
#fl-activator:hover {
transform: translateY(-2px);
box-shadow: 0 15px 30px -5px rgba(6, 182, 212, 0.6);
border-color: #38bdf8;
}
#fl-activator.active {
background: var(--fl-accent);
color: #0f172a;
}
.fl-badge {
background: var(--fl-rose);
color: white;
border-radius: 9999px;
padding: 0.15rem 0.45rem;
font-size: 0.7rem;
font-weight: 800;
}
#fl-drawer {
position: fixed;
top: 0;
right: -420px;
width: 400px;
height: 100vh;
background: rgba(11, 15, 23, 0.95);
backdrop-filter: blur(16px);
border-left: 1px solid var(--fl-border);
box-shadow: -15px 0 35px rgba(0, 0, 0, 0.7);
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 999992;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#fl-drawer.open {
right: 0;
}
.fl-header {
padding: 1rem;
border-bottom: 1px solid var(--fl-border);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--fl-bg);
}
.fl-title {
font-size: 0.95rem;
font-weight: 800;
color: var(--fl-accent);
display: flex;
align-items: center;
gap: 0.4rem;
}
.fl-close-btn {
background: transparent;
border: none;
color: var(--fl-text-muted);
font-size: 1.2rem;
cursor: pointer;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.fl-close-btn:hover {
color: var(--fl-text);
background: var(--fl-card-bg);
}
.fl-body {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.fl-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.fl-btn {
background: var(--fl-card-bg);
border: 1px solid var(--fl-border);
color: var(--fl-text);
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
flex: 1;
transition: all 0.15s ease;
}
.fl-btn:hover {
border-color: var(--fl-accent);
background: #1e293b;
}
.fl-btn-primary {
background: var(--fl-accent);
color: #0f172a;
border: none;
font-weight: 700;
}
.fl-btn-primary:hover {
background: #38bdf8;
}
.fl-card {
background: var(--fl-card-bg);
border: 1px solid var(--fl-border);
border-radius: 8px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 0.75rem;
position: relative;
}
.fl-card:hover {
border-color: #334155;
}
.fl-tag {
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
}
.fl-tag-feature { background: rgba(6, 182, 212, 0.15); color: #38bdf8; }
.fl-tag-bug { background: rgba(239, 68, 68, 0.15); color: #f87171; }
.fl-tag-ui_ux { background: rgba(16, 185, 129, 0.15); color: #34d399; }
.fl-tag-architecture { background: rgba(245, 158, 11, 0.15); color: #fbbf24; }
.fl-input, .fl-textarea, .fl-select {
width: 100%;
background: #090d16;
border: 1px solid var(--fl-border);
color: var(--fl-text);
padding: 0.5rem;
border-radius: 6px;
font-size: 0.75rem;
box-sizing: border-box;
font-family: inherit;
}
.fl-input:focus, .fl-textarea:focus, .fl-select:focus {
outline: none;
border-color: var(--fl-accent);
}
.fl-textarea {
resize: vertical;
min-height: 80px;
}
</style>
<!-- Floating Trigger Button -->
<div id="fl-activator" onclick="this.getRootNode().host.toggleDrawer()">
<span>🔍 FeatureLens</span>
<span id="fl-badge" class="fl-badge" style="display:none;">0</span>
</div>
<!-- Slide-Out Drawer Panel -->
<div id="fl-drawer">
<div class="fl-header">
<div class="fl-title">
<span>FeatureLens Studio</span>
<span style="font-size:0.65rem; color:var(--fl-text-muted); font-weight:400;">(OSHW-AI)</span>
</div>
<button class="fl-close-btn" onclick="this.getRootNode().host.toggleDrawer(false)">&times;</button>
</div>
<div class="fl-body">
<div class="fl-actions">
<button class="fl-btn fl-btn-primary" onclick="this.getRootNode().host.startInspectMode()">
🎯 Inspect & Pin
</button>
<button class="fl-btn" onclick="this.getRootNode().host.openCreateForm()">
New Note
</button>
</div>
<!-- Create Note Form (Hidden by default) -->
<div id="fl-form-container" style="display:none;">
<div class="fl-card" style="border-color:var(--fl-accent);">
<div style="font-weight:700; color:var(--fl-accent);">Create AI Staging Note</div>
<div id="fl-target-label" style="font-size:0.65rem; color:var(--fl-text-muted);">🌐 Global Target</div>
<input type="text" id="fl-title-input" class="fl-input" placeholder="Title (e.g. Wire WebSocket telemetry)" />
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:0.4rem;">
<select id="fl-cat-select" class="fl-select">
<option value="feature">Feature Request</option>
<option value="bug">Bug Report</option>
<option value="ui_ux">UI/UX Polish</option>
<option value="architecture">Architecture Spike</option>
</select>
<select id="fl-prio-select" class="fl-select">
<option value="medium">Priority: Medium</option>
<option value="high">Priority: High</option>
<option value="critical">Priority: Critical</option>
<option value="low">Priority: Low</option>
</select>
</div>
<textarea id="fl-desc-input" class="fl-textarea" placeholder="Describe the idea or technical requirement for the AI agent..."></textarea>
<div style="display:flex; gap:0.4rem; margin-top:0.3rem;">
<button class="fl-btn fl-btn-primary" onclick="this.getRootNode().host.saveNote()">Save to Postgres</button>
<button class="fl-btn" onclick="this.getRootNode().host.closeCreateForm()">Cancel</button>
</div>
</div>
</div>
<!-- Notes List Container -->
<div id="fl-list-container" style="display:flex; flex-direction:column; gap:0.5rem;">
<div style="color:var(--fl-text-muted); font-size:0.75rem; text-align:center;">Loading notes...</div>
</div>
</div>
</div>
`;
}
renderNotesList() {
const container = this.shadowRoot.querySelector('#fl-list-container');
if (!container) return;
container.innerHTML = '';
if (this.notes.length === 0) {
container.innerHTML = `<div style="color:var(--fl-text-muted); font-size:0.75rem; text-align:center; padding:1rem;">No active feature notes. Click <strong>Inspect & Pin</strong> to create one!</div>`;
return;
}
this.notes.forEach(note => {
const card = document.createElement('div');
card.className = 'fl-card';
let targetInfo = note.target_selector ? `<code>${note.target_selector}</code>` : 'Global Scope';
if (note.target_type === '3d_mesh') {
targetInfo = `3D Part: <strong>${note.part_id}</strong>`;
}
card.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<span class="fl-tag fl-tag-${note.category}">${note.category}</span>
<span style="font-size:0.65rem; color:${note.status === 'completed' ? 'var(--fl-emerald)' : 'var(--fl-text-muted)'}; font-weight:700; text-transform:uppercase;">${note.status}</span>
</div>
<div style="font-weight:700; color:var(--fl-text); font-size:0.8rem;">${note.title}</div>
<div style="color:var(--fl-text-muted); font-size:0.65rem;">${targetInfo}</div>
<div style="color:#cbd5e1; font-size:0.7rem; margin-top:0.2rem; line-height:1.3;">${note.description || 'No description provided.'}</div>
<div style="display:flex; gap:0.4rem; margin-top:0.4rem; padding-top:0.4rem; border-top:1px solid var(--fl-border);">
<button class="fl-btn fl-btn-primary" style="font-size:0.65rem; padding:0.25rem 0.5rem;" onclick="this.getRootNode().host.copyAiPrompt('${note.id}')">
📋 Copy AI Prompt
</button>
<button class="fl-btn" style="font-size:0.65rem; padding:0.25rem 0.4rem;" onclick="this.getRootNode().host.updateStatus('${note.id}', '${note.status === 'completed' ? 'open' : 'completed'}')">
${note.status === 'completed' ? 'Reopen' : 'Complete'}
</button>
<button class="fl-btn" style="font-size:0.65rem; padding:0.25rem 0.4rem; color:var(--fl-rose);" onclick="this.getRootNode().host.deleteNote('${note.id}')">
🗑
</button>
</div>
`;
container.appendChild(card);
});
}
}
// Register Custom Web Component
if (!customElements.get('feature-lens')) {
customElements.define('feature-lens', FeatureLensElement);
}

View File

@ -994,6 +994,7 @@
<script src="./traveler.js"></script>
<script src="./hil.js"></script>
<script src="./config_gen.js"></script>
<script src="./featurelens.js"></script>
<script>
let scene, camera, renderer, controls;
let groupSkeleton, groupSheets, groupFacade, groupDrives, groupFans, groupPcba, groupIsolatedPart;
@ -1049,7 +1050,9 @@
const canvas = document.getElementById('webgl-canvas');
scene = new THREE.Scene();
window.scene = scene;
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 1, 3000);
window.camera = camera;
camera.position.set(650, 450, 750);
renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true });
@ -2309,5 +2312,6 @@
});
};
</script>
<feature-lens></feature-lens>
</body>
</html>