Version history and recent changes to 2plot.dev and its component libraries

Changelog

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

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, and this project adheres to Semantic Versioning.


[Unreleased]

Added

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.

(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

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.

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.

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).

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.

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

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

third party** (owner decision — "I don't think we are gaining much from this"). Four things went, together:

every session. It now takes ANALYTICS_KEEP_CLIENT_IP=1 — the same switch the read table already honoured — and it is off.

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".

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.

stored IPs to ip-api.com — both retired practices in one file.

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

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-forremote_addr. Consequence worth stating plainly: visitor counts, sessions and geography on this host were the edge's, and are only now the visitors'.

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

/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

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.

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.

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.

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

footer shape — the sidebar's Changelog link is the one.

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)

(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.

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

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

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

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".

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.

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

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.

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.

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.

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.)

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.

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.

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.

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.

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.

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.

.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.

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

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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

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.

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.

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.

.. 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.

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.

under an intro that already supplies # Changelog, so the file's own title was a second h1 with identical text.

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.

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.

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.

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.

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

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.

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.

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

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.

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.

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.

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.

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.

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.

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.

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.

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.

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

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.

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.

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

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.

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

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.

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).

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.

.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.

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 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.)

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.

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.

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.

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.

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.

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.

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.

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.

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.

_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.

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.

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.

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.

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.

two** — the link-suffix helper double-verified the same key on every link-generating surface.

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.

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.

./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.

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.

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.

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

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.

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.

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.

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).

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

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).

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/).

0.9.2 and 1.0.0 wheels, the only differences are __version__ and one corrected docstring (clear_headerclear_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.

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.

Dash Documentation BoilerplateBoilerplate, Dash Improve My LLMsLLMs, Dash EmailEmail, Dash FlowsFlows — via each page's frontmatter name:. Endpoints, package names and descriptions are untouched, so no URL or catalogue entry moves.

https://boilerplate.2plot.dev rather than its onrender.com origin, matching the subdomain promoted to live in the network directory.

Added

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

shippinglive, 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

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 and 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

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).

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

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.

in the satellite's own source, per the "verified, never guessed" rule.

Added

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

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.

(all ~27 docs pages via pages/markdown.py, plus every static page); the home page finally has a description= too.

the image auxiliaries (secure_url, type, width, height, alt, twitter:image:alt). The doubled og:*/twitter:* set is deleted.

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.

now derived from SITE_SHORT_NAME ("2plot.dev | "), and the changelog and analytics pages join the prefix convention.

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 keyboilerplate, leaflet, email, flexlayout — never the package name:

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.

X-Satellite-App, page-tiers — so a satellite still sending ?app=dash-leaflet2 accrues under leaflet and history stays one series.

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.

rename lands with zero history to migrate.

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:

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.

site's name.

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.

INTERNAL_UA_TOKEN ("2plot-internal").

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.

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:

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.

against production (stdlib-only, case-insensitive response headers), so the two failure modes read identically.

with cancel-in-progress, timeout-minutes on every job, buildx type=gha layer caching.

≥ 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+.

monthly actions) and an advisory pip-audit job.

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.

(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.

"{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.

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.

when that user is an admin here. Verification is the lock; the cap is what still holds if the lock is ever misconfigured.

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.

page's path in it discloses a URL meant to be undiscoverable.

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.

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

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.

tierllms_publicanonymouswith an agent linksigned in
publiconproseproseprose
publicoff404404404
authonprose (unchanged)proseprose
authoffgate documentproseprose
admineither404prose (admin key)prose (admins)
hiddeneither404404404

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.

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:

button, and the "Copy for llm" button already produces the right URL;

(CLERK_WEBHOOK_SECRET was in .env but nothing consumed it until now);

alive;

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.

"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

<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.

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.

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.

/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.

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

(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-SearchBotAllow: /). 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.

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.

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.

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.

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.

(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.

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.

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.

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

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).

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.

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.

/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

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

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.

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)

and button/CTA live examples; added to the home catalogue table and download counter

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)

dash_email component→HTML renderer; verified with a live send from [email protected]

Added — Advertising platform

rotation weights, live preview, active toggles, and per-campaign impressions / clicks / CTR

(serve_ad) instead of being baked into layouts at startup: board changes apply in production without a restart, and impressions count real views

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

shared 2plot production Render Postgres (AD_DATABASE_URL/DATABASE_URL), auto-DDL, one-time JSON migration, JSON-file fallback in dev

Added — Navigation & branding

Components into an admin-only section, rendered per request against the ADMIN_EMAILS allowlist (links never sent to non-admins)

Changed

YouTube references now @2plotai

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

dash-flows, and dash-nle-timeline pages now open with a link to their dedicated deep-dive docs site plus GitHub/PyPI links

fits on small screens

Fixed

every impression/click log — repaired (190 impressions / 4 clicks salvaged) and the loader now auto-recovers with a .corrupt backup

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

needs _set_react_version); health_endpoint, app.setup_apis(), and Dash hooks all carried forward unchanged

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; docs folder renamed to docs/flexlayout_dash/ (endpoint URL /pip/dash_flex_layout unchanged)

callbackHotspots diffing, and the 0.4.0 gyroscope look-around (orientation / orientationSupported / orientationActive)

API and the JSON-safe prop surface (38-prop table regenerated from docstrings)

Added — New Component Documentation

calendar/timeline; replaces dash-fullcalendar

(admin-only visibility by default)

scene compositor, including the "Python owns clips / apply lastEdit" pattern

Added — Authentication & Access Control (Clerk)

(headless mode), configure_app() after; account menu embedded in the header

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

visibility tier and llms.txt exposure, persisted to page_visibility.json, applied without restart

is never served to anonymous or crawler traffic, llms_public is toggleable per page

startup warning

Removed

(superseded by dash-mui-scheduler)

Infrastructure

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

2plot.xyz

packages

[1.2.0] - 2026-04-08

Added

Discord Integration — dash-widgetbot v0.4.1

dash-flows v1.2.0 Feature Documentation

dash-mui-charts v1.1.0 Upgrade

New Layout Manager Documentation

Changed

Fixed


[1.1.0] - 2026-01-05

Added

Changed

Fixed

Documentation


[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

🎨 Modern UI/UX

📊 Analytics & Tracking

🤖 AI Integration & Chat

🔍 SEO & LLM Integration

🐋 Production Infrastructure

🎯 Developer Experience


📁 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

Key Configuration Points


📊 Analytics Features Breakdown

Session-Based Tracking

Visitor Analytics

AI Chat Analytics


🚀 Getting Started

Installation

# 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

Production Deployment

# Build Docker image
docker build -t pip-docs .

# Run with Docker Compose
docker-compose up -d

# Access at port 8550

🎯 Future Enhancements

Planned Features

Under Consideration


🙏 Acknowledgments

Built With

Special Thanks


📝 License

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


📞 Support & Community

Get Help

Stay Connected


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

Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs: