diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 795e2a8..1634c08 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -105,6 +105,8 @@ interface MatinsSundayAntiphons { } const sundayAntiphons = matinsSundayAntiphonsData as unknown as MatinsSundayAntiphons; +const NOCTURN_NUMERAL = ['I', 'II', 'III'] as const; + type PsalmPart = Extract; function plainPsalm(number: number): PsalmPart { @@ -302,15 +304,19 @@ export function resolveOrdo(date: string): ResolvedOrdo { const parts: ResolvedPart[] = [ { kind: 'versicle', text: resolveCommon(getOpeningVersicleId(day.season, day.winner)) }, plainPsalm(3), + { kind: 'section-heading', label: 'Invitatory' }, ...invitatoryParts(day), { kind: 'hymn', text: resolveCommon('matins-hymn-ferial') }, ]; if (threeNocturns && day.weekday === 'sunday') { + parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` }); parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn1, day)); parts.push(...(nocturn1Readings ?? [])); + parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[1]}` }); parts.push(...sundayPsalmNocturn(sundayAntiphons.nocturn2, day)); parts.push(...(nocturn2Readings ?? [])); + parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[2]}` }); parts.push(...sundayCanticleNocturn(sundayAntiphons.nocturn3, day)); parts.push(...(nocturn3Readings ?? [])); parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') }); @@ -324,18 +330,24 @@ export function resolveOrdo(date: string): ResolvedOrdo { // three-tier picture). const override = winner.kind === 'sanctoral' ? getMatinsPsalmodyOverride(winner.id) : undefined; if (override) { + parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` }); parts.push(...sundayPsalmNocturn(override.nocturn1, day)); parts.push(...(nocturn1Readings ?? [])); + parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[1]}` }); parts.push(...sundayPsalmNocturn(override.nocturn2, day)); parts.push(...(nocturn2Readings ?? [])); + parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[2]}` }); parts.push(...sundayCanticleNocturn(override.nocturn3, day)); parts.push(...(nocturn3Readings ?? [])); } else { const [n1, n2, n3] = ferialPsalmodyThreeNocturns(day); + parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[0]}` }); parts.push(...n1); parts.push(...(nocturn1Readings ?? [])); + parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[1]}` }); parts.push(...n2); parts.push(...(nocturn2Readings ?? [])); + parts.push({ kind: 'section-heading', label: `Nocturn ${NOCTURN_NUMERAL[2]}` }); parts.push(...n3); parts.push(...(nocturn3Readings ?? [])); } diff --git a/src/hours/types.ts b/src/hours/types.ts index a669d3d..33bd8bc 100644 --- a/src/hours/types.ts +++ b/src/hours/types.ts @@ -210,6 +210,11 @@ export type ResolvedPart = // have a single unnumbered nocturn and don't use this wrapper at all — // see hours/matins.ts). `nocturn` is 1-3. | { kind: 'nocturn-psalmody'; nocturn: number; psalms: ResolvedPart[] } + // A standalone structural heading with no content of its own — marks the + // start of a larger named section that doesn't correspond to one specific + // part kind (Matins' "Invitatory", "Nocturn I/II/III"). Distinct from the + // per-part `

` every other kind already uses. + | { kind: 'section-heading'; label: string } // Matins only. Sung after the last lesson on a 3-nocturn day (Sunday or // a Duplex+ sanctoral winner) — see hours/matins.ts. Never present on a // plain ferial (1-nocturn) day. diff --git a/src/ui/hour-view.ts b/src/ui/hour-view.ts index b578ca5..308fb6a 100644 --- a/src/ui/hour-view.ts +++ b/src/ui/hour-view.ts @@ -122,6 +122,8 @@ function renderPart(part: ResolvedPart, languages: readonly string[]): string { ${part.psalms.map((p) => renderPart(p, languages)).join('')} `; + case 'section-heading': + return `

${escapeHtml(part.label)}

`; case 'te-deum': return `
diff --git a/src/ui/styles.css b/src/ui/styles.css index 7814d84..ec6f7f5 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -275,6 +275,15 @@ button:focus-visible { margin: 0 0 var(--space-1); } +.section-heading { + font-size: 1.2rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-brass); + font-family: var(--font-ui); + margin: var(--space-4) 0 var(--space-2); +} + .ordo-part-citation { font-family: var(--font-ui); font-size: 0.85em; diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index 91595f7..13230a7 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -24,8 +24,9 @@ describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => { it('opens with the Deus in adjutorium versicle, then Psalm 3, then the Invitatory (Psalm 94)', () => { expect(ordo.parts[0]?.kind).toBe('versicle'); expect(ordo.parts[1]).toMatchObject({ kind: 'psalm', psalmNumber: 3 }); - expect(ordo.parts[2]).toMatchObject({ kind: 'psalm', psalmNumber: 94 }); - const invitatoryAntiphon = (ordo.parts[2] as { antiphon?: { text: Record } }).antiphon; + expect(ordo.parts[2]).toMatchObject({ kind: 'section-heading', label: 'Invitatory' }); + expect(ordo.parts[3]).toMatchObject({ kind: 'psalm', psalmNumber: 94 }); + const invitatoryAntiphon = (ordo.parts[3] as { antiphon?: { text: Record } }).antiphon; expect(invitatoryAntiphon?.text.la).toContain('Veníte'); // Real practice repeats the invitatory antiphon between verse groups — // deliberately not modeled that way here (see hours/matins.ts's own