Forte Reply — Step 4: Implementation Summary

Stage 1 - Embed hidden compose panel markup

  • Changes:

    • Added templates/partials/paned_compose_panel.php: wraps partials/reply_form.php in a hidden .paned-compose-panel container, prefilled with thread_id/parent_id = the thread's root post, board_tags = general, using a Forte-specific paned-compose-form class (not the classic .stack) to keep styling isolated from site.css.

    • Included the new partial from templates/partials/paned_content_pane.php, passing through $thread (already available in that scope).

  • Verification:

    • php -l clean on both new/changed files.

    • curl http://127.0.0.1:8001/threads/root-001/forte — 200 OK; response contains <div class="paned-compose-panel" data-paned-compose-panel hidden> with a paned-compose-form reusing /compose/reply, and hidden inputs thread_id=root-001 / parent_id=root-001.

    • curl http://127.0.0.1:8001/threads/root-001 (classic page) — 200 OK; grep -c paned-compose-panel returns 0, confirming no leakage into the classic UI.

    • Toolbar "Reply" button remains disabled (unchanged) since wiring it up is Stage 2's scope.

  • Notes:

    • reply_form.php was reused completely unchanged; only its container and formClass differ per Step 2's shared-component inventory.

Stage 2 - Enable Reply button and wire panel toggle

  • Changes:

    • templates/pages/forte.php: removed disabled from the toolbar Reply button, added data-paned-reply hook (button is unconditionally enabled since Forte always has a default-selected post).

    • public/assets/paned_reader.js: added a click handler on [data-paned-reply] that toggles [data-paned-compose-panel]'s hidden attribute; follows the same querySelector + guarded addEventListener pattern already used for the prev/next buttons.

  • Verification:

    • node --check public/assets/paned_reader.js and php -l templates/pages/forte.php both clean.

    • curl http://127.0.0.1:8001/threads/root-001/forte — Reply button now renders without disabled and with data-paned-reply; New/Refresh buttons remain disabled (untouched, out of scope).

    • Headless-browser click test (Selenium + system chromium-browser/chromedriver, no new project deps): loaded /threads/root-001/forte, confirmed panel starts hidden, first click on Reply removes hidden, second click restores it, zero SEVERE console log entries.

  • Notes:

    • Selection-based sync of the panel's parent_id is deferred to Stage 3, as planned.

Stage 3 - Sync compose panel target with pane selection

  • Changes:

    • public/assets/paned_reader.js: hoisted the composePanel lookup to the top of the DOMContentLoaded handler (alongside listBody/contentPane) and had the central selectPost(postId) function update the compose form's hidden input[name="parent_id"] on every selection change (click, prev/next buttons, and arrow-key navigation all funnel through selectPost, so all of them stay in sync automatically). Removed the now-redundant composePanel lookup from the Stage 2 toggle-wiring block.

    • Per the Stage 3 risk note, only the hidden parent_id field is reset on selection change; any in-progress body text the reader typed is left untouched.

  • Verification:

    • node --check public/assets/paned_reader.js clean.

    • Headless-browser test (Selenium + chromium-browser): on /threads/root-001/forte, parent_id starts as root-001; clicking the reply-001 row updates it to reply-001; clicking back to the root-001 row restores root-001; zero SEVERE console log entries.

  • Notes:

    • No change needed to the prev/next or arrow-key handlers since they all call the same selectPost.

Stage 4 - Style compose panel to match Forte chrome

  • Changes:

    • public/assets/forte.css: added scoped rules for .paned-compose-panel/.paned-compose-head (beveled panel matching .paned-content-head's look), .paned-compose-form textarea (monospace body font, sunken-bevel border matching .paned-content-pane), and .paned-window .paned-compose-form button (raised-bevel toolbar-style buttons). No site.css changes.

    • Fixed a real cross-stylesheet leak found during visual verification: site.css's .compose-form-actions > button[type="submit"] { flex: 1 1 auto } (from the classic compose UI) was stretching the reused submit buttons full-width inside the Forte panel since the new rules hadn't overridden flex. Added .paned-window .paned-compose-form .compose-form-actions > button { flex: 0 0 auto; width: auto; } (higher specificity than the site.css rule) so the buttons render at their natural, toolbar-like width instead.

  • Verification:

    • grep -c paned-compose public/assets/site.css — 0 (no leakage into the classic stylesheet).

    • Headless-browser screenshots (Selenium + chromium-browser) of /threads/root-001/forte with the composer open: panel head, textarea, and buttons all render in the paned beveled/monospace style consistent with the rest of the window; buttons are compact and toolbar-like rather than the classic full-width bars, after the flex fix above.

    • git status --short public/assets/ confirms only forte.css changed among stylesheets.

  • Notes:

    • This stage is a good illustration of Step 2's stated maintenance risk: reusing reply_form.php's classes across two independently-styled surfaces means a classic-side layout rule can leak into Forte until explicitly overridden.

Stage 5 - Return to Forte after replying

  • Changes:

    • partials/reply_form.php: added a hidden return_to input, defaulting to '' when the caller doesn't pass one (classic callers are unchanged).

    • partials/paned_compose_panel.php: passes returnTo = /threads/{root_post_id}/forte.

    • Application.php::handleComposeReplySubmit: on success, resolves the redirect target via new resolveComposeReplyReturnTo(string $requestedReturnTo, string $threadId): string, which only honors return_to if it exactly matches /threads/{the actual created reply's thread_id}/forte; anything else (empty, a foreign URL, or a mismatched thread id) falls back to the original /threads/{thread_id} classic location. This closes an open-redirect/header-injection risk from trusting a client-supplied hidden field outright.

    • Validation-error path (renderComposeReplyPage) intentionally still renders the classic compose-error page rather than a Forte-native one — building that view is materially larger scope (a new template) than this stage's budget, and the common/success path now stays in Forte. Flagged as a known follow-up rather than silently expanded.

  • Verification:

    • php -l clean on all three changed files.

    • curl -i -X POST /compose/reply with a valid return_to=/threads/root-001/forte — 303 redirect Location correctly targets /threads/root-001/forte?created_post_id=...#post-....

    • Same request with no return_to — redirects to the original classic /threads/root-001?... location (unchanged behavior).

    • Same request with return_to=https://evil.example.com and with return_to pointing at a different thread's Forte URL — both fall back to the classic /threads/root-001?... location (open-redirect/cross-thread guard confirmed).

    • curl http://127.0.0.1:8001/threads/root-001/forte after a successful post-from-Forte submission shows the new reply body text in the rendered page.

  • Notes:

    • No changes needed to post_card.php/thread_root_card.php's classic "Reply" links or thread.php's inline composer — they don't pass returnTo, so they keep the pre-existing classic redirect behavior automatically.