feat: Vue SPA demo mode support
Some checks failed
CI / Backend (Python) (push) Failing after 10s
CI / Frontend (Vue) (push) Successful in 18s
Mirror / mirror (push) Failing after 8s

- useToast.ts: global reactive toast singleton for cross-component toasts
- App.vue: sticky demo mode banner + global toast slot
- router: bypass wizard gate entirely in demo mode (pre-seeded data)
- ApplyWorkspace, CompanyResearchModal: guard generate() in demo mode
- fineTune store: guard submitJob() in demo mode
- ui_switcher.py: remove Vue→Streamlit fallback in demo mode (now handled natively)

All LLM-triggering actions show a toast and no-op in demo mode.
Backend already blocks inference via DEMO_MODE env; Vue layer adds UX signal.

Closes #46
This commit is contained in:
pyr0ball 2026-04-06 00:07:26 -07:00
parent 9f9453a3b0
commit 6115a68550
7 changed files with 99 additions and 6 deletions

View file

@ -124,12 +124,6 @@ def sync_ui_cookie(yaml_path: Path, tier: str) -> None:
# UI components must not crash the app — silent fallback to default
pref = "streamlit"
# Demo mode: Vue SPA has no demo data wiring — always serve Streamlit.
# (The tier downgrade check below is skipped in demo mode, but we must
# also block the Vue navigation itself so Caddy doesn't route to a blank SPA.)
if pref == "vue" and _DEMO_MODE:
pref = "streamlit"
# Tier downgrade protection (skip in demo — demo bypasses tier gate)
if pref == "vue" and not _DEMO_MODE and not can_use(tier, "vue_ui_beta"):
if profile is not None:

View file

@ -6,7 +6,20 @@
<main class="app-main" :class="{ 'app-main--wizard': isWizard }" id="main-content" tabindex="-1">
<!-- Skip to main content link (screen reader / keyboard nav) -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- Demo mode banner sticky top bar, visible on all pages -->
<div v-if="config.isDemo" class="demo-banner" role="status" aria-live="polite">
👁 Demo mode changes are not saved and AI features are disabled.
</div>
<RouterView />
<!-- Global toast rendered at App level so any component can trigger it -->
<Transition name="global-toast">
<div v-if="toast.message.value" class="global-toast" role="status" aria-live="polite">
{{ toast.message.value }}
</div>
</Transition>
</main>
</div>
</template>
@ -17,13 +30,17 @@ import { RouterView, useRoute } from 'vue-router'
import { useMotion } from './composables/useMotion'
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
import { useTheme } from './composables/useTheme'
import { useToast } from './composables/useToast'
import AppNav from './components/AppNav.vue'
import { useAppConfigStore } from './stores/appConfig'
import { useDigestStore } from './stores/digest'
const motion = useMotion()
const route = useRoute()
const { toggle, restore } = useHackerMode()
const { initTheme } = useTheme()
const toast = useToast()
const config = useAppConfigStore()
const digestStore = useDigestStore()
const isWizard = computed(() => route.path.startsWith('/setup'))
@ -110,4 +127,51 @@ body {
margin-left: 0;
padding-bottom: 0;
}
/* Demo mode banner — sticky top bar */
.demo-banner {
position: sticky;
top: 0;
z-index: 200;
background: var(--color-warning);
color: #1a1a1a; /* forced dark — warning bg is always light enough */
text-align: center;
font-size: 0.85rem;
font-weight: 600;
padding: 6px var(--space-4, 16px);
letter-spacing: 0.01em;
}
/* Global toast — bottom-center, above tab bar */
.global-toast {
position: fixed;
bottom: calc(72px + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
background: var(--color-surface-raised, #2a3650);
color: var(--color-text, #eaeff8);
padding: 10px 20px;
border-radius: var(--radius-md, 8px);
font-size: 0.9rem;
font-weight: 500;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
white-space: nowrap;
z-index: 9000;
pointer-events: none;
}
.global-toast-enter-active, .global-toast-leave-active {
transition: opacity 220ms ease, transform 220ms ease;
}
.global-toast-enter-from, .global-toast-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
@media (min-width: 1024px) {
.global-toast {
bottom: calc(24px + env(safe-area-inset-bottom));
left: calc(50% + var(--sidebar-width, 220px) / 2);
}
}
</style>

View file

@ -283,9 +283,12 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useApiFetch } from '../composables/useApi'
import { useAppConfigStore } from '../stores/appConfig'
import type { Job } from '../stores/review'
import ResumeOptimizerPanel from './ResumeOptimizerPanel.vue'
const config = useAppConfigStore()
const props = defineProps<{ jobId: number }>()
const emit = defineEmits<{
@ -379,6 +382,7 @@ async function pollTaskStatus() {
async function generate() {
if (generating.value) return
if (config.isDemo) { showToast('AI features are disabled in demo mode'); return }
generating.value = true
clState.value = 'queued'
taskError.value = null

View file

@ -88,6 +88,10 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
import { useAppConfigStore } from '../stores/appConfig'
import { showToast } from '../composables/useToast'
const config = useAppConfigStore()
const props = defineProps<{
jobId: number
@ -181,6 +185,7 @@ async function load() {
}
async function generate() {
if (config.isDemo) { showToast('AI features are disabled in demo mode'); state.value = 'empty'; return }
state.value = 'generating'
stage.value = null
errorMsg.value = null

View file

@ -0,0 +1,20 @@
/**
* useToast global reactive toast singleton.
*
* Module-level ref shared across all importers; no Pinia needed for a single
* ephemeral string. Call showToast() from anywhere; App.vue renders it.
*/
import { ref } from 'vue'
const _message = ref<string | null>(null)
let _timer = 0
export function showToast(msg: string, duration = 3500): void {
clearTimeout(_timer)
_message.value = msg
_timer = window.setTimeout(() => { _message.value = null }, duration)
}
export function useToast() {
return { message: _message }
}

View file

@ -56,6 +56,9 @@ router.beforeEach(async (to, _from, next) => {
const config = useAppConfigStore()
if (!config.loaded) await config.load()
// Demo mode: pre-seeded data, no wizard needed — route freely
if (config.isDemo) return next()
// Wizard gate runs first for every route except /setup itself
if (!to.path.startsWith('/setup') && !config.wizardComplete) {
return next('/setup')

View file

@ -1,6 +1,8 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
import { useAppConfigStore } from '../appConfig'
import { showToast } from '../../composables/useToast'
export interface TrainingPair {
index: number
@ -41,6 +43,7 @@ export const useFineTuneStore = defineStore('settings/fineTune', () => {
}
async function submitJob() {
if (useAppConfigStore().isDemo) { showToast('AI features are disabled in demo mode'); return }
const { data, error } = await useApiFetch<{ job_id: string }>('/api/settings/fine-tune/submit', { method: 'POST' })
if (!error && data) { inFlightJob.value = true; jobStatus.value = 'queued' }
}