diff --git a/web/src/composables/useApi.ts b/web/src/composables/useApi.ts new file mode 100644 index 0000000..d677091 --- /dev/null +++ b/web/src/composables/useApi.ts @@ -0,0 +1,20 @@ +export type ApiError = + | { kind: 'network'; message: string } + | { kind: 'http'; status: number; detail: string } + +export async function useApiFetch( + url: string, + opts?: RequestInit, +): Promise<{ data: T | null; error: ApiError | null }> { + try { + const res = await fetch(url, opts) + if (!res.ok) { + const detail = await res.text().catch(() => '') + return { data: null, error: { kind: 'http', status: res.status, detail } } + } + const data = await res.json() as T + return { data, error: null } + } catch (e) { + return { data: null, error: { kind: 'network', message: String(e) } } + } +} diff --git a/web/src/composables/useEasterEgg.ts b/web/src/composables/useEasterEgg.ts new file mode 100644 index 0000000..62c48e7 --- /dev/null +++ b/web/src/composables/useEasterEgg.ts @@ -0,0 +1,43 @@ +import { onMounted, onUnmounted } from 'vue' + +const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'] + +export function useKonamiCode(onActivate: () => void) { + let pos = 0 + + function handler(e: KeyboardEvent) { + if (e.key === KONAMI[pos]) { + pos++ + if (pos === KONAMI.length) { + pos = 0 + onActivate() + } + } else { + pos = 0 + } + } + + onMounted(() => window.addEventListener('keydown', handler)) + onUnmounted(() => window.removeEventListener('keydown', handler)) +} + +export function useHackerMode() { + function toggle() { + const root = document.documentElement + if (root.dataset.theme === 'hacker') { + delete root.dataset.theme + localStorage.removeItem('cf-hacker-mode') + } else { + root.dataset.theme = 'hacker' + localStorage.setItem('cf-hacker-mode', 'true') + } + } + + function restore() { + if (localStorage.getItem('cf-hacker-mode') === 'true') { + document.documentElement.dataset.theme = 'hacker' + } + } + + return { toggle, restore } +} diff --git a/web/src/composables/useHaptics.ts b/web/src/composables/useHaptics.ts new file mode 100644 index 0000000..4406dd9 --- /dev/null +++ b/web/src/composables/useHaptics.ts @@ -0,0 +1,18 @@ +import { useMotion } from './useMotion' + +export function useHaptics() { + const { rich } = useMotion() + + function vibrate(pattern: number | number[]) { + if (rich.value && typeof navigator !== 'undefined' && 'vibrate' in navigator) { + navigator.vibrate(pattern) + } + } + + return { + label: () => vibrate(40), + discard: () => vibrate([40, 30, 40]), + skip: () => vibrate(15), + undo: () => vibrate([20, 20, 60]), + } +} diff --git a/web/src/composables/useMotion.ts b/web/src/composables/useMotion.ts new file mode 100644 index 0000000..eee0ae1 --- /dev/null +++ b/web/src/composables/useMotion.ts @@ -0,0 +1,28 @@ +import { computed, ref } from 'vue' + +const STORAGE_KEY = 'cf-avocet-rich-motion' + +// OS-level prefers-reduced-motion — checked once at module load +const OS_REDUCED = typeof window !== 'undefined' + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + : false + +// Reactive ref so toggling localStorage triggers re-reads in the same session +const _richOverride = ref( + typeof window !== 'undefined' + ? localStorage.getItem(STORAGE_KEY) + : null +) + +export function useMotion() { + const rich = computed(() => + !OS_REDUCED && _richOverride.value !== 'false' + ) + + function setRich(enabled: boolean) { + localStorage.setItem(STORAGE_KEY, enabled ? 'true' : 'false') + _richOverride.value = enabled ? 'true' : 'false' + } + + return { rich, setRich } +} diff --git a/web/src/smoke.test.ts b/web/src/smoke.test.ts index 119b9e0..a601b38 100644 --- a/web/src/smoke.test.ts +++ b/web/src/smoke.test.ts @@ -5,3 +5,26 @@ describe('scaffold', () => { expect(1 + 1).toBe(2) }) }) + +describe('composable imports', () => { + it('useApi imports', async () => { + const { useApiFetch } = await import('./composables/useApi') + expect(typeof useApiFetch).toBe('function') + }) + + it('useMotion imports', async () => { + const { useMotion } = await import('./composables/useMotion') + expect(typeof useMotion).toBe('function') + }) + + it('useHaptics imports', async () => { + const { useHaptics } = await import('./composables/useHaptics') + expect(typeof useHaptics).toBe('function') + }) + + it('useEasterEgg imports', async () => { + const { useKonamiCode, useHackerMode } = await import('./composables/useEasterEgg') + expect(typeof useKonamiCode).toBe('function') + expect(typeof useHackerMode).toBe('function') + }) +}) diff --git a/web/src/test-setup.ts b/web/src/test-setup.ts new file mode 100644 index 0000000..5021a5e --- /dev/null +++ b/web/src/test-setup.ts @@ -0,0 +1,17 @@ +// jsdom does not implement window.matchMedia — stub it so composables that +// check prefers-reduced-motion can import without throwing. +if (typeof window !== 'undefined' && !window.matchMedia) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }) +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 00529c8..c22afdb 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, + setupFiles: ['./src/test-setup.ts'], }, })