# Changelog

> Version history of Dash Improve My LLMs. The timeline on this page is rendered from `CHANGELOG.md`, reproduced below.

**Site index:** [https://llms.2plot.dev/llms.txt](https://llms.2plot.dev/llms.txt) — every page on this site, as Markdown.  
**Network index:** [https://2plot.dev/llms.txt](https://2plot.dev/llms.txt) — The 2plot network; start here to discover sibling sites.  
**Sibling sites:** 13 more in The 2plot network — listed in the site index above.  
**Sitemap:** https://llms.2plot.dev/sitemap.xml  


---

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [llms-2plot-dev 1.6.0] - 2026-09-03

**Sync 1.6.43, all three items.**

### Fixed

- **The read table drops internal traffic too.** `track_visit` has
  dropped requests carrying `INTERNAL_UA_TOKEN` since the network's
  internal-traffic contract existed; `record_read` — the
  `on_document_read` hook added with the 2.8.0 floor — never learned it.
  So the hub's health sweep, this site's own link audit and every
  post-deploy battery were writing `reads` rows, and were the busiest
  unidentified "vendor" on this host's board. Measured before the fix:
  one token-carrying probe wrote one row, `vendor_key` null, crawler
  lane. "Counted nowhere" now includes the read table.
  **Reporting consequence:** read counts DROP from this release, and the
  drop is the network's own probes leaving the numbers. The clean window
  for this host's ledger starts 2026-09-02 (ops' fleet ruling); earlier
  rows keep the inflation and are not being re-sent, because re-importing
  them would re-import exactly what this fix removes.

### Changed

- **Three verification traps added to `.claude/CLAUDE.md`** — which
  branch Render builds can be timed on a green push but is only PROVEN by
  a red one (this host has that proof, run 33337712632); a disagreeing
  lane IS the finding, and the browser lane spans three artifacts of
  which curl sees two; and assert a corpus is non-empty before trusting
  any negative, with four families of silent-green measured in this tree.

### Notes

- The drop keys on `ua`, which is what the package's `EVENT_FIELDS` calls
  it — `user_agent` is the visits row's name and keying on it here would
  have been a silent no-op. Verified by reading `EVENT_FIELDS` out of the
  wheels rather than inferring: `ua` present at 2.8.0 (local), 2.9.0
  (**what production actually resolves**) and 2.9.4 (what CI pulls from
  the `>=2.8.0` floor); `user_agent` present at none. 2.9.4 adds
  `vendor_class`, which this fork picks up automatically because the row
  is built by iterating `EVENT_FIELDS`.

## [llms-2plot-dev 1.5.0] - 2026-08-31

**Sync item 18 — the 1.6.41 remainder, nineteen files as one contract.**

### Fixed

- **`.. exec::` reached only the browser.** A markdown2dash directive that
  renders Dash components puts its output in the React tree alone, while
  the machine lane, the prerender and the crawler HTML are built from the
  markdown SOURCE with the directive line stripped. Four pages published
  prose that referred to a component the reader could not see —
  `/showcase/robots-sandbox/llms.txt` read "Move the switches. The
  document on the right is generated by…" and then stopped. The directive
  now expands into the module's source through the same fence-aware pass
  `.. source::` uses: one parse, two consumers.
- **The battery probed a path that does not exist and never probed one
  that does.** `network_smoke.HIDDEN_DOC_PATHS` listed `/admin/llms.txt`
  (a redirect, not a page) and omitted `/admin/traffic/llms.txt`, added
  with the ledger round. It is now pinned against the page registry.
- **A bare test client sends `Werkzeug/x.y`**, which dimll ≥2.8 puts on
  the crawler lane, so `mark_hidden` pages 404 and every-page-200 loops go
  red at a floor bump. `tests/test_agent_key_route.py` now names the
  browser lane and keeps the internal token.

### Added

- **A skip link** as the first tab stop on every page, and prop tables
  that scroll inside their own box.
- **`nav:` frontmatter** for short sidebar labels (no page needs one here
  — the longest name is 18 characters).
- **`/changelog` carries a real `<lastmod>`**, derived from the newest
  dated release heading. The sitemap honesty pin learns that second
  source rather than being loosened.
- **`tests/test_exec_lane_parity.py`** — content pins, derived from the
  docs tree, that MUTATION-CHECK themselves. The expansion was disabled
  and every content pin confirmed red before being restored.
- **CD fails fast when superseded** (a later commit already serving, via
  the compare API) instead of timing out, and refuses to push when a
  `release/*` branch would make `release` an unusable ref.
- **`GITHUB_URL` must resolve** on the wire — a wrong repo slug is a live
  404 that no amount of "profile vs repo" framing catches.

### Changed

- **`header.py` and `test_nav_contract.py` are byte-copies again.** Both
  seams this fork reported at 1.6.38 as template-side were closed at
  1.6.41 (`LOGO_ASSET` et al., and a registry-derived aside pin), so
  `DIVERGENCES.md` 8 is retired. The byte-identity evidence recorded
  against 519d496 was stale and is superseded by a re-measurement against
  4ac02e0.
- **`/api` remains unregistered** — `dash_improve_my_llms` ships no
  component metadata, so all four empty-`/api` mechanisms are moot here.

## [llms-2plot-dev 1.4.0] - 2026-08-30

**Sync items 16 and 17 — the navigation contract and the battery's lane.**

### Added

- **`/changelog`** — this file as a Timeline, linked under Home in the
  sidebar and from the footer, and served as its own `llms.txt`.
- **A footer** — © Pip Install Python LLC, the owner's GitHub profile,
  Discord and YouTube, every icon labelled.
- **An Admin section in the sidebar, visible to the OWNER.** Both admin
  pages (`/admin/control-board`, `/admin/traffic`) were previously
  hidden from everyone including the owner by a blanket `/admin/*` path
  filter; they are now listed for `is_admin_user()` and for nobody else —
  the anonymous tree carries no `/admin/` href at all.
- **An `Other Apps` menu in the top bar**, built from
  `network_directory.PRIMARY` rather than a hand-typed list.
- **`lib/aside.py`** — the aside column collapses on pages with no
  `.. toc::`, so `/changelog` and `/` render full width.

### Changed

- **The sidebar is generated from frontmatter**, not from three
  hand-typed link clusters in `components/navbar.py`. Every page already
  declared `category:`; the navbar simply ignored it. Each page now also
  declares `order:`. **`/audiences/web-crawlers` is listed once** — it
  appeared twice, as "Web Crawlers" under This package and as
  "A · What the crawler sees" under Showcase.
- **The sidebar's `Pip Components` and `Other Apps I've built` sections
  are gone** — the network is listed once, in the top bar's menu.
  `Resources` is third-party only: `dmc`. No `community.plotly.com`, no
  `2plot.dev`.
- **`pages/home.py` renders through markdown2dash**, not `dcc.Markdown`,
  so home's headings, tables and code fences match every docs page.
- **`/admin/traffic` uses `dmc.DatePickerInput`** instead of
  `dcc.Dropdown`, and gains a **People** section — the day's human hits,
  visitors, sessions and median session — above the crawler tables, with
  the line that humans never enter the read ledger.
- **Mobile fit**: code blocks inside a List item, Blockquote or Timeline
  scroll in their own box instead of widening the document; the rules are
  in `main.css` against public Mantine class names, never per page.
- **`/api` is NOT registered here, and that is a measurement.**
  `dash_improve_my_llms` ships no component metadata — no
  `metadata.json`, no bundled JS, no generated component classes
  (checked on 2.8.0). It is a library of routes, middleware and config
  objects, so there are no props to tabulate. `API_PACKAGES` is `[]`.
- **The live battery's default User-Agent names the browser lane**
  (item 17). At dimll ≥2.8 a UA with no browser engine token is
  crawler-lane, so `scripts/network_smoke.py`'s bare internal token made
  every default-UA check read the prerendered crawler document. A Chrome
  token now leads, the internal token follows it (substring match, so
  internal-traffic exclusion still holds), and `CRAWLER_UA` is untouched.

### Fixed

- **Admin URLs no longer leak into the machine corpus.** Five pages
  hyperlinked `/admin/control-board` in their prose, which put an admin
  path in `/llms.txt`. Found by item 16's rewritten
  `tests/test_excluded_links_hidden.py`. The prose keeps the words and
  drops the link — the URL is useless to anyone who cannot authenticate,
  and publishing it invited crawlers to an admin path.

## [llms-2plot-dev 1.3.0] - 2026-08-30

**Round 3.4, the posture flip — consumed as sync item 15, with
boilerplate.2plot.dev as the other canary.**

### Changed

- **The AI-training wall is retired** (owner decision, 2026-08-30).
  `run.py` sets `RobotsConfig(block_ai_training=False)`. It stood while a
  refused read was the only thing this site could say about a training
  crawler; since the 2.8.0 ledger round every read is RECORDED and
  reconcilable — vendor, tier, verdict, bytes, verified — and the hub's
  Ledger tab agrees with this host's own count (llms 52 = 52). A wall that
  refuses the read also refuses the evidence. Per-vendor block/meter
  through the callable `vendor_policy` seam is the instrument now: aimed,
  live, revocable per request, which a blanket bucket never was.
  Claude-User, Claude-SearchBot, ChatGPT-User, OAI-SearchBot and
  PerplexityBot were never in the training bucket and are unaffected.
- **In-process, both real UAs (ClaudeBot, GPTBot), before → after:**
  `/` 403 → 200 (13,778 B crawler document) · `/llms.txt` 200 → 200 ·
  `/healthz` 403 → 200. On the wire at 14:09Z the pre-flip triple was
  identical to the in-process one, which means every 403 this host serves
  is the APP's — no separate edge wall has been observed here.
- **Three robots.txt fingerprint checks became posture checks**
  (`tests/test_llms_routes.py`, `scripts/network_smoke.py`,
  `scripts/smoke_live.py`). `ClaudeBot -> Disallow: /` was asserted as
  proof the running artifact was the intended package; it was really a
  statement of policy, and policy changed. The vendor SPLIT still
  fingerprints the package (OAI-SearchBot, Claude-User, Claude-SearchBot
  all `Allow: /`), and ClaudeBot/GPTBot/CCBot are now asserted NOT to
  carry `Disallow: /`.
- **`tests/test_vendor_policy.py`'s default-posture pin now asserts 200**
  where it asserted 403, and `tests/test_showcase.py` asserts the panel
  shows the served document rather than a 403 — the showcase exists to
  show the truth about this host, so it moves with the posture.
- **On the wire after deploy (build 625c91c, 22:05Z), both real UAs:**
  `/` 200 (14,133 B crawler document) · `/llms.txt` 200 (13,801 B) ·
  `/healthz` 200, and `/robots.txt` carrying `Allow: /` for ClaudeBot,
  GPTBot and CCBot. **No edge wall appeared when the app's came down** —
  every 403 this host ever served was its own, and the owner has since
  confirmed no Cloudflare rule exists on the zone. The `DIVERGENCES.md`
  posture fence is re-dated to `200/200/200`.

## [llms-2plot-dev 1.2.0] - 2026-08-29

**Consumed `SYNC-1.6.22-1.6.35` items 12 and 13 at
dash-documentation-boilerplate 1.6.35 (`4c63992`).** The ledger round and
the release-branch round. Both items are contract-class: ported into this
fork's shape, not byte-copied. Per-item dispositions are in the sync report.

### Added

- **`/admin/traffic`** — this host's own crawler ledger, behind the control
  board's exact gate (fails CLOSED without Clerk, for the same reason).
  Vendor × day, vendor → tier for a picked day, top paths per vendor, and
  the v3 headline numbers for the same day so the two accountings can be
  read side by side. Plain tables, no charts, no interval callback: a
  14 × 40 table of strings is about a millisecond, five charts were ten
  seconds (fleet fact 18).
- **The read table.** `dash-improve-my-llms` 2.8.0 emits one event per
  corpus document it serves (`on_document_read`) and does no I/O with it;
  `run.py` registers `AnalyticsTracker.record_read` once, which keeps the
  row as a `reads` table in the SAME analytics file — same buffer, lock,
  flush cadence and retention as `visits`, with `client_ip` dropped unless
  `ANALYTICS_KEEP_CLIENT_IP=1`. `reads` is JOINED by the rollup, never
  summed into `human_hits` / `bot_hits` / `pages`.
- **Rollup v4**, additive and present only on a day with reads:
  `vendors[]` (one row per vendor key × verified × policy, with per-tier
  counts and bytes, null key kept as the unverifiable bulk) and `reads`.
  Every v3 key is byte-identical; the reporter POSTs what `daily_rollup`
  returns and is unchanged.
- **CD promotes `main` → `release`.** `.github/workflows/cd.yml`'s `deploy`
  job now fast-forwards `release` to the run's own sha after the matrix is
  green, and Render watches `release` (`render.yaml`). `needs: [test]` is
  the whole gate, so `release` cannot receive an uncertified commit by
  construction. `tests/test_cd_promotes_release.py` pins the structure.
- **A `posture` fence in `DIVERGENCES.md`** — measured, not intended:
  `ai_bots`, `healthz: full`, `runtime: python`, `deploy: release-branch`.

### Notes on the first promoted run

- **The first promoted run went red on its build-match wait, and that was
  the owner step outstanding, not a defect.** `5c73a53` reached `main` at
  17:43:00Z and `release` at 17:45:24Z; at 18:22:44Z `/healthz` still
  served `ae1dce6`. This service is not Blueprint-managed, so
  `render.yaml`'s `branch:` is documentation and the dashboard Branch
  field is the switch — item 13 says exactly this in its notes. The owner
  switched it at ~18:00Z and the next run (33281935425, `0081f65`) went
  fully green in five minutes: promote, wait, and verify including its
  `/healthz build == github.sha` step. `main == release == wire`.
- On the same run, item 13's `verify` fix proved itself: the job was
  **skipped** rather than run. Under the previous
  `always() && != 'cancelled' && != 'skipped'` gate it would have run
  after the failed deploy and reported GREEN against `ae1dce6` — the
  previous build. That is the defect the gate change exists to prevent,
  observed here on the first run that could exhibit it.

### Changed

- **`human_hits` DROPS and `bot_hits` RISES from this release.** UA-less
  and library clients (`httpx`, `Go-http-client`, `node-fetch`, an empty
  User-Agent) move from the human lane to the crawler lane, because
  `classify()` puts them there and this app no longer disagrees with it.
  The hub's day-over-day view will show a step on the adoption date. That
  is the number becoming true, not a regression.
- **There is ONE classifier.** `lib/analytics_tracker.py` delegated
  `is_bot` and `detect_bot_type` to `dash_improve_my_llms.classify()` and
  now ends with ZERO User-Agent strings. Its own lists had filed ClaudeBot
  — Anthropic's TRAINING crawler — under "search", still named the retired
  `anthropic-ai` / `claude-web` tokens, and knew nothing of `bytespider` or
  `Claude-User`. The names and signatures are kept for callers. Crawler
  rows gain `vendor_key`, `vendor_class`, `verified`, `lane`; human rows
  are byte-identical to before, and the `INTERNAL_UA_TOKEN` drop still
  happens FIRST.
- **The floor moves to `dash-improve-my-llms>=2.8.0`** in every encoding:
  `requirements.txt` (four lines), `run.py`'s `LLMS_PKG_FLOOR`, and CI's
  install line plus both version asserts. Not a degradable feature —
  `lib/analytics_tracker` imports `classify` and `_ledger.EVENT_FIELDS` at
  module scope.
- **`verify` runs only on `needs.deploy.result == 'success'`.** The old
  `always() && != 'cancelled' && != 'skipped'` admitted `failure`, so a
  failed promote could still be followed by a green verify of the
  PREVIOUS build. Verify's first step now also asserts `/healthz` build ==
  this run's sha itself. This fork's `SITE_URL` guard (DIVERGENCES.md 4)
  is kept as a second conjunct.

### Removed

- **The Render deploy-hook secret**, and every trace of its name from
  `cd.yml`. A push to `main` is no longer a deploy; it is a candidate.

### Fixed

- **`tests/test_proxy_scheme.py` sends `BROWSER_UA`.** At the 2.8.0 floor
  an absent User-Agent is the crawler lane, so the UA-less probe received
  the crawler document — which carries no `twitter:url` — and failed on
  "no tag" without saying anything about the forwarded scheme. Either lane
  can be the one you did not mean to test.

## [llms-2plot-dev 1.1.0] - 2026-08-27

**Kit adoption — consumed `SYNC-1.6.10-1.6.16`, `SYNC-1.6.17-1.6.21` and
`SYNC-1.6.22-1.6.29` at dash-documentation-boilerplate 1.6.29 (`5589318`).**
This host was a live kit-lineage site that had never been on the fleet
roster; it joins with this release. Per-item dispositions are in the sync
report; the deliberate differences are now recorded in `DIVERGENCES.md`.

### Added

- **The `.claude/` development kit** — `CLAUDE.md` (this site's own guide
  above the network's behavioral contract and verification traps, both
  ported verbatim), `settings.json` pointed at THIS host, and the three
  shipped skills (`wire-verify`, `sync-template`, `report`) byte-verbatim.
  `.gitignore` moves from a blanket `.claude/` ignore to the template's
  ALLOW-LIST form, which is what keeps credentials under `.claude/`
  structurally uncommittable, and gains the session-document block.
- **`DIVERGENCES.md`** — six recorded divergences with reasons, plus the
  machine-readable `byte-owned` fence (empty: this fork makes no byte-level
  claim on any `sync-verbatim` path, and drift is never fenced).
- **`tests/test_claude_kit.py`, `tests/test_auth_demos.py`** — byte-verbatim
  kit cargo. **`tests/test_python_version.py`** — the one-fleet-Python
  agreement pins.
- **`python` on `/healthz`**, all three backends, plus the battery's
  `python_matches_declared` check: the serving interpreter is now on the
  wire, so a stale image can be contradicted from outside.
- **The configured-auth branch is certified.** Two pins render
  `lib.auth.register()` with a FAKE non-empty Clerk config — the branch a
  zero-secret suite had never executed, including the `pk_live`
  satellite-mode auto-enable that can only exist in production.
- **CI asserts Docker's own health verdict** (`docker inspect
  .State.Health.Status`), failing on `none`: the external curl proves the
  app answers, never the HEALTHCHECK instruction itself.

### Changed

- **One fleet Python: 3.14**, in every encoding at once — `Dockerfile`
  (`python:3.14-slim`, a MINOR tag: the old `3.11.8` patch pin could never
  receive a 3.11.x security release), the CI matrix main, the lint and
  pip-audit jobs, `cd.yml`'s verify job, and `render.yaml`'s
  `PYTHON_VERSION` (full `X.Y.Z`, as Render's native runtime requires). The
  window legs are 3.13 and 3.12.
- **`Dockerfile` honors `$PORT`** — shell-form `CMD` with the default at the
  point of use (`${PORT:-8550}`), and the HEALTHCHECK probes the same
  variable. Exec-form `CMD` never expands env, so the old form hardcoded the
  port whatever the platform asked for.
- **`render.yaml` names THIS host.** Every identity field still said
  `boilerplate` — service name, domain, `APP_BASE_URL`, `SATELLITE_APP_KEY`,
  `AD_APP_ID` — inherited at fork time and never corrected, while production
  served `llms.2plot.dev` with `app: "llms"`. `POLICY_STORE_FILE` is now
  declared on the mounted disk beside the other two stores.
- **CD is sized for the worst build**: the build-match wait runs 100 × 15s
  under a 30-minute job timeout (a floor bump busts the pip cache by design,
  so this pipeline's most important deploy is also its slowest), a hookless
  deploy emits `::warning` rather than a quiet notice, and the verify job
  now stands down on a `skipped` deploy as well as a `cancelled` one.
- **`.github/dependabot.yml`** — the 1.6.24 rewrite: the pip ecosystem is
  removed entirely. On range requirements dependabot can only propose FLOOR
  RAISES, so the old allow-list group structurally produced the very PR
  class it existed to suppress. Floors move through sync specs, every
  encoding at once. Security updates ride GitHub's separate channel.
- **`scripts/smoke_live.py`** is the template's current file: the auth POST
  now carries the same SSL context as `fetch` (without it every POST died in
  the macOS handshake and read as missing auth wiring), and `wake()`
  tolerates a legacy `fetch` stub. A source pin in
  `tests/test_auth_wiring.py` holds the SSL half — no wired test can, they
  all monkeypatch `post`.
- **The gate card promises only what ships** — "and the AI assistant" is
  gone from the demo-card copy; nothing here wires one.
- **`lib/auth_demos.py` points at a demo this site can render.** The
  inherited `/examples/visualization` entry named a page and a module that
  exist on no fork; every gate card rendered demo-less and silent since fork
  time. This site's hero is `/showcase/robots-sandbox`.

### Removed

- **The vestigial Node layer** (template issue #12, CVE-2026-1615, removed
  upstream in 1.6.9 and carried here with this Dockerfile sync).
  `package.json` / `package-lock.json` were dash-mantine-components'
  component-build toolchain, inherited through the fork lineage and used by
  nothing in this repo — no webpack config, no `src/ts`, no CI job, no
  served asset — while the image apt-installed nodejs+npm and `npm
  install`ed a known-vulnerable `jsonpath@1.1.1` into every production
  build.

## [llms-2plot-dev 1.0.0] - 2026-08-22

**This repository forked here.** Everything below this entry is the history of
`dash-documentation-boilerplate`, the template this site was forked from at
1.6.7 — kept because the machinery is inherited and its reasoning still
applies. Everything from here up is `llms-2plot-dev`, the documentation site
for `dash-improve-my-llms` and the 2plot network's owner-control bench.

### Added

- **The site's own content.** Three audience pages (`/audiences/mcp-clients`,
  `/audiences/web-crawlers`, `/audiences/llm-context` — URLs preserved byte
  for byte from the retiring service), a five-page Reference section, and
  three showcases that run the package's own pure handlers in-process.
- **`lib/policy_store.py`** — the writable layer. Flock-guarded JSON,
  validated on write, atomic on replace, fail-open on read, re-stat'd on
  every call. Reaches `dash-improve-my-llms` 2.7.0 through its callable
  seams, so a control-board toggle lands on the next request in every worker
  with no restart.
- **The control board's country guardrail** — a click-to-select world map
  over the inherited page-visibility board, with the admin gate re-checked
  server-side in the write callback.
- **`BUGS-2.7.0.md`** — the pre-release soak that gates the package's tag.

### Changed

- **Identity, on every surface**: brand, description, origin, favicons from
  the hook mark, header logo and wordmark, the GitHub link, the social-card
  object, and the JSON-LD blocks.
- **The template's documentation is DELETED, not hidden** (owner decision).
  `excluded_links` hid the eleven tutorial pages from the sidebar but left
  them in `sitemap.xml`, `/llms.txt`, `/llms-full.txt` and the MCP resource
  set — so this host would have published the boilerplate's documentation as
  its own. This overrides the migration kickoff's "NEVER deleted (wave-sync
  purity)" rule: template syncs touching `docs/` now need resolving by hand,
  and that cost was accepted for a site that stands on its own.
- Sixteen `301` redirects for retired and deleted URLs.

### Notes

- `requirements.txt` still floors at `dash-improve-my-llms>=2.6.1`. 2.7.0 is
  unpublished, so every 2.7.0 call site sits behind the `LLMS_HAS_27`
  capability probe in `run.py` and the app boots on either release.

---

## [1.6.7] - 2026-08-22

### Added

- **Auth-wiring guards, both halves** (the flexlayout finding):
  dash-clerk-auth wires either side of `Dash(...)` — `register()` is
  the UI half, `configure_app(app)` the server half (`/api/auth/*`
  routes + per-request identity). Flexlayout's batch-2 pass shipped
  the first call without the second: components rendered and ClerkJS
  reported signed-in while every server render read signed-out — the
  control board served the owner the sign-in card forever,
  `POST /api/auth/session` answered 405 through Dash's GET-only page
  catch-all, and sign-out never revoked. Invisible to every suite,
  because Clerk is off in test environments and `configure_app`
  no-ops without keys. Two guards now, one per environment:
  `tests/test_auth_wiring.py` pins structurally (AST) that run.py
  calls BOTH halves; `scripts/smoke_live.py` gains an "Auth wiring"
  block that POSTs both endpoints on the live host (registered =
  2xx/4xx; unregistered = 404/405), gated on the package's inline
  bootstrap being present in the served shell so clerk-off hosts skip
  rather than fail. Measured baselines: boilerplate answers 401/200,
  flexlayout answered 405/405. Note: the battery's POST probes need
  real egress — sandboxed environments that allow only GET report
  transport-0.

## [1.6.6] - 2026-08-22

### Changed

- **dimll floor 2.6.0 → 2.6.1** (requirements incl. the commented
  backend extras, run.py's boot floor + its message, and the test —
  the floor lives in more than one place; all moved together). 2.6.1
  makes the universal prerender VISIBLE to non-JS consumers: below it
  the injected block carries a literal `hidden` attribute, so every
  visibility-respecting reader (html-to-text extractors, arguably
  crawler content-weighting) saw only "Loading..." — the outside-audit
  finding of 2026-08-22, diagnosed live across six hosts and fixed at
  the package. The generic-UA prerender test now asserts the fixed
  shape: div without `hidden`, plus the marked synchronous hide script
  that keeps JS browsers flash-free (React's mount wipes the pair, so
  nothing changes for humans). The fleet inherits 2.6.1 on each host's
  next deploy with no requirements edit; this release is the reference
  host's own pickup plus the floor that makes the guarantee permanent.

## [1.6.5] - 2026-08-22

Batch-1 closeout: the wave's other three hosts (emojimart, modelviewer,
excalidraw) shipped dark, and four of their findings trace to this
template. All four are fixed at the source so batch 2 and every future
fork inherit the fix instead of rediscovering it.

### Added

- **Runtime-imports guard** (`tests/test_runtime_imports.py`, the
  modelviewer finding): a fork died in production on a
  function-local `import PIL` that every dev machine happened to
  satisfy — suite green, boots locally, dies in a clean image, and one
  docs example took all ten pages down because Dash imports every page
  at construction. The test AST-walks every runtime module and asserts
  each absolute import resolves in the environment CI installs
  (requirements.txt and nothing else); nesting is deliberately ignored
  because it does not predict boot-fatality. The optional-backend
  exemption (fastapi/quart select by env) is earned by two companion
  tests: the extras must stay documented as commented requirements
  lines, and the carrier modules must never be hoisted to run.py's
  unconditional top level. A third companion pins that runtime code
  never imports build-time `scripts/`.
- **CSS hygiene guard** (`tests/test_css_hygiene.py`, the excalidraw
  finding, landed at the source): fails on any hashed `.m_*` Mantine
  selector in `assets/*.css`. Three forks have paid for this class —
  leaflet's floating drawer, emojimart's 63vh drawer, and excalidraw
  inheriting two dead-or-harmful hashed rules **from this template**.
- **modelviewer + excalidraw joined the canonical network directory**
  (`lib/network_directory.py`): both were deliberately absent until
  they deployed; both are live and build-identity-verified as of
  2026-08-21/22. The fleet re-copy carries the entries everywhere.
- **Markdown tables scroll in their own box** (`table.m2d-table`,
  GitHub's recipe: content-width, capped at the container, scrollable
  past it — the excalidraw finding): a `<table>` is min-content sized,
  so one wide prop table dragged an entire page 105px sideways at
  414px. A no-op for tables that already fit; covers kwargs prop
  tables too, since markdown2dash stamps the class on every table.

### Fixed

- **The three hashed-selector fossils removed from `assets/main.css`**
  (dmc-docs fork era, present since the initial commit):
  `.m_46b77525` put an `!important` margin on every Input wrapper in
  every docs example; `.m_5caae85b` was dead in DMC 2.7 **and** 2.8;
  `.m_9cdde9a` restated Mantine's own aside declarations around one
  intentful pixel — the TOC's 15px breathing gap, which moved to the
  static `aside.mantine-AppShell-aside` rule.
- **`scripts/make_favicons.py` now flattens the apple-touch icon onto
  opaque white** (the emojimart finding): iOS composites the icon's
  alpha onto its own background — black on some surfaces, white on
  others — so every fork that ran this script shipped an icon that
  renders differently everywhere it appears. Every other size keeps
  its transparency. The template's own `apple-touch-icon.png` is
  regenerated (the other seven files regenerated byte-identical,
  confirming provenance), and a header-level PNG colour-type test
  pins opacity without needing Pillow in CI.
- **The header wordmark now hides below `xs` with the accessible name
  preserved** — the pattern both modelviewer and excalidraw needed and
  implemented divergently. `visibleFrom` keeps the node in the DOM
  (the typing animation still finds it) but `display:none` DOES remove
  it from the accessibility tree, so the home link now carries a
  permanent `aria-label` and the logo img is explicitly decorative
  (`alt=""`). Without the label, phones would get a home link with no
  name at all — the modelviewer defect, which excalidraw's pass
  reasoned incorrectly about and likely still ships.

## [1.6.4] - 2026-08-21

Two fleet-class fixes surfaced by the wave's first pair, landed at the
source so the other eighteen forks inherit them.

### Fixed

- **CD now verifies the artifact it shipped, not "whatever is live"**
  (the muicharts finding): with `RENDER_DEPLOY_HOOK_URL` unset, the old
  workflow skipped the wait and ran the live battery seconds after the
  push — against the previous release, every run, invisibly.
  `/healthz` now reports the running instance's commit
  (`RENDER_GIT_COMMIT`, optional field — the fleet probe contract is
  unchanged), and the CD wait holds until it matches the run's SHA,
  falling back once (with a warning) on builds predating the field.
- **The byte-copy identity trap** (the pannellum finding): the
  reporter must stay byte-identical across forks, so its fallback
  app key says "boilerplate" everywhere — while a fork's other modules
  default to the fork's own key. `run.py` now claims the identity via
  `os.environ.setdefault("SATELLITE_APP_KEY", ...)` before any
  hub-facing import — the marked FORK POINT; forks change that one
  string and keep the reporter byte-identical. A real env value always
  wins.

## [1.6.3] - 2026-08-21

### Changed

- **Vendored dash-clerk-auth 1.0.4 → 1.0.5** (sha256 `a2f9062e…b74f3`,
  full provenance in requirements.txt). Fixes the return-trip stale
  gate the owner observed live: landing back on an auth-gated page
  after signing in on the primary showed the gate card until a manual
  refresh, because the first server render precedes `__dca_identity`
  minting. 1.0.5 syncs the session and reloads once, with a
  sessionStorage no-loop marker shared by both reconciliation paths.
  The provenance rule is now general: only the recorded sha admits a
  tarball — stale early builds have bitten on both of the last two
  releases and are indistinguishable by name, size, or date.

## [1.6.2] - 2026-08-21

The pre-wave hygiene pass, from the four-repo review.

### Fixed

- **Date-skew corrections (leaflet handoff §8):** seven committed
  provenance stamps read `2026-08-22` for events whose verified date is
  `2026-08-21` (git author dates corroborate) — CHANGELOG headers
  1.5.3–1.6.1, `components/header.py`, `lib/ad_client.py`,
  `requirements.txt`. All corrected; the three release commit SUBJECTS
  carrying the wrong date are immutable and stand corrected by this
  entry. A date nobody can trace is worse than no date.
- `docs/authentication/authentication.md` now documents the
  control-board override layer (override → frontmatter →
  `PAGE_DEFAULT_TIER`, hub ceiling on top) instead of contradicting
  shipped behavior; `lastmod` bumped accordingly.
- `run.py`'s floor failure message now names what a 2.5.x actually
  loses first — silently swallowed `lastmod`, the lying sitemap —
  matching the comment that raised the floor.
- `lib/auth.py`'s signout-shim docstring caught up with reality
  (upstream fix shipped in 1.0.3/1.0.4; the shim is a deliberate
  duplicate until the fleet-wide retirement pass).

### Changed

- README caught up three releases: dimll floor 2.5.1 → 2.6.0 in five
  places, a new Access Control & Live Page Management section
  (control board, admin allowlist, gate teasers), the mobile-drawer
  standard under UI/UX, and the admin env vars in Configuration.
- `.env.example` gains the admin surface (`ADMIN_EMAILS`,
  `ADMIN_USER_IDS`, `ALLOW_UNGATED_ADMIN`) — the gate for the 1.6.0
  headline feature was previously undiscoverable from the env template.
- `.claude/CLAUDE.md` Customization Points now lists the control board,
  the override store, and the auth-demo teasers.

## [1.6.1] - 2026-08-21

### Fixed

- Accessibility + agentic-browsing names on the header's icon controls
  (hamburger, theme toggle, GitHub link — `create_link` now requires a
  label), and the network-ad image reserves a square box via
  `aspect-ratio` so the aside no longer layout-shifts when the creative
  loads. All three were Lighthouse findings on the pilot host measured
  against template code — every fork inherits the fix.

## [1.6.0] - 2026-08-21

Every fork gets its own live control board — the leaflet pilot's proven
UX, ported with its scar tissue included.

### Added

- **`/admin/control-board`** (`pages/control_board.py`): flip any docs
  page between public / auth / admin / hidden and toggle its llms.txt
  exposure, live — changes apply on the next render, no restart. Gated
  by the ADMIN_EMAILS/ADMIN_USER_IDS allowlist + owner; **fails CLOSED**
  without Clerk (`ALLOW_UNGATED_ADMIN=1` for local work), and the write
  callback re-checks the gate server-side (pattern-matching callbacks
  stay callable by anyone who can POST). The board stays OUT of both
  tier ledgers — its machine surfaces are silenced package-side via
  `mark_hidden()` (sitemap, llms.txt, MCP, prerender, crawler HTML all
  treat it as absent) so `access.gating_configured()` stays False on
  all-public forks and the hot path stays check-free.
- **`lib/page_visibility.py`** — the override store, with both fleet
  lessons built in: mtime-throttled cross-worker reload (a toggle lands
  on every gunicorn worker within ~1s — the pilot's coin-flip defect)
  and loud persistence guards (boot warns when `PAGE_VISIBILITY_FILE`
  is unset OR points under /var/ without a real mount — the
  twice-observed silent-reset-per-deploy class).
- Override-first resolution in `lib/access.py`: board override →
  frontmatter → env default, with the hub ceiling still applied on top
  (an override can loosen a local declaration, never a network
  restriction). `pages/markdown.py` registers every docs page on both
  ledgers from the one declared value.
- The sign-in card's live-demo teaser now ships ARMED: DEMOS carries a
  working entry (`/examples/visualization` → the theme-aware chart), so
  gating that page shows "Live demo — try it" above "Authentication
  required — You're looking at a live preview of {page}. Create a free
  account to unlock the full documentation — every interactive example,
  the complete API reference, and the AI assistant."
- `render.yaml` + `.env.example`: `PAGE_VISIBILITY_FILE` on the
  /var/data disk, with the blueprint-vs-dashboard drift warning
  inline. 14 new tests (`tests/test_control_board.py`).

## [1.5.4] - 2026-08-21

### Changed

- Navigation order: "Other Apps I've built" now sits above "Resources",
  and "Resources" is the LAST section — own-work ranks above third-party
  links, and the only section that navigates away from the network
  closes the list.

## [1.5.3] - 2026-08-21

### Changed

- **Vendored dash-clerk-auth 1.0.3 → 1.0.4** (sha256 `7a7c333a…cf701a`,
  recorded in full in requirements.txt with the stale-first-build
  warning). What 1.0.4 fixes, from the live network certification: the
  FastAPI auth endpoints were never callable (un-annotated request
  param → required query field → 422 on every POST — inert on this
  Flask host, fatal on fastapi ones), and the ghost-cookie fresh-load
  case — a page loading with ClerkJS signed-out while the server still
  held the identity now reconciles with a signout POST + single reload,
  which is the cross-host sign-out path no click shim can cover.
  `revokeServerSession` also verifies its response now. The 1.5.1 shim
  remains an idempotent duplicate; retirement is one clean release
  cycle after the fleet is on >=1.0.4.

## [1.5.2] - 2026-08-21

### Changed

- **Vendored dash-clerk-auth 1.0.2 → 1.0.3** (sha256 `2c6b40f4…da1944`,
  recorded in full in requirements.txt — the tarball IS the release;
  there is no PyPI for this package). 1.0.3 fixes sign-out revocation
  package-side (both entry points + the signed-in→signed-out listener
  transition, so sign-outs propagate across tabs and hosts), replaces
  the DiceBear default avatar with an inline SVG data URI (no third
  party in the UI path), and discards non-absolute
  `satellite_sign_in_redirect` values loudly. 1.5.1's app-side signout
  shim is idempotent alongside it and retires next release.
- Provenance caveat recorded in requirements.txt: vendor from the hook
  repo's `dist/` artifact ONLY — its `main` currently holds a broken
  build (boot-time collection error on Python 3.10/3.11) until the
  import-fix PR lands; verify the sha before re-vendoring.

## [1.5.1] - 2026-08-21

Pilot-week hotfix: Sign Out that actually signs out, and an honest
floor comment.

### Fixed

- **Sign Out now revokes the server session.** dash-clerk-auth 1.0.2's
  logout runs `window.Clerk.signOut()` client-side and reloads — but the
  server keeps trusting the signed `__dca_identity` cookie (max-age
  `session_lifetime_days`, default **7 days**) and the Flask session it
  minted at sign-in, so a signed-out browser kept rendering every
  auth-gated page; on a shared computer the next person inherited the
  previous user's access. The package ships the endpoint that fixes this
  (`POST /api/auth/signout`) but nothing ever called it. New
  `lib/auth.py:_install_signout_delegation()` — a capture-phase delegate
  on the logout menu item (the sign-in delegation's proven pattern) —
  owns the click and sequences `Clerk.signOut()` FIRST (so the slow path
  can't re-verify `__session` and re-mint), then the server signout,
  then the reload, awaited so the reload never races the cookie clears.
  The package-side fix ships in dash-clerk-auth 1.0.3; this delegate is
  idempotent alongside it and retires a release after the fleet vendors
  `>=1.0.3`.

### Changed

- **Floor-comment honesty** (`run.py`, `pages/markdown.py`): 1.5.0's
  claim that passing `lastmod=` "TypeErrors on anything older" was
  false — measured on 2.5.1 by the pip-docs+ stage-4 session, the
  signature is `(path, name=None, description=None, llms_doc=None,
  **kwargs)`, so older packages accept the date and silently ignore it.
  The 2.6.0 floor stays load-bearing, but for honesty (below it, every
  stamped date is swallowed and the sitemap goes back to swearing
  everything changed at build time), not crash avoidance.

## [1.5.0] - 2026-08-20

The reference host proves dimll 2.6.0 (stage 2 of the network rollout
order). The floor is load-bearing: pages/markdown.py passes `lastmod=`
unconditionally, which TypeErrors on anything older.

### Changed

- `dash-improve-my-llms[flask]>=2.6.0` (was 2.5.1), and
  `LLMS_PKG_FLOOR = (2, 6, 0)`. What arrives: icon autodiscovery, truthful
  sitemap `<lastmod>`, JSON-LD `publisher.logo`, and the llms.txt viewer
  banner de-dup (package-side, free).
- Every docs page's frontmatter now declares `lastmod:` with its REAL git
  last-commit date (2025-11-09 through 2026-08-19 — eleven pages, zero
  invented dates). The `Meta` model gains the field with a
  YAML-date-to-ISO validator; `register_page_metadata` passes it through;
  unset pages omit the tag — truth or silence. Deliberately not scripted
  from file mtimes, which reset on every Docker build and would re-invent
  the daily-lie sitemap 2.6.0 exists to end.
- `configure_seo(icons=)`'s `.ico` entry moved to the
  `assets/favicon/favicon.ico` copy (byte-identical to the root one
  index.html links) so the declared list is SET-equal to what 2.6.0's
  discovery finds.

### Added

- `tests/test_seo_icons.py`: discovery-vs-declaration set-agreement (the
  proof the fleet can rely on discovery alone once its pixels are right —
  order-inequality is not a failure, per the release notes) and
  sitemap-honesty pins (every emitted `<lastmod>` traceable to a
  frontmatter declaration; the undeclared home page carries none).

## [1.4.1] - 2026-08-19

### Changed

- `dash-clerk-auth` is now installed by requirements.txt (from the vendored
  tarball) rather than riding the image uninstalled. 1.4.0 shipped the whole
  sign-in surface — avatar, gate cards, delegation — but the deployed
  reference site could not render any of it because the package it wires was
  never on `sys.path`. Runtime posture is unchanged: with no `CLERK_*` keys
  the site is exactly as public as before, so forks inherit the capability,
  never a login wall. Alongside it, the fleet security floors are now
  asserted rather than merely permitted: `clerk-backend-api>=7.0.0,<8` and
  `cryptography>=50.0.0` (the four-advisory baseline dash-clerk-auth 1.0.1
  widened its cap for).

## [1.4.0] - 2026-08-19

The interactive gate and the real-time half of the fleet's analytics land on
the template. Humans meet a sign-in card on gated pages while agents keep
reading the machine surfaces through the data window — the two lanes split
onto separate axes, each flipped per host by one env var. The satellite
reporter grows a presence beacon so the hub board can show "active right
now" without waiting for a rollup. On THIS host the gate ships dark twice
over: every tier is public, and dash-clerk-auth is deliberately not in
requirements.txt (the vendored tarball exists for the docs' optional-auth
install command) — the presence beacon is what this deploy turns on.

### Added
- **The interactive gate** (`lib/gate_layouts.py`): every markdown docs page
  renders through a per-request verdict — sign-in card at HTTP 200 (with an
  optional live teaser demo via `lib/auth_demos.py`, table empty in the
  template), forbidden and 404 cards, the content on allow. The verdict is
  the new `access.resolve_page_access()`: docs fall open without Clerk,
  admin fails closed, and `?key=` never unlocks a browser layout. The gate
  switch is `PAGE_DEFAULT_TIER=auth` per deployment; `/`,
  `/getting-started` and the corpus pseudo-paths are pinned public so no
  env flip can gate the funnel. Card buttons ride
  `assets/auth_gate.js`/`.css` (satellite mode navigates to the primary
  with `?returnTo=`; local dev opens the Clerk modal).
- **The second tier axis, `llms_public`** (frontmatter, or
  `LLMS_PUBLIC_DEFAULT`, default open): a gated page's machine twin —
  `/<page>/llms.txt`, crawler HTML, the prerender — stays public while the
  interactive page is gated. That split is the data-window posture, and the
  later agent flip is `LLMS_PUBLIC_DEFAULT=0`, env only. The exemption
  never applies to a hub-imposed tier: a satellite's env default cannot
  loosen what the network restricted.
- **`GET /api/agent-key`** (`lib/agent_key.py`, all three backends): turns
  the browser's Clerk session into the hub-minted `?key=` that the "Copy
  for LLM" button (`assets/llms_copy.js`) now appends, so a copied URL
  keeps working inside an assistant that has no cookie. 204 for
  anonymous / Clerk-off / hub-down; `Cache-Control: private, no-store`
  always; the token is read from the `__session` cookie, never the query.
- **The presence beacon** (`lib/satellite_reporter.py`): a second,
  fail-silent daemon thread POSTs `{app, active}` to the hub's
  `/api/satellite/active` every 60s (`SATELLITE_PRESENCE_INTERVAL_S`,
  floor 30, `0` disables) — distinct human visitors inside the session
  window, the same derivation as the hub's own count. Display-only and
  ephemeral hub-side; the daily rollup stays the sole source of the daily
  numbers. A hub that predates the endpoint 404s harmlessly.
- Clerk avatar in the header (`components/header.py::create_clerk_avatar`),
  rendered only when Clerk is configured.

### Changed
- `render.yaml`: rollup cadence `SATELLITE_REPORT_INTERVAL_S=900` (the
  fleet is on paid instances and the hub board now reads near-real-time),
  the full Clerk satellite env block, and the two gate knobs — remembering
  that env/plan changes apply on Blueprint sync, not git push.
- `lib/auth.py`: the hand-rolled 0.9.0/0.9.1 satellite fixups are retired —
  both are upstream in the vendored dash-clerk-auth 1.0.2. What remains is
  capture-phase *delegation* (`_install_satellite_signin_delegation`,
  back-ported from the leaflet pilot 2026-08-19): late-rendered
  `#clerk-login-button`s get exactly one handler, preferring
  `buildSatelliteRedirect()` with `?returnTo=`, falling back to
  `redirectToSignIn` on origin+pathname so stale `__clerk_*` params never
  ride into the next sign-in.
- The corpus pseudo-paths (`/llms-small.txt`, `/llms-full.txt`) register
  `public` explicitly instead of falling through the tier default, so
  `PAGE_DEFAULT_TIER` can never gate them; `/` likewise (it registers via
  pages/home.py, which no frontmatter ever tiers).
- Vendored `dash_clerk_auth` 0.9.1 → 1.0.2 (the clerk-backend-api `<8`
  cap for the `cryptography>=50` floor, plus the avatar session fix).

### Fixed
- The peer-host key-leak test judges parsed origins, not substrings —
  bare-host matching flags a site's own links whenever a peer host is a
  substring of its own (`2plot.dev` ⊂ `leaflet.2plot.dev`; found by the
  leaflet pilot, this repo was saved only by its hostname). The invariant
  stated properly: any URL carrying a key must be same-origin.
- `lib/agent_key.py` records why it must not use
  `from __future__ import annotations`: PEP 563 turns the FastAPI
  `Request` annotation into a string resolved against module globals,
  where the locally imported class does not exist — the parameter silently
  becomes a required query field and the route 422s.

## [1.3.0] - 2026-08-15

Instrument first: the 402 groundwork lands on the template. The network's
metered lane is gated on ~30 days of crawl data (owner decision 2026-08-10);
this release is what makes that data exist and stay true on every satellite
forked from here — machine-surface demand reported per document, counted
once, tested, and tierable per deployment. No payment code ships here.
Rollout plan: `kickoff/KICKOFF-x402-instrumentation-rollout.md` (local).

### Added
- **The daily rollup now reports the machine surfaces** (the network's
  v3 analytics fields): unique bot visitors per day (`bot_visitors`, a
  daily distinct count), and llms.txt / robots / sitemap / page.json rows
  in `pages` with a per-row bot split — mirroring the hub's own
  self-report semantics exactly. These fetches were always recorded; they
  were only hidden from the report. A day with only machine-surface
  fetches is now reported instead of skipped — crawlers hammering
  llms.txt with zero human visits is exactly the signal the hub's
  day-pass board exists to see.
- **The machine-surface rollup is tested** (`tests/test_traffic_rollup.py`)
  — 15 hand-checkable cases pinning the partition (every path is a page
  visit or a machine-surface hit, never both), the machine-only-day
  report, the per-row bot split, and the distinct `bot_visitors` count.
  This data is the evidence base for the network's 402 pricing decision;
  untested measurement code deciding a revenue model was the wrong risk
  to carry.
- **Tier registrations for the corpus documents.** Every satellite built
  from this template now declares access tiers for `/llms-small.txt` and
  `/llms-full.txt` (served by dash-improve-my-llms ≥ 2.4.0; inert on older
  versions): `LLMS_SMALL_TIER` / `LLMS_FULL_TIER` env vars set them
  locally (unset = public; documented in `.env.example` and visible in
  `render.yaml` so every fork sees the knob), and the hub's page-tier
  ceilings can tighten either network-wide with no redeploy here. The
  dependency-floor message notes the 2.4.0 requirement for the tier
  documents.
- **Generic version placeholder `{{VERSION:<distribution>}}`** (new
  `lib/versions.py`, used by both markdown loaders). Prose may now state
  the installed version of *any* package — not just dash-improve-my-llms —
  so every satellite can write `{{VERSION:<its-pypi-name>}}` for the
  library it documents and a package upgrade propagates to the browser
  page, the copy button, `/llms.txt` and every `/<page>/llms.txt` on the
  next deploy, with no prose edit. `{{DIMLL_VERSION}}` remains as a legacy
  alias. Fenced code blocks and inline code spans are left verbatim (the
  network-standard page shows the syntax in a fence), and a placeholder
  naming an uninstalled distribution fails the boot instead of leaking.
  The identity tests now also sweep for bold version claims next to any
  PyPI link, not only dash-improve-my-llms's.

### Fixed
- **Machine-surface fetches were double-counted.** `_SKIP` excluded
  `/llms.txt`, `/robots` and `/sitemap` from page visits by substring —
  but `/llms.txt` does not substring-match `/llms-small.txt`, so the tier
  documents and `page.json` twins landed in BOTH `load_visits` and
  `load_agent_hits`, inflating `human_hits`/`bot_hits`/`pages` for
  exactly the surfaces the 402 board prices. `_SKIP` now names all three.
  The hub's `traffic_insights._SKIP` has the same gap (its comment claims
  the exclusion; its tuple doesn't deliver it) — port this fix there
  before the data window opens.
- **Dash-built components rendered empty props tables.** The
  numpy-docstring branch in `lib/directives/kwargs.py` (for
  dash-mantine-components' hand-written docs) shadowed the base
  markdown2dash parser for the `Keyword arguments:` format that
  dash-generate-components emits — the format of every component a
  library satellite documents — so their `.. kwargs::` tables rendered
  silently empty. Found on muicharts' `/api`; pannellum's likely affected
  too. The directive now falls back to the base parser for that shape.

## [1.2.5] - 2026-08-01

### Fixed — `scripts/smoke_live.py` failed CD on healthy sites

The post-deploy battery is the fleet's deploy gate: `cd.yml` runs it against
the live host after every merge and its exit code decides whether the run
goes green. Its `fetch` was a single `urlopen` — no retry, no wake-up — while
most of the fleet sits on Render tiers where a cold start or a dropped
connection is routine. Measured on dash-flows-upgraded: two runs minutes
apart against the same host, `FAIL canonical on /interactions` then
`ok canonical on /interactions`. A misdiagnosed failure is worse than a slow
one; it sends you to look at canonical tags that were correct all along.

Both fixes already existed in-fleet and never met (blueprint LESSONS §21
states the rule outright):

- **A wake-up loop before the first check.** `/healthz` is polled up to 24
  times, 10s apart — deliberately wider than §21's "12×5s is plenty",
  because a free-tier cold start routinely takes 60–90s and the window only
  costs time when the host is actually down. Awake means `ok: true`, not any
  200: Render's loading page and a CDN error page can both be 200s. A host
  that never wakes is ONE failure ("nothing else was tested"), not a cascade
  of forty per-check failures that all mean the same thing.
- **A retry ladder inside `fetch`** — the shape `scripts/network_smoke.py`
  already had, and that leaflet's copy of this very script grew without the
  fix ever flowing back to the canonical here. Transport errors and 5xx
  retry with backoff; 2xx/3xx/4xx return immediately, because a 404 is a
  verdict and retrying it only slows the battery. Retries print to the CD
  log — a green run that shows retries is a host worth watching.

Proven live before shipping, twice over: on the first run after the change,
flows' llms.txt dropped the connection mid-body (`IncompleteRead`) and passed
on retry — the exact flake that triggered this fix — and a run against
email.2plot.dev saw its OWN pages do the same on two *fatal* checks that
would have turned that deploy red.

Tunables (env, so satellites stretch them without editing the file):
`SMOKE_WAKE_ATTEMPTS`, `SMOKE_WAKE_INTERVAL_S`, `SMOKE_FETCH_RETRIES`. Exit
semantics unchanged; no check weakened, removed, or reordered. The file
remains the canonical copy — satellites take it verbatim on their next touch.

## [1.2.4] - 2026-08-01

### Fixed — the network bulletin was never wired up

`NETWORK_BULLETIN_URL` has been set in production, pointing at a hub endpoint
that works, against code that never read it. The wiring sat **commented out**
in `run.py` under a note saying "2plot.dev does not serve
/api/network/bulletin yet". The hub started serving it; the comment did not
change.

Nothing failed. `configure_bulletin` is opt-in, so an unwired app makes no
request at all and the viewer header renders perfectly well on the package's
built-in tips and an "No announcements." empty state. The only symptom was an
announcement that never appeared — which nobody goes looking for.

Now `lib/bulletin.py`, shaped like `lib/proxy.py` and `lib/access.py`: a
`configure()` that returns whether it wired, and a boot line that says which
of the two states the process is in. No commented-out code to go stale, and
`tests/test_bulletin.py::test_run_py_wires_it_rather_than_leaving_it_commented_out`
fails the moment someone comments it out again — commented wiring cannot
define the name it asserts on.

Two details worth keeping:

- **`app_id` comes from `SATELLITE_APP_KEY`**, reused from
  `lib.satellite_reporter.app_key()` rather than hard-coded. The hub scopes
  announcements by `?app=` and uses it to see which satellites actually render
  the bulletin, so a fork left announcing itself as `boilerplate` would
  receive this template's news *and* be miscounted. One notion of "which
  satellite am I", not two that can disagree.
- **The TTL is floored at 60s.** It is configurable via
  `NETWORK_BULLETIN_TTL_S`, and a small value would refetch on nearly every
  llms.txt view; junk falls back to the default rather than raising at boot.

Verified end to end against the live hub: the rendered header carries the
hub's own tip wording ("Append /llms.txt to any URL") rather than the
package's default ("Append /llms.txt to any page URL"), and the current
announcement.

One thing that cost time and is worth recording: on macOS the package's
bulletin client fails with `CERTIFICATE_VERIFY_FAILED`, because it uses a bare
`urlopen` with no CA bundle and the system Python has no OS trust-store
integration. That is a local-development artifact only — Linux containers have
a working store — but locally it looks exactly like a broken fetch. Run with
`SSL_CERT_FILE=$(python -c "import certifi;print(certifi.where())")` to tell
the two apart. `scripts/smoke_live.py` and `scripts/audit_links.py` already
carry their own certifi context for the same reason.

### Changed — one identifier for this app on every hub surface

`AD_APP_ID` now defaults to **`boilerplate`**, not
`dash-documentation-boilerplate`. Four modules present an identity to the hub
— `lib/ad_client.py`, `lib/satellite_reporter.py`, `lib/hub_client.py` and
`lib/bulletin.py` — each with its own fallback, and the ad client was the odd
one out. The visible cost was a column on `/admin/ad-board` that did not line
up with `/traffic`; the invisible one is that `hub_client.app_id()` falls back
to `AD_APP_ID` when `SATELLITE_APP_KEY` is unset, so a deployment that set the
long name for ads alone was silently presenting it as its hub identity too.

`lib/satellite_reporter.app_key()` still refuses to chain to `AD_APP_ID`. The
two agreeing here is a convenience, not a contract — leaflet.2plot.dev runs
`AD_APP_ID=dash-leaflet2` against directory key `leaflet`, and setting one for
ads must never re-key a satellite's analytics series.

`render.yaml` now sets `AD_APP_ID` explicitly rather than leaning on the code
default, so the deployed value is visible in the blueprint.

**This splits ad history.** The ad server keys impressions and clicks by
`app`, so anything already logged under `dash-documentation-boilerplate` stays
there — worth a look at `/admin/ad-board` on 2plot.dev before assuming the
numbers reset.

### Added — `.env.example`

The repo had none, so every configurable was discoverable only by reading
`lib/`. Each block states what turns ON when set and what the app does when
it is not — because almost every one of these fails silently rather than
loudly: no `APP_BASE_URL` deindexes a fork, no `CROSS_APP_WEBHOOK_SECRET`
means the hub simply never charts this app, no `NETWORK_BULLETIN_URL` renders
a header that looks complete.

Not gitignored (the pattern is `.env`, exactly), and `.dockerignore` already
whitelists it against the `.env*` exclusion added in 1.2.2.

`render.yaml` gains `NETWORK_BULLETIN_URL` so the deployment documents itself
rather than depending on someone remembering to set it in the dashboard.

## [1.2.3] - 2026-08-01

**The social card, finished.** 1.2.2 closed three of four defects and left
this one open because the artwork did not exist. It does now.

### Added — `scripts/make_social_card.py`

Renders the 1200×630 card: the artwork composited onto a frame carrying the
brand, tagline and domain, using the manifest's own `background_color` and
`theme_color` so the card, the browser chrome and the install splash cannot
disagree. Output lands in `build/social-cards/<domain>.png`, which is
gitignored.

A TEMPLATE FILE, and that is the point — pass `--brand/--tagline/--domain`
and every satellite is framed identically, instead of each card being made by
hand once and drifting. Three details that are not incidental: the artwork's
alpha bounding box is cropped before fitting (`assets/ddb.png` carries ~66px
of transparent margin that would otherwise be centred as if it were image); a
brand too long for two lines shrinks once rather than colliding with the
domain strip; and fonts resolve from a candidate list (macOS, then
Debian/Ubuntu) rather than being bundled, because shipping a licensed TTF in
a template every satellite forks is a question best not answered.

Pillow stays out of `requirements.txt`. Nothing at runtime renders images,
and a docs site should not carry an image library into production for a
script run by hand every few months.

**1200×630 = 1.91:1**, the Open Graph documented ideal, which also degrades
cleanly into Twitter's 2:1 `summary_large_image` slot. Deliberately not
leaflet's 1280×515 (2.49:1), which is wider than both and gets cropped on
each — and what sits at that URL today is the 2plot wordmark rather than a
per-site card at all. This is the shape for the network to converge on.

### Changed — `og:image` moved to the CDN

```
was:  https://boilerplate.2plot.dev/assets/ddb.png   784×741  (1.06:1)
now:  https://cdn.2plot.ai/github_assets/boilerplate.2plot.dev.png  1200×630
```

The old image's declared dimensions were honest, so nothing was broken — it
was simply near-square, and `summary_large_image` letterboxed it into a wide
slot with bars either side.

Moving it off the app is the network rule and it is about cold starts, not
tidiness: a card the app serves is fetched by the scraper at unfurl time, and
on a cold free-tier container that request lands mid-wake and times out. The
preview renders blank **once**, and the platform caches the miss — so the
first person to share the link poisons it for everyone. The CDN has no cold
start.

`og:image:secure_url` and `og:image:type` join the auxiliaries in
`templates/index.html`, matching what leaflet carries. Both are tags Dash does
not emit, which is the only reason they belong in the template.

### Added — the live card check that no offline test can make

The card's dimensions are now declared in **three** places: `lib/constants.py`,
`templates/index.html`, and the CDN object itself. `test_social_card.py` pins
the first two against each other, but nothing offline can look at the third —
so replacing the uploaded file with a differently-shaped one would leave every
test green while the platform reserves the wrong box and crops into it.

`scripts/smoke_live.py` now fetches the real file after every deploy and reads
its actual pixel dimensions out of the PNG's IHDR chunk, checking them against
the declared tags, plus the ratio, plus that `og:image` is neither empty nor
app-served. Two tests prove the check fires rather than merely existing:
`test_a_reshaped_card_on_the_cdn_fails_the_deploy` and
`test_an_empty_og_image_fails_the_deploy`.

That second case is not hypothetical — it is 2plot.dev's live state today, and
the reason `kickoff/` now holds a handoff for it.

`fetch()` in that script changed from `errors="replace"` to
`errors="surrogateescape"` to make this possible. `"replace"` substitutes
U+FFFD for every invalid byte and is one-way, so the PNG header was gone
before it could be read; surrogateescape round-trips exactly and behaves
identically for text.

### Changed — the two peer tests narrowed to peers

`test_smoke_script_rejects_a_peer_serving_its_spa_shell` and
`test_a_dead_peer_is_reported_but_does_not_fail_the_deploy` stubbed *every*
off-host URL, which now included the CDN-hosted card and failed the
(correctly fatal) card checks. The card is off-host but it is this
deployment's own responsibility, not a peer's — the distinction 1.2.2 drew
between "this host is fatal, somebody else's host is a warning" holds, the
stubs just needed to respect it.

### Changed — `build/` and `kickoff/` are gitignored

`build/` because the card is published to the CDN and never committed or
served. `kickoff/` because handoff notes start a session in *another* repo: a
task list for 2plot.dev has no business in the template's checkout, and every
satellite forking this repo would inherit a to-do that was never theirs.

## [1.2.2] - 2026-08-01

**Finishing 1.2.1, and the three things it exposed.** 1.2.1 shipped the right
template and half the change. Everything below was measured against the live
site rather than a local boot, because the local/deployed gap is precisely
what hid the first defect for a day.

### Fixed — the 1.2.1 files were never committed

`assets/favicon/` (the whole icon set plus `site.webmanifest`) and
`tests/test_social_card.py` were sitting UNTRACKED. The committed template
pointed at `/assets/favicon/…`, the deploy builds from git, so production
404'd the manifest, the apple-touch-icon and every PNG icon link — the entire
installable-app surface — while every local boot looked perfect because the
files were on disk. Nothing in the app reported it; `git status` was the only
place it appeared. Measured on the live site:

```
/assets/favicon/site.webmanifest      404
/assets/favicon/apple-touch-icon.png  404
/assets/favicon/favicon-32x32.png     404
```

The guard test was untracked too, so the one thing that would have caught this
had never run in CI either. Both are now tracked, and
`test_every_asset_the_template_references_resolves` widens the check from
"the manifest icons resolve" to "**every** `/assets/…` the template
references resolves" — because the failure was never about icons, it was
about a template referencing a file the repository does not have. A checkout
is what CI tests, so it fails there the moment something is not committed.

The manifest's contents needed no change; they were already correct.

### Fixed — the fork source's brand on every share card

`PAGE_TITLE_PREFIX` still read `"Dash Pip Components | "`, inherited from the
upstream this template was forked from and never changed. That is not only a
browser-tab string: Dash passes each page's title straight into `og:title` and
`twitter:title` (`dash/_pages.py:_page_meta_tags`), so every unfurl of
`boilerplate.2plot.dev` advertised **a different site**, while `<title>`,
`og:site_name` and the `/llms.txt` H1 all correctly said this one.

Now `f"{SITE_SHORT_NAME} | "`, matching the network convention the other
satellites already use (`dash-leaflet2 | `, `Dash Email | `) and derived from
the brand rather than retyped, so the two cannot drift.
`tests/test_site_identity.py` pins the prefix, the derivation, the rendered
`og:title`/`twitter:title`, and sweeps the identity surfaces for any surviving
mention of the old brand.

Nobody sees their own share cards, which is the whole reason this needed a
test rather than a look at the page.

### Fixed — `twitter:url` advertised `http://` (`lib/proxy.py`)

Dash builds that tag from `request.url`, and on Flask `request.url` comes from
`wsgi.url_scheme`. Requests arrive over Cloudflare → Render → gunicorn and the
last hop is plaintext, so production told every social scraper
`http://boilerplate.2plot.dev/`. `og:url` looked fine throughout because the
template hard-codes it.

gunicorn does try to fix this — it rewrites the scheme from
`X-Forwarded-Proto`, but only when the immediate peer is in
`forwarded_allow_ips`, which defaults to `127.0.0.1`. Reading the header
ourselves one layer above gunicorn sidesteps the question entirely:
`HTTP_X_FORWARDED_PROTO` is in the environ either way.

Notes on the implementation, all of them load-bearing:

- **Only the scheme is taken.** Host is not rewritten from
  `X-Forwarded-Host`; `BASE_URL` is already this project's single source of
  truth for the public origin, and a second header-derived notion of "what
  host am I" is how a fork ends up serving two.
- **The FIRST entry of the header wins.** Proxies append, as with
  `X-Forwarded-For`, so the last entry is the hop nearest the app — the
  plaintext one being seen past. Reading from the wrong end reinstates the
  bug and still passes a single-proxy test, so there is a test for it.
- **`TRUST_PROXY_HEADERS=0`** turns it off. This trusts a header from whoever
  connected, which is correct behind Render (it overwrites the header on every
  inbound request) and wrong for an app exposed directly, where a client could
  forge it.
- **The server object is wrapped, never rebound** — `app.server` stays the
  Flask/FastAPI/Quart instance that gunicorn imports as `run:server` and that
  `run.py` hangs `before_request` off. All three backends are handled.

The sibling `leaflet.2plot.dev` already serves `https` in the same tag from an
identical Cloudflare/Render/gunicorn stack with no proxy configuration of its
own; the difference we could observe is that it deploys as a Docker service
rather than a native one, which would plausibly put the proxy on loopback and
satisfy gunicorn's default. That is inference — Render's internal topology is
not visible to us — and the fix deliberately does not depend on which
explanation is true.

### Added — client-side URL sync on SPA navigation

Ported from `leaflet.2plot.dev` and adapted: that site hard-codes a static
canonical and this one does not (dash-improve-my-llms injects a per-page one),
so this version only ever *corrects* tags that exist and never creates one.

Three tags go stale after the first client-side route change, each for a
different reason: `og:url` is static in the template, `twitter:url` is
server-rendered from the entry request, and the injected canonical is right on
arrival and wrong thereafter. Dash routes through `history.pushState`, which
fires no event, so the tags advertise the landing URL for the rest of the
session. The origin is read from the existing `og:url` tag rather than
hard-coded a second time.

This helps Google, which runs JS. It cannot help social scrapers, which do
not — which is why the scheme half had to be fixed server-side.

### Changed — `test_exactly_one_canonical_tag_for_browsers` counts elements

It counted the substring `rel="canonical"`, and the new sync script's selector
(`link[rel="canonical"]`) is not a canonical tag. Same lesson as the
`dv-banner` chrome check it sits beside: match the markup, not the words, so a
file may legitimately discuss what it is being checked for.

### Still open — the card image is not on the CDN

`og:image` remains `/assets/ddb.png`, 784×741, served by the app. The declared
dimensions match the file honestly, so nothing is broken, but it misses two
network rules: cards belong on `cdn.2plot.ai` so a cold free-tier container
cannot blank a preview, and `summary_large_image` wants roughly 1.91:1
(leaflet's is 1280×515). `https://cdn.2plot.ai/github_assets/boilerplate.2plot.dev.png`
does not exist yet, and pointing `og:image` at a 404 is strictly worse than
the present state, so this waits on the asset. When it lands, the change is
`OG_IMAGE_URL` plus the width/height constants, plus the `og:image:secure_url`
and `og:image:type` tags leaflet carries.

## [1.2.1] - 2026-07-31

**The social card and the installable app** — the two surfaces that live
entirely outside the app, and so fail where nobody is looking. Found while
rolling the standard onto `leaflet.2plot.dev`, which inherited the same shapes
from this template. Satellites copy `tests/test_social_card.py` verbatim.

### Fixed

- **Two `og:image` tags per page, and the wrong one won.** `templates/index.html`
  declared `og:image` / `twitter:image` statically while Dash also emits both
  per page. With no `image_url=` passed, Dash *inferred* an image from the
  assets folder, found `assets/logo.svg`, and emitted it alongside the static
  tag. Every major scraper rejects SVG, and the inferred tag came last — so the
  card described so carefully in the template lost to an image nothing can
  render. `lib/constants.OG_IMAGE_URL` is now passed to `register_page`, and
  the template keeps only the auxiliaries Dash omits.
- **The same duplication across nine other tags** — `description`, `og:type`,
  `og:title`, `og:description`, `twitter:card`, `twitter:url`, `twitter:title`,
  `twitter:description`, `twitter:image` were all declared statically *and*
  emitted by Dash. The static copies described the site where Dash's describe
  the page, so the duplicate was both redundant and the less accurate of the
  two. `test_no_meta_tag_dash_emits_is_also_declared_statically` pins the rule.
- **The home page published an empty `description`.** `pages/home.py` never
  passed one, so Dash emitted `description`, `og:description` and
  `twitter:description` as `content=""` on the most-linked page on the site.
- **The web app manifest was inert, and named the wrong product.** Its link and
  the `apple-touch-icon` were commented out behind a note saying the files were
  missing — a note that outlived their arrival in `assets/favicon/` — and the
  commented hrefs pointed at `/assets/`, one level above where they live. The
  manifest itself still read *"Dash Email — Email components for Plotly Dash"*,
  copied in from another repo; that string is what an installed app would have
  shown on the home screen. Fixed, linked, and its `theme_color` reconciled
  with the `theme-color` meta tag.

### Added

- `tests/test_social_card.py` — a template file. Asserts the image is declared
  exactly once, is absolute, is not an SVG and resolves; that the manifest is
  linked, served, correctly named and has resolving icons; and that
  `templates/index.html` is still wired in, since it looks removable (
  dash-improve-my-llms appears to cover OpenGraph) and is not — its injection
  runs only on the prerender path, which social scrapers do not take.
- `lib/constants.OG_IMAGE_URL` / `_WIDTH` / `_HEIGHT` / `_ALT` — the per-site
  values a fork changes.

## [1.2.0] - 2026-07-31

**The 2plot network standard, landed on the template.**

`2plot.ai` (the network root) and `2plot.dev` (the section hub) shipped this
first; satellites are next, and this repo is the one they fork. So the point
of this release is not that `boilerplate.2plot.dev` complies — it is that the
files a satellite copies verbatim now carry the standard with them. The new
[Network Standard](https://boilerplate.2plot.dev/network-standard) page is the
per-site checklist.

The three obligations below share a shape, and it is worth naming: **every
failure they prevent is silent.** Nothing errors, no dashboard turns red, and
the damage accumulates for months. That is why each one is now pinned by a
test rather than by a convention.

### Added — explicit site identity (`lib/constants.SITE_BRAND`)

One constant, `"Dash Documentation Boilerplate — the 2plot network's
template"`, now reaches every surface that states what this site is:
`Dash(title=)`, `register_page_metadata(path="/", name=…)`, the first line of
`pages/home.md`, and `templates/index.html` (`og:site_name`, `og:title`,
`twitter:title`, the schema.org `SoftwareApplication.name`, the `<noscript>`
heading).

What this fixes is not cosmetic. `dash-improve-my-llms` resolves the
`/llms.txt` H1 and the llms viewer's brand chip through
`resolve_site_title(home_page_name, app.title)`, and given nothing useful it
publishes what it finds. On this host that was the `Dash()` constructor's
default title: every agent that fetched `boilerplate.2plot.dev/llms.txt` cold
was told the site is called **"Dash"**. The page rendered perfectly the whole
time. 2.3.4 fixed half of it — generic candidates (`Home`, `Index`, `Dash`)
are now skipped rather than served — but a package cannot invent a name; the
other half is stating one.

Naming rules, from the standard: the brand says what the site *is*; the
package name (`dash-documentation-boilerplate`) belongs in the description;
"Pip Install Python" is the byline and never the site name.

`tests/test_site_identity.py` pins all of it, including the direction that is
easy to lose — that `SITE_BRAND` is not itself one of the generic values the
package skips.

### Added — the internal-traffic contract, both halves

The point of truth is [2plot.ai's satellite-analytics
document](https://2plot.ai/docs/satellite-analytics), "Internal traffic": any
request whose User-Agent contains `2plot-internal` is network machinery
talking to itself and is counted **nowhere**.

*Inbound.* `lib/analytics_tracker.track_visit` drops token-carrying requests
at write time, **before** `detect_device_type`. The ordering is the whole
point: a health sweep and a CI battery both look like bots, so classified
first they land in `bot_hits` and get reported to the hub as crawler interest
in these docs. `/healthz` and `/health` stopped being stored at all —
`lib/traffic_rollup` already filtered them on the way out, but a row that
exists and must be discounted is still a row somebody has to know about.

*Outbound — the half that was missing here.* Every call this host makes to
another network host now sends `INTERNAL_UA`:

- `lib/ad_client.py` → `2plot.dev`, **once per docs page view**;
- `lib/satellite_reporter.py` → `2plot.ai`, hourly;
- `lib/hub_client.py` → `2plot.dev`, per agent-key verify and tier fetch;
- `scripts/network_smoke.py`, `scripts/smoke_live.py`, `scripts/audit_links.py`.

The ad client is the one that mattered. All of these were arriving as
`python-requests/2.x`, which matches the hub's own bot patterns — so this
satellite's readers were inflating 2plot.dev's `bot_hits`, once per page view,
and had been for as long as the ad slot has existed. The battery scripts keep
their Googlebot and Chrome tokens *and* append the internal one: the target
still exercises exactly the path under test, it just knows the caller is
machinery. The click beacon is the deliberate exception — a browser cannot set
a User-Agent, and a click is a real person.

`tests/test_internal_traffic.py` proves the exclusion reaches the numbers the
hub actually charts (`human_hits` / `bot_hits` in `daily_rollup`), proves the
positive case still counts (a rule that drops everything would satisfy the
negative assertions), and asserts the outbound header on all three clients and
all three scripts.

### Added — `scripts/network_smoke.py`, run in three seats

The same named checks against the CI container, against production after a
deploy, and in-process from `tests/test_network_smoke.py`, so a failure reads
identically wherever it happens. It proves identity (the `/llms.txt` H1 is the
brand, verbatim), the deployed artifact (the robots.txt crawler split, which
is the only fingerprint visible from outside — pip metadata is not), that no
owner-only surface leaks, that a crawler gets prose and not the JavaScript
stub, and that agents and browsers get different content types under a
`Vary: Accept`.

The in-process seat is not redundant: a script that only ever runs in CI and
after a deploy is exactly the code that rots, where a typo turns a check into
a silent pass. That test also breaks a check on purpose and requires the
battery to report it.

### Changed — CI on the network baseline

`.github/workflows/ci.yml` is now a template file in its own right:
least-privilege `permissions: contents: read`, `timeout-minutes` on every job
(the default is six hours, which is how one hung `curl` burns a day of runner
minutes), `docker/setup-buildx-action` with a `type=gha` cache, and version
fingerprints asserted **inside the built image** rather than in the runner.
The container is booted and probed by the battery before anything is allowed
to merge. `cd.yml` runs the battery against the live host before
`smoke_live.py`.

`tests/conftest.py` now boots the app secretless, the way CI's container does:
every `CLERK_*`, `CROSS_APP_WEBHOOK_SECRET` and `SESSION_SECRET` is pinned to
`""` **before** `run.py` is imported, because `load_dotenv()` runs during that
import and a developer's local `.env` would otherwise flip the app into a
configured posture and quietly invalidate every fail-closed assertion in
`tests/test_access.py`. The analytics ledger moves to a temp dir in the same
block — the suite had been appending its own hits to the repo's checked-out
`visitor_analytics.json`.

Added `.github/dependabot.yml` with a `dash-network` group (a package release
lands as one reviewable PR per repo, not five) and an advisory `pip-audit`
job.

### Changed — dependency floors

- **`dash-improve-my-llms` >= 2.3.4** (from 2.3.2). The network standard;
  `run.py`'s startup floor and CI's in-image fingerprint both assert it.
  There is no vendored copy of this package anywhere in the repo — the stale
  comments in `Dockerfile`, `render.yaml` and `README.md` that still described
  one are gone. `vendor/` holds `dash_clerk_auth` alone.
- **`gunicorn` >= 23.0.0** (from 21.2.0). 21.x carried two HTTP
  request-smuggling CVEs (CVE-2024-6827, CVE-2024-1135), both fixed in 23.0.
  `markdown2dash` 0.1.2 declares `gunicorn>=21.2.0,<22.0.0` — a markdown
  parser pinning a WSGI server — which pip cannot reconcile with that floor,
  so markdown2dash is installed with `--no-deps` and its real dependencies
  (`docutils`, `jsonpath`, `mistune`) are listed in `requirements.txt`
  instead. Every install path does the same two commands: `requirements.txt`,
  `scripts/dev.sh`, the `Dockerfile`, `render.yaml`'s `buildCommand`, CI, and
  the README. CI's in-image assert is what keeps the dodge honest.

### Added — `.dockerignore`

Found by booting the image locally as part of verifying this release: the
Dockerfile ends in `COPY . .`, so a developer's `.env` was being baked into
the production image. The container died at boot with `Could not import
dash.backends._fastapi` — the local file said `DASH_BACKEND=fastapi` and the
image has no FastAPI extra. It never appeared in CI, where the checkout has no
`.env`, which is precisely what made it worth a file rather than a lesson: the
same `COPY` would carry real Clerk keys and the webhook secret into an image
layer on any machine that has them. The ledger, session store, virtualenv and
`node_modules` are excluded too. `docs/**/*.md` deliberately is **not** —
those files *are* the app.

### Note on versioning

1.1.0 was declared in `README.md` and `lib/constants.APP_VERSION` but never
cut here; everything previously sitting under `[Unreleased]` ships as part of
1.2.0. `templates/index.html`'s `softwareVersion` and `APP_VERSION` now agree,
which `tests/test_config.py` asserts.

---

Previously unreleased, now shipping as part of 1.2.0 — three threads of work:
the CI/CD system, network analytics reporting, and the upgrade to
`dash-improve-my-llms` 2.2.0.

2.1.0 was assigned during that package's development and never published, so
there is no 2.1.0 anywhere and 2.0.0 upgrades straight to 2.2.0. Work
described here as "2.1-era" in earlier drafts shipped as part of 2.2.0.

### Changed — dash-improve-my-llms from PyPI (2.3.3); vendored copy removed

The four-host verification gate passed, `dash-improve-my-llms` published, and
this repo switched from the vendored sdist to the PyPI pin
(`dash-improve-my-llms[flask]>=2.3.2`) — the Phase-5 step the vendor block
always anticipated. `vendor/dash_improve_my_llms-*.tar.gz` is gone; CI's ASGI
legs and the Dockerfile install from PyPI too. `vendor/` still carries
`dash_clerk_auth` (not on PyPI, deliberately outside requirements.txt).

The floor resolves to 2.3.3, which recategorises the Anthropic crawlers:
`ClaudeBot` — the actual *training* crawler — moves to `Disallow`, while the
user-triggered and search fetchers `Claude-User` / `Claude-SearchBot` are
allowed, matching the intent the OAI-SearchBot fix established for OpenAI.
It also strips unexpanded directive lines from resolved prose. The artifact
fingerprint in `tests/test_llms_routes.py` and `scripts/smoke_live.py` now
asserts the full crawler split, so a host running a stale build fails its
post-deploy battery by name.

Verifying that fingerprint exposed a real misconfiguration:
`run.py` set `block_ai_training=False`, so the training bucket was never
emitted and every training crawler was silently allowed — the opposite of the
"blocks AI training, allows AI search" policy this project documents, and it
would have made 2.3.3's ClaudeBot recategorisation invisible on this host.
Now `block_ai_training=True`, matching the documented policy and the rest of
the network.

### Changed — production rollout: re-vendor 2.3.2 / 0.9.1, live hub contract

Deployment prep for `boilerplate.2plot.dev` (rollout step 4; the hub's auth
endpoints are now live in production).

- **`dash-improve-my-llms` 2.3.0 → 2.3.2** (vendored). The vendored 2.3.0 was
  a pre-fix build whose robots.txt disallowed OAI-SearchBot — ChatGPT
  search's crawler, exactly the audience these surfaces exist for. 2.3.2
  allows it. `User-agent: OAI-SearchBot` → `Allow: /` in a live host's
  `/robots.txt` is the fingerprint that it runs the fixed artifact (pip
  metadata is invisible from outside); `test_robots_artifact_fingerprint`
  now asserts it locally so a vendored regression fails CI, not production.
  2.3.1 was assigned during development and never published.
- **`dash-clerk-auth` 0.9.0 → 0.9.1** (vendored, built from the
  Dash-Clerk-Auth-Hook working tree). 0.9.0 ships a bug hitting every Clerk
  satellite forked from this template: clerk-js v5 auto-instantiates from the
  script tag's `data-*` attributes and reads the *instance* domain, so on a
  satellite the user button never mounts (dead avatar) while server-side
  session verification keeps working. 0.9.1 emits
  `data-clerk-domain="<satellite_domain>"` on the tag when `is_satellite=True`.
  This app runs no Clerk by design — the bump is for the template's sake.
  `lib/auth.py`'s fixup #1 guards on the attribute's absence, so it degrades
  to a no-op under 0.9.1 and stays for forks still on 0.9.0.
- **`lib/hub_client.py` aligned with the hub's real contract.** Two functions
  predated the hub going live. `current_key()` now sends
  `{"token": <Clerk session token>, "app": ...}` — the hub 401s any
  caller-asserted identity (`user_id` in the payload is the forgery path) and
  verifies the token against Clerk's JWKS, minting at `scope=auth`, never
  admin. Call it on copy-button click, never on page render; `None` degrades
  to copying the plain URL. `hub_tiers()` is no longer a stub: signed POST
  `/api/page-tiers` `{"app": ...}` → `{"tiers": {path: tier}, "ttl": s}`,
  cached for the returned TTL with failures cached 60s — so a down hub costs
  one timeout per window, not one per request, and resolves to the local
  tier, which the ceiling rule guarantees can never loosen anything.
  `verify()` already matched the hub and is untouched.

### Added — AI/LLM surfaces (dash-improve-my-llms 2.2.0)

- **`lib/network_directory.py`** — the peer/affiliated/external directory,
  defined once here and copied verbatim into every satellite. Publishes
  `<link rel="related">` tags, a `## Network` section in `/llms.txt`, and
  followed links in the prerendered body, so an agent landing on one satellite
  can enumerate the rest. Filters the app's own URL out of `peers`.
- **Wordmark** — `"2"` + morse(`plot`) + `"ai"`, drawn as columns of dots and
  dashes in the header of the rendered `llms.txt` view. No period glyph: the
  morse block already separates the halves, and a literal `.` beside it reads
  as punctuation dropped into a graphic. The renderer turns a suffix ending in
  `i` into an upward flourish, so `"ai"` draws as `a` plus that mark, with the
  real domain in `label` for screen readers and the SVG `<title>`. It lives in
  the shared module rather than per-app, which is what keeps one mark across
  the network instead of twelve near-identical ones.
- **Page `llms.txt` documents are no longer dead ends.** Each now opens with
  the site index, the network index one level up the hub chain (`2plot.dev`,
  correct for a `*.2plot.dev` subdomain), and the sitemap. These documents are
  usually read in isolation — pasted into a chat, handed to an agent — and an
  agent fetches a URL rather than crawling from one, so previously its
  exploration simply stopped there.
- **The same URL content-negotiates.** Agents, crawlers and curl get the
  Markdown byte for byte; browsers get it rendered behind a header carrying
  the network identity. `?raw=1` and `?format=html` override, both variants
  send `Vary: Accept`, and the rendered view is `noindex` so it never competes
  with the page it documents. Verified identical on Flask, FastAPI and Quart.
- **`docs/networks/networks.md`** — the guide for satellite authors: the three
  tiers, why per-host SEO can't express any of this, the wordmark and bulletin
  conventions, the one-URL-two-audiences contract, and the verification
  commands.
- **Network bulletin left deliberately unwired.** `configure_bulletin()` sits
  commented next to `add_llms_routes` with a pointer to the contract.
  `2plot.dev` does not serve `/api/network/bulletin` yet, and pointing at a
  dead endpoint gains nothing: the client degrades silently and the header
  renders fine without it — the "Tips for getting started" and "What's new"
  panels use the package's built-in defaults, which a bulletin only overrides.

### Added — Clerk authentication and llms.txt access control

Opt-in, and off in a default clone. This is the template every `*.2plot.dev`
subdomain is forked from, so the goal was a pattern good enough to copy rather
than a one-off. Requires `dash-improve-my-llms` 2.3.0 (`configure_access`,
`configure_viewer_identity`) and the vendored `dash-clerk-auth` 0.9.0, which is
deliberately **not** on the active requirements line — a default install should
not pull in an auth stack the site does not use.

- **`lib/auth.py`** — adapted from `2plot_leaflet/lib/auth.py`, the
  implementation already sharing authenticated state across `2plot.ai` →
  `2plot.dev` → `leaflet.2plot.dev` in production. Keeps both satellite fixups
  for `dash-clerk-auth` 0.9.0 (clerk-js reads `domain` as a *constructor*
  option from `data-clerk-domain`, and a satellite must `redirectToSignIn()`
  rather than open a modal that 403s), the `pk_live` auto-enable so production
  cannot silently boot in primary mode, `DISABLE_CLERK=1`, and call-time env
  reads. Changed for the template: the satellite domain derives from
  `APP_BASE_URL`, which every deployment must set anyway — one variable rather
  than two, and one fewer way to announce another site's domain to Clerk.
- **`lib/page_tiers.py`** — `public < auth < admin < hidden`, declared in
  markdown frontmatter (`tier: admin`) because this template is already
  frontmatter-driven and marking one page should not require a control board.
  Two rules: everything except `hidden` falls open when Clerk is unavailable
  (documentation must not brick over a missing credential), and
  `effective_tier = more_restrictive(local, hub)` so a satellite may restrict
  further but never loosen.
- **`lib/hub_client.py`** — the client for the hub's `/api/agent-key/current`
  and `/api/agent-key/verify`. Authenticates the caller with the network's
  existing `CROSS_APP_WEBHOOK_SECRET` HMAC scheme, the one
  `lib/satellite_reporter` already uses: it authenticates *who is asking* and
  derives nothing, which is what keeps "satellites hold no key material" true
  while still keeping the verify endpoint from being an open key-guessing
  oracle. Verdicts cached on a SHA-256 fingerprint of the key rather than the
  key, because that cache is process memory a debugger or error reporter can
  dump. `allow` cached 900s, `deny` 60s — a brief hub outage must not gate
  readers who were fine a minute ago, while a revoked key should stop working
  promptly.
- **`lib/access.py`** — the policy, and its ordering is the design:
  tier → **local Clerk session** → hub, only for `?key=`. A signed-in visitor
  resolves entirely on this host, so the hub being down gates nothing for them;
  only the agent path, which arrives with no cookie, needs the hub at all.
  Reversing it would couple every satellite's availability to one host for no
  benefit. Kept out of `run.py` so satellites inherit one file.
- **`docs/authentication/`** — three layers, so a reader stops at the one they
  need: the default (nothing to do), a standalone site with its own Clerk, and
  joining or running a network. Names the two traps: the Clerk token's `iat` is
  the token's age, not the sign-in's, so wiring it renders a clock that resets
  every minute; and identity must never travel in the bulletin, which is
  TTL-cached and shared across every satellite.
- **`handoff/`** — kickoff prompts for the two repos this unblocks: an addendum
  pairing with the `pip-docs+` hub brief, carrying the request shapes and cache
  TTLs the client already sends, and a per-subdomain port guide.
- **`tests/test_access.py`** — 17 tests against a fake hub. The two that
  justify the design: signed-in browser with the hub unreachable still resolves
  to `allow`, and a valid key with the hub down degrades to `gated` rather than
  500 or prose. One asserts the *ordering* rather than the outcome — a
  signed-in reader must trigger zero hub calls, since "allowed" could otherwise
  come from a hub that happened to agree.

**Inert until a tier says otherwise.** With the wiring in place, no Clerk keys,
and every page public, all 43 surfaces are byte-identical to the build before
any of it existed — measured, with a control run to strip out the per-request
ids Dash puts in page HTML.

### Changed — dash-improve-my-llms 2.2.0 → 2.3.0

Vendored, as before; 2.3.0 is additive and opt-in. Verified as a no-op on the
surfaces that matter: every Markdown document, the root index, `sitemap.xml`,
`robots.txt` and the crawler HTML are byte-identical. The HTML viewer variants
grow by 192 bytes each — three CSS rules for the identity block that ship
whether or not identity is configured. Behaviourally a no-op; not literally
byte-identical everywhere, which is worth stating precisely since this baseline
is what a later regression gets attributed to.

### Added — CI/CD and tests

- **`.github/workflows/ci.yml`** — flake8 (blocking), then the full test suite
  across a matrix of Python version × backend × Dash version: Flask, FastAPI
  and Quart on Python 3.12, Python 3.11 and 3.13 on Flask, and the bottom of
  the `~=4.4.1` range pinned explicitly on Flask and FastAPI so a 4.4.0-only
  regression cannot hide behind pip resolving to 4.4.1. Asserts the resolved
  Dash and `dash-improve-my-llms` versions before running anything, boots the
  app under gunicorn (a page can render under a test client and still fail
  under a real WSGI worker), and builds and probes the Docker image.
- **`.github/workflows/cd.yml`** — runs CI, POSTs the `RENDER_DEPLOY_HOOK_URL`
  secret, waits for the new instance to be *sustainably* healthy (Render swaps
  instances rather than restarting in place, so a single 200 from `/healthz`
  proves nothing), then verifies the live site. Skips the deploy step when the
  secret is absent instead of failing, so a fork isn't red on day one.
- **`tests/`** — a pytest suite that boots `run.py` itself rather than a test
  app. `conftest.py` normalises the three backends' test clients behind one
  synchronous `.get()`, including driving Quart's async client from a
  fixture-owned event loop. Covers page registration and reachability, stub
  bodies, rendered prose, canonical tags, sitemap/robots/llms.txt, content
  negotiation in both directions, the navigation block, the banner and its
  panels, the network directory and wordmark, docs frontmatter and directive
  targets, heading anchors, and the `BASE_URL` guard.
- **`scripts/smoke_live.py`** — post-deploy checks against a live satellite,
  standard library only. Covers the failures that are silent in production: a
  canonical on the wrong host, a page serving the JavaScript stub, viewer
  chrome leaking into an agent's Markdown, a missing `Vary: Accept`, a missing
  network directory, and dead peer `llms.txt` links. Run in CD and by hand
  (`python scripts/smoke_live.py https://emojimart.2plot.dev`), and itself
  tested against the in-process app so a typo can't turn every live check into
  a silent pass.
- **`scripts/dev.sh`** — starts the development server with *this* project's
  interpreter, resolved from the script's own location rather than from an IDE
  setting or `PATH`.
- **`scripts/audit_links.py`** — walks every page's `llms.txt`, extracts every
  link, resolves internal paths in-process and checks the rest over the
  network. A dead link in an `llms.txt` is worse than one on a page: the agent
  holding that document has no navigation to fall back on and no way to tell a
  typo from a host that is down.

  Classified rather than lumped together, because the classes want different
  responses: `internal` is a real defect, `self-host` is correct once deployed,
  `network` is a peer awaiting the rollout, `unpushed` is a file that exists
  locally and 404s only until the branch is pushed, and `external` is someone
  else's problem to route around. Code spans and fenced blocks are skipped —
  a URL inside backticks renders as `<code>`, not `<a>` — and a transport
  failure is retried once, because an audit that cries wolf gets ignored.
- **`LICENSE`** — the MIT text the README badge, `pages/home.md` and the
  Schema.org block have all claimed since 0.1.0 without the file ever existing.
- **`render.yaml`** — Render Blueprint for `boilerplate.2plot.dev`: gunicorn,
  `/healthz` health check, custom domain, and a persistent disk for the
  analytics ledger (on an ephemeral filesystem a mid-day deploy wipes it and
  the next hourly report overwrites the day's real total).
- **`.flake8`**, **`pytest.ini`**.

### Added — Network analytics reporting to 2plot.ai

- **`lib/satellite_reporter.py`** — hourly signed rollup POSTed to
  `https://2plot.ai/api/satellite/traffic`, so a deployed docs site shows up on
  the hub's owner-only `/traffic` dashboard. HMAC-SHA256 over
  `"{timestamp}." + body` with `CROSS_APP_WEBHOOK_SECRET`, matching the
  network's existing webhook scheme. Off by default: no secret, no reporting.
  Re-posts yesterday during the first hours of a new day so the final hits of a
  day aren't left out, and uses a lease file so only one web worker reports per
  interval instead of every worker racing. `python -m lib.satellite_reporter
  --dry-run` prints the payload without sending it.
- **`lib/traffic_rollup.py`** — derives the reported numbers (`human_hits`,
  `bot_hits`, `visitors`, `sessions`, `median_session_s`, top pages, countries)
  using the hub's own definitions, so this app's figures are comparable with
  every other app on the chart. Infrastructure paths (`/healthz`, `/llms.txt`,
  `/robots.txt`, `/sitemap.xml`, assets, Dash internals) are excluded from the
  report but stay in the local ledger.
- **`lib/health.py`** — `/healthz` on Flask and Quart, matching the endpoint
  the FastAPI build already declared. The hub's hourly sweep probes it for
  up/down + latency, which previously only worked on one of the three backends.
- Quart now tracks visitors too; previously only Flask and FastAPI did.

### Changed — dependencies

- **`dash-improve-my-llms` 2.0.0 → 2.2.0**, installed from `vendor/` until it
  is published to PyPI. App 1 of 4 in a staged rollout, first because every
  satellite documentation site is forked from this repo — a convention set here
  propagates, and so does a mistake.

  Page metadata now *merges* instead of assigning, so no later bookkeeping call
  can erase a page's prose; the prerender reaches every visitor rather than
  only recognised crawlers; and the Markdown renderer emits real anchors,
  tables, code fences and rules. Measured on this app: link counts in crawler
  bodies went from 3 per page to 3–11, code fences from 0 to 5–29 per page, and
  horizontal rules stopped rendering as literal `---` text. No page serves the
  crawler stub, before or after — this repo was never affected by the
  prose-erasure bug, having no bridge loop over `dash.page_registry`.

- **Dash pinned to `~=4.4.1`** (was `>=4.4.0`). Verified matrix, from real apps
  on each backend with the failure reproduced on stock Dash:

  | Dash | Flask | FastAPI | Quart |
  |---|---|---|---|
  | 4.1.0 | ok | n/a — no pluggable backends | n/a |
  | 4.2.0 | ok | ok | ok |
  | 4.3.0 | ok | **broken — every non-root page 500s** | ok |
  | 4.4.0 | ok | ok | ok |
  | 4.4.1 | ok | ok | ok |

  4.3.0 added an early-return path guard to the ASGI middleware that returns
  before `set_current_request`, while the page catch-all still calls
  `get_current_request()` — so it raises `RuntimeError: No active request in
  context`. The catch-all is byte-identical between 4.2.0 and 4.3.0; only the
  middleware changed. 4.4.0 set the context inside the catch-all as well, so a
  future middleware guard cannot reintroduce it: 4.4.x is structurally safer,
  not merely currently-passing.

  `~=4.4.1` lets patch releases flow without twenty pull requests while
  blocking 4.5.0, so a minor bump goes through the matrix deliberately. Pinned
  for the most constrained backend network-wide, **including Flask-only apps** —
  `DASH_BACKEND` is an env var and this is a shared template, so a Flask
  deployment becomes a FastAPI deployment with one env change and no code
  change.

- **Dependency floors are enforced at startup, not advised.** A version below
  the floor stops the boot, names what would degrade, and prints
  `sys.executable` alongside the expected interpreter. `ALLOW_STALE_DEPS=1`
  opts out for anyone deliberately testing an older release. The Dash floor is
  fatal only on FastAPI, where 4.3.0 is an outage rather than a degradation.
  See *Fixed — environment and tooling* for why this is a hard failure.

- **`network_directory.apply()` gates the `wordmark` argument** on the
  installed signature. During a staged rollout this module reaches satellites
  before the new package does, and Python raises `TypeError` on an unknown
  keyword — so passing it unconditionally would turn an older satellite's boot
  into a crash rather than a missing graphic. Same technique `run.py` uses for
  Dash's `enable_mcp`.

### Changed — hosts, branding and repo hygiene

- **`BASE_URL` moved to `lib/constants.py`** and reads `APP_BASE_URL` from the
  environment, defaulting to `https://boilerplate.2plot.dev`.
  `require_owned_base_url()` refuses to boot in production when `APP_BASE_URL`
  is unset or points at a platform hostname (`*.onrender.com` and friends).
  This is the template's highest-consequence footgun: a fork that leaves the
  default in place emits the boilerplate's canonical URL on every one of its
  pages, which asks Google to deindex it, and nothing about the app looks
  broken while it happens.
- **YouTube links now point at [@2plotai](https://www.youtube.com/@2plotai)**;
  `plotly.pro` is replaced by `2plot.ai` throughout, and the deployment host by
  `boilerplate.2plot.dev`. A test fails the build if a live link to
  `plotly.pro` reappears.
- **`.claude/` is untracked and gitignored.** Local session workspace; noise in
  a template other people fork.
- **Dockerfile** copies `vendor/` before the pip layer (the build fails
  otherwise while the package installs from an sdist), declares a `HEALTHCHECK`
  against `/healthz`, and no longer leaves apt lists in the image.

### Fixed — SEO and template

- **Every page shipped two `<link rel="canonical">` tags.** `templates/index.html`
  hard-coded one pointing at the site root while the package injected the
  correct per-page one. A conflicting pair is treated as no signal at all, so
  the per-page canonicals were doing nothing. The template no longer sets one.
- **Two advertised LLM endpoints were 404s.** `<meta name="llms-page-json">`
  and `llms-architecture` pointed at `/page.json` and `/architecture.txt`,
  both removed in dash-improve-my-llms 2.0. The `<noscript>` block linked to
  them too.
- **The Open Graph image never existed.** Every share rendered a blank card
  against `assets/og-image.png`, a file not in the repo. Now points at a real
  asset with its actual declared dimensions.
- **`apple-touch-icon.png` and `site.webmanifest` 404'd on every page load** —
  both `<link>`ed but neither shipped. Commented out with instructions.
- **`piratesbagain.com`** in the navbar (missing `r`) — a dead outbound link
  on every page.
- Placeholder metadata left in the template: `"Your Organization Name"`,
  `"Your Name or Organization"`, `yourdomain.com`, and a `price` of
  `"29_000_000"` in the SoftwareApplication schema (not a valid number, and
  the project is MIT-licensed).

### Fixed — every page shipped the same hard-coded title

`templates/index.html` hard-coded a `<title>` and contained no `{%title%}`
placeholder anywhere, so the per-page titles `pages/markdown.py` registers were
discarded and every page's title depended entirely on `dash-improve-my-llms`
rewriting that one element. `LLMSConfig(prerender=False)` — the documented
one-argument rollback — silently reverted every page on every satellite to one
identical string.

Now `<title>{%title%}</title>`, with `app.title` set from a new
`constants.APP_TITLE`. Without that second half the placeholder resolves to
Dash's default, the bare string `"Dash"`, which is worse than what it replaced.

**The trap, for anyone editing that block.** The package finds the element with
`re.compile(r"<title>.*?</title>", DOTALL | IGNORECASE)` and rewrites the first
match:

- Delete the element and no closing tag remains to anchor on — nothing is
  rewritten and no page has a title at all.
- Spell the tag name in angle brackets inside a nearby *comment* and the match
  starts there instead, running to the next closing tag and replacing every
  line in between. The comment, and any markup after it, vanishes from the
  served page. With rewriting on it still looks correct, so the damage is only
  visible in the served bytes.

The comment above the element used to contain a literal `<title>` for exactly
this reason, and the first attempt at this fix reintroduced it *while
explaining it*. The block now describes the tags in words, and three tests pin
it: the placeholder is present, the title regex matches nothing but the element
itself, and no comment spells the tag in angle brackets. A fourth asserts every
page serves a distinct title.

### Fixed — dead links in the llms.txt documents

Found by `scripts/audit_links.py` across all 10 documents and 102 links.

- **The MIT `LICENSE` file did not exist.** `pages/home.md` and the README
  both linked to it, and the Schema.org block declared the licence — so the
  one link a reader follows to check the terms was the one that 404'd. Added.
- **The development-server port was wrong.** `pages/home.md` said
  `http://localhost:8553`; `run.py` binds **8559**. The Docker instruction
  (8550) was right for the container but rendered as a live link that 404s for
  anyone not running the image — both are now code spans, so they read as
  instructions rather than as something to click.
- **The `SKILLS.md` link pointed at the wrong path** —
  `dash-improve-my-llms/blob/main/SKILLS.md`, but the file lives under
  `docs/`. Fixed to `blob/main/docs/SKILLS.md`.

### Fixed — Markdown rendering

- **A heading containing inline code crashed the site at startup.**
  markdown2dash's renderer does `create_heading_id(text[0])`, and when the
  first inline token is formatted, `text[0]` is a component rather than a
  string — `AttributeError` at import, taking every page down. Fixed in
  `lib/directives/headings.py`.
- **TOC anchors pointed at ids that didn't exist.** Even when it didn't crash,
  the renderer slugged only the *first* inline token (`## Wiring **it** up` →
  `id="wiring"`) while the `toc` directive slugged the raw markdown
  (`wiring-**it**-up`). Both now use one `slugify`, so the link and its target
  agree. Plain headings slug exactly as before, so no existing anchor moved.

### Fixed — MCP wiring

- **The MCP server was never enabled.** `run.py` did
  `from dash import mcp_enabled`, but the symbol lives in `dash.mcp` — the
  import always raised, and the app printed "MCP not available in dash 4.4.1
  (needs >=4.3)" while running 4.4.1. `mcp_enabled` is also the decorator for
  marking a *function* as an MCP tool, not a server switch. The server is
  started from Dash's constructor, so `enable_mcp=` / `mcp_path=` is now passed
  there, and it works on all three backends rather than only FastAPI. Passed as
  `**kwargs` so naming a 4.3+ keyword can't break the boot on an older Dash.

### Fixed — environment and tooling

- **The app booted silently against another project's virtualenv.** An IDE run
  configuration pointing elsewhere started this app against whatever versions
  that environment held — on `dash-improve-my-llms` 2.0.0 there is no
  `llms_viewer.py` at all, so `/<page>/llms.txt` served plain Markdown to every
  visitor and nothing in the log said why. It cost a debugging session across
  two repositories, chasing a stale process and a browser cache that were both
  innocent, and survived a server restart and an incognito window because
  neither was the variable.

  Made worse by this repo's own `enable_mcp` fix, which removed the
  `TypeError` that had been failing loudly on the wrong interpreter — trading a
  crash for a plausible wrong answer.

  Warnings were tried first and were not enough: they scroll past above a wall
  of page-loading output while the app keeps serving. The floors are now fatal
  (see *Changed — dependencies*), and `scripts/dev.sh` removes the choice of
  interpreter entirely. A test asserts the same floor, so `pytest` in the wrong
  environment reports the cause instead of thirty downstream symptoms.
- **CI installed a tarball path that no longer existed.** `ci.yml` hardcoded
  the vendored filename for the FastAPI and Quart legs, so a version bump broke
  exactly two of the matrix entries. It now globs `vendor/`.
- **Header lookups in the test client were case-sensitive.** Werkzeug returns
  `Content-Type`, httpx returns `content-type`, so the content-negotiation
  assertions passed on Flask and failed on FastAPI and Quart — reading like a
  backend bug when the served headers were identical and correct.
- **A peer serving its SPA shell counted as a live document.** The peer check
  asserted only `status == 200`, but a Dash app answers its catch-all with the
  app shell for *any* unmatched path — `2plot.dev/api/this-endpoint-cannot-exist`
  returns `200 text/html`, as does `/api/network/bulletin`, which does not
  exist. A status-only check therefore passes against every host in the
  network whether or not it publishes anything. `smoke_live.py` now rejects an
  HTML body for a document URL, and the same reasoning applies to the
  network-wide check in `ROLLOUT.md`.
- **`smoke_live.py` extracted malformed peer URLs.** Its pattern stopped only
  at whitespace and `)`, and the 2.2.0 navigation block writes links as
  `[https://host/llms.txt](https://host/llms.txt)` — so it produced
  `https://2plot.dev](https://2plot.dev/llms.txt`, which would 404 in CD and
  fail a perfectly good deploy. Invisible locally, because the test shim
  answers 200 for off-host URLs.
- **Viewer-chrome detection keyed on a bare class name.** `docs/networks`
  legitimately *documents* `dv-banner`, so a substring check failed on the
  page's own prose. Both the suite and `smoke_live.py` now match rendered
  markup (`<div class="dv-banner"`), which a Markdown document can never
  contain — otherwise the check quietly teaches people to stop documenting the
  viewer.

### Fixed — Analytics accuracy

- **AI-search crawlers were not being counted.** The visitor hook was
  registered after `add_llms_routes`, and the package's bot middleware
  short-circuits ClaudeBot / ChatGPT-User / PerplexityBot with its own
  response — so those requests never reached the tracker. The hook is now
  registered first on Flask/Quart (and last on FastAPI, where Starlette runs
  the most recently added middleware outermost).
- **Every visitor looked like one visitor behind a proxy.** The tracker used
  `remote_addr`, which on Render/Cloudflare is the proxy. It now reads
  `CF-Connecting-IP`, `True-Client-IP`, `X-Real-IP` and `X-Forwarded-For`
  first, and takes the country from Cloudflare's `CF-IPCountry` header when
  present — free, instant and accurate.
- **Concurrent workers overwrote each other's hits.** The ledger was read,
  modified and rewritten with no lock; under four workers most hits were lost.
  Writes now take an `flock` and land via an atomic replace.
- **Geolocation no longer blocks page views.** The ip-api.com lookup ran inline
  with a 2s timeout on the first hit from each new IP. It now runs in a bounded
  background thread and is backfilled into the buffered hit before it is
  written, so the country is still recorded. Disable with
  `ANALYTICS_GEO_LOOKUP=0`.
- **The ledger is bounded and no longer rewritten on every request.** Hits are
  buffered (10 hits / 30s) and pruned to `ANALYTICS_RETENTION_DAYS` (45) and
  `ANALYTICS_MAX_VISITS` (20000); the hub holds the durable history.
- The ledger path is now absolute (`TRAFFIC_ANALYTICS_FILE`, else repo root) —
  a relative default wrote a different file depending on the working directory.
- Tablets are no longer counted as mobile (iPads and most Android tablets send
  a mobile token too, and the mobile test ran first).

## [1.0.0] - 2026-06-14

First stable release. The boilerplate moves to **Dash 4.x** with pluggable
backends and **dash-improve-my-llms 2.0**, and retires the experimental TOON
format entirely. This is a significant architectural release — see the
migration notes at the end of this section.

> **Versioning note:** the `0.5.0`–`0.8.0` entries below were the December 2025
> TOON line. That work has been removed (see "Removed" below) and the project
> resumes a single, monotonic version line at `1.0.0`. A short-lived second
> `0.5.0` (the May 2026 dash-improve-my-llms 2.0 preview) has been folded into
> this entry.

### Added — Pluggable backends (Flask / FastAPI / Quart)

- **`lib/backend.py`** — single source of truth for backend selection. Reads
  the `DASH_BACKEND` environment variable (`flask` | `fastapi` | `quart`),
  falls back to `flask`, and exposes `BackendInfo` (label, color, icon,
  async flag) so UI components stay in sync with the running backend.
- **`run.py`** constructs `Dash(backend=resolve_backend(), ...)` and attaches
  `app._backend_info` for layout components.
- **`components/backend_badge.py`** — a navbar/header badge that shows which
  backend the site is currently running on.
- **`lib/asgi_middleware.py`** and **`lib/asgi_routes.py`** — ASGI middleware
  and showcase routes (`/healthz`, `/api/backend`, `/api/pages`) that light up
  on the FastAPI/Quart backends.
- New documentation sections:
  - **Pluggable Backends** (`docs/backends/`) — run the site on any of the
    three backends with one env var.
  - **Backend Deep Dive** (`docs/backend-comparison/`) — architecture,
    strengths/weaknesses, deployment, and best practices for each backend.
  - **FastAPI Showcase** (`docs/fastapi-showcase/`) — OpenAPI docs, a native
    JSON API, ASGI middleware, async demo, endpoint explorer, and a stress
    test, showing what the ASGI backends unlock.

### Added — AI/LLM integration via dash-improve-my-llms 2.0

- **`LLMS_DOC` pattern.** Pages expose a module-level prose string (or call
  `register_page_metadata(path, llms_doc=...)`); the package serves it verbatim
  at `/<page>/llms.txt` under whichever backend is active.
  - `pages/markdown.py` registers the expanded markdown body (with
    `.. source::` directives inlined) for every markdown-driven page.
  - `pages/home.py` exports `LLMS_DOC = content` for the root prose.
- **Multi-backend AI/LLM surfaces.** `add_llms_routes(app)` auto-detects the
  backend and serves `/llms.txt`, `/<page>/llms.txt`, `/sitemap.xml`, and
  `/robots.txt` under Flask, FastAPI, and Quart alike — no `if IS_FLASK:` gate.
- **MCP resource bridge.** Each page's prose registers as a `dash.mcp` resource
  on Dash 4.3+ (a silent no-op on older Dash).

### Changed

- **Upgraded Dash 3.2.0 → 4.2.0** and **Dash Mantine Components 2.4.0 → 2.7.0**
  (Mantine 8.3.6). React 18.2.0.
- **`docs/ai-integration/ai-integration.md`** fully rewritten for the 2.0
  surface (LLMS_DOC, multi-backend, MCP bridge).
- **`requirements.txt`** now pins `dash>=4.1.0`, `dash-mantine-components>=2.7.0`,
  and `dash-improve-my-llms[flask]>=2.0.0`, with commented `[fastapi]`,
  `[quart]`, and `[all]` extras plus `uvicorn` for ASGI deployment.
- **`docs/example/example.md`** "Highlighting Important Elements" section
  rewritten around the `LLMS_DOC` pattern.
- **`components/header.py`**, **`components/appshell.py`**, and
  **`components/navbar.py`** updated for the new backend badge and navigation
  (TOON Format and Handoff entries removed).
- **`lib/directives/llms_copy.py`** / **`assets/llms_copy.js`** updated for the
  2.0 `/<page>/llms.txt` routing.
- `APP_VERSION` and `package.json` bumped to `1.0.0`.

### Removed

- **The entire TOON format system** — `lib/toon_generator.py` (~1100 lines),
  the `docs/toon-format/` page, the TOON Analytics Dashboard
  (`docs/data-visualization/toon_dashboard.py`), and all `/llms.toon`
  routes. `dash-improve-my-llms` 2.0 removed TOON from its public API
  (`TOONConfig`, `toon_encode`, `generate_*_toon` no longer exist).
- **`/page.json` and `/<page>/page.json`** routes — dropped in
  dash-improve-my-llms 2.0; Dash 4.3 MCP exposes layouts as resources natively.
- **`/architecture.txt`** — likewise superseded by MCP.
- **`mark_important()`** and **`mark_component_hidden()`** — now deprecated
  no-ops in 2.0. Write the emphasis directly into a page's `LLMS_DOC` markdown.
- **`LLMS_INTEGRATION.md`** and the `docs/handoff/` doc (the FastAPI port plan
  that became 2.0) — superseded by the in-app AI Integration page.

### Migration notes (from any 0.x)

1. **Backend:** the site defaults to Flask, so no change is required. To run on
   FastAPI or Quart, install the matching extra (`pip install "dash[fastapi]"`)
   and set `DASH_BACKEND=fastapi`.
2. **AI/LLM prose:** give each page module an `LLMS_DOC = """..."""` string at
   module scope (or `register_page_metadata(path, llms_doc=...)` when the prose
   is computed). The startup `UserWarning` from 2.0 names every page still
   missing prose.
3. **dash-improve-my-llms extra:** pick `[flask]`, `[fastapi]`, `[quart]`, or
   `[all]` in `requirements.txt`.
4. **Removed APIs:** replace any `mark_important()` / `mark_component_hidden()`
   calls (now no-ops) with `LLMS_DOC` content, and remove references to TOON,
   `/page.json`, and `/architecture.txt`.

---

## [0.8.0] - 2025-12-14

### Added
- **TOON v3.3 Format Enhancements** - Major comprehension improvements from ~75-80% to ~95%+
  - **New Dataclasses**:
    - `CodeTip` - Short instructional code snippets with context
    - `BestPractice` - Numbered best practices with multi-line code examples
    - `Pattern` - Architectural patterns with implementation code
    - `Resource` - External resource links with full URLs
  - **New Extraction Functions**:
    - `extract_code_tips()` - Finds short code snippets (2-15 lines) with headings
    - `extract_best_practices()` - Extracts numbered practices from "Best Practices" sections
    - `extract_patterns()` - Captures pattern implementations from "Common Patterns" sections
    - `extract_resources()` - Extracts markdown links with full URLs preserved
  - **New TOON Sections**:
    - `tips[N]{context,lang,code}:` - Compact code tips with one-line previews
    - `bestPractices[N]:` - Full multi-line code snippets for each practice
    - `patterns[N]:` - Pattern descriptions with implementation code blocks
    - `resources[N]{name,url}:` - External links without URL truncation

### Changed
- **Updated TOON format version from toon/3.2 to toon/3.3**
- **Enhanced summary line** to include tips, best practices, patterns, and resources counts
- **Improved content deduplication** - Tips exclude Best Practices and Patterns sections to avoid duplicate code

### Fixed
- **Code block detection in section boundaries** - Headings inside code blocks (like `## My Visualization` in markdown examples) were incorrectly detected as section boundaries
  - Added code block range detection using `code_block_ranges` list
  - Added `is_in_code_block()` helper to filter out false headings
  - Applied fix to `extract_code_tips()`, `extract_best_practices()`, and `extract_patterns()`
- **`re.escape()` issue** - `re.escape("Best Practices")` was escaping spaces incorrectly
  - Changed to custom escaping that only escapes regex special chars but preserves spaces

### Technical Details
- Updated `lib/toon_generator.py` (~1100 lines after updates)
- Test results for Data Visualization page:
  - 6 tips (properly deduplicated)
  - 5 best practices (all with full multi-line code)
  - 3 patterns (all with implementation code)
  - 4 resources (with full URLs)
  - TOON size: 11,444 chars

---

## [0.7.0] - 2025-12-13

### Added
- **Custom Documentation-Aware TOON Generator** (`lib/toon_generator.py`)
  - Custom TOON route that processes raw markdown from `NAME_CONTENT_MAP`
  - Achieves **54.7% token reduction** vs llms.txt while preserving all content
  - Full directive awareness (exec, source, kwargs, toc, llms_copy)
  - Features:
    - Section extraction with hierarchical structure (h2-h6)
    - Directive parsing with option extraction
    - Source file embedding with smart code compression
    - Table and list preservation in compact format
    - Exec component detection with callback markers
    - Deduplication of code examples and directives
  - Smart code compression (`compress_code()`) that:
    - Preserves imports, function/class definitions
    - Keeps callback decorators and Input/Output patterns
    - Truncates long files with line count indicator
  - TOON v3.2 format with optimized output:
    - Compact section format: `[level] title`
    - Grouped directives by type
    - Inline table format with pipe separators
    - Key lists extraction for substantial bullet points

### Changed
- **Custom `/<page>/llms.toon` route** in `run.py`
  - Overrides default dash-improve-my-llms TOON for markdown pages
  - Uses raw markdown from NAME_CONTENT_MAP instead of rendered components
  - Processes source directives to embed actual file content

### Fixed
- **TOON content gap issue** - Previous TOON was only capturing 15-20% of documentation content
  - Root cause: dash-improve-my-llms extracts from rendered Dash components, losing directive context
  - Solution: Custom route processes raw markdown with full directive awareness
  - Previous TOON was 185% the size of llms.txt (27,669 chars vs 14,943 chars)
  - New TOON is 45.3% the size of llms.txt (6,965 chars vs 15,369 chars)

### Technical Details
- New module: `lib/toon_generator.py` (698 lines)
  - `generate_documentation_toon()` - Main entry point
  - `build_documentation_toon()` - TOON string builder
  - `extract_sections()` - Hierarchical section parser
  - `extract_directives()` - Directive extractor with options
  - `process_source_directive()` - File content reader
  - `process_exec_directive()` - Component metadata extractor
  - `compress_code()` - Smart code compression
  - `compress_section_content()` - Content summarization
  - `extract_tables()` / `extract_lists()` - Structure extractors

---

## [0.6.0] - 2025-12-13

### Added
- **Enhanced TOON Format v3.1** - Lossless semantic compression with 40-50% token reduction
  - Application context with related pages and multi-page awareness
  - Page purpose explanations with human-readable descriptions
  - Component breakdown with type distribution
  - Human-readable callback descriptions
  - Synthesized page summaries
  - Link categorization (internal vs external)

### Changed
- **Upgraded dash-improve-my-llms from v1.0.0 to v1.1.0**
  - Lossless semantic compression preserves all meaningful content
  - New content extraction: `extract_markdown_content()`, `parse_markdown_content()`
  - Smart compression: `compress_code_example()`, `compress_section_content()`
  - New helper functions: `_generate_page_summary()`, `_format_callback_description()`

### New TOONConfig Options
- `preserve_code_examples=True` - Include code snippets from markdown
- `preserve_headings=True` - Keep section structure
- `preserve_markdown=True` - Extract dcc.Markdown content
- `max_code_lines=30` - Max lines per code example
- `max_sections=20` - Max sections to include
- `max_content_items=100` - Increased from 20

### Documentation
- **Updated AI/LLM Integration Guide** with v1.1.0 TOON enhancements
  - Added design principle: lossless semantic compression
  - Updated token efficiency comparison table
  - Added 6 content gap examples (context, purpose, components, callbacks, summary, navigation)
  - Updated TOONConfig with new v1.1.0 options

### Improved
- Better content preservation in TOON format
- Optimal information density vs token reduction balance
- Enhanced developer experience with richer TOON output

---

## [0.5.0] - 2025-12-13

### Added
- **TOON Format Support** - Token-Oriented Object Notation for 50-60% fewer tokens
  - New `/llms.toon` endpoint for token-optimized LLM documentation
  - New `/architecture.toon` endpoint for token-optimized architecture
  - New `/<page>/llms.toon` per-page TOON format endpoints
  - TOON provides tabular arrays and explicit length markers for LLM validation
  - Ideal for API calls, large apps, and cost-conscious deployments

### Changed
- **Upgraded dash-improve-my-llms from v0.3.0 to v1.0.0**
  - Production-ready release with comprehensive test coverage (88 tests, 98% coverage)
  - New API exports: `TOONConfig`, `toon_encode`, `generate_llms_toon`, `generate_architecture_toon`
  - Zero-change migration: existing code works without modifications

### Documentation
- **Updated AI/LLM Integration Guide** with comprehensive TOON format documentation
  - Added TOON format section with benefits comparison table
  - Added example comparison (markdown vs TOON token usage)
  - Added TOONConfig configuration examples
  - Added programmatic TOON generation examples
  - Updated available routes table with new TOON endpoints
  - Updated key functions reference with new TOON imports

### Improved
- Better AI/LLM documentation organization
- Enhanced developer experience with new format options
- Cost optimization through token-efficient TOON format

---

## [0.4.0] - 2025-11-10

### Added
- **LLM Copy Button Directive** (`.. llms_copy::`)
  - New custom directive that adds a "Copy for llm 📋" button to documentation pages
  - Copies the page's `/llms.txt` URL to clipboard for easy AI assistant sharing
  - Users can paste the URL into ChatGPT, Claude, or other AI assistants for context-aware help
  - Features:
    - Automatic URL construction based on current page path
    - Visual feedback with "✓ Copied! ✓" confirmation
    - Fallback clipboard method for non-HTTPS contexts (HTTP development servers)
    - Works across all modern browsers
    - Tooltip: "Copy llms.txt URL for AI assistants"
  - Implementation:
    - Python directive: `lib/directives/llms_copy.py`
    - JavaScript handler: `assets/llms_copy.js`
    - Uses both modern Clipboard API and legacy `execCommand` fallback
    - Mutation observer for Dash-rendered content detection
  - Documentation updated in Custom Directives guide
  - Added to all 5 example documentation pages

## [0.3.0] - 2025-11-09

### Added - Documentation System
- **Comprehensive Getting Started Guide** (385+ lines)
  - Detailed directive options documentation (`:code: false`, `:defaultExpanded`, `:withExpandedButton`)
  - Interactive examples with best practices
  - File structure examples and patterns
- **Custom Directives Guide** (476 lines)
  - Complete documentation for all 4 directives (toc, exec, source, kwargs)
  - 3 live Python examples (button, counter, form validation)
- **Data Visualization Guide** (465+ lines)
  - 5 chart type examples with full implementations
  - Plotly template integration guide
  - Real-time updates and dashboard patterns
- **Interactive Components Guide** (569 lines)
  - 6 callback pattern examples
  - State management, pattern matching, chained callbacks
  - Loading states demonstration
- **AI/LLM Integration Guide** (577 lines)
  - Complete dash-improve-my-llms documentation
  - SEO optimization strategies
  - Bot management and privacy controls

### Added - Theme System
- **DMC Figure Templates Integration**
  - All Plotly charts now use `dmc.add_figure_templates()`
  - Theme-aware callbacks for 6 chart examples
  - Charts dynamically update with light/dark theme toggle
  - Proper background rendering in both themes
- **Code Block Theming**
  - Theme-aware CSS for markdown code blocks
  - Proper syntax highlighting in light and dark modes
  - Inline code and code block styling
- **Comprehensive Theme Configuration**
  - Professional typography hierarchy (h1-h6)
  - Systematic 4px-based spacing scale
  - 5-level shadow system
  - Consistent border radius system
  - Global component defaults via theme.components
  - Softer black (#1a1b1e) for better contrast

### Added - UI/UX Enhancements
- **Navigation Improvements**
  - Custom page ordering (Getting Started → Custom Directives → AI/LLM → Interactive → Visualization)
  - Better visual hierarchy
  - Organized documentation sections
- **Typography System**
  - Inter font family across application
  - Optimized line heights (md: 1.55 for body text)
  - Proper font sizes (16px base)
  - Font smoothing and text rendering optimization
- **Layout Refinements**
  - Better responsive breakpoints (md for navbar)
  - Improved spacing consistency
  - Enhanced mobile experience
  - Better heading spacing (1.5em top, 0.5em bottom)

### Added - Production Features
- **SEO-Ready HTML Template**
  - Comprehensive meta tags with developer guidance
  - Open Graph and Twitter Card configuration
  - Structured data (Schema.org) for Organization and SoftwareApplication
  - Analytics integration (Google Analytics ready to enable)
  - Favicon configuration with multiple formats
  - Performance optimization (preconnect hints)
  - Search engine verification placeholders
  - Enhanced noscript fallback with styled content
  - 297 lines of documentation and configuration

### Improved
- **15 Working Python Examples**
  - Button interactions, counters, form validation
  - 5 chart types (bar, line, scatter, realtime, dashboard)
  - Callback patterns and state management
  - All examples theme-aware and fully functional
- **Directive System**
  - Fixed kwargs directive to parse component specifications (e.g., `dmc.Button`)
  - Better error handling and fallbacks
  - Support for directive options
- **Code Quality**
  - Fixed JSON serialization error (removed lambda from theme styles)
  - Better import statements
  - Comprehensive inline comments
  - Fixed DMC 2.4.0 compatibility issues

### Changed
- **Better Performance**
  - Optimized theme switching
  - Smooth transitions
  - Better font loading
- **Documentation Organization**
  - Clear learning path
  - Progressive complexity
  - Better code examples

### Fixed
- Import errors in example files (missing dmc, State imports)
- DMC 2.4.0 compatibility (removed unsupported `type` prop from TextInput)
- JSON serialization error in theme configuration
- Heading ID generation with code blocks in markdown
- Theme persistence and switching
- Code block rendering in dark mode

## [0.2.0] - 2025-11-09

### Changed
- **BREAKING**: Migrated from Dash 2.5.0+ to Dash 3.2.0
- **BREAKING**: Migrated from dash-mantine-components 0.14.7 to 2.4.0
- **BREAKING**: Updated all Mantine packages from 7.14.1 to 8.3.6
- Updated Flask from 1.0.4+ to 3.1.2
- Updated Plotly from 5.0.0+ to 6.4.0
- Updated `app.run_server()` to `app.run()` (Dash 3.x standard)

### Removed
- **BREAKING**: Removed deprecated package imports:
  - `dash-html-components` (now part of main `dash` package)
  - `dash-core-components` (now part of main `dash` package)
  - `dash_table` (now part of main `dash` package)

### Fixed
- Replaced deprecated `NotificationProvider` with `NotificationContainer`
- Fixed Mantine version mismatch between package.json and DMC version
- Added node_modules to .gitignore

### Added
- Added package-lock.json for reproducible npm builds
- Comprehensive migration documentation (8 detailed guides)
- Project analysis and assessment documentation
- Persistent theme preference storage using localStorage
- Browser color scheme preference detection on first visit
- Smooth theme transitions without page flash
- AI/LLM & SEO Integration (dash-improve-my-llms v0.3.0)
  - Automatic llms.txt, page.json, architecture.txt generation
  - SEO-optimized sitemap.xml with intelligent priority
  - Bot management (blocks AI training, allows AI search)
  - Structured data for better search indexing
  - Privacy controls for sensitive pages

### Improved
- Better dependency management with cleaner requirements.txt
- Improved code organization with inline comments
- Enhanced theme management system
- Better performance with latest Dash and DMC versions

## [0.1.0] - 2024-11-30

### Added
- Initial release of Dash Documentation Boilerplate
- Markdown-driven documentation system
- Support for light and dark themes
- Responsive design for mobile and desktop
- Docker deployment support
- Interactive code examples with syntax highlighting
- Custom markdown directives:
  - `toc` - Table of contents generation
  - `exec` - Executable Python code blocks
  - `source` - Source code display with syntax highlighting
  - `kwargs` - Component props documentation
- AppShell layout with header, navbar, and responsive drawer
- Search functionality for navigation
- Theme toggle with icon indicators
- Integration with dash-mantine-components (DMC)
- Integration with python-frontmatter for metadata
- Custom CSS styling system
- Docker and docker-compose configuration

### Documentation
- README with getting started guide
- Project structure documentation
- Example documentation pages

---

## Version History Summary

| Version | Date | Dash | DMC | Mantine | Python | Features |
|---------|------|------|-----|---------|--------|----------|
| 1.0.0 | 2026-06-14 | 4.2.0 | 2.7.0 | 8.3.6 | 3.11+ | Pluggable backends (Flask/FastAPI/Quart), dash-improve-my-llms 2.0, TOON removed |
| 0.8.0 | 2025-12-14 | 3.2.0 | 2.4.0 | 8.3.6 | 3.11+ | TOON v3.3, tips/best practices/patterns/resources extraction |
| 0.7.0 | 2025-12-13 | 3.2.0 | 2.4.0 | 8.3.6 | 3.11+ | Custom TOON generator, documentation-aware TOON v3.2 |
| 0.6.0 | 2025-12-13 | 3.2.0 | 2.4.0 | 8.3.6 | 3.11+ | Enhanced TOON v3.1, dash-improve-my-llms v1.1.0 |
| 0.5.0 | 2025-12-13 | 3.2.0 | 2.4.0 | 8.3.6 | 3.11+ | TOON format, dash-improve-my-llms v1.0.0 |
| 0.4.0 | 2025-11-10 | 3.2.0 | 2.4.0 | 8.3.6 | 3.11+ | LLM Copy Button directive |
| 0.3.0 | 2025-11-09 | 3.2.0 | 2.4.0 | 8.3.6 | 3.11+ | Comprehensive docs, theme system, SEO |
| 0.2.0 | 2025-11-09 | 3.2.0 | 2.4.0 | 8.3.6 | 3.11+ | Migration to Dash 3.x, DMC 2.4.0, AI/LLM |
| 0.1.0 | 2024-11-30 | 2.5.0+ | 0.14.7 | 7.14.1 | 3.11+ | Initial release |

---

## Migration Guides

### Migrating to 1.0.0 from any 0.x

This is the major release that moves the boilerplate to Dash 4.x. See the
**Migration notes** under [1.0.0](#100---2026-06-14) for the full checklist.
In short:

1. **Backend:** defaults to Flask — no change required. For FastAPI/Quart,
   `pip install "dash[fastapi]"` (or `[quart]`) and set `DASH_BACKEND=fastapi`.
2. **AI/LLM prose:** add an `LLMS_DOC` string to each page module (or call
   `register_page_metadata(path, llms_doc=...)`); the 2.0 startup warning lists
   pages still missing prose.
3. **dash-improve-my-llms extra:** pick `[flask]` / `[fastapi]` / `[quart]` /
   `[all]` in `requirements.txt`.
4. **Removed APIs:** drop any TOON usage (`TOONConfig`, `toon_encode`,
   `generate_*_toon`), `/page.json`, `/architecture.txt`, and the now-no-op
   `mark_important()` / `mark_component_hidden()` calls — move emphasis into
   `LLMS_DOC` instead.

### Migrating to 0.6.0 from 0.5.0

**Zero changes required!** The upgrade is fully backwards compatible.

Key changes:
1. Update `dash-improve-my-llms` in requirements.txt to `>=1.1.0`
2. TOON output now includes richer, lossless semantic content automatically

Optional new TOONConfig options:
```python
from dash_improve_my_llms import TOONConfig

app._toon_config = TOONConfig(
    # New in v1.1.0:
    preserve_code_examples=True,   # Include code snippets
    preserve_headings=True,        # Keep section structure
    preserve_markdown=True,        # Extract dcc.Markdown content
    max_code_lines=30,             # Max lines per code example
    max_sections=20,               # Max sections to include
    max_content_items=100,         # Increased from 20
)
```

### Migrating to 0.5.0 from 0.4.0

**Zero changes required!** The upgrade is fully backwards compatible.

Key changes:
1. Update `dash-improve-my-llms` in requirements.txt to `>=1.0.0`
2. New TOON endpoints are automatically available:
   - `/llms.toon` - Token-optimized LLM docs
   - `/architecture.toon` - Token-optimized architecture
   - `/<page>/llms.toon` - Per-page TOON format

Optional new features:
```python
# Configure TOON output (optional)
from dash_improve_my_llms import TOONConfig

app._toon_config = TOONConfig(
    indent=2,
    delimiter=",",
    include_metadata=True
)

# Programmatic TOON encoding (optional)
from dash_improve_my_llms import toon_encode
toon_string = toon_encode({"key": "value"})
```

### Migrating to 0.3.0 from 0.2.0

Minor updates, mostly additive. Key changes:
1. Documentation content significantly expanded
2. Chart examples now use DMC figure templates
3. Enhanced SEO features in index.html
4. Better theme integration across all components

### Migrating to 0.2.0 from 0.1.0

Major breaking changes. See migration documentation:

- **Quick Start**: `MIGRATION_README.md`
- **Detailed Guide**: `claude.md`
- **Step-by-Step**: `MIGRATION_CHECKLIST.md`
- **Code Changes**: `CODE_CHANGES_SUMMARY.md`

Key changes to be aware of:
1. Update all imports from `dash_html_components` to `from dash import html`
2. Update all imports from `dash_core_components` to `from dash import dcc`
3. Replace `dmc.NotificationProvider()` with `dmc.NotificationContainer()`
4. Update custom components to use DMC 2.4.0 API
5. Check CSS for any Mantine 8 specific changes

---

## Support

- **Issues**: [GitHub Issues](https://github.com/pip-install-python/Dash-Documentation-Boilerplate/issues)
- **Discussions**: [GitHub Discussions](https://github.com/pip-install-python/Dash-Documentation-Boilerplate/discussions)
- **Dash Community**: [Plotly Community Forum](https://community.plotly.com/)

---

[unreleased]: https://github.com/pip-install-python/Dash-Documentation-Boilerplate/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/pip-install-python/Dash-Documentation-Boilerplate/compare/v0.8.0...v1.0.0
[0.8.0]: https://github.com/pip-install-python/Dash-Documentation-Boilerplate/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/pip-install-python/Dash-Documentation-Boilerplate/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/pip-install-python/Dash-Documentation-Boilerplate/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/pip-install-python/Dash-Documentation-Boilerplate/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/pip-install-python/Dash-Documentation-Boilerplate/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/pip-install-python/Dash-Documentation-Boilerplate/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/pip-install-python/Dash-Documentation-Boilerplate/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/pip-install-python/Dash-Documentation-Boilerplate/releases/tag/v0.1.0
