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 @@
+
+
+ Searching eBay for {{ query }}…
+
No listings found for {{ store.query }}.
@@ -375,8 +378,13 @@