Files
homepage/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte
T
Alexander 20368131c5
CI / update (push) Successful in 3m20s
fix: eliminate all 167 svelte-check warnings
Refactor page components to use $derived + invalidateAll() where data
is read-only or re-fetched after mutations. Suppress state_referenced_locally
for intentional patterns (form state, optimistic updates, pagination).
Fix a11y issues with role="presentation", add standard line-clamp properties,
remove unused CSS selectors and empty rulesets.
2026-04-08 14:06:15 +02:00

1115 lines
34 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount, tick } from "svelte";
import { createLanguageContext } from "$lib/contexts/languageContext.js";
import { createPip } from "$lib/js/pip.svelte";
import PipImage from "$lib/components/faith/PipImage.svelte";
import "$lib/css/action_button.css";
import Kreuzzeichen from "$lib/components/faith/prayers/Kreuzzeichen.svelte";
import Credo from "$lib/components/faith/prayers/Credo.svelte";
import Paternoster from "$lib/components/faith/prayers/Paternoster.svelte";
import AveMaria from "$lib/components/faith/prayers/AveMaria.svelte";
import GloriaPatri from "$lib/components/faith/prayers/GloriaPatri.svelte";
import FatimaGebet from "$lib/components/faith/prayers/FatimaGebet.svelte";
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";
import LanguageToggle from "$lib/components/faith/LanguageToggle.svelte";
import StreakCounter from "$lib/components/faith/StreakCounter.svelte";
import RosarySvg from "./RosarySvg.svelte";
import MysterySelector from "./MysterySelector.svelte";
import MysteryImageColumn from "./MysteryImageColumn.svelte";
/** @typedef {import('./rosaryData.js').MysteryType} MysteryType */
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";
let { data } = $props();
// Toggle for including Luminous mysteries (initialized from URL param or default)
// svelte-ignore state_referenced_locally
let includeLuminous = $state(data.initialLuminous);
// Toggle for showing mystery images (initialized from URL param or default)
// svelte-ignore state_referenced_locally
let showImages = $state(data.initialShowImages);
// Flag to prevent saving before we've loaded from localStorage
let hasLoadedFromStorage = $state(false);
// Create language context for prayer components (LanguageToggle will use this)
// For Latin route, force showLatin on so only Latin prayers render
// svelte-ignore state_referenced_locally
const langContext = createLanguageContext({ urlLang: /** @type {'en'|'de'} */ (data.lang), initialLatin: data.lang === 'la' ? true : data.initialLatin });
// Update lang store when data.lang changes (e.g., after navigation)
// For Latin route, force showLatin on — Latin text is only rendered when showLatin is true
$effect(() => {
langContext.lang.set(data.lang);
if (data.lang === 'la') {
langContext.showLatin.set(true);
}
});
// UI labels based on URL language (reactive)
const isEnglish = $derived(data.lang === 'en');
const isLatin = $derived(data.lang === 'la');
/** @type {'en'|'de'|'la'} */
const lang = $derived(isLatin ? 'la' : isEnglish ? 'en' : 'de');
const labels = $derived(isLatin ? getLabelsLatin() : getLabels(isEnglish));
// Save toggle states to localStorage whenever they change (but only after initial load)
$effect(() => {
if (typeof localStorage !== 'undefined' && hasLoadedFromStorage) {
localStorage.setItem('rosary_includeLuminous', includeLuminous.toString());
}
});
$effect(() => {
if (typeof localStorage !== 'undefined' && hasLoadedFromStorage) {
localStorage.setItem('rosary_showImages', showImages.toString());
}
});
// Use server-computed initial values (supports no-JS via URL params)
// svelte-ignore state_referenced_locally
let selectedMystery = $state(/** @type {MysteryType} */ (data.initialMystery));
let todaysMystery = $derived(/** @type {MysteryType} */ (data.todaysMystery));
// Derive these values from selectedMystery so they update automatically
let currentMysteries = $derived(mysteries[selectedMystery]);
let currentMysteriesLatin = $derived(mysteriesLatin[selectedMystery]);
let currentMysteriesEnglish = $derived(mysteriesEnglish[selectedMystery]);
let currentMysteryTitles = $derived(isLatin ? mysteryTitlesLatin[selectedMystery] : isEnglish ? mysteryTitlesEnglish[selectedMystery] : mysteryTitles[selectedMystery]);
let currentMysteryDescriptions = $derived(data.mysteryDescriptions[selectedMystery] || []);
// Function to switch mysteries
/** @param {MysteryType} mysteryType */
function selectMystery(mysteryType) {
selectedMystery = mysteryType;
lastMysteryTarget = 'before';
}
// Build URLs preserving full state (for no-JS fallback)
/** @param {{ mystery?: string, luminous?: boolean, latin?: boolean, images?: boolean }} [opts] */
function buildHref({ mystery = selectedMystery, luminous = includeLuminous, latin = data.initialLatin, images = showImages } = {}) {
const params = new URLSearchParams();
params.set('mystery', mystery);
if (!luminous) params.set('luminous', '0');
if (!latin) params.set('latin', '0');
if (!images) params.set('images', '0');
return `?${params.toString()}`;
}
/** @param {string} mystery */
function mysteryHref(mystery) {
return buildHref({ mystery });
}
// Toggle hrefs navigate to opposite state (for no-JS self-submit)
let luminousToggleHref = $derived(buildHref({ luminous: !includeLuminous }));
let latinToggleHref = $derived(buildHref({ latin: !data.initialLatin }));
let imagesToggleHref = $derived(buildHref({ images: !showImages }));
// When luminous toggle changes, update today's mystery and fix invalid selection
$effect(() => {
todaysMystery = getMysteryForWeekday(new Date(), includeLuminous);
if (!includeLuminous && selectedMystery === 'lichtreichen') {
const season = getLiturgicalSeason();
/** @type {Record<string, MysteryType>} */
const seasonalMap = { eastertide: 'glorreichen', lent: 'schmerzhaften' };
selectedMystery = (season ? seasonalMap[season] : null) ?? todaysMystery;
}
});
// Active section tracking
let activeSection = $state("cross");
/** @type {Record<string, HTMLElement>} */
let sectionElements = {};
/** @type {HTMLElement | undefined} */
let svgContainer;
// Map pater sections to the large bead they share (so SVG highlights correctly)
const svgActiveSection = $derived(
activeSection === 'secret1_pater' ? 'lbead2' :
activeSection.endsWith('_pater') ? `secret${parseInt(activeSection[6]) - 1}_transition` :
activeSection
);
// Whether the rosary has mystery images (stable, doesn't change during scroll)
const hasMysteryImages = $derived(showImages && (allMysteryImages[selectedMystery]?.size ?? 0) > 0);
// Mystery image scroll target based on active section (returns decade number 1-5, or 'before'/'after')
/**
* @param {string} section
* @returns {number | 'before' | 'after'}
*/
function getMysteryScrollTarget(section) {
const secretMatch = section.match(/^secret(\d)/);
if (secretMatch) {
const num = parseInt(secretMatch[1]);
// _transition (Gloria + Fatima) stays on the current mystery image;
// _pater and plain decade both show the mystery they belong to
return num;
}
if (section === 'final_transition') return 5;
if (section.startsWith('final_')) return 'after';
return 'before';
}
// Mobile PiP: which image src to show (null = hide)
/**
* @param {MysteryType} mystery
* @param {string} section
*/
function getMysteryImage(mystery, section) {
const images = allMysteryImages[mystery];
if (!images || images.size === 0) return null;
const target = getMysteryScrollTarget(section);
if (target === 'before' || target === 'after') return null;
return images.get(target)?.src ?? null;
}
const mysteryPipSrc = $derived(getMysteryImage(selectedMystery, activeSection));
// Mobile PiP drag/enlarge
const pip = createPip({ fullscreenEnabled: true });
/** @type {HTMLElement | null} */
let rosaryPipEl = $state(null);
/** @type {string | null} */
let lastPipSrc = $state(null);
function isMobilePip() {
return !window.matchMedia('(min-width: 1200px)').matches;
}
$effect(() => {
if (mysteryPipSrc) lastPipSrc = mysteryPipSrc;
});
$effect(() => {
if (!rosaryPipEl || !isMobilePip()) return;
if (mysteryPipSrc) {
// Wait for DOM update so the <img> has rendered with dimensions
tick().then(() => {
if (rosaryPipEl) pip.show(rosaryPipEl);
});
} else {
pip.hide();
}
});
/** @type {HTMLElement | undefined} */
let mysteryImageContainer;
/** @type {number | null} */
let mysteryScrollRaf = null;
/** @type {number | string} */
let lastMysteryTarget = 'before';
/** @param {number} targetY @param {number} [duration] */
function scrollMysteryImage(targetY, duration = 1200) {
const container = mysteryImageContainer;
if (!container) return;
if (mysteryScrollRaf) cancelAnimationFrame(mysteryScrollRaf);
const startY = container.scrollTop;
const distance = targetY - startY;
if (Math.abs(distance) < 1) return;
const startTime = performance.now();
const ease = (/** @type {number} */ t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
const step = (/** @type {number} */ now) => {
const progress = Math.min((now - startTime) / duration, 1);
container.scrollTop = startY + distance * ease(progress);
if (progress < 1) mysteryScrollRaf = requestAnimationFrame(step);
else mysteryScrollRaf = null;
};
mysteryScrollRaf = requestAnimationFrame(step);
}
// Scroll the mystery image column to the relevant image
const IMAGE_COL_HEADER_OFFSET = 6; // rem — keep images below the sticky header
$effect(() => {
if (!mysteryImageContainer || !hasMysteryImages) return;
const targetName = getMysteryScrollTarget(activeSection);
const targetEl = /** @type {HTMLElement | null} */ (mysteryImageContainer.querySelector(`[data-target="${targetName}"]`));
if (targetEl) {
const isEdge = targetName === 'before' || targetName === 'after';
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
// Edge pads (before/after): scroll flush so previous image hides behind the header
const offset = isEdge ? 0 : rem * IMAGE_COL_HEADER_OFFSET;
const target = Math.max(0, targetEl.offsetTop - offset);
// Smooth-scroll the last image away when naturally transitioning from decade 5 to 'after'
const isNaturalEnd = targetName === 'after' && lastMysteryTarget === 5;
if (isEdge && !isNaturalEnd) {
// Snap instantly when jumping to top/bottom (not a natural scroll)
if (mysteryScrollRaf) cancelAnimationFrame(mysteryScrollRaf);
mysteryScrollRaf = null;
mysteryImageContainer.scrollTop = target;
} else {
scrollMysteryImage(target);
}
lastMysteryTarget = targetName;
}
});
// Counter for tracking Ave Maria progress in each decade (0-10 for each)
let decadeCounters = $state({
secret1: 0,
secret2: 0,
secret3: 0,
secret4: 0,
secret5: 0
});
// Modal state for displaying Bible citations
let showModal = $state(false);
let selectedReference = $state('');
let selectedTitle = $state('');
/** @type {any} */
let selectedVerseData = $state(null);
// Function to advance the counter for a specific decade
/** @param {number} decadeNum */
function advanceDecade(decadeNum) {
const key = /** @type {'secret1'|'secret2'|'secret3'|'secret4'|'secret5'} */ (`secret${decadeNum}`);
if (decadeCounters[key] < 10) {
decadeCounters[key] += 1;
// When we reach 10, auto-scroll to next section after a brief delay
// 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' });
}
}, 500);
}
}
}
// Function to handle citation click
/** @param {string} reference @param {string} [title] @param {any} [verseData] */
function handleCitationClick(reference, title = '', verseData = null) {
selectedReference = reference;
selectedTitle = title;
selectedVerseData = verseData;
showModal = true;
}
// 1 Cor 13 reference for the three theological virtue Ave Marias
const theologicalVirtueData = $derived(isEnglish ? theologicalVirtueVerseDataEn : theologicalVirtueVerseDataDe);
const pos = sectionPositions;
onMount(() => {
// Load toggle states from localStorage only if not overridden by URL params
if (!data.hasUrlLuminous) {
const savedIncludeLuminous = localStorage.getItem('rosary_includeLuminous');
if (savedIncludeLuminous !== null) {
includeLuminous = savedIncludeLuminous === 'true';
}
}
if (!data.hasUrlImages) {
const savedShowImages = localStorage.getItem('rosary_showImages');
if (savedShowImages !== null) {
showImages = savedShowImages === 'true';
}
}
// If no mystery was specified in URL, recompute based on loaded preferences
if (!data.hasUrlMystery) {
todaysMystery = getMysteryForWeekday(new Date(), includeLuminous);
const season = getLiturgicalSeason();
/** @type {Record<string, MysteryType>} */
const seasonalMap = { eastertide: 'glorreichen', lent: 'schmerzhaften' };
selectMystery(season ? seasonalMap[season] ?? todaysMystery : todaysMystery);
}
// Clean up URL params after hydration (state is now in component state)
if (window.location.search) {
history.replaceState({}, '', window.location.pathname);
}
// Now allow saving to localStorage
hasLoadedFromStorage = true;
// Mark JS as active so no-JS fallback images hide
document.documentElement.classList.add('js-enabled');
// PiP resize handler — show/hide when crossing the breakpoint
const onPipResize = () => {
if (!rosaryPipEl) return;
if (isMobilePip() && mysteryPipSrc) {
pip.show(rosaryPipEl);
} else if (!isMobilePip()) {
pip.hide();
}
};
window.addEventListener('resize', onPipResize);
// Bidirectional scroll sync between prayers, SVG, and image column
const cleanupScrollSync = setupScrollSync({
getSvgContainer: () => svgContainer,
getSectionElements: () => sectionElements,
getMysteryImageContainer: () => mysteryImageContainer,
getActiveSection: () => activeSection,
setActiveSection: (s) => { activeSection = s; },
});
return () => {
cleanupScrollSync();
window.removeEventListener('resize', onPipResize);
document.documentElement.classList.remove('js-enabled');
};
});
</script>
<style>
.page-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 1rem 2rem 1rem;
}
.page-container:has(.has-mystery-image) {
max-width: calc(1400px + 25vw + 3rem);
}
.rosary-layout {
position: relative;
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
@media (min-width: 900px) {
.rosary-layout {
grid-template-columns: clamp(250px, 30vw, 400px) 1fr;
gap: 3rem;
}
}
/* Sidebar with rosary visualization */
.rosary-sidebar {
position: relative;
}
/* Mobile layout: fixed left sidebar for visualization */
@media (max-width: 900px) {
.rosary-layout {
grid-template-columns: clamp(20px, 10vw, 80px) 1fr;
gap: 0;
}
.rosary-sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow: hidden;
}
.rosary-visualization {
height: 100%;
padding: 1rem 0;
display: flex;
align-items: flex-start;
justify-content: center;
}
/* Make SVG beads larger on mobile by scaling up and center it */
.rosary-visualization :global(svg) {
transform: scale(3.5);
transform-origin: center top;
}
/* Disable mask on mobile to show full visualization */
.rosary-visualization {
-webkit-mask-image: none;
mask-image: none;
}
.prayers-content {
max-width: 100%;
padding-left: 1rem;
}
/* Reduce padding in prayer cards for mobile */
.prayer-section {
padding: 10rem;
}
}
.rosary-visualization {
padding: 2rem 0;
position: sticky;
top: 0;
max-height: 100dvh;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
/* Hide scrollbar completely */
.rosary-visualization::-webkit-scrollbar {
display: none;
}
/* Main content area with prayers */
.prayers-content {
scroll-snap-type: y proximity;
max-width: 700px;
}
.prayer-section {
scroll-snap-align: start;
scroll-margin-top: 3rem;
padding: 2rem;
margin-bottom: 2rem;
background: var(--accent-dark);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
position: relative;
}
/* No-JS: highlight SVG beads when a prayer section is :target */
.rosary-layout:has(#start1:target) :global(.bead[data-section="start1"]),
.rosary-layout:has(#start2:target) :global(.bead[data-section="start2"]),
.rosary-layout:has(#start3:target) :global(.bead[data-section="start3"]),
.rosary-layout:has(#secret1:target) :global(.bead[data-section="secret1"]),
.rosary-layout:has(#secret2:target) :global(.bead[data-section="secret2"]),
.rosary-layout:has(#secret3:target) :global(.bead[data-section="secret3"]),
.rosary-layout:has(#secret4:target) :global(.bead[data-section="secret4"]),
.rosary-layout:has(#secret5:target) :global(.bead[data-section="secret5"]),
.rosary-layout:has(#final_salve:target) :global(.bead[data-section="final_salve"]),
.rosary-layout:has(#final_schlussgebet:target) :global(.bead[data-section="final_schlussgebet"]),
.rosary-layout:has(#final_michael:target) :global(.bead[data-section="final_michael"]) {
fill: var(--nord11) !important;
filter: drop-shadow(0 0 8px var(--nord11));
}
.rosary-layout:has(#lbead1:target) :global(.large-bead[data-section="lbead1"]),
.rosary-layout:has(#lbead2:target) :global(.large-bead[data-section="lbead2"]),
.rosary-layout:has(#secret1_pater:target) :global(.large-bead[data-section="lbead2"]),
.rosary-layout:has(#secret1_transition:target) :global(.large-bead[data-section="secret1_transition"]),
.rosary-layout:has(#secret2_pater:target) :global(.large-bead[data-section="secret1_transition"]),
.rosary-layout:has(#secret2_transition:target) :global(.large-bead[data-section="secret2_transition"]),
.rosary-layout:has(#secret3_pater:target) :global(.large-bead[data-section="secret2_transition"]),
.rosary-layout:has(#secret3_transition:target) :global(.large-bead[data-section="secret3_transition"]),
.rosary-layout:has(#secret4_pater:target) :global(.large-bead[data-section="secret3_transition"]),
.rosary-layout:has(#secret4_transition:target) :global(.large-bead[data-section="secret4_transition"]),
.rosary-layout:has(#secret5_pater:target) :global(.large-bead[data-section="secret4_transition"]),
.rosary-layout:has(#final_transition:target) :global(.large-bead[data-section="final_transition"]),
.rosary-layout:has(#final_paternoster:target) :global(.large-bead[data-section="final_paternoster"]) {
fill: var(--nord13) !important;
filter: drop-shadow(0 0 10px var(--nord13));
}
.rosary-layout:has(#cross:target) :global([data-section="cross"] .cross-symbol),
.rosary-layout:has(#final_cross:target) :global([data-section="final_cross"] .cross-symbol) {
fill: var(--nord11) !important;
filter: drop-shadow(0 0 10px var(--nord11));
}
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme="dark"])) .prayer-section {
background: var(--nord5);
}
}
:global(:root[data-theme="light"]) .prayer-section {
background: var(--nord5);
}
.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;
}
@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;
}
}
.prayer-section h2 {
color: var(--nord10);
margin-bottom: 1rem;
font-size: 1.8rem;
}
.eastertide-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25em 0.6em;
font-size: 0.75rem;
font-weight: 600;
border-radius: 999px;
background-color: var(--nord14);
color: var(--nord0);
z-index: 1;
}
.prayer-section h3 {
color: var(--nord11);
margin-top: 1.5rem;
margin-bottom: 0.75rem;
font-size: 1.3rem;
}
/* Prayer component bilingual styling */
.prayer-section :global(p) {
text-align: center;
font-size: 1.25em;
}
.repeat-count {
color: var(--nord9);
font-style: italic;
font-size: 0.95rem;
}
h1 {
text-align: center;
font-size: 3em;
margin-bottom: 2rem;
}
/* Controls row: toggles + streak counter */
.controls-row {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
margin: 0 auto 2rem auto;
}
@media (min-width: 900px) {
.controls-row {
flex-direction: row;
justify-content: center;
gap: 3rem;
}
}
/* Toggle controls container */
.toggle-controls {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
/* Mystery description styling */
.decade-buttons {
display: flex;
flex-direction: row;
gap: 1rem;
justify-content: flex-end;
align-items: center;
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;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.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-button:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.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);
}
:global(:root[data-theme="light"]) .bible-reference-button:hover {
background: var(--nord4);
border-color: var(--nord3);
}
/* Footnote styles */
.footnotes-section {
margin-top: 1.5rem;
font-size: 0.85rem;
color: var(--nord4);
}
@media(prefers-color-scheme: light) {
:global(:root:not([data-theme="dark"])) .footnotes-section {
color: var(--nord0);
}
}
:global(:root[data-theme="light"]) .footnotes-section {
color: var(--nord0);
}
.footnotes-section p {
margin: 0.25rem 0;
text-align: left;
}
.footnotes-section .symbol {
font-weight: bold;
margin-right: 0.5em;
color: var(--nord11);
}
.scroll-top-button {
margin: 2rem auto 0;
width: fit-content;
text-decoration: none;
}
.scroll-padding {
height: 50vh;
}
/* Inline mystery images: visible without JS, hidden when JS takes over */
.decade-inline-image {
margin: 1rem auto;
text-align: center;
}
.decade-inline-image img {
max-width: 100%;
max-height: 40vh;
border-radius: 8px;
}
.decade-inline-image figcaption {
font-size: 0.85rem;
color: var(--nord4);
margin-top: 0.5rem;
}
:global(html.js-enabled) .decade-inline-image {
display: none;
}
/* Mystery images: third grid column (desktop), PiP (mobile) */
.mystery-image-column {
display: none;
}
@media (min-width: 1200px) {
.rosary-layout.has-mystery-image {
grid-template-columns: clamp(250px, 30vw, 400px) 1fr auto;
}
.mystery-image-column {
display: block;
position: sticky;
top: 0;
align-self: start;
max-height: 100vh;
overflow-y: hidden;
overflow-x: hidden;
}
}
</style>
<svelte:head>
<title>{labels.pageTitle}</title>
<meta name="description" content={labels.pageDescription}>
</svelte:head>
<div class="page-container" id="top">
<h1>{labels.pageTitle}</h1>
<h2 style="text-align:center;">{labels.mysteries}</h2>
<!-- Mystery Selector (links for no-JS, enhanced with onclick for JS) -->
<MysterySelector {selectedMystery} {todaysMystery} {includeLuminous} {labels} {mysteryHref} {selectMystery} season={getLiturgicalSeason()} />
<!-- Toggle Controls & Streak Counter -->
<div class="controls-row">
<StreakCounter streakData={data.streakData} {lang} isLoggedIn={data.isLoggedIn} />
<div class="toggle-controls">
<!-- Luminous Mysteries Toggle (link for no-JS, enhanced with onclick for JS) -->
<Toggle
bind:checked={includeLuminous}
label={labels.includeLuminous}
href={luminousToggleHref}
/>
<Toggle
bind:checked={showImages}
label={labels.showImages}
href={imagesToggleHref}
/>
<!-- Language Toggle (link for no-JS, enhanced with onclick for JS) — hidden on Latin route -->
{#if !isLatin}
<LanguageToggle
initialLatin={data.initialLatin}
hasUrlLatin={data.hasUrlLatin}
href={latinToggleHref}
/>
{/if}
</div>
</div>
<div class="rosary-layout" class:has-mystery-image={hasMysteryImages}>
<!-- Sidebar: Rosary Visualization -->
<div class="rosary-sidebar">
<div class="rosary-visualization" bind:this={svgContainer}>
<RosarySvg {pos} {BEAD_SPACING} {DECADE_OFFSET} activeSection={svgActiveSection} {decadeCounters} />
</div>
</div>
<!-- Main Content: Prayer Sections -->
<div class="prayers-content">
<!-- Cross & Credo -->
<div
class="prayer-section"
id="cross"
bind:this={sectionElements.cross}
data-section="cross"
>
<h2>{labels.beginning}</h2>
<h3>{labels.signOfCross}</h3>
<Kreuzzeichen />
<h3>Credo</h3>
<Credo />
<div class="footnotes-section">
<p><span class="symbol"></span>{labels.footnoteSign}</p>
<p><span class="symbol"></span>{labels.footnoteBow}</p>
</div>
</div>
<!-- First Large Bead -->
<div
class="prayer-section"
id="lbead1"
bind:this={sectionElements.lbead1}
data-section="lbead1"
>
<h3>{labels.ourFather}</h3>
<Paternoster />
</div>
<!-- First Ave Maria (Faith) -->
<div
class="prayer-section"
id="start1"
bind:this={sectionElements.start1}
data-section="start1"
>
<h3>{labels.hailMary}: {labels.faith}</h3>
<AveMaria
mysteryLatin="Jesus, qui adáugeat nobis fidem"
mystery="Jesus, der in uns den Glauben vermehre"
mysteryEnglish="Jesus, who may increase our faith"
/>
<div class="decade-buttons">
<span class="bible-reference-text">{theologicalVirtueData.reference}</span>
<button
class="bible-reference-button"
onclick={() => handleCitationClick(theologicalVirtueData.reference, theologicalVirtueData.title, theologicalVirtueData.verseData)}
aria-label={labels.showBibleVerse}
>📖</button>
</div>
</div>
<!-- Second Ave Maria (Hope) -->
<div
class="prayer-section"
id="start2"
bind:this={sectionElements.start2}
data-section="start2"
>
<h3>{labels.hailMary}: {labels.hope}</h3>
<AveMaria
mysteryLatin="Jesus, qui corróboret nobis spem"
mystery="Jesus, der in uns die Hoffnung stärke"
mysteryEnglish="Jesus, who may strengthen our hope"
/>
<div class="decade-buttons">
<span class="bible-reference-text">{theologicalVirtueData.reference}</span>
<button
class="bible-reference-button"
onclick={() => handleCitationClick(theologicalVirtueData.reference, theologicalVirtueData.title, theologicalVirtueData.verseData)}
aria-label={labels.showBibleVerse}
>📖</button>
</div>
</div>
<!-- Third Ave Maria (Love) -->
<div
class="prayer-section"
id="start3"
bind:this={sectionElements.start3}
data-section="start3"
>
<h3>{labels.hailMary}: {labels.love}</h3>
<AveMaria
mysteryLatin="Jesus, qui perficiat in nobis caritátem"
mystery="Jesus, der in uns die Liebe entzünde"
mysteryEnglish="Jesus, who may kindle our love"
/>
<div class="decade-buttons">
<span class="bible-reference-text">{theologicalVirtueData.reference}</span>
<button
class="bible-reference-button"
onclick={() => handleCitationClick(theologicalVirtueData.reference, theologicalVirtueData.title, theologicalVirtueData.verseData)}
aria-label={labels.showBibleVerse}
>📖</button>
</div>
</div>
<!-- Gloria Patri before decades -->
<div
class="prayer-section"
id="lbead2"
bind:this={sectionElements.lbead2}
data-section="lbead2"
>
<h3>{labels.gloriaPatri}</h3>
<GloriaPatri />
</div>
<!-- 5 Decades -->
{#each [1, 2, 3, 4, 5] as decadeNum}
<!-- Mystery title + Pater Noster (start of decade) -->
<div
class="prayer-section"
id={`secret${decadeNum}_pater`}
bind:this={sectionElements[`secret${decadeNum}_pater`]}
data-section={`secret${decadeNum}_pater`}
>
<h2>{decadeNum}. {labels.decade}: {currentMysteryTitles[decadeNum - 1]}</h2>
<h3>{labels.ourFather}</h3>
<Paternoster />
</div>
<!-- Ave Maria decade (Gesätz) -->
<div
class="prayer-section decade"
id={`secret${decadeNum}`}
bind:this={sectionElements[`secret${decadeNum}`]}
data-section={`secret${decadeNum}`}
>
{#if showImages && allMysteryImages[selectedMystery]?.get(decadeNum)}
{@const img = /** @type {NonNullable<ReturnType<(typeof allMysteryImages)[MysteryType]['get']>>} */ (allMysteryImages[selectedMystery].get(decadeNum))}
<figure class="decade-inline-image">
<img src={img.src} alt={isEnglish ? img.title : img.titleDe} loading="lazy" />
<figcaption>{img.artist ? `${img.artist}, ` : ''}<em>{isEnglish ? img.title : img.titleDe}</em>{img.year ? `, ${img.year}` : ''}</figcaption>
</figure>
{/if}
<h3>{labels.hailMary} <span class="repeat-count">(10×)</span></h3>
<AveMaria
mysteryLatin={currentMysteriesLatin[decadeNum - 1]}
mystery={currentMysteries[decadeNum - 1]}
mysteryEnglish={currentMysteriesEnglish[decadeNum - 1]}
/>
<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"
onclick={() => handleCitationClick(description.reference, description.title, description.verseData)}
aria-label={labels.showBibleVerse}
>
📖
</button>
{/if}
<CounterButton onclick={() => advanceDecade(decadeNum)} />
</div>
</div>
<!-- Transition prayers (Gloria, Fatima) -->
{#if decadeNum < 5}
<div
class="prayer-section"
id={`secret${decadeNum}_transition`}
bind:this={sectionElements[`secret${decadeNum}_transition`]}
data-section={`secret${decadeNum}_transition`}
>
<h3>{labels.gloriaPatri}</h3>
<GloriaPatri />
<h3>{labels.fatimaPrayer} <span class="repeat-count">({labels.optional})</span></h3>
<FatimaGebet />
</div>
{/if}
{/each}
<!-- Final prayers after 5th decade -->
<div
class="prayer-section"
id="final_transition"
bind:this={sectionElements.final_transition}
data-section="final_transition"
>
<h2>{labels.conclusion}</h2>
<h3>{labels.gloriaPatri}</h3>
<GloriaPatri />
<h3>{labels.fatimaPrayer} <span class="repeat-count">({labels.optional})</span></h3>
<FatimaGebet />
</div>
<div
class="prayer-section"
id="final_salve"
bind:this={sectionElements.final_salve}
data-section="final_salve"
>
{#if isEastertide()}
<span class="eastertide-badge">{labels.eastertide}</span>
<h3>Regína Cæli</h3>
<ReginaCaeli />
{:else}
<h3>Salve Regina</h3>
<SalveRegina />
{/if}
</div>
<div
class="prayer-section"
id="final_schlussgebet"
bind:this={sectionElements.final_schlussgebet}
data-section="final_schlussgebet"
>
<h3>{labels.finalPrayer}</h3>
<RosaryFinalPrayer />
</div>
<div
class="prayer-section"
id="final_michael"
bind:this={sectionElements.final_michael}
data-section="final_michael"
>
<h3>{labels.saintMichael}</h3>
<MichaelGebet />
</div>
<div
class="prayer-section"
id="final_paternoster"
bind:this={sectionElements.final_paternoster}
data-section="final_paternoster"
>
<h3>{labels.ourFather}</h3>
<Paternoster />
</div>
<div
class="prayer-section"
id="final_cross"
bind:this={sectionElements.final_cross}
data-section="final_cross"
>
<h3>{labels.signOfCross}</h3>
<Kreuzzeichen />
<div class="footnotes-section">
<p><span class="symbol"></span>{labels.footnoteSign}</p>
</div>
</div>
<a class="scroll-top-button action_button" href="#top" onclick={(e) => { e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); }
} aria-label="Scroll to top">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
</a>
<div class="scroll-padding"></div>
</div>
<!-- Third column: Mystery images (desktop scrollable sticky) -->
<div class="mystery-image-column" bind:this={mysteryImageContainer}>
{#if hasMysteryImages}
<MysteryImageColumn images={allMysteryImages[selectedMystery]} {isEnglish} />
{/if}
</div>
</div>
<!-- Mobile PiP for mystery images -->
{#if hasMysteryImages}
<PipImage {pip} src={lastPipSrc} visible={!!mysteryPipSrc} onload={() => pip.reposition()} bind:el={rosaryPipEl} />
{/if}
</div>
<!-- Bible citation modal -->
{#if showModal}
<BibleModal reference={selectedReference} title={selectedTitle} verseData={selectedVerseData} lang={data.lang} onClose={() => showModal = false} />
{/if}