feat: add tactile haptic feedback to rosary prayer cards
All checks were successful
CI / update (push) Successful in 3m56s
All checks were successful
CI / update (push) Successful in 3m56s
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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.26.2",
|
"version": "1.27.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -59,7 +59,8 @@
|
|||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"mongoose": "^9.4.1",
|
"mongoose": "^9.4.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5",
|
||||||
|
"web-haptics": "^0.0.6"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|||||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -53,6 +53,9 @@ importers:
|
|||||||
sharp:
|
sharp:
|
||||||
specifier: ^0.34.5
|
specifier: ^0.34.5
|
||||||
version: 0.34.5
|
version: 0.34.5
|
||||||
|
web-haptics:
|
||||||
|
specifier: ^0.0.6
|
||||||
|
version: 0.0.6(svelte@5.55.1)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: 1.56.1
|
specifier: 1.56.1
|
||||||
@@ -2033,6 +2036,23 @@ packages:
|
|||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
web-haptics@0.0.6:
|
||||||
|
resolution: {integrity: sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=18'
|
||||||
|
react-dom: '>=18'
|
||||||
|
svelte: '>=4'
|
||||||
|
vue: '>=3'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
svelte:
|
||||||
|
optional: true
|
||||||
|
vue:
|
||||||
|
optional: true
|
||||||
|
|
||||||
webidl-conversions@7.0.0:
|
webidl-conversions@7.0.0:
|
||||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3765,6 +3785,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
xml-name-validator: 5.0.0
|
xml-name-validator: 5.0.0
|
||||||
|
|
||||||
|
web-haptics@0.0.6(svelte@5.55.1):
|
||||||
|
optionalDependencies:
|
||||||
|
svelte: 5.55.1
|
||||||
|
|
||||||
webidl-conversions@7.0.0: {}
|
webidl-conversions@7.0.0: {}
|
||||||
|
|
||||||
webidl-conversions@8.0.0: {}
|
webidl-conversions@8.0.0: {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bocken"
|
name = "bocken"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
<!-- AndroidTV support -->
|
<!-- AndroidTV support -->
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.VibrationAttributes
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
import android.speech.tts.TextToSpeech
|
import android.speech.tts.TextToSpeech
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
@@ -100,6 +104,36 @@ class AndroidBridge(private val context: Context) {
|
|||||||
return LocationForegroundService.getIntervalState()
|
return LocationForegroundService.getIntervalState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-vibrate bypassing silent/DND by using USAGE_ACCESSIBILITY attributes.
|
||||||
|
* Why: default web Vibration API uses USAGE_TOUCH which Android silences.
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
fun forceVibrate(durationMs: Long, intensityPct: Int) {
|
||||||
|
val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||||
|
}
|
||||||
|
if (vibrator?.hasVibrator() != true) return
|
||||||
|
|
||||||
|
val amplitude = (intensityPct.coerceIn(1, 100) * 255 / 100).coerceAtLeast(1)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val effect = VibrationEffect.createOneShot(durationMs, amplitude)
|
||||||
|
val attrs = VibrationAttributes.Builder()
|
||||||
|
.setUsage(VibrationAttributes.USAGE_ACCESSIBILITY)
|
||||||
|
.build()
|
||||||
|
vibrator.vibrate(effect, attrs)
|
||||||
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
vibrator.vibrate(VibrationEffect.createOneShot(durationMs, amplitude))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
vibrator.vibrate(durationMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns true if at least one TTS engine is installed on the device. */
|
/** Returns true if at least one TTS engine is installed on the device. */
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun hasTtsEngine(): Boolean {
|
fun hasTtsEngine(): Boolean {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"productName": "Bocken",
|
"productName": "Bocken",
|
||||||
"identifier": "org.bocken.app",
|
"identifier": "org.bocken.app",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"build": {
|
"build": {
|
||||||
"devUrl": "http://192.168.1.4:5173",
|
"devUrl": "http://192.168.1.4:5173",
|
||||||
"frontendDist": "https://bocken.org"
|
"frontendDist": "https://bocken.org"
|
||||||
|
|||||||
@@ -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
src/lib/js/haptics.ts
Normal file
33
src/lib/js/haptics.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, tick } from "svelte";
|
import { onMount, onDestroy, tick } from "svelte";
|
||||||
|
import { createHaptics } from "$lib/js/haptics";
|
||||||
import { createLanguageContext } from "$lib/contexts/languageContext.js";
|
import { createLanguageContext } from "$lib/contexts/languageContext.js";
|
||||||
import { createPip } from "$lib/js/pip.svelte";
|
import { createPip } from "$lib/js/pip.svelte";
|
||||||
import PipImage from "$lib/components/faith/PipImage.svelte";
|
import PipImage from "$lib/components/faith/PipImage.svelte";
|
||||||
@@ -14,7 +15,6 @@ import SalveRegina from "$lib/components/faith/prayers/SalveRegina.svelte";
|
|||||||
import ReginaCaeli from "$lib/components/faith/prayers/ReginaCaeli.svelte";
|
import ReginaCaeli from "$lib/components/faith/prayers/ReginaCaeli.svelte";
|
||||||
import RosaryFinalPrayer from "$lib/components/faith/prayers/RosaryFinalPrayer.svelte";
|
import RosaryFinalPrayer from "$lib/components/faith/prayers/RosaryFinalPrayer.svelte";
|
||||||
import MichaelGebet from "$lib/components/faith/prayers/MichaelGebet.svelte";
|
import MichaelGebet from "$lib/components/faith/prayers/MichaelGebet.svelte";
|
||||||
import CounterButton from "$lib/components/CounterButton.svelte";
|
|
||||||
import BibleModal from "$lib/components/faith/BibleModal.svelte";
|
import BibleModal from "$lib/components/faith/BibleModal.svelte";
|
||||||
import { theologicalVirtueVerseDataDe, theologicalVirtueVerseDataEn } from "$lib/data/mysteryVerseData";
|
import { theologicalVirtueVerseDataDe, theologicalVirtueVerseDataEn } from "$lib/data/mysteryVerseData";
|
||||||
import Toggle from "$lib/components/Toggle.svelte";
|
import Toggle from "$lib/components/Toggle.svelte";
|
||||||
@@ -27,6 +27,7 @@ import MysteryImageColumn from "./MysteryImageColumn.svelte";
|
|||||||
import { mysteries, mysteriesLatin, mysteriesEnglish, mysteryTitles, mysteryTitlesEnglish, mysteryTitlesLatin, allMysteryImages, getLabels, getLabelsLatin, getMysteryForWeekday, BEAD_SPACING, DECADE_OFFSET, sectionPositions } from "./rosaryData.js";
|
import { mysteries, mysteriesLatin, mysteriesEnglish, mysteryTitles, mysteryTitlesEnglish, mysteryTitlesLatin, allMysteryImages, getLabels, getLabelsLatin, getMysteryForWeekday, BEAD_SPACING, DECADE_OFFSET, sectionPositions } from "./rosaryData.js";
|
||||||
import { isEastertide, getLiturgicalSeason } from "$lib/js/easter.svelte";
|
import { isEastertide, getLiturgicalSeason } from "$lib/js/easter.svelte";
|
||||||
import { setupScrollSync } from "./rosaryScrollSync.js";
|
import { setupScrollSync } from "./rosaryScrollSync.js";
|
||||||
|
import { BookOpen } from "@lucide/svelte";
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
// Toggle for including Luminous mysteries (initialized from URL param or default)
|
// Toggle for including Luminous mysteries (initialized from URL param or default)
|
||||||
@@ -252,6 +253,15 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Haptic feedback. Uses native Android bridge (bypasses silent mode) inside
|
||||||
|
// Tauri app, else web-haptics in browser. Two profiles:
|
||||||
|
// - BEAD: soft+crisp for each Ave Maria inside a decade
|
||||||
|
// - CARD: heavier thump for advancing to the next prayer card
|
||||||
|
const { trigger: triggerHaptic, destroy: destroyHaptics } = createHaptics();
|
||||||
|
onDestroy(destroyHaptics);
|
||||||
|
const HAPTIC_BEAD = { duration: 25, intensity: 0.6 };
|
||||||
|
const HAPTIC_CARD = { duration: 80, intensity: 1.0 };
|
||||||
|
|
||||||
// Counter for tracking Ave Maria progress in each decade (0-10 for each)
|
// Counter for tracking Ave Maria progress in each decade (0-10 for each)
|
||||||
let decadeCounters = $state({
|
let decadeCounters = $state({
|
||||||
secret1: 0,
|
secret1: 0,
|
||||||
@@ -268,9 +278,79 @@ let selectedTitle = $state('');
|
|||||||
/** @type {any} */
|
/** @type {any} */
|
||||||
let selectedVerseData = $state(null);
|
let selectedVerseData = $state(null);
|
||||||
|
|
||||||
// Function to advance the counter for a specific decade
|
// Ordered list of tappable prayer-section cards (matches DOM render order).
|
||||||
|
// Used for card-tap delegation: tap any non-decade card → advance to next.
|
||||||
|
const orderedSections = [
|
||||||
|
'cross', 'lbead1', 'start1', 'start2', 'start3', 'lbead2',
|
||||||
|
'secret1_pater', 'secret1', 'secret1_transition',
|
||||||
|
'secret2_pater', 'secret2', 'secret2_transition',
|
||||||
|
'secret3_pater', 'secret3', 'secret3_transition',
|
||||||
|
'secret4_pater', 'secret4', 'secret4_transition',
|
||||||
|
'secret5_pater', 'secret5',
|
||||||
|
'final_transition', 'final_salve', 'final_schlussgebet',
|
||||||
|
'final_michael', 'final_paternoster', 'final_cross'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @param {string} name */
|
||||||
|
function scrollToSection(name) {
|
||||||
|
const el = sectionElements[name];
|
||||||
|
if (!el) return;
|
||||||
|
const top = el.getBoundingClientRect().top + window.scrollY;
|
||||||
|
const offset = parseFloat(getComputedStyle(document.documentElement).fontSize) * 3;
|
||||||
|
window.scrollTo({ top: top - offset, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the target prayer-section for a tap event, applying shared filters. */
|
||||||
|
/** @param {Event} e */
|
||||||
|
function resolvePrayerTarget(e) {
|
||||||
|
const target = /** @type {HTMLElement | null} */ (e.target);
|
||||||
|
if (!target) return null;
|
||||||
|
if (target.closest('button, a, input, select, textarea, [role="button"]')) return null;
|
||||||
|
const section = /** @type {HTMLElement | null} */ (target.closest('.prayer-section'));
|
||||||
|
if (!section) return null;
|
||||||
|
const name = section.getAttribute('data-section');
|
||||||
|
if (!name) return null;
|
||||||
|
return { section, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire haptic immediately on finger-down — don't wait for click's release+no-movement check.
|
||||||
|
* Tradeoff: a scroll gesture starting on a card emits one brief haptic. Worth it for snappy tap feel. */
|
||||||
|
/** @param {PointerEvent} e */
|
||||||
|
function handlePrayerCardPointerDown(e) {
|
||||||
|
if (!e.isPrimary) return;
|
||||||
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||||
|
const hit = resolvePrayerTarget(e);
|
||||||
|
if (!hit) return;
|
||||||
|
if (hit.section.classList.contains('decade')) {
|
||||||
|
const key = /** @type {'secret1'|'secret2'|'secret3'|'secret4'|'secret5'} */ (`secret${parseInt(hit.name.replace('secret', ''), 10)}`);
|
||||||
|
// Predict post-increment count: the 10th bead fires the heavier card haptic.
|
||||||
|
triggerHaptic(decadeCounters[key] === 9 ? HAPTIC_CARD : HAPTIC_BEAD);
|
||||||
|
} else {
|
||||||
|
triggerHaptic(HAPTIC_CARD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click handler performs the state change only (scroll/increment). Haptic already
|
||||||
|
* fired on pointerdown; click's built-in movement filter prevents scroll-gesture advances. */
|
||||||
|
/** @param {MouseEvent} e */
|
||||||
|
function handlePrayerCardClick(e) {
|
||||||
|
const hit = resolvePrayerTarget(e);
|
||||||
|
if (!hit) return;
|
||||||
|
if (hit.section.classList.contains('decade')) {
|
||||||
|
const decadeNum = parseInt(hit.name.replace('secret', ''), 10);
|
||||||
|
incrementDecadeCounter(decadeNum);
|
||||||
|
} else {
|
||||||
|
const idx = orderedSections.indexOf(hit.name);
|
||||||
|
if (idx >= 0 && idx < orderedSections.length - 1) {
|
||||||
|
scrollToSection(orderedSections[idx + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Increment decade counter; auto-scroll to next section when reaching 10.
|
||||||
|
* Haptic is fired upstream on pointerdown for lower latency — not here. */
|
||||||
/** @param {number} decadeNum */
|
/** @param {number} decadeNum */
|
||||||
function advanceDecade(decadeNum) {
|
function incrementDecadeCounter(decadeNum) {
|
||||||
const key = /** @type {'secret1'|'secret2'|'secret3'|'secret4'|'secret5'} */ (`secret${decadeNum}`);
|
const key = /** @type {'secret1'|'secret2'|'secret3'|'secret4'|'secret5'} */ (`secret${decadeNum}`);
|
||||||
if (decadeCounters[key] < 10) {
|
if (decadeCounters[key] < 10) {
|
||||||
decadeCounters[key] += 1;
|
decadeCounters[key] += 1;
|
||||||
@@ -279,24 +359,9 @@ function advanceDecade(decadeNum) {
|
|||||||
// and reset the counter
|
// and reset the counter
|
||||||
if (decadeCounters[key] === 10) {
|
if (decadeCounters[key] === 10) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Reset counter to clear highlighting
|
|
||||||
decadeCounters[key] = 0;
|
decadeCounters[key] = 0;
|
||||||
|
const nextSection = decadeNum < 5 ? `secret${decadeNum}_transition` : 'final_transition';
|
||||||
// Determine next section
|
scrollToSection(nextSection);
|
||||||
let nextSection;
|
|
||||||
if (decadeNum < 5) {
|
|
||||||
nextSection = `secret${decadeNum}_transition`;
|
|
||||||
} else {
|
|
||||||
nextSection = 'final_transition';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll to next section
|
|
||||||
const nextElement = sectionElements[nextSection];
|
|
||||||
if (nextElement) {
|
|
||||||
const elementTop = nextElement.getBoundingClientRect().top + window.scrollY;
|
|
||||||
const offset = parseFloat(getComputedStyle(document.documentElement).fontSize) * 3;
|
|
||||||
window.scrollTo({ top: elementTop - offset, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -483,6 +548,11 @@ onMount(() => {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: manipulation;
|
||||||
|
user-select: none;
|
||||||
|
transition: transform 0.08s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* No-JS: highlight SVG beads when a prayer section is :target */
|
/* No-JS: highlight SVG beads when a prayer section is :target */
|
||||||
@@ -535,24 +605,13 @@ onMount(() => {
|
|||||||
|
|
||||||
.prayer-section.decade {
|
.prayer-section.decade {
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
min-height: 50vh; /* Only decades need minimum height for scroll-snap */
|
|
||||||
padding-bottom: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduce min-height in monolingual mode since content is shorter */
|
.prayer-section:active {
|
||||||
.prayer-section.decade:has(:global(.prayer-wrapper.monolingual)),
|
transform: scale(0.995);
|
||||||
.prayer-section.decade:has(:global(.prayer-wrapper.lang-la)) {
|
|
||||||
min-height: 30vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.prayer-section.decade {
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.prayer-section.decade:has(:global(.prayer-wrapper.monolingual)),
|
|
||||||
.prayer-section.decade:has(:global(.prayer-wrapper.lang-la)) {
|
|
||||||
min-height: 20vh;
|
|
||||||
}
|
|
||||||
.prayer-section {
|
.prayer-section {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -638,70 +697,48 @@ h1 {
|
|||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bible-reference-text {
|
/* Understated citation: icon + reference as an inline typographic link. */
|
||||||
color: var(--nord8);
|
.bible-reference-link {
|
||||||
font-size: 0.9rem;
|
display: inline-flex;
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media(prefers-color-scheme: light) {
|
|
||||||
:global(:root:not([data-theme="dark"])) .bible-reference-text {
|
|
||||||
color: var(--nord10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
:global(:root[data-theme="light"]) .bible-reference-text {
|
|
||||||
color: var(--nord10);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bible-reference-button {
|
|
||||||
background: var(--nord3);
|
|
||||||
border: 2px solid var(--nord2);
|
|
||||||
color: var(--nord6);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: all 0.2s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 0.45rem;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
padding: 0.25rem 0.35rem;
|
||||||
|
margin: 0;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-variant-numeric: oldstyle-nums;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.78;
|
||||||
|
transition: color 0.15s ease, opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bible-reference-button:hover {
|
.bible-reference-link :global(svg) {
|
||||||
background: var(--nord8);
|
flex-shrink: 0;
|
||||||
border-color: var(--nord9);
|
opacity: 0.7;
|
||||||
transform: translateY(-2px);
|
transition: opacity 0.15s ease, transform 0.2s ease;
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bible-reference-button:active {
|
.bible-reference-link:hover {
|
||||||
transform: translateY(0);
|
color: var(--color-primary-hover);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(prefers-color-scheme: light) {
|
.bible-reference-link:hover :global(svg) {
|
||||||
:global(:root:not([data-theme="dark"])) .bible-reference-button {
|
opacity: 1;
|
||||||
background: var(--nord5);
|
transform: rotate(-4deg);
|
||||||
border-color: var(--nord4);
|
|
||||||
color: var(--nord0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(:root:not([data-theme="dark"])) .bible-reference-button:hover {
|
.bible-reference-link:focus-visible {
|
||||||
background: var(--nord4);
|
outline: 1px dotted currentColor;
|
||||||
border-color: var(--nord3);
|
outline-offset: 3px;
|
||||||
}
|
opacity: 1;
|
||||||
}
|
|
||||||
:global(:root[data-theme="light"]) .bible-reference-button {
|
|
||||||
background: var(--nord5);
|
|
||||||
border-color: var(--nord4);
|
|
||||||
color: var(--nord0);
|
|
||||||
}
|
|
||||||
:global(:root[data-theme="light"]) .bible-reference-button:hover {
|
|
||||||
background: var(--nord4);
|
|
||||||
border-color: var(--nord3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footnote styles */
|
/* Footnote styles */
|
||||||
@@ -834,7 +871,9 @@ h1 {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content: Prayer Sections -->
|
<!-- Main Content: Prayer Sections -->
|
||||||
<div class="prayers-content">
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="prayers-content" onpointerdown={handlePrayerCardPointerDown} onclick={handlePrayerCardClick}>
|
||||||
<!-- Cross & Credo -->
|
<!-- Cross & Credo -->
|
||||||
<div
|
<div
|
||||||
class="prayer-section"
|
class="prayer-section"
|
||||||
@@ -878,12 +917,14 @@ h1 {
|
|||||||
mysteryEnglish="Jesus, who may increase our faith"
|
mysteryEnglish="Jesus, who may increase our faith"
|
||||||
/>
|
/>
|
||||||
<div class="decade-buttons">
|
<div class="decade-buttons">
|
||||||
<span class="bible-reference-text">{theologicalVirtueData.reference}</span>
|
|
||||||
<button
|
<button
|
||||||
class="bible-reference-button"
|
class="bible-reference-link"
|
||||||
onclick={() => handleCitationClick(theologicalVirtueData.reference, theologicalVirtueData.title, theologicalVirtueData.verseData)}
|
onclick={() => handleCitationClick(theologicalVirtueData.reference, theologicalVirtueData.title, theologicalVirtueData.verseData)}
|
||||||
aria-label={labels.showBibleVerse}
|
aria-label={labels.showBibleVerse}
|
||||||
>📖</button>
|
>
|
||||||
|
<BookOpen size={15} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span>{theologicalVirtueData.reference}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -901,12 +942,14 @@ h1 {
|
|||||||
mysteryEnglish="Jesus, who may strengthen our hope"
|
mysteryEnglish="Jesus, who may strengthen our hope"
|
||||||
/>
|
/>
|
||||||
<div class="decade-buttons">
|
<div class="decade-buttons">
|
||||||
<span class="bible-reference-text">{theologicalVirtueData.reference}</span>
|
|
||||||
<button
|
<button
|
||||||
class="bible-reference-button"
|
class="bible-reference-link"
|
||||||
onclick={() => handleCitationClick(theologicalVirtueData.reference, theologicalVirtueData.title, theologicalVirtueData.verseData)}
|
onclick={() => handleCitationClick(theologicalVirtueData.reference, theologicalVirtueData.title, theologicalVirtueData.verseData)}
|
||||||
aria-label={labels.showBibleVerse}
|
aria-label={labels.showBibleVerse}
|
||||||
>📖</button>
|
>
|
||||||
|
<BookOpen size={15} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span>{theologicalVirtueData.reference}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -924,12 +967,14 @@ h1 {
|
|||||||
mysteryEnglish="Jesus, who may kindle our love"
|
mysteryEnglish="Jesus, who may kindle our love"
|
||||||
/>
|
/>
|
||||||
<div class="decade-buttons">
|
<div class="decade-buttons">
|
||||||
<span class="bible-reference-text">{theologicalVirtueData.reference}</span>
|
|
||||||
<button
|
<button
|
||||||
class="bible-reference-button"
|
class="bible-reference-link"
|
||||||
onclick={() => handleCitationClick(theologicalVirtueData.reference, theologicalVirtueData.title, theologicalVirtueData.verseData)}
|
onclick={() => handleCitationClick(theologicalVirtueData.reference, theologicalVirtueData.title, theologicalVirtueData.verseData)}
|
||||||
aria-label={labels.showBibleVerse}
|
aria-label={labels.showBibleVerse}
|
||||||
>📖</button>
|
>
|
||||||
|
<BookOpen size={15} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span>{theologicalVirtueData.reference}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -958,7 +1003,7 @@ h1 {
|
|||||||
<Paternoster />
|
<Paternoster />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ave Maria decade (Gesätz) -->
|
<!-- Ave Maria decade (Gesätz) - whole card tappable via parent delegation -->
|
||||||
<div
|
<div
|
||||||
class="prayer-section decade"
|
class="prayer-section decade"
|
||||||
id={`secret${decadeNum}`}
|
id={`secret${decadeNum}`}
|
||||||
@@ -983,16 +1028,15 @@ h1 {
|
|||||||
<div class="decade-buttons">
|
<div class="decade-buttons">
|
||||||
{#if currentMysteryDescriptions[decadeNum - 1]}
|
{#if currentMysteryDescriptions[decadeNum - 1]}
|
||||||
{@const description = currentMysteryDescriptions[decadeNum - 1]}
|
{@const description = currentMysteryDescriptions[decadeNum - 1]}
|
||||||
<span class="bible-reference-text">{description.reference}</span>
|
|
||||||
<button
|
<button
|
||||||
class="bible-reference-button"
|
class="bible-reference-link"
|
||||||
onclick={() => handleCitationClick(description.reference, description.title, description.verseData)}
|
onclick={() => handleCitationClick(description.reference, description.title, description.verseData)}
|
||||||
aria-label={labels.showBibleVerse}
|
aria-label={labels.showBibleVerse}
|
||||||
>
|
>
|
||||||
📖
|
<BookOpen size={15} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span>{description.reference}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<CounterButton onclick={() => advanceDecade(decadeNum)} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user