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",
"version": "1.26.1",
"version": "1.27.0",
"private": true,
"type": "module",
"scripts": {
@@ -59,7 +59,8 @@
"leaflet": "^1.9.4",
"mongoose": "^9.4.1",
"node-cron": "^4.2.1",
"sharp": "^0.34.5"
"sharp": "^0.34.5",
"web-haptics": "^0.0.6"
},
"pnpm": {
"onlyBuiltDependencies": [

24
pnpm-lock.yaml generated
View File

@@ -53,6 +53,9 @@ importers:
sharp:
specifier: ^0.34.5
version: 0.34.5
web-haptics:
specifier: ^0.0.6
version: 0.0.6(svelte@5.55.1)
devDependencies:
'@playwright/test':
specifier: 1.56.1
@@ -2033,6 +2036,23 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
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:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
@@ -3765,6 +3785,10 @@ snapshots:
dependencies:
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@8.0.0: {}

View File

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

View File

@@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- AndroidTV support -->
<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.pm.PackageManager
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.webkit.JavascriptInterface
import androidx.core.app.ActivityCompat
@@ -100,6 +104,36 @@ class AndroidBridge(private val context: Context) {
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. */
@JavascriptInterface
fun hasTtsEngine(): Boolean {

View File

@@ -1,7 +1,7 @@
{
"productName": "Bocken",
"identifier": "org.bocken.app",
"version": "0.4.0",
"version": "0.5.0",
"build": {
"devUrl": "http://192.168.1.4:5173",
"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)) {
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);
}
: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);
}

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>
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 { createPip } from "$lib/js/pip.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 RosaryFinalPrayer from "$lib/components/faith/prayers/RosaryFinalPrayer.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 { theologicalVirtueVerseDataDe, theologicalVirtueVerseDataEn } from "$lib/data/mysteryVerseData";
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 { isEastertide, getLiturgicalSeason } from "$lib/js/easter.svelte";
import { setupScrollSync } from "./rosaryScrollSync.js";
import { BookOpen } from "@lucide/svelte";
let { data } = $props();
// 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)
let decadeCounters = $state({
secret1: 0,
@@ -268,9 +278,79 @@ let selectedTitle = $state('');
/** @type {any} */
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 */
function advanceDecade(decadeNum) {
function incrementDecadeCounter(decadeNum) {
const key = /** @type {'secret1'|'secret2'|'secret3'|'secret4'|'secret5'} */ (`secret${decadeNum}`);
if (decadeCounters[key] < 10) {
decadeCounters[key] += 1;
@@ -279,24 +359,9 @@ function advanceDecade(decadeNum) {
// and reset the counter
if (decadeCounters[key] === 10) {
setTimeout(() => {
// Reset counter to clear highlighting
decadeCounters[key] = 0;
// Determine next section
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' });
}
const nextSection = decadeNum < 5 ? `secret${decadeNum}_transition` : 'final_transition';
scrollToSection(nextSection);
}, 500);
}
}
@@ -483,6 +548,11 @@ onMount(() => {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
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 */
@@ -535,24 +605,13 @@ onMount(() => {
.prayer-section.decade {
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.decade:has(:global(.prayer-wrapper.monolingual)),
.prayer-section.decade:has(:global(.prayer-wrapper.lang-la)) {
min-height: 30vh;
.prayer-section:active {
transform: scale(0.995);
}
@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 {
padding: 0.5rem;
}
@@ -638,70 +697,48 @@ h1 {
margin-top: 1.5rem;
}
.bible-reference-text {
color: var(--nord8);
font-size: 0.9rem;
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;
/* Understated citation: icon + reference as an inline typographic link. */
.bible-reference-link {
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
gap: 0.45rem;
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 {
background: var(--nord8);
border-color: var(--nord9);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
.bible-reference-link :global(svg) {
flex-shrink: 0;
opacity: 0.7;
transition: opacity 0.15s ease, transform 0.2s ease;
}
.bible-reference-button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.bible-reference-link:hover {
color: var(--color-primary-hover);
opacity: 1;
}
@media(prefers-color-scheme: light) {
:global(:root:not([data-theme="dark"])) .bible-reference-button {
background: var(--nord5);
border-color: var(--nord4);
color: var(--nord0);
}
:global(:root:not([data-theme="dark"])) .bible-reference-button:hover {
background: var(--nord4);
border-color: var(--nord3);
}
}
:global(:root[data-theme="light"]) .bible-reference-button {
background: var(--nord5);
border-color: var(--nord4);
color: var(--nord0);
.bible-reference-link:hover :global(svg) {
opacity: 1;
transform: rotate(-4deg);
}
:global(:root[data-theme="light"]) .bible-reference-button:hover {
background: var(--nord4);
border-color: var(--nord3);
.bible-reference-link:focus-visible {
outline: 1px dotted currentColor;
outline-offset: 3px;
opacity: 1;
}
/* Footnote styles */
@@ -834,7 +871,9 @@ h1 {
</div>
<!-- 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 -->
<div
class="prayer-section"
@@ -878,12 +917,14 @@ h1 {
mysteryEnglish="Jesus, who may increase our faith"
/>
<div class="decade-buttons">
<span class="bible-reference-text">{theologicalVirtueData.reference}</span>
<button
class="bible-reference-button"
class="bible-reference-link"
onclick={() => handleCitationClick(theologicalVirtueData.reference, theologicalVirtueData.title, theologicalVirtueData.verseData)}
aria-label={labels.showBibleVerse}
>📖</button>
>
<BookOpen size={15} strokeWidth={1.75} aria-hidden="true" />
<span>{theologicalVirtueData.reference}</span>
</button>
</div>
</div>
@@ -901,12 +942,14 @@ h1 {
mysteryEnglish="Jesus, who may strengthen our hope"
/>
<div class="decade-buttons">
<span class="bible-reference-text">{theologicalVirtueData.reference}</span>
<button
class="bible-reference-button"
class="bible-reference-link"
onclick={() => handleCitationClick(theologicalVirtueData.reference, theologicalVirtueData.title, theologicalVirtueData.verseData)}
aria-label={labels.showBibleVerse}
>📖</button>
>
<BookOpen size={15} strokeWidth={1.75} aria-hidden="true" />
<span>{theologicalVirtueData.reference}</span>
</button>
</div>
</div>
@@ -924,12 +967,14 @@ h1 {
mysteryEnglish="Jesus, who may kindle our love"
/>
<div class="decade-buttons">
<span class="bible-reference-text">{theologicalVirtueData.reference}</span>
<button
class="bible-reference-button"
class="bible-reference-link"
onclick={() => handleCitationClick(theologicalVirtueData.reference, theologicalVirtueData.title, theologicalVirtueData.verseData)}
aria-label={labels.showBibleVerse}
>📖</button>
>
<BookOpen size={15} strokeWidth={1.75} aria-hidden="true" />
<span>{theologicalVirtueData.reference}</span>
</button>
</div>
</div>
@@ -958,7 +1003,7 @@ h1 {
<Paternoster />
</div>
<!-- Ave Maria decade (Gesätz) -->
<!-- Ave Maria decade (Gesätz) - whole card tappable via parent delegation -->
<div
class="prayer-section decade"
id={`secret${decadeNum}`}
@@ -983,16 +1028,15 @@ h1 {
<div class="decade-buttons">
{#if currentMysteryDescriptions[decadeNum - 1]}
{@const description = currentMysteryDescriptions[decadeNum - 1]}
<span class="bible-reference-text">{description.reference}</span>
<button
class="bible-reference-button"
class="bible-reference-link"
onclick={() => handleCitationClick(description.reference, description.title, description.verseData)}
aria-label={labels.showBibleVerse}
>
📖
<BookOpen size={15} strokeWidth={1.75} aria-hidden="true" />
<span>{description.reference}</span>
</button>
{/if}
<CounterButton onclick={() => advanceDecade(decadeNum)} />
</div>
</div>