diff --git a/.env.example b/.env.example
index 7367919..9b03fe6 100644
--- a/.env.example
+++ b/.env.example
@@ -7,6 +7,12 @@ DASH_BACKEND=flask
PORT=8050
HOST=127.0.0.1
DASH_DEBUG=true
+# APP_BASE_URL is the network-standard name and is read FIRST;
+# DASH_LEAFLET2_BASE_URL is this repo's own spelling and remains supported.
+# An alias, never a rename — both are set on the live service, because
+# dropping one of two env names from a running host is how it starts
+# advertising the wrong canonical origin and deindexes itself quietly.
+# APP_BASE_URL=http://localhost:8050
DASH_LEAFLET2_BASE_URL=http://localhost:8050
# --- Optional: MUI X Pro ----------------------------------------------------
@@ -16,13 +22,28 @@ DASH_LEAFLET2_BASE_URL=http://localhost:8050
# --- Optional: 2plot.dev ad network -----------------------------------------
# AD_SERVER_URL=https://2plot.dev
-# AD_APP_ID=dash-leaflet2
+# AD_APP_ID=leaflet
# --- Optional: 2plot.ai satellite analytics ---------------------------------
# Without CROSS_APP_WEBHOOK_SECRET nothing is reported; /healthz still serves.
# Set DRY_RUN while developing so you never beacon the live hub.
# CROSS_APP_WEBHOOK_SECRET=
+# `leaflet` is the hub's directory key — NOT the package name `dash-leaflet2`,
+# which the hub only still accepts as a legacy id. Both spellings of the env
+# var are honoured; SATELLITE_APP_KEY is the network-standard name.
# SATELLITE_APP_ID=leaflet
+# SATELLITE_APP_KEY=leaflet
+
+# --- Optional: 2plot.dev network bulletin -----------------------------------
+# The hub's announcements + tips, rendered in this site's llms.txt viewer
+# header. Unset means the feature is off and the viewer still renders — the
+# tell for an unwired host is "No announcements." plus ONE generic tip where
+# the hub publishes two.
+#
+# NOTE: the dash-improve-my-llms package never reads this variable. lib/bulletin
+# does, and passes it to configure_bulletin(). Setting it in an app without that
+# wiring does nothing, silently.
+# NETWORK_BULLETIN_URL=https://2plot.dev/api/network/bulletin
SATELLITE_ANALYTICS_DRY_RUN=1
# --- Optional: Clerk auth (satellite of the 2plot.ai primary) ---------------
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e6486df..2e7aaff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,70 @@ Nothing yet.
---
+## [0.2.2] — 2026-08-01
+
+The rest of the 2plot network standard, from the checklist's "found on the
+email pass" — the items that each bit a satellite which already looked
+finished. Documentation site and network wiring only; no `dl2.*` component
+changed.
+
+> **Deploy note.** `og:image` now declares 1200×630, and the battery reads the
+> CDN file's real pixels after every deploy. The new card
+> (`scripts/make_social_card.py`) must be uploaded to
+> `cdn.2plot.ai/github_assets/leaflet.2plot.dev.png` **before** this ships, or
+> `social_card_real_pixels` fails the deploy — deliberately.
+
+### Fixed
+
+- **The network bulletin was never wired.** The hub publishes announcements and
+ tips at `2plot.dev/api/network/bulletin`, and every satellite renders them in
+ its llms.txt viewer header. This host had no `lib/bulletin.py` at all, so it
+ showed "No announcements." and one generic tip where the hub publishes two —
+ and an unwired host still renders both panels, which is why nobody noticed.
+ Note that `dash_improve_my_llms/bulletin.py` never reads
+ `NETWORK_BULLETIN_URL`: setting that variable without this code does nothing,
+ silently. `run.py` now prints which of the two states it booted in.
+- **The social card was the wrong shape, and the wrong image.** 1280×515
+ (2.49:1) is wider than both the Open Graph ideal and Twitter's 2:1 slot, so
+ every platform cropped it — and the file was the 2plot network wordmark
+ rather than a card for this site. Replaced with a generated 1200×630 card,
+ and the battery now reads the served PNG's IHDR so a re-upload at a different
+ size cannot pass while every offline test stays green.
+- **`dash-clerk-auth` 0.9.0 renders a dead avatar on satellites** — the header
+ control appears and never resolves the signed-in user. This host is a
+ satellite of the 2plot.ai primary, so it is exactly the affected shape.
+ Vendored 0.9.1.
+- **`markdown2dash` was installed without `--no-deps` in two places** —
+ `scripts/compat_matrix.py` and the README quickstart. In the matrix that
+ meant every per-Dash-version venv booted an app with no documentation pages,
+ so the compatibility run measured nothing.
+- **`AD_APP_ID` was the package name, not the directory key.** The hub lists
+ `dash-leaflet2` under `legacy_ids` and folds it in at ingest specifically
+ "until leaflet's own network-standard pass sets `AD_APP_ID=leaflet`". It now
+ does, and `SATELLITE_APP_KEY` is set alongside `SATELLITE_APP_ID`.
+
+### Added
+
+- **The Control Board appears in the nav, to admins only** — its own section in
+ both the desktop navbar and the mobile drawer, hidden by default and revealed
+ server-side by the same predicate the page itself uses. The link is cosmetic:
+ `/admin/control-board` gates itself on every render and again in its mutating
+ callback, and fails closed without Clerk.
+- `lib/bulletin.py`, `scripts/make_social_card.py`, `tests/test_bulletin.py`,
+ `tests/test_admin_nav.py`, and `social_card_real_pixels` in the battery.
+- `SITE_SHORT_NAME` (with `PAGE_TITLE_PREFIX` derived from it rather than typed
+ twice) and `OG_IMAGE_TYPE`.
+
+### Changed
+
+- **`BASE_URL` accepts `APP_BASE_URL` first**, falling back to this repo's
+ `DASH_LEAFLET2_BASE_URL`. An alias, never a rename — both are set in
+ `render.yaml`, because removing one of two env names from a live service is
+ how a host starts advertising the wrong canonical origin and deindexes
+ itself quietly.
+
+---
+
## [0.2.1] — 2026-07-31
Brings this satellite onto the **2plot network standard** that 2plot.ai (root),
diff --git a/README.md b/README.md
index dcf4b9f..378f80f 100644
--- a/README.md
+++ b/README.md
@@ -148,6 +148,10 @@ You can also run the docs site locally:
```bash
pip install -r requirements.txt
+# markdown2dash pins gunicorn<22, against the CVE-driven gunicorn>=23 floor
+# in requirements.txt. Its real dependencies are listed there, so it installs
+# without its dependency graph — the docs pages need it.
+pip install --no-deps markdown2dash==0.1.2
python run.py # open http://127.0.0.1:8050
```
diff --git a/components/navbar.py b/components/navbar.py
index 8f01083..1162938 100644
--- a/components/navbar.py
+++ b/components/navbar.py
@@ -10,6 +10,7 @@
from collections import OrderedDict
import dash_mantine_components as dmc
+from dash import ALL, Input, Output, callback, ctx, html
from dash_iconify import DashIconify
CATEGORY_ORDER = [
@@ -25,11 +26,19 @@
EXCLUDED_LINKS: set[str] = {
"/404",
"/not-found",
- # Admin surfaces are not documentation — reachable by URL for allowlisted
- # accounts, never advertised in the docs nav.
+ # Admin surfaces are not documentation, so they never join the categorised
+ # docs sections. `/admin/control-board` gets its own section instead, which
+ # is hidden by default and revealed server-side to allowlisted accounts —
+ # see `create_admin_section` below.
"/admin/control-board",
}
+# The control board's nav entry. Rendered into both the desktop navbar and the
+# mobile drawer, so its id is pattern-matched: two components may not share a
+# plain string id, and the reveal callback has to reach both.
+ADMIN_NAV_ID = "admin-nav-section"
+_HIDDEN = {"display": "none"}
+
def create_nav_link(icon: str, text: str, href: str, external: bool = False):
return dmc.Anchor(
@@ -64,6 +73,75 @@ def create_nav_section(title: str, links: list):
)
+def create_admin_section(loc: str):
+ """The owner-only Control Board link, hidden until the server says otherwise.
+
+ Hidden by DEFAULT, and revealed by `_reveal_admin_nav` below rather than by
+ anything the browser knows. The distinction matters: `clerk-auth-store`
+ lives in the page and a determined visitor can put whatever they like in
+ it, so the decision is made server-side against the real session.
+
+ Even so, this link is cosmetic. `/admin/control-board` gates itself twice —
+ `pages/control_board.layout()` re-checks on every render, and the mutating
+ callback re-checks before it will change anything — and it fails CLOSED
+ when Clerk is unavailable. Revealing this link grants nothing; hiding it
+ stops the board being advertised to readers it would only reject.
+ """
+ return html.Div(
+ id={"type": ADMIN_NAV_ID, "loc": loc},
+ style=_HIDDEN,
+ children=dmc.Stack(
+ [
+ dmc.Divider(mt="md", mb="sm"),
+ create_nav_section(
+ "Admin",
+ [
+ create_nav_link(
+ "tabler:adjustments-cog",
+ "Control Board",
+ "/admin/control-board",
+ )
+ ],
+ ),
+ ],
+ gap="xs",
+ ),
+ )
+
+
+@callback(
+ Output({"type": ADMIN_NAV_ID, "loc": ALL}, "style"),
+ Input("url", "pathname"),
+ # The app sets `prevent_initial_callbacks=True` globally, so without this
+ # the section would stay hidden until the visitor navigated somewhere —
+ # including for the owner, on the page they signed in to.
+ prevent_initial_call=False,
+)
+def _reveal_admin_nav(_pathname):
+ """Show the Admin section only to accounts the control board would admit.
+
+ Deliberately the SAME predicate the page itself uses (`is_admin_user`, or
+ `admin_access_open` when Clerk is off) rather than a bare comparison
+ against the owner's address. A nav that used a narrower rule would hide the
+ board from an ADMIN_EMAILS account that can still open it by URL — a link
+ that lies about access is worse than no link.
+
+ With ADMIN_EMAILS unset, `is_admin_user` reduces to the owner's address
+ alone, which is exactly the owner-only behaviour wanted here.
+
+ `url.pathname` is the trigger rather than `clerk-auth-store` because the
+ store only exists when Clerk is running; a callback with a missing Input
+ never fires, which would silently disable this everywhere Clerk is off.
+ Satellite sign-in round-trips through Clerk's hosted pages and returns as a
+ full page load, so this re-evaluates at exactly the right moment anyway.
+ """
+ from lib.auth import admin_access_open, clerk_enabled, is_admin_user
+
+ visible = is_admin_user() if clerk_enabled() else admin_access_open()
+ style = {} if visible else _HIDDEN
+ return [style] * len(ctx.outputs_list)
+
+
def _categorize(data) -> "OrderedDict[str, list]":
"""Bucket page registry entries by their `category` field, preserving the
intentional CATEGORY_ORDER and folding unknown categories into 'Other'."""
@@ -88,7 +166,7 @@ def _categorize(data) -> "OrderedDict[str, list]":
return OrderedDict((k, v) for k, v in buckets.items() if v)
-def create_content(data):
+def create_content(data, loc: str = "navbar"):
buckets = _categorize(data)
sections = []
for i, (title, links) in enumerate(buckets.items()):
@@ -129,6 +207,8 @@ def create_content(data):
)
)
+ sections.append(create_admin_section(loc))
+
return dmc.ScrollArea(
offsetScrollbars=True,
type="scroll",
@@ -139,7 +219,7 @@ def create_content(data):
def create_navbar(data):
return dmc.AppShellNavbar(
- children=create_content(data),
+ children=create_content(data, loc="navbar"),
style={"borderRight": "1px solid var(--mantine-color-gray-3)"},
)
@@ -153,7 +233,7 @@ def create_navbar_drawer(data):
radius="md",
withCloseButton=True,
size="280px",
- children=create_content(data),
+ children=create_content(data, loc="drawer"),
trapFocus=False,
position="left",
)
diff --git a/dash_leaflet2/package-info.json b/dash_leaflet2/package-info.json
index 3ceeac8..17e7328 100644
--- a/dash_leaflet2/package-info.json
+++ b/dash_leaflet2/package-info.json
@@ -1,6 +1,6 @@
{
"name": "dash-leaflet2",
- "version": "0.2.1",
+ "version": "0.2.2",
"description": "Leaflet 2-native Dash components. A from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4.",
"main": "src/ts/index.ts",
"repository": {
diff --git a/lib/bulletin.py b/lib/bulletin.py
new file mode 100644
index 0000000..31b5554
--- /dev/null
+++ b/lib/bulletin.py
@@ -0,0 +1,103 @@
+"""Network bulletin — hub-published tips and announcements.
+
+NETWORK FILE: copied from dash-email, which took it from
+dash-documentation-boilerplate 1.2.4. Only ``app_id()`` differs, and it is
+derived rather than typed — see the note there, because this repo is the one
+where the obvious import is wrong twice over.
+
+The hub (2plot.dev) serves one JSON document at ``/api/network/bulletin`` and
+every satellite renders it in the header of its llms.txt viewer. That is the
+whole point: a twenty-site network says "here is what changed" once, in one
+place, instead of in twenty repositories that immediately drift.
+
+WHY THIS FILE EXISTS RATHER THAN FOUR LINES IN run.py
+-----------------------------------------------------
+On the boilerplate it WAS four lines in ``run.py``, and they were **commented
+out** — with a note saying the hub did not serve the endpoint yet. The hub
+started serving it, the comment did not change, and ``NETWORK_BULLETIN_URL``
+was set in production for a while against code that never read it. Nothing
+failed: ``configure_bulletin`` is opt-in, so an unwired app makes no request
+and the viewer header renders perfectly well with the package's built-in
+defaults. The only symptom was an announcement that never appeared, which is
+not a symptom anyone notices.
+
+So the wiring is a function that returns whether it wired, ``run.py`` prints
+that, and ``tests/test_bulletin.py`` exercises it directly — no commented-out
+code, and a boot log line that says which of the two states you are in.
+
+Env:
+ NETWORK_BULLETIN_URL the hub endpoint. Absent -> feature off, silently.
+ NETWORK_BULLETIN_TTL_S seconds a cached bulletin stays fresh (default 900)
+"""
+
+from __future__ import annotations
+
+import os
+from typing import Optional
+
+DEFAULT_TTL_S = 900.0
+
+# The hub endpoint. Not a default — `configure()` requires the env var to be
+# set, because a satellite that silently starts calling a hub it was never
+# pointed at is the kind of surprise the network must not ship. This is here so
+# `.env.example` and render.yaml have one place to copy from.
+HUB_BULLETIN_URL = "https://2plot.dev/api/network/bulletin"
+
+
+def url() -> Optional[str]:
+ return os.environ.get("NETWORK_BULLETIN_URL") or None
+
+
+def _ttl() -> float:
+ try:
+ return max(60.0, float(os.environ.get("NETWORK_BULLETIN_TTL_S",
+ DEFAULT_TTL_S)))
+ except (TypeError, ValueError):
+ return DEFAULT_TTL_S
+
+
+def app_id() -> str:
+ """This app's key in the hub's network directory — "leaflet".
+
+ Derived from ``lib.satellite_analytics`` rather than hard-coded, so the key
+ this app announces itself with and the key it reports traffic under cannot
+ drift apart. A satellite left announcing itself as "boilerplate" would
+ receive the template's announcements, and the hub's "who is rendering the
+ bulletin" view would count it as that repo.
+
+ TWO IMPORTS THAT LOOK RIGHT AND ARE NOT, both specific to this repo:
+
+ * ``lib.satellite_reporter`` is what every other satellite uses, and it
+ does not exist here — a verbatim copy of the sibling file raises
+ ModuleNotFoundError on the first bulletin fetch. This app keeps the same
+ value in ``lib.satellite_analytics.APP_ID`` (env ``SATELLITE_APP_ID``).
+ * ``AD_APP_ID`` is the obvious one and the wrong one. It is the ad
+ network's identifier, historically the long ``dash-leaflet2``, whereas
+ the directory key is ``leaflet``. Announcing the long form would file
+ this host under a name the hub's directory does not use.
+ """
+ from lib.satellite_analytics import APP_ID
+
+ return APP_ID
+
+
+def configure() -> bool:
+ """Point the package at the hub's bulletin. Returns whether it did.
+
+ Fail-open by design, in both directions. With no URL the feature is off and
+ the viewer header still renders — the package ships default tips and an
+ empty state for announcements. With a URL that is unreachable, the
+ package's client degrades silently rather than failing a page render: a hub
+ outage must not take the documentation down with it.
+ """
+ endpoint = url()
+ if not endpoint:
+ return False
+
+ try:
+ from dash_improve_my_llms import configure_bulletin
+ except ImportError: # pragma: no cover - older releases lack the feature
+ return False
+
+ configure_bulletin(url=endpoint, ttl=_ttl(), app_id=app_id())
+ return True
diff --git a/lib/constants.py b/lib/constants.py
index a5a6030..6e9f6d4 100644
--- a/lib/constants.py
+++ b/lib/constants.py
@@ -1,10 +1,9 @@
import os
-PAGE_TITLE_PREFIX = "dash-leaflet2 | "
PRIMARY_COLOR = "green"
# Keep in step with pyproject.toml and package.json when cutting a release.
-APP_VERSION = "0.2.1"
+APP_VERSION = "0.2.2"
LEAFLET_VERSION = "2.0.0-alpha.1"
# ---------------------------------------------------------------------------
@@ -34,6 +33,13 @@
# - "Pip Install Python" is the byline (who made it), never the site name.
SITE_BRAND = "dash-leaflet2 — Leaflet 2 maps for Dash"
+# The short form, for places a full brand line does not fit: the installed
+# app's home-screen label and the per-page
prefix. Derived rather than
+# typed twice — PAGE_TITLE_PREFIX used to be its own literal, which is one
+# rename away from a site whose tab titles disagree with its brand.
+SITE_SHORT_NAME = "dash-leaflet2"
+PAGE_TITLE_PREFIX = f"{SITE_SHORT_NAME} | "
+
SITE_DESCRIPTION = (
"dash-leaflet2 — Leaflet 2 (alpha) mapping components for Plotly Dash 4. "
"Wraps Leaflet 2 core directly instead of react-leaflet, exposing unified "
@@ -44,7 +50,18 @@
# Public origin, used for canonical URLs, the sitemap and llms.txt. Override per
# deployment; the default is the 2plot network subdomain this site ships to.
-BASE_URL = os.environ.get("DASH_LEAFLET2_BASE_URL", "https://leaflet.2plot.dev").rstrip("/")
+# APP_BASE_URL first, this repo's own spelling second. `BASE_URL` is a REQUIRED
+# NAME, not a preference: the shared scripts/ and tests/ import it on every
+# host in the network. The env var behind it is where hosts differ, and the
+# rule from the email pass is an ALIAS, never a rename — render.yaml sets the
+# legacy name on a live service, and removing one of two env names from a
+# running host is how it starts advertising the wrong canonical origin, which
+# deindexes it silently.
+BASE_URL = (
+ os.environ.get("APP_BASE_URL")
+ or os.environ.get("DASH_LEAFLET2_BASE_URL")
+ or "https://leaflet.2plot.dev"
+).rstrip("/")
# ---------------------------------------------------------------------------
# The social card
@@ -63,12 +80,19 @@
# assets-derived one, so passing it at `register_page` time fixes every page at
# the source instead of fighting tag order inside templates/index.html.
#
-# 1280x515 (2.49:1) rather than the 1.91:1 the card specs ask for, so previews
-# centre-crop roughly 100px off the top and bottom. The wordmark sits in the
-# middle band and survives the crop.
+# 1200x630 = exactly 1.91:1, the Open Graph documented ideal, which also
+# degrades cleanly into Twitter's 2:1 `summary_large_image` slot. The file that
+# lived at this URL until 0.2.2 was 1280x515 (2.49:1) — wider than both, so
+# every platform cropped roughly 100px off the top and bottom — and it was the
+# 2plot network wordmark rather than a card for this site at all. Regenerate
+# with `python scripts/make_social_card.py`, then upload BY HAND; the values
+# below must match the file's real IHDR, and
+# `scripts/network_smoke.py::social_card_real_pixels` reads those bytes after
+# every deploy precisely so a re-upload at a different size cannot pass.
OG_IMAGE_URL = "https://cdn.2plot.ai/github_assets/leaflet.2plot.dev.png"
-OG_IMAGE_WIDTH = 1280
-OG_IMAGE_HEIGHT = 515
+OG_IMAGE_WIDTH = 1200
+OG_IMAGE_HEIGHT = 630
+OG_IMAGE_TYPE = "image/png"
OG_IMAGE_ALT = "dash-leaflet2 — Leaflet 2 maps for Dash, at leaflet.2plot.dev"
# ---------------------------------------------------------------------------
diff --git a/package-info.json b/package-info.json
index 0bb1bec..46b587b 100644
--- a/package-info.json
+++ b/package-info.json
@@ -1,6 +1,6 @@
{
"name": "dash-leaflet2",
- "version": "0.2.1",
+ "version": "0.2.2",
"description": "Leaflet 2-native Dash components. A from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4.",
"main": "src/ts/index.ts",
"repository": {
diff --git a/package.json b/package.json
index 3ceeac8..17e7328 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "dash-leaflet2",
- "version": "0.2.1",
+ "version": "0.2.2",
"description": "Leaflet 2-native Dash components. A from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4.",
"main": "src/ts/index.ts",
"repository": {
diff --git a/pyproject.toml b/pyproject.toml
index cedc2dc..6b97c04 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "dash-leaflet2"
-version = "0.2.1"
+version = "0.2.2"
description = "Leaflet 2-native Dash components — a from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4."
readme = "README.md"
# 3.9, not 3.8. Dash 4.4.1 itself requires >=3.9, so a 3.8 user would be
diff --git a/render.yaml b/render.yaml
index 07b9d05..ac18614 100644
--- a/render.yaml
+++ b/render.yaml
@@ -40,6 +40,16 @@ services:
- key: WEB_CONCURRENCY
value: "2"
# Public origin for canonical URLs, sitemap.xml and llms.txt.
+ #
+ # BOTH spellings, deliberately. APP_BASE_URL is the network-standard name
+ # that the shared scripts/ and tests/ expect; DASH_LEAFLET2_BASE_URL is
+ # what this service has been running with. lib/constants reads the
+ # standard one first and falls back to the legacy one, so either alone
+ # works — but dropping the old name from a LIVE service is how a host
+ # starts advertising the wrong canonical origin, which deindexes it
+ # quietly. Keep both until the service is confirmed on the new name.
+ - key: APP_BASE_URL
+ value: https://leaflet.2plot.dev
- key: DASH_LEAFLET2_BASE_URL
value: https://leaflet.2plot.dev
@@ -49,8 +59,13 @@ services:
- key: AD_SERVER_URL
value: https://2plot.dev
# This app's identity in the ad network's /admin/ad-board tables.
+ # The hub's directory key, NOT the package name. `canonical_app_id()` on
+ # 2plot.dev folds the legacy `dash-leaflet2` in at ingest, so the change
+ # is safe mid-flight — but the hub's directory entry lists `dash-leaflet2`
+ # under `legacy_ids` precisely "until leaflet's own network-standard pass
+ # sets AD_APP_ID=leaflet". This is that pass.
- key: AD_APP_ID
- value: dash-leaflet2
+ value: leaflet
# --- 2plot.ai satellite analytics (lib/satellite_analytics.py) ----
# The shared HMAC secret every satellite holds. Set it in the Render
@@ -70,6 +85,24 @@ services:
# only POSTs when the day's numbers actually changed.
- key: SATELLITE_REPORT_INTERVAL_S
value: "1800"
+ # The same key under the network-standard spelling. lib/satellite_analytics
+ # reads SATELLITE_APP_ID; the shared tooling and the rest of the fleet say
+ # SATELLITE_APP_KEY. Both are set because removing one of two env names on
+ # a live service is how a host starts reporting under the wrong id.
+ - key: SATELLITE_APP_KEY
+ value: leaflet
+
+ # --- 2plot.dev network bulletin (lib/bulletin.py) -----------------
+ # The hub's announcement feed, rendered in the header of this site's
+ # llms.txt viewer. Opt-in — unset means the feature is simply off.
+ #
+ # NOTE: Render applies blueprint envVars on a BLUEPRINT SYNC, not on an
+ # autoDeploy from a git push. Adding this line alone leaves the variable
+ # absent on the service and the panel empty, which is exactly how
+ # email.2plot.dev shipped wired code and an unwired deployment. Sync the
+ # blueprint or add it in the dashboard.
+ - key: NETWORK_BULLETIN_URL
+ value: https://2plot.dev/api/network/bulletin
# --- Clerk satellite auth (lib/auth.py) --------------------------
# All of these are OPTIONAL: with them absent the site runs fully public
diff --git a/requirements.txt b/requirements.txt
index 0e27a45..e2f1054 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -95,10 +95,13 @@ dash-mui-charts>=1.2.3
# and redeploy; /admin/control-board fails CLOSED without Clerk, so nothing is
# left exposed by doing so.
#
-# 0.9.0 carries the two satellite fixes lib/auth.py depends on; it is not on
-# PyPI, and is vendored the same way across 2plotai, 2plotxyz and HoneyComb.
+# 0.9.1, not 0.9.0. Both carry the two satellite fixes lib/auth.py depends on,
+# but 0.9.0 renders a DEAD AVATAR on a satellite — the header control appears
+# and never resolves the signed-in user. This host is a satellite of the
+# 2plot.ai primary, so it is exactly the affected shape. Not on PyPI; vendored
+# the same way across the network (0.9.1 is what the boilerplate ships).
# Used by: lib/auth.py, /admin/control-board
-./vendor/dash_clerk_auth-0.9.0.tar.gz
+./vendor/dash_clerk_auth-0.9.1.tar.gz
clerk-backend-api>=5.0.0,<6
# --- Deployment ------------------------------------------------------------
diff --git a/run.py b/run.py
index 9821533..254e781 100644
--- a/run.py
+++ b/run.py
@@ -49,7 +49,7 @@
register_page_metadata,
)
-from lib import auth, network_directory, satellite_analytics
+from lib import auth, bulletin, network_directory, satellite_analytics
from lib.backend import get_backend_info, resolve_backend
from lib.constants import (
APP_VERSION,
@@ -222,6 +222,22 @@
# firing, a page has genuinely lost its prose — which is worth hearing about.
add_llms_routes(app, LLMSConfig(warn_missing_llms_doc=True))
+# The hub's announcement feed, rendered in the header of this site's llms.txt
+# viewer. Opt-in: with NETWORK_BULLETIN_URL unset the feature is simply off and
+# the viewer still renders, so the failure mode is an announcement that never
+# appears — which nobody notices.
+#
+# Hence a function that RETURNS whether it wired, and a boot line that says so.
+# The boilerplate shipped four commented-out lines here for weeks, against a
+# hub endpoint that was already serving, and the only symptom was silence. An
+# unwired host still renders both banner panels; the tell is one generic tip
+# where the hub publishes two.
+print(
+ "[dash-leaflet2] network bulletin: "
+ + (f"wired -> {bulletin.url()}" if bulletin.configure()
+ else "off (NETWORK_BULLETIN_URL unset)")
+)
+
# ----------------------------------------------------------------------------
# 2plot.ai satellite analytics: /healthz for the hub's hourly health sweep,
# per-request + SPA page-view tracking, and the signed traffic rollup POSTed to
diff --git a/scripts/compat_matrix.py b/scripts/compat_matrix.py
index ec93750..0db51ac 100644
--- a/scripts/compat_matrix.py
+++ b/scripts/compat_matrix.py
@@ -181,6 +181,21 @@ def make_venv(version: str) -> tuple[Path, Path] | None:
log(f"{version}: requirements install FAILED\n{r.stderr[-1500:]}")
return None
+ # markdown2dash is deliberately NOT in requirements.txt: it declares
+ # `gunicorn>=21.2,<22`, which pip cannot resolve against the CVE-driven
+ # `gunicorn>=23` floor (CVE-2024-6827, CVE-2024-1135 — request smuggling).
+ # Its real dependencies are listed there instead, and it installs without
+ # its dependency graph. `pages/markdown.py` imports it, so without this
+ # every venv in the matrix boots an app with no documentation pages and
+ # the whole run measures nothing. Same pair as the Dockerfile, ci.yml and
+ # release.yml.
+ log(f"{version}: installing markdown2dash (--no-deps)")
+ r = run([str(py), "-m", "pip", "install", "-q", "--no-deps",
+ "markdown2dash==0.1.2"], cwd=PROJECT_ROOT)
+ if r.returncode:
+ log(f"{version}: markdown2dash install FAILED\n{r.stderr[-1500:]}")
+ return None
+
# Confirm the resolver did not quietly upgrade Dash to satisfy something.
r = run([str(py), "-c", "import dash; print(dash.__version__)"])
actual = r.stdout.strip()
diff --git a/scripts/make_social_card.py b/scripts/make_social_card.py
new file mode 100644
index 0000000..6e31ffe
--- /dev/null
+++ b/scripts/make_social_card.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+"""Render the 1200x630 social card for a 2plot satellite.
+
+ python scripts/make_social_card.py # defaults, this site
+ python scripts/make_social_card.py --open # ...and preview it
+ python scripts/make_social_card.py \
+ --artwork assets/logo.png --brand "dash-email" \
+ --tagline "email components for Dash" --domain email.2plot.dev
+
+TEMPLATE FILE: satellites copy this verbatim and pass their own values, so
+every card in the network is framed identically instead of being hand-made
+once per site and drifting.
+
+Output goes to `build/social-cards/.png`, which is gitignored. The
+card is NOT served by the app — publish it to the CDN:
+
+ https://cdn.2plot.ai/github_assets/.png
+
+That is deliberate and is the network rule. A card served by the app itself
+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,
+permanently, because platforms cache the miss. The CDN has no cold start.
+
+WHY 1200x630 and not leaflet's 1280x515
+---------------------------------------
+1200x630 is exactly 1.91:1, the Open Graph documented ideal, and it degrades
+cleanly into Twitter's 2:1 `summary_large_image` slot. leaflet.2plot.dev's is
+1280x515 = 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 to converge on, not that one.
+
+Pillow is a build-time dependency only. It is deliberately absent from
+requirements.txt: nothing at runtime renders images, and a docs site should
+not carry an image library into production to support a script run by hand
+every few months.
+"""
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO_ROOT))
+
+try:
+ from PIL import Image, ImageDraw, ImageFont
+except ImportError: # pragma: no cover - the one dependency, named clearly
+ sys.exit("This script needs Pillow:\n pip install Pillow")
+
+# Card geometry. WIDTH/HEIGHT are the contract; everything else is derived so
+# a fork can change the padding without recomputing a layout by hand.
+WIDTH, HEIGHT = 1200, 630
+PAD = 72
+ART_BOX = 430 # the square the artwork is fitted inside, right-hand side
+RULE_W = 6 # the accent bar under the brand
+
+# Palette, from assets/favicon/site.webmanifest so the card, the browser
+# chrome and the install splash cannot disagree.
+BG_TOP = (26, 27, 30) # #1a1b1e — manifest background_color
+BG_BOTTOM = (17, 20, 26) # a shade deeper, for a gradient with a direction
+ACCENT = (18, 184, 134) # #12B886 — manifest theme_color
+TEXT = (245, 246, 247)
+MUTED = (150, 158, 168)
+
+# Font families in preference order. `truetype` is tried on each until one
+# loads: macOS ships the first group, Debian/Ubuntu CI images the second.
+# There is no bundled font on purpose — shipping a licensed TTF in a template
+# every satellite forks is a licensing question nobody wants to answer.
+FONT_CANDIDATES = {
+ "bold": [
+ "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
+ "/System/Library/Fonts/HelveticaNeue.ttc",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
+ ],
+ "regular": [
+ "/System/Library/Fonts/Supplemental/Arial.ttf",
+ "/System/Library/Fonts/Helvetica.ttc",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
+ ],
+ "mono": [
+ "/System/Library/Fonts/Menlo.ttc",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
+ "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
+ ],
+}
+
+
+def load_font(kind: str, size: int):
+ for path in FONT_CANDIDATES[kind]:
+ if Path(path).exists():
+ try:
+ return ImageFont.truetype(path, size)
+ except OSError:
+ continue
+ # Pillow >= 10.1 scales its built-in font; older ones give a 10px bitmap
+ # and the card looks broken rather than merely plain. Say so.
+ print(f"[card] WARNING: no {kind} system font found — falling back to "
+ "Pillow's built-in, which will look wrong. Install DejaVu or "
+ "Liberation fonts.", file=sys.stderr)
+ try:
+ return ImageFont.load_default(size=size)
+ except TypeError: # pragma: no cover - Pillow < 10.1
+ return ImageFont.load_default()
+
+
+def vertical_gradient(size, top, bottom):
+ """A one-pixel-wide gradient stretched across the canvas.
+
+ Cheaper and smoother than filling row by row on the full-width image, and
+ the resample keeps the banding invisible at this height.
+ """
+ w, h = size
+ strip = Image.new("RGB", (1, h))
+ for y in range(h):
+ t = y / max(1, h - 1)
+ strip.putpixel((0, y), tuple(
+ round(top[i] + (bottom[i] - top[i]) * t) for i in range(3)
+ ))
+ return strip.resize((w, h), Image.BILINEAR)
+
+
+def wrap(draw, text, font, max_width):
+ """Greedy word wrap against measured pixel width, not a character count."""
+ words, lines, current = text.split(), [], ""
+ for word in words:
+ trial = f"{current} {word}".strip()
+ if draw.textlength(trial, font=font) <= max_width or not current:
+ current = trial
+ else:
+ lines.append(current)
+ current = word
+ if current:
+ lines.append(current)
+ return lines
+
+
+def build_card(artwork: Path, brand: str, tagline: str, domain: str) -> Image.Image:
+ card = vertical_gradient((WIDTH, HEIGHT), BG_TOP, BG_BOTTOM).convert("RGBA")
+ draw = ImageDraw.Draw(card)
+
+ # --- artwork, right ----------------------------------------------------
+ # `thumbnail` preserves aspect ratio, so a square-ish logo and a wide one
+ # both land inside the same box without being stretched. The alpha bbox is
+ # cropped first: assets/ddb.png carries ~66px of transparent margin, which
+ # would otherwise be centred as if it were part of the image.
+ art = Image.open(artwork).convert("RGBA")
+ bbox = art.getchannel("A").getbbox()
+ if bbox:
+ art = art.crop(bbox)
+ art.thumbnail((ART_BOX, ART_BOX), Image.LANCZOS)
+ art_x = WIDTH - PAD - ART_BOX + (ART_BOX - art.width) // 2
+ art_y = (HEIGHT - art.height) // 2
+ card.alpha_composite(art, (art_x, art_y))
+
+ # --- text, left --------------------------------------------------------
+ text_width = WIDTH - (PAD * 2) - ART_BOX - 48
+
+ brand_font = load_font("bold", 62)
+ tagline_font = load_font("regular", 29)
+ domain_font = load_font("mono", 25)
+
+ brand_lines = wrap(draw, brand, brand_font, text_width)
+ # Shrink once rather than overflow: a three-line brand at 62px collides
+ # with the domain strip below.
+ if len(brand_lines) > 2:
+ brand_font = load_font("bold", 50)
+ brand_lines = wrap(draw, brand, brand_font, text_width)
+
+ tagline_lines = wrap(draw, tagline, tagline_font, text_width)[:3]
+
+ brand_lh, tagline_lh = 74, 40
+ block_h = (len(brand_lines) * brand_lh) + 26 + (len(tagline_lines) * tagline_lh)
+ y = (HEIGHT - block_h - 60) // 2
+
+ # Accent rule, aligned to the top of the brand block.
+ draw.rounded_rectangle(
+ [PAD, y + 6, PAD + RULE_W, y + block_h - 10], radius=RULE_W // 2, fill=ACCENT
+ )
+ text_x = PAD + RULE_W + 28
+
+ for line in brand_lines:
+ draw.text((text_x, y), line, font=brand_font, fill=TEXT)
+ y += brand_lh
+ y += 26
+ for line in tagline_lines:
+ draw.text((text_x, y), line, font=tagline_font, fill=MUTED)
+ y += tagline_lh
+
+ # Domain, bottom left — the one string a reader uses to decide whether the
+ # link goes where they think it does.
+ draw.text((text_x, HEIGHT - PAD - 26), domain, font=domain_font, fill=ACCENT)
+
+ return card.convert("RGB")
+
+
+def main() -> int:
+ from lib.constants import BASE_URL, SITE_BRAND
+
+ default_domain = BASE_URL.split("://", 1)[-1].rstrip("/")
+
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ ap.add_argument("--artwork", default="assets/ddb.png",
+ help="source image, transparent PNG (default: %(default)s)")
+ ap.add_argument("--brand", default=SITE_BRAND.split(" — ")[0],
+ help="headline (default: the brand, minus its tagline)")
+ ap.add_argument("--tagline",
+ default="The markdown-driven documentation template every "
+ "*.2plot.dev component site is forked from.")
+ ap.add_argument("--domain", default=default_domain)
+ ap.add_argument("--out", default=None,
+ help="default: build/social-cards/.png")
+ ap.add_argument("--open", action="store_true", help="preview when done (macOS)")
+ args = ap.parse_args()
+
+ artwork = (REPO_ROOT / args.artwork) if not Path(args.artwork).is_absolute() \
+ else Path(args.artwork)
+ if not artwork.exists():
+ return print(f"artwork not found: {artwork}", file=sys.stderr) or 1
+
+ out = Path(args.out) if args.out else \
+ REPO_ROOT / "build" / "social-cards" / f"{args.domain}.png"
+ out.parent.mkdir(parents=True, exist_ok=True)
+
+ card = build_card(artwork, args.brand, args.tagline, args.domain)
+ # optimize=True typically halves the file; scrapers fetch this on every
+ # cold unfurl and some give up on slow responses.
+ card.save(out, "PNG", optimize=True)
+
+ kb = out.stat().st_size // 1024
+ print(f"[card] {out.relative_to(REPO_ROOT)} {card.width}x{card.height} {kb} KB")
+ print(f"[card] ratio {card.width / card.height:.2f}:1")
+ print(f"[card] publish to: https://cdn.2plot.ai/github_assets/{args.domain}.png")
+ print("[card] then update OG_IMAGE_URL / OG_IMAGE_WIDTH / OG_IMAGE_HEIGHT "
+ "in lib/constants.py")
+
+ if args.open and sys.platform == "darwin":
+ subprocess.run(["open", str(out)], check=False)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/network_smoke.py b/scripts/network_smoke.py
index 96881a8..54f51e8 100644
--- a/scripts/network_smoke.py
+++ b/scripts/network_smoke.py
@@ -91,6 +91,8 @@
# URL is served from the 2plot CDN so a sleeping free-tier container never
# costs a preview. Keep in step with lib/constants.OG_IMAGE_URL.
OG_IMAGE_URL = "https://cdn.2plot.ai/github_assets/leaflet.2plot.dev.png"
+OG_IMAGE_WIDTH = 1200
+OG_IMAGE_HEIGHT = 630
# --- robots.txt fingerprint, and this site's DELIBERATE divergence -----------
# pip metadata is invisible from outside a running host, so the robots.txt
@@ -126,12 +128,17 @@ class SmokeFailure(Exception):
pass
-def fetch(url: str, ua: str = UA, method: str = "GET",
- body: bytes | None = None, headers: dict | None = None,
- timeout: int = TIMEOUT, retries: int = 3):
- """(status, headers, text) — HTTP errors are results, not exceptions;
+def fetch_raw(url: str, ua: str = UA, method: str = "GET",
+ body: bytes | None = None, headers: dict | None = None,
+ timeout: int = TIMEOUT, retries: int = 3):
+ """(status, headers, BYTES) — HTTP errors are results, not exceptions;
network errors raise AFTER retries.
+ Bytes rather than text, because one caller needs them: the social card is a
+ PNG and its real dimensions live in the IHDR chunk at bytes 16..24. A
+ decode with `errors="replace"` substitutes U+FFFD for every invalid byte
+ and is one-way, so the header would be gone before it could be read.
+
Response headers come back lower-cased: gunicorn sends `content-type`,
proxies often re-case it — callers must not care.
"""
@@ -146,15 +153,28 @@ def fetch(url: str, ua: str = UA, method: str = "GET",
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return (r.status, {k.lower(): v for k, v in r.headers.items()},
- r.read().decode("utf-8", "replace"))
+ r.read())
except urllib.error.HTTPError as e:
return (e.code, {k.lower(): v for k, v in e.headers.items()},
- e.read().decode("utf-8", "replace"))
+ e.read())
except Exception as exc: # timeout, reset, truncated read, …
last_exc = exc
raise last_exc
+def fetch(url: str, ua: str = UA, method: str = "GET",
+ body: bytes | None = None, headers: dict | None = None,
+ timeout: int = TIMEOUT, retries: int = 3):
+ """(status, headers, text) — `fetch_raw` with the body decoded.
+
+ A thin delegate ON PURPOSE. The in-process test patches ONE transport, and
+ if these were two independent implementations the card check would keep
+ reaching the real CDN from a unit test while everything else was stubbed.
+ """
+ status, hdrs, raw = fetch_raw(url, ua, method, body, headers, timeout, retries)
+ return status, hdrs, raw.decode("utf-8", "replace")
+
+
def record(name: str, verdict: str, detail: str = "") -> None:
_RESULTS.append((name, verdict, detail))
print(f"[{verdict:>4}] {name}" + (f" — {detail}" if detail else ""), flush=True)
@@ -305,11 +325,33 @@ def social_card_is_shareable():
expect(bool(twitter) and all(t.strip() for t in twitter),
"twitter:image is missing or empty")
- # The image has to actually exist. A 404 here is the whole card.
- img_status, img_headers, _ = fetch(OG_IMAGE_URL)
+ expect("/assets/" not in images[0],
+ "the app is serving its own card — a cold container blanks the "
+ "preview, and the platform caches the miss")
+
+ # The file has to exist AND be the shape the tags promise. Read the
+ # BYTES, not the decoded text: PNG stores its dimensions in the IHDR
+ # chunk at bytes 16..24, which a lossy decode destroys.
+ #
+ # This is the check that catches a re-upload at a different size —
+ # every offline test stays green while the platform reserves the box
+ # the tags declare and crops the image into it. It is how the previous
+ # card (1280x515, 2.49:1, and the 2plot wordmark rather than a per-site
+ # card at all) went unnoticed.
+ img_status, img_headers, raw = fetch_raw(OG_IMAGE_URL)
expect(img_status == 200, f"the og:image URL returns {img_status}")
expect(img_headers.get("content-type", "").startswith("image/"),
f"og:image serves {img_headers.get('content-type')!r}")
+ # 24 bytes is exactly the signature plus the IHDR width/height, which
+ # is all this reads — `>` rather than `>=` would reject a perfectly
+ # readable header for being minimal.
+ expect(raw[1:4] == b"PNG" and len(raw) >= 24,
+ "og:image is not a PNG (or is truncated)")
+ actual_w = int.from_bytes(raw[16:20], "big")
+ actual_h = int.from_bytes(raw[20:24], "big")
+ expect((actual_w, actual_h) == (OG_IMAGE_WIDTH, OG_IMAGE_HEIGHT),
+ f"the CDN file is {actual_w}x{actual_h}, the tags declare "
+ f"{OG_IMAGE_WIDTH}x{OG_IMAGE_HEIGHT}")
def installable_as_an_app():
"""The manifest, and whether a browser could offer to install this.
@@ -347,7 +389,7 @@ def installable_as_an_app():
("crawler_gets_prose", crawler_gets_prose),
("agents_and_browsers_get_different_types",
agents_and_browsers_get_different_types),
- ("social_card_is_shareable", social_card_is_shareable),
+ ("social_card_real_pixels", social_card_is_shareable),
("installable_as_an_app", installable_as_an_app),
):
check(name, fn)
diff --git a/templates/index.html b/templates/index.html
index 5d5b314..e02b9fa 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -67,8 +67,8 @@
asserted by tests/test_social_card.py. -->
-
-
+
+
diff --git a/tests/test_admin_nav.py b/tests/test_admin_nav.py
new file mode 100644
index 0000000..b3b3eb7
--- /dev/null
+++ b/tests/test_admin_nav.py
@@ -0,0 +1,238 @@
+"""The Control Board's navigation entry, and what it is allowed to imply.
+
+The link is cosmetic and must stay that way. `/admin/control-board` gates
+itself twice — `pages/control_board.layout()` re-checks on every render and the
+mutating callback re-checks before changing anything — and it fails CLOSED when
+Clerk is unavailable. So these tests are not about access control; they are
+about a link that would otherwise either advertise an admin surface to every
+reader, or hide it from someone who can legitimately open it.
+
+The default asserted here is HIDDEN, in the same zero-secret posture CI's
+container runs: no Clerk, `admin_access_open()` false. A test that only checked
+the visible case would pass just as happily against a section hard-coded open.
+"""
+
+from __future__ import annotations
+
+import json
+
+from components.navbar import ADMIN_NAV_ID, _reveal_admin_nav, create_content
+
+
+def _walk(component):
+ """Every component in a layout tree, depth-first."""
+ yield component
+ children = getattr(component, "children", None)
+ if children is None:
+ return
+ if not isinstance(children, (list, tuple)):
+ children = [children]
+ for child in children:
+ if hasattr(child, "children") or hasattr(child, "id"):
+ yield from _walk(child)
+
+
+def _admin_nodes(tree):
+ return [
+ c for c in _walk(tree)
+ if isinstance(getattr(c, "id", None), dict)
+ and c.id.get("type") == ADMIN_NAV_ID
+ ]
+
+
+def _hrefs(tree):
+ return [h for h in (getattr(c, "href", None) for c in _walk(tree)) if h]
+
+
+# --------------------------------------------------------------- the section --
+
+
+def test_the_admin_section_exists_in_both_shells(pages):
+ """The desktop navbar and the mobile drawer each render their own copy.
+
+ They cannot share a plain string id, which is why the id is a dict. If the
+ two ever collide, Dash raises a duplicate-id error at layout validation and
+ the whole site fails to render — so this also guards the boot.
+ """
+ entries = [entry for _p, _n, entry in pages]
+ navbar_nodes = _admin_nodes(create_content(entries, loc="navbar"))
+ drawer_nodes = _admin_nodes(create_content(entries, loc="drawer"))
+
+ assert len(navbar_nodes) == 1, "the navbar has no Admin section"
+ assert len(drawer_nodes) == 1, "the drawer has no Admin section"
+ assert navbar_nodes[0].id != drawer_nodes[0].id, "both shells share one id"
+
+
+def test_the_admin_section_is_hidden_before_any_callback_runs(pages):
+ """The rendered default, which is what an anonymous visitor is served.
+
+ If this were ever `{}`, every reader would see a Control Board link until
+ the reveal callback happened to run — and on a page load that beats the
+ callback, they would see it permanently.
+ """
+ entries = [entry for _p, _n, entry in pages]
+ for loc in ("navbar", "drawer"):
+ node = _admin_nodes(create_content(entries, loc=loc))[0]
+ assert node.style == {"display": "none"}, f"{loc} ships the link visible"
+
+
+def test_the_control_board_is_not_in_the_documentation_sections(pages):
+ """It must appear once, in its own gated section — never among the docs."""
+ entries = [entry for _p, _n, entry in pages]
+ content = create_content(entries, loc="navbar")
+ admin_node = _admin_nodes(content)[0]
+
+ everywhere = _hrefs(content).count("/admin/control-board")
+ inside_gate = _hrefs(admin_node).count("/admin/control-board")
+ assert everywhere == 1, f"the board is linked {everywhere} times"
+ assert inside_gate == 1, "the board's link is outside the gated section"
+
+
+# --------------------------------------------------------------- the reveal --
+
+
+def _run_reveal(monkeypatch, *, n_outputs=2):
+ """Invoke the callback with a stubbed `ctx.outputs_list`.
+
+ The callback returns one style per matched component, and outside a real
+ request `ctx.outputs_list` is empty — which would make it return `[]` and
+ every assertion below vacuous.
+ """
+ import components.navbar as navbar
+
+ class _Ctx:
+ outputs_list = [
+ {"id": {"type": ADMIN_NAV_ID, "loc": loc}, "property": "style"}
+ for loc in ("navbar", "drawer")[:n_outputs]
+ ]
+
+ monkeypatch.setattr(navbar, "ctx", _Ctx)
+ return _reveal_admin_nav("/")
+
+
+def test_it_stays_hidden_with_no_clerk_and_no_override(monkeypatch):
+ """The zero-secret default — the posture CI's container boots in.
+
+ `admin_access_open()` is false unless ALLOW_UNGATED_ADMIN is set, so the
+ link stays hidden on a stock deploy with no Clerk keys. That matches the
+ page, which fails closed in exactly the same situation.
+ """
+ monkeypatch.delenv("ALLOW_UNGATED_ADMIN", raising=False)
+ assert _run_reveal(monkeypatch) == [{"display": "none"}] * 2
+
+
+def test_the_ungated_override_reveals_it(monkeypatch):
+ """Proves the predicate is consulted rather than the style hard-coded.
+
+ Without this, a section that was simply never revealed would pass every
+ other test in this file.
+ """
+ monkeypatch.setenv("ALLOW_UNGATED_ADMIN", "1")
+ assert _run_reveal(monkeypatch) == [{}] * 2
+
+
+def test_it_reveals_for_an_admin_user_and_hides_for_everyone_else(monkeypatch):
+ """The signed-in case, with Clerk reporting enabled.
+
+ `is_admin_user` is patched rather than a Clerk session faked: the point
+ being pinned is that the callback delegates to the SAME predicate the
+ control board uses, not that Clerk works.
+ """
+ import components.navbar as navbar
+ import lib.auth as auth
+
+ monkeypatch.setattr(auth, "clerk_enabled", lambda: True)
+
+ monkeypatch.setattr(auth, "is_admin_user", lambda user=None: True)
+ assert _run_reveal(monkeypatch) == [{}] * 2
+
+ monkeypatch.setattr(auth, "is_admin_user", lambda user=None: False)
+ assert _run_reveal(monkeypatch) == [{"display": "none"}] * 2
+ assert navbar.ADMIN_NAV_ID # module still importable after patching
+
+
+def test_the_owner_email_is_always_an_admin():
+ """`is_admin_user` folds OWNER_EMAIL in, so the nav is owner-only by default.
+
+ With ADMIN_EMAILS unset — the state this deployment is in — the allowlist
+ reduces to the owner's address alone, which is the requested behaviour.
+ Using the page's own predicate rather than a literal comparison means
+ adding an ADMIN_EMAILS entry later updates the nav and the gate together,
+ instead of leaving a link that lies about access.
+ """
+ from lib.auth import OWNER_EMAIL, is_admin_user
+
+ class _User:
+ def __init__(self, email):
+ self.email = email
+ self.user_id = ""
+
+ assert is_admin_user(_User(OWNER_EMAIL)) is True
+ assert is_admin_user(_User(OWNER_EMAIL.upper())) is True, "must be case-insensitive"
+ assert is_admin_user(_User("someone-else@example.com")) is False
+ assert is_admin_user(None) is False
+
+
+# ------------------------------------------------------------- the real gate --
+
+
+def test_the_board_itself_still_fails_closed(client):
+ """The link is cosmetic; this is the check that actually protects anything.
+
+ In the zero-secret suite Clerk is off and ALLOW_UNGATED_ADMIN is unset, so
+ the board must not render its controls to an anonymous visitor.
+ """
+ response = client.get("/admin/control-board")
+ assert response.ok, "the route should answer, then refuse"
+ assert "cb-vis" not in response.text, (
+ "the control board served its visibility switches to an anonymous "
+ "visitor — the page is no longer failing closed"
+ )
+
+
+def test_the_board_is_still_hidden_from_agents(client):
+ """Unchanged by this work, and worth re-asserting beside it."""
+ assert client.get("/admin/control-board/llms.txt").status == 404
+
+
+def _reveal_dependency(app):
+ """The reveal callback's entry in the serialised dependency list.
+
+ `prevent_initial_call` is recorded on `app._callback_list`, NOT in
+ `app.callback_map` — the map's values carry the handler and its I/O spec
+ and have no such key, so reading it there returns None for every callback
+ and an assertion against it can only ever fail. Both structures are also
+ empty until `_setup_server()` runs, which is why these tests take `client`:
+ module-level `@callback` registers into Dash's global registry and only
+ transfers to the app on the first request.
+ """
+ matches = [
+ cb for cb in app._callback_list
+ if ADMIN_NAV_ID in json.dumps(cb.get("output"))
+ ]
+ assert matches, "the reveal callback is not registered"
+ assert len(matches) == 1, f"expected one reveal callback, found {len(matches)}"
+ return matches[0]
+
+
+def test_the_reveal_callback_fires_on_first_load(app, client):
+ """The app sets `prevent_initial_callbacks=True` globally.
+
+ Without the per-callback opt-out the section stays hidden until the visitor
+ navigates — including for the owner, on the page they signed in to, which
+ is the page they are most likely to be looking at.
+ """
+ client.get("/") # force _setup_server() before inspecting the registry
+ assert _reveal_dependency(app).get("prevent_initial_call") is False, (
+ "the reveal callback would not fire on first load"
+ )
+
+
+def test_the_pattern_output_covers_every_shell(app, client):
+ """One callback, both copies — an ALL output rather than two callbacks."""
+ client.get("/")
+ output = _reveal_dependency(app)["output"]
+ assert "ALL" in json.dumps(output), (
+ f"{output} is not a pattern-matching output; the drawer's copy would "
+ "never be revealed"
+ )
diff --git a/tests/test_bulletin.py b/tests/test_bulletin.py
new file mode 100644
index 0000000..ce4237a
--- /dev/null
+++ b/tests/test_bulletin.py
@@ -0,0 +1,94 @@
+"""The network bulletin — wired, or off, and never a comment.
+
+NETWORK FILE: adapted from dash-documentation-boilerplate 1.2.4, where this
+existed because the wiring had sat COMMENTED OUT in run.py for weeks against a
+hub endpoint that was already serving. 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.
+
+The load-bearing test is the last one: commented-out wiring cannot define the
+name it asserts on, so it fails the moment somebody comments it out again.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from conftest import REPO_ROOT
+
+
+@pytest.fixture(autouse=True)
+def _clean_env(monkeypatch):
+ """conftest pins NETWORK_BULLETIN_URL to "" for the whole session; these
+ tests set it themselves and must not leak it into the others."""
+ monkeypatch.delenv("NETWORK_BULLETIN_URL", raising=False)
+ monkeypatch.delenv("NETWORK_BULLETIN_TTL_S", raising=False)
+
+
+def test_no_url_means_the_feature_is_simply_off():
+ from lib import bulletin
+
+ assert bulletin.url() is None
+ assert bulletin.configure() is False
+
+
+def test_configure_reports_that_it_wired(monkeypatch):
+ from lib import bulletin
+
+ monkeypatch.setenv("NETWORK_BULLETIN_URL", bulletin.HUB_BULLETIN_URL)
+
+ seen = {}
+
+ def fake_configure_bulletin(**kwargs):
+ seen.update(kwargs)
+
+ import dash_improve_my_llms
+
+ monkeypatch.setattr(dash_improve_my_llms, "configure_bulletin",
+ fake_configure_bulletin)
+
+ assert bulletin.configure() is True
+ assert seen["url"] == bulletin.HUB_BULLETIN_URL
+ assert seen["app_id"] == "leaflet"
+
+
+def test_the_app_id_is_the_directory_key_not_a_second_opinion():
+ """One id on every hub surface.
+
+ Two imports look right here and are not, both specific to this repo:
+ `lib.satellite_reporter` is what every sibling satellite uses and does not
+ exist in this one, and `AD_APP_ID` is the ad network's identifier —
+ historically the long `dash-leaflet2`, where the hub's directory key is
+ `leaflet`. Announcing the long form would file this host under a name the
+ directory does not use.
+ """
+ from lib import bulletin
+ from lib.satellite_analytics import APP_ID
+
+ assert bulletin.app_id() == APP_ID == "leaflet"
+
+
+def test_a_bad_ttl_falls_back_rather_than_crashing_the_boot(monkeypatch):
+ from lib import bulletin
+
+ monkeypatch.setenv("NETWORK_BULLETIN_TTL_S", "not-a-number")
+ assert bulletin._ttl() == bulletin.DEFAULT_TTL_S
+ monkeypatch.setenv("NETWORK_BULLETIN_TTL_S", "5")
+ assert bulletin._ttl() == 60.0, "a too-short TTL would hammer the hub"
+
+
+def test_run_py_wires_it_rather_than_leaving_it_commented_out():
+ """The regression this file exists for.
+
+ Commented-out wiring cannot define the name it asserts on, so requiring a
+ real call here is what makes commenting it out fail loudly.
+ """
+ source = (REPO_ROOT / "run.py").read_text()
+ live = "\n".join(
+ line for line in source.splitlines() if not line.strip().startswith("#")
+ )
+ assert "bulletin.configure()" in live, (
+ "run.py no longer calls bulletin.configure() outside a comment"
+ )
diff --git a/tests/test_network_smoke.py b/tests/test_network_smoke.py
index 0e6dd68..4c8a1d7 100644
--- a/tests/test_network_smoke.py
+++ b/tests/test_network_smoke.py
@@ -52,25 +52,41 @@ def wired(battery, client, monkeypatch):
"""
seen_agents = []
- def fetch(url, ua=battery.UA, method="GET", body=None, headers=None,
- timeout=None, retries=1):
+ def _png_header(width: int, height: int) -> bytes:
+ """The 24 bytes the card check actually reads.
+
+ PNG signature (8) + length/type of the IHDR chunk (8) + width and
+ height as big-endian uint32 (8). The battery reads bytes 16..24 and
+ nothing else, so a synthetic header is a faithful stand-in for a real
+ image — and it keeps the suite off the network.
+ """
+ return (b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR"
+ + width.to_bytes(4, "big") + height.to_bytes(4, "big"))
+
+ def fetch_raw(url, ua=battery.UA, method="GET", body=None, headers=None,
+ timeout=None, retries=1):
assert method == "GET", f"the satellite battery issued a {method}"
seen_agents.append(ua)
accept = (headers or {}).get("Accept")
# Off-host URLs — today just the CDN-hosted social card — resolve to a
- # stub. Reaching the real CDN from a unit test would make the suite
- # depend on another service being up; that the asset genuinely
- # resolves is the DEPLOYED battery's job, which is where the check
- # earns its keep.
+ # stub at the DECLARED size, so the check passes here and still has to
+ # be earned against the real CDN after a deploy. Reaching the real CDN
+ # from a unit test would make the suite depend on another service.
if not url.startswith(BASE) and "://" in url:
- return 200, {"content-type": "image/png"}, ""
+ return (200, {"content-type": "image/png"},
+ _png_header(battery.OG_IMAGE_WIDTH, battery.OG_IMAGE_HEIGHT))
path = url[len(BASE):] if url.startswith(BASE) else url
response = client.get(path or "/", user_agent=ua, accept=accept)
- return response.status, dict(response.headers), response.text
-
- monkeypatch.setattr(battery, "fetch", fetch)
+ return response.status, dict(response.headers), response.text.encode()
+
+ # `fetch_raw`, NOT `fetch`. The card check reads PNG bytes, and `fetch` is
+ # a thin decoding delegate — patching it would leave `fetch_raw` reaching
+ # the real CDN from a unit test, and patching only `fetch` in a repo where
+ # they were separate implementations is how the boilerplate's copy of this
+ # test silently kept hitting the network.
+ monkeypatch.setattr(battery, "fetch_raw", fetch_raw)
monkeypatch.setattr(battery, "_RESULTS", [])
battery.seen_agents = seen_agents
return battery
diff --git a/tests/test_social_card.py b/tests/test_social_card.py
index 76d4a4a..affca80 100644
--- a/tests/test_social_card.py
+++ b/tests/test_social_card.py
@@ -34,6 +34,7 @@
from lib.constants import (
OG_IMAGE_ALT,
OG_IMAGE_HEIGHT,
+ OG_IMAGE_TYPE,
OG_IMAGE_URL,
OG_IMAGE_WIDTH,
SITE_BRAND,
@@ -118,7 +119,34 @@ def test_the_auxiliary_image_tags_match_the_constants(client):
assert _meta(html, "property", "og:image:width") == [str(OG_IMAGE_WIDTH)]
assert _meta(html, "property", "og:image:height") == [str(OG_IMAGE_HEIGHT)]
assert _meta(html, "property", "og:image:alt") == [OG_IMAGE_ALT]
- assert _meta(html, "property", "og:image:secure_url") == [OG_IMAGE_URL]
+ assert _meta(html, "property", "og:image:type") == [OG_IMAGE_TYPE]
+ assert _meta(html, "property", "og:image:secure_url") == [OG_IMAGE_URL], (
+ "secure_url must be the same file as og:image, not a stale copy"
+ )
+
+
+def test_the_declared_ratio_suits_a_large_image_card():
+ """`summary_large_image` wants roughly 1.91:1.
+
+ The card this replaced was 1280x515 = 2.49:1 — wider than both the Open
+ Graph ideal and Twitter's 2:1 slot, so every platform cropped roughly
+ 100px off the top and bottom. It was also the 2plot wordmark rather than a
+ per-site card at all.
+ """
+ ratio = OG_IMAGE_WIDTH / OG_IMAGE_HEIGHT
+ assert 1.7 <= ratio <= 2.05, f"{OG_IMAGE_WIDTH}x{OG_IMAGE_HEIGHT} is {ratio:.2f}:1"
+
+
+def test_the_card_is_hosted_off_the_app():
+ """The card must be on the CDN, not served by this app.
+
+ Not a style rule. A card the app serves is fetched by the scraper at
+ unfurl time; 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.
+ """
+ assert OG_IMAGE_URL.startswith("https://cdn.2plot.ai/github_assets/")
+ assert "/assets/" not in OG_IMAGE_URL, "the app is serving its own card again"
def test_the_twitter_card_is_a_large_image(client):
diff --git a/vendor/dash_clerk_auth-0.9.0.tar.gz b/vendor/dash_clerk_auth-0.9.0.tar.gz
deleted file mode 100644
index 290b84e..0000000
Binary files a/vendor/dash_clerk_auth-0.9.0.tar.gz and /dev/null differ
diff --git a/vendor/dash_clerk_auth-0.9.1.tar.gz b/vendor/dash_clerk_auth-0.9.1.tar.gz
new file mode 100644
index 0000000..adb8d6b
Binary files /dev/null and b/vendor/dash_clerk_auth-0.9.1.tar.gz differ