Files
upbeatBytes/frontend/src/lib/components/FeedbackModal.svelte
T
thejayman77 452e5a3fe4 Hardening pass: scheduler backoff, FK cascade, a11y, test safety net
Pre-traffic cleanup from an audit:

* Scheduler: poll_due_sources now keys on the last *attempt* (success or
  failure), not the last success, and scales the wait by the consecutive-
  failure streak (capped at a day). A failing feed (e.g. Phys.org's HTTP 429s)
  used to be retried every cycle because it had no successful run; it now backs
  off and recovers on its own. Extracted due_source_rows() + tests.

* FK hygiene: deleting a daily_brief is supposed to cascade to its items, but
  SQLite enforces foreign keys per-connection — connect() already sets the
  pragma, so the cascade is correct going forward; added a regression test.
  (Orphaned items + Phys.org settings were cleaned directly on the live DB.)

* a11y: modal/drawer dialogs are now focusable (tabindex), close on Escape
  (window) and on backdrop click via a target check (dropping the inner
  stopPropagation handlers). Build is warning-free.

* tests: conftest points any un-mocked LLM client at a closed port with a 1s
  timeout, so an accidental real call fails fast instead of hanging the suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:18:18 +00:00

106 lines
4.3 KiB
Svelte
Raw 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.
<script>
import { postJSON } from '$lib/api.js';
import { visitorId } from '$lib/analytics.js';
let { onclose } = $props();
let category = $state('idea');
let message = $state('');
let email = $state('');
let hp = $state(''); // honeypot — must stay empty
let status = $state('idle'); // idle | sending | sent | error
let error = $state('');
const CATEGORIES = [
['idea', 'Idea'],
['concern', 'Concern'],
['bug', 'Bug'],
['praise', 'Praise'],
];
async function submit(e) {
e.preventDefault();
if (!message.trim() || status === 'sending') return;
status = 'sending';
error = '';
try {
await postJSON('/api/feedback', { category, message, email, hp, visitor: visitorId() });
status = 'sent';
} catch (err) {
status = 'error';
error = err?.message || 'Something went wrong — try again.';
}
}
function onkey(e) {
if (e.key === 'Escape') onclose?.();
}
</script>
<svelte:window onkeydown={onkey} />
<!-- Backdrop: clicking it closes; keyboard users close with Escape (window). -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="overlay" role="presentation" onclick={(e) => e.target === e.currentTarget && onclose?.()}>
<div class="sheet rise" role="dialog" aria-modal="true" aria-label="Send feedback" tabindex="-1">
<button class="x" onclick={onclose} aria-label="Close">×</button>
{#if status === 'sent'}
<h2>Thank you 💛</h2>
<p class="sub">Your note is on its way to us. We read every one.</p>
<button class="primary" onclick={onclose}>Done</button>
{:else}
<h2>Share feedback</h2>
<p class="sub">An idea, a concern, a bug, or just a hello — we'd love to hear it.</p>
<form onsubmit={submit}>
<div class="cats">
{#each CATEGORIES as [val, label] (val)}
<button type="button" class="cat" class:on={category === val} onclick={() => (category = val)}>{label}</button>
{/each}
</div>
<textarea bind:value={message} rows="4" placeholder="What's on your mind?" required></textarea>
<input type="email" bind:value={email} placeholder="Email (optional, only if you'd like a reply)" autocomplete="email" />
<!-- honeypot: hidden from people, tempting to bots -->
<input class="hp" tabindex="-1" autocomplete="off" bind:value={hp} aria-hidden="true" />
<button class="primary" type="submit" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send feedback'}
</button>
</form>
{#if status === 'error'}<p class="err">{error}</p>{/if}
{/if}
</div>
</div>
<style>
.overlay {
position: fixed; inset: 0; background: rgba(10, 22, 38, 0.32);
display: flex; align-items: center; justify-content: center; padding: 20px; z-index: 60;
}
.sheet {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); width: 100%; max-width: 420px; padding: 26px 24px 22px; position: relative;
}
.x { position: absolute; top: 12px; right: 14px; background: none; border: none; font-size: 1.5rem; line-height: 1; color: var(--muted); cursor: pointer; }
h2 { font-size: 1.4rem; margin: 0 0 6px; }
.sub { margin: 0 0 16px; color: var(--muted); font-size: 0.92rem; }
form { display: flex; flex-direction: column; gap: 10px; }
.cats { display: flex; flex-wrap: wrap; gap: 6px; }
.cat {
border: 1px solid var(--line); background: var(--bg); color: var(--ink);
border-radius: 999px; padding: 5px 13px; font-size: 0.85rem; cursor: pointer;
}
.cat.on { background: var(--accent); border-color: var(--accent); color: #fff; }
textarea, input {
font: inherit; padding: 10px 12px; border: 1px solid var(--line); border-radius: 12px;
background: var(--bg); color: var(--ink); resize: vertical;
}
textarea:focus, input:focus { outline: none; border-color: var(--accent); }
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; opacity: 0; }
.primary {
font: inherit; font-weight: 600; background: var(--accent); color: #fff; border: none;
border-radius: 999px; padding: 11px 16px; cursor: pointer;
}
.primary:hover { background: var(--accent-deep); }
.primary:disabled { opacity: 0.6; cursor: default; }
.err { margin: 10px 0 0; color: #9a3b3b; font-size: 0.86rem; }
</style>