feat: add tactile haptic feedback to rosary prayer cards

Every prayer card now vibrates on tap — non-decade cards advance to the
next section, decade cards increment the Ave Maria counter with auto-scroll
at 10. Two profiles (bead vs card) give distinct tactile feel; the 10th
bead fires the heavier card haptic to mark decade completion.

Native Android path via AndroidBridge.forceVibrate uses VibrationAttributes
USAGE_ACCESSIBILITY so vibration bypasses silent / Do-Not-Disturb inside
the Tauri app. Browser falls back to the web-haptics npm package. Haptic
fires on pointerdown with touch-action: manipulation for near-zero tap
latency; state change stays on click so scroll gestures don't advance.

- Remove CounterButton (whole card is now the tap target)
- Replace emoji with Lucide BookOpen icon, restyle citation as an
  understated inline typographic link (no background chip)
- Drop decade min-height leftover from the pre-auto-advance layout

Bumps site to 1.27.0 and Tauri app to 0.5.0 (new Android capability).
This commit is contained in:
2026-04-12 20:14:56 +02:00
parent cfd1d953fb
commit 69c2e05462
9 changed files with 246 additions and 178 deletions
-69
View File
@@ -1,69 +0,0 @@
<script lang="ts">
let { onclick } = $props<{ onclick?: () => void }>();
</script>
<button class="counter-button" {onclick} aria-label="Nächstes Ave Maria">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4V2.21c0-.45-.54-.67-.85-.35l-2.8 2.79c-.2.2-.2.51 0 .71l2.79 2.79c.32.31.86.09.86-.36V6c3.31 0 6 2.69 6 6 0 .79-.15 1.56-.44 2.25-.15.36-.04.77.23 1.04.51.51 1.37.33 1.64-.34.37-.91.57-1.91.57-2.95 0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-.79.15-1.56.44-2.25.15-.36.04-.77-.23-1.04-.51-.51-1.37-.33-1.64.34C4.2 9.96 4 10.96 4 12c0 4.42 3.58 8 8 8v1.79c0 .45.54.67.85.35l2.79-2.79c.2-.2.2-.51 0-.71l-2.79-2.79c-.31-.31-.85-.09-.85.36V18z"/>
</svg>
</button>
<style>
.counter-button {
width: 3rem;
height: 3rem;
border-radius: 50%;
background: var(--nord1);
border: 2px solid var(--nord9);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
@media(prefers-color-scheme: light) {
:global(:root:not([data-theme="dark"])) .counter-button {
background: var(--nord5);
border-color: var(--nord10);
}
}
:global(:root[data-theme="light"]) .counter-button {
background: var(--nord5);
border-color: var(--nord10);
}
.counter-button:hover {
background: var(--nord2);
transform: scale(1.1);
}
@media(prefers-color-scheme: light) {
:global(:root:not([data-theme="dark"])) .counter-button:hover {
background: var(--nord4);
}
}
:global(:root[data-theme="light"]) .counter-button:hover {
background: var(--nord4);
}
.counter-button svg {
width: 1.5rem;
height: 1.5rem;
fill: var(--nord9);
transition: transform 0.3s ease;
}
@media(prefers-color-scheme: light) {
:global(:root:not([data-theme="dark"])) .counter-button svg {
fill: var(--nord10);
}
}
:global(:root[data-theme="light"]) .counter-button svg {
fill: var(--nord10);
}
.counter-button:hover svg {
transform: rotate(180deg);
}
</style>
+33
View File
@@ -0,0 +1,33 @@
import { createWebHaptics } from 'web-haptics/svelte';
export type HapticPulse = { duration: number; intensity?: number };
type AndroidBridgeShape = {
forceVibrate?: (durationMs: number, intensityPct: number) => void;
};
function getBridge(): AndroidBridgeShape | undefined {
if (typeof window === 'undefined') return undefined;
return (window as unknown as { AndroidBridge?: AndroidBridgeShape }).AndroidBridge;
}
/**
* Progressively-enhanced haptics: uses the Tauri Android bridge (bypasses silent
* mode via VibrationAttributes.USAGE_ACCESSIBILITY) when running inside the
* installed app; falls back to web-haptics in the browser.
*/
export function createHaptics() {
const web = createWebHaptics();
const nativeAvailable = typeof getBridge()?.forceVibrate === 'function';
function trigger(pulse: HapticPulse = { duration: 30, intensity: 0.7 }): void {
const bridge = getBridge();
if (bridge?.forceVibrate) {
bridge.forceVibrate(pulse.duration, Math.round((pulse.intensity ?? 0.7) * 100));
return;
}
web.trigger([pulse]);
}
return { trigger, destroy: web.destroy, nativeAvailable };
}