peregrine/web/src/views/HomeView.vue
pyr0ball 82c26074d8 fix: search prefs wizard data loss, resume sync link, docs + GUI help links
Bug fixes (filed as #125–#128):
- Wizard step 7 read data.titles instead of data.search.titles — user-entered
  job titles and locations were silently dropped on every wizard run (#125)
- GET /api/settings/search returned "titles" key but store expected "job_titles" —
  Settings → Search Prefs always showed empty even when data existed (#126)
- remote_only preference not persisted during wizard setup (#127)
- apply-to-profile didn't set default_resume_id in user.yaml, so future
  Resume Profile saves never synced back to the library entry (#128)

Also:
- Wizard step headings corrected (off-by-one after Training step was inserted)
- Ollama host in wizard inference step now reads from saved wizard state
- Resume upload during wizard now creates a library entry and sets it as default

Docs:
- New: docs/user-guide/daily-workflow.md — end-to-end daily usage guide
- Updated: docs/user-guide/settings.md — rewritten for Vue SPA (was Streamlit)
- mkdocs.yml nav: Daily Workflow added as first User Guide entry

GUI help links:
- web/src/composables/useDocsUrl.ts — shared docs base URL composable
- Home: "Daily Workflow guide ↗" link in subtitle
- Job Review: "? Docs" link in title row
- Resume Library: "? Help" link in header
- Settings → Resume Profile: "? Help" link in page header
- Settings → Search Prefs: "? Help" link in page header
2026-06-15 16:52:56 -07:00

1047 lines
34 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">
<HintChip
v-if="config.isDemo"
view-key="home"
message="Start in Job Review — 12 jobs are waiting for your verdict"
/>
<!-- 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
<a href="https://docs.circuitforge.tech/peregrine/user-guide/daily-workflow/" target="_blank" rel="noopener" class="home__docs-link" aria-label="Daily Workflow documentation">Daily Workflow guide </a>
</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"
/>
<label class="add-jobs__skip-review">
<input type="checkbox" v-model="skipReview" />
Skip review add directly to Apply queue
</label>
<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'
import HintChip from '../components/HintChip.vue'
import { useAppConfigStore } from '../stores/appConfig'
const config = useAppConfigStore()
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('')
const skipReview = ref(true)
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, skip_review: skipReview.value }),
})
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;
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.home__docs-link {
font-size: 0.7rem;
text-transform: none;
letter-spacing: 0;
color: var(--color-text-muted);
text-decoration: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
padding: 1px 7px;
}
.home__docs-link:hover { color: var(--color-primary); border-color: var(--color-primary); }
.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; }
.add-jobs__skip-review {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
}
/* ── 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>