From b993f6f4a93484c3f2653eef7457a9d8c570732f Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 1 May 2026 23:11:36 -0700 Subject: [PATCH] feat(ux): active search indicator + Candycore easter egg theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/App.vue | 10 ++ web/src/assets/theme.css | 56 +++++++- web/src/components/SearchProgress.vue | 169 ++++++++++++++++++++++++ web/src/composables/useCandycoreMode.ts | 98 ++++++++++++++ web/src/composables/useSnipeMode.ts | 3 + web/src/views/SearchView.vue | 21 ++- 6 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 web/src/components/SearchProgress.vue create mode 100644 web/src/composables/useCandycoreMode.ts diff --git a/web/src/App.vue b/web/src/App.vue index a2560dd..5158017 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -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); diff --git a/web/src/assets/theme.css b/web/src/assets/theme.css index 2929655..20ef547 100644 --- a/web/src/assets/theme.css +++ b/web/src/assets/theme.css @@ -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' */ diff --git a/web/src/components/SearchProgress.vue b/web/src/components/SearchProgress.vue new file mode 100644 index 0000000..0a6d705 --- /dev/null +++ b/web/src/components/SearchProgress.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/web/src/composables/useCandycoreMode.ts b/web/src/composables/useCandycoreMode.ts new file mode 100644 index 0000000..03f76d7 --- /dev/null +++ b/web/src/composables/useCandycoreMode.ts @@ -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 } +} diff --git a/web/src/composables/useSnipeMode.ts b/web/src/composables/useSnipeMode.ts index 775c3bb..c99debf 100644 --- a/web/src/composables/useSnipeMode.ts +++ b/web/src/composables/useSnipeMode.ts @@ -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') diff --git a/web/src/views/SearchView.vue b/web/src/views/SearchView.vue index 24ed50d..76a7879 100644 --- a/web/src/views/SearchView.vue +++ b/web/src/views/SearchView.vue @@ -355,6 +355,9 @@ + + +

No listings found for {{ store.query }}.

@@ -375,8 +378,13 @@

+ + + + Re-searching… + - + Updating scores… @@ -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;