custom-nas/viewer/featurelens.js

787 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* FeatureLens — Visual Feedback & AI Task Staging Overlay
* Zero-dependency Web Component with Shadow DOM Isolation & Mobile Touch Ergonomics
*/
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() {
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] Backend unreachable, 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 Touch/Pointer Engine)
// -------------------------------------------------------------
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.775rem;
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;
max-width: 90vw;
box-sizing: border-box;
text-align: center;
justify-content: center;
`;
banner.innerHTML = `<span>🎯 Tap any 2D UI element or 3D CAD part</span> <span style="color:#ef4444; margin-left:0.5rem;">[Cancel]</span>`;
banner.onclick = () => this.stopInspectMode();
document.body.appendChild(banner);
this._onPointerMove = this.handlePointerMove.bind(this);
this._onPointerClick = this.handlePointerClick.bind(this);
this._onKeyDown = (e) => { if (e.key === 'Escape') this.stopInspectMode(); };
window.addEventListener('mousemove', this._onPointerMove, true);
window.addEventListener('touchmove', this._onPointerMove, { passive: false, capture: true });
window.addEventListener('click', this._onPointerClick, true);
window.addEventListener('touchend', this._onPointerClick, { passive: false, capture: 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._onPointerMove, true);
window.removeEventListener('touchmove', this._onPointerMove, { passive: false, capture: true });
window.removeEventListener('click', this._onPointerClick, true);
window.removeEventListener('touchend', this._onPointerClick, { passive: false, capture: true });
window.removeEventListener('keydown', this._onKeyDown, true);
}
getPointerCoords(e) {
if (e.touches && e.touches.length > 0) {
return { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY, target: document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY) };
}
if (e.changedTouches && e.changedTouches.length > 0) {
return { clientX: e.changedTouches[0].clientX, clientY: e.changedTouches[0].clientY, target: document.elementFromPoint(e.changedTouches[0].clientX, e.changedTouches[0].clientY) };
}
return { clientX: e.clientX, clientY: e.clientY, target: e.target };
}
handlePointerMove(e) {
if (!this.isInspectMode) return;
const { clientX, clientY, target } = this.getPointerCoords(e);
if (!target) return;
// Check if hovering 3D canvas
if (window.scene && window.camera && target.tagName === 'CANVAS' && this.raycaster) {
const rect = target.getBoundingClientRect();
this.mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((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) {
this.hoverOverlay.style.display = 'none';
return;
}
}
// 2D Element highlight
if (target && target !== this.hoverOverlay && !target.closest('feature-lens')) {
const rect = 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;
if (e.cancelable) e.preventDefault();
e.stopPropagation();
const { clientX, clientY, target } = this.getPointerCoords(e);
if (!target) return;
let targetType = 'dom_element';
let targetSelector = '';
let partId = null;
let coords3d = null;
// 3D Intersect check
if (window.scene && window.camera && target.tagName === 'CANVAS' && this.raycaster) {
const rect = target.getBoundingClientRect();
this.mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((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(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 promptText = this.generateAiPrompt(note);
try {
await navigator.clipboard.writeText(promptText);
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:', promptText);
}
}
// -------------------------------------------------------------
// 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;
}
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-sheet-handle {
display: none;
width: 36px;
height: 4px;
background: #475569;
border-radius: 9999px;
margin: 0 auto 0.5rem auto;
}
#fl-activator {
position: fixed;
bottom: max(1.25rem, env(safe-area-inset-bottom));
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;
min-height: 44px;
touch-action: manipulation;
}
#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: -440px;
width: 420px;
height: 100vh;
background: rgba(11, 15, 23, 0.98);
backdrop-filter: blur(20px);
border-left: 1px solid var(--fl-border);
box-shadow: -15px 0 35px rgba(0, 0, 0, 0.85);
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1), bottom 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.4rem;
cursor: pointer;
padding: 0.25rem 0.6rem;
border-radius: 6px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.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;
-webkit-overflow-scrolling: touch;
}
.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.55rem 0.75rem;
border-radius: 6px;
font-size: 0.775rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
flex: 1;
min-height: 44px;
transition: all 0.15s ease;
touch-action: manipulation;
}
.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.85rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
font-size: 0.775rem;
position: relative;
}
.fl-tag {
padding: 0.15rem 0.45rem;
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.6rem;
border-radius: 6px;
font-size: 0.8rem;
box-sizing: border-box;
font-family: inherit;
min-height: 44px;
}
.fl-input:focus, .fl-textarea:focus, .fl-select:focus {
outline: none;
border-color: var(--fl-accent);
}
.fl-textarea {
resize: vertical;
min-height: 90px;
}
/* Mobile Responsive Bottom Sheet Transformation */
@media (max-width: 640px) {
#fl-activator {
bottom: max(1rem, env(safe-area-inset-bottom));
right: 1rem;
padding: 0.5rem 0.85rem;
font-size: 0.75rem;
}
.fl-sheet-handle {
display: block;
}
#fl-drawer {
top: auto;
bottom: -100%;
right: 0;
left: 0;
width: 100%;
height: 82vh;
border-left: none;
border-top: 1px solid var(--fl-border);
border-radius: 20px 20px 0 0;
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}
#fl-drawer.open {
bottom: 0;
right: 0;
}
.fl-header {
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
padding: 0.6rem 1rem 0.5rem 1rem;
}
.fl-close-btn {
position: absolute;
top: 0.6rem;
right: 1rem;
}
}
</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 / Bottom Sheet Panel -->
<div id="fl-drawer">
<div class="fl-header">
<div class="fl-sheet-handle"></div>
<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 -->
<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.7rem; 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.85rem;">${note.title}</div>
<div style="color:var(--fl-text-muted); font-size:0.7rem;">${targetInfo}</div>
<div style="color:#cbd5e1; font-size:0.75rem; margin-top:0.2rem; line-height:1.35;">${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.7rem; padding:0.35rem 0.5rem;" onclick="this.getRootNode().host.copyAiPrompt('${note.id}')">
📋 Copy AI Prompt
</button>
<button class="fl-btn" style="font-size:0.7rem; padding:0.35rem 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.7rem; padding:0.35rem 0.4rem; color:var(--fl-rose); flex:0 0 44px;" onclick="this.getRootNode().host.deleteNote('${note.id}')">
🗑️
</button>
</div>
`;
container.appendChild(card);
});
}
}
if (!customElements.get('feature-lens')) {
customElements.define('feature-lens', FeatureLensElement);
}