Compare commits

2 Commits

Author SHA1 Message Date
a4738134fe extract PiP drag/snap/enlarge logic into shared createPip() utility
All checks were successful
CI / update (push) Successful in 1m32s
Both StickyImage and rosary page now use the same pip.svelte.ts factory
for mobile drag-to-corner, snap, and double-tap enlarge behavior.
2026-02-09 08:48:20 +01:00
6433576b28 rosary: add mystery images with scrollable sticky column and mobile PiP
Add a third grid column for sorrowful mystery images (mocking for
mysteries 2-3, crucifixion for mystery 5). Desktop uses a scrollable
sticky sidebar synced to prayer scroll position. Mobile shows a
floating PiP thumbnail. Extract prayer page PiP logic into reusable
StickyImage component.
2026-02-09 08:47:53 +01:00
7 changed files with 593 additions and 247 deletions

View File

@@ -0,0 +1,224 @@
<script>
import { onMount } from 'svelte';
import { createPip } from '$lib/js/pip.svelte';
/**
* @param {'layout' | 'overlay'} mode
* - 'layout': flex row on desktop (image sticky right, content left). Use as page-level wrapper.
* - 'overlay': image floats over the page (fixed position, IntersectionObserver show/hide). Use when nested inside existing layouts.
*/
let { src, alt = '', mode = 'layout', children } = $props();
let pipEl = $state(null);
let contentEl = $state(null);
let inView = $state(false);
const pip = createPip();
function isMobile() {
return !window.matchMedia('(min-width: 1024px)').matches;
}
// PiP drag behavior only on mobile for both modes
function isPipActive() {
return isMobile();
}
function updateVisibility() {
if (!pipEl) return;
if (isPipActive()) {
// Mobile PiP mode
if (inView) {
pip.show(pipEl);
} else {
pip.hide();
}
} else {
// Desktop (both modes): CSS handles everything
pipEl.style.opacity = '';
pipEl.style.transform = '';
}
}
$effect(() => {
inView;
updateVisibility();
});
function onResize() {
if (!pipEl) return;
if (isPipActive() && inView) {
pip.reposition();
} else {
updateVisibility();
}
}
onMount(() => {
updateVisibility();
window.addEventListener('resize', onResize);
let observer;
if (contentEl) {
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
inView = entry.isIntersecting;
}
},
{ threshold: 0 }
);
observer.observe(contentEl);
}
return () => {
window.removeEventListener('resize', onResize);
observer?.disconnect();
};
});
</script>
<div class="sticky-image-layout" class:overlay={mode === 'overlay'}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="image-wrap"
class:enlarged={pip.enlarged}
bind:this={pipEl}
onpointerdown={pip.onpointerdown}
onpointermove={pip.onpointermove}
onpointerup={pip.onpointerup}
>
<img {src} {alt}>
</div>
<div class="content-scroll" bind:this={contentEl}>
{@render children()}
</div>
</div>
<style>
.sticky-image-layout {
display: flex;
flex-direction: column;
align-items: center;
margin: auto;
padding: 0 1em;
}
.sticky-image-layout.overlay {
display: contents;
}
.image-wrap {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
width: auto;
opacity: 0;
touch-action: none;
cursor: grab;
user-select: none;
transition: opacity 0.25s ease;
}
.image-wrap:active {
cursor: grabbing;
}
.image-wrap img {
height: 25vh;
width: auto;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
pointer-events: none;
transition: height 0.25s ease;
}
.image-wrap.enlarged img {
height: 37.5vh;
}
.content-scroll {
width: 100%;
max-width: 700px;
}
.overlay .content-scroll {
max-width: none;
}
@media (min-width: 1024px) {
.sticky-image-layout.overlay {
display: grid;
grid-template-columns: 1fr auto;
gap: 2rem;
width: calc(100% + 25vw + 2rem);
}
.overlay .image-wrap {
position: sticky;
top: 4rem;
left: auto;
transform: none !important;
width: auto;
align-self: start;
order: 1;
opacity: 1;
z-index: auto;
cursor: default;
touch-action: auto;
user-select: auto;
transition: none;
}
.overlay .image-wrap img {
height: auto;
max-height: calc(100vh - 5rem);
width: auto;
max-width: 25vw;
border-radius: 0;
box-shadow: none;
pointer-events: auto;
}
.sticky-image-layout:not(.overlay) {
flex-direction: row;
align-items: flex-start;
gap: 2em;
}
.sticky-image-layout:not(.overlay) .content-scroll {
flex: 0 1 700px;
}
.sticky-image-layout:not(.overlay) .image-wrap {
position: sticky;
top: 4rem;
left: auto;
transform: none !important;
opacity: 1;
flex: 1;
background-color: transparent;
padding: 0;
order: 1;
cursor: default;
touch-action: auto;
user-select: auto;
transition: none;
}
.sticky-image-layout:not(.overlay) .image-wrap img {
max-height: calc(100vh - 4rem);
height: auto;
width: 100%;
object-fit: contain;
border-radius: 0;
box-shadow: none;
}
}
@media (prefers-color-scheme: light) {
.sticky-image-layout:not(.overlay) .image-wrap {
background-color: var(--nord5);
}
}
@media (prefers-color-scheme: light) and (min-width: 1024px) {
.sticky-image-layout:not(.overlay) .image-wrap {
background-color: transparent;
}
}
@media (min-width: 1400px) {
.sticky-image-layout:not(.overlay)::before {
content: '';
flex: 1;
order: -1;
}
}
</style>

164
src/lib/js/pip.svelte.ts Normal file
View File

@@ -0,0 +1,164 @@
/**
* Shared PiP (Picture-in-Picture) drag/snap/enlarge composable.
* Extracts duplicated mobile PiP logic from StickyImage and rosary page.
*/
interface PipOptions {
margin?: number;
tapThreshold?: number;
doubleTapMs?: number;
initialCorner?: Corner;
smallHeight?: number;
largeHeight?: number;
}
type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export function createPip(opts: PipOptions = {}) {
const margin = opts.margin ?? 16;
const tapThreshold = opts.tapThreshold ?? 10;
const doubleTapMs = opts.doubleTapMs ?? 400;
const smallVh = opts.smallHeight ?? 25;
const largeVh = opts.largeHeight ?? 37.5;
let corner: Corner = $state(opts.initialCorner ?? 'bottom-right');
let dragging = $state(false);
let enlarged = $state(false);
let dragOffset = { x: 0, y: 0 };
let dragPos = { x: 0, y: 0 };
let dragMoved = false;
let lastTapTime = 0;
let el: HTMLElement | null = null;
function getCornerPos(c: Corner, target: HTMLElement) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const r = target.getBoundingClientRect();
const positions: Record<Corner, { x: number; y: number }> = {
'top-left': { x: margin, y: margin },
'top-right': { x: vw - r.width - margin, y: margin },
'bottom-left': { x: margin, y: vh - r.height - margin },
'bottom-right': { x: vw - r.width - margin, y: vh - r.height - margin },
};
return positions[c];
}
function nearestCorner(x: number, y: number, target: HTMLElement): Corner {
const vw = window.innerWidth;
const vh = window.innerHeight;
const r = target.getBoundingClientRect();
const cx = x + r.width / 2;
const cy = y + r.height / 2;
const left = cx < vw / 2;
const top = cy < vh / 2;
return `${top ? 'top' : 'bottom'}-${left ? 'left' : 'right'}` as Corner;
}
function snapToCorner(target: HTMLElement, c: Corner) {
const pos = getCornerPos(c, target);
corner = c;
dragPos = pos;
target.style.transition = 'transform 0.25s ease';
target.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
target.addEventListener('transitionend', () => { target.style.transition = ''; }, { once: true });
}
function toggleEnlarged() {
if (!el) return;
const rect = el.getBoundingClientRect();
const vh = window.innerHeight / 100;
const currentH = enlarged ? largeVh * vh : smallVh * vh;
const targetH = enlarged ? smallVh * vh : largeVh * vh;
const ratio = targetH / currentH;
enlarged = !enlarged;
const newW = rect.width * ratio;
const newH = rect.height * ratio;
let newX = rect.left;
let newY = rect.top;
if (corner.includes('right')) newX = rect.right - newW;
if (corner.includes('bottom')) newY = rect.bottom - newH;
dragPos = { x: newX, y: newY };
el.style.transition = 'transform 0.25s ease';
el.style.transform = `translate(${newX}px, ${newY}px)`;
el.addEventListener('transitionend', () => {
el!.style.transition = '';
}, { once: true });
}
function show(target: HTMLElement) {
el = target;
const pos = getCornerPos(corner, target);
dragPos = pos;
target.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
target.style.opacity = '1';
}
function hide() {
if (el) el.style.opacity = '0';
}
function reposition() {
if (!el) return;
const pos = getCornerPos(corner, el);
dragPos = pos;
el.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
}
function onpointerdown(e: PointerEvent) {
if (!el) return;
dragging = true;
dragMoved = false;
const r = el.getBoundingClientRect();
dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
el.setPointerCapture(e.pointerId);
el.style.transition = '';
e.preventDefault();
}
function onpointermove(e: PointerEvent) {
if (!dragging || !el) return;
const x = e.clientX - dragOffset.x;
const y = e.clientY - dragOffset.y;
if (!dragMoved) {
const dx = Math.abs(x - dragPos.x);
const dy = Math.abs(y - dragPos.y);
if (dx > tapThreshold || dy > tapThreshold) dragMoved = true;
}
dragPos = { x, y };
el.style.transform = `translate(${x}px, ${y}px)`;
}
function onpointerup(_e: PointerEvent) {
if (!dragging || !el) return;
dragging = false;
if (!dragMoved) {
const now = Date.now();
if (now - lastTapTime < doubleTapMs) {
lastTapTime = 0;
toggleEnlarged();
return;
}
lastTapTime = now;
}
const r = el.getBoundingClientRect();
snapToCorner(el, nearestCorner(r.left, r.top, el));
}
return {
get corner() { return corner; },
get dragging() { return dragging; },
get enlarged() { return enlarged; },
show,
hide,
reposition,
onpointerdown,
onpointermove,
onpointerup
};
}

View File

@@ -17,6 +17,7 @@
import JosephGebet from "$lib/components/prayers/JosephGebet.svelte";
import Confiteor from "$lib/components/prayers/Confiteor.svelte";
import AblassGebete from "$lib/components/prayers/AblassGebete.svelte";
import StickyImage from "$lib/components/StickyImage.svelte";
let { data } = $props();
@@ -66,150 +67,11 @@
// Toggle href for no-JS fallback (navigates to opposite latin state)
const latinToggleHref = $derived(data.initialLatin ? '?latin=0' : '?');
// PiP drag-to-corner logic
let pipEl = $state(null);
let corner = $state('bottom-right');
let dragging = $state(false);
let enlarged = $state(false);
let dragOffset = { x: 0, y: 0 };
let dragPos = $state({ x: 0, y: 0 });
let dragMoved = false;
let lastTapTime = 0;
const MARGIN = 16;
const TAP_THRESHOLD = 10; // px movement to distinguish tap from drag
const DOUBLE_TAP_MS = 400;
function getCornerPos(c, el) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const r = el.getBoundingClientRect();
const positions = {
'top-left': { x: MARGIN, y: MARGIN },
'top-right': { x: vw - r.width - MARGIN, y: MARGIN },
'bottom-left': { x: MARGIN, y: vh - r.height - MARGIN },
'bottom-right': { x: vw - r.width - MARGIN, y: vh - r.height - MARGIN },
};
return positions[c];
}
function snapToCorner(el, c) {
const pos = getCornerPos(c, el);
corner = c;
dragPos = pos;
el.style.transition = 'transform 0.25s ease';
el.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
el.addEventListener('transitionend', () => { el.style.transition = ''; }, { once: true });
}
function nearestCorner(x, y, el) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const r = el.getBoundingClientRect();
const cx = x + r.width / 2;
const cy = y + r.height / 2;
const left = cx < vw / 2;
const top = cy < vh / 2;
return `${top ? 'top' : 'bottom'}-${left ? 'left' : 'right'}`;
}
function onPointerDown(e) {
if (!pipEl || window.matchMedia('(min-width: 1024px)').matches) return;
dragging = true;
dragMoved = false;
const r = pipEl.getBoundingClientRect();
dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
pipEl.setPointerCapture(e.pointerId);
pipEl.style.transition = '';
e.preventDefault();
}
function onPointerMove(e) {
if (!dragging || !pipEl) return;
const x = e.clientX - dragOffset.x;
const y = e.clientY - dragOffset.y;
if (!dragMoved) {
const dx = Math.abs(x - dragPos.x);
const dy = Math.abs(y - dragPos.y);
if (dx > TAP_THRESHOLD || dy > TAP_THRESHOLD) dragMoved = true;
}
dragPos = { x, y };
pipEl.style.transform = `translate(${x}px, ${y}px)`;
}
function toggleEnlarged() {
if (!pipEl) return;
const rect = pipEl.getBoundingClientRect();
const vh = window.innerHeight / 100;
const currentH = enlarged ? 37.5 * vh : 25 * vh;
const targetH = enlarged ? 25 * vh : 37.5 * vh;
const ratio = targetH / currentH;
enlarged = !enlarged;
// Calculate new size and keep the anchored corner fixed
const newW = rect.width * ratio;
const newH = rect.height * ratio;
let newX = rect.left;
let newY = rect.top;
if (corner.includes('right')) newX = rect.right - newW;
if (corner.includes('bottom')) newY = rect.bottom - newH;
dragPos = { x: newX, y: newY };
pipEl.style.transition = 'transform 0.25s ease';
pipEl.style.transform = `translate(${newX}px, ${newY}px)`;
pipEl.addEventListener('transitionend', () => {
pipEl.style.transition = '';
}, { once: true });
}
function onPointerUp(e) {
if (!dragging || !pipEl) return;
dragging = false;
if (!dragMoved) {
// It was a tap, check for double-tap
const now = Date.now();
if (now - lastTapTime < DOUBLE_TAP_MS) {
lastTapTime = 0;
toggleEnlarged();
return;
}
lastTapTime = now;
}
const r = pipEl.getBoundingClientRect();
snapToCorner(pipEl, nearestCorner(r.left, r.top, pipEl));
}
function onResize() {
if (!pipEl) return;
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
if (isDesktop) {
pipEl.style.opacity = '';
return;
}
const pos = getCornerPos(corner, pipEl);
dragPos = pos;
pipEl.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
pipEl.style.opacity = '1';
}
onMount(() => {
// Clean up URL params after hydration (state is now in component state)
if (window.location.search) {
history.replaceState({}, '', window.location.pathname);
}
// Initial position for PiP
if (pipEl && !window.matchMedia('(min-width: 1024px)').matches) {
const pos = getCornerPos(corner, pipEl);
dragPos = pos;
pipEl.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
pipEl.style.opacity = '1';
}
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
});
</script>
@@ -273,92 +135,6 @@ h1 {
background-color: var(--nord5);
}
}
.crucifix-layout {
display: flex;
flex-direction: column;
align-items: center;
margin: auto;
padding: 0 1em;
}
.crucifix-layout .crucifix-wrap {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
width: auto;
opacity: 0;
touch-action: none;
cursor: grab;
user-select: none;
}
.crucifix-layout .crucifix-wrap:active {
cursor: grabbing;
}
.crucifix-layout .crucifix-wrap img {
height: 25vh;
width: auto;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
pointer-events: none;
transition: height 0.25s ease;
}
.crucifix-layout .crucifix-wrap.enlarged img {
height: 37.5vh;
}
.crucifix-layout .prayer-scroll {
width: 100%;
max-width: 700px;
}
@media (min-width: 1024px) {
.crucifix-layout {
flex-direction: row;
align-items: flex-start;
gap: 2em;
}
.crucifix-layout .prayer-scroll {
flex: 0 1 700px;
}
.crucifix-layout .crucifix-wrap {
position: sticky;
top: 4rem;
left: auto;
transform: none !important;
opacity: 1;
flex: 1;
background-color: transparent;
padding: 0;
order: 1;
cursor: default;
touch-action: auto;
user-select: auto;
}
.crucifix-layout .crucifix-wrap img {
max-height: calc(100vh - 4rem);
height: auto;
width: 100%;
object-fit: contain;
border-radius: 0;
box-shadow: none;
}
}
@media (prefers-color-scheme: light) {
.crucifix-layout .crucifix-wrap {
background-color: var(--nord5);
}
}
@media (prefers-color-scheme: light) and (min-width: 1024px) {
.crucifix-layout .crucifix-wrap {
background-color: transparent;
}
}
@media (min-width: 1400px) {
.crucifix-layout::before {
content: '';
flex: 1;
order: -1;
}
}
</style>
{#if prayerId === 'ablassgebete'}
@@ -372,26 +148,13 @@ h1 {
/>
</div>
<div class="crucifix-layout">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="crucifix-wrap"
class:enlarged
bind:this={pipEl}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
>
<img src="/glaube/crucifix.webp" alt="Crucifix">
</div>
<div class="prayer-scroll">
<div class="gebet-wrapper">
<div class="gebet" class:bilingue={isBilingue}>
<AblassGebete verbose={true} />
</div>
<StickyImage src="/glaube/crucifix.webp" alt="Crucifix">
<div class="gebet-wrapper">
<div class="gebet" class:bilingue={isBilingue}>
<AblassGebete verbose={true} />
</div>
</div>
</div>
</StickyImage>
{:else}
<div class="container">
<h1>{prayerName}</h1>

View File

@@ -1,6 +1,7 @@
<script>
import { onMount } from "svelte";
import { createLanguageContext } from "$lib/contexts/languageContext.js";
import { createPip } from "$lib/js/pip.svelte";
import "$lib/css/christ.css";
import "$lib/css/action_button.css";
import Kreuzzeichen from "$lib/components/prayers/Kreuzzeichen.svelte";
@@ -18,7 +19,6 @@ import Toggle from "$lib/components/Toggle.svelte";
import LanguageToggle from "$lib/components/LanguageToggle.svelte";
import StreakCounter from "$lib/components/StreakCounter.svelte";
import MysteryIcon from "$lib/components/MysteryIcon.svelte";
let { data } = $props();
// Mystery variations for each type of rosary
@@ -314,6 +314,94 @@ let activeSection = $state("cross");
let sectionElements = {};
let svgContainer;
// Whether the rosary has mystery images (stable, doesn't change during scroll)
const hasMysteryImages = $derived(selectedMystery === 'schmerzhaften');
// Mystery image scroll target based on active section
function getMysteryScrollTarget(section) {
switch (section) {
case 'secret1_transition':
case 'secret2':
case 'secret2_transition':
case 'secret3':
return 'mocking';
case 'secret3_transition':
case 'secret4':
return 'between';
case 'secret4_transition':
case 'secret5':
return 'crucifixion';
case 'final_transition':
case 'final_salve':
case 'final_schlussgebet':
case 'final_michael':
case 'final_paternoster':
case 'final_cross':
return 'after';
default:
return 'before';
}
}
// Mobile PiP: which image to show (null = hide)
function getMysteryImage(mystery, section) {
if (mystery !== 'schmerzhaften') return null;
const target = getMysteryScrollTarget(section);
if (target === 'mocking') return '/glaube/sorrowful/2-3.mocking.webp';
if (target === 'crucifixion') return '/glaube/sorrowful/5.crucification.webp';
return null;
}
const mysteryPipSrc = $derived(getMysteryImage(selectedMystery, activeSection));
// Mobile PiP drag/enlarge
const pip = createPip();
let rosaryPipEl = $state(null);
let lastPipSrc = $state(null);
function isMobilePip() {
return !window.matchMedia('(min-width: 900px)').matches;
}
$effect(() => {
if (mysteryPipSrc) lastPipSrc = mysteryPipSrc;
if (!rosaryPipEl || !isMobilePip()) return;
if (mysteryPipSrc) {
pip.show(rosaryPipEl);
} else {
pip.hide();
}
});
let mysteryImageContainer;
let mysteryScrollRaf = null;
function scrollMysteryImage(targetY, duration = 1200) {
if (!mysteryImageContainer) return;
if (mysteryScrollRaf) cancelAnimationFrame(mysteryScrollRaf);
const startY = mysteryImageContainer.scrollTop;
const distance = targetY - startY;
if (Math.abs(distance) < 1) return;
const startTime = performance.now();
const ease = (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
const step = (now) => {
const progress = Math.min((now - startTime) / duration, 1);
mysteryImageContainer.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
$effect(() => {
if (!mysteryImageContainer || !hasMysteryImages) return;
const targetName = getMysteryScrollTarget(activeSection);
const targetEl = mysteryImageContainer.querySelector(`[data-target="${targetName}"]`);
if (targetEl) {
scrollMysteryImage(targetEl.offsetTop);
}
});
// Counter for tracking Ave Maria progress in each decade (0-10 for each)
let decadeCounters = $state({
secret1: 0,
@@ -423,6 +511,14 @@ onMount(() => {
// Now allow saving to localStorage
hasLoadedFromStorage = true;
// PiP resize handler
const onPipResize = () => {
if (rosaryPipEl && isMobilePip() && mysteryPipSrc) {
pip.reposition();
}
};
window.addEventListener('resize', onPipResize);
let scrollLock = null; // Track which side initiated the scroll: 'prayer', 'svg', or 'click'
let scrollLockTimeout = null;
@@ -767,6 +863,7 @@ onMount(() => {
clearTimeout(scrollLockTimeout);
clearTimeout(svgScrollTimeout);
window.removeEventListener('scroll', handleWindowScroll);
window.removeEventListener('resize', onPipResize);
if (svgContainer) {
svgContainer.removeEventListener('scroll', handleSvgScroll);
}
@@ -782,6 +879,9 @@ onMount(() => {
margin: 0 auto;
padding: 2rem 1rem;
}
.page-container:has(.has-mystery-image) {
max-width: calc(1400px + 25vw + 3rem);
}
.rosary-layout {
position: relative;
@@ -1281,6 +1381,75 @@ h1 {
.scroll-padding {
height: 50vh;
}
/* Mystery images: third grid column (desktop), PiP (mobile) */
.mystery-image-column {
display: none;
}
@media (min-width: 900px) {
.rosary-layout.has-mystery-image {
grid-template-columns: clamp(250px, 30vw, 400px) 1fr auto;
}
.mystery-image-column {
display: block;
position: sticky;
top: 6rem;
align-self: start;
max-height: calc(100vh - 7rem);
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
}
.mystery-image-column::-webkit-scrollbar {
display: none;
}
.mystery-image-pad {
height: calc(100vh - 5rem);
}
.mystery-image-column img {
max-height: calc(100vh - 5rem);
width: auto;
max-width: 25vw;
object-fit: contain;
border-radius: 6px;
margin-right: 2rem;
display: block;
}
}
/* Mobile PiP for mystery images */
.mystery-pip {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
opacity: 0;
touch-action: none;
cursor: grab;
user-select: none;
transition: opacity 0.25s ease;
}
.mystery-pip:active {
cursor: grabbing;
}
.mystery-pip img {
height: 25vh;
width: auto;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
pointer-events: none;
transition: height 0.25s ease;
}
.mystery-pip.enlarged img {
height: 37.5vh;
}
@media (min-width: 900px) {
.mystery-pip {
display: none;
}
}
</style>
<svelte:head>
<title>{labels.pageTitle}</title>
@@ -1370,7 +1539,7 @@ h1 {
</div>
</div>
<div class="rosary-layout">
<div class="rosary-layout" class:has-mystery-image={hasMysteryImages}>
<!-- Sidebar: Rosary Visualization -->
<div class="rosary-sidebar">
<div class="rosary-visualization" bind:this={svgContainer}>
@@ -1562,7 +1731,6 @@ h1 {
>
<h2>{decadeNum}. {labels.decade}: {currentMysteryTitles[decadeNum - 1]}</h2>
<!-- Mystery description with Bible reference button -->
<h3>{labels.hailMary} <span class="repeat-count">(10×)</span></h3>
<AveMaria
mysteryLatin={currentMysteriesLatin[decadeNum - 1]}
@@ -1570,7 +1738,6 @@ h1 {
mysteryEnglish={currentMysteriesEnglish[decadeNum - 1]}
/>
<!-- Bible reference and counter buttons -->
<div class="decade-buttons">
{#if currentMysteryDescriptions[decadeNum - 1]}
{@const description = currentMysteryDescriptions[decadeNum - 1]}
@@ -1673,8 +1840,36 @@ h1 {
</button>
<div class="scroll-padding"></div>
</div>
<!-- Third column: Mystery images (desktop scrollable sticky) -->
<div class="mystery-image-column" bind:this={mysteryImageContainer}>
{#if hasMysteryImages}
<div class="mystery-image-pad" data-target="before"></div>
<img src="/glaube/sorrowful/2-3.mocking.webp" alt={isEnglish ? 'Mocking of Christ' : 'Verspottung Christi'} data-target="mocking">
<div class="mystery-image-pad" data-target="between"></div>
<img src="/glaube/sorrowful/5.crucification.webp" alt={isEnglish ? 'Crucifixion of Christ' : 'Kreuzigung Christi'} data-target="crucifixion">
<div class="mystery-image-pad" data-target="after"></div>
{/if}
</div>
</div>
<!-- Mobile PiP for mystery images -->
{#if hasMysteryImages}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mystery-pip"
class:visible={!!mysteryPipSrc}
class:enlarged={pip.enlarged}
bind:this={rosaryPipEl}
onpointerdown={pip.onpointerdown}
onpointermove={pip.onpointermove}
onpointerup={pip.onpointerup}
>
{#if lastPipSrc}
<img src={lastPipSrc} alt="">
{/if}
</div>
{/if}
</div>
<!-- Bible citation modal -->

BIN
static/glaube/mocking.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB