# Dash Documentation Boilerplate — the 2plot network's template — full corpus

## Access policy

- Terms: these documents are free to fetch. A free account unlocks any gated document.
- Identity: agents may present a key by appending `?key=<value>` to any document URL. Get one: https://2plot.ai
- Rate: prefer ONE `/llms-full.txt` fetch over N per-page fetches. On 429, honour `Retry-After` and back off exponentially.
- Coordination: start at https://2plot.dev/llms.txt — one index enumerates every site; do not rediscover the network by crawling it.
- Crawler policy (mirrors /robots.txt): allowed: GPTBot, ClaudeBot, CCBot, Google-Extended, FacebookBot, Omgili, ByteSpider, Amazonbot, Applebot-Extended, meta-externalagent, AI2Bot, Diffbot, Timpibot, ImagesiftBot, ChatGPT-User, Claude-User, Claude-SearchBot, PerplexityBot, OAI-SearchBot, Perplexity-User, Googlebot, Bingbot, Slurp, DuckDuckBot, GoogleOther, Google-InspectionTool, Storebot-Google, AdsBot-Google.
- Accounting: every document read is logged with the requesting vendor (verified against published IP ranges where the operator publishes them). See https://2plot.dev/llms.txt

> dash-documentation-boilerplate — the markdown-driven documentation template every *.2plot.dev component site is forked from. Interactive examples, Dash Mantine Components theming, and first-class AI/LLM and SEO surfaces via dash-improve-my-llms. By Pip Install Python.

Generated from 15 pages. The page index lives at https://boilerplate.2plot.dev/llms.txt and a compact briefing at https://boilerplate.2plot.dev/llms-small.txt; each page also serves its own document at `<page>/llms.txt`.

---

<!-- / — https://boilerplate.2plot.dev/llms.txt -->

# Dash Documentation Boilerplate — the 2plot network's template

![logo](assets/intro_img.jpg)

> **`dash-documentation-boilerplate` — the markdown-driven documentation template every `*.2plot.dev` component site is forked from.** By [Pip Install Python](https://2plot.dev).

```bash
git clone https://github.com/pip-install-python/dash-documentation-boilerplate
```

This host documents no package of its own — it is the template the network's component sites are forked from.

Create beautiful, interactive documentation for your Dash components, data science workflows, and applications with markdown-driven content, live code examples, and automatic theme persistence.

---

## What is This?

The Dash Documentation Boilerplate is a **production-ready framework** for creating professional documentation sites for your Dash projects. Whether you're documenting a component library, showcasing data visualizations, or building a comprehensive application guide, this boilerplate provides everything you need.

It is also the reference implementation of the **2plot network standard**: the site-identity rules, the internal-traffic analytics contract, and the CI baseline that every satellite copies verbatim. Those files are listed on the [Network Standard](/network-standard) page.

### Built With Modern Technologies

- **Dash 4.1+** - Pluggable backends (Flask / FastAPI / Quart), MCP-aware
- **Dash Mantine Components 2.7+** - Beautiful, accessible UI components
- **Mantine 8.3+** - Modern React component library
- **React 18** - Latest React features
- **Python 3.12+** - Modern Python with type hints

---

## Key Features

### 📝 Markdown-Driven Documentation
Write your documentation in **markdown files** with full Python integration. The framework automatically discovers markdown files in the `docs/` directory and generates pages with:

- **Frontmatter metadata** for page configuration
- **Custom directives** for interactive examples
- **Automatic routing** based on your file structure
- **Table of contents** generation

### 🎨 Beautiful UI/UX
Built with Dash Mantine Components for a modern, professional look:

- **Responsive design** - Works beautifully on mobile, tablet, and desktop
- **Dark & Light themes** - Automatic theme persistence via localStorage
- **Smooth transitions** - Professional animations and interactions
- **Customizable** - Easy to theme with your brand colors
- **Accessible** - WCAG compliant components

### 🔧 Custom Directives
Powerful directives to enhance your documentation:

- `.. toc::` - Generate table of contents from headings
- `.. exec::module.path` - Embed interactive Python components
- `.. source::path/to/file.py` - Display source code with syntax highlighting
- `.. kwargs::ComponentName` - Auto-generate component props documentation

### 🤖 AI/LLM Integration
Powered by [dash-improve-my-llms](https://pypi.org/project/dash-improve-my-llms/) **2.10.0** (the version is read from the installed package, never hardcoded):

- **`LLMS_DOC` pattern** — write a module-level prose string per page; the package serves it verbatim at `/<page>/llms.txt`
- **Tiered corpus** — `/llms.txt` (index), `/llms-small.txt` (compact briefing), `/llms-full.txt` (full corpus), each rendered for browsers and raw for agents
- **Multi-backend** — same surface under Flask, FastAPI, and Quart (auto-detected)
- **MCP bridge** — each page's prose registers as a `dash.mcp` resource on Dash 4.3+
- **SEO** — `/sitemap.xml` with priority inference, `/robots.txt` with bot-class policies, and `configure_seo()` so crawlers receive the same icons, social card, and titles a browser gets
- **Bot management** — training crawlers blocked, search citations allowed, browsers untouched
- **Share with AI** — paste your URL into ChatGPT/Claude; they read the prose docs directly

### 🐋 Production Ready

- **Docker support** - Dockerfile and docker-compose included
- **Gunicorn server** - Production-ready WSGI server
- **Environment config** - Easy deployment configuration
- **Optimized builds** - Fast loading and rendering

---

## Quick Start

### 1. Installation

```bash
# Clone the repository
git clone https://github.com/pip-install-python/Dash-Documentation-Boilerplate.git
cd Dash-Documentation-Boilerplate

# Install Python dependencies
pip install -r requirements.txt
# markdown2dash pins gunicorn<22 against this project's CVE-driven
# gunicorn>=23 floor, so it installs without its dependency graph.
pip install --no-deps markdown2dash==0.1.2

# Install Node dependencies (for Mantine components)
npm install
```

### 2. Run the Development Server

```bash
./scripts/dev.sh          # or: python run.py
```

Visit `http://localhost:8559` in your browser.

### 3. Create Your First Documentation Page

Create a new folder in `docs/` with a markdown file:

```markdown
---
name: My Component
description: Description of my awesome component
endpoint: /components/my-component
icon: mdi:code-tags
---

## My Component

Your documentation content here...

.. toc::

## Features

- Feature 1
- Feature 2
```

That's it! Your page will automatically appear in the navigation.

---

## Example Documentation

This site includes several example pages to demonstrate the capabilities:

- **Getting Started** - Learn how to create documentation pages
- **Custom Directives** - See all available directives in action
- **Interactive Components** - Examples of callbacks and state management
- **Data Visualization** - Plotly integration examples
- **AI Integration** - Showcase AI/LLM features

---

## Project Structure

```
dash-documentation-boilerplate/
├── assets/                      # Static assets and CSS
│   ├── m2d.css                 # Markdown-to-Dash styling
│   └── main.css                # Custom styles
│
├── components/                  # Reusable UI components
│   ├── appshell.py             # Main app layout
│   ├── header.py               # Header with search and theme toggle
│   └── navbar.py               # Navigation sidebar
│
├── docs/                        # Your documentation content
│   └── your-component/
│       ├── component.md        # Markdown documentation
│       └── examples.py         # Python interactive examples
│
├── lib/                         # Utility libraries
│   ├── constants.py            # App-wide constants
│   └── directives/             # Custom markdown directives
│       ├── kwargs.py           # Component props tables
│       ├── source.py           # Source code display
│       └── toc.py              # Table of contents
│
├── pages/                       # Dash multi-page app
│   ├── home.md                 # This home page
│   ├── home.py                 # Home page layout
│   └── markdown.py             # Dynamic markdown loader
│
├── scripts/                     # Post-deploy checks against a live site
├── tests/                       # pytest suite (all three backends)
├── .github/workflows/           # CI and CD
│
├── templates/
│   └── index.html              # Custom HTML template
│
├── CHANGELOG.md                # Version history
├── README.md                   # Full documentation
├── render.yaml                 # Render deployment blueprint
├── requirements.txt            # Python dependencies
├── package.json                # Node dependencies
├── Dockerfile                  # Docker container
└── run.py                      # Application entry point
```

---

## Customization

### Change Primary Color

Edit `lib/constants.py`:

```python
PRIMARY_COLOR = "teal"  # Change to any Mantine color
```

### Modify Styles

- `assets/main.css` - General application styling
- `assets/m2d.css` - Markdown-specific styling

### Configure AI/LLM Integration

Update `run.py` to configure bot management and SEO:

```python
from dash_improve_my_llms import RobotsConfig

app._base_url = "https://your-production-url.com"
app._robots_config = RobotsConfig(
    block_ai_training=True,
    allow_ai_search=True,
    crawl_delay=10
)
```

---

## Deployment

### Docker

```bash
# Build the image
docker build -t dash-docs-boilerplate .

# Run the container
docker run -p 8550:8550 dash-docs-boilerplate
```

### Docker Compose

```bash
docker-compose up
```

Visit `http://localhost:8550`

---

## Resources

- **GitHub Repository**: [Dash-Documentation-Boilerplate](https://github.com/pip-install-python/Dash-Documentation-Boilerplate)
- **Full Documentation**: See [README.md](https://github.com/pip-install-python/Dash-Documentation-Boilerplate/blob/main/README.md)
- **Changelog**: [CHANGELOG.md](https://github.com/pip-install-python/Dash-Documentation-Boilerplate/blob/main/CHANGELOG.md)
- **AI Integration Guide**: [AI/LLM Integration](/examples/ai-integration)
- **Multi-Site Networks**: [Wiring a family of documentation sites together](/networks)

### Community

- **GitHub**: [@pip-install-python](https://github.com/pip-install-python) ![GitHub](https://img.shields.io/github/followers/pip-install-python?style=social)
- **YouTube**: [2plot.ai](https://www.youtube.com/@2plotai?sub_confirmation=1) — build-alongs and component walkthroughs

---

## License

MIT License - see [LICENSE](https://github.com/pip-install-python/Dash-Documentation-Boilerplate/blob/main/LICENSE) for details.

---

**Ready to start?** Check out the example documentation pages to see what you can build!

---

<!-- /authentication — https://boilerplate.2plot.dev/authentication/llms.txt -->

# Authentication

> Gate documentation pages with Clerk — as a standalone site, or as one satellite in a network of documentation sites

---



### Start here: you probably don't need this

Authentication in this template is **entirely optional and off by default**.
With no `CLERK_*` keys in the environment — which is how a fresh clone runs —
nothing on this page is active. No script injection, no session machinery, no
routes, no per-request checks. Every page is public, exactly as it would be if
this page did not exist.

That is deliberate, and it is the first design rule worth stating: **a
documentation site must never break because a deploy forgot a credential.**
Everything below fails in that direction.

Read the layer you need and stop:

| You want | Read |
|---|---|
| Public documentation, no accounts | Nothing. You're done. |
| One site, some pages behind a login | **Layer 1** |
| Several documentation sites sharing one account system | **Layer 2** |

---

### The distinction the whole design follows from

Two different questions get asked of a documentation site, and only one of them
arrives with a session attached:

| | Question | Answered by | Asked by |
|---|---|---|---|
| 1 | Who is this human? | A session cookie | A person in a browser |
| 2 | May this fetch of a text file proceed? | A key in the URL | An agent, later, elsewhere |

**A cookie can never answer question 2.** When someone signs in, copies
`https://yoursite.dev/guide/llms.txt`, and pastes it into an assistant, the
assistant fetches that URL with **no cookie**. A cookie-only gate therefore
returns the gate document to the one consumer the URL exists for, and your site
looks broken to exactly the audience `llms.txt` was built for.

So authority has to be able to travel *in the URL*:

```
https://yoursite.dev/guide/llms.txt?key=k2p_8f31…
```

Layer 1 needs only question 1. Layer 2 needs both.

---

### Layer 1 — one site, your own Clerk

The simplest useful setup: your docs, your Clerk application, some pages
requiring a login.

#### 1. Install the auth package

`dash-clerk-auth` is not on PyPI. Vendor the wheel and install it:

```bash
pip install ./vendor/dash_clerk_auth-0.9.1.tar.gz
```

It stays out of the active `requirements.txt` on purpose — a default install
should not pull in an auth stack the site does not use.

#### 2. Set the keys

```env
CLERK_SECRET_KEY=sk_test_...        # backend SDK key, never sent to the client
CLERK_PUBLISHABLE_KEY=pk_test_...   # embedded in the injected <script>
CLERK_SIGN_IN_URL=https://your-app.accounts.dev/sign-in
SESSION_SECRET=<a long random string>
```

All three `CLERK_*` values must be present. `lib/auth.py`'s `clerk_enabled()`
is the single source of truth, and it is all-or-nothing by design — a
half-configured auth stack is worse than none, because it looks enabled.

`SESSION_SECRET` is not optional in production. Without it `dash-clerk-auth`
falls back to a **public development default**, and the app warns loudly at
startup.

#### 3. Mark a page

Tiers live in the page's markdown frontmatter:

```markdown
---
name: Internal Runbook
description: How we deploy
endpoint: /runbook
tier: admin
---
```

Four tiers, least to most restrictive:

| Tier | Who may read | The document | Listed in sitemap and index? |
|---|---|---|---|
| `public` | anyone, including agents and crawlers | prose, 200 | yes |
| `auth` | any signed-in user | gate document, **200** | **yes** |
| `admin` | signed in *and* allowlisted | gate document, 200 | yes |
| `hidden` | nobody | **404** | no |

`auth` and `hidden` are genuinely different intents, and the difference is what
makes a sign-up funnel work:

- **`auth` / `admin` → gated.** "This document exists and you may not read it
  yet." The URL stays in the sitemap and the index, because a page's existence
  is public knowledge even when its content is not. An agent that fetches it
  learns what the page is and how its user gets access.
- **`hidden` → denied.** "There is nothing here." Same 404 as a page that never
  existed, delisted everywhere. Use it where advertising the URL is itself a
  disclosure.

Absent frontmatter, a page is `public`. Change the default for a whole site
with `PAGE_DEFAULT_TIER`.

#### 3b. Flip tiers live: the control board

Frontmatter is the *declaration*; `/admin/control-board` is the *override*.
Every docs page gets a row there with a tier selector and an
`llms.txt public` switch, and a toggle applies on the next page render —
no restart, no redeploy. The full precedence, strongest claim first:

```
control-board override  ->  frontmatter tier:  ->  PAGE_DEFAULT_TIER
```

with the hub's network ceiling still applied on top of whatever resolves
locally — an override can loosen your own declaration, never what the
network restricted.

The board is owner/admin-only (the `ADMIN_EMAILS` / `ADMIN_USER_IDS`
allowlist below) and **fails closed**: without Clerk configured it
returns a 404-style card rather than a working panel, because a board
that can hide any page on the site must never fall open. Set
`ALLOW_UNGATED_ADMIN=1` to work on it locally.

Overrides persist to `PAGE_VISIBILITY_FILE` — point it at a mounted disk
in production (`/var/data/page_visibility.json` in `render.yaml`) or
every toggle silently resets on the next deploy. The boot log warns
loudly when the variable is unset *or* the path is not on a real mount;
the absence of that `[visibility]` warning in a deploy log is the
acceptance check.

#### 4. Who counts as an admin

```env
ADMIN_EMAILS=you@example.com,colleague@example.com
ADMIN_USER_IDS=user_2abc...
OWNER_EMAIL=you@example.com
```

The owner's email always counts, so a deploy that set the Clerk keys but forgot
`ADMIN_EMAILS` still lets you in.

#### 5. Local development

```env
DISABLE_CLERK=1
```

Reads as "intentionally off" without touching the `CLERK_*` values. This exists
because a shell env prefix does not survive an IDE run configuration, and
half-disabling auth by deleting one key produces confusing failures.

---

### Layer 2 — a network of documentation sites

This is what `*.2plot.dev` runs. One Clerk application serves a **primary**
domain and any number of **satellite** domains, so the same person is
recognised across every site without signing in twice.

```
2plot.ai            Clerk primary — hosts sign-in, owns the accounts
2plot.dev           satellite + network hub — mints and verifies keys
leaflet.2plot.dev   satellite
flows.2plot.dev     satellite
…                   every component documentation site
```

#### Satellite configuration

```env
CLERK_IS_SATELLITE=true
CLERK_SATELLITE_DOMAIN=flows.2plot.dev   # host only, no scheme
CLERK_FRONTEND_API=https://<app>.clerk.accounts.dev
CLERK_SIGN_IN_URL=https://2plot.ai/sign-in
CLERK_SIGN_UP_URL=https://2plot.ai/sign-up
```

`CLERK_SATELLITE_DOMAIN` defaults to the host in `APP_BASE_URL`, which every
deployment must set anyway — so in practice you set one variable and the
satellite domain follows. That is deliberate: it removes a way to announce
another site's domain to Clerk.

`CLERK_FRONTEND_API` is **required** in satellite mode. A production
custom-domain instance cannot derive it from `CLERK_SIGN_IN_URL`.

Leave satellite mode off locally — Clerk rejects satellites on `localhost`. A
`pk_test` key stays primary; a `pk_live` key with a satellite domain
auto-enables satellite mode even if `CLERK_IS_SATELLITE` was missed, so
production cannot silently boot in the wrong one.

Each subdomain must also be added to the **allowed-subdomains list in the Clerk
dashboard** before it will work.

#### The ordering that matters

`lib/access.py` resolves in this order, and the order is the design:

```
tier  =  board override -> frontmatter -> PAGE_DEFAULT_TIER   (then the hub ceiling)
      ->  short-circuit public / hidden
      ->  local Clerk session          (a person in a browser)
      ->  hub verification of ?key=    (an agent, later, elsewhere)
```

**Local session first, hub only as a fallback.** A signed-in visitor is
resolved entirely on their own satellite, so the hub being down gates nothing
for them. Only the agent path — no cookie, key in the URL — needs the hub.

Reversing this couples every satellite's availability to one host for no
benefit: the fail-safe is `gated`, so a hub outage would silently gate every
restricted document on every subdomain at once. It is the single most
consequential ordering in this template.

#### Where key material lives, and why not here

A satellite holds **no key material at all**. It cannot mint keys and it cannot
verify them offline; it asks the hub.

The tempting alternative is to share the hub's signing secret so every
satellite can verify locally with no round trip. Reject it: the key is derived
by HMAC over the user, session and scope, so **anything that can verify can
also mint** — including an admin-scoped key. Sharing it puts a network-wide
admin-minting secret on twenty deployments, where one compromised satellite is
a full compromise, and rotation means redeploying all of them at once.

What a satellite *does* hold is `CROSS_APP_WEBHOOK_SECRET` — the same shared
secret the traffic reporter already uses — which authenticates *who is asking*.
It signs the request; it derives nothing. A verify endpoint open to the
internet would be a key-guessing oracle, which is why the caller is
authenticated as well as the key.

#### The hub sets the ceiling

Once the hub publishes tiers, two writers exist over one value. The rule:

```
effective_tier = more_restrictive_of(hub_tier, local_tier)
```

A satellite may lock its own page down further. It may **never** loosen what
the hub restricted. So a satellite misconfiguration cannot expose something the
network gated, and "loosen this page" stays a hub action — which is where that
authority belongs.

#### Keep the network catalogue public

The hub's `/llms.txt` is the map of what exists. **Gate individual documents,
not the map.** An agent that follows a satellite's "Network index" link and
arrives with no key should get exactly what it asked for: a list of what the
network contains.

This is also why the package never appends a key to a cross-host link. A
capability should not travel to another origin, and with a public catalogue it
does not need to.

---

### Two traps worth naming

**Do not wire the Clerk token's `iat` as "signed in since".** The session token
is refreshed roughly every 60 seconds, so `iat` is the age of the *token*, not
of the sign-in. Wiring it renders a clock that resets every minute. `lib/auth.py`
keeps a session-first-seen timestamp keyed on `ClerkUser.session_id` instead.

**Never put the visitor's identity in the network bulletin.** The bulletin is
TTL-cached and shared by every viewer of every satellite. A username in it is a
privacy bug wearing a cache. Identity is per-request and local — that is what
`configure_viewer_identity()` is for, and it renders in the HTML viewer only,
so the Markdown an agent receives is byte-identical either way.

---

### What the reader actually sees

| Reader | `auth` page | Its `llms.txt` |
|---|---|---|
| Anonymous browser | gate document | gate document, 200 |
| Signed-in browser | full prose | full prose, plus their name in the viewer banner |
| Agent, no key | gate document, 200 | gate document explaining how to get access |
| Agent with a valid key | full prose | full prose, and generated links carry the key onward |
| Any reader, `hidden` page | 404 | 404 |

The gate document is deliberately useful rather than a wall: it names the page,
says what would unlock it, and points at where to sign in. An agent that
fetches a gated document should come away knowing what it is and how its user
gets access — that is the difference between a gate and a dead end.

---

### Verifying

The checks that justify this design over the simpler ones, all automated in
`tests/test_access.py`:

```
1. Signed-in browser, hub UNREACHABLE  -> still allowed   (local resolution)
2. Agent with valid key, hub reachable -> allowed
3. Agent with valid key, hub down      -> gated, not 500, not prose
4. Satellite sets public, hub says auth -> effective auth  (ceiling holds)
5. Key never appears in sitemap.xml, canonical tags, or cross-host links
6. Identity renders in the HTML viewer only; Markdown byte-identical
7. No tiers declared -> byte-identical to a build with no access control
```

Checks 1 and 3 are the point. Check 7 is what lets a fork inherit all of this
without inheriting a gate it did not ask for.


```python
# File: lib/access.py

"""The access policy this site hands to dash-improve-my-llms.

One function matters — :func:`check` — and its *ordering* is the whole design:

    tier -> short-circuit public/hidden
         -> local Clerk session          (a person in a browser)
         -> hub verification of ?key=    (an agent, later, elsewhere)

**Local session first, hub only as a fallback.** A signed-in visitor is
resolved entirely on this host, so the hub being down gates nothing for them.
Only the agent path — a fetch with no cookie, carrying a key in the URL —
needs the hub at all. Reversing this order would couple every satellite's
availability to one host for no benefit: it is the single most consequential
line in this file.

Two questions get asked of a documentation site, and only one has a session
attached. Clerk answers "who is this human?". It can never answer "may this
fetch of a text file proceed?", because when someone pastes
``https://host/guide/llms.txt`` into an assistant, the fetch arrives with no
cookie. That is why authority has to be able to travel in the URL, and why
both mechanisms exist rather than one.

Unwired by default: :func:`configure` does nothing unless this deployment has
tiers that are not all public, so a fork inherits the machinery without
inheriting a gate it did not ask for.
"""

from __future__ import annotations

import logging
from typing import Optional

from lib import auth, hub_client, page_tiers, page_visibility

logger = logging.getLogger(__name__)


def local_tier(path: str) -> str:
    """This page's tier before the hub ceiling — override, then frontmatter.

    ``page_visibility.tier_override`` answers None for a page the control
    board never touched, which is the distinction that makes this work: an
    untouched page falls through to what its frontmatter declared (or the
    ``PAGE_DEFAULT_TIER`` env), rather than to the override store's own
    unknown-path default. Both ledgers speak the same four-value vocabulary.
    """
    override = page_visibility.tier_override(path)
    if override is not None:
        return override
    return page_tiers.local_tier(path)


def llms_public(path: str) -> bool:
    """The machine-surface axis, resolved with the same precedence.

    ``LLMS_PUBLIC_DEFAULT`` still governs every page that neither the board
    nor the frontmatter pinned, so the phase-4 agent flip stays one
    environment change.
    """
    override = page_visibility.llms_public_override(path)
    if override is not None:
        return override
    return page_tiers.get_llms_public(path)


def _raw_tier(path: str) -> str:
    """Local tier (override-aware) with the hub's ceiling applied, no
    degradation. The ceiling only ever RAISES: a board override can loosen a
    local declaration, never what the network restricted."""
    hub_tier = hub_client.hub_tiers().get(path)
    local = local_tier(path)
    return page_tiers.more_restrictive(local, hub_tier) if hub_tier else local


def _request_key() -> str:
    """The ``?key=`` on the current request, or "". Never raises, never logs."""
    try:
        from flask import request, has_request_context

        if not has_request_context():
            return ""
        return (request.args.get("key") or "").strip()
    except Exception:
        return ""


def effective_tier(path: str) -> str:
    """This page's tier, with the hub ceiling applied and degradation handled."""
    tier = _raw_tier(path)
    if not auth.clerk_enabled():
        # No way to identify anyone: everything except `hidden` falls open.
        # See lib/page_tiers.degraded_tier for why that is the right trade
        # for reading documentation, and why admin surfaces don't rely on it.
        return page_tiers.degraded_tier(tier)
    return tier


def check(path: str) -> str:
    """``allow`` | ``gated`` | ``deny`` for the current request.

    Runs inside the request, on every request, on paths that may be hot — so
    it stays cheap: a dict lookup, and at most one cached hub call for the
    agent path.
    """
    tier = effective_tier(path)

    if tier == "hidden":
        return "deny"
    if tier == "public":
        return "allow"

    # The second axis: an `auth` page whose machine twin stays open. This is
    # the window posture — humans meet the sign-in card (resolve_page_access,
    # the interactive lane) while llms.txt, crawler HTML and the prerender
    # keep serving prose so the crawl-demand dataset survives. The phase-4
    # agent flip is LLMS_PUBLIC_DEFAULT=0, which turns this branch off
    # everywhere at once; admin/hidden never reach it. The exemption applies
    # ONLY to a locally declared gate: when the hub's ceiling is what raised
    # this page above public, the machine lane must stay bound too — a
    # satellite's env default cannot loosen what the network restricted.
    if tier == "auth" and llms_public(path):
        hub_tier = hub_client.hub_tiers().get(path)
        if hub_tier not in ("auth", "admin", "hidden"):
            return "allow"

    # A signed-in reader in a browser. Resolved here, without the hub.
    user = auth.current_user()
    if user is not None:
        if tier == "admin":
            return "allow" if auth.is_admin_user(user) else "gated"
        return "allow"

    # No session: an agent, or an anonymous browser. Only a key can help now.
    #
    # THE VERDICT IS NOT THE AUTHORISATION (1.6.44 item 18, from the
    # 2026-09-02 hub incident, 0.26.0 → 0.26.1). The hub gated two admin-data
    # routes on this same endpoint, whose all-unknown-tier fallback answered
    # "allow" WITHOUT READING THE KEY, and the lane was open for 24 minutes.
    # The contract the fleet took from it: a host's own data is gated by a
    # secret THAT HOST HOLDS; a verify verdict may be a second factor and is
    # metering evidence first; "ask the authority" is the wrong shape for a
    # gate, because a tier the authority has not learned yet is UNVERIFIED,
    # not permitted.
    #
    # Why this call is within the contract, stated rather than assumed:
    #
    #   * it is the machine lane only. Human and admin access never reach
    #     here — `resolve_page_access` answers those locally, from Clerk,
    #     and `check` has already returned for a signed-in user above.
    #   * the host-held secret is CROSS_APP_WEBHOOK_SECRET. `hub_client`
    #     cannot even ask without it (`enabled()`), and with no secret the
    #     answer is `gated`. So the secret this host holds is load-bearing
    #     on every path through this call, which is exactly what the
    #     contract requires.
    #   * every fallback is CLOSED, in the opposite direction to the
    #     incident: no key, no secret, no response, an unrecognised verdict
    #     — all four return `gated`. `tests/test_access.py` source-pins
    #     those four, because a behavioural suite cannot see a restored
    #     default that pre-empts its own guard.
    key = _request_key()
    if not key:
        return "gated"
    return hub_client.verify(key, path, tier)


def resolve_page_access(path: str) -> str:
    """``allow`` | ``sign_in`` | ``forbidden`` | ``hidden`` — the INTERACTIVE
    verdict, for browser layouts (lib/gate_layouts wraps every docs page in
    it).

    Distinct from :func:`check` on purpose: the machine lane answers "may
    this fetch of a text file proceed" and honours ``?key=`` and
    ``llms_public``; a browser layout is a different question — keys never
    unlock layouts, and ``llms_public`` says nothing about the interactive
    experience.

    Fail postures are the boilerplate's, not pip-docs+'s: with Clerk
    unavailable, ``auth`` pages fall OPEN (documentation must never brick on
    a missing credential) while ``admin`` pages stay CLOSED unless
    ``ALLOW_UNGATED_ADMIN`` says this is a local box. Uses the raw effective
    tier — not ``degraded_tier()``, which maps admin to public and would
    fall the wrong way here.
    """
    tier = _raw_tier(path)

    if tier == "hidden":
        return "hidden"
    if tier == "public":
        return "allow"

    if not auth.clerk_enabled():
        if tier == "admin":
            return "allow" if auth.admin_access_open() else "forbidden"
        return "allow"

    user = auth.current_user()
    if user is None:
        return "sign_in"
    if tier == "admin" and not auth.is_admin_user(user):
        return "forbidden"
    return "allow"


def gate_doc(path: str) -> str:
    """The Markdown an unauthorised reader receives at 200.

    Deliberately useful rather than a wall: it names the page, says what would
    unlock it and where to sign in. An agent that fetches a gated document
    should come away knowing what it is and how its user gets access — that is
    the difference between a gate and a dead end, and it is why `gated` keeps
    the URL listed in the index and sitemap.
    """
    tier = effective_tier(path)
    name = _page_name(path) or path
    sign_in = _sign_in_url()

    lines = [
        f"# {name}",
        "",
        "> This document exists but is not public.",
        "",
        "---",
        "",
    ]
    if tier == "admin":
        lines += ["Reading it requires an administrator account on this site."]
    else:
        lines += ["Reading it requires a signed-in account."]
    lines += [
        "",
        "**If you are a person:** sign in"
        + (f" at {sign_in}" if sign_in else "")
        + ", then reload this page.",
        "",
        "**If you are an assistant:** ask whoever gave you this URL to copy it "
        "again while signed in. The copied link carries a key that authorises "
        "this document, and it will work when pasted here.",
        "",
        "The page's existence is public; only its content is restricted.",
        "",
    ]
    return "\n".join(lines)


def link_suffix() -> str:
    """Carry authority to the links generated in *this* response.

    An agent handed an authorised URL must be able to follow the site-index
    and per-page links inside it, or the catalogue is untraversable one hop
    from the document it was given. The package appends this to same-origin
    generated URLs only — never to canonical tags, the sitemap, or peer hosts,
    because a capability should not travel to another origin.
    """
    key = _request_key()
    return f"?key={key}" if key else ""


def _page_name(path: str) -> Optional[str]:
    try:
        import dash

        for entry in dash.page_registry.values():
            if page_tiers.normalize(entry.get("path", "")) == page_tiers.normalize(path):
                return entry.get("name")
    except Exception:
        pass
    return None


def sign_in_url() -> Optional[str]:
    """Where a reader signs in: the network bulletin's word first, env second.

    The bulletin travels with the hub's announcements, so a network-wide
    sign-in move is one hub edit instead of N satellite env changes; the env
    remains the local override and the offline fallback.
    """
    try:
        from dash_improve_my_llms.bulletin import get_bulletin

        url = ((get_bulletin() or {}).get("network") or {}).get("sign_in_url")
        if isinstance(url, str) and url.strip():
            return url.strip()
    except Exception:
        pass
    import os

    return (os.getenv("CLERK_SIGN_IN_URL") or "").strip() or None


# Old private name — gate_doc() predates the bulletin-aware lookup.
_sign_in_url = sign_in_url


def gating_configured() -> bool:
    """True when at least one page is not public.

    A site whose pages are all public gains nothing from a per-request check
    and pays for it on every hot path, so the wiring stays off until a tier
    says otherwise.
    """
    return any(tier != "public" for tier in page_tiers.registered().values())


def configure(force: bool = False) -> bool:
    """Hand the policy to the package. Returns True when access control is on.

    Call after the pages are registered — the decision depends on their tiers.
    """
    if not (force or gating_configured()):
        return False

    from dash_improve_my_llms import configure_access, configure_viewer_identity

    configure_access(check, gate_doc=gate_doc, link_suffix=link_suffix)
    configure_viewer_identity(auth.viewer_identity)
    logger.info(
        "access control ON (clerk=%s, hub=%s)",
        auth.clerk_enabled(),
        hub_client.enabled(),
    )
    return True
```


---

*Source: /authentication*

---

<!-- /backend-comparison — https://boilerplate.2plot.dev/backend-comparison/llms.txt -->

# Backend Deep Dive

> An in-depth comparison of Flask, FastAPI and Quart as Dash 4.x backends — architecture, strengths, weaknesses, deployment, and concrete best practices for each.

---



### Why this page exists

Dash 4.1 ships with a real choice for the first time:

```python
app = Dash(__name__, backend="flask")    # WSGI, sync
app = Dash(__name__, backend="fastapi")  # ASGI, async, websockets, MCP
app = Dash(__name__, backend="quart")    # ASGI, async, Flask-style API
```

Each backend pulls a different runtime model under your Dash app. The choice changes what your callbacks can do, which third-party packages will keep working, and how you deploy to production. This page goes one layer deeper than the [Pluggable Backends](/backends) overview and lays out the **trade-offs per backend** so you can pick one with eyes open.

---

### Mental model: WSGI vs ASGI

Before comparing backends, the single most important distinction is the protocol they speak to the web server.

| | WSGI | ASGI |
|--|--|--|
| Stands for | Web Server Gateway Interface | Asynchronous Server Gateway Interface |
| Concurrency | One request per worker thread | Many requests per event loop |
| Native `async def` | No | Yes |
| WebSockets | No (needs a separate stack) | Yes (first-class) |
| HTTP/2, SSE, long-lived streams | Awkward / via extensions | Native |
| Process manager | `gunicorn`, `mod_wsgi` | `uvicorn`, `hypercorn`, `daphne` |
| CPU-bound work | Fine (sync) | Needs `run_in_executor` or background tasks |

**Flask is WSGI. FastAPI and Quart are ASGI.** Everything else flows from that.

---

### Flask backend — the safe default

#### Architecture

- Pure WSGI synchronous server.
- One worker thread handles one request at a time.
- The entire Dash 3.x ecosystem and almost every third-party Dash extension assume Flask under the hood.

#### Pros

- **Mature ecosystem.** Every Flask extension (`Flask-Login`, `Flask-Caching`, `Flask-Limiter`, `Flask-Babel`, …) plugs in without translation.
- **Predictable concurrency.** No reentrancy footguns; if your code is thread-safe in Flask 2/3 it's thread-safe under Dash.
- **First-class for Dash addons.** `dash-improve-my-llms`, `dash-auth`, most observability hooks register through Flask's `before_request` / `after_request`.
- **Cheapest to deploy.** `gunicorn` with sync or `gthread` workers is well understood and rock-solid.
- **Easiest to debug.** Stack traces stop in your callback — no event-loop noise, no task scheduling.

#### Cons

- **No native async.** `async def` callbacks fall back to a thread pool, losing most of the benefit of being async.
- **No websocket callbacks** (Dash 4.2's `ctx.websocket` is ASGI-only).
- **No persistent callbacks** (Dash 4.2 RC).
- **Limited MCP transport.** Dash 4.3's MCP framework uses Streamable HTTP, which prefers ASGI.
- **Worker-per-request scaling.** Long-running callbacks tie up a worker; you need lots of them (or background callbacks) under load.

#### When Flask is the right call

- You already depend on Flask extensions and don't want to migrate.
- Your callbacks are CPU-bound or short and synchronous.
- You're shipping internal dashboards where two-digit RPS is plenty.
- You want the maximum number of third-party Dash add-ons to "just work."
- You're under deadline pressure — Flask is the path of least surprise.

#### Best practices

1. **Use a process manager with multiple workers.** `gunicorn -w 4 -k gthread --threads 8 run:server` is a sensible default for I/O-bound dashboards.
2. **Push long work to background callbacks** (`background=True`, Celery / Redis Queue) — never block a worker for >1s if you can help it.
3. **Add `Flask-Caching`** for expensive layout or data calls. Set `dcc.Store(storage_type="memory")` plus server-side caching for big payloads.
4. **Use `before_request`/`after_request`** for cross-cutting concerns (auth, telemetry, response headers). This is exactly the pattern this boilerplate uses for analytics.
5. **Don't import `asyncio` in callbacks.** If you find yourself wanting to, you've outgrown Flask — switch to FastAPI or Quart.
6. **Pin `gunicorn` worker timeouts** higher than your slowest callback (`--timeout 60`), or split slow work into a background callback.

#### Deployment recipe

```bash
pip install "dash>=4.1.0" gunicorn
gunicorn run:server \
    -w 4 -k gthread --threads 8 \
    -b 0.0.0.0:8550 --timeout 60 \
    --access-logfile - --error-logfile -
```

---

### FastAPI backend — async, websockets, MCP

#### Architecture

- ASGI server with native `async def`.
- Dash hooks into FastAPI's routing and dependency injection.
- `app.server` is a real `FastAPI` instance — every FastAPI feature (path operations, dependencies, OpenAPI, `Depends`, middleware) is available.

```python
from fastapi import FastAPI
from dash import Dash

api = FastAPI(title="My App API")

@api.get("/healthz")
def healthz():
    return {"ok": True}

app = Dash(__name__, server=api)  # Dash auto-detects FastAPI
```

#### Pros

- **Native `async def` callbacks.** I/O-heavy callbacks (DB queries, HTTP fetches) can fan out without tying up a thread.
- **Websocket callbacks** (Dash 4.2+). Stream live updates with `ctx.websocket` without bolting on Flask-SocketIO.
- **Persistent callbacks** (Dash 4.2 RC). Long-lived server-side state tied to a user session.
- **MCP-friendly.** Dash 4.3's MCP server exposes layout, components, and callbacks as tools over Streamable HTTP — best supported on ASGI.
- **Free OpenAPI docs** for any non-Dash routes you add (`/docs`, `/redoc`).
- **Excellent typing.** Pydantic v2 models cross-validate request/response without extra plumbing — pairs naturally with `pydantic` already in this stack.
- **Modern observability.** Native compatibility with OpenTelemetry, Sentry's ASGI middleware, structlog, etc.

#### Cons

- **Newer, fewer Dash add-ons.** Packages that hard-code `app.server.before_request` (including `dash-improve-my-llms` today) need adaptation.
- **Async footguns.** A blocking call (`requests.get`, `time.sleep`, sync DB driver) inside an `async def` callback freezes the entire event loop for that worker.
- **Two mental models.** Sync and async callbacks mix in the same app — you must remember which is which.
- **Deployment is `uvicorn`-shaped.** `gunicorn` is still usable as a process manager (`-k uvicorn.workers.UvicornWorker`), but the muscle memory is different.

#### When FastAPI is the right call

- You want **websocket callbacks** or **persistent callbacks** from Dash 4.2.
- You're building an **MCP server** on top of your Dash app (Dash 4.3).
- Your callbacks do **lots of network I/O** (databases, external APIs, search) and you want true concurrency.
- You're already exposing a **JSON API alongside the dashboard** — FastAPI lets you do both in one process with one set of types.
- You want **OpenAPI** documentation for free.

#### Best practices

1. **Use async drivers everywhere.** `asyncpg` instead of `psycopg2`, `httpx.AsyncClient` instead of `requests`, `aioboto3` instead of `boto3`. One sync call inside an `async def` callback wipes out the benefits.
2. **Wrap unavoidable blocking calls** in `asyncio.to_thread(...)` or `loop.run_in_executor(...)` so they don't stall the event loop.
3. **Mark callbacks `async def` only when you'll actually `await` something.** A sync callback wrapped in `async def` adds overhead without benefit.
4. **Replace `@server.before_request` with ASGI middleware:**

   ```python
   @app.server.middleware("http")
   async def track_visitor(request, call_next):
       try:
           tracker.track_visit(request.url.path, request.headers.get("user-agent", ""), request.client.host)
       except Exception:
           pass
       return await call_next(request)
   ```
5. **Run with `uvicorn`** in development and either `uvicorn` workers or `hypercorn` in production:

   ```bash
   pip install "dash[fastapi]" "uvicorn[standard]"
   uvicorn run:server --host 0.0.0.0 --port 8550 --workers 4
   ```
6. **Health-check the event loop**, not just the process. A blocked loop will pass a TCP health-check but serve nothing — use a `/healthz` route that returns the loop's latency.
7. **Use `BackgroundTasks`** (FastAPI primitive) for fire-and-forget work that doesn't need a callback round-trip.
8. **Watch out for `set_props`** — under ASGI it's near-instant, which is a feature, but multi-worker setups still need a shared message bus (Redis pub/sub) for cross-worker notifications.

#### Deployment recipe

```bash
pip install "dash[fastapi]" "uvicorn[standard]"
uvicorn run:server --host 0.0.0.0 --port 8550 \
    --workers 4 --log-level info \
    --proxy-headers --forwarded-allow-ips='*'
```

For containerized deploys, replace `gunicorn run:server` in the Dockerfile with the `uvicorn` command above.

---

### Quart backend — async with Flask ergonomics

#### Architecture

- ASGI server with an **API surface almost identical to Flask** (`@server.route`, `@server.before_request`, `request.headers`, etc., but `async`).
- Designed as a drop-in async replacement for Flask apps.

```python
from quart import Quart
from dash import Dash

server = Quart(__name__)

@server.before_request
async def log_path():
    print(server.request.path)

app = Dash(__name__, server=server)
```

#### Pros

- **Same decorators as Flask, but async.** Easiest migration path for teams who already use `before_request`, `after_request`, blueprints, etc.
- **Native async + websockets** like FastAPI.
- **Smaller API surface** than FastAPI — less to learn if you don't need OpenAPI / dependency injection.
- Works with **Quart-Auth, Quart-CORS, Quart-Schema** — the Flask-style addon family but async.

#### Cons

- **Smallest ecosystem** of the three. Many Flask extensions don't have Quart equivalents yet.
- **Less momentum than FastAPI.** Most new Dash features (MCP, websocket callbacks) are tested on FastAPI first.
- **Same async footguns** as FastAPI — blocking calls freeze the loop.
- Documentation and community examples are sparser; debugging unusual cases takes more effort.

#### When Quart is the right call

- You have an existing **Flask codebase** with custom middleware and want async without rewriting every route.
- You want websocket callbacks **without** committing to FastAPI's larger surface (Pydantic-heavy, OpenAPI-by-default).
- You like Flask's mental model — globals, decorators, blueprints — but need async I/O.

#### Best practices

1. **Replace `requests` with `httpx`** (or `aiohttp`) — Quart will let you `await` it natively.
2. **Convert `before_request` to `async def`.** Same decorator, but the function must be a coroutine.
3. **Don't mix Flask-only extensions.** `Flask-Login` will fail under Quart; use `Quart-Auth` instead.
4. **Use `hypercorn` or `uvicorn`** as the ASGI server.

   ```bash
   pip install "dash[quart]" hypercorn
   hypercorn run:server --bind 0.0.0.0:8550 --workers 4
   ```
5. **Treat it as "Flask with `async`"** in code review — the syntactic similarity hides a different runtime model. Reviewers should explicitly ask "could this block the loop?" for every PR.

#### Deployment recipe

```bash
pip install "dash[quart]" "uvicorn[standard]"
uvicorn run:server --host 0.0.0.0 --port 8550 --workers 4
```

---

### Side-by-side at a glance

| Concern | Flask | FastAPI | Quart |
|--|--|--|--|
| Protocol | WSGI | ASGI | ASGI |
| `async def` callbacks | Threaded | Native | Native |
| Websocket callbacks (Dash 4.2) | no | yes | yes |
| Persistent callbacks (Dash 4.2) | no | yes | yes |
| MCP server (Dash 4.3) | partial | best support | yes |
| Add-on compatibility | Highest | Growing | Smallest |
| `dash-improve-my-llms` today | yes | not yet | not yet |
| OpenAPI for sibling routes | no | built-in | optional (`quart-schema`) |
| Easiest migration *from Flask* | n/a | medium | trivial |
| Recommended process manager | `gunicorn` | `uvicorn` | `uvicorn` / `hypercorn` |
| Suitable for high-RPS I/O | medium | high | high |
| Maturity / risk | lowest | medium | medium |

---

### A decision flow

1. **Do you need websocket callbacks, persistent callbacks, or MCP today?** → FastAPI.
2. **Do you have a large existing Flask codebase you want to make async with minimal diff?** → Quart.
3. **Do you depend on `dash-improve-my-llms`, `Flask-Login`, or other Flask-only Dash add-ons?** → Flask.
4. **Are your callbacks I/O bound (DBs, HTTP, queues) and is throughput your bottleneck?** → FastAPI.
5. **Is this an internal tool that will see <50 concurrent users and you want zero surprises?** → Flask.
6. **Otherwise** → start on Flask, switch to FastAPI when you hit a feature wall. Dash makes the swap a one-line change.

---

### Cross-backend best practices

These hold no matter which backend you pick.

1. **Don't put `app.server.route` decorations in the hot path of `run.py`.** Group them into a small `register_extra_routes(server, backend)` function so you can gate it on backend type without making `run.py` unreadable. (This is what `if IS_FLASK:` does in this boilerplate's `run.py`.)
2. **Keep callbacks fast.** Anything over ~250ms hurts UX; over ~3s should be a background callback or `dcc.Loading`.
3. **Cache expensive layout work**, especially when `use_pages=True` — page registry traversals add up.
4. **Pin Python version** in deployment. Async semantics shifted noticeably between 3.10 and 3.11; debugging across versions is painful.
5. **Health-check separately from readiness-check.** Health = "process is up." Readiness = "DB / cache / dependent services are reachable."
6. **Log structured.** `structlog` works with all three backends and saves you when debugging a callback that only misbehaves on one of them.
7. **Don't mix `set_props` and pattern-matching callbacks** unless you've thought about the ordering — under FastAPI both fire faster than under Flask, exposing races you wouldn't see in development.
8. **Test on the backend you'll deploy on.** A test suite that only exercises Flask will miss async-only failures.

---

### Switching backends in this boilerplate

Everything above is enforceable in one line — flip `DASH_BACKEND` in `.env`:

```env
DASH_BACKEND=flask     # default, full feature parity
DASH_BACKEND=fastapi   # async + websockets + MCP
DASH_BACKEND=quart     # async with Flask-style decorators
```

The badge in the header reflects the resolved backend. `dash-improve-my-llms` serves `/llms.txt`, `/robots.txt`, and `/sitemap.xml` natively under Flask, FastAPI, and Quart — only the analytics `before_request` hook is still Flask-only (an ASGI middleware port is wired up automatically under FastAPI). See [Pluggable Backends](/backends) for the implementation details.


---

*Source: /backend-comparison*

---

<!-- /backends — https://boilerplate.2plot.dev/backends/llms.txt -->

# Pluggable Backends

> Run this documentation site on Flask, FastAPI or Quart with one environment variable, and learn what each backend unlocks.

---



### Overview

Dash **4.1+** introduces a pluggable server backend. Instead of being hard-wired to Flask, a Dash app can now be created on top of **FastAPI** or **Quart**, unlocking native async, websocket callbacks, and a transport surface that works well with the new MCP tooling shipping in Dash 4.3.

This boilerplate exposes the choice through a single environment variable so you can flip backends without editing code:

```bash
# Default — fully featured, all integrations on
DASH_BACKEND=flask python run.py

# Async backends (install the extra first)
pip install "dash[fastapi]"
DASH_BACKEND=fastapi python run.py

pip install "dash[quart]"
DASH_BACKEND=quart python run.py
```

The badge in the header and the navbar reflects whichever backend booted, so you always know what's running.

---

### How it's wired

The active backend is resolved once in `lib/backend.py` and threaded into both `run.py` and the UI components:


```python
# File: lib/backend.py

"""
Backend selection helper for the Dash documentation boilerplate.

Dash 4.1+ supports pluggable backends:
    app = Dash(backend="flask"  | "fastapi" | "quart")

This module owns the single source of truth for which backend the app is
running on, so both `run.py` and UI components (e.g. the navbar badge) stay
in sync.

Backend is selected via the ``DASH_BACKEND`` environment variable. Falls back
to ``flask`` if unset or invalid.
"""
from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Literal

try:
    from dotenv import load_dotenv
    load_dotenv()
except ImportError:
    pass

BackendName = Literal["flask", "fastapi", "quart"]

SUPPORTED_BACKENDS: tuple[BackendName, ...] = ("flask", "fastapi", "quart")
DEFAULT_BACKEND: BackendName = "flask"


@dataclass(frozen=True)
class BackendInfo:
    name: BackendName
    label: str
    color: str          # DMC color name
    icon: str           # iconify icon id
    description: str
    is_async: bool


_BACKEND_INFO: dict[str, BackendInfo] = {
    "flask": BackendInfo(
        name="flask",
        label="Flask",
        color="gray",
        icon="simple-icons:flask",
        description="WSGI backend. Default. Full feature parity with Dash 3.x.",
        is_async=False,
    ),
    "fastapi": BackendInfo(
        name="fastapi",
        label="FastAPI",
        color="teal",
        icon="simple-icons:fastapi",
        description="ASGI backend. Adds websocket callbacks, async support and MCP-friendly transport.",
        is_async=True,
    ),
    "quart": BackendInfo(
        name="quart",
        label="Quart",
        color="violet",
        icon="simple-icons:python",
        description="ASGI backend with a Flask-compatible API surface.",
        is_async=True,
    ),
}


def resolve_backend(name: str | None = None) -> BackendName:
    """Resolve the backend to use.

    Reads ``DASH_BACKEND`` env var when ``name`` is not given. Unknown values
    fall back to the default backend rather than raising — the docs site
    should always boot.
    """
    raw = (name or os.environ.get("DASH_BACKEND") or DEFAULT_BACKEND).strip().lower()
    if raw not in SUPPORTED_BACKENDS:
        return DEFAULT_BACKEND
    return raw  # type: ignore[return-value]


def get_backend_info(name: str | None = None) -> BackendInfo:
    return _BACKEND_INFO[resolve_backend(name)]
```


`run.py` then passes the resolved name straight to the `Dash()` constructor:

```python
from lib.backend import resolve_backend, get_backend_info

BACKEND = resolve_backend()
BACKEND_INFO = get_backend_info(BACKEND)
IS_FLASK = BACKEND == "flask"

app = Dash(
    __name__,
    backend=BACKEND,            # <-- new in Dash 4.1
    suppress_callback_exceptions=True,
    use_pages=True,
    ...
)
app._backend_info = BACKEND_INFO
```

The only remaining Flask-only integration is the `before_request` analytics hook, which the boilerplate transparently swaps for a Starlette `BaseHTTPMiddleware` when the backend is FastAPI. `add_llms_routes(app)` from `dash-improve-my-llms` auto-detects the backend and mounts its own router under all three — no `IS_FLASK` gate is needed for the AI/LLM surfaces anymore.

---

### What you get on each backend

| Feature | Flask | FastAPI | Quart |
|--|--|--|--|
| Synchronous callbacks | yes | yes | yes |
| `async def` callbacks | n/a | yes | yes |
| Websocket callbacks (`ctx.websocket`) | no | yes (Dash 4.2+) | yes (Dash 4.2+) |
| Persistent callbacks (Dash 4.2 rc) | no | yes | yes |
| MCP server / `mcp_enabled` decorator (Dash 4.3) | partial | yes | yes |
| `add_llms_routes` (dash-improve-my-llms) | yes | yes | yes |
| `/llms.txt`, `/robots.txt`, `/sitemap.xml` | yes | yes | yes |
| `gunicorn` deploy | yes | n/a | n/a |
| `uvicorn` deploy | n/a | yes | yes |

The AI/LLM surfaces (`/llms.txt`, `/robots.txt`, `/sitemap.xml`, bot middleware, MCP bridge) work identically under all three backends — pick the one that matches the rest of your stack.

---

### Choosing a backend

#### Flask (default)

The safe choice. Everything in this boilerplate works without changes, including the analytics tracker, the `dash-improve-my-llms` integration, and the SEO routes.

Deploy with `gunicorn run:server -b 0.0.0.0:8550` (already wired into the included `Dockerfile`).

#### FastAPI

Pick this when you want:

- Async callbacks (`async def update_table(...)`)
- Real-time websocket callbacks for streaming UI updates
- A first-class MCP server using Dash 4.3's framework utilities
- Easy interop with an existing FastAPI app — just pass it in:

```python
from fastapi import FastAPI
from dash import Dash

api = FastAPI()
api.add_api_route("/healthz", lambda: {"ok": True})

app = Dash(__name__, server=api)        # Dash detects FastAPI automatically
```

Deploy with `uvicorn run:server --host 0.0.0.0 --port 8550`.

#### Quart

A drop-in option for teams already on Quart, or who want async with Flask-style ergonomics (`@server.before_request` becomes `@server.before_request` returning a coroutine).

```python
from quart import Quart
from dash import Dash

server = Quart(__name__)
app = Dash(__name__, server=server)
```

Deploy with `uvicorn run:server --host 0.0.0.0 --port 8550`.

---

### Custom backends

`Dash(backend=...)` also accepts a subclass of `dash.backends.base_server.BaseDashServer` plus matching request/response adapters, so internal frameworks (a hardened ASGI server, an in-process test harness, etc.) can be plugged in without forking Dash. See the `dash.backends` module for the protocol.

---

### Migration checklist for your own apps

1. Bump `dash>=4.1.0` (and `dash-mantine-components>=2.7.0` if you use DMC).
2. Replace any `app.run_server(...)` calls with `app.run(...)`.
3. Audit every `@app.server.before_request` / `@app.server.route` — Flask-only. Wrap them in an `if backend == "flask":` guard or rewrite as FastAPI/Quart middleware.
4. Install the extra: `pip install "dash[fastapi]"` or `pip install "dash[quart]"`.
5. Pass `backend="fastapi"` (or `"quart"`) to `Dash(...)`, or set `DASH_BACKEND` if you're using this boilerplate's helper.
6. Swap your process manager: `gunicorn` → `uvicorn` (or `hypercorn`).

---

### What this site does today

This page is itself rendered by whichever backend `DASH_BACKEND` points at. The header badge tells you which one — change the env var, restart `run.py`, and the badge updates to match.


---

*Source: /backends*

---

<!-- /changelog — https://boilerplate.2plot.dev/changelog/llms.txt -->

# Changelog

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

---

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

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

## [1.6.45] - 2026-09-11

Twelve drop items, the owner's T1/C1 rider, and eight riders that arrived
while it was being built. The release pins the network's package exactly
again, stops serving soft 404s to crawlers, and spends most of its length
on the difference between a check that passed and a check that could not
have failed.

Three of its findings were defects in this tree that nobody had reported:
a page hidden from the control board went on being listed in the sidebar
and both search dropdowns; two admin-gate tests were green run alone and
red in the full suite; and a well-known detect was wrong on both lanes at
once, in opposite directions, each error hiding the other.

### Added
- `lib/soft404.py` — an unregistered path answers 404 on the crawler
  lane and keeps Dash's own 404 page for browsers. Before this, every
  mistyped link and hallucinated URL was served as a real page: `GET
  /definitely-not-a-page` with a Googlebot UA returned 200 and 46,160
  bytes of app shell, byte-identical to the browser's.
- `{{INSTALL}}` on the landing page (T1) — how to obtain what this host
  documents, with nothing typed by hand. It lives in the markdown, not
  the layout, so it reaches `/llms.txt` and every agent.
- Battery rows `assets_cached`, `healthz_keys_vs_template`,
  `crawler_404` and `openapi_document_renders`.
- `--require ROW,ROW` on the battery: a row that SKIPS where it was
  required exits non-zero. cd.yml's verify job requires
  `ai_bot_posture`, `edge_honours_vary` and `healthz_keys_vs_template`.
- Regression tests for shapes this tree does not have and must not
  acquire: a component library imported inside a layout (blank first
  render after every restart), a FastAPI handler whose annotations
  cannot resolve (one route poisons the whole OpenAPI document), a
  search listing an admin or hidden page, a malformed icon name.

### Changed
- `dash-improve-my-llms` pinned to `== 2.10.0`.
- `components/navbar.py::page_tier` resolves through `lib.access` —
  override first, then frontmatter. It read the frontmatter tier, so a
  page the owner hid from the control board stayed in the sidebar and in
  both search dropdowns while every request was correctly gated.
- The UA pin walks call sites with `ast` instead of scanning files: a
  bare `.test_client()` beside a named one used to pass.
- `{{VERSION:...}}` resolves a fork's own package from the tree when the
  distribution is not installed; every other name still raises.
- `scripts/promote_sampler.py` requires two CONSECUTIVE bad reads before
  calling anything wrong, and prints the push→promote gap — under 180 s
  it reports "no verdict possible" rather than a number that looks like
  evidence.
- The Network Standard page states the reporting contract in one
  sentence with no link; the linked document answers 404 to crawlers and
  gives browsers an SPA shell.

### Fixed
- `tests/conftest.py` snapshots the callback map at import. `Dash()`
  clears `dash._callback.GLOBAL_CALLBACK_MAP`, so any test building its
  own app emptied the real app's callbacks — 0 entries after
  `tests/test_access.py`, down from 35 — and the admin-gate tests read a
  wiped registry in every full-suite run.
- The well-known provenance detect reads the handler's `__module__`,
  drives the ASGI lifespan and recurses through `original_router`. It
  had been calling the package's own routes this host's on flask, and
  passing on fastapi by sweeping zero paths.
- The Quart lane registers the crawler-404 hook. Quart is ASGI but not
  Starlette, so `add_middleware` never reached it.

### Notes
- Invalid OpenAPI document (duplicate `operationId` for GET and HEAD,
  16 operations / 13 distinct) is RECORDED in `DIVERGENCES.md` as a
  dimll 2.11 item, not worked around.
- Agent-readiness, PRE-deploy baseline against the live 1.6.44 build:
  level 1, 4 passed, `contentSignals` the sole level-2 requirement. The
  post-release scan is owed after this ships — most of the failing
  checks are what items 1, 3 and 5 deliver.

## [1.6.44] - 2026-09-04

Twenty-three items: nineteen from the notes-78–90 drop, four from the
2026-09-04 rider written after the crash-recovery survey. The release
pins the network's own package exactly, retires a shim on the strength
of that pin, stops storing visitor IP addresses, and spends a lot of its
length on the difference between a check that passed and a check that
ran.

### Added
- `Legal` nav section with template-owned `/terms` and `/privacy`. One
  markdown string per page serves the browser and the machine lane, so
  the site cannot hold two versions of its own terms. The privacy page
  is generated from the mechanism and BOUND to it: a test reads a real
  visit row out of a real tracker and fails if the tracker stores a
  field the page does not describe (item 15).
- `ledger` on `/healthz` — path, `persistent`, `visits`, `reads`.
  `persistent` is measured from the resolved path, not declared: a path
  under the app tree is the container filesystem and reads false even
  where a blueprint declares a disk (item 20).
- `geo.headers_seen` on `/healthz`: which Cloudflare visitor-location
  headers this process has actually received, so "is the transform on
  for this zone?" is answerable from outside (item 16).
- A boot warning when `TRAFFIC_ANALYTICS_FILE` is unset, mirroring the
  `[visibility]` guard so one habit covers both stores (item 22).
- The fleet probe convention: `lib.constants.probe_ua()`, used by both
  workflows, the three batteries and the container HEALTHCHECK. Measured
  on dimll 2.9.4 — the suffix moves neither lane, vendor nor bot_type on
  any engine, so suppression is the tracker's write-time drop and lane
  classification holds by construction (item 4).
- Four battery invariants that now run against the DEPLOYED host on
  every CD, plus `skip` as a real verdict and a header mapping that
  keeps repeated `Link` values (items 5, 19).
- `scripts/promote_sampler.py` and `scripts/kit_traps.py` (items 17, 14).
- `py_compile sweep of docs/` in CI — the check `flake8` cannot make,
  because `.flake8` excludes `docs/*/` (item 7).

### Changed
- `dash-improve-my-llms` is pinned EXACTLY at 2.9.4. No `>=` floors
  anywhere (item 1, owner decision 0bb).
- The tracker no longer stores raw client IP addresses. The address is
  resolved, used to compute a keyed one-way `visitor_key`, and dropped;
  `ANALYTICS_KEEP_CLIENT_IP=1` still keeps it. Location comes from the
  edge's headers or not at all (item 16).
- `reads` prune by retention date only. The count cap was shared with
  `visits`, so on a corpus served to every crawler the oldest READ rows
  went first — the ledger eating its own history inside its own
  retention window (item 21).
- `vendor_class` prefers the package's value and derives from the
  package's REGISTRY only when absent — never a local map (item 8).
- `/assets/` is served with a cache lifetime. It was going out as
  `cache-control: no-cache` with `cf-cache-status: DYNAMIC`, so the edge
  stored nothing and every visitor revalidated the stylesheet and three
  scripts on every page load (item 6g; fleet-wide on four hosts).
- The Other Apps menu opens from the keyboard; prose links no longer
  rely on colour alone; touch targets reach 44px at phone width; content
  images carry intrinsic width/height (item 6).
- `DIVERGENCES.md` distinguishes a DIVERGENCE from a RECORDED
  CONVENTION — a match kept on purpose, which nothing in a diff tells
  from an accident (item 9).

### Fixed
- **`llms_version` was absent from `/healthz` on the FastAPI lane** from
  the moment item 1 added it. A pydantic `response_model` drops every
  field it does not declare, silently; the template serves Flask in
  production, so nothing said so. The model now declares the known keys
  AND takes `extra="allow"`, and a test asserts every key
  `health_payload` produces reaches the wire on whichever lane answers
  (found while building item 20).
- `HeadAsGetMiddleware` is retired, gated on the 2.9.4 pin rather than
  on the date. It converted HEAD to GET above the router, so it MASKED
  the package's own fix instead of conflicting with it (item 2).

### Kit
- Six traps and two contract clauses: name the check that actually ran;
  print the resolved version beside the result; the CD double-run trap
  (and PyYAML parsing `on:` as the boolean True); traps-section currency
  per fork; a verify verdict is metering evidence, never sole
  authorisation; a proxied robots.txt is not your robots.txt; and the
  owner's standing build-word sentence, carried verbatim with the
  reading that keeps it from pre-authorising its own amendment (items 7,
  10, 12, 14, 18, 19, 23).
- The spec-format rule learns that a docstring is a STRING. Five
  formatting-bound detects on this tree in one release — a raw grep
  matching the comment that explains an absence, a comment-strip
  matching the live docstring doing the same, a fragment that wraps
  across a line, and a blockquote's `> ` markers. `ast.parse` for code,
  flattened whitespace for prose (item 13).

## [1.6.43] - 2026-09-01

What the item-18 fan-out taught the template, including four defects
this seat introduced and the fleet caught. Twelve of thirteen forks
reported, eleven live and wire-verified; seven of them found the
fourth empty-page mechanism live in their own trees, none having been
looking for it.

### Fixed
- `record_read` drops INTERNAL_UA_TOKEN traffic, as `track_visit` has
  since the internal-traffic contract existed. The
  `on_document_read` hook added at the 2.8.0 floor never learned to,
  so the network's own probes — the hub's health sweep, every
  satellite's link audit, every post-deploy battery — landed in the
  `reads` table and became the busiest "vendor" on every board in the
  fleet. Found by pipdocs (69 rows where 67 were real), reproduced
  here before accepting it, dropped FIRST before any field is read.
  "Counted nowhere" has to include the read table or the contract is
  only half kept. Pinned both ways so it cannot pass by dropping
  everything.
- PHANTOM RELEASES on /changelog. 1.6.41 widened the heading match to
  accept an unbracketed `## 2.0.0 — date` and free text came through
  with it: muicharts rendered a Timeline card badged `Component
  License Requirements` and a page claiming 15 releases where there
  are 14; this repo had `## Migration Guides` and `## Support` and had
  not noticed; flows found `## Migration Guide`. Unbracketed labels
  must now look like a release; brackets stay trusted as intent. It
  EXCLUDES WITHOUT DROPPING — the prose folds into the preceding
  release rather than vanishing, which a narrower heading regex does
  not do (pannellum asked; measured, not reasoned).
- `scripts/audit_links.py` drove a bare in-process test client, so
  the link audit read the CRAWLER document for every page and would
  report this site's own `mark_hidden` admin pages as broken internal
  links. Present in four trees. `AUDIT_UA` stays for the external
  urllib probes — the audit's identity is the question there — and a
  separate `AUDIT_BROWSER_UA` carries a real engine token plus the
  internal token on the client via `environ_base`. It is not enough to
  find a bare client: the UA it is handed may be on the wrong lane too
  (llms, measuring the same thing independently).

### Added
- `.. exec::module` expands into the module's SOURCE for the machine
  lane, through the same fence-aware pass `.. source::` uses — one
  parse, two consumers (owner's decision 0aa, road (a)). Seven forks
  had this class live: a directive that renders Dash components puts
  its output only in the React tree while the machine lane, the
  prerender and the crawler HTML are built from the markdown source
  where the directive line is stripped. muischeduler's
  `/event-calendar/llms.txt` carried 0 of 33 props; flexlayout's
  `/basic/llms.txt` ran its prose straight into `### Notes`.
- The DEDUPE rule (ops) and the `:code: false` GUARD (muischeduler),
  which is where this release went wrong before it went right. The
  first cut published to the machine lane exactly the source three
  authors had withheld with `:code: false`, live on this wire between
  d5675d8 and 9b2ad49, and the byte-growth reported as the round's win
  WAS the defect. `:code: false` never meant "secret": three forks
  measured it independently at 8 of 8, 10 of 11 and 27 of 28 always
  accompanying an explicit `.. source::`, so it means "do not
  auto-dump the code; here it is where I put it". Precedence is dedupe
  first and silent, then the flag with a MARKER naming the module,
  else expand — because broken, hidden and absent must not look alike.
- `tests/test_exec_lane.py`: nine pins, content never headings,
  negative fixtures for every branch, and a mutation check that
  disables the expansion and requires the content pins to go red.

### Changed
- The test-client UA pin resolves per CALL SITE, not per file, and
  requires a User-Agent specifically. The file-scoped `"headers=" in
  src` form both over- and under-flagged and missed the only real
  offender in four trees; `headers={"CF-IPCountry": "FR"}` satisfied
  it while naming no lane at all (muischeduler). Comments and
  docstrings are stripped first, after the pin flagged its own
  explanatory comment (muicharts' rule, proven on itself).
- The battery hidden-paths pin is SUBSET plus a reality check, and the
  check measures the ROUTING STACK rather than the package's registry
  (leaflet, muischeduler, flexlayout). Equality deleted real hidden
  surfaces; subset alone lost the stale canaries it was catching on
  five forks. `is_hidden()` answers what the package believes; a
  crawler-lane request answers what a crawler receives, and this
  round's whole defect class was a registry-side belief the wire did
  not honour.
- The corpus sweep covers the TIER DOCS, both clauses LINK-shaped. The
  only real leak anyone found was in `/llms-full.txt`, not the index
  (muicharts), and a changelog that cannot name the page it added is
  not a changelog (emojimart).
- The layout-nesting positive control derives from the REGISTRY, not
  `lib.aside.ASIDE_PATHS` — a module a lockdown fork has no equivalent
  for, while the docstring already claimed the registry (clerkhook).
- `.claude/CLAUDE.md` gains two traps: leaflet's method for measuring
  which branch Render builds on a GREEN push, by timing the swap
  against the promote rather than the push (strong evidence, not
  proof; four hosts correctly declined to call the fence row proven);
  and the artifact rule in both directions — a props table absent from
  the crawler document is a defect of the SITE, not the harness, and a
  skip link absent from curl output is a feature that lives in the
  layout.

### Notes
- Acceptance ran at the RESOLVED version, printed rather than assumed
  (note 89): suite at dimll **2.8.0**, 467 passed, 3 skipped, exit codes
  captured. **Production resolves 2.9.4**, which the sandbox does not
  run, so the `record_read` fix is verified here at 2.8.0 and on the
  wire by the ops seat at 2.9.4. `ua` IS in `EVENT_FIELDS` at every
  version the floor admits — measured by IMPORTING each wheel:
  2.8.0/2.9.0/2.9.1 carry 15 fields, 2.9.2/2.9.4 carry 16
  (`vendor_class` arrives at 2.9.2); `ua` is present in all five and
  `user_agent` in none. That is what matters, because the drop keys on
  `ua` and a key that moved between versions would make the fix a
  silent no-op on production while passing here.
  CORRECTED after shipping: this entry first said "2.8.0 has 16 fields,
  2.9.4 has the same 16". Wrong — that was 2.9.4 measured twice. The
  comparison ran with the shell's cwd inside the unpacked 2.9.4 wheel,
  so `import dash_improve_my_llms` resolved from the CURRENT DIRECTORY
  rather than site-packages, and two readings of one wheel were reported
  as two versions agreeing. The load-bearing half (`ua` at both) was
  true; the supporting detail was not.
- Ships `sync/SYNC-1.6.43.md`: THREE items only — the `record_read` drop
  (port it first; its consequence accrues), pipdocs' two item-12 port
  corrections, and the kit's three new traps. Everything else in this
  release is already ported and wire-verified on eleven forks from the
  item-18 round, and its corrections live in SYNC-1.6.22-1.6.42's item
  18. A detect that passes on arrival is not a vacuous spec — the
  passing detect IS the proof the round landed.
- Four defects in this release were introduced by this seat and caught
  by forks measuring rather than accepting: the `:code: false`
  over-publication (live on the wire), a UA pin accepting `headers=`
  without a UA, a battery pin contradicting the item text it shipped
  beside, and a wrong caution about a pin being network-dependent.
- WHEN A LANE DISAGREES, THAT IS THE FINDING — never relocate the
  assertion to the lane that passes. pannellum named it against
  itself: it moved a props assertion onto the rendered layout and the
  pin passed for a fortnight over a corpus serving zero props. Every
  fork that shipped this class did some version of that move.
- CENSUS BEFORE YOU ADOPT (muischeduler): measure what an incoming
  change would DO to your tree before taking it. "A change from the
  template is still a change, and 'it came from upstream' is not a
  measurement." That refusal is the only reason the `:code: false`
  guard existed before this fanned out to twelve hosts.
- Neither of the two corrections that mattered most came from a pin.
  One came from opening a file and seeing a fence on the next line;
  the other from two greps disagreeing. Every pin involved was green
  throughout.

## [1.6.42] - 2026-08-30

The second wave of fork findings (notes 64–77): two more promote
failure modes, every changelog heading shape the fleet writes, the
blank-page class nothing could see, and the third lane surface.

### Added
- cd.yml's promote step guards `release/*` refs: a branch at any depth
  under `release/` makes `release` a directory and the push fails
  "cannot lock ref" (muischeduler's second failure, run 33318542986 —
  its first was the workflow-permission cap; `verify` skipping on both
  is contract (a2) working). The guard fails with the owner step
  (delete or rename, never --force); item 13 carries the detect
  (`git ls-remote --heads origin 'release/*'` must be empty).
- `tests/test_layout_nesting.py` (adopted from emojimart 44ae41b;
  the defect is modelviewer's): `parse()` returns a LIST, and
  `children=[hero, parse(...)]` nests it — React #31, the page renders
  EMPTY with a green suite, and every machine-lane check stays green
  because the prerender reads the markdown. Walk over every registered
  page + a non-vacuity fixture + a positive control derived from the
  registry + an AST source pin on both builders; pages/home.py's
  comment says splat-or-concatenate.
- Sync item 18 — the 1.6.41 remainder: nineteen files moved, two were
  ported; excalidraw found the seam from inside (`slim_generated_on`
  imported, never called — dead code and a lastmod-less /api on every
  two-file fork). Per-seam detects including cargo-without-caller;
  byte-identity evidence against 519d496 declared stale; the
  silent-empty-`load_package` wording (emojimart), the corpus half of
  the admin-absent pin (llms, note 75), and the hide+aria pairing
  (muicharts).

### Changed
- `pages/changelog.py` parses every heading shape measured on the
  fleet's main branches (note 67a): bracketed or bare labels, `-`/`–`/
  `—`, the ISO date wherever it sits, trailing notes kept — and the
  badge says `v` only when the label IS a version (`## [Unreleased]`
  read "VUNRELEASED"; a date-labelled release "v2026-08-30"). Bold
  spans containing inline code render (bold split before code —
  note 67b). Eight fixture headings pinned.
- `scripts/network_smoke.py` `HIDDEN_DOC_PATHS` lists the two real
  admin pages and is pinned against the registry
  (pannellum's tuple carried a page deleted 08-02 and missed
  /admin/traffic).
- `tests/test_agent_key_route.py`'s client names a browser-lane UA
  (`environ_base`); a new kit grep fails any `.test_client()` user
  that names none — a bare client sends `Werkzeug/x.y`, crawler lane
  at ≥ 2.8 (leaflet's every-page-200 loop went red at the floor bump;
  the template's third-lane instance).
- Item 16's first detect is the ABSENCE of `sections_for` (llms's
  clusters had other names and the `page_order` grep said
  already-present on a defective tree); item 17(b)'s og:image detect
  greps the injection call, not the string (llms has the string only
  in a comment).
- `lib/api_reference.py`: two overlong lines reflowed (E501 on forks
  that lint line length; this repo's flake8 does not).
- Tests 450 → 458.

### Recorded, no change
- llms's two "template seams" (header identity constants; the
  registry-derived aside pin) were both closed at 1.6.41 — llms
  measured against stale 519d496. Recorded in item 18's detects.
- The fork-tool harness fixes (smoke_test.py, compat_matrix.py,
  route_parity.py) are spec text only — this tree carries none of
  those tools.

## [1.6.41] - 2026-08-30

muischeduler's item-16 port measured nine files byte-identical to the
template and six not; the six were template asks. No runtime behaviour
changes beyond a skip link and a header breakpoint.

### Added
- Skip link ("Skip to content" → `#main-content`), the first tab stop,
  visible only on keyboard focus — adopted from muischeduler
  (`components/appshell.py`, `assets/main.css`); pinned.
- `lib/constants.py`: `LOGO_ASSET`, `LOGO_STYLE`, `WORDMARK_COLOR`,
  `WORDMARK_VISIBLE_FROM` beside `WORDMARK` — `components/header.py`'s
  last fork content, so it can become cargo. `create_link()` takes
  `visible_from`; the GitHub icon drops below `xs` (the footer carries
  it on phones).

- `/changelog` and `/api` register the full machine record —
  `page_visibility.register_default`, `page_tiers.register`,
  `register_page_metadata(... lastmod=)` (the changelog's newest dated
  heading; `/api`'s committed-extract `generated` stamp) — so both carry
  a sitemap `lastmod` and sit under the control board's llms.txt toggle
  (leaflet's finding: a module-level `LLMS_DOC` alone entered the sitemap
  undated).
- `lib/api_reference.py` reads three sources in order: `metadata.json`,
  the committed `api_metadata.json` extract written by the new
  `scripts/build_api_metadata.py` (a component repo's `metadata.json`
  can be a 27 MB gitignored artifact absent on the host — leaflet's
  `/api` was empty in production while every local check passed), then
  the classes' docstrings (hook-based packages ship no metadata —
  modelviewer). Pipes are escaped in every Markdown cell. Two fixture
  packages pin the fallbacks.
- Sidebar links to `tier: auth` / `admin` docs pages carry a lock icon
  and a `dmc.Tooltip` ("Sign in required" / "Admin access required") —
  adopted from excalidraw. `title=` is never used: DMC 2.8 rejects it
  at app construction (recorded in item 16).

- Frontmatter `nav:` — a short sidebar/search label, default = `name`
  (emojimart, muicharts: long names where they had short labels;
  shortening `name:` would churn `<title>`, og:title and the llms.txt
  heading). `Meta.nav` → `register_page(nav=)`; the sidebar and both
  search boxes read it.
- `scripts/smoke_live.py` fetches `GITHUB_URL` (its own request, never
  the stubbable `fetch`; unreachable is a notice, a reachable 404 is
  red) — pannellum: four spellings of `dash-pannellum` for the repo
  `dash_pannellum`, a live 404 the profile-vs-repo framing never caught.
- `pages/api.py` yields when a docs page owns `/api` (pannellum,
  muicharts serve it as a `.. kwargs::` page with curated prose) —
  read from the docs' frontmatter, since page modules load before
  `pages/markdown.py`.
- `assets/main.css`: `.m2d-block-props table` joins the scrolling-table
  rule (a fork's `.. props::` directive stamps the wrapper; the rule
  must match the table).

### Changed
- `pages/changelog.py` accepts `-`, `–` and `—` between version and
  date (leaflet's em-dash headings rendered every version dateless),
  bare or bracketed versions, and prose-first releases — paragraphs
  under a version heading become a `Notes` section of `para` items
  (pannellum's `## 2.0.0 — 2026-08-02` releases rendered eight empty
  headings, silently).
- `tests/test_traffic_rollup_v4.py` builds its read rows THROUGH
  `AnalyticsTracker.record_read` from a classify()-shaped event
  (leaflet, note 61): the stored row carries only the package's
  `EVENT_FIELDS`, which on 2.8.0 lacks `vendor_class`, so every host's
  rollup has been sending `class: null` while the hand-built fixture
  asserted "training". The pin follows the seam (`CLASS_ON_THIS_PACKAGE`)
  and a new test asserts the row shape; dimll 2.9.2 adds the field and
  the pin flips with it. `vendor_rows` keeps its fallback-free semantics.
- `components/header.py` reads `HEADER_HEIGHT` instead of a literal 70.
- The aside pin names only template-owned TOC-less pages (`/changelog`,
  the admin pages); a fork may serve `/` or `/api` as a docs page with
  its own toc (muicharts). `tests/test_seo_icons.py` counts the
  changelog's newest dated heading and `/api`'s `generated` stamp as
  declared dates.
- Item 13 gains the owner step a template cannot see in itself: the
  repo's Actions default workflow permission must be "Read and write"
  or it caps the promote job's `contents: write` — muischeduler's first
  promote failed there with a green matrix and `verify` correctly
  skipped; detect via `gh api …/actions/permissions/workflow`, remedy =
  the setting + re-run, never a PAT or a force push. The promote step's
  comment in cd.yml says the same.
- Item 12/15/16 detects and wording: `EXCLUDED_LINKS` / `_DIMLL_FLOOR`
  spellings, "any in-process probe that sends no UA", "every tool that
  asserts a ClaudeBot stanza", and `components/header.py` is never
  cargo. Item 16 notes: count canonical/meta ELEMENTS, never
  substrings (the template's browser document: one element, three
  substring hits — a comment and a script selector; note 63).
- `test_resources_are_third_party_only` bans the owner's links only;
  an upstream project on GitHub is allowed (contract 5 requires it).
- `tests/test_nav_contract.py`: the API pin branches on `API_PACKAGES`
  (declared → `/api` registered, in the sidebar, components from
  `metadata.json`; none → no `/api`); the aside pin and
  `tests/test_excluded_links_hidden.py`'s positive control derive their
  pages from the registry instead of naming `/backend-comparison` and
  `/getting-started` — both files are fork-invariant now.
- Item 15's detect anchors to the line start (`^\s*block_ai_training=True`)
  so a comment documenting the flag no longer fires it; item 16 notes
  name muischeduler's nine byte-identical files as next round's cargo
  candidates. Tests 438 → 450.

## [1.6.40] - 2026-08-30

Two fleet-class findings from muischeduler's item-12 port, both checked
against this tree.

### Fixed
- `scripts/network_smoke.py`: the battery's default `UA` names the
  browser lane first — a Chrome/AppleWebKit token, then the internal
  token, then " network-smoke". At dash-improve-my-llms ≥ 2.8 the old
  bare-internal-token UA classified as a crawler, so every default-UA
  check read the prerendered crawler document; muischeduler's own
  manifest and og:image checks went red in CD's verify job when its
  floor moved. This template never went red only because its battery
  has no browser-document check on the default UA (its default-UA
  checks read healthz, llms.txt, robots and sitemap, crawler-lane
  surfaces regardless) — the lane was still wrong. Measured at 2.8.0:
  the new UA is `browser`, `INTERNAL_UA_TOKEN` is still a substring so
  the tracker drops it, `CRAWLER_UA` untouched. `scripts/smoke_live.py`
  already had this shape. Pinned in `tests/test_network_smoke.py`.

### Recorded, no change
- No og:image / twitter:card / twitter:image augmentation exists in
  this `run.py` (Dash emits them per page; `templates/index.html`
  declares only the auxiliaries and the one exempted static
  `twitter:card`). muischeduler's duplicate is fork-local (its
  divergence 8); sync item 17 tells forks how to check theirs.
- Wording notes folded into items 9, 12, 13 and 15 from the first fork
  reports: CI may pin the floor more than once (grep the number); tests
  that bypass conftest's `get()` via `app.server.test_client()` send no
  UA and land on the crawler lane; 1.6.33-block forks take the newer
  kit-test bytes on the one-hunk item-13 conflict; item 9 is a
  measurement pass, never a copy.
- `sync/SYNC-1.6.22-1.6.38.md` → `…-1.6.40.md`, item 17. Tests 437 → 438.

## [1.6.39] - 2026-08-30

The visual pass on 1.6.38 (ops seat in the owner's Chrome; contract
met, four notes). Fix-forward, same round.

### Fixed
- `/changelog` (and every page that renders no `.. toc::` — home, `/api`,
  the admin pages) is full width: `lib/aside.py` records which endpoints
  fill the aside and a callback on `url.pathname` collapses the
  AppShell's aside column elsewhere. Before, the shell reserved the
  column on every page.
- The mobile drawer is `keepMounted=True`. Measured on the wire at phone
  width: the hamburger's callback fired (`opened` → true, `n_clicks`
  incrementing, the dependency registered) while the drawer content
  never mounted in an unfocused window — a mount-on-open transition the
  nav must not depend on. It also guarantees `#navbar-admin-mobile`
  exists on every load. The owner's "burger does not open the
  navigation" report is taken as the fact; this removes the mechanism
  that can produce it.
- Code blocks no longer widen the page (`assets/main.css`): a flex parent
  with the default `min-width:auto` — Mantine's List item wrapper, a
  Blockquote — refused to shrink below the code's intrinsic width
  (measured: a 959 px document in a 200 px viewport; widest element the
  List itemWrapper around "Replace @server.before_request…" on
  /backend-comparison). Re-measured on the owner's local build at 391
  px after the first rule: the wrapper was constrained but its
  `.mantine-List-itemLabel` child still grew to 887 px — the label is
  constrained too; the `.. kwargs::` prop table carries
  `m2d-block-kwargs`, not `m2d-table`, and joins the scrolling-table
  rule (a 457 px table on /examples/directives); and `/changelog`'s
  bullet rows get `min-width: 0; overflow-wrap: anywhere` so a
  60-character test name in a `code` span breaks instead of pushing the
  row out of its card (a 549 px document); and inline code in prose
  (`code.m2d-codespan`) wraps with `overflow-wrap: anywhere` — the last
  353 px token on /backend-comparison. Verified on the owner's local
  build at 391 px: every page's document width equals the viewport,
  zero overflowing flow elements (13 pages swept in same-origin
  iframes). Public Mantine class names only.
- The Other Apps dropdown has a solid themed background and border in
  both schemes; every PRIMARY app has an icon (piratesbargain.com
  `mdi:pirate`, 2plot.media `mdi:movie-open-play-outline`), and the
  fleet's docs hosts lose their `mdi:web` placeholders.
- Four pins in `tests/test_nav_contract.py`. Tests 432 → 437.

## [1.6.38] - 2026-08-30

Navigation — **uniform where it should be identical, free where identity
matters** (owner design round, 2026-08-30; gates the fleet's 12+13
pushes as sync item 16). The survey found one root cause: the sidebar
was hand-written in `components/navbar.py` — a `page_order` of display
names, an `excluded_links` list inherited from the DMC docs that matched
nothing here, literal sections, the network typed by hand twice — and
every fork edited that file differently or not at all. `navbar.py` is no
longer a file forks edit.

### Added
- `lib/constants.py` navigation block: `GITHUB_URL` (one constant;
  `SAME_AS` reads it), `CATEGORY_ORDER`, `DISCORD_URL`, `YOUTUBE_URL`,
  `YOUTUBE_SUBSCRIBE_URL`, `DMC_URL`, `UPSTREAM`, `API_PACKAGES`,
  `resources()`.
- Other Apps as a top-bar hover menu (`components/header.py`) from
  `lib/network_directory.other_apps_for()` — the registry's `PRIMARY`
  set (2plot.ai, 2plot.dev, 2plot.media, piratesbargain.com,
  ai-agent.buzz; the owner's review of 2026-08-30 — never the docs
  subdomains, which 2plot.dev's catalogue lists) minus this host,
  labelled by domain, icons from a new `ICONS` table keyed by URL.
  2plot.media joins AFFILIATED so the registry stays the source. The
  sidebar's "Pip Components" and "Other Apps I've built" sections are
  gone.
- Admin section, owner-only: `navbar-admin-{desktop,mobile}` filled by a
  callback that returns nothing unless `is_admin_user()` (pip-docs+'s
  pattern); the startup tree carries no `/admin/` href; search lists
  sidebar pages only (never `/admin/*`, never hidden-tier).
- `/changelog` (`pages/changelog.py`): `CHANGELOG.md` as a DMC Timeline;
  the file, minus its H1, is the page's LLMS_DOC. Linked under Home and
  in the footer.
- `components/footer.py` — `AppShellFooter`: © {computed year} Pip
  Install Python LLC · GitHub profile (`GITHUB_PROFILE_URL`; the repo is
  the top bar's icon) · Discord · YouTube, every icon labelled; no
  Changelog link (the sidebar's is the single one), no Terms/Privacy.
- `/api` (`pages/api.py`, `lib/api_reference.py`): one `dmc.Table` per
  exported component from each `API_PACKAGES` entry's `metadata.json`
  (prop · type · default · description); the same tables as Markdown for
  `/api/llms.txt`. Not registered when the list is empty (the template's
  case); tested with `tests/fixtures/fake_dash_pkg`.
- `pages/markdown.py` `Meta.order` (default 1000) → `register_page(order=)`;
  every docs page declares `category:` and `order:`.
- `/admin/traffic`: `dmc.DatePickerInput` day picker (ledger-bounded,
  presets Today / Yesterday / Last 7 days) and a **People** section — the
  day's human hits, visitors, sessions and median session — above the
  crawler ledger, with the line "humans never enter the read ledger —
  the tables below are crawlers only".
- Inline `![alt](src)` images render through markdown2dash
  (`lib/directives/headings.py` `patch_renderer`): the library has no
  image renderer and mistune's fallback raised on DMC children.
- `tests/test_nav_contract.py` (19 pins) and the rewritten
  `tests/test_excluded_links_hidden.py` (admin pages hidden from both
  audiences and the sidebar tree). Tests 413 → 432.

### Changed
- `components/navbar.py`: sections from frontmatter against
  `CATEGORY_ORDER`; `page_order` and `excluded_links` deleted; Resources
  rendered from `resources()` — third-party only: `dmc` and the
  fork's `UPSTREAM` (owner's review, 2026-08-30).
- `components/header.py`: GitHub icon → `GITHUB_URL`; `dmc.Burger`
  `aria-label`; version badge from `API_PACKAGES[0]` when declared;
  search data from `navbar.search_data`.
- `pages/home.py` renders through markdown2dash, not `dcc.Markdown`
  (the fleet's no-`dcc` rule); `lib/directives/source.py` sets
  `copyLabel`/`copiedLabel`.
- The template's docs are five short sections: Getting started ·
  Backends · Content · Network · Auth.
- `sync/SYNC-1.6.22-1.6.37.md` → `…-1.6.38.md`, item 16 (contract now;
  names the files that become cargo next round).

### Recorded, no change
- Two places the design did not fit the tree: an `icon` field on the
  registry entries breaks `register_network` at boot (the package
  forwards every key) — icons live in `ICONS` keyed by URL; and Dash 4
  generated component classes carry no `_prop_names` — `metadata.json`
  and the docstring are the sources. Both are in item 16's contract.
- No `dcc.` remains in `pages/` or `components/` except `Location` and
  `Store`.

## [1.6.37] - 2026-08-29

Round 3.4 — **the posture flip.** Owner decision, 2026-08-29: AI-training
crawlers are allowed by default, because the ledger now records and
reconciles every read (boilerplate 534 == 534 against the wire). The
wall decided by vendor class what nobody could account for; a read
that is a row is priceable, and the tool from here on is per-vendor
policy, not the class. Canary: boilerplate + llms; the fleet via sync
item 15; clerkhook keeps its wall by design.

### Changed
- `run.py`: `RobotsConfig(block_ai_training=False, …)`; the comment
  block says why, and shows the per-vendor line
  (`vendor_policy={"bytespider": "block", "gptbot": "meter"}`) that
  replaces the class wall.
- The robots.txt fingerprint of ClaudeBot flips to `Allow: /` in
  `tests/test_llms_routes.py`, `scripts/smoke_live.py` and
  `scripts/network_smoke.py` — that line now fingerprints the POSTURE;
  OAI-SearchBot / Claude-User / Claude-SearchBot still fingerprint the
  artifact.
- `DIVERGENCES.md` posture fence: the interim measurement, dated with
  both UAs (2026-08-29T23:49Z, build ecc66f8: `/` 403, `/llms.txt`
  200, `/healthz` 403 — identical for ClaudeBot and GPTBot, in-process
  and on the wire). Two walls produced it: the app's (retired here) and
  the edge's (a Cloudflare rule on `/`, the owner's, narrowed after
  this release is verified on the wire). Expected after both:
  200/200/200; the block is re-measured and re-dated then.
- `sync/SYNC-1.6.22-1.6.36.md` → `…-1.6.37.md`, item 15.

### Recorded, no change
- Landed and measured on the wire (2026-08-30T00:09Z, build 700a170): ClaudeBot and
  GPTBot both 200/200/200 on `/`, `/llms.txt`, `/healthz`, robots.txt
  with no Disallow for either, and NO Cloudflare edit — the "edge wall"
  the drop framed was never observed on this host; every 403 was the
  app's. The fence, item 15 and this entry are corrected to say so;
  the owner is checking whether any zone rule exists at all.
- The robots.txt shape after the flip is no training stanza at all
  (GPTBot/ClaudeBot under `User-agent: *`), so the three fingerprint
  sites assert "no Disallow", not "Allow: /".

## [1.6.36] - 2026-08-29

What the first wire round taught the block. Spec, kit test and README;
the only test code that moves is the v4 rollup cargo test, made
v3-agnostic — no runtime code. Two findings, both clerkhook's
(2026-08-29, seat-verified): a live fan-out would have dropped
`tests/test_traffic_rollup_v4.py` and `tests/test_analytics_classifier.py`
at `tests/` root, where that fork's PACKAGE suite lives (its matrix
would ERROR, not skip), and the v4 test asserted v3 seams
(`load_agent_hits`, `bot_visitors`) that the fork — v4 without v3 —
does not have. The fence grammar could not express "I decline this
cargo", because the kit test required every fenced path to exist.

### Added
- `- <path>  # declined: <reason>` in the byte-owned fence
  (`sync/README.md` fork-fences section): the fork refuses this cargo.
  The only fence entry that may name a path absent at HEAD; the reason
  is mandatory; a spec's own block cannot carry one. `_machine_fence`
  in `tests/test_claude_kit.py` accepts it, and three new cases pin it
  (declined-missing validates; plain-missing still fails; bare
  `# declined` fails; a spec block cannot decline). The machine half
  already existed — `scripts/fanout.py` skips every path DIVERGENCES
  names; the ops seat adds the declined-entry test there.
- Posture key `unknown_ai: allow | meter | block` — the host's
  `default_unknown_ai`; dimll 2.9.0 widened "block" to absent and
  unrecognised UAs, which makes the value a posture a probe can see.
  `_POSTURE_KEYS`/`_POSTURE_ENUMS` extended; the template's own fence
  declares `unknown_ai: allow`.
- Kit trap: headless browsers are crawler-lane from dimll 2.9.0 —
  measured on the 2.9.0 wheel: `HeadlessChrome/…` and a Playwright UA
  classify `lane: crawler, bot_type: monitor, vendor_key: headless`
  (2.8.0 says browser). A host that screenshots itself for social
  cards gets the crawler document unless its screenshot service sends
  a non-headless UA. Item 8's notes point at it.
- `tests/fixtures/rollup_pre_v3.py` (clerkhook's rollup shape: no
  `load_agent_hits`, no `agent_visits`, no `bot_visitors`) and
  `tests/test_rollup_v4_is_v3_agnostic.py`, which runs the cargo test
  against it in a subprocess (`ROLLUP_V4_MODULE`). Green in this suite
  means the cargo is safe on every rollup shape the fleet has.
- `sync/README.md`: the dead-cargo rule (block rules) and the
  one-session-per-tree authoring rule (a sub-agent must not edit
  files its parent also edits; the completion signal is the report,
  never an idle notice).
- `sync/SYNC-1.6.22-1.6.35.md` → `…-1.6.36.md`, item 14.

### Changed
- `tests/test_traffic_rollup_v4.py` asserts only v4: `vendors[]`,
  `reads`, their shapes, and that every non-v4 key is identical with
  and without reads. `daily_rollup(app, day)` with the ledger in the
  env is the one call shape every version accepts; the
  `load_agent_hits` import and the `bot_visitors` assertion are gone.
- Item 12's fence note corrected: the question was never "does it
  import anything fork-shaped" but "does it pass against the OLDEST
  fork's rollup". Grep of the 12 local fork clones: clerkhook is the
  only pre-v3 fork; the other 11 carry v3.
- Item 9's key list names `deploy` and `unknown_ai`.

### Recorded, no change
- Dead cargo, first instance: `scripts/smoke_live.py` on clerkhook —
  611 lines, referenced by nothing, asserting content IS served on a
  host whose posture denies every surface (its DIVERGENCES §6 names
  `scripts/lockdown_smoke.py` as the inversion). Remedy = delete it +
  a `# declined:` entry; a clerkhook session action (item 14), not
  done here. Same fork: its site tests already live under
  `tests/site/`; the two item-12 cargo tests at `tests/` root are to
  be declined there.
- The floor stays ≥2.8.0. 2.9.0 is not required this round: nothing
  here depends on its behaviour — the `unknown_ai` key is a posture
  declaration and the headless trap is a warning; a host on 2.8.0
  simply has no headless lane yet. The floor moves when a release
  needs 2.9.0's routes, not its registry.

## [1.6.35] - 2026-08-29

Pipeline and docs only — no runtime code moves. **Render deploys
`release`, and only CD writes `release`** (owner decision A of
2026-08-29, chosen over hook-only and branch protection). The
measurement: 14:12Z that day, de0bcff was pushed to main; Render,
watching main, built it within the minute; cd.yml run 33256965081 went
red at 14:13Z with the deploy job skipped; `/healthz` served the red
build for ~6 minutes until acc3651. CI could not stop a deploy because
the platform watched the same branch CI was still judging. Auto-deploy
stays exactly as it is — it now watches a branch that only ever holds
certified commits.

### Changed
- `.github/workflows/cd.yml`: the `deploy` job (still `needs: [test]`
  — that is the gate) checks out and runs "Promote to release":
  `git push origin HEAD:refs/heads/release`, the run's own sha,
  fast-forward, never forced — a non-fast-forward means somebody wrote
  `release` by hand and the job fails and says so. Job-level
  `permissions: contents: write` on `deploy` only; the workflow stays
  `contents: read`. Guarded with `if: github.event_name == 'push' ||
  inputs.target_url == ''` so a verify-only dispatch against another
  host never moves the ref (the hook step had no such guard). The
  build-match wait is unchanged. First run creates the branch.
- `render.yaml`: `branch: release`, autoDeploy left unset (on).
- `DIVERGENCES.md` posture fence gains `deploy: release-branch`
  (absent reads as main); `tests/test_claude_kit.py` `_POSTURE_KEYS`
  admits it.
- `.claude/CLAUDE.md`: the trap — `build == HEAD` on `/healthz` means
  HEAD of `release`; `main` ahead of `release` is an uncertified push
  pending, never drift, never a hand deploy.
- `sync/SYNC-1.6.22-1.6.34.md` → `…-1.6.35.md`, item 13 (contract:
  every fork's cd.yml differs in host, timeouts and comments — 12 of 12
  measured — so nothing rides the block).
- Tests: +7, `tests/test_cd_promotes_release.py` — parses cd.yml and
  render.yaml and pins the structure (needs, the unforced push, the
  dispatch guard, the job-only write grant, the hook's absence, the
  branch, the fence).

### Removed
- The Render deploy-hook step and its secret from cd.yml. A
  `RENDER_DEPLOY_HOOK_URL` repository secret is now inert and safe to
  delete (owner; dashboards are out of a session's reach).

### Recorded, no change
- Fix-forward, same version (ops finding on run 33262495272): `verify
  the live site` ran and reported GREEN on a run whose promote step had
  FAILED — its `if: always() && != 'cancelled' && != 'skipped'` admitted
  `failure`, so it smoke-tested the previous build. Now
  `if: needs.deploy.result == 'success'`, and verify's first step asserts
  `/healthz build == github.sha` itself (also catches a promote that
  succeeded while Render's build did not). Pinned.
- Fix-forward, same version: the promote checkout is `fetch-depth: 0`.
  Run 33262495272 (747d8b3) failed "Promote to release" in one second —
  a depth-1 clone pushing onto an EXISTING `release` is rejected as
  non-fast-forward ("fetch first"), because a shallow history cannot
  show the remote that release's tip is an ancestor. ea4e104's promote
  passed only because it CREATED the branch. Reproduced locally
  (depth-1 rejected, `--unshallow` lands); pinned in
  `tests/test_cd_promotes_release.py`. That failed run is also the
  dashboard proof: with Branch = release, main moved and neither
  `release` nor the wire did (36 samples over 7 min, ops seat).
- Dashboard Branch set to `release` for boilerplate-2plot-dev by the
  owner, 2026-08-29 (after the first promoted run showed Render had
  built ea4e104 from main). This entry's own push is the road's proof
  — see the ops report for its three timestamps.
- Owner step, named not assumed: if the Render service is not
  Blueprint-managed, the dashboard's Branch field is the switch, not
  render.yaml. The first promoted run cannot distinguish the two from
  the wire (main and release hold the same sha); the next red push on
  main can. A `branch` field on `/healthz` from `RENDER_GIT_BRANCH`
  would make it one read — a runtime change, proposed, not done.

## [1.6.34] - 2026-08-29

The ledger round — the template half of the network's ledger plan
(`PLAN-ledger-and-corpus-2026-08-29`: *the ledger is the asset, not the
wall*). dash-improve-my-llms 2.8.0 now produces the ledger's key
(`classify()`) and its row (`on_document_read`); this release makes the
app the first thing that KEEPS the row, and the first place a host's
bot accounting can be checked against its own wire. Nothing new leaves
the host: the reporter POSTs the same endpoint with two more keys the
hub ignores until round 3.3. No hub change, no posture change.

**The finding, dated 2026-08-29.** `lib/analytics_tracker.py` was the
fleet's fourth classifier, and it was wrong about the vendor the whole
plan is written around: its search list named `claudebot` — Anthropic's
*training* crawler, which the package's registry has said since 2.3.3
and this repo's own `run.py` comment said six lines from where the list
ignored it — and still carried the retired `anthropic-ai` /
`claude-web` tokens. `device_type == "bot"` decided `human_hits`, and
it was set from that list, so an `httpx`, `Go-http-client` or UA-less
request was a *human* in every rollup the hub has ever received. Every
host in the fleet reported those numbers.

### Added
- `AnalyticsTracker.record_read` and the ledger's second table:
  `run.py` registers it with `on_document_read` (once; guarded against
  the suite's repeat imports) and every corpus document the package
  serves lands as a row in `reads` in the same `visitor_analytics.json`
  — every `_ledger.EVENT_FIELDS` key verbatim plus `kind: read`, same
  buffer, lock, flush cadence and retention as `visits`; `client_ip`
  dropped unless `ANALYTICS_KEEP_CLIENT_IP=1`. A pre-1.6.34 ledger has
  no `reads` key and reads as empty. `reads` is a second table joined
  by the rollup, never summed into `human_hits` / `bot_hits` / `pages`.
- Rollup v4 in `lib/traffic_rollup.py`, additive and present only on a
  day with reads: `vendors[]` — one row per `(key, verified, policy)`
  with `class`, `hits`, `bytes` and a seven-key `tiers` map from the
  package's `TIERS` tuple; the null-key row (the unidentified crawler
  lane) is kept on purpose; sorted by hits, capped at 40 — and
  `reads: int`, the reconciliation total. `load_reads()` and
  `vendor_rows()` are the new seams; `daily_rollup(..., reads=)` loads
  them itself when not handed them, so `lib/satellite_reporter.py`
  changes nothing. Every v3 key byte-identical; `tests/test_traffic_rollup.py`
  passes unmodified.
- `pages/traffic.py` — `/admin/traffic`, the host's own ledger behind
  the control board's exact gate (fails closed without Clerk;
  `ALLOW_UNGATED_ADMIN=1` locally; `mark_hidden` at import): vendor ×
  day with a bytes column, vendor → tier and top paths per vendor for a
  picked day, the v3 headline numbers for the same day, and the line
  that says `n/a` means the operator publishes no ranges (Anthropic
  does not; ClaudeBot is always `n/a`). Plain tables, no charts, one
  dropdown — fleet fact 18.
- `.claude/CLAUDE.md`: the ONE-classifier trap.
- `sync/SYNC-1.6.22-1.6.33.md` → `…-1.6.34.md`, item 12 (class
  contract, with `tests/test_analytics_classifier.py` and
  `tests/test_traffic_rollup_v4.py` as the block's cargo — the two that
  import nothing fork-shaped).
- Tests: 372 → 401. `test_analytics_classifier.py` (each pin a UA from
  the wire, plus the grep that fails if a hand-written list comes
  back), `test_read_ledger.py` (the real app: one `GET /llms.txt` from
  GPTBot → exactly one row with tier/lane/vendor/verdict/status/bytes
  and no `client_ip`; a Chrome `GET /` → none; the hook registered
  once; a raising writer warns and the response is unchanged),
  `test_traffic_rollup_v4.py`, `test_traffic_page.py` (hidden from the
  sitemap and llms.txt, denied identically to the board, and a 3-vendor
  × 2-day fixture whose cells are asserted as numbers).

### Changed
- dash-improve-my-llms floor **2.7.1 → 2.8.0** in every encoding
  (`requirements.txt` × 4, `run.py`'s `LLMS_PKG_FLOOR` and its
  diagnostic; `tests/test_config.py` reads the boot floor). Why 2.8:
  one classifier, the read event, `Vary: User-Agent`, verified vendor
  identity. 2.8.1 (the resolved `policy` on every event) is NOT waited
  on — `policy` is `None` until then and the rollup groups it as
  `"default"`.
- `lib/analytics_tracker.py` delegates: `is_bot` and `detect_bot_type`
  keep their names and signatures and their bodies are `classify()`;
  `track_visit` classifies once, after the real client IP is resolved,
  and crawler rows gain `vendor_key`, `vendor_class`, `verified`,
  `lane`. Human rows are unchanged byte-for-byte. The module carries
  zero User-Agent strings. Tokens the old list had that the registry
  lacks — `headlesschrome`, `phantomjs`, `monitoring`, `uptime`,
  `pingdom`, `better-uptime` — are a pushback to the package seat,
  named in the 1.6.34 report, not a list kept here.
- **Reporting consequence:** `human_hits` drops and `bot_hits` rises
  on every host that adopts this, because UA-less and library clients
  move from human to crawler (and an empty UA is a bot now, where it
  used to be a desktop human). That is the number becoming true, not a
  regression; the hub's day-over-day view will show a step on adoption
  day.

### Fixed
- (follow-up commit, same version — `de0bcff` went red in CD and Render's
  autoDeploy shipped it anyway, uncertified.) `tests/test_proxy_scheme.py`'s
  end-to-end tag test sent NO User-Agent; under 2.8.0 an absent UA is the
  crawler lane, so it received the crawler document — which has no
  `twitter:url` at all — and failed on "no tag" on every Flask leg
  (green on 2.7.1; it passed the template's local 3.11 run only by test
  ordering). It now names the browser lane. The mirror of the kit's
  machine-lane trap: either lane can be the one you did not mean to
  test. Also three flake8 F401s in the new files.
- Nothing else — the classifier finding was never a bug by its own
  lights; it is a Changed.

### Recorded, no change
- In-process (test client, no client address) the package cannot check
  GPTBot's published ranges and says `verified: n/a`; on the wire the
  same request is `verified` / `unverified`. The drop's acceptance
  named the pair; the tree pins the triple and says why.
- `DIVERGENCES.md`'s posture fence is unchanged: `/` still answers 403
  to training UAs. The flip is round 3.4, the owner's.

## [1.6.33] - 2026-08-27

A correction release: no behaviour changes, no test changes, no
change to what the sync block carries. What changes is what the
documents TEACH, and it lands ahead of the fan-out that carries item
11, because that item was about to teach two forks a mechanism that is
false. **Scope note, per the drop that requested this one:** its §4
asked whether the middleware should survive the package fix, and said
a code change would be a legitimate expansion. It did not become one —
the answer is "keep it", so the change is the docstring that says why.
Comments and docstrings naming the wrong layer were corrected in five
files; not one line of executable code moved.

**It is FastAPI, not Starlette.** Every document here said Starlette
registers only the methods a route declares. It does not:
`starlette.routing.Route.__init__` ends with
`if "GET" in self.methods: self.methods.add("HEAD")` — the same
courtesy Werkzeug extends. `fastapi.routing.APIRoute` is the class
that takes `methods` literally and adds nothing. Both statements
verified in the installed source (starlette 1.1.0, fastapi 0.141.1).
Two seats and three probes reached for the wrong layer on this, which
is reason enough to state the true one precisely once: a fork that
reads "Starlette does not derive HEAD" searches the wrong package, and
may conclude a bare-Starlette mount is affected when it is not.

**The population was seven hosts, not two.** Item 11 named pannellum
and muischeduler. The ops seat's wire sweep of 2026-08-27/28 found the
hub (2plot.ai) had the defect too — it was never in the item's
population — plus four second-ring sites the fan-out does not reach:
piratesbargain.com, ai-agent.buzz, 2plot.xyz and cast.2plot.net. They
are named in the notes as known-affected-reached-by-a-drop, because
notes that stop at the fan-out's edge read as "these two hosts" when
the truth is "every ASGI host in the network, and we know which."

**The middleware stays after the package fix, and this tree measured
why.** dash-improve-my-llms 2.7.2 declares `["GET", "HEAD"]` on its
own doc routes, which removes one reason for `HeadAsGetMiddleware` and
not the others. `/` is served by Dash's page catch-all, and *every*
Dash route is a FastAPI `APIRoute`:
`dash/backends/_fastapi.py::add_url_rule` calls
`add_api_route(..., methods=methods or ["GET"])`, with the catch-all
registered `methods=["GET"]`. Re-measured in-process with the
middleware removed: 10 of the 11 pins red, the single green being
`HEAD /` with a *crawler* UA — the prerender answering before the
router. The same path with a *browser* UA is 405. Nothing in this repo
or in the package can declare methods on Dash's routes.

### Changed
- `sync/SYNC-1.6.22-1.6.32.md` → `sync/SYNC-1.6.22-1.6.33.md`. Item
  11's `contract:` carries the corrected layer and the reason to get
  it right; its `notes:` carry the seven-host population, the
  middleware's permanence with the Dash measurement behind it, and an
  upstream item that is now Dash's rather than the package's.
- Item 10's `notes:` gain the hub as a **fourth** red and the shape
  lesson under it: a hand-declared `/healthz` drifts from the fleet
  shape silently, and a package floor can make a key *impossible*
  rather than merely absent. Different states, different remedies,
  and only one of them is a divergence.
- `.claude/CLAUDE.md`'s HEAD trap: the layer corrected, the population
  widened to every ASGI host, and the "keep the middleware" note so
  the next floor bump does not delete it.
- Comment/docstring corrections in `lib/asgi_middleware.py`,
  `tests/conftest.py`, `tests/test_head_method.py`,
  `scripts/network_smoke.py`, `scripts/smoke_live.py` — the same false
  sentence, five times, in the files a fork reads while porting.

### Recorded, no change
- Item 10's red list disagrees with its own dated table on two rows
  (`llms` missing `python` on 08-27; `pipdocs` missing `backend` on
  08-28, with no llms row). Both are written down, neither is
  reconciled here — the spec says to re-measure rather than pick.
- 1.6.32's "empty body" is read as **the wire is empty**, never as
  "your adapter empties it": Werkzeug's response, httpx's ASGI
  transport and h11 under both servers each drop it, and h11 raises if
  a server forwards one. The package seat declined to assert emptiness
  in its adapters for that reason and was right.

## [1.6.32] - 2026-08-27

A defect release, and one this template shipped to two forks itself.
**`HEAD` returned 405 on every route of both FastAPI hosts** —
pannellum and muischeduler, measured on the wire — `/healthz`,
`/robots.txt` and `/sitemap.xml` included. Werkzeug derives a HEAD
rule from every GET rule; Starlette does not, so every route declared
`@router.get(...)` answered "method not allowed" to the method most
uptime monitoring probes with, against the two hosts whose deploy
proof IS `/healthz`. Neither fork did anything wrong: they inherited
it by choosing the backend this template ships.

It hid perfectly. CI never issued a HEAD, both live tools GET (which
is the standing rule, and this defect is why the rule exists), and a
browser never sends HEAD for a document. The one in-process probe that
did run HEAD — 1.6.31's, checking whether the ASGI lane lost the
`Link` headers — ran it against `/`, which is the single route that
answers, because the package's prerender middleware replies before the
request reaches the router at all. True observation, wrong conclusion,
twice over: a 405 carries no `Link` header either.

### Added
- `lib/asgi_middleware.py` — `HeadAsGetMiddleware`, pure ASGI, added
  last so Starlette runs it outermost: a HEAD request is re-dispatched
  as GET and answered with the same status and headers and an empty
  body. Middleware rather than `methods=["GET", "HEAD"]` on the
  declarations because this tree declares only two of the affected
  routes — `/llms.txt`, `/<page>/llms.txt`, `/robots.txt`,
  `/sitemap.xml` and the policy panel come from
  dash-improve-my-llms' own FastAPI adapter (GET-only; only the root
  icon paths declare HEAD) and `/` from Dash's page catch-all. Fixing
  what we declare would have left three of the four crawler-facing
  surfaces 405ing.
- `tests/test_head_method.py` — status, `content-type` and `Link`
  parity between HEAD and GET on all five core routes, both UAs, per
  backend. Proven the only way it can be: with the middleware removed
  the FastAPI leg fails 9 of 11 and the Flask and Quart legs pass. A
  pin that is green on all three before the fix is testing the test
  client, not the router.
- One check in each live tool — `HEAD /healthz` answers what `GET
  /healthz` answers. One request, and the check that would have caught
  this on the first CD run after either fork switched backends.
- `Client.head()` / `Client.open()` in `tests/conftest.py`, so the
  method is a first-class question across all three test clients.

### Changed
- `scripts/smoke_live.py`'s `fetch` grows a `method` keyword — used by
  exactly one call site, and every other one still GETs. This is
  1.6.29's wake() hazard verbatim: twelve fixed-signature `fetch`
  stubs in `tests/test_smoke_live.py` had to grow `method` in the same
  commit, and `tests/test_network_smoke.py`'s stub asserted
  `method == "GET"` outright — it now names the one path allowed to
  differ rather than dropping the guard. Forks port the check and the
  stubs together.
- `.claude/CLAUDE.md`: the HEAD trap now carries the measured
  mechanism (nothing answers HEAD on the ASGI lane) in place of the
  1.6.31 text (the ASGI hosts drop the `Link` headers, cause open),
  and warns off both false verifications — one Flask host, and
  `HEAD /` with a crawler UA. A second trap generalises the certifi
  habit: any throwaway probe a session writes against production needs
  the SSL context and a retry guard, because fixing the shipped tools
  does not cover the next ad-hoc script.
- `sync/SYNC-1.6.22-1.6.31.md` → `sync/SYNC-1.6.22-1.6.32.md`, gaining
  item 11 (HEAD answers wherever GET answers; conditional on a
  non-Flask backend, `already-present` on a Flask fork — run the
  detect anyway, the answer records which lane the fork is on).

### Fixed
- Quart was measured, not assumed, and needs nothing: all five core
  routes answer HEAD 200 there. Its test client returns the body where
  Werkzeug's and Starlette's strip it, which is the client — h11,
  under uvicorn and hypercorn both, frames a HEAD response as
  content-length 0 and never writes those bytes.

## [1.6.31] - 2026-08-27

What the F4 round taught, most of it arriving as fork pushback the ops
seat adjudicated and accepted. The headline is a retirement: "byte-copy
the template's current file" stops being the recommended port for
`scripts/smoke_live.py`, because three forks contradicted its premise
independently — the file's CONTENT is fork-owned on at least three
hosts, not just its interface. A copy deletes measured checks silently
(green: a deleted check does not fail, it stops being true) or asserts
a posture the host deliberately does not have. Diff and port; copying
is safe only when the diff is empty in both directions.

Two more things this release stops being able to hide. `/healthz` is
read by four different machines BY KEY NAME, and a thirteen-host read
found one fork answering `dash` where the battery asks for
`dash_version` — reporting the Python item green, correctly, while
being invisible to every reader. And spec item 7, the configured gate
page, has been `open` here for three releases while four forks
reported it `open` for the same reason and rightly declined to invent
a shape; the template owed the reference and now has one.

### Added
- `tests/test_healthz_shape.py` — the payload's KEY SET is a fleet
  contract (`app backend build dash_version geo ok python`). Extras
  are always fine; a rename is the failure this pins, because it is
  invisible to every check that reads a value rather than a key.
  Checks the builder AND the live route (a typed response model that
  drops a field leaves the builder green), and `build` the only
  honest way: set `RENDER_GIT_COMMIT`, assert the key appears.
- `tests/test_gate_configured.py` — spec item 7 adopted here at last.
  Renders a real registered page, pinned to `auth` through the control
  board's own writer, with three FAKE non-empty `CLERK_*` values, and
  asserts the sign-in card rendered — the branch production serves and
  no test had ever reached, since every battery in this fleet boots
  zero-secret. The card's markers are read off `lib/gate_layouts.py`
  by rendering its own card, never re-typed. Its second test is the
  non-vacuity control (same page, zero-secret, serves the CONTENT —
  the documented fall-open); its third pins `admin` closed in both
  postures. `conftest.py` is unchanged: `clerk_enabled()` reads the
  environment per call, so `monkeypatch.setenv` was enough.
- Spec item 10 — `/healthz` declares the fleet's shape, with the
  three current reds and each one's remedy.
- `sync/README.md` gains a section on the fork's OWN fences
  (`byte-owned`, `posture`): grammar, absence-vs-empty, and the rule
  that content you ported rather than copied goes in the byte-owned
  fence in the same touch. The fence had been validated by the kit
  test since 1.6.22 and documented nowhere — every fork's was empty,
  so nothing had ever exercised the format until flows put a path in
  one (flows).

### Changed
- Sync spec renamed `SYNC-1.6.22-1.6.30.md` → `SYNC-1.6.22-1.6.31.md`.
  Nothing new rides the block and no cargo file's bytes change: both
  new tests call into fork-owned modules, which the mirror rule makes
  contract, not cargo.
- Spec item 6's contract: **diff, do not copy**, with the three
  contradicting hosts named — flexlayout (six fork-added check blocks
  a copy would delete), leaflet (its open-training posture, same
  shape, unfenced), muischeduler (the template's `ClaudeBot ->
  Disallow: /` assertion FAILS on its deliberate
  `block_ai_training=False`). The tolerant `wake()` stays and its
  claim is narrowed to what it buys: a stale-stub landing degrades to
  the fork's old red checks instead of a suite-wide TypeError. Its
  notes gain the fence rule and the measurement behind it — three of
  the four forks with fork-owned content fence the path; leaflet does
  not (ops seat, eleven public forks, 2026-08-27).
- Spec item 8's `files:` names `tests/test_social_card.py`, and its
  notes name the expected red the way 1.6.26 named the DEMOS red:
  the static `name="twitter:card"` trips the fork's own
  no-static-duplicate sweep, and the fix is writing the exemption
  into that test, never deleting the tag you just added (pannellum
  and modelviewer, independently). `files:` is a scope statement; one
  that omits a file the body requires teaches sessions to distrust it.
- The block's SEQUENCING note is struck. `scripts/fanout.py`
  implemented all three gate forms on 2026-08-26 — before the last
  round ran — while the note kept telling every fork session the
  fan-out was unsafe.
- `.claude/CLAUDE.md`, two traps. "GET, not HEAD" is now stated as
  what it is: a BACKEND-level divergence (ten Flask hosts serve the
  `Link` headers on HEAD, both FastAPI hosts drop them), so verifying
  it false on one host proves nothing — excalidraw was right about
  its own host and wrong about the rule. Where the loss happens is
  recorded as still open: this repo's in-process probe gets both
  `Link` headers on HEAD on both backends, so the app code is not it.
  And the UA-less-curl counter-observation is marked unconfirmed —
  muischeduler filed the same note and retracted it.

### Fixed
- `scripts/smoke_live.py` fetched the home page twice in sections 2,
  3 and 3c: every loop prepends `/` and this fleet's sitemap lists
  `/` first. Filtered once at the sitemap parse rather than at three
  call sites. The waste was minor; the reason it is pinned is that
  two checks emitted the SAME label, so one failing home page printed
  two identical FAIL lines and read as two broken pages (flexlayout
  reported 3c; it was all three).

## [1.6.30] - 2026-08-27

The F4 battery's first round, closed by two §A fork sessions
(emojimart f6da429, muicharts 9c4985e) that corrected the ops seat's
diagnosis the same way and found fleet-class gaps on the way. Two
lessons carry: a green suite proves nothing about a file NOTHING
exercises — where 1.6.29 diagnosed the loud failure (a legacy stub
TypeErrors), the quiet one is a fork with no `tests/test_smoke_live.py`
at all, where the byte copy landed green and CD against production
was the first thing ever to run the script — and identity that is
authored twice drifts, which is why the browser head and the crawler
head disagreed on six of seven audited hosts.

### Changed
- Sync spec renamed `SYNC-1.6.22-1.6.29.md` → `SYNC-1.6.22-1.6.30.md`.
  Nothing new rides the block; what changes is what the items ask.
- Spec item 6 now names **both** live tools (`scripts/smoke_live.py`
  and `scripts/network_smoke.py`) and grows a fourth detect question:
  does `tests/test_smoke_live.py` exist at all? Absence is
  NOT-ADOPTED, never not-applicable. Its 1.6.29 note claiming
  emojimart and muicharts "matched signatures" was wrong — neither had
  the test — and the item now names, per state, what each fork that
  already took the copy must do.
- Spec item 5 corrected three ways: the site lane is declared by JOB
  NAME (not by file), the compat window is *the fleet minor, one
  adjacent minor, and the site's declared floor where it is lower*
  (emojimart's 3.10 leg and muicharts' narrowing are both right), and
  the render.yaml pin compares the MINOR because Render resolves the
  patch itself (muicharts asked 3.14.7, was served 3.14.3).
- Spec item 3's `files:` line now names the 1.6.26 docstring paragraph
  in `lib/auth_demos.py` alongside the entry (pannellum): a fork whose
  copy still says the table "ships EMPTY" describes a template that
  stopped existing at 1.6.26.
- Spec item 1's retirement note: the SUPERSESSION class is not retired
  with the item — dispatch lag and two pushes in one window reach the
  same state with no bot anywhere. Same correction in the kit
  CLAUDE.md trap.
- `sync/README.md`: the who-stubs-this rule gains its mirror — **what
  does this call into?** — and a new rule, that a file LEAVING the
  block must be accompanied by the forks it already landed on and what
  each must do (removal stops the next fan-out and undoes nothing).
- `.claude/skills/wire-verify/SKILL.md` step 3 names a crawler UA
  instead of relying on curl's default. Which lane answers is the
  package's UA classification, not the absence of a UA.
- Kit CLAUDE.md gains four traps: a failed STEP in a
  `continue-on-error` job is not a failed RUN; never round-trip JSON
  through zsh `echo`; repeated HTTP headers survive only if you keep
  them (`dict(resp.headers)` and a dict comprehension over `.items()`
  both drop all but the last); name the crawler UA when probing the
  machine lane.

### Added
- Spec item 8 — **the two heads declare the same identity.** New pin
  `test_the_browser_head_declares_the_configured_icons`: the browser
  head's icon links equal `configure_seo(icons=)` as unordered
  (rel, href, sizes) triples, query strings stripped so Dash's
  cache-busting favicon link is not read as a phantom declaration. It
  caught the template itself — `templates/index.html` declared
  `/assets/favicon.ico` while run.py declared
  `/assets/favicon/favicon.ico`, byte-identical files at different
  paths, invisible to the wire-side parity check because that one
  compares the set of SIZES and an .ico declares none. The browser
  link now points at the declared path.
- `test_the_twitter_card_is_a_large_image` tightened to pin both forms
  at exactly one occurrence each. Dash emits `property=`, which
  Twitter's parser does not read; the static `name=` tag is the only
  declaration a scraper sees, which is why it is the standing
  exception to the no-static-duplicate rule — an exception that decays
  in both directions unless pinned on both sides.
- Spec item 9 — **DIVERGENCES.md declares this host's POSTURE.** A
  second machine fence (```yaml posture) with `ai_bots` (path →
  status, measured with a real vendor UA), `healthz`
  (`minimal`|`full`) and `runtime` (`docker`|`python`). The kit test
  validates shape (one fence, known keys, enum values, integer
  statuses) and holds `runtime:` against render.yaml; absence skips,
  empty means template defaults. The hub reads this instead of its own
  seeded table. Measured here 2026-08-27:
  `{"/": 403, "/llms.txt": 200, "/healthz": 403}` for ClaudeBot and
  GPTBot, 200 on all three for a browser.

### Fixed
- `scripts/network_smoke.py` had no SSL context — CI runs it against
  `http://localhost`, so TLS never came up and the omission was
  invisible for six months of releases. From a Mac against a
  production https host it returned 0/12,
  indistinguishable from the site being down (muicharts). Same
  certifi-backed `_ssl_context()` as smoke_live.py and audit_links.py;
  verification stays on either way.
- `tests/test_python_version.py` is job-scoped: `SITE_LANE_JOBS` /
  `PACKAGE_LANE_JOBS` classify every job, the pins read site-lane job
  bodies only, and a new guard fails loudly on any job that declares a
  Python literal and sits in neither set. Until now the greps read the
  whole file while the docstring promised the site lane only — the
  first fork with a package matrix failed on the lane the item
  disclaims. The window rule follows item 5's correction, and
  `SITE_PYTHON_FLOOR` (3.12 here) is pinned against the README's own
  promise so a floor cannot be invented inside the test.

## [1.6.29] - 2026-08-26

The 1.6.28 corrections' own correction, one round later. The LIVE
fan-out of SYNC-1.6.22-1.6.28 (2plot-network run 33000661276) went
red on 7 of 12 forks with one signature: the block's new cargo
`scripts/smoke_live.py` calls `fetch(url, retries=1, timeout=10)` in
wake(), and every fork's OWN `tests/test_smoke_live.py` monkeypatches
`fetch` with the pre-1.6.2x signature `(url, user_agent, accept)` —
TypeError before a single check ran. The file is byte-invariant; its
INTERFACE is pinned by a fork-owned, fork-specific test that can
never be cargo. Invariance of a file is not invariance of its
contract.

### Changed
- Sync spec renamed `SYNC-1.6.22-1.6.28.md` → `SYNC-1.6.22-1.6.29.md`.
  `scripts/smoke_live.py` removed from the sync-verbatim block; item 6
  reclassed **contract** for every fork — detect: wake loop
  (`SMOKE_WAKE_ATTEMPTS`) + fetch retries (`SMOKE_FETCH_RETRIES`) +
  explicit SSL context on EVERY urlopen; acceptance: the fork's own
  `tests/test_smoke_live.py` green against its own copy, stubs updated
  in the same touch. The divergent-tool branch (clerkhook's
  lockdown_smoke.py) kept as written.
- `sync/README.md` gains the authoring rule the round bought: **a
  whole file is verbatim-safe only if no fork-owned test exercises
  its interface** — ask "who stubs this?" for every cargo candidate.
- `scripts/smoke_live.py` wake() is stub-tolerant: when a legacy
  fetch stub rejects the retries/timeout kwargs (TypeError at
  signature binding — the real fetch cannot raise it), the probe
  falls back to a bare `fetch(url)`. A template copy landing ahead of
  a fork's stub update now degrades to that fork's honest check
  results instead of a suite-wide crash. Pinned by
  `test_wake_survives_a_legacy_fetch_stub`. This makes the release
  spec + one CD-tool robustness change, not spec-only as dropped.
- Spec item 3 notes gain the 1.6.29 cargo audit of
  `tests/test_auth_demos.py`: nothing stubs it, but it calls INTO two
  fork-owned seams (DEMOS's dict shape, conftest's `app_module`
  fixture) — fleet-uniform today; a reshaped fork reads a red there
  as interface drift, not the detect firing.

## [1.6.28] - 2026-08-26

Batch-2/3 corrections release — spec text, gate grammar and reference
pins only; no runtime change. Five forks consumed the 1.6.27 spec at
`055363e`; three (flows, muischeduler, clerkhook) filed the SAME
correction to item 5 independently, two filed the same `# requires:`
gap — independent convergence, adjudicated by the ops seat.

### Changed
- Sync spec renamed `SYNC-1.6.22-1.6.27.md` → `SYNC-1.6.22-1.6.28.md`.
  Item 5 amended: render.yaml BRANCHES on `runtime:` (`python` →
  PYTHON_VERSION required, minor-pinned; `docker` → must be ABSENT —
  nothing reads it there, and a string that reads like the platform's
  setting but can never be true is the item's own defect class); the
  SITE lane's Python split from PACKAGE matrices and both named; a
  MISSING healthz `python` field is not-adopted, never n/a
  (emojimart); minimal-payload hosts (clerkhook's recorded
  divergence) get skip-with-notice from `python_matches_declared`.
- The sync-verbatim blocks in ALL THREE specs gate on the kit
  CONTRACT (`# requires-contract: .claude/CLAUDE.md :: Check the
  prompt against this tree`), never on kit paths — flows' pre-existing
  non-kit CLAUDE.md read as adopted was the third "no .claude/"
  misread. `tests/test_auth_demos.py` carries a per-file gate
  (`# requires: lib/auth_demos.py`) so lockdown forks receive the
  rest of the block (clerkhook, dry run 32991971564). SEQUENCING:
  fanout.py must learn both forms before the next round.
- `tests/test_claude_kit.py` `_machine_fence` validates the two new
  gate forms — path + clause real at template HEAD, per-file gate
  path real — the release's one kit-test change.
- `tests/test_python_version.py`: the render.yaml pin branches on the
  service runtime (both branches upstream as the fleet reference);
  SITE-lane naming in the module and test docstrings.
- `SYNC-1.6.17-1.6.21.md` item 1 amended: detect wording (`pytest -v`
  shows PASSED — a SKIP or an absent test is not-ported; emojimart's
  "6 passed, 1 skipped" read identically for both unported states);
  the fence question ("is the difference a fork DECISION?" — drift is
  never fenced; flexlayout/muicharts' behind-1.6.24 dependabot.yml);
  muicharts fan-out PR #6 cited as the fence's concrete case.
- `.claude/CLAUDE.md` traps: anonymous api.github.com is 60 req/h —
  read a run once after CI completes, never poll blind; a JSON body
  without the asked-for field is a rate-limit error, not an empty
  list; `git fetch` before any audit.
- `/report` skill + `sync/README.md` + all three specs' Reporting
  sections: `open` added to the disposition vocabulary (detect fires,
  deliberately out of this session's scope).

### Added
- Spec item 6: `scripts/smoke_live.py` is versioned cargo — verbatim,
  rides the block (fork-invariant by construction; muischeduler's CD
  certified with a 1.2.4-vintage copy); contract half for forks whose
  CD uses a different live tool (wake loop + retries + SSL context).
- Spec item 7: the battery must see the CONFIGURED gate/lock page —
  fake config in a test, non-vacuous configured-branch assertion,
  marker/stripping rules imported from the live tool by path
  (clerkhook 5c5e9ea; class fleet-wide). The template's own adoption
  is recorded `open` — this suite blanks every Clerk secret before
  import.
- `sync/README.md`: the gate rule (a gate names a contract, never a
  path, wherever a pre-existing file can occupy the path; three gate
  forms documented) and the vendoring rule (specs are NOT vendored
  into consumers — decided over scoping the pins to the authoring
  repo).

## [1.6.27] - 2026-08-25

One Python for the fleet. The repo declared three (Dockerfile
`3.11.8-slim` — a patch pin that never received a 3.11.x security
release; CI matrix `3.12`; render.yaml `3.12.0`), the docker
boot/battery tested an interpreter the matrix never ran, and healthz
could not contradict any of it — the drift was invisible to the
battery by construction (ops-seat finding, read in the tree,
2026-08-25). The fleet Python is now **3.14**, decided by evidence:
the full suite and the real image's boot/battery ran green on
`python:3.14-slim` before anything shipped.

### Changed

- **`Dockerfile`**: `FROM python:3.11.8-slim` → `python:3.14-slim`.
  MINOR tag, never a patch pin — the minor tag tracks 3.14.x fix
  releases through the registry; the patch pin was the security bug.
- **CI matrix**: main `3.12` → `3.14` (all three backends); window
  legs `3.11`/`3.13` → `3.13`/`3.12` (three wide around the main —
  3.15 is at rc1 and does not exist as a release). The two dash-4.4.0
  bottom-range rows stay on 3.12 deliberately: each include row
  varies one axis. lint, pip-audit and cd.yml's verify job align to
  3.14.
- **`render.yaml`**: `PYTHON_VERSION` `3.12.0` → `3.14.7` (Render's
  native runtime requires full X.Y.Z; the minor is what the test
  pins). Blueprint env applies on a sync, not a push — a lagging
  dashboard value is exactly what the new battery check catches.
- **README / home.md**: supported floor 3.11+ → 3.12+ (the window).

### Added

- **`/healthz` reports `python`** (`platform.python_version()`) —
  one field, both backends: set in `lib/health.py`'s shared builder,
  typed as a required field on `HealthResponse` in
  `lib/asgi_routes.py`, pinned per backend in
  `tests/test_llms_routes.py`. The serving interpreter is now
  observable, so this drift class cannot re-hide.
- **`python_matches_declared`** in `scripts/network_smoke.py`: the
  served minor must equal the Dockerfile's FROM minor (declaration
  read from the checkout; field presence alone where there is none).
  Runs in CI against the booted container and in CD against
  production, like every battery check.
- **`tests/test_python_version.py`** — the encodings-agreement pins:
  FROM tag is minor-only, render.yaml/matrix main/singleton jobs all
  carry the same minor, matrix legs stay inside the three-wide
  window. Session-class for forks (presumes Dockerfile+render.yaml).
- **Spec item 5** in the rolling spec, renamed
  `SYNC-1.6.22-1.6.26.md` → `SYNC-1.6.22-1.6.27.md`: conditional
  (predicate: the fork has a Dockerfile) + contract. Nothing new
  rides the sync-verbatim block — every file the item touches is
  fork-divergent or presumes files not every fork carries. The eight
  open dependabot docker PRs are the ops seat's triage: merge where
  byte-equivalent, close with "template first, spec item 5".

## [1.6.26] - 2026-08-25

Batch-1 adoption feedback folds back into the specs: four corrections
where the retro-spec described the template's shape as a requirement,
one consumer precedent, and two new template-class items the round
surfaced — a gate-card demo table whose one entry resolved on no fork
(silently, by design), and a home lane that skipped the versions
pipeline the docs lane ran. Sources: excalidraw 76a9112, pannellum
97d1cba, email 9bcd32a, all wire-verified by the ops seat.

### Added

- **`tests/test_auth_demos.py`** — every DEMOS endpoint must be a
  registered page on THIS site and its module must import and expose
  `component`. `build_demo` degrades silently BY DESIGN (a broken
  example must never take down the sign-in funnel) and its warning
  only fires when the endpoint's card renders — which never happens
  when the endpoint is not a page there, so every fork's gate cards
  have rendered demo-less since fork time without a line of log.
  This test is the one loud surface. Byte-verbatim: it rides the
  sync-verbatim block, gated on `lib/auth_demos.py`, and is EXPECTED
  red on a fork until that fork picks its own entry (or empties the
  table) — the red is the detect firing.
- **A source pin in `tests/test_site_identity.py`**: both
  `pages/home.py` and `pages/markdown.py` call
  `substitute_versions` — /llms.txt serves home.md's text, so a
  `{{VERSION:…}}` token there ships raw on the most-read machine
  surface when home skips the call. AST, not grep (the
  marker-in-comment trap cuts both ways). Session-class, not block
  cargo: it presumes `lib/versions.py`, which pre-`{{VERSION}}`
  forks don't have.
- **Spec items 3–4** in the rolling spec, which extends its range
  and is renamed `SYNC-1.6.22-1.6.24.md` → `SYNC-1.6.22-1.6.26.md`.
  Item 3 (DEMOS resolution) is contract-class with the block-carried
  test as detect; item 4 (the content-pipeline agreement) is ruled
  contract, not verbatim — batch-1's own evidence (pannellum's
  hand-written home) proves home.py is not whole-file verbatim in
  the population.

### Fixed

- **Four SYNC-1.6.10-1.6.16 corrections** (each marked "batch-1
  correction, 2026-08-25" in place, emojimart's precedent): item 7's
  `files:` no longer claims `tests/test_auth_wiring.py` "ports
  everywhere" — the pin's home is fork-shaped and the pin is not
  auth-specific; item 8's `files:` is labeled the template's shape,
  not a requirement (the detect is shape-independent); item 6's
  .gitignore list omitted `kickoff/` while reading as complete — the
  template's block is now named the source of truth — and its notes
  gain the write-guard-scope (probe, don't assume) and
  one-terminal-line copy-command lessons; item 5's no-HEALTHCHECK
  note generalizes from a fork list to "any fork predating 1.6.14 —
  the detect decides".
- **`sync/README.md` consumer precedent**: a fork may carry a
  blanket-ignored `.claude/` with its own content — a kit item is
  then a MERGE, not an install-over; the allow-list ignore form is
  what keeps credentials under `.claude/` structurally
  uncommittable; and the adoption gate reading that fork
  `not-adopted` is correct, not a bug.
- **`lib/auth_demos.py` docstring** no longer claims the table
  "ships EMPTY in the template" — it has carried one working entry
  since the pattern shipped; the docstring now says so and points at
  the new test.

## [1.6.25] - 2026-08-25

The bot actor comes off main; the build-match wait learns to say
"superseded" in one poll instead of red-at-timeout. Owner option A on
the 1.6.24 report: the auto-merge workflow could only ever fix the
GATE (merge waits for CI), never the ACTOR — a GITHUB_TOKEN merge
gets zero workflow runs on its sha, so no CD run certifies the
deploy, and every repair (a merge-capable secret in every repo, or a
workflow_run re-trigger that must ALSO dispatch cd.yml through the
anti-recursion exemption) is a quirk chain propping up the fabric's
one proof. ~11 human clicks a month fleet-wide is cheaper; the
workflow_run shape is recorded as the deferred option if clicks ever
become the burden.

### Removed

- **`.github/workflows/dependabot-automerge.yml`** (added 1.6.24,
  retired one release later) and its line in SYNC-1.6.22-1.6.24's
  sync-verbatim block; spec item 1 is marked RETIRED with its
  history appended, not rewritten. Standing policy in its place:
  actions-group PRs are merged by a human click when the PR's CI is
  green — the human actor's push triggers CD, which certifies the
  merge. No fork ever received the workflow (the pair never fanned
  out), so the deletion has no fleet tail beyond the spec's
  consumer note. The dependabot.yml actions-group comment now
  states the policy instead of pointing at the deleted file.

### Changed

- **`cd.yml`'s build-match wait fails FAST on supersession.** When
  a different build serves, one compare-API call per distinct sha
  classifies it: "ahead" (the live build is a DESCENDANT of this
  run's sha) means branch HEAD moved past this run and the hook
  shipped it — this run's build can never serve again, so the wait
  errors "superseded" immediately instead of red after 25 minutes.
  "behind" (the previous release still serving mid-build, or a
  deliberate ROLLBACK — an older build is not a descendant),
  "diverged", and unknown/404 shas all keep waiting. Everything
  else — 100×15s, matched≥3, the pre-build-field fallback — is
  unchanged.
- The trap texts (CLAUDE.md, spec item 1 notes, the 1.6.24 entry
  below) keep the observed narrative; the remedy line becomes
  "actions PRs: human merge when green; never a bot actor on main".

## [1.6.24] - 2026-08-25

Dependabot stops proposing pip floor-raises; the actions group
auto-merges. Owner decision on the ops seat's fleet-wide reading of
all 38 open dependabot PRs: 18 were pip floor-raises — the
`dash-network` allow-list group, built to make a package release ONE
reviewable PR, could only ever propose floor raises on range
requirements, structurally producing the class the allow-list exists
to suppress. Drift detection, the group's stated purpose, is already
answered on the wire by the contract battery (healthz `dash_version`
against the floor).

### Removed

- **The `pip` ecosystem entry in `.github/dependabot.yml`** —
  allow-list, `dash-network` group and all. Floors move deliberately
  through sync specs, every encoding at once. SECURITY updates are
  unaffected, verified against GitHub's docs before shipping: they
  ride the alerts channel (dependency graph + Dependabot alerts, a
  repo setting), and "there is no interaction between the settings
  specified in the dependabot.yml file and Dependabot security
  alerts" (code-security/concepts/supply-chain-security/
  dependabot-security-updates). Removing the entry closes NOTHING:
  the template's own five pip floor-raise PRs (#6–#10) outlived the
  08-23 allow-list that already excluded their packages — closing
  them is SYNC-1.6.22-1.6.24 item 2, an owner action.

### Added

- **`.github/workflows/dependabot-automerge.yml`**: on dependabot
  PRs whose branch is `dependabot/github_actions/*` (the branch
  prefix IS the ecosystem — no third-party metadata action to pin),
  `gh pr merge --auto --squash` with `GITHUB_TOKEN`, permissions
  contents+pull-requests write on this workflow only. Docker
  base-image PRs are NOT auto-merged — a Python major is a merits
  decision. Inert until the owner flips "Allow auto-merge", and
  over-eager until `main` requires the CI checks (auto-merge waits
  for REQUIRED checks only) — both enumerated as the new spec's
  item 1.
- **SYNC-1.6.22-1.6.24.md** — "the next spec" did not exist (1.6.22
  and 1.6.23 shipped as amendments), so this release authors it.
  Both dependabot files join the sync-verbatim block under the
  existing kit gate: inert on a non-adopted fork anyway, and one
  gate keeps one rule.
- A CLAUDE.md verification trap, amended same-day to the OBSERVED
  shape: the workflow fired live within minutes (dependabot rebased
  PR #4 on the config change; merged as `4a1d430`, 79s before its
  own CI finished — no required checks on main). The merge sha got
  ZERO workflow runs (GITHUB_TOKEN anti-recursion) yet reached
  production anyway: the deploy hook builds branch HEAD, so the
  in-flight 1.6.24 CD shipped the merge while its build-match wait
  held out for `f25cb4f` and went red. Not a lag, a race; the fix
  was a re-verify commit. (Remedy line adjusted in 1.6.25 — actions
  PRs: human merge when green; never a bot actor on main.)

## [1.6.23] - 2026-08-25

The sync-verbatim block gains its adoption gate. Found by the ops
seat's first `forks=all` fan-out dry run (32812831785): six of the
ten public fleet forks predate the .claude kit, and the ungated
block would have dropped three skills and a FAILING kit test into
each — the machine doing a session's job badly.

### Added

- **`# requires: <repo-relative path>`** (sync/README.md): zero or
  more lines inside the fence naming files that must already exist
  in the consuming fork for the block to apply AT ALL. A fork
  missing any receives nothing and is flagged `not-adopted` — its
  next step is the contract item the cargo assumes, not the cargo.
  Any block carrying kit files or the kit test requires the kit's
  own markers (`.claude/CLAUDE.md`, `.claude/settings.json`); both
  specs' blocks gained the two lines — the 1.6.10–16 spec is what a
  fresh fork consumes first, so it gates too.
- **`_machine_fence` validates the directive**: a required path must
  exist at template HEAD and stay inside the repo — a typo'd gate
  gates nothing, so the pin fails it.
- Gate is per-BLOCK, not per-path, per the ops seat's position
  (adopted here): skills without the kit's CLAUDE.md contract are
  inert files, and a partial kit is worse than none — the next
  adopter session has to detect which half landed. If a future
  block ever carries mixed-prerequisite cargo, the fix is that
  spec's requires set, not per-path gating; today's cargo is all
  item-6 halves.

## [1.6.22] - 2026-08-24

The ops seat's correction of its own 1.6.21 drop, after proving the
sequencing note on a fresh fence-less clone (1 failed, 5 passed,
1 skipped): the byte-owned pin failing on absence let one unported
contract item keep EVERY later mechanical PR red — revoking the
fan-out's "verbatim class = green merge" promise indefinitely, and
contradicting the same release's own rule that the machine tolerates
absence via the mention heuristic.

### Fixed

- **The byte-owned pin SKIPS when the fence is absent**, with the
  reason naming the adoption path ("port SYNC-1.6.17-1.6.21 item 1;
  until then the fan-out uses the mention heuristic"). One or more
  fences still validate exactly as before (exactly one, paths real
  at HEAD, no escapes). Template-side behaviour unchanged — the
  template's fence exists. CI guards what a fork HAS declared; the
  contract item's session round drives adoption. A fork that once
  had the fence and lost it degrades safely: the mention heuristic
  over-flags and never restores, and deleting the fence is a
  session-class DIVERGENCES.md edit the report contract covers.
- The spec's sequencing note now describes the skip, not the red.

## [1.6.21] - 2026-08-24

DIVERGENCES.md gets its machine half. Found by the ops seat's A1
rehearsal of the fan-out against 1.6.20: honouring divergences by
prose MENTION over-flags — muicharts' host-pin nuance names the kit
test while its bytes are template-owned, a false positive recurring
every release on a file the template intends to own.

### Added

- **The `byte-owned` block** (DIVERGENCES.md, final section): the
  fence is the machine answer to "may the fan-out overwrite this
  path?" — listed paths are the fork's byte-for-byte; empty means
  the template owns every sync-verbatim path here; a fork without
  the block gets the conservative mention heuristic (over-flags,
  never restores). The template's own block ships empty.
- **The pin** (`tests/test_claude_kit.py`): exactly one byte-owned
  fence wherever DIVERGENCES.md exists, paths real at HEAD, none
  escaping the repo — validation shared with the sync-verbatim
  fence via one helper. Byte-portable.
- **`sync/SYNC-1.6.17-1.6.21.md`**: the range's first spec (1.6.17–
  1.6.20 shipped without one — the drop said "the next spec" and
  none existed; corrected by authoring it). Item 1 (contract): the
  byte-owned retro-add — fence content is per-fork judgment and
  cannot fan out; the pilots were audited first and none of the
  three carries a byte-level claim on a sync-verbatim path, so all
  three blocks start empty. Item 2 (conditional): the 1.6.19 docker
  health-verdict step. Sequencing note recorded in the spec: this
  spec's own sync-verbatim block ships the enforcing test, so
  fan-out PRs run red until item 1 is ported — the designed flag,
  not an accident.

## [1.6.20] - 2026-08-24

The F3b prerequisite, authored by the ops seat (2plot-network) and
executed here: the sync spec grows a machine-readable half. Prose
stays the contract; the fenced block is what the fan-out workflow
byte-copies into a fork.

### Added

- **The `sync-verbatim` block** (`sync/README.md`): every spec
  carries exactly one fenced list of whole-file byte-copy targets.
  The class boundary becomes explicit — whole-file = machine,
  fragment = session. "The template wins on these bytes" is
  something a session can apply to a function; a workflow cannot,
  and without the block the fan-out had nothing safe to carry.
  Recorded divergences on a listed path win (the workflow skips and
  flags); `.claude/settings.json` never rides along unless the
  spec's prose declares a fleet-wide settings change — it CAN fan
  out mechanically, so it must be deliberate.
- **The pin extends** (`tests/test_claude_kit.py`): every
  `SYNC-*.md` has exactly one block, every listed path exists at
  HEAD, none escapes the repo. An empty block is a statement; a
  missing one is an omission. Fork-side the whole test still skips
  (no `sync/`).
- **The retro-spec gains its block**: item 6's four byte-verbatim
  halves (the three skills + the kit test). Verified against the
  actual pilot population before listing — leaflet2 (pushed HEAD via
  API; its local checkout is months stale), muicharts, flexlayout:
  wire-verify and report SKILL.md byte-identical to the template on
  all three; sync-template SKILL.md and the kit test lag identically
  on all three (pre-1.6.17/1.6.18 copies — no localisation anywhere,
  and exactly the cargo the fan-out's first run should carry).
  Rejected from the block: nothing beyond the drop's own fragment
  exclusions (3a, 7).

## [1.6.19] - 2026-08-24

F2 closes: emojimart synced from the spec alone (078e514,
wire-verified — the conditional predicate delivered its missing
HEALTHCHECK unprompted). Its remaining corrections and its
template-class finding, adopted:

### Fixed

- **Spec item 2's detect can't catch item 2's own failure mode**
  (the sharp one): sampling two of four floor encodings reports
  already-present on exactly the forks that drifted — emojimart's
  ci.yml asserts still gated (2,6,0) while both sampled encodings
  were correct, a guard that silently stopped guarding. The detect
  now also requires that no GATING encoding names a lower version —
  a negative grep that doesn't collide with the never-grep rule,
  because it greps for what must NOT appear in gates.
- **Spec item 5's detect is port-agnostic** (defaulted at the point
  of use is the contract; 8550 is the template's number) and **item
  7's files** acknowledge forks without post() — the source pin is
  the half that ports everywhere.

### Added

- **CI asserts Docker's own health verdict** (emojimart's
  template-class finding — the guard on the guard): the boot step
  curls /healthz from outside, proving the app answers, but a broken
  HEALTHCHECK instruction shipped silently while everything stayed
  green. The docker job now polls
  `docker inspect '{{.State.Health.Status}}'` to `healthy`, fails on
  `none` (no HEALTHCHECK = opaque to the orchestrator), and dumps
  the probe's own log on failure.

## [1.6.18] - 2026-08-24

The retro-spec's first real consumer (emojimart, the F2 acceptance
run) returned two spec corrections and one permission lesson — all
adopted.

### Fixed

- **Spec item 9's detect was unsatisfiable by grep** — the fix's own
  explanatory comment necessarily names the phrase it retired, so
  the template failed its own detect. Now "the intro STRING,
  comments stripped" — the marker-in-comment trap, in its third
  costume.
- **The kit's sync-spec pin skips where no `sync/` exists**: forks
  consume specs, only the template authors them — the byte-verbatim
  kit port failed on arrival at every fork without the guard. The
  pin wakes if a fork ever authors specs.

### Added

- **The settings-write precedent** (spec item 6 notes):
  `.claude/settings.json` is permission-classed and a session may be
  unable to write it — that guard is CORRECT, and the procedure is:
  stage the exact content in the scratchpad, hand the owner a
  one-line `!` copy, orchestrator verifies the staged content before
  the copy.

## [1.6.17] - 2026-08-24

The F2 fabric build: propagation as artifact, not conversation.

### Added

- **`sync/` — release sync specs** (`sync/README.md` defines the
  format; `sync/SYNC-1.6.10-1.6.16.md` is the retro-spec pilot).
  Each item carries a class (`verbatim` / `contract` /
  `conditional`), a **detect** check so forks at different levels
  consume the same document and apply only what they lack, and an
  **acceptance** pin. The authoring rules are the F1 pilots'
  earned corrections: floors stated by `LLMS_PKG_FLOOR` semantics
  (the ladder retains old rungs by design — grep finds history and
  calls it the present), CLAUDE.md's contract/traps sections
  verbatim with the rest adapted, DIVERGENCES retirements marked
  not deleted, and the contract-class evidence standard (a diff or
  passing pins, never an assertion). A format pin in
  tests/test_claude_kit.py rejects any spec item lacking
  detect + acceptance: an item without both is not specifiable.
- **`/sync-template` aligned**: the current floor is what
  `LLMS_PKG_FLOOR` says, never what grepping finds; detect-first
  dispositions (`already-present` with evidence); the spec is
  subject to the same correction contract as any prompt.

## [1.6.16] - 2026-08-24

The first harvest of the F1 pilots: three template-class defects found
by fork sessions exercising the new behavioral contract, all verified
against this tree before adoption, plus one honesty fix.

### Fixed

- **`scripts/smoke_live.py` `post()` passes `context=SSL_CONTEXT`**
  (flexlayout's finding): `fetch()` carried it; `post()` didn't, so on
  any Python without OS trust-store integration (macOS — the fleet's
  whole local-dev half) every auth POST died in the TLS handshake,
  returned 0, and the check accused the app of the exact
  `configure_app` regression it exists to detect. CI never saw it
  (Linux verifies fine); no wired test could (they monkeypatch
  `post`) — a SOURCE pin in tests/test_auth_wiring.py now sweeps every
  `urlopen` in the file.
- **cd.yml's verify job also skips on a SKIPPED deploy** (muicharts'
  fix, adopted): with only `!= 'cancelled'`, a skipped deploy still
  ran verification against whatever build happened to be serving —
  one cause, two red jobs.
- **The machine lane publishes the site brand at the root**
  (leaflet's finding): `_build_llms_doc` took `metadata.name`, so a
  home page named "Home" put `# Home` in the preamble while the
  package injects the site brand — mismatched H1s the 2.7.0 dedup
  cannot fold (leaflet's home served three). `published_name(path,
  name)` (ported from leaflet's fork-side fix into
  lib/page_visibility.py) returns `SITE_BRAND` at "/" and the page
  name elsewhere; the markdown pipeline now publishes through it, so
  fresh forks stop re-living the defect.
- **The gate card stops promising "the AI assistant"** (leaflet's
  catch): no fork wires one, and a sign-in card selling a feature
  that doesn't exist spends credibility at the highest-intent moment.
  The card now promises exactly what ships.

## [1.6.15] - 2026-08-24

The F1 fabric build: the `.claude/` development kit ships with the
template, so every fork inherits the network's behavioral contract
instead of a blank slate. Until now the blanket `.claude/` gitignore
kept the project instructions local-only — forks inherited NOTHING.

### Added

- **`.claude/CLAUDE.md` gains the network role & behavioral
  contract**: identity derives from the repo, never the file; check
  every prompt against this tree; corrections are the session's job;
  wire-verify your own deploy before reporting; divergence is
  legitimate when recorded; never touch env values/dashboards/other
  repos — plus the fleet's verification traps (cache bust, GET not
  HEAD, dependabot-run watchers, two-lane rule). File case
  canonicalized (`claude.md` → `CLAUDE.md` — macOS forgave it,
  Linux would not).
- **`DIVERGENCES.md`** at the repo root: the boundary between design
  and drift. Syncs read it first and must not restore what it
  records; an unrecorded difference is treated as drift. Fleet
  precedents named (flexlayout, flows, clerkhook, muischeduler).
- **Three skills**: `/wire-verify` (the acceptance ritual as one
  command — healthz identity/build/geo, both lanes, the traps),
  `/sync-template` (divergence-aware, spec-driven, acceptance-
  pinned), `/report` (evidence-first structure with the observed
  anti-patterns named and rejected).
- **`.claude/settings.json`** (checked in): fleet model default
  (opus — seats override locally via settings.local.json, which
  stays ignored), sandbox network allowlist naming THIS host +
  the hub so sessions can wire-verify their own production
  (clerkhook and pannellum both couldn't reach theirs), WebFetch
  domain allows. `tests/test_claude_kit.py` pins the kit shipped,
  case-correct, and — the anti-drift pin — that allowedDomains
  follows `lib/constants.BASE_URL`, so a fork keeping the
  template's host verbatim goes red until it points at itself.

### Changed

- **`.gitignore`**: `.claude/` blanket ignore becomes an allow-list
  (`CLAUDE.md`, `settings.json`, `skills/` ship; scratch and
  settings.local.json stay local), and the session-document
  convention is now enforced network-wide: `X402-SYNC-REPORT.md`,
  `HANDOFF-*.md`, `KICKOFF-*.md` ignored — two public fleet repos
  were caught tracking theirs, and clerkhook's staged handoff was
  one push from public.

## [1.6.14] - 2026-08-23

### Fixed

- **The container honors `$PORT`** (Dockerfile): exec-form CMD never
  expands env, so the old
  `["gunicorn", "run:server", "-b", "0.0.0.0:8550"]` hardcoded the
  port regardless of what the platform asked for — it only worked on
  Render because Render port-detects. Now shell form with
  `${PORT:-8550}`, and the HEALTHCHECK probes the same variable.
  run.py has honored `$PORT` since 1.6.8; the container lane finally
  agrees. Credit: excalidraw fixed this fork-side in its gate-wave
  pass and, on floor-round sync, rightly refused to adopt the
  template's regression — this release makes the template agree with
  the fork instead.

## [1.6.13] - 2026-08-23

Two wave-2 findings folded back, both from hosts that hit what the
template hadn't: dash-email's CD timeout class and dash-flows' proof
that the Flask-lane healthz pin couldn't fail.

### Changed

- **CD's build-match wait is sized for the worst build, not the
  median** (cd.yml): the loop grows to 100 × 15s and the job timeout
  to 30 minutes. A floor bump busts the Docker dependency cache by
  design, so the pipeline's most important deploy is also Render's
  slowest build — dash-email's wait timed out on exactly that class.
- **Hookless deploys warn instead of whispering**: with
  RENDER_DEPLOY_HOOK_URL unset the step now emits a `::warning` with
  accurate wording (the push deploys only if autoDeploy fires; the
  build-match wait still holds for THIS commit). dash-email hit the
  case where autoDeploy also skipped — nothing deployed, the wait
  honestly timed out, and the quiet `::notice` was why the cause took
  a full run to see.

### Added

- **Context-free `_resolved_country` pin** (dash-flows' finding): the
  in-request pins pass even if a Flask route drops `headers=` — the
  context fallback reads the same headers, and the lanes that truly
  break are unreachable from a Flask-pinned suite. The new test calls
  `_resolved_country({"CF-IPCountry": "DE"})` outside any request
  context, where there is no fallback to hide behind.

## [1.6.12] - 2026-08-23

### Fixed

- **healthz `geo.resolved` works on every backend**: each route now
  hands its own request headers to `health_payload`, which passes them
  to `geo.explain_resolution` explicitly. The 1.6.10 version read
  Flask's request context, so the FastAPI and Quart lanes answered
  "no request context" forever — pannellum's production healthz
  (FastAPI) was the host that showed it, caught by the round-3 wave-1
  wire check within hours of shipping. `normalize_headers` accepts
  Flask/Starlette/Quart headers and never raises; the Flask-context
  fallback stays for callers that pass nothing. Both backend tests now
  pin that a spoofed `CF-IPCountry` header surfaces in `resolved`.
  Waves 2–4 of the floor round port this version automatically;
  pannellum takes it as a one-line follow-up.

## [1.6.11] - 2026-08-23

The fleet floor-round opener: the dimll floor moves to ≥2.7.1, and the
pin written for what that floor buys immediately caught a template bug
of its own.

### Changed

- **dimll floor ≥2.7.1** in every encoding (requirements.txt, run.py's
  `LLMS_PKG_FLOOR` + boot message, the tests). What it buys: 2.7.0
  dedups the prerender H1 (every page served two h1s to crawlers) and
  the home footer's doubled /llms.txt link, hardens the idempotency
  probe (the marker-in-comment trap), and ships the geo guardrail +
  operator panel seams; 2.7.1 adds the llms.txt v2 discovery relations
  on both lanes + Link headers, the text/plain Accept ramp, and the
  representation digest. The requirements line changing IS each fork's
  Docker cache bust — the round-2 lesson, now restated beside the
  floor. The boot floor turns a stale image into a loud refusal.

### Fixed

- **`_expand_source_directives` is fence-aware** (pages/markdown.py):
  it expanded `.. source::` examples sitting INSIDE fenced code blocks
  — docs/example and docs/directives teach the directive inside
  ```markdown fences — injecting a fence into the open fence, closing
  it early, and rendering the inlined Python file as markdown on the
  machine lane: every `# comment` line became an `<h1>` (five h1s on
  the directives tutorial, on every fork, browser lane unaffected).
  Found minutes after writing the single-h1 pin below.

### Added

- **Every-page generic-lane structure pin** (tests/test_pages.py):
  exactly one `<h1>` per document (comments stripped), no duplicate
  llms.txt links in the prerender footer, home carries exactly the
  root link — plus a unit pin that fenced directive examples stay
  documentation.

## [1.6.10] - 2026-08-23

Two `/healthz` defects found (and first fixed) on llms-2plot-dev during
its production verification, ported back as the reference
implementation — plus the identity and geo diagnostics that exposed
them. The probe is how CD, the hub sweep, and any outside verification
know WHAT is serving; both defects made it lie by omission.

### Fixed

- **The Flask/Quart payload was a snapshot**: `register_health_route`
  computed the dict once at registration and the route closed over it.
  Harmless while every field was static, silently wrong the moment one
  is not — on the fork, the route is mounted before `configure_geo`
  runs, so the new geo diagnostic reported the guardrail unconfigured
  on a host where it is configured. Built per request now.
- **FastAPI built its own payload and never called `health_payload`**,
  so a FastAPI deployment silently lacked `build` — the exact field
  cd.yml's build-match wait polls for. It would have fallen into the
  "predates the build field" warning path forever, verifying whichever
  release happened to be serving: the muicharts defect the wait was
  written to prevent, reintroduced per-backend. All backends now render
  from the one `health_payload`; `HealthResponse` only types it for
  Swagger.

### Added

- **`app` in the healthz payload** (`SATELLITE_APP_KEY`, else
  `"unknown"`): `build` says which commit answered, `app` says which
  satellite — different questions on a fleet where every host shares
  one template and a hostname can be repointed between services
  (llms.2plot.dev was, 2026-08-23).
- **`geo` in the healthz payload** on dash-improve-my-llms ≥ 2.7.0:
  `{configured, denied, resolved}` — counts and flags only, never the
  denylist's country codes; `resolved` reveals only the caller's own
  country, and is the per-host check GEO.md calls mandatory before
  trusting a denylist. On older packages the key is OMITTED, not
  error-flagged — the fleet's ≥2.7.1 floor round lights it up with no
  further change. Four tests pin the contract (per-request liveness,
  identity fields, FastAPI parity, counts-not-codes).

## [1.6.9] - 2026-08-23

### Removed

- **The vestigial Node layer left the production image** (issue #12,
  CVE-2026-1615): `package.json`/`package-lock.json` were
  dash-mantine-components' component-build toolchain, inherited
  through the fork lineage and used by NOTHING here — no webpack
  config, no `src/ts`, no CI job, no served asset — yet the
  Dockerfile apt-installed nodejs+npm and `npm install`ed the tree
  into every production image, fleet-wide, including a
  known-vulnerable `jsonpath@1.1.1` (static-eval expression
  evaluation; fixed upstream in 1.3.0, March 2026). Exploitability
  here was effectively nil — no Node process serves traffic, nothing
  evaluates user-supplied JSONPath, and the code isn't bundled into
  any served asset — but dead weight that trips scanners and bloats
  images is still dead weight. Both files deleted, the Dockerfile
  keeps only curl (the HEALTHCHECK's), with the rationale in place so
  no future fork re-adds Node by inheritance. Forks that genuinely
  build JS components add their own toolchain knowingly. Docs-fleet
  forks pick this up with the next Dockerfile sync (the ≥2.7.1 floor
  round carries it).

## [1.6.8] - 2026-08-22

Five fork footguns upstreamed from the llms-2plot-dev phase-1 fork
audit — each fires on every fork, not just that one, and each carries
that repo's fix as its reference implementation.

### Fixed

- **`excluded_links` now hides from BOTH audiences**: the navbar wires
  every excluded path through dimll's `mark_hidden`, so a page removed
  from the sidebar is also removed from sitemap.xml, /llms.txt, the
  tier corpora, MCP, the prerender and the crawler document. Before
  this, llms-2plot-dev "hid" the template's tutorial pages and kept
  publishing them to every crawler as its own documentation — duplicate
  content invisible from a browser. `tests/test_excluded_links_hidden.py`
  pins the parity from both ends, with a positive control so an empty
  sitemap can't pass it vacuously.
- **The header wordmark moved to `lib/constants.WORDMARK`**: it was a
  hardcoded "Dash Docs" string in components/header.py, so a fork that
  edited the constants identity block — reasonably assuming that was
  the whole job — still served the template's wordmark beside its own
  logo. The aria-label derives from the same constant, so the
  accessible name can never disagree with the visible one.
- **The foreign-canonical smoke test derives its host from BASE_URL**:
  it spelled the template's hostname literally, so on any renamed fork
  its rewrite matched nothing and the test passed as a no-op — a guard
  that silently stopped guarding exactly where it was needed. An
  in-stub assertion now errors if the rewrite ever fails to change a
  canonical-bearing page.
- **`page_visibility.json` (+ lock/tmp) joined .gitignore**: with
  PAGE_VISIBILITY_FILE unset the control-board store falls back to the
  app directory, so running the board locally wrote a real policy file
  into the checkout — same class as the analytics ledger, which was
  already ignored for exactly this reason.
- **run.py's dev server honours $PORT and HOST**: the port was a string
  literal and the host hardcoded, so a platform injecting $PORT needed
  a code change. Production never reaches this block (gunicorn).

## [1.6.7] - 2026-08-22

### Added

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

## [1.6.6] - 2026-08-22

### Changed

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

## [1.6.5] - 2026-08-22

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

### Added

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

### Fixed

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

## [1.6.4] - 2026-08-21

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

### Fixed

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

## [1.6.3] - 2026-08-21

### Changed

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

## [1.6.2] - 2026-08-21

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

### Fixed

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

### Changed

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

## [1.6.1] - 2026-08-21

### Fixed

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

## [1.6.0] - 2026-08-21

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

### Added

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

## [1.5.4] - 2026-08-21

### Changed

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

## [1.5.3] - 2026-08-21

### Changed

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

## [1.5.2] - 2026-08-21

### Changed

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

## [1.5.1] - 2026-08-21

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

### Fixed

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

### Changed

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

## [1.5.0] - 2026-08-20

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

### Changed

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

### Added

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

## [1.4.1] - 2026-08-19

### Changed

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

## [1.4.0] - 2026-08-19

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

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

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

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

## [1.3.0] - 2026-08-15

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

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

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

## [1.2.5] - 2026-08-01

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

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

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

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

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

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

## [1.2.4] - 2026-08-01

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

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

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

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

Two details worth keeping:

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

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

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

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

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

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

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

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

### Added — `.env.example`

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

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

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

## [1.2.3] - 2026-08-01

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## [1.2.2] - 2026-08-01

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

### Fixed — the 1.2.1 files were never committed

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Changed — `test_exactly_one_canonical_tag_for_browsers` counts elements

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

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

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

## [1.2.1] - 2026-07-31

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

### Fixed

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

### Added

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

## [1.2.0] - 2026-07-31

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Changed — CI on the network baseline

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

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

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

### Changed — dependency floors

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

### Added — `.dockerignore`

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

### Note on versioning

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

---

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Added — CI/CD and tests

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

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

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

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

### Changed — dependencies

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

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

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

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

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

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

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

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

### Changed — hosts, branding and repo hygiene

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

### Fixed — SEO and template

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

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

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

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

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

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

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

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

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

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

### Fixed — Markdown rendering

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

### Fixed — MCP wiring

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

### Fixed — environment and tooling

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

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

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

### Fixed — Analytics accuracy

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

## [1.0.0] - 2026-06-14

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

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

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

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

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

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

### Changed

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

### Removed

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

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

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

---

## [0.8.0] - 2025-12-14

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

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

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

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

---

## [0.7.0] - 2025-12-13

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

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

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

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

---

## [0.6.0] - 2025-12-13

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

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

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

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

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

---

## [0.5.0] - 2025-12-13

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

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

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

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

---

## [0.4.0] - 2025-11-10

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

## [0.3.0] - 2025-11-09

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

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

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

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

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

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

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

## [0.2.0] - 2025-11-09

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

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

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

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

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

## [0.1.0] - 2024-11-30

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

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

---

## Version History Summary

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

---

## Migration Guides

### Migrating to 1.0.0 from any 0.x

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

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

### Migrating to 0.6.0 from 0.5.0

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

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

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

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

### Migrating to 0.5.0 from 0.4.0

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

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

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

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

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

### Migrating to 0.3.0 from 0.2.0

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

### Migrating to 0.2.0 from 0.1.0

Major breaking changes. See migration documentation:

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

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

---

## Support

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

---

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

---

<!-- /examples/ai-integration — https://boilerplate.2plot.dev/examples/ai-integration/llms.txt -->

# AI/LLM Integration

> Make your Dash app discoverable to MCP clients, web crawlers, and paste-into-chat users with the dash-improve-my-llms package this site runs.

---



### Introduction

This boilerplate ships with [dash-improve-my-llms](https://pypi.org/project/dash-improve-my-llms/) **2.10.0** (this page reads the version from the installed package), a small companion package that fills the gaps Dash itself doesn't cover for AI-friendliness.

Since 2.0 the package narrows its scope to the three jobs that are genuinely not addressed by Dash 4.3's native MCP server:

| Audience              | Protocol                        | What it serves                                 |
|-----------------------|---------------------------------|------------------------------------------------|
| MCP clients           | JSON-RPC over Streamable HTTP   | Bridges `LLMS_DOC` → `dash.mcp` resources       |
| Web crawlers          | Plain HTTPS, often no JS        | `/robots.txt`, `/sitemap.xml`, prerender HTML  |
| Paste-into-chat users | One-shot HTTP fetch             | `/llms.txt`, `/<page>/llms.txt` as markdown    |

If you're on Dash 4.3+, MCP clients also get **live** component-tree introspection from Dash itself — `dash-improve-my-llms` no longer duplicates that.

---

### What you get

| Route | Purpose |
|---|---|
| `/llms.txt` | The site index: the home prose, every page, and the network directory |
| `/llms-small.txt` | Compact briefing of the whole site — fits a small context window (2.4.0+) |
| `/llms-full.txt` | The full corpus: every page's prose in one document (2.4.0+) |
| `/<page>/llms.txt` | Per-page narrative documentation (that page's `LLMS_DOC`) |
| `/robots.txt` | Bot policy — generated from your `RobotsConfig` |
| `/sitemap.xml` | SEO sitemap with priority inference, respects `mark_hidden()` |
| `/favicon.ico` + apple-touch paths | 302 to a `configure_seo()` icon, instead of Dash's app shell (2.5.0+) |
| Bot middleware | Training bots → 403, search bots → static HTML, browsers → React app |
| MCP bridge | Each non-hidden page's `LLMS_DOC` registers as a `dash.mcp` resource on Dash 4.3+ |

Every `llms` document content-negotiates: agents and crawlers get raw
Markdown byte-for-byte, a browser gets the same Markdown rendered behind the
network header (`?raw=1` forces the raw side).

**Removed in 2.0** (the package no longer serves these — Dash MCP or your own `LLMS_DOC` covers them):

- `/page.json`, `/<page>/page.json`
- `/architecture.txt`, `/architecture.toon`
- `/llms.toon`, `/<page>/llms.toon`

If your old code linked to any of those endpoints, the fix is to either delete the link or point it at the equivalent `/<page>/llms.txt`.

---

### Quick start — five lines

```python
from dash import Dash
from dash_improve_my_llms import add_llms_routes

app = Dash(__name__, use_pages=True)
add_llms_routes(app)
```

That's it. `add_llms_routes` detects the active backend (**Flask**, **FastAPI**, or **Quart**) and dispatches to the matching adapter — no `if backend == "flask"` gate, no environment variable, no code change.

On Dash 4.3+, it also registers each page's prose as an MCP resource.

---

### The LLMS_DOC pattern

This is the one new idea in 2.0. Each page module exports a module-level string named `LLMS_DOC`. That string is the literal body of `/<page>/llms.txt`. No layout walking, no extraction.

```python
# pages/equipment.py
from dash import html, register_page

register_page(__name__, path="/equipment", name="Equipment Catalog")

LLMS_DOC = """\
# Equipment Catalog

> Browse the equipment library with text search and a category dropdown.

## What this page does

The catalog renders a list of equipment items with name, category, and
status. Two controls filter the list in real time:

- A free-text search input that matches against the item name.
- A category dropdown: All, Tools, Machinery, Vehicles.

## What the user can do

- Type in the search box to narrow by name.
- Switch category to constrain by class.
- Combine both — filters AND together.

## What the page does NOT do

This is a demo. Item list is in-memory. No persistence, no edit/create,
no per-item detail view.
"""

def layout():
    return html.Div([...])
```

#### Where the prose lives

| Where | When to use it |
|---|---|
| Module-level `LLMS_DOC` | Default. Keeps prose next to the layout. |
| `register_page_metadata(path, llms_doc="...")` | When the prose is computed, imported from another file, or generated at runtime. |

The package looks up explicit registration first, then falls back to the module attribute.

#### Recommended structure for a good LLMS_DOC

1. `# Title` — matches the page name.
2. `> One-line tagline` — quoted blockquote at the top.
3. `## What this page does` — narrative description.
4. `## What the user can do` — interactions, in plain prose.
5. `## What the page does NOT do` — guard against the LLM hallucinating capabilities.

Length: 300–2000 words is typical. The package emits a warning naming pages without prose but never truncates.

#### How this boilerplate wires it up

Markdown-driven pages get their prose registered automatically. `pages/markdown.py` reads each `.md` file, expands `.. source::` directives by inlining the referenced file, then calls `register_page_metadata(path, llms_doc=expanded_markdown)`. That's why `/getting-started/llms.txt` returns the full page content with code samples already inlined — exactly what a paste-into-chat user wants.

The home page uses the simpler pattern: `pages/home.py` exports `LLMS_DOC = content` where `content` is the markdown body of `pages/home.md`.

---

### Multi-backend support

```bash
# 2.5.1 is the 2plot network floor — this repo's requirements.txt pins it.
# The package itself requires dash>=4.1 (there is no Dash 3.x support).
pip install "dash-improve-my-llms[flask]>=2.5.1"      # Dash's default backend
pip install "dash-improve-my-llms[fastapi]>=2.5.1"    # Dash 4.1+ FastAPI
pip install "dash-improve-my-llms[quart]>=2.5.1"      # Dash 4.1+ Quart
pip install "dash-improve-my-llms[all]>=2.5.1"        # all three
```

`add_llms_routes(app)` inspects `app.server` (via `dash.backends.get_server_type` on Dash 4.2+, falling back to `type(app.server).__name__`) and dispatches to the matching adapter. `GET /robots.txt` returns byte-identical content whether the app is Flask, FastAPI, or Quart.

The handlers live in `dash_improve_my_llms/handlers.py` as **pure functions**. Each adapter is a thin I/O wrapper, so behavior across backends is identical by construction.

---

### Bot management

```python
from dash_improve_my_llms import RobotsConfig

# Balanced (default-ish) — block training, allow citations
app._robots_config = RobotsConfig(
    block_ai_training=True,      # GPTBot, CCBot, anthropic-ai, Google-Extended
    allow_ai_search=True,        # ChatGPT-User, ClaudeBot, PerplexityBot
    allow_traditional=True,      # Googlebot, Bingbot, DuckDuckBot
    crawl_delay=10,
    disallowed_paths=["/admin", "/api/*"],
)
```

The bot middleware:

1. Training bot + `block_ai_training=True` → **403 Forbidden**.
2. Search or traditional bot → **prerendered static HTML** built from the page's `LLMS_DOC` (so they actually see content instead of an empty React shell).
3. Real browser → passes through to the Dash app.

Verify with `curl`:

```bash
# Training bot — expect 403 when block_ai_training=True
curl -A "Mozilla/5.0 (compatible; GPTBot/1.0)" http://localhost:8559/

# Search bot — expect static HTML with the LLMS_DOC content baked in
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1)" http://localhost:8559/

# Real browser — passes through to Dash
curl -A "Mozilla/5.0 (Macintosh)" http://localhost:8559/ | head -20
```

---

### Hiding pages from crawlers and MCP

```python
from dash_improve_my_llms import mark_hidden

mark_hidden("/admin")
mark_hidden("/internal/metrics")
```

Effects: excluded from `/sitemap.xml`, added to `/robots.txt` Disallow list, returns 404 to crawler requests, returns 404 to `/admin/llms.txt`, and skipped when registering MCP resources.

There is **no component-level hiding** in 2.0. To hide content from extraction, simply don't write it into the page's `LLMS_DOC`.

---

### MCP integration

There is no bespoke MCP endpoint here, and there should not be one: Dash 4.x's
native MCP support is the path, and `dash-improve-my-llms` rides it by
registering its resources on that server when it is enabled. Turn MCP on in
Dash and the prose below is already there.

On Dash 4.3+, every non-hidden page's `LLMS_DOC` is registered as a `dash.mcp` resource:

- **URI**: `llms:///<page-path>` (e.g. `llms:///equipment`, `llms:///` for root)
- **mimeType**: `text/markdown`
- **content**: the `LLMS_DOC` string

This means an MCP-aware client can fetch the prose docs through its existing tool-call surface without an extra HTTP fetch. To opt out:

```python
from dash_improve_my_llms import LLMSConfig, add_llms_routes
add_llms_routes(app, LLMSConfig(register_mcp_resources=False))
```

On Dash 3.x or 4.1/4.2 the MCP bridge silently no-ops — the HTTP routes still work.

---

### Migration notes (1.x → 2.0)

If you upgraded an existing app:

1. **Install the right extra** — `pip install "dash-improve-my-llms[flask]>=2.5.1"` (or `[fastapi]`, `[quart]`).
2. **Add `LLMS_DOC` to each page module.** The startup `UserWarning` names every page that's missing prose.
3. **Remove `mark_important()` and `mark_component_hidden()` calls.** They're deprecation no-ops in 2.0 and will be deleted in 2.1.
4. **Remove links to dropped routes** — `/page.json`, `/architecture.txt`, `/architecture.toon`, `/llms.toon` (and per-page variants).
5. **Stop using `TOONConfig`, `PageType`, `generate_*_toon`, `toon_encode`, `extract_*`** — all removed from the public API.

The HTTP surfaces that survived (`/llms.txt`, `/robots.txt`, `/sitemap.xml`) and the APIs `RobotsConfig`, `mark_hidden`, `register_page_metadata` are byte-compatible with 1.x.

---

### Debugging

```bash
# Confirm routes are wired
curl -s http://localhost:8559/llms.txt | head -5
curl -s http://localhost:8559/robots.txt | head -10
curl -s http://localhost:8559/sitemap.xml | head -10

# See which pages are missing prose: just boot the app and read the UserWarning.
python run.py 2>&1 | grep -i "llms_doc"
```

```python
# Inspect backend detection
from dash_improve_my_llms import _detect_backend
print(_detect_backend(app))   # "flask" | "fastapi" | "quart"
```

```python
# Pure-function trace of a crawler request — no server needed
from dash_improve_my_llms.handlers import handle_bot_request

result = handle_bot_request(
    path="/",
    user_agent="Mozilla/5.0 (compatible; GPTBot/1.0)",
    app=app,
    page_metadata={},
    hidden_paths=set(),
)
```

---

### Why the surface shrank

Earlier releases generated `/page.json`, `/architecture.txt`, `/llms.toon`, and tried to extract prose from Dash component trees. Two things changed:

1. **Dash 4.3 shipped MCP.** A live, structurally-accurate description of the component tree is available natively through Streamable HTTP. Reading it from a boot-time snapshot is strictly worse.
2. **Layout extraction was never reliable.** Walking `dcc.Markdown` children and guessing what was important led to surprising outputs. The `LLMS_DOC` pattern moves the responsibility to you — write the prose, the package serves it.

The result is ~4,400 → ~1,900 lines of public surface, fewer endpoints to remember, and no quiet "the extraction missed my best paragraph" failures.

---

### Where to read more

- [`SKILLS.md`](https://github.com/pip-install-python/dash-improve-my-llms/blob/main/docs/SKILLS.md) — the practical guide bundled with the package.
- [`CHANGELOG.md`](https://github.com/pip-install-python/dash-improve-my-llms/blob/main/CHANGELOG.md) — full 2.0 breaking-changes list.
- [PyPI: dash-improve-my-llms](https://pypi.org/project/dash-improve-my-llms/) — install + latest version.
- [llms.txt spec](https://llmstxt.org/) — the convention the package implements.


---

*Source: /examples/ai-integration*

---

<!-- /examples/directives — https://boilerplate.2plot.dev/examples/directives/llms.txt -->

# Custom Directives

> Comprehensive guide to all custom markdown directives available

---



### Introduction

This documentation boilerplate comes with **powerful custom directives** that extend standard markdown with interactive features. Directives are special commands that let you embed Python components, display source code, generate tables of contents, and more.

All directives use the syntax: `.. directive_name::optional_argument`

---

### Available Directives

#### 1. Table of Contents - `.. toc::`
#### 2. Execute Python - `.. exec::`
#### 3. Source Code Display - `.. source::`
#### 4. Component Props - `.. kwargs::`
#### 5. LLM Copy Button - `.. llms_copy::`

---

### 1. Table of Contents Directive

The `.. toc::` directive automatically generates a navigable table of contents from your markdown headings.

#### Usage

Simply add `.. toc::` anywhere in your markdown (typically at the top):

```markdown
.. toc::

### Section 1
Content...

### Section 2
Content...

#### Subsection 2.1
More content...
```

#### Features

- **Auto-generates** from H2 and H3 headings
- **Clickable links** that scroll to sections
- **Hierarchical structure** showing document organization
- **Automatically updates** when you add new sections

#### Example

The table of contents at the top of this page was generated using `.. toc::`!

---

### 2. Execute Python Directive

The `.. exec::` directive embeds interactive Dash components from Python modules.

#### Usage

```markdown
.. exec::docs.module.submodule
```

This imports and renders the `component` variable from the specified module path.

#### How It Works

1. Create a Python file (e.g., `docs/directives/button_example.py`)
2. Define a `component` variable with your Dash component
3. Reference it in markdown with `.. exec::docs.directives.button_example`

#### Example: Simple Button

**Markdown:**
```markdown
.. exec::docs.directives.button_example
```

**Python file** (`docs/directives/button_example.py`):
```python
import dash_mantine_components as dmc

component = dmc.Button(
    "Click me!",
    variant="filled",
    color="teal"
)
```

**Result:**


---

### 3. Source Code Display Directive

The `.. source::` directive displays syntax-highlighted source code from files.

#### Usage

```markdown
.. source::path/to/file.py
```

#### Features

- **Syntax highlighting** for Python code
- **Line numbers** for easy reference
- **Copy-to-clipboard** functionality (if enabled)
- **Clean formatting** with proper indentation

#### Example: Display Source

Let's display the source code of the button example we just saw:

**Markdown:**
```markdown
.. source::docs/directives/button_example.py
```

**Result:**


```python
# File: docs/directives/button_example.py

import dash_mantine_components as dmc

# Simple button example for directive showcase
component = dmc.Button(
    "Click me!",
    variant="filled",
    color="teal",
    size="md"
)
```


---

### 4. Component Props Directive

The `.. kwargs::` directive auto-generates a documentation table for component properties.

#### Usage

```markdown
.. kwargs::ComponentName
```

For Dash Mantine Components:
```markdown
.. kwargs::dmc.Button
.. kwargs::dmc.TextInput
.. kwargs::dmc.Select
```

#### Features

- **Automatic extraction** of component properties
- **Type information** for each prop
- **Default values** when available
- **Descriptions** of what each prop does

#### Example: Button Props

**Markdown:**
```markdown
.. kwargs::dmc.Button
```

**Result:**


---

### 5. LLM Copy Button Directive

The `.. llms_copy::` directive adds a button that copies the page's `/llms.txt` URL to the clipboard, making it easy for users to share documentation with AI assistants like ChatGPT or Claude.

#### Usage

```markdown
.. llms_copy::Page Title
```

The page title should match the `name` field in your page's frontmatter.

#### Features

- **One-click copying** of the page's llms.txt URL
- **Visual feedback** showing when URL is copied
- **Works in all browsers** with fallback for non-HTTPS contexts
- **AI-friendly** - users can paste the URL into ChatGPT, Claude, or other AI assistants
- **Automatic URL detection** - constructs the correct URL based on current page

#### Example

At the top of this page, you'll see a "Copy for llm 📋" button. Clicking it copies a URL like:

```
http://your-site.com/examples/directives/llms.txt
```

Users can then paste this URL into ChatGPT or Claude with a prompt like:

```
"Can you help me understand this documentation? http://your-site.com/examples/directives/llms.txt"
```

The AI assistant will fetch the page's markdown content and can provide context-aware help.

#### When to Use

Place this directive at the top of documentation pages where users might want AI assistance:

```markdown
---
name: My Component
description: Complex component documentation
endpoint: /components/my-component
---

.. llms_copy::My Component

.. toc::

### Overview
...
```

#### Benefits for Users

- **Quick AI help** - Share documentation with AI assistants instantly
- **Better context** - AI gets the full page content in markdown format
- **No copy-paste** - URL copying is more reliable than copying page content
- **Future-proof** - Works with any AI that can fetch URLs

---

### Interactive Examples with Callbacks

You can create fully interactive examples with callbacks using the `.. exec::` directive.

#### Example: Counter

Here's an interactive counter that demonstrates callbacks:


The source code:


```python
# File: docs/directives/counter_example.py

from dash import html, callback, Input, Output, State
import dash_mantine_components as dmc
from dash_iconify import DashIconify

# Interactive counter example with callbacks
component = html.Div([
    dmc.Title("Interactive Counter", order=4, mb=10),
    dmc.Group([
        dmc.Button(
            "Increment",
            id="increment-btn",
            variant="filled",
            color="teal",
            leftSection=DashIconify(icon="mdi:plus-circle")
        ),
        dmc.Button(
            "Decrement",
            id="decrement-btn",
            variant="filled",
            color="red",
            leftSection=DashIconify(icon="mdi:minus-circle")
        ),
        dmc.Button(
            "Reset",
            id="reset-btn",
            variant="light",
            color="gray"
        ),
    ], gap="sm", mb=15),
    dmc.Paper([
        dmc.Text("Current Count:", size="sm", c="dimmed"),
        dmc.Title(
            "0",
            id="counter-display",
            order=2,
            c="teal"
        )
    ], p="md", withBorder=True, radius="md")
])


@callback(
    Output("counter-display", "children"),
    Input("increment-btn", "n_clicks"),
    Input("decrement-btn", "n_clicks"),
    Input("reset-btn", "n_clicks"),
    State("counter-display", "children"),
    prevent_initial_call=True
)
def update_counter(inc_clicks, dec_clicks, reset_clicks, current):
    from dash import ctx

    current_value = int(current) if current else 0

    button_id = ctx.triggered_id

    if button_id == "increment-btn":
        return str(current_value + 1)
    elif button_id == "decrement-btn":
        return str(current_value - 1)
    elif button_id == "reset-btn":
        return "0"

    return current
```


---

### Complex Example: Form with Validation

Let's create a more complex example with form inputs and validation:

Source code for this example:


```python
# File: docs/directives/form_example.py

from dash import html, callback, Input, Output, State, ALL
import dash_mantine_components as dmc
from dash_iconify import DashIconify

# Form validation example
component = html.Div([
    dmc.Title("User Registration Form", order=4, mb=10),
    dmc.Stack([
        dmc.TextInput(
            label="Full Name",
            placeholder="Enter your full name",
            id="form-name",
            required=True,
            leftSection=DashIconify(icon="mdi:account")
        ),
        dmc.TextInput(
            label="Email",
            placeholder="your.email@example.com",
            id="form-email",
            required=True,
            leftSection=DashIconify(icon="mdi:email")
        ),
        dmc.PasswordInput(
            label="Password",
            placeholder="Enter password",
            id="form-password",
            required=True,
            leftSection=DashIconify(icon="mdi:lock")
        ),
        dmc.Select(
            label="Country",
            placeholder="Select your country",
            id="form-country",
            data=[
                {"label": "United States", "value": "us"},
                {"label": "United Kingdom", "value": "uk"},
                {"label": "Canada", "value": "ca"},
                {"label": "Australia", "value": "au"},
                {"label": "Germany", "value": "de"},
            ],
            leftSection=DashIconify(icon="mdi:earth")
        ),
        dmc.Checkbox(
            label="I agree to the terms and conditions",
            id="form-terms",
        ),
        dmc.Group([
            dmc.Button(
                "Submit",
                id="form-submit-btn",
                variant="filled",
                color="teal",
                fullWidth=True
            ),
        ]),
        dmc.Paper(
            id="form-output",
            p="md",
            withBorder=True,
            radius="md",
            style={"display": "none"}
        )
    ], gap="sm")
])


@callback(
    Output("form-output", "children"),
    Output("form-output", "style"),
    Input("form-submit-btn", "n_clicks"),
    State("form-name", "value"),
    State("form-email", "value"),
    State("form-password", "value"),
    State("form-country", "value"),
    State("form-terms", "checked"),
    prevent_initial_call=True
)
def validate_form(n_clicks, name, email, password, country, terms):
    errors = []

    if not name or len(name.strip()) < 2:
        errors.append("Name must be at least 2 characters")

    if not email or "@" not in email:
        errors.append("Please enter a valid email")

    if not password or len(password) < 6:
        errors.append("Password must be at least 6 characters")

    if not country:
        errors.append("Please select a country")

    if not terms:
        errors.append("You must agree to the terms and conditions")

    if errors:
        return [
            dmc.Alert(
                title="Validation Errors",
                color="red",
                children=[
                    html.Ul([html.Li(error) for error in errors])
                ]
            )
        ], {"display": "block"}

    return [
        dmc.Alert(
            title="Success!",
            color="teal",
            children=f"Welcome, {name}! Your account has been created."
        )
    ], {"display": "block"}
```


---

### Combining Directives

The real power comes from **combining multiple directives** in one documentation page:

#### Pattern 1: Show Code & Result

```markdown
### My Feature

Description of the feature...

#### Interactive Demo

.. exec::docs.my-feature.demo

#### Source Code

.. source::docs/my-feature/demo.py
```

#### Pattern 2: Documentation with Props

```markdown
### Component API

#### Example Usage

.. exec::docs.component.example

#### Component Properties

.. kwargs::MyComponent

#### Full Source

.. source::docs/component/example.py
```

#### Pattern 3: Comprehensive Page

```markdown
---
name: My Component
description: Full documentation
endpoint: /components/my-component
---

.. toc::

### Overview
Description...

### Basic Example
.. exec::docs.component.basic
.. source::docs/component/basic.py

### Advanced Example
.. exec::docs.component.advanced
.. source::docs/component/advanced.py

### API Reference
.. kwargs::MyComponent

### Related Components
Links to other docs...
```

---

### Best Practices

#### 1. Use Meaningful Module Names

✅ Good:
```markdown
.. exec::docs.buttons.primary_button_example
```

❌ Bad:
```markdown
.. exec::docs.ex1
```

#### 2. Show Code and Result Together

Always pair `.. exec::` with `.. source::` so users can see both the result and how it's made:

```markdown
.. exec::docs.component.example
.. source::docs/component/example.py
```

#### 3. Add Context with Markdown

Explain what the example demonstrates before showing it:

```markdown
This example demonstrates how to handle form validation:

.. exec::docs.forms.validation_example
```

#### 4. Use Table of Contents for Long Pages

For documentation pages with multiple sections, always include:

```markdown
.. toc::
```

#### 5. Document Component Props

When documenting a new component, include the props table:

```markdown
.. kwargs::YourComponent
```

---

### Advanced Tips

#### Organizing Python Examples

Keep your Python examples organized by feature:

```text
docs/
├── buttons/
│   ├── buttons.md
│   ├── basic_button.py
│   ├── icon_button.py
│   └── loading_button.py
├── forms/
│   ├── forms.md
│   ├── text_input.py
│   ├── select.py
│   └── validation.py
```

#### Reusing Components

You can reference the same Python component from multiple markdown files:

```markdown
# In docs/examples/page1.md
.. exec::docs.shared.common_example

# In docs/examples/page2.md
.. exec::docs.shared.common_example
```

#### Error Handling

If a directive fails to render:

1. Check the module path is correct
2. Ensure the Python file has a `component` variable
3. Check for syntax errors in your Python code
4. Look at the server logs for detailed error messages

---

### Directive Reference

#### Quick Reference Table

| Directive | Syntax | Purpose |
|-----------|--------|---------|
| `toc` | `.. toc::` | Generate table of contents |
| `exec` | `.. exec::module.path` | Render Python component |
| `source` | `.. source::file/path.py` | Display source code |
| `kwargs` | `.. kwargs::ComponentName` | Show component props |
| `llms_copy` | `.. llms_copy::Page Title` | Add AI-friendly copy button |

---

### Next Steps

- **Interactive Components** - Learn advanced callback patterns
- **Data Visualization** - Create beautiful charts and graphs
- **AI Integration** - Make your docs AI-friendly

---

### Need Help?

If you're having trouble with directives:

1. Check the Python module path is correct (use dots, not slashes)
2. Ensure your Python file exports a `component` variable
3. Look for syntax errors in your code
4. Check the terminal for error messages

For more help, visit the [GitHub Issues](https://github.com/pip-install-python/Dash-Documentation-Boilerplate/issues).


---

*Source: /examples/directives*

---

<!-- /examples/interactive — https://boilerplate.2plot.dev/examples/interactive/llms.txt -->

# Interactive .md

> Advanced callback patterns and state management examples

---



### Introduction

This page demonstrates **advanced interactive patterns** using Dash callbacks, state management, and component interactions. Learn how to build complex, responsive user interfaces with real-time updates.

---

### Callback Basics

Callbacks are the heart of interactivity in Dash. They connect component properties and define how your app responds to user actions.

#### Simple Callback Example

A basic button that updates text:

Source code:


```python
# File: docs/interactive-components/simple_callback.py

from dash import html, callback, Input, Output
import dash_mantine_components as dmc

component = html.Div([
    dmc.Title("Simple Callback Example", order=4, mb=10),
    dmc.Button(
        "Click Me!",
        id="simple-button",
        variant="filled",
        color="teal",
        mb=15
    ),
    dmc.Paper([
        html.Div(
            "Button not clicked yet",
            id="simple-output"
        )
    ], p="md", withBorder=True, radius="md")
])


@callback(
    Output("simple-output", "children"),
    Input("simple-button", "n_clicks"),
    prevent_initial_call=False
)
def update_output(n_clicks):
    if n_clicks is None:
        return "Button not clicked yet"
    return f"Button has been clicked {n_clicks} times!"
```


---

### Multiple Inputs

Handle multiple input sources in a single callback:

Source code:


```python
# File: docs/interactive-components/multiple_inputs.py

from dash import html, callback, Input, Output
import dash_mantine_components as dmc

component = html.Div([
    dmc.Title("Multiple Inputs Example", order=4, mb=10),
    dmc.Text("Enter two numbers to see their sum:", mb=10),

    dmc.Stack([
        dmc.NumberInput(
            label="First Number",
            id="num1-input",
            value=0,
            min=0,
            max=100
        ),
        dmc.NumberInput(
            label="Second Number",
            id="num2-input",
            value=0,
            min=0,
            max=100
        ),
        dmc.Paper([
            dmc.Text("Result:", size="sm", c="dimmed", mb=5),
            dmc.Title(
                "0",
                id="sum-output",
                order=3,
                c="teal"
            )
        ], p="md", withBorder=True, radius="md")
    ], gap="sm")
])


@callback(
    Output("sum-output", "children"),
    Input("num1-input", "value"),
    Input("num2-input", "value")
)
def calculate_sum(num1, num2):
    num1 = num1 or 0
    num2 = num2 or 0
    total = num1 + num2
    return f"{num1} + {num2} = {total}"
```


---

### State Management

Use `State` to access component values without triggering the callback:

Source code:


```python
# File: docs/interactive-components/state_example.py

from dash import html, callback, Input, Output, State
import dash_mantine_components as dmc

component = html.Div([
    dmc.Title("State Management Example", order=4, mb=10),
    dmc.Text(
        "Type your name and click submit. Notice the callback only fires when you click submit, not on every keystroke.",
        mb=10,
        c="dimmed",
        size="sm"
    ),

    dmc.Stack([
        dmc.TextInput(
            label="Enter Your Name",
            placeholder="John Doe",
            id="name-input-state"
        ),
        dmc.Button(
            "Submit",
            id="submit-btn-state",
            variant="filled",
            color="teal"
        ),
        dmc.Paper([
            html.Div(
                "Click submit to see your name",
                id="greeting-output"
            )
        ], p="md", withBorder=True, radius="md")
    ], gap="sm")
])


@callback(
    Output("greeting-output", "children"),
    Input("submit-btn-state", "n_clicks"),
    State("name-input-state", "value"),
    prevent_initial_call=True
)
def display_greeting(n_clicks, name):
    if not name:
        return "Please enter your name above!"

    return html.Div([
        dmc.Text(f"Hello, {name}! 👋", size="lg", fw=500, c="teal"),
        dmc.Text(f"You've submitted this form {n_clicks} time(s).", size="sm", c="dimmed", mt=5)
    ])
```


---

### Pattern Matching Callbacks

Create dynamic components with pattern-matching callbacks:

Source code:


```python
# File: docs/interactive-components/pattern_matching.py

from dash import html, callback, Input, Output, State, ALL, MATCH
import dash_mantine_components as dmc
from dash_iconify import DashIconify

component = html.Div([
    dmc.Title("Pattern Matching Callbacks", order=4, mb=10),
    dmc.Text("Click 'Add Item' to dynamically create new inputs with pattern-matching callbacks.", mb=15, c="dimmed", size="sm"),

    dmc.Button(
        "Add Item",
        id="add-item-btn",
        variant="filled",
        color="teal",
        leftSection=DashIconify(icon="mdi:plus"),
        mb=15
    ),

    html.Div(id="items-container"),

    dmc.Paper([
        dmc.Text("Total of all items:", size="sm", c="dimmed", mb=5),
        dmc.Title("0", id="total-sum", order=3, c="teal")
    ], p="md", withBorder=True, radius="md", mt=15)
])


@callback(
    Output("items-container", "children"),
    Input("add-item-btn", "n_clicks"),
    State("items-container", "children"),
    prevent_initial_call=True
)
def add_item(n_clicks, children):
    children = children or []
    new_id = len(children)

    new_item = dmc.Group([
        dmc.NumberInput(
            label=f"Item {new_id + 1}",
            id={"type": "item-input", "index": new_id},
            value=0,
            min=0,
            style={"flex": 1}
        ),
        dmc.ActionIcon(
            DashIconify(icon="mdi:delete"),
            id={"type": "delete-btn", "index": new_id},
            variant="filled",
            color="red",
            size="lg",
            mt=25
        )
    ], mb=10)

    children.append(new_item)
    return children


@callback(
    Output("total-sum", "children"),
    Input({"type": "item-input", "index": ALL}, "value")
)
def calculate_total(values):
    total = sum(v or 0 for v in values)
    return f"Total: {total}"


@callback(
    Output("items-container", "children", allow_duplicate=True),
    Input({"type": "delete-btn", "index": ALL}, "n_clicks"),
    State("items-container", "children"),
    prevent_initial_call=True
)
def delete_item(delete_clicks, children):
    from dash import ctx

    if not ctx.triggered or not any(delete_clicks):
        return children

    # Find which delete button was clicked
    button_id = ctx.triggered_id
    if button_id:
        index_to_remove = button_id["index"]
        children = [child for i, child in enumerate(children) if i != index_to_remove]

    return children
```


---

### Chained Callbacks

Connect multiple callbacks to create complex workflows:

Source code:


```python
# File: docs/interactive-components/chained_callbacks.py

from dash import html, callback, Input, Output, State, dcc
import dash_mantine_components as dmc

# Sample data structure
data = {
    "USA": {
        "California": ["Los Angeles", "San Francisco", "San Diego"],
        "Texas": ["Houston", "Dallas", "Austin"],
        "Florida": ["Miami", "Orlando", "Tampa"]
    },
    "Canada": {
        "Ontario": ["Toronto", "Ottawa", "Hamilton"],
        "Quebec": ["Montreal", "Quebec City", "Laval"],
        "British Columbia": ["Vancouver", "Victoria", "Kelowna"]
    },
    "Mexico": {
        "Jalisco": ["Guadalajara", "Puerto Vallarta", "Zapopan"],
        "Nuevo León": ["Monterrey", "San Pedro", "Santa Catarina"]
    }
}

component = html.Div([
    dmc.Title("Chained Callbacks Example", order=4, mb=10),
    dmc.Text(
        "Select a country to populate states, then select a state to populate cities. Each selection triggers the next dropdown.",
        mb=15,
        c="dimmed",
        size="sm"
    ),

    dmc.Stack([
        dmc.Select(
            label="Country",
            placeholder="Select a country",
            id="country-dropdown",
            data=[{"label": country, "value": country} for country in data.keys()]
        ),
        dmc.Select(
            label="State/Province",
            placeholder="Select a country first",
            id="state-dropdown",
            data=[],
            disabled=True
        ),
        dmc.Select(
            label="City",
            placeholder="Select a state first",
            id="city-dropdown",
            data=[],
            disabled=True
        ),
        dmc.Paper([
            dmc.Text("Your Selection:", size="sm", c="dimmed", mb=5),
            html.Div(
                "Make selections above to see your full location",
                id="selection-output"
            )
        ], p="md", withBorder=True, radius="md")
    ], gap="sm")
])


@callback(
    Output("state-dropdown", "data"),
    Output("state-dropdown", "disabled"),
    Output("state-dropdown", "value"),
    Input("country-dropdown", "value")
)
def update_states(country):
    if not country:
        return [], True, None

    states = list(data[country].keys())
    state_options = [{"label": state, "value": state} for state in states]

    return state_options, False, None


@callback(
    Output("city-dropdown", "data"),
    Output("city-dropdown", "disabled"),
    Output("city-dropdown", "value"),
    Input("state-dropdown", "value"),
    State("country-dropdown", "value")
)
def update_cities(state, country):
    if not state or not country:
        return [], True, None

    cities = data[country][state]
    city_options = [{"label": city, "value": city} for city in cities]

    return city_options, False, None


@callback(
    Output("selection-output", "children"),
    Input("city-dropdown", "value"),
    State("state-dropdown", "value"),
    State("country-dropdown", "value")
)
def display_selection(city, state, country):
    if not all([country, state, city]):
        return "Make selections above to see your full location"

    return html.Div([
        dmc.Text("📍 Your Location:", fw=500, mb=5),
        dmc.Text(f"{city}, {state}, {country}", size="lg", c="teal")
    ])
```


---

### Loading States

Provide visual feedback during long-running operations:

Source code:


```python
# File: docs/interactive-components/loading_states.py

from dash import html, callback, Input, Output, dcc
import dash_mantine_components as dmc
import time

component = html.Div([
    dmc.Title("Loading States Example", order=4, mb=10),
    dmc.Text(
        "Click the button to trigger a slow operation. Notice the loading indicator while processing.",
        mb=15,
        c="dimmed",
        size="sm"
    ),

    dmc.Button(
        "Process Data",
        id="process-btn",
        variant="filled",
        color="teal",
        mb=15
    ),

    dcc.Loading(
        id="loading-component",
        type="default",
        children=[
            dmc.Paper([
                html.Div(
                    "Click the button to start processing",
                    id="loading-output"
                )
            ], p="md", withBorder=True, radius="md")
        ]
    )
])


@callback(
    Output("loading-output", "children"),
    Input("process-btn", "n_clicks"),
    prevent_initial_call=True
)
def process_data(n_clicks):
    # Simulate a slow operation
    time.sleep(2)

    return html.Div([
        dmc.Alert(
            title="Processing Complete!",
            color="teal",
            children=[
                dmc.Text(f"Successfully processed request #{n_clicks}", mb=5),
                dmc.Text("This simulated a 2-second operation.", size="sm", c="dimmed")
            ]
        )
    ])
```


---

### Callback Patterns Reference

#### Common Callback Patterns

##### Pattern 1: Single Input, Single Output

```python
@callback(
    Output("output-id", "children"),
    Input("input-id", "value")
)
def update_output(input_value):
    return f"You entered: {input_value}"
```

##### Pattern 2: Multiple Inputs, Single Output

```python
@callback(
    Output("result", "children"),
    Input("input1", "value"),
    Input("input2", "value")
)
def combine_inputs(val1, val2):
    return f"{val1} + {val2} = {val1 + val2}"
```

##### Pattern 3: Single Input, Multiple Outputs

```python
@callback(
    Output("output1", "children"),
    Output("output2", "children"),
    Input("trigger", "n_clicks")
)
def update_multiple(n_clicks):
    return f"Clicks: {n_clicks}", f"Double: {n_clicks * 2}"
```

##### Pattern 4: Using State

```python
@callback(
    Output("display", "children"),
    Input("submit-btn", "n_clicks"),
    State("input-field", "value")
)
def submit_form(n_clicks, value):
    if n_clicks is None:
        return "Click submit to see value"
    return f"Submitted: {value}"
```

##### Pattern 5: Pattern Matching with ALL

```python
@callback(
    Output("summary", "children"),
    Input({"type": "dynamic-input", "index": ALL}, "value")
)
def aggregate_inputs(values):
    return f"Total: {sum(v or 0 for v in values)}"
```

##### Pattern 6: Pattern Matching with MATCH

```python
@callback(
    Output({"type": "output", "index": MATCH}, "children"),
    Input({"type": "input", "index": MATCH}, "value")
)
def update_matching(value):
    return f"Value: {value}"
```

---

### Advanced Techniques

#### Preventing Initial Calls

Prevent callbacks from firing on page load:

```python
@callback(
    Output("output", "children"),
    Input("button", "n_clicks"),
    prevent_initial_call=True
)
def update(n_clicks):
    return f"Button clicked {n_clicks} times"
```

#### Circular Callbacks

Allow circular callback chains with `allow_duplicate=True`:

```python
@callback(
    Output("value", "data", allow_duplicate=True),
    Input("increment", "n_clicks"),
    State("value", "data"),
    prevent_initial_call=True
)
def increment_value(n_clicks, current):
    return (current or 0) + 1
```

#### Determining Trigger

Find out which input triggered the callback:

```python
from dash import ctx

@callback(
    Output("output", "children"),
    Input("btn1", "n_clicks"),
    Input("btn2", "n_clicks")
)
def update(btn1_clicks, btn2_clicks):
    trigger_id = ctx.triggered_id

    if trigger_id == "btn1":
        return "Button 1 was clicked"
    elif trigger_id == "btn2":
        return "Button 2 was clicked"

    return "No button clicked yet"
```

#### Background Callbacks

For long-running operations (requires `diskcache` or `celery`):

```python
from dash import DiskcacheManager
import diskcache

cache = diskcache.Cache("./cache")
background_callback_manager = DiskcacheManager(cache)

@callback(
    Output("result", "children"),
    Input("submit", "n_clicks"),
    background=True,
    manager=background_callback_manager
)
def long_running_task(n_clicks):
    # Simulate long operation
    time.sleep(10)
    return "Task complete!"
```

---

### Best Practices

#### 1. Keep Callbacks Simple

Break complex logic into multiple callbacks:

✅ **Good:**
```python
@callback(Output("processed", "data"), Input("raw", "data"))
def process_data(raw):
    return process(raw)

@callback(Output("chart", "figure"), Input("processed", "data"))
def create_chart(processed):
    return make_figure(processed)
```

❌ **Bad:**
```python
@callback(Output("chart", "figure"), Input("raw", "data"))
def do_everything(raw):
    processed = process(raw)
    return make_figure(processed)
```

#### 2. Use State for Form Inputs

Use `State` to avoid triggering callbacks on every keystroke:

```python
# Good for forms
@callback(
    Output("result", "children"),
    Input("submit-button", "n_clicks"),
    State("text-input", "value")
)
```

#### 3. Validate Inputs

Always validate input values:

```python
@callback(Output("output", "children"), Input("input", "value"))
def update(value):
    if value is None or value == "":
        return "Please enter a value"

    if not isinstance(value, str):
        return "Invalid input type"

    return f"Valid input: {value}"
```

#### 4. Handle None Values

Check for `None` to handle initial callbacks:

```python
@callback(Output("output", "children"), Input("input", "value"))
def update(value):
    if value is None:
        return "Waiting for input..."

    return f"Processing: {value}"
```

#### 5. Use Proper IDs

Choose descriptive, unique IDs:

✅ Good: `"user-email-input"`, `"submit-form-button"`
❌ Bad: `"input1"`, `"btn"`

---

### Performance Tips

#### 1. Memoization

Cache expensive computations:

```python
from functools import lru_cache

@lru_cache(maxsize=128)
def expensive_computation(param):
    # Heavy processing here
    return result
```

#### 2. Clientside Callbacks

Use JavaScript callbacks for simple UI updates:

```python
app.clientside_callback(
    """
    function(n_clicks) {
        return n_clicks || 0;
    }
    """,
    Output("counter", "children"),
    Input("button", "n_clicks")
)
```

#### 3. Partial Updates

Update only what changed:

```python
@callback(
    Output("table", "data"),
    Input("refresh", "n_clicks"),
    State("table", "data")
)
def update_table(n_clicks, current_data):
    # Only update specific rows
    new_data = current_data.copy()
    new_data[0] = updated_row
    return new_data
```

---

### Common Pitfalls

#### 1. Circular Dependency

❌ **Wrong:**
```python
@callback(Output("a", "value"), Input("b", "value"))
def update_a(b): return b

@callback(Output("b", "value"), Input("a", "value"))
def update_b(a): return a
```

✅ **Fixed:**
```python
@callback(
    Output("a", "value", allow_duplicate=True),
    Input("b", "value"),
    prevent_initial_call=True
)
def update_a(b): return b
```

#### 2. Modifying Global State

❌ **Wrong:**
```python
global_data = []

@callback(Output("out", "children"), Input("btn", "n_clicks"))
def update(n):
    global_data.append(n)  # Don't do this!
    return len(global_data)
```

✅ **Fixed:**
```python
@callback(
    Output("store", "data"),
    Output("out", "children"),
    Input("btn", "n_clicks"),
    State("store", "data")
)
def update(n, data):
    data = data or []
    data.append(n)
    return data, len(data)
```

---

### Testing Callbacks

#### Example Test

```python
from dash.testing import DashComposite

def test_callback(dash_duo):
    app = create_app()
    dash_duo.start_server(app)

    # Find input and click button
    input_elem = dash_duo.find_element("#my-input")
    input_elem.send_keys("test value")

    button = dash_duo.find_element("#submit-button")
    button.click()

    # Wait for callback to complete
    dash_duo.wait_for_text_to_equal("#output", "Expected text")
```

---

### Next Steps

- **Data Visualization** - Create interactive charts
- **AI Integration** - Make your components AI-friendly
- **Getting Started** - Learn the basics

---

Happy coding! 🚀


---

*Source: /examples/interactive*

---

<!-- /examples/visualization — https://boilerplate.2plot.dev/examples/visualization/llms.txt -->

# Data Visualization

> Interactive charts and graphs with Plotly integration

---



### Introduction

This page demonstrates how to create **interactive data visualizations** using Plotly with your Dash Documentation Boilerplate. Plotly provides powerful, interactive charts that integrate seamlessly with Dash callbacks.

---

### Basic Chart Example

Let's start with a simple bar chart example:

Source code:


```python
# File: docs/data-visualization/basic_chart.py

from dash import dcc, callback, Input, Output
import dash_mantine_components as dmc
import pandas as pd
import plotly.express as px

# Register Mantine templates
dmc.add_figure_templates(default="mantine_light")

# Sample data
df = pd.DataFrame({
    "Category": ["Product A", "Product B", "Product C", "Product D", "Product E"],
    "Sales": [120, 95, 180, 140, 165]
})

# Create initial figure
fig = px.bar(
    df,
    x="Category",
    y="Sales",
    title="Product Sales Comparison",
    color="Sales",
    color_continuous_scale="teal"
)

fig.update_layout(
    xaxis_title="Product Category",
    yaxis_title="Sales (Units)",
    showlegend=False,
    height=400
)

component = dcc.Graph(figure=fig, id='figure-basic-chart', config={'displayModeBar': False})


@callback(
    Output('figure-basic-chart', "figure"),
    Input("color-scheme-storage", "data"),
)
def update_figure_theme(theme):
    """Update chart template based on color scheme"""
    template = "mantine_dark" if theme == "dark" else "mantine_light"

    # Recreate the figure with the correct template
    fig = px.bar(
        df,
        x="Category",
        y="Sales",
        title="Product Sales Comparison",
        color="Sales",
        color_continuous_scale="teal",
        template=template
    )

    fig.update_layout(
        xaxis_title="Product Category",
        yaxis_title="Sales (Units)",
        showlegend=False,
        height=400
    )

    return fig
```

    :defaultExpanded: false
    :withExpandedButton: true

---

### Interactive Line Chart

This example shows multiple lines with hover tooltips and a legend:

Source code:


```python
# File: docs/data-visualization/line_chart.py

from dash import dcc, callback, Input, Output
import dash_mantine_components as dmc
import pandas as pd
import plotly.express as px
import numpy as np

# Register Mantine templates
dmc.add_figure_templates(default="mantine_light")

# Generate sample time series data
dates = pd.date_range('2024-01-01', periods=30, freq='D')
np.random.seed(42)

df = pd.DataFrame({
    'Date': dates,
    'Revenue': np.cumsum(np.random.randn(30) * 10 + 100),
    'Costs': np.cumsum(np.random.randn(30) * 8 + 80),
    'Profit': np.cumsum(np.random.randn(30) * 5 + 20)
})

# Melt the dataframe for plotting multiple lines
df_melted = df.melt(id_vars=['Date'], var_name='Metric', value_name='Amount')

# Create initial figure
fig = px.line(
    df_melted,
    x='Date',
    y='Amount',
    color='Metric',
    title='Financial Metrics Over Time',
    color_discrete_map={
        'Revenue': '#12B886',  # Teal
        'Costs': '#FA5252',    # Red
        'Profit': '#228BE6'    # Blue
    }
)

fig.update_layout(
    xaxis_title='Date',
    yaxis_title='Amount ($)',
    hovermode='x unified',
    height=450,
    legend=dict(
        orientation="h",
        yanchor="bottom",
        y=1.02,
        xanchor="right",
        x=1
    )
)

component = dcc.Graph(figure=fig, id='figure-line-chart')


@callback(
    Output('figure-line-chart', "figure"),
    Input("color-scheme-storage", "data"),
)
def update_figure_theme(theme):
    """Update chart template based on color scheme"""
    template = "mantine_dark" if theme == "dark" else "mantine_light"

    # Recreate the figure with the correct template
    fig = px.line(
        df_melted,
        x='Date',
        y='Amount',
        color='Metric',
        title='Financial Metrics Over Time',
        color_discrete_map={
            'Revenue': '#12B886',
            'Costs': '#FA5252',
            'Profit': '#228BE6'
        },
        template=template
    )

    fig.update_layout(
        xaxis_title='Date',
        yaxis_title='Amount ($)',
        hovermode='x unified',
        height=450,
        legend=dict(
            orientation="h",
            yanchor="bottom",
            y=1.02,
            xanchor="right",
            x=1
        )
    )

    return fig
```


---

### Scatter Plot with Filtering

An interactive scatter plot with dropdown filtering:

Source code:


```python
# File: docs/data-visualization/scatter_plot.py

from dash import html, dcc, callback, Input, Output, State
import pandas as pd
import plotly.express as px
import dash_mantine_components as dmc
import numpy as np

# Register Mantine templates
dmc.add_figure_templates(default="mantine_light")

# Generate sample data
np.random.seed(42)
df = pd.DataFrame({
    'Height': np.random.normal(170, 10, 100),
    'Weight': np.random.normal(70, 15, 100),
    'Age': np.random.randint(20, 60, 100),
    'Gender': np.random.choice(['Male', 'Female'], 100)
})

component = html.Div([
    dmc.Title("Height vs Weight Analysis", order=4, mb=10),
    dmc.Select(
        label="Filter by Gender",
        data=[
            {"label": "All", "value": "all"},
            {"label": "Male", "value": "Male"},
            {"label": "Female", "value": "Female"}
        ],
        value="all",
        id="gender-filter",
        mb=15,
        style={"maxWidth": "200px"}
    ),
    dcc.Graph(id="scatter-chart")
])


@callback(
    Output("scatter-chart", "figure"),
    Input("gender-filter", "value"),
    Input("color-scheme-storage", "data"),
)
def update_scatter(gender, theme):
    """Update scatter plot based on gender filter and theme"""
    template = "mantine_dark" if theme == "dark" else "mantine_light"

    if gender == "all":
        filtered_df = df
    else:
        filtered_df = df[df['Gender'] == gender]

    fig = px.scatter(
        filtered_df,
        x='Height',
        y='Weight',
        color='Gender',
        size='Age',
        title=f'Height vs Weight{" - " + gender if gender != "all" else ""}',
        hover_data=['Age'],
        color_discrete_map={'Male': '#228BE6', 'Female': '#FA5252'},
        template=template
    )

    fig.update_layout(
        xaxis_title='Height (cm)',
        yaxis_title='Weight (kg)',
        height=450
    )

    return fig
```


---

### Real-Time Data Updates

This example demonstrates how to update charts in real-time using intervals:

Source code:


```python
# File: docs/data-visualization/realtime_chart.py

from dash import html, dcc, callback, Input, Output
import dash_mantine_components as dmc
import plotly.graph_objects as go
from collections import deque
import random
from datetime import datetime, timedelta

# Register Mantine templates
dmc.add_figure_templates(default="mantine_light")

# Initialize deque to store recent data points
MAX_DATA_POINTS = 50
time_data = deque(maxlen=MAX_DATA_POINTS)
value_data = deque(maxlen=MAX_DATA_POINTS)

# Initialize with some data
start_time = datetime.now()
for i in range(20):
    time_data.append(start_time + timedelta(seconds=i))
    value_data.append(random.uniform(20, 80))

component = html.Div([
    dmc.Title("Real-Time Data Stream", order=4, mb=10),
    dmc.Alert(
        "This chart updates every 2 seconds with simulated real-time data",
        color="blue",
        mb=15
    ),
    dcc.Graph(id="realtime-graph"),
    dcc.Interval(
        id="interval-component",
        interval=2000,  # Update every 2 seconds
        n_intervals=0
    )
])


@callback(
    Output("realtime-graph", "figure"),
    Input("interval-component", "n_intervals"),
    Input("color-scheme-storage", "data"),
)
def update_realtime(n, theme):
    """Update real-time chart with new data and theme"""
    template = "mantine_dark" if theme == "dark" else "mantine_light"

    # Add new data point
    time_data.append(datetime.now())

    # Generate new value with some randomness
    if len(value_data) > 0:
        last_value = value_data[-1]
        new_value = last_value + random.uniform(-5, 5)
        # Keep value in reasonable range
        new_value = max(10, min(90, new_value))
    else:
        new_value = 50

    value_data.append(new_value)

    # Create figure
    fig = go.Figure()

    fig.add_trace(go.Scatter(
        x=list(time_data),
        y=list(value_data),
        mode='lines+markers',
        name='Sensor Reading',
        line=dict(color='#12B886', width=2),
        marker=dict(size=6)
    ))

    fig.update_layout(
        title='Live Sensor Data',
        xaxis_title='Time',
        yaxis_title='Value',
        yaxis_range=[0, 100],
        hovermode='x',
        height=400,
        showlegend=False,
        template=template
    )

    return fig
```


---

### Chart Types Reference

#### Available Plotly Chart Types

Plotly Express provides many chart types:

##### Basic Charts
- **Bar Charts** - `px.bar()`
- **Line Charts** - `px.line()`
- **Scatter Plots** - `px.scatter()`
- **Pie Charts** - `px.pie()`
- **Histograms** - `px.histogram()`

##### Statistical Charts
- **Box Plots** - `px.box()`
- **Violin Plots** - `px.violin()`
- **Density Heatmaps** - `px.density_heatmap()`

##### Scientific Charts
- **Scatter Matrix** - `px.scatter_matrix()`
- **Parallel Coordinates** - `px.parallel_coordinates()`
- **3D Scatter** - `px.scatter_3d()`

##### Financial Charts
- **Candlestick** - `go.Candlestick()`
- **OHLC** - `go.Ohlc()`
- **Waterfall** - `go.Waterfall()`

---

### Customization Tips

#### Theme Integration

Make charts match your Mantine theme automatically using DMC figure templates:

```python
import dash_mantine_components as dmc
import plotly.express as px

# Register Mantine templates (mantine_light and mantine_dark)
dmc.add_figure_templates(default="mantine_light")

# Charts now automatically use the Mantine template
fig = px.bar(df, x="category", y="value")

# The templates match your theme's colors, fonts, and styling
# For more info: https://www.dash-mantine-components.com/plotly-templates
```

All examples on this page use `dmc.add_figure_templates()` to ensure charts render correctly in both light and dark modes.

#### Responsive Charts

Make charts responsive to window size:

```python
fig.update_layout(
    autosize=True,
    margin=dict(l=20, r=20, t=40, b=20),
)

# In your component
dcc.Graph(
    figure=fig,
    config={'responsive': True},
    style={'height': '400px'}
)
```

#### Interactive Features

Enable useful interactions:

```python
config = {
    'displayModeBar': True,
    'displaylogo': False,
    'modeBarButtonsToRemove': ['pan2d', 'lasso2d'],
    'toImageButtonOptions': {
        'format': 'png',
        'filename': 'chart',
        'height': 1080,
        'width': 1920,
        'scale': 2
    }
}

dcc.Graph(figure=fig, config=config)
```

---

### Performance Tips

#### 1. Limit Data Points

For large datasets, consider:

```python
# Sample data if too large
if len(df) > 10000:
    df = df.sample(10000)
```

#### 2. Use Scattergl for Large Scatter Plots

```python
import plotly.graph_objects as go

fig = go.Figure(data=go.Scattergl(
    x=x_data,
    y=y_data,
    mode='markers'
))
```

#### 3. Optimize Update Frequency

```python
# Use longer intervals for real-time updates
dcc.Interval(
    interval=2000,  # Update every 2 seconds
    n_intervals=0
)
```

---

### Common Patterns

#### Pattern 1: Chart with Controls

```markdown
## My Visualization

Use the controls below to customize the view:

.. exec::docs.viz.controlled_chart
```

```python
component = html.Div([
    dmc.Select(
        label="Metric",
        data=[...],
        id="metric-select"
    ),
    dcc.Graph(id="chart")
])

@callback(Output("chart", "figure"), Input("metric-select", "value"))
def update(metric):
    return create_figure(metric)
```

#### Pattern 2: Multi-View Dashboard

```python
component = dmc.SimpleGrid([
    dmc.Card([dcc.Graph(figure=fig1)]),
    dmc.Card([dcc.Graph(figure=fig2)]),
    dmc.Card([dcc.Graph(figure=fig3)]),
    dmc.Card([dcc.Graph(figure=fig4)]),
], cols={"base": 1, "sm": 2})
```

#### Pattern 3: Clickable Charts

```python
@callback(
    Output("details", "children"),
    Input("chart", "clickData")
)
def display_click_data(clickData):
    if not clickData:
        return "Click on a data point"
    return f"Selected: {clickData['points'][0]['x']}"
```

---

### Best Practices

#### 1. Add Axis Labels

Always label your axes:

```python
fig.update_layout(
    xaxis_title="Time (hours)",
    yaxis_title="Temperature (°C)"
)
```

#### 2. Include Titles

Make charts self-explanatory:

```python
fig.update_layout(
    title="Monthly Sales Performance",
    title_x=0.5  # Center the title
)
```

#### 3. Use Color Wisely

Choose accessible colors:

```python
# Good - accessible color palette
colors = ['#1f77b4', '#ff7f0e', '#2ca02c']

# Even better - use colorblind-friendly palettes
import plotly.express as px
fig = px.bar(df, color_discrete_sequence=px.colors.qualitative.Safe)
```

#### 4. Add Hover Information

Provide context on hover:

```python
fig = px.scatter(
    df, x="x", y="y",
    hover_data=["category", "value", "date"]
)
```

#### 5. Handle Empty Data

Always check for empty datasets:

```python
if df.empty:
    return {
        'data': [],
        'layout': {
            'xaxis': {'visible': False},
            'yaxis': {'visible': False},
            'annotations': [{
                'text': 'No data available',
                'showarrow': False,
                'font': {'size': 20}
            }]
        }
    }
```

---

### Resources

- **Plotly Express**: [plotly.com/python/plotly-express/](https://plotly.com/python/plotly-express/)
- **Plotly Graph Objects**: [plotly.com/python/graph-objects/](https://plotly.com/python/graph-objects/)
- **Dash Core Components**: [dash.plotly.com/dash-core-components/graph](https://dash.plotly.com/dash-core-components/graph)
- **Color Scales**: [plotly.com/python/builtin-colorscales/](https://plotly.com/python/builtin-colorscales/)

---

### Next Steps

- **Interactive Components** - Learn advanced callback patterns
- **AI Integration** - Make your visualizations AI-friendly
- **Getting Started** - Create your first documentation page

---

Happy visualizing! 📊


---

*Source: /examples/visualization*

---

<!-- /fastapi-showcase — https://boilerplate.2plot.dev/fastapi-showcase/llms.txt -->

# FastAPI Showcase

> What the FastAPI backend unlocks in this boilerplate — OpenAPI docs, a native JSON API, ASGI middleware, and a path to MCP.

---



### Why a dedicated showcase?

Most Dash docs assume Flask. With Dash 4.1+ you can swap in a real FastAPI app under the hood, and once you do, things you used to bolt on with extra packages — OpenAPI docs, native async, websocket callbacks, structured JSON APIs — become first-class.

This boilerplate ships both backends side by side. Switch with one env var:

```env
# .env
DASH_BACKEND=fastapi
```

When the badge in the header reads **FastAPI · async**, the surface described below is live.

---

### What lights up on FastAPI

| Surface | Path | Notes |
|--|--|--|
| Swagger UI | `/docs` | Interactive OpenAPI explorer (FastAPI built-in) |
| ReDoc | `/redoc` | Alternative OpenAPI viewer |
| OpenAPI schema | `/openapi.json` | Machine-readable spec |
| Liveness probe | `/healthz` | Returns active backend + Dash version |
| Active backend | `/api/backend` | Backend name, label, async flag |
| Page registry | `/api/pages` | All Dash pages, sortable list with metadata |
| LLM markdown | `/<page>/llms.txt` | Mounted by `dash-improve-my-llms` — backend-detected, identical body across Flask/FastAPI/Quart |
| Bot policy | `/robots.txt` | Same surface as the Flask build; same `RobotsConfig` |
| Sitemap | `/sitemap.xml` | Same priority inference; respects `mark_hidden()` |

Everything in the **Surface** column appears in `/docs` because the routes are declared with typed Pydantic models and `response_model=...` — Swagger UI picks them up automatically.

#### Try it live

This widget hits the JSON endpoints directly from your browser and pretty-prints the response. Useful for confirming the surface is up before you point a real client at it.


<!-- component rendered from docs/fastapi-showcase/endpoint_explorer.py; source withheld by `:code: false` -->


---

### The OpenAPI link in the header

The header surfaces a Swagger badge **only when the active backend is FastAPI**. The logic is small:

```python
# components/header.py
def _create_openapi_link():
    info = get_backend_info()
    if info.name != "fastapi":
        return None
    return dmc.Anchor(
        dmc.Badge("OpenAPI", leftSection=DashIconify(icon="logos:swagger"), ...),
        href="/docs",
        target="_blank",
    )
```

Open it in a new tab and you can `Try it out` against any of the routes without leaving the app.

---

### How the routes are mounted

`run.py` calls `add_llms_routes(app)` unconditionally — dash-improve-my-llms detects the FastAPI backend and mounts its own router. The boilerplate's `lib/asgi_routes.py` only carries the **showcase** surfaces (`/healthz`, `/api/backend`, `/api/pages`) — the things that demonstrate first-class OpenAPI integration:


```python
# File: lib/asgi_routes.py

"""
FastAPI showcase routes for the documentation boilerplate.

The AI/LLM surfaces (``/llms.txt``, ``/<page>/llms.txt``, ``/robots.txt``,
``/sitemap.xml``) are mounted by ``dash-improve-my-llms`` 2.0 directly —
the package detects the FastAPI backend and registers its own router.
This module only carries the **showcase** surfaces that demonstrate
first-class OpenAPI integration under Dash 4.1+'s FastAPI backend:

- ``/healthz``       — liveness probe
- ``/api/backend``   — active backend info
- ``/api/pages``     — registered Dash pages, sortable list

These show up in Swagger UI at ``/docs`` and ReDoc at ``/redoc`` because
each route declares a Pydantic ``response_model``.
"""
from __future__ import annotations

from typing import List, Optional

import dash
from fastapi import APIRouter, FastAPI, Request
from pydantic import BaseModel, ConfigDict, Field


# ---------------------------------------------------------------------------
# Pydantic models — these power the OpenAPI schema at /docs
# ---------------------------------------------------------------------------


class BackendInfoModel(BaseModel):
    name: str = Field(..., description="Active backend identifier")
    label: str = Field(..., description="Human-readable backend label")
    is_async: bool = Field(..., description="True for ASGI backends (fastapi, quart)")
    description: str


class PageSummary(BaseModel):
    name: str
    path: str
    title: Optional[str] = None
    description: Optional[str] = None
    icon: Optional[str] = None


class PageListResponse(BaseModel):
    backend: str
    count: int
    pages: List[PageSummary]


class HealthResponse(BaseModel):
    """The probe contract, identical on every backend.

    ``lib.health.health_payload`` is the single source — this model only
    types it for Swagger. It used to be built independently here, which
    meant a FastAPI deployment silently lacked ``build``: cd.yml's
    build-match wait polls /healthz for exactly that field, so it would
    have fallen into the "predates the build field" warning path forever,
    verifying whichever release happened to be serving — the muicharts
    defect the wait was written to prevent, reintroduced per-backend
    (found on llms-2plot-dev, 2026-08-23).
    """

    # ADDITIVE KEYS ARE KEPT (1.6.44 item 20, and the reason is item 1).
    # A pydantic response_model DROPS every field it does not declare, in
    # silence. That is how `llms_version` — added to health_payload two days
    # ago as item 1's rider, and specifically so the CI-vs-production gap
    # would stop being self-reported — was present on the Flask lane and
    # ABSENT on this one, on a template whose production runs Flask so
    # nothing said so. The two ASGI forks in the fleet would have shipped a
    # healthz with no llms_version and no way to notice.
    #
    # Two defences, because either alone has failed here: the known keys are
    # declared below (so Swagger documents them), and `extra="allow"` keeps
    # anything health_payload adds NEXT. A model that silently narrows the
    # payload is the "two lanes are different documents" trap wearing a
    # type annotation.
    model_config = ConfigDict(extra="allow")

    ok: bool = True
    backend: str
    dash_version: str
    # The serving interpreter (platform.python_version()) — required, not
    # Optional: every process has one, and an absent field is exactly the
    # invisibility that let the image/matrix/render.yaml Pythons drift
    # apart (ops-seat finding, 2026-08-25).
    python: str
    # Optional because they are environment-dependent, not backend-dependent.
    build: Optional[str] = None
    app: Optional[str] = None
    geo: Optional[dict] = None
    # The resolved dash-improve-my-llms version (item 1's rider) and the
    # ledger block (item 20). Optional because health_payload omits
    # llms_version when the import itself fails, which is the finding.
    llms_version: Optional[str] = None
    ledger: Optional[dict] = None


# ---------------------------------------------------------------------------
# Router factories
# ---------------------------------------------------------------------------


def build_api_router(app, backend_info) -> APIRouter:
    """Native FastAPI showcase routes — populate /docs and /redoc."""
    router = APIRouter(prefix="/api", tags=["showcase"])

    @router.get("/backend", response_model=BackendInfoModel, summary="Active backend")
    def get_backend() -> BackendInfoModel:
        return BackendInfoModel(
            name=backend_info.name,
            label=backend_info.label,
            is_async=backend_info.is_async,
            description=backend_info.description,
        )

    @router.get(
        "/pages",
        response_model=PageListResponse,
        summary="Registered Dash pages",
    )
    def list_pages() -> PageListResponse:
        pages: List[PageSummary] = []
        for p in dash.page_registry.values():
            pages.append(PageSummary(
                name=p.get("name"),
                path=p.get("path"),
                title=p.get("title"),
                description=p.get("description"),
                icon=p.get("icon"),
            ))
        return PageListResponse(
            backend=backend_info.name,
            count=len(pages),
            pages=sorted(pages, key=lambda x: x.path),
        )

    return router


def build_health_router() -> APIRouter:
    router = APIRouter(tags=["health"])

    # response_model_exclude_none: THE MIRROR IMAGE of the drop this model
    # was declared to prevent (muischeduler, note 189; measured on this tree
    # 2026-09-12). `health_payload` OMITS a key it has no value for —
    # `build` when RENDER_GIT_COMMIT is unset — but an `Optional[...] = None`
    # field is SERVED AS null, so the flask lane omitted the key and this
    # lane added it: nine keys here, eight there, the lanes disagreeing about
    # the KEY SET whenever the env var is absent. Invisible in production
    # (Render sets the commit) and invisible to any test reading VALUES.
    #
    # Safe here because `health_payload` never intentionally returns null —
    # measured: zero None values anywhere in the payload, nested included.
    # It omits instead. So "null" always means "the builder withheld this",
    # and dropping those fields makes the wire match the builder exactly.
    @router.get("/healthz", response_model=HealthResponse,
                response_model_exclude_none=True, summary="Liveness probe")
    def healthz(request: Request) -> HealthResponse:
        # One payload builder for all three backends — see HealthResponse.
        # Built per request: `geo` reports live state, and this route is
        # mounted long before any geo configuration runs. The request's
        # headers go with it — geo's `resolved` reads the country header
        # from THIS request, and the Flask-context fallback inside
        # health_payload can never see a Starlette request (pannellum's
        # production healthz answered "no request context" until this).
        from lib.health import health_payload

        return HealthResponse(**health_payload("fastapi", headers=request.headers))

    return router


def register_asgi_routes(app, backend_info) -> None:
    """Mount the showcase FastAPI routers on ``app.server``.

    These must be registered **before** ``add_llms_routes(app)`` so that
    the package's catch-all ``/<page>/llms.txt`` matcher does not shadow
    ``/healthz`` or ``/api/*``.
    """
    server: FastAPI = app.server  # type: ignore[assignment]
    server.include_router(build_health_router())
    server.include_router(build_api_router(app, backend_info))
```


Two things to notice:

1. **Pydantic models drive the schema.** `BackendInfoModel`, `PageSummary`, `PageListResponse`, `HealthResponse` — each one shows up in `/docs` with example values.
2. **Route ordering matters.** Mount your showcase routes *before* `add_llms_routes(app)`. The package's `/<page>/llms.txt` is greedy and would otherwise shadow `/api/*` matches.

`run.py` calls `register_asgi_routes(app, BACKEND_INFO)` in the `elif BACKEND == "fastapi":` branch and that's the whole wire-up.

---

### Analytics as ASGI middleware

The Flask build uses `@server.before_request` for the visitor tracker. Under FastAPI, that hook doesn't exist — instead we mount a Starlette middleware:


```python
# File: lib/asgi_middleware.py

"""
ASGI/Starlette middleware ports of Flask-only hooks used in this boilerplate.

When the Dash backend is FastAPI, these slot in where the Flask
``before_request`` decorator was used.
"""
from __future__ import annotations

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response

from lib.analytics_tracker import tracker


# ``HeadAsGetMiddleware`` LIVED HERE FROM 1.6.32 TO 1.6.44, AND IS RETIRED.
#
# Why it existed: FastAPI's ``APIRoute`` takes ``methods`` literally, so a
# route declared ``@router.get(...)`` answered 405 to HEAD — and Dash's page
# catch-all is an ``APIRoute`` too, registered from the ASGI lifespan
# startup, which nothing in this repo could declare methods on. Every route
# of both FastAPI forks 405'd on HEAD (measured on pannellum and
# muischeduler, 2026-08-27), ``/healthz`` included, which is the default
# probe method of most uptime monitors.
#
# Why it is gone: at dash-improve-my-llms 2.9.4 the package walks the
# router itself and adds HEAD wherever GET is allowed, INCLUDING Dash's
# routes and its lifespan-registered catch-all — the one case this
# middleware was left in for. Its own docstring said "DO NOT REMOVE THIS
# WHEN THE PACKAGE FLOOR REACHES 2.7.2", and that was true AT 2.7.2, when
# the package fixed only its own doc routes. It stopped being true at
# 2.9.4, which is why the retirement is gated on the pin (item 1) and not
# on the calendar.
#
# The reason to remove rather than leave harmless: it converts HEAD to GET
# ABOVE the router, so every HEAD looked correct whatever the router did.
# It MASKED the package's fix instead of conflicting with it, and would
# have masked a regression in it just as well.
#
# MEASURED BEFORE REMOVING (1.6.44, dimll 2.9.4, FastAPI lane, in-process):
# 5 paths x 3 UAs — /healthz, /llms.txt, /robots.txt, /sitemap.xml, / with
# browser, crawler and internal-probe UAs — HEAD status == GET status in
# all 15 pairs WITHOUT this middleware, including ``/`` to a browser UA,
# the exact case the old docstring said would 405. The disable was proved
# non-vacuous first: the middleware stack was asserted to contain
# ``HeadAsGetMiddleware`` in the enabled run and not to contain it in the
# disabled one, because a parity result from a run that never removed
# anything would have been worthless. tests/test_head_method.py holds the
# parity so this cannot regress silently.


class AnalyticsMiddleware(BaseHTTPMiddleware):
    """Track every request through the analytics tracker.

    Mirrors the Flask ``before_request`` shim in ``run.py``. Failures are
    silently swallowed — analytics should never block a real response.
    """

    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            client = request.client
            ip = client.host if client else None
            # Headers carry the real client IP/country behind a proxy or CDN;
            # request.client is the last hop (the proxy) in production.
            tracker.track_visit(
                request.url.path,
                request.headers.get("user-agent", ""),
                ip,
                headers=dict(request.headers),
            )
        except Exception:
            pass
        return await call_next(request)


class StaticCacheMiddleware(BaseHTTPMiddleware):
    """Give ``/assets/`` a cache lifetime (1.6.44 item 6g).

    The ASGI half of the Flask ``after_request`` in ``run.py``; the policy
    itself lives in ``lib/static_cache`` so the two lanes cannot drift into
    serving different lifetimes for the same file.
    """

    async def dispatch(self, request: Request, call_next) -> Response:
        response = await call_next(request)
        from lib.static_cache import cache_control_for

        value = cache_control_for(request.url.path)
        if value and response.status_code == 200:
            response.headers["Cache-Control"] = value
        return response


class CrawlerNotFoundMiddleware(BaseHTTPMiddleware):
    """A path with no page behind it answers 404 to a crawler (item 8).

    The ASGI half of the Flask `before_request` in `run.py`; the decision
    itself lives in `lib/soft404` so the two lanes cannot drift into
    answering differently for the same URL.
    """

    def __init__(self, app, dash_app=None):
        super().__init__(app)
        self._dash_app = dash_app

    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            from lib.soft404 import (NOT_FOUND_BODY, NOT_FOUND_TYPE,
                                     should_answer_404)

            if self._dash_app is not None and should_answer_404(
                    self._dash_app, request.url.path,
                    request.headers.get("user-agent", "")):
                from starlette.responses import Response as _Response

                return _Response(NOT_FOUND_BODY, status_code=404,
                                 media_type=NOT_FOUND_TYPE)
        except Exception:
            pass        # never let this turn a served page into an error
        return await call_next(request)


def register_asgi_middleware(app) -> None:
    """Attach all ASGI middleware to ``app.server`` (a FastAPI instance).

    ``HeadAsGetMiddleware`` was removed here at 1.6.44 (item 2) once the
    package answers HEAD at the route level — see the module docstring for
    what was measured before removing it.
    """
    app.server.add_middleware(AnalyticsMiddleware)
    app.server.add_middleware(StaticCacheMiddleware)
    app.server.add_middleware(CrawlerNotFoundMiddleware, dash_app=app)
```


This sits transparently in front of every request, including Dash's internal `_dash-update-component` calls, with no impact on callback latency (analytics writes go through the same backgroundable tracker).

---

### Async callbacks (you can write them now)

Once you're on FastAPI, callbacks can be `async def` and `await` directly. A typical pattern:

```python
import httpx
from dash import Input, Output, callback

@callback(Output("price", "children"), Input("ticker", "value"))
async def fetch_price(ticker):
    async with httpx.AsyncClient(timeout=5) as client:
        r = await client.get(f"https://api.example.com/{ticker}")
    return r.json()["price"]
```

The key discipline: **don't mix sync I/O into async callbacks**. Replace `requests` with `httpx.AsyncClient`, `psycopg2` with `asyncpg`, `time.sleep` with `asyncio.sleep`. A single blocking call inside an `async def` will stall every other request handled by that worker.

If you can't avoid a blocking call, wrap it:

```python
import asyncio

@callback(Output("data", "children"), Input("trigger", "n_clicks"))
async def slow_path(_):
    return await asyncio.to_thread(legacy_sync_fetch)
```

#### See the difference

This widget runs two callbacks side by side: a sync one that sleeps 3 × 500 ms sequentially (~1500 ms wall-clock) and an async one that fans out the same work with `asyncio.gather` (~500 ms). Both work on any backend, but the async path is what scales when concurrent users hit your app.


<!-- component rendered from docs/fastapi-showcase/async_demo.py; source withheld by `:code: false` -->


#### Stress test it

The button below fires N parallel `GET /healthz` requests from your browser and reports wall-clock + percentile latencies. On the FastAPI backend the wall-clock stays flat as N rises (the event loop services them concurrently). On Flask's default sync workers, wall-clock grows roughly linearly with N — that's the practical cost of WSGI's one-request-per-thread model.


<!-- component rendered from docs/fastapi-showcase/stress_test.py; source withheld by `:code: false` -->


---

### Websocket callbacks (Dash 4.2+)

ASGI gives Dash native websocket support. From a callback you can grab the websocket directly via `ctx.websocket` and push updates to the browser without polling.

```python
from dash import Input, Output, callback, ctx

@callback(Output("stream", "children"), Input("start", "n_clicks"))
async def stream(_):
    ws = ctx.websocket
    for i in range(10):
        await ws.send({"i": i})
    return "done"
```

This is gated behind the FastAPI backend — the Flask backend will simply not expose `ctx.websocket`.

---

### MCP server (Dash 4.3+)

When Dash 4.3 ships, the same FastAPI app can expose the dashboard's layout, components, pages, and (whitelisted) callbacks as MCP tools over Streamable HTTP. The wire-up in this boilerplate is best-effort:

```python
# run.py — runs only if the installed Dash supports it
try:
    from dash import mcp_enabled
    HAS_MCP = True
except ImportError:
    HAS_MCP = False

if HAS_MCP and BACKEND == "fastapi" and os.environ.get("DASH_MCP_ENABLED") == "1":
    mcp_enabled(app)
```

Set `DASH_MCP_ENABLED=1` in `.env` once you upgrade to a Dash version that includes MCP, and the server will be reachable at `/mcp` (subject to whatever path Dash settles on for the GA release).

---

### What's missing on FastAPI today

Honesty matters for a docs site:

- **`set_props`** works on FastAPI but if you scale to multiple workers you'll need Redis pub/sub for cross-worker fanout (same caveat as Flask).
- The `dash-improve-my-llms` MCP bridge silently no-ops on Dash 3.x and on Dash 4.1/4.2 — it requires Dash 4.3+ (where `dash.mcp` lands). The HTTP surfaces (`/llms.txt`, `/robots.txt`, `/sitemap.xml`) work on every version.

---

### Deployment

Swap the process manager:

```bash
# Flask (Dockerfile default today)
gunicorn run:server -b 0.0.0.0:8550

# FastAPI
pip install "dash[fastapi]" "uvicorn[standard]"
uvicorn run:server --host 0.0.0.0 --port 8550 \
    --workers 4 --proxy-headers --forwarded-allow-ips='*'
```

The `app.server` attribute returns either a `Flask` or `FastAPI` instance depending on `DASH_BACKEND`, so `run:server` works for both — only the runner changes.

---

### Recap

| Concern | Before | After |
|--|--|--|
| Backend selection | hardcoded Flask | `DASH_BACKEND` env var |
| OpenAPI surface | none | `/docs`, `/redoc`, `/openapi.json` |
| Health check | none | `/healthz` |
| JSON API for tooling | none | `/api/backend`, `/api/pages` |
| Async callbacks | thread-pooled | native `async def` |
| Websocket callbacks | not available | `ctx.websocket` |
| MCP integration | not available | best-effort, gated on `DASH_MCP_ENABLED` |
| LLM routes | `add_llms_routes()` | ported in `lib/asgi_routes.py` (subset) |
| Analytics | `before_request` | Starlette middleware |

Flip the env var, restart, watch the badge change. That's the whole user surface — everything else is a clean import switch under the hood.


---

*Source: /fastapi-showcase*

---

<!-- /getting-started — https://boilerplate.2plot.dev/getting-started/llms.txt -->

# Getting Started

> Learn how to create documentation pages with markdown and Python

---



### Introduction

Welcome to the Dash Documentation Boilerplate! This guide will teach you how to create beautiful, interactive documentation pages using markdown and Python.

The boilerplate uses a **markdown-driven approach** where you write documentation in `.md` files and supplement them with interactive Python components. The framework automatically discovers your documentation and generates pages with routing.

---

### Creating Your First Page

#### Step 1: Create a Folder

Create a new folder in the `docs/` directory for your component or feature:

```bash
mkdir -p docs/my-component
```

#### Step 2: Create a Markdown File

Create a markdown file with **frontmatter metadata** at the top:

```markdown
---
name: My Component
description: A description of my awesome component
endpoint: /components/my-component
package: my-component
icon: mdi:code-tags
---

## My Component

Your documentation content here...
```

##### Frontmatter Fields

- **name** (required): Display name in the navigation
- **description** (required): The page's SERP snippet, not a subtitle. Write a
  complete sentence of 110–155 characters that names the package — Google
  shows it under the result, so "explicit control over the attribution box."
  wastes the one line of copy a searcher reads before deciding to click.
- **endpoint** (required): URL path for the page (e.g., `/components/my-component`)
- **package** (optional): Package name for organization
- **icon** (optional): Material Design icon (from [Iconify](https://icon-sets.iconify.design/mdi/))
- **schema_type** (optional): schema.org `@type` for the page's structured
  data. Defaults to `TechArticle` — every page here documents software, and
  the generic `WebPage` tells Google nothing it did not already know.

#### Step 3: Write Your Content

Use standard markdown syntax for your documentation:

```markdown
## Features

This component provides:

- Feature 1
- Feature 2
- Feature 3

### Usage

Here's how to use it:

\`\`\`python
import dash_mantine_components as dmc

component = dmc.Button("Click me!")
\`\`\`

### Advanced Examples

More detailed examples below...
```

That's it! The page will automatically appear in the navigation when you restart the server.

---

### Using Custom Directives

The boilerplate provides powerful **custom directives** to enhance your documentation:

#### Table of Contents - `.. toc::`

Automatically generate a table of contents from your headings:

```markdown
.. toc::

### Section 1
Content here...

### Section 2
Content here...
```

#### Execute Python Components - `.. exec::`

Embed interactive Dash components from Python modules:

```markdown
.. exec::docs.my-component.example
```

This will import and render the `component` variable from `docs/my-component/example.py`.

**Tip:** Use `:code: false` to hide the code and show only the rendered output:

```markdown
.. exec::docs.my-component.example
    :code: false
```

#### Display Source Code - `.. source::`

Show syntax-highlighted source code:

```markdown
.. source::docs/my-component/example.py
```

**Tip:** Make long code collapsible:

```markdown
.. source::docs/my-component/example.py
    :defaultExpanded: false
    :withExpandedButton: true
```

#### Component Props - `.. kwargs::`

Auto-generate a props documentation table:

```markdown
.. kwargs::dmc.Button
```

---

### Directive Options

The custom directives support **options** that control how content is displayed. Options are specified using a colon syntax after the directive.

#### Common Directive Options

##### Option: code false - Hide Source Code Display

Use with `.. exec::` to show only the rendered component without the code:

```markdown
.. exec::docs.my-component.example
    :code: false
```

**When to use:**
- ✅ You want to show the interactive demo without cluttering the page
- ✅ The code will be shown separately with `.. source::`
- ✅ The example is self-explanatory and code isn't needed inline

**Example:**
```markdown
Here's an interactive demo:

.. exec::docs.data-visualization.basic_chart
    :code: false

Source code:

.. source::docs/data-visualization/basic_chart.py
```

##### Option: defaultExpanded false - Collapse Code by Default

Use with `.. source::` to show code in a collapsed state initially:

```markdown
.. source::docs/my-component/example.py
    :defaultExpanded: false
```

**When to use:**
- ✅ Long source files that might overwhelm the page
- ✅ Optional/advanced code that users can view if interested
- ✅ Multiple code examples on one page

##### Option: withExpandedButton true - Add Expand/Collapse Button

Use with `.. source::` to add an interactive expand/collapse button:

```markdown
.. source::docs/my-component/example.py
    :defaultExpanded: false
    :withExpandedButton: true
```

**When to use:**
- ✅ Combined with `:defaultExpanded: false` for collapsible code
- ✅ Giving users control over viewing source code
- ✅ Keeping documentation clean while making code accessible

#### Combining Options

You can combine multiple options for fine-grained control:

```markdown
### Interactive Demo

The component in action:

.. exec::docs.components.advanced-example
    :code: false

<Click to view source code>

.. source::docs/components/advanced-example.py
    :defaultExpanded: false
    :withExpandedButton: true
```

This pattern creates a clean documentation flow:
1. User sees the interactive demo first
2. User can optionally expand to view the source code
3. Page stays uncluttered for quick scanning

#### Complete Example

Here's a real-world example combining all these options:

```markdown
---
name: My Component
description: An awesome interactive component
endpoint: /components/my-component
---

.. toc::

### Overview

This component provides interactive data filtering...

### Live Demo

Try it out below:

.. exec::docs.components.my-component.demo
    :code: false

Want to see how it works? View the source:

.. source::docs/components/my-component/demo.py
    :defaultExpanded: false
    :withExpandedButton: true

### API Reference

Component props documentation:

.. kwargs::dmc.MyComponent
```

#### Best Practices

**✅ Do:**
- Use `:code: false` when showing code separately with `.. source::`
- Collapse long code files with `:defaultExpanded: false`
- Add expand buttons for optional code viewing
- Keep the user experience in mind - don't overwhelm with code

**❌ Don't:**
- Hide code without providing a way to view it
- Use `:code: false` for simple examples where seeing code helps understanding
- Collapse very short code snippets (< 20 lines)

---

### Creating Interactive Examples

#### Basic Example

Create a Python file in your docs folder (e.g., `docs/my-component/example.py`):

```python
from dash import html, dcc
import dash_mantine_components as dmc

component = dmc.Button(
    "Click Me!",
    id="my-button",
    variant="filled",
    color="blue"
)
```

Then reference it in your markdown:

```markdown
.. exec::docs.my-component.example
```

#### Example with Callbacks

You can create interactive examples with callbacks:

```python
from dash import html, dcc, callback, Input, Output
import dash_mantine_components as dmc

component = html.Div([
    dmc.Button("Click Me!", id="click-button", n_clicks=0),
    html.Div(id="click-output")
])

@callback(
    Output("click-output", "children"),
    Input("click-button", "n_clicks")
)
def update_output(n_clicks):
    return f"Button clicked {n_clicks} times!"
```

---

### Interactive Example

Below is a working example showing a button that updates a graph. You can interact with it right here in the documentation!

The source code for this example is shown below:


```python
# File: docs/example/introduction.py

from dash import dcc, html, Input, Output, callback
import dash_mantine_components as dmc
import pandas as pd
import plotly.express as px

# Register Mantine templates
dmc.add_figure_templates(default="mantine_light")

# Sample data
df1 = pd.DataFrame({
    "Fruit": ["Apples", "Oranges", "Bananas", "Grapes"],
    "Amount": [4, 1, 2, 2]
})

df2 = pd.DataFrame({
    "Fruit": ["Apples", "Oranges", "Bananas", "Grapes"],
    "Amount": [2, 4, 5, 1]
})

component = html.Div([
    html.H1("Graph Update Example", style={'textAlign': 'center', 'marginBottom': '20px'}),
    html.Div(
        [
            dcc.Graph(id='example-graph'),
            html.Button(
                'Update Graph',
                id='update-button',
                n_clicks=0,
                style={'marginTop': '10px', 'padding': '10px 20px'},
            ),
        ],
        id='interactive-section',
    ),
])


@callback(
    Output('example-graph', 'figure'),
    Input('update-button', 'n_clicks'),
    Input("color-scheme-storage", "data"),
)
def update_graph(n_clicks, theme):
    """Update graph based on button clicks and theme."""
    template = "mantine_dark" if theme == "dark" else "mantine_light"

    if n_clicks % 2 == 0:
        fig = px.bar(df1, x="Fruit", y="Amount", title="Fruit Amounts", template=template)
    else:
        fig = px.bar(df2, x="Fruit", y="Amount", title="Updated Fruit Amounts", template=template)
    return fig
```


---

### Documenting a Page for AI Assistants

When users paste your documentation URL into ChatGPT, Claude, or any
MCP-aware client, the request hits `/<page>/llms.txt`. With
**dash-improve-my-llms**, that endpoint serves a module-level string
called `LLMS_DOC` verbatim — no layout walking, no extraction, no surprises:

```python
# pages/my_feature.py
from dash import html, register_page

register_page(__name__, path="/my-feature", name="My Feature")

LLMS_DOC = """\
# My Feature

> One-line tagline that explains what this page is for.

## What this page does

A short narrative description: what controls exist, how they relate to
the data, and what the user is supposed to do here.

## What the user can do

- Type in the search box to narrow by name.
- Switch category to constrain by class.

## What the page does NOT do

This is a demo. No persistence, no edit/create, no per-item detail view.
"""

def layout():
    return html.Div([...])
```

For pages that don't have a module (like the markdown-driven docs in
this boilerplate), pass the prose explicitly:

```python
from dash_improve_my_llms import register_page_metadata

register_page_metadata(
    path="/my-feature",
    name="My Feature",
    description="Search and filter the catalog.",
    llms_doc="""... your prose here ...""",
)
```

`dash-improve-my-llms` will emit a `UserWarning` at startup naming
any page that's missing prose, so you'll know exactly where to fill in.
See the **AI/LLM Integration** page for the full LLMS_DOC pattern and
the rest of the 2.0 toolkit.

---

### File Structure Example

Here's a complete example of a documentation folder structure:

```
docs/
└── my-component/
    ├── my-component.md       # Main documentation
    ├── basic-example.py      # Basic usage example
    ├── advanced-example.py   # Advanced usage example
    └── callbacks-example.py  # Interactive callback example
```

In your markdown file:

```markdown
---
name: My Component
description: An awesome component
endpoint: /components/my-component
icon: mdi:star
---

.. toc::

### Overview
Description of the component...

### Basic Usage
.. exec::docs.my-component.basic-example
.. source::docs/my-component/basic-example.py

### Advanced Usage
.. exec::docs.my-component.advanced-example
.. source::docs/my-component/advanced-example.py

### Interactive Example
.. exec::docs.my-component.callbacks-example
    :code: false

.. source::docs/my-component/callbacks-example.py

### Component Props
.. kwargs::MyComponent
```

---

### Markdown Formatting Tips

#### Code Blocks

Use triple backticks with language specification:

```python
def hello_world():
    print("Hello, World!")
```

```javascript
function helloWorld() {
    console.log("Hello, World!");
}
```

```bash
pip install dash-mantine-components
```

#### Emphasis

- **Bold text** with `**text**`
- *Italic text* with `*text*`
- `Inline code` with backticks

#### Links

- [External link](https://dash.plotly.com/)
- [Internal link](/getting-started)

#### Lists

Unordered:
- Item 1
- Item 2
  - Nested item

Ordered:
1. First item
2. Second item
3. Third item

#### Blockquotes

> This is a blockquote
> It can span multiple lines

#### Tables

| Feature | Support |
|---------|---------|
| Markdown | ✅ |
| Python | ✅ |
| Callbacks | ✅ |

---

### Best Practices

#### 1. Use Descriptive Names

Choose clear, descriptive names for your pages and components:

✅ Good: `name: Button Component`
❌ Bad: `name: Comp1`

#### 2. Organize by Feature

Group related documentation together:

```
docs/
├── components/
│   ├── button/
│   └── input/
├── layouts/
│   ├── grid/
│   └── stack/
└── examples/
    ├── dashboard/
    └── form/
```

#### 3. Add Table of Contents

Always include `.. toc::` at the top of long documentation pages.

#### 4. Show Code and Results

Use `.. exec::` and `.. source::` together to show both the code and the result. Consider using `:code: false` with exec and `:defaultExpanded: false` with source for cleaner presentation.

#### 5. Test Your Examples

Make sure your interactive examples actually work before documenting them!

---

### Next Steps

Now that you know the basics, check out these example pages:

- **Custom Directives** - See all directives in action with examples
- **Interactive Components** - Advanced callback patterns and state management
- **Data Visualization** - Plotly charts and graphs integration
- **AI Integration** - Using the dash-improve-my-llms features

---

### Need Help?

- **Documentation**: Check the [README.md](https://github.com/pip-install-python/Dash-Documentation-Boilerplate/blob/main/README.md)
- **Issues**: Report bugs on [GitHub Issues](https://github.com/pip-install-python/Dash-Documentation-Boilerplate/issues)
- **Community**: Join the [Dash Community Forum](https://community.plotly.com/)

---

Happy documenting! 🚀


---

*Source: /getting-started*

---

<!-- /network-standard — https://boilerplate.2plot.dev/network-standard/llms.txt -->

# Network Standard

> The site-identity rules, the internal-traffic analytics contract and the CI baseline every 2plot satellite copies from this template

---



### Introduction

The [Multi-Site Networks](/networks) page covers how a family of documentation
sites is wired together so agents can see it as one ecosystem. This page
covers what each site in that family has to *do* to hold up its end.

There are three obligations, and every one of them exists because the failure
it prevents is **silent** — nothing errors, nothing 500s, no dashboard turns
red, and the damage accumulates for months:

| Obligation | The silent failure it prevents |
|---|---|
| **Explicit site identity** | Your site publishes a framework default as its name |
| **The internal-traffic contract** | Your monitoring shows up in someone else's traffic charts |
| **The CI baseline** | A stale artifact serves happily and nobody can tell from outside |

Everything below is already wired up in this template. Fork it and you inherit
it. What you change is listed under each section.

---

### 1. Explicit site identity

A site states what it is, in the same words, on every surface a reader or an
agent can reach.

That sounds like a style rule. It is not — it is a defence against a specific
bug. `dash-improve-my-llms` resolves the `/llms.txt` H1 and the llms viewer's
brand chip through `resolve_site_title(home_page_name, app.title)`. Give it
nothing and it publishes whatever it finds, which on a stock Dash app is the
constructor's default title: the bare string **`Dash`**.

That is what this very host served in production until 2.3.4 landed. Every
agent that fetched `boilerplate.2plot.dev/llms.txt` cold was told the site was
called "Dash". The page rendered perfectly the whole time.

2.3.4 fixed half of it — `resolve_site_title` now *skips* generic candidates
(`Home`, `Homepage`, `Index`, `Main`, `Dash`) and falls through to the next
one — but a package cannot invent a name for you. The other half is stating it.

#### The four surfaces

One constant, `lib/constants.SITE_BRAND`, reaches all of them:

```python
# lib/constants.py
SITE_BRAND = "Dash Documentation Boilerplate — the 2plot network's template"
APP_TITLE = SITE_BRAND
```

```python
# run.py
app = Dash(__name__, title=APP_TITLE, ...)      # <title>, and the fallback

register_page_metadata(                          # the /llms.txt H1 and the
    path="/",                                    # llms viewer's brand chip
    name=SITE_BRAND,
    description=SITE_DESCRIPTION,
)
```

```markdown
<!-- pages/home.md, first line -->
# Dash Documentation Boilerplate — the 2plot network's template
```

...plus `templates/index.html`, where the same string fills `og:site_name`,
`og:title`, `twitter:title`, the schema.org `SoftwareApplication.name` and the
`<noscript>` heading.

#### The naming rules

- The **brand** says what the site *is*. `"dash-leaflet2 — Leaflet 2 maps for
  Dash"`, not `"Documentation"`.
- The **package name** goes in the description, never in the brand.
- **"Pip Install Python" is the byline** — who made it. It is never the site
  name; twenty satellites cannot all be called the same thing.

#### Version claims are derived, never written

The same principle covers the *numbers* a site publishes about itself. Prose
that states a package version writes a placeholder — the distribution name
exactly as it appears on PyPI — and the markdown loaders substitute the
version of whatever is actually installed:

```markdown
Powered by [dash-improve-my-llms](https://pypi.org/project/dash-improve-my-llms/) **{{VERSION:dash-improve-my-llms}}**
```

The placeholder works for **any installed distribution**, so a satellite makes
the same claim about the package it documents — `dash-mui-charts
**{{VERSION:dash-mui-charts}}**` — and a `pip install --upgrade` plus redeploy
updates the browser page, the copy button, `/llms.txt` and every
`/<page>/llms.txt` together. No prose edit, no drift between the docs and the
crawler surfaces. This host served "Powered by 2.3.4" on `/llms.txt` for
months while 2.5.1 ran the site; that class of lie is what the placeholder
retires.

A placeholder naming a distribution that is not installed fails the boot (and
therefore CI) rather than leaking into served prose. Version *floors* in
install instructions (`pip install "pkg>=2.5.1"`) stay literal — they are
requirements, not claims about what is running.

#### What a fork changes

`SITE_BRAND` and `SITE_DESCRIPTION` in `lib/constants.py`, the first line of
`pages/home.md`, the meta tags in `templates/index.html`, and
`EXPECTED_BRAND` in `tests/test_site_identity.py`. That last one is
deliberate: renaming a site should require editing the test that says what it
is called. Anywhere the fork's docs state the documented package's current
version, write `{{VERSION:<its-pypi-name>}}` instead of a number.

---

### 2. The internal-traffic contract

The reporting contract is private; every kit carries its half. The rule is
one sentence:

> Any request whose `User-Agent` contains `2plot-internal` is network
> machinery talking to itself, and is counted **nowhere**.

Health sweeps, CI smoke batteries, the four-times-daily heartbeat, and every
server-to-server call one host makes to another. None of it is a visitor.
None of it is a bot. It simply does not appear.

The contract has two halves and both are required. A host that honours only
the inbound half keeps its own numbers clean while polluting everyone else's.

#### Inbound: drop at write time, before classification

```python
# lib/analytics_tracker.py — the first thing track_visit does
from lib.constants import INTERNAL_UA_TOKEN

if INTERNAL_UA_TOKEN in (user_agent or "").lower():
    return
```

Two details are load-bearing:

- **Before `detect_device_type`, not after.** A health sweep and a CI battery
  both look like bots. Classified first, they land in `bot_hits` and get
  reported to the hub as crawler interest in your documentation.
- **At write time, not read time.** Filtering on the way out leaves rows in the
  ledger that a human reading a local analytics view has to know to discount.

`/healthz` is dropped in the same place, for the same reason: the hub sweeps
it hourly and the hosting platform's own probe hits it far more often.

#### Outbound: every call carries the token

```python
# lib/constants.py
INTERNAL_UA_TOKEN = "2plot-internal"
INTERNAL_UA = "2plot-internal/1.0 (+https://2plot.ai/docs/satellite-analytics)"

def internal_ua(caller: str = "") -> str:
    return f"{INTERNAL_UA} {caller}" if caller.strip() else INTERNAL_UA
```

Everything this app sends to another network host now uses it:

| Caller | Was arriving as | Volume |
|---|---|---|
| `lib/ad_client.py` → 2plot.dev | `python-requests/2.x` | **one per docs page view** |
| `lib/satellite_reporter.py` → 2plot.ai (rollup) | `python-requests/2.x` | one per report interval (default hourly; the fleet runs 15 min) |
| `lib/satellite_reporter.py` → 2plot.ai (presence beacon) | `python-requests/2.x` | one per minute |
| `lib/hub_client.py` → 2plot.dev | `python-requests/2.x` | per agent-key verify |
| `scripts/network_smoke.py`, `smoke_live.py`, `audit_links.py` | crawler / browser UAs | every deploy |

The ad client is the one that mattered. It fetches a campaign server-to-server
on **every single page view**, and `python-requests` matches 2plot.dev's bot
patterns — so this satellite's readers were being charted as bots on the hub.

The battery scripts keep their Googlebot and Chrome tokens *and* append the
internal one: the target still exercises exactly the code path under test, it
just knows the caller is machinery.

The click beacon is the deliberate exception. It is fired by the reader's own
browser, which cannot set a `User-Agent` — and a click is a real person.

#### What a fork changes

Nothing. The token string must stay byte-identical across the network, and
`lib/constants.py`, `lib/analytics_tracker.py`, `lib/ad_client.py`,
`lib/hub_client.py` and `lib/satellite_reporter.py` are copied verbatim.

---

### 3. The CI baseline

pip metadata is invisible from outside a running host. A satellite serving a
two-year-old artifact looks exactly like one serving today's. So the baseline
is built around proving, from the inside and then from the outside, what is
actually deployed.

#### The workflow

`.github/workflows/ci.yml` is a template file. Its shape is the standard:

- `permissions: contents: read` — nothing here publishes, comments or tags.
- `concurrency` with `cancel-in-progress` — a rapid second push does not race
  the first.
- `timeout-minutes` on **every** job. The GitHub default is six hours, which
  is how one hung `curl` burns a day of runner minutes with nobody watching.
- `docker/setup-buildx-action` with `cache-from/to: type=gha` — the real image,
  built the way the platform builds it, without paying full price every run.
- **Version fingerprints asserted inside the image**, not in the runner:

```python
assert parts(version('dash-improve-my-llms')) >= (2, 3, 4)   # resolve_site_title
assert parts(version('gunicorn'))[:2] >= (23, 0)             # CVE-2024-6827, -1135
```

- **A secretless pytest suite.** No `CLERK_*`, no `CROSS_APP_WEBHOOK_SECRET`,
  no `SESSION_SECRET`. The zero-secret boot is itself the first invariant:
  fail-closed behaviour is only provable when nothing is configured.
  `tests/conftest.py` pins each secret to `""` *before* importing `run.py`,
  because `load_dotenv()` runs during that import and a developer's local
  `.env` would otherwise flip the app into a configured posture.
- `.github/dependabot.yml` with a **`dash-network` group**, so a package
  release lands as one reviewable pull request per repo instead of five.
- An **advisory** `pip-audit`. Worth knowing the day a CVE lands; not worth
  anybody's broken build at 2am.

#### The battery

`scripts/network_smoke.py` runs in three seats with the same check names, so a
failure reads identically wherever it happens:

```bash
python scripts/network_smoke.py --base-url http://localhost:8550    # CI container
python scripts/network_smoke.py --base-url https://your.2plot.dev   # after deploy
pytest tests/test_network_smoke.py                                  # in-process
```

It proves identity (`llms_txt_identity` — the H1 is the brand, verbatim), the
artifact (`robots_artifact_fingerprint` — the crawler split that only a
current build emits), that no owner-only surface leaks, that a crawler gets
prose rather than the JavaScript stub, and that agents and browsers get
different content types with a `Vary: Accept` that keeps a CDN from mixing
them.

The in-process seat is not redundant. A script that only ever runs in CI and
after a deploy is exactly the code that rots: a typo turns a check into a
silent pass and the battery keeps reporting green over a broken host.
`tests/test_network_smoke.py` therefore also breaks a check on purpose and
requires it to be reported as a failure.

#### What a fork changes

The block marked `per-site` at the top of `scripts/network_smoke.py` — the
expected H1, the container port, the paths that must 404 — and the matrix and
image name in `ci.yml`. Everything else is the standard; if a check there is
wrong, it is wrong on twenty hosts.

---

### The files a satellite copies verbatim

Each of these is copied as-is; the only edits are the per-site values called
out above.

> `scripts/network_smoke.py` is deliberately **not** inlined on this page. It
> carries the literal stub marker it searches for, and inlining it would put
> that string into this page's prose — where `tests/test_pages.py` reads it as
> this page serving the stub. Read it in the repository instead. That is not a
> footnote: "the document about the check contains the string the check looks
> for" is the same class of bug as the viewer-chrome detection on the
> [Multi-Site Networks](/networks) page, and it is why both checks are worth
> writing narrowly.

| File | Role |
|---|---|
| `.github/workflows/ci.yml` | the CI baseline |
| `.github/workflows/cd.yml` | deploy, then run both batteries against the live host |
| `.github/dependabot.yml` | the `dash-network` update group |
| `lib/constants.py` | `SITE_BRAND`, `INTERNAL_UA`, `internal_ua()` (brand values are per-site) |
| `lib/analytics_tracker.py` | the write-time internal-traffic drop |
| `lib/network_directory.py` | the cross-host peer list |
| `tests/conftest.py` | the secretless boot |
| `tests/test_internal_traffic.py` | proves the exclusion reaches `human_hits` / `bot_hits` |
| `tests/test_site_identity.py` | pins every identity surface (change `EXPECTED_BRAND`) |
| `tests/test_network_smoke.py` | runs the battery in-process |

---

### Checklist for a new satellite

1. Set `SITE_BRAND` and `SITE_DESCRIPTION` in `lib/constants.py`. Brand says
   what the site is; package name in the description; byline is not the name.
2. Set `EXPECTED_BRAND` in `tests/test_site_identity.py` to match.
3. Make the first line of `pages/home.md` `# <brand>`.
4. Update the meta tags and JSON-LD in `templates/index.html`.
5. Set `SITE_H1` and `HIDDEN_DOC_PATHS` in `scripts/network_smoke.py`.
6. Set `APP_BASE_URL` in the deployment environment — see
   [Multi-Site Networks](/networks) for why leaving it at the template's
   default deindexes your site.
7. Set `SATELLITE_APP_KEY` to your own directory key, or your traffic rollups
   overwrite another app's rows at the hub.
8. Add your site to `PEERS` in `lib/network_directory.py`, in the same change
   that ships it — never before, because a dead directory entry teaches an
   agent to distrust the whole list.
9. Run `pytest tests -q` with no secrets set. Green means the posture is right.


---

*Source: /network-standard*

---

<!-- /networks — https://boilerplate.2plot.dev/networks/llms.txt -->

# Multi-Site Networks

> Wire a family of separately-hosted documentation sites together so search engines and AI agents can see them as one ecosystem

---



### Introduction

This page is for the case where you run **more than one** app: a hub, a set of
subdomains, some projects on unrelated domains, and dependencies on third-party
documentation. If you run one site on one host, you don't need any of it —
`add_llms_routes(app)` is the whole story, and the [AI/LLM Integration](/examples/ai-integration)
page covers that.

Everything below is already wired up in this boilerplate. Fork it and you
inherit the wiring; what you have to change is *one value*, and the last
section explains why getting it wrong is the most expensive mistake available
in this template.

---

### The problem: every SEO surface is scoped to one origin

Each thing the package serves stops at the host boundary, by design:

| Surface | Scope |
|---|---|
| `sitemap.xml` | Its own origin only — that's the sitemap spec, not a limitation to work around |
| Internal prose links | Within the app |
| `robots.txt` | One host |

So a network of a dozen hosts is a dozen disconnected islands. Nothing in any
of them says the other eleven exist.

The consequence differs by audience, and the second one is the expensive one:

- **Search engines** treat cross-host links as weak signals, but they do have
  crawlers that follow them. Given enough time and inbound links, Google finds
  your subdomains.
- **Agents don't crawl.** A model answering a question about your ecosystem
  fetches one or two URLs and reasons from what comes back. If it lands on
  `leaflet.2plot.dev`, it sees one library. There is no second request, no link
  traversal, no accumulation over time. Whatever that one response says *is*
  the ecosystem, for that answer.

Fixing it needs an explicit, machine-readable statement of the relationships,
served from **every** host.

---

### The three tiers

`register_network()` takes three lists, and keeping them distinct is the point
— collapsing them would either overclaim ownership of other people's sites or
bury your own network in an undifferentiated pile of links.

#### `peers` — same network, same operator

The hosts you own that are genuinely one product family. Peers get
`<link rel="related">` tags in `<head>`, a `## Network` section in `/llms.txt`,
and **followed** links in the prerendered body. This is the tier that builds
the cross-host graph you actually own, so put real relationships here and
nothing else.

#### `affiliated` — yours, on its own domain

Projects you built on domains that aren't part of the primary brand. Listed
under `## Related projects` with followed links. Separating these from `peers`
is what lets an agent answer *"what is the 2plot network?"* without sweeping in
every unrelated domain you happen to own — while still being able to answer
*"what else has this author built?"*.

#### `external` — third-party references

Documentation you depend on or point readers at. **Not yours.** Listed under
`## External references` and emitted with `rel="nofollow noopener"` — they are
references, not endorsements, and shouldn't pass ranking signal. Point at the
machine-readable URL when one exists:

```python
external=[
    {"name": "Dash Mantine Components",
     "url": "https://www.dash-mantine-components.com",
     "description": "The UI component layer these docs are built with.",
     "llms_txt": "https://www.dash-mantine-components.com/llms.txt"},
]
```

---

### One definition, imported everywhere

The peer list lives in `lib/network_directory.py` — **one file, copied verbatim
into every satellite**, not twelve hand-edited variants. A directory that
disagrees with itself across hosts is worse than no directory: an agent that
gets two different answers about the same network learns to trust neither.


```python
# File: lib/network_directory.py

"""Cross-host directory for the 2plot network — one definition, every satellite.

Why this file exists
--------------------
Search engines follow links between hosts weakly; agents don't follow them at
all. A model answering "what does this ecosystem provide?" fetches one or two
URLs and reasons from what came back. Landing on ``leaflet.2plot.dev`` it sees
one library, with nothing in the markup saying the other eleven hosts exist.
``sitemap.xml`` cannot fix that — a sitemap is scoped to its own origin by
design — so ``dash-improve-my-llms`` 2.1 emits an explicit machine-readable
directory instead: ``<link rel="related">`` tags in ``<head>``, a ``## Network``
section in ``/llms.txt``, and followed links in the prerendered body.

Keep the definition **here**, in the template, and import it. Twelve
hand-maintained copies of the same peer list will drift, and a directory that
disagrees with itself across hosts is worse than no directory at all.

Three tiers, and the distinction is load-bearing:

``PEERS``
    Same network, same operator. These build the cross-host graph you own.
``AFFILIATED``
    Yours, on unrelated domains. Findable when asked "what else did you
    build?" without being swept into "what is the 2plot network?".
``EXTERNAL``
    Third-party docs you reference but don't own. Emitted ``rel="nofollow"``
    — references, not endorsements.

Usage in a satellite's ``run.py``, before ``add_llms_routes(app)``::

    from lib.constants import BASE_URL
    from lib import network_directory

    app._base_url = BASE_URL
    network_directory.apply(BASE_URL)
"""

from __future__ import annotations

from typing import Any, Dict, List

# Only list hosts that are actually live. A directory entry pointing at a
# subdomain with no site is a dead link an agent will follow once and then
# distrust the rest of the list for. The full docs fleet went live on paid
# hosting 2026-08-19/20 — muicharts, flexlayout and llms joined in that
# window (the drift sweep of 2026-08-20 found seven different versions of
# this list across nine repos; this copy is the canonical one and the fleet
# syncs FROM here, verbatim). excalidraw.2plot.dev and modelviewer.2plot.dev
# were deliberately absent until they deployed; both went live in the gate
# wave (2026-08-21/22, verified via /healthz build identity) and joined in
# 1.6.5. The fleet re-copy carries them to every satellite's directory.
PEERS: List[Dict[str, str]] = [
    {
        "name": "2plot.ai",
        "url": "https://2plot.ai",
        "description": "Network hub and account origin.",
    },
    {
        "name": "2plot.dev",
        "url": "https://2plot.dev",
        "description": "Package index for every open-source component in the network.",
    },
    {
        "name": "Documentation boilerplate",
        "url": "https://boilerplate.2plot.dev",
        "description": "The markdown-driven documentation template every satellite site is built from.",
    },
    {
        "name": "dash-leaflet2",
        "url": "https://leaflet.2plot.dev",
        "description": "Leaflet 2 maps as Dash components.",
    },
    {
        "name": "dash-mui-scheduler",
        "url": "https://muischeduler.2plot.dev",
        "description": "MUI X Scheduler — calendars and event scheduling for Dash.",
    },
    {
        "name": "dash-mui-charts",
        "url": "https://muicharts.2plot.dev",
        "description": "MUI X charts, tree views and time pickers for Dash.",
    },
    {
        "name": "flexlayout-dash",
        "url": "https://flexlayout.2plot.dev",
        "description": "IDE-style dockable, resizable and floatable window panels.",
    },
    {
        "name": "dash-improve-my-llms",
        "url": "https://llms.2plot.dev",
        "description": "The AI/LLM and SEO package every site in this network is built on.",
    },
    {
        "name": "dash-flows",
        "url": "https://flows.2plot.dev",
        "description": "Node-graph editors built on React Flow.",
    },
    {
        "name": "dash-pannellum",
        "url": "https://pannellum.2plot.dev",
        "description": "360° panorama and virtual-tour viewer.",
    },
    {
        "name": "dash-emoji-mart",
        "url": "https://emojimart.2plot.dev",
        "description": "Emoji picker component.",
    },
    {
        "name": "dash-email",
        "url": "https://email.2plot.dev",
        "description": "Email composition and delivery components.",
    },
    {
        "name": "dash-model-viewer",
        "url": "https://modelviewer.2plot.dev",
        "description": "3D model viewer with AR support, built on Google's model-viewer.",
    },
    {
        "name": "dash-excalidraw",
        "url": "https://excalidraw.2plot.dev",
        "description": "Excalidraw virtual whiteboard and sketching canvas.",
    },
]

# pip-install-python.com is deliberately NOT here: the domain is retired
# network-wide (the fleet's retire-pip-install-python-domain sweep), and
# leaflet's test_social_card pins its absence. A directory that keeps
# pointing agents at a retired origin re-teaches them the identity the
# network spent a release unlearning.
AFFILIATED: List[Dict[str, str]] = [
    {
        "name": "Pirate's Bargain",
        "url": "https://piratesbargain.com",
        "description": "Deal aggregator built on the same Dash stack.",
    },
    {
        "name": "ai-agent.buzz",
        "url": "https://ai-agent.buzz",
        "description": "Agent tooling directory.",
    },
    {
        "name": "2plot.media",
        "url": "https://2plot.media",
        "description": "Media and streaming, on the same Dash stack.",
    },
]

# The top bar's *Other Apps* menu lists the PRIMARY applications only
# (owner, 2026-08-30): the hub, the catalogue and the affiliated sites —
# never the fleet's docs subdomains, which 2plot.dev's catalogue already
# lists. A set of URLs, not a hand-typed menu, so the registry stays the
# source and the menu==registry test asserts this filtered set.
PRIMARY = frozenset({
    "https://2plot.ai",
    "https://2plot.dev",
    "https://2plot.media",
    "https://piratesbargain.com",
    "https://ai-agent.buzz",
})

EXTERNAL: List[Dict[str, Any]] = [
    {
        "name": "Dash Mantine Components",
        "url": "https://www.dash-mantine-components.com",
        "description": "The UI component layer these docs are built with.",
        "llms_txt": "https://www.dash-mantine-components.com/llms.txt",
    },
    {
        "name": "Plotly Dash documentation",
        "url": "https://dash.plotly.com",
        "description": "Upstream framework documentation.",
    },
]

NETWORK_NAME = "The 2plot network"
NETWORK_DESCRIPTION = (
    "Open-source Dash component libraries by Pip Install Python. Each component "
    "has its own documentation site and its own llms.txt; 2plot.dev indexes all "
    "of them, and 2plot.ai is the hub."
)
HUB_URL = "https://2plot.dev"

# The mark drawn in the header of the rendered llms.txt view: "2" + morse
# encoding of "plot" + "ai", as columns of dots and dashes.
#
# No period glyph between the halves — the morse block already separates them,
# and a literal "." next to it reads as punctuation dropped into a graphic.
# The renderer turns a suffix ending in "i" into an upward flourish, so "ai"
# draws as "a" plus that mark; `label` carries the real domain for screen
# readers and the SVG <title>, which is the only place the dot belongs.
#
# Defined here rather than per-app because this module is copied verbatim into
# every satellite — that is what keeps one mark across the network instead of
# twelve slightly different ones.
WORDMARK = {
    "morse": "plot",
    "prefix": "2",
    "suffix": "ai",
    "label": "2plot.ai",
}


def peers_for(app_url: str) -> List[Dict[str, str]]:
    """`PEERS` with this app removed.

    A site listing itself as its own peer reads as generated rather than
    curated, and it wastes a slot in a list an agent may only skim.
    """
    own = app_url.rstrip("/")
    return [p for p in PEERS if p["url"].rstrip("/") != own]


# Menu icons, keyed by URL and kept OUT of the entries above: the package's
# `register_network` forwards every entry key to `register_network_site`
# verbatim, and an unknown key is a TypeError at boot (measured 1.6.38).
ICONS: Dict[str, str] = {
    "https://2plot.ai": "simple-icons:plotly",
    "https://2plot.dev": "solar:box-bold-duotone",
    "https://boilerplate.2plot.dev": "streamline-pixel:content-files-favorite-book",
    "https://leaflet.2plot.dev": "mdi:map-outline",
    "https://muischeduler.2plot.dev": "mdi:calendar-clock",
    "https://muicharts.2plot.dev": "mdi:chart-areaspline",
    "https://flexlayout.2plot.dev": "mdi:view-dashboard-outline",
    "https://llms.2plot.dev": "mdi:robot-outline",
    "https://flows.2plot.dev": "mdi:graph-outline",
    "https://pannellum.2plot.dev": "mdi:panorama-variant-outline",
    "https://emojimart.2plot.dev": "mdi:emoticon-outline",
    "https://email.2plot.dev": "mdi:email-outline",
    "https://modelviewer.2plot.dev": "mdi:cube-outline",
    "https://excalidraw.2plot.dev": "mdi:draw",
    "https://piratesbargain.com": "mdi:pirate",
    "https://ai-agent.buzz": "game-icons:beehive",
    "https://2plot.media": "mdi:movie-open-play-outline",
    "https://www.dash-mantine-components.com": "mdi:web",
    "https://dash.plotly.com": "mdi:web",
}


def other_apps_for(app_url: str) -> List[Dict[str, str]]:
    """The top bar's *Other Apps* menu (1.6.38): the PRIMARY entries of
    PEERS + AFFILIATED, in registry order, this app removed, each labelled
    by its DOMAIN — short enough for a menu, and the one name every host
    agrees on. Never hand-typed twice again: the survey found 2plot.dev
    listed twice on four hosts and a retired domain on one. `icon` comes
    from ICONS below (default `mdi:web`)."""
    own = app_url.rstrip("/")
    out = []
    for entry in PEERS + AFFILIATED:
        url = entry["url"].rstrip("/")
        if url == own or url not in PRIMARY:
            continue
        domain = url.split("://", 1)[-1].split("/", 1)[0]
        out.append({"label": domain, "url": entry["url"],
                    "icon": ICONS.get(url, "mdi:web"), "name": entry["name"]})
    return out


def apply(app_url: str) -> None:
    """Publish the directory for the app served at ``app_url``.

    Degrades rather than fails on older releases of the package. A satellite
    pinned behind this file should still boot: losing the directory, or losing
    the wordmark, is a degradation — refusing to start is not.

    That matters during a staged rollout, when this module reaches satellites
    before the new package does. ``register_network`` arrived in 2.1 and its
    ``wordmark`` argument in 2.2, and Python raises ``TypeError`` on an unknown
    keyword, so the argument is only passed when the installed signature
    actually accepts it.
    """
    try:
        from dash_improve_my_llms import register_network
    except ImportError:  # pragma: no cover - only on <2.1
        import warnings

        warnings.warn(
            "dash-improve-my-llms is older than 2.1, so the cross-host network "
            "directory will not be published. Upgrade to publish it.",
            RuntimeWarning,
            stacklevel=2,
        )
        return

    import inspect

    extra: Dict[str, Any] = {}
    if "wordmark" in inspect.signature(register_network).parameters:
        extra["wordmark"] = WORDMARK

    register_network(
        name=NETWORK_NAME,
        description=NETWORK_DESCRIPTION,
        hub_url=HUB_URL,
        peers=peers_for(app_url),
        affiliated=AFFILIATED,
        external=EXTERNAL,
        **extra,
    )
```


Two details in there that are easy to get wrong:

1. **`peers_for()` filters the app's own URL out.** A site listing itself as
   its own peer reads as generated rather than curated, and burns a slot in a
   list a reader may only skim.
2. **`apply()` degrades instead of failing** when the installed
   `dash-improve-my-llms` predates 2.1. Losing the directory is a degradation;
   refusing to boot over it is not proportionate.

Only list hosts that are **actually live**. An entry pointing at a subdomain
with no site is a dead link an agent follows once, and then discounts the rest
of your list. Add a subdomain in the same change that ships it.

---

### The wordmark

The same module carries the network's mark, drawn in the header of the
rendered `llms.txt` view:

```python
WORDMARK = {
    "morse": "plot",
    "prefix": "2",
    "suffix": "ai",
    "label": "2plot.ai",
}
```

`"2"` + the morse encoding of `plot` drawn as columns of dots and dashes +
`"ai"`. There is deliberately **no period glyph**: the morse block already
separates the halves, and a literal `.` beside it reads as punctuation dropped
into a graphic. The renderer turns a suffix ending in `i` into an upward
flourish, so `"ai"` draws as `a` plus that mark, and `label` carries the real
domain for screen readers and the SVG `<title>` — which is the one place the
dot belongs.

It renders as self-contained inline SVG: no external fonts, no image requests,
no script, because it lands in a documentation page that has to work behind any
CSP. Like the peer list, it lives in this module rather than per-app, which is
what keeps one mark across the network instead of twelve near-identical ones.

### The bulletin

`configure_bulletin(url=...)` points an app at a hub-published JSON document of
tips and announcements, rendered in the same header — so a twenty-site network
says "here is what changed" once instead of in twenty repositories.

This boilerplate ships it **commented out** in `run.py`, and a satellite should
too until the hub actually serves the endpoint. It is opt-in by design: with no
call, the package makes no outbound requests at all. When it is enabled, the
fetch happens on a daemon thread behind a TTL cache, so a request never blocks
on it and a dead endpoint degrades to "no bulletin" rather than a 500.

---

### Wiring it into a satellite

In `run.py`, before `add_llms_routes(app)`:

```python
from lib.constants import BASE_URL, require_owned_base_url
from lib import network_directory

require_owned_base_url()
app._base_url = BASE_URL
network_directory.apply(BASE_URL)

add_llms_routes(app, LLMSConfig(warn_missing_llms_doc=True))
```

Order matters: the package reads the registered directory when it builds
`/llms.txt` and the prerendered HTML, so registration has to happen first.

Leave `warn_missing_llms_doc=True` on. It names every page with no prose, which
is exactly the list of pages that serve a stub body to crawlers. Turning it off
doesn't fix anything — it hides the to-do list. If a page genuinely shouldn't be
indexed, `mark_hidden()` it; hidden pages are excluded from the warning by
design.

---

### The one value you must change

`BASE_URL` drives `<link rel="canonical">` on every page, the absolute URLs in
`sitemap.xml`, and the "this app" entry in `/llms.txt`.

A satellite forked from this template that never changes it emits:

```html
<link rel="canonical" href="https://boilerplate.2plot.dev/some-page">
```

…on every one of its own pages. That tells Google the entire satellite is a
duplicate of the boilerplate and asks for it to be dropped from the index.
Traffic disappears, and **nothing in the app looks broken** — which is why this
is the highest-consequence footgun in the template and why it gets a guard
rather than a comment.

So `lib/constants.py` reads the origin from the environment, and
`require_owned_base_url()` refuses to boot in production if you left it at the
default:

```python
DEFAULT_BASE_URL = "https://boilerplate.2plot.dev"
BASE_URL = os.environ.get("APP_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
```

Set `APP_BASE_URL` in your host's environment (Render → Environment →
`APP_BASE_URL=https://yoursite.2plot.dev`) and change `DEFAULT_BASE_URL` to
your own domain while you're forking.

The guard also rejects platform-generated hostnames — `*.onrender.com`,
`*.herokuapp.com`, `*.fly.dev`. Those keep resolving after you attach a custom
domain, so canonicals pointing at them split link equity across two hosts for
as long as nobody notices. This is not hypothetical: one satellite in this
network shipped with `dash-pannellum.onrender.com` as its canonical host and
spent months telling search engines its `2plot.dev` URLs were the duplicates.

---

### One URL, two audiences

From 2.2.0 on, `/<page>/llms.txt` content-negotiates. Agents, crawlers, curl
and link unfurlers get the Markdown byte for byte; a browser gets it rendered
behind the header described above. `?raw=1` forces Markdown, `?format=html`
forces the view, and both variants send `Vary: Accept` so a CDN cannot hand
cached HTML to the next agent.

The document also opens with a navigation block — the site index, the network
index one level up the hub chain, and the sitemap. Before that, a page's
`llms.txt` was a dead end: it described one page and gave an agent nothing to
follow, which matters because an agent fetches a URL rather than crawling from
one.

Nothing to configure. But two things are worth checking on a satellite, because
both fail silently:

- **The viewer's chrome must never reach a plain fetch.** If it does, every
  agent in the network pays tokens for decoration and it appears in no
  dashboard.
- **Any route you add must not shadow `/<page>/llms.txt`.** Mount custom routes
  *before* `add_llms_routes`, as `lib/asgi_routes.py` does. A custom handler on
  that path swallows both surfaces at once.

### Verifying a deployment

Six checks worth running after every deploy — all of these failures are silent
and expensive:

```bash
APP=https://yoursite.2plot.dev

# 1. Canonical must point at THIS host.
curl -s $APP/ | grep -o 'rel="canonical" href="[^"]*"'

# 2. No page may serve the JavaScript stub.
curl -s -A "Googlebot/2.1" $APP/ | grep -c "requires JavaScript"   # expect 0

# 3. The directory is published.
curl -s $APP/llms.txt | grep -A3 "## Network"

# 4. Every peer llms.txt in the directory resolves.
curl -s $APP/llms.txt | grep -oE 'https://[^ ]+/llms\.txt' | sort -u \
  | while read -r u; do printf "%s %s\n" "$(curl -s -o /dev/null -w '%{http_code}' "$u")" "$u"; done

# 5. Agents get Markdown; the viewer's chrome must not leak into it.
curl -sI $APP/networks/llms.txt | grep -i 'content-type\|vary'   # text/markdown, Vary: Accept
curl -s $APP/networks/llms.txt | grep -c 'dv-banner'             # expect 0

# 6. Browsers get the rendered view.
curl -s -H 'Accept: text/html' $APP/networks/llms.txt | grep -c 'mk-wordmark'   # expect 1
```

Checks 4 and 5 are the ones worth automating: a directory of dead links and
chrome leaking to agents both degrade quietly, and nothing else will tell you.
This repo runs all six against the live deployment via
[`scripts/smoke_live.py`](https://github.com/pip-install-python/Dash-Documentation-Boilerplate/blob/main/scripts/smoke_live.py)
in `.github/workflows/cd.yml` after every release.


---

*Source: /networks*

---

<!-- /privacy — https://boilerplate.2plot.dev/privacy/llms.txt -->

# Privacy

> What Dash Documentation Boilerplate stores about a visit, what it does not store, and where the numbers go — described from the code that does it.

This page describes what the code in this repository actually does. Each
claim below corresponds to something readable in
`lib/analytics_tracker.py`, and the test suite holds the two together.

## What is stored about a visit

Every request that is not a bot or network machinery records one row:

- the **time** of the request;
- the **path** requested;
- a **device type** (desktop, mobile, tablet, bot);
- the **User-Agent** string your browser or client sent;
- a **visitor key** — a keyed one-way hash of your network address and
  User-Agent, truncated, used to tell one visitor from another within the
  retention window;
- a **location**, if and only if the network edge in front of this site sent
  one (see below).

Crawler rows additionally carry the vendor identity the classifier
determined — which bot it was, and whether it verified.

## What is NOT stored

- **Your IP address.** It is read from the request so the site can tell one
  visitor from another and so the edge's country header can be matched to
  it, and then it is reduced to the visitor key and discarded. It is not
  written to disk. (An operator running their own copy of this template can
  set `ANALYTICS_KEEP_CLIENT_IP=1` to keep it. This site does not.)
- **Anything from a third-party lookup service.** Earlier versions of this
  template sent visitor addresses to a geolocation API. That code was
  removed — not disabled — in release 1.6.44. This app makes no outbound
  request about you.
- **Cookies for analytics.** The visitor key is computed per request from
  what your client already sent. Nothing is stored in your browser to track
  you. Signing in sets a session cookie, which is what keeps you signed in.

## Where location comes from

From the network edge, or not at all. Cloudflare sits in front of this site
and adds headers describing where a request entered its network:
`CF-IPCountry` always, and `CF-IPCity`, `CF-Region`, `CF-IPLatitude` and
`CF-IPLongitude` when the zone is configured to send them. Whatever arrives
is stored; whatever does not is simply absent. There is no lookup and no
fallback to one.

You can see which of those headers this host is actually receiving — they
are listed in the `geo.headers_seen` field of
[https://boilerplate.2plot.dev/healthz](https://boilerplate.2plot.dev/healthz).

## Network machinery is counted nowhere

The 2plot network's own traffic — health checks, deploy batteries, link
audits — carries a marker in its User-Agent and is dropped before anything
is recorded. It is not in these numbers, by design.

## How long it is kept, and where it goes

Rows are pruned on a retention window and the file is capped in size. A
daily summary — counts by day, by page, by country, by crawler vendor — is
sent to the 2plot network hub. The summary carries no visitor keys, no
addresses and no User-Agent strings: it is counts.

## Signing in

Sign-in is handled by Clerk. What Clerk stores about an account is governed
by Clerk's own privacy policy. This site keeps the identifier it needs to
decide what you may see.

## Questions

The [Discord](https://discord.gg/e5s5uHWUHH), or an issue on
[the repository](https://github.com/pip-install-python/dash-documentation-boilerplate).

---

<!-- /terms — https://boilerplate.2plot.dev/terms/llms.txt -->

# Terms of Use

> Terms of use for Dash Documentation Boilerplate: what this site is, what it is not, and the terms the documentation and its code examples are offered under.

## What this site is

Dash Documentation Boilerplate is documentation, published by Pip Install Python LLC. It describes a
template for building Dash documentation sites and the network standard the
`*.2plot.dev` sites are built to. It is reference material: it is not advice,
it is not a service, and nothing on it is a commitment to keep any particular
behaviour working in your project.

## The documentation and the code in it

The prose and the code examples are published so you can read them, run them
and copy them. They are offered under the **MIT License**, and that
licence — not this page — is what governs your use of them. The text lives
in the repository:

- [https://github.com/pip-install-python/dash-documentation-boilerplate](https://github.com/pip-install-python/dash-documentation-boilerplate)

Everything here is offered **as is**, without warranty of any kind. Running a
code example against your own data, in your own deployment, is your decision
and your responsibility.

## Accounts

Some pages are gated behind a sign-in. An account exists so the site can tell
whether you may see a page; it is not a subscription and carries no
entitlement. Accounts may be ended at any time, by you or by us, and the
[Privacy](/privacy) page describes what is kept while one exists.

## Links to other sites

This site links to other sites in the 2plot network and to third-party
projects. Those sites have their own terms and their own privacy practices,
and this page does not speak for them.

## Changes

These terms change when the site does. The change history for the whole site,
including this page, is the [Changelog](/changelog) and the repository's
commit history — there is no separate archive of previous versions, because
the repository already is one.

## Contact

Questions about these terms: the [Discord](https://discord.gg/e5s5uHWUHH) or an issue on
[the repository](https://github.com/pip-install-python/dash-documentation-boilerplate).
