# Advent Calendar Framework — Architecture & Contracts

This document is the single source of truth for how the framework fits together.
Every module, project and test in this repository is written against it.

## 1. Goals

- A **framework** (`core/`) for building digital advent calendars that work on phones and desktops.
- Three **projects** that use it:
  - `template/` — a starter you copy to make a new calendar (simple daily messages).
  - `AudioVideo/` — each door reveals a video, audio clip or picture.
  - `Puzzle/` — each door is an interactive puzzle you solve on the page.
- **No build step, no dependencies.** Plain ES modules + CSS. Any static host works
  (GitHub Pages, Netlify, the church website, a folder on a USB stick behind a local server).
- The calendar is a **core element** only (title + grid + door reveal). Site headers, footers and
  navigation come from the host website later.

## 2. The rules (date gating)

Let the *season year* be the year of the December the calendar is for (e.g. 2026 → December 2026 + January 2027).

| When (in the configured time zone)        | Which doors can be opened          | Which answers can be shown           |
|-------------------------------------------|------------------------------------|--------------------------------------|
| Before 1 December                         | none                               | none                                 |
| 1 December … `days` December (`advent`)   | today's door and every earlier day | doors from **yesterday and earlier** |
| Rest of December (`after-advent`)         | all                                | all                                  |
| January of the following year (`january`) | all                                | all                                  |
| Anything else (`closed`)                  | none (or all if `afterJanuary: 'open'`) | same as "can be opened"         |

- A door's **answer** is shown if the player **guesses correctly**, *or* from the **following day** onward.
- Doors without an `answer` simply reveal their content when opened.
- "Today" is the visitor's local date unless `timeZone` is set in config (recommended: `'Europe/London'`).
- For testing: `?date=YYYY-MM-DD` on the page URL overrides today when the page is served from
  `localhost`/`127.0.0.1`, or when the config sets `allowDateOverride: true`.

All of this is implemented in `core/gating.js` as pure functions (no DOM, no clock) and unit-tested.

## 3. Repository layout

```
core/                       # THE FRAMEWORK (shared by all projects)
  calendar.js               # <advent-calendar> custom element: grid, doors, dialog, animations
  advent-calendar.css       # all styling; themeable via --ac-* custom properties
  gating.js                 # pure date rules (phase, door state)         [contract — written first]
  storage.js                # progress persistence (localStorage w/ memory fallback) [contract]
  dom.js                    # h(), escapeHtml(), normalizeAnswer(), seeded rng  [contract]
  types/                    # door TYPE renderers (what shows inside an opened door)
    text.js                 #   type: 'text'   — HTML body
    media.js                #   type: 'media'  — image / video / audio / youtube / vimeo
    puzzle.js               #   type: 'puzzle' — loads core/puzzles/<kind>.js and wires check/solve/reveal
  puzzles/                  # PUZZLE KIND modules (interactive)
    answer-form.js          #   shared "type an answer + Check" form
    riddle.js  multiple-choice.js  anagram.js  code-lock.js
    wordsearch.js  memory-match.js  sliding-puzzle.js  ordering.js
  tests/                    # node --test unit tests (no browser needed)
template/                   # starter project: index.html + calendar.config.js + README.md
AudioVideo/                 # media calendar: index.html + calendar.config.js + media/ + README.md
Puzzle/                     # puzzle calendar: index.html + calendar.config.js + assets/ + README.md
tools/serve.js              # zero-dependency static dev server (npm start)
tools/screenshot.js         # Playwright screenshots of each project at phone + desktop widths
docs/ARCHITECTURE.md        # this file
index.html                  # landing page linking the three projects
package.json                # npm start / npm test / npm run screenshots
```

Every file inside `core/` must be generic: nothing church-specific, nothing project-specific.

## 4. Using the framework (what a project's `index.html` looks like)

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Puzzle Advent Calendar</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,500;0,9..144,700;1,9..144,500&family=Outfit:wght@500;700&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="../core/advent-calendar.css">
</head>
<body>
  <advent-calendar src="./calendar.config.js"></advent-calendar>
  <script type="module" src="../core/calendar.js"></script>
</body>
</html>
```

`src` is resolved relative to the page (`new URL(src, document.baseURI)`) and dynamically imported.
Programmatic use is also supported:

```js
import { AdventCalendar } from '../core/calendar.js';
document.querySelector('advent-calendar').init(config);
```

## 5. Calendar config (`calendar.config.js`)

```js
export default {
  id: 'puzzle-2026',                 // REQUIRED. Storage key for progress. Change it → progress resets.
  title: 'A little wonder, every day.',   // optional headline (omit → no heading rendered)
  subtitle: '25 days of Christmas at Glendale', // optional
  year: 2026,                        // optional season year. Default: current year (or previous year if it is January)
  days: 25,                          // 24 or 25 (default 25)
  timeZone: 'Europe/London',         // optional IANA zone. Default: visitor's local time
  locale: 'en-GB',                   // optional, for date wording ("12 December")
  afterJanuary: 'locked',            // 'locked' (default) | 'open'
  allowDateOverride: false,          // honour ?date=YYYY-MM-DD anywhere (always honoured on localhost)
  messages: {                        // all optional; {date} and {days} are substituted
    before: 'The first door opens on 1 December.',
    countdown: 'Opens in {days} days',          // shown under `before` while counting down
    locked: 'Opens {date}',                      // toast when tapping a locked door
    today: 'Door {day} is open today.',
    allOpen: 'Every door is open — enjoy the whole calendar.',
    closed: 'This calendar has closed for the year.',
    tryAgain: 'Not quite — try again.',
    revealTomorrow: 'The answer will be revealed tomorrow.',
    correct: 'That’s right!',
    showAnswer: 'Show the answer',
    hint: 'Need a hint?',
  },
  background: { image: './bg.jpg' }, // optional illustration behind the grid (cover, centre)
  doors: [ /* exactly `days` entries, days 1..days, see §6 */ ],
};
```

## 6. Door config

```js
{
  day: 1,                       // 1..days (REQUIRED, unique)
  type: 'text',                 // 'text' (default) | 'media' | 'puzzle'
  title: 'Make room for kindness',           // shown in the opened card
  body: '<p>Send someone a friendly message today.</p>',  // HTML string. Used by 'text'; optional for others (shown above the media/puzzle)
  media: { ... },               // when type === 'media' — see core/types/media.js (§8)
  puzzle: { kind: 'riddle', ... }, // when type === 'puzzle' — see §9
  answer: {                     // optional on ANY door type
    accept: ['star', 'the star of bethlehem'],   // strings; matching is case/punctuation/accent/whitespace-insensitive
                                                  // and ignores a leading "the", "a", "an"
    reveal: '<p>The answer is <strong>a star</strong>. …</p>',  // HTML shown when solved / when the answer is available
  },
  hint: 'It shines in the east.', // optional; a "Need a hint?" button shows it
}
```

- A `text` or `media` door **with** an `answer` gets a simple answer box (the riddle form) beneath its content.
- A `puzzle` door **without** `answer.accept` must be self-checking (wordsearch, memory-match, sliding-puzzle, ordering);
  `answer.reveal` is still optional on those (shown after solving / when available).

## 7. Core contracts (already written — do not change signatures)

### `core/gating.js`
```js
PHASES = { BEFORE:'before', ADVENT:'advent', AFTER_ADVENT:'after-advent', JANUARY:'january', CLOSED:'closed' }
resolveSeasonYear(today, configuredYear)  → number
getPhase(today, seasonYear, { days })     → phase string
getDoorState(day, today, seasonYear, { days, afterJanuary })
   → { day, phase, openable, answerAvailable, isToday, opensOn:{year,month,day} }
todayParts(date = new Date(), timeZone?)  → { year, month, day }   (month 1-12)
parseDateParts('YYYY-MM-DD')              → { year, month, day } | null
daysUntil(fromParts, toParts)             → integer (calendar days)
formatDate(parts, locale = 'en-GB')       → '12 December'
```
`today` is always a parts object `{ year, month, day }`.

### `core/storage.js`
```js
createStore(calendarId, { storage?, clock? })  → store
store.read()                 → { version, opened:{[day]:iso}, solved:{[day]:iso}, attempts:{[day]:n}, revealed:{[day]:iso}, hinted:{[day]:iso} }
store.isOpened(day) / isSolved(day) / isRevealed(day) / isHinted(day) → boolean
store.attempts(day)          → number
store.markOpened(day) / markSolved(day) / markRevealed(day) / markHinted(day) / addAttempt(day)
store.reset()
memoryStorage()              → in-memory Storage-like object (used automatically when localStorage is unavailable)
```

### `core/dom.js`
```js
h(tag, attrs?, ...children)      // attrs: class/className, style (string|object), dataset:{...}, on<Event>: fn, aria-*, any attribute; children: string | Node | array | null
svg(markupString)                // parse an inline SVG string → SVGElement
escapeHtml(str)
normalizeAnswer(str)             // lowercase, NFD + strip diacritics, strip punctuation, collapse whitespace, drop leading "the/a/an"
answerMatches(guess, accept)     // accept: string | string[] → boolean (uses normalizeAnswer)
hashString(str)                  // 32-bit FNV-1a → integer
seededRandom(seed)               // mulberry32 → () => float in [0,1)
shuffle(array, rng = Math.random)// returns a NEW shuffled array (Fisher–Yates)
```

## 8. Door type renderer contract (`core/types/<type>.js`)

```js
export default {
  type: 'media',
  /** Render into `root` (an empty element inside the opened card). May return a cleanup function. */
  render(root, ctx) { … },
};
```
`ctx` is the **door context** (§10). `core/calendar.js` lazy-loads `./types/${door.type}.js` on first use.
`registerDoorType(module)` (exported from calendar.js) lets a project add custom types without touching core.

## 9. Puzzle kind contract (`core/puzzles/<kind>.js`)

```js
export default {
  kind: 'wordsearch',
  /** Build the interactive puzzle into `root`. May return a cleanup function. */
  mount(root, ctx) { … },
  /** Optional. Show the solved state (e.g. highlight all words). Called after solving AND when the
      answer is revealed because the day has passed. Must be safe to call more than once. */
  reveal(root, ctx) { … },
};
```
`core/types/puzzle.js` lazy-loads `../puzzles/${door.puzzle.kind}.js`, calls `mount`, and renders the
shared chrome around it: feedback line (aria-live), hint button, "Show the answer" button (only when
`state.answerAvailable && !state.solved`), and the reveal panel (`answer.reveal` HTML).
After solving: root gets class `is-solved`, `reveal()` is called, and the puzzle root is made `inert`.

Puzzle kinds and their data (`door.puzzle`):

| kind              | fields                                                                                   | checks via |
|-------------------|------------------------------------------------------------------------------------------|------------|
| `riddle`          | `prompt` (HTML), `emoji?` (big emoji line), `image?` (url), `imageAlt?`, `blur?` (number of wrong guesses until fully sharp, default 0 = no blur), `placeholder?`, `inputMode?` ('text'\|'numeric') | `answer.accept` |
| `multiple-choice` | `prompt`, `options: string[]`, `shuffle?: boolean`                                        | `answer.accept` (must contain the correct option text) |
| `anagram`         | `prompt?`, `letters?` (string; default = first `answer.accept` entry scrambled with `ctx.rng`) — tap letters to build the word, backspace/clear, or type | `answer.accept` |
| `code-lock`       | `prompt`, `digits` (2–6), `clue?: string[]` (one clue per digit, optional)                | `answer.accept` (e.g. `['1225']`) |
| `wordsearch`      | `prompt?`, `words: string[]`, `size?` (default 10), `grid?: string[]` (rows; generated deterministically when absent) | self (all words found → `api.solve()`) |
| `memory-match`    | `prompt?`, `items?: string[]` (each used twice) **or** `pairs?: [{a,b}]`                  | self |
| `sliding-puzzle`  | `prompt?`, `image?` (url; without it tiles show numbers), `size?` (3 or 4, default 3)     | self |
| `ordering`        | `prompt`, `items: string[]` **in the correct order** (presented shuffled)                  | self (Check button → `api.solve()` or `api.fail()`) |

All puzzles must be usable with touch, mouse **and keyboard**, and look right at 320px wide.
Use `ctx.rng()` (seeded) for any shuffling so a reload shows the same layout.

## 10. Door context (`ctx`) passed to type renderers and puzzle kinds

```js
ctx = {
  day: 5,
  door,                       // the door config entry
  puzzle: door.puzzle,        // convenience
  answer: door.answer,        // convenience (may be undefined)
  config,                     // the whole calendar config
  state: {                    // live; re-read after api calls
    openable, isToday, answerAvailable, opensOn,   // from gating
    solved, revealed, attempts, hinted,            // from storage
  },
  api: {
    check(guess)   → boolean  // compares with answer.accept; true → marks solved & shows reveal; false → adds attempt & shows "try again"
    solve()                   // self-checking puzzles: mark solved & show reveal
    fail(message?)            // record a wrong attempt and show feedback
    reveal()                  // show the answer if state.solved || state.answerAvailable (no-op otherwise)
    hint()                    // show the hint (if any)
    seed,                     // integer, stable per (config.id, day)
    rng,                      // seeded PRNG () => [0,1)
    h, svg, escapeHtml, normalizeAnswer, shuffle,   // helpers from core/dom.js
    t(key, vars?)             // message lookup from config.messages with defaults, {var} substitution
  },
};
```

## 11. Visual design (from the reference artwork)

Deep teal night sky, antique-gold doors, cream parchment reveal. Everything via CSS custom properties
(`--ac-*`) so the church template can re-theme without editing core.

```css
:root {
  --ac-sky:        #0d3447;   /* night-sky teal */
  --ac-sky-deep:   #082330;
  --ac-sky-light:  #175066;   /* horizon glow */
  --ac-door:       #0f3d52;
  --ac-door-edge:  #0a2c3c;
  --ac-gold:       #d4af5a;
  --ac-gold-deep:  #b8923f;
  --ac-gold-light: #efd68a;
  --ac-cream:      #f6ecd6;
  --ac-parchment:  #fbf4e4;
  --ac-ink:        #22302f;
  --ac-ink-soft:   #4b5a58;
  --ac-font-display: 'Outfit', 'Segoe UI', system-ui, sans-serif;   /* headline, card title */
  --ac-font-serif:   'Fraunces', 'Cormorant Garamond', Georgia, serif; /* numerals, subtitle, Day N */
}
```

- **Backdrop**: teal gradient (deep at top → lighter at bottom) with a CSS star-field (layered
  `radial-gradient` dots, two or three sizes) and a soft vignette. Optional `background.image` sits behind it.
- **Heading** (only if `title`): cream, display font, ~clamp(1.8rem, 5vw, 3rem); subtitle in gold serif italic.
- **Grid**: 3 columns under 640px, 5 columns from 640px; gap ~clamp(10px, 2.5vw, 22px); max-width 760px, centred.
- **Door tile** (`<button class="ac-door">`): aspect-ratio 4/5; deep teal fill; double gold border
  (thin outer line + inset thin inner line); gold "hinge" nubs on left & right edges; tiny 4-point gold star
  centred near the top; large gold serif numeral centred; small gold sprig (inline SVG) near the bottom.
  Two silhouettes: `ac-door--notched` (rectangle with small concave notched corners — use `clip-path`
  or a `mask`) and `ac-door--arch` (rounded top). Arches sit in the **centre column**:
  `:nth-child(3n+2)` under 640px, `:nth-child(5n+3)` at 640px+. The last door (`ac-door--final`) is solid gold
  with a cream numeral; on the 3-column grid it sits alone in the centre column of the last row.
- **States**: `is-locked` (desaturated/dimmed, padlock badge in the top-right corner on hover/focus,
  `aria-disabled="true"`, tap → shake + toast "Opens 12 December"; the locked final door stays dimmed gold
  with a soft cream numeral); `is-openable`; `is-today` (soft pulsing gold glow); `is-opened` (warm gold inner
  glow, numeral turns cream, door looks ajar); `is-solved` (small gold tick badge, same corner as the padlock).
  Hover/focus: lift + brighter gold. Visible `:focus-visible` ring.
- **Reveal dialog** (`<dialog class="ac-dialog">`): dark translucent backdrop. Scene = two door leaves
  (same decoration as the tile, the right leaf's sprig mirrored) hinged on the outer edges that swing open
  (`rotateY(±105deg)`, ~0.9s, `transform-origin` at the hinge edge, `perspective` on the scene) revealing a
  parchment card with a gold frame and warm glow. From 1200px wide the leaves swing on to `±150deg` and stay,
  standing open either side of the full-width card with their inner faces showing; below that they disappear
  once open, so a narrower screen never squeezes the card to make room for them.
  Card: "Day N" (gold serif small caps), title (display font, ink), content, feedback,
  actions. Close button (top-right, cream circle). Previous/next day buttons (only to openable days): a row
  under the card, or round arrows floating beside it between 900px and 1199px.
  Under `prefers-reduced-motion: reduce` nothing animates — the card just appears (with the leaves already
  standing open from 1200px). The dialog scrolls internally on small screens (`max-height: 100dvh`).
- **Toast** (`.ac-toast`): small pill at the bottom, auto-hides after ~2.5s, `role="status"`.
- Minimum tap target 44px. Contrast: gold on teal and ink on parchment both pass WCAG AA.

## 12. Accessibility

- Doors are real `<button>`s with `aria-label="Day 5 — opens 5 December"` / `"Day 5 — open"` / `"Day 5 — opened"` / `"Day 5 — solved"`.
- Locked doors are **not** `disabled` (so they can explain themselves); they use `aria-disabled` and a toast.
- Dialog uses native `<dialog>` + `showModal()` (focus trap, Esc closes). Focus goes to the card heading.
- Feedback messages live in an `aria-live="polite"` region.
- All puzzles are operable by keyboard; drag interactions always have a non-drag alternative.
- Respects `prefers-reduced-motion`.

## 13. Testing & verification

- `npm test` → `node --test core/tests/*.test.js` (gating, storage, dom helpers, puzzle helpers).
- `npm start` → `node tools/serve.js` (serves the repo root on http://localhost:8080).
- `npm run screenshots` → Playwright renders every project at 390×844 and 1280×900 for several `?date=` values into `screenshots/`.
- Manual: `http://localhost:8080/Puzzle/?date=2026-12-10`.
