Milestone 0/1: shell, calendar/psalter/hours scaffold, Prime
Deploy / deploy (push) Failing after 1m8s
Deploy / deploy (push) Failing after 1m8s
Client-side-first PWA (Vite/TS, no backend) per the approved plan: day- navigable shell listing all 8 hours, Prime fully resolves via the calendar -> psalter -> ordo pipeline, the other 7 hours are registered but flagged not-implemented. Sanctoral/temporal calendar data uses a day -> id indirection layer (saints, easter-offsets, fixed-date-calendar) so reassigning a feast to a different day is a data edit, not a code change. Docker (Caddy-serving-static) + Gitea CI workflow scaffolded to match the eec/drip/bookshop operational pattern. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import type { HourId } from './hours/types';
|
||||
import type { LanguageCode } from './psalter/types';
|
||||
|
||||
export interface AppState {
|
||||
date: string; // ISO YYYY-MM-DD
|
||||
selectedHour: HourId;
|
||||
/** Exactly one or two entries — see plan: one-language / two-language layout only. */
|
||||
languages: [LanguageCode] | [LanguageCode, LanguageCode];
|
||||
}
|
||||
|
||||
export function todayIso(): string {
|
||||
const d = new Date();
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
const state: AppState = {
|
||||
date: todayIso(),
|
||||
selectedHour: 'prime',
|
||||
languages: ['en'],
|
||||
};
|
||||
|
||||
type Listener = (state: Readonly<AppState>) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function getState(): Readonly<AppState> {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function subscribe(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of listeners) listener(state);
|
||||
}
|
||||
|
||||
export function setDate(date: string): void {
|
||||
state.date = date;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function goToToday(): void {
|
||||
setDate(todayIso());
|
||||
}
|
||||
|
||||
export function shiftDate(days: number): void {
|
||||
const d = new Date(`${state.date}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
setDate(d.toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
export function setSelectedHour(hourId: HourId): void {
|
||||
state.selectedHour = hourId;
|
||||
notify();
|
||||
}
|
||||
|
||||
export function setLanguages(languages: AppState['languages']): void {
|
||||
state.languages = languages;
|
||||
notify();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Precedence/occurrence rules for when a lower-ranked feast is commemorated
|
||||
// rather than fully displaced by the day's winning feast. Deliberately not
|
||||
// designed yet — see plan point 5 ("calendar rules are expected to iterate").
|
||||
// Unused until milestone 4.
|
||||
export {};
|
||||
@@ -0,0 +1,8 @@
|
||||
// Computus (the date of Easter, and everything the temporal cycle hangs off
|
||||
// of it — Septuagesima, Ash Wednesday, Ascension, Pentecost, Trinity Sunday).
|
||||
// Not implemented yet: unused until milestone 4 (Lauds/Vespers), which is the
|
||||
// first hour content that actually varies by season. Deliberately left empty
|
||||
// rather than half-built ahead of need — see the "content stores" section of
|
||||
// the plan for why calendar-rule logic is being kept swappable rather than
|
||||
// front-loaded.
|
||||
export {};
|
||||
@@ -0,0 +1,6 @@
|
||||
// Sanctoral occurrence resolution: given a date, which saint(s) are
|
||||
// assigned via data/calendar/sanctoral-calendar.yml's day -> saint-id
|
||||
// mapping, and their rank/propers/common from data/calendar/saints/<id>.yml.
|
||||
// See calendar/temporal.ts for the separate Easter/fixed-date resolution.
|
||||
// Unused until milestone 4.
|
||||
export {};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { LiturgicalDay } from './types';
|
||||
import { weekdayOf } from './weekday';
|
||||
|
||||
/**
|
||||
* Resolves everything about a given day *except* hour content — weekday,
|
||||
* season, and any occurring feasts. Season and occurring feasts are stubs
|
||||
* until calendar/easter.ts and calendar/feasts.ts land at milestone 4;
|
||||
* hours that only need weekday (Prime, Compline, Terce, Sext, None) can
|
||||
* already rely on this fully.
|
||||
*/
|
||||
export function resolveDay(isoDate: string): LiturgicalDay {
|
||||
return {
|
||||
date: isoDate,
|
||||
weekday: weekdayOf(isoDate),
|
||||
// Placeholder string, not a real resolution — real season/temporal-id
|
||||
// lookup (data/calendar/easter-offsets.yml + fixed-date-calendar.yml)
|
||||
// lands at milestone 4.
|
||||
season: 'trinitytide',
|
||||
occurring: [],
|
||||
};
|
||||
}
|
||||
|
||||
export type { LiturgicalDay, OccurringFeast, Season, Weekday, FeastRank } from './types';
|
||||
@@ -0,0 +1,8 @@
|
||||
// Temporal-cycle occurrence resolution: given a date, which temporal-id
|
||||
// applies (season, proper Sunday/feria), by combining two offset systems —
|
||||
// data/calendar/easter-offsets.yml (Septuagesima through Trinitytide) and
|
||||
// data/calendar/fixed-date-calendar.yml (Christmas, Epiphany). Also where
|
||||
// the Advent-start and Epiphanytide-length wrinkles noted in
|
||||
// fixed-date-calendar.yml get arbitrated. Depends on calendar/easter.ts for
|
||||
// the Easter date itself. Unused until milestone 4.
|
||||
export {};
|
||||
@@ -0,0 +1,46 @@
|
||||
export type Weekday =
|
||||
| 'sunday'
|
||||
| 'monday'
|
||||
| 'tuesday'
|
||||
| 'wednesday'
|
||||
| 'thursday'
|
||||
| 'friday'
|
||||
| 'saturday';
|
||||
|
||||
// Deliberately an open string, not a hardcoded union. Which temporal-id a
|
||||
// given day resolves to — and what it's *called* ("trinitytide" vs "time
|
||||
// after pentecost") — is a property of data/calendar/easter-offsets.yml and
|
||||
// fixed-date-calendar.yml, not a compile-time decision. This is what
|
||||
// actually resolves the "opinionated but configurable" tension: the opinion
|
||||
// (trinitytide) lives in editable data, not in a TS enum you'd recompile to
|
||||
// change.
|
||||
export type Season = string;
|
||||
|
||||
// Open-ended on purpose — the actual ranking scheme (double/semidouble/simple,
|
||||
// or whatever the finalized rank system turns out to be) is a rule decision for
|
||||
// milestone 4, not a type decision for milestone 0.
|
||||
export type FeastRank = string;
|
||||
|
||||
export interface OccurringFeast {
|
||||
id: string;
|
||||
name: string;
|
||||
rank: FeastRank;
|
||||
commemorated: boolean;
|
||||
// Placeholder for the first/second-Vespers overlap wrinkle — unresolved until
|
||||
// milestone 4 actually needs it.
|
||||
vespersFrom?: 'today' | 'firstVespersOfTomorrow';
|
||||
}
|
||||
|
||||
export interface LiturgicalDay {
|
||||
/** ISO date, e.g. "2026-08-09" */
|
||||
date: string;
|
||||
weekday: Weekday;
|
||||
/**
|
||||
* Real season resolution depends on Easter's date (see calendar/easter.ts,
|
||||
* not implemented until milestone 4). Until then this is a placeholder and
|
||||
* must not be trusted by any hour's logic.
|
||||
*/
|
||||
season: Season;
|
||||
/** Always [] until milestone 4 wires up calendar/feasts.ts. */
|
||||
occurring: OccurringFeast[];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Weekday } from './types';
|
||||
|
||||
const WEEKDAYS: readonly Weekday[] = [
|
||||
'sunday',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param isoDate "YYYY-MM-DD". Parsed as UTC midnight so the weekday doesn't
|
||||
* shift depending on the caller's local timezone.
|
||||
*/
|
||||
export function weekdayOf(isoDate: string): Weekday {
|
||||
const date = new Date(`${isoDate}T00:00:00Z`);
|
||||
const day = WEEKDAYS[date.getUTCDay()];
|
||||
if (!day) {
|
||||
throw new Error(`invalid ISO date: ${isoDate}`);
|
||||
}
|
||||
return day;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Offsets in days from Easter Sunday (0). Negative = before Easter, positive
|
||||
# = after. Covers the whole Easter-anchored span: Septuagesima through the
|
||||
# last Sunday of Trinitytide. A temporal-id's *name* (and hence whether it
|
||||
# reads "trinitytide" or "time after pentecost") lives here as data, not as
|
||||
# a compiled TS enum — see calendar/types.ts's Season type.
|
||||
#
|
||||
# PLACEHOLDER: only a handful of anchor points, to prove the shape. The full
|
||||
# set of Sundays/ferias between anchors is content-authoring work for
|
||||
# milestone 4.
|
||||
offsets:
|
||||
-63: septuagesima-sunday
|
||||
-56: sexagesima-sunday
|
||||
-49: quinquagesima-sunday
|
||||
-46: ash-wednesday
|
||||
0: easter-sunday
|
||||
39: ascension-thursday
|
||||
49: pentecost-sunday
|
||||
56: trinity-sunday
|
||||
@@ -0,0 +1,18 @@
|
||||
# Fixed civil-calendar dates (MM-DD) that anchor part of the temporal cycle
|
||||
# and don't move with Easter at all — unlike easter-offsets.yml.
|
||||
#
|
||||
# Deliberately NOT covering Advent or the Sundays after Epiphany here, even
|
||||
# though they're "near" these fixed points — both have a wrinkle that isn't
|
||||
# a plain fixed date:
|
||||
# - Advent's start is "the Sunday nearest Nov 30" (equivalently, the 4th
|
||||
# Sunday before Christmas) — a nearest-weekday-to-a-fixed-date rule, not
|
||||
# a fixed date or a numeric offset.
|
||||
# - Epiphanytide's *start* is fixed (Jan 6), but its *length* is secretly
|
||||
# Easter-dependent: how many Sundays after Epiphany you get before
|
||||
# Septuagesima cuts in varies with how early Easter falls that year —
|
||||
# i.e. this file's fixed end and easter-offsets.yml's Septuagesima
|
||||
# start have to arbitrate a boundary at resolution time.
|
||||
# Both are milestone 4 occurrence-resolution logic, not data-file entries.
|
||||
dates:
|
||||
"12-25": christmas-day
|
||||
"01-06": epiphany
|
||||
@@ -0,0 +1,9 @@
|
||||
# PLACEHOLDER — demonstrates the saint-record shape only. Not a real saint,
|
||||
# not a real rank, not a real day assignment (see sanctoral-calendar.yml for
|
||||
# where this id gets pointed at a day). Which day(s) point here can change
|
||||
# freely without ever touching this file.
|
||||
id: example-confessor
|
||||
name: "Example Confessor (placeholder)"
|
||||
rank: placeholder # real rank scheme is a milestone 4 decision, not made yet
|
||||
common: common-of-a-confessor-not-bishop
|
||||
propers: null # set to a propers id once/if this saint has a full proper
|
||||
@@ -0,0 +1,12 @@
|
||||
# Maps a calendar day (MM-DD — the sanctoral cycle repeats every civil year
|
||||
# regardless of weekday, unlike the temporal cycle) to the saint-id(s)
|
||||
# celebrated that day. This indirection is the whole point: to move a saint
|
||||
# to a different day, edit this mapping. The saint's own record
|
||||
# (data/calendar/saints/<id>.yml) never changes — it's the same record
|
||||
# whichever day points at it.
|
||||
#
|
||||
# PLACEHOLDER: "01-01" -> example-confessor is fake data proving the shape,
|
||||
# not a real calendar assignment. Real sanctoral content-authoring
|
||||
# (a pre-1910-leaning calendar, per the plan) is a separate task.
|
||||
days:
|
||||
"01-01": [example-confessor]
|
||||
@@ -0,0 +1,14 @@
|
||||
# PLACEHOLDER structure: proves the fixed-parts + weekday-variable-psalm
|
||||
# pipeline works. The hymn/chapter/prayer textRefs don't resolve to real
|
||||
# text yet (data/propers/ doesn't exist yet) — they render as "pending"
|
||||
# in the UI. Real content is a separate task from this scaffolding.
|
||||
id: prime
|
||||
parts:
|
||||
- kind: hymn
|
||||
textRef: { source: common, id: hymn-iam-lucis-orto-sidere }
|
||||
- kind: variable
|
||||
resolve: by-weekday
|
||||
- kind: chapter
|
||||
textRef: { source: common, id: prime-chapter-ferial }
|
||||
- kind: prayer
|
||||
textRef: { source: common, id: prime-collect }
|
||||
@@ -0,0 +1,27 @@
|
||||
# Vulgate/Gallican numbering. PLACEHOLDER content: only verses 1-2 are
|
||||
# filled in, to prove the loading/rendering pipeline end to end. Marked
|
||||
# `draft` (not `verified`) because this hasn't been checked against a
|
||||
# primary source yet — real content acquisition (from Divinum Officium
|
||||
# and/or period breviaries, per the plan's content-sourcing note) is a
|
||||
# separate task from this scaffolding work.
|
||||
number: 1
|
||||
verses:
|
||||
- n: 1
|
||||
text:
|
||||
la: "Beatus vir, qui non abiit in consilio impiorum, et in via peccatorum non stetit, et in cathedra pestilentiae non sedit;"
|
||||
en: "Blessed is the man who hath not walked in the counsel of the ungodly, nor stood in the way of sinners, nor sat in the chair of pestilence."
|
||||
status:
|
||||
la: draft
|
||||
en: draft
|
||||
- n: 2
|
||||
text:
|
||||
la: "Sed in lege Domini voluntas ejus, et in lege ejus meditabitur die ac nocte."
|
||||
en: "But his will is in the law of the Lord, and on his law he shall meditate day and night."
|
||||
status:
|
||||
la: draft
|
||||
en: draft
|
||||
- n: 3
|
||||
text: {}
|
||||
status:
|
||||
la: missing
|
||||
en: missing
|
||||
@@ -0,0 +1,16 @@
|
||||
# PLACEHOLDER distribution: real Benedictine Prime actually distributes
|
||||
# multiple fixed psalms plus a rotating section of Ps 118 across the week,
|
||||
# and the user has modifications in mind on top of that — none of that is
|
||||
# designed yet. This file exists only to prove the (hourId, weekday) -> psalm
|
||||
# numbers lookup works end to end, using the one psalm we have sample data
|
||||
# for. Expect this file to be rewritten entirely once real content-authoring
|
||||
# starts (see plan: "psalter modifications are edits to this data, not
|
||||
# engine changes").
|
||||
prime:
|
||||
sunday: [1]
|
||||
monday: [1]
|
||||
tuesday: [1]
|
||||
wednesday: [1]
|
||||
thursday: [1]
|
||||
friday: [1]
|
||||
saturday: [1]
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ResolvedOrdo } from './types';
|
||||
|
||||
// Milestone 2. Not built yet.
|
||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
return { hourId: 'compline', date, parts: [], notImplemented: true };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { HourId, ResolvedOrdo } from './types';
|
||||
import { HOUR_IDS } from './types';
|
||||
import * as prime from './prime';
|
||||
import * as compline from './compline';
|
||||
import * as terce from './terce';
|
||||
import * as sext from './sext';
|
||||
import * as none from './none';
|
||||
import * as lauds from './lauds';
|
||||
import * as vespers from './vespers';
|
||||
import * as matins from './matins';
|
||||
|
||||
type Resolver = (date: string) => ResolvedOrdo;
|
||||
|
||||
// Uniform on purpose — even though terce/sext/none are structurally
|
||||
// identical, callers never branch on which hour they're asking for.
|
||||
const registry: Record<HourId, Resolver> = {
|
||||
prime: prime.resolveOrdo,
|
||||
compline: compline.resolveOrdo,
|
||||
terce: terce.resolveOrdo,
|
||||
sext: sext.resolveOrdo,
|
||||
none: none.resolveOrdo,
|
||||
lauds: lauds.resolveOrdo,
|
||||
vespers: vespers.resolveOrdo,
|
||||
matins: matins.resolveOrdo,
|
||||
};
|
||||
|
||||
export function resolveOrdo(hourId: HourId, date: string): ResolvedOrdo {
|
||||
return registry[hourId](date);
|
||||
}
|
||||
|
||||
export { HOUR_IDS };
|
||||
export type {
|
||||
HourId,
|
||||
HourDefinition,
|
||||
HourPart,
|
||||
ResolvedOrdo,
|
||||
ResolvedPart,
|
||||
ResolvedText,
|
||||
ResolvedVerse,
|
||||
PropersRef,
|
||||
} from './types';
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ResolvedOrdo } from './types';
|
||||
|
||||
// Milestone 4 — first hour needing real sanctoral occurrence + rank/commemoration
|
||||
// resolution (calendar/feasts.ts, calendar/easter.ts). Not built yet.
|
||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
return { hourId: 'lauds', date, parts: [], notImplemented: true };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ResolvedOrdo } from './types';
|
||||
|
||||
// Milestone 5, most complex. Needs its own resolution path (reading-candidate
|
||||
// pool + rank/season-driven slotting), not the static parts-array pattern the
|
||||
// other hours use — see the plan's "Matins schema seam" note. Not built yet.
|
||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
return { hourId: 'matins', date, parts: [], notImplemented: true };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ResolvedOrdo } from './types';
|
||||
|
||||
// Milestone 3. Not built yet — will call into hours/shared/little-hour-shared.ts
|
||||
// alongside terce.ts and sext.ts once that's written.
|
||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
return { hourId: 'none', date, parts: [], notImplemented: true };
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
|
||||
import { resolveDay } from '../calendar';
|
||||
import { getPsalmNumbersFor } from '../psalter/distribution';
|
||||
import { getPsalm } from '../psalter';
|
||||
import primeDefinitionData from '../data/hours/prime.yml';
|
||||
|
||||
const primeDefinition = primeDefinitionData as HourDefinition;
|
||||
|
||||
// No propers data store yet (see plan: "content stores on the horizon") — every
|
||||
// fixed textRef resolves as pending until that lands. Keeping this as an
|
||||
// explicit function (rather than inlining) is exactly the seam that gets
|
||||
// swapped out for a real lookup later.
|
||||
function resolveTextRef(): ResolvedText {
|
||||
return { text: {}, status: { la: 'missing', en: 'missing' } };
|
||||
}
|
||||
|
||||
function resolvePart(part: HourPart, psalmNumbers: number[]): ResolvedPart[] {
|
||||
switch (part.kind) {
|
||||
case 'hymn':
|
||||
case 'chapter':
|
||||
case 'responsory':
|
||||
case 'versicle':
|
||||
case 'prayer':
|
||||
return [{ kind: part.kind, text: resolveTextRef() }];
|
||||
case 'variable':
|
||||
// Only 'by-weekday' is meaningful for Prime; by-season/by-feast-rank
|
||||
// don't apply until milestone 4.
|
||||
return psalmNumbers.map((n) => {
|
||||
const psalm = getPsalm(n);
|
||||
return {
|
||||
kind: 'psalm' as const,
|
||||
psalmNumber: n,
|
||||
verses: psalm.verses.map((v) => ({ n: v.n, text: v.text, status: v.status })),
|
||||
};
|
||||
});
|
||||
case 'psalm':
|
||||
return [
|
||||
{
|
||||
kind: 'psalm',
|
||||
psalmNumber: part.psalmNumber,
|
||||
verses: getPsalm(part.psalmNumber).verses.map((v) => ({
|
||||
n: v.n,
|
||||
text: v.text,
|
||||
status: v.status,
|
||||
})),
|
||||
},
|
||||
];
|
||||
case 'canticle':
|
||||
return [{ kind: 'canticle', canticleId: part.canticleId }];
|
||||
case 'lesson':
|
||||
// Prime has no lessons — Matins is where lesson slotting matters.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
const day = resolveDay(date);
|
||||
const psalmNumbers = getPsalmNumbersFor('prime', day.weekday);
|
||||
const parts = primeDefinition.parts.flatMap((part) => resolvePart(part, psalmNumbers));
|
||||
return { hourId: 'prime', date, parts };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ResolvedOrdo } from './types';
|
||||
|
||||
// Milestone 3. Not built yet — will call into hours/shared/little-hour-shared.ts
|
||||
// alongside terce.ts and none.ts once that's written.
|
||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
return { hourId: 'sext', date, parts: [], notImplemented: true };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ResolvedOrdo } from './types';
|
||||
|
||||
// Milestone 3. Not built yet — will call into hours/shared/little-hour-shared.ts
|
||||
// alongside sext.ts and none.ts once that's written.
|
||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
return { hourId: 'terce', date, parts: [], notImplemented: true };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export type HourId =
|
||||
| 'prime'
|
||||
| 'compline'
|
||||
| 'terce'
|
||||
| 'sext'
|
||||
| 'none'
|
||||
| 'lauds'
|
||||
| 'vespers'
|
||||
| 'matins';
|
||||
|
||||
export const HOUR_IDS: readonly HourId[] = [
|
||||
'prime',
|
||||
'compline',
|
||||
'terce',
|
||||
'sext',
|
||||
'none',
|
||||
'lauds',
|
||||
'vespers',
|
||||
'matins',
|
||||
];
|
||||
|
||||
/** Reference into one of the proper-text stores (data/propers/**). Not built yet — placeholder shape. */
|
||||
export interface PropersRef {
|
||||
source: 'sanctoral' | 'temporal' | 'common';
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type HourPart =
|
||||
| { kind: 'hymn' | 'chapter' | 'responsory' | 'versicle' | 'prayer'; textRef: PropersRef }
|
||||
| { kind: 'psalm'; psalmNumber: number; antiphonRef?: PropersRef }
|
||||
| { kind: 'canticle'; canticleId: string; antiphonRef?: PropersRef }
|
||||
| {
|
||||
kind: 'lesson';
|
||||
textRef: PropersRef;
|
||||
nocturn?: number;
|
||||
lessonSource?: 'historical' | 'continuous-plan';
|
||||
}
|
||||
// Unused by Prime/Compline/the little hours — exercised starting milestone 4.
|
||||
| { kind: 'variable'; resolve: 'by-weekday' | 'by-season' | 'by-feast-rank' };
|
||||
|
||||
export interface HourDefinition {
|
||||
id: HourId;
|
||||
parts: HourPart[];
|
||||
}
|
||||
|
||||
/** A part with its text actually filled in, ready for the UI to render. */
|
||||
export type ResolvedPart =
|
||||
| { kind: 'hymn' | 'chapter' | 'responsory' | 'versicle' | 'prayer'; text: ResolvedText }
|
||||
| { kind: 'psalm'; psalmNumber: number; antiphon?: ResolvedText; verses: ResolvedVerse[] }
|
||||
| { kind: 'canticle'; canticleId: string; antiphon?: ResolvedText };
|
||||
|
||||
export interface ResolvedVerse {
|
||||
n: number;
|
||||
text: Partial<Record<string, string>>;
|
||||
status: Partial<Record<string, 'verified' | 'draft' | 'missing'>>;
|
||||
}
|
||||
|
||||
export interface ResolvedText {
|
||||
text: Partial<Record<string, string>>;
|
||||
status: Partial<Record<string, 'verified' | 'draft' | 'missing'>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "ordo" for one hour on one day — the fully resolved output of
|
||||
* combining calendar resolution, the psalter distribution, and an
|
||||
* HourDefinition. This is the seam between the engine and the UI: the UI
|
||||
* never needs to know whether a part came from a static YAML row or a
|
||||
* resolved rule.
|
||||
*/
|
||||
export interface ResolvedOrdo {
|
||||
hourId: HourId;
|
||||
date: string;
|
||||
parts: ResolvedPart[];
|
||||
/** Set when this hour hasn't been built yet — UI shows "coming soon" instead of empty content. */
|
||||
notImplemented?: true;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ResolvedOrdo } from './types';
|
||||
|
||||
// Milestone 4, alongside Lauds — also has the first/second-Vespers overlap
|
||||
// wrinkle (see OccurringFeast.vespersFrom). Not built yet.
|
||||
export function resolveOrdo(date: string): ResolvedOrdo {
|
||||
return { hourId: 'vespers', date, parts: [], notImplemented: true };
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// Latin subset only — this content is Latin/English, no need to ship
|
||||
// cyrillic/greek glyph data into the offline precache.
|
||||
import '@fontsource/eb-garamond/latin-400.css';
|
||||
import '@fontsource/eb-garamond/latin-600.css';
|
||||
import '@fontsource/source-serif-4/latin-400.css';
|
||||
import '@fontsource/source-serif-4/latin-400-italic.css';
|
||||
import '@fontsource/inter/latin-400.css';
|
||||
import '@fontsource/inter/latin-600.css';
|
||||
import './ui/styles.css';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
import { initRouter } from './router';
|
||||
import { mountShell } from './ui/shell';
|
||||
|
||||
const root = document.getElementById('app');
|
||||
if (!root) {
|
||||
throw new Error('missing #app root element');
|
||||
}
|
||||
|
||||
initRouter();
|
||||
mountShell(root);
|
||||
|
||||
registerSW({ immediate: true });
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { HourId } from '../hours/types';
|
||||
import type { Weekday } from '../calendar/types';
|
||||
import distributionData from '../data/psalter-distribution.yml';
|
||||
|
||||
type DistributionTable = Partial<Record<HourId, Partial<Record<Weekday, number[]>>>>;
|
||||
|
||||
const distribution = distributionData as DistributionTable;
|
||||
|
||||
/** Placeholder distribution — see data/psalter-distribution.yml for caveats. */
|
||||
export function getPsalmNumbersFor(hourId: HourId, weekday: Weekday): number[] {
|
||||
return distribution[hourId]?.[weekday] ?? [];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Psalm } from './types';
|
||||
|
||||
// @rollup/plugin-yaml parses each .yml into a plain object at build time —
|
||||
// no YAML parser shipped to the client.
|
||||
const modules = import.meta.glob<{ default: Psalm }>('../data/psalms/*.yml', {
|
||||
eager: true,
|
||||
});
|
||||
|
||||
const psalms = new Map<number, Psalm>();
|
||||
for (const mod of Object.values(modules)) {
|
||||
psalms.set(mod.default.number, mod.default);
|
||||
}
|
||||
|
||||
export function getPsalm(number: number): Psalm {
|
||||
const psalm = psalms.get(number);
|
||||
if (!psalm) {
|
||||
throw new Error(`no data for psalm ${number}`);
|
||||
}
|
||||
return psalm;
|
||||
}
|
||||
|
||||
export type { Psalm, PsalmVerse, LanguageCode, TranslationStatus } from './types';
|
||||
@@ -0,0 +1,16 @@
|
||||
/** e.g. "la", "en" — open-ended so a third language is a data change, not a schema change. */
|
||||
export type LanguageCode = string;
|
||||
|
||||
export type TranslationStatus = 'verified' | 'draft' | 'missing';
|
||||
|
||||
export interface PsalmVerse {
|
||||
n: number;
|
||||
text: Partial<Record<LanguageCode, string>>;
|
||||
status: Partial<Record<LanguageCode, TranslationStatus>>;
|
||||
}
|
||||
|
||||
/** Keyed by Vulgate/Gallican numbering, not Hebrew/modern numbering. */
|
||||
export interface Psalm {
|
||||
number: number;
|
||||
verses: PsalmVerse[];
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getState, setDate, setSelectedHour, subscribe } from './app-state';
|
||||
import { HOUR_IDS, type HourId } from './hours/types';
|
||||
|
||||
// Real paths (reground.org/vu/2026-08-09/prime), not hash routing — the
|
||||
// container's Caddy does SPA fallback via try_files, so there's no need to
|
||||
// avoid a rewrite rule the way a bare static docroot would have.
|
||||
const BASE = import.meta.env.BASE_URL;
|
||||
|
||||
function isHourId(value: string): value is HourId {
|
||||
return (HOUR_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function parseLocation(): { date?: string; hour?: HourId } {
|
||||
const path = window.location.pathname;
|
||||
if (!path.startsWith(BASE)) return {};
|
||||
const [date, hour] = path.slice(BASE.length).split('/').filter(Boolean);
|
||||
return {
|
||||
date: date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined,
|
||||
hour: hour && isHourId(hour) ? hour : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function urlFor(date: string, hour: HourId): string {
|
||||
return `${BASE}${date}/${hour}`;
|
||||
}
|
||||
|
||||
let lastUrl = '';
|
||||
|
||||
function pushUrl(): void {
|
||||
const { date, selectedHour } = getState();
|
||||
const url = urlFor(date, selectedHour);
|
||||
if (url === lastUrl) return;
|
||||
lastUrl = url;
|
||||
window.history.pushState(null, '', url);
|
||||
}
|
||||
|
||||
export function initRouter(): void {
|
||||
const { date, hour } = parseLocation();
|
||||
if (date) setDate(date);
|
||||
if (hour) setSelectedHour(hour);
|
||||
|
||||
const { date: currentDate, selectedHour } = getState();
|
||||
lastUrl = urlFor(currentDate, selectedHour);
|
||||
window.history.replaceState(null, '', lastUrl);
|
||||
|
||||
subscribe(() => pushUrl());
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
const parsed = parseLocation();
|
||||
// Set lastUrl first so the notify() inside setDate/setSelectedHour below
|
||||
// doesn't turn this back-navigation into a new forward pushState.
|
||||
lastUrl = window.location.pathname;
|
||||
if (parsed.date) setDate(parsed.date);
|
||||
if (parsed.hour) setSelectedHour(parsed.hour);
|
||||
});
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.yml' {
|
||||
const value: unknown;
|
||||
export default value;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getState, setLanguages, type AppState } from '../app-state';
|
||||
|
||||
// Hardcoded to en/la since those are the only languages with any data so
|
||||
// far. The two-language ceiling (never three+ columns) is the real
|
||||
// constraint per the plan; the specific set of offered languages is
|
||||
// expected to grow independently of that.
|
||||
const OPTIONS: { label: string; languages: AppState['languages'] }[] = [
|
||||
{ label: 'English', languages: ['en'] },
|
||||
{ label: 'Latin', languages: ['la'] },
|
||||
{ label: 'English + Latin', languages: ['en', 'la'] },
|
||||
];
|
||||
|
||||
export function renderLanguageToggle(container: HTMLElement): void {
|
||||
const { languages } = getState();
|
||||
const current = languages.join('+');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="language-toggle" role="group" aria-label="Display language">
|
||||
${OPTIONS.map((opt) => {
|
||||
const isActive = opt.languages.join('+') === current;
|
||||
return `<button type="button" class="lang-btn${isActive ? ' is-active' : ''}" data-langs="${opt.languages.join(',')}">${opt.label}</button>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.querySelectorAll<HTMLButtonElement>('[data-langs]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const langs = btn.dataset.langs!.split(',') as AppState['languages'];
|
||||
setLanguages(langs);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getState, shiftDate, goToToday } from '../app-state';
|
||||
import { formatDateLong } from './format';
|
||||
|
||||
export function renderDayNav(container: HTMLElement): void {
|
||||
const { date } = getState();
|
||||
container.innerHTML = `
|
||||
<nav class="day-nav" aria-label="Day navigation">
|
||||
<button type="button" class="day-nav-btn" data-action="prev" aria-label="Previous day">←</button>
|
||||
<button type="button" class="day-nav-btn day-nav-today" data-action="today">Today</button>
|
||||
<span class="day-nav-date">${formatDateLong(date)}</span>
|
||||
<button type="button" class="day-nav-btn" data-action="next" aria-label="Next day">→</button>
|
||||
</nav>
|
||||
`;
|
||||
container.querySelector('[data-action="prev"]')?.addEventListener('click', () => shiftDate(-1));
|
||||
container.querySelector('[data-action="next"]')?.addEventListener('click', () => shiftDate(1));
|
||||
container.querySelector('[data-action="today"]')?.addEventListener('click', () => goToToday());
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Formats an ISO date as e.g. "Sunday, August 9, 2026" — always in UTC so
|
||||
* it matches calendar/weekday.ts's interpretation regardless of the
|
||||
* viewer's local timezone. */
|
||||
export function formatDateLong(isoDate: string): string {
|
||||
const date = new Date(`${isoDate}T00:00:00Z`);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function hourLabel(hourId: string): string {
|
||||
return hourId.charAt(0).toUpperCase() + hourId.slice(1);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getState, setSelectedHour } from '../app-state';
|
||||
import { HOUR_IDS, resolveOrdo, type HourId } from '../hours';
|
||||
import { hourLabel } from './format';
|
||||
|
||||
export function renderHourList(container: HTMLElement): void {
|
||||
const { date, selectedHour } = getState();
|
||||
|
||||
const items = HOUR_IDS.map((hourId) => {
|
||||
const ordo = resolveOrdo(hourId, date);
|
||||
const isSelected = hourId === selectedHour;
|
||||
const isReady = !ordo.notImplemented;
|
||||
return `
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="hour-item${isSelected ? ' is-selected' : ''}${isReady ? '' : ' is-pending'}"
|
||||
data-hour="${hourId}"
|
||||
aria-current="${isSelected ? 'true' : 'false'}"
|
||||
>
|
||||
<span class="hour-name">${hourLabel(hourId)}</span>
|
||||
${isReady ? '' : '<span class="hour-status">coming soon</span>'}
|
||||
</button>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = `<ul class="hour-list">${items}</ul>`;
|
||||
|
||||
container.querySelectorAll<HTMLButtonElement>('[data-hour]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
setSelectedHour(btn.dataset.hour as HourId);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { getState } from '../app-state';
|
||||
import { resolveOrdo } from '../hours';
|
||||
import type { ResolvedPart, ResolvedText, ResolvedVerse } from '../hours/types';
|
||||
import { hourLabel } from './format';
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function cellFor(resolved: ResolvedText | ResolvedVerse, lang: string): string {
|
||||
const status = resolved.status[lang] ?? 'missing';
|
||||
const text = resolved.text[lang];
|
||||
if (status === 'missing' || !text) {
|
||||
return '<span class="text-pending">(translation pending)</span>';
|
||||
}
|
||||
const escaped = escapeHtml(text);
|
||||
return status === 'draft' ? `<span class="text-draft" title="unverified draft text">${escaped}</span>` : escaped;
|
||||
}
|
||||
|
||||
function renderColumns(resolved: ResolvedText | ResolvedVerse, languages: readonly string[]): string {
|
||||
const cols = languages
|
||||
.map((lang) => `<div class="lang-column" lang="${lang}">${cellFor(resolved, lang)}</div>`)
|
||||
.join('');
|
||||
return `<div class="lang-columns lang-columns-${languages.length}">${cols}</div>`;
|
||||
}
|
||||
|
||||
function renderPart(part: ResolvedPart, languages: readonly string[]): string {
|
||||
switch (part.kind) {
|
||||
case 'hymn':
|
||||
case 'chapter':
|
||||
case 'responsory':
|
||||
case 'versicle':
|
||||
case 'prayer':
|
||||
return `
|
||||
<section class="ordo-part ordo-part-${part.kind}">
|
||||
<h3 class="ordo-part-label">${hourLabel(part.kind)}</h3>
|
||||
${renderColumns(part.text, languages)}
|
||||
</section>
|
||||
`;
|
||||
case 'psalm':
|
||||
return `
|
||||
<section class="ordo-part ordo-part-psalm">
|
||||
<h3 class="ordo-part-label">Psalm ${part.psalmNumber}</h3>
|
||||
${part.antiphon ? renderColumns(part.antiphon, languages) : ''}
|
||||
<ol class="psalm-verses">
|
||||
${part.verses.map((v) => `<li class="psalm-verse">${renderColumns(v, languages)}</li>`).join('')}
|
||||
</ol>
|
||||
</section>
|
||||
`;
|
||||
case 'canticle':
|
||||
return `
|
||||
<section class="ordo-part ordo-part-canticle">
|
||||
<h3 class="ordo-part-label">Canticle</h3>
|
||||
${part.antiphon ? renderColumns(part.antiphon, languages) : ''}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHourView(container: HTMLElement): void {
|
||||
const { date, selectedHour, languages } = getState();
|
||||
const ordo = resolveOrdo(selectedHour, date);
|
||||
|
||||
if (ordo.notImplemented) {
|
||||
container.innerHTML = `
|
||||
<div class="hour-view hour-view-pending">
|
||||
<h2>${hourLabel(selectedHour)}</h2>
|
||||
<p>This hour hasn't been built yet.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="hour-view">
|
||||
<h2>${hourLabel(selectedHour)}</h2>
|
||||
${ordo.parts.map((part) => renderPart(part, languages)).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { subscribe } from '../app-state';
|
||||
import { renderDayNav } from './day-nav';
|
||||
import { renderHourList } from './hour-list';
|
||||
import { renderHourView } from './hour-view';
|
||||
import { renderLanguageToggle } from './bilingual-toggle';
|
||||
|
||||
export function mountShell(root: HTMLElement): void {
|
||||
root.innerHTML = `
|
||||
<header class="site-header">
|
||||
<span class="site-title">vu</span>
|
||||
<div data-slot="language-toggle"></div>
|
||||
</header>
|
||||
<div data-slot="day-nav"></div>
|
||||
<main id="main" class="app-main">
|
||||
<nav data-slot="hour-list" class="hour-list-nav" aria-label="Hours"></nav>
|
||||
<div data-slot="hour-view" class="hour-view-region"></div>
|
||||
</main>
|
||||
`;
|
||||
|
||||
const dayNavEl = root.querySelector<HTMLElement>('[data-slot="day-nav"]');
|
||||
const hourListEl = root.querySelector<HTMLElement>('[data-slot="hour-list"]');
|
||||
const hourViewEl = root.querySelector<HTMLElement>('[data-slot="hour-view"]');
|
||||
const languageToggleEl = root.querySelector<HTMLElement>('[data-slot="language-toggle"]');
|
||||
if (!dayNavEl || !hourListEl || !hourViewEl || !languageToggleEl) {
|
||||
throw new Error('shell markup is missing an expected slot');
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
renderDayNav(dayNavEl!);
|
||||
renderHourList(hourListEl!);
|
||||
renderHourView(hourViewEl!);
|
||||
renderLanguageToggle(languageToggleEl!);
|
||||
}
|
||||
|
||||
render();
|
||||
subscribe(render);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/* ==========================================================================
|
||||
vu — design tokens adapted from michaelferrara-site's parchment/ink/oxblood
|
||||
palette and EB Garamond/Source Serif 4/Inter type system (the "most me"
|
||||
existing site, per the plan). Same technical pattern as reground-site and
|
||||
michaelferrara-site: CSS custom properties, prefers-color-scheme dark mode,
|
||||
a skip-link, explicit :focus-visible rings.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
--color-bg: #f7f3ea;
|
||||
--color-bg-raised: #efe9db;
|
||||
--color-ink: #2a2521;
|
||||
--color-ink-heading: #1c1712;
|
||||
--color-accent: #7a2e2e;
|
||||
--color-accent-hover: #5c2222;
|
||||
--color-brass: #8c7a5b;
|
||||
--color-border: #ddd3bd;
|
||||
--color-pending: #9a8f7d;
|
||||
|
||||
--font-body: 'Source Serif 4', Georgia, 'Times New Roman', serif;
|
||||
--font-heading: 'EB Garamond', 'Palatino Linotype', Georgia, serif;
|
||||
--font-ui: 'Inter', 'IBM Plex Sans', -apple-system, sans-serif;
|
||||
|
||||
--font-size-base: 19px;
|
||||
--line-height-base: 1.65;
|
||||
--measure: 70ch;
|
||||
--radius: 4px;
|
||||
|
||||
--space-1: 0.5rem;
|
||||
--space-2: 1rem;
|
||||
--space-3: 1.75rem;
|
||||
--space-4: 3rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* Matins and Compline get used in the dark far more than a marketing
|
||||
site ever would — this needs to be a good reading mode, not just
|
||||
present. */
|
||||
--color-bg: #1a1613;
|
||||
--color-bg-raised: #24201b;
|
||||
--color-ink: #e9e2d4;
|
||||
--color-ink-heading: #f5efe2;
|
||||
--color-accent: #d98a8a;
|
||||
--color-accent-hover: #e6a6a6;
|
||||
--color-brass: #b3a483;
|
||||
--color-border: #3a332a;
|
||||
--color-pending: #7d7364;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--color-ink-heading);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: 0 0 var(--radius) 0;
|
||||
}
|
||||
.skip-link:focus {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Header + language toggle
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.site-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.site-title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.4rem;
|
||||
color: var(--color-ink-heading);
|
||||
}
|
||||
|
||||
.language-toggle {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg-raised);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lang-btn.is-active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Day nav
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.day-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
font-family: var(--font-ui);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.day-nav-btn {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-raised);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.day-nav-date {
|
||||
font-weight: 600;
|
||||
min-width: 16rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Layout: hour list + hour view
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.app-main {
|
||||
display: grid;
|
||||
grid-template-columns: 12rem 1fr;
|
||||
gap: var(--space-3);
|
||||
max-width: 64rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.app-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.hour-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.hour-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.hour-item:hover {
|
||||
background: var(--color-bg-raised);
|
||||
}
|
||||
|
||||
.hour-item.is-selected {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.hour-item.is-pending {
|
||||
color: var(--color-pending);
|
||||
}
|
||||
|
||||
.hour-item.is-selected.is-pending {
|
||||
color: var(--color-bg);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.hour-status {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.hour-view-region {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hour-view-pending p {
|
||||
color: var(--color-pending);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Ordo parts + bilingual columns
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.ordo-part {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.ordo-part-label {
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-brass);
|
||||
font-family: var(--font-ui);
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
|
||||
.psalm-verses {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.psalm-verse {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* One-language vs two-language layout — the real display-width ceiling from
|
||||
the plan: never more than two columns, regardless of how many languages
|
||||
eventually have data. */
|
||||
.lang-columns-1 {
|
||||
max-width: var(--measure);
|
||||
}
|
||||
|
||||
.lang-columns-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.lang-columns-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.lang-column[lang='la'] {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.text-pending {
|
||||
color: var(--color-pending);
|
||||
font-style: italic;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.text-draft {
|
||||
border-bottom: 1px dashed var(--color-brass);
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
.site-header,
|
||||
.day-nav,
|
||||
.hour-list-nav,
|
||||
.language-toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user