Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------------------------------------------------
Expand All @@ -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) ---------------
Expand Down
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
90 changes: 85 additions & 5 deletions components/navbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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(
Expand Down Expand Up @@ -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'."""
Expand All @@ -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()):
Expand Down Expand Up @@ -129,6 +207,8 @@ def create_content(data):
)
)

sections.append(create_admin_section(loc))

return dmc.ScrollArea(
offsetScrollbars=True,
type="scroll",
Expand All @@ -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)"},
)

Expand All @@ -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",
)
2 changes: 1 addition & 1 deletion dash_leaflet2/package-info.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
103 changes: 103 additions & 0 deletions lib/bulletin.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading