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)
This commit is contained in:
pyr0ball 2026-04-16 06:33:57 -07:00
parent 689703d065
commit f8c78031a0
9 changed files with 155 additions and 6 deletions

View file

@ -0,0 +1,22 @@
-- Migration 006: Add columns and tables present in the live DB but missing from migrations
-- These were added via direct ALTER TABLE after the v0.8.5 baseline was written.
-- date_posted: used for ghost-post shadow-score detection
ALTER TABLE jobs ADD COLUMN date_posted TEXT;
-- hired_feedback: JSON blob saved when a job reaches the 'hired' outcome
ALTER TABLE jobs ADD COLUMN hired_feedback TEXT;
-- references_ table: contacts who can provide references for applications
CREATE TABLE IF NOT EXISTS references_ (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
relationship TEXT,
company TEXT,
email TEXT,
phone TEXT,
notes TEXT,
tags TEXT,
prep_email TEXT,
role TEXT
);

View file

@ -0,0 +1,79 @@
<template>
<div class="demo-banner" role="status" aria-live="polite">
<span class="demo-banner__label">👁 Demo mode changes are not saved</span>
<div class="demo-banner__ctas">
<a
href="https://circuitforge.tech/peregrine"
class="demo-banner__cta demo-banner__cta--primary"
target="_blank"
rel="noopener"
>Get free key</a>
<a
href="https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine"
class="demo-banner__cta demo-banner__cta--secondary"
target="_blank"
rel="noopener"
>Self-host</a>
</div>
</div>
</template>
<script setup lang="ts">
// No props DemoBanner is only rendered when config.isDemo is true (App.vue)
</script>
<style scoped>
.demo-banner {
position: sticky;
top: 0;
z-index: 200;
background: var(--color-surface-raised);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px var(--space-4);
gap: var(--space-3);
}
.demo-banner__label {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.demo-banner__ctas {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
}
.demo-banner__cta {
font-size: 0.75rem;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-decoration: none;
transition: opacity var(--transition);
}
.demo-banner__cta:hover {
opacity: 0.85;
}
.demo-banner__cta--primary {
background: var(--color-primary);
color: white;
}
.demo-banner__cta--secondary {
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
@media (max-width: 480px) {
.demo-banner__label {
display: none;
}
}
</style>

View file

@ -44,7 +44,7 @@ function dismiss(): void {
.hint-chip__message {
flex: 1;
font-size: 0.85rem;
color: var(--app-primary-light, #68A8D8);
color: var(--color-text, #1a202c);
line-height: 1.4;
}

View file

@ -216,7 +216,23 @@ watch(() => props.job.id, () => {
}
})
defineExpose({ dismissApprove, dismissReject, dismissSkip })
/** Restore card to its neutral state — used when an action is blocked (e.g. demo guard). */
function resetCard() {
dx.value = 0
dy.value = 0
isExiting.value = false
isHeld.value = false
if (wrapperEl.value) {
wrapperEl.value.style.transition = 'none'
wrapperEl.value.style.transform = ''
wrapperEl.value.style.opacity = ''
requestAnimationFrame(() => {
if (wrapperEl.value) wrapperEl.value.style.transition = ''
})
}
}
defineExpose({ dismissApprove, dismissReject, dismissSkip, resetCard })
</script>
<style scoped>

View file

@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DemoBanner from '../DemoBanner.vue'
describe('DemoBanner', () => {
it('renders the demo label', () => {
const w = mount(DemoBanner)
expect(w.text()).toContain('Demo mode')
})
it('renders a free key link', () => {
const w = mount(DemoBanner)
expect(w.find('a.demo-banner__cta--primary').exists()).toBe(true)
expect(w.find('a.demo-banner__cta--primary').text()).toContain('free key')
})
it('renders a self-host link', () => {
const w = mount(DemoBanner)
expect(w.find('a.demo-banner__cta--secondary').exists()).toBe(true)
expect(w.find('a.demo-banner__cta--secondary').text()).toContain('Self-host')
})
})

View file

@ -3,6 +3,7 @@ import { showToast } from './useToast'
export type ApiError =
| { kind: 'network'; message: string }
| { kind: 'http'; status: number; detail: string }
| { kind: 'demo-blocked' }
// Strip trailing slash so '/peregrine/' + '/api/...' → '/peregrine/api/...'
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
@ -21,7 +22,9 @@ export async function useApiFetch<T>(
const body = JSON.parse(rawText) as { detail?: string }
if (body.detail === 'demo-write-blocked') {
showToast('Demo mode — sign in to save changes')
return { data: null, error: null }
// Return a truthy error so callers bail early (no optimistic UI update),
// but the toast is already shown so no additional error handling needed.
return { data: null, error: { kind: 'demo-blocked' as const } }
}
} catch { /* not JSON — fall through to normal error */ }
}

View file

@ -55,6 +55,11 @@
<div v-else class="apply-split" :class="{ 'has-selection': selectedJobId !== null }" ref="splitEl">
<!-- Left: narrow job list -->
<div class="apply-split__list">
<HintChip
v-if="config.isDemo"
view-key="apply"
message="The Spotify cover letter is ready — open it to see how AI drafts from your resume"
/>
<div class="split-list__header">
<h1 class="split-list__title">Apply</h1>
<span v-if="coverLetterCount >= 5" class="marathon-badge" title="You're on a roll!">

View file

@ -274,7 +274,8 @@ function capitalize(s: string) { return s.charAt(0).toUpperCase() + s.slice(1) }
async function onApprove() {
const job = store.currentJob
if (!job) return
await store.approve(job)
const ok = await store.approve(job)
if (!ok) { stackRef.value?.resetCard(); return }
showUndoToast('approved')
checkStoopSpeed()
}
@ -282,7 +283,8 @@ async function onApprove() {
async function onReject() {
const job = store.currentJob
if (!job) return
await store.reject(job)
const ok = await store.reject(job)
if (!ok) { stackRef.value?.resetCard(); return }
showUndoToast('rejected')
checkStoopSpeed()
}

View file

@ -10,7 +10,7 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8601',
target: process.env.VITE_API_TARGET || 'http://localhost:8601',
changeOrigin: true,
},
},