Compare commits

2 Commits

Author SHA1 Message Date
be7880304c feat: add tactile haptic feedback to rosary prayer cards
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).
2026-04-12 20:15:40 +02:00
8023a907de fix: adjust LinksGrid nth-child offsets for earlier, more frequent color pops
Shift pop-b and pop-c selectors so accent colors appear sooner in the
grid and the light/white pop-c repeats more frequently (every 5th
instead of every 7th item).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:34:23 +02:00
10 changed files with 248 additions and 180 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.26.1", "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
View File

@@ -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: {}

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "bocken" name = "bocken"
version = "0.4.0" version = "0.5.0"
edition = "2021" edition = "2021"
[lib] [lib]

View File

@@ -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" />

View File

@@ -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 {

View File

@@ -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"

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>

View File

@@ -17,10 +17,10 @@
:global(.links_grid a:nth-child(3n+1) svg:not(.lock-icon)) { :global(.links_grid a:nth-child(3n+1) svg:not(.lock-icon)) {
fill: var(--grid-fill-pop-a); fill: var(--grid-fill-pop-a);
} }
:global(.links_grid a:nth-child(5n+3) svg:not(.lock-icon)) { :global(.links_grid a:nth-child(5n+2) svg:not(.lock-icon)) {
fill: var(--grid-fill-pop-b); fill: var(--grid-fill-pop-b);
} }
:global(.links_grid a:nth-child(7n) svg:not(.lock-icon)) { :global(.links_grid a:nth-child(5n+4) svg:not(.lock-icon)) {
fill: var(--grid-fill-pop-c); fill: var(--grid-fill-pop-c);
} }

33
src/lib/js/haptics.ts Normal file
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 };
}

View File

@@ -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>