<!-- Feedback FAB + modal — migrated to Obsidian token components -->
<!-- Uses: action-btn, form-input, chip, modal-backdrop, modal, ghost-cta, toast via toast.js -->
<!-- FAB trigger -->
<button id="feedback-fab"
onclick="document.getElementById('feedback-modal-backdrop').style.display='flex'"
class="feedback-fab"
title="Send feedback"
aria-label="Send feedback">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</button>
<!-- Capture overlay -->
<div id="feedback-capture-overlay" class="feedback-capture-overlay">
<div style="text-align:center;">
<div class="feedback-capture-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
</div>
<div style="font-family:var(--sans);color:var(--text-secondary);">Capturing page screenshot…</div>
</div>
</div>
<!-- Modal backdrop -->
<div id="feedback-modal-backdrop" class="modal-backdrop" style="display:none;">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="feedback-modal-title">
<div class="modal__header">
<h3 class="modal__title" id="feedback-modal-title">Send Feedback</h3>
<button class="modal__close"
onclick="document.getElementById('feedback-modal-backdrop').style.display='none'"
aria-label="Close">×</button>
</div>
<div class="modal__body">
{% if g.user and g.user.get('is_admin') %}
{% set _admin_personas = [
("anon_new", "Anonymous New"),
("anon_returning", "Anonymous Returning"),
("free_auth", "Free Authenticated"),
("beta_empty", "Beta Empty"),
("beta_active", "Beta Active (3 watches)"),
("power_user", "Power User (12 watches)"),
("admin_reset", "Admin (reset)"),
] %}
<div id="persona-panel" style="margin-bottom:var(--space-4);padding-bottom:var(--space-4);border-bottom:1px solid var(--glass-border);">
<div style="font-family:var(--mono);font-size:var(--text-xs);color:var(--text-secondary);">QA Persona</div>
<select id="persona-select" name="persona_id" class="form-input" style="margin-top:var(--space-1);">
{% for pid, plabel in _admin_personas %}
<option value="{{ pid }}"{% if session.get('persona_id') == pid %} selected{% endif %}>{{ plabel }}</option>
{% endfor %}
</select>
<button type="button"
class="action-btn"
style="margin-top:var(--space-2);width:100%;"
hx-post="/admin/impersonate"
hx-target="#persona-status"
hx-swap="innerHTML"
hx-include="#persona-select"
hx-vals='{"csrf_token": "{{ csrf_token }}"}'>
Apply Persona
</button>
<div style="margin-top:var(--space-2);display:flex;justify-content:space-between;align-items:center;">
<span id="persona-status" style="font-family:var(--mono);font-size:var(--text-xs);">
{% if session.get('persona_id') %}
<span style="color:var(--signal-green);">Active: {{ session.get('persona_label', session.get('persona_id', '')) }}</span>
{% endif %}
</span>
<a href="/admin/reset-impersonation" class="ghost-cta" style="font-size:var(--text-xs);">Reset</a>
</div>
</div>
{% endif %}
{% if g.user and g.user.get('is_admin') %}
<div id="qa-review-panel" style="margin-bottom:var(--space-4);padding-bottom:var(--space-4);border-bottom:1px solid var(--glass-border);">
<!-- Header row -->
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-family:var(--mono);font-size:var(--text-xs);color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.06em;">QA Reviews</span>
<span id="qa-pending-badge" style="font-family:var(--mono);font-size:var(--text-xs);color:var(--accent);background:var(--accent-glow);border:1px solid var(--accent-ring);border-radius:3px;padding:1px 6px;">
{{ qa_pending_count|default(0) }} pending
</span>
</div>
<!-- Hidden fields for the HTMX form -->
<input type="hidden" id="qa-page" name="page" value="">
<input type="hidden" id="qa-persona" name="persona" value="">
<input type="hidden" id="qa-viewport" name="viewport" value="">
<input type="hidden" id="qa-dimension" name="dimension" value="">
<input type="hidden" id="qa-score" name="pipeline_score" value="">
<input type="hidden" id="qa-sprint" name="sprint" value="qs10">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<!-- Context display -->
<div id="qa-context" style="font-family:var(--mono);font-size:var(--text-xs);color:var(--text-tertiary);margin-top:var(--space-2);">No item selected</div>
<!-- Note input -->
<textarea id="qa-note" name="note" rows="2" class="form-input"
placeholder="Why? (optional — this is training data)"
style="margin-top:var(--space-2);font-size:var(--text-xs);resize:vertical;"></textarea>
<!-- Verdict buttons -->
<div style="display:flex;gap:8px;margin-top:var(--space-2);">
<button type="button" class="action-btn"
style="flex:1;padding:6px;font-size:var(--text-xs);color:var(--signal-green);border-color:var(--signal-green);"
hx-post="/admin/qa-decision"
hx-target="#qa-result"
hx-swap="innerHTML"
hx-include="#qa-review-panel"
hx-vals='{"tim_verdict":"accept"}'>Accept</button>
<button type="button" class="action-btn"
style="flex:1;padding:6px;font-size:var(--text-xs);color:var(--accent);border-color:var(--accent);"
hx-post="/admin/qa-decision"
hx-target="#qa-result"
hx-swap="innerHTML"
hx-include="#qa-review-panel"
hx-vals='{"tim_verdict":"note"}'>Note</button>
<button type="button" class="action-btn"
style="flex:1;padding:6px;font-size:var(--text-xs);color:var(--signal-red);border-color:var(--signal-red);"
hx-post="/admin/qa-decision"
hx-target="#qa-result"
hx-swap="innerHTML"
hx-include="#qa-review-panel"
hx-vals='{"tim_verdict":"reject"}'>Reject</button>
</div>
<!-- Result span -->
<span id="qa-result" style="font-family:var(--mono);font-size:var(--text-xs);display:block;margin-top:var(--space-2);"></span>
</div>
{% endif %}
<form id="feedback-form" hx-post="/feedback/submit" hx-target="#feedback-result" hx-swap="innerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="page_url" class="feedback-page-url">
<input type="hidden" name="screenshot_data" id="feedback-screenshot-data">
<!-- Feedback type chips -->
<div style="display:flex;gap:8px;margin-bottom:var(--space-4);">
<label class="fb-chip-label" style="flex:1;text-align:center;cursor:pointer;">
<input type="radio" name="feedback_type" value="bug" style="display:none;">
<span class="chip fb-chip" style="display:block;padding:8px;cursor:pointer;">Bug</span>
</label>
<label class="fb-chip-label" style="flex:1;text-align:center;cursor:pointer;">
<input type="radio" name="feedback_type" value="suggestion" checked style="display:none;">
<span class="chip fb-chip fb-chip--active" style="display:block;padding:8px;cursor:pointer;">Suggestion</span>
</label>
<label class="fb-chip-label" style="flex:1;text-align:center;cursor:pointer;">
<input type="radio" name="feedback_type" value="question" style="display:none;">
<span class="chip fb-chip" style="display:block;padding:8px;cursor:pointer;">Question</span>
</label>
</div>
<!-- Message -->
<textarea name="message" rows="4" class="form-input"
placeholder="What's on your mind?"
required minlength="3"
style="resize:vertical;font-family:var(--mono);"></textarea>
<!-- Screenshot actions -->
<div style="margin-top:var(--space-2);display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button type="button" id="capture-screenshot-btn"
class="action-btn"
onclick="fbCaptureScreenshot()"
style="width:auto;padding:4px 10px;font-size:var(--text-xs);">
Capture Page
</button>
<label class="action-btn" style="width:auto;padding:4px 10px;font-size:var(--text-xs);cursor:pointer;">
Upload Image
<input type="file" accept="image/*" onchange="fbHandleFileUpload(this)" style="display:none;">
</label>
<span id="screenshot-status" style="font-family:var(--mono);font-size:var(--text-xs);color:var(--text-secondary);"></span>
</div>
<div id="screenshot-preview" style="display:none;margin-top:var(--space-2);position:relative;">
<img id="screenshot-thumb" style="max-width:100%;max-height:120px;border-radius:var(--radius-sm);border:1px solid var(--glass-border);">
<button type="button" onclick="fbClearScreenshot()"
class="screenshot-clear-btn"
aria-label="Remove screenshot">
×
</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:var(--space-3);">
<span id="feedback-result" style="font-family:var(--sans);font-size:var(--text-xs);color:var(--text-secondary);"></span>
<button type="submit" class="action-btn" style="width:auto;">Send →</button>
</div>
</form>
</div>
</div>
</div>
<style nonce="{{ csp_nonce }}">
/* FAB */
.feedback-fab {
position: fixed; bottom: 24px; right: 24px;
width: 44px; height: 44px; border-radius: var(--radius-full);
display: flex; align-items: center; justify-content: center;
cursor: pointer; z-index: 1000;
background: var(--glass); border: 1px solid var(--glass-border);
color: var(--text-secondary);
transition: border-color 0.3s, color 0.3s;
}
.feedback-fab:hover { border-color: var(--glass-hover); color: var(--accent); }
/* Capture overlay */
.feedback-capture-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.70); z-index: 1002;
align-items: center; justify-content: center;
}
.feedback-capture-icon { font-size: var(--text-xl); margin-bottom: var(--space-3); }
/* Screenshot clear */
.screenshot-clear-btn {
position: absolute; top: 4px; right: 4px;
background: rgba(0, 0, 0, 0.70); border: none;
color: var(--text-primary); border-radius: var(--radius-full);
width: 20px; height: 20px; cursor: pointer; font-size: var(--text-xs);
line-height: 20px; text-align: center;
}
/* Modal token component (DESIGN_TOKENS.md §5) */
.modal-backdrop {
position: fixed; inset: 0; z-index: 90;
background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
animation: backdrop-in 0.2s ease-out;
}
@keyframes backdrop-in { from { opacity: 0; } to { opacity: 1; } }
.modal {
background: var(--obsidian-mid);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
max-width: 440px; width: calc(100vw - 32px);
max-height: calc(100vh - 64px); overflow-y: auto;
animation: modal-fade-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modal-fade-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
.modal__header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--space-6) var(--space-6) 0;
}
.modal__title {
font-family: var(--sans); font-size: var(--text-lg); font-weight: 400;
color: var(--text-primary); margin: 0;
}
.modal__close {
background: none; border: none; color: var(--text-secondary);
font-size: 20px; cursor: pointer; padding: 0; transition: color 0.2s;
}
.modal__close:hover { color: var(--text-primary); }
.modal__body {
padding: var(--space-4) var(--space-6) var(--space-6);
font-family: var(--sans); font-size: var(--text-sm);
color: var(--text-secondary); line-height: 1.5;
}
/* Chip token */
.chip {
font-family: var(--mono); font-size: var(--text-xs); font-weight: 400;
color: var(--text-secondary); background: var(--glass);
border: 1px solid var(--glass-border); padding: 1px 7px;
border-radius: 3px; white-space: nowrap;
transition: border-color 0.2s, color 0.2s, background 0.2s;
}
.fb-chip--active {
border-color: var(--accent-ring);
color: var(--accent);
background: var(--accent-glow);
}
/* form-input token */
.form-input {
width: 100%; padding: 10px 14px;
font-family: var(--mono); font-size: var(--text-sm); font-weight: 300;
color: var(--text-primary); background: var(--glass);
border: 1px solid var(--glass-border); border-radius: var(--radius-sm);
outline: none; transition: border-color 0.3s, box-shadow 0.3s;
}
.form-input:focus {
border-color: var(--accent-ring);
box-shadow: 0 0 0 3px rgba(94, 234, 212, 0.1);
}
.form-input::placeholder { color: var(--text-tertiary); }
/* action-btn token */
.action-btn {
font-family: var(--mono); font-size: var(--text-sm); font-weight: 400;
color: var(--text-secondary); background: var(--glass);
border: 1px solid var(--glass-border); border-radius: var(--radius-sm);
padding: 8px 16px; cursor: pointer;
transition: border-color 0.3s, color 0.3s, background 0.3s;
}
.action-btn:hover {
border-color: var(--glass-hover); color: var(--text-primary);
background: var(--obsidian-light);
}
@media (max-width: 768px) {
.modal-backdrop { align-items: flex-end; }
.modal {
max-width: 100%; width: 100%;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
max-height: 85vh;
animation: modal-slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modal-slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.modal::before {
content: ''; display: block; width: 32px; height: 4px;
background: var(--glass-hover); border-radius: 2px;
margin: var(--space-3) auto 0;
}
}
</style>
<script nonce="{{ csp_nonce }}">
(function() {
// Set page URL
var urlInputs = document.querySelectorAll('.feedback-page-url');
urlInputs.forEach(function(el) { el.value = window.location.href; });
// Chip radio visual toggle
document.querySelectorAll('.fb-chip-label').forEach(function(label) {
var radio = label.querySelector('input[type=radio]');
var chip = label.querySelector('.fb-chip');
radio.addEventListener('change', function() {
document.querySelectorAll('.fb-chip').forEach(function(c) {
c.classList.remove('fb-chip--active');
});
if (radio.checked) { chip.classList.add('fb-chip--active'); }
});
});
// Reset form after successful HTMX submit
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.pathInfo && evt.detail.pathInfo.requestPath === '/feedback/submit'
&& evt.detail.successful) {
setTimeout(function() {
var form = document.getElementById('feedback-form');
if (form) {
form.querySelector('textarea[name="message"]').value = '';
fbClearScreenshot();
var sugRadio = form.querySelector('input[value="suggestion"]');
if (sugRadio) { sugRadio.checked = true; sugRadio.dispatchEvent(new Event('change')); }
}
if (typeof showToast === 'function') {
showToast('Feedback sent \u2014 thanks!', { type: 'success' });
}
}, 2000);
setTimeout(function() {
document.getElementById('feedback-modal-backdrop').style.display = 'none';
document.getElementById('feedback-result').textContent = '';
}, 3000);
}
});
// Close on backdrop click
document.getElementById('feedback-modal-backdrop').addEventListener('click', function(e) {
if (e.target === this) { this.style.display = 'none'; }
});
// Close on Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var backdrop = document.getElementById('feedback-modal-backdrop');
if (backdrop && backdrop.style.display === 'flex') { backdrop.style.display = 'none'; }
}
});
})();
var FB_MAX_BYTES = 5 * 1024 * 1024;
var _html2canvasLoaded = false;
function _loadHtml2Canvas(callback) {
if (_html2canvasLoaded && window.html2canvas) { callback(); return; }
var script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js';
script.crossOrigin = 'anonymous';
script.onload = function() { _html2canvasLoaded = true; callback(); };
script.onerror = function() {
var status = document.getElementById('screenshot-status');
status.textContent = 'Failed to load capture library. Try uploading instead.';
status.style.color = 'var(--signal-red)';
document.getElementById('capture-screenshot-btn').disabled = false;
};
document.head.appendChild(script);
}
function fbCaptureScreenshot() {
var btn = document.getElementById('capture-screenshot-btn');
var status = document.getElementById('screenshot-status');
var backdrop = document.getElementById('feedback-modal-backdrop');
var overlay = document.getElementById('feedback-capture-overlay');
btn.disabled = true;
status.textContent = '';
backdrop.style.display = 'none';
overlay.style.display = 'flex';
_loadHtml2Canvas(function() {
requestAnimationFrame(function() {
html2canvas(document.body, {
scale: 1, useCORS: true, logging: false,
backgroundColor: '#0a0a0f',
ignoreElements: function(el) { return el.id === 'feedback-capture-overlay'; }
}).then(function(canvas) {
overlay.style.display = 'none';
backdrop.style.display = 'flex';
var dataUrl = canvas.toDataURL('image/jpeg', 0.7);
if (dataUrl.length > FB_MAX_BYTES) { dataUrl = canvas.toDataURL('image/jpeg', 0.4); }
if (dataUrl.length > FB_MAX_BYTES) {
status.textContent = 'Image too large. Try uploading a smaller file.';
status.style.color = 'var(--signal-red)';
btn.disabled = false;
return;
}
fbSetScreenshot(dataUrl);
status.textContent = 'Page captured!';
status.style.color = 'var(--signal-green)';
btn.disabled = false;
}).catch(function() {
overlay.style.display = 'none';
backdrop.style.display = 'flex';
status.textContent = 'Capture failed. Try uploading instead.';
status.style.color = 'var(--signal-red)';
btn.disabled = false;
});
});
});
}
function fbHandleFileUpload(input) {
var status = document.getElementById('screenshot-status');
if (!input.files || !input.files[0]) return;
var file = input.files[0];
if (!file.type.startsWith('image/')) {
status.textContent = 'Please select an image file.';
status.style.color = 'var(--signal-red)';
return;
}
var reader = new FileReader();
reader.onload = function(e) {
var dataUrl = e.target.result;
if (dataUrl.length > FB_MAX_BYTES) {
status.textContent = 'Image too large (max ~4MB).';
status.style.color = 'var(--signal-red)';
return;
}
fbSetScreenshot(dataUrl);
status.textContent = file.name + ' attached';
status.style.color = 'var(--signal-green)';
};
reader.readAsDataURL(file);
}
function fbSetScreenshot(dataUrl) {
document.getElementById('feedback-screenshot-data').value = dataUrl;
var preview = document.getElementById('screenshot-preview');
var thumb = document.getElementById('screenshot-thumb');
thumb.src = dataUrl;
preview.style.display = 'block';
}
window.qaLoadItem = function(item) {
document.getElementById('qa-page').value = item.page || '';
document.getElementById('qa-persona').value = item.persona || '';
document.getElementById('qa-viewport').value = item.viewport || '';
document.getElementById('qa-dimension').value = item.dimension || '';
document.getElementById('qa-score').value = item.pipeline_score || '';
document.getElementById('qa-sprint').value = item.sprint || 'qs10';
var ctx = document.getElementById('qa-context');
if (ctx) {
ctx.textContent = (item.page || '?') + ' \u00b7 ' + (item.dimension || '?') + ' \u00b7 ' + (item.pipeline_score || '?') + '/5';
}
var result = document.getElementById('qa-result');
if (result) { result.textContent = ''; }
};
function fbClearScreenshot() {
document.getElementById('feedback-screenshot-data').value = '';
document.getElementById('screenshot-preview').style.display = 'none';
document.getElementById('screenshot-thumb').src = '';
document.getElementById('screenshot-status').textContent = '';
}
</script>