From edf41f306eb3f5d5cb51f2c70e5d8d9c68f274cb Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Fri, 19 Jun 2026 16:22:17 -0700 Subject: [PATCH] feat(404): branded leaf-fall custom 404 page + dark 500 fallback (#1190) Add custom error pages wired via root-URLconf handler404/handler500. - 404: the page loads fully assembled (a full-window grid of triangles with the Makeability Lab logo embedded), then the background grid flutters down like falling leaves and settles into a pile, leaving the logo standing (makelab-404.js, MakeabilityLabLogoLeafFall from the pinned js@0.6.0 dist). Click / press R to replay. The logo's L overlay is set semi-translucent via the lib's setLTriangleFillColor. Honors prefers-reduced-motion (static settled state). Recovery content -- big "404 / PAGE NOT FOUND" heading above the logo, supportive text + nav links below -- lives in the DOM, with a tight black highlight per line for legibility over the grid. - Responsive logo placement: the logo (and its grid) shrinks and is lifted by a continuous, width-based amount so it stays well-placed from desktop down to narrow phones (no breakpoint jump). - 500: static dark fallback with the colored logo (no JS/CDN dependency). - Dev-only /404-preview/ and /500-preview/ routes (registered only when DEBUG=True) so the pages can be viewed locally with static files intact, avoiding the DEBUG=False/static-serving pitfall of earlier attempts. - Regression tests: 404 status, custom template, nav links present, requested-path HTML-escaping (reflected-XSS guard), preview route absent when not DEBUG. Co-Authored-By: Claude Opus 4.8 (1M context) --- makeabilitylab/urls.py | 8 + website/static/website/css/404.css | 220 +++++++++++++++++++++++ website/static/website/js/makelab-404.js | 185 +++++++++++++++++++ website/templates/website/404.html | 80 +++++++++ website/templates/website/500.html | 49 +++++ website/tests/test_custom_404.py | 66 +++++++ website/urls.py | 12 ++ website/views/__init__.py | 1 + website/views/custom_404.py | 68 +++++++ 9 files changed, 689 insertions(+) create mode 100644 website/static/website/css/404.css create mode 100644 website/static/website/js/makelab-404.js create mode 100644 website/templates/website/404.html create mode 100644 website/templates/website/500.html create mode 100644 website/tests/test_custom_404.py create mode 100644 website/views/custom_404.py diff --git a/makeabilitylab/urls.py b/makeabilitylab/urls.py index e4ea6924..71234640 100644 --- a/makeabilitylab/urls.py +++ b/makeabilitylab/urls.py @@ -28,6 +28,14 @@ from website.sitemaps import sitemaps +# Custom error pages (#1190). These MUST be declared in the root URLconf -- Django +# only looks for handler404/handler500 here, not in the app-level website/urls.py. +# They take effect only when DEBUG=False; see website.views.custom_404 for how to +# preview them in local dev. +# https://docs.djangoproject.com/en/5.2/topics/http/views/#customizing-error-views +handler404 = "website.views.custom_404" +handler500 = "website.views.custom_500" + urlpatterns = [ re_path(r'^admin/', admin.site.urls), diff --git a/website/static/website/css/404.css b/website/static/website/css/404.css new file mode 100644 index 00000000..65f21bc8 --- /dev/null +++ b/website/static/website/css/404.css @@ -0,0 +1,220 @@ +/* + * Custom error page styling (#1190). + * + * The 404 page is the light, airy grid-fade: a full-window canvas paints a + * staggered triangle grid that resolves into the logo, with the recovery text + * overlaid. Because the grid is light and busy, each line of text gets a tight + * black highlight (.error-hl, "highlighter" style) for legibility rather than a + * full rectangle. + * + * The 500 page reuses this shell but is static and dark (.error-page--static). + */ + +.error-page { + position: relative; + overflow: hidden; + /* Fill the viewport; the overlay's top padding clears the fixed navbar and + the footer flows beneath. */ + min-height: 100vh; + background: #ffffff; /* the grid canvas paints over this */ + display: flex; + /* Default align-items:stretch lets the overlay fill the height so its + space-between pushes the heading up and the links down, leaving the + vertical center clear for the logo to resolve. */ +} + +/* The animated backdrop. Fills the stage; the overlay renders on top. */ +.error-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; +} + +/* Text + links layer. Pointer-events pass through to the page (so a click + anywhere replays the animation) except on the links themselves. */ +.error-overlay { + position: relative; + z-index: 1; + pointer-events: none; + width: 100%; + max-width: 900px; + margin: 0 auto; + /* Top padding clears the fixed navbar (~70px) but stays tight so the heading + sits well above the central logo; the larger bottom padding lifts the foot + (text + links) up off the settled leaf pile. */ + padding: clamp(3.5rem, 7vh, 5.5rem) var(--space-6) clamp(4.5rem, 11vh, 7rem); + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + text-align: center; +} + +/* Tight black highlight behind the text itself (hugs each wrapped line, not a + bounding rectangle). box-decoration-break:clone repeats the padding/radius on + every line fragment; the generous line-height keeps the per-line bars apart. */ +.error-hl { + display: inline; + background: rgba(0, 0, 0, 0.82); + color: #ffffff; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + padding: 0.12em 0.35em; + border-radius: 5px; +} + +.error-code { + font-family: 'Raleway', sans-serif; + font-weight: var(--font-weight-bold); + font-size: clamp(3rem, 12vw, 7rem); + /* Tight line-height so the highlight box hugs the digits (font size unchanged). */ + line-height: 1.0; + letter-spacing: 0.05em; + /* Gap below restores breathing room between the 404 box and PAGE NOT FOUND + (the tighter line-height had removed it). */ + margin: 0 0 20px; +} + +/* Slimmer highlight just for the big 404 so its black box isn't oversized. */ +.error-code .error-hl { + padding: 0.04em 0.22em; +} + +.error-title { + font-family: 'Raleway', sans-serif; + font-weight: var(--font-weight-bold); + font-size: clamp(var(--font-size-2xl), 5vw, var(--font-size-4xl)); + line-height: 1.5; + letter-spacing: 0.04em; + text-transform: uppercase; + margin: 0; +} + +/* Nudge the heading block down a touch from the top. */ +.error-text { + margin-top: 30px; +} + +/* Supportive text + links, anchored below the central logo. The margin-bottom + lifts the whole group (text + chips together) up off the bottom. */ +.error-foot { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 28px; +} + +.error-subtitle { + font-size: clamp(var(--font-size-base), 2vw, var(--font-size-lg)); + line-height: 1.7; + max-width: 40rem; + margin: 0 auto; +} + +.error-path { + font-family: 'Roboto Mono', 'Courier New', monospace; + font-size: 0.9em; + /* Override Bootstrap's pink color; keep it light on the black highlight. */ + color: #ffffff; + background: rgba(255, 255, 255, 0.22); + border-radius: var(--border-radius-sm); + padding: 0.1em 0.4em; + /* Long/odd paths shouldn't blow out the layout. */ + word-break: break-all; +} + +/* Recovery links sit at the bottom of the stage, below the resolving logo (the + overlay's space-between pushes them here; the margin is just a floor). Dark + pills read clearly over the light grid. */ +.error-links { + pointer-events: auto; + margin-top: var(--space-8); + display: flex; + flex-wrap: wrap; + gap: var(--space-3) var(--space-4); + justify-content: center; +} + +.error-links a { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-5); + border: 1px solid rgba(0, 0, 0, 0.82); + border-radius: 2rem; + color: #ffffff; + font-weight: var(--font-weight-medium); + text-decoration: none; + background: rgba(0, 0, 0, 0.82); + transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease; +} + +.error-links a:hover, +.error-links a:focus-visible { + background: #000000; + border-color: #000000; + transform: translateY(-1px); +} + +/* Keyboard focus must stay clearly visible over the light grid. */ +.error-links a:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* ---- Static variant (500 page: dark, logo image, no canvas) -------------- */ +.error-page--static { + background: #0b1622; + align-items: center; + justify-content: center; +} + +.error-page--static .error-overlay { + justify-content: center; + color: var(--color-text-on-dark); +} + +/* On the dark 500 page the text is plain (no highlight needed) and the links + become light pills. */ +.error-page--static .error-code, +.error-page--static .error-title { + color: #ffffff; +} + +.error-page--static .error-subtitle { + color: var(--color-text-on-dark-muted); +} + +.error-page--static .error-links { + margin-top: var(--space-8); +} + +.error-page--static .error-links a { + color: var(--color-white); + border-color: rgba(255, 255, 255, 0.35); + background: rgba(255, 255, 255, 0.08); +} + +.error-page--static .error-links a:hover, +.error-page--static .error-links a:focus-visible { + background: rgba(255, 255, 255, 0.18); + border-color: var(--color-white); +} + +.error-page--static .error-links a:focus-visible { + outline-color: var(--color-white); +} + +.error-static-logo { + width: clamp(180px, 30vw, 320px); + height: auto; + margin-bottom: var(--space-6); +} + +@media (max-width: 768px) { + .error-overlay { + padding-top: clamp(5rem, 12vh, 7rem); + } +} diff --git a/website/static/website/js/makelab-404.js b/website/static/website/js/makelab-404.js new file mode 100644 index 00000000..121a73bb --- /dev/null +++ b/website/static/website/js/makelab-404.js @@ -0,0 +1,185 @@ +/** + * Makeability Lab 404 Animation — Leaf Fall (#1190) + * + * The page loads fully assembled: a full-window grid of colored triangles with + * the Makeability Lab logo embedded. After a short beat it "breaks" — the + * background grid triangles flutter down like falling leaves and pile at the + * bottom, leaving the logo standing intact. (Per the library, dropLeaves() drops + * only the background grid; the logo's own pieces stay fixed.) + * + * Uses MakeabilityLabLogoLeafFall from the pinned CDN dist (makeabilitylab/js + * 0.5.0). Click anywhere or press R to replay. Honors prefers-reduced-motion by + * rendering just the static logo (grid hidden), no animation. + * + * Recovery content (heading, links) lives in the DOM, not here. + * + * Requires inside a sized parent (.error-page). + * + * @author Makeability Lab + */ + +import { + MakeabilityLabLogo, + MakeabilityLabLogoLeafFall, +} from 'https://cdn.jsdelivr.net/gh/makeabilitylab/js@0.6.0/dist/makelab.logo.js'; + +// ============================================================================= +// Configuration +// ============================================================================= + +const TRIANGLE_SIZE = 70; +// Logo size also sets the grid cell size (the grid is built from the logo's +// triangle size), so shrinking the logo makes the whole grid finer too. +const LOGO_WIDTH_FRACTION = 0.48; +const LOGO_MAX_WIDTH = 320; +const DPR = window.devicePixelRatio || 1; + +// How long the assembled logo holds before the leaves drop. +const DROP_DELAY_MS = 1500; +// Drop animation length (groundStagger 700 + groundFallMax 1700 + buffer); the +// loop stops after this so we don't spin the CPU once everything has settled. +const DROP_ANIM_MS = 2900; + +// ============================================================================= +// Setup +// ============================================================================= + +const canvas = document.getElementById('makelab-404-canvas'); +const ctx = canvas.getContext('2d'); +const stage = canvas.parentElement; // the sized .error-page section + +function prefersReducedMotion() { + if (window.MakeLab && window.MakeLab.prefersReducedMotion) { + return window.MakeLab.prefersReducedMotion(); + } + return !!(window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches); +} + +const reducedMotion = prefersReducedMotion(); +if (reducedMotion) { + canvas.setAttribute('aria-label', 'Makeability Lab logo'); +} + +let logicalWidth = 0; +let logicalHeight = 0; +let logo = null; +let leafFall = null; +let isReady = false; +let startTime = null; // rAF timestamp when the current cycle began +let dropped = false; // whether dropLeaves() has fired this cycle +let running = false; // whether the rAF loop is active + +// ============================================================================= +// Layout +// ============================================================================= + +function sizeCanvas() { + const rect = stage.getBoundingClientRect(); + logicalWidth = Math.max(rect.width, 1); + logicalHeight = Math.max(rect.height, 1); + + canvas.width = logicalWidth * DPR; + canvas.height = logicalHeight * DPR; + ctx.setTransform(DPR, 0, 0, DPR, 0, 0); + + // (Re)build the logo + leaf-fall for the current size. The grid fills the + // canvas, aligned to the centered logo. + logo = new MakeabilityLabLogo(0, 0, TRIANGLE_SIZE); + const naturalWidth = MakeabilityLabLogo.numCols * TRIANGLE_SIZE; + const maxWidth = Math.min(LOGO_MAX_WIDTH, logicalWidth * LOGO_WIDTH_FRACTION); + logo.setLogoSize(Math.min(naturalWidth, maxWidth)); + // Bias the logo slightly below center so it clears the heading above it. + logo.centerLogo(logicalWidth, logicalHeight * 1.08); + // Nudge the logo up by some grid cells. On tall, narrow (mobile) viewports the + // centered logo sits too low, so lift it more there. Use a CONTINUOUS ramp + // (not a hard breakpoint) so there's no jarring jump near 768px: 1 cell on + // desktop, smoothly increasing to ~3 cells as the width shrinks to ~360px. + // (cellSize scales with the logo, so this stays in grid-cell units.) Done + // before building the leaf-fall so its grid aligns to this final position and + // the reveal stays seamless. + const narrowness = Math.max(0, Math.min(1, (768 - logicalWidth) / (768 - 360))); + const cellsUp = 1 + narrowness * 2; + logo.setLogoPosition(logo.x, logo.y - cellsUp * logo.cellSize); + + // Make the "L" overlay semi-translucent so it reads as a subtle sheen and lets + // the colored logo show through (setLTriangleFillColor added in js 0.6.0). + logo.setLTriangleFillColor('rgba(255, 255, 255, 0.5)'); + + leafFall = new MakeabilityLabLogoLeafFall(logo, logicalWidth, logicalHeight, { + startAssembled: true, + }); + + isReady = true; + + if (reducedMotion) { + // Static: jump straight to the settled end state (leaves already piled, + // colored logo intact) with no animation. We can't just hide the grid -- + // the logo's colors ARE pinned grid cells, so hiding it would leave only + // the black outline. + leafFall.dropLeaves(); + leafFall.update(0); // captures the drop start clock + leafFall.update(DROP_ANIM_MS + 1000); // fast-forward to the settled pile + render(); + } else { + start(); + } +} + +// ============================================================================= +// Rendering +// ============================================================================= + +function render() { + if (!isReady) return; + ctx.clearRect(0, 0, logicalWidth, logicalHeight); + leafFall.draw(ctx); +} + +// ============================================================================= +// Animation loop — hold assembled, drop leaves, then settle and stop. +// ============================================================================= + +function frame(now) { + if (startTime === null) startTime = now; + const elapsed = now - startTime; + + if (!dropped && elapsed >= DROP_DELAY_MS) { + leafFall.dropLeaves(); + dropped = true; + } + + leafFall.update(elapsed); + render(); + + if (elapsed < DROP_DELAY_MS + DROP_ANIM_MS) { + requestAnimationFrame(frame); + } else { + running = false; // settled; stop spinning the CPU + } +} + +function start() { + if (reducedMotion || !leafFall) return; + leafFall.reset(); + startTime = null; + dropped = false; + if (!running) { + running = true; + requestAnimationFrame(frame); + } +} + +// ============================================================================= +// Kick-off +// ============================================================================= + +const resizeObserver = new ResizeObserver(() => sizeCanvas()); +resizeObserver.observe(stage); +sizeCanvas(); + +if (!reducedMotion) { + // Replay on click or the "R" key. + window.addEventListener('click', start); + window.addEventListener('keydown', (e) => { if (e.key === 'r' || e.key === 'R') start(); }); +} diff --git a/website/templates/website/404.html b/website/templates/website/404.html new file mode 100644 index 00000000..8597b940 --- /dev/null +++ b/website/templates/website/404.html @@ -0,0 +1,80 @@ +{% extends "website/base.html" %} +{% load static %} + +{% comment %} +================================================================================ +CUSTOM 404 PAGE (#1190) + +A branded "leaf fall" experience: the page loads fully assembled (a full-window +grid of triangles with the logo embedded), then the background grid flutters down +like falling leaves, leaving the Makeability Lab logo standing in the center +(makelab-404.js). The real recovery content -- big "404 / Page not found" +heading above the logo, supportive text and navigation links below -- lives in +the DOM so it works without JavaScript and for screen readers. Honors +prefers-reduced-motion: just the static logo, no animation. + +The text sits over the busy grid, so each line gets a tight black highlight +(.error-hl) for legibility. +================================================================================ +{% endcomment %} + +{% block pagetitle %}Page Not Found (404){% endblock %} + +{% block stylesheets %} + +{% endblock %} + +{% block external_scripts %} + +{% endblock %} + +{% comment %} No hero carousel on the error page. {% endcomment %} +{% block maincarousel %}{% endblock %} + +{% block content %} +
+ {% comment %} + The canvas is purely the branded backdrop; all meaningful content is the + text overlay below. role="img" + aria-label give it an accessible name; + makelab-404.js drops "Animated" from the label under reduced motion. + {% endcomment %} + + +
+
+ +

Page not found

+
+ +
+

+ + We couldn’t find {{ requested_path }}. + The page may have been moved or removed — here’s where to go next: + +

+ + +
+
+
+{% endblock %} diff --git a/website/templates/website/500.html b/website/templates/website/500.html new file mode 100644 index 00000000..dea598b9 --- /dev/null +++ b/website/templates/website/500.html @@ -0,0 +1,49 @@ +{% extends "website/base.html" %} +{% load static %} + +{% comment %} +================================================================================ +CUSTOM 500 PAGE (#1190) + +Deliberately minimal and static: handler500 renders with a bare context (no +custom context processors) and a 500 means something already went wrong, so this +page avoids the canvas animation / CDN dependency entirely. It reuses the dark +error-page styling with a static logo image instead. +================================================================================ +{% endcomment %} + +{% block pagetitle %}Server Error (500){% endblock %} + +{% block stylesheets %} + +{% endblock %} + +{% block maincarousel %}{% endblock %} + +{% block content %} +
+
+ + +

Something broke on our end.

+

+ A server error occurred while loading this page. It’s us, not you. + Try again in a moment, or head back home. +

+ + +
+
+{% endblock %} diff --git a/website/tests/test_custom_404.py b/website/tests/test_custom_404.py new file mode 100644 index 00000000..7905bc18 --- /dev/null +++ b/website/tests/test_custom_404.py @@ -0,0 +1,66 @@ +""" +Regression tests for the custom 404 page (#1190). + +A custom ``handler404`` only takes effect when ``DEBUG=False`` -- which is the +default under the test settings -- so the Django test client exercises the real +error path here without any DEBUG hackery (the local-dev preview route, which is +the part that needs DEBUG=True, is covered separately below by asserting it stays +*off* in production-like settings). + +These run as ``DatabaseTestCase`` because the page extends ``base.html``, whose +footer touches the request/template stack; using the DB-backed base keeps it +consistent with the rest of the view-layer suite. +""" + +from django.urls import NoReverseMatch, reverse + +from website.tests.base import DatabaseTestCase + + +class Custom404Tests(DatabaseTestCase): + """The handler404 wiring renders our branded template with a 404 status.""" + + # A path that matches no URL pattern (the catch-all project route is + # commented out in website/urls.py, so a single bogus segment 404s). + BOGUS_URL = "/this-page-does-not-exist-1190/" + + def test_unknown_url_returns_404_status(self): + response = self.client.get(self.BOGUS_URL) + self.assertEqual(response.status_code, 404) + + def test_unknown_url_uses_custom_template(self): + response = self.client.get(self.BOGUS_URL) + self.assertTemplateUsed(response, "website/404.html") + + def test_404_page_offers_a_way_home(self): + """The page must surface real navigation, not just the animation.""" + response = self.client.get(self.BOGUS_URL) + html = response.content.decode() + # The recovery links are the whole point: a lost visitor needs routes + # out. Assert the key destinations are present and linked. + self.assertIn(reverse("website:index"), html) + self.assertIn(reverse("website:people"), html) + self.assertIn(reverse("website:publications"), html) + self.assertIn(reverse("website:projects"), html) + + def test_404_page_does_not_leak_raw_requested_path(self): + """ + The requested path is echoed back, but must be HTML-escaped so a crafted + URL can't inject markup (reflected-XSS guard). Django autoescaping does + this; this test pins it so a future {% autoescape off %} can't regress it. + """ + response = self.client.get("//") + self.assertEqual(response.status_code, 404) + self.assertNotIn("", response.content.decode()) + + +class Custom404PreviewRouteTests(DatabaseTestCase): + """The dev-only preview route must never exist in production-like settings.""" + + def test_preview_route_absent_when_not_debug(self): + # Under the test settings DEBUG is False, mirroring production. The + # preview route is registered only `if settings.DEBUG`, so it should + # neither reverse nor resolve here. + with self.assertRaises(NoReverseMatch): + reverse("website:custom_404_preview") + self.assertEqual(self.client.get("/404-preview/").status_code, 404) diff --git a/website/urls.py b/website/urls.py index bede2f68..3bb03353 100644 --- a/website/urls.py +++ b/website/urls.py @@ -110,4 +110,16 @@ # re_path(r'(?P[a-zA-Z\- ]+)/$', views.redirect_project, name='project'), ] +# Local-dev only (#1190): the real 404/500 pages render only when DEBUG=False, +# but DEBUG=False also stops runserver from serving static files, so the page's +# CSS/JS break -- which is exactly what derailed the earlier attempts. These +# preview routes let you view the error pages at a normal URL with DEBUG=True +# (static files intact). They are registered ONLY when DEBUG is on, so they can +# never appear in production. The handler wiring itself is covered by tests. +if settings.DEBUG: + urlpatterns += [ + path('404-preview/', views.preview_404, name='custom_404_preview'), + path('500-preview/', views.preview_500, name='custom_500_preview'), + ] + urlpatterns = format_suffix_patterns(urlpatterns) \ No newline at end of file diff --git a/website/views/__init__.py b/website/views/__init__.py index 0cbd2219..49e8dc6a 100644 --- a/website/views/__init__.py +++ b/website/views/__init__.py @@ -10,4 +10,5 @@ from .view_project_people import * from .serve_pdf import * from .awards import awards +from .custom_404 import custom_404, custom_500, preview_404, preview_500 diff --git a/website/views/custom_404.py b/website/views/custom_404.py new file mode 100644 index 00000000..34cfcaf4 --- /dev/null +++ b/website/views/custom_404.py @@ -0,0 +1,68 @@ +""" +Custom error pages (#1190). + +Django routes unmatched URLs and unhandled server errors to the module-level +``handler404`` / ``handler500`` callables declared in the *root* URLconf +(``makeabilitylab/urls.py``) -- not the app URLconf. These views render the +branded Makeability Lab error templates instead of Django's bare defaults. + +Important: custom handlers only run when ``DEBUG=False``. In local dev +(``DEBUG=True``) Django shows its own debug traceback page instead, so to *see* +these pages while iterating use the DEBUG-only preview routes wired up in +``website/urls.py`` (``/404-preview/``, ``/500-preview/``). +""" + +import logging + +from django.shortcuts import render + +# Module logger (configured in settings.LOGGING). +_logger = logging.getLogger(__name__) + + +def _render_404(request, status=404): + """Render the 404 template. Shared by the handler and the dev preview.""" + context = { + # Echoed back to the visitor so they can spot a typo'd URL. Rendered + # through Django's autoescaping in the template (never {% autoescape off %}) + # so a crafted path can't inject markup -- a regression test pins this. + "requested_path": request.path, + # base.html keys the navbar to a readable light theme when this is set, + # which the dark 404 canvas needs. + "navbar_white": True, + } + return render(request, "website/404.html", context, status=status) + + +def custom_404(request, exception): + """ + handler404. Logs the missing path (useful for spotting broken inbound links) + and renders the branded 404 page with a 404 status. + + ``exception`` is supplied by Django (the raised ``Http404``); we don't echo + it to the page -- it can contain internal detail -- but it's available for + logging if needed. + """ + _logger.warning("404 (page not found) for path: %s", request.path) + return _render_404(request) + + +def custom_500(request): + """ + handler500. Note Django calls this with *only* ``request`` (no exception), + and renders it with the bare ``django.template.context.RequestContext`` -- + so the template must not depend on custom context processors. ``500.html`` + is intentionally a static, no-JS fallback for that reason. + """ + _logger.error("500 (server error) for path: %s", request.path) + return render(request, "website/500.html", status=500) + + +def preview_404(request): + """DEBUG-only: view the 404 page at a real URL with static files served.""" + return _render_404(request, status=200) + + +def preview_500(request): + """DEBUG-only: view the 500 page at a real URL with static files served.""" + return render(request, "website/500.html", {"navbar_white": True}, status=200)