peregrine/web/src/views/HomeView.vue
pyr0ball 065c02feb7
Some checks failed
CI / test (push) Failing after 20s
feat(vue): Home dashboard parity — Enrich button, Danger Zone, setup banners (closes #57)
API additions (dev-api.py):
- GET /api/tasks — list active background tasks
- DELETE /api/tasks/{task_id} — per-task cancel
- POST /api/tasks/kill — kill all stuck tasks
- POST /api/tasks/discovery|email-sync|enrich|score|sync — queue/trigger each workflow
- POST /api/jobs/archive — archive by statuses array
- POST /api/jobs/purge — hard delete by statuses or target (email/non_remote/rescrape)
- POST /api/jobs/add — queue URL imports
- POST /api/jobs/upload-csv — upload CSV with URL column
- GET  /api/config/setup-banners — list undismissed onboarding hints
- POST /api/config/setup-banners/{key}/dismiss — dismiss a banner

HomeView.vue:
- 4th WorkflowButton: "Fill Missing Descriptions" (always visible, not gated on enrichment_enabled)
- Danger Zone redesign: scope radio (pending-only vs pending+approved), Archive & reset (primary)
  vs Hard purge (secondary), inline confirm dialogs, active task list with per-task cancel,
  Kill all stuck button, More Options (email purge / non-remote / wipe+rescrape)
- Setup banners: dismissible onboarding hints pulled from /api/config/setup-banners,
  5-second polling for active task list to stay live

app/Home.py:
- Danger Zone redesign: same scope radio + archive/purge with confirm steps
- Background task list with per-task cancel and Kill all stuck button
- More options expander (email purge, non-remote, wipe+rescrape)
- Setup banners section at page bottom
2026-04-04 22:05:06 -07:00

1005 lines
33 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="home">
<!-- Header -->
<header class="home__header">
<div>
<h1 class="home__greeting">
{{ greeting }}
<span v-if="isMidnight" aria-label="Late night session">🌙</span>
</h1>
<p class="home__subtitle">Discover Review Apply</p>
</div>
</header>
<!-- Metric cards -->
<section class="home__metrics" aria-label="Pipeline overview">
<RouterLink
v-for="metric in metrics"
:key="metric.status"
:to="metric.link"
class="metric-card"
:class="`metric-card--${metric.status}`"
:aria-label="`${metric.count ?? 0} ${metric.label} jobs`"
>
<span class="metric-card__count" aria-hidden="true">
{{ store.loading ? '—' : (metric.count ?? 0) }}
</span>
<span class="metric-card__label">{{ metric.label }}</span>
</RouterLink>
</section>
<!-- Primary workflow -->
<section class="home__section" aria-labelledby="workflow-heading">
<h2 id="workflow-heading" class="home__section-title">Primary Workflow</h2>
<div class="home__actions">
<WorkflowButton
emoji="🚀"
label="Run Discovery"
description="Scan job boards for new listings"
:loading="taskRunning === 'discovery'"
@click="runDiscovery"
/>
<WorkflowButton
emoji="📧"
label="Sync Emails"
description="Fetch and classify inbox"
:loading="taskRunning === 'email'"
@click="syncEmails"
/>
<WorkflowButton
emoji="📊"
label="Score Unscored"
description="Run match scoring on new jobs"
:loading="taskRunning === 'score'"
@click="scoreUnscored"
/>
<WorkflowButton
emoji="🔍"
label="Fill Missing Descriptions"
description="Re-fetch truncated job descriptions"
:loading="taskRunning === 'enrich'"
@click="runEnrich"
/>
</div>
<button
v-if="unsyncedCount > 0"
class="sync-banner"
:disabled="taskRunning === 'sync'"
:aria-busy="taskRunning === 'sync'"
@click="syncIntegration"
>
<span aria-hidden="true">📤</span>
<span>
Sync {{ unsyncedCount }} approved {{ unsyncedCount === 1 ? 'job' : 'jobs' }}
{{ integrationName }}
</span>
<span v-if="taskRunning === 'sync'" class="spinner" aria-hidden="true" />
</button>
</section>
<!-- Auto-enrichment status -->
<section v-if="store.status?.enrichment_enabled" class="home__section">
<div class="enrichment-row" role="status" aria-live="polite">
<span class="enrichment-row__dot" :class="enrichmentDotClass" aria-hidden="true" />
<span class="enrichment-row__text">
{{ store.status?.enrichment_last_run
? `Last enriched ${formatRelative(store.status.enrichment_last_run)}`
: 'Auto-enrichment active' }}
</span>
</div>
</section>
<!-- Backlog management -->
<section v-if="showBacklog" class="home__section" aria-labelledby="backlog-heading">
<h2 id="backlog-heading" class="home__section-title">Backlog Management</h2>
<p class="home__section-desc">
You have
<strong>{{ store.counts?.pending ?? 0 }} pending</strong>
and
<strong>{{ store.counts?.approved ?? 0 }} approved</strong>
listings.
</p>
<div class="home__actions home__actions--secondary">
<button
v-if="(store.counts?.pending ?? 0) > 0"
class="action-btn action-btn--secondary"
@click="archiveByStatus(['pending'])"
>
📦 Archive Pending
</button>
<button
v-if="(store.counts?.rejected ?? 0) > 0"
class="action-btn action-btn--secondary"
@click="archiveByStatus(['rejected'])"
>
📦 Archive Rejected
</button>
<button
v-if="(store.counts?.approved ?? 0) > 0"
class="action-btn action-btn--secondary"
@click="archiveByStatus(['approved'])"
>
📦 Archive Approved (unapplied)
</button>
</div>
</section>
<!-- Add jobs by URL -->
<section class="home__section" aria-labelledby="add-heading">
<h2 id="add-heading" class="home__section-title">Add Jobs by URL</h2>
<div class="add-jobs">
<div class="add-jobs__tabs" role="tablist">
<button
role="tab"
:aria-selected="addTab === 'url'"
class="add-jobs__tab"
:class="{ 'add-jobs__tab--active': addTab === 'url' }"
@click="addTab = 'url'"
>Paste URLs</button>
<button
role="tab"
:aria-selected="addTab === 'csv'"
class="add-jobs__tab"
:class="{ 'add-jobs__tab--active': addTab === 'csv' }"
@click="addTab = 'csv'"
>Upload CSV</button>
</div>
<div class="add-jobs__panel" role="tabpanel">
<template v-if="addTab === 'url'">
<textarea
v-model="urlInput"
class="add-jobs__textarea"
placeholder="Paste one job URL per line…"
rows="4"
aria-label="Job URLs to add"
/>
<button
class="action-btn action-btn--primary"
:disabled="!urlInput.trim()"
@click="addByUrl"
>Add Jobs</button>
</template>
<template v-else>
<p class="home__section-desc">Upload a CSV with a <code>url</code> column.</p>
<input type="file" accept=".csv" aria-label="CSV file" @change="handleCsvUpload" />
</template>
</div>
</div>
</section>
<!-- Danger Zone -->
<section class="home__section">
<details class="danger-zone">
<summary class="danger-zone__summary"> Danger Zone</summary>
<div class="danger-zone__body">
<!-- Queue reset -->
<div class="dz-block">
<p class="dz-block__title">Queue reset</p>
<p class="dz-block__desc">
Archive clears your review queue while keeping job URLs for dedup same listings
won't resurface on the next discovery run. Use hard purge only for a full clean slate
including dedup history.
</p>
<fieldset class="dz-scope" aria-label="Clear scope">
<legend class="dz-scope__legend">Clear scope</legend>
<label class="dz-scope__option">
<input type="radio" v-model="dangerScope" value="pending" />
Pending only
</label>
<label class="dz-scope__option">
<input type="radio" v-model="dangerScope" value="pending_approved" />
Pending + approved (stale search)
</label>
</fieldset>
<div class="dz-actions">
<button
class="action-btn action-btn--primary"
:disabled="!!confirmAction"
@click="beginConfirm('archive')"
>
📦 Archive &amp; reset
</button>
<button
class="action-btn action-btn--secondary"
:disabled="!!confirmAction"
@click="beginConfirm('purge')"
>
🗑 Hard purge (delete)
</button>
</div>
<!-- Inline confirm -->
<div v-if="confirmAction" class="dz-confirm" role="alertdialog" aria-live="assertive">
<p v-if="confirmAction.type === 'archive'" class="dz-confirm__msg dz-confirm__msg--info">
Archive <strong>{{ confirmAction.statuses.join(' + ') }}</strong> jobs?
URLs are kept for dedup — nothing is permanently deleted.
</p>
<p v-else class="dz-confirm__msg dz-confirm__msg--warn">
Permanently delete <strong>{{ confirmAction.statuses.join(' + ') }}</strong> jobs?
This removes URLs from dedup history too. Cannot be undone.
</p>
<div class="dz-confirm__actions">
<button class="action-btn action-btn--primary" @click="executeConfirm">
{{ confirmAction.type === 'archive' ? 'Yes, archive' : 'Yes, delete' }}
</button>
<button class="action-btn action-btn--secondary" @click="confirmAction = null">
Cancel
</button>
</div>
</div>
</div>
<hr class="dz-divider" />
<!-- Background tasks -->
<div class="dz-block">
<p class="dz-block__title">Background tasks — {{ activeTasks.length }} active</p>
<template v-if="activeTasks.length > 0">
<div
v-for="task in activeTasks"
:key="task.id"
class="dz-task"
>
<span class="dz-task__icon">{{ taskIcon(task.task_type) }}</span>
<span class="dz-task__type">{{ task.task_type.replace(/_/g, ' ') }}</span>
<span class="dz-task__label">
{{ task.title ? `${task.title}${task.company ? ' @ ' + task.company : ''}` : `job #${task.job_id}` }}
</span>
<span class="dz-task__status">{{ task.status }}</span>
<button
class="btn-ghost btn-ghost--sm dz-task__cancel"
@click="cancelTaskById(task.id)"
:aria-label="`Cancel ${task.task_type} task`"
>
</button>
</div>
</template>
<button
class="action-btn action-btn--secondary dz-kill"
:disabled="activeTasks.length === 0"
@click="killAll"
>
⏹ Kill all stuck
</button>
</div>
<hr class="dz-divider" />
<!-- More options -->
<details class="dz-more">
<summary class="dz-more__summary">More options</summary>
<div class="dz-more__body">
<!-- Email purge -->
<div class="dz-more__item">
<p class="dz-block__title">Purge email data</p>
<p class="dz-block__desc">Clears all email thread logs and email-sourced pending jobs.</p>
<template v-if="moreConfirm === 'email'">
<p class="dz-confirm__msg dz-confirm__msg--warn">
Deletes all email contacts and email-sourced jobs. Cannot be undone.
</p>
<div class="dz-confirm__actions">
<button class="action-btn action-btn--primary" @click="executePurgeTarget('email')">Yes, purge emails</button>
<button class="action-btn action-btn--secondary" @click="moreConfirm = null">Cancel</button>
</div>
</template>
<button v-else class="action-btn action-btn--secondary" @click="moreConfirm = 'email'">
📧 Purge Email Data
</button>
</div>
<!-- Non-remote purge -->
<div class="dz-more__item">
<p class="dz-block__title">Purge non-remote</p>
<p class="dz-block__desc">Removes pending/approved/rejected on-site listings from the DB.</p>
<template v-if="moreConfirm === 'non_remote'">
<p class="dz-confirm__msg dz-confirm__msg--warn">
Deletes all non-remote jobs not yet applied to. Cannot be undone.
</p>
<div class="dz-confirm__actions">
<button class="action-btn action-btn--primary" @click="executePurgeTarget('non_remote')">Yes, purge on-site</button>
<button class="action-btn action-btn--secondary" @click="moreConfirm = null">Cancel</button>
</div>
</template>
<button v-else class="action-btn action-btn--secondary" @click="moreConfirm = 'non_remote'">
🏢 Purge On-site Jobs
</button>
</div>
<!-- Wipe + re-scrape -->
<div class="dz-more__item">
<p class="dz-block__title">Wipe all + re-scrape</p>
<p class="dz-block__desc">Deletes all non-applied jobs then immediately runs a fresh discovery.</p>
<template v-if="moreConfirm === 'rescrape'">
<p class="dz-confirm__msg dz-confirm__msg--warn">
Wipes ALL pending, approved, and rejected jobs, then re-scrapes.
Applied and synced records are kept.
</p>
<div class="dz-confirm__actions">
<button class="action-btn action-btn--primary" @click="executePurgeTarget('rescrape')">Yes, wipe + scrape</button>
<button class="action-btn action-btn--secondary" @click="moreConfirm = null">Cancel</button>
</div>
</template>
<button v-else class="action-btn action-btn--secondary" @click="moreConfirm = 'rescrape'">
🔄 Wipe + Re-scrape
</button>
</div>
</div>
</details>
</div>
</details>
</section>
<!-- Setup banners -->
<section v-if="banners.length > 0" class="home__section" aria-labelledby="setup-heading">
<h2 id="setup-heading" class="home__section-title">Finish setting up Peregrine</h2>
<div class="banners">
<div v-for="banner in banners" :key="banner.key" class="banner">
<span class="banner__icon" aria-hidden="true">💡</span>
<span class="banner__text">{{ banner.text }}</span>
<RouterLink :to="banner.link" class="banner__link">Go to settings →</RouterLink>
<button
class="btn-ghost btn-ghost--sm banner__dismiss"
@click="dismissBanner(banner.key)"
:aria-label="`Dismiss: ${banner.text}`"
>
</button>
</div>
</div>
</section>
<!-- Stoop speed toast — easter egg 9.2 -->
<Transition name="toast">
<div v-if="stoopToast" class="stoop-toast" role="status" aria-live="polite">
🦅 Stoop speed.
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useJobsStore } from '../stores/jobs'
import { useApiFetch } from '../composables/useApi'
import WorkflowButton from '../components/WorkflowButton.vue'
const store = useJobsStore()
// Greeting — easter egg 9.7: midnight mode
const userName = ref('')
const hour = new Date().getHours()
const isMidnight = computed(() => hour >= 0 && hour < 5)
const greeting = computed(() => {
const name = userName.value ? `${userName.value}'s` : 'Your'
return isMidnight.value ? `${name} Late-Night Job Search` : `${name} Job Search`
})
const metrics = computed(() => [
{ status: 'pending', label: 'Pending', count: store.counts?.pending, link: '/review?status=pending' },
{ status: 'approve', label: 'Approved', count: store.counts?.approved, link: '/review?status=approved' },
{ status: 'applied', label: 'Applied', count: store.counts?.applied, link: '/review?status=applied' },
{ status: 'synced', label: 'Synced', count: store.counts?.synced, link: '/review?status=synced' },
{ status: 'reject', label: 'Rejected', count: store.counts?.rejected, link: '/review?status=rejected' },
])
const integrationName = computed(() => store.status?.integration_name ?? 'Export')
const unsyncedCount = computed(() => store.status?.integration_unsynced ?? 0)
const showBacklog = computed(() => (store.counts?.pending ?? 0) > 0 || (store.counts?.approved ?? 0) > 0)
const enrichmentDotClass = computed(() =>
store.status?.enrichment_last_run ? 'enrichment-row__dot--ok' : 'enrichment-row__dot--idle',
)
function formatRelative(isoStr: string) {
const mins = Math.round((Date.now() - new Date(isoStr).getTime()) / 60000)
if (mins < 2) return 'just now'
if (mins < 60) return `${mins} min ago`
const hrs = Math.round(mins / 60)
return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`
}
// ── Task execution ─────────────────────────────────────────────────────────
const taskRunning = ref<string | null>(null)
const stoopToast = ref(false)
async function runTask(key: string, endpoint: string) {
taskRunning.value = key
await useApiFetch(endpoint, { method: 'POST' })
taskRunning.value = null
store.refresh()
fetchActiveTasks()
}
const runDiscovery = () => runTask('discovery', '/api/tasks/discovery')
const syncEmails = () => runTask('email', '/api/tasks/email-sync')
const scoreUnscored = () => runTask('score', '/api/tasks/score')
const syncIntegration = () => runTask('sync', '/api/tasks/sync')
const runEnrich = () => runTask('enrich', '/api/tasks/enrich')
// ── Add jobs ───────────────────────────────────────────────────────────────
const addTab = ref<'url' | 'csv'>('url')
const urlInput = ref('')
async function addByUrl() {
const urls = urlInput.value.split('\n').map(u => u.trim()).filter(Boolean)
await useApiFetch('/api/jobs/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls }),
})
urlInput.value = ''
store.refresh()
}
function handleCsvUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const form = new FormData()
form.append('file', file)
useApiFetch('/api/jobs/upload-csv', { method: 'POST', body: form })
}
// ── Backlog archive ────────────────────────────────────────────────────────
async function archiveByStatus(statuses: string[]) {
await useApiFetch('/api/jobs/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statuses }),
})
store.refresh()
}
// ── Danger Zone ────────────────────────────────────────────────────────────
interface TaskRow { id: number; task_type: string; status: string; title?: string; company?: string; job_id: number }
interface Banner { key: string; text: string; link: string }
interface ConfirmAction { type: 'archive' | 'purge'; statuses: string[] }
const activeTasks = ref<TaskRow[]>([])
const dangerScope = ref<'pending' | 'pending_approved'>('pending')
const confirmAction = ref<ConfirmAction | null>(null)
const moreConfirm = ref<string | null>(null)
const banners = ref<Banner[]>([])
let taskPollInterval: ReturnType<typeof setInterval> | null = null
async function fetchActiveTasks() {
const { data } = await useApiFetch<TaskRow[]>('/api/tasks')
activeTasks.value = data ?? []
}
async function fetchBanners() {
const { data } = await useApiFetch<Banner[]>('/api/config/setup-banners')
banners.value = data ?? []
}
function scopeStatuses(): string[] {
return dangerScope.value === 'pending' ? ['pending'] : ['pending', 'approved']
}
function beginConfirm(type: 'archive' | 'purge') {
moreConfirm.value = null
confirmAction.value = { type, statuses: scopeStatuses() }
}
async function executeConfirm() {
const action = confirmAction.value
confirmAction.value = null
if (!action) return
const endpoint = action.type === 'archive' ? '/api/jobs/archive' : '/api/jobs/purge'
const key = action.type === 'archive' ? 'statuses' : 'statuses'
await useApiFetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: action.statuses }),
})
store.refresh()
fetchActiveTasks()
}
async function cancelTaskById(id: number) {
await useApiFetch(`/api/tasks/${id}`, { method: 'DELETE' })
fetchActiveTasks()
}
async function killAll() {
await useApiFetch('/api/tasks/kill', { method: 'POST' })
fetchActiveTasks()
}
async function executePurgeTarget(target: string) {
moreConfirm.value = null
await useApiFetch('/api/jobs/purge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target }),
})
store.refresh()
fetchActiveTasks()
}
async function dismissBanner(key: string) {
await useApiFetch(`/api/config/setup-banners/${key}/dismiss`, { method: 'POST' })
banners.value = banners.value.filter(b => b.key !== key)
}
function taskIcon(taskType: string): string {
const icons: Record<string, string> = {
cover_letter: '✉️', company_research: '🔍', discovery: '🌐',
enrich_descriptions: '📝', email_sync: '📧', score: '📊',
scrape_url: '🔗',
}
return icons[taskType] ?? '⚙️'
}
onMounted(async () => {
store.refresh()
const { data } = await useApiFetch<{ name: string }>('/api/config/user')
if (data?.name) userName.value = data.name
fetchActiveTasks()
fetchBanners()
taskPollInterval = setInterval(fetchActiveTasks, 5000)
})
onUnmounted(() => {
if (taskPollInterval) clearInterval(taskPollInterval)
})
</script>
<style scoped>
.home {
max-width: 900px;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.home__header {
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border-light);
}
.home__greeting {
font-family: var(--font-display);
font-size: var(--text-3xl);
color: var(--app-primary);
line-height: 1.1;
}
.home__subtitle {
margin-top: var(--space-2);
color: var(--color-text-muted);
font-size: var(--text-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.home__metrics {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--space-3);
}
.metric-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-4) var(--space-3);
background: var(--color-surface-raised);
border: 2px solid transparent;
border-radius: var(--radius-lg);
text-decoration: none;
min-height: 44px;
transition:
border-color 150ms ease,
box-shadow 150ms ease,
transform 150ms ease;
}
.metric-card:hover {
border-color: var(--app-primary-light);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.metric-card__count {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 700;
line-height: 1;
}
.metric-card__label {
font-size: var(--text-xs);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.metric-card--pending .metric-card__count { color: var(--status-pending); }
.metric-card--approve .metric-card__count { color: var(--status-approve); }
.metric-card--applied .metric-card__count { color: var(--status-applied); }
.metric-card--synced .metric-card__count { color: var(--status-synced); }
.metric-card--reject .metric-card__count { color: var(--status-reject); }
.home__section { display: flex; flex-direction: column; gap: var(--space-4); }
.home__section-title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
}
.home__section-desc { font-size: var(--text-sm); color: var(--color-text-muted); }
.home__actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-3);
}
.home__actions--secondary { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.sync-banner {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
background: var(--app-primary-light);
border: 1px solid var(--app-primary);
border-radius: var(--radius-md);
color: var(--app-primary);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
width: 100%;
text-align: left;
transition: background 150ms ease, box-shadow 150ms ease;
}
.sync-banner:hover { background: var(--color-surface-alt); box-shadow: var(--shadow-sm); }
.sync-banner:disabled { opacity: 0.6; cursor: not-allowed; }
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-left: auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
.action-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
border: none;
min-height: 44px;
transition: background 150ms ease, box-shadow 150ms ease;
}
.action-btn--primary { background: var(--app-accent); color: var(--app-accent-text); }
.action-btn--primary:hover { background: var(--app-accent-hover); }
.action-btn--primary:disabled { opacity: 0.4; cursor: not-allowed; }
.action-btn--secondary { background: var(--color-surface-alt); color: var(--color-text); border: 1px solid var(--color-border); }
.action-btn--secondary:hover { background: var(--color-border-light); }
.action-btn--secondary:disabled { opacity: 0.4; cursor: not-allowed; }
.enrichment-row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-surface-raised);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.enrichment-row__dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.enrichment-row__dot--ok { background: var(--color-success); }
.enrichment-row__dot--idle { background: var(--color-text-muted); }
.enrichment-row__text { flex: 1; }
.btn-ghost {
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.btn-ghost--sm { padding: var(--space-1) var(--space-3); font-size: var(--text-xs); }
.btn-ghost:hover { background: var(--color-surface-alt); color: var(--color-text); }
.add-jobs {
background: var(--color-surface-raised);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
overflow: hidden;
}
.add-jobs__tabs { display: flex; border-bottom: 1px solid var(--color-border-light); }
.add-jobs__tab {
flex: 1;
padding: var(--space-3) var(--space-4);
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
font-size: var(--text-sm);
color: var(--color-text-muted);
cursor: pointer;
transition: color 150ms ease, border-color 150ms ease;
}
.add-jobs__tab--active { color: var(--app-primary); border-bottom-color: var(--app-primary); font-weight: 600; }
.add-jobs__panel {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.add-jobs__textarea {
width: 100%;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-mono);
font-size: var(--text-sm);
resize: vertical;
}
.add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
/* ── Danger Zone ──────────────────────────────────────── */
.danger-zone {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.danger-zone__summary {
padding: var(--space-3) var(--space-4);
cursor: pointer;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
list-style: none;
user-select: none;
}
.danger-zone__summary::-webkit-details-marker { display: none; }
.danger-zone__summary::before { content: '▶ '; font-size: 0.7em; }
details[open] > .danger-zone__summary::before { content: '▼ '; }
.danger-zone__body {
padding: 0 var(--space-4) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.dz-block { display: flex; flex-direction: column; gap: var(--space-3); }
.dz-block__title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.dz-block__desc {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.dz-scope {
border: none;
padding: 0;
margin: 0;
display: flex;
gap: var(--space-5);
flex-wrap: wrap;
}
.dz-scope__legend {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin-bottom: var(--space-2);
float: left;
width: 100%;
}
.dz-scope__option {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
cursor: pointer;
}
.dz-actions {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
}
.dz-confirm {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.dz-confirm__msg {
font-size: var(--text-sm);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border-left: 3px solid;
}
.dz-confirm__msg--info {
background: rgba(52, 152, 219, 0.1);
border-color: var(--app-primary);
color: var(--color-text);
}
.dz-confirm__msg--warn {
background: rgba(192, 57, 43, 0.08);
border-color: var(--color-error);
color: var(--color-text);
}
.dz-confirm__actions {
display: flex;
gap: var(--space-3);
}
.dz-divider {
border: none;
border-top: 1px solid var(--color-border-light);
margin: 0;
}
.dz-task {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--color-surface-alt);
border-radius: var(--radius-md);
font-size: var(--text-xs);
}
.dz-task__icon { flex-shrink: 0; }
.dz-task__type { font-family: var(--font-mono); color: var(--color-text-muted); min-width: 120px; }
.dz-task__label { flex: 1; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dz-task__status { color: var(--color-text-muted); font-style: italic; }
.dz-task__cancel { margin-left: var(--space-2); }
.dz-kill { align-self: flex-start; }
.dz-more {
background: transparent;
border: none;
}
.dz-more__summary {
cursor: pointer;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
list-style: none;
user-select: none;
padding: var(--space-1) 0;
}
.dz-more__summary::-webkit-details-marker { display: none; }
.dz-more__summary::before { content: '▶ '; font-size: 0.7em; }
details[open] > .dz-more__summary::before { content: '▼ '; }
.dz-more__body {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-5);
margin-top: var(--space-4);
}
.dz-more__item { display: flex; flex-direction: column; gap: var(--space-2); }
/* ── Setup banners ────────────────────────────────────── */
.banners {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.banner {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.banner__icon { flex-shrink: 0; }
.banner__text { flex: 1; color: var(--color-text); }
.banner__link { color: var(--app-primary); text-decoration: none; white-space: nowrap; font-weight: 500; }
.banner__link:hover { text-decoration: underline; }
.banner__dismiss { margin-left: var(--space-1); }
/* ── Toast ────────────────────────────────────────────── */
.stoop-toast {
position: fixed;
bottom: var(--space-6);
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: 200;
}
.toast-enter-active,
.toast-leave-active {
transition: opacity 300ms ease, transform 300ms ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(8px);
}
@media (max-width: 768px) {
.home { padding: var(--space-4); gap: var(--space-6); }
.home__greeting { font-size: var(--text-2xl); }
.home__metrics { grid-template-columns: repeat(3, 1fr); }
.dz-more__body { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
.home__metrics { grid-template-columns: repeat(2, 1fr); }
.home__metrics .metric-card:last-child { grid-column: 1 / -1; }
}
</style>