diff --git a/.github/workflows/refresh-downloads.yml b/.github/workflows/refresh-downloads.yml
new file mode 100644
index 0000000..fb628de
--- /dev/null
+++ b/.github/workflows/refresh-downloads.yml
@@ -0,0 +1,60 @@
+name: Refresh mod downloads
+
+# Keeps the `downloads` branch: the hub's own copies of each mod's current ZIP (the release its Download button
+# offers) and their manifest.json, which the "Download several at once" picker joins in the browser. GitHub
+# doesn't let a page fetch release downloads; its raw file host does, so the picker reads this branch there.
+# Runs after the hourly release refresh has had time to commit (:17), and whenever the page or these scripts
+# change. The branch is replaced by one fresh commit each time, so it never grows.
+on:
+ schedule:
+ - cron: '37 * * * *'
+ push:
+ branches: [main]
+ paths:
+ - index.html
+ - scripts/update-downloads.mjs
+ - scripts/downloads-lib.mjs
+ - .github/workflows/refresh-downloads.yml
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+concurrency:
+ group: refresh-downloads
+ cancel-in-progress: false
+
+jobs:
+ refresh:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ ref: main
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 22
+ - name: Fetch the current copies
+ run: |
+ mkdir -p mirror
+ if git fetch --depth=1 origin downloads; then
+ git archive FETCH_HEAD | tar -x -C mirror
+ else
+ echo "No downloads branch yet: starting empty."
+ fi
+ - id: update
+ run: node scripts/update-downloads.mjs mirror
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Publish the downloads branch
+ if: steps.update.outputs.changed == 'true'
+ run: |
+ cd mirror
+ git init -q -b downloads
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add -A
+ git commit -q -m "Mod downloads for the Download several picker"
+ git push --force "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" downloads
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index fee63cc..5ca0d9b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
.claude/
node_modules/
+_mirror/
diff --git a/.impeccable/design.json b/.impeccable/design.json
index 5628d36..29b92fd 100644
--- a/.impeccable/design.json
+++ b/.impeccable/design.json
@@ -1008,6 +1008,13 @@
"description": "A paper panel without a mod colour, in a 5px double rule; fills a board's spare slot.",
"html": "
Which one? One BeaverBuddies at a time
Stability Fork
Everyone builds one colony together.
Timber Together
Each player runs a colony of their own.
Install only one of the two.
",
"css": ".ds-notice { max-width: 320px; background: var(--tile, #f5ecd9); color: var(--tile-ink, #2a2118); border: 5px double var(--tile-rule, #dccdab); border-radius: 8px; padding: 22px 20px; box-shadow: 0 2px 0 rgba(0,0,0,.22), 0 12px 20px -12px rgba(0,0,0,.65); font-family: var(--sans, system-ui, sans-serif); } .ds-notice h3 { font-family: var(--display, 'Anybody', 'Arial Narrow', sans-serif); font-weight: 800; font-size: 1.35rem; line-height: 1.08; margin: 0 0 14px; } .ds-notice dl { margin: 0 0 14px; } .ds-notice dl > div { padding: 10px 0; border-top: 1px solid var(--tile-rule, #dccdab); } .ds-notice dt { font-family: var(--display, 'Anybody', 'Arial Narrow', sans-serif); font-weight: 800; } .ds-notice dt::before { content: ''; display: inline-block; width: 10px; height: 10px; margin-right: 8px; border-radius: 2px; background: var(--c); } .ds-notice dd { margin: 4px 0 0; color: var(--tile-muted, #5d4c39); font-size: .95rem; } .ds-notice > p { margin: 0; padding-top: 12px; border-top: 1.5px solid var(--tile-ink, #2a2118); font-weight: 650; font-size: .95rem; }"
+ },
+ {
+ "name": "Download Picker",
+ "kind": "form",
+ "description": "\"Download several at once\": a paper sheet with a Multiplayer radio group (None, Stability Fork, Timber Together: only one can run) and a two-column checklist of the other mods, each row a pine control, the mod's swatch and name, and its version and size; a pine download button and an aria-live status line span the foot. assets/bundle.js joins the picked mods' ZIPs into one in the browser.",
+ "html": "",
+ "css": ".ds-picker{max-width:520px;background:#f5ecd9;color:#2a2118;border:1px solid #dccdab;border-radius:6px;box-shadow:0 2px 0 rgba(0,0,0,.12),0 12px 24px -14px rgba(0,0,0,.45);font:16px/1.5 system-ui,sans-serif}.ds-picker fieldset{border:0;margin:0;padding:20px 24px 12px}.ds-picker legend{font:800 1.15rem/1.2 Anybody,'Arial Narrow',sans-serif;padding:0;margin-bottom:10px}.ds-pick{display:grid;grid-template-columns:auto 1fr;gap:12px;align-items:center;min-height:54px;border-bottom:1px solid #dccdab}.ds-pick input{width:20px;height:20px;accent-color:#2f5d3a}.ds-pick b{display:inline-flex;align-items:center;gap:8px;font:700 .98rem/1.2 Anybody,'Arial Narrow',sans-serif}.ds-pick b::before{content:'';width:12px;height:12px;border-radius:3px;background:var(--c);box-shadow:0 0 0 2px #f5ecd9,0 0 0 3px var(--c)}.ds-pick small{display:block;padding-left:20px;color:#5d4c39;font-size:.85rem}.ds-pick-go{margin:4px 24px 20px;min-height:48px;padding:8px 18px;border:0;border-radius:8px;background:#2f5d3a;color:#f5ecd9;font-weight:750}"
}
],
"narrative": {
diff --git a/CLAUDE.md b/CLAUDE.md
index 772c5ac..b67e5fd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,7 +10,9 @@ main).
- Never launch or drive Timberborn, and never touch installed mods or saves. The maintainer (Kyler) playtests himself.
- Commit on a branch and open a PR. Kyler has said to merge PRs automatically: merge, then check the page live.
-- **Never edit `data/releases.json`.** `.github/workflows/refresh-releases.yml` regenerates it hourly
+- **Never edit `data/releases.json` or the `downloads` branch.** The branch holds the download picker's copies of
+ each mod's ZIP; `.github/workflows/refresh-downloads.yml` replaces it hourly (`node scripts/update-downloads.mjs`).
+- About `data/releases.json`: `.github/workflows/refresh-releases.yml` regenerates it hourly
(`node scripts/update-releases.mjs`) and commits straight to `main` as github-actions[bot]. So `main` moves several
times a day: before a PR run `git fetch origin && git merge origin/main`, and on a conflict in that file take
main's copy (`git checkout --theirs data/releases.json`).
@@ -41,7 +43,8 @@ text and the player docs follows these rules.
- **Where:** the repo root: `index.html` (opening with the need index, three walnut boards of mods, install, footer),
`404.html` (uses root paths `/assets/...`; every other path is relative), `assets/style.css`, `assets/site.js`
- (download buttons, theme toggle), `assets/fonts/`, `assets/img/`, `assets/favicon.svg`. Live at
+ (download buttons, theme toggle), `assets/bundle.js` + `assets/zipmerge.js` (the "Download several at once"
+ picker, `#bundle`: joins the picked mods' ZIPs in the browser), `assets/fonts/`, `assets/img/`, `assets/favicon.svg`. Live at
https://timbermods.github.io/.
- **Published:** GitHub Pages "legacy" build from `main`, folder `/` (`.nojekyll` stays). Merging to main publishes;
a build takes about a minute.
@@ -82,7 +85,8 @@ text and the player docs follows these rules.
#f0d27a, caution #f4dfb3 on #663a04 / #3b301b on #f1cf8b, Stable pill #2c6536 / #9fd49a, Preview pill #7a4a06 /
#f0c070.
- Fonts: Anybody 700/800, self-hosted in `assets/fonts/` (`anybody-latin-{700,800}-normal.woff2`, `OFL-Anybody.txt`).
- No other webfonts, nothing from a CDN; the only external requests are GitHub's API.
+ No other webfonts, nothing from a CDN; the only external requests are GitHub's API and the picker's reads of the
+ `downloads` branch from raw.githubusercontent.com.
- Textures: `birch.webp`, `forest-floor.webp`, `walnut.webp`, `walnut-dark.webp`, made by `assets/img/make_textures.py`
(numpy + Pillow, fixed seeds; run it from `assets/img/`). Change the script, don't edit images. Every shipping raster
carries provenance (`.webp.json` sidecar, or a tEXt chunk in `og.png`): after adding or changing one, run (with `$IMP`
@@ -118,7 +122,10 @@ text and the player docs follows these rules.
- A caution is `
…Label: …
` inside the card; `data-maturity` equals Label (e.g. `Beta`,
`Young mod`). Remove both together when a mod is ready. Any `.caution` outside a card fails the test.
- Footer: one `[data-generated]` with default text "Release data loads from GitHub.".
-- Anchors: `#top`, `#mods`, `#install` (the org profile links `/#install`) and the eight card ids.
+- The picker: `form[data-bundle]`, inputs named `multiplayer` (radios, `value=""` = None) and `mod` whose values are
+ the panel ids, `[data-ver]` per row, `[data-bundle-all]`, `[data-bundle-go]`, `[data-bundle-size]`,
+ `[data-bundle-note]`. A new mod gets a row there too (Multiplayer only for a BeaverBuddies mod).
+- Anchors: `#top`, `#mods`, `#bundle`, `#install` (the org profile links `/#install`) and the eight card ids.
### Content rules
@@ -151,6 +158,7 @@ changes its look. Write every change by *Writing README and website text* above.
unofficial…") when the count, game version or a one-line summary changes (`grep -n "1.1.2.4\|[Ee]ight" *.html README.md PRODUCT.md`);
- the install `.notes` if requirements in general change; the footer credit if a new BeaverBuddies derivative lands;
- `404.html`'s mod list (a copy of the cards' names, URLs and `--c`; **kept in sync by hand**);
+ - the download picker's row (`#bundle`: name, `--c`, and the panel id as the input's value);
- PRODUCT.md's Operating Context and README's mod list; the org profile (`timbermods/.github`,
`profile/README.md`) repeats each card's text, accent, category and group: update it in a PR to that repo. Its
images come from this site: after card art changes here (and is live), run `python profile/make_images.py` there.
@@ -159,7 +167,7 @@ changes its look. Write every change by *Writing README and website text* above.
`scripts/card-art.py` (add a `JOBS` entry) + provenance, a need-index row, the 404 row, and the counts in step 2.
A board with an empty spot fills it with an `.insert` notice in an `.insert-sleeve`. The tests read cards from
`index.html`, so no test edit is needed unless you change `site.js`.
-4. Test: `node scripts/test-site.mjs` must print `15/15 checks passed`.
+4. Test: `node scripts/test-site.mjs` must print `20/20 checks passed`.
5. Preview: `node scripts/serve.mjs` (serves the repo root at http://127.0.0.1:8765/; missing paths get a plain 404,
so open `/404.html` directly). Capture light, dark and a 390px phone: with the personal skill,
`python ~/.claude/skills/impeccable-site-flow/scripts/capsite.py http://127.0.0.1:8765/ "" 404.html`;
diff --git a/DESIGN.md b/DESIGN.md
index 9b70894..f94ef8b 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -289,11 +289,11 @@ A woodland palette: pale birch and dark forest floor for the ground, walnut and
## Layout
-A single centred column 1180px wide with a fluid gutter (clamp(16px, 4vw, 32px)). The order is fixed: header bar (min 68px, rule beneath), the valley with the wordmark, cover, three walnut boards, install, footer.
+A single centred column 1180px wide with a fluid gutter (clamp(16px, 4vw, 32px)). The order is fixed: header bar (min 68px, rule beneath), the valley with the wordmark, cover, three walnut boards, the download picker, install, footer.
The valley runs the full width under the header (aspect 1280:360, min 230px, max 500px), with the wordmark over its sky at the top of the content column. Below it, the cover is two columns (0.9fr / 1.1fr): the lead and facts on the left, the need index on the right (rows of need and mod name, each mod name led by its colour swatch). Each board holds a head (title and lead, 760px max) and a grid of panels, three across, gap clamp(14px, 2vw, 22px), panels stretched to equal height with the download block pushed to the bottom. Where a board has a spare slot, a notice fills it (the Play together board's "Which one?"). Boards stack with clamp(24px, 4vw, 40px) between them. Install is a three-column steps sheet over three columns of notes.
-Responsive: at 1000px the panels go two across and the notice spans the row; at 860px the cover, steps and notes collapse to one column and the wordmark runs on one line; at 620px the panels go one across, banners crop to 16:7, boards tighten (26px 12px 20px, 6px corners, nails inset 8px) and the download button stops wrapping. Interactive targets are at least 44px tall; download buttons 48px.
+Responsive: at 1000px the panels go two across and the notice spans the row; at 860px the cover, steps, notes and the picker collapse to one column and the wordmark runs on one line; at 620px the panels go one across, banners crop to 16:7, boards tighten (26px 12px 20px, 6px corners, nails inset 8px) and the download button stops wrapping. Interactive targets are at least 44px tall; download buttons 48px.
### Named Rules
**The One Board Per Group Rule.** Every group of mods gets its own walnut board; a mod panel never sits directly on the ground, and nothing but panels and a notice sits on a board.
@@ -352,6 +352,9 @@ A 6px-cornered strip in caution wash and ink with a stroked warning-triangle ico
- **Header:** the logo and wordmark on the left; Mods, Install, GitHub as 44px-tall ink links (underline on hover) and the 44px round theme toggle (sun by day, moon at night) on the right.
- **Need index:** a list under a 1.5px ink rule; each 46px row pairs a need with a mod name led by a 12px swatch in the mod's colour ringed off the ground. The 404 reuses it with mod names only.
+### Download picker
+"Download several at once" (`#bundle`): a paper sheet like the install steps, split by a paper rule into two groups. **Multiplayer** (1fr) is a radio list, None, BeaverBuddies Stability Fork and Timber Together, under the hint "Only one can run at a time."; **More mods** (2fr) is a two-column list of the other six under a **Select all** / **Clear these** button. Every group has a subhead (800, 1.15rem) and a 44px hint line, then a 1.5px ink rule; each row (min 54px, paper rule beneath) is a pine-green checkbox or radio, the mod name led by its 12px colour swatch ringed off the paper (as in the need index), and its version and size beneath in muted tabular figures. The foot spans both groups: a 48px pine download button (pine field, paper lettering, min 250px; "Download 3 mods · 2.6 MB", or "Pick a mod first" while disabled at 55% opacity) beside an aria-live status line and one muted line pointing to Installing for Harmony and Mod Settings. At 860px the groups stack and the list runs one across; at 620px the button runs full width.
+
### Install steps
A paper sheet split into three steps by paper rules, each a pine numeral, title and short text; below it three notes, each under a 1.5px ink rule.
@@ -359,10 +362,11 @@ A paper sheet split into three steps by paper rules, each a pine numeral, title
**The Panel, Not Card Rule.** In user-facing text the mods are shown as panels, never "cards". The CSS classes `.card`, `.binder`, `.page`, `.pockets` and `.sleeve` are leftover names from the previous look. `.card` must stay: `assets/site.js` selects `.card[data-repo]` and `scripts/test-site.mjs` reads ``. The others are not read by either and are kept only to avoid churn.
### Markup contract (JS and test hooks)
-`assets/site.js` reads these hooks and `scripts/test-site.mjs` (15 checks) tests them. `data/releases.json` is written by the scheduled bot and is never edited by hand.
+`assets/site.js` reads these hooks and `scripts/test-site.mjs` (20 checks) tests them. `data/releases.json` is written by the scheduled bot and is never edited by hand.
- `article.card[data-repo][data-asset]` on every mod panel and nowhere else. `data-maturity` goes on every panel that carries a `.caution`, and equals the caution's label.
- Inside each panel: `[data-download]` (an `a.btn` whose fallback href is the repo's Releases page, containing `.btn-label` and `[data-size]`), `[data-note]` (aria-live), `[data-status]` (a hidden pill), `[data-notes]` (the release-notes link, falling back to Releases), and an `h3` with the mod name.
- `[data-generated]` in the footer for the release-data stamp.
+- The picker (`assets/bundle.js`): `form[data-bundle]` holding inputs named `multiplayer` (radios; `value=""` is None) and `mod` (checkboxes), whose values are the panel ids; each row's `[data-ver]`; `[data-bundle-all]`, `[data-bundle-go]` (with `.btn-label` and `[data-bundle-size]`) and `[data-bundle-note]` (aria-live). It reads the `downloads` branch's `manifest.json`, whose ids are the panel ids.
## Do's and Don'ts
diff --git a/DEVELOPING.md b/DEVELOPING.md
index 6e7eb60..418ef3f 100644
--- a/DEVELOPING.md
+++ b/DEVELOPING.md
@@ -42,6 +42,20 @@ The script keeps each repo's newest 12 releases and, under `latest`, GitHub's La
none). It rewrites the file, and its `generated` time, only when the data changed. The footer reads "Release data last
changed ". To run it yourself, set `GITHUB_TOKEN` to avoid rate limits.
+## Download several at once
+
+The picker (`#bundle`, `assets/bundle.js`) lets a visitor tick mods and get one ZIP with a folder for each. GitHub
+doesn't let a page fetch release downloads, so the hub keeps its own copies on the `downloads` branch:
+`.github/workflows/refresh-downloads.yml` runs `node scripts/update-downloads.mjs ` hourly (at :37, after the
+snapshot) and whenever `index.html` or the scripts change. For each mod it picks the release its Download button
+offers, rewrites that ZIP into a plain form (`scripts/downloads-lib.mjs`) and lists it in `manifest.json` with its
+version, folder, size and SHA-256. The branch is replaced by one fresh commit each time, so it never grows; never
+edit it by hand. The page reads the copies from `raw.githubusercontent.com`, checks each checksum and joins them with
+`assets/zipmerge.js`, without unpacking anything.
+
+To preview the picker with local copies: `node scripts/update-downloads.mjs _mirror`, then open
+`http://127.0.0.1:8765/?downloads=_mirror/`. Delete `_mirror` afterwards.
+
## Tests
```bash
@@ -49,7 +63,7 @@ node scripts/test-site.mjs
```
Every check must pass. They run offline: `assets/site.js` against a stub page and a fake GitHub API, and
-`update-releases.mjs` in a scratch folder against the same fake API. `.github/workflows/tests.yml` runs them on every
+`update-releases.mjs` and `update-downloads.mjs` in scratch folders against the same fake API, and the ZIP joining. `.github/workflows/tests.yml` runs them on every
pull request and every push to `main`.
## Adding or changing a mod
@@ -69,10 +83,11 @@ caution's label (`data-maturity="Beta"`). When the mod is ready, remove both. Th
| `404.html` | The not-found page, with a link to each mod's site |
| `assets/style.css` | Styles: walnut boards on birch by day, on a forest floor at night |
| `assets/site.js` | The download buttons and the theme toggle |
+| `assets/bundle.js`, `assets/zipmerge.js` | The "Download several at once" picker, and the ZIP joining it uses |
| `assets/fonts/` | Anybody, self-hosted (SIL Open Font License, `OFL-Anybody.txt`) |
| `assets/img/` | The opening valley by day and at dusk (`make_valley.py`), the birch, forest-floor and walnut textures (`make_textures.py`), and the social preview `og.png` |
| `assets/img/cards/` | Each mod's picture, a 960×600 capture from its own site |
| `data/releases.json` | The release snapshot (generated) |
-| `scripts/` | `serve.mjs` (preview), `test-site.mjs` (tests), `update-releases.mjs` (snapshot) |
+| `scripts/` | `serve.mjs` (preview), `test-site.mjs` (tests), `update-releases.mjs` (snapshot), `update-downloads.mjs` + `downloads-lib.mjs` (the picker's copies) |
| `scripts/card-art.py` | Recaptures the mods' pictures from their live sites: `python scripts/card-art.py [id ...]` (Playwright, Pillow, installed Microsoft Edge) |
-| `.github/workflows/` | The hourly snapshot refresh (`refresh-releases.yml`) and the tests (`tests.yml`) |
+| `.github/workflows/` | The hourly snapshot refresh (`refresh-releases.yml`), the picker's copies (`refresh-downloads.yml`) and the tests (`tests.yml`) |
diff --git a/PRODUCT.md b/PRODUCT.md
index eeef2ef..53801fe 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -103,7 +103,7 @@ name, tagline, blurb, accent color and requirements and links back to the hub an
## Capabilities and Constraints
**Stack and hosting:** one static page (`index.html`), `assets/style.css`, `assets/site.js`, no build step, no
-dependencies, no external requests besides GitHub's API (Anybody is self-hosted in `assets/fonts/` and text uses the system font stack; nothing from a CDN). GitHub Pages
+dependencies, no external requests besides GitHub's API and, for the download picker, GitHub's raw file host (Anybody is self-hosted in `assets/fonts/` and text uses the system font stack; nothing from a CDN). GitHub Pages
"legacy" build from `main`, folder `/ (root)`; `.nojekyll` stays. All paths are relative (so it also works from a
project repo), except in `404.html`, which GitHub Pages serves at any missing address and so uses root paths. Preview with `node scripts/serve.mjs` (http://127.0.0.1:8765/). Light and dark themes: tokens on
`:root`, `prefers-color-scheme` guarded by `:root:not([data-theme="light"])`, `:root[data-theme="dark"]`; the toggle
@@ -152,8 +152,19 @@ on every push to `main` and every pull request, Node 22; the bot's pushes don't
as it was) on any API error other than a 404 from `/releases/latest`, and leaves the file untouched when nothing
changed. The workflow depends on the paths `scripts/update-releases.mjs` and `data/releases.json`, and needs
`contents: write`.
-9. **Anchors other sites link to:** `#install` (the org profile links `https://timbermods.github.io/#install`) and
- `#mods`; the card ids above are the natural deep links. Keep them.
+9. **Anchors other sites link to:** `#install` (the org profile links `https://timbermods.github.io/#install`),
+ `#bundle` (the download picker) and `#mods`; the card ids above are the natural deep links. Keep them.
+
+10. **The download picker** (`#bundle`, `assets/bundle.js` + `assets/zipmerge.js`): the visitor ticks mods and gets one
+ `timbermods-mods.zip` with a folder for each and a `Timbermods.txt` guide. GitHub doesn't let a page fetch release
+ downloads, so `.github/workflows/refresh-downloads.yml` (hourly at :37, and when the page or its scripts change) runs
+ `scripts/update-downloads.mjs`: for each panel it picks the release its Download button offers (same rule as
+ `site.js`, tested), rewrites that ZIP into a plain form (`scripts/downloads-lib.mjs`: files only, no data
+ descriptors or ZIP64, exactly one top folder, no two mods sharing one) and publishes the copies with a
+ `manifest.json` as the `downloads` branch, one fresh commit each time. The page reads them from
+ `raw.githubusercontent.com` (which allows it), checks each copy's SHA-256 and joins them without unpacking.
+ Only one of the two BeaverBuddies mods can be picked. Harmony and Mod Settings are never bundled. Never edit the
+ `downloads` branch by hand.
**Shared files:** the hub does **not** use the shared `release.js` that most mod sites carry (its `site.js` is its own
and follows the same main rule, differing in edge cases). If a `release.js` is ever copied in, it is replaced
diff --git a/README.md b/README.md
index bf89498..14234a7 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,8 @@ latest** button for its current release and a link to its own site.
- [Persistent Work Areas](https://timbermods.github.io/PersistentWorkAreas/): working-area outlines that stay on screen
- [The Tipsy Tail](https://timbermods.github.io/timberborn-tipsy-tail/): a swim-up pool bar
-Every mod installs the same way: see [Installing any of these](https://timbermods.github.io/#install).
+Want several? [Download several at once](https://timbermods.github.io/#bundle) as one ZIP. Every mod installs the same
+way: see [Installing any of these](https://timbermods.github.io/#install).
Working on the site itself? See [DEVELOPING.md](DEVELOPING.md).
diff --git a/assets/bundle.js b/assets/bundle.js
new file mode 100644
index 0000000..c4fab50
--- /dev/null
+++ b/assets/bundle.js
@@ -0,0 +1,147 @@
+/*
+ * "Download several at once": pick mods, get one ZIP with a folder for each.
+ *
+ * GitHub doesn't let a page fetch release downloads, so the hub keeps its own copies of each mod's current ZIP
+ * on the `downloads` branch (scripts/update-downloads.mjs, hourly: the same release each Download button offers).
+ * This reads their manifest.json from GitHub's raw file host, which does allow it, fetches the picked copies,
+ * checks each against its SHA-256 and joins them with assets/zipmerge.js. Nothing is unpacked or sent anywhere.
+ *
+ * For a local preview, ?downloads= reads the copies from there instead.
+ */
+(() => {
+ 'use strict';
+
+ const form = document.querySelector('[data-bundle]');
+ const Zip = window.TimbermodsZip;
+ if (!form || !Zip) return;
+
+ const RAW = 'https://raw.githubusercontent.com/timbermods/timbermods.github.io/downloads/';
+ const base = (() => {
+ try {
+ const q = new URLSearchParams(location.search).get('downloads');
+ return q ? new URL(q.replace(/\/?$/, '/'), location.href).href : RAW;
+ } catch (e) { return RAW; }
+ })();
+
+ const note = form.querySelector('[data-bundle-note]');
+ const go = form.querySelector('[data-bundle-go]');
+ const goLabel = go.querySelector('.btn-label');
+ const goSize = go.querySelector('[data-bundle-size]');
+ const all = form.querySelector('[data-bundle-all]');
+ const inputs = [...form.querySelectorAll('input')];
+ const boxes = inputs.filter((i) => i.type === 'checkbox');
+ let mods = new Map();
+ let busy = false;
+
+ const formatSize = (bytes) => bytes >= 1048576
+ ? (bytes / 1048576).toFixed(1) + ' MB'
+ : Math.max(1, Math.round(bytes / 1024)) + ' KB';
+ const plural = (n) => n + (n === 1 ? ' mod' : ' mods');
+ const say = (text) => { note.textContent = text; };
+
+ const chosen = () => inputs.filter((i) => i.checked && i.value && mods.has(i.value)).map((i) => mods.get(i.value));
+
+ function refresh() {
+ const picked = chosen();
+ go.disabled = busy || !picked.length;
+ goLabel.textContent = picked.length ? 'Download ' + plural(picked.length) : 'Pick a mod first';
+ goSize.textContent = picked.length ? formatSize(picked.reduce((n, m) => n + m.size, 0)) : '';
+ const open = boxes.filter((b) => !b.disabled);
+ all.textContent = open.length && open.every((b) => b.checked) ? 'Clear these' : 'Select all';
+ }
+
+ function lock(on) {
+ busy = on;
+ inputs.forEach((i) => { i.disabled = on || (i.value !== '' && !mods.has(i.value)); });
+ all.disabled = on;
+ refresh();
+ }
+
+ // The text file at the top of the ZIP: what is in it and where it goes. CRLF, for Notepad.
+ function readme(picked, when) {
+ const lines = [
+ 'Timbermods: ' + plural(picked.length) + ', downloaded ' + when.toISOString().slice(0, 10) + '.',
+ '',
+ 'Close Timberborn. Extract every folder in this ZIP into Documents\\Timberborn\\Mods,',
+ 'then turn the mods on in the game\'s mod manager.',
+ '',
+ ];
+ for (const m of picked) {
+ lines.push(m.name + ' ' + m.version + (m.prerelease ? ' (preview)' : '') + ', folder ' + m.folder);
+ lines.push(' Requires: ' + (m.requires || 'Nothing extra'));
+ lines.push(' Release notes: ' + m.notes);
+ }
+ lines.push('', 'Harmony and Mod Settings are separate mods: subscribe to them on the Steam Workshop.', 'https://timbermods.github.io/', '');
+ return lines.join('\r\n');
+ }
+
+ async function sha256(bytes) {
+ const d = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
+ return [...d].map((b) => b.toString(16).padStart(2, '0')).join('');
+ }
+
+ async function load() {
+ try {
+ const res = await fetch(base + 'manifest.json', { cache: 'no-cache' });
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ mods = new Map((await res.json()).mods.map((m) => [m.id, m]));
+ } catch (e) {
+ say('The download list didn\'t load. Use each mod\'s own Download button above.');
+ inputs.forEach((i) => { i.disabled = true; });
+ return;
+ }
+ for (const input of inputs) {
+ if (!input.value) continue;
+ const m = mods.get(input.value);
+ const ver = input.closest('.pick').querySelector('[data-ver]');
+ if (m) ver.textContent = m.version + ' · ' + formatSize(m.size);
+ else { input.checked = false; ver.textContent = 'Not available'; }
+ }
+ say('');
+ lock(false);
+ }
+
+ form.addEventListener('change', refresh);
+
+ all.addEventListener('click', () => {
+ const open = boxes.filter((b) => !b.disabled);
+ const on = !open.every((b) => b.checked);
+ open.forEach((b) => { b.checked = on; });
+ refresh();
+ });
+
+ form.addEventListener('submit', async (ev) => {
+ ev.preventDefault();
+ const picked = chosen();
+ if (busy || !picked.length) return;
+ lock(true);
+ try {
+ const parts = [];
+ for (const [i, m] of picked.entries()) {
+ say('Downloading ' + m.name + ' (' + (i + 1) + ' of ' + picked.length + ')…');
+ const res = await fetch(base + m.file);
+ if (!res.ok) throw new Error(m.name + ' didn\'t download');
+ const bytes = new Uint8Array(await res.arrayBuffer());
+ if (await sha256(bytes) !== m.sha256) throw new Error(m.name + ' didn\'t match its checksum');
+ parts.push(bytes);
+ }
+ const when = new Date();
+ const zip = Zip.merge(parts, [{ name: 'Timbermods.txt', text: readme(picked, when) }], when);
+ const url = URL.createObjectURL(new Blob([zip], { type: 'application/zip' }));
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'timbermods-mods.zip';
+ document.body.append(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
+ say('Saved timbermods-mods.zip with ' + plural(picked.length) + '. Extract it into Documents\\Timberborn\\Mods.');
+ } catch (e) {
+ say(e.message + '. Try again, or use its own Download button above.');
+ } finally {
+ lock(false);
+ }
+ });
+
+ load();
+})();
diff --git a/assets/style.css b/assets/style.css
index 5b973fb..a782e05 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -227,6 +227,37 @@ code { font-family: var(--mono); font-size: .9em; background: var(--code-bg); pa
.notes h3 { font: 700 1.05rem/1.25 var(--display); margin: 0 0 .3em; }
.notes p { margin: 0; color: var(--muted); }
+/* ---------- download several at once: a paper sheet of ticks, one row per mod in its own colour ---------- */
+.bundle { padding: clamp(40px, 6vw, 72px) 0; border-top: 1px solid var(--rule); }
+.picker { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); margin: 24px 0 0; background: var(--tile); color: var(--tile-ink); border-radius: 6px; border: 1px solid var(--tile-rule); box-shadow: 0 2px 0 rgba(0, 0, 0, .12), 0 12px 24px -14px rgba(0, 0, 0, .45); }
+.pick-group { min-width: 0; margin: 0; padding: 20px 24px 18px; border: 0; }
+.pick-group + .pick-group { border-left: 1px solid var(--tile-rule); }
+.pick-group legend { float: left; width: 100%; padding: 0; margin: 0 0 4px; font: 800 1.15rem/1.2 var(--display); }
+.pick-hint { clear: both; margin: 0 0 10px; min-height: 44px; display: flex; align-items: center; color: var(--tile-muted); font-size: .92rem; }
+.pick-list { border-top: 1.5px solid var(--tile-ink); }
+.pick-list-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 28px; }
+.pick { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; align-items: center; min-height: 54px; padding: 6px 2px; border-bottom: 1px solid var(--tile-rule); cursor: pointer; }
+.pick input { width: 20px; height: 20px; margin: 0; accent-color: var(--tile-link); cursor: pointer; }
+.pick-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
+.pick-name { display: inline-flex; align-items: center; gap: 8px; font: 700 .98rem/1.2 var(--display); }
+/* the mod's own colour, as in the need index */
+.pick-name::before { content: ""; flex: none; width: 12px; height: 12px; border-radius: 3px; background: var(--c); box-shadow: 0 0 0 2px var(--tile), 0 0 0 3px var(--c); }
+.pick-none .pick-name::before { display: none; }
+.pick-ver { padding-left: 20px; color: var(--tile-muted); font-size: .85rem; font-variant-numeric: tabular-nums; }
+.pick-ver:empty { display: none; }
+.pick:has(input:disabled) { cursor: default; }
+.pick:has(input:disabled) .pick-name { opacity: .6; }
+.pick-all { min-height: 44px; padding: 0 14px; border-radius: 6px; border: 1.5px solid var(--tile-rule); background: transparent; color: var(--tile-ink); font: 700 .9rem/1 var(--display); cursor: pointer; }
+.pick-all:hover:not(:disabled) { border-color: var(--tile-ink); }
+.pick-all:disabled { opacity: .55; cursor: default; }
+.pick-foot { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; padding: 16px 24px 20px; border-top: 1px solid var(--tile-rule); }
+.pick-go, .pick-go:hover { background: var(--link); color: var(--tile); min-width: 250px; }
+.pick-go:disabled { opacity: .55; filter: none; cursor: default; }
+.pick-status { flex: 1 1 280px; }
+.pick-note { margin: 0; min-height: 1.4em; font-weight: 650; }
+.pick-fine { margin: 2px 0 0; color: var(--tile-muted); font-size: .9rem; }
+.pick-fine a { color: var(--tile-link); }
+
/* ---------- footer ---------- */
.site-footer { border-top: 1px solid var(--rule); padding: 32px 0 40px; font-size: .95rem; color: var(--muted); }
.site-footer p { max-width: 90ch; }
@@ -246,7 +277,8 @@ code { font-family: var(--mono); font-size: .9em; background: var(--code-bg); pa
.insert { align-self: stretch; }
}
@media (max-width: 860px) {
- .cover-grid, .steps, .notes { grid-template-columns: minmax(0, 1fr); }
+ .cover-grid, .steps, .notes, .picker, .pick-list-two { grid-template-columns: minmax(0, 1fr); }
+ .pick-group + .pick-group { border-left: 0; border-top: 1px solid var(--tile-rule); }
.steps li + li { border-left: 0; border-top: 1px solid var(--tile-rule); }
}
@media (max-width: 620px) {
@@ -260,6 +292,8 @@ code { font-family: var(--mono); font-size: .9em; background: var(--code-bg); pa
.site-header nav a { padding: 0 8px; }
.needs a { grid-template-columns: minmax(0, 1fr); gap: 2px; }
.needs .to { justify-self: start; }
+ .pick-group, .pick-foot { padding-left: 16px; padding-right: 16px; }
+ .pick-go { min-width: 0; width: 100%; }
.blurb { font-size: .93rem; }
}
@media (prefers-reduced-motion: reduce) {
diff --git a/assets/zipmerge.js b/assets/zipmerge.js
new file mode 100644
index 0000000..fafab6a
--- /dev/null
+++ b/assets/zipmerge.js
@@ -0,0 +1,113 @@
+/*
+ * Joins mod ZIPs into one without unpacking them: each part's file records are copied byte for byte and a new
+ * central directory is written after them. The parts are the hub's own copies of each mod's release ZIP, which
+ * scripts/downloads-lib.mjs rewrites into a plain form (no data descriptors, no ZIP64, one top folder per mod),
+ * so this stays small. Used by assets/bundle.js in the browser and by scripts/test-site.mjs in Node.
+ */
+(function (root) {
+ 'use strict';
+
+ const TABLE = (() => {
+ const t = new Uint32Array(256);
+ for (let n = 0; n < 256; n++) {
+ let c = n;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ t[n] = c >>> 0;
+ }
+ return t;
+ })();
+
+ function crc32(bytes) {
+ let c = 0xffffffff;
+ for (let i = 0; i < bytes.length; i++) c = TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+ }
+
+ const utf8 = (s) => new TextEncoder().encode(s);
+ const view = (bytes) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+
+ // The files of a ZIP, from its central directory: [{ name, offset, recordLength, central }], where `central` is
+ // the entry's central directory header (a view into `bytes`) and the local record runs recordLength bytes.
+ function readEntries(bytes) {
+ const v = view(bytes);
+ let eocd = -1;
+ for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 65535); i--) {
+ if (v.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
+ }
+ if (eocd < 0) throw new Error('not a ZIP file');
+ const count = v.getUint16(eocd + 10, true);
+ let p = v.getUint32(eocd + 16, true);
+ const entries = [];
+ for (let i = 0; i < count; i++) {
+ if (v.getUint32(p, true) !== 0x02014b50) throw new Error('damaged ZIP directory');
+ const flags = v.getUint16(p + 8, true), csize = v.getUint32(p + 20, true), offset = v.getUint32(p + 42, true);
+ const n = v.getUint16(p + 28, true), m = v.getUint16(p + 30, true), k = v.getUint16(p + 32, true);
+ if (flags & 8) throw new Error('ZIP data descriptors are not supported');
+ if (csize === 0xffffffff || offset === 0xffffffff) throw new Error('ZIP64 is not supported');
+ const name = new TextDecoder().decode(bytes.subarray(p + 46, p + 46 + n));
+ if (v.getUint32(offset, true) !== 0x04034b50) throw new Error('damaged ZIP record for ' + name);
+ const recordLength = 30 + v.getUint16(offset + 26, true) + v.getUint16(offset + 28, true) + csize;
+ entries.push({ name, offset, recordLength, central: bytes.subarray(p, p + 46 + n + m + k) });
+ p += 46 + n + m + k;
+ }
+ return entries;
+ }
+
+ function dosTime(date) {
+ return {
+ time: (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1),
+ date: ((Math.max(1980, date.getFullYear()) - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
+ };
+ }
+
+ // A file stored uncompressed: its local record and its central directory header (offset filled in later).
+ function storedFile(name, data, when) {
+ const nameBytes = utf8(name), crc = crc32(data), t = dosTime(when);
+ const local = new Uint8Array(30 + nameBytes.length + data.length), lv = view(local);
+ lv.setUint32(0, 0x04034b50, true); lv.setUint16(4, 20, true); lv.setUint16(6, 0x0800, true);
+ lv.setUint16(10, t.time, true); lv.setUint16(12, t.date, true); lv.setUint32(14, crc, true);
+ lv.setUint32(18, data.length, true); lv.setUint32(22, data.length, true); lv.setUint16(26, nameBytes.length, true);
+ local.set(nameBytes, 30); local.set(data, 30 + nameBytes.length);
+ const central = new Uint8Array(46 + nameBytes.length), cv = view(central);
+ cv.setUint32(0, 0x02014b50, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true); cv.setUint16(8, 0x0800, true);
+ cv.setUint16(12, t.time, true); cv.setUint16(14, t.date, true); cv.setUint32(16, crc, true);
+ cv.setUint32(20, data.length, true); cv.setUint32(24, data.length, true); cv.setUint16(28, nameBytes.length, true);
+ central.set(nameBytes, 46);
+ return { name, local, central };
+ }
+
+ // parts: ZIPs as Uint8Arrays. extra: [{ name, text }] added as stored files after them. Returns the joined ZIP.
+ // Refuses a file name that two parts share, so no mod can overwrite another's file.
+ function merge(parts, extra = [], when = new Date()) {
+ const records = [], centrals = [], seen = new Set();
+ let offset = 0;
+ const add = (name, record, central) => {
+ if (seen.has(name)) throw new Error('two of these ZIPs both contain ' + name);
+ seen.add(name);
+ const c = central.slice();
+ view(c).setUint32(42, offset, true);
+ records.push(record); centrals.push(c); offset += record.length;
+ };
+ for (const bytes of parts) {
+ for (const e of readEntries(bytes)) add(e.name, bytes.subarray(e.offset, e.offset + e.recordLength), e.central);
+ }
+ for (const { name, text } of extra) {
+ const f = storedFile(name, utf8(text), when);
+ add(f.name, f.local, f.central);
+ }
+ const cdSize = centrals.reduce((n, c) => n + c.length, 0);
+ const out = new Uint8Array(offset + cdSize + 22);
+ let p = 0;
+ for (const r of records) { out.set(r, p); p += r.length; }
+ for (const c of centrals) { out.set(c, p); p += c.length; }
+ const v = view(out);
+ v.setUint32(p, 0x06054b50, true);
+ v.setUint16(p + 8, centrals.length, true); v.setUint16(p + 10, centrals.length, true);
+ v.setUint32(p + 12, cdSize, true); v.setUint32(p + 16, offset, true);
+ return out;
+ }
+
+ const api = { crc32, readEntries, merge };
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ else root.TimbermodsZip = api;
+})(typeof globalThis !== 'undefined' ? globalThis : this);
diff --git a/index.html b/index.html
index 80a66a0..158a461 100644
--- a/index.html
+++ b/index.html
@@ -50,7 +50,7 @@
-
Unofficial mods for Timberborn, made by players. Each one has a download for its latest release and a link to its own site.
+
Unofficial mods for Timberborn, made by players. Each one has a download for its latest release and a link to its own site, or download several at once.
Eight mods for Timberborn 1.1.2.4. Most are made to work in BeaverBuddies co-op.
+
+
+
Download several at once
+
Pick the mods you want and get one ZIP with a folder for each. Extract it into Documents\Timberborn\Mods.
+
+
+
+
Installing any of these
@@ -248,7 +285,7 @@
Installing any of these
1
Download the ZIP
-
Use the mod's Download latest button. On GitHub, pick the ZIP under Assets, not Source code.
+
Use the mod's Download latest button, or download several at once. On GitHub, pick the ZIP under Assets, not Source code.