diff --git a/internal/check-poster-attribution.mjs b/internal/check-poster-attribution.mjs
new file mode 100644
index 000000000..24a1c9125
--- /dev/null
+++ b/internal/check-poster-attribution.mjs
@@ -0,0 +1,122 @@
+#!/usr/bin/env node
+/**
+ * check-poster-attribution — the poster footer credits whoever actually served the tiles.
+ *
+ * The footer is a LICENCE statement, so it has to be true rather than merely present.
+ * posterDefaults ships '© Mapbox © OpenStreetMap'; under the fleet's MapLibre default Mapbox serves
+ * nothing, OpenFreeMap serves everything, and any poster carrying the default credited a company
+ * with no involvement while omitting the three parties whose licences apply.
+ *
+ * ★ THE FIXTURE IS THE REAL THING, and it is the reason this check exists in this shape. Fetched
+ * 2026-09-28: tiles.openfreemap.org/styles/liberty declares two sources and NEITHER has an
+ * `attribution` field, so reading the style alone returns nothing. The credit lives in the TileJSON
+ * at tiles.openfreemap.org/planet, as HTML with anchors and a © entity, which the engine merges
+ * into the SOURCE object after it loads. A check written against a tidy invented style would have
+ * passed while the real map printed the wrong credit.
+ *
+ * Headless, no network, no browser, no dependencies. Exit 0 pass · 1 fail.
+ */
+import { resolve, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { readFileSync } from 'node:fs'
+
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const { attributionTextFromMap, attributionToText } = await import(resolve(ROOT, 'library/maps/map-attribution.js'))
+
+const fails = []
+const eq = (what, got, want) => { if (got !== want) fails.push(`${what}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`) }
+const ok = (what, cond, detail) => { if (!cond) fails.push(what + (detail ? `\n ${detail}` : '')) }
+
+// The exact string tiles.openfreemap.org/planet returns.
+const OFM_TILEJSON_HTML =
+ 'OpenFreeMap ' +
+ '© OpenMapTiles ' +
+ 'Data from OpenStreetMap'
+const OFM_TEXT = 'OpenFreeMap © OpenMapTiles Data from OpenStreetMap'
+
+const fakeMap = ({ sources, live }) => ({
+ getStyle: () => ({ sources }),
+ getSource: (id) => (live && Object.prototype.hasOwnProperty.call(live, id) ? live[id] : undefined),
+})
+
+// 1. the real shape: style declares nothing, the loaded source carries the TileJSON credit
+{
+ const map = fakeMap({
+ sources: { ne2_shaded: { type: 'raster' }, openmaptiles: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' } },
+ live: { ne2_shaded: {}, openmaptiles: { attribution: OFM_TILEJSON_HTML } },
+ })
+ eq('the OpenFreeMap credit is read from the LOADED source, not the style', attributionTextFromMap(map), OFM_TEXT)
+ ok('and it no longer says Mapbox', !attributionTextFromMap(map).includes('Mapbox'))
+}
+
+// 2. a Mapbox map still credits Mapbox — this must not become "always OpenFreeMap"
+{
+ const map = fakeMap({
+ sources: { composite: { type: 'vector' } },
+ live: { composite: { attribution: '© Mapbox © OpenStreetMap' } },
+ })
+ eq('a Mapbox-served map credits Mapbox', attributionTextFromMap(map), '© Mapbox © OpenStreetMap')
+}
+
+// 3. a style that DOES declare attribution is honoured (the source is preferred, not required)
+{
+ const map = fakeMap({ sources: { s: { type: 'raster', attribution: 'Tiles by Someone' } }, live: {} })
+ eq('a style-declared attribution is used when there is no live source', attributionTextFromMap(map), 'Tiles by Someone')
+}
+{
+ const map = fakeMap({
+ sources: { s: { type: 'raster', attribution: 'the stale request' } },
+ live: { s: { attribution: 'what actually came back' } },
+ })
+ eq('the LOADED source wins over the style declaration', attributionTextFromMap(map), 'what actually came back')
+}
+
+// 4. repeated credits collapse; several distinct ones all survive
+{
+ const map = fakeMap({
+ sources: { a: {}, b: {}, c: {} },
+ live: { a: { attribution: '© OpenStreetMap' }, b: { attribution: '© OpenStreetMap' }, c: { attribution: 'geoBoundaries' } },
+ })
+ eq('the same credit twice is printed once', attributionTextFromMap(map), '© OpenStreetMap geoBoundaries')
+}
+
+// 5. it must never invent, and never throw — an empty answer hands the caller back its fallback
+{
+ eq('no map -> empty', attributionTextFromMap(null), '')
+ eq('a map with no getStyle -> empty', attributionTextFromMap({}), '')
+ eq('sources with no attribution -> empty', attributionTextFromMap(fakeMap({ sources: { a: {} }, live: { a: {} } })), '')
+ const throwing = { getStyle: () => { throw new Error('style not loaded') } }
+ eq('getStyle throwing before load -> empty, not a crash', attributionTextFromMap(throwing), '')
+ const badSource = { getStyle: () => ({ sources: { a: {} } }), getSource: () => { throw new Error('nope') } }
+ eq('getSource throwing -> empty, not a crash', attributionTextFromMap(badSource), '')
+}
+
+// 6. flattening: a footer is text, and entities must not print raw
+{
+ eq('anchors are removed', attributionToText('© OpenStreetMap'), '© OpenStreetMap')
+ eq('© becomes ©', attributionToText('© OpenMapTiles'), '© OpenMapTiles')
+ eq('whitespace collapses', attributionToText(' a \n b '), 'a b')
+ ok('no markup survives', !/[<>]/.test(attributionToText(OFM_TILEJSON_HTML)))
+ ok('no undecoded entity survives', !/&[a-z]+;/i.test(attributionToText(OFM_TILEJSON_HTML)))
+}
+
+// 7. the renderer must actually consult it, and must keep the spec's value as a fallback.
+{
+ const SRC = readFileSync(resolve(ROOT, 'library/composables/poster/useMapPoster.js'), 'utf8')
+ .replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
+ ok('the renderer imports the helper', /import\s*\{[^}]*attributionTextFromMap[^}]*\}\s*from/.test(SRC))
+ ok('and calls it with the live source map', /attributionTextFromMap\s*\(\s*sourceMap\s*\)/.test(SRC))
+ // The spread `...(spec.slots?.footer || {})` contains a brace, so a [^}] class stops short of the
+ // property it is looking for and reports the opposite of the truth.
+ ok('the derived credit reaches the footer slot', /footer\s*:\s*\{[\s\S]{0,160}?attribution\s*:\s*credit/.test(SRC))
+ ok('an empty credit falls back to the spec rather than printing nothing',
+ /credit\s*\?[\s\S]{0,240}?:\s*spec/.test(SRC),
+ 'a licence footer may never be blank')
+}
+
+if (fails.length) {
+ console.error('POSTER-ATTRIBUTION: FAIL')
+ for (const f of fails) console.error(` ✗ ${f}`)
+ process.exit(1)
+}
+console.log('POSTER-ATTRIBUTION: PASS — the credit is read from the LOADED source (where the TileJSON lands) rather than the style or a constant, HTML and entities are flattened for print, duplicates collapse, a Mapbox map still says Mapbox, and an unreadable map falls back instead of printing a blank licence line.')
diff --git a/library/composables/poster/useMapPoster.js b/library/composables/poster/useMapPoster.js
index 792d69b51..f4f7e0977 100644
--- a/library/composables/poster/useMapPoster.js
+++ b/library/composables/poster/useMapPoster.js
@@ -34,6 +34,7 @@
import { ref, readonly } from 'vue';
import { equalHeightLatBands } from '../../maps/mercator-slice.js';
import { computeLayout } from './usePosterLayout.js';
+import { attributionTextFromMap } from '../../maps/map-attribution.js';
import { engineFor } from '../useMapProvider.js';
const MAX_SINGLE_CANVAS = 4096; // WebGL safe ceiling for most hardware
@@ -126,7 +127,17 @@ export function useMapPoster(getSourceMap) {
: await _renderTileStitch(sourceMap, px, signal);
_setProgress('compose', 0, 1, 'compositing overlay');
- const composed = await _composeOverlay(baseRaster, spec, px);
+ // ★ THE FOOTER CREDITS WHOEVER ACTUALLY SERVED THE TILES. posterDefaults ships
+ // '© Mapbox © OpenStreetMap', which under the fleet's MapLibre default credits a company that
+ // served nothing and omits the three parties whose licences do apply. Read from the map rather
+ // than from a constant; the spec's value stays as the fallback, because an EMPTY attribution is
+ // the one outcome a licence never allows. The caller's spec is copied, not mutated — it belongs
+ // to whoever passed it in.
+ const credit = attributionTextFromMap(sourceMap);
+ const specForOverlay = credit
+ ? { ...spec, slots: { ...spec.slots, footer: { ...(spec.slots?.footer || {}), attribution: credit } } }
+ : spec;
+ const composed = await _composeOverlay(baseRaster, specForOverlay, px);
_setProgress('encode', 0, 1, `encoding ${format.toUpperCase()}`);
const blob = (format === 'pdf')
diff --git a/library/constants/posterDefaults.js b/library/constants/posterDefaults.js
index bad8faeff..78319c04c 100644
--- a/library/constants/posterDefaults.js
+++ b/library/constants/posterDefaults.js
@@ -71,6 +71,11 @@ export const posterDefaults = {
fontSizePt: 10,
color: '#222',
// Caller may set left/right text; attribution is enforced by FooterSlot.
+ // ★ A FALLBACK OF LAST RESORT, NOT THE ANSWER. useMapPoster reads the credit from the map's
+ // LOADED sources (library/maps/map-attribution.js) and overrides this, because under the
+ // fleet's MapLibre default Mapbox serves nothing and this line would credit the wrong party.
+ // It stays non-empty only so a map that cannot be read still prints something: a licence
+ // footer may never be blank.
attribution: '© Mapbox © OpenStreetMap',
backgroundColor: 'rgba(255,255,255,0.92)',
},
diff --git a/library/maps/map-attribution.js b/library/maps/map-attribution.js
new file mode 100644
index 000000000..01e521373
--- /dev/null
+++ b/library/maps/map-attribution.js
@@ -0,0 +1,54 @@
+/**
+ * map-attribution — the credit the tiles on THIS map actually ask for, as plain text.
+ *
+ * ★ WHY THIS IS READ AND NOT DECLARED. The poster footer defaulted to '© Mapbox © OpenStreetMap',
+ * which is a licence statement, so it must be true. Under the fleet's MapLibre default Mapbox serves
+ * nothing and OpenFreeMap serves everything, and any poster carrying that default credited a company
+ * with no involvement while omitting the three parties whose licences actually apply.
+ *
+ * ★ AND IT CANNOT BE READ FROM THE STYLE. Checked against the live style 2026-09-28:
+ * tiles.openfreemap.org/styles/liberty declares TWO sources and NEITHER carries an `attribution`
+ * field. The credit lives in the TileJSON the vector source points at
+ * (tiles.openfreemap.org/planet), which the engine fetches and merges into the SOURCE OBJECT once it
+ * loads. So `map.getStyle().sources[id].attribution` is null and `map.getSource(id).attribution` is
+ * the real answer — the style is the request, the source is what came back. This reads both and
+ * prefers the live source, because a style that DOES declare one should still be honoured.
+ *
+ * The TileJSON value is HTML with anchors and entities; a poster footer is text, so it is flattened
+ * rather than escaped-and-printed. Returns '' when nothing can be read, and the CALLER keeps its own
+ * fallback — printing an empty attribution is the one outcome that is never acceptable.
+ */
+
+const ENTITIES = { '©': '©', '&': '&', ' ': ' ', '<': '<', '>': '>', '"': '"', ''': "'" };
+
+/** Flatten one attribution value (HTML or plain) to trimmed, single-spaced text. */
+export function attributionToText(value) {
+ if (!value) return '';
+ return String(value)
+ .replace(/<[^>]*>/g, ' ') // anchors carry the link, not the credit
+ .replace(/&[a-z]+;|\d+;/gi, (e) => ENTITIES[e.toLowerCase()] ?? ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+/**
+ * Every distinct credit the map's sources ask for, joined into one line.
+ *
+ * @param {Object} map a mapboxgl/maplibregl Map instance
+ * @returns {string} plain text, or '' when the map cannot be read
+ */
+export function attributionTextFromMap(map) {
+ if (!map || typeof map.getStyle !== 'function') return '';
+ let style;
+ try { style = map.getStyle(); } catch (_) { return ''; } // getStyle throws before the style loads
+ const sources = (style && style.sources) || {};
+ const seen = [];
+ for (const id of Object.keys(sources)) {
+ // The live source first: it is the one the TileJSON was merged into.
+ let live = null;
+ try { live = typeof map.getSource === 'function' ? map.getSource(id) : null; } catch (_) { live = null; }
+ const text = attributionToText((live && live.attribution) || sources[id].attribution);
+ if (text && !seen.includes(text)) seen.push(text);
+ }
+ return seen.join(' ');
+}
diff --git a/package.json b/package.json
index 0c91ad29a..ee8451e22 100644
--- a/package.json
+++ b/package.json
@@ -296,7 +296,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms",
- "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap",
+ "gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",