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:
pyr0ball 2026-05-01 23:11:36 -07:00
parent 05f845962f
commit b993f6f4a9
6 changed files with 353 additions and 4 deletions

View file

@ -21,6 +21,7 @@ import { useMotion } from './composables/useMotion'
import { useSnipeMode } from './composables/useSnipeMode' import { useSnipeMode } from './composables/useSnipeMode'
import { useTheme } from './composables/useTheme' import { useTheme } from './composables/useTheme'
import { useKonamiCode } from './composables/useKonamiCode' import { useKonamiCode } from './composables/useKonamiCode'
import { useCandycoreMode } from './composables/useCandycoreMode'
import { useSessionStore } from './stores/session' import { useSessionStore } from './stores/session'
import { useBlocklistStore } from './stores/blocklist' import { useBlocklistStore } from './stores/blocklist'
import { usePreferencesStore } from './stores/preferences' import { usePreferencesStore } from './stores/preferences'
@ -31,6 +32,8 @@ import FeedbackButton from './components/FeedbackButton.vue'
const motion = useMotion() const motion = useMotion()
const { activate, restore } = useSnipeMode() const { activate, restore } = useSnipeMode()
const { restore: restoreTheme } = useTheme() const { restore: restoreTheme } = useTheme()
const { restore: restoreCandy, useWordTrigger } = useCandycoreMode()
useWordTrigger()
const session = useSessionStore() const session = useSessionStore()
const blocklistStore = useBlocklistStore() const blocklistStore = useBlocklistStore()
const preferencesStore = usePreferencesStore() const preferencesStore = usePreferencesStore()
@ -42,6 +45,7 @@ useKonamiCode(activate)
onMounted(async () => { onMounted(async () => {
restore() // re-apply snipe mode from localStorage on hard reload restore() // re-apply snipe mode from localStorage on hard reload
restoreTheme() // re-apply explicit theme override 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 await session.bootstrap() // fetch tier + feature flags from API
blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately blocklistStore.fetchBlocklist() // pre-load so card block buttons reflect state immediately
preferencesStore.load() // load user preferences after session resolves preferencesStore.load() // load user preferences after session resolves
@ -57,6 +61,12 @@ onMounted(async () => {
padding: 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 { html {
font-family: var(--font-body, sans-serif); font-family: var(--font-body, sans-serif);
color: var(--color-text, #e6edf3); color: var(--color-text, #e6edf3);

View file

@ -87,7 +87,7 @@
Snipe Mode data attribute overrides this via higher specificity. Snipe Mode data attribute overrides this via higher specificity.
*/ */
/* Explicit dark override — beats OS preference when user forces dark in Settings */ /* 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: #0d1117;
--color-surface-2: #161b22; --color-surface-2: #161b22;
--color-surface-raised: #1c2129; --color-surface-raised: #1c2129;
@ -113,7 +113,7 @@
} }
@media (prefers-color-scheme: light) { @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 */ /* Surfaces — warm cream, like a tactical field notebook */
--color-surface: #f8f5ee; --color-surface: #f8f5ee;
--color-surface-2: #f0ece3; --color-surface-2: #f0ece3;
@ -153,7 +153,7 @@
} }
/* Explicit light override — beats OS preference when user forces light in Settings */ /* 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: #f8f5ee;
--color-surface-2: #f0ece3; --color-surface-2: #f0ece3;
--color-surface-raised: #e8e3d8; --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); --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 ─────────────────── */ /* ── Snipe Mode easter egg theme ─────────────────── */
/* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */ /* Activated by Konami code; stored as 'cf-snipe-mode' in localStorage */
/* Applied: document.documentElement.dataset.snipeMode = 'active' */ /* Applied: document.documentElement.dataset.snipeMode = 'active' */

View 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>

View 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 }
}

View file

@ -58,6 +58,9 @@ export function useSnipeMode(audioEnabled = true) {
} }
function activate() { function activate() {
// Clear candycore if it's on — can't have both
delete document.documentElement.dataset.candycore
localStorage.removeItem('cf-candycore')
active.value = true active.value = true
document.documentElement.dataset[DATA_ATTR] = 'active' document.documentElement.dataset[DATA_ATTR] = 'active'
localStorage.setItem(LS_KEY, 'active') localStorage.setItem(LS_KEY, 'active')

View file

@ -355,6 +355,9 @@
</div> </div>
</div> </div>
<!-- Loading (scraping in progress, no results yet) -->
<SearchProgress v-else-if="store.loading && !store.results.length" :query="store.query" />
<!-- No results --> <!-- No results -->
<div v-else-if="!store.results.length && !store.loading && store.query" class="results-empty"> <div v-else-if="!store.results.length && !store.loading && store.query" class="results-empty">
<p>No listings found for <strong>{{ store.query }}</strong>.</p> <p>No listings found for <strong>{{ store.query }}</strong>.</p>
@ -375,8 +378,13 @@
</span> </span>
</p> </p>
<div class="toolbar-actions"> <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 --> <!-- 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> <span class="enriching-dot" aria-hidden="true"></span>
Updating scores Updating scores
</span> </span>
@ -456,6 +464,7 @@ import { useBlocklistStore } from '../stores/blocklist'
import { useReportedStore } from '../stores/reported' import { useReportedStore } from '../stores/reported'
import ListingCard from '../components/ListingCard.vue' import ListingCard from '../components/ListingCard.vue'
import LLMQueryPanel from '../components/LLMQueryPanel.vue' import LLMQueryPanel from '../components/LLMQueryPanel.vue'
import SearchProgress from '../components/SearchProgress.vue'
const route = useRoute() const route = useRoute()
const store = useSearchStore() const store = useSearchStore()
@ -1440,6 +1449,16 @@ async function onSearch() {
white-space: nowrap; 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 { .enriching-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;