peregrine/web/src/components/ApplyWorkspace.vue
pyr0ball faf0a7c4dc feat(apply): ATS resume optimizer — gap report + LLM rewrite (paid tier)
- scripts/resume_optimizer.py: extract_jd_signals, prioritize_gaps,
  rewrite_for_ats, hallucination_check, render_resume_text
- dev_api.py: GET/POST /api/jobs/{id}/resume_optimizer + /task endpoints
- web/src/components/ResumeOptimizerPanel.vue: gap report (all tiers),
  per-section LLM rewrite + hallucination badge (paid+)
- ApplyWorkspace.vue: ResumeOptimizerPanel wired in below cover letter

Closes #29
2026-03-31 21:24:49 -07:00

872 lines
28 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.

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="workspace">
<div v-if="loadingJob" class="workspace__loading" aria-live="polite">
<span class="spinner" aria-hidden="true" />
<span>Loading job</span>
</div>
<div v-else-if="!job" class="workspace__not-found" role="alert">
<p>Job not found.</p>
</div>
<template v-else>
<!-- Two-panel layout: job details | cover letter + resume optimizer -->
<div class="workspace__panels">
<!-- Left: Job details -->
<aside class="workspace__job-panel">
<div class="job-details">
<!-- Badges -->
<div class="job-details__badges">
<span
v-if="job.match_score !== null"
class="score-badge"
:class="[scoreBadgeClass, { 'score-badge--shimmer': shimmeringBadge }]"
>
{{ job.match_score }}%
</span>
<span v-if="job.is_remote" class="remote-badge">Remote</span>
</div>
<h1 class="job-details__title">{{ job.title }}</h1>
<div class="job-details__company">
{{ job.company }}
<span v-if="job.location" aria-hidden="true"> · </span>
<span v-if="job.location" class="job-details__location">{{ job.location }}</span>
</div>
<div v-if="job.salary" class="job-details__salary">{{ job.salary }}</div>
<!-- Description -->
<div class="job-details__desc" :class="{ 'job-details__desc--clamped': !descExpanded }">
{{ job.description ?? 'No description available.' }}
</div>
<button
v-if="(job.description?.length ?? 0) > 300"
class="expand-btn"
:aria-expanded="descExpanded"
@click="descExpanded = !descExpanded"
>
{{ descExpanded ? 'Show less ▲' : 'Show more ▼' }}
</button>
<!-- Keyword gaps -->
<div v-if="gaps.length > 0" class="job-details__gaps">
<span class="gaps-label">Missing keywords:</span>
<span v-for="kw in gaps.slice(0, 6)" :key="kw" class="gap-pill">{{ kw }}</span>
<span v-if="gaps.length > 6" class="gaps-more">+{{ gaps.length - 6 }}</span>
</div>
<a v-if="job.url" :href="job.url" target="_blank" rel="noopener noreferrer" class="job-details__link">
View listing
</a>
</div>
</aside>
<!-- Right: Cover letter -->
<main class="workspace__cl-panel">
<h2 class="cl-heading">Cover Letter</h2>
<!-- State: none no draft yet -->
<template v-if="clState === 'none'">
<div class="cl-empty">
<p class="cl-empty__hint">No cover letter yet. Generate one with AI or paste your own.</p>
<div class="cl-empty__actions">
<button class="btn-generate" :disabled="generating" @click="generate()">
<span aria-hidden="true"></span> Generate with AI
</button>
<button class="btn-ghost" @click="clState = 'ready'; clText = ''">
Paste / write manually
</button>
</div>
</div>
</template>
<!-- State: queued / running generating -->
<template v-else-if="clState === 'queued' || clState === 'running'">
<div class="cl-generating" role="status" aria-live="polite">
<span class="spinner spinner--lg" aria-hidden="true" />
<p class="cl-generating__label">
{{ clState === 'queued' ? 'Queued…' : (taskStage ?? 'Generating cover letter…') }}
</p>
<p class="cl-generating__hint">This usually takes 2060 seconds</p>
</div>
</template>
<!-- State: failed -->
<template v-else-if="clState === 'failed'">
<div class="cl-error" role="alert">
<span aria-hidden="true"></span>
<span class="cl-error__msg">Cover letter generation failed</span>
<span v-if="taskError" class="cl-error__detail">{{ taskError }}</span>
<div class="cl-error__actions">
<button class="btn-generate" @click="generate()">Retry</button>
<button class="btn-ghost" @click="clState = 'ready'; clText = ''">
Write manually instead
</button>
</div>
</div>
</template>
<!-- State: ready editor -->
<template v-else-if="clState === 'ready'">
<div class="cl-editor">
<div class="cl-editor__toolbar">
<span class="cl-editor__wordcount" aria-live="polite">
{{ wordCount }} words
</span>
<button
class="btn-ghost btn-ghost--sm"
:disabled="isSaved || saving"
@click="saveCoverLetter"
>
{{ saving ? 'Saving…' : (isSaved ? '✓ Saved' : 'Save') }}
</button>
</div>
<textarea
ref="textareaEl"
v-model="clText"
class="cl-editor__textarea"
aria-label="Cover letter text"
placeholder="Your cover letter…"
@input="isSaved = false; autoResize()"
/>
</div>
<!-- Download PDF -->
<button class="btn-download" :disabled="!clText.trim() || downloadingPdf" @click="downloadPdf">
<span aria-hidden="true">📄</span>
{{ downloadingPdf ? 'Generating PDF…' : 'Download PDF' }}
</button>
</template>
<!-- Regenerate button (when ready, offer to redo) -->
<button
v-if="clState === 'ready'"
class="btn-ghost btn-ghost--sm cl-regen"
@click="generate()"
>
Regenerate
</button>
<!-- ATS Resume Optimizer -->
<ResumeOptimizerPanel :job-id="props.jobId" />
<!-- Bottom action bar -->
<div class="workspace__actions">
<button
class="action-btn action-btn--apply"
:disabled="actioning"
@click="markApplied"
>
<span aria-hidden="true">🚀</span>
{{ actioning === 'apply' ? 'Marking…' : 'Mark as Applied' }}
</button>
<button
class="action-btn action-btn--reject"
:disabled="!!actioning"
@click="rejectListing"
>
<span aria-hidden="true"></span>
{{ actioning === 'reject' ? 'Rejecting…' : 'Reject Listing' }}
</button>
</div>
</main>
</div>
</template>
<!-- Toast -->
<Transition name="toast">
<div v-if="toast" class="toast" role="status" aria-live="polite">{{ toast }}</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useApiFetch } from '../composables/useApi'
import type { Job } from '../stores/review'
import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue'
const props = defineProps<{ jobId: number }>()
const emit = defineEmits<{
'job-removed': []
'cover-letter-generated': []
}>()
// Perfect Match — intentionally matches score-badge--high boundary (≥70%); update together
const PERFECT_MATCH_THRESHOLD = 70
const shimmeringBadge = ref(false)
// ─── Job ──────────────────────────────────────────────────────────────────────
const job = ref<Job | null>(null)
const loadingJob = ref(true)
const descExpanded = ref(false)
const gaps = computed<string[]>(() => {
if (!job.value?.keyword_gaps) return []
try { return JSON.parse(job.value.keyword_gaps) as string[] }
catch { return [] }
})
const scoreBadgeClass = computed(() => {
const s = job.value?.match_score ?? 0
if (s >= 70) return 'score-badge--high'
if (s >= 50) return 'score-badge--mid-high'
if (s >= 30) return 'score-badge--mid'
return 'score-badge--low'
})
// ─── Cover letter state machine ───────────────────────────────────────────────
// none → queued → running → ready | failed
type ClState = 'none' | 'queued' | 'running' | 'ready' | 'failed'
const clState = ref<ClState>('none')
const clText = ref('')
const isSaved = ref(true)
const saving = ref(false)
const generating = ref(false)
const taskStage = ref<string | null>(null)
const taskError = ref<string | null>(null)
const wordCount = computed(() => {
const words = clText.value.trim().split(/\s+/).filter(Boolean)
return words.length
})
// ─── Polling ──────────────────────────────────────────────────────────────────
let pollTimer = 0
function startPolling() {
stopPolling()
pollTimer = window.setInterval(pollTaskStatus, 2000)
}
function stopPolling() {
clearInterval(pollTimer)
}
async function pollTaskStatus() {
const { data } = await useApiFetch<{
status: string
stage: string | null
message: string | null
}>(`/api/jobs/${props.jobId}/cover_letter/task`)
if (!data) return
taskStage.value = data.stage
if (data.status === 'completed') {
stopPolling()
// Re-fetch the job to get the new cover letter text
await fetchJob()
clState.value = 'ready'
generating.value = false
emit('cover-letter-generated')
} else if (data.status === 'failed') {
stopPolling()
clState.value = 'failed'
taskError.value = data.message ?? 'Unknown error'
generating.value = false
} else {
clState.value = data.status === 'queued' ? 'queued' : 'running'
}
}
// ─── Actions ──────────────────────────────────────────────────────────────────
async function generate() {
if (generating.value) return
generating.value = true
clState.value = 'queued'
taskError.value = null
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/cover_letter/generate`, { method: 'POST' })
if (error) {
clState.value = 'failed'
taskError.value = error.kind === 'http' ? error.detail : 'Network error'
generating.value = false
return
}
startPolling()
}
async function saveCoverLetter() {
saving.value = true
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/cover_letter`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: clText.value }),
})
saving.value = false
if (error) {
showToast('Save failed — please try again')
return
}
isSaved.value = true
}
// ─── PDF download ─────────────────────────────────────────────────────────────
const downloadingPdf = ref(false)
async function downloadPdf() {
if (!job.value) return
downloadingPdf.value = true
try {
const res = await fetch(`/api/jobs/${props.jobId}/cover_letter/pdf`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const company = job.value.company.replace(/[^a-zA-Z0-9]/g, '') || 'Company'
const dateStr = new Date().toISOString().slice(0, 10)
a.href = url
a.download = `CoverLetter_${company}_${dateStr}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch {
showToast('PDF generation failed — save first and try again')
} finally {
downloadingPdf.value = false
}
}
// ─── Mark applied / reject ────────────────────────────────────────────────────
const actioning = ref<'apply' | 'reject' | null>(null)
async function markApplied() {
if (actioning.value) return
actioning.value = 'apply'
if (!isSaved.value) await saveCoverLetter()
await useApiFetch(`/api/jobs/${props.jobId}/applied`, { method: 'POST' })
actioning.value = null
showToast('Marked as applied ✓')
setTimeout(() => emit('job-removed'), 1200)
}
async function rejectListing() {
if (actioning.value) return
actioning.value = 'reject'
await useApiFetch(`/api/jobs/${props.jobId}/reject`, { method: 'POST' })
actioning.value = null
showToast('Listing rejected')
setTimeout(() => emit('job-removed'), 1000)
}
// ─── Toast ────────────────────────────────────────────────────────────────────
const toast = ref<string | null>(null)
let toastTimer = 0
function showToast(msg: string) {
clearTimeout(toastTimer)
toast.value = msg
toastTimer = window.setTimeout(() => { toast.value = null }, 3500)
}
// ─── Auto-resize textarea ─────────────────────────────────────────────────────
const textareaEl = ref<HTMLTextAreaElement | null>(null)
function autoResize() {
const el = textareaEl.value
if (!el) return
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}
watch(clText, () => nextTick(autoResize))
// ─── Data loading ─────────────────────────────────────────────────────────────
async function fetchJob() {
const { data } = await useApiFetch<Job>(`/api/jobs/${props.jobId}`)
if (data) {
job.value = data
if (data.cover_letter) {
clText.value = data.cover_letter as string
clState.value = 'ready'
isSaved.value = true
}
if ((data.match_score ?? 0) >= PERFECT_MATCH_THRESHOLD) {
shimmeringBadge.value = false
nextTick(() => { shimmeringBadge.value = true })
setTimeout(() => { shimmeringBadge.value = false }, 850)
}
}
}
onMounted(async () => {
await fetchJob()
loadingJob.value = false
// Check if a generation task is already in flight
if (clState.value === 'none') {
const { data } = await useApiFetch<{ status: string; stage: string | null }>(`/api/jobs/${props.jobId}/cover_letter/task`)
if (data && (data.status === 'queued' || data.status === 'running')) {
clState.value = data.status as ClState
taskStage.value = data.stage
generating.value = true
startPolling()
}
}
await nextTick(autoResize)
})
onUnmounted(() => {
stopPolling()
clearTimeout(toastTimer)
})
// Extra type to allow cover_letter field on Job
declare module '../stores/review' {
interface Job { cover_letter?: string | null }
}
</script>
<style scoped>
.workspace {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-6) var(--space-6) var(--space-12);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.workspace__loading,
.workspace__not-found {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
padding: var(--space-16);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
/* ── Two-panel layout ────────────────────────────────────────────────── */
.workspace__panels {
display: grid;
grid-template-columns: 1fr 1.3fr;
gap: var(--space-6);
align-items: start;
}
/* ── Job details panel ───────────────────────────────────────────────── */
.workspace__job-panel {
position: sticky;
top: var(--space-4);
}
.job-details {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.job-details__badges { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.job-details__title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
line-height: 1.25;
}
.job-details__company {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
}
.job-details__location { font-weight: 400; }
.job-details__salary {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-success);
}
.job-details__desc {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.job-details__desc--clamped {
display: -webkit-box;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
overflow: hidden;
}
.expand-btn {
align-self: flex-start;
background: transparent;
border: none;
color: var(--app-primary);
font-size: var(--text-xs);
cursor: pointer;
padding: 0;
font-weight: 600;
}
.job-details__gaps { display: flex; flex-wrap: wrap; gap: var(--space-1); align-items: center; }
.gaps-label { font-size: var(--text-xs); color: var(--color-text-muted); font-weight: 600; }
.gap-pill {
padding: 1px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
background: var(--color-surface-alt);
border: 1px solid var(--color-border-light);
color: var(--color-text-muted);
}
.gaps-more { font-size: var(--text-xs); color: var(--color-text-muted); }
.job-details__link {
font-size: var(--text-xs);
color: var(--app-primary);
font-weight: 600;
text-decoration: none;
align-self: flex-start;
transition: opacity 150ms ease;
}
.job-details__link:hover { opacity: 0.7; }
/* ── Cover letter panel ──────────────────────────────────────────────── */
.workspace__cl-panel {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.cl-heading {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
}
/* Empty state */
.cl-empty {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
padding: var(--space-8);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
text-align: center;
}
.cl-empty__hint { font-size: var(--text-sm); color: var(--color-text-muted); max-width: 36ch; }
.cl-empty__actions { display: flex; flex-direction: column; gap: var(--space-2); width: 100%; max-width: 260px; }
/* Generating state */
.cl-generating {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
padding: var(--space-10);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
text-align: center;
}
.cl-generating__label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.cl-generating__hint { font-size: var(--text-xs); color: var(--color-text-muted); }
/* Error state */
.cl-error {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-5);
background: rgba(192, 57, 43, 0.06);
border: 1px solid var(--color-error);
border-radius: var(--radius-lg);
color: var(--color-error);
font-size: var(--text-sm);
font-weight: 600;
}
.cl-error__msg { font-weight: 700; }
.cl-error__detail { font-size: var(--text-xs); color: var(--color-text-muted); font-weight: 400; }
.cl-error__actions { display: flex; flex-direction: column; gap: var(--space-2); width: 100%; }
/* Editor */
.cl-editor {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
}
.cl-editor__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-4);
border-bottom: 1px solid var(--color-border-light);
background: var(--color-surface-alt);
}
.cl-editor__wordcount {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-family: var(--font-mono);
}
.cl-editor__textarea {
width: 100%;
min-height: 360px;
padding: var(--space-5);
border: none;
background: transparent;
color: var(--color-text);
font-family: var(--font-body);
font-size: var(--text-sm);
line-height: 1.7;
resize: none;
overflow: hidden;
}
.cl-editor__textarea:focus { outline: none; }
.cl-regen {
align-self: flex-end;
color: var(--color-text-muted);
}
/* Download button */
.btn-download {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease;
min-height: 44px;
width: 100%;
justify-content: center;
}
.btn-download:hover:not(:disabled) { background: var(--app-primary-light); border-color: var(--app-primary); }
.btn-download:disabled { opacity: 0.5; cursor: not-allowed; }
/* Generate button */
.btn-generate {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
background: var(--app-accent);
color: var(--app-accent-text);
border: none;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 700;
cursor: pointer;
min-height: 44px;
transition: background 150ms ease;
width: 100%;
}
.btn-generate:hover:not(:disabled) { background: var(--app-accent-hover); }
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
/* ── Action bar ──────────────────────────────────────────────────────── */
.workspace__actions {
display: flex;
gap: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--color-border-light);
margin-top: var(--space-2);
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
font-weight: 700;
cursor: pointer;
border: 2px solid transparent;
min-height: 48px;
transition: background 150ms ease, border-color 150ms ease;
}
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.action-btn--apply {
background: rgba(39, 174, 96, 0.10);
border-color: var(--color-success);
color: var(--color-success);
}
.action-btn--apply:hover:not(:disabled) { background: rgba(39, 174, 96, 0.20); }
.action-btn--reject {
background: rgba(192, 57, 43, 0.08);
border-color: var(--color-error);
color: var(--color-error);
}
.action-btn--reject:hover:not(:disabled) { background: rgba(192, 57, 43, 0.16); }
/* ── Shared badges ───────────────────────────────────────────────────── */
.score-badge {
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 700;
font-family: var(--font-mono);
}
.score-badge--high { background: rgba(39,174,96,0.12); color: var(--score-high); }
.score-badge--mid-high { background: rgba(43,124,184,0.12); color: var(--score-mid-high); }
.score-badge--mid { background: rgba(212,137,26,0.12); color: var(--score-mid); }
.score-badge--low { background: rgba(192,57,43,0.12); color: var(--score-low); }
/* Perfect Match shimmer — fires once when a ≥70% job opens */
@keyframes shimmer-badge {
0% { box-shadow: 0 0 0 0 rgba(212, 175, 55, 0); background: rgba(39,174,96,0.12); }
30% { box-shadow: 0 0 8px 3px rgba(212, 175, 55, 0.6); background: rgba(212, 175, 55, 0.2); }
100% { box-shadow: 0 0 0 0 rgba(212, 175, 55, 0); background: rgba(39,174,96,0.12); }
}
.score-badge--shimmer { animation: shimmer-badge 850ms ease-out forwards; }
.remote-badge {
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
background: var(--app-primary-light);
color: var(--app-primary);
}
/* ── Ghost button ────────────────────────────────────────────────────── */
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-2) var(--space-4);
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
min-height: 36px;
transition: background 150ms ease, color 150ms ease;
text-decoration: none;
}
.btn-ghost:hover { background: var(--color-surface-alt); color: var(--color-text); }
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost--sm { font-size: var(--text-xs); padding: var(--space-1) var(--space-3); min-height: 28px; }
/* ── 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;
flex-shrink: 0;
}
.spinner--lg { width: 2rem; height: 2rem; border-width: 3px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Toast ───────────────────────────────────────────────────────────── */
.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-5);
font-size: var(--text-sm);
color: var(--color-text);
box-shadow: var(--shadow-lg);
z-index: 300;
white-space: nowrap;
}
.toast-enter-active, .toast-leave-active { transition: opacity 250ms ease, transform 250ms ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.workspace__panels {
grid-template-columns: 1fr;
}
.workspace__job-panel {
position: static;
}
.cl-editor__textarea { min-height: 260px; }
.toast {
left: var(--space-4);
right: var(--space-4);
transform: none;
bottom: calc(56px + env(safe-area-inset-bottom) + var(--space-3));
}
.toast-enter-from, .toast-leave-to { transform: translateY(8px); }
}
@media (max-width: 600px) {
.workspace { padding: var(--space-4); }
.workspace__actions { flex-direction: column; }
}
</style>