feat(avocet): useApi, useMotion, useHaptics, useEasterEgg (Konami/hacker mode)
- useApiFetch: typed fetch wrapper with network/http error discrimination - useMotion: reactive localStorage override for rich-animation toggle, respects OS prefers-reduced-motion - useHaptics: label/discard/skip/undo vibration patterns, gated on rich mode - useKonamiCode + useHackerMode: 10-key Konami sequence → hacker theme, persisted in localStorage - test-setup.ts: jsdom matchMedia stub so useMotion imports cleanly in Vitest - smoke.test.ts: import smoke tests for all 4 composables (12 tests, all passing)
This commit is contained in:
parent
209f49f7ea
commit
e823c84196
7 changed files with 150 additions and 0 deletions
20
web/src/composables/useApi.ts
Normal file
20
web/src/composables/useApi.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
export type ApiError =
|
||||||
|
| { kind: 'network'; message: string }
|
||||||
|
| { kind: 'http'; status: number; detail: string }
|
||||||
|
|
||||||
|
export async function useApiFetch<T>(
|
||||||
|
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) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
43
web/src/composables/useEasterEgg.ts
Normal file
43
web/src/composables/useEasterEgg.ts
Normal file
|
|
@ -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 }
|
||||||
|
}
|
||||||
18
web/src/composables/useHaptics.ts
Normal file
18
web/src/composables/useHaptics.ts
Normal file
|
|
@ -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]),
|
||||||
|
}
|
||||||
|
}
|
||||||
28
web/src/composables/useMotion.ts
Normal file
28
web/src/composables/useMotion.ts
Normal file
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -5,3 +5,26 @@ describe('scaffold', () => {
|
||||||
expect(1 + 1).toBe(2)
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
17
web/src/test-setup.ts
Normal file
17
web/src/test-setup.ts
Normal file
|
|
@ -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,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -7,5 +7,6 @@ export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
|
setupFiles: ['./src/test-setup.ts'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue