Add AJAX comment submission with graceful fallback.

Reply forms now submit via fetch() when JS is available, inserting
the new comment into the DOM without a page reload. Falls back to
the traditional POST + redirect when JS is disabled or on any error.
Documents capability-driven presentation practice in CLAUDE.md.
This commit is contained in:
russell@unturf.com 2026-02-07 16:39:59 -05:00
parent e5693b61b0
commit f9a00708db
3 changed files with 165 additions and 0 deletions

View file

@ -242,6 +242,40 @@ curl -s "https://git.unturf.com/api/v4/projects/engineering%2Fremarkbox%2Fremark
The pipeline status will show `"status":"success"` when deployment is complete.
## Capability-Driven Presentation
Follow Russell Ballestrini's capability-driven presentation practice
(russell.ballestrini.net/capability-driven-presentation/). A page need not look
identical across all browsers. Accommodate what the user's browser can do:
1. **Single canonical URI** — one URL serves the content.
2. **Consistent content** — regardless of viewer capabilities.
3. **Graceful enhancement/degradation** — use available capabilities to enhance presentation.
### The `js-only` / `<noscript>` pattern
Already implemented in `base.j2`:
```html
<noscript>
<style>.js-only {display: none;}</style>
</noscript>
```
Apply the `js-only` class to any element that requires JavaScript to function
(preview panels, AJAX submit buttons, typeahead UIs). When JS is unavailable,
these elements hide automatically — the user never sees a broken control.
### AJAX form submission
Comment reply forms use progressive enhancement: the form works as a normal
POST + redirect without JS. When JS is available, `initAjaxCommentForms()` in
`custom.js` intercepts the submit, sends via `fetch()` with
`X-Requested-With: XMLHttpRequest`, and inserts the new comment into the DOM
without a page reload. The server returns JSON (HTTP 201) for AJAX requests
from verified/anonymous users, and falls back to the normal redirect flow for
unverified users or on any error.
## Style
- **Never use "AI" — always say "machine learning."** We grow machine learning, not "AI." This term is forbidden in all permacomputer discourse, marketing, & documentation.

View file

@ -212,6 +212,110 @@ function escapeHtml(text) {
return div.innerHTML;
}
// AJAX comment submission — capability-driven presentation.
// When JS is available, intercepts reply form POSTs and submits
// via fetch so the page does not reload. When JS is disabled,
// the form falls back to the normal POST + redirect.
function initAjaxCommentForms() {
document.querySelectorAll('form[action*="/reply"]').forEach(function(form) {
// Skip forms that are already wired up.
if (form.dataset.ajaxBound) return;
form.dataset.ajaxBound = '1';
form.addEventListener('submit', function(e) {
var submitBtn = form.querySelector('.rb-submit');
if (!submitBtn) return; // let normal submit proceed
e.preventDefault();
var formData = new FormData(form);
var originalLabel = submitBtn.value;
submitBtn.disabled = true;
submitBtn.value = 'Sending...';
fetch(form.action, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(response) {
if (!response.ok || response.status !== 201) {
// Auth redirect, validation error, or server error
// — fall back to regular form submit.
submitBtn.disabled = false;
submitBtn.value = originalLabel;
form.submit();
return;
}
return response.json();
})
.then(function(data) {
if (!data) return;
insertReply(data, form);
// Clear the textarea.
var textarea = form.querySelector('.common-textarea');
if (textarea) textarea.value = '';
// Clear the preview.
var preview = form.querySelector('.preview');
if (preview) preview.innerHTML = '';
submitBtn.disabled = false;
submitBtn.value = originalLabel;
})
.catch(function() {
// Network error — fall back to regular form submit.
submitBtn.disabled = false;
submitBtn.value = originalLabel;
form.submit();
});
});
});
}
function insertReply(data, form) {
// Build a new node div matching the existing markup structure.
var nodeDiv = document.createElement('div');
nodeDiv.id = 'node-' + data.id;
nodeDiv.className = 'node';
var authorSpan = '<span class="author-and-date">'
+ '<strong>' + escapeHtml(data.author_name) + '</strong> '
+ '<span class="date">' + escapeHtml(data.ago_string) + '</span>'
+ '</span>';
var contentDiv = '<div id="node-data-' + data.id + '" class="node-data">'
+ data.data_html + '</div>';
var statusHtml = '';
if (!data.approved) {
statusHtml = '<span>(hidden: waiting for approval)</span>';
}
nodeDiv.innerHTML = authorSpan + statusHtml + contentDiv;
// Insert after the reply form's parent container.
var parentNode = form.closest('.node, .remark-box-div-main');
if (parentNode) {
// Find or create a children container.
var childrenContainer = parentNode.querySelector('[id^="node-children-"]');
if (childrenContainer) {
childrenContainer.insertBefore(nodeDiv, childrenContainer.firstChild);
} else {
parentNode.appendChild(nodeDiv);
}
}
// Add anchor for the new comment.
var anchor = document.createElement('a');
anchor.className = 'anchor';
anchor.id = data.id;
nodeDiv.parentNode.insertBefore(anchor, nodeDiv);
nodeDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Briefly highlight the new comment.
nodeDiv.classList.add('focused');
setTimeout(function() { nodeDiv.classList.remove('focused'); }, 3000);
}
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', function() {
@ -229,6 +333,9 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
// Initialize AJAX comment forms (capability-driven presentation).
initAjaxCommentForms();
// Vote button handlers
document.querySelectorAll('button.vote-up').forEach(function(btn) {
btn.addEventListener('click', function() {

View file

@ -4,6 +4,8 @@ from pyramid.csrf import check_csrf_token
from pyramid.httpexceptions import HTTPFound
from pyramid.response import Response
from . import (
get_referer_or_home,
get_embed_route_uri,
@ -137,6 +139,28 @@ def reply_node(request):
msg = ("Your post was successful!", "success")
request.session.flash(msg)
# AJAX request — return JSON instead of redirect (capability-driven presentation).
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
if is_ajax and (user_surrogate or child.verified):
author_name = ""
if child.user:
author_name = child.user.name or ""
elif child.user_surrogate:
author_name = child.user_surrogate.name or "Anonymous"
return Response(
json={
"id": str(child.id),
"parent_id": str(child.parent_id) if child.parent_id else None,
"data_html": child.data_html,
"author_name": author_name,
"ago_string": child.human_created_timestamp,
"approved": child.approved,
"depth": child.graph_depth,
"verified": child.verified,
},
status_code=201,
)
# set return_to URI.
if request.mode == "embed":
return_to = get_embed_route_uri(request, child.root.uri.data, child.id)