faith: use identical hero card for details and calendar overview

This commit is contained in:
2026-04-21 09:57:08 +02:00
parent fd4753905e
commit 845e2eefa3
3 changed files with 300 additions and 342 deletions
@@ -0,0 +1,292 @@
<script lang="ts">
import type { CalendarDay } from '$lib/calendarTypes';
import {
formatLongDate,
rankEmphasis,
humanizePsalterWeek,
humanizeSundayCycle,
t,
t1962,
type CalendarLang
} from './calendarI18n';
import { litBg, litInk } from './calendarColors';
let {
day,
lang,
todayIso,
href
}: {
day: CalendarDay;
lang: CalendarLang;
todayIso: string;
href?: string;
} = $props();
const color = $derived(day.colorKeys[0] ?? 'GREEN');
const isToday = $derived(day.iso === todayIso);
const rankEmph = $derived(rankEmphasis(day.rank));
const rankNum = $derived(
rankEmph === 3 ? 'I' : rankEmph === 2 ? 'II' : rankEmph === 1 ? 'III' : 'IV'
);
function firstOr(arr: string[], fallback = ''): string {
return arr && arr.length ? arr[0] : fallback;
}
</script>
{#snippet card()}
<section
class="hero-banner"
style="background: {litBg(color)}; color: {litInk(color)}"
>
<span class="hc-rank" aria-hidden="true">{rankNum}</span>
<div class="hc-date">
{#if isToday}{t('today', lang)} · {/if}{formatLongDate(day.iso, lang)}
</div>
<h2 class="hc-name">{day.name}</h2>
<div class="hc-tags">
{#if day.rankName}
<span class="hc-tag">{day.rankName}</span>
{/if}
{#if day.colorNames.length}
<span class="hc-tag">{firstOr(day.colorNames)}</span>
{/if}
{#if day.seasonNames.length}
<span class="hc-tag">{firstOr(day.seasonNames)}</span>
{/if}
{#if day.psalterWeek}
<span class="hc-tag">{t('psalterWeek', lang)}: {humanizePsalterWeek(day.psalterWeek, lang)}</span>
{/if}
{#if day.sundayCycle}
<span class="hc-tag">{t('cycle', lang)}: {humanizeSundayCycle(day.sundayCycle)}</span>
{/if}
</div>
{#if day.rite1962 && day.rite1962.commemorations.length}
<div class="hc-commems">
<div class="hc-commems-head">
<svg class="hc-commems-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
<span class="hc-commems-title">{t1962('commemorations', lang)}</span>
</div>
<div class="hc-commems-list">
{#each day.rite1962.commemorations as c (c.id)}
<span class="hc-commem">{c.name}</span>
{/each}
</div>
</div>
{/if}
{#if day.rite1962?.stationChurches?.length}
<div class="hc-stations">
<svg class="hc-stations-label" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 9h4"/><path d="M12 7v5"/><path d="M14 22v-4a2 2 0 0 0-4 0v4"/><path d="M18 22V5l-6-3-6 3v17"/><path d="M4 10.5V22"/><path d="M20 10.5V22"/><path d="M22 22H2"/></svg>
<span class="hc-stations-text">
<span class="hc-stations-title">{t1962('stationChurch', lang)}:</span>
{#each day.rite1962.stationChurches as s, i (s.key + (s.mass ?? ''))}
{#if i > 0}<span class="hc-stations-sep"> · </span>{/if}<span class="hc-station-name">{s.name}</span>{#if s.mass}<span class="hc-station-mass"> ({s.mass.replace(/_/g, ' ')})</span>{/if}
{/each}
</span>
</div>
{/if}
{#if href}
<svg class="hc-chevron" width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
{/if}
</section>
{/snippet}
{#if href}
<a class="hero-link" {href} aria-label={day.name}>
{@render card()}
</a>
{:else}
{@render card()}
{/if}
<style>
.hero-link {
display: block;
text-decoration: none;
color: inherit;
margin-bottom: 1.5rem;
}
.hero-banner {
position: relative;
border-radius: var(--radius-card);
padding: 2rem 2.2rem 3rem;
box-shadow: var(--shadow-md);
overflow: hidden;
transition: transform var(--transition-normal), box-shadow var(--transition-normal),
background 650ms cubic-bezier(0.33, 1, 0.68, 1),
color 650ms cubic-bezier(0.33, 1, 0.68, 1);
}
.hero-banner::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 10% 110%, rgba(255, 255, 255, 0.14), transparent 45%),
radial-gradient(circle at 95% -10%, rgba(0, 0, 0, 0.12), transparent 45%);
pointer-events: none;
}
.hero-banner > * {
position: relative;
}
.hero-link:hover .hero-banner {
transform: translateY(-2px);
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.22);
}
.hero-link:active .hero-banner {
transform: translateY(0);
}
.hero-link:focus-visible .hero-banner {
outline: 3px solid var(--color-primary);
outline-offset: 3px;
}
.hc-rank {
position: absolute;
top: 1.2rem;
right: 1.4rem;
min-width: 2.9rem;
height: 2.9rem;
padding: 0 0.85rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: Georgia, 'Times New Roman', serif;
font-style: italic;
font-weight: 600;
font-size: 1.4rem;
letter-spacing: 0.04em;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.28);
opacity: 0.88;
}
.hc-date {
font-size: 0.74rem;
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
opacity: 0.88;
margin-bottom: 0.6rem;
}
.hc-name {
font-size: clamp(1.4rem, 3vw, 2rem);
line-height: 1.12;
margin: 0 0 0.3rem;
letter-spacing: -0.01em;
font-weight: 700;
}
.hc-tags {
margin-top: 0.9rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.hc-tag {
padding: 0.3rem 0.75rem;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.22);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.hc-commems {
margin-top: 1.4rem;
padding-top: 1.1rem;
border-top: 1px solid rgba(255, 255, 255, 0.22);
}
.hc-commems-head {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.55rem;
}
.hc-commems-icon {
flex-shrink: 0;
opacity: 0.75;
}
.hc-commems-title {
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 0.7rem;
opacity: 0.85;
}
.hc-commems-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.6rem;
}
.hc-commem {
padding: 0.3rem 0.7rem;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.22);
font-size: 0.82rem;
}
.hc-stations {
margin-top: 0.9rem;
display: flex;
align-items: flex-start;
gap: 0.55rem;
font-size: 0.85rem;
line-height: 1.45;
}
.hc-stations-label {
flex-shrink: 0;
margin-top: 0.1rem;
opacity: 0.78;
}
.hc-stations-title {
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
font-size: 0.72rem;
opacity: 0.85;
margin-right: 0.35rem;
}
.hc-station-name {
font-style: italic;
}
.hc-station-mass {
opacity: 0.75;
font-size: 0.78rem;
}
.hc-stations-sep {
opacity: 0.6;
}
.hc-chevron {
position: absolute;
bottom: 1.2rem;
right: 1.5rem;
width: 30px;
height: 30px;
opacity: 0;
transform: translateX(-6px);
transition: transform var(--transition-normal), opacity var(--transition-normal);
}
.hero-link:hover .hc-chevron,
.hero-link:focus-visible .hc-chevron {
opacity: 0.88;
transform: translateX(0);
}
@media (max-width: 640px) {
.hero-banner {
padding: 1.5rem 1.4rem 2.2rem;
}
.hc-rank {
top: 1rem;
right: 1rem;
min-width: 1.9rem;
height: 1.9rem;
padding: 0 0.5rem;
font-size: 0.9rem;
}
.hc-chevron {
bottom: 0.9rem;
right: 1.1rem;
width: 22px;
height: 22px;
}
}
</style>
@@ -6,13 +6,8 @@
import { import {
getMonthName, getMonthName,
getWeekdayShort, getWeekdayShort,
formatLongDate,
hexFor,
rankEmphasis, rankEmphasis,
humanizePsalterWeek,
humanizeSundayCycle,
t, t,
t1962,
dioceseLabel, dioceseLabel,
DIOCESES_1962, DIOCESES_1962,
DIOCESES_1969, DIOCESES_1969,
@@ -22,6 +17,7 @@
} from '../../../../calendarI18n'; } from '../../../../calendarI18n';
import { litBg, litInk, LIT_COLOR_VAR } from '../../../../calendarColors'; import { litBg, litInk, LIT_COLOR_VAR } from '../../../../calendarColors';
import RingView from './RingView.svelte'; import RingView from './RingView.svelte';
import HeroCard from '../../../../HeroCard.svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -134,10 +130,6 @@
const pageTitle = $derived(t('calendar', lang)); const pageTitle = $derived(t('calendar', lang));
function firstOr(arr: string[], fallback = ''): string {
return arr && arr.length ? arr[0] : fallback;
}
// When switching rites we drop ?diocese because the ID spaces differ (1962 has // When switching rites we drop ?diocese because the ID spaces differ (1962 has
// diocesan calendars, 1969 only "general" or "switzerland"). The server // diocesan calendars, 1969 only "general" or "switzerland"). The server
// re-applies each rite's default if none is given. // re-applies each rite's default if none is given.
@@ -214,56 +206,7 @@
</aside> </aside>
{/if} {/if}
{#if hero} {#if hero}
{@const heroColor = hero.colorKeys[0] ?? 'GREEN'} <HeroCard day={hero} {lang} {todayIso} href={detailHref(hero.iso)} />
{@const heroIsToday = hero.iso === todayIso}
<a class="today-card-link" href={detailHref(hero.iso)} aria-label={hero.name}>
<section
class="today-banner"
style="background: {litBg(heroColor)}; color: {litInk(heroColor)}"
>
<span class="tc-cross" aria-hidden="true"></span>
<div class="tc-today">
{#if heroIsToday}{t('today', lang)} · {/if}{formatLongDate(hero.iso, lang)}
</div>
<h2 class="tc-name">{hero.name}</h2>
<div class="tc-tags">
{#if hero.rankName}
<span class="tc-tag">{hero.rankName}</span>
{/if}
{#if hero.colorNames.length}
<span class="tc-tag">{firstOr(hero.colorNames)}</span>
{/if}
{#if hero.seasonNames.length}
<span class="tc-tag">{firstOr(hero.seasonNames)}</span>
{/if}
{#if hero.psalterWeek}
<span class="tc-tag">{t('psalterWeek', lang)}: {humanizePsalterWeek(hero.psalterWeek, lang)}</span>
{/if}
{#if hero.sundayCycle}
<span class="tc-tag">{t('cycle', lang)}: {humanizeSundayCycle(hero.sundayCycle)}</span>
{/if}
</div>
{#if hero.rite1962 && hero.rite1962.commemorations.length}
<div class="tc-commems">
{#each hero.rite1962.commemorations as c (c.id)}
<span class="tc-commem">{c.name}</span>
{/each}
</div>
{/if}
{#if hero.rite1962?.stationChurches?.length}
<div class="tc-stations">
<span class="tc-stations-label" aria-hidden="true"></span>
<span class="tc-stations-text">
<span class="tc-stations-title">{t1962('stationChurch', lang)}:</span>
{#each hero.rite1962.stationChurches as s, i (s.key + (s.mass ?? ''))}
{#if i > 0}<span class="tc-stations-sep"> · </span>{/if}<span class="tc-station-name">{s.name}</span>{#if s.mass}<span class="tc-station-mass"> ({s.mass.replace(/_/g, ' ')})</span>{/if}
{/each}
</span>
</div>
{/if}
<span class="tc-arrow" aria-hidden="true"></span>
</section>
</a>
{/if} {/if}
<!-- Color legend + view switcher --> <!-- Color legend + view switcher -->
@@ -577,155 +520,6 @@
margin: 0; margin: 0;
} }
/* ====== Today banner (design handoff) ====== */
.today-card-link {
display: block;
text-decoration: none;
color: inherit;
margin-bottom: 1.5rem;
}
.today-banner {
position: relative;
border-radius: var(--radius-card);
padding: 2rem 2.2rem;
box-shadow: var(--shadow-md);
overflow: hidden;
transition: transform var(--transition-normal), box-shadow var(--transition-normal),
background 650ms cubic-bezier(0.33, 1, 0.68, 1),
color 650ms cubic-bezier(0.33, 1, 0.68, 1);
}
.today-banner::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 10% 110%, rgba(255, 255, 255, 0.14), transparent 45%),
radial-gradient(circle at 95% -10%, rgba(0, 0, 0, 0.12), transparent 45%);
pointer-events: none;
}
.today-banner > * {
position: relative;
}
.today-card-link:hover .today-banner {
transform: translateY(-2px);
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.22);
}
.today-card-link:active .today-banner {
transform: translateY(0);
}
.today-card-link:focus-visible .today-banner {
outline: 3px solid var(--color-primary);
outline-offset: 3px;
}
.tc-cross {
position: absolute;
top: 1.2rem;
right: 1.6rem;
font-size: 3.4rem;
line-height: 1;
opacity: 0.28;
font-family: serif;
}
.tc-today {
font-size: 0.74rem;
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
opacity: 0.88;
margin-bottom: 0.6rem;
}
.tc-name {
font-size: clamp(1.4rem, 3vw, 2rem);
line-height: 1.12;
margin: 0 0 0.3rem;
letter-spacing: -0.01em;
font-weight: 700;
}
.tc-tags {
margin-top: 0.9rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tc-tag {
padding: 0.3rem 0.75rem;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.22);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.tc-commems {
margin-top: 1.4rem;
padding-top: 1.1rem;
border-top: 1px solid rgba(255, 255, 255, 0.22);
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.6rem;
}
.tc-commem {
padding: 0.3rem 0.7rem;
border-radius: var(--radius-pill);
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.22);
font-size: 0.82rem;
}
.tc-stations {
margin-top: 0.9rem;
display: flex;
align-items: baseline;
gap: 0.55rem;
font-size: 0.85rem;
line-height: 1.45;
}
.tc-stations-label {
font-size: 0.95rem;
opacity: 0.7;
flex-shrink: 0;
}
.tc-stations-title {
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
font-size: 0.72rem;
opacity: 0.85;
margin-right: 0.35rem;
}
.tc-station-name {
font-style: italic;
}
.tc-station-mass {
opacity: 0.75;
font-size: 0.78rem;
}
.tc-stations-sep {
opacity: 0.6;
}
.tc-arrow {
position: absolute;
bottom: 1.1rem;
right: 1.4rem;
font-size: 1.6rem;
font-weight: 300;
opacity: 0.55;
transition: transform var(--transition-normal), opacity var(--transition-normal);
}
.today-card-link:hover .tc-arrow {
opacity: 1;
transform: translateX(4px);
}
@media (max-width: 640px) {
.today-banner {
padding: 1.5rem 1.4rem;
}
.tc-cross {
font-size: 2.4rem;
top: 1rem;
right: 1rem;
}
}
/* ====== View switcher + color legend ====== */ /* ====== View switcher + color legend ====== */
.overview-controls { .overview-controls {
display: flex; display: flex;
@@ -5,20 +5,12 @@
formatLongDate, formatLongDate,
getMonthName, getMonthName,
hexFor, hexFor,
humanizePsalterWeek,
humanizeSundayCycle,
properLabel, properLabel,
t, t,
t1962, t1962,
type CalendarLang type CalendarLang
} from '../../../../../calendarI18n'; } from '../../../../../calendarI18n';
import HeroCard from '../../../../../HeroCard.svelte';
function kindLabel(kind: 'tempora' | 'sancti', l: CalendarLang): string {
if (kind === 'tempora')
return l === 'de' ? 'Temporale' : l === 'la' ? 'Temporale' : 'Temporal';
return l === 'de' ? 'Sanktorale' : l === 'la' ? 'Sanctorale' : 'Sanctoral';
}
import { litBg, litInk } from '../../../../../calendarColors';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -57,7 +49,6 @@
const prevHref = $derived(shiftDay(-1)); const prevHref = $derived(shiftDay(-1));
const nextHref = $derived(shiftDay(1)); const nextHref = $derived(shiftDay(1));
const isToday = $derived(iso === todayIso);
const monthTitle = $derived(`${getMonthName(month, lang)} ${year}`); const monthTitle = $derived(`${getMonthName(month, lang)} ${year}`);
</script> </script>
@@ -82,47 +73,12 @@
</div> </div>
</nav> </nav>
<header <HeroCard {day} {lang} {todayIso} />
class="detail-hero"
style="background: {litBg(day.colorKeys[0])}; color: {litInk(day.colorKeys[0])}; --accent: {dayHex}"
>
<div class="hero-date">
{formatLongDate(iso, lang)}
{#if isToday}
<span class="today-pip">{t('today', lang)}</span>
{/if}
</div>
<h1 class="hero-name">{day.name}</h1>
<div class="hero-tags">
{#if day.rankName}
<span class="tag tag-rank">{day.rankName}</span>
{/if}
{#if day.seasonNames.length}
<span class="tag tag-season">{day.seasonNames[0]}</span>
{/if}
{#if day.colorNames.length}
<span class="tag tag-color">
<span class="color-swatch" style="background: {dayHex}"></span>
{day.colorNames[0]}
</span>
{/if}
{#if day.psalterWeek}
<span class="tag">{t('psalterWeek', lang)}: {humanizePsalterWeek(day.psalterWeek, lang)}</span>
{/if}
{#if day.sundayCycle}
<span class="tag">{t('cycle', lang)}: {humanizeSundayCycle(day.sundayCycle)}</span>
{/if}
</div>
</header>
{#if day.rite1962} {#if day.rite1962}
{@const d = day.rite1962} {@const d = day.rite1962}
<section class="detail" style="--accent: {dayHex}"> <section class="detail" style="--accent: {dayHex}">
<dl class="detail-extras"> <dl class="detail-extras">
<div>
<dt>{t1962('source', lang)}</dt>
<dd>{kindLabel(d.kind, lang)}</dd>
</div>
{#if d.vigilOf} {#if d.vigilOf}
<div> <div>
<dt>{t1962('vigilOf', lang)}</dt> <dt>{t1962('vigilOf', lang)}</dt>
@@ -154,21 +110,6 @@
</ul> </ul>
</div> </div>
{/if} {/if}
{#if d.stationChurches?.length}
<div class="stations">
<h4>{t1962('stationChurch', lang)}</h4>
<ul>
{#each d.stationChurches as s (s.key + (s.mass ?? ''))}
<li>
<span class="station-name">{s.name}</span>
{#if s.mass}
<span class="station-mass">{s.mass.replace(/_/g, ' ')}</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
{#if d.propers.length} {#if d.propers.length}
<section class="propers"> <section class="propers">
<h4>{t1962('propers', lang)}</h4> <h4>{t1962('propers', lang)}</h4>
@@ -278,62 +219,6 @@
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.detail-hero {
border-radius: var(--radius-card);
padding: 1.5rem 1.75rem;
box-shadow: var(--shadow-md);
}
.hero-date {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: var(--text-sm);
opacity: 0.85;
margin-bottom: 0.25rem;
}
.today-pip {
font-size: 0.62rem;
letter-spacing: 0.14em;
text-transform: uppercase;
font-weight: 700;
padding: 3px 8px;
border-radius: 100px;
background: rgba(0, 0, 0, 0.12);
}
.hero-name {
margin: 0 0 0.75rem;
font-size: 2rem;
line-height: 1.2;
}
.hero-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.7rem;
background: rgba(0, 0, 0, 0.14);
color: inherit;
border-radius: var(--radius-pill);
font-size: var(--text-sm);
font-weight: 500;
}
.tag-rank {
background: rgba(0, 0, 0, 0.22);
font-weight: 600;
}
.color-swatch {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.4);
display: inline-block;
}
.detail { .detail {
background: var(--color-surface); background: var(--color-surface);
border-radius: var(--radius-card); border-radius: var(--radius-card);
@@ -366,7 +251,6 @@
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.commems h4, .commems h4,
.stations h4,
.propers h4 { .propers h4 {
margin: 0.5rem 0 0.4rem; margin: 0.5rem 0 0.4rem;
font-size: 0.72rem; font-size: 0.72rem;
@@ -375,12 +259,10 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-weight: 600; font-weight: 600;
} }
.commems, .commems {
.stations {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.commems ul, .commems ul {
.stations ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
@@ -388,8 +270,7 @@
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0.35rem;
} }
.commems li, .commems li {
.stations li {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -398,19 +279,10 @@
border-radius: var(--radius-sm, 6px); border-radius: var(--radius-sm, 6px);
font-size: 0.85rem; font-size: 0.85rem;
} }
.commem-name, .commem-name {
.station-name {
flex: 1 1 auto; flex: 1 1 auto;
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.station-name {
font-style: italic;
}
.station-mass {
color: var(--color-text-tertiary);
font-size: 0.78rem;
text-transform: capitalize;
}
.propers { .propers {
margin-top: 1rem; margin-top: 1rem;