Repository source: docs/plans/archive/forte_hidden_content_permalinks_step4_implementation_summary.md
Forte Hidden Content Permalinks — Step 4: Implementation Summary
Stage 1 - Board-visibility-independent content-summary endpoint
Changes:
New
GET /api/get_forte_content_summary?post_id=...route, handled by newhandleForteContentSummary(array $query): void.New
forteContentSummary(string $postId): ?array- resolves via the existingfetchPost()(the same lookup every other single-post read uses), not the board's own filteredfetchThreads(), so identity/bootstrap/approval-only posts resolve exactly like any other post. Returnspost_id,thread_id,is_reply,title(via the existingThreadTitle::displayTitle()helper),author_label,created_at,body_preview(full body - the dialog itself scrolls), andreply_count(fromthreads.reply_countfor the post's root).null/404 for an unknown post id.
Verification:
php -l- no syntax errors.GET ?post_id=<board-visible reply>- 200, correct thread id,is_reply: true,reply_countmatches the thread's real count.GET ?post_id=<hidden identity_bootstrap post>(agent-bootstrap-20260502071201-2d91e107, excluded from the board's own listing) - 200, resolves exactly like a normal post - the entire point of this endpoint.GET ?post_id=<genuine root thread>(root-001) - 200,is_reply: false,thread_idequals its ownpost_id.GETwith a nonexistent id, and with nopost_idat all - both 404.Regression: classic
/activity/,/forte/,/forte/activity/all still 200.
Notes:
Caught and fixed a wrong assumption during verification:
posts.thread_idis self-referential for a root post (equals its ownpost_id), neverNULL- sothread_id !== nullis not a valid root/reply check for this table (unlike a similarly-named check on a different, write-side domain object seen elsewhere in this codebase, which does usenullfor "is a root").posts.parent_id(nullonly for a root) is the correct discriminator here; caught by comparing actual query output for a known root post against a known reply before trusting the first draft.isApplicationRoute()'s route allowlist (used only for members-only-mode gating) was not updated for this new endpoint - the same precedent as/api/forte_commit_detail//api/forte_activity_page, neither of which are in that list either, and both already work correctly without it.
Stage 2 - Content-summary modal on the Activity page
Changes:
New
templates/partials/paned_content_summary_dialog.php, mirroringpaned_profile_summary_dialog.php's structure exactly (native<dialog>, title bar with close button, loading/content/error states inside.paned-standalone-body- already scrollable via existing CSS, no new styles needed) with fields for kind (Thread/Reply), author, date, reply count, body text, and a "View full thread" link. Included fromforte_activity.phpas a sibling of.paned-board-layout(same placementpaned_profile_summary_dialog.phpuses inforte_board.php), so it survivessoftNavigateToSort()'s wholesale layout swap and only needs wiring once.New
showContentSummary(postId, fullHref)inpaned_activity_reader.js, mirroringshowProfileSummary()inpaned_board_reader.jsfield-for-field: sets the "full link" href, opens the dialog, fetches Stage 1's endpoint, and populates the dialog (falling back to an error state on failure). A document-level click listener (bound once, matching the profile-link interception pattern including its modifier-key/middle-click bypass) intercepts[data-forte-content-link]clicks and opens the dialog instead of navigating - the anchor's realhrefstays set underneath for a no-JS/failed-JS fallback.paned_activity_detail_article.php: content links now carrydata-forte-content-link data-post-id="<id>"(forte_link'slabelis already the target post id in every real content case) - excluded forsite_feature_flag, whose link isn't a post at all.
Verification:
php -l/node --check- no syntax errors.Browser (Playwright): clicking a board-visible item's content link opens the dialog in place (URL unchanged), correct title/author/kind/reply-count/body populate from the live endpoint, close button closes it.
Browser: clicking a hidden
identity_bootstrapitem's content link (item 226,view=bootstrap) also opens the dialog correctly - this already works today, before Stage 3 even removes the classic-fallback href, because the interception only checks fordata-forte-content-link/data-post-id, not the link's destination form. Its "View full thread" link still points at the classic route for now (expected - Stage 3 changes the href form, Stages 4-5 make that new form actually resolve).Regression: normal row selection/keyboard nav on the Activity page unaffected; classic
/activity/and the Forte board (/forte/) unaffected.
Notes:
No
activityItemBoardLink()/href changes in this stage - purely additive dialog/JS/template-attribute work, as scoped.
Stage 3 - Every content link routes through the new preview
Changes:
activityItemBoardLink(): dropped theisBoardVisiblecheck and the classic/posts///threads/fallback branch entirely - any item with a resolvable thread/post id now always returns the/forte?selected=<threadId>&created_post_id=<postId>#post-<postId>form.site_feature_flag's distinct destination is unchanged; an item missing a resolvable id still returns['href' => '', 'label' => ''](no link rendered at all, same as before).
Verification:
php -l- no syntax errors.Item 226 (hidden
identity_bootstrap,view=bootstrap): link href is now the/forte?selected=...form (previously classic); clicking it opens the summary dialog correctly, and the dialog's "View full thread" link now also uses the new form (still won't resolve as a real board destination until Stages 4-5 - expected).Item 1337 (
thread_label_add, rootroot-001): link correctly resolves to the thread's root id; dialog opens with the root thread's own summary ("Hello world"), no error.Regression: all 5 activity views (
all/content/identity/bootstrap/approval) load correctly; classic/posts/root-001, classic/activity/, and RSS all still 200/unaffected (their own routes and rendering are untouched - only the Activity page's own link generation changed).
Notes:
Between this stage and Stage 5, a hidden thread's "View full thread" link is a real, well-formed URL that doesn't yet land on working content - an expected, temporary gap called out in the Step 3 plan, closed by Stages 4-5.
Stage 4 - Resolve a hidden thread on demand for the board
Changes:
New
fetchThreadById(string $threadId): ?array- the exact queryfetchThreads()runs, minus itsisHiddenBootstrapBoardTagsJsonexclusion, for one thread by id; reuses the existinghydrateThreadRow()single-row hydrator so its shape matches every other thread row exactly.renderForteBoard(): whenresolveForteBoardSelection()can't find the requested selection in the board's own (filtered)$threads, it now triesfetchThreadById()directly; a hit sets$selectedThreadIdand produces$contentThreads($threadsplus this one extra thread) -$threadsitself,$tagGroups, and all counts stay untouched. Reply-tree building,$allPostIds,$highlightedPostId, and the viewer-like/flag lookups now iterate$contentThreadsinstead of$threads, so a hidden thread's replies/reactions render correctly too -fetchAllThreadReplyPosts()already covers every thread regardless of board-tag visibility (it only filters moderation'sis_hidden), so no new reply-fetching was needed.forte_board.phppasses the new$contentThreads(not$threads) topaned_board_content_pane.phpspecifically -paned_board_thread_list.phpand the folder tree/status bar keep receiving the original, unaffected$threads.
Verification:
php -l- no syntax errors.GET /forte?selected=<hidden identity_bootstrap root>: 200, its content article renders and is unhidden (selected), no matching row exists in the list, anddata-paned-board-total-countis unchanged (530, same as a plain/forte/load).GET /forte?selected=<hidden thread with a real reply>&created_post_id=<that reply>: 200, the reply tree renders inside the hidden thread's article (no PHP warnings/fatals in the response).GET /forte?selected=<nonexistent id>: 200, falls back to the "no thread selected" placeholder exactly as before this stage (regression-safe for an unresolvable id).Regression:
GET /forte?selected=<a normal, board-visible thread>still gets both a list row and a content article, exactly as before.Confirmed (expected, not yet fixed): visiting the dialog's "View full thread" link for a hidden thread in a real browser doesn't yet show it - the client-side
restoreSelectionFromUrl()still requires a matching visible row before it will select anything, which this stage doesn't touch. This is precisely Stage 5's job.
Notes:
replyEnabled(toolbar prop,selectedThreadId !== '') becomes true for a hidden thread too once Stage 5 lets the client actually select it - left as-is deliberately: a reader replying to a bootstrap/hidden thread is an existing, valid capability this feature doesn't need to restrict, not a new one it's introducing.
Stage 5 - Client-side selection for a thread with no list row
Changes:
restoreSelectionFromUrl()inpaned_board_reader.js: when?selected=matches no visible row, it now also checks for a matching content-pane article (data-paned-board-content-post-id) before giving up - only falling back to the "no thread selected" placeholder if neither exists.selectThread()itself needed no changes; its row-matching loop was already tolerant of zero matches. Row scroll-into-view is skipped when there's no row (nothing to scroll to); the reply-highlight/scroll behavior forcreated_post_idis unchanged and shared by both paths.
Verification:
node --check- no syntax errors; unit-checked the new condition's operator precedence in isolation (no row + a selection -> falls through to the content-post check; a row present -> short-circuits tonullimmediately; no selection at all ->null) - matches intent in all three cases.Browser (Playwright), direct URL:
/forte?selected=<hidden thread>&created_post_id=<reply>#post-<reply>- the hidden thread's content article is visible, the placeholder is hidden, no console errors.Browser, full end-to-end: Activity item (hidden
identity_bootstrap, item 226) -> content-summary dialog -> "View full thread" -> lands on the Forte board with that exact thread now visible and selected. No console errors anywhere in the flow.Regression: a normal, board-visible thread (
root-001) still gets its list row highlighted and scrolled into view on direct selection; a nonexistent id and no-selection-at-all both still show the placeholder exactly as before; clicking an ordinary row still selects it.
Notes:
This closes the gap Stage 4 explicitly left open - the feature's end-to-end flow (Activity link -> in-place preview -> full thread, for board-visible and hidden content alike) is now fully working. Stage 6 is the final regression/integration pass across the whole feature.
Stage 6 - End-to-end regression pass
Changes: none - verification-only stage, as scoped.
Verification (all via a real browser, Playwright):
All 5 non-commit activity views (
content/identity/bootstrap/approval, plusallcovered throughout earlier stages) - one representative item per view, clicking its content link opens the dialog with correct data, and "View full thread" lands on a correctly-selected, visible thread in the board. Covered both a root-post item and a reply item (approval view item 234: a reply into threadbootstrap-20260915111820-50f698ae) - the reply case confirms the thread's root is selected and the specific reply is highlighted viacreated_post_id, not just "some content is visible."Commits view (Stage 3-era work, untouched by this feature): 100 commit rows still render, clicking one still opens its on-demand manifest correctly - confirms this feature's changes to the shared activity-item article template didn't regress the separate commit-row/detail code path.
Forte board's own profile-summary dialog (the pattern this feature's dialog was modeled on) still opens correctly from an author link inside a selected thread - confirms the new dialog didn't interfere with the existing one (distinct
data-role/dialog-selector attributes throughout).Classic
/posts/<id>,/threads/<id>,/activity/, and RSS (/activity/?format=rss) - all still 200, all untouched by this feature (only Forte's own link-generation and board-selection logic changed).Zero browser console errors across every scenario above.
Notes:
No issues surfaced requiring code changes - the feature works end-to-end as specified in Step 2, across board-visible and hidden content, roots and replies, with no regressions to the Commits view, the profile-summary dialog, or the classic interface.
Post-completion fixes (after "Approved Step 4" was requested)
Close on backdrop click; message-box styling. User feedback: the content-summary dialog should close when clicking outside it, and should read as a plain message box (gray header, white body, "View full thread" in a gray footer) rather than the app's own blue-titlebar window chrome.
paned_content_summary_dialog.php: dropped the shared.paned-new-thread-dialogclass (used by the app's other dialogs, which should keep their current look); restructured into three explicit zones -.paned-content-summary-header,.paned-content-summary-body,.paned-content-summary-footer(the "View full thread" link moved out of the content area into this new persistent footer, so it's available even during loading).forte.css: new dedicated rules for the three zones (gray header/footer via--paned-chrome-dark, white body via--paned-content-bg) scoped entirely to.paned-content-summary-dialog- the profile-summary and new-thread dialogs are untouched.paned_activity_reader.js: added a click listener on the dialog element itself, closing it whenevent.target === dialog(the standard way to detect a native<dialog>'s backdrop click, since the dialog's own box is sized to its content).
Verification:
Browser (Playwright): header/body/footer computed background colors match the design (gray/white/gray); clicking inside the body leaves the dialog open; clicking the backdrop closes it; the existing close button still works.
Regression: the board's profile-summary dialog still opens with its original blue titlebar, unaffected.
Follow-up correction: message-box styling replaced with a closer match to the real interface, plus a real page-load bug fix. User clarified with a reference screenshot (a classic Windows dialog: blue titlebar, plain gray body, an inset white preview box with margin on all sides, buttons in a plain gray footer) and specific corrections: metadata should live in the header area formatted like the main interface's own post header, the titlebar should be blue (not gray, undoing the prior fix's header-recoloring), only the post's own text should be white (not the whole body area), and that white area needs visible margin around it. They also caught a real bug: an empty, uninitialized dialog was appearing on every page load.
Bug fix:
.paned-content-summary-dialoghad an unconditionaldisplay: flex- since author-origin CSS always wins over the browser's own UA stylesheet regardless of selector specificity, this silently overrode the browser's defaultdialog:not([open]) { display: none }, so the closed dialog rendered inline on every page load instead of staying hidden. Fixed by movingdisplay: flexonto.paned-content-summary-dialog[open]instead, so it only applies once the dialog is actually shown.Structure: rebuilt the dialog's body as a miniature, literal clone of the main reader's own content pane - reusing
.paned-content-pane(white,margin: 0.5rem, bordered - this is where the "margin all around" comes from) containing an<article class="paned-content-post">with.paned-content-head/.paned-content-subject/.paned-content-meta(gray, holding the post's title and Kind/From/Date/Replies) and.post-card .body(the actual post text, on white) - the exact same classes and DOM shapepaned_activity_detail_article.phpuses, not a lookalike. The dialog's own titlebar reverted to the shared.paned-dialog-titlebar(blue, matching every other dialog in the app) with a fixed "Post Preview" label; the footer (.paned-content-summary-footer, plain background, top border, "View full thread") sits below, unchanged in spirit from the prior fix.paned_activity_reader.js:showContentSummary()updated for the new field locations (subject/kind/author/date/replies now populate the head/meta area instead of a single hidden/shown content block); the backdrop-click-to-close listener from the prior fix needed no changes.
Verification:
Browser (Playwright): confirmed
getComputedStyle(dialog).display === "none"on a fresh page load (the bug is gone); opened the dialog and confirmed each zone's actual background color (blue titlebar, gray content-head, white.paned-content-pane,margin: 0.5remon the pane) plus a full-page screenshot showing the result reads as a coherent message box closely matching the reference image's layout.Regression: hidden-content item (226) still shows correct data and its "View full thread" link still resolves to the right board thread; an unknown post id still shows the error state gracefully; the board's own profile-summary dialog is unaffected.