diff --git a/relations/bp/fragments/routes.py b/relations/bp/fragments/routes.py
index d323720..b2ccfea 100644
--- a/relations/bp/fragments/routes.py
+++ b/relations/bp/fragments/routes.py
@@ -78,10 +78,7 @@ def register():
slug = (child.metadata_ or {}).get("slug", "")
if not slug:
continue
- nav_label = defn.nav_label or ""
- if post_slug and nav_label:
- path = f"/{post_slug}/{nav_label}/{slug}/"
- elif post_slug:
+ if post_slug:
path = f"/{post_slug}/{slug}/"
else:
path = f"/{slug}/"
diff --git a/shared/browser/app/errors.py b/shared/browser/app/errors.py
index 71bc020..4b8e0e4 100644
--- a/shared/browser/app/errors.py
+++ b/shared/browser/app/errors.py
@@ -69,6 +69,107 @@ def _sexp_error_page(errnum: str, message: str, image: str | None = None) -> str
)
+async def _rich_error_page(errnum: str, message: str, image: str | None = None) -> str | None:
+ """Try to render an error page with site headers and post breadcrumb.
+
+ Returns HTML string on success, None if we should fall back to the
+ minimal error page. All failures are swallowed — this must never
+ make a bad situation worse.
+ """
+ from quart import g
+
+ # Skip for internal/static requests
+ if request.path.startswith(("/internal/", "/static/", "/auth/")):
+ return None
+
+ try:
+ # If the app's url_value_preprocessor didn't run (no route match),
+ # manually extract the first path segment as a candidate slug and
+ # try to hydrate post data so context processors can use it.
+ segments = [s for s in request.path.strip("/").split("/") if s]
+ slug = segments[0] if segments else None
+
+ if slug and not getattr(g, "post_data", None):
+ # Set g.post_slug / g.page_slug so app context processors
+ # (inject_post, etc.) can pick it up.
+ from shared.infrastructure.data_client import fetch_data
+ raw = await fetch_data(
+ "blog", "post-by-slug",
+ params={"slug": slug},
+ required=False,
+ )
+ if raw:
+ g.post_slug = slug
+ g.page_slug = slug # cart uses page_slug
+ g.post_data = {
+ "post": {
+ "id": raw["id"],
+ "title": raw["title"],
+ "slug": raw["slug"],
+ "feature_image": raw.get("feature_image"),
+ "status": raw["status"],
+ "visibility": raw["visibility"],
+ },
+ }
+ # Also set page_post for cart app
+ from shared.contracts.dtos import PostDTO, dto_from_dict
+ post_dto = dto_from_dict(PostDTO, raw)
+ if post_dto and getattr(post_dto, "is_page", False):
+ g.page_post = post_dto
+
+ # Build template context (runs app context processors → fragments)
+ from shared.sexp.page import get_template_context
+ ctx = await get_template_context()
+
+ # Build headers: root header + post header if available
+ from shared.sexp.jinja_bridge import render
+ from shared.sexp.helpers import (
+ root_header_html, call_url, full_page,
+ )
+
+ hdr = root_header_html(ctx)
+
+ post = ctx.get("post") or {}
+ if post.get("slug"):
+ title = (post.get("title") or "")[:160]
+ feature_image = post.get("feature_image")
+ label_html = ""
+ if feature_image:
+ label_html += (
+ f''
+ )
+ label_html += f"{escape(title)}"
+ post_row = render(
+ "menu-row",
+ id="post-row", level=1,
+ link_href=call_url(ctx, "blog_url", f"/{post['slug']}/"),
+ link_label_html=label_html,
+ child_id="post-header-child",
+ external=True,
+ )
+ hdr += (
+ f'