# Build log — decisions & assumptions

- 2026-08-31 · Performance · Moved the 2.3 MB application payload out of the six main
  HTML shell documents and into one fingerprinted file under `/assets/`, with an early
  preload and deferred execution. Browsers can now cache the application once across
  Overview, Explorer, Statistics, Dispatches, Methodology, and Glossary. The build still
  scans the external app for forbidden inline event handlers, and the export tests enforce
  both the fingerprinted reference and a 150 KB shell cap.

- 2026-07-24 · CA-0 · Data extracted from index.html to data/coups.json (1,161 records, CITATION/NOTES
  expanded per record). Four records use `_1`/`_2` id suffixes for same-day paired events
  (Ethiopia 1977-02-03, Kazakhstan 2022-01-05); schema pattern allows this.
- 2026-07-24 · CA-0 · lat/lng are COUNTRY CENTROIDS from a bundled ISO3 table (143 codes incl.
  historical CSK/DDR/SUN/YAR/YMD). All 1,161 records geolocated at country level.
- 2026-07-24 · CA-0 · The app keeps citation_note/notes as shared constants at runtime (re-attached
  after data injection) to avoid ~600 KB of duplicated strings in index.html. coups.json carries the
  full expanded strings, as the acceptance criteria require.
- 2026-07-24 · CA-0 · No git repo existed for the site; version history now lives in this project
  directory. Deliverable to the owner remains the dated kit + wrangler deploy (no workflow change).
- 2026-07-24 · CA-1 · data/external/ contains SMALL FIXTURES, not the real Cline/Powell&Thyne
  downloads (this build environment cannot fetch them). TODO: replace with real files —
  see data/external/README.md. Matcher + tests are ready.
- 2026-07-24 · CA-1 · Dataset lineage: baseline records descend from Cline v2.2.2, so cline_id is set
  to the record's own id when the record's source_dataset is a Cline release, even where the (fixture)
  external file lacks the row. This makes agreement_status=partial the honest floor for baseline records.
- 2026-07-24 · CA-2 · Confidence rule documented in docs/confidence.md; rendered in the app
  (explorer cards + case file) and on static event pages.
- 2026-07-24 · CA-3 · Open-data license: CC BY 4.0 (brief default). /data/ page documents schema,
  license, citation. Exports regenerate on every build.
- 2026-07-24 · CA-4 · Basemap: MapLibre demo tiles (free, keyless). Extension point for Google
  Photorealistic 3D Tiles marked in static/map/index.html. Map smoke test asserts GeoJSON integrity
  and page structure; full tile rendering can't be exercised in the offline build sandbox.
- 2026-07-24 · CA-7 · 51 events back-coded with tactics/lead_actor/duration in
  data/tactics-backfill.json (keyed by iso3|date, resolved at build). Composer updated with the same
  controlled vocabulary. Composer-added records flow into index.html directly; run extract+build via
  Claude afterwards to sync coups.json (documented limitation).
- 2026-07-24 · CA-6 (feed) · feed.xml emitted at build: all dispatches + 10 most recent records by
  event date (no added-date field exists yet; approximation logged). Linked in the app head.
  SUPERSEDED 2026-07-25 by P1-2 — see below.
- 2026-07-24 · CA-5 · Worker scaffolding complete in /worker (GDELT keyless source, filter tests,
  sample payload, wrangler example, promotion-path guardrail). Awaiting owner's Cloudflare KV setup.
- 2026-07-24 · CA-1 · Real Powell & Thyne file confirmed reachable (uky.edu, V2025.09.12, ~475 rows).
  Raw download blocked in build sandbox; build/convert-pt.mjs written so the owner's saved copy
  converts in one command. LLM-transcription of the dataset was rejected on data-integrity grounds.
- 2026-07-24 · Generic imagery · 18 Brumaire painting (PD) added to the /event/ records hub.
- 2026-07-24 · CA-1 (Cline) · **Real Cline Center release ingested.** Owner supplied the official
  Illinois Data Bank download (DOI 10.13012/B2IDB-9651987_V10, "Coup data 2.2.2.csv", 1,161 rows).
  build/convert-cline.mjs maps it to data/external/cline.csv (cowcode -> iso3 via our own record ids,
  event_type -> status); build/audit-cline.mjs reconciles record-by-record on coup_id. Result: **1,161
  of 1,161 matched, 0 date drift, 0 status drift, 0 country drift, 0 missing, 0 orphans** — the
  database is a faithful 1:1 of the release. Report at data/cline-audit.md. crossref.mjs gained an
  exact-id pass (an id hit is authoritative and now beats date-proximity guessing). The prior fixture
  artifacts (1 disagree, 1 candidate) are gone. agreement_status: partial 1152, agree 9; the agree
  count stays low only until the real Powell & Thyne file lands.
- 2026-07-24 · CA-1 (second source) · Powell & Thyne's public files at www.uky.edu/~clthyn2/coup_data/
  now return 403 Forbidden (verified from the owner's browser and from the build host; Powell's own
  site still links to them, so the links are stale, not withdrawn). Substituted the **CAM dataset**
  (Coup Agency and Mechanisms; Albrecht, Koehler & Schutz; militarycoups.org/cam_list_current.csv,
  487 military coups 1950–2022) as the independent second coding. Added `cam_id` to the schema,
  extract/build column order, and the /data/ field reference; build/convert-cam.mjs written and
  smoke-tested against a 5-row fixture (all 5 matched real records, incl. a correct ±3-day match on
  Fiji 1987-09-25 -> our 1987-09-28). Fixture then deleted so no cherry-picked rows ship as data.
  CAM's `successful` flag means "held power >=10 days", a stricter test than ours, so a status
  difference against CAM is deliberately NOT counted as a conflict; only date drift is.
  convert-pt.mjs retained in case the uky host returns.
- 2026-07-24 · CA-1 (second source, real file) · **CAM ingested.** Owner supplied
  cam_list_current.csv (488 rows, all coup=1, 1950–2022). convert-cam.mjs took all 488 with zero
  skips — the COW→ISO3 map derived from our own record ids covered every country in the file.
  agreement_status moved from partial 1152 / agree 9 to **agree 460, partial 701**, i.e. 460
  records now corroborated by two genuinely independent codings.
  First run also produced 16 `disagree` records. Every one was a **matcher artifact, not a data
  conflict**: the ±3-day pass assigned greedily in file order, so an early CAM row with a small gap
  consumed the record a later row matched exactly, and the leftover fell through to the year
  fallback. Our Sudan 1959-11-09 record was paired with CAM's 1959-03-04 while CAM's own
  1959-11-09 row sat in the "events we lack" list. Two fixes in crossref.mjs: (a) the date pass now
  collects every candidate pairing, sorts by day-gap and assigns closest-first across the whole
  file, so exact dates always win; (b) `yearFallback` is now an option, OFF for CAM — in an
  independent dataset two events in the same country and year are usually two different events, not
  a re-dating of ours. Result: **0 disagree**, and the 30 leftover CAM rows are reported honestly as
  candidate events for research rather than silently mis-paired.
- 2026-07-24 · Companion fields · **leader_in_power, regime_type, last_election now filled.**
  The owner also sent leaderlist.xlsx, regimelist.xlsx and electionlist.xlsx from Powell's site.
  These turned out to be exactly the three datasets the site's own glossary and methodology page
  have cited since launch — Archigos-lineage leaders, Geddes–Wright–Frantz regimes, and the
  election list — described as "companion fields" that no record actually carried. The front end
  held a hand-built inline `POWELL` blob of uncertain provenance (1,135 entries) doing the job.
  Now: build/xlsx-to-csv.py copies each sheet to CSV verbatim (no hand or LLM transcription);
  build/convert-companions.mjs normalises them to data/external/{leaders,regimes,elections}.csv;
  build/companions.mjs derives the fields into coups.json by documented rule and writes
  data/companions-audit.md. Coverage: leader 1,109/1,161 (95.5%), regime 1,120 (96.5%), prior
  election 975 (84.0%). The hand-built blob was replaced by a `__COMPANION_DATA__` injection token
  regenerated from coups.json each build, so the page can no longer drift from the data.
  Validation of the old blob against the new derivation: leaders agreed 1,082/1,112 and regimes
  1,116/1,122; nearly all differences were surname-first names now flipped for display
  ("Duvalier, Francois" -> "Francois Duvalier"), plus a handful where the old blob named the leader
  the coup *installed* — the new rule takes the tenure starting strictly before the event date, so
  the field means the incumbent. Static event pages gained Regime type and Last election rows.
  Deliberately NOT touched: target_leader. A companion value must not overwrite a baseline or
  researched field, per the three-layer separation the methodology page promises.

- 2026-07-25 · P1-2 (feed discovery + added_on) · The feed existed but was findable from exactly one
  page, and it was ordered by the wrong date. Both halves fixed.
  Discovery: `<link rel="alternate" type="application/rss+xml">` is now in the `<head>` of every
  page the site produces — 1,172 of 1,172 measured after the build — and a "Subscribe (RSS)" link
  is in every footer (1,171 of 1,172; the map page has no footer, so its link sits in the top bar).
  Ordering: added a required-by-use `added_on` field, the date a record entered the atlas, which is
  NOT the event date. Without it a 1960 coup researched today sorted to the bottom of the feed and
  no subscriber ever saw it. build/build.mjs stamps any record arriving without an `added_on` with
  today's date, once, and writes it straight back into data/coups.json, so the value is fixed from
  then on and later builds cannot move it. All 1,161 records of the original load were stamped
  together on 2026-07-25; that is the date the field was introduced, not a claim about when each
  record was researched. The feed now orders records by `added_on` (falling back to event date for
  the whole tied original load) and shows `added_on` as each item's pubDate, with the event date
  written into the item description so the two can never be confused. `added_on` is declared in
  data/coups.schema.json, exported in coups.json and as the 36th CSV column, and documented in the
  field reference on /data/.

- 2026-07-25 · P1-3 (exports discoverable) · sitemap.xml listed 1,171 pages and zero download
  files, so a dataset crawler reaching the site map alone could not find the open data — only a
  human clicking through /data/ could. The four export files (coups.csv, coups.json,
  coups.geojson, research.json) are now in the sitemap, which is 1,176 URLs with zero broken.
  build/pages.mjs asserts each export exists on disk before listing it, so renaming an export
  breaks the build instead of leaving a dead URL in the sitemap. /data/ also gained a
  schema.org/Dataset JSON-LD block covering license, temporal and spatial coverage, the Cline
  Center DOI as isBasedOn, five variableMeasured entries, and all four downloads as DataDownload
  distributions. Its record count and dateModified are filled by the build's token pass, so the
  structured data cannot claim a number the dataset does not support.


- 2026-07-25 · P1-6 (owner research store) · DELETED, not repaired. The detail page carried an
  "owner research" store that saved research into one browser's localStorage. Nothing else could
  ever read it: no other visitor saw it, no build could publish it, it never reached
  data/research.json, and clearing the browser destroyed it with no warning. The form that was
  supposed to create an entry had never been wired up — its field ids did not match what the save
  function read — so in practice no entry could exist in the first place. Repairing it would have
  built a second, private, unreviewable research layer alongside the canonical one, which
  contradicts the whole point of a citable dataset. Removed: RESEARCH_LS and the functions
  ownerAll, ownerSave, ownerFor, ownerForEvent, ownerItem, ownerAddDetail, ownerDelete,
  ownerExport, ownerImport and ownerToolsVisible, plus 7 orphaned CSS rules. researchStatus now
  reads only RMETA, so the badge reflects published research and nothing else. Research is added
  by editing data/research.json and rebuilding. KEPT, deliberately: the public tip form
  (ownerFormHTML → sendTip), which is wired and works and is how outside contributions arrive;
  and the "OWNER NOTE" provenance chip, because HUMAN appears 12 times as a real label value in
  published data/research.json details. The methodology wording for "Partially researched" was
  rewritten to stop referring to owner-added material: it now reads "the write-up exists but is
  not finished — some claims are still unsourced or unchecked."

- 2026-07-25 · P1-11 (confidence square explained) · The coloured H/M/L square on every card was an
  unlabelled letter whose only explanation was a mouse-hover tooltip, which says nothing at all to
  a touch or keyboard user and nothing to a screen reader. The static template's square now
  carries role="img" and aria-label="Documentation strength: high|medium|low". Above the explorer
  results there is now a visible key, readable without hovering anything: "Each card carries a
  documentation grade: H high, M medium, L low — how many separate outlets the record cites and
  whether the other datasets agree", linking to /data/confidence.md for the exact rule.
  Screenshot-checked after, because the first layout attempt wrapped "L low" onto its own line.

- 2026-07-25 · P1-4 remainder (credit the companion datasets by name) · The field reference on
  /data/ described vdem_v2x_polyarchy, deaths and adverse_regime_change without saying where they
  came from. V-Dem v16 and the Center for Systemic Peace coup list are now both named and linked
  on their rows, with coverage counts injected by the build (__N_VDEM__ → 1,147 records,
  __N_CSP__ → 498 records) rather than typed by hand, so the credit cannot drift from the data.
  The deaths row also states that a zero means the source list recorded none, not that none
  occurred. A row for added_on was added at the same time.

- 2026-07-25 · P1-5 (static/app parity for the research layer) · The static event page showed the
  research summary and stopped there, while the app showed the claim-by-claim breakdown beneath
  it. So the page a search engine indexes, a screen reader reads, and anyone with JavaScript off
  sees was carrying the conclusions without the evidence. The static template now renders the same
  research layer from the same data/research.json: who was targeted, key figures, and every
  claim with its provenance label, its named source and its link. 9,290 claim rows across 1,161
  pages, verified to match data/research.json record for record with zero disagreements. The
  provenance label wording is copied verbatim from provenanceLabel() in the app template — if one
  is ever changed it must be changed in both places, and there is a comment saying so in
  build/pages.mjs. Following the P1-11 lesson, the labels are not explained by hover alone: a
  visible one-line key above the list spells out SOURCE REVIEWED, INDIRECT CITATION and OWNER
  NOTE. The intro line is chosen from what is actually on the page rather than from the status
  word, because one record (16006131960) carries checked claims but no finished summary and is
  still filed as "Dataset only"; printing "no research has been added" above its sourced claims
  would contradict the page itself. The explorer link was also moved above the fold and renamed —
  it used to sit below the fact table as "Open in the interactive explorer", it now reads "Open
  the full case file" and sits directly under the summary on all 1,161 pages, 313px down at
  900px wide. Separately, the static pages, the map page and the data page never declared a
  favicon, so every visit produced a 404 for /favicon.ico while the app declared
  /images/favicon.png; all 1,172 built pages now declare it and the page loads with zero console
  errors.

- 2026-07-25 · P2 (page titles) · Every event page title read "Country, Month Year — Type
  (Status)". 74 titles were shared by 163 pages, because some countries had more than one
  recorded event of the same type and status in the same month, so a search engine had no way to
  tell those pages apart. Titles now widen only as far as they need to: the month is used when it
  is unique, the exact day when it is not, and the record id is appended in the handful of cases
  where two events share a country, a day, a type and a status. Result: 0 duplicate titles across
  1,161 pages. build/pages.mjs throws if a duplicate ever reappears, so this cannot silently come
  back.

- 2026-07-25 · P2 (meta descriptions) · The description was the summary chopped at 158
  characters, which cut almost every one of them mid-word. It now ends on a real boundary:
  a sentence end if one falls inside the limit, otherwise the last clause break (comma, semicolon,
  colon or dash), otherwise a whole word with an ellipsis. Across all 1,161 pages that is 131
  ending on a sentence, 758 on a clause, 1 whole, and 271 on a word — 890 improved. The clause
  floor was set by measuring: at 0.6 of the limit 375 pages still fell back to a bare word cut, at
  0.5 only 271 did, and the shortest description produced is 78 characters, which is still a
  usable snippet. 0.5 was chosen for that reason and not by taste.

- 2026-07-25 · P2 ("1 months before") · The election-gap phrase clamped the number to a minimum
  of 1 but then tested the unclamped number to decide between "month" and "months", so any gap
  that rounded down to zero months printed "1 months before". Maldives, 1983-09-10 was the visible
  case. Both now read the same clamped number, in build/build.mjs.

- 2026-07-25 · P2 (related records) · An event page listed six other records from the same
  country and gave no hint that more existed, so a country with 30 records looked like it had six.
  892 pages now say how many there are and link to the rest — "Showing 6 of N other <country>
  records. See all N in the explorer". The link passes ?country=, which renderExplorer already
  reads, so the filter is applied on arrival rather than the visitor having to set it.

- 2026-07-25 · P2 (sitemap lastmod) · Every one of the 1,176 sitemap entries carried today's
  date, so each rebuild told search engines the whole site had changed. Each built file is now
  fingerprinted (sha1) into data/page-stamps.json; a page keeps its old date until its bytes
  actually change. Same principle as added_on: measure once, then leave it alone. The file is
  committed so the history survives a fresh checkout, and rebuilds itself if it is ever missing.
  Proved with a live test: all 1,176 stamps were backdated to 2026-01-01, a rebuild held all
  1,176; one research summary was then edited and a rebuild moved exactly two URLs (the event page
  and the research file) while 1,174 held.

- 2026-07-25 · P2 (map library) · The map page loaded MapLibre from unpkg.com with no integrity
  hash, so a third party could change the code running on the site and the page could not tell.
  MapLibre 5.24.0 is now served from the site's own /vendor/ directory, copied unmodified from the
  npm tarball with its LICENSE, and still carries integrity hashes as a second check. The build
  refuses to run if the vendor copy is missing. Separately, when the free basemap server does not
  answer, the page used to sit blank with no explanation; it now shows a plain-language notice and
  points at the explorer and the record list, neither of which needs the map. The first draft of
  that notice said the events were still plotted — that was wrong, because the plotting is gated
  on the style loading, so nothing is drawn at all. The wording was corrected and checked against
  a screenshot taken with the basemap blocked.

- 2026-07-25 · P2 (dead code in the app) · Three things in templates/index.template.html were
  computed and never used. barRow() drew a bar for the old light-theme data page, which the dark
  dashboard replaced with SVG columns; it and its five .data-bar-* rules are gone. hexa()
  converted a hex colour to rgba() for translucent fills the dashboard ended up not using; gone.
  The third, validateAtlasData(), needed more care than the other two. It checked four real
  things — duplicate atlas id, duplicate upstream source record, malformed date, and a research
  write-up pointing at an event that does not exist — and on the shipped data it found nothing.
  My first reading of it was wrong: I thought it was comparing fields that do not exist and firing
  thousands of false errors, and I wrote that into the file before checking. It is not; those
  fields are assigned to every record a few lines earlier, and running the check against the built
  page returns zero errors. The comment was corrected before commit. The genuine problem was where
  it ran: its report went to console.error and to a variable nothing read, so a real failure would
  have shipped anyway with no one the wiser. All four checks belong at build time and now live
  there. Two were already in build/validate.mjs (duplicate id; date is a real calendar date).
  The orphan-research check was already there too. The fourth was the same test as duplicate id,
  because the source key is built from the id, so build/validate.mjs gained the stronger version
  instead: no two records may claim the same upstream cline_id, powell_thyne_id or cam_id. All
  three are unique across the current 1,161 records. Tested by deliberately copying one cline_id
  onto a neighbouring record — the build stopped and named both records.

- 2026-07-25 · P2 (nav highlight) · route() marked the active tab by calling activate("name") and
  looking for a link with data-nav="name". "Search Database" had no data-nav, so every visit to
  the explorer called activate("explorer") and quietly matched nothing — no tab lit up and nothing
  reported it. The attribute was added. The Map link deliberately has none and now carries a
  comment saying why: it leaves this page for a separate document at /map/, so this router can
  never own it. To stop the two drifting apart again, build/build.mjs now compares every
  activate() name in the template against every data-nav in it and refuses to build on a mismatch
  in either direction. Tested by removing the new attribute: the build stopped with
  "router calls activate(explorer) but no nav link has that data-nav".

- 2026-07-25 · P2 (numbers with commas) · The home page ledger printed "1161" about 200px above a
  line reading "1,161 events on file", and the explorer header read "of 1161 matching events".
  All the counts the visitor sees now go through toLocaleString, so the same number never appears
  two ways on one screen. Checked in the browser: ledger 1,161 / slip 1,161 / explorer
  "Showing 1–50 of 1,161". A scan of the built page for any remaining bare four-digit count
  outside the injected data blobs returns none.

- 2026-07-25 · P2 ("Baseline only") · The explorer offered a fourth research-status filter,
  "Baseline only", for records with no research. Every one of the 1,161 records has a research
  entry, so researchStatus() never returns it and the option was filtered away before it could be
  drawn — a choice that could never appear. Removed, with a note saying to put it back if records
  without research are ever added. The filter now shows the three real statuses and they sum to
  the full collection: Researched 1,152, Partially researched 6, Research unresolved 3.

- 2026-07-25 · P2 (form spam) · All three Formspree forms — the tip form on every event page and
  the two Field Dispatches signups — posted to an open endpoint with nothing to stop a bot
  submitting in bulk. Each form now carries a hidden honeypot field named _gotcha, offscreen,
  aria-hidden and not reachable by keyboard; Formspree discards any submission that fills it in.
  Verified in the browser on the home page and on an event page: present on all three forms,
  offscreen, aria-hidden="true", tabindex="-1". That handles routine bots but not a targeted
  flood, and captcha and rate limiting are account settings that only the site owner can switch
  on. Those are written out step by step in docs/owner-actions.md rather than left implied.

- 2026-07-25 · CA-8 (4D Governance Time Machine) · DESCOPED, not built. The idea was a view that
  lets a visitor scrub through time and see how governance looked country by country. We measured
  whether the data can carry it before writing any of it. The atlas covers 143 countries across
  1945-2026, which is 82 years, so a time machine would need 11,726 country-year cells. We hold a
  row for 908 of them: 7.7%. Worse than the size of the gap is its shape — the rows we have exist
  only in years a country had a coup, so a scrubber would show a world that is blank everywhere
  until something violent happens and then goes blank again. That is not a gap a visitor would
  read as missing data; it would read as a claim that countries are governed only during coups,
  which is exactly the kind of overreach this whole review was called to remove. Interpolating
  across the empty 92.3% would mean inventing governance scores, which the atlas does not do.
  Decision: do not build it on this dataset. It becomes buildable if a country-year governance
  source (V-Dem's full annual panel, for example) is licensed and loaded as its own layer, kept
  clearly separate from the coup records. Checked before writing this: nothing anywhere in the
  repo — no plan document, no page, no code — claims the Time Machine exists, so no promise had
  to be withdrawn.

- 2026-07-25 · CA-5 (live recent events) · BUILT, site half. The Worker in /worker already fetched
  and filtered GDELT headlines; what was missing was any way for the site to show them, so the
  home page still carried a hand-typed "developing" line that could only be changed by editing
  HTML and rebuilding everything. Now build/recent.mjs turns a GDELT payload into data/recent.json
  using worker/transform.js — the exact same filterArticles the Worker runs and that
  worker/test-transform.mjs tests, now wired into npm test — and build/build.mjs publishes that
  file to dist/data/recent.json after asserting its origin is known and every item is marked
  unverified. The page fetches it at runtime, so replacing that one file replaces the headlines
  with no rebuild. No model reads, rewrites or summarises a headline anywhere in this path;
  titles, links and outlets are copied through byte for byte. Three things keep these headlines
  from ever being mistaken for records: each row carries a visible "unverified — under review"
  tag, the rail states in plain words that these come from an automated search and are not part
  of the database, and the rail refuses to draw at all unless the file was written by the deployed
  Worker. That last one matters because the build ships a committed fixture with example.com
  placeholder links so the code path stays testable, and a fixture is precisely the thing that
  must never appear on a live page. Tested in the browser in both states: with the fixture the
  rail does not render; with a worker-origin file carrying three items, one of them deliberately
  not marked unverified, exactly the two valid rows render, both tagged, both rel="nofollow
  noopener". Nothing on this path can reach data/coups.json; promotion still goes recent.json ->
  a person reads it -> data/candidates.json -> coups.json, as written in worker/README.md.

- 2026-07-25 · P2 (form spam, follow-up) · The honeypot fix above covered three forms and missed a
  fourth. Every one of the 1,161 static event pages carries its own plain-HTML correction form,
  generated by build/pages.mjs, posting to the same Formspree endpoint — and it had no honeypot.
  It was found by opening a built event page in a browser and counting the field, which returned
  zero. The field was added, and a check now runs at build time over both the interactive template
  and the static-page generator: any form that posts to Formspree without a _gotcha field stops the
  build. Negative-tested by deleting the new field, which stopped the build with "build/pages.mjs:
  a form posting to Formspree has no _gotcha honeypot". Rechecked after rebuilding: 1,161 event
  pages carry a Formspree form and 0 of them are missing the honeypot.

- 2026-07-26 · Page uniformity (shared chrome) · The site had three separate page shells that had
  drifted apart. The interactive app carried the seal, the full navigation and a four-column footer;
  the 1,161 static event pages, the event index, the dispatch pages, the /data/ page and the /map/
  page carried a bare text wordmark with no picture, no navigation, and either a single grey line of
  a footer or none at all — and they used a different paper colour, ink colour and typeface, so they
  did not look like the same website. The cause was that each shell was written out by hand in a
  different file. build/chrome.mjs is now the only place the design tokens, the chrome stylesheet,
  the header and the footer are written; every surface receives them at build time through four
  markers. The seal is set in the stylesheet rather than by script, because a static page runs no
  JavaScript and that is exactly why the logo was missing. The map page was changed from a
  full-viewport layout to normal flow so that a footer has somewhere to sit.

- 2026-07-26 · Page uniformity (guards) · Three checks now make this impossible to break again, and
  each was negative-tested. One: any page missing a marker stops the build — proved by deleting the
  footer marker from static/data-page/index.html. Two: if the app template and build/chrome.mjs ever
  disagree on a colour, the build stops naming both values — proved by changing --oxblood in the
  template. Three: after every build all 1,172 built pages are opened and checked for the header, the
  footer and the seal — proved by removing the footer call from build/pages.mjs, which stopped the
  build and listed the offending files. Verified in a browser on seven surfaces: identical navigation,
  identical seal, four footer columns and "6 contributing datasets" everywhere, and the dark
  statistics page correctly inherits the dark version of the same chrome.

- 2026-07-27 · Plain web addresses · Every address the site hands out is now a word after the domain:
  /explorer, /statistics, /news, /methodology, /glossary and /record/<country-date>. The "#" existed
  because a static host returns "not found" for an address that is not a real folder, so the app used
  the part after the "#" — which the browser never sends to the server — to decide what to draw. The
  server is now told the six addresses instead, in dist/_redirects, which the build writes. Each
  address is listed by name rather than a catch-all, so a genuine typo or a missing image still
  returns a proper "not found" instead of silently showing the app.

- 2026-07-27 · Old "#" links keep working · Anything already shared or bookmarked is rewritten to its
  new home the moment it loads, including record links of the form #/event/cline:2.2.2:<id>, which
  are looked up and sent to /record/<slug>. In-page clicks are caught before the browser reloads, and
  the back and forward buttons are wired to the same router, so navigation behaves as it did before.

- 2026-07-27 · Two pages were both called "Data" · The charts dashboard is now Statistics, at
  /statistics, and the downloads page is labelled "Downloads & Documentation" at /data/. They now
  link to each other in both directions. This also removed the address collision, since both pages
  previously wanted the path /data.

- 2026-07-27 · Everything is reachable from the footer · The Explore column lists all nine surfaces —
  Overview, Search the Database, All Records A–Z, Map, Dispatches, Statistics & Charts, Downloads &
  Documentation, Methodology & Research Notes, Glossary of Terms. The downloads page previously had
  no link anywhere on the site and could only be reached by typing the address.

- 2026-07-27 · Search-engine details follow the new addresses · Each app view now sets its own
  canonical address, title and social-preview text instead of every view claiming to be the front
  page. /record/<slug> points at its printed twin /event/<slug>/, which is the version in the
  sitemap. The five app addresses were added to sitemap.xml (1,176 → 1,181 URLs) and a hardcoded
  "#" address inside the news structured-data block was corrected.

- 2026-07-27 · One typeface rule for the whole site · Body copy is Archivo and headings are Spectral
  everywhere. The static pages had been setting body copy in Spectral, so the same record read as two
  different documents depending on which address you arrived at. The rule is written once in
  build/chrome.mjs; the record badges, the correction-form fields and the map page's fallback notice
  were brought onto the same tokens; the A–Z index opener image is capped so the page starts with
  words rather than a screen of painting.

- 2026-07-27 · Three more guards, each negative-tested · One: if the app's address list and the
  server rules disagree, the build stops and prints both — proved by renaming /statistics to /stats
  in the template. Two: any link still written in the old "#/" style stops the build — proved by
  restoring one on the downloads page. Three: any page that sets its own body font instead of the
  shared rule stops the build — proved by adding a Spectral body rule to the downloads page. Verified
  in a browser across thirteen surfaces: all load, all carry the same header, seal and footer, all
  report the same body typeface, none carries an old "#" link, and each shows the correct canonical
  address and the correct highlighted navigation item.

- 2026-07-25 · The H / M / L documentation grade explains itself · The small square was a coloured
  letter with a hover tooltip, invisible to touch and keyboard users, and on a card it sat inside the
  card's link so clicking it opened the record instead. It is now a real button carrying
  data-info="documentation", which opens the same glossary panel the round "i" icons use. On the
  printed event pages, which run no script, it is a link to /glossary#g-documentation instead. The
  click handler now matches on the data-info attribute rather than a class, so any new control only
  has to carry the attribute to be explained.

- 2026-07-25 · The grade definition moved from the results page to the glossary · The paragraph of
  H / M / L definitions above the search results was read past by every visitor on every visit. It is
  now a glossary entry, reached by clicking the square, by the "Full glossary" link in the panel, or
  by browsing /glossary. Glossary rather than Methodology because it is a term definition and the
  glossary already had the anchor and pop-up machinery.

- 2026-07-25 · The definition panel moved onto the page body · It was being clipped mid-sentence
  inside result cards: a card is overflow:hidden and lifts on hover, and a transformed ancestor
  becomes the containing block for anything positioned inside it. The panel is now appended to the
  body and anchored to the window, and its width is measured rather than guessed — the guess is why
  it used to hang off the right edge. Checked at 1280px and at 390px: fully on screen at both.

- 2026-07-25 · The "Researched" label was dropped from result cards · 1,152 of 1,161 records are
  researched, so the label marked nothing and crowded the line. The nine records that are not
  finished still show their status, so the exception stays visible. The label is unchanged on the
  record page and the "Research status" filter is untouched.

- 2026-07-25 · The grade square lines up with the date · It was pinned to the top-right corner and
  read as stuck onto the card rather than part of it. Date, country code and grade now share one
  right-hand line. The card title carries the link and stretches invisibly over the whole card, so a
  click anywhere still opens the record while the square sits above that layer on its own.

- 2026-07-25 · Numbered pages replaced "Load more" · Reaching the end of 1,161 records took 24
  clicks and there was no way to jump, bookmark or share a position. The list is now paged, 25 to a
  page by default, with a choice of 10, 25, 50 or 100. Page number and page size both live in the
  web address, so a link to page 7 opens page 7. The strip prints the first page, the last page and
  the current page's neighbours with a … for the run between, so 117 pages never print as 117
  buttons. Default is 25 rather than 10 because 10 to a page makes 117 pages.

- 2026-07-25 · An About page at /about/, linked from the footer · Built as a real HTML page rather
  than an address inside the app, because the readers it is written for — search engines and the
  assistants people now ask questions of — see the text without running any script. It explains what
  the database is, what is in it, where the data comes from, what the documentation grade means, who
  maintains it, and how to cite it. Every figure on it is counted from the data at build time, so it
  cannot drift. It carries a JSON-LD graph naming the site, the dataset and Trevor Williams as its
  creator, which is what lets a search engine connect his name to the subject. The page helper writes
  a single JSON-LD block, so the graph is spliced in after the page is written rather than changing
  the helper for every other page.

- 2026-07-25 — Rebuilt static/images/favicon.png. The problem was real: every white area of the
  seal (the outer ring, the dome, the outlines) was fully transparent, so a light page faked the
  white behind it and a dark page turned the dome into a black silhouette. The file is now opaque
  RGB with an anti-aliased circular alpha mask, so the seal reads correctly on any background.

- 2026-07-25 — Site-wide light/dark switch. A complete dark palette already existed as body.night
  but only ever applied to Statistics; it is promoted to :root[data-theme="dark"] in build/chrome.mjs
  as DARK_TOKENS and now reaches all four page surfaces. A pre-paint inline script reads
  localStorage["ca-theme"] before the first frame, falling back to the reader's system setting, so
  there is no flash. The header button persists the choice and fires ca-theme-change; the Statistics
  charts read their greys from the tokens at draw time and redraw on that event. Statistics no longer
  forces itself dark. Two drift guards in build/build.mjs assert both token blocks in the app
  template match chrome.mjs exactly. The four series colours are unchanged — the dataviz validator
  passes them on the light surface too, so a colour means the same thing in either theme.

- 2026-07-25 — Sort options on the explorer. The results list was hard-wired to newest-first. Five
  orders now: newest first, oldest first, country A-Z, country Z-A, best documented first. Every
  order breaks ties by date newest-first, so the sequence is stable. The choice rides in the web
  address next to per and page, so a sorted list can be shared; changing it returns to page 1.

- 2026-07-25 — The closing band stays dark in both themes. "A database designed to keep growing" and
  the Field Dispatches signup were painted with var(--ink) on var(--paper), so the dark theme inverted
  the one deliberately dark panel into a cream slab. The band now re-declares the daylight token values
  on itself, so every rule beneath it keeps working and the band looks identical in either theme.

- 2026-07-25 — The Search button stays dark in both themes. Both search buttons (home slip, explorer bar)
  were var(--ink) and turned cream at night. Dark theme now pins them to #0F1216 with an inset hairline;
  hover still turns red. Inset shadow rather than a border because both buttons are border:none and a
  real border would resize them.

- 2026-07-25 — One approved seal, at an address no cache has seen. static/images/favicon.png renamed to
  coup-atlas-seal-v2.png and all five references updated. The file was already the approved artwork; the
  problem was browsers holding the older transparent-white copy at the old address, which read as a black
  dome once pages could go dark. _redirects 301s the old address; build.mjs now fails a page that omits the
  new seal or still mentions the old one, so "only one approved logo" is enforced by the build, not by care.

- 2026-07-25 — Navigation glitch (map -> Statistics landing on the home page) investigated, not reproduced.
  All five app addresses cold-load correctly and the click from /map/ lands on /statistics; the map page
  links to an absolute /statistics, there is no service worker, and the server rules are 200 rewrites that
  preserve the address. No _headers file was added: Cloudflare Pages joins duplicate Cache-Control values
  with a comma, so an unverified /* rule would corrupt caching site-wide. Left for a test on the live site.
- 2026-07-25 — Navigation glitch found and fixed. A video from the owner showed the address bar going
  /map/ -> coupatlas.com -> /statistics, so the host, not the app, was moving the reader. The five app
  addresses were server rules pointing at /index.html, and Cloudflare Pages redirects /index.html to /
  ("/about/index.html will be redirected to /about/"), so the address was thrown away before the app
  started. The second click worked because by then the app was running. build/build.mjs now writes the
  shell to explorer.html, statistics.html, news.html, methodology.html and glossary.html; the host serves
  each at its extension-less address, and a file cannot be redirected away from itself. /map/ and
  /event/<slug>/ never had the bug because they were always real files.
- 2026-07-25 — /record/* changed from a 200 rewrite to a 301 to /event/:splat/. A shared /record/ link now
  lands on the printed page that is in the sitemap instead of a second copy of the same record.
- 2026-07-25 — build/preview.mjs now serves foo.html at /foo and honours 3xx rules as real redirects, so
  the local preview reproduces what the host does instead of testing something else.
- 2026-07-25 — The body-font check exempts every copy of the app shell, not just index.html. The shell
  carries its own stylesheet; the copies are the same bytes. The exempt list is built from APP_ROUTES, so
  a hand-written file at the top of dist is still checked.
- 2026-07-25 — Dispatches page given an <h1>. It opened with an <h2> and no <h1>, so to a search engine
  and a screen reader it was a page with no name. Uses the explorer's heading block, so no new CSS.
- 2026-07-25 — The last six hotlinked photos are now hosted here. Three were saved and resized to the
  house standard and the three Wikimedia addresses in the template were replaced with local ones. No
  page on the site now asks another site for a picture.
- 2026-07-25 — Removed the two lines that copied the browser-tab icon onto the seal in the header and
  the footer. Changing the tab icon silently changed the logo on every page, which is the fault that
  took days to find. The app template now names the seal picture in its own stylesheet, in plain sight.
- 2026-07-25 — The seal check in build.mjs now counts two appearances per page, not one. The footer
  picture alone satisfied the old check, which is how the top seal went missing on the six app pages
  without the build noticing.
- 2026-07-25 — --ink-faint darkened from #7C8187 to #62676C. The old value scored about 3.2 to 1 on
  paper and needed 4.5. It sets footer headings, dates, meta lines and table headers on every page.
  The closing panel keeps the old value locally on purpose: that band is dark, so darkening reverses.
- 2026-07-25 — --brass darkened from #8A6D3B to #7A5F33 for the same reason. The developing strip is a
  dark band on a pale page, so it carries the night brass locally rather than the darker one.
- 2026-07-25 — build/pages.mjs had no night rules at all and ten fixed light colours, so on the 1,169
  printed pages the grade squares, research chips, buttons and correction form stayed light when the
  switch was flipped. Worst of it: a white form field holding light lettering, so a reader typing a
  correction saw nothing. Night rules added, matching the values the interactive pages already use.
- 2026-07-25 — Six dispatch photographs were missing on the live site. The app wrote picture addresses
  the short way ("images/x.jpg"), which a browser reads as "next to the address in the bar", so at
  /news/ it went looking in /news/images/ and found nothing. An imgSrc() helper now puts the leading
  slash on. The printed pages had always done this; the app was the half that did not.
- 2026-07-25 — build/build.mjs refuses to finish if a picture address is written the short way, in
  plain markup or built inside the app. The guard was proved against the exact broken form first.
- 2026-07-25 — The explorer now stamps each opening with a number, so a search started by an earlier
  opening cannot paint its results over a later one. A reader who left and came straight back was at
  the same address with fresh filters, and the late job quietly put the old search back on screen.
- 2026-07-25 — Five chart labels drawn from the data were being written into the page unescaped:
  the bar rows, their tooltips, the two headline figures and the subregion table. Escaped now.
- 2026-07-25 — The tip form inside the app sent e.atlas_id, a field no record has, so every tip ever
  sent from the app arrived saying "record undefined". It now carries the real number and the event's
  name on the form itself, so the subject line reads "Coup Atlas tip: Venezuela 2026".
- 2026-07-25 — dist/data/citations-report.md is published, and it was claiming 536 URLs had been
  repaired for "spaces inside percent-escapes" when most carried no percent sign at all. Split into
  what it really is: 186 punctuation repairs and 493 addresses moved from http to https.
- 2026-07-25 — build/crossref.mjs rewrote data/coups.json every time the tests imported it. It now
  only writes when run as a command, like build/citations.mjs already did. Proved with a byte
  comparison of data/coups.json taken before and after a full build and test run.
- 2026-07-25 — The crossref report printed "(cam: ?)" on all thirty CAM candidates, because it read
  only the first two id fields. The one useful thing on the line — the number to look the event up
  by — was the thing missing.
- 2026-07-25 — The Map tab could never light up as the page you are on: the map is its own document,
  so it had no route name and was given null, and nothing is ever equal to null. It has a name now
  and build/build.mjs marks it current when it stamps the chrome on.
- 2026-07-25 — The footer read "1 contributing dataset are drawn on" when the count was one. The noun
  was made to agree with the number; the verb after it was not.
- 2026-07-25 — worker/transform.js: "seizure power" was written as an alternative nobody types, so
  that branch could never fire; the exclusion list was tested against every headline, throwing away
  real coverage for mentioning a film or a game anywhere in the sentence; and the duplicate key was
  cut at 60 characters, shorter than a wire headline, so different stories collapsed into one.
- 2026-07-25 — worker/worker.js never wrote the origin key the page waits for, so the headline strip
  could not have appeared even after a successful deployment. It also overwrote a good list with an
  empty one on a quiet hour. Both fixed, and a test now reads both files and checks they agree.
- 2026-07-25 — build/preview.mjs applies dist/_headers, so the browser rules — the content security
  policy above all — are exercised locally instead of first meeting a reader in production.
- 2026-07-25 — A link carrying two ?country= values filtered correctly but left the single country
  box naming one of them, so the control disagreed with the results it had produced. The box now
  reports "2 countries selected" and the pills name each one.
- 2026-07-25 — A page number past the end of the list was corrected on screen but not in the address
  bar, so a shared link kept claiming a page that does not exist. The address is corrected too.
- 2026-07-25 — The content security policy no longer says 'unsafe-inline' for scripts. That phrase is
  the browser's instruction to run any script written into a page, including an injected one, which
  is the single thing the policy exists to stop. The four inline scripts this build writes — the
  theme pre-paint, the theme switch, the app and the map — are now named by fingerprint instead.
  Styles keep the exemption; that limit is written down in the auditor pack.
- 2026-07-25 — The tip form's onsubmit attribute became a listener on the document. A piece of
  JavaScript inside an HTML attribute cannot be named by fingerprint and would have forced the
  blanket permission straight back on.
- 2026-07-25 — build/build.mjs refuses to write the policy if any page carries JavaScript in an HTML
  attribute, or if any inline script is not named in the policy it just wrote. Both guards were
  proved by breaking the source on purpose: the first missed an onclick the app built at runtime
  until it was taught to read inside the scripts as well.
- 2026-07-25 — templates/index.template.html gained safeUrl(), the check build/pages.mjs has always
  had: an address that is not plainly http or https is not made into a link at all. Wired into the
  detail source link, the citation link, the reconstructed citation link and the headline strip.
  esc() had been doing the work alone there, and esc() only makes quotes safe — "javascript:…"
  carries no quotes and went through untouched.
- 2026-07-25 — the headline strip already dropped non-http rows before drawing, so it was not the
  exposed one; the detail source link and the citation link were. Written down because the first
  reading of this had it the other way round.
- 2026-07-25 — templates/index.template.html gained embedSrc() for the film boxes: an iframe address
  must be one of the two YouTube players the browser rules already name, or no box is drawn.
- 2026-07-25 — build/build.mjs refuses to finish if any href or src is built straight from a stored
  address without passing safeUrl(), embedSrc() or imgSrc() first. Proved by putting esc(i.url) back
  into the source: 6 pages caught.
- 2026-07-25 — that guard also lifts safeUrl() and embedSrc() out of the shipped script and runs them
  against seven hostile addresses. Presence alone was not enough — emptying a checker out while
  leaving its name in place passed the first version of the guard, and nothing on a finished page
  would have looked wrong.
- 2026-07-25 — verified in a real browser (linksafe.mjs): four hostile addresses fed to the headline
  strip through the news file produced no links and ran nothing, the one ordinary headline beside
  them still appeared, and 395 links across 11 page kinds are all ordinary web addresses.
- 2026-07-25 — the Methodology page said the browser validates duplicate IDs, orphans and dates at
  startup. It did until the browser-side validator was removed on 25 July; the sentence outlived it
  and was simply untrue. Rewritten to say where the checks actually run, and to say plainly that the
  page used to claim otherwise.
- 2026-07-25 — docs/confidence.md pointed at build/build.mjs for confidenceFor(); it lives in
  build/spec.mjs.
- 2026-07-25 — build/golden-before.json and build/golden-after.json deleted. Nothing in the repo
  referenced either file.
- 2026-07-25 — the finished site is 30.3 MB as one archive and the delivery channel stops at 30 MB.
  Split into two rather than shrunk: part 1 is dist without dist/event, part 2 is dist/event, both
  opening into dist/. Verified by extracting both into one folder and diffing against dist — 2446
  files, no differences.
- 2026-07-25 — measured where the bytes go while looking for something to cut: the shared chrome CSS
  is stamped inline into all 1178 built pages, 13.7 KB each, 15.8 MiB of the 55.9 MiB of HTML (28%).
  Compressing the event pages with it inline gives 14.6 MB; with a link to one shared file, 10.1 MB.
  Not changed — it is an efficiency matter, not a correctness one, and refactoring build/chrome.mjs
  on the last day of work is the kind of change that breaks something quietly. Recorded in the review
  pack as the clearest improvement left.
- 2026-07-25 — the source archive was rebuilt and then tested the way an auditor would meet it:
  extracted into an empty folder, `npm run build` run against nothing else. It builds, every count
  matches, all tests pass, and the dist it produces is byte-for-byte identical to the one being
  deployed. .git excluded (98 MB of prior dist snapshots); dist excluded (delivered separately).
- 2026-07-25 — retracted my own advice. The review pack told the owner to switch on Formspree's
  reCAPTCHA. Formspree's built-in reCAPTCHA redirects the visitor to a challenge page on formspree.io;
  the tip form posts with fetch and expects JSON, so the toggle would have failed every tip with
  "Something went wrong" while the newsletter box, which posts normally, kept working. Wrong advice
  that breaks a working form quietly is worse than no advice. Domain restriction is the setting to
  make — dashboard only, no code change. A real CAPTCHA (Turnstile or reCAPTCHA v3) needs a widget in
  the page, a token in the request and script-src widened to a third party; not doing that today.
- 2026-07-25 — Formspree domain restriction saved: project restricted to coupatlas.com, entered bare
  so it matches the www form too. Owner action closed. The setting is on the PROJECT settings page,
  not the form's; the form's own Spam protection panel lists only Formshield and CAPTCHA and gives no
  hint the third setting is a level up, which cost real time to find. Written down so the next person
  does not repeat it. Side effect: submissions from *.pages.dev preview links are now rejected, so the
  tip form must be tested on the live domain.
- 2026-07-25 — data/coups.schema.json line 313: the rule that a citation's web address must look like a
  web address was written with one level of escaping too many, so it matched nothing. Corrected. Checked
  against the live data before tightening it: 630 citation addresses, none rejected by the corrected rule.
- 2026-07-25 — and the fix above would still not have run. build/validate.mjs read the schema one level
  down only; the citation address sits three levels down (source_trail → cites → url). Every rule the
  schema states about anything nested had never once been applied to the data. The checker is now
  recursive and names the exact spot, so a failure says which citation on which record. Proved on an
  isolated copy: a citation address of javascript:alert(1) is now rejected, and was not by any build
  before today. A rule nothing runs is not a rule, and it looks like protection.
- 2026-07-25 — build/validate.mjs: a record that leaves out lead_actor_type failed with '"undefined" not
  in vocabulary', which reads like bad data when it is a blank. Omitted and null now both mean "not
  classified", and the message names the seven legal values when a value really is wrong. Latent, not
  live: of 1161 records none omit the field, 1110 set it to null, 51 carry a value.
- 2026-07-25 — composer.html moved to attic/composer-superseded.html with a red panel on the page and a
  plain-English attic/README.txt. Checked rather than assumed that it would still appear to work: it
  splices on 'const NEWS = [' and 'const EVENTS = [' and both still exist in the built index.html, so it
  would have accepted the file and handed back something broken three ways — the next build overwrites
  it, the record would exist in that one file and nowhere else, and the security header pins a
  fingerprint of the very script it edits, so the site would have come up blank. Verified the build
  copies by explicit file lists, so nothing in attic/ is published.
- 2026-07-25 — the shared chrome CSS is no longer stamped into every page. Written once to
  /assets/chrome.<fingerprint>.css and linked by all 1170 built pages; the name carries a hash of the
  contents, so a changed rule always arrives under a new name and it is safe to cache for a year. Old
  fingerprints are swept each build. Measured: HTML 55.9 MiB → 40.5 MiB (−27%), event folder compressed
  14.6 MB → 9.7 MB against a predicted 10.1 MB, whole site 92 MB → 75 MB. Counts unchanged, all tests pass.
- 2026-07-25 — the seal guard would have failed on all 1170 pages when the CSS moved out, because one of
  the two seal references it counts lives inside the CSS. Updated first, so it never actually failed: it
  now reads the linked stylesheet as well as the page, and refuses to build if a page links a stylesheet
  the build never wrote. Without that it would have gone on passing while looking at nothing.
- 2026-07-25 — the Statistics page said records without a second dataset "rest on a single corroborating
  source", which reads as a claim about the evidence rather than about dataset coverage. Rewritten to name
  the coverage limit. The same caveat was already correct on the data page, in the methodology text and on
  the printed record pages, so the Statistics panel was the only one out of step.
- 2026-07-25 — the Colombia dispatch photograph's licence supplied by the owner: Creative Commons Public
  Domain Mark, released by the author on Flickr, confirmed by Wikimedia's own reviewer on 1 Nov 2025.
  Caption now names author, source and terms. But the handoff's claim that "every other photograph names
  both" was wrong, and checking before closing the item is what caught it: three photographs — the
  Bosphorus Bridge, Petro–Trump, and Ortega–Murillo images — say only "via Wikimedia Commons" or "via
  Flickr, cropped" and name neither photographer nor terms. Reopened as an owner item rather than closed.
- 2026-07-25 — the two-part archive split is retired. It existed because the finished site zipped to
  30.3 MB against a 30 MB delivery limit, 349 KB over. With the shared stylesheet no longer stamped into
  every page it zips to 26.1 MB, about 4 MB of headroom, so it goes back as one file again and the
  merge-two-folders instructions can be thrown away. A refactor done for one reason paid off somewhere
  unrelated, which is worth noting because the reverse happens more often.
- 2026-07-25 — audit/checks/photo-live.mjs reported two good photographs as broken: the Biden-Erdogan
  image on the Turkey record and the Petro-Trump image on the Colombia dispatch. Both are marked
  loading="lazy", so the browser correctly had not fetched them yet, and the check measured before
  scrolling. Files verified present, valid JPEG and served 200 first; then proved by scrolling, at which
  point they draw at 864px and 1000px wide. The check now walks the page to the bottom the way a reader
  would before measuring. The site was never wrong; the check was. Written down because a check that
  cries wolf is one people stop reading, which is worse than not having it.
- 2026-07-25 — two more photograph licences supplied by the owner and applied. The Petro-Trump White
  House image on the Colombia dispatch is a Public Domain Mark work by Fotografia oficial de la
  Presidencia de Colombia, Flickr, reviewed by FlickreviewR 2 on 3 Feb 2026. The Ortega-Murillo image on
  the Nicaragua dispatch is CC BY 2.0 by the Office of the President, Republic of China (Taiwan), January
  2017 — attribution is a condition of that licence, so the author is now named rather than just the
  source. Three of the four gaps closed.
- 2026-07-25 — the fourth, static/images/news-turkey-bridge-2016.jpg, cannot be closed from here. Its
  embedded metadata has been stripped (checked the file directly: no EXIF, no author, no date) and
  nothing in the repo, the build log or the version history records the source. Left open with a
  description of the image so it can be identified, and a note that replacing it with a photograph of
  known provenance is likely faster than tracing this one.
- 2026-07-25 — the last photograph licence supplied by the owner, who found the file on Commons. The
  Bosphorus Bridge image is CC BY-SA 4.0, own work by the uploader "g", 3 August 2018, uploaded during
  Wiki Loves Earth 2020. Credited accordingly. Every photograph on the site now names its author, its
  source and its terms; the open owner item is closed. Two notes for the record: the uploader's own
  description says "15 June coup attempt", which is wrong — it was 15 July — but that is their text, not
  ours, and our caption is correct. And share-alike binds adaptations of the image, not a page that
  displays it beside text, so nothing about this obliges the rest of the site to be CC BY-SA.
- 2026-07-25 — the record view is drawn by one file, build/record-view.mjs, and both the app and the
  1,161 printed record pages run it. Before this the printed page was a hand-kept copy of the app's
  markup and had already drifted in four visible ways (a research section that arrived open, the wrong
  related-records treatment, "Send correction" where the owner had written "Send tip", and a two-line
  democracy score where he had made it one line with an information button). Hand-copying was abandoned
  rather than corrected: there is no second copy left to keep in step.
- 2026-07-25 — the record view's styling is taken from the app's own stylesheet at build time, not
  retyped. build/record-css.mjs reads the <style> block inside templates/index.template.html and keeps
  every rule naming a class the record markup actually uses; build/pages.mjs reads that class list off
  the record bodies it is about to write, so a class introduced tomorrow brings its styling with it.
  The box the record sits in is the app's own `main` rule renamed to `main.record`, because `main` on a
  page of prose is a narrow column and must stay one.
- 2026-07-25 — the small information panels are drawn by the same handler at both addresses. Click
  tested on a printed page: each button opens exactly one panel, each panel links the full glossary,
  Escape and clicking away both close it, and every panel is word for word the app's.
- 2026-07-25 — one stylesheet became two. A page of prose is a document and wants paragraph gaps, list
  indents and oxblood links; a record page is the app's view and must start from the rules the app's
  stylesheet opens with, which set no margins anywhere and let a link take the colour of what it sits
  in. Carrying both in one sheet is what made the same record come out spaced and coloured differently
  at the two addresses — 62 settled properties apart. Neither `main.record *` nor `:where(main.record) *`
  fixes it: the first is too strong and wrecks the layout, the second too weak and loses to `p{margin}`.
  So: chrome.<fingerprint>.css for the 9 pages of prose, record.<fingerprint>.css for the 1,161 record
  pages, built from the same parts in the same order with only the opening rules swapped. No page now
  downloads the styling for the other kind of page.
- 2026-07-25 — the build's body-font guard now allows two rules, not one. It stops the build when a page
  sets a body font of its own, which is how the same record once came out serif at one address and sans
  at the other. A record page legitimately carries the app's `body` rule, so that one is allowed too —
  read out of the app's stylesheet by appBodyRule() rather than typed here, so the two cannot come apart.
  Caught by reading the guard before running the build; it would have failed on all 1,161 record pages.
- 2026-07-25 — all six string injections in build/build.mjs now pass a function to .replace(). With a
  plain string, JavaScript treats $& and $` inside the replacement as instructions rather than text, so
  any injected content containing them was silently corrupted. A function replacement has no such rule.
- 2026-07-25 — proved rather than asserted. Every one of the 1,161 records was rendered through the app
  in a real browser and compared character for character against its printed page: 1,161 identical, 0
  different. Every settled style was compared element by element at 1280px and 390px, in light and dark,
  on records spread across the dataset: every one matches. And the printed page and the app view are
  pixel-identical down the whole record — the first differing row is in the empty space above the
  footer, where the app leaves 86px more.
- 2026-07-25 — the address check in build/build.mjs no longer requires the app's safeUrl and embedSrc
  helpers to sit at the left margin. It looks for the declaration anywhere in the script rather than at
  the start of a line, because moving that code inside a block would otherwise have made the check
  quietly decide the helpers were missing and stop the build for the wrong reason.
- 2026-07-25 — the footer on a phone was cut down from 1151px to 644px on a record page. The Explore
  links now run across in a line rather than stacking ten deep, the four About the Data folds sit two
  across, the social links keep their logos and drop their names, and the fine print at the foot stays
  centred. Wrapped link names hang under the words, not under the fold arrow. The desktop footer is
  unchanged, measured at 457px before and after.
- 2026-07-25 — the phone footer rules are written once, in build/chrome.mjs as FOOTER_PHONE_CSS, and
  stamped into templates/index.template.html by build/build.mjs through the /*__FOOTER_PHONE_CSS__*/
  marker. The app template carries its own stylesheet rather than linking the shared one, so without
  this the homepage and the 1,161 record pages would have drifted apart.
- 2026-07-25 — the Developing strip is now one link across its whole width, and on screens narrower
  than 1001px the headline rolls instead of being cut off. It is held still for the first 18% of each
  pass so the opening words can be read, then travels at 60 pixels a second, which is about half a
  comfortable reading pace. Measured at 60.0px/sec with a median frame gap of 16.7ms. Pressing it
  stops it. A reader who has asked for reduced motion gets a still strip that wraps. At 1001px and
  wider nothing moves and the strip renders exactly as it did before.
- 2026-07-25 — the reCAPTCHA instruction in docs/owner-actions.md was wrong and has been withdrawn.
  Turning it on would have broken the tip form on the app pages, where the form is posted in the
  background and expects JSON back; reCAPTCHA answers with a challenge page instead, and the CSP
  connect-src / form-action lines written by build/build.mjs block the captcha from loading anyway.
  The replacement advice is to restrict the Formspree project to coupatlas.com, keep Formshield and
  the _gotcha honeypot, and turn on rate limiting. Side effect: submissions from *.pages.dev preview
  links are rejected, so the tip form can only be tested on the live domain.
- 2026-07-25 — two guides were written as PDFs rather than markdown, at the owner's request: a
  visitor help guide describing how to use the site, and an owner's manual describing how to run and
  change it. Both are generated from Python files that read every figure live out of data/coups.json,
  so no number in either document is typed by hand. Neither lives in the repo; they are deliverables.
- 2026-07-31 — a seventh dispatch was added, on the newly reported recruitment allegations in
  Nigeria's 2025 coup plot case, sourced to the Premium Times investigation of 29 July plus the
  Financial Times, Reuters and the Associated Press. It is badged Developing, so it now drives the
  wire strip on the homepage in place of the Guinea-Bissau dispatch, which keeps its own Developing
  badge on its card. No new photograph was licensed. The hero is the existing public domain portrait
  of Aguiyi-Ironsi (47501151966-1.jpg), captioned as 1966 history rather than as an image of the 2025
  case. A second 1966 portrait was tried inside the body and removed: both Nigeria photographs are
  tall portraits, and at 0.77 aspect two of them filled 46% of the page with faces from sixty years
  before the story. One file photo, honestly captioned, carries it. Linked to record 47510232025.
- 2026-07-31 — the hero photograph on the Nigeria dispatch was changed at the owner's request.
  The 1966 Aguiyi-Ironsi portrait was replaced with a photograph of President Bola Tinubu taken on
  25 August 2025 at the Palacio do Planalto in Brasilia by Ricardo Stuckert of the Brazilian
  presidency, published by Lula Oficial on Flickr and carried on Wikimedia Commons under CC BY-SA
  4.0. The caption was rewritten: the old one said no photograph from the 2025 case had been
  released, which stops being true once the hero is the sitting president himself. At the owner's
  request the caption describes only the photograph itself, naming who is in it, where and when, and
  leaves the allegations to the article, where they are already set out and properly qualified.
  Share-alike applies to edits of the photograph, not to the site, and the file is published
  unmodified apart from resizing. The credit line in the caption carries the attribution the licence
  requires, which is how the other six dispatch photographs are credited.
- 2026-07-31 — the build would not run on Windows, and now does. Four files turned a file URL
  into a path with .pathname instead of fileURLToPath(). On Linux and macOS the two give the same
  answer; on Windows .pathname keeps a leading slash, so "/C:/Users/..." was read as a relative
  path and opened as "C:\C:\Users\...". The build got as far as validate and then died in
  build/record-css.mjs, a file nobody had edited. Fixed in build/record-css.mjs, build/pages.mjs
  (both SITE and DATA), build/check-record-parity.mjs and, for a path with a space in it, the
  quoting of the node command in build/build.mjs that runs pages.mjs. Every other file in build/
  already used fileURLToPath; these four were the exceptions. build/build.mjs now refuses to build
  if the pattern comes back, because nothing on a Linux machine can catch it otherwise. No output
  changed: both shared stylesheets kept their fingerprints, chrome.04732c19c140.css and
  record.4e12d20e63b6.css, which is the proof that only path handling moved.
- 2026-08-28 — a ninth dispatch was added, "Can Haiti Reach Election Day?" The analysis follows
  Haiti's revised December 2026 election calendar through voter access, gang control, the timing of
  the Gang Suppression Force mandate, and the resilience of physical and electronic election
  records. It links the ballot-preservation concern to the November 2025 Guinea-Bissau coup record
  and to reporting that the electoral commission could not complete that vote after ballots and
  tally sheets were seized and its results server was destroyed. The featured Haiti National Police
  graduation photograph and the inline Fils-Aimé–Rubio photograph are U.S. government works carried
  by Wikimedia Commons as public domain. Both are published with full captions and source credits.
- 2026-08-31 — added the developing dispatch “The Coup Devours Its Own” and a separate August 29, 2026 Niger attempted-coup record (43608292026). The editorial classification is based on coordinated attacks against Air Base 101, the presidential district and state broadcaster, while the article retains “apparent” because the mutineers’ leadership and formal program remain unclear. Three Wikimedia Commons images were added with full credits: two Sule Barma / Voice of America images marked public domain in the United States, and Barke11’s Diori Hamani International Airport photograph under CC BY-SA 4.0, resized for web.
- 2026-08-31 — standardized visible photo captions across dispatches, event media galleries and the all-records opener. Captions now identify the photographer or originating institution directly; Creative Commons and other attribution licenses retain their license codes, while acquisition-platform and unnecessary rights-status wording is omitted. Event-page credit rendering was also hardened so a credit-only or license-only caption does not produce a stray separator.
- 2026-08-31 — changed the shared Google Fonts request from `display=swap` to `display=optional`. A live performance trace showed late Archivo, Spectral and IBM Plex Mono swaps moving the header, navigation and footer enough to produce a 0.79 CLS score. Optional display keeps the custom type when it is immediately available while preventing a late font replacement from shifting the page for first-time visitors.
- 2026-08-31 — self-hosted the Latin and Latin-ext WOFF2 subsets for Archivo, Spectral and IBM Plex Mono, including their OFL license files. Cloudflare Fonts continued to override the external Google Fonts request and produced a 0.65 CLS score even after `display=optional`; the site now declares the local faces inline, preloads the four header faces and removes the two external font origins from its content security policy.
- 2026-08-31 — final speed and SEO review: corrected the homepage dispatch-card heading order (`h4` to `h3`), added a 1600×721 Open Graph/Twitter sharing image, and expanded the CSP to allow Cloudflare Web Analytics' official beacon origin without weakening the existing script hashes. Full build and test suite passed (1,162 records, 1,183 pages, 1,187 sitemap URLs). Deployed to Cloudflare Pages at https://a7fbb4a2.coupatlas.pages.dev and queued live-domain verification.
- 2026-08-31 — completed the production performance deployment and live-domain verification. The homepage now reserves the application viewport before JavaScript renders, preventing the initial layout jump. The full build and test suite passed again, the production build was uploaded to Cloudflare Pages, and `https://coupatlas.com/` was verified with the fix present. Final live measurements were 508 ms LCP, 101 ms TTFB and 0.00 CLS; Lighthouse reported 100 for SEO and 100 for accessibility. Search indexing is allowed and the production sitemap returns all 1,187 URLs.
- 2026-09-04 — Published a developing dispatch on the GSF deployment shortfall and September mandate deadline ahead of Haiti's December election. Added two optimized, date-specific contextual images, preserved visible author and CC license attribution, and added optional concise alt text plus safe inline links in dispatch captions.
- 2026-09-04 — Restored the two Haiti dispatch captions to the wording approved in the source document. Retained only the verified year correction from 2005 to 2025 and the clickable CC BY 3.0 license credit required for the featured image.
