# Advent Calendars

A small, dependency-free framework for **digital advent calendars** that work on phones and
desktops, plus three ready-made calendars built on it:

- **`template/`** — the starter: a short message, task or question behind every door. Copy it to make your own.
- **`AudioVideo/`** — a picture, video or audio clip behind every door.
- **`Puzzle/`** — an interactive puzzle behind every door, solved right on the page.

Doors unlock day by day through December, everything is open in January, and an answer appears
the day after its door opens, or as soon as you guess it. Progress is remembered on the visitor's
device. No build step, no server code: it is plain HTML, CSS and ES modules, so it runs on any
static host and drops into an existing website as a single custom element.

The calendar is the **core element only** (title, grid of doors, door reveal). The host website
supplies the page header, footer and navigation around it.

## The rules of the calendar

The *season year* is the year of the December the calendar is for (2026 means December 2026 plus
January 2027). "Today" is worked out in the configured `timeZone`.

| When                                         | 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.
- Tapping a locked door shakes it and shows when it opens ("Opens 12 December").

## The three projects

| Project                      | What is behind the doors                              | Config                                                |
|------------------------------|-------------------------------------------------------|-------------------------------------------------------|
| [template/](template/)       | Text: a message, a task, sometimes a question         | [template/calendar.config.js](template/calendar.config.js) |
| [AudioVideo/](AudioVideo/)   | Pictures, video and audio from `AudioVideo/media/`     | [AudioVideo/calendar.config.js](AudioVideo/calendar.config.js) |
| [Puzzle/](Puzzle/)           | Riddles, anagrams, code locks, wordsearches and more  | [Puzzle/calendar.config.js](Puzzle/calendar.config.js) |

Each project folder has its own README with notes specific to that calendar. The root
[index.html](index.html) is a landing page that links to all three.

## Quick start

```bash
git clone <this repository> advent-calendars
cd advent-calendars
npm start
```

`npm start` runs `tools/serve.js`, a tiny static server with no dependencies, and prints the URLs:

```
http://localhost:8080/              landing page
http://localhost:8080/template/
http://localhost:8080/AudioVideo/
http://localhost:8080/Puzzle/
```

Use `PORT=3000 npm start` for another port, or `HOST=0.0.0.0 npm start` to open the calendar on a
phone on the same Wi-Fi (the server then prints the address to type in).

### Previewing a date

Add `?date=YYYY-MM-DD` to a calendar's address to see it as it will look on that day:

```
http://localhost:8080/Puzzle/?date=2026-11-20    before advent: everything locked, countdown shown
http://localhost:8080/Puzzle/?date=2026-12-01    first door open
http://localhost:8080/Puzzle/?date=2026-12-12    doors 1–12 open, answers available for 1–11
http://localhost:8080/Puzzle/?date=2027-01-03    January: everything open
```

The override is always honoured on `localhost` / `127.0.0.1`. On any other host it only works when
the config sets `allowDateOverride: true` (the sample configs do, so you can share previews; remove
it for a live calendar).

## Making your own calendar

1. Copy `template/` to a new folder (for example `christmas-2026/`).
2. Edit `calendar.config.js`: give the calendar a new `id`, set the `year`, and write the 24 or 25 doors.
3. Open the folder through `npm start` and preview it with `?date=`.
4. Deploy the whole repository (or just your folder plus `core/`) to any static host (see below).

The `index.html` in your folder stays as it is; everything you change lives in the config file.

### `calendar.config.js`

```js
export default {
  id: 'christmas-2026',               // REQUIRED. Storage key for progress. Change it → everyone's progress resets.
  title: 'A little wonder, every day.', // optional headline (omit → no heading rendered)
  subtitle: '25 days of Christmas',   // optional, under the title
  year: 2026,                         // season year. Default: current year (or previous year in January)
  days: 25,                           // 24 or 25 (default 25)
  timeZone: 'Europe/London',          // IANA zone used to decide "today". Default: visitor's local time
  locale: 'en-GB',                    // for date wording ("12 December")
  afterJanuary: 'locked',             // 'locked' (default) | 'open' — what happens from February onwards
  allowDateOverride: false,           // honour ?date=YYYY-MM-DD anywhere (always honoured on localhost)
  messages: {                         // all optional; {date}, {day} and {days} are substituted
    before: 'The first door opens on 1 December.',
    countdown: 'Opens in {days} days',
    locked: 'Opens {date}',
    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, centred)
  doors: [ /* exactly `days` entries, days 1..days */ ],
};
```

| Field               | Required | Meaning                                                                                                      |
|---------------------|----------|--------------------------------------------------------------------------------------------------------------|
| `id`                | yes      | Unique name for this calendar. Progress is stored under it in the browser; change it and everyone starts afresh. |
| `title`, `subtitle` | no       | Heading above the grid. Leave `title` out and no heading is rendered (useful when the host page has its own). |
| `year`              | no       | The December the calendar is for. Omit to roll over to the current season automatically.                      |
| `days`              | no       | 24 or 25 doors (default 25).                                                                                  |
| `timeZone`          | no       | IANA time zone that decides when a day starts. `'Europe/London'` is recommended so everyone opens doors together. |
| `locale`            | no       | Locale for date wording in messages.                                                                         |
| `afterJanuary`      | no       | `'locked'` (default) hides everything from February; `'open'` leaves the whole calendar browsable.           |
| `allowDateOverride` | no       | Let `?date=` work on any host, not just localhost. Handy for sharing previews; remove for a live calendar.    |
| `messages`          | no       | Override any of the built-in wording. Placeholders `{date}`, `{day}`, `{days}` are filled in.                 |
| `background`        | no       | `{ image: url }` — an illustration behind the star field.                                                    |
| `doors`             | yes      | One entry per day, see below.                                                                                 |

### A door

```js
{
  day: 3,                         // 1..days, unique (REQUIRED)
  type: 'text',                   // 'text' (default) | 'media' | 'puzzle'
  title: 'A question for today',  // shown at the top of the opened card
  body: '<p>What did the wise men follow?</p>',  // HTML. The content of a text door; optional intro for media/puzzle doors
  media: { … },                   // when type is 'media'
  puzzle: { kind: 'riddle', … },  // when type is 'puzzle'
  answer: {                       // optional on ANY door type
    accept: ['star', 'the star of bethlehem'],  // any of these counts as correct. Matching ignores case,
                                                // punctuation, accents, extra spaces and a leading "the", "a" or "an"
    reveal: '<p>They followed <strong>a star</strong>.</p>',  // HTML shown once solved, or from the next day
  },
  hint: 'It shines in the east.', // optional; adds a "Need a hint?" button
}
```

Config content is written by you, the site admin, so `body`, `reveal` and prompts may contain HTML.
Anything a visitor types is always treated as plain text.

The full contract, including the `ctx` object handed to door types and puzzles, is in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).

## Door types

**`text`** — `body` HTML. Add an `answer` and the door gets a simple answer box beneath the text.

**`media`** — `media: { kind, src, poster?, alt?, caption?, autoplay?, loop? }` where `kind` is
`image`, `video`, `audio`, `youtube` or `vimeo`. Local files go in the project's `media/` folder
(`src` may also be a list of `{ src, type }` sources for video and audio); YouTube and Vimeo take
the video's id or URL and load nothing from the third party until the visitor presses play. `alt`
describes an image for screen readers, `poster` is the still shown before playing (or cover art for
audio) and `caption` is HTML under the media. An `answer` adds an answer box under the media
("How many crowns can you count?").

**`puzzle`** — `puzzle: { kind, … }` renders an interactive puzzle with a feedback line, hint button,
"Show the answer" button (once the answer is available) and the `answer.reveal` panel. Kinds:

| Kind              | Data                                                                                   | Checked by      |
|-------------------|----------------------------------------------------------------------------------------|-----------------|
| `riddle`          | `prompt`, `emoji?`, `image?` + `imageAlt?`, `blur?` (wrong guesses until the image is sharp), `placeholder?`, `inputMode?` | `answer.accept` |
| `multiple-choice` | `prompt`, `options: string[]`, `shuffle?`                                              | `answer.accept` (contains the correct option) |
| `anagram`         | `prompt?`, `letters?` (default: the answer scrambled) — tap letters or type             | `answer.accept` |
| `code-lock`       | `prompt`, `digits` (2–6), `clue?: string[]`                                            | `answer.accept` (e.g. `['1225']`) |
| `wordsearch`      | `prompt?`, `words: string[]`, `size?`, `grid?`                                         | itself          |
| `memory-match`    | `prompt?`, `items?: string[]` or `pairs?: [{ a, b }]`                                  | itself          |
| `sliding-puzzle`  | `prompt?`, `image?`, `size?` (3 or 4)                                                  | itself          |
| `ordering`        | `prompt`, `items: string[]` in the correct order (shown shuffled)                      | itself          |

Self-checking puzzles do not need `answer.accept`; `answer.reveal` is still optional on them.
Every puzzle works with touch, mouse and keyboard, and layouts are seeded so a reload shows the same
arrangement. New kinds are added by dropping a module into `core/puzzles/` (see the architecture
notes); new door types can be registered from a project with `registerDoorType()`.

## Embedding in an existing website

The calendar is one custom element. Put these three lines on any page that can serve the `core/`
folder:

```html
<link rel="stylesheet" href="/calendar/core/advent-calendar.css">

<advent-calendar src="/calendar/christmas-2026/calendar.config.js"></advent-calendar>

<script type="module" src="/calendar/core/calendar.js"></script>
```

`src` is resolved relative to the page. The element renders only the calendar (title, grid, door
reveal); your page keeps its own header, footer and navigation around it. The fonts in the sample
pages (Fraunces and Outfit from Google Fonts) are optional; without them the calendar falls back to
system fonts.

Programmatic use, if you prefer to build the config in code:

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

### Theming

Every colour and font is a CSS custom property, so the host page re-themes the calendar without
touching `core/`:

```css
advent-calendar {
  --ac-sky: #0d3447;        /* night sky */
  --ac-sky-deep: #082330;
  --ac-sky-light: #175066;
  --ac-door: #0f3d52;       /* door tile */
  --ac-door-edge: #0a2c3c;
  --ac-gold: #d4af5a;       /* borders, numerals, buttons */
  --ac-gold-deep: #b8923f;
  --ac-gold-light: #efd68a;
  --ac-cream: #f6ecd6;      /* text on the sky */
  --ac-parchment: #fbf4e4;  /* the opened card */
  --ac-ink: #22302f;        /* text on parchment */
  --ac-ink-soft: #4b5a58;
  --ac-font-display: 'Outfit', system-ui, sans-serif;
  --ac-font-serif: 'Fraunces', Georgia, serif;
}
```

## Deploying

Anything that serves static files works: GitHub Pages, Netlify, Cloudflare Pages, the church
website's own hosting, or a folder on a USB stick behind a local server. There is nothing to build.

**Cloudflare Pages (set up for this repository)**

`.github/workflows/deploy.yml` publishes the site to the Cloudflare Pages project
**advent-calendars** whenever `main` changes. You can also run it by hand from the Actions tab
(Deploy, Run workflow). It runs the unit tests and copies only what visitors need into `dist/`: the
landing page, `core/` without its tests, the three calendars and the two documents the landing page
links to. It then publishes, and checks that the live address serves exactly those files. The run's
summary page shows the live address. It needs two repository secrets:

- `CLOUDFLARE_API_TOKEN`: an API token with the "Cloudflare Pages: Edit" permission
- `CLOUDFLARE_ID`: the Cloudflare account ID (a secret named `CLOUDFLARE_ACCOUNT_ID` works too)

If the account has no project of that name, the first run creates it. If the tests fail, nothing
is published and the live site stays as it was.

**GitHub Pages**

1. Push the repository to GitHub.
2. In the repository go to **Settings → Pages**.
3. Under **Build and deployment** choose **Deploy from a branch**, pick your branch (usually `main`)
   and the **`/ (root)`** folder, then save.
4. After a minute the site is live at `https://<user>.github.io/<repository>/`, with the calendars at
   `/template/`, `/AudioVideo/` and `/Puzzle/`.

Remember to remove `allowDateOverride: true` from a live config, otherwise anyone can peek ahead by
adding `?date=`.

## Testing

```bash
npm test               # unit tests for the date rules, storage and helpers (node --test, no browser)
npm run screenshots    # renders every project at phone and desktop sizes into screenshots/
```

The screenshot script needs Playwright with Chromium (`npm i -D playwright && npx playwright install chromium`).
It starts the dev server itself (or uses `BASE_URL=http://localhost:8080` if you already have one),
renders each project for 20 Nov, 1 Dec, 12 Dec and 3 Jan, opens door 5 on 12 Dec, and fails if any
page logs a console error. Continuous integration runs `npm test` on every push and pull request
(`.github/workflows/ci.yml`).

## How progress is stored

Opened doors, solved puzzles, wrong-guess counts and revealed answers are kept in the browser's
`localStorage` under the calendar's `id`. Nothing is sent anywhere, so progress is per device and
per browser. Two calendars on one site never share progress because the key includes the `id`,
and **changing the `id` resets everyone's progress** (useful for a fresh start each year). Where
storage is unavailable (private browsing, restrictive settings) the calendar still works; it just
forgets between visits.

## A note on secrecy

The answers live in `calendar.config.js`, which the browser downloads. A determined person can open
the developer tools and read tomorrow's answer, or add `?date=` if the override is enabled. For a
church or family calendar that is fine: the date gating is a gentle rule for people playing along,
not a lock. If you ever need real secrecy (prizes, competitions), the answers would have to be kept
on a server that only releases each day's content when the day arrives.

## Licence

MIT. See [LICENSE](LICENSE).
