# Changelog

> Version history for the Pip Install Python documentation site (2plot.dev) — the docs framework, the component catalogue, and the site's own infrastructure.

**Site index:** [https://2plot.dev/llms.txt](https://2plot.dev/llms.txt) — every page on this site, as Markdown.  
**Network index:** [https://2plot.ai/llms.txt](https://2plot.ai/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://2plot.dev/sitemap.xml  


The timeline on this page is rendered from `CHANGELOG.md`, reproduced below.

---

All notable changes to Pip Install Python Documentation 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).

---

## [Unreleased]

### Added
- **A lock on nav links to pages that need an account** (kit 1.6.41, adopted
  from excalidraw). The visibility contract hid `hidden` pages and said nothing
  about `auth`, so an auth-tier component page was listed to an anonymous
  reader indistinguishably from a public one and clicking it landed on a
  sign-in card with no warning. 17 catalogue pages carry the lock today; the
  four public ones in the sidebar do not. Signage only — no gate changed.
  It appears in the sidebar as `fluent:lock-closed-16-regular` inside a
  `dmc.Tooltip` ("Sign in required"): DMC 2.8's `Anchor` rejects `title=` with
  a TypeError at app construction, and `DashIconify` takes no aria-*, so the
  Tooltip's label is the accessible text rather than decoration. Both search
  Selects mark the same pages with a trailing lock glyph instead — a DMC Select
  option is `{label, value}` with a string label and cannot carry a component.
  The tier comes from `lib.page_visibility.nav_lock_label`, which resolves
  through `get_visibility` — the same merged resolver the gate reads, so a
  stored control-board override beats frontmatter here exactly as it does at
  the gate. Reading frontmatter directly would have mislabelled `Leaflet2` on
  2026-09-08, when its file said public and the stored override said auth.
  **The `admin` label is unreachable on this host**, and deliberately so: the
  admin boards are not registered through `register_default`, so they resolve
  to the `get_settings` fallback rather than to an `admin` tier. They are kept
  from non-owners by `render_admin_section` — a callback on `clerk_enabled()`
  / `is_admin_user()` — and the tests assert their absence through that
  mechanism. A tier-based admin check here would pass while testing nothing.
  Known limit, recorded rather than fixed: `run.py` assigns `app.layout` as a
  value at import, so the nav — this signage and the pre-existing `hidden`
  filter alike — resolves at boot. A control-board flip changes the gate
  immediately and the signage after a restart. The gate is never stale; only
  the label is. Network-wide item for a later release.
- **`tests/test_nav_lock_signage.py`** — every auth page locked and no public
  page locked, in both nav builds; both Selects marking the same set as the
  sidebar; an injected override proving the label follows the override and not
  the file; admin absent for anonymous and stranger and present for the owner;
  `hidden` absent for everyone. Each sweep asserts its corpus is non-empty
  first, since an absence proves nothing about a set that was empty.
- **A `LICENSE` file — the first this repo has had** (owner decision). The
  site's **source is proprietary**, © 2026 Pip Install Python LLC, all rights
  reserved; the **documentation content is CC BY 4.0**, code examples
  explicitly inside that grant; the **`dash-*` packages keep their own
  licences**, which travel with each package and govern it.
  Until now this was the fleet's only host with no licence file, and
  `pages/home.md` declared the site "MIT License" while linking to
  `Dash-Documentation-Boilerplate/blob/main/LICENSE` — the TEMPLATE's file, in
  another repository. Because `/llms.txt` serves home.md's text, that wrong
  line reached every agent that fetched the machine lane, not just readers of
  the home page.
  The file names all three tiers rather than only the one it governs: a reader
  who opens `LICENSE` and stops must not conclude the documentation is closed
  too.
- **`tests/test_licence_statement.py`** — the statement lives in three files
  (`LICENSE`, `pages/home.md`, `/terms`) with no reason to be edited together,
  which is precisely how the last one went stale. Each must name each tier;
  the old MIT claim and the template link are pinned as absent; and the
  machine lane is checked through `/llms.txt` itself, since that is the
  channel the wrong line travelled down.

### Changed
- **`tests/test_navbar_order.py` unwraps the lock.** It unpacked every sidebar
  link as a bare `Anchor`; an auth link is now a `Tooltip` around one, whose
  `Group` carries a trailing icon. The helpers see through both rather than the
  signage being reshaped to keep the old assertions compiling.

- **`/terms` gains a three-tier Licensing section**, replacing the
  packages-only note. It keeps the code examples explicitly inside the CC BY
  grant — documentation whose examples cannot be used is not documentation —
  and states plainly that viewing the site's source where it is visible does
  not license reuse.

- **The owner-review notice is down on `/terms` and `/privacy`**, from both
  lanes together — the machine document is the same string as the page, so the
  two can never say different things about the same practice. The owner has
  read the prose.
- **`/privacy` no longer narrates the retired practice.** The dated paragraph
  describing the old IP storage and the ip-api.com lookup is removed at the
  owner's request; the page states current practice only, and the record of
  what changed lives in this changelog and the git history. Pinned so the page
  cannot describe a retired practice in either direction — not as current
  (false) and not as history (unwanted).
- **The arrived Cloudflare geo headers are logged ON CHANGE, not once per
  boot**, and the line now names the request path. A boot-only line made a
  dashboard fix invisible until the next redeploy, which is backwards for a
  setting being toggled right now: this host's first production boot logged
  `cf-ipcountry (no cf-ipcity …)` hours after the zone's managed transform was
  enabled, and answering "which request was that?" meant reasoning about
  `skip_paths` and the internal-UA drop from memory. Now the next request after
  a dashboard fix says so, with no deploy — and a regression surfaces the same
  way. Bounded by construction: the set has five members. Pinned at 37 requests
  producing exactly 3 lines.
- **`/healthz` declares its `backend`.** `DIVERGENCES.md` 17 recorded the whole
  key set as a deliberate divergence on the grounds that "one Flask lane here
  means one key" — which does not follow. A single-backend host still has to
  say WHICH backend, because that is what makes a fleet payload comparable, and
  a host that declares nothing is indistinguishable from one whose declaration
  went stale. Only *adding* `app` was ever a divergence; the entry is corrected
  in place (struck, not deleted) and the payload now carries `backend: "flask"`
  alongside the fleet keys rather than instead of them.

### Security
- **`/api/agent-key/verify` answered `allow` for any key when the caller sent
  an unrecognised tier.** Measured on the wire by the ops seat and reproduced
  through this route with a valid signature: `tier` defaulted to `"public"`
  when absent, and `most_restrictive` **skipped** values it did not recognise,
  so `tier="ops"` — or `""`, or nothing at all — collapsed to `"public"` and
  the route returned `allow` **without ever reading the key**. A bogus key, a
  literal `"x"` and an empty string all came back `allow`, including for an
  admin path.
  `tier` is now REQUIRED and must be in `TIER_ORDER`; anything else is
  rejected with **400** and never normalised. `most_restrictive`'s all-unknown
  fallback returns `"hidden"` rather than `"public"`, which is what its own
  docstring has said since it was written — the last line contradicted the
  paragraph above it. With the guard in place the route cannot reach that
  fallback, so it is defence in depth; a legitimately public path is
  unaffected because it sends `"public"`, a known tier.
  **Reachability, so this is neither over- nor under-read:** the route is
  HMAC-signed and fails closed (503 unconfigured, 401 unsigned), so it was
  never anonymously reachable. The boundary it defeated is
  satellite-to-satellite — one shared secret, and `authenticate_caller`'s own
  docstring notes any satellite can claim to be any other. Under the fleet's
  decision (d) no host gates content on a verify verdict, so nothing
  downstream was trusting the answer.
  Pinned by `tests/test_verify_lane.py`: one test per row of the measured
  table, the four bypass rows asserting 400 and the three already-correct rows
  asserting they still return `gated` — a fix that tightened the good path
  into uselessness would look identical on the bypass rows alone. Plus a
  source pin that the `or "public"` default cannot return, since restoring it
  would pass every verdict test (the guard would never see an empty string).

### Removed
- **The visitor tracker no longer stores IP addresses, and no longer calls a
  third party** (owner decision — "I don't think we are gaining much from
  this"). Four things went, together:
  - the **raw client IP** written to the visit row on the first request of
    every session. It now takes `ANALYTICS_KEEP_CLIENT_IP=1` — the same switch
    the read table already honoured — and it is off.
  - the **ip-api.com round trip** on the request path. Location comes from the
    Cloudflare headers already attached to the request: `CF-IPCountry` always,
    plus city, region and coordinates where the zone's managed transform
    supplies them. The reader stores whatever arrives and degrades to country
    alone, so an edited transform rule cannot silently produce rows that read
    like "Cloudflare could not resolve this visitor".
  - the **unkeyed hash**. The session key was `md5(ip:ua)`; over an input
    space that small an unkeyed digest of an IP is closer to storing the IP
    than to protecting it. It is HMAC-SHA256 now, keyed from
    `ANALYTICS_HASH_KEY` or a fresh per-boot random key.
  - **`backfill_locations.py`**, which rewrote historical rows by feeding
    stored IPs to ip-api.com — both retired practices in one file.
- **The dev geo fixture.** `get_geolocation` had a localhost branch that dealt
  out entries from a hardcoded ten-city list, seeded by the session id, "to
  make development testing more realistic". Once written, those rows were
  indistinguishable from measurements — this checkout's own file holds 23 of
  them across 8 invented cities, every one `127.0.0.1`.

### Fixed
- **The tracker was recording the proxy, not the visitor.** `run.py` passed
  `request.remote_addr` alone, which behind Cloudflare is the **edge**. So
  every visitor shared an address: the session key grouped strangers together,
  the classifier judged `verified` against the proxy, and the "approximate
  city-level location" resolved a datacenter. `lib/traffic_reporter` has
  preferred `cf-connecting-ip` since it was written and says why in its own
  comment; this tracker never got the same treatment. `run.py` now hands over
  `dict(request.headers)` and `client_ip()` reads
  `cf-connecting-ip` → first hop of `x-forwarded-for` → `remote_addr`.
  Consequence worth stating plainly: **visitor counts, sessions and geography
  on this host were the edge's, and are only now the visitors'.**
- **`/privacy` rewritten from the new code**, the same way the original was
  written from the old. It carries a dated note saying what changed and that
  the location it used to report was a datacenter's — a page that quietly
  improved would leave every earlier reader believing something false. The
  owner-review notice stays up until the owner reads the prose.

### Added
- **A "Legal" sidebar section, driven by the page registry** (owner request).
  `/terms` and `/privacy` were landing in the **Components** accordion simply
  by existing — "everything not excluded is a component" was the only rule the
  navbar had, so a page could not be anywhere else without a hand-placed link.
  Each page now declares `category="Legal"` in its own `register_page` call and
  `components/navbar.py` reads it from the registry, ordering sections from one
  `REGISTRY_NAV_SECTIONS` tuple that BOTH the desktop accordion and the mobile
  drawer iterate — so the two navs cannot disagree about what sections exist,
  which is exactly how the drawer once shipped an Analytics section the desktop
  nav had disabled. Registry key: **`category`**, value **`"Legal"`**; a page
  that declares nothing is a Component, which is what every `/pip/` page is.
  Pinned six ways, including that `components/navbar.py` names neither legal
  path in CODE (comments excluded — the grep must fail on a hand-placed link,
  not on the documentation) and that both navs render the section.
  Mutation-checked: adding a hand-placed link turns the registry pin red.

### Fixed
- **`/terms` and `/privacy` were soft 404s linked from every page.** Neither
  was a page or a route, and Dash answers 200 for any path — so both returned
  HTTP 200 with the site-generic `<title>` and "404 - Page Not Found" in the
  body, while the footer pointed two internal links at them from every page
  the site serves. This is the same defect class an outside SEO review found
  on `/pip/*` (see `tests/test_soft_404s.py`), arriving through the chrome
  instead of through a retired URL, and worse: an unknown `/pip/` path has to
  be guessed at, while these were advertised site-wide.
  Both are real pages now. The prose is written FROM THE CODE that implements
  it — what the ledger row actually contains, that an IP is stored and sent to
  ip-api.com on the first request of a session, the 3-day retention, that
  `2plot-internal` traffic is counted nowhere, that the hourly hub rollup
  carries no IPs or session ids, and which third parties hold what. Each page
  keeps ONE markdown string and renders it for the browser AND hands it to the
  machine lane as its `llms_doc`, so the policy a crawler reads and the policy
  a person reads cannot drift apart. Both carry a visible notice that the
  owner's legal review is still pending.
- **The header's GitHub icon pointed at the wrong repository.** It linked to
  `Dash-Documentation-Boilerplate` — the template this site is forked FROM —
  so the one control offering "the source" handed every reader somebody else's
  tree. Same fix for `templates/index.html`'s `llms-github-repo` meta, which
  named the GitHub *profile* rather than a repository.
- **The four footer icon links had no accessible name.** GitHub, Discord,
  YouTube and the mailto announced as a bare "link" to a screen reader and as
  nothing at all on hover. `tests/test_a11y_labels.py` existed and passed
  throughout, because it only ever built the HEADER — it now builds the app
  shell too, and `components/appshell.py:_footer_icon` takes its label
  positionally, mirroring `create_link`'s guard, so the next footer icon
  cannot be added without naming it.
- **The © year was hard-coded to 2025.** Computed from `date.today()` now.
- **The mobile drawer showed an "Analytics" nav section the desktop nav had
  commented out**, so the two navigations disagreed about what this site
  offers. Commented out in the drawer to match; whichever way the owner wants
  it, they must move together, and the comment says so.

### Added
- **The footer gains Discord** and drops its Changelog link, per the fleet
  footer shape — the sidebar's Changelog link is the one.
- **`tests/test_soft_404s.py` pins the class, not just the two URLs**: every
  internal link in the app shell must resolve to a registered page.
  Registration is the check rather than a request, because a request cannot
  tell the difference — Dash answers 200 for any path and lets the client-side
  router decide, which is exactly how two dead links survived in the footer of
  every page for months. Mutation-checked: pointing a footer link at an
  unserved path turns it red.

### Added (release road)
- **`.github/workflows/cd.yml` — this host joins the fleet's release road**
  (owner decision 0ac). Until now a push to `main` WAS the deploy: Render
  autoDeployed it and `ci.yml`'s `verify-live` job certified the result
  afterwards, so a red build served real traffic for as long as the matrix
  took to fail. That race is the reason for the road, and it is the last
  host on it.
  Now `cd.yml` calls the whole `ci.yml` matrix through `workflow_call` and,
  only on green, fast-forwards a `release` branch — unforced, from a
  `fetch-depth: 0` checkout, with `contents: write` on that one job and the
  workflow itself left `read`. A push to `main` is a CANDIDATE; `main` ahead
  of `release` means an uncertified push is pending, never drift.
  `verify-live` moved in as the gated `verify` job, unchanged in substance
  and stronger in gating: it runs only on `needs.deploy.result == 'success'`
  (the old fleet condition admitted `failure`, so a failed promote still
  verified — green — against the PREVIOUS build) and re-asserts
  `/healthz build == github.sha` itself before running the battery.
  Three guards this host needed specifically: a `git ls-remote --heads
  origin 'release/*'` check before the push, because a branch named
  `release/anything` makes `refs/heads/release` an impossible ref and the
  raw failure names the lock rather than the cause; a supersession
  classifier in the build-anchor wait, so a live build that is a DESCENDANT
  of this run's sha fails fast instead of turning a race into a 25-minute
  timeout; and `PROBE_UA` on every `/healthz` curl, because a bare probe
  would put a hundred CI hits per deploy into the analytics and — at the
  2.8.0 floor, where an unrecognised UA is a crawler — make this workflow
  the busiest vendor on the site's own `/admin/traffic`.
  OWNER STEPS, which the pipeline cannot take: the repository is private, so
  Settings → Actions → General → Workflow permissions must be **Read and
  write** (a job-level grant cannot exceed it); and there is no `render.yaml`
  here, so the **dashboard's Branch field** is what actually points Render at
  `release` — until that click, this host still deploys `main`, and the
  pipeline is correct either way.
- **`tests/test_cd_promotes_release.py`** — twelve structural pins on the
  road, the part a fork drifts on silently. Three are inverted for this tree
  and say why: render.yaml must stay ABSENT (the switch is a dashboard
  click, not a file), verify must run `network_smoke.py` and must NOT run
  `smoke_live.py` (divergence 8, two-sided so a future "restoring" sync
  cannot quietly hand this host two batteries), and the hook pin asserts
  there was never a hook rather than that one was removed. The posture-fence
  pin SKIPS until the first green promote — `deploy:` absent reads as main,
  and main is still what this host deploys.

### Changed
- **`ci.yml` no longer triggers on a push to `main`**, and declares
  `workflow_call` instead. cd.yml calls it, so a main push runs the matrix
  exactly once — inside the run that promotes. Leaving `main` in both would
  run it twice and put both copies in the same `ci-refs/heads/main`
  concurrency group, where `cancel-in-progress: true` lets one cancel the
  other and take the promote's gate down with it. Pull requests targeting
  main are unaffected. This is the FLEET SHAPE, not a divergence — the
  template's ci.yml is PR-trigger + workflow_call and a fork main-push
  produces exactly one run; clerkhook, the build this was modelled on, is
  the outlier (ops seat measurement, 2026-09-01). Recorded as entry 15
  anyway, because the reason is invisible from the file and this host
  builds a Docker image in that matrix, so the duplicate would be expensive
  as well as risky.

### Removed
- **`scripts/smoke_live.py`, restoring `DIVERGENCES.md` 8.** The F3b fan-out
  landed it in `bcabdef` from a PR built against the SUPERSEDED 1.6.22-1.6.28
  spec — the file was pulled out of the sync-verbatim block at template 1.6.29
  precisely because a byte copy detonates on forks whose own tests stub its
  `fetch`, but that PR had sat open since Aug 29 and the fence and divergence
  checks run at PR-BUILD time, so nothing expired it. (Root-caused by the ops
  seat from this repo's git; the machinery fix — supersession fast-fail on
  fan-out PRs — is theirs, and this is the second instance of the class.)
  It was never wired here: CI invokes `scripts/network_smoke.py` alone, this
  fork's single battery, run identically against the booted container and
  against production so a failure reads the same in both places. The copy
  still carried the template's own host in its usage banner
  (`https://boilerplate.2plot.dev`), which is what an unadapted byte copy
  looks like. Both `tests/test_smoke_live_ssl.py` and
  `scripts/network_smoke.py`'s header already said in prose that this repo has
  no smoke_live.py; the deletion makes them true again.

### Added
- **`/admin/traffic` — this host's own crawler ledger** (sync spec 1.6.34 item
  12e). Vendor x day, vendor -> tier, and the top paths each vendor pulled,
  next to the same day's v3 headline numbers, so "does ClaudeBot actually get
  the corpus here?" is answerable from the host before the hub folds anything.
  Behind the control board's exact gate, hidden from every machine surface
  (`mark_hidden`). Plain tables and one day picker, no charts and no interval
  callback — fleet fact 18: a 14 x 40 table of strings is about a millisecond
  and five charts were ten seconds.
  The window is 14 days; this host's ledger prunes at 3 and lives on an
  ephemeral filesystem, so the older columns are empty by construction. The
  page says that in its footnote rather than letting a blank column read as
  "no crawler came".
- **The read table.** `dash-improve-my-llms` 2.8.0 emits one structured event
  per corpus document it serves — tier, lane, vendor, verification, policy,
  verdict, status, bytes — and does no I/O with it; until now this app threw
  every one away and re-derived a worse version of the same facts from the
  User-Agent. `lib/traffic_reporter.record_read` keeps the row in the JSONL
  ledger the hourly rollup is already rebuilt from: a SECOND table, same file,
  same lock, same 3-day prune, tagged `kind: "read"`. `client_ip` is dropped
  unless `ANALYTICS_KEEP_CLIENT_IP=1`.
- **Rollup v4, additive.** `build_rollup` gains `vendors[]` (one row per
  vendor x verification x policy, with a seven-key `tiers` breakdown and a
  byte total) and `reads`, present only on a day that has read events. Every
  v3 key is byte-identical and the reads are JOINED, never summed into
  `human_hits` / `bot_hits` / `pages` — the request hook already ledgers its
  own row for the same request. The reporter is unchanged: it POSTs whatever
  the rollup returns.

### Changed
- **There is ONE classifier, and it is the package's.**
  `lib/analytics_tracker.py` carried its own User-Agent lists for a year.
  They filed **ClaudeBot under "search"** — it is Anthropic's *training*
  crawler, which the package's registry has said since 2.3.3 and this repo's
  own `run.py` comment says six lines from where the list ignored it — still
  named the retired `anthropic-ai` / `claude-web` tokens, and counted every
  UA-less or library client (`httpx`, `Go-http-client`, `node-fetch`) as a
  person. Every one of those numbers went to the hub. `is_bot` and
  `detect_bot_type` keep their names and signatures and delegate to
  `classify()`; the module now ends with zero User-Agent strings and
  `tests/test_analytics_classifier.py` greps it so a list cannot come back
  quietly. A token the registry lacks is a pushback to the package seat.
- **REPORTING CONSEQUENCE, deliberate: `human_hits` DROPS and `bot_hits`
  RISES from the day this ships.** At the 2.8.0 floor an absent User-Agent is
  on the crawler lane (no browser sends none), so UA-less and library clients
  move from human to crawler. That is the number becoming true, not a
  regression — the hub's day-over-day view will show the step.
- **Dependency floor `dash-improve-my-llms` >= 2.8.0**, moved in all four
  encodings it lives in: `requirements.txt` (which is also the Docker cache
  bust — a `>=` floor that already resolves under a cached layer never
  re-resolves), `run.py`'s `LLMS_PKG_FLOOR` boot refusal, the CI assert
  against the built image, and the installed environment.
  `tests/test_dependency_floor.py` compares all four.
- **The test client declares its lane.** With an absent UA now meaning
  crawler, a bare `client.get("/")` receives the crawler document — no
  manifest link, no `theme-color`, no `og:image:type` — and eight tests in
  `test_social_card.py` / `test_page_structure.py` went red saying nothing
  about heads. `tests/conftest.py` now sends `BROWSER_UA` unless the caller
  names a User-Agent, restoring the pre-2.8 lane for every existing test and
  leaving the crawler lane to the tests that ask for it by name. (The fleet
  hit this in `tests/test_proxy_scheme.py`, which this repo does not carry.)
- **The internal-UA drop binds the read table too.** The network's own probes
  fetch `/llms.txt`, so `record_read` drops `INTERNAL_UA_TOKEN` events first,
  exactly as `record_hit` does — otherwise the fleet's machinery would be the
  busiest vendor on this host's own board. Caught by
  `tests/test_internal_traffic.py`; the template's `record_read` has no
  equivalent drop and this is filed as a pushback to the ops seat.
- **`/api/page-chat-stream` is sign-in only** (owner decision 2026-08-26,
  resolving `DIVERGENCES.md` §11). The endpoint calls a paid model on every
  request and had neither auth nor a rate limit; it now carries
  `@require_signed_in`, answering **401** `{"error": "sign-in required"}` to a
  signed-out caller. There is no anonymous lane and no metered tier — the
  owner chose the simple boundary over a quota.
  A **decorator** rather than a first-line check on purpose: it runs before
  the view body is on the stack, so no later edit *inside* the view can end up
  ahead of the gate. When Clerk is not configured the answer is **503**
  `{"error": "authentication unavailable"}` — deliberately the inverse of the
  page gate, which degrades *open* so a missing dev credential cannot brick
  the docs. On an endpoint that bills, "no auth configured" reading as "no
  auth required" is how an unmetered paid lane gets left open. Consequence
  worth knowing: **local dev without Clerk keys now gets 503 from the chat**,
  and the UI shows the sign-in prompt rather than a chat that would die at the
  first token.
  The page-chat UI mirrors the check at click time and offers the existing
  Clerk sign-in flow (`assets/auth_gate.js` matches the new button alongside
  the gate card's own), because the browser cannot see the 401: the transport
  is `EventSource`, whose `onerror` carries no status code and no body, so
  without the mirror a signed-out click renders as "Connection error. Please
  try again." Pinned by `tests/test_page_chat_auth.py` and, on the wire, by
  the battery's `page_chat_requires_sign_in` — which accepts 401 *or* 503,
  since the secretless CI container legitimately has no Clerk.
- **One fleet Python: 3.14, in every place this repo encodes one.** The image
  moves `FROM python:3.11.8-slim` to `python:3.14-slim` and both CI
  `setup-python` legs move 3.11 → 3.14. Two defects, not one. The first is the
  *patch pin*: `3.11.8-slim` resolves to a frozen build that never receives
  3.11.x security releases — it reads as the most careful form of pinning and
  is the least secure one, which is why review kept passing it. The minor tag
  tracks the patch stream through the registry. The second is *silent
  disagreement between encodings*: the image said 3.11.8, CI said 3.11, and the
  local virtualenv this repo is developed in is 3.12 — each locally consistent,
  nothing comparing them. `tests/test_python_version.py` now compares them.
  Decided by evidence rather than preference: the full suite runs green on
  3.14.4 with dash 4.4.1, dash-improve-my-llms 2.7.1, gunicorn 26.2.0 and
  cryptography 50.0.1, every wheel present — no package forced a stop at 3.13.
- **The `dash-improve-my-llms` floor moves to `>=2.7.1`**, in every encoding at
  once: `requirements.txt`, a new `LLMS_PKG_FLOOR` in `run.py`, and the assert
  CI runs against the built image. 2.7.0 dedups the prerender H1 — every page
  here was serving two to a generic client, the injected header plus the doc
  body's own — plus the home footer's doubled `/llms.txt` link, and hardens the
  idempotency probe so a page that merely *mentions* the prerender marker no
  longer loses its prerender. 2.7.1 adds the llms.txt v2 discovery relations on
  both lanes with `Link` headers, the `Accept: text/plain` ramp, and the
  representation digest. The requirements line changing IS the Docker cache
  bust: the dependency layer is keyed on that file's bytes, so a `>=` floor can
  never pull a newer release through a cache hit.
- **A stale environment now refuses to boot.** `LLMS_PKG_FLOOR` is checked at
  import and names `sys.executable` when it fails, which closes the other half
  of the same trap — an IDE run configuration pointed at another project's
  virtualenv, or a cache-hit image, previously served visibly older behaviour
  while nothing looked wrong. `ALLOW_STALE_DEPS=1` downgrades it to a warning.
- **The production image drops its Node layer.** It apt-installed `nodejs` and
  `npm` and ran `npm install` against a `package.json` that is
  dash-mantine-components' component-build toolchain, inherited through the
  fork lineage — no webpack config, no `src/ts`, no CI build job, no served
  node asset. `curl` replaces it, for a new `HEALTHCHECK` that probes the same
  `/healthz` the hub sweeps hourly.
- **Dependabot stops proposing pip version updates entirely**, and
  `.github/dependabot.yml` becomes the template's file verbatim (adding the
  `docker` ecosystem this repo never had). Earlier in this same cycle the pip
  updates were merely *scoped* to the network packages, on the reasoning that
  floors encode minimum-compatibility knowledge — `gunicorn>=23` IS the
  request-smuggling CVE fact — that a raise erases while adding nothing. That
  did not go far enough: on a `>=` requirement dependabot can only propose a
  floor raise, so the surviving `dash-network` group structurally produced the
  exact PR class the allow-list existed to suppress (fleet-wide, 18 of 38 open
  dependabot PRs were pip floor-raises). Floors now move only through sync
  specs, every encoding at once; drift detection — the group's stated purpose —
  is already answered on the wire by the contract battery reading healthz
  `dash_version`. Security updates ride a separate channel and are unaffected.
- **The post-deploy wait is sized for the slowest build, not the median.** A
  floor bump busts the dependency cache by design, so the pushes that matter
  most are also the slow ones; the build-match window is 25 minutes and the job
  timeout 60.

### Added
- **The `.claude/` development kit ships.** `.claude/` was blanket-ignored
  here, so this repo inherited none of the network's behavioral contract and
  could contribute none of it back. `.gitignore` now carries the template's
  allow-list (`.claude/*` plus three re-includes) instead: `.claude/CLAUDE.md`
  — a quick reference for this tree with the fleet's contract and verification
  traps verbatim — `.claude/settings.json`, and `.claude/skills/`, which now
  holds the three network skills (`wire-verify`, `sync-template`, `report`)
  next to this repo's own three. Everything else under `.claude/` stays local
  and is now *structurally* uncommittable, which matters more here than
  elsewhere: `support_files/` carries vendored sdists and a subdomain playbook.
  `tests/test_claude_kit.py` pins all of it, including that the settings point
  at *this* fork's host rather than the template's.
- **`DIVERGENCES.md`** — the twelve deliberate differences between this repo
  and the template, each with its reason, plus the machine-readable
  `byte-owned` fence the fleet fan-out reads before it overwrites anything.
  The fence is empty by audited decision: all six paths the consumed sync
  specs list are byte-verbatim template copies here and are meant to stay
  mechanically updatable. Session working documents
  (`X402-SYNC-REPORT.md`, `HANDOFF-*.md`, `KICKOFF-*.md`) move from
  `.git/info/exclude` into `.gitignore` — a per-clone exclude protects one
  checkout and no fork.
- **`tests/test_auth_demos.py`** — every entry in `lib/auth_demos.py` must be
  a registered page here whose module imports and exposes a `component`.
  `build_demo` swallows import errors by design and its warning only fires
  when that card renders, so a dead entry is otherwise perfectly silent. All
  17 entries resolve.
- **CI asserts Docker's own health verdict.** The existing boot step proves
  the *app* answers; it cannot prove the `HEALTHCHECK` instruction works, and
  a broken probe would ship silently while everything stayed green. The new
  step polls `docker inspect` to `healthy` and treats `none` as a failure —
  no HEALTHCHECK means the container is opaque to Render.
- **`tests/test_smoke_live_ssl.py`** — a source pin that every `urlopen` in
  `scripts/network_smoke.py` carries `context=SSL_CONTEXT`, which the battery
  now builds from certifi when present and the default trust store otherwise.
  Without it, macOS dies in the TLS handshake and this battery reports it as
  every check failing at once — i.e. as production being down. CI is Linux and
  is blind to the defect by construction; nothing that mocks the network can
  see it either.
- **The unhooked deploy says so.** `verify-live` opens with a `::warning` that
  this repo has no deploy hook and no `render.yaml`, so a push reaches
  production only if the service's autoDeploy fires. A sibling repo lost a run
  to exactly this: nothing deployed, and the build-match wait timed out on a
  deploy nobody had started.
- **`/healthz` carries `python`** — the running interpreter, read from the
  process. It is the *only* truth about production's Python here: this fork has
  no `render.yaml`, so the Render service is dashboard-configured and the repo
  cannot state what it runs. If Render serves this app as a native Python
  service rather than from the Dockerfile, then CI has been testing an image
  production never runs, and no other surface could tell.
- **`python_matches_declared` joins the smoke battery**, armed in both seats.
  It compares the served interpreter's minor against the Dockerfile's `FROM`.
  Against the CI container that is near-tautological — the check checking
  itself, and cheap; against production it is the only thing that can see the
  disagreement above. A red here is the check working, and its message names
  the owner action (set the Render service's Python version) rather than
  leaving a bare mismatch.
- **`tests/test_python_version.py`** (7 pins, 1 dormant) — the image declares
  the fleet minor, the tag pins a minor and never a patch, every CI
  `setup-python` leg agrees, the fingerprint step asserts the same version from
  inside the built image, `/healthz` reports the interpreter actually running,
  and the battery reads its declaration from the Dockerfile. The dormant one
  arms itself the moment a `render.yaml` appears.
- **`/healthz` reports the geo guardrail's live state** — `{configured,
  denied, resolved}`, counts and flags only, never the denylist's country
  codes. It exists because nothing outside the boot log and the credentialed
  operator panel could answer "is the denylist actually in force?": geo can be
  fully configured and still never match if the edge is not forwarding a
  country header, which now reads as `resolved: unknown` in one line. The
  payload moves to `lib/health.py`. The key is present only on
  dash-improve-my-llms >= 2.7.0, which makes its ABSENCE in production the tell
  that a floor bump never reached the image; the smoke battery gates on it.
- **A `.dockerignore`.** Docker does not read `.gitignore`, so `COPY . .` was
  taking whatever sat in the working tree — on a developer machine that meant
  baking pickled Clerk identities, issued agent keys and the local ledgers into
  the image. Render and CI build from clean checkouts and were never affected.

### Fixed
- **`pip-audit` no longer fails CI on a network blip.** Run #115 went red on
  `ReadTimeout: HTTPSConnectionPool(host='pypi.org', port=443)` — pip-audit
  asks PyPI about each requirement in series on a 15-second socket timeout, so
  one slow response ends the job with nothing wrong in the tree (the identical
  `requirements.txt` had audited clean on the same interpreter twenty minutes
  earlier, in #111). An *enforcing* audit that goes red on the service trains
  people to re-run it without reading it, which costs more than the flake. Now
  three attempts with `--timeout 60`, and the failure message distinguishes a
  timeout from an advisory. A real finding still fails — it just fails three
  times first.
- **The deploy anchor could pass on a failed poll — and did, under
  observation.** `verify-live`'s build-wait compared with
  `case "$GITHUB_SHA" in "$live"*)`. With `$live` empty the pattern is `*`,
  which matches anything, so a single unreachable `/healthz` read as "the
  deployed build matches": the step exits 0 having proven nothing and the
  battery below verifies the PREVIOUS release, with every downstream signal
  still green. And the poll fails for the most ordinary reason there is —
  Render 502s while it swaps instances, which is exactly the window the step
  exists to sit through. Caught by running a watcher with the identical
  construct during this round's own deploy: it announced a match against a
  502 at 80s while production was still on the old commit. The comparison is
  now guarded on a non-empty reading, and `tests/test_python_version.py` pins
  both the guard and — by executing it — the glob behaviour that makes the
  guard necessary.
- **CI run #107 (`575903e`) was red, and the ruling is: a real defect in the
  commit — not a deploy failure.** Two jobs failed on ONE root cause. The
  floor-round edit rewrote an assert message in the image-fingerprint step with
  double quotes: `f"expected >=2.7.1 (single H1 + llms.txt v2 relations)…"`.
  That whole Python program lives inside a shell double-quoted string
  (`python -c "…"`), so the new quote *closed the shell string* and the shell
  parsed the rest as its own — dying on `syntax error near unexpected token
  '('` at script line 11, exit 2. Every sibling assert happened to use single
  quotes, so the form had survived on convention alone. `actionlint` caught it
  correctly and independently (shellcheck SC1036/SC1088 at 11:47) and was the
  workflow's second red; it had passed on the author's machine only because
  shellcheck is not installed there. The step now feeds Python on stdin through
  a *quoted* heredoc (`<<'PY'`), which removes the shell from the loop entirely
  and makes the quoting style inside irrelevant — the offending double-quoted
  f-string is kept verbatim as the proof.
  Two consequences worth recording. `verify-live` never ran: it `needs: [test,
  build]`, so it was SKIPPED, not timed out — the 25-minute build wait added in
  the same commit is untested by this run, and production was already serving
  `575903e` (verified on the wire) throughout. And the same defect turned the
  dependabot PR runs (#108, #109) red, since those branches carry main.
- **The tutorial page taught broken syntax to agents.** `.. source::` and
  `.. sourcetabs::` were expanded by raw regex, which cannot see fenced code
  blocks — so the directives `/pip/dash_documentation_boilerplate` uses to
  *teach* the syntax were being executed. The placeholder path they name does
  not exist, so the machine lane of that page served
  `<!-- Error: File not found: ... -->` where the example belongs. Had the path
  existed, the injected fence would have closed the open one early and the
  inlined file would have rendered as markdown, every `# comment` becoming an
  `<h1>`. Both expanders and the source-file listing are fence-aware now.
- **Every page served two `<h1>` elements to crawlers,** and they were
  identical across the whole site: `templates/index.html`'s `<noscript>` block
  carried `<h1>2plot.dev Documentation</h1>`, and crawlers run no JavaScript
  and *parse* noscript. Demoted to `h2`, with the hierarchy below it shifted to
  match.
- **`/changelog` served its title twice** — the page reproduces `CHANGELOG.md`
  under an intro that already supplies `# Changelog`, so the file's own title
  was a second `h1` with identical text.
- **Satellite pages could serve a duplicate title.** The "Full documentation"
  banner was prepended *above* the document's own `# Title`, which defeats the
  package's dedup (it drops its injected heading only when the prose opens with
  one). `/pip/dash_mui_charts` was the only page showing it, purely because it
  was the only satellite page whose markdown began with `# ` — so the fix is in
  the banner helper, which now slots the pointer under a leading title, rather
  than in the one document.
- **The noscript block's component list was stale** — it named
  `dash-fullcalendar` and `dash-dock` (both retired, both 301'd elsewhere in
  this same app) and `dash-summernote` (no page at all), and omitted nine
  components that do exist. Crawlers read that block.
- **The container ignored `$PORT`.** `gunicorn.conf.py` hardcoded
  `0.0.0.0:8550`; this only ever worked because Render port-detects rather than
  assigns. The `CMD` stays exec-form on purpose, so gunicorn remains PID 1 and
  actually receives the `SIGTERM` that `graceful_timeout` governs.
- **"This page as Markdown" on /pip led to a 404 until you refreshed.** Every
  SAME-ORIGIN `llms.txt` anchor on the catalogue — the header link and the
  seven component cards without a satellite — was a bare `dmc.Anchor`. Dash's
  page router intercepts a same-origin `<a>` click and resolves it
  client-side; `/pip/<pkg>/llms.txt` is a Flask route, not a registered page,
  so the router found nothing and rendered the 404 page, while a refresh of
  the identical URL hit the server and worked. They now carry
  `target="_blank"`, matching the cross-origin satellite links beside them,
  which never showed the bug precisely because they already had it.
- **The changelog page silently dropped most of its own content.** Sections
  were stored in a dict keyed by heading, so a version carrying more than one
  `### Fixed` — which a long-lived `[Unreleased]` block accumulates, one per
  pass — kept only the last. The Unreleased card rendered **12 of 57** entries,
  and 2.2.2 lost one, with nothing to indicate anything was missing. Repeated
  headings now merge instead of overwriting.

## [2.3.0] - 2026-08-23

Three weeks of continuously-deployed work that had accumulated under
`[Unreleased]` since 2.2.2, cut so the public timeline reflects what is
actually running: the network's SEO and discoverability standard (dimll 2.6.1,
visible prerender, honest sitemap, crawler identity, the soft-404 sweep), the
auth chain through dash-clerk-auth 1.0.5, the analytics reporters the hub's
402 and presence boards read, and the mobile/navigation pass.


### Added
- **Every docs page states when its prose actually changed.** A `lastmod:`
  field joins the docs frontmatter (`Meta.lastmod`, with the coercion
  validator YAML forces — a bare `lastmod: 2026-07-28` arrives as a
  `datetime.date`, which `Optional[str]` would reject at import), seeded from
  each page's real `git log -1 --format=%cs` date and passed through to
  `register_page_metadata`. Twenty pages, eight distinct dates spanning
  2025-11-16 to 2026-08-18, against a sitemap that currently stamps all of
  them with today's build date. dash-improve-my-llms >= 2.6.0 emits the value
  verbatim and omits the tag where it is unset — truth or silence; below that
  floor it is accepted and ignored (`register_page_metadata` takes
  `**kwargs`), so the declaration is in place and inert until the floor moves.
  Maintain it by hand in the same commit as the prose; never script it from
  file mtimes, which reset on every Docker build.

- **The `priced` verdict is now designed into the access spec** (Part 4 of
  `llms-access-and-identity-spec.md`): an HTTP-402 lane for anonymous bulk
  readers — the 402 body is a gate document plus machine-payable headers, a
  settled payment mints a normal day-scoped agent key, prices may travel in
  the bulletin but the pay-to address never does, and any payment-path
  failure degrades to `gated` (never publishes, never charges). Design of
  record only — no payment code ships with this entry.
- **The tiered corpus documents are on the control board** — `/llms-small.txt`
  and `/llms-full.txt` register as pseudo-pages (never in
  `dash.page_registry`, so they cannot leak into the sitemap or page list),
  giving the owner the same visibility tiers over the compact briefing and
  the full corpus as over any page. Default public — the tiers are packaging
  today; inert until dash-improve-my-llms ≥ 2.4.0 serves the routes.

### Changed
- **Two satellites join the network: dash-model-viewer and dash-excalidraw.**
  `modelviewer.2plot.dev` is promoted from `planned` to `live`;
  `excalidraw.2plot.dev` is a new directory entry — both deployed and verified
  2026-08-21/22, both straight to a subdomain with no onrender phase. They
  appear in the `/llms.txt` Network section, gain their `1200x630` CDN social
  card on the `/pip` catalogue, and their pages gain the satellite banner.
  Because `card_url_for` trusts `status: live` without probing at request time,
  every live entry's card was fetched by hand first — all 12 return 200, and
  both new ones are exactly 1200×630.
- **The README's satellite list is current again.** It named four sites by
  their retired `*.onrender.com` URLs; it now lists all twelve live ones on
  their `*.2plot.dev` subdomains, generated from `lib/network_directory.py`
  with a note saying which of the two is the source. That was the only stale
  onrender URL on any reader-facing surface — the `legacy_url` fields are
  deliberately never published, and the served `/llms.txt` contains zero.

- **The Components accordion is ordered, from one source.** `/pip` — the
  catalogue that indexes every component — leads, followed by LLMs (the package
  every site in the network runs) and Boilerplate (the template every site is
  forked from). After those, components appear in the order
  `lib/network_directory.SATELLITES` already ranks them, live satellites before
  planned ones, and anything without a satellite trails alphabetically.
  Importance is read from that ledger rather than restated in the navbar,
  so a new satellite slots itself in and a promotion reorders it. The list it
  replaces had drifted completely: all five names in it
  ("Getting Started", "Custom Directives", …) referred to pages that no longer
  exist, so it sorted nothing and the accordion was in registry order.
- **The standalone "Docs Boilerplate" nav link is gone**, along with its mobile
  twin — which was labelled "Documentation" and still pointed at the retired
  `dash-documentation-boilerplate.onrender.com`. Both duplicated the Boilerplate
  page already inside the accordion, which now carries the retired link's
  `streamline-pixel` mark. The page's frontmatter `icon:` is unchanged, so its
  `/pip` card keeps `tabler:template`.

- **The floor moves to dash-improve-my-llms 2.6.0, and the sitemap stops
  lying.** `<lastmod>` is now emitted verbatim from each page's `lastmod:`
  frontmatter and omitted where none is declared, so the sitemap went from
  **22 entries all stamped with the build date** to **19 entries carrying 8
  distinct real dates** (2025-11-16 → 2026-08-18) with 3 correctly silent.
  The frontmatter had been in place and inert since 2026-08-20; this is the
  release that reads it. The floor waited on the authored mark for the other
  half of 2.6.0: icon autodiscovery would have published the template's icons
  as this site's brand. The viewer banner de-duplication arrives package-side
  with the same bump.
- **The crawler document has an identity of its own.** `configure_seo` is
  ported: an explicit icon list (declared wins over autodiscovery, and
  `tests/test_seo_icons.py` now pins the two as SET-equal — the proof the
  fleet can rely on discovery alone once its pixels are right), the CDN social
  card with dimensions, and `publisher` / `logo` / `same_as` lifted from the
  Organization block in `templates/index.html` into `lib/constants.py` so both
  heads read one source. `logo` is explicit rather than left to the auto-pick
  (largest raster ≥112px, which would have chosen android-chrome-512x512 and
  disagreed with the browser head). Before this, the document Googlebot reads
  carried no icons and no publisher at all.

- **The corpus routes actually serve.** The `dash-improve-my-llms` floor moves
  to `>=2.5.1` (the fleet standard; the venv was on 2.3.4, the only host below
  it). Below 2.4 this site answered `/llms-small.txt` and `/llms-full.txt`
  with the Dash SPA shell — an HTML 200, not even a 404 — so an agent asking
  for the corpus got a page of JavaScript. Both now serve real Markdown (4.5 KB
  briefing, 780 KB corpus), the root index gained its "Other sizes of this
  document" section, and the control-board pseudo-pages registered for them
  stop being inert. Gating is unchanged (both public) and the corpus honours
  every tier: the hidden page has no section, and the gated page contributes
  its gate document, not its prose.
- **The POS Printer docs page moved to `/pip/dash_pos_printer`.** It was the
  one page of 21 registered under `/components/`, which meant `/pip`'s
  `startswith("/pip/")` filter dropped it from the component catalogue while
  `/llms.txt` still advertised it — a page an agent could find but the
  catalogue denied. Its access verdict is unchanged (`auth` +
  `llms_public: false`, now pinned in frontmatter so it survives a redeploy
  rather than living only in the runtime override), and the retired path plus
  its `/llms.txt` twin 301 to the new one for anything holding the old
  address.
- **The archived half of the home table names its successors.** Each retired
  component's description now ends in the component that replaced it — Charty
  and Nivo → MUI Charts, Discord → WidgetBot, Dynamic Grid Layout → Flex
  Layout, Dock → Dock View, FullCalendar → MUI Scheduler (the mirror of the
  forward note the Scheduler row already carried). Credit Cards and Swiper
  have no successor and say nothing. Dash Summernote's row admits it has no
  docs page yet and points at PyPI.

- **The self-report speaks v3 — 2plot.dev's own machine surfaces are finally
  measured.** `lib/traffic_reporter` now mirrors the fleet template's
  `traffic_rollup.daily_rollup`: the machine-readable document surfaces
  (`/llms.txt` and its tiered twins, `/robots.txt`, `/sitemap.xml`,
  `/<page>/page.json`) are ledgered instead of dropped at write time and
  reported as machine-surface `pages` rows with a per-row `bot_hits` split;
  `bot_visitors` (daily distinct crawlers) joins the payload; a day with
  only crawler fetches is reported instead of skipped. Page-visit
  exclusions are pinned byte-identical to the fleet's `_SKIP` tuple — the
  network's one measurement rule — closing this host's own double-count
  gap (`page.json` fetches used to inflate `pages` as ordinary visits).
  Six invariant tests pin containment, the visit/machine-row partition,
  and the write-time behavior.

### Fixed
- **Retired package paths were indexable soft-404s.** `/pip/dash_charty`
  rendered "404 - Page Not Found" while serving **HTTP 200** with the
  site-generic `<title>`, no canonical and stale copy — and Google had indexed
  it, outranking `/pip` itself for some queries. Every unknown `/pip/*` path
  behaved identically, so this was a class rather than one URL. Now: seven
  retired paths with a traced successor answer **301** (charty and nivo →
  MUI Charts, dock → Dock View, fullcalendar and full_calendar_component →
  MUI Scheduler, discord → WidgetBot, dynamic-grid-layout → Flex Layout),
  eight known-dead paths answer **410**, and any other single-segment
  `/pip/<name>` answers a real **404**. Error bodies carry
  `<meta name="robots" content="noindex, nofollow">` and are not the SPA shell.
  Machine twins redirect too, so an agent following an old index entry reaches
  the successor's document. Implemented as a `before_request` rather than
  routes, because a `/pip/<path:rest>` rule would sit in front of the package's
  own `/<page>/llms.txt` matcher.
- **The stale "18+ custom Dash components" copy is gone** from the JSON-LD
  description, the no-JS marketing block, the webmanifest and the developer
  guide. A hardcoded count goes stale by construction; the catalogue is the
  place that knows how many there are.
- **The browser head declared only two icon sizes.** It listed 16/32 while the
  server serves — and `configure_seo` already declared to crawlers — 96, 192
  and 512 as well. All seven now appear in both heads, pinned as set-equal by a
  test, so Google's favicon pipeline sees a high-resolution source directly.

### Added
- **`/healthz` reports which build is answering.** `build` carries
  `RENDER_GIT_COMMIT` (absent locally, present on Render), and CI's post-deploy
  battery now waits for it to equal the commit under test. Render swaps
  instances rather than restarting in place, so the previous release answers
  `/healthz` throughout the next build — an unanchored battery verifies the
  PREVIOUS release on every run, passes, and reports nothing. The workflow's
  own comments admitted this and worked around it with sleeps and retries;
  the anchor replaces guesswork with proof.

- **A live presence beacon — this host goes green on the hub's presence panel.**
  `POST /api/satellite/active` roughly every 60s with
  `{"app": "dev", "active": N}`, where `active` is distinct human visitors
  inside the 30-minute session window. It rides the existing signed rail: same
  `CROSS_APP_WEBHOOK_SECRET`, same `X-AI-Canvas-*` headers, same internal UA
  token so the hub's own visitor and bot boards never count it. 2plot.dev sat
  under "Not reporting live" while its hourly rollups arrived fine — the two
  answer different questions ("who is here now" vs "what happened last hour")
  and feed different panels, so no amount of rollup traffic could move the row.
  First beat at 20s so the row greens well before the first rollup; 60s
  thereafter, which leaves a missed ping of headroom inside the hub's 180s live
  TTL, so a genuinely dead beacon decays into an honest "not reporting" instead
  of a stale green. Bots and machine-surface fetches are not presence — both
  reporters now resolve "is this a page visit" through one predicate, so they
  cannot drift apart on what a person is.

### Fixed
- **This host reported "not reported" on the hub's 402 board for 30 days.** The
  rollup's `pages[]` caps at the top 20 paths by hits, and with this much human
  traffic `/llms.txt`, `/robots.txt`, `/sitemap.xml` and the per-package llms
  twins never survived that cut — so the hub, which can only count what it
  receives, priced the busiest documentation surface on the network at zero.
  The rollup now also sends `agent_surfaces`, a top-level
  `{path: {hits, bot_hits}}` map that lives *outside* the cap and cannot lose
  its slots to human pages. Measured against a ledger shaped like this host's
  real traffic: 6 of 7 machine surfaces missed the top-20 cut and all 7 now
  reach the hub. Entries are pre-sorted by `bot_hits` and capped at 40 here, so
  this host chooses which surfaces survive rather than discovering the hub's
  cut afterwards; paths are truncated to 160 chars to match what the hub
  stores. `pages[]` is deliberately untouched — widening its cap would
  re-crowd the same slots on the next traffic spike, which is the bug class the
  dedicated field exists to end. Counters, never deltas: cumulative for the
  day, recomputed from the ledger each cycle, so a mid-day container eviction
  is lossless via the hub's restart heuristic.

- **The prerender was invisible to anything that doesn't run JavaScript.**
  Through dash-improve-my-llms 2.6.0 the container shipped as
  `<div id="dimll-prerender" data-dimll-prerender="1" hidden>`, so a non-JS
  parser — the outside audit among them — got an empty page where the prose
  was. On this host that is every `/pip/` package page, the network's
  storefront. The floor moves to `>=2.6.1`, which ships the div visible and
  hides it from browsers with a marked *synchronous* inline script instead, so
  neither audience gets the wrong thing.
  **The floor bump is also the cache bust**, which is why the number moves
  rather than the `>=` being left alone: the Docker build caches its dependency
  layer on the bytes of `requirements.txt`, so a floor that already resolves
  under the cached layer never re-resolves and a new release can never arrive.
  The same version is asserted in `.github/workflows/ci.yml`, which had drifted
  to `>= (2, 3, 4)` and now checks the built image against `>= (2, 6, 1)`.

- **The persistence banner contradicted the store it was reporting on.** The
  first deploy with the new line logged, one second apart: `synced with
  Postgres (10 overrides)` and `board overrides: JSON only … EPHEMERAL`. The
  first was true. The banner called `ad_storage.pg_enabled()` for itself, and
  that returns False until a *background* pool init finishes — so it sampled a
  race and announced that control-board settings were being thrown away on a
  boot where they were being persisted. `lib/page_visibility` now records what
  the sync actually did (`postgres` / `pending` / `json-ephemeral`) on every
  exit path, and the banner reports that through `persistence_summary()`.
  A diagnostic has to read the same source of truth as the thing it diagnoses.
  "Configured but not ready yet" is its own state, because collapsing it into
  "ephemeral" is what produced the false alarm.

- **A hash-keyed CSS rule was pinning the mobile drawer to 63vh.**
  `.m_b8a05bbd { margin-top: 80px !important; height: 63vh !important }` had sat
  in `assets/main.css` since the 2024-11-30 initial commit and survived the
  Mantine 7 → 8 upgrade. Those class hashes are build artifacts, not an API:
  after a version bump a selector either matches nothing or matches something
  its author never saw, and by DMC 2.8 this one had drifted onto the Drawer.
  Its two `!important` declarations beat the docking geometry set in
  `components/navbar.py`, so the drawer looked correct in every test and was
  broken in the browser. Removed. `tests/test_css_selectors.py` now fails on any
  NEW `.m_<hash>` rule, and specifically on any that forces `height`/`top` with
  `!important`; the five pre-existing ones are listed rather than silently
  tolerated. Same family as the bare `table { width: 100% }` already recorded in
  `.claude/rules/general.md` — selectors reaching past the component API into
  markup nobody here controls.

- **The mobile drawer is a docked panel, and the burger can close it.** It was
  a floating card (`offset=8`, `radius="md"`, 280px) hovering over the page;
  it now runs flush from the bottom of the fixed header to the bottom of the
  viewport, with no close-button row and square corners — the network standard,
  ported from the boilerplate. Its overlay starts below the header so the
  hamburger stays tappable, which is what makes the new toggle reachable: the
  old callback hard-returned `true`, so the button could only ever OPEN the
  drawer and the overlay was the only way out. The drawer body gained the
  sticky page search phones had no access to at all, since the header's Select
  is `visibleFrom="sm"`. `HEADER_HEIGHT` moves to `lib/constants.py` — three
  copies of a layout constant is how a docked panel drifts loose.
- **The four icon-only header controls announce themselves.** The hamburger,
  the Discord and GitHub links and the colour-scheme toggle were an icon and
  nothing else — a bare "button" or "link" to a screen reader. `create_link`
  now takes its label positionally, so a new icon link cannot be added without
  naming one. (DMC 2.8's ActionIcon/Anchor accept `aria-*` but reject `title=`,
  which raises at import — pinned by a test.)
- **The ad slot stops reflowing the page on load.** `serve_ad` fills `src` on
  mount, so a `height: auto` image was zero-high and then jumped, shifting
  everything below it on every page view. The slot now reserves `1 / 1` up
  front and `objectFit: contain` letterboxes anything non-square.

- **The navbar brand mark was a broken image, and is now theme-paired.**
  `components/header.py` built its logo path at runtime, as
  `get_asset_url('apple-touch-icon.png')`, so the literal-string grep that
  cleared `assets/apple-touch-icon.png` as orphaned during the pixel swap never
  saw it — the header rendered a broken image on every page. The mark now comes
  from a CSS custom property paired across themes: `--nav-logo` resolves to
  `dev-light.png` (black pills, for light backgrounds) on bare `:root` and to
  `dev.png` (pale pills, for dark) under
  `[data-mantine-color-scheme="dark"]`, the same pairing the hub's network map
  uses for these two files. One element, one fetch, correct in the first paint —
  where a callback would flash the wrong mark on load and a
  `<picture media="prefers-color-scheme">` could not see an explicit Mantine
  toggle at all. `tests/test_asset_references.py` now walks the source for
  `get_asset_url(...)` targets and fails on any that do not exist, which is the
  check that would have caught the deletion.

- **The Organization JSON-LD did not parse, so none of it reached Google.** An
  explanatory HTML comment was written *inside*
  `<script type="application/ld+json">`. JSON has no comments: the block failed
  to parse entirely and `logo`, `sameAs` and `contactPoint` were all invisible,
  while the page rendered perfectly and nothing logged an error. The comment was
  explaining the publisher-logo repoint — so it silently cancelled the fix it
  documented, through the whole pixel swap. Caught by live certification, not by
  anything here. The note now lives outside the tag, and
  `tests/test_structured_data.py` parses every `ld+json` block on the served
  page (browser and Googlebot) so it cannot recur.

- **2plot.dev's branded search result was the documentation template's icon.**
  All nine icon files here were byte-identical to the template's previous set,
  and `templates/index.html`'s JSON-LD publisher logo pointed at one of them —
  so the mark Google rendered beside this site was never this site's. The
  authored `dev` mark (2plotai `favicons_src/dev.png`, 512×512) is now
  regenerated into the full set by `scripts/make_favicons.py`, the fleet's
  one-source-image tool, copied in as part of the file set. **Zero
  template-identical bytes remain**, and the now-orphaned root
  `assets/apple-touch-icon.png` is deleted. `/favicon.ico`, which 404'd,
  serves the new mark.

- **dash-clerk-auth 1.0.5 closes the return trip.** The mirror of 1.0.4's
  ghost, and measured live on this host during the owner's row-6 test:
  authenticate from 2plot.dev, come back, and the browser has a Clerk session
  while the server does not — the page was rendered before the round trip, so
  the gate card persisted until a manual refresh. 1.0.5 adds the symmetric
  fresh-load reconciliation through `POST /api/auth/session`, the package's own
  identity writer, so prose appears after one automatic reload. Vendored with
  sha256 `a2f9062e…b74f3`, verified at vendor time and again on the committed
  copy; any other 1.0.5 hash is the wrong artifact. With 1.0.4 this makes
  reconciliation bidirectional, which fully supersedes `lib/clerk_signout.py` —
  kept installed anyway (its POST is idempotent) until the fleet-wide
  retirement pass removes it from every host at once.
- **Boot says whether control-board changes survive a redeploy.** The overrides
  live in `page_visibility.json` at the project root, which is ephemeral in a
  container — so without Postgres every tier the owner sets silently reverts to
  frontmatter defaults on the next deploy. The access banner now states which
  of the two it is.

- **dash-clerk-auth 1.0.4 closes the cross-host sign-out ghost.** 1.0.3 fixed
  the live transition, which needs the tab to have watched the sign-out happen.
  A browser that signed out on 2plot.ai and then simply *navigated* here never
  had one: ClerkJS resolves signed-out immediately while the server still holds
  `__dca_identity` and has already served gated content from it. Live
  certification caught this host minting **agent keys to a signed-out browser**
  that way — `/api/agent-key` was never wrong, `current_clerk_user()` was being
  handed a ghost. 1.0.4 reconciles on fresh load: server-rendered-signed-in plus
  browser-signed-out revokes server-side and reloads once, guarded so a failed
  POST cannot spin. Vendored as a dist tarball, sha256 `7a7c333a…f701a` verified
  at vendor time and again on the committed copy — **`22117b20…` is a stale
  first build of the same version number and is rejected**, which is why the
  hash is checked and not the filename. The FastAPI route fix in the same
  release is inert here (this host is Flask). `lib/clerk_signout.py` is now
  fully superseded and comes out next pass.

- **dash-clerk-auth 1.0.3 fixes sign-out package-side.** Vendored as a dist
  tarball (there is no PyPI release for it), sha256 verified at vendor time and
  again on the committed copy:
  `2c6b40f4620afdc016580d75c01b7f7f6760f030b61d1d827c498bcb01da1944` — the same
  artifact leaflet and boilerplate 1.5.2 carry. The package now sequences Clerk
  sign-out → `POST /api/auth/signout` → reload itself, so
  `lib/clerk_signout.py` becomes an idempotent duplicate POST; it stays this
  release and retires next. Also in 1.0.3: the signed-out avatar is an inline
  SVG data URI instead of a call out to the DiceBear API — one less
  third-party image service in the page. Do NOT re-vendor from the authoring
  repo's `main`, which holds a build whose f-string uses PEP 701 syntax and is
  a hard SyntaxError on 3.10/3.11; because the package declares a
  `[dash_hooks]` entry point Dash resolves at every `Dash()` construction, that
  is a whole-site outage, not a sign-in one. This artifact is confirmed clean:
  `encoded` is hoisted out of the f-string, and every module in it parses under
  3.10 grammar.

- **Sign Out never revoked the server's idea of who you are.**
  dash-clerk-auth 1.0.2's logout handler runs `window.Clerk.signOut()` and
  reloads — client-side only. The signed `__dca_identity` cookie and the Flask
  session both survive it, for the rest of `session_lifetime_days` (default
  **7 days**), so a signed-out browser kept rendering every auth-gated page.
  The package already ships `POST /api/auth/signout`, which clears both;
  nothing called it. `lib/clerk_signout.py` is a capture-phase delegate on
  `#clerk-logout-menu-item` that owns the click and sequences what the package
  should have: Clerk sign-out FIRST (kills `__session`, so the slow path cannot
  re-verify and re-mint), THEN the server signout, THEN the reload — awaited,
  so the reload never races the cookie clears. The server POST runs even when
  ClerkJS never loaded, which is the stale-ghost case that needs it most.
  Interim until dash-clerk-auth 1.0.3 (the 1.0.2 bump does NOT contain the
  fix); it degrades to a harmless duplicate POST once that lands.

- **Control-board toggles were per-process until the store was re-read.**
  `_overrides` loaded once at import, so a board toggle mutated it only in the
  process that served the POST — every other worker went on enforcing its
  boot-time copy, making an anonymous refresh of a just-published page a coin
  flip decided by which worker answered (the leaflet pilot's live defect of
  2026-08-21, against this module's original of that design). `_maybe_reload()`
  now does a 1s-throttled `os.stat` of the store on the override read path and
  re-reads only on an observed `st_mtime_ns` change; a missing file, a stat
  error or an unchanged stamp leave memory alone. Writes record their own
  stamp, so the writing process never bounces its own change back. A picked-up
  change also re-runs the refresh hooks — otherwise a reloading worker would
  enforce the new verdict while still advertising the old `/pip` catalogue.
  This host pins `workers = 1`, so the defect was latent here rather than
  live; `preload_app = True` means every future worker would fork from the
  same stale snapshot, which is why it is fixed now rather than when the
  worker count changes.

- **The JSON-LD publisher logo pointed at the one icon the regenerator does
  not rewrite.** `scripts/make_favicons.py` — the fleet's
  one-source-image-to-full-set tool — writes `assets/favicon/*` and
  `assets/favicon.ico`, and nothing else. `templates/index.html` pointed the
  Organization `logo` (the mark Google renders beside this site's result) at
  `/assets/apple-touch-icon.png`, which it does not write. Swapping in an
  authored mark would therefore have refreshed every browser-facing icon and
  left the Google-facing one on the old art, undetectably: the two files are
  byte-identical right up until they aren't. Repointed to
  `/assets/favicon/apple-touch-icon.png`, so every icon reference in the
  template is now inside the regenerated set. No published bytes change today.
  `tests/test_seo_icons.py` pins it.

- **This host's crawler documents were a thinner record than its browser
  head.** `lib/page_visibility.register_llms_doc` forwarded only
  path/name/description/llms_doc to `register_page_metadata`, so the document
  Googlebot reads carried no `og:image` and typed every documentation page as
  a bare schema.org `WebPage` — while `templates/index.html` had been giving
  browsers the full set all along. Two heads describing one page, disagreeing
  on identity. The registry entry now remembers an `extra` dict
  (`title` / `image_url` / `schema_type` / `lastmod`) and `reapply_llms_doc`
  re-splats it: a one-shot pass-through looks correct on a cold boot and
  silently reverts to the thin record the first time a control-board toggle
  re-registers the page. Docs pages type as `TechArticle`, the home page as
  `WebSite`, `/pip` as `CollectionPage`; `/pip`'s toggle-driven
  re-registration carries its own identity for the same reason. Six tests
  pin the declaration and its survival across re-registration.

- **The root `/llms.txt` opened with the same sentence twice.** The generator
  emits the registered page description as a blockquote and then appends the
  home prose with only its H1 stripped, so the hand-written blockquote at the
  top of `pages/home.md` landed directly under a near-identical generated one.
  The hand-written copy is gone; `register_page_metadata(path="/")` is the
  single source of the tagline.

- **Satellite gate documents can finally say "Sign in at 2plot.ai."** The
  bulletin has specified `network.sign_in_url` / `network.account_label`
  since 2.3.0, but the hub never actually published them — every
  satellite's gate document silently omitted the sign-in line, cutting the
  account funnel off at exactly the moment an agent was told it needed an
  account. The bulletin payload now carries both (with sensible defaults
  even for a bulletin saved before the fields existed), and the network
  board's identity form edits them.
- **Authorised document links render with one key verification instead of
  two** — the link-suffix helper double-verified the same key on every
  link-generating surface.
- **The /pip card's `llms.txt` link now routes to the component's dedicated
  site** (`https://<sub>.2plot.dev/llms.txt`, new tab) whenever the network
  directory says that satellite is serving — the owner's requested behavior:
  the satellite's index twin is the complete reference, where this site's
  `/pip/<component>/llms.txt` is only the quick-start twin. Components with
  no dedicated site keep the local twin link, and the "This page as
  Markdown" header link (`/pip/llms.txt`) is unchanged. Verified by
  building every catalogue card through the real `_card` callee: 10
  satellite-routed, 9 local.
- Sign-in works on the satellite again. Clicking "Sign in" or "Create free
  account" on 2plot.dev produced Clerk's *"This operation is not allowed on
  a satellite domain"* error — both the auth-gate card and the header button
  opened Clerk's modal, which satellites reject. They now navigate to
  `https://2plot.ai/onboarding?returnTo=<the page you were on>`; the hub
  signs you in and sends you straight back (the sign-up button carries
  `&mode=signup` so the hub opens the create-account flow directly).
  Ships via dash-clerk-auth 0.9.2 (vendored): the header button is
  satellite-aware in the package, and the new
  `satellite_sign_in_redirect` / `CLERK_SATELLITE_SIGN_IN_REDIRECT` setting
  defaults to the hub's onboarding page. Satellite env vars are now
  documented in `.env.example`, and the subdomain blueprint STANDARD raises
  its floor to dash-clerk-auth ≥ 0.9.2 with the modal-on-satellite pitfall
  spelled out for the other `*.2plot.dev` hosts.
- **`requirements.txt` pointed at two vendored wheels that no longer exist.**
  `./vendor/dash_clerk_auth-0.9.2-py3-none-any.whl` and
  `./vendor/dash_leaflet2-0.1.0-py3-none-any.whl` were both removed from
  `vendor/`, so `pip install -r requirements.txt` — and therefore the Docker
  build, which `COPY vendor/ ./vendor/` before pip runs — could not resolve.
  Repinned to the artifacts below. Nothing warned about this locally because
  the site's `.venv` already had both packages installed from the old wheels.
- The home page's brand H1 had picked up a leading blank line, so
  `pages/home.md` no longer opened with `# 2plot.dev — …`. That line is the
  string `/llms.txt` publishes as the site's H1 and
  `tests/test_site_identity.py` pins verbatim; restored.
- The home page's YouTube badge rendered as *invalid*. The cause was not the
  URL: shields.io's `youtube/channel/subscribers/…` endpoint is failing
  service-wide — it returns `invalid` for every channel, including
  well-known IDs that certainly exist, so its YouTube API credentials are
  dead upstream. Replaced with a static `badge/YouTube-@2plotai` shield
  (verified rendering) linked to the channel's `?sub_confirmation=1` URL.
  The live subscriber count is the deliberate trade for a badge that draws.
- The footer's YouTube icon (`components/appshell.py`) still pointed at the
  retired `@pipinstallpython` channel while every other surface — README,
  `templates/index.html`, the email templates — had moved to `@2plotai`. Now
  `https://www.youtube.com/@2plotai?sub_confirmation=1`.

### Fixed
- **pip-install-python.com showed a black page with a raw Clerk error.** The
  retired domain is a second custom domain on this same Render service, so it
  served the real app — and then ClerkJS booted, saw a host that is not the
  registered satellite domain (`CLERK_SATELLITE_DOMAIN=2plot.dev`), and the
  primary rejected the handshake with
  `does not match one of the allowed values for parameter redirect_url`. Every
  visitor arriving from an older post or video got that JSON body on a black
  screen. `lib/canonical_host.py` (ported from dash-mui-scheduler, same
  contract — keep them in sync) now 301s every non-canonical host to
  `2plot.dev`, which also covers the `*.onrender.com` URL that never stops
  answering once a custom domain is attached.
  - Applied as the **outermost WSGI wrapper** in `run.py`, deliberately: a
    Flask `before_request` is not enough, because dash-improve-my-llms answers
    crawler-classified requests with prerendered HTML and short-circuits the
    remaining handlers — so a crawler on the retired domain would have been
    served a duplicate of the page instead of the redirect that consolidates
    it. Firing at the WSGI layer also means the 301 lands before a byte of HTML
    is parsed, so ClerkJS never runs on the wrong host at all.
  - **Path- and query-preserving**, which is the entire point: the deep links
    in old posts and videos land on the page they promised, and a 301 is what
    passes the old domain's accumulated ranking to the new one. Exempt:
    `/healthz` (a 3xx there fails the Render deploy), `/api/` and `/webhooks`
    (Discord interactions and the Clerk webhook verify a signature over the
    body), `/assets` and `/_dash*`, and localhost/test clients. GET and HEAD
    only — most clients downgrade a redirected POST to GET and drop the body.
  - Defaults **on** here, where the rest of the fleet defaults off. The fleet's
    default guards against redirecting to a domain whose DNS has not resolved
    yet; here that risk is inverted and already realised. `CANONICAL_HOST_REDIRECT=0`
    disables it. Covered by `tests/test_canonical_host.py` (11 cases).
- **The network directory advertised the retired domain, and would have become
  self-referential.** `AFFILIATED` listed `https://pip-install-python.com` with
  `Machine-readable: https://pip-install-python.com/llms.txt`, published in this
  host's own `/llms.txt`. Once the 301 above shipped, an agent following that
  line would have been sent straight back to this host's `/llms.txt`. A host
  that redirects to you is an alias, not a peer — entry removed. Verified: zero
  references remain in the published index, and the other affiliated entries
  are untouched.

### Changed
- **dash-leaflet2 installs from PyPI.** 0.2.2 was published 2026-08-05, so
  `requirements.txt` drops `./vendor/dash_leaflet2-0.2.2-py3-none-any.whl` for
  `dash-leaflet2==0.2.2` and the wheel leaves `vendor/`, which now holds only
  the clerk and excalidraw artifacts. Note the PyPI wheel is a *different build*
  of the same version than the one that was vendored (152,503 vs 152,509 bytes —
  zip metadata), which is why the swap was verified by installing from PyPI and
  booting rather than assumed. Same move as flexlayout 2.0.0 and muicharts 1.4.0.
  It also gains its missing home-page catalogue row — dash-leaflet2 was the one
  maintained package absent from that table, since a package with no PyPI
  release has no pepy badge to show. Badge verified live (372 downloads).
- **Vendored dependencies refreshed**: `dash-clerk-auth` 0.9.2 → **1.0.0**
  and `dash-leaflet2` 0.1.0 → **0.2.2**, both as wheels (the repo's vendor
  convention — an sdist would make the Docker build compile at install time).
  Verified 2026-08-04 that neither is on PyPI yet, so `vendor/` remains the
  install path and `.claude/tasks/pypi-publish-checklist.md` is updated with
  the new versions and dash-leaflet2's real source path (`2plot_leaflet/`,
  not `dash-leaflet2/`).
  - dash-clerk-auth **1.0.0 requires no application change**: diffing the
    0.9.2 and 1.0.0 wheels, the only differences are `__version__` and one
    corrected docstring (`clear_header` → `clear_cookie_header`). 1.0.0
    declares the 0.9.x surface stable, which is exactly what `run.py`
    already calls — all ten `register_clerk_auth` keyword arguments and all
    four imported symbols (`register_clerk_auth`, `configure_app`,
    `create_clerk_menu`, `current_user`) verified present. Booted against
    the real env: registers clean, backend autodetected as Flask.
    Note its `requires-python` floor rose to ≥3.10 (3.9 was never actually
    installable); this site runs 3.11, so no impact.
  - dash-leaflet2 **0.2.2** over the 0.2.0 that was staged: 0.2.1/0.2.2 were
    documentation-site and network releases with no `dl2.*` component
    change, but 0.2.2's regenerated component stubs import
    `dash.types.NumberType` (falling back for `dash<=4.1`) rather than
    defining it locally — the better fit for the Dash 4.4.1 pinned here.

- Navigation labels shortened for the four longest sidebar entries —
  *Dash Documentation Boilerplate* → **Boilerplate**, *Dash Improve My LLMs*
  → **LLMs**, *Dash Email* → **Email**, *Dash Flows* → **Flows** — via each
  page's frontmatter `name:`. Endpoints, package names and descriptions are
  untouched, so no URL or catalogue entry moves.
- The navbar's "Docs Boilerplate" link now points at
  `https://boilerplate.2plot.dev` rather than its `onrender.com` origin,
  matching the subdomain promoted to `live` in the network directory.

### Added
- **Hero banner on the home page** — the 2plot wordmark above the fold,
  served from `cdn.2plot.ai/github_assets/`. Both the light and dark
  variants ship in the layout and CSS picks one off
  `data-mantine-color-scheme` (`assets/main.css`); a callback on the theme
  store would leave the wrong banner on screen for a round trip on every
  page load, and a dark-mode wordmark on the light theme is exactly the
  mismatch worth avoiding. Light is the default and dark the override, so a
  missing color-scheme attribute shows one banner rather than none. It is a
  real component in `pages/home.py`, not markup in `home.md` — that page
  renders through `dcc.Markdown` with `dangerously_allow_html` off, where an
  `<img>` tag would come out as literal text.

## [2.2.2] - 2026-08-02

### Changed
- Network directory: `muischeduler`, `flows` and `emojimart` promoted
  `shipping` → `live`, and all three added to `VERIFIED_APP_IDS`
  (source-verified: muischeduler's `traffic_report.app_key()`, flows'
  `satellite_reporter.app_key()`, emojimart's `constants.APP_KEY`; each
  battery-verified live on its subdomain 2026-08-02). Their `/pip` entries
  now render the CDN social card, and the live `/llms.txt` Network section
  gains all three subdomains. Note for the traffic ledger: flows reported
  under the key `email` for a few hours on 2026-08-02 (a dash-email env
  block pasted onto its Render service during an unrelated fix) — corrected
  the same day; a small same-day blip on email's series is expected.

### Added
- Catalogue presence for the two network-infrastructure projects that had
  none here: summary/funnel docs pages at `/pip/dash_improve_my_llms` and
  `/pip/dash_documentation_boilerplate` (public, concise overview + install /
  use-the-template quickstart, funneling to the full docs at
  [llms.2plot.dev](https://llms.2plot.dev) and
  [boilerplate.2plot.dev](https://boilerplate.2plot.dev)), rows in the home
  page's Currently Maintained table, and `dash-improve-my-llms` in the PyPI
  download counter. The boilerplate gets a GitHub-stars badge instead of a
  pepy badge and stays out of the counter — it is a use-this-template repo,
  not a PyPI package (verified: pypi.org 404s for
  `dash-documentation-boilerplate`).

### Changed
- Network directory: the documentation boilerplate promoted from
  `OTHER_PEERS` to a full `SATELLITES` entry (key `boilerplate`, status
  `live`, `package: None` since it is not on PyPI), and the `llms` entry's
  `docs_path` now points at its new summary page — so both render their
  CDN-verified social cards on `/pip` and get the subdomain banner. The
  boilerplate's canonical/legacy app ids and `VERIFIED_APP_IDS` entry are
  unchanged, and it is listed exactly once in `/llms.txt`'s Network section
  (via `satellite_peers()` now, instead of `OTHER_PEERS`).
- `/pip` component index: entries whose satellite is WIRED — `status: live`
  with a subdomain in `lib/network_directory.SATELLITES` — now show the
  satellite's 1200×630 social card (from `cdn.2plot.ai/github_assets/`) in
  place of the icon+name header, with the name kept as a caption and alt
  text. Rollout state is now visible at a glance: a card means that
  subdomain's docs are verified wired; unwired entries keep the plain
  icon+name row. Derived via the new `card_url_for()` — status is the trust
  signal (the hub promotes to `live` only after the card is verified on the
  CDN), so the page never probes the CDN at request time, and a `shipping`
  entry forced onto its subdomain by `SATELLITE_SUBDOMAINS_LIVE` still shows
  no card until promoted.

## [2.2.1] - 2026-08-01

### Changed
- Network directory: `flexlayout` and `email` entries promoted to `live` —
  both satellites measured on the full network standard 2026-08-01 (brand H1,
  OAI robots, zero empty og tags, 1200×630 CDN card). flexlayout now appears
  in `/llms.txt`'s Network section and the per-page banners.
- `VERIFIED_APP_IDS` grew `leaflet`, `email` and `flexlayout` — each id read
  in the satellite's own source, per the "verified, never guessed" rule.

### Added
- `.claude/support_files/subdomain_blueprint/` (local ops docs, untracked) —
  the unified satellite standard (README / STANDARD / LESSONS / QUEUE /
  KICKOFF), reconciling the boilerplate's network-standard doc, the
  `.subdomains` package bundle and the rollout kickoff into one point of
  direction for the remaining migrations. The kickoff task file slims to an
  ops status ledger pointing at it.

### Known issues
- Upstream (dash-improve-my-llms): the crawler prerender carries no
  `og:image`, so Slackbot/Twitterbot unfurls are text-only network-wide while
  Facebook and browsers get the full card. Targeted at dimll 2.4; repro in
  `.claude/knowledge/llms-package-notes.md`.

## [2.2.0] - 2026-08-01

### Fixed — the share card was blank (port of boilerplate 1.2.1–1.2.3)

Every OG/Twitter tag was declared twice: once statically in
`templates/index.html`, and once per page by Dash — which, with no
`image_url=` passed to any `register_page`, emitted `og:image`/`og:description`
as `content=""`. The empty tags came later in document order, scrapers
honoured them, and every unfurl of 2plot.dev rendered a blank card.

- Every `register_page` now passes `image_url=lib.constants.OG_IMAGE_URL`
  (all ~27 docs pages via `pages/markdown.py`, plus every static page); the
  home page finally has a `description=` too.
- `templates/index.html` declares ONLY what Dash omits: `og:site_name` and
  the image auxiliaries (`secure_url`, `type`, `width`, `height`, `alt`,
  `twitter:image:alt`). The doubled `og:*`/`twitter:*` set is deleted.
- The card itself: `assets/plotly-pro-og.gif` (5.05 MB, 58-frame animated,
  910×480 declared as 1200×630 — over Twitter's 5 MB ceiling and a lying
  size) is deleted; `scripts/make_social_card.py` (from the boilerplate)
  renders a true 1200×630 PNG published at
  `https://cdn.2plot.ai/github_assets/2plot.dev.png` — CDN-hosted so a
  scraper's fetch never races a cold container.
- `og:title` no longer says "Dash Pip Components": `PAGE_TITLE_PREFIX` is
  now derived from `SITE_SHORT_NAME` ("2plot.dev | "), and the changelog and
  analytics pages join the prefix convention.
- Guard rails: `tests/test_social_card.py` (ported; empty/duplicate/SVG
  cards, manifest installability, untracked-asset sweep),
  `tests/test_site_identity.py` grows the fork-brand sweep, and the smoke
  battery gains `social_card_real_pixels` — it reads the REAL CDN file's
  IHDR pixels against the declared size after every deploy.

### Changed — one short app id per app, on every hub surface

Four id namespaces disagreed (directory long ids, the ad board's hardcoded
list, the bulletin's verified ids, and whatever each satellite sent). The
canonical id is now the directory **key** — `boilerplate`, `leaflet`,
`email`, `flexlayout` — never the package name:

- `lib/network_directory.py`: every satellite's `app_id` IS its `key`; the
  PyPI name moved to a `package` field; old spellings live in `legacy_ids`;
  new `canonical_app_id()` folds any legacy id into the canonical one.
- Applied at every ingest point — ad serve/click, bulletin `?app=`,
  `X-Satellite-App`, page-tiers — so a satellite still sending
  `?app=dash-leaflet2` accrues under `leaflet` and history stays one series.
- The ad board's Target-apps suggestions derive from the directory (the
  hardcoded long-name list is gone) and canonicalize on save; the network
  board's preview reads the `package` field instead of guessing from a
  `dash-` prefix.
- Timing note: stored data carried only `2plot.dev`/null app ids, so the
  rename lands with zero history to migrate.
- `tests/test_app_ids.py` pins the fold, the ingest points, and the ad
  board's vocabulary.

## [2.1.0] - 2026-07-31

### Changed — one site identity, everywhere (review finding F2)

2plot.ai's directory advertises this host as "the developer wing of the
network"; this host's own `/llms.txt` opened "# Pip Install Python
Documentation … Welcome to my notebook". Explicit identity now beats
fallback — the same principle dash-improve-my-llms 2.3.4's
`resolve_site_title` enforces per page:

- One brand constant in `run.py` — **"2plot.dev — the 2plot network's
  developer wing"** — feeds `Dash(title=)`, the home page's
  `register_page_metadata` (which 2.3.4 promotes to the `/llms.txt` H1,
  og:title and schema.org name), and the opening of `pages/home.md`.
- "Pip Install Python" stays as the maintainer byline in the prose, not the
  site's name.
- `tests/test_site_identity.py` and `scripts/network_smoke.py` pin every
  surface to the constant, so no surface can drift back.

### Added — the network's internal-traffic convention

Adopted the analytics point of truth
(https://2plot.ai/docs/satellite-analytics, "Internal traffic"): network
machinery talking to itself is counted **nowhere**.

- `lib/constants.py` mirrors the network-wide `INTERNAL_UA` /
  `INTERNAL_UA_TOKEN` ("2plot-internal").
- Both trackers drop token-carrying requests at write time, BEFORE bot
  classification: `lib/traffic_reporter.record_hit` (the ledger behind the
  signed hourly rollups to 2plot.ai — internal hits can no longer inflate
  `human_hits`/`bot_hits`) and `lib/analytics_tracker.track_visit` (the
  visitor file behind `/analytics`). `/healthz` probes are never a visit
  either.
- The hourly rollup POST itself now sends the internal UA, so the hub's
  4×-daily heartbeat and this host's own reporting stay invisible to every
  ledger in the network.

### Added — CI raised to the network's security standard

The Docker-gate core (build Render's exact image, boot it secretless, prove
the surfaces) is the template the standard grew from; the rest now matches
2plot.ai's reference pipeline:

- **In-process pytest suite** (`tests/`, zero secrets, state in tmp): site
  identity, hidden/admin documents 404, robots OAI-SearchBot fingerprint,
  sitemap host + no admin leaks, `/healthz`, the network bulletin payload
  shape, doc-key **mint→verify round trip** plus tamper rejection, hub-auth
  API failing closed (503) without its secret, and the internal-traffic
  exclusions.
- **`scripts/network_smoke.py`** — the same named-check battery in CI and
  against production (stdlib-only, case-insensitive response headers), so
  the two failure modes read identically.
- **Workflow hardening**: `permissions: contents: read`, per-ref concurrency
  with cancel-in-progress, `timeout-minutes` on every job, buildx `type=gha`
  layer caching.
- **Fingerprints inside the image**: dash 4.4.x, dash-improve-my-llms
  ≥ 2.3.4 (was ≥ 2.3.2), and **gunicorn ≥ 23** — the image resolves 26.0.0
  today; the assert exists because markdown2dash installs `--no-deps` to
  dodge its spurious `gunicorn<22` pin, and this is what proves the dodge
  keeps holding against the request-smuggling advisories fixed in 22+.
- **`.github/dependabot.yml`** (weekly pip with a `dash-network` group,
  monthly actions) and an advisory **pip-audit** job.
- **CD lessons ported back from the boilerplate rollout** (the template
  shipped first and surfaced the pitfalls): an **actionlint** job — a double
  quote inside a `${{ }}` expression is a lex error that invalidates the
  whole workflow file, and such runs die with *zero jobs*, so there is
  nothing to click and the failure is silent; the image fingerprint step now
  **imports markdown2dash**, proving `--no-deps` skipped only its dependency
  graph and not the package; and the deploy-hook job is replaced by
  **verify-live** — on a push to `main` (the branch Render auto-deploys),
  wait for *sustained* health (five consecutive 200s — Render swaps
  instances, so a single 200 is the old build talking), then run the same
  smoke battery against https://2plot.dev, retried across the build window.
- First pip-audit pass actioned: **requests 2.32.5 → 2.33.0**
  (PYSEC-2026-2275; still compatible with the urllib3 1.26 line). The
  `urllib3<2` findings are documented and deliberately deferred — the pin
  exists because urllib3 2.x broke HTTPS-proxy SSL CONNECT on the QuotaGuard
  Discord path; lifting it requires a verified proxied discord.com call on
  urllib3 ≥ 2.7, tracked as a follow-up.

### Added — this host is now the network's auth hub

Satellites run Clerk locally, so they resolve a signed-in **browser** without
asking anyone. What they cannot do is validate an **agent** fetch: it arrives
with no cookie carrying `?key=`, and only this host holds the secret that key
derives from. Three endpoints (`lib/network_auth.py`), shaped like
`lib/ad_network.py` because that is this host's established satellite-facing
pattern:

```
POST /api/agent-key/verify    {key, path, tier, app}  -> {verdict, ttl}
POST /api/agent-key/current   {token, app}            -> {key, scope}
POST /api/page-tiers          {app}                   -> {tiers, ttl}
```

Satellites therefore hold **no key material at all**. Sharing `SESSION_SECRET`
would let them verify locally, but anything that can verify can also mint —
including `scope=admin` — and twenty deployments holding a network-wide
admin-minting secret is the failure this avoids.

- **Callers are authenticated by signature**: HMAC-SHA256 over
  `"{timestamp}." + body` with `CROSS_APP_WEBHOOK_SECRET`, the scheme
  `lib/traffic_reporter.py` already uses. Plus a ±300s window (nothing else
  makes a signed body single-use, so without it a captured request replays
  forever), constant-time comparison, and **503 when the secret is unset**
  rather than degrading open. Read through `_satellite_secret(app_id)` so
  per-satellite secrets later are a registry lookup, not a refactor.
- **`/current` proves the user rather than believing the caller.** A shared
  secret cannot substantiate "user X is signed in on my site", so a satellite
  forwards the browser's Clerk session token and this host verifies it against
  Clerk's JWKS (`verify_user_sync`, JWKS-verified, cached min(5 min, JWT exp)).
  A caller-asserted `user_id` is refused with a message saying why. Without
  this, a satellite could name any user and be handed their key — attributable
  to a person who never asked for it, alive up to 30 days.
- **Minting through a satellite is capped at `scope=auth`**, never admin, even
  when that user is an admin here. Verification is the lock; the cap is what
  still holds if the lock is ever misconfigured.
- **The hub sets the ceiling.** Verdicts resolve
  `most_restrictive(tier_the_satellite_claims, hub_ceiling(app, path))`, applied
  hub-side as well as in the feed, so a satellite that never reads the feed
  still cannot loosen anything. An unrecognised tier is "no opinion", never
  `public`.
- **The tier feed is POST and authenticated**, not a public GET: a `hidden`
  page's path in it discloses a URL meant to be undiscoverable.
- TTLs returned explicitly — `allow 900`, `gated 60`, `deny 60`. A brief hub
  outage must not gate readers who were fine a minute ago; the stated cost is
  that a revoked key keeps working on a satellite for up to 15 minutes.
- **`2plot.dev/llms.txt` stays public**, with a regression test. It is the map
  every satellite's navigation block points at, and the package deliberately
  never appends a key to a cross-host link — so gating it would wall off every
  agent on its first hop outward.

**`/admin/network-board` → Page tiers** publishes those ceilings per satellite:
pick an app, set `auth` / `admin` / `hidden` on a path, clear to hand the page
back. `public` is deliberately not offered — it would restrict nothing while
reading like a decision. Each satellite's row shows when it last pulled the
feed, which is the only way to tell a published ceiling has actually landed.

**Deployment note:** `CROSS_APP_WEBHOOK_SECRET` is currently set on *neither*
this host nor the satellites. Until it is, every verify answers 503, the client
reads that as an outage, and agent access on every satellite stays gated. That
is configuration, not code.

### Security

- **The control board's tiers now gate every surface, not just the page.**
  A page set to `auth` was serving its full prose to anyone: 90 KB through
  `/<page>/llms.txt`, again in the crawler HTML, again in the prerendered block,
  and listed in the sitemap. Only the interactive Dash layout was gated, because
  that was the only surface this app controlled. `lib/page_visibility.llms_verdict`
  now answers per request and dash-improve-my-llms consults it on all six
  surfaces, via the new `configure_access` hook (2.3.0).

  What each setting means is now stated on the control board itself, in a
  legend, because nothing on that screen previously said the tier stopped at the
  page.

  | tier | `llms_public` | anonymous | with an agent link | signed in |
  |---|---|---|---|---|
  | `public` | on | prose | prose | prose |
  | `public` | off | 404 | 404 | 404 |
  | `auth` | on | prose *(unchanged)* | prose | prose |
  | `auth` | off | **gate document** | **prose** | prose |
  | `admin` | either | **404** | prose (admin key) | prose (admins) |
  | `hidden` | either | **404** | **404** | 404 |

  `public` + off denies everyone including key holders — a public page has no
  notion of entitlement, so the toggle means "no machine-readable document
  exists". `auth` + off stays in the sitemap: the URL is public, the content is
  not, and de-listing would defeat the sign-in funnel the tier exists for.
  `hidden` is absolute; no key opens it.

- **Agent links: entitlement that survives the paste.** A cookie cannot gate
  these documents — the whole point is to hand the URL to Claude or ChatGPT,
  which fetch it with no session. So a gated document is reachable with
  `?key=…`, and `lib/llms_access.py` binds that key to the Clerk session that
  created it:

  - **created automatically** on any authenticated request — there is no mint
    button, and the "Copy for llm" button already produces the right URL;
  - **rotates on re-login** — the HMAC covers Clerk's `session_id`;
  - **dies on logout** — `POST /api/clerk/webhook` revokes on `session.ended`
    (`CLERK_WEBHOOK_SECRET` was in `.env` but nothing consumed it until now);
  - **capped at 30 days** regardless, so an abandoned tab can't keep a link
    alive;
  - **per session, not per user**, so a second device doesn't invalidate the
    first.

  The key is derived (`HMAC(SESSION_SECRET, user:session:scope:version)`), never
  stored, so the new `llms_access_keys` table holds no secret material and a
  dump of it yields nothing usable — while still supporting revocation and
  showing last-used as a leak signal. Keyed responses carry
  `Cache-Control: private, no-store` and `X-Robots-Tag: noindex`, and the key
  reaches no canonical tag, sitemap or published index. Links *inside* a keyed
  document carry the key, or an agent handed one authorised URL would hit gate
  documents one hop later.

- **The llms.txt viewer names its reader** when signed in — email, plan, and
  "session since". HTML variant only: the Markdown that agents and crawlers get
  from the same URL never contains it. Identity-bearing responses are
  `private, no-store` too; the package previously set no `Cache-Control` at all,
  so a CDN could have served one visitor's banner to the next.

  The timestamp is first-seen-per-session rather than Clerk's `iat`, which
  refreshes about every 60 seconds and would have read as a clock resetting
  every minute.

### Fixed

- **Every page canonicalised to the homepage.** `templates/index.html` hardcoded
  `<link rel="canonical" href="https://2plot.dev">` with no path, on all 27
  pages — so every documentation page declared itself a duplicate of `/`, and
  the only URL asking to be indexed was the homepage. The package emits a
  correct per-page canonical, so the hardcoded one was also about to become a
  *second, conflicting* tag, which search engines resolve by ignoring both.
  Verified after: exactly one canonical per indexable page, each pointing at its
  own URL.

- **Same bug for `<title>`.** The template carried a hardcoded title element
  *before* `{%title%}`, and the first one wins, so all 27 pages shipped the
  identical title "2plot.dev - Open Source Dash Packages & Documentation".
  Removed; one title element remains and the package rewrites it per page.
  Bare `og:url` / `twitter:url` went too — Dash Pages and the package both emit
  per-page versions.

  Note for anyone editing that template: the package's title rewrite is a
  DOTALL regex that does not know what an HTML comment is, so *mentioning* a
  title tag in a comment there deletes everything up to the real one. It cost
  10,739 characters of head on the first attempt at this fix. Both tags are now
  described in words, with a warning at the top of the file.

- **A custom route was shadowing the package's `/<page>/llms.txt`.** It was
  registered before `add_llms_routes` and won, so neither of 2.2.0's headline
  features reached this host: no navigation block (the three lines that stop a
  page's llms.txt being a dead end for an agent) and no rendered viewer for
  people. It existed for a real reason — it re-checked `llms_accessible()` per
  request so control-board toggles applied immediately, which static
  `mark_hidden()` cannot do. That behaviour now lives in
  `lib/page_visibility.py` (`register_llms_doc` / `apply_llms_state`), which
  pushes each verdict into the package's own registry instead of intercepting
  the route. A toggle now moves the 404, the root index *and* the sitemap entry
  on the next request, which is more than the old route managed.

- **`warn_missing_llms_doc=False` was hiding two stub pages.** Turned on;
  `/changelog` now builds its `LLMS_DOC` from `CHANGELOG.md` (it renders as a
  DMC Timeline, which a crawler cannot read) and `/404` is `mark_hidden` — a
  not-found page has no business in an index. Zero pages now serve the crawler
  stub, and the startup warning is silent for the right reason.

  The call had to move *below* the prose registrations: it evaluates the warning
  immediately, and this app registers prose in `run.py` rather than per page
  module, so from its old position it named 26 of 27 pages and meant nothing.

- **Home page prose**, which is now the lead of the network's most-read
  document: "their is a chat tab" → "there is", lowercase "ai" → "AI",
  a dangling "Welcome to my notebook," and a sentence fragment, plus stale
  install instructions (`dash>=3.0`, `dash-mantine-components>=2.4` — this app
  needs 4.2 and 2.8) and a "Visit" link to the boilerplate's old Render URL.

### Added

- **dash-improve-my-llms, vendored** rather than from PyPI, landing at **2.3.2**
  (started as 2.2.0, then 2.3.0 for `configure_access`, then the 2.3.2 rebuild —
  two artifacts both claimed 2.3.0 before and after the OAI-SearchBot robots
  fix, and the bump makes "which build is this host running" answerable from
  pip metadata; remotely, the fingerprint is `robots.txt` showing
  `User-agent: OAI-SearchBot` → `Allow: /`). Nothing publishes to PyPI until
  all four Phase-1 apps are verified in production; Phase 5 reverts
  `requirements.txt` to a PyPI pin. `Dockerfile` already copies `vendor/`
  before pip runs, so no build change was needed.

- **Cross-host network directory** (`lib/network_directory.py`, applied in
  `run.py` before `add_llms_routes`). Emits `<link rel="related">` tags, a
  `## Network` section in `/llms.txt`, and followed links in the prerendered
  body — a sitemap cannot cross origins, and an agent fetches one URL rather
  than crawling from it. Deliberately the same shape as the satellites' copy so
  the two can be diffed; what differs here is that **2plot.dev is a section hub**,
  so `hub_url` points one level up at `https://2plot.ai` while the `*.2plot.dev`
  satellites point here. Carries the morse wordmark (2 + morse `plot` + ai),
  gated on the installed signature so the same file cannot break a satellite
  still on 2.0.

  Every `external` / `affiliated` entry carries an explicit `llms_txt`, because
  the package otherwise synthesizes `{url}/llms.txt` for entries that have no
  such document — `https://dash.plotly.com/llms.txt` 404s, and publishing that
  in a directory whose value is that its links resolve is worse than omitting
  the entry. Plotly's real one is at `https://plotly.com/llms.txt`.

- **`GET /api/network/bulletin`** (`lib/network_bulletin.py`) — the endpoint
  every other satellite in the network reads, so the network can say "here is
  what changed" once instead of in twenty repositories. Mirrors
  `lib/ad_network.py` deliberately: server-to-server, `?app=<id>` attribution,
  `_clean()` caps on every field, permissive CORS, short `Cache-Control`, and
  per-app fetch counters. Caps mirror the client's exactly, so the board's
  preview is what satellites render and an oversized entry cannot push the
  response past the client's 64 KB ceiling (at which point it discards the whole
  document, not just the long field). Storage via the shared Postgres
  `site_settings` table with the usual JSON-file fallback;
  `network_bulletin.json` is a tracked seed like `advertising_config.json`.

- **`/admin/network-board`** — the admin surface that curates it: network
  identity, *Tips for getting started*, and *What's new*, each entry with an
  active toggle and optional per-app targeting (empty = every satellite, same
  convention as a campaign's `apps`). A copyable `NETWORK_BULLETIN_URL` plus the
  `configure_bulletin` snippet for satellites, a per-satellite fetch table (the
  only way to tell a satellite is configured rather than merely deployed), and a
  standing warning that saved text reaches every satellite within one TTL.
  Same `ADMIN_EMAILS` gate as the other boards, re-checked server-side in every
  callback. Lives in the navbar's **Admin** section (`tabler:broadcast`).

  *What's new* entries take their date from a `DatePickerInput` defaulting to
  today, published as `YYYY-MM-DD`. The field is free-form in the contract, but
  every use of it is "when did this land", and a picker is how twenty sites end
  up agreeing on one format.

  The **Preview tab renders the real thing, per satellite**: pick an app id and
  the card is drawn by the package's own `render_llms_viewer` with the exact
  payload that app would receive, in a locked-down iframe (`sandbox=""`; the
  viewer HTML is self-contained, so nothing is lost). That makes per-app
  targeting visible — an entry aimed at one satellite is demonstrably absent
  from every other one's card — and it cannot drift from the endpoint, because
  it *is* the endpoint's output through the client's own renderer. The JSON
  payload is shown underneath.

  Bulletin app ids live in `lib/network_directory.py`, split into verified and
  expected, because they are **not** the ad network's ids: the boilerplate calls
  itself `boilerplate` for the bulletin and `dash-documentation-boilerplate` for
  ads. The board badges an id as verified-in-code, expected-but-unconfirmed, or
  unrecognised, and treats "has actually fetched" as the only ground truth.

- **`/pip` — the component catalogue as one page**, for people and as one
  document an agent can read the whole catalogue from. Generated from the docs'
  own frontmatter, so a new `docs/<component>/<component>.md` appears
  automatically. Its `LLMS_DOC` is written as absolute Markdown links (2.2.0
  renders those as real anchors, so the prose *is* the link graph), and it
  re-renders on any visibility change via a new `on_visibility_change` hook —
  an index that keeps advertising a page the admin just unpublished is worse
  than no index. Navbar icon `tabler:apps`.

- **Optional self-rendering of the bulletin**, gated on `NETWORK_BULLETIN_URL`
  (the same variable the satellites use) so the hub can show the same banner it
  publishes. Off by default: `_base_url` is the production host, so an
  unconditional call would have a dev machine reading production's bulletin.

- **Every component page now links its own documentation site**, in a call-out
  under the description and as the first line of the page's `llms.txt`. Those
  sites carry the complete API reference and examples with more depth and
  testing than the quick-start examples rendered here, and saying so is more
  useful than letting a reader assume this page is everything.

  Both come from `lib/network_directory.py`, not per-page markdown. Four pages
  previously carried a hand-written `*.onrender.com` blockquote — four URLs to
  re-edit at migration, and no link at all for the components that never got
  one. Those are removed.

- **The directory is built around the `*.onrender.com` → `*.2plot.dev`
  migration.** Each satellite carries its subdomain, its current Render URL and
  a `status` of `live` / `shipping` / `planned`; one function resolves to
  whichever answers today, and nothing without a resolvable URL is published
  anywhere — peer list, `rel="related"`, `/pip`, or page banners. So
  `pannellum` / `posprinter` / `modelviewer` are known to the plumbing and
  advertised nowhere.

  **On rollout day, set `SATELLITE_SUBDOMAINS_LIVE=1`** and every `shipping`
  entry switches to its subdomain across all four surfaces at once — no code
  change, no redeploy. Verified in both states. As of today only
  `leaflet.2plot.dev` is live, so that is the only subdomain advertised.

- **The bulletin board's targeting suggestions come from the directory** instead
  of a hand-kept list, which had drifted: it offered `leaflet` where the real id
  is `dash-leaflet2`, and omitted `llms`, `dash-emoji-mart` and
  `flexlayout-dash`. An entry targeted at an id no satellite sends is never
  delivered and never reports an error — it just silently reaches nobody.

### Notes

- Upstream defects found while wiring this, with local workarounds and no
  changes to the package: `mark_hidden()` has no inverse; the prerender's title
  rewrite is comment-blind; directory entries advertise a synthesized
  `llms.txt`. Written up in `.claude/knowledge/llms-package-notes.md` (local
  only — `.claude/` is not tracked in this repo).

- For the record, since the point of the rollout is the delta: **pages serving
  the crawler stub went 2 → 0**, and both were genuinely undocumented pages
  fixed by hand here. This app never had 2.2.0's prose-erasure bug — nothing
  loops over `dash.page_registry` refreshing names after `llms_doc` is set. Its
  gains are the canonical, the title, the nav block, the viewer, the root index
  and the network directory.

- **Control Board settings now survive redeploys** — page visibility tiers
  and llms.txt toggles persist to Postgres (new generic `site_settings`
  JSONB key-value table in `lib/ad_storage.py`, key `page_visibility`)
  instead of only the container's ephemeral `page_visibility.json`. On
  boot the stored overrides are pulled once the background DB init
  finishes (reads never block); with no database the JSON file keeps
  working as before, and existing JSON overrides seed Postgres on first
  contact. No more re-doing the board after every release.

- **2plot.dev is now installable as an app on phones** — the template linked
  `/assets/site.webmanifest`, but the file never existed (404), so browsers
  never offered "Install app" / Add to Home Screen. Added a real
  `assets/favicon/` bundle (192/512 android-chrome icons, 96/32/16
  favicons, apple-touch-icon, `site.webmanifest` with
  `display: standalone`) and pointed the template at it, matching the
  setup 2plot.media and HoneyComb already had.

### Removed

- **Old `/analytics/traffic` visitor dashboard** (`pages/analytics.py`) and
  its orphaned `lib/ad_analytics.py` helper — superseded by
  `/admin/ad-analytics`. The navbar Analytics section keeps Api Cost;
  visitor tracking itself is untouched. All admin boards (email, ad,
  ad-analytics) are now also excluded from the sitemap/llms surface like
  the control board already was.

### Added

- **`/admin/ad-analytics` — ad network performance dashboard** (admin-gated,
  in the admin nav section): KPI cards with sparklines (impressions, clicks,
  CTR, apps reporting), daily network traffic line chart, impressions
  stacked by app, impression/click share donuts, per-campaign
  impressions+clicks and CTR bars, a radial CTR-by-app chart, a
  campaign-activity timeline (dash-mui-scheduler `EventTimeline` — the
  served history day by day), and a top-pages table. 7/30/90-day ranges.
  Backed by a new `get_timeseries` aggregation (`lib/advertising.py`) with
  a Postgres daily GROUP BY (`stats_timeseries` in `lib/ad_storage.py`) so
  the history is persistent in production, and the JSON fallback in dev;
  the page footer shows which source is live. Built with dash-mui-charts +
  dash-mui-scheduler.

- **Ad board image uploads to the network CDN** — the `/admin/ad-board`
  campaign form now has a drop zone under the Image link field: drop or pick
  an image (PNG/JPG/GIF/WebP/AVIF, up to 10 MB) and it uploads to the shared
  2plot R2 bucket and comes back as a permanent `https://cdn.2plot.ai/ads/…`
  URL, filled into the form and previewed immediately. Ads finally survive
  redeploys (no more ephemeral `/assets/` images) and the same URL works
  across every app in the ad network. Pasting an external URL still works;
  deployments without the `R2_*` env vars keep the paste-a-link flow and the
  upload zone explains itself instead of failing. (`lib/r2_images.py`; adds
  `boto3`.)

### Added — Email system (dash-email + Resend)

- **dash-email 0.0.1 docs page** (`/pip/dash_email`) — quick start, rows/columns,
  and button/CTA live examples; added to the home catalogue table and download counter
- **`/admin/email-board`** — admin control board for transactional and promotional
  email: template picker (welcome / announcement / newsletter / plain) with live
  dash_email preview and generated-HTML view; single, scheduled ("in 1 hour" / ISO),
  and per-recipient batch sends (100/chunk); recipients as a searchable MultiSelect
  populated from the Clerk user base (`lib/clerk_users.py`) with select-all and
  custom-address entry; durable outbox history (`email_outbox.json`)
- **`lib/email_provider.py`** — Resend REST API via `requests` (no SDK) +
  dash_email component→HTML renderer; verified with a live send from
  `dashboard@2plot.dev`

### Added — Advertising platform

- **`/admin/ad-board`** — campaign CRUD from an image link + destination link, with
  rotation weights, live preview, active toggles, and per-campaign
  impressions / clicks / CTR
- **Live ad serving** — ads are now resolved per page view by a mount callback
  (`serve_ad`) instead of being baked into layouts at startup: board changes apply
  in production without a restart, and impressions count real views
- **Cross-app ad network** — satellites on their own domains fetch ads from
  `GET /api/ad-network/serve` (server-to-server, per page view) and beacon clicks to
  `POST /api/ad-network/click` (CORS, preflight-free text/plain) with app + page
  attribution; per-campaign **app targeting** and a board **Performance tab**
  (per-app and per-page CTR); satellite kit `lib/ad_client.py` (2s timeout, 60s
  failure circuit breaker) wired into dash-documentation-boilerplate, dash-email,
  dash-mui-scheduler, dash-flows, and dash-mui-charts
- **Postgres persistence** (`lib/ad_storage.py`) — campaigns + analytics in the
  shared 2plot production Render Postgres (`AD_DATABASE_URL`/`DATABASE_URL`),
  auto-DDL, one-time JSON migration, JSON-file fallback in dev

### Added — Navigation & branding

- **Admin nav section** — Control Board / Email Board / Ad Board moved out of
  Components into an admin-only section, rendered per request against the
  `ADMIN_EMAILS` allowlist (links never sent to non-admins)
- **README.md** — project README in the catalogue-standard format

### Changed

- **2plot.dev rebrand** of `templates/index.html` (titles, og:site_name, noscript);
  YouTube references now `@2plotai`
- **PyPI cleanup** — dash-email 0.0.1, dash-nle-timeline 0.0.1, and
  dash-mui-scheduler 0.1.0 now install from PyPI (vendor archives removed);
  dash-mui-charts / flexlayout-dash / dash-excalidraw stay vendored while PyPI
  lags the deployed versions
- **Docs cross-links** — dash-email, dash-mui-scheduler, dash-mui-charts,
  dash-flows, and dash-nle-timeline pages now open with a link to their dedicated
  deep-dive docs site plus GitHub/PyPI links
- **Header** — "Other Apps" menu hidden below the `md` breakpoint so the header
  fits on small screens
- **`.claude/` removed from the repository** (now gitignored)

### Fixed

- **Corrupt `advertising_analytics.json`** (truncated write) that had been failing
  every impression/click log — repaired (190 impressions / 4 clicks salvaged) and
  the loader now auto-recovers with a `.corrupt` backup
- **dash-email docs examples in dark mode** — all example text now carries explicit
  email-safe colors, plus a `[data-email-component]` CSS safety net so email
  previews always render as light artifacts

---

## [2.0.0] - 2026-07-07

### Changed — Core Framework Upgrade

- **Dash 3.3.0 → 4.4.0** — removed stale `_dash_renderer` imports (DMC 2.7+ no longer
  needs `_set_react_version`); `health_endpoint`, `app.setup_apis()`, and Dash hooks all
  carried forward unchanged
- **dash-mantine-components 2.4.0 → 2.8.0** (Mantine 8.3.x) — no breaking API changes
- **dash-improve-my-llms 0.3.0 → 2.0.0** (`[flask]` extra now required) — adopted the
  `LLMS_DOC` pattern: every docs page's processed markdown is registered via
  `register_page_metadata(llms_doc=…)`, powering bot-detection prerendering now and
  `dash.mcp` resources when we reach Dash 4.3+ MCP; the package's `/page.json`,
  `/architecture.txt`, and TOON endpoints were removed upstream (our own
  `/<page>/llms.txt` and `/<page>/page.json` routes are unaffected)
- **flexlayout-dash 1.0.0 → 1.1.0** — BREAKING import rename `dash_flex_layout` →
  `flexlayout_dash`; docs folder renamed to `docs/flexlayout_dash/` (endpoint URL
  `/pip/dash_flex_layout` unchanged)
- **dash-pannellum → 0.4.0** — docs now cover `lookAt` camera control, per-name
  `callbackHotspots` diffing, and the 0.4.0 gyroscope look-around
  (`orientation` / `orientationSupported` / `orientationActive`)
- **dash-excalidraw → 0.1.0** — docs rewritten for the command/lastExport imperative
  API and the JSON-safe prop surface (38-prop table regenerated from docstrings)
- **dash-mui-charts → 1.3.0**, **dash-flows → 1.3.0**, **dash-ag-grid → 35.x**

### Added — New Component Documentation

- **dash-mui-scheduler 0.1.0** (`/pip/dash_mui_scheduler`) — MUI X Scheduler event
  calendar/timeline; replaces dash-fullcalendar
- **dash-leaflet2 0.1.0** (`/pip/dash_leaflet2`) — Leaflet 2-native map components
  (admin-only visibility by default)
- **dash-nle-timeline 0.0.1** (`/pip/dash_nle_timeline`) — non-linear-editor timeline +
  scene compositor, including the "Python owns clips / apply lastEdit" pattern

### Added — Authentication & Access Control (Clerk)

- **dash-clerk-auth 0.9.0** integration: `register_clerk_auth()` before `Dash()`
  (headless mode), `configure_app()` after; account menu embedded in the header
- **Four-tier page visibility** (`lib/page_visibility.py`): `public` / `auth` /
  `admin` (ADMIN_EMAILS/ADMIN_USER_IDS allowlist) / `hidden`, defaulting from docs
  frontmatter (`visibility:`, `llms_public:`) and resolved per request — component docs
  default to `auth` so the sign-in card drives account creation
- **Admin Control Board** (`/admin/control-board`) — live toggles for every docs page's
  visibility tier and llms.txt exposure, persisted to `page_visibility.json`, applied
  without restart
- llms.txt / page.json routes respect visibility: hidden pages 404, admin-tier content
  is never served to anonymous or crawler traffic, `llms_public` is toggleable per page
- Graceful degradation: without Clerk keys the site runs fully open (dev mode) with a
  startup warning

### Removed

- **dash-fullcalendar** — docs removed, package moved to the Archived catalogue
  (superseded by dash-mui-scheduler)

### Infrastructure

- `vendor/` wheels for not-yet-published packages (flexlayout-dash 1.1.0,
  dash-mui-charts 1.3.0, dash-mui-scheduler 0.1.0, dash-excalidraw 0.1.0,
  dash-leaflet2 0.1.0, dash-nle-timeline 0.0.1, dash-clerk-auth 0.9.0) — see
  `.claude/tasks/pypi-publish-checklist.md`
- "Other Apps" header menu → piratesbargain.com, ai-agent.buzz, 2plot.ai, 2plot.media,
  2plot.xyz
- Home catalogue: 17 maintained / 8 archived packages; download counter tracks the new
  packages

## [1.2.0] - 2026-04-08

### Added

#### Discord Integration — `dash-widgetbot` v0.4.1
- Global Discord Crate widget embedded on every page via `add_discord_crate()` in `run.py`
- Custom floating action button replacing the default Crate button (`assets/widgetbot_fix.js`)
- Slash command bridge: `/ask`, `/ai`, `/gen`, `/status`, `/navigate` commands registered and dispatched through `callbacks/discord_handlers.py`
- Crate text bridge — intercepts free-text `/cmd` messages from WidgetBot and routes to bot handlers
- Bot bridge HTTP endpoint (`/api/bot-bridge-prompt`) serving AI-assisted responses with agent avatar
- Discord interactions endpoint (`/api/discord/interactions`) with guild command registration
- `lib/bot_bridge.py` — streaming Gemini AI integration for Discord bot responses
- New docs page at `/pip/dash_widgetbot` — comprehensive guide covering Crate setup, slash commands, bridge callbacks, and functions-as-props event handling

#### `dash-flows` v1.2.0 Feature Documentation
- **Sub-flows (Collapsible Groups)** — `docs/dash_flows/subflows.py` with working double-click collapse via `toggleCollapseNode` / `collapsedGroups` callback
- **Smart Handle Positioning** — `docs/dash_flows/smart_handles.py` — edges auto-route to the closest node side
- **Floating Edges** — `docs/dash_flows/floating_edges.py` — edges that connect to node perimeters
- **Helper Lines** — `docs/dash_flows/helper_lines.py` — alignment guides when dragging nodes
- **Undo / Redo** — `docs/dash_flows/undo_redo.py` — history tracking with `enableUndoRedo`, `undoRedoAction`, `undoRedoState`
- **Add Node on Edge Drop** — `docs/dash_flows/add_node_drop.py`
- **Animated Layout Transitions** — `docs/dash_flows/animated_layout.py`
- **Computing Flows** — `docs/dash_flows/computing_flows.py` — topological sort and data propagation
- **Resize Constraints** — `docs/dash_flows/resize_constraints.py` — aspect ratio lock, min/max dimensions
- **Accessibility (ARIA)** — `docs/dash_flows/accessibility.py` — screen reader labels and keyboard navigation
- **Viewport Portal** — `docs/dash_flows/viewport_portal.py` — floating annotations at flow coordinates
- **Custom Icons** — `docs/dash_flows/custom_icons.py` — DashIconify integration with stacked/horizontal layouts

#### `dash-mui-charts` v1.1.0 Upgrade
- **LiveTradingChart** — new component: real-time OHLCV candlestick streaming with volume histogram, forecast line with uncertainty bands, and swing-point alert labels
- **Tick Configuration & Date Formatting** — new `dateFormat` / `dateTickFormat` props on time-scale axes; no JavaScript required for formatted date labels
- **Functions-as-props pattern** — `alertFormatter` and `alertFilter` props accepting `{function, options}` dicts resolved from `window.dashMuiChartsFunctions`
- `assets/muiChartsFunctions.js` — JS registry providing `priceAlertFormatter` (▲/▼ price labels) and `swingAlertFilter` (configurable lookback-based swing detection)
- New docs section **Tick Configuration & Date Formatting** in `docs/dash_mui_charts/tick_hover.py` — time-scale axis, angled labels, reference lines, click-event capture
- New docs section **Live Trading Chart** in `docs/dash_mui_charts/live_trading.py` — full interactive demo with Start/Stop/Reset, Speed/Volatility/Drift/Window sliders, stats readout, and alert history log

#### New Layout Manager Documentation
- `docs/dash_dock_view/` — comprehensive docs for `dash-dock-view` drag-and-drop panel system
- `docs/dash_flex_layout/` — comprehensive docs for `flexlayout-dash` multi-panel tabbed layout
- `docs/dash_flows/` — full v1.2.0 documentation suite (12 example files)

### Changed

- **`dash-mui-charts` alert tuning** — reduced default `alertProbability` to `0.03` and raised `alertThresholdPct` to `3.0` in the docs example to keep labels sparse and meaningful
- **Navbar** — Changelog link moved out of the Components accordion; now appears as a top-level link between Index and Docs Boilerplate in both desktop and mobile layouts
- **`requirements.txt`** — upgraded `dash-mui-charts>=1.0.0` → `dash-mui-charts==1.1.0`

### Fixed

- **`dash-flows` sub-flows collapse** — `docs/dash_flows/subflows.py` was missing the callback connecting `doubleClickedNode` → `toggleCollapseNode`; double-clicking a group node now correctly collapses and expands it
- **`dash-flows` React Flow height error** — component renders correctly inside dock tabs; error was cosmetic (fired before container had visible dimensions) and resolves on scroll

---

## [1.1.0] - 2026-01-05

### Added
- **Liquid Glass Theme Showcases**
  - Created isolated liquid glass design example for `dash_dock_view/themes_example.py` with Apple WWDC 2025 inspired glassmorphism
  - Created liquid glass theme example for `dash_flex_layout/theming_example.py` with 3-panel layout and theme switching
  - Inline style definitions for backdrop blur, saturation, translucency effects
  - Dynamic theme switching between light and dark modes with helper functions
  - 5 themed panels in dock example, 3 themed panels in flex layout example
  - Professional gradient overlays and smooth transitions

### Changed
- **Component Migration to DMC**
  - Replaced all `dash.html` components with `dash_mantine_components` (DMC) in dash_flex_layout examples
  - Migrated `ide_layout.py`, `callbacks_example.py`, and `theming_example.py` to use DMC components
  - Updated HTML elements: `html.Div` → `dmc.Box`, `html.H4` → `dmc.Title`, `html.Pre` → `dmc.Code`, `html.P` → `dmc.Text`
  - Improved component theming consistency across light and dark modes

### Fixed
- **Theme-Aware Component Styling**
  - Fixed dmc.Paper components staying white in dark mode in dash_flows examples
  - Removed hardcoded `bg="gray.0"` props from `basic_nodes_edges.py` (2 instances) and `node_interactions.py` (3 instances)
  - Papers now properly respect Mantine's automatic light/dark mode theming
  - Fixed SegmentedControl labels to use emoji strings instead of DashIconify components (prevents JavaScript errors)
  - Removed invalid `height` prop usage on DashFlexLayout components

- **Layout and Sizing Issues**
  - Added `style={"height": "100%", "width": "100%"}` to all DashFlexLayout instances
  - Fixed components not filling their containers in 5 files: `introduction.py`, `two_panel_layout.py`, `ide_layout.py`, `callbacks_example.py`, `theming_example.py`
  - Replaced `html.Div` wrappers with `dmc.Box` for proper sizing behavior
  - Ensured all examples properly utilize 500px/450px/400px container heights

### Documentation
- **Code Quality Improvements**
  - Established rule: Never use `html.Style` (doesn't exist in Dash)
  - Standardized on inline Python style dictionaries for isolated component styling
  - Added comprehensive comments to liquid glass style definitions
  - Improved code examples with theme-aware patterns

---

## [1.0.0] - 2025-11-16

### Initial Production Release

Production-ready documentation platform for Pip Install Python's custom Dash components and Python packages, now live at **https://pip-install-python.com**

---

## 🎯 Core Features

### 📚 Documentation System
- **Markdown-Driven Content**
  - Write documentation in Markdown with Python integration
  - Frontmatter metadata for page configuration
  - Custom directive system for interactive examples
  - Automatic page generation and routing
  - Table of contents generation

- **Custom Markdown Directives**
  - `.. toc::` - Automatic table of contents from headings
  - `.. exec::module.path` - Render executable Python components
  - `.. source::file/path.py` - Display source code with syntax highlighting
  - `.. sourcetabs::module.path` - Tabbed source code display
  - `.. kwargs::ComponentName` - Component props documentation tables
  - `.. llms_copy::` - Copy llms.txt URL for AI assistant sharing

- **18 Custom Component Libraries Documented**
  - Currently maintained: dash-summernote, dash-insta-stories, dash-image-gallery, dash-fullcalendar, dash-gauge, dash-emoji-mart, dash-dock, dash-pannellum, dash-planet, dash-model-viewer, dash-excalidraw
  - Archived: dash-credit-cards, dash-charty, dash-nivo, dash-discord, dash-dynamic-grid-layout, dash-swiper, dash-fullcalendar
  - Each with comprehensive guides, live examples, and download statistics

### 🎨 Modern UI/UX
- **Responsive Design**
  - Mobile, tablet, and desktop optimized layouts
  - Adaptive navigation with hamburger menu on mobile
  - Responsive AppShell with collapsible sidebar
  - Custom breakpoints for optimal viewing

- **Theme System**
  - Light and dark mode with automatic persistence
  - Browser preference detection on first visit
  - Smooth theme transitions without page flash
  - Theme-aware Plotly charts with DMC figure templates
  - Professional typography with Inter font family
  - Systematic 4px-based spacing scale
  - 5-level shadow system for depth
  - Code block theming with proper syntax highlighting

- **Navigation & Search**
  - Custom page ordering and organization
  - Searchable component navigation
  - Icon-based visual hierarchy
  - "Other Apps" menu for quick access to related projects
  - Breadcrumb-style organization

### 📊 Analytics & Tracking

- **Visitor Analytics System**
  - Session-based tracking using MD5 hash of IP + user agent
  - Device type detection (desktop, mobile, tablet, bot)
  - Bot type classification (training, search, traditional)
  - Geolocation tracking with ip-api.com integration
  - Unique visitor counting (sessions, not page views)
  - First-visit-only location data to avoid duplicates
  - Privacy-conscious tracking with session IDs
  - Race condition handling for concurrent file access
  - JSON data persistence with error recovery

- **Analytics Dashboard** (`/analytics/traffic`)
  - Real-time visitor statistics (unique visitors by device type)
  - Interactive geolocation bubble map with Plotly
  - Device breakdown visualization (desktop/mobile/tablet/bot)
  - Traffic by page with bar charts
  - Hourly traffic patterns with line charts
  - Browser detection and analytics
  - Comprehensive visitor metrics and insights

- **PyPI Download Statistics**
  - Automatic aggregation across all 18 packages
  - Real-time download counters from pypistats.org API
  - Visual presentation on home page
  - Smart error handling and caching
  - Historical data tracking

### 🤖 AI Integration & Chat

- **Page-Level AI Chat**
  - Context-aware AI assistant on every documentation page
  - Streaming SSE responses for real-time interaction
  - Page context injection (markdown content, code examples, metadata)
  - Session-based conversation tracking
  - Multiple response formats (markdown, code)
  - Chat history and analytics tracking
  - Cost and token usage monitoring
  - Error handling and graceful degradation

- **AI Analytics Dashboard** (`/analytics`)
  - Cost breakdown by model (Claude Opus, Sonnet, Haiku)
  - Total API costs and token usage tracking
  - Questions per page visualization
  - Recent user questions with interactive modal details
  - Chat log viewing with formatted Q&A display
  - Time-based filtering and analysis
  - Cost efficiency metrics

- **AI Chat Modal System**
  - Row selection in dash-ag-grid tables
  - Modal display of complete chat interactions
  - Formatted question and response sections
  - Metadata badges (timestamp, page, model, cost, tokens)
  - Markdown rendering for responses
  - Clean, professional UI with DMC Paper components

### 🔍 SEO & LLM Integration

- **dash-improve-my-llms Integration (v0.3.0)**
  - Automatic llms.txt generation for AI understanding
  - page.json with technical architecture details
  - architecture.txt with ASCII art overview
  - robots.txt with intelligent bot management
  - sitemap.xml with smart priority inference
  - Schema.org structured data for search engines
  - Custom page-specific llms.txt routes
  - Source directive processing for complete context

- **Bot Management**
  - Blocks AI training bots (GPTBot, CCBot, anthropic-ai)
  - Allows AI search bots (ChatGPT-User, ClaudeBot, PerplexityBot)
  - Allows traditional search engines (Googlebot, Bingbot)
  - Configurable crawl delays and disallowed paths
  - Privacy controls with mark_hidden() for sensitive pages

- **SEO Optimization**
  - Google Analytics integration (G-6WYY9JHMP2)
  - Minimal, optimized HTML template
  - Fast page load times
  - Mobile-friendly design
  - Proper meta tag configuration
  - Structured data for rich search results

### 🐋 Production Infrastructure

- **Technology Stack**
  - **Dash 3.2.0** - Modern Plotly Dash framework
  - **Dash Mantine Components 2.4.0** - Beautiful React UI
  - **Mantine 8.3.6** - Latest Mantine design system
  - **React 18.2.0** - Modern React features
  - **Python 3.11+** - Latest Python capabilities
  - **Flask 3.1.2** - Production web server
  - **Plotly 6.4.0** - Interactive visualizations

- **Deployment Ready**
  - Docker and docker-compose support
  - Gunicorn production server configuration
  - Environment-based configuration
  - Error handling and logging
  - Graceful degradation for missing data
  - Production URL configuration (pip-install-python.com)

- **Performance Optimizations**
  - Fast file pattern matching with Glob
  - Efficient JSON data persistence
  - LRU caching for geolocation lookups
  - Optimized font loading (Inter, JetBrains Mono)
  - CSS optimization and minification
  - Chart template caching
  - Smart skip paths for analytics tracking

### 🎯 Developer Experience

- **Code Quality**
  - PEP 8 compliant Python code
  - Comprehensive inline comments
  - Type hints where appropriate
  - Modular, reusable components
  - Clean separation of concerns
  - Error handling throughout

- **Documentation Quality**
  - 5 comprehensive guides with 15+ examples
  - Live interactive code demonstrations
  - Best practices and patterns
  - Troubleshooting guides
  - Migration documentation
  - API references

- **Development Tools**
  - Hot reload during development
  - Debug mode support
  - Comprehensive error messages
  - Development server on port 8502
  - Environment variable support

---

## 📁 Project Structure

```
pip-docs/
├── assets/                          # Static assets
│   ├── main.css                    # Custom styles (theme-aware)
│   ├── m2d.css                     # Markdown styling
│   ├── chat.js                     # AI chat client
│   ├── llms_copy.js                # LLM copy button handler
│   ├── model_viewer_*.js           # 3D model viewer scripts
│   └── [images, icons, etc.]
│
├── callbacks/                       # Dash callbacks
│   └── chat_callbacks.py           # AI chat functionality
│
├── components/                      # UI components
│   ├── appshell.py                 # Main layout with MantineProvider
│   ├── header.py                   # Header with search and theme toggle
│   └── navbar.py                   # Navigation sidebar (custom ordering)
│
├── docs/                            # Documentation content
│   ├── dash_*/                     # Component-specific docs (18 packages)
│   └── [markdown files + Python examples]
│
├── lib/                             # Utility libraries
│   ├── analytics_tracker.py        # Visitor analytics system
│   ├── constants.py                # App-wide constants
│   ├── directives/                 # Custom markdown directives
│   └── page_chat/                  # AI chat context gathering
│
├── pages/                           # Dash pages
│   ├── home.py                     # Home page with download stats
│   ├── markdown.py                 # Dynamic markdown loader
│   ├── analytics.py                # Visitor analytics dashboard
│   ├── api_analytics.py            # AI chat analytics dashboard
│   └── not_found_404.py            # Custom 404 page
│
├── templates/
│   └── index.html                  # Production HTML template
│
├── CHANGELOG.md                    # This file
├── README.md                       # Project documentation
├── CLAUDE.md                       # AI development notes
├── requirements.txt                # Python dependencies
├── package.json                    # Node.js dependencies
├── run.py                          # Application entry point
└── visitor_analytics.json          # Analytics data store
```

---

## 🔧 Configuration

### Environment Variables
- `DASH_DEBUG` - Enable debug mode (default: False)
- `DASH_HOST` - Server host (default: 0.0.0.0)
- `DASH_PORT` - Server port (default: 8502)
- `ANTHROPIC_API_KEY` - API key for AI chat (required for chat features)

### Key Configuration Points
- `run.py:44` - Base URL for SEO (set to https://2plot.dev)
- `run.py:47-53` - Bot management policies
- `lib/constants.py` - App-wide constants and theming
- `templates/index.html` - Google Analytics and meta tags
- `components/appshell.py` - Theme configuration and MantineProvider settings

---

## 📊 Analytics Features Breakdown

### Session-Based Tracking
- Unique session ID generated from IP address + user agent (MD5 hash)
- Prevents duplicate counting of same visitor across pages
- First-visit-only geolocation to reduce API calls
- Consistent localhost location assignment for development testing

### Visitor Analytics
- **Device Detection**: Desktop, mobile, tablet, bot classification
- **Bot Classification**: Training bots, search bots, traditional crawlers
- **Geolocation**: City, region, country with coordinates
- **Privacy**: No PII stored, only hashed session IDs
- **Performance**: Skip tracking for assets (.css, .js, images, Dash internals)

### AI Chat Analytics
- **Cost Tracking**: Per-model cost breakdown (Opus $15/$75, Sonnet $3/$15, Haiku $0.25/$1.25)
- **Token Usage**: Input and output token counting
- **Question Analysis**: Track questions per page
- **Response Logging**: Complete chat history with timestamps
- **Modal Details**: Interactive row selection for full chat logs

---

## 🚀 Getting Started

### Installation
```bash
# Clone repository
git clone https://github.com/pip-install-python/pip-docs.git
cd pip-docs

# Install Python dependencies
pip install -r requirements.txt

# Install Node dependencies
npm install

# Run development server
python run.py
```

### Access Points
- **Main Application**: http://localhost:8502
- **Visitor Analytics**: http://localhost:8502/analytics/traffic
- **AI Chat Analytics**: http://localhost:8502/analytics
- **404 Page**: http://localhost:8502/404

### Production Deployment
```bash
# Build Docker image
docker build -t pip-docs .

# Run with Docker Compose
docker-compose up -d

# Access at port 8550
```

---

## 🎯 Future Enhancements

### Planned Features
- Extended analytics dashboard with more visualizations
- User authentication and personalized experiences
- Advanced search with full-text indexing
- Version switcher for documentation
- Code playground/sandbox for testing components
- Automated screenshot generation for examples
- More chart types (heatmaps, 3D plots, geographic maps)
- Export analytics to CSV/JSON
- Real-time visitor tracking with WebSocket
- A/B testing framework for documentation

### Under Consideration
- Multi-language support (i18n)
- Community contributions and voting system
- Interactive tutorials and walkthroughs
- Video tutorials integration
- RSS feed for updates
- Newsletter integration
- Component comparison tools
- Performance benchmarking suite

---

## 🙏 Acknowledgments

### Built With
- [Plotly Dash](https://dash.plotly.com/) - Web framework
- [Dash Mantine Components](https://dash-mantine-components.com/) - UI components
- [Mantine](https://mantine.dev/) - React component library
- [dash-improve-my-llms](https://pypi.org/project/dash-improve-my-llms/) - AI/SEO integration
- [dash-ag-grid](https://dash.plotly.com/dash-ag-grid) - Advanced data grids
- [Plotly](https://plotly.com/) - Interactive visualizations
- [Anthropic Claude](https://anthropic.com/) - AI chat capabilities

### Special Thanks
- Plotly Dash team for the amazing framework
- Snehil Vijay for Dash Mantine Components & Ann Marie W for maintaining DMC
- The many open-source community members for continuous support
- All contributors to the custom Dash components

---

## 📝 License

This project is licensed under the MIT License - see the LICENSE file for details.

---

## 📞 Support & Community

### Get Help
- **GitHub Issues**: [Report bugs or request features](https://github.com/pip-install-python/pip-docs/issues)
- **GitHub Discussions**: [Ask questions and share ideas](https://github.com/pip-install-python/pip-docs/discussions)
- **Dash Community**: [Plotly Community Forum](https://community.plotly.com/)
- **Discord**: [Join the Dash Discord](https://discord.gg/uwQ2f3KCad)

### Stay Connected
- **Website**: https://2plot.dev
- **GitHub**: [@pip-install-python](https://github.com/pip-install-python)
- **YouTube**: [Pip Install Python](https://youtube.com/@PipInstallPython)
- **2plot.ai**: https://plotly.pro
- **ai-agent.buzz**: https://ai-agent.buzz

---

**Made with ❤️ by Pip Install Python LLC**

**Star this repo if you find it useful!** ⭐

---

[1.1.0]: https://github.com/pip-install-python/pip-docs/releases/tag/v1.1.0
[1.0.0]: https://github.com/pip-install-python/pip-docs/releases/tag/v1.0.0
