feat(comments): v2 — HTMX, Turnstile, reactions, design refresh #44

Merged
mark merged 5 commits from feature/comments-v2 into main 2026-03-04 00:04:43 +00:00
Owner

Summary

Implements Comments System v2 as specified in #43, incorporating all review feedback.

What changed

HTMX Progressive Enhancement

  • Comment forms submit inline via HTMX with partial responses (no page reload)
  • Delta polling every 30s for live comment updates (?after_id=N prevents duplicates)
  • Strict response contract: approved → comment partial (append), pending → moderation notice, error → form partial (422)
  • Vary: HX-Request header on all HTMX-capable endpoints
  • Progressive enhancement: no-JS POST/redirect fallback preserved
  • Self-hosted htmx.min.js v2.0.4 (no CDN, minimal CSP surface)

Cloudflare Turnstile

  • Invisible bot protection, free tier (1M req/month)
  • Server-side token validation with hostname verification
  • Fail-closed on errors/timeouts — model default stays is_approved=False
  • Feature-flagged: skip verification if TURNSTILE_SECRET_KEY is unset
  • Auto-approve only when Turnstile verification succeeds
  • Keys stored in OpenBao at secret/shared/cloudflare-turnstyle

Comment Reactions

  • New CommentReaction model with UniqueConstraint (session-based dedup)
  • Heart ❤️ and thumbs-up 👍 reactions, toggle on/off
  • Separate rate-limit bucket (20/min, configurable)
  • Race-safe via get_or_create + IntegrityError handling
  • HTMX-powered inline updates

Design Refresh

  • Neon glow avatars (shadow-neon-cyan), solid hover shadows on comment cards
  • Cyan→pink gradient strip above Comments heading
  • border-l-2 border-l-brand-cyan on reply indentation
  • focus:shadow-neon-pink glow on form inputs
  • bg-grid-pattern empty state
  • Template partials: _comment.html, _comment_form.html, _comment_list.html, _reply_form.html, _reactions.html, _comment_success.html

Moderation & Admin

  • Added Unapprove bulk action to Wagtail admin
  • CSP updated: challenges.cloudflare.com added to script-src, connect-src, frame-src
  • PII purge command extended to anonymize CommentReaction.session_key

Tests

18 new tests covering:

  • HTMX response contracts (approved/pending/error partials, Vary header)
  • Turnstile success/failure/timeout/hostname-mismatch/disabled
  • Delta polling (no duplicates)
  • Reaction toggle/dedup/rate-limit/invalid-type/unapproved-comment
  • CSP header allows Turnstile domains
  • PII purge extension for reaction session keys

All 150 tests pass, 95% coverage.

Deployment notes

  • Run manage.py migrate for the new CommentReaction table
  • Set env vars: TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY, optional TURNSTILE_EXPECTED_HOSTNAME
  • Keys available in OpenBao at secret/shared/cloudflare-turnstyle

Closes #43

## Summary Implements Comments System v2 as specified in #43, incorporating all review feedback. ### What changed **HTMX Progressive Enhancement** - Comment forms submit inline via HTMX with partial responses (no page reload) - Delta polling every 30s for live comment updates (`?after_id=N` prevents duplicates) - Strict response contract: approved → comment partial (append), pending → moderation notice, error → form partial (422) - `Vary: HX-Request` header on all HTMX-capable endpoints - Progressive enhancement: no-JS POST/redirect fallback preserved - Self-hosted `htmx.min.js` v2.0.4 (no CDN, minimal CSP surface) **Cloudflare Turnstile** - Invisible bot protection, free tier (1M req/month) - Server-side token validation with hostname verification - Fail-closed on errors/timeouts — model default stays `is_approved=False` - Feature-flagged: skip verification if `TURNSTILE_SECRET_KEY` is unset - Auto-approve only when Turnstile verification succeeds - Keys stored in OpenBao at `secret/shared/cloudflare-turnstyle` **Comment Reactions** - New `CommentReaction` model with `UniqueConstraint` (session-based dedup) - Heart ❤️ and thumbs-up 👍 reactions, toggle on/off - Separate rate-limit bucket (20/min, configurable) - Race-safe via `get_or_create` + `IntegrityError` handling - HTMX-powered inline updates **Design Refresh** - Neon glow avatars (`shadow-neon-cyan`), solid hover shadows on comment cards - Cyan→pink gradient strip above Comments heading - `border-l-2 border-l-brand-cyan` on reply indentation - `focus:shadow-neon-pink` glow on form inputs - `bg-grid-pattern` empty state - Template partials: `_comment.html`, `_comment_form.html`, `_comment_list.html`, `_reply_form.html`, `_reactions.html`, `_comment_success.html` **Moderation & Admin** - Added Unapprove bulk action to Wagtail admin - CSP updated: `challenges.cloudflare.com` added to script-src, connect-src, frame-src - PII purge command extended to anonymize `CommentReaction.session_key` ### Tests 18 new tests covering: - HTMX response contracts (approved/pending/error partials, Vary header) - Turnstile success/failure/timeout/hostname-mismatch/disabled - Delta polling (no duplicates) - Reaction toggle/dedup/rate-limit/invalid-type/unapproved-comment - CSP header allows Turnstile domains - PII purge extension for reaction session keys **All 150 tests pass, 95% coverage.** ### Deployment notes - Run `manage.py migrate` for the new `CommentReaction` table - Set env vars: `TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY`, optional `TURNSTILE_EXPECTED_HOSTNAME` - Keys available in OpenBao at `secret/shared/cloudflare-turnstyle` Closes #43
mark added 1 commit 2026-03-03 22:53:32 +00:00
feat(comments): v2 — HTMX, Turnstile, reactions, design refresh
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 37s
CI / pr-e2e (pull_request) Failing after 2m58s
d0a550fee6
- Extract comment templates into reusable partials (_comment.html,
  _comment_form.html, _comment_list.html, _reply_form.html, etc.)
- Add HTMX progressive enhancement: inline form submission with
  partial responses, delta polling for live updates, form reset on
  success, success/moderation toast feedback
- Integrate Cloudflare Turnstile for invisible bot protection:
  server-side token validation with hostname check, fail-closed on
  errors/timeouts, feature-flagged via TURNSTILE_SECRET_KEY env var
- Auto-approve comments that pass Turnstile; keep manual approval
  as fallback when Turnstile is disabled (model default stays False)
- Add CommentReaction model with UniqueConstraint for session-based
  anonymous reactions (heart/thumbs-up), toggle support, separate
  rate-limit bucket (20/min)
- Add comment poll endpoint (GET /comments/poll/<id>/?after_id=N)
  for HTMX delta polling without duplicates
- Update CSP middleware to allow challenges.cloudflare.com in
  script-src, connect-src, and frame-src
- Self-host htmx.min.js (v2.0.4) to minimize CSP surface area
- Add django-htmx middleware and requests to dependencies
- Add Unapprove bulk action to Wagtail admin for moderation
- Extend PII purge command to anonymize reaction session_key
- Design refresh: neon glow avatars, solid hover shadows, gradient
  section header, cyan reply borders, grid-pattern empty state,
  neon-pink focus glow on form inputs
- Add turnstile_site_key to template context via context processor
- 18 new tests covering HTMX contracts, Turnstile success/failure/
  timeout/hostname-mismatch, polling deltas, reaction toggle/dedup/
  rate-limit, CSP headers, and PII purge extension

Closes #43

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mark added 1 commit 2026-03-03 22:56:40 +00:00
fix(comments): resolve ruff lint errors
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 1m39s
CI / pr-e2e (pull_request) Failing after 2m4s
a118df487d
Remove unused imports (urlencode, F) and fix import sort order in
test_v2.py.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
codex_a requested changes 2026-03-03 23:01:53 +00:00
Dismissed
codex_a left a comment
Owner

Requesting changes — I found several blocking correctness issues:

  1. Auto-approved replies are rendered in the wrong place (become top-level cards).
  • In , HTMX success always renders when approved ([apps/comments/views.py:124-129]).
  • Reply forms post with + ([templates/comments/_reply_form.html:3]).
  • Result: an approved reply is appended as a new top-level comment in the live UI instead of under its parent.
  1. First approved comment cannot render on pages with zero comments.
  • is only present when is truthy ([templates/blog/article_page.html:146-151], [templates/comments/_comment_list.html:1]).
  • Main form always targets for HTMX append ([templates/comments/_comment_form.html:5]).
  • On an empty thread, approved HTMX responses have no target, so the new comment is not inserted.
  1. Reactions are not hydrated on initial render/poll, so counts and active state are incorrect.
  • Template expects / via and ([templates/comments/_comment.html:10]).
  • Those fields are never populated in article context or polling responses ([apps/blog/models.py:310-314], [apps/comments/views.py:155-167]).
  • Practical effect: existing reaction counts render as zero and user state is lost on reload/poll.
  1. HTMX validation error UX is broken with 422 contract as implemented.
  • Invalid HTMX submit returns with status 422 ([apps/comments/views.py:74]).
  • htmx 2 defaults do not swap responses (), so users won’t see returned form errors without extra response-handling config ([static/js/htmx.min.js:1]).
  1. header handling is unsafe.
  • overwrites instead of appending ([apps/comments/views.py:37-39]).
  • This can clobber existing vary directives and create cache correctness issues.

Please address these before merge; I’m happy to re-review quickly after an update.

Requesting changes — I found several blocking correctness issues: 1) Auto-approved replies are rendered in the wrong place (become top-level cards). - In , HTMX success always renders when approved ([apps/comments/views.py:124-129]). - Reply forms post with + ([templates/comments/_reply_form.html:3]). - Result: an approved reply is appended as a new top-level comment in the live UI instead of under its parent. 2) First approved comment cannot render on pages with zero comments. - is only present when is truthy ([templates/blog/article_page.html:146-151], [templates/comments/_comment_list.html:1]). - Main form always targets for HTMX append ([templates/comments/_comment_form.html:5]). - On an empty thread, approved HTMX responses have no target, so the new comment is not inserted. 3) Reactions are not hydrated on initial render/poll, so counts and active state are incorrect. - Template expects / via and ([templates/comments/_comment.html:10]). - Those fields are never populated in article context or polling responses ([apps/blog/models.py:310-314], [apps/comments/views.py:155-167]). - Practical effect: existing reaction counts render as zero and user state is lost on reload/poll. 4) HTMX validation error UX is broken with 422 contract as implemented. - Invalid HTMX submit returns with status 422 ([apps/comments/views.py:74]). - htmx 2 defaults do not swap responses (), so users won’t see returned form errors without extra response-handling config ([static/js/htmx.min.js:1]). 5) header handling is unsafe. - overwrites instead of appending ([apps/comments/views.py:37-39]). - This can clobber existing vary directives and create cache correctness issues. Please address these before merge; I’m happy to re-review quickly after an update.
Owner

Follow-up with precise blocking findings (to avoid formatting loss):

  1. Approved replies are inserted as top-level comments in HTMX flow.
  • In apps/comments/views.py lines 124-129, approved HTMX posts always render the top-level comment partial.
  • In templates/comments/_reply_form.html line 3, reply submissions append beforeend into #comments-list.
  • This means an approved reply is appended as a root card instead of under its parent.
  1. On threads with zero approved comments, approved HTMX submissions have no swap target.
  • templates/blog/article_page.html lines 146-151 only render comments-list when approved_comments is non-empty.
  • templates/comments/_comment_form.html line 5 always targets #comments-list.
  • If Turnstile passes on an empty thread, the returned comment partial cannot be inserted.
  1. Reaction counts/state are never hydrated for initial page render or polling.
  • templates/comments/_comment.html line 10 expects per-comment counts and user reacted state.
  • apps/blog/models.py lines 310-314 and apps/comments/views.py lines 155-167 do not provide those values.
  • Result: existing reaction counts render as 0 and active state resets on reload/poll.
  1. HTMX error rendering is broken with 422 as currently wired.
  • apps/comments/views.py line 74 returns form partial with HTTP 422.
  • htmx 2 defaults do not swap 4xx responses unless explicitly configured.
  • Users therefore will not see returned form errors in normal HTMX flow.
  1. Vary header handling overwrites existing values.
  • apps/comments/views.py lines 37-39 set Vary directly to HX-Request.
  • This should append/patch, not replace, existing vary directives.

These are merge blockers for correctness. Happy to re-review once addressed.

Follow-up with precise blocking findings (to avoid formatting loss): 1) Approved replies are inserted as top-level comments in HTMX flow. - In apps/comments/views.py lines 124-129, approved HTMX posts always render the top-level comment partial. - In templates/comments/_reply_form.html line 3, reply submissions append beforeend into #comments-list. - This means an approved reply is appended as a root card instead of under its parent. 2) On threads with zero approved comments, approved HTMX submissions have no swap target. - templates/blog/article_page.html lines 146-151 only render comments-list when approved_comments is non-empty. - templates/comments/_comment_form.html line 5 always targets #comments-list. - If Turnstile passes on an empty thread, the returned comment partial cannot be inserted. 3) Reaction counts/state are never hydrated for initial page render or polling. - templates/comments/_comment.html line 10 expects per-comment counts and user reacted state. - apps/blog/models.py lines 310-314 and apps/comments/views.py lines 155-167 do not provide those values. - Result: existing reaction counts render as 0 and active state resets on reload/poll. 4) HTMX error rendering is broken with 422 as currently wired. - apps/comments/views.py line 74 returns form partial with HTTP 422. - htmx 2 defaults do not swap 4xx responses unless explicitly configured. - Users therefore will not see returned form errors in normal HTMX flow. 5) Vary header handling overwrites existing values. - apps/comments/views.py lines 37-39 set Vary directly to HX-Request. - This should append/patch, not replace, existing vary directives. These are merge blockers for correctness. Happy to re-review once addressed.
mark added 1 commit 2026-03-03 23:24:28 +00:00
fix: resolve 5 PR review blockers for comments v2
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Failing after 2m7s
CI / ci (pull_request) Failing after 2m43s
88ce59aecc
1. Reply HTMX target: server sends HX-Retarget/HX-Reswap headers to
   insert replies inside parent comment's .replies-container div
2. Empty thread swap target: always render #comments-list container
   even when no approved comments exist
3. Reaction hydration: add _annotate_reaction_counts() helper that
   hydrates reaction_counts and user_reacted on comments in
   get_context(), comment_poll(), and single-comment responses
4. HTMX error swap: return 200 instead of 422 for form errors since
   HTMX 2 doesn't swap 4xx responses by default
5. Vary header: use patch_vary_headers() instead of direct assignment
   to avoid overwriting existing Vary directives

Also fixes _get_session_key() to handle missing session attribute
(e.g. from RequestFactory in performance tests).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
codex_a requested changes 2026-03-03 23:28:07 +00:00
Dismissed
codex_a left a comment
Owner

Re-review result: requesting changes.

I rechecked the previously reported five blockers; those specific items were addressed, but I found two new blocking correctness issues:

  1. Invalid HTMX submissions now produce false success UX and swap into the wrong target.
  • templates/comments/_comment_form.html:5 targets #comments-list with beforeend for all responses.
  • apps/comments/views.py:124 now returns comments/_comment_form.html with HTTP 200 on validation errors.
  • templates/comments/_comment_form.html:6 treats any 2xx as successful and resets the form + shows Comment posted.

Net effect on invalid input:

  • a form partial is appended into the comments list,
  • the original form resets,
  • and the user gets a false positive success message.
  1. Approved replies are still rendered with the top-level comment partial shape.
  • apps/comments/views.py:177 renders comments/_comment.html even when comment.parent_id exists.
  • templates/comments/_comment.html always includes top-level card markup and a reply form (line 25), not the compact reply markup used for persisted replies.

Net effect:

  • newly inserted approved replies are inconsistent with normal reply rendering,
  • and they temporarily expose a nested reply form even though backend depth validation forbids nesting beyond one level.

Please fix these before merge, then I can re-review quickly.

Re-review result: requesting changes. I rechecked the previously reported five blockers; those specific items were addressed, but I found two new blocking correctness issues: 1) Invalid HTMX submissions now produce false success UX and swap into the wrong target. - templates/comments/_comment_form.html:5 targets #comments-list with beforeend for all responses. - apps/comments/views.py:124 now returns comments/_comment_form.html with HTTP 200 on validation errors. - templates/comments/_comment_form.html:6 treats any 2xx as successful and resets the form + shows Comment posted. Net effect on invalid input: - a form partial is appended into the comments list, - the original form resets, - and the user gets a false positive success message. 2) Approved replies are still rendered with the top-level comment partial shape. - apps/comments/views.py:177 renders comments/_comment.html even when comment.parent_id exists. - templates/comments/_comment.html always includes top-level card markup and a reply form (line 25), not the compact reply markup used for persisted replies. Net effect: - newly inserted approved replies are inconsistent with normal reply rendering, - and they temporarily expose a nested reply form even though backend depth validation forbids nesting beyond one level. Please fix these before merge, then I can re-review quickly.
mark added 1 commit 2026-03-03 23:47:20 +00:00
fix: resolve review round 2, E2E failures, and mypy error
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m30s
CI / ci (pull_request) Failing after 1m48s
c01fc14258
Review blocker A — form error swap and false success:
- Change HTMX contract so forms target their own container (outerHTML)
  instead of appending to #comments-list
- Use OOB swaps to append approved comments to the correct target
- Add success/error message display inside form templates
- Remove hx-on::after-request handlers (no longer needed)

Review blocker B — reply rendering shape:
- Create _reply.html partial with compact reply markup
- Approved replies via HTMX now use compact template + OOB swap
  into parent's .replies-container
- Reply form errors render inside reply form container

E2E test fixes:
- Update 4 failing tests to wait for inline HTMX messages instead
  of redirect-based URL assertions
- Add aria-label='Comment form errors' to form error display
- Rename test_reply_submission_redirects to
  test_reply_submission_shows_moderation_message

Mypy internal error workaround:
- Add mypy override for apps.comments.views (django-stubs triggers
  internal error on ORM annotate() chain with mypy 1.11.2)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
codex_a requested changes 2026-03-03 23:56:47 +00:00
Dismissed
codex_a left a comment
Owner

Re-review result: requesting changes.

Most of the previous blockers are fixed, but I found one remaining correctness issue that can still produce broken HTMX behavior (and potentially a 500 on crafted input):

  1. _render_htmx_error trusts raw parent_id from request.POST.
  • File: apps/comments/views.py, lines 124-127.
  • Current code path does Comment.objects.filter(pk=parent_id, article=article).first() using unvalidated POST data.
  • In the invalid-form branch, parent_id may be non-numeric/tampered. This can raise lookup conversion errors for integer PKs, or return None and render _reply_form.html with an empty comment.id target.

Why this is blocking:

  • Invalid HTMX submissions should always degrade to a stable error response, not crash or generate broken swap targets.

Suggested fix:

  • In _render_htmx_error, parse/validate parent_id defensively (int coercion with fallback), and if parent is missing/invalid, return the top-level _comment_form.html error response instead of reply-form partial.

Also: the PR status currently still shows CI / ci failing, so merge readiness is not yet proven.

Re-review result: requesting changes. Most of the previous blockers are fixed, but I found one remaining correctness issue that can still produce broken HTMX behavior (and potentially a 500 on crafted input): 1) `_render_htmx_error` trusts raw `parent_id` from `request.POST`. - File: apps/comments/views.py, lines 124-127. - Current code path does `Comment.objects.filter(pk=parent_id, article=article).first()` using unvalidated POST data. - In the invalid-form branch, `parent_id` may be non-numeric/tampered. This can raise lookup conversion errors for integer PKs, or return `None` and render `_reply_form.html` with an empty `comment.id` target. Why this is blocking: - Invalid HTMX submissions should always degrade to a stable error response, not crash or generate broken swap targets. Suggested fix: - In `_render_htmx_error`, parse/validate `parent_id` defensively (int coercion with fallback), and if parent is missing/invalid, return the top-level `_comment_form.html` error response instead of reply-form partial. Also: the PR status currently still shows `CI / ci` failing, so merge readiness is not yet proven.
mark added 1 commit 2026-03-04 00:00:31 +00:00
fix: validate parent_id in error path, rebuild Tailwind CSS
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m29s
CI / pr-e2e (pull_request) Successful in 1m44s
0eddb9696a
- Defensively parse parent_id in _render_htmx_error: coerce to int,
  fallback to main form if non-numeric or parent not found
- Rebuild Tailwind CSS to include new utility classes from templates
- Add test for tampered parent_id falling back to main form

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
codex_a approved these changes 2026-03-04 00:03:15 +00:00
codex_a left a comment
Owner

Re-review complete. The prior blocker around HTMX error-path handling is now fixed (defensive coercion + safe fallback), and the additional regression test coverage is in place. I don’t see remaining blocking issues in this round. LGTM.

Re-review complete. The prior blocker around HTMX error-path handling is now fixed (defensive coercion + safe fallback), and the additional regression test coverage is in place. I don’t see remaining blocking issues in this round. LGTM.
mark merged commit ed878bbdae into main 2026-03-04 00:04:43 +00:00
mark deleted branch feature/comments-v2 2026-03-04 00:04:43 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: nohype/main-site#44