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
This commit is contained in:
parent
05f845962f
commit
b993f6f4a9
6 changed files with 353 additions and 4 deletions
|
|
@ -21,6 +21,7 @@ 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'
|
||||
|
|
@ -31,6 +32,8 @@ 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()
|
||||
|
|
@ -42,6 +45,7 @@ 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
|
||||
|
|
@ -57,6 +61,12 @@ onMounted(async () => {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@
|
|||
Snipe Mode data attribute overrides this via higher specificity.
|
||||
*/
|
||||
/* Explicit dark override — beats OS preference when user forces dark in Settings */
|
||||
[data-theme="dark"]:not([data-snipe-mode="active"]) {
|
||||
[data-theme="dark"]:not([data-snipe-mode="active"]):not([data-candycore="active"]) {
|
||||
--color-surface: #0d1117;
|
||||
--color-surface-2: #161b22;
|
||||
--color-surface-raised: #1c2129;
|
||||
|
|
@ -113,7 +113,7 @@
|
|||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme="dark"]):not([data-snipe-mode="active"]) {
|
||||
:root:not([data-theme="dark"]):not([data-snipe-mode="active"]):not([data-candycore="active"]) {
|
||||
/* Surfaces — warm cream, like a tactical field notebook */
|
||||
--color-surface: #f8f5ee;
|
||||
--color-surface-2: #f0ece3;
|
||||
|
|
@ -153,7 +153,7 @@
|
|||
}
|
||||
|
||||
/* Explicit light override — beats OS preference when user forces light in Settings */
|
||||
[data-theme="light"]:not([data-snipe-mode="active"]) {
|
||||
[data-theme="light"]:not([data-snipe-mode="active"]):not([data-candycore="active"]) {
|
||||
--color-surface: #f8f5ee;
|
||||
--color-surface-2: #f0ece3;
|
||||
--color-surface-raised: #e8e3d8;
|
||||
|
|
@ -178,6 +178,56 @@
|
|||
--shadow-lg: 0 10px 30px rgba(60,45,20,0.2), 0 4px 8px rgba(60,45,20,0.1);
|
||||
}
|
||||
|
||||
/* ── Candycore easter egg theme ─────────────────────
|
||||
Activated by typing "neon" outside a form field (tribute to artist Neon).
|
||||
Palette sourced from snipe_v0_Neon_IPad_Paint.jpeg:
|
||||
purple-black sky + lavender primary + cyan glow + yellow crown + pink text.
|
||||
Stored as 'cf-candycore' in localStorage.
|
||||
Applied: document.documentElement.dataset.candycore = 'active'
|
||||
NOTE: Snipe Mode is declared last and overrides this when both are active.
|
||||
*/
|
||||
[data-candycore="active"] {
|
||||
--app-primary: #c77dff;
|
||||
--app-primary-hover: #a855f7;
|
||||
--app-primary-light: rgba(199, 125, 255, 0.15);
|
||||
|
||||
/* Purple-black night sky */
|
||||
--color-surface: #08051a;
|
||||
--color-surface-2: #100d28;
|
||||
--color-surface-raised: #1a1248;
|
||||
|
||||
/* Purple glow borders */
|
||||
--color-border: rgba(199, 125, 255, 0.20);
|
||||
--color-border-light: rgba(199, 125, 255, 0.10);
|
||||
|
||||
/* Candy-floss text — pink-white, muted bubblegum */
|
||||
--color-text: #ffd6f5;
|
||||
--color-text-muted: #f09099;
|
||||
--color-text-inverse: #08051a;
|
||||
|
||||
/* Trust signals — straight from the painting */
|
||||
--trust-high: #00c8e0; /* cyan (outline glow) = good */
|
||||
--trust-mid: #ffe520; /* yellow (crown stripe) = caution */
|
||||
--trust-low: #ff6eb4; /* hot pink = danger */
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #00c8e0;
|
||||
--color-error: #ff6eb4;
|
||||
--color-warning: #ffe520;
|
||||
--color-info: #c77dff;
|
||||
--color-accent: #00c8e0; /* cyan accent */
|
||||
|
||||
/* Purple glow shadows */
|
||||
--shadow-sm: 0 1px 3px rgba(199, 125, 255, 0.12);
|
||||
--shadow-md: 0 4px 12px rgba(199, 125, 255, 0.20);
|
||||
--shadow-lg: 0 10px 30px rgba(199, 125, 255, 0.28);
|
||||
|
||||
/* Glow helpers (used in scoped styles if needed) */
|
||||
--candy-glow-xs: rgba(199, 125, 255, 0.08);
|
||||
--candy-glow-sm: rgba(199, 125, 255, 0.18);
|
||||
--candy-glow-md: rgba(199, 125, 255, 0.45);
|
||||
}
|
||||
|
||||
/* ── Snipe Mode easter egg theme ─────────────────── */
|
||||
/* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */
|
||||
/* Applied: document.documentElement.dataset.snipeMode = 'active' */
|
||||
|
|
|
|||
169
web/src/components/SearchProgress.vue
Normal file
169
web/src/components/SearchProgress.vue
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<template>
|
||||
<div class="search-progress" role="status" aria-label="Search in progress" aria-live="polite">
|
||||
<!-- Indeterminate progress bar -->
|
||||
<div class="progress-track" aria-hidden="true">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- Status line -->
|
||||
<p class="progress-label">
|
||||
Searching eBay for <strong>{{ query }}</strong>…
|
||||
</p>
|
||||
|
||||
<!-- Skeleton listing cards -->
|
||||
<div class="skeleton-list" aria-hidden="true">
|
||||
<div v-for="n in 4" :key="n" class="skeleton-card">
|
||||
<div class="skeleton-thumb"></div>
|
||||
<div class="skeleton-body">
|
||||
<div class="skeleton-line skeleton-line--title"></div>
|
||||
<div class="skeleton-line skeleton-line--meta"></div>
|
||||
<div class="skeleton-footer">
|
||||
<div class="skeleton-chip"></div>
|
||||
<div class="skeleton-chip skeleton-chip--price"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ query: string }>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-progress {
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
/* ── Indeterminate progress bar ───────────────── */
|
||||
.progress-track {
|
||||
height: 3px;
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
width: 40%;
|
||||
background: var(--app-primary);
|
||||
border-radius: var(--radius-full);
|
||||
animation: progress-slide 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
|
||||
/* ── Status label ─────────────────────────────── */
|
||||
.progress-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.progress-label strong {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Skeleton cards ───────────────────────────── */
|
||||
.skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.skeleton-thumb {
|
||||
width: 100px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-md);
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 12px;
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-line--title {
|
||||
width: 70%;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.skeleton-line--meta {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.skeleton-footer {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.skeleton-chip {
|
||||
height: 22px;
|
||||
width: 64px;
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-full);
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-chip--price {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
/* Stagger shimmer so cards don't all pulse in sync */
|
||||
.skeleton-card:nth-child(2) .skeleton-line,
|
||||
.skeleton-card:nth-child(2) .skeleton-thumb,
|
||||
.skeleton-card:nth-child(2) .skeleton-chip { animation-delay: 0.15s; }
|
||||
|
||||
.skeleton-card:nth-child(3) .skeleton-line,
|
||||
.skeleton-card:nth-child(3) .skeleton-thumb,
|
||||
.skeleton-card:nth-child(3) .skeleton-chip { animation-delay: 0.3s; }
|
||||
|
||||
.skeleton-card:nth-child(4) .skeleton-line,
|
||||
.skeleton-card:nth-child(4) .skeleton-thumb,
|
||||
.skeleton-card:nth-child(4) .skeleton-chip { animation-delay: 0.45s; }
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.skeleton-thumb {
|
||||
width: 72px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.skeleton-line--title { width: 85%; }
|
||||
}
|
||||
</style>
|
||||
98
web/src/composables/useCandycoreMode.ts
Normal file
98
web/src/composables/useCandycoreMode.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useSnipeMode } from './useSnipeMode'
|
||||
|
||||
const LS_KEY = 'cf-candycore'
|
||||
const DATA_ATTR = 'candycore'
|
||||
|
||||
// Module-level ref — shared across all callers
|
||||
const active = ref(false)
|
||||
|
||||
/**
|
||||
* Candycore easter egg theme — activated by typing "neon" outside a form field.
|
||||
* Tribute to artist Neon, whose iPad painting (snipe_v0_Neon_IPad_Paint.jpeg)
|
||||
* defined the candy palette: lavender primary, cyan glow, yellow crown, bubblegum pink.
|
||||
*
|
||||
* Mutually exclusive with Snipe Mode (each deactivates the other).
|
||||
* Stores state in localStorage under 'cf-candycore'.
|
||||
*/
|
||||
export function useCandycoreMode() {
|
||||
const snipe = useSnipeMode(false /* no sound on deactivate */)
|
||||
|
||||
function _playCandySound() {
|
||||
try {
|
||||
const ctx = new AudioContext()
|
||||
// Ascending arpeggio: C5 → E5 → G5 → C6
|
||||
const notes = [523.25, 659.25, 783.99, 1046.50]
|
||||
const step = 0.08
|
||||
|
||||
notes.forEach((freq, i) => {
|
||||
const t = ctx.currentTime + i * step
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.setValueAtTime(freq, t)
|
||||
gain.gain.setValueAtTime(0, t)
|
||||
gain.gain.linearRampToValueAtTime(0.22, t + 0.01)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + step * 1.4)
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.start(t)
|
||||
osc.stop(t + step * 1.5)
|
||||
})
|
||||
|
||||
setTimeout(() => ctx.close(), (notes.length * step + 0.3) * 1000)
|
||||
} catch {
|
||||
// Web Audio API unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function activate() {
|
||||
// Deactivate Snipe Mode if it's running — can't have both
|
||||
if (snipe.active.value) snipe.deactivate()
|
||||
active.value = true
|
||||
document.documentElement.dataset[DATA_ATTR] = 'active'
|
||||
localStorage.setItem(LS_KEY, 'active')
|
||||
_playCandySound()
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
active.value = false
|
||||
delete document.documentElement.dataset[DATA_ATTR]
|
||||
localStorage.removeItem(LS_KEY)
|
||||
}
|
||||
|
||||
function restore() {
|
||||
if (localStorage.getItem(LS_KEY) === 'active') {
|
||||
active.value = true
|
||||
document.documentElement.dataset[DATA_ATTR] = 'active'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a document keydown listener that fires activate() when the user
|
||||
* types "neon" outside of any form field. Call from component setup().
|
||||
* The listener is automatically removed when the calling component unmounts.
|
||||
*/
|
||||
function useWordTrigger() {
|
||||
const TARGET = 'neon'
|
||||
let buffer = ''
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
const tag = (e.target as HTMLElement | null)?.tagName ?? ''
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
||||
if (e.key.length !== 1) return // skip modifier/arrow keys
|
||||
|
||||
buffer = (buffer + e.key.toLowerCase()).slice(-TARGET.length)
|
||||
if (buffer === TARGET) {
|
||||
buffer = ''
|
||||
if (active.value) deactivate()
|
||||
else activate()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', handleKey))
|
||||
onUnmounted(() => document.removeEventListener('keydown', handleKey))
|
||||
}
|
||||
|
||||
return { active, activate, deactivate, restore, useWordTrigger }
|
||||
}
|
||||
|
|
@ -58,6 +58,9 @@ export function useSnipeMode(audioEnabled = true) {
|
|||
}
|
||||
|
||||
function activate() {
|
||||
// Clear candycore if it's on — can't have both
|
||||
delete document.documentElement.dataset.candycore
|
||||
localStorage.removeItem('cf-candycore')
|
||||
active.value = true
|
||||
document.documentElement.dataset[DATA_ATTR] = 'active'
|
||||
localStorage.setItem(LS_KEY, 'active')
|
||||
|
|
|
|||
|
|
@ -355,6 +355,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading (scraping in progress, no results yet) -->
|
||||
<SearchProgress v-else-if="store.loading && !store.results.length" :query="store.query" />
|
||||
|
||||
<!-- No results -->
|
||||
<div v-else-if="!store.results.length && !store.loading && store.query" class="results-empty">
|
||||
<p>No listings found for <strong>{{ store.query }}</strong>.</p>
|
||||
|
|
@ -375,8 +378,13 @@
|
|||
</span>
|
||||
</p>
|
||||
<div class="toolbar-actions">
|
||||
<!-- Re-search indicator — loading while stale results are still visible -->
|
||||
<span v-if="store.loading && store.results.length" class="enriching-badge enriching-badge--searching" aria-live="polite" title="Fetching new results…">
|
||||
<span class="enriching-dot" aria-hidden="true"></span>
|
||||
Re-searching…
|
||||
</span>
|
||||
<!-- Live enrichment indicator — visible while SSE stream is open -->
|
||||
<span v-if="store.enriching" class="enriching-badge" aria-live="polite" title="Scores updating as seller data arrives">
|
||||
<span v-else-if="store.enriching" class="enriching-badge" aria-live="polite" title="Scores updating as seller data arrives">
|
||||
<span class="enriching-dot" aria-hidden="true"></span>
|
||||
Updating scores…
|
||||
</span>
|
||||
|
|
@ -456,6 +464,7 @@ import { useBlocklistStore } from '../stores/blocklist'
|
|||
import { useReportedStore } from '../stores/reported'
|
||||
import ListingCard from '../components/ListingCard.vue'
|
||||
import LLMQueryPanel from '../components/LLMQueryPanel.vue'
|
||||
import SearchProgress from '../components/SearchProgress.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useSearchStore()
|
||||
|
|
@ -1440,6 +1449,16 @@ async function onSearch() {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.enriching-badge--searching {
|
||||
background: color-mix(in srgb, var(--color-info) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-info) 30%, transparent);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.enriching-badge--searching .enriching-dot {
|
||||
background: var(--color-info);
|
||||
}
|
||||
|
||||
.enriching-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue