snipe/web/src/App.vue
pyr0ball b993f6f4a9 feat(ux): active search indicator + Candycore easter egg theme
Search indicator:
- SearchProgress.vue: indeterminate amber progress bar + status line
  + 4 staggered skeleton cards shown while loading=true and no results yet
  (fills the previously-blank results area during initial scrape phase)
- Re-search badge: blue "Re-searching…" pill in toolbar when loading=true
  over existing stale results (distinct from the amber enrichment badge)

Candycore theme:
- New [data-candycore="active"] CSS block; palette sourced from
  snipe_v0_Neon_IPad_Paint.jpeg — purple-black sky, lavender primary,
  cyan glow, yellow crown, bubblegum pink text
- useCandycoreMode.ts: word trigger ("neon", typed outside form fields),
  ascending arpeggio audio, localStorage persistence, restore on reload
- Mutually exclusive with Snipe Mode (each deactivates the other)
- Added :not([data-candycore="active"]) guards to existing dark/light
  theme override selectors so they don't stomp on Candycore
2026-05-01 23:11:36 -07:00

124 lines
4 KiB
Vue

<template>
<!-- Root uses .app-root class, NOT id="app" index.html owns #app.
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
<!-- Skip to main content must be first focusable element before the nav -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<AppNav />
<main class="app-main" id="main-content" tabindex="-1">
<RouterView />
</main>
<!-- Feedback FAB hidden when FORGEJO_API_TOKEN not configured -->
<FeedbackButton :current-view="String(route.name ?? 'unknown')" />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { useMotion } from './composables/useMotion'
import { useSnipeMode } from './composables/useSnipeMode'
import { useTheme } from './composables/useTheme'
import { useKonamiCode } from './composables/useKonamiCode'
import { useCandycoreMode } from './composables/useCandycoreMode'
import { useSessionStore } from './stores/session'
import { useBlocklistStore } from './stores/blocklist'
import { usePreferencesStore } from './stores/preferences'
import { useReportedStore } from './stores/reported'
import AppNav from './components/AppNav.vue'
import FeedbackButton from './components/FeedbackButton.vue'
const motion = useMotion()
const { activate, restore } = useSnipeMode()
const { restore: restoreTheme } = useTheme()
const { restore: restoreCandy, useWordTrigger } = useCandycoreMode()
useWordTrigger()
const session = useSessionStore()
const blocklistStore = useBlocklistStore()
const preferencesStore = usePreferencesStore()
const reportedStore = useReportedStore()
const route = useRoute()
useKonamiCode(activate)
onMounted(async () => {
restore() // re-apply snipe mode from localStorage on hard reload
restoreTheme() // re-apply explicit theme override on hard reload
restoreCandy() // re-apply candycore mode from localStorage on hard reload
await session.bootstrap() // fetch tier + feature flags from API
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
preferencesStore.load() // load user preferences after session resolves
reportedStore.load() // pre-load reported sellers so cards show badge immediately
})
</script>
<style>
/* Global resets — unscoped, applied once to document */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Global keyboard focus indicator — safety net so no stylesheet can silently remove focus rings */
:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
html {
font-family: var(--font-body, sans-serif);
color: var(--color-text, #e6edf3);
background: var(--color-surface, #0d1117);
overflow-x: clip; /* no BFC side effects. Gotcha #3. */
}
body {
min-height: 100dvh; /* dynamic viewport — mobile chrome-aware. Gotcha #13. */
overflow-x: hidden;
}
#app { min-height: 100dvh; }
/* Layout root — sidebar pushes content right on desktop */
.app-root {
display: flex;
min-height: 100dvh;
}
/* Main content area */
.app-main {
flex: 1;
min-width: 0; /* prevents flex blowout */
/* Desktop: offset by sidebar width */
margin-left: var(--sidebar-width, 220px);
}
/* Skip-to-content link — visible only on keyboard focus */
.skip-link {
position: absolute;
top: -999px;
left: var(--space-4);
background: var(--app-primary);
color: var(--color-text-inverse);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
font-weight: 600;
z-index: 9999;
text-decoration: none;
transition: top 0ms;
}
.skip-link:focus {
top: var(--space-4);
}
/* Mobile: no sidebar margin, add bottom tab bar clearance */
@media (max-width: 1023px) {
.app-main {
margin-left: 0;
padding-bottom: calc(56px + env(safe-area-inset-bottom));
}
}
</style>