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,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Deploy
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Build and smoke-test the container
|
||||
# vu has no server-side logic, so there's nothing an integration
|
||||
# test would exercise beyond "does the built bundle actually get
|
||||
# served correctly under the /vu/ base path" — this is that check,
|
||||
# run against the exact image that ships.
|
||||
run: |
|
||||
docker build -t vu:ci .
|
||||
docker run -d --name vu-smoke -p 8080:80 vu:ci
|
||||
sleep 1
|
||||
curl -sf http://localhost:8080/ | grep -q 'vu — Divine Office'
|
||||
docker stop vu-smoke
|
||||
|
||||
- name: Trigger redeploy via SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "$VU_DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
# Forced-command key, same pattern as eec/bookshop/drip: the
|
||||
# server-side sudoers grant runs the vu redeploy script regardless
|
||||
# of what's requested here — see the vu Ansible role.
|
||||
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new vu-deploy@reground.org true
|
||||
env:
|
||||
VU_DEPLOY_SSH_KEY: ${{ secrets.VU_DEPLOY_SSH_KEY }}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
dev-dist
|
||||
*.local
|
||||
@@ -0,0 +1,9 @@
|
||||
:80 {
|
||||
root * /srv
|
||||
file_server
|
||||
# SPA fallback: real paths like /2026-08-09/prime aren't real files, so
|
||||
# fall back to index.html and let src/router.ts read the URL client-side.
|
||||
# The hub's outer Caddy strips the /vu prefix (path_mount + strip_prefix)
|
||||
# before proxying here, so paths inside the container never have it.
|
||||
try_files {path} /index.html
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# Multi-stage: build the static bundle with Node, serve it with Caddy.
|
||||
# vu has no server-side logic — this container exists for operational
|
||||
# consistency with the rest of the hub (docker-compose + Ansible role +
|
||||
# systemd, same as eec/drip/bookshop), not because a live process is needed.
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM caddy:alpine
|
||||
COPY --from=build /app/dist /srv
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#7a2e2e" />
|
||||
<title>vu — Divine Office</title>
|
||||
<link rel="icon" href="icons/icon-192.png" />
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+7107
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "vu",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/eb-garamond": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/source-serif-4": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-yaml": "^5.0.0",
|
||||
"jsdom": "^30.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { weekdayOf } from '../../src/calendar/weekday';
|
||||
import { resolveDay } from '../../src/calendar';
|
||||
|
||||
describe('weekdayOf', () => {
|
||||
it('resolves a known Sunday', () => {
|
||||
// 2026-08-09 is a Sunday.
|
||||
expect(weekdayOf('2026-08-09')).toBe('sunday');
|
||||
});
|
||||
|
||||
it('resolves each day of a full week correctly', () => {
|
||||
const expected = [
|
||||
['2026-08-09', 'sunday'],
|
||||
['2026-08-10', 'monday'],
|
||||
['2026-08-11', 'tuesday'],
|
||||
['2026-08-12', 'wednesday'],
|
||||
['2026-08-13', 'thursday'],
|
||||
['2026-08-14', 'friday'],
|
||||
['2026-08-15', 'saturday'],
|
||||
] as const;
|
||||
for (const [date, weekday] of expected) {
|
||||
expect(weekdayOf(date)).toBe(weekday);
|
||||
}
|
||||
});
|
||||
|
||||
it('is not affected by the local timezone offset', () => {
|
||||
// Regression guard: resolving via `new Date(isoDate)` without pinning to
|
||||
// UTC would give different weekdays depending on the host's timezone.
|
||||
expect(weekdayOf('2026-01-01')).toBe('thursday');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDay', () => {
|
||||
it('stubs season and occurring until milestone 4', () => {
|
||||
const day = resolveDay('2026-08-09');
|
||||
expect(day.date).toBe('2026-08-09');
|
||||
expect(day.weekday).toBe('sunday');
|
||||
expect(day.occurring).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveOrdo } from '../../src/hours';
|
||||
|
||||
describe('resolveOrdo("prime", ...)', () => {
|
||||
it('is implemented and resolves a psalm from the weekday-variable slot', () => {
|
||||
const ordo = resolveOrdo('prime', '2026-08-09');
|
||||
expect(ordo.notImplemented).toBeUndefined();
|
||||
expect(ordo.hourId).toBe('prime');
|
||||
|
||||
const psalmParts = ordo.parts.filter((p) => p.kind === 'psalm');
|
||||
expect(psalmParts.length).toBeGreaterThan(0);
|
||||
expect(psalmParts[0]).toMatchObject({ kind: 'psalm', psalmNumber: 1 });
|
||||
});
|
||||
|
||||
it('resolves fixed parts (hymn, chapter, prayer) as pending until propers data exists', () => {
|
||||
const ordo = resolveOrdo('prime', '2026-08-09');
|
||||
const hymn = ordo.parts.find((p) => p.kind === 'hymn');
|
||||
expect(hymn).toBeDefined();
|
||||
if (hymn && hymn.kind === 'hymn') {
|
||||
expect(hymn.text.status.en).toBe('missing');
|
||||
}
|
||||
});
|
||||
|
||||
it('surfaces verified/draft/missing status per language, per verse', () => {
|
||||
const ordo = resolveOrdo('prime', '2026-08-09');
|
||||
const psalm = ordo.parts.find((p) => p.kind === 'psalm');
|
||||
expect(psalm && psalm.kind === 'psalm' ? psalm.verses.length : 0).toBe(3);
|
||||
if (psalm && psalm.kind === 'psalm') {
|
||||
expect(psalm.verses[0]?.status.en).toBe('draft');
|
||||
expect(psalm.verses[2]?.status.en).toBe('missing');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrdo for not-yet-built hours', () => {
|
||||
it('flags compline as not implemented rather than throwing', () => {
|
||||
const ordo = resolveOrdo('compline', '2026-08-09');
|
||||
expect(ordo.notImplemented).toBe(true);
|
||||
expect(ordo.parts).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mountShell } from '../../src/ui/shell';
|
||||
import { setDate, setSelectedHour, setLanguages } from '../../src/app-state';
|
||||
|
||||
describe('shell', () => {
|
||||
// app-state is a module-level singleton, so each test must reset every
|
||||
// field it might have mutated — not just the one it cares about — or
|
||||
// state leaks across tests in this file.
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
setDate('2026-08-09');
|
||||
setSelectedHour('prime');
|
||||
setLanguages(['en']);
|
||||
window.history.replaceState(null, '', '/vu/2026-08-09/prime');
|
||||
});
|
||||
|
||||
it('renders the day nav, all 8 hours, and Prime content by default', () => {
|
||||
const root = document.getElementById('app')!;
|
||||
mountShell(root);
|
||||
|
||||
expect(root.querySelector('.day-nav-date')?.textContent).toContain('August 9, 2026');
|
||||
|
||||
const hourNames = Array.from(root.querySelectorAll('.hour-name')).map((el) => el.textContent);
|
||||
expect(hourNames).toEqual([
|
||||
'Prime',
|
||||
'Compline',
|
||||
'Terce',
|
||||
'Sext',
|
||||
'None',
|
||||
'Lauds',
|
||||
'Vespers',
|
||||
'Matins',
|
||||
]);
|
||||
|
||||
expect(root.querySelector('.hour-item.is-selected .hour-name')?.textContent).toBe('Prime');
|
||||
expect(root.querySelector('.hour-view h2')?.textContent).toBe('Prime');
|
||||
expect(root.querySelectorAll('.ordo-part-psalm').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('switches to a not-yet-built hour and shows the pending message', () => {
|
||||
const root = document.getElementById('app')!;
|
||||
mountShell(root);
|
||||
|
||||
const complineBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-hour]')).find(
|
||||
(btn) => btn.dataset.hour === 'compline',
|
||||
);
|
||||
complineBtn?.click();
|
||||
|
||||
expect(root.querySelector('.hour-view h2')?.textContent).toBe('Compline');
|
||||
expect(root.querySelector('.hour-view-pending')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('moving to the next day updates the displayed date', () => {
|
||||
const root = document.getElementById('app')!;
|
||||
mountShell(root);
|
||||
|
||||
root.querySelector<HTMLButtonElement>('[data-action="next"]')?.click();
|
||||
expect(root.querySelector('.day-nav-date')?.textContent).toContain('August 10, 2026');
|
||||
});
|
||||
|
||||
it('switching to two-language mode renders two columns per verse', () => {
|
||||
const root = document.getElementById('app')!;
|
||||
mountShell(root);
|
||||
|
||||
const bilingualBtn = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-langs]')).find(
|
||||
(btn) => btn.dataset.langs === 'en,la',
|
||||
);
|
||||
bilingualBtn?.click();
|
||||
|
||||
const firstVerseColumns = root.querySelector('.psalm-verse .lang-columns');
|
||||
expect(firstVerseColumns?.classList.contains('lang-columns-2')).toBe(true);
|
||||
expect(firstVerseColumns?.querySelectorAll('.lang-column').length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client", "vite-plugin-pwa/client"]
|
||||
},
|
||||
"include": ["src", "tests", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import yaml from '@rollup/plugin-yaml';
|
||||
|
||||
// Served under reground.org/vu (Caddy path_mount to the vu container) — every
|
||||
// asset URL and the service worker's registration scope must be scoped to
|
||||
// this path, or they'll resolve against the domain root instead.
|
||||
const base = '/vu/';
|
||||
|
||||
export default defineConfig({
|
||||
base,
|
||||
plugins: [
|
||||
yaml(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
scope: base,
|
||||
base,
|
||||
manifest: {
|
||||
name: 'vu',
|
||||
short_name: 'vu',
|
||||
start_url: base,
|
||||
scope: base,
|
||||
display: 'standalone',
|
||||
background_color: '#f7f3ea',
|
||||
theme_color: '#7a2e2e',
|
||||
icons: [
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
// Precache everything the shell needs so a day's office renders
|
||||
// fully offline once visited once.
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user