From e44666ad9869afb15fc340eb117d24f969a23ac8 Mon Sep 17 00:00:00 2001 From: julioest Date: Tue, 28 Apr 2026 20:50:57 -0400 Subject: [PATCH 01/30] Story 2296: V3 Post Detail page Adds the V3 post detail page at /v3/news/entry//, used by all Entry types (blog, news, link, video, poll). - New V3PostDetailView with real next/related queries. - Reusable _post_header include (title, meta, author block). - Body escapes user content and falls back to summary when content is empty. --- config/v3_urls.py | 7 +- news/views.py | 58 ++++++++++++++- static/css/v3/post-detail.css | 95 +++++++++++++++++++++++++ static/css/v3/post-header.css | 35 +++++++++ templates/news/v3/detail.html | 47 ++++++++++++ templates/v3/includes/_post_header.html | 26 +++++++ 6 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 static/css/v3/post-detail.css create mode 100644 static/css/v3/post-header.css create mode 100644 templates/news/v3/detail.html create mode 100644 templates/v3/includes/_post_header.html diff --git a/config/v3_urls.py b/config/v3_urls.py index 545b93adc..c7d7d81f7 100644 --- a/config/v3_urls.py +++ b/config/v3_urls.py @@ -48,7 +48,7 @@ from django.urls import path from core.views import LearnPageView, V3ComponentDemoView -from news.views import V3AllTypesCreateView +from news.views import V3AllTypesCreateView, V3PostDetailView from users.views import ( V3LoginView, V3PasswordResetDoneView, @@ -74,6 +74,11 @@ V3AllTypesCreateView.as_view(), name="v3-news-create", ), + path( + "v3/news/entry//", + V3PostDetailView.as_view(), + name="v3-news-detail", + ), path( "v3/accounts/signup/", V3SignupView.as_view(), diff --git a/news/views.py b/news/views.py index 23c51c552..4b30814cb 100644 --- a/news/views.py +++ b/news/views.py @@ -11,7 +11,7 @@ from django.http import Http404, HttpResponseRedirect, HttpResponseForbidden from django.shortcuts import redirect, get_object_or_404 from django.template.defaultfilters import date as datefilter -from django.urls import reverse_lazy +from django.urls import reverse, reverse_lazy from django.utils.functional import cached_property from django.utils.http import url_has_allowed_host_and_scheme from django.utils.timezone import localtime, now @@ -422,6 +422,62 @@ def get_context_data(self, **kwargs): return context +class V3PostDetailView(V3Mixin, TemplateView): + v3_template_name = "news/v3/detail.html" + + TAG_LABELS = {"blogpost": "blog"} + + def get_v3_context_data(self, **kwargs): + entry = get_object_or_404(Entry, slug=self.kwargs["slug"]) + if not entry.can_view(self.request.user): + raise Http404() + + next_entry = ( + Entry.objects.published() + .select_related("author") + .filter(publish_at__gt=entry.publish_at) + .exclude(pk=entry.pk) + .order_by("publish_at") + .first() + ) + related_qs = ( + Entry.objects.published().select_related("author").exclude(pk=entry.pk) + ) + if next_entry is not None: + related_qs = related_qs.exclude(pk=next_entry.pk) + + return { + "object": entry, + "post_author": self._author_card(entry.author), + "post_tag": self.TAG_LABELS.get(entry.tag, entry.tag), + "next_post_items": ( + [self._post_card_item(next_entry)] if next_entry else [] + ), + "related_posts": [ + self._post_card_item(e) for e in related_qs.order_by("-publish_at")[:3] + ], + } + + @staticmethod + def _author_card(author): + return { + "name": author.display_name, + "profile_url": author.github_profile_url or "", + "role": "Contributor", + "avatar_url": author.get_avatar_url(), + } + + @classmethod + def _post_card_item(cls, entry): + return { + "title": entry.title, + "url": reverse("v3-news-detail", args=[entry.slug]), + "date": entry.publish_at, + "tag": entry.tag, + "author": cls._author_card(entry.author), + } + + class V3AllTypesCreateView(V3Mixin, AllTypesCreateView): v3_template_name = "news/v3/create.html" http_method_names = ["get", "post"] diff --git a/static/css/v3/post-detail.css b/static/css/v3/post-detail.css new file mode 100644 index 000000000..46167a016 --- /dev/null +++ b/static/css/v3/post-detail.css @@ -0,0 +1,95 @@ +/* ========================================================================== + Post Detail — single-post page layout. + Layout: content-centered column with sibling sections below (Next Post, + Related Posts). Single column across all breakpoints per ticket #2296. + ========================================================================== */ + +.post-detail-page { + display: flex; + flex-direction: column; + gap: var(--space-xl); + max-width: 696px; + margin: 0 auto; + padding: var(--space-xl) var(--space-large); + font-family: var(--font-sans); + color: var(--color-text-primary); +} + +.post-detail { + display: flex; + flex-direction: column; + gap: var(--space-large); +} + +.post-detail__figure { + margin: 0; +} + +.post-detail__image { + display: block; + width: 100%; + height: auto; + border-radius: var(--border-radius-l); +} + +.post-detail__body { + font-size: var(--font-size-base); + line-height: var(--line-height-relaxed); + color: var(--color-text-primary); +} + +.post-detail__body > * + * { + margin-top: var(--space-medium); +} + +.post-detail__body a { + color: var(--color-text-link-accent); + text-decoration: underline; +} + +.post-detail__body img { + max-width: 100%; + height: auto; + border-radius: var(--border-radius-m); +} + +.post-detail__body pre, +.post-detail__body code { + font-family: var(--font-mono, monospace); + font-size: var(--font-size-small); +} + +.post-detail__body pre { + padding: var(--space-medium); + border-radius: var(--border-radius-m); + background: var(--color-surface-mid); + overflow-x: auto; +} + +.post-detail__next, +.post-detail__related { + display: flex; + flex-direction: column; +} + +/* Related posts render in a single column across all breakpoints (AC). */ +.post-detail__related .card-group__list { + grid-template-columns: 1fr; +} + +/* ---------- Breakpoints ---------- */ + +/* Tablet and desktop: add vertical breathing room */ +@media (min-width: 768px) { + .post-detail-page { + padding: var(--space-xxl) var(--space-large); + } +} + +/* Mobile: tighter gaps */ +@media (max-width: 767px) { + .post-detail-page { + gap: var(--space-large); + padding: var(--space-large); + } +} diff --git a/static/css/v3/post-header.css b/static/css/v3/post-header.css new file mode 100644 index 000000000..e5740f0e1 --- /dev/null +++ b/static/css/v3/post-header.css @@ -0,0 +1,35 @@ +/* ========================================================================== + Post Header — title, meta row, and author block at the top of a post. + ========================================================================== */ + +.post-header { + display: flex; + flex-direction: column; + gap: var(--space-medium); +} + +.post-header__title { + margin: 0; + font-family: var(--font-display); + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-tight); + letter-spacing: var(--letter-spacing-display-regular); + color: var(--color-text-primary); +} + +.post-header__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +.post-header__meta > :not(:first-child)::before { + content: "•"; + color: var(--color-text-secondary); + user-select: none; + margin-right: 6px; +} diff --git a/templates/news/v3/detail.html b/templates/news/v3/detail.html new file mode 100644 index 000000000..9314dc96b --- /dev/null +++ b/templates/news/v3/detail.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}{{ object.title }}{% endblock %} + +{% block extra_head %} + {{ block.super }} + + +{% endblock %} + +{% block content %} +
+
+ {% include "v3/includes/_post_header.html" with title=object.title publish_date=object.publish_at tag=post_tag author=post_author %} + + {% if object.image %} +
+ +
+ {% endif %} + +
+ {% if object.external_url %} +

+ {{ object.external_url }} +

+ {% endif %} + {% with body=object.content|default:object.visible_content %} + {% if body %}{{ body|urlize|linebreaks }}{% endif %} + {% endwith %} +
+
+ + {% if next_post_items %} +
+ {% include "v3/includes/_post_card.html" with heading="Next Post" items=next_post_items variant="card" theme="default" %} +
+ {% endif %} + + {% if related_posts %} +
+ {% include "v3/includes/_post_card.html" with heading="Related Post" items=related_posts variant="card" theme="default" %} +
+ {% endif %} +
+{% endblock %} diff --git a/templates/v3/includes/_post_header.html b/templates/v3/includes/_post_header.html new file mode 100644 index 000000000..0568b993d --- /dev/null +++ b/templates/v3/includes/_post_header.html @@ -0,0 +1,26 @@ +{% comment %} + V3 Post Header – header block for a single post: title, meta row + (publish date + optional category tag), and an author profile block. + + Variables: + title (string, required) + publish_date (datetime, required) + tag (string, optional) — short label rendered as plain text + author (dict, optional) — passed verbatim to _user_profile.html + + Usage: + {% include "v3/includes/_post_header.html" with title=object.title publish_date=object.publish_at tag=object.tag author=post_author %} +{% endcomment %} +
+

{{ title }}

+
+ + {% if tag %} + {{ tag|capfirst }} + + {% endif %} +
+ {% if author %} + {% include "v3/includes/_user_profile.html" with author=author only %} + {% endif %} +
From 0294b8ebfeffaea23040a989461c7ee09da5d722 Mon Sep 17 00:00:00 2001 From: julioest Date: Wed, 29 Apr 2026 20:43:49 -0400 Subject: [PATCH 02/30] feat: text_paragraphs filter for post bodies Source content hard-wrapped at ~80 chars was rendering a forced
on every soft newline. The new filter splits on blank lines into

tags and joins single newlines with spaces, so prose flows to the container width. Autolinks URLs and escapes HTML; preserves XSS protection. --- news/templatetags/news_tags.py | 24 ++++++++++++++++++++++++ templates/news/v3/detail.html | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/news/templatetags/news_tags.py b/news/templatetags/news_tags.py index 4aaae85af..87c12d10d 100644 --- a/news/templatetags/news_tags.py +++ b/news/templatetags/news_tags.py @@ -1,7 +1,31 @@ +import re + from django import template +from django.template.defaultfilters import urlize +from django.utils.safestring import mark_safe register = template.Library() +_PARAGRAPH_SPLIT = re.compile(r"\n\s*\n+") + + +@register.filter +def text_paragraphs(value): + """Render hard-wrapped plain text as autolinked paragraphs. + + Blank lines become paragraph breaks; single newlines inside a + paragraph collapse to spaces so source hard-wrapped at ~80 chars + flows naturally to the container width. + """ + if not value: + return "" + paragraphs = [] + for chunk in _PARAGRAPH_SPLIT.split(str(value)): + text = " ".join(line.strip() for line in chunk.splitlines() if line.strip()) + if text: + paragraphs.append(f"

{urlize(text, autoescape=True)}

") + return mark_safe("\n".join(paragraphs)) + @register.simple_tag(takes_context=True) def can_edit(context, news_item, *args, **kwargs): diff --git a/templates/news/v3/detail.html b/templates/news/v3/detail.html index 9314dc96b..b0124fb79 100644 --- a/templates/news/v3/detail.html +++ b/templates/news/v3/detail.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% load static %} +{% load news_tags %} {% block title %}{{ object.title }}{% endblock %} @@ -27,7 +28,7 @@

{% endif %} {% with body=object.content|default:object.visible_content %} - {% if body %}{{ body|urlize|linebreaks }}{% endif %} + {% if body %}{{ body|text_paragraphs }}{% endif %} {% endwith %} From 293a83f3d8a2e6b79403841258d9e9e734ef463d Mon Sep 17 00:00:00 2001 From: julioest Date: Wed, 29 Apr 2026 20:43:54 -0400 Subject: [PATCH 03/30] style: V3 post detail visuals to match Figma - Body typography per Figma: text-secondary, line-height 135%, letter-spacing, paragraph spacing via flex gap. - 1px separator line above body with 32px on each side. - 64px gap between body and first sibling section; 32px between Next Post and Related Post. - Card-group + post-card refinement: page-flush borders, outer-corner rounding only, transparent card-group background in light and dark, subtle per-card background. - Post-header bullet now a CSS-drawn dot. - Spacing fix: Figma "xxl" (32px) maps to --space-xl. --- static/css/v3/post-detail.css | 125 +++++++++++++++++++++++++++++++--- static/css/v3/post-header.css | 14 +++- 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/static/css/v3/post-detail.css b/static/css/v3/post-detail.css index 46167a016..56f7221f9 100644 --- a/static/css/v3/post-detail.css +++ b/static/css/v3/post-detail.css @@ -1,7 +1,7 @@ /* ========================================================================== Post Detail — single-post page layout. Layout: content-centered column with sibling sections below (Next Post, - Related Posts). Single column across all breakpoints per ticket #2296. + Related Posts). ========================================================================== */ .post-detail-page { @@ -18,7 +18,7 @@ .post-detail { display: flex; flex-direction: column; - gap: var(--space-large); + gap: var(--space-xl); } .post-detail__figure { @@ -33,13 +33,24 @@ } .post-detail__body { + display: flex; + flex-direction: column; + gap: var(--space-large); font-size: var(--font-size-base); - line-height: var(--line-height-relaxed); - color: var(--color-text-primary); + line-height: var(--line-height-loose); + color: var(--color-text-secondary); + padding-top: var(--space-xl); + border-top: 1px solid var(--color-stroke-weak); } -.post-detail__body > * + * { - margin-top: var(--space-medium); +.post-detail__body p { + padding-top: 0; + padding-bottom: 0; + font-family: var(--font-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-loose); + letter-spacing: var(--letter-spacing-tight); } .post-detail__body a { @@ -47,6 +58,12 @@ text-decoration: underline; } +.post-detail__external-url a { + color: var(--color-text-secondary); + text-decoration: underline; + text-underline-position: from-font; +} + .post-detail__body img { max-width: 100%; height: auto; @@ -55,7 +72,7 @@ .post-detail__body pre, .post-detail__body code { - font-family: var(--font-mono, monospace); + font-family: var(--font-mono); font-size: var(--font-size-small); } @@ -72,17 +89,107 @@ flex-direction: column; } -/* Related posts render in a single column across all breakpoints (AC). */ +/* The first section after the article gets an extra 32px of top spacing + on top of the page-level 32px gap, totaling 64px (Figma spec). The + subsequent section (next -> related) keeps the plain 32px gap. */ +.post-detail-page > .post-detail + section { + margin-top: var(--space-xl); +} + +/* Inner card-group defaults to max-width: 458px, a tinted background, + a border, and vertical padding; none of those belong in the + post-detail context. */ +.post-detail__next .card-group, +.post-detail__related .card-group { + max-width: none; + background: none; + border: none; + padding-top: 0; + padding-bottom: 0; +} + +.post-detail__next .card-group__heading, +.post-detail__related .card-group__heading { + padding-left: 0; +} + +/* Specificity bumped by chaining .card-group--card to beat the + `.card-group--card .card-group__list { padding: 0 var(--space-card) }` + rule in card-group.css. */ +.post-detail__next .card-group--card .card-group__list, +.post-detail__related .card-group--card .card-group__list { + padding: 0; + gap: 0; +} + +/* `.card-group--card .card-group__item` (card-group.css) gives each + item its own background and border-radius. The per-item radius is + what creates the apparent "indented hairline" between stacked cards + (rounded corners notch into each other). Strip both so the post-card + inside controls the visual. */ +.post-detail__next .card-group--card .card-group__item, +.post-detail__related .card-group--card .card-group__item { + background: none; + border-radius: 0; +} + +.post-detail__next .post-card, +.post-detail__related .post-card { + background: var(--color-surface-weak); + border: 1px solid var(--color-stroke-weak); +} + +/* Collapse adjacent borders so stacked cards share a single hairline. */ +.post-detail__next .card-group__item:not(:first-child) .post-card, +.post-detail__related .card-group__item:not(:first-child) .post-card { + border-top: none; +} + +/* Stacked post-cards: square by default; only the outer two corners of + the stack are rounded. Single-item lists (e.g. Next Post) end up + fully rounded since :first-child and :last-child both match. */ +.post-detail__next .post-card, +.post-detail__related .post-card { + border-radius: 0; +} + +.post-detail__next .card-group__item:first-child .post-card, +.post-detail__related .card-group__item:first-child .post-card { + border-top-left-radius: var(--border-radius-xl); + border-top-right-radius: var(--border-radius-xl); +} + +.post-detail__next .card-group__item:last-child .post-card, +.post-detail__related .card-group__item:last-child .post-card { + border-bottom-left-radius: var(--border-radius-xl); + border-bottom-right-radius: var(--border-radius-xl); +} + +/* Related posts render in a single column across all breakpoints. */ .post-detail__related .card-group__list { grid-template-columns: 1fr; } +/* ---------- Dark mode ---------- */ + +/* card-group.css applies a grey background in dark mode via + `html.dark .card-group--default`. That selector is more specific than + our base override, so we re-strip it here for the post-detail + sections. */ +html.dark .post-detail__next .card-group, +html.dark .post-detail__related .card-group { + background: none; +} + /* ---------- Breakpoints ---------- */ /* Tablet and desktop: add vertical breathing room */ @media (min-width: 768px) { .post-detail-page { - padding: var(--space-xxl) var(--space-large); + padding-top: var(--space-xxl); + padding-bottom: var(--space-xxl); + padding-left: 0; + padding-right: 0; } } diff --git a/static/css/v3/post-header.css b/static/css/v3/post-header.css index e5740f0e1..5b92a4930 100644 --- a/static/css/v3/post-header.css +++ b/static/css/v3/post-header.css @@ -18,6 +18,8 @@ color: var(--color-text-primary); } +/* Meta row spacing is a Figma-spec value (6px) with no exact token + equivalent (--space-s = 4px, --space-default = 8px desktop). */ .post-header__meta { display: flex; flex-wrap: wrap; @@ -27,9 +29,15 @@ color: var(--color-text-secondary); } +/* Inline separator dot. Width/height are glyph metrics, not layout + spacing, so they don't map to a space-* token. */ .post-header__meta > :not(:first-child)::before { - content: "•"; - color: var(--color-text-secondary); - user-select: none; + content: ""; + display: inline-block; + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--color-text-secondary); margin-right: 6px; + vertical-align: middle; } From 118a4933fb3ea719c437a412afad8cd5bbda7d88 Mon Sep 17 00:00:00 2001 From: julioest Date: Wed, 29 Apr 2026 20:56:52 -0400 Subject: [PATCH 04/30] feat: wire V3 post author role and badge Author role derives from user.maintainers (Maintainer when the user maintains any LibraryVersion, Contributor otherwise). Badge picks the user's first Badge and points at the static convention static/img/v3/badges/badge-{name}.png. Both lookups are batched via prefetch_related on the entry, next, and related querysets. --- news/views.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/news/views.py b/news/views.py index 4b30814cb..c570cc8fe 100644 --- a/news/views.py +++ b/news/views.py @@ -427,21 +427,32 @@ class V3PostDetailView(V3Mixin, TemplateView): TAG_LABELS = {"blogpost": "blog"} + AUTHOR_PREFETCH = ("author__badges", "author__maintainers") + def get_v3_context_data(self, **kwargs): - entry = get_object_or_404(Entry, slug=self.kwargs["slug"]) + entry = get_object_or_404( + Entry.objects.select_related("author").prefetch_related( + *self.AUTHOR_PREFETCH + ), + slug=self.kwargs["slug"], + ) if not entry.can_view(self.request.user): raise Http404() next_entry = ( Entry.objects.published() .select_related("author") + .prefetch_related(*self.AUTHOR_PREFETCH) .filter(publish_at__gt=entry.publish_at) .exclude(pk=entry.pk) .order_by("publish_at") .first() ) related_qs = ( - Entry.objects.published().select_related("author").exclude(pk=entry.pk) + Entry.objects.published() + .select_related("author") + .prefetch_related(*self.AUTHOR_PREFETCH) + .exclude(pk=entry.pk) ) if next_entry is not None: related_qs = related_qs.exclude(pk=next_entry.pk) @@ -460,11 +471,22 @@ def get_v3_context_data(self, **kwargs): @staticmethod def _author_card(author): + # Truthiness checks (rather than .exists() / .first() with kwargs) + # so prefetch_related caches are reused — see queryset setup below. + is_maintainer = bool(author.maintainers.all()) + badges = list(author.badges.all()) + badge = badges[0] if badges else None + badge_url = ( + f"{settings.STATIC_URL}img/v3/badges/badge-{badge.name}.png" + if badge and badge.name + else "" + ) return { "name": author.display_name, "profile_url": author.github_profile_url or "", - "role": "Contributor", + "role": "Maintainer" if is_maintainer else "Contributor", "avatar_url": author.get_avatar_url(), + "badge_url": badge_url, } @classmethod From 9428b7218950cf192f842c1c4a0b6f65b7ca10b0 Mon Sep 17 00:00:00 2001 From: julioest Date: Wed, 29 Apr 2026 21:00:33 -0400 Subject: [PATCH 05/30] feat: pass description on V3 post-card data _post_card_item now exposes entry.summary as description so the cards have content for the description slot the PostFeed work in flight will render. No template changes here; the dict is plumbed end-to-end on our side and waits for the PostFeed include to read it. --- news/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/news/views.py b/news/views.py index c570cc8fe..81aad02bb 100644 --- a/news/views.py +++ b/news/views.py @@ -493,6 +493,7 @@ def _author_card(author): def _post_card_item(cls, entry): return { "title": entry.title, + "description": entry.summary or "", "url": reverse("v3-news-detail", args=[entry.slug]), "date": entry.publish_at, "tag": entry.tag, From 7db8b8a6c08c49c477b94a3213801d0b58ea63c4 Mon Sep 17 00:00:00 2001 From: julioest Date: Wed, 29 Apr 2026 23:06:43 -0400 Subject: [PATCH 06/30] fix: add aria-label to V3 post external URL link Link entries render their external URL as the link text, which screen readers spell out character by character. The new aria-label gives a meaningful announcement (post title plus new-tab cue) while keeping the URL visible for sighted users. Focus-visible styling is already covered globally by v3-style-overrides.css; body text contrast meets WCAG AA in both light and dark modes. --- templates/news/v3/detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/news/v3/detail.html b/templates/news/v3/detail.html index b0124fb79..314a60013 100644 --- a/templates/news/v3/detail.html +++ b/templates/news/v3/detail.html @@ -24,7 +24,7 @@
{% if object.external_url %}

- {{ object.external_url }} + {{ object.external_url }}

{% endif %} {% with body=object.content|default:object.visible_content %} From 29b6a4ed2bfc607e21adcb15769cea9c7b435be7 Mon Sep 17 00:00:00 2001 From: julioest Date: Wed, 29 Apr 2026 23:08:59 -0400 Subject: [PATCH 07/30] style: V3 post detail mobile + tablet spacing Mobile (<768px): - post-header gap raised to --space-large. - 64px between article body and the first sibling section. - 64px page padding-bottom (gap below the last related card). Tablet (768-1279px): - post-header gap raised to --space-large. --- static/css/v3/post-detail.css | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/static/css/v3/post-detail.css b/static/css/v3/post-detail.css index 56f7221f9..6578f1b65 100644 --- a/static/css/v3/post-detail.css +++ b/static/css/v3/post-detail.css @@ -193,10 +193,27 @@ html.dark .post-detail__related .card-group { } } -/* Mobile: tighter gaps */ +/* Tablet only */ +@media (min-width: 768px) and (max-width: 1279px) { + .post-header { + gap: var(--space-large); + } +} + +/* Mobile */ @media (max-width: 767px) { + .post-header { + gap: var(--space-large); + } .post-detail-page { gap: var(--space-large); padding: var(--space-large); + padding-bottom: 64px; + } + /* Total gap between article body and the first sibling section is + 64px on mobile (Figma spec). Subtracts the page-level flex gap so + gap + margin-top = 64. */ + .post-detail-page > .post-detail + section { + margin-top: calc(64px - var(--space-large)); } } From 35fd291af4af7ca0fb5e08f7aa44f51a69e1c36b Mon Sep 17 00:00:00 2001 From: julioest Date: Wed, 29 Apr 2026 23:09:04 -0400 Subject: [PATCH 08/30] style: V3 post-header date format m/d/Y The post-header date now reads m/d/Y per design. Next/related card dates still render d/m/Y since that format lives in the shared _post_card.html include, which is also used by the homepage, learn page, and component demo. Updating those is a separate change. --- templates/v3/includes/_post_header.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/v3/includes/_post_header.html b/templates/v3/includes/_post_header.html index 0568b993d..1a9413732 100644 --- a/templates/v3/includes/_post_header.html +++ b/templates/v3/includes/_post_header.html @@ -14,7 +14,7 @@

{{ title }}

- + {% if tag %} {{ tag|capfirst }} From dbc1e530cb5f4cd2af7959aff089c59cf8262605 Mon Sep 17 00:00:00 2001 From: julioest Date: Tue, 5 May 2026 10:28:03 -0400 Subject: [PATCH 09/30] fix: use --font-code token in V3 post body --- static/css/v3/post-detail.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/css/v3/post-detail.css b/static/css/v3/post-detail.css index 6578f1b65..4d60566a7 100644 --- a/static/css/v3/post-detail.css +++ b/static/css/v3/post-detail.css @@ -72,7 +72,7 @@ .post-detail__body pre, .post-detail__body code { - font-family: var(--font-mono); + font-family: var(--font-code); font-size: var(--font-size-small); } From 30df4f6ebc08d06910b119ff758cef89e3426118 Mon Sep 17 00:00:00 2001 From: julioest Date: Tue, 5 May 2026 10:28:07 -0400 Subject: [PATCH 10/30] fix: pluralize Related Posts heading --- templates/news/v3/detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/news/v3/detail.html b/templates/news/v3/detail.html index 314a60013..743ceb585 100644 --- a/templates/news/v3/detail.html +++ b/templates/news/v3/detail.html @@ -41,7 +41,7 @@ {% if related_posts %}
- {% include "v3/includes/_post_card.html" with heading="Related Post" items=related_posts variant="card" theme="default" %} + {% include "v3/includes/_post_card.html" with heading="Related Posts" items=related_posts variant="card" theme="default" %}
{% endif %}
From 433b601647cc042240b05b700d88c1a9c59239cc Mon Sep 17 00:00:00 2001 From: julioest Date: Tue, 5 May 2026 10:28:11 -0400 Subject: [PATCH 11/30] style: V3 post-detail card and profile gaps --- static/css/v3/post-detail.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/static/css/v3/post-detail.css b/static/css/v3/post-detail.css index 4d60566a7..738d9280b 100644 --- a/static/css/v3/post-detail.css +++ b/static/css/v3/post-detail.css @@ -139,6 +139,15 @@ border: 1px solid var(--color-stroke-weak); } +.post-detail-page .post-detail__next .post-card__title-block, +.post-detail-page .post-detail__related .post-card__title-block { + gap: var(--space-s); +} + +.post-detail-page .user-profile__header { + row-gap: var(--space-default); +} + /* Collapse adjacent borders so stacked cards share a single hairline. */ .post-detail__next .card-group__item:not(:first-child) .post-card, .post-detail__related .card-group__item:not(:first-child) .post-card { From 09d63aaf10416ac75c4667570cec437a3b57395f Mon Sep 17 00:00:00 2001 From: julioest Date: Tue, 5 May 2026 16:38:53 -0400 Subject: [PATCH 12/30] fix: tighten V3 post-detail mobile padding --- static/css/v3/post-detail.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/css/v3/post-detail.css b/static/css/v3/post-detail.css index 738d9280b..71c4529ed 100644 --- a/static/css/v3/post-detail.css +++ b/static/css/v3/post-detail.css @@ -216,7 +216,7 @@ html.dark .post-detail__related .card-group { } .post-detail-page { gap: var(--space-large); - padding: var(--space-large); + padding: var(--space-medium); padding-bottom: 64px; } /* Total gap between article body and the first sibling section is From a36a8320f2e86d2110755d819d64db22d7f48b5e Mon Sep 17 00:00:00 2001 From: julioest Date: Wed, 6 May 2026 11:33:36 -0400 Subject: [PATCH 13/30] fix: exclude deleted entries from V3 next/related Entry.objects.published() filters published=True but not the soft-delete flag, so deleted posts could surface in the V3 post detail Next Post and Related Posts sections. Filter deleted_at__isnull=True on both querysets. --- news/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/news/views.py b/news/views.py index 81aad02bb..3ca77aa7f 100644 --- a/news/views.py +++ b/news/views.py @@ -443,7 +443,7 @@ def get_v3_context_data(self, **kwargs): Entry.objects.published() .select_related("author") .prefetch_related(*self.AUTHOR_PREFETCH) - .filter(publish_at__gt=entry.publish_at) + .filter(publish_at__gt=entry.publish_at, deleted_at__isnull=True) .exclude(pk=entry.pk) .order_by("publish_at") .first() @@ -452,6 +452,7 @@ def get_v3_context_data(self, **kwargs): Entry.objects.published() .select_related("author") .prefetch_related(*self.AUTHOR_PREFETCH) + .filter(deleted_at__isnull=True) .exclude(pk=entry.pk) ) if next_entry is not None: From 92f8977620bf7697432ed64f138e2dd418ee4b1b Mon Sep 17 00:00:00 2001 From: julioest Date: Wed, 6 May 2026 21:16:44 -0400 Subject: [PATCH 14/30] fix: stable next/related order and deleted notice Add pk as a secondary sort key on the V3 next-post and related-posts queries so results stay deterministic when multiple entries share the same publish_at. Restore the admin-only "entry deleted" notice at the top of the V3 detail article, mirroring the legacy template. Non-admins still 404 via can_view, so the notice only renders for users authorized to view deleted entries. --- news/views.py | 5 +++-- static/css/v3/post-detail.css | 11 +++++++++++ templates/news/v3/detail.html | 5 +++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/news/views.py b/news/views.py index 3ca77aa7f..2dafdf997 100644 --- a/news/views.py +++ b/news/views.py @@ -445,7 +445,7 @@ def get_v3_context_data(self, **kwargs): .prefetch_related(*self.AUTHOR_PREFETCH) .filter(publish_at__gt=entry.publish_at, deleted_at__isnull=True) .exclude(pk=entry.pk) - .order_by("publish_at") + .order_by("publish_at", "pk") .first() ) related_qs = ( @@ -466,7 +466,8 @@ def get_v3_context_data(self, **kwargs): [self._post_card_item(next_entry)] if next_entry else [] ), "related_posts": [ - self._post_card_item(e) for e in related_qs.order_by("-publish_at")[:3] + self._post_card_item(e) + for e in related_qs.order_by("-publish_at", "-pk")[:3] ], } diff --git a/static/css/v3/post-detail.css b/static/css/v3/post-detail.css index 71c4529ed..c7f7374c1 100644 --- a/static/css/v3/post-detail.css +++ b/static/css/v3/post-detail.css @@ -21,6 +21,17 @@ gap: var(--space-xl); } +.post-detail__deleted-notice { + padding: var(--space-default) var(--space-large); + border: 1px solid var(--color-stroke-error); + border-radius: var(--border-radius-m); + background: var(--color-surface-error-weak); + color: var(--color-text-error); + font-family: var(--font-sans); + font-size: var(--font-size-small); + line-height: var(--line-height-tight); +} + .post-detail__figure { margin: 0; } diff --git a/templates/news/v3/detail.html b/templates/news/v3/detail.html index 743ceb585..08fa46a4f 100644 --- a/templates/news/v3/detail.html +++ b/templates/news/v3/detail.html @@ -13,6 +13,11 @@ {% block content %}
+ {% if object.deleted_at %} +
+ Entry deleted on {{ object.deleted_at|date:"m/d/Y" }}{% if object.deleted_by %} by {{ object.deleted_by.display_name }}{% endif %}. +
+ {% endif %} {% include "v3/includes/_post_header.html" with title=object.title publish_date=object.publish_at tag=post_tag author=post_author %} {% if object.image %} From 4103cd63ec723fce49ffd12fb84f852731b8f27f Mon Sep 17 00:00:00 2001 From: julioest Date: Wed, 6 May 2026 21:31:30 -0400 Subject: [PATCH 15/30] refactor: extract user_profile_card helper Move the dict builder for v3/includes/_user_profile.html out of V3PostDetailView into users/profile_cards.py so other views (testimonial card, post card, user card) can reuse it without duplicating the maintainer/badge logic. --- news/views.py | 27 ++++----------------------- users/profile_cards.py | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 23 deletions(-) create mode 100644 users/profile_cards.py diff --git a/news/views.py b/news/views.py index 2dafdf997..725f9c920 100644 --- a/news/views.py +++ b/news/views.py @@ -29,6 +29,7 @@ from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadData from core.mixins import V3Mixin +from users.profile_cards import user_profile_card from .acl import can_approve from .constants import NEWS_APPROVAL_SALT, MAGIC_LINK_EXPIRATION from .forms import BlogPostForm, EntryForm, LinkForm, NewsForm, PollForm, VideoForm @@ -460,7 +461,7 @@ def get_v3_context_data(self, **kwargs): return { "object": entry, - "post_author": self._author_card(entry.author), + "post_author": user_profile_card(entry.author), "post_tag": self.TAG_LABELS.get(entry.tag, entry.tag), "next_post_items": ( [self._post_card_item(next_entry)] if next_entry else [] @@ -472,34 +473,14 @@ def get_v3_context_data(self, **kwargs): } @staticmethod - def _author_card(author): - # Truthiness checks (rather than .exists() / .first() with kwargs) - # so prefetch_related caches are reused — see queryset setup below. - is_maintainer = bool(author.maintainers.all()) - badges = list(author.badges.all()) - badge = badges[0] if badges else None - badge_url = ( - f"{settings.STATIC_URL}img/v3/badges/badge-{badge.name}.png" - if badge and badge.name - else "" - ) - return { - "name": author.display_name, - "profile_url": author.github_profile_url or "", - "role": "Maintainer" if is_maintainer else "Contributor", - "avatar_url": author.get_avatar_url(), - "badge_url": badge_url, - } - - @classmethod - def _post_card_item(cls, entry): + def _post_card_item(entry): return { "title": entry.title, "description": entry.summary or "", "url": reverse("v3-news-detail", args=[entry.slug]), "date": entry.publish_at, "tag": entry.tag, - "author": cls._author_card(entry.author), + "author": user_profile_card(entry.author), } diff --git a/users/profile_cards.py b/users/profile_cards.py new file mode 100644 index 000000000..7cd9b7d44 --- /dev/null +++ b/users/profile_cards.py @@ -0,0 +1,25 @@ +from django.conf import settings + + +def user_profile_card(user): + """Build the dict consumed by v3/includes/_user_profile.html. + + Truthiness checks (rather than .exists() / .filter() with kwargs) + so prefetch_related caches are reused. Callers should prefetch + "badges" and "maintainers" on the user. + """ + is_maintainer = bool(user.maintainers.all()) + badges = list(user.badges.all()) + badge = badges[0] if badges else None + badge_url = ( + f"{settings.STATIC_URL}img/v3/badges/badge-{badge.name}.png" + if badge and badge.name + else "" + ) + return { + "name": user.display_name, + "profile_url": user.github_profile_url or "", + "role": "Maintainer" if is_maintainer else "Contributor", + "avatar_url": user.get_avatar_url(), + "badge_url": badge_url, + } From 2b01c9566c751e723fa267b53c3bfb6bd5310fcd Mon Sep 17 00:00:00 2001 From: julioest Date: Wed, 6 May 2026 21:51:06 -0400 Subject: [PATCH 16/30] fix: preserve list and signoff breaks in posts text_paragraphs was collapsing every single newline to a space, which destroyed numbered and bullet lists in legacy posts and merged intentional line breaks like sign-offs into the surrounding sentence. Detect author-formatted paragraphs (any list marker, or any non-final line under 60 chars) and preserve their breaks as
. Genuine hard-wrapped prose still collapses to flow at the container width. --- news/templatetags/news_tags.py | 35 ++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/news/templatetags/news_tags.py b/news/templatetags/news_tags.py index 87c12d10d..877dac8bc 100644 --- a/news/templatetags/news_tags.py +++ b/news/templatetags/news_tags.py @@ -7,23 +7,46 @@ register = template.Library() _PARAGRAPH_SPLIT = re.compile(r"\n\s*\n+") +_LIST_MARKER = re.compile(r"^(?:[*\-+]\s|\d+[.)]\s)") +_SHORT_LINE_THRESHOLD = 60 + + +def _should_preserve_breaks(lines): + """Decide whether a paragraph's single newlines should render as
. + + Lists (bullet, dash, plus, or numbered) and short-line blocks + (sign-offs like "Thank you,\nName") read as author-formatted + layout; collapsing them to spaces destroys their meaning. Hard- + wrapped prose has internal lines near 80 chars, so checking + "any non-final line under 60 chars" identifies the former. + """ + if any(_LIST_MARKER.match(line) for line in lines): + return True + return any(len(line) < _SHORT_LINE_THRESHOLD for line in lines[:-1]) @register.filter def text_paragraphs(value): """Render hard-wrapped plain text as autolinked paragraphs. - Blank lines become paragraph breaks; single newlines inside a - paragraph collapse to spaces so source hard-wrapped at ~80 chars - flows naturally to the container width. + Blank lines become paragraph breaks. Within a paragraph, single + newlines collapse to spaces so hard-wrapped (~80 char) source + flows to container width, unless the paragraph looks author- + formatted (list items or short-line sign-off), in which case + the breaks are preserved as
. """ if not value: return "" paragraphs = [] for chunk in _PARAGRAPH_SPLIT.split(str(value)): - text = " ".join(line.strip() for line in chunk.splitlines() if line.strip()) - if text: - paragraphs.append(f"

{urlize(text, autoescape=True)}

") + lines = [line.strip() for line in chunk.splitlines() if line.strip()] + if not lines: + continue + if _should_preserve_breaks(lines): + joined = "
".join(urlize(line, autoescape=True) for line in lines) + else: + joined = urlize(" ".join(lines), autoescape=True) + paragraphs.append(f"

{joined}

") return mark_safe("\n".join(paragraphs)) From f294e5a4364e0c1f171bc75e055c81bea34f4f63 Mon Sep 17 00:00:00 2001 From: julioest Date: Thu, 7 May 2026 09:24:29 -0400 Subject: [PATCH 17/30] refactor: V3 detail reuses /news/entry route Folds V3PostDetailView into EntryDetailView via V3Mixin so the same /news/entry// URL serves either template based on the v3 waffle flag, dropping the temporary /v3/news/entry/ route. Per julhoang's review on PR #2373. Ports the admin actions row (Approve, Edit, Delete) and Pending Moderation badge into the V3 detail template so moderators keep those controls when the v3 flag is active. EntryModerationDetailView opts out of v3 rendering with v3_template_name = None; iter_v3_views skips views without a v3 template so the registry test ignores the opt-out. --- config/v3_urls.py | 7 +- core/mixins.py | 6 +- news/views.py | 128 ++++++++++++++++------------------ static/css/v3/post-detail.css | 19 +++++ templates/news/v3/detail.html | 23 ++++++ 5 files changed, 110 insertions(+), 73 deletions(-) diff --git a/config/v3_urls.py b/config/v3_urls.py index c7d7d81f7..545b93adc 100644 --- a/config/v3_urls.py +++ b/config/v3_urls.py @@ -48,7 +48,7 @@ from django.urls import path from core.views import LearnPageView, V3ComponentDemoView -from news.views import V3AllTypesCreateView, V3PostDetailView +from news.views import V3AllTypesCreateView from users.views import ( V3LoginView, V3PasswordResetDoneView, @@ -74,11 +74,6 @@ V3AllTypesCreateView.as_view(), name="v3-news-create", ), - path( - "v3/news/entry//", - V3PostDetailView.as_view(), - name="v3-news-detail", - ), path( "v3/accounts/signup/", V3SignupView.as_view(), diff --git a/core/mixins.py b/core/mixins.py index ae0f9b246..611a1810c 100644 --- a/core/mixins.py +++ b/core/mixins.py @@ -59,7 +59,11 @@ def walk(patterns): if view_class is not None: break callback = getattr(callback, "__wrapped__", None) - if view_class and issubclass(view_class, V3Mixin): + if ( + view_class + and issubclass(view_class, V3Mixin) + and getattr(view_class, "v3_template_name", None) + ): yield entry, view_class yield from walk(get_resolver().url_patterns) diff --git a/news/views.py b/news/views.py index 725f9c920..2e424106d 100644 --- a/news/views.py +++ b/news/views.py @@ -224,9 +224,19 @@ def test_func(self): return can_approve(self.request.user) -class EntryDetailView(DetailView): +class EntryDetailView(V3Mixin, DetailView): model = Entry template_name = "news/detail.html" + v3_template_name = "news/v3/detail.html" + + TAG_LABELS = {"blogpost": "blog"} + AUTHOR_PREFETCH = ("author__badges", "author__maintainers") + + def get_queryset(self): + qs = super().get_queryset() + if getattr(self, "_v3_active", False): + qs = qs.select_related("author").prefetch_related(*self.AUTHOR_PREFETCH) + return qs def get_object(self, *args, **kwargs): # Published news are available to anyone, @@ -236,11 +246,60 @@ def get_object(self, *args, **kwargs): raise Http404() return result + def get_v3_context_data(self, **kwargs): + self.object = self.get_object() + entry = self.object + next_entry = ( + Entry.objects.published() + .select_related("author") + .prefetch_related(*self.AUTHOR_PREFETCH) + .filter(publish_at__gt=entry.publish_at, deleted_at__isnull=True) + .exclude(pk=entry.pk) + .order_by("publish_at", "pk") + .first() + ) + related_qs = ( + Entry.objects.published() + .select_related("author") + .prefetch_related(*self.AUTHOR_PREFETCH) + .filter(deleted_at__isnull=True) + .exclude(pk=entry.pk) + ) + if next_entry is not None: + related_qs = related_qs.exclude(pk=next_entry.pk) + return { + "post_author": user_profile_card(entry.author), + "post_tag": self.TAG_LABELS.get(entry.tag, entry.tag), + "next_post_items": ( + [self._post_card_item(next_entry)] if next_entry else [] + ), + "related_posts": [ + self._post_card_item(e) + for e in related_qs.order_by("-publish_at", "-pk")[:3] + ], + } + + @staticmethod + def _post_card_item(entry): + return { + "title": entry.title, + "description": entry.summary or "", + "url": reverse("news-detail", args=[entry.slug]), + "date": entry.publish_at, + "tag": entry.tag, + "author": user_profile_card(entry.author), + } + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) next_url = self.request.GET.get("next") if url_has_allowed_host_and_scheme(next_url, allowed_hosts=None): context["next_url"] = next_url + context["user_can_approve"] = self.object.can_approve(self.request.user) + context["user_can_edit"] = self.object.can_edit(self.request.user) + context["user_can_delete"] = self.object.can_delete(self.request.user) + if getattr(self, "_v3_active", False): + return context context["next"] = get_published_or_none(self.object.get_next_by_publish_at) context["prev"] = get_published_or_none(self.object.get_previous_by_publish_at) if self.object.tag: @@ -253,13 +312,11 @@ def get_context_data(self, **kwargs): context["prev_in_category"] = get_published_or_none( partial(self.object.get_previous_by_publish_at, **category_kwarg) ) - context["user_can_approve"] = self.object.can_approve(self.request.user) - context["user_can_edit"] = self.object.can_edit(self.request.user) - context["user_can_delete"] = self.object.can_delete(self.request.user) return context -class EntryModerationDetailView(LoginRequiredMixin, EntryDetailView): ... +class EntryModerationDetailView(LoginRequiredMixin, EntryDetailView): + v3_template_name = None class EntryModerationMagicApproveView(View): @@ -423,67 +480,6 @@ def get_context_data(self, **kwargs): return context -class V3PostDetailView(V3Mixin, TemplateView): - v3_template_name = "news/v3/detail.html" - - TAG_LABELS = {"blogpost": "blog"} - - AUTHOR_PREFETCH = ("author__badges", "author__maintainers") - - def get_v3_context_data(self, **kwargs): - entry = get_object_or_404( - Entry.objects.select_related("author").prefetch_related( - *self.AUTHOR_PREFETCH - ), - slug=self.kwargs["slug"], - ) - if not entry.can_view(self.request.user): - raise Http404() - - next_entry = ( - Entry.objects.published() - .select_related("author") - .prefetch_related(*self.AUTHOR_PREFETCH) - .filter(publish_at__gt=entry.publish_at, deleted_at__isnull=True) - .exclude(pk=entry.pk) - .order_by("publish_at", "pk") - .first() - ) - related_qs = ( - Entry.objects.published() - .select_related("author") - .prefetch_related(*self.AUTHOR_PREFETCH) - .filter(deleted_at__isnull=True) - .exclude(pk=entry.pk) - ) - if next_entry is not None: - related_qs = related_qs.exclude(pk=next_entry.pk) - - return { - "object": entry, - "post_author": user_profile_card(entry.author), - "post_tag": self.TAG_LABELS.get(entry.tag, entry.tag), - "next_post_items": ( - [self._post_card_item(next_entry)] if next_entry else [] - ), - "related_posts": [ - self._post_card_item(e) - for e in related_qs.order_by("-publish_at", "-pk")[:3] - ], - } - - @staticmethod - def _post_card_item(entry): - return { - "title": entry.title, - "description": entry.summary or "", - "url": reverse("v3-news-detail", args=[entry.slug]), - "date": entry.publish_at, - "tag": entry.tag, - "author": user_profile_card(entry.author), - } - - class V3AllTypesCreateView(V3Mixin, AllTypesCreateView): v3_template_name = "news/v3/create.html" http_method_names = ["get", "post"] diff --git a/static/css/v3/post-detail.css b/static/css/v3/post-detail.css index c7f7374c1..74a648b7c 100644 --- a/static/css/v3/post-detail.css +++ b/static/css/v3/post-detail.css @@ -32,6 +32,25 @@ line-height: var(--line-height-tight); } +.post-detail__admin-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-default); + align-items: center; + justify-content: flex-start; +} + +.post-detail__approve-form { + margin: 0; +} + +.post-detail__pending-badge { + font-family: var(--font-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + color: var(--color-text-error); +} + .post-detail__figure { margin: 0; } diff --git a/templates/news/v3/detail.html b/templates/news/v3/detail.html index 08fa46a4f..2e0babc45 100644 --- a/templates/news/v3/detail.html +++ b/templates/news/v3/detail.html @@ -18,6 +18,29 @@ Entry deleted on {{ object.deleted_at|date:"m/d/Y" }}{% if object.deleted_by %} by {{ object.deleted_by.display_name }}{% endif %}.
{% endif %} + {% if user_can_approve or user_can_edit or user_can_delete %} +
+ {% if not object.is_approved and not object.deleted_at %} + {% if user_can_approve %} +
+ {% csrf_token %} + {% if next_url %}{% endif %} + {% include "v3/includes/_button.html" with label="Approve" type="submit" style="green" %} +
+ {% else %} + Pending Moderation + {% endif %} + {% endif %} + {% if user_can_edit and not object.deleted_at %} + {% url 'news-update' object.slug as edit_url %} + {% include "v3/includes/_button.html" with url=edit_url label="Edit" style="secondary" %} + {% endif %} + {% if user_can_delete and not object.deleted_at %} + {% url 'news-delete' object.slug as delete_url %} + {% include "v3/includes/_button.html" with url=delete_url label="Delete" style="error" %} + {% endif %} +
+ {% endif %} {% include "v3/includes/_post_header.html" with title=object.title publish_date=object.publish_at tag=post_tag author=post_author %} {% if object.image %} From 6714d657991e52f5f8ea07470454bc8ee0151005 Mon Sep 17 00:00:00 2001 From: julioest Date: Sun, 10 May 2026 01:45:57 -0400 Subject: [PATCH 18/30] fix: V3 post header date format and trim blank line Render the publish date as "May 8th, 2026" (F jS, Y) per review feedback, and drop the orphan blank line between the tag span and {% endif %} in _post_header.html. --- templates/v3/includes/_post_header.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/templates/v3/includes/_post_header.html b/templates/v3/includes/_post_header.html index 1a9413732..5b937a3d7 100644 --- a/templates/v3/includes/_post_header.html +++ b/templates/v3/includes/_post_header.html @@ -14,10 +14,9 @@

{{ title }}

- + {% if tag %} {{ tag|capfirst }} - {% endif %}
{% if author %} From 71de46964cf6f8f3b0dbd39a1a0c9fcde0aed291 Mon Sep 17 00:00:00 2001 From: julioest Date: Sun, 10 May 2026 01:47:36 -0400 Subject: [PATCH 19/30] fix: V3 post title uses letter-spacing-tight token Per review feedback, swap the post-header title tracking from --letter-spacing-display-regular (-0.02em) to --letter-spacing-tight (-0.01em). --- static/css/v3/post-header.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/css/v3/post-header.css b/static/css/v3/post-header.css index 5b92a4930..b4c475344 100644 --- a/static/css/v3/post-header.css +++ b/static/css/v3/post-header.css @@ -14,7 +14,7 @@ font-size: var(--font-size-2xl); font-weight: var(--font-weight-medium); line-height: var(--line-height-tight); - letter-spacing: var(--letter-spacing-display-regular); + letter-spacing: var(--letter-spacing-tight); color: var(--color-text-primary); } From 522865546baa1ef58dbab467820ae3dbd5d4630e Mon Sep 17 00:00:00 2001 From: julioest Date: Sun, 10 May 2026 02:16:38 -0400 Subject: [PATCH 20/30] fix: V3 post card uses category key with tag label Per review feedback, expose entry.tag under the "category" key in _post_card_item rather than "tag", since the _post_card component uses "tag" for library hashtags (e.g. #beast) which we don't store yet. Also pass the value through TAG_LABELS so next/related cards display "blog" for blogposts, matching the post header. --- news/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/news/views.py b/news/views.py index 2e424106d..9757b2f7a 100644 --- a/news/views.py +++ b/news/views.py @@ -279,14 +279,14 @@ def get_v3_context_data(self, **kwargs): ], } - @staticmethod - def _post_card_item(entry): + @classmethod + def _post_card_item(cls, entry): return { "title": entry.title, "description": entry.summary or "", "url": reverse("news-detail", args=[entry.slug]), "date": entry.publish_at, - "tag": entry.tag, + "category": cls.TAG_LABELS.get(entry.tag, entry.tag).capitalize(), "author": user_profile_card(entry.author), } From b7f47e9ca03e8ca46051319e3c27ee4b9ad22fde Mon Sep 17 00:00:00 2001 From: julioest Date: Sun, 10 May 2026 02:40:07 -0400 Subject: [PATCH 21/30] fix: V3 registry surfaces opted-out subclasses Move the "has v3 template" filter out of iter_v3_views and into the test that needs it. The discovery function now returns every V3Mixin subclass, including ones that opt out of v3 rendering with v3_template_name = None (like EntryModerationDetailView). The V3 Demo registry can show them again, while the drift test still only verifies real templates load. --- core/mixins.py | 6 +----- core/tests/test_v3_registry.py | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/core/mixins.py b/core/mixins.py index 611a1810c..ae0f9b246 100644 --- a/core/mixins.py +++ b/core/mixins.py @@ -59,11 +59,7 @@ def walk(patterns): if view_class is not None: break callback = getattr(callback, "__wrapped__", None) - if ( - view_class - and issubclass(view_class, V3Mixin) - and getattr(view_class, "v3_template_name", None) - ): + if view_class and issubclass(view_class, V3Mixin): yield entry, view_class yield from walk(get_resolver().url_patterns) diff --git a/core/tests/test_v3_registry.py b/core/tests/test_v3_registry.py index bc742eb92..ab3cda7f9 100644 --- a/core/tests/test_v3_registry.py +++ b/core/tests/test_v3_registry.py @@ -23,6 +23,14 @@ def _get_v3_view_classes() -> set[type]: return {view_class for _, view_class in iter_v3_views()} +def _get_v3_view_classes_with_template() -> set[type]: + return { + view_class + for view_class in _get_v3_view_classes() + if getattr(view_class, "v3_template_name", None) + } + + @pytest.fixture(scope="session") def v3_view_classes(): return sorted(_get_v3_view_classes(), key=lambda c: c.__name__) @@ -35,13 +43,17 @@ def test_v3_views_discovered(v3_view_classes): @pytest.mark.parametrize( "view_class", - _get_v3_view_classes(), + _get_v3_view_classes_with_template(), ids=lambda c: c.__name__, ) def test_v3_template_exists(view_class): - """Every V3 view must point to a `v3_template_name` that Django can load.""" - template = getattr(view_class, "v3_template_name", None) - assert template, f"{view_class.__name__}: no v3_template_name set" + """Every V3 view that declares a template must point to a real one. + + Views that opt out of v3 rendering (`v3_template_name = None`, e.g. + EntryModerationDetailView) are excluded from this check but still + surface in `iter_v3_views()` so the V3 Demo registry can list them. + """ + template = view_class.v3_template_name try: get_template(template) except TemplateDoesNotExist: From f7b8b6070484df0fb9908d1f53473b4408fbab4b Mon Sep 17 00:00:00 2001 From: julioest Date: Sun, 10 May 2026 03:03:40 -0400 Subject: [PATCH 22/30] fix: V3 post detail renders body as markdown Swap text_paragraphs for the standard markdown filter (via wagtail-markdown) plus urlize, so post bodies align with what Create Post outputs from the WYSIWYG and where Wagtail RichTextField is heading. Real
    and
      lists, autolinked URLs, and bold/italic/code all render for new posts. Add scoped styles for lists, headings, and strong inside .post-detail__body so the rendered markdown picks up the v3 type system. Heads up: legacy "Thank you,\nName" sign-offs now collapse to one line since markdown treats single newlines as soft breaks. Authors can drop a blank line if they want the break back. --- news/templatetags/news_tags.py | 47 ----------------------------- static/css/v3/post-detail.css | 55 ++++++++++++++++++++++++++++++++++ templates/news/v3/detail.html | 4 +-- 3 files changed, 57 insertions(+), 49 deletions(-) diff --git a/news/templatetags/news_tags.py b/news/templatetags/news_tags.py index 877dac8bc..4aaae85af 100644 --- a/news/templatetags/news_tags.py +++ b/news/templatetags/news_tags.py @@ -1,54 +1,7 @@ -import re - from django import template -from django.template.defaultfilters import urlize -from django.utils.safestring import mark_safe register = template.Library() -_PARAGRAPH_SPLIT = re.compile(r"\n\s*\n+") -_LIST_MARKER = re.compile(r"^(?:[*\-+]\s|\d+[.)]\s)") -_SHORT_LINE_THRESHOLD = 60 - - -def _should_preserve_breaks(lines): - """Decide whether a paragraph's single newlines should render as
      . - - Lists (bullet, dash, plus, or numbered) and short-line blocks - (sign-offs like "Thank you,\nName") read as author-formatted - layout; collapsing them to spaces destroys their meaning. Hard- - wrapped prose has internal lines near 80 chars, so checking - "any non-final line under 60 chars" identifies the former. - """ - if any(_LIST_MARKER.match(line) for line in lines): - return True - return any(len(line) < _SHORT_LINE_THRESHOLD for line in lines[:-1]) - - -@register.filter -def text_paragraphs(value): - """Render hard-wrapped plain text as autolinked paragraphs. - - Blank lines become paragraph breaks. Within a paragraph, single - newlines collapse to spaces so hard-wrapped (~80 char) source - flows to container width, unless the paragraph looks author- - formatted (list items or short-line sign-off), in which case - the breaks are preserved as
      . - """ - if not value: - return "" - paragraphs = [] - for chunk in _PARAGRAPH_SPLIT.split(str(value)): - lines = [line.strip() for line in chunk.splitlines() if line.strip()] - if not lines: - continue - if _should_preserve_breaks(lines): - joined = "
      ".join(urlize(line, autoescape=True) for line in lines) - else: - joined = urlize(" ".join(lines), autoescape=True) - paragraphs.append(f"

      {joined}

      ") - return mark_safe("\n".join(paragraphs)) - @register.simple_tag(takes_context=True) def can_edit(context, news_item, *args, **kwargs): diff --git a/static/css/v3/post-detail.css b/static/css/v3/post-detail.css index 74a648b7c..5aaf57b4e 100644 --- a/static/css/v3/post-detail.css +++ b/static/css/v3/post-detail.css @@ -113,6 +113,61 @@ overflow-x: auto; } +.post-detail__body ul, +.post-detail__body ol { + padding-left: var(--space-large); + list-style-position: outside; +} + +.post-detail__body ul { + list-style-type: disc; +} + +.post-detail__body ol { + list-style-type: decimal; +} + +.post-detail__body ul ul { + list-style-type: circle; +} + +.post-detail__body ol ol, +.post-detail__body ul ol { + list-style-type: lower-alpha; +} + +.post-detail__body li { + font-family: var(--font-sans); + font-size: var(--font-size-base); + line-height: var(--line-height-loose); +} + +.post-detail__body h1, +.post-detail__body h2, +.post-detail__body h3, +.post-detail__body h4, +.post-detail__body h5, +.post-detail__body h6 { + color: var(--color-text-primary); + font-family: var(--font-display); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-tight); + margin: 0; +} + +.post-detail__body h1 { font-size: var(--font-size-xl); } +.post-detail__body h2 { font-size: var(--font-size-large); } +.post-detail__body h3 { font-size: var(--font-size-medium); } +.post-detail__body h4, +.post-detail__body h5, +.post-detail__body h6 { font-size: var(--font-size-base); } + +.post-detail__body b, +.post-detail__body strong { + color: var(--color-text-primary); + font-weight: var(--font-weight-medium); +} + .post-detail__next, .post-detail__related { display: flex; diff --git a/templates/news/v3/detail.html b/templates/news/v3/detail.html index 2e0babc45..c48dc2e4e 100644 --- a/templates/news/v3/detail.html +++ b/templates/news/v3/detail.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% load static %} -{% load news_tags %} +{% load wagtailmarkdown %} {% block title %}{{ object.title }}{% endblock %} @@ -56,7 +56,7 @@

      {% endif %} {% with body=object.content|default:object.visible_content %} - {% if body %}{{ body|text_paragraphs }}{% endif %} + {% if body %}{{ body|markdown|urlize }}{% endif %} {% endwith %}
From e56ef838638ce7096f843e42c5ca68def7b492a3 Mon Sep 17 00:00:00 2001 From: julioest Date: Sun, 10 May 2026 03:30:22 -0400 Subject: [PATCH 23/30] chore: trim redundant CSS in post-detail body Most of .post-detail__body p, the li block, and the margin on h1-h6 were just restating things that already inherit or that the global stylesheet resets. Slim them down, move the shared letter-spacing up to .post-detail__body, and leave a note on the p padding override since frontend/styles.css applies py-5 to every

and that would otherwise break the flex gap rhythm here. --- static/css/v3/post-detail.css | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/static/css/v3/post-detail.css b/static/css/v3/post-detail.css index 5aaf57b4e..00b31231e 100644 --- a/static/css/v3/post-detail.css +++ b/static/css/v3/post-detail.css @@ -68,19 +68,18 @@ gap: var(--space-large); font-size: var(--font-size-base); line-height: var(--line-height-loose); + letter-spacing: var(--letter-spacing-tight); color: var(--color-text-secondary); padding-top: var(--space-xl); border-top: 1px solid var(--color-stroke-weak); } +/* Reset the site-wide `p { @apply py-5 }` from frontend/styles.css + so paragraph rhythm comes from the body's flex `gap`, not from + each

contributing its own 1.25rem of top/bottom padding. */ .post-detail__body p { padding-top: 0; padding-bottom: 0; - font-family: var(--font-sans); - font-size: var(--font-size-base); - font-weight: var(--font-weight-regular); - line-height: var(--line-height-loose); - letter-spacing: var(--letter-spacing-tight); } .post-detail__body a { @@ -136,12 +135,6 @@ list-style-type: lower-alpha; } -.post-detail__body li { - font-family: var(--font-sans); - font-size: var(--font-size-base); - line-height: var(--line-height-loose); -} - .post-detail__body h1, .post-detail__body h2, .post-detail__body h3, @@ -152,7 +145,6 @@ font-family: var(--font-display); font-weight: var(--font-weight-medium); line-height: var(--line-height-tight); - margin: 0; } .post-detail__body h1 { font-size: var(--font-size-xl); } From 666f824fbddd918810e577c8bd236deeef077c80 Mon Sep 17 00:00:00 2001 From: julioest Date: Sun, 10 May 2026 04:18:32 -0400 Subject: [PATCH 24/30] fix: post-card dates render in written form Match the post header's date format (e.g. "January 30th, 2026") on next/related cards, the homepage community feed, and the community page. One-line change in the shared _post_card.html include so all three surfaces stay in sync. --- templates/v3/includes/_post_card.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/v3/includes/_post_card.html b/templates/v3/includes/_post_card.html index 9b56770bd..c6061960d 100644 --- a/templates/v3/includes/_post_card.html +++ b/templates/v3/includes/_post_card.html @@ -40,7 +40,7 @@

{{ item.title }}

{% endif %} {% if item.date or item.category or item.tag %}
- {% if item.date %}{% endif %} + {% if item.date %}{% endif %} {% if item.category %}{{ item.category }}{% endif %} {% if item.tag %}#{{ item.tag }}{% endif %}
From 8f234efcd03d147792d823118a9e124e1fdc34ab Mon Sep 17 00:00:00 2001 From: julioest Date: Mon, 11 May 2026 22:23:01 -0400 Subject: [PATCH 25/30] chore: rename TAG_LABELS to CATEGORY_LABELS Aligns the constant name with the card's `category` key (and the post header's "Blog" / "News" / "Link" display label) instead of the source field name `entry.tag`. --- news/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/news/views.py b/news/views.py index 9757b2f7a..7ace0251c 100644 --- a/news/views.py +++ b/news/views.py @@ -229,7 +229,7 @@ class EntryDetailView(V3Mixin, DetailView): template_name = "news/detail.html" v3_template_name = "news/v3/detail.html" - TAG_LABELS = {"blogpost": "blog"} + CATEGORY_LABELS = {"blogpost": "blog"} AUTHOR_PREFETCH = ("author__badges", "author__maintainers") def get_queryset(self): @@ -269,7 +269,7 @@ def get_v3_context_data(self, **kwargs): related_qs = related_qs.exclude(pk=next_entry.pk) return { "post_author": user_profile_card(entry.author), - "post_tag": self.TAG_LABELS.get(entry.tag, entry.tag), + "post_tag": self.CATEGORY_LABELS.get(entry.tag, entry.tag), "next_post_items": ( [self._post_card_item(next_entry)] if next_entry else [] ), @@ -286,7 +286,7 @@ def _post_card_item(cls, entry): "description": entry.summary or "", "url": reverse("news-detail", args=[entry.slug]), "date": entry.publish_at, - "category": cls.TAG_LABELS.get(entry.tag, entry.tag).capitalize(), + "category": cls.CATEGORY_LABELS.get(entry.tag, entry.tag).capitalize(), "author": user_profile_card(entry.author), } From ad08b2f999ded71fe9e364eb50ae3176c1018ae9 Mon Sep 17 00:00:00 2001 From: julioest Date: Mon, 11 May 2026 22:26:42 -0400 Subject: [PATCH 26/30] fix: post-card dates use 3-letter month abbreviation Per design: cards switch from "January 30th, 2026" to "Jan 30th, 2026". One-character format change (F -> M). The post header keeps the full month name since it has more room. --- templates/v3/includes/_post_card.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/v3/includes/_post_card.html b/templates/v3/includes/_post_card.html index c6061960d..8a82b91c8 100644 --- a/templates/v3/includes/_post_card.html +++ b/templates/v3/includes/_post_card.html @@ -40,7 +40,7 @@

{{ item.title }}

{% endif %} {% if item.date or item.category or item.tag %}
- {% if item.date %}{% endif %} + {% if item.date %}{% endif %} {% if item.category %}{{ item.category }}{% endif %} {% if item.tag %}#{{ item.tag }}{% endif %}
From 70a710f7c059e83408bd192cefc0da1871782160 Mon Sep 17 00:00:00 2001 From: julioest Date: Tue, 12 May 2026 23:24:59 -0400 Subject: [PATCH 27/30] Lazy-load the post detail hero image It's below the fold for most viewports, so deferring the fetch cuts initial-paint bytes without affecting readers who scroll. --- templates/news/v3/detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/news/v3/detail.html b/templates/news/v3/detail.html index c48dc2e4e..d2c084297 100644 --- a/templates/news/v3/detail.html +++ b/templates/news/v3/detail.html @@ -45,7 +45,7 @@ {% if object.image %}
- +
{% endif %} From a9431e706e8e3721cca4482c6339b6458d05117e Mon Sep 17 00:00:00 2001 From: julioest Date: Tue, 12 May 2026 23:29:57 -0400 Subject: [PATCH 28/30] Note related-posts library-scoping intent related_qs was meant to filter by libraries linked to the current entry, not "any other published post." That relation doesn't exist in the schema yet, so a TODO captures the intent until it does. --- news/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/news/views.py b/news/views.py index 7ace0251c..7a9faf6c9 100644 --- a/news/views.py +++ b/news/views.py @@ -258,6 +258,10 @@ def get_v3_context_data(self, **kwargs): .order_by("publish_at", "pk") .first() ) + # TODO: once Entry has a relation to libraries, scope related + # posts to those linked to the libraries referenced by this + # entry. Falls back to "any other published post" until that + # relation exists. related_qs = ( Entry.objects.published() .select_related("author") From 3b71d487ba160f97f87eb9477fef982f32d407a2 Mon Sep 17 00:00:00 2001 From: julioest Date: Wed, 20 May 2026 12:47:53 -0400 Subject: [PATCH 29/30] fix: V3 post detail badge uses placeholder The /news/ post list already shows a hardcoded gold-medal placeholder via User.badge_url. The detail page was reading real badge rows and rendering nothing, since no user has any yet, which read as "missing badges" in QA. Pointing the detail at the same placeholder so both pages match. Heads up: when we wire real badge data, swapping User.badge_url upgrades both pages at once. --- news/views.py | 2 +- users/profile_cards.py | 18 ++++-------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/news/views.py b/news/views.py index 7a9faf6c9..295f30dce 100644 --- a/news/views.py +++ b/news/views.py @@ -230,7 +230,7 @@ class EntryDetailView(V3Mixin, DetailView): v3_template_name = "news/v3/detail.html" CATEGORY_LABELS = {"blogpost": "blog"} - AUTHOR_PREFETCH = ("author__badges", "author__maintainers") + AUTHOR_PREFETCH = ("author__maintainers",) def get_queryset(self): qs = super().get_queryset() diff --git a/users/profile_cards.py b/users/profile_cards.py index 7cd9b7d44..9aaeec996 100644 --- a/users/profile_cards.py +++ b/users/profile_cards.py @@ -1,25 +1,15 @@ -from django.conf import settings - - def user_profile_card(user): """Build the dict consumed by v3/includes/_user_profile.html. - Truthiness checks (rather than .exists() / .filter() with kwargs) - so prefetch_related caches are reused. Callers should prefetch - "badges" and "maintainers" on the user. + Callers should prefetch "maintainers" on the user. """ is_maintainer = bool(user.maintainers.all()) - badges = list(user.badges.all()) - badge = badges[0] if badges else None - badge_url = ( - f"{settings.STATIC_URL}img/v3/badges/badge-{badge.name}.png" - if badge and badge.name - else "" - ) return { "name": user.display_name, "profile_url": user.github_profile_url or "", "role": "Maintainer" if is_maintainer else "Contributor", "avatar_url": user.get_avatar_url(), - "badge_url": badge_url, + # Placeholder until real per-user badge data lands; matches the + # /news/ post list, which reads User.badge_url directly. + "badge_url": user.badge_url, } From a56423490c15a1a486ba33b34a973320895e4b8c Mon Sep 17 00:00:00 2001 From: julioest Date: Wed, 20 May 2026 13:06:30 -0400 Subject: [PATCH 30/30] fix: V3 post body links match body color The body link rule was painting links with --color-text-link-accent (mid-blue), but Figma wants links to read as plain text with just an underline. Switching to color: inherit so links follow the body's --color-text-secondary in both light and dark mode. --- static/css/v3/post-detail.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/css/v3/post-detail.css b/static/css/v3/post-detail.css index 00b31231e..a9c8d9e81 100644 --- a/static/css/v3/post-detail.css +++ b/static/css/v3/post-detail.css @@ -83,7 +83,7 @@ } .post-detail__body a { - color: var(--color-text-link-accent); + color: inherit; text-decoration: underline; }