Milestone 0/1: shell, calendar/psalter/hours scaffold, Prime
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:
2026-08-09 15:20:06 -04:00
commit a5dc6502d7
53 changed files with 8634 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.git
+47
View File
@@ -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 }}
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
dev-dist
*.local
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+7107
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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

+64
View File
@@ -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();
}
+5
View File
@@ -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 {};
+8
View File
@@ -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 {};
+6
View File
@@ -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 {};
+23
View File
@@ -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';
+8
View File
@@ -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 {};
+46
View File
@@ -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[];
}
+24
View File
@@ -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;
}
+18
View File
@@ -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
+18
View File
@@ -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
+12
View File
@@ -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]
+14
View File
@@ -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 }
+27
View File
@@ -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
+16
View File
@@ -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]
+6
View File
@@ -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 };
}
+41
View File
@@ -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';
+7
View File
@@ -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 };
}
+8
View File
@@ -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 };
}
+7
View File
@@ -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 };
}
+61
View File
@@ -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 };
}
+7
View File
@@ -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 };
}
+7
View File
@@ -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 };
}
+76
View File
@@ -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;
}
+7
View File
@@ -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
View File
@@ -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 });
+12
View File
@@ -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] ?? [];
}
+22
View File
@@ -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';
+16
View File
@@ -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[];
}
+56
View File
@@ -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);
});
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.yml' {
const value: unknown;
export default value;
}
+32
View File
@@ -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);
});
});
}
+17
View File
@@ -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">&larr;</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">&rarr;</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());
}
+17
View File
@@ -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);
}
+34
View File
@@ -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);
});
});
}
+80
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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>
`;
}
+37
View File
@@ -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);
}
+319
View File
@@ -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;
}
}
+40
View File
@@ -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([]);
});
});
+41
View File
@@ -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([]);
});
});
+74
View File
@@ -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);
});
});
+18
View File
@@ -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"]
}
+43
View File
@@ -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,
},
});