peregrine/web/src/views/JobReviewView.vue
pyr0ball 230cfb074c fix(demo): smoke-test fixes — card reset, toast error type, apply hint, text contrast
- JobCardStack: expose resetCard() to restore card after a blocked action
- JobReviewView: call resetCard() when approve/reject returns false; prevents
  card going blank after demo guard blocks the action
- useApi: add 'demo-blocked' to ApiError union; return truthy error from the
  403 interceptor so store callers bail early (no optimistic UI update)
- ApplyView: add HintChip to desktop split-pane layout (was mobile-only)
- HintChip: fix text color — --app-primary-light is near-white in light theme,
  causing invisible text; switch to --color-text for cross-theme contrast
- vite.config.ts: support VITE_API_TARGET env var for dev proxy override
- migrations/006: add date_posted, hired_feedback columns and references_ table
  (columns existed in live DB but were missing from migration history)
- DemoBanner: commit component and test (were untracked)
2026-04-21 10:14:37 -07:00

958 lines
29 KiB
Vue
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.

<template>
<div class="review">
<HintChip
v-if="config.isDemo"
view-key="review"
message="Swipe right to approve, left to skip. One of these jobs is a ghost post — can you spot it?"
/>
<!-- Header -->
<header class="review__header">
<div class="review__title-row">
<h1 class="review__title">Review Jobs</h1>
<button class="help-btn" :aria-expanded="showHelp" @click="showHelp = !showHelp">
<span aria-hidden="true">?</span>
<span class="sr-only">Keyboard shortcuts</span>
</button>
</div>
<!-- Status filter tabs (segmented control) -->
<div class="review__tabs" role="tablist" aria-label="Filter by status">
<button
v-for="tab in TABS"
:key="tab.status"
role="tab"
class="review__tab"
:class="{ 'review__tab--active': activeTab === tab.status }"
:aria-selected="activeTab === tab.status"
@click="setTab(tab.status)"
>
{{ tab.label }}
<span v-if="tab.status === 'pending' && store.remaining > 0" class="tab-badge">
{{ store.remaining }}
</span>
</button>
</div>
</header>
<!-- PENDING: card stack -->
<div v-if="activeTab === 'pending'" class="review__body">
<!-- Loading -->
<div v-if="store.loading" class="review__loading" aria-live="polite" aria-label="Loading jobs…">
<span class="spinner" aria-hidden="true" />
<span>Loading queue</span>
</div>
<!-- Empty state falcon stoop animation (easter egg 9.3) -->
<div v-else-if="store.remaining === 0 && !store.loading" class="review__empty" role="status">
<span class="empty-falcon" aria-hidden="true">🦅</span>
<h2 class="empty-title">Queue cleared.</h2>
<p class="empty-desc">Nothing to review right now. Run discovery to find new listings.</p>
</div>
<!-- Card stack -->
<template v-else-if="store.currentJob">
<!-- Keyboard hint bar -->
<div class="hint-bar" aria-hidden="true">
<span class="hint"><kbd></kbd><kbd>J</kbd> Reject</span>
<span class="hint-counter">{{ store.remaining }} remaining</span>
<span class="hint"><kbd></kbd><kbd>L</kbd> Approve</span>
</div>
<JobCardStack
ref="stackRef"
:job="store.currentJob"
:remaining="store.remaining"
@approve="onApprove"
@reject="onReject"
@skip="onSkip"
/>
<!-- Action buttons (non-swipe path) -->
<div class="review__actions" aria-label="Review actions">
<button
class="action-btn action-btn--reject"
aria-label="Reject this job"
@click="stackRef?.dismissReject()"
>
<span aria-hidden="true"></span> Reject
</button>
<button
class="action-btn action-btn--skip"
aria-label="Skip come back later"
@click="stackRef?.dismissSkip()"
>
<span aria-hidden="true"></span> Skip
</button>
<button
class="action-btn action-btn--approve"
aria-label="Approve this job"
@click="stackRef?.dismissApprove()"
>
<span aria-hidden="true"></span> Approve
</button>
</div>
<!-- Undo hint -->
<p class="review__undo-hint" aria-hidden="true">Press <kbd>Z</kbd> to undo</p>
</template>
</div>
<!-- OTHER STATUS: list view -->
<div v-else class="review__body">
<div v-if="store.loading" class="review__loading" aria-live="polite">
<span class="spinner" aria-hidden="true" />
<span>Loading</span>
</div>
<template v-else>
<!-- Sort + filter bar -->
<div class="list-controls" aria-label="Sort and filter">
<select v-model="sortBy" class="list-sort" aria-label="Sort by">
<option value="match_score">Best match</option>
<option value="date_found">Newest first</option>
<option value="company">Company AZ</option>
</select>
<label class="list-filter-remote">
<input type="checkbox" v-model="filterRemote" />
Remote only
</label>
<span class="list-count">{{ sortedFilteredJobs.length }} job{{ sortedFilteredJobs.length !== 1 ? 's' : '' }}</span>
</div>
<div v-if="sortedFilteredJobs.length === 0" class="review__empty" role="status">
<p class="empty-desc">No {{ activeTab }} jobs{{ filterRemote ? ' (remote only)' : '' }}.</p>
</div>
<ul v-else class="job-list" role="list">
<li v-for="job in sortedFilteredJobs" :key="job.id" class="job-list__item">
<div class="job-list__info">
<span class="job-list__title">{{ job.title }}</span>
<span class="job-list__company">
{{ job.company }}
<span v-if="job.is_remote" class="remote-tag">Remote</span>
</span>
</div>
<div class="job-list__meta">
<span v-if="job.match_score !== null" class="score-pill" :class="scorePillClass(job.match_score)">
{{ job.match_score }}%
</span>
<button
v-if="activeTab === 'approved'"
class="job-list__action"
@click="router.push(`/apply/${job.id}`)"
:aria-label="`Draft cover letter for ${job.title}`"
> Draft</button>
<a :href="job.url" target="_blank" rel="noopener noreferrer" class="job-list__link">
View
</a>
</div>
</li>
</ul>
</template>
</div>
<!-- Help overlay -->
<Transition name="overlay">
<div
v-if="showHelp"
class="help-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="help-title"
@click.self="showHelp = false"
>
<div class="help-modal">
<h2 id="help-title" class="help-modal__title">Keyboard Shortcuts</h2>
<dl class="help-keys">
<div class="help-keys__row">
<dt><kbd></kbd> / <kbd>L</kbd></dt>
<dd>Approve</dd>
</div>
<div class="help-keys__row">
<dt><kbd></kbd> / <kbd>J</kbd></dt>
<dd>Reject</dd>
</div>
<div class="help-keys__row">
<dt><kbd>S</kbd></dt>
<dd>Skip (come back later)</dd>
</div>
<div class="help-keys__row">
<dt><kbd>Enter</kbd></dt>
<dd>Expand / collapse description</dd>
</div>
<div class="help-keys__row">
<dt><kbd>Z</kbd></dt>
<dd>Undo last action</dd>
</div>
<div class="help-keys__row">
<dt><kbd>?</kbd></dt>
<dd>Toggle this help</dd>
</div>
</dl>
<button class="help-modal__close" @click="showHelp = false" aria-label="Close help"></button>
</div>
</div>
</Transition>
<!-- Undo toast -->
<Transition name="toast">
<div
v-if="undoToast"
class="undo-toast"
role="status"
aria-live="polite"
>
<span>{{ undoToast.message }}</span>
<button class="undo-toast__btn" @click="doUndo">Undo</button>
</div>
</Transition>
<!-- Stoop speed toast easter egg 9.2 -->
<Transition name="toast">
<div v-if="stoopToastVisible" class="stoop-toast" role="status" aria-live="polite">
🦅 Stoop speed.
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useReviewStore } from '../stores/review'
import JobCardStack from '../components/JobCardStack.vue'
import HintChip from '../components/HintChip.vue'
import { useAppConfigStore } from '../stores/appConfig'
const config = useAppConfigStore()
const store = useReviewStore()
const route = useRoute()
const router = useRouter()
const stackRef = ref<InstanceType<typeof JobCardStack> | null>(null)
// ─── Tabs ──────────────────────────────────────────────────────────────────────
const TABS = [
{ status: 'pending', label: 'Pending' },
{ status: 'approved', label: 'Approved' },
{ status: 'rejected', label: 'Rejected' },
{ status: 'applied', label: 'Applied' },
{ status: 'synced', label: 'Synced' },
]
const activeTab = ref((route.query.status as string) ?? 'pending')
async function setTab(status: string) {
activeTab.value = status
if (status === 'pending') {
await store.fetchQueue()
} else {
await store.fetchList(status)
}
}
// ─── Undo toast ────────────────────────────────────────────────────────────────
const undoToast = ref<{ message: string } | null>(null)
let toastTimer = 0
function showUndoToast(action: 'approved' | 'rejected' | 'skipped') {
clearTimeout(toastTimer)
undoToast.value = { message: `${capitalize(action)}` }
toastTimer = window.setTimeout(() => { undoToast.value = null }, 5000)
}
async function doUndo() {
clearTimeout(toastTimer)
undoToast.value = null
await store.undo()
}
function capitalize(s: string) { return s.charAt(0).toUpperCase() + s.slice(1) }
// ─── Action handlers ───────────────────────────────────────────────────────────
async function onApprove() {
const job = store.currentJob
if (!job) return
const ok = await store.approve(job)
if (!ok) { stackRef.value?.resetCard(); return }
showUndoToast('approved')
checkStoopSpeed()
}
async function onReject() {
const job = store.currentJob
if (!job) return
const ok = await store.reject(job)
if (!ok) { stackRef.value?.resetCard(); return }
showUndoToast('rejected')
checkStoopSpeed()
}
function onSkip() {
const job = store.currentJob
if (!job) return
store.skip(job)
showUndoToast('skipped')
}
// ─── Stoop speed — easter egg 9.2 ─────────────────────────────────────────────
const stoopToastVisible = ref(false)
function checkStoopSpeed() {
if (!store.stoopAchieved && store.isStoopSpeed) {
store.markStoopAchieved()
stoopToastVisible.value = true
setTimeout(() => { stoopToastVisible.value = false }, 3500)
}
}
// ─── Keyboard shortcuts ────────────────────────────────────────────────────────
const showHelp = ref(false)
function onKeyDown(e: KeyboardEvent) {
// Don't steal keys when typing in an input
if ((e.target as Element).closest('input, textarea, select, [contenteditable]')) return
if (activeTab.value !== 'pending') return
switch (e.key) {
case 'ArrowRight':
case 'l':
case 'L':
e.preventDefault()
stackRef.value?.dismissApprove()
break
case 'ArrowLeft':
case 'j':
case 'J':
e.preventDefault()
stackRef.value?.dismissReject()
break
case 's':
case 'S':
e.preventDefault()
stackRef.value?.dismissSkip()
break
case 'z':
case 'Z':
e.preventDefault()
doUndo()
break
case 'Enter':
// Expand/collapse — bubble to the card's button naturally; no action needed here
break
case '?':
showHelp.value = !showHelp.value
break
case 'Escape':
showHelp.value = false
break
}
}
// ─── List view: sort + filter ─────────────────────────────────────────────────
type SortKey = 'match_score' | 'date_found' | 'company'
const sortBy = ref<SortKey>('match_score')
const filterRemote = ref(false)
const sortedFilteredJobs = computed(() => {
let jobs = [...store.listJobs]
if (filterRemote.value) jobs = jobs.filter(j => j.is_remote)
jobs.sort((a, b) => {
if (sortBy.value === 'match_score') return (b.match_score ?? -1) - (a.match_score ?? -1)
if (sortBy.value === 'date_found') return new Date(b.date_found).getTime() - new Date(a.date_found).getTime()
if (sortBy.value === 'company') return (a.company ?? '').localeCompare(b.company ?? '')
return 0
})
return jobs
})
// Reset filters when switching tabs
watch(activeTab, () => {
filterRemote.value = false
sortBy.value = 'match_score'
})
// ─── List view score pill ─────────────────────────────────────────────────────
function scorePillClass(score: number) {
if (score >= 80) return 'score-pill--high'
if (score >= 60) return 'score-pill--mid'
return 'score-pill--low'
}
// ─── Lifecycle ────────────────────────────────────────────────────────────────
onMounted(async () => {
document.addEventListener('keydown', onKeyDown)
await store.fetchQueue()
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
clearTimeout(toastTimer)
})
</script>
<style scoped>
.review {
max-width: 680px;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-6);
min-height: 100dvh;
}
/* ── Header ─────────────────────────────────────────────────────────── */
.review__header {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.review__title-row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.review__title {
font-family: var(--font-display);
font-size: var(--text-2xl);
color: var(--app-primary);
flex: 1;
}
.help-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-surface-raised);
color: var(--color-text-muted);
font-size: var(--text-sm);
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
transition: background 150ms ease, border-color 150ms ease;
display: flex;
align-items: center;
justify-content: center;
}
.help-btn:hover { background: var(--app-primary-light); border-color: var(--app-primary); }
/* ── Tabs ────────────────────────────────────────────────────────────── */
.review__tabs {
display: flex;
background: var(--color-surface-raised);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
padding: 3px;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
}
.review__tabs::-webkit-scrollbar { display: none; }
.review__tab {
flex: 1;
min-width: 0;
padding: var(--space-2) var(--space-3);
border: none;
border-radius: calc(var(--radius-lg) - 3px);
background: transparent;
color: var(--color-text-muted);
font-size: var(--text-xs);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
transition: background 150ms ease, color 150ms ease;
min-height: 32px;
}
.review__tab--active {
background: var(--app-primary);
color: white;
box-shadow: var(--shadow-sm);
}
.review__tab:not(.review__tab--active):hover {
background: var(--color-surface-alt);
color: var(--color-text);
}
.tab-badge {
background: var(--color-warning);
color: white;
font-size: 0.65rem;
font-weight: 700;
border-radius: 999px;
padding: 1px 5px;
line-height: 1.4;
}
.review__tab--active .tab-badge { background: rgba(255,255,255,0.3); }
/* ── Body ────────────────────────────────────────────────────────────── */
.review__body {
display: flex;
flex-direction: column;
gap: var(--space-4);
flex: 1;
}
/* ── Loading ─────────────────────────────────────────────────────────── */
.review__loading {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-12);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
/* ── Empty state — falcon stoop (easter egg 9.3) ────────────────────── */
.review__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
padding: var(--space-16) var(--space-8);
text-align: center;
}
.empty-falcon {
font-size: 4rem;
animation: falcon-stoop 1.2s cubic-bezier(0.4, 0, 0.2, 1) forwards;
display: block;
}
@keyframes falcon-stoop {
0% { transform: translateY(-60px) rotate(-30deg); opacity: 0; }
60% { transform: translateY(6px) rotate(0deg); opacity: 1; }
80% { transform: translateY(-4px); }
100% { transform: translateY(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.empty-falcon { animation: none; }
}
.empty-title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
}
.empty-desc {
font-size: var(--text-sm);
color: var(--color-text-muted);
max-width: 32ch;
}
/* ── Hint bar ────────────────────────────────────────────────────────── */
.hint-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-1);
}
.hint {
font-size: var(--text-xs);
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: 4px;
}
.hint-counter {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
font-family: var(--font-mono);
}
kbd {
display: inline-flex;
align-items: center;
padding: 1px 5px;
border-radius: 4px;
border: 1px solid var(--color-border);
background: var(--color-surface-alt);
font-size: 0.7rem;
font-family: var(--font-mono);
color: var(--color-text);
line-height: 1.5;
}
/* ── Action buttons ──────────────────────────────────────────────────── */
.review__actions {
display: flex;
gap: var(--space-3);
justify-content: center;
margin-top: var(--space-2);
}
.action-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-6);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
font-weight: 700;
cursor: pointer;
border: 2px solid transparent;
min-height: 44px;
transition: background 150ms ease, border-color 150ms ease, transform 100ms ease;
}
.action-btn:active { transform: scale(0.96); }
.action-btn--reject {
background: rgba(192, 57, 43, 0.08);
border-color: var(--color-error);
color: var(--color-error);
}
.action-btn--reject:hover { background: rgba(192, 57, 43, 0.16); }
.action-btn--skip {
background: var(--color-surface-raised);
border-color: var(--color-border);
color: var(--color-text-muted);
}
.action-btn--skip:hover { background: var(--color-surface-alt); }
.action-btn--approve {
background: rgba(39, 174, 96, 0.08);
border-color: var(--color-success);
color: var(--color-success);
}
.action-btn--approve:hover { background: rgba(39, 174, 96, 0.16); }
.review__undo-hint {
text-align: center;
font-size: var(--text-xs);
color: var(--color-text-muted);
}
/* ── Job list (non-pending tabs) ─────────────────────────────────────── */
.job-list {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.job-list__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
min-height: 44px;
transition: box-shadow 150ms ease;
}
.job-list__item:hover { box-shadow: var(--shadow-sm); }
.job-list__info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.job-list__title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-list__company {
font-size: var(--text-xs);
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-list__meta { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; }
.score-pill {
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 700;
font-family: var(--font-mono);
}
.score-pill--high { background: rgba(39, 174, 96, 0.15); color: var(--score-high); }
.score-pill--mid { background: rgba(212, 137, 26, 0.15); color: var(--score-mid); }
.score-pill--low { background: rgba(192, 57, 43, 0.15); color: var(--score-low); }
.job-list__link {
font-size: var(--text-xs);
color: var(--app-primary);
text-decoration: none;
font-weight: 600;
}
.job-list__action {
font-size: var(--text-xs);
font-weight: 600;
color: var(--app-primary);
background: color-mix(in srgb, var(--app-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--app-primary) 25%, transparent);
border-radius: var(--radius-sm);
padding: 2px 8px;
cursor: pointer;
transition: background 150ms;
white-space: nowrap;
}
.job-list__action:hover {
background: color-mix(in srgb, var(--app-primary) 18%, transparent);
}
.remote-tag {
font-size: 0.65rem;
font-weight: 700;
color: var(--color-info);
background: color-mix(in srgb, var(--color-info) 12%, transparent);
border-radius: var(--radius-full);
padding: 1px 5px;
margin-left: 4px;
}
/* ── List controls (sort + filter) ──────────────────────────────────── */
.list-controls {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
margin-bottom: var(--space-3);
}
.list-sort {
font-size: var(--text-xs);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
color: var(--color-text);
padding: 3px 8px;
cursor: pointer;
}
.list-filter-remote {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
}
.list-count {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin-left: auto;
}
/* ── Help overlay ────────────────────────────────────────────────────── */
.help-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
z-index: 400;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
}
.help-modal {
background: var(--color-surface-raised);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
padding: var(--space-6);
width: 100%;
max-width: 360px;
position: relative;
box-shadow: var(--shadow-xl, 0 16px 48px rgba(0,0,0,0.2));
}
.help-modal__title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
margin-bottom: var(--space-4);
}
.help-keys { display: flex; flex-direction: column; gap: var(--space-3); }
.help-keys__row {
display: flex;
align-items: center;
gap: var(--space-4);
font-size: var(--text-sm);
}
.help-keys__row dt { width: 6rem; flex-shrink: 0; display: flex; align-items: center; gap: 4px; }
.help-keys__row dd { color: var(--color-text-muted); }
.help-modal__close {
position: absolute;
top: var(--space-4);
right: var(--space-4);
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--color-border-light);
background: transparent;
color: var(--color-text-muted);
font-size: var(--text-base);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 150ms ease;
}
.help-modal__close:hover { background: var(--color-surface-alt); }
/* ── Toasts ──────────────────────────────────────────────────────────── */
.undo-toast {
position: fixed;
bottom: var(--space-6);
left: 50%;
transform: translateX(-50%);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-4);
display: flex;
align-items: center;
gap: var(--space-4);
font-size: var(--text-sm);
color: var(--color-text);
box-shadow: var(--shadow-lg);
z-index: 300;
white-space: nowrap;
}
.undo-toast__btn {
background: var(--app-primary);
color: white;
border: none;
border-radius: var(--radius-md);
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
font-weight: 700;
cursor: pointer;
transition: background 150ms ease;
}
.undo-toast__btn:hover { background: var(--app-primary-hover); }
.stoop-toast {
position: fixed;
bottom: calc(var(--space-6) + 56px);
right: var(--space-6);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-muted);
box-shadow: var(--shadow-lg);
z-index: 300;
}
/* ── Toast transitions ───────────────────────────────────────────────── */
.toast-enter-active, .toast-leave-active { transition: opacity 280ms ease, transform 280ms ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(8px) translateX(-50%); }
.stoop-toast.toast-enter-from,
.stoop-toast.toast-leave-to { transform: translateY(8px); }
.overlay-enter-active, .overlay-leave-active { transition: opacity 200ms ease; }
.overlay-enter-from, .overlay-leave-to { opacity: 0; }
/* ── Spinner ─────────────────────────────────────────────────────────── */
.spinner {
width: 1.2rem;
height: 1.2rem;
border: 2px solid var(--color-border);
border-top-color: var(--app-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Screen reader only ──────────────────────────────────────────────── */
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 767px) {
.review { padding: var(--space-4); gap: var(--space-4); }
.review__title { font-size: var(--text-xl); }
.review__tab {
font-size: 0.65rem;
padding: var(--space-2);
}
.hint-bar { display: none; } /* mobile: no room — swipe speaks for itself */
.review__actions { gap: var(--space-2); }
.action-btn { padding: var(--space-3) var(--space-4); font-size: var(--text-xs); }
.undo-toast {
left: var(--space-4);
right: var(--space-4);
transform: none;
bottom: calc(56px + env(safe-area-inset-bottom) + var(--space-4));
}
.toast-enter-from, .toast-leave-to { transform: translateY(8px); }
}
</style>