tractatus/public/js/components/feedback.js
TheFlow e9511afd85 fix: Hide feedback FAB on mobile, add to drawer, persist install dismissal
FAB overlaps PWA install prompt and update notifications on small screens.
Mobile users now access feedback via the navbar drawer instead. Install
prompt dismissal persists in localStorage and is skipped in standalone mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 05:55:30 +13:00

618 lines
26 KiB
JavaScript

/**
* Tractatus Framework - Governed Feedback Component
* Demonstrates Agent Lightning + Tractatus governance in action
*
* Features:
* - Floating Action Button (FAB) for quick access
* - Modal dialog for feedback submission
* - Mobile-optimized bottom sheet
* - Real-time governance pathway classification
* - Integration with BoundaryEnforcer, PluralisticDeliberator, CrossReferenceValidator
*/
class TractausFeedback {
constructor() {
console.log('[Feedback] Constructor called');
this.isOpen = false;
this.isMobile = window.matchMedia('(max-width: 768px)').matches;
this.selectedType = null;
this.csrfToken = null;
this.init();
console.log('[Feedback] Constructor complete');
}
async init() {
console.log('[Feedback] Init called');
// Render components IMMEDIATELY (don't wait for CSRF)
this.renderFAB();
console.log('[Feedback] FAB rendered');
this.renderModal();
console.log('[Feedback] Modal rendered');
// Attach event listeners
this.attachEventListeners();
console.log('[Feedback] Event listeners attached');
// Get CSRF token in parallel (non-blocking)
this.fetchCsrfToken();
// Listen for window resize
window.addEventListener('resize', () => {
this.isMobile = window.matchMedia('(max-width: 768px)').matches;
});
// Listen for external open feedback requests (from navbar, etc.)
window.addEventListener('openFeedbackModal', () => {
this.openModal();
});
}
async fetchCsrfToken() {
try {
const response = await fetch('/api/csrf-token');
const data = await response.json();
this.csrfToken = data.csrfToken;
} catch (error) {
console.error('[Feedback] Failed to fetch CSRF token:', error);
}
}
/**
* Render Floating Action Button (FAB)
* Omnipresent on all pages for quick feedback access
*/
renderFAB() {
const fabHTML = `
<button id="feedback-fab"
class="hidden md:flex bg-gradient-to-r from-blue-600 to-blue-700 text-white p-4 rounded-full shadow-lg hover:shadow-xl hover:scale-110 transition-all duration-200 group items-center gap-2"
style="position: fixed !important; bottom: 1.5rem !important; right: 1.5rem !important; z-index: 999999 !important; visibility: visible !important; opacity: 1 !important;"
aria-label="Give Feedback"
title="Give Feedback">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
<span class="max-w-0 overflow-hidden group-hover:max-w-xs transition-all duration-300 whitespace-nowrap font-semibold">
Feedback
</span>
</button>
`;
document.body.insertAdjacentHTML('beforeend', fabHTML);
}
/**
* Render Feedback Modal/Bottom Sheet
* Adapts to mobile (bottom sheet) vs desktop (modal)
*/
renderModal() {
const modalHTML = `
<!-- Feedback Modal/Bottom Sheet -->
<div id="feedback-modal" class="hidden fixed inset-0 z-50" role="dialog" aria-modal="true" aria-labelledby="feedback-modal-title">
<!-- Backdrop -->
<div id="feedback-backdrop" class="absolute inset-0 bg-gray-900/60 backdrop-blur-sm transition-opacity"></div>
<!-- Modal/Sheet Container -->
<div id="feedback-container" class="absolute md:inset-0 md:flex md:items-center md:justify-center">
<!-- Modal Content -->
<div id="feedback-panel"
class="bg-white md:rounded-xl shadow-2xl w-full md:max-w-2xl md:mx-4
fixed bottom-0 left-0 right-0 md:relative
max-h-[85vh] md:max-h-[90vh] overflow-hidden
transform transition-transform duration-300 ease-out translate-y-full md:translate-y-0">
<!-- Header -->
<div class="flex justify-between items-center px-6 py-4 border-b border-gray-200 bg-gradient-to-r from-blue-50 to-blue-100">
<div>
<h2 id="feedback-modal-title" class="text-xl font-bold text-gray-900">Governed Feedback System</h2>
<p class="text-sm text-gray-600 mt-0.5">Powered by Tractatus + Agent Lightning</p>
</div>
<button id="feedback-close-btn" class="text-gray-500 hover:text-gray-700 p-2 rounded-lg hover:bg-white/50 transition" aria-label="Close">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content (scrollable) -->
<div class="overflow-y-auto max-h-[calc(85vh-120px)] md:max-h-[calc(90vh-180px)]">
<!-- Step 1: Type Selection -->
<div id="feedback-step-1" class="p-6">
<label class="block text-sm font-semibold text-gray-900 mb-3">What type of feedback do you have?</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button type="button" data-type="technical_question" class="feedback-type-btn p-4 border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition text-left group">
<div class="flex items-start gap-3">
<span class="text-2xl">🔧</span>
<div>
<div class="font-semibold text-gray-900 group-hover:text-blue-700">Technical Question</div>
<div class="text-xs text-gray-500 mt-1">Installation, usage, bugs</div>
<div class="text-xs text-blue-600 mt-1.5 font-medium">→ AI responds autonomously</div>
</div>
</div>
</button>
<button type="button" data-type="feature" class="feedback-type-btn p-4 border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition text-left group">
<div class="flex items-start gap-3">
<span class="text-2xl">💡</span>
<div>
<div class="font-semibold text-gray-900 group-hover:text-blue-700">Feature Request</div>
<div class="text-xs text-gray-500 mt-1">Suggest new capabilities</div>
<div class="text-xs text-amber-600 mt-1.5 font-medium">→ Requires deliberation</div>
</div>
</div>
</button>
<button type="button" data-type="research" class="feedback-type-btn p-4 border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition text-left group">
<div class="flex items-start gap-3">
<span class="text-2xl">🔬</span>
<div>
<div class="font-semibold text-gray-900 group-hover:text-blue-700">Research Collaboration</div>
<div class="text-xs text-gray-500 mt-1">Co-authorship, joint research</div>
<div class="text-xs text-amber-600 mt-1.5 font-medium">→ Requires deliberation</div>
</div>
</div>
</button>
<button type="button" data-type="commercial" class="feedback-type-btn p-4 border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition text-left group">
<div class="flex items-start gap-3">
<span class="text-2xl">💼</span>
<div>
<div class="font-semibold text-gray-900 group-hover:text-blue-700">Commercial Inquiry</div>
<div class="text-xs text-gray-500 mt-1">Licensing, consulting, partnerships</div>
<div class="text-xs text-red-600 mt-1.5 font-medium">→ Human review required</div>
</div>
</div>
</button>
<button type="button" data-type="bug" class="feedback-type-btn p-4 border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition text-left group">
<div class="flex items-start gap-3">
<span class="text-2xl">🐛</span>
<div>
<div class="font-semibold text-gray-900 group-hover:text-blue-700">Bug Report</div>
<div class="text-xs text-gray-500 mt-1">Something isn't working</div>
<div class="text-xs text-blue-600 mt-1.5 font-medium">→ AI responds autonomously</div>
</div>
</div>
</button>
<button type="button" data-type="general" class="feedback-type-btn p-4 border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition text-left group">
<div class="flex items-start gap-3">
<span class="text-2xl">💬</span>
<div>
<div class="font-semibold text-gray-900 group-hover:text-blue-700">General Comment</div>
<div class="text-xs text-gray-500 mt-1">Other feedback</div>
<div class="text-xs text-blue-600 mt-1.5 font-medium">→ AI responds autonomously</div>
</div>
</div>
</button>
</div>
<!-- Governance Info -->
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-sm">
<p class="font-semibold text-blue-900">How this works</p>
<p class="text-blue-700 mt-1">Your feedback is automatically classified by our <strong>BoundaryEnforcer</strong> to determine the appropriate response pathway, directing your feedback to the right channel while maintaining governance.</p>
</div>
</div>
</div>
</div>
<!-- Step 2: Feedback Form -->
<div id="feedback-step-2" class="hidden p-6">
<form id="feedback-form">
<!-- Pathway Indicator -->
<div id="pathway-indicator" class="mb-6 p-4 rounded-lg border-2"></div>
<!-- Content -->
<div class="mb-4">
<label for="feedback-content" class="block text-sm font-semibold text-gray-900 mb-2">Your Feedback</label>
<textarea id="feedback-content"
name="content"
rows="6"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
placeholder="Describe your feedback in detail..."
required></textarea>
<p class="text-xs text-gray-500 mt-1">Be specific to help us provide the best response.</p>
</div>
<!-- Optional Contact Info -->
<div class="mb-4">
<label class="block text-sm font-semibold text-gray-900 mb-2">Contact Information (Optional)</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input type="text"
id="feedback-name"
name="name"
class="px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
placeholder="Your name">
<input type="email"
id="feedback-email"
name="email"
class="px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
placeholder="your@email.com">
</div>
<p class="text-xs text-gray-500 mt-2">Provide your email if you'd like a response. We won't share your information.</p>
</div>
<!-- Governance Constraints Display -->
<div id="constraints-display" class="mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<p class="text-xs font-semibold text-gray-700 mb-2">Governance Constraints:</p>
<ul id="constraints-list" class="text-xs text-gray-600 space-y-1"></ul>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button type="button" id="feedback-back-btn" class="px-4 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition font-medium">
← Back
</button>
<button type="submit" id="feedback-submit-btn" class="flex-1 px-6 py-2.5 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg hover:shadow-lg transition font-semibold">
Submit Feedback
</button>
</div>
</form>
</div>
<!-- Step 3: Confirmation -->
<div id="feedback-step-3" class="hidden p-6">
<div class="text-center py-8">
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900 mb-2">Feedback Received!</h3>
<p id="confirmation-message" class="text-gray-600 mb-4"></p>
<!-- Tracking Info -->
<div class="inline-block bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 mb-6">
<p class="text-xs text-gray-500 mb-1">Tracking ID:</p>
<code id="tracking-id" class="text-sm font-mono text-blue-600"></code>
</div>
<!-- Governance Summary -->
<div id="governance-summary" class="bg-blue-50 border border-blue-200 rounded-lg p-4 text-left mb-6"></div>
<button id="feedback-done-btn" class="px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-semibold">
Done
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
}
/**
* Attach all event listeners
*/
attachEventListeners() {
// FAB click - open modal
const fab = document.getElementById('feedback-fab');
if (fab) {
fab.addEventListener('click', () => this.openModal());
}
// Close buttons
const closeBtn = document.getElementById('feedback-close-btn');
const backdrop = document.getElementById('feedback-backdrop');
if (closeBtn) closeBtn.addEventListener('click', () => this.closeModal());
if (backdrop) backdrop.addEventListener('click', () => this.closeModal());
// Type selection buttons
const typeButtons = document.querySelectorAll('.feedback-type-btn');
typeButtons.forEach(btn => {
btn.addEventListener('click', () => {
this.selectedType = btn.getAttribute('data-type');
this.showStep2();
});
});
// Back button
const backBtn = document.getElementById('feedback-back-btn');
if (backBtn) {
backBtn.addEventListener('click', () => this.showStep1());
}
// Form submission
const form = document.getElementById('feedback-form');
if (form) {
form.addEventListener('submit', (e) => this.handleSubmit(e));
}
// Done button
const doneBtn = document.getElementById('feedback-done-btn');
if (doneBtn) {
doneBtn.addEventListener('click', () => this.closeModal());
}
}
/**
* Open feedback modal
*/
openModal() {
this.isOpen = true;
const modal = document.getElementById('feedback-modal');
const panel = document.getElementById('feedback-panel');
modal.classList.remove('hidden');
// Animate in
setTimeout(() => {
panel.classList.remove('translate-y-full');
panel.classList.add('translate-y-0');
}, 10);
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Reset to step 1
this.showStep1();
}
/**
* Close feedback modal
*/
closeModal() {
this.isOpen = false;
const modal = document.getElementById('feedback-modal');
const panel = document.getElementById('feedback-panel');
// Animate out
panel.classList.remove('translate-y-0');
panel.classList.add('translate-y-full');
setTimeout(() => {
modal.classList.add('hidden');
}, 300);
// Restore body scroll
document.body.style.overflow = '';
// Reset form
this.resetForm();
}
/**
* Show step 1 (type selection)
*/
showStep1() {
document.getElementById('feedback-step-1').classList.remove('hidden');
document.getElementById('feedback-step-2').classList.add('hidden');
document.getElementById('feedback-step-3').classList.add('hidden');
}
/**
* Show step 2 (feedback form)
*/
showStep2() {
document.getElementById('feedback-step-1').classList.add('hidden');
document.getElementById('feedback-step-2').classList.remove('hidden');
document.getElementById('feedback-step-3').classList.add('hidden');
// Update pathway indicator based on type
this.updatePathwayIndicator();
}
/**
* Show step 3 (confirmation)
*/
showStep3(data) {
document.getElementById('feedback-step-1').classList.add('hidden');
document.getElementById('feedback-step-2').classList.add('hidden');
document.getElementById('feedback-step-3').classList.remove('hidden');
// Display confirmation message
const confirmationMessage = document.getElementById('confirmation-message');
confirmationMessage.textContent = data.message;
// Display tracking ID
const trackingId = document.getElementById('tracking-id');
trackingId.textContent = data.feedbackId;
// Display governance summary
this.displayGovernanceSummary(data);
}
/**
* Update pathway indicator based on selected type
*/
updatePathwayIndicator() {
const indicator = document.getElementById('pathway-indicator');
const constraintsList = document.getElementById('constraints-list');
const pathwayInfo = {
technical_question: {
pathway: 'Autonomous',
color: 'blue',
icon: '🤖',
description: 'AI will respond autonomously with technical information',
constraints: ['cite_documentation', 'no_financial_commitments', 'no_legal_advice', 'accurate_only']
},
bug: {
pathway: 'Autonomous',
color: 'blue',
icon: '🤖',
description: 'AI will respond autonomously with troubleshooting guidance',
constraints: ['cite_documentation', 'no_financial_commitments', 'accurate_only']
},
general: {
pathway: 'Autonomous',
color: 'blue',
icon: '🤖',
description: 'AI will respond autonomously with general information',
constraints: ['cite_documentation', 'no_financial_commitments', 'no_legal_advice', 'stay_on_topic']
},
feature: {
pathway: 'Deliberation',
color: 'amber',
icon: '⚖️',
description: 'Requires multi-stakeholder deliberation before response',
constraints: ['align_with_roadmap', 'assess_scope', 'community_benefit']
},
research: {
pathway: 'Deliberation',
color: 'amber',
icon: '⚖️',
description: 'Requires stakeholder consultation (maintainer, research lead, community)',
constraints: ['check_availability', 'align_with_research_gaps', 'assess_mutual_benefit']
},
commercial: {
pathway: 'Human Mandatory',
color: 'red',
icon: '👤',
description: 'Requires personal human review and response',
constraints: ['no_financial_commitments', 'no_pricing_discussion', 'refer_to_human']
}
};
const info = pathwayInfo[this.selectedType];
indicator.className = `mb-6 p-4 rounded-lg border-2 border-${info.color}-200 bg-${info.color}-50`;
indicator.innerHTML = `
<div class="flex items-start gap-3">
<span class="text-2xl">${info.icon}</span>
<div>
<p class="font-semibold text-${info.color}-900">Pathway: ${info.pathway}</p>
<p class="text-sm text-${info.color}-700 mt-1">${info.description}</p>
</div>
</div>
`;
// Display constraints
constraintsList.innerHTML = info.constraints.map(c => `
<li class="flex items-center gap-2">
<svg class="w-3 h-3 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
${this.formatConstraint(c)}
</li>
`).join('');
}
/**
* Format constraint for display
*/
formatConstraint(constraint) {
const labels = {
cite_documentation: 'Must cite documentation',
no_financial_commitments: 'No financial commitments',
no_legal_advice: 'No legal advice',
accurate_only: 'Factually accurate only',
helpful_tone: 'Helpful, respectful tone',
stay_on_topic: 'Stay on topic',
align_with_roadmap: 'Align with roadmap',
assess_scope: 'Assess feasibility',
community_benefit: 'Community benefit assessment',
check_availability: 'Check maintainer availability',
align_with_research_gaps: 'Align with research priorities',
assess_mutual_benefit: 'Mutual benefit assessment',
no_pricing_discussion: 'No pricing discussion',
refer_to_human: 'Escalate to human'
};
return labels[constraint] || constraint;
}
/**
* Handle form submission
*/
async handleSubmit(e) {
e.preventDefault();
const submitBtn = document.getElementById('feedback-submit-btn');
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting...';
try {
const formData = {
type: this.selectedType,
content: document.getElementById('feedback-content').value,
name: document.getElementById('feedback-name').value || null,
email: document.getElementById('feedback-email').value || null
};
const response = await fetch('/api/feedback/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken
},
credentials: 'include',
body: JSON.stringify(formData)
});
const data = await response.json();
if (data.success) {
this.showStep3(data);
} else {
alert('Error: ' + (data.message || 'Failed to submit feedback'));
submitBtn.disabled = false;
submitBtn.textContent = 'Submit Feedback';
}
} catch (error) {
console.error('[Feedback] Submission error:', error);
alert('An error occurred. Please try again.');
submitBtn.disabled = false;
submitBtn.textContent = 'Submit Feedback';
}
}
/**
* Display governance summary after submission
*/
displayGovernanceSummary(data) {
const summary = document.getElementById('governance-summary');
const pathwayIcons = {
autonomous: '🤖',
deliberation: '⚖️',
human_mandatory: '👤'
};
summary.innerHTML = `
<div class="text-sm">
<p class="font-semibold text-blue-900 mb-3">Governance Summary</p>
<div class="space-y-2 text-blue-700">
<div class="flex items-center gap-2">
<span class="text-lg">${pathwayIcons[data.pathway]}</span>
<span><strong>Pathway:</strong> ${data.pathway.replace('_', ' ')}</span>
</div>
<div><strong>Classification:</strong> BoundaryEnforcer approved</div>
<div><strong>Tracking:</strong> <code class="text-xs bg-white px-2 py-0.5 rounded">${data.feedbackId}</code></div>
${data.trackingUrl ? `<div class="mt-3 pt-3 border-t border-blue-200"><a href="${data.trackingUrl}" class="text-blue-600 hover:text-blue-800 underline text-xs">Track status →</a></div>` : ''}
</div>
</div>
`;
}
/**
* Reset form to initial state
*/
resetForm() {
this.selectedType = null;
document.getElementById('feedback-form').reset();
this.showStep1();
}
}
// Auto-initialize when DOM is ready
console.log('[Feedback] Auto-init starting, readyState:', document.readyState);
if (document.readyState === 'loading') {
console.log('[Feedback] Waiting for DOMContentLoaded');
document.addEventListener('DOMContentLoaded', () => {
console.log('[Feedback] DOMContentLoaded fired, creating instance');
new TractausFeedback();
});
} else {
console.log('[Feedback] DOM already ready, creating instance immediately');
new TractausFeedback();
}