Step 4: Implementation Summary — Merge Same-Author Reply Chains on Thread Pages

Stage 1 - Mark continuations server-side

  • Changes:

    • templates/pages/thread.php: added a $continuationWindowSeconds = 15 * 60 named window and a local $isThreadPostContinuation closure (same author, time delta within the window, neither post agent-authored via author_label === 'reply-agent'); the reply loop now tracks the previous post (root or prior reply) and passes isContinuation into each post_card.php partial call.

    • templates/partials/post_card.php: added continuation class and data-time="HH:MM" to the <article> when flagged, plus data-author="<author_label>" on every post card.

    • templates/partials/thread_root_card.php: added data-author="<author_label>" to the root <article> (root is never itself a continuation, but is the comparison anchor for the first reply).

  • Verification:

    • php -l on all three changed files — no syntax errors.

    • Scratch script (/tmp/.../scratchpad/verify_stage1.php, removed after use) built a temp repo copy with a root post, 6 guest replies 40s–85s apart, and a 7th guest reply ~28 minutes later; rendered /threads/cont-root via a real Application instance and inspected the raw HTML: all 6 quick replies got continuation + correct data-time="HH:MM", the 28-minute-later reply did not, and the root card got data-author="guest" with no continuation class.

    • Standalone unit check of the closure logic confirmed the agent-authored exclusion: a guest reply immediately after an agent-authored post is NOT flagged as a continuation, and two consecutive agent-authored posts are NOT flagged either.

    • php tests/run.php (full suite): 590 run, typically 584 passed / 6 failed, matching the existing tracked pre-existing baseline. Ran the full suite 5x; the only failure that varied between runs was WriteApiSmokeTest::testIncrementalApprovalMatchesFreshRebuildForTransitiveApprovalAndScoreRefresh, which failed 2/5 times with this stage's changes present. Investigated: the diff is on the /activity/?view=approval page, not the thread page, and is caused by two posts sharing the exact same 2026-09-30T05:11:16Z timestamp getting tie-broken in a different order between the incremental-update DB path and the fresh-full-rebuild DB path — nothing to do with this stage's continuation/data-time/data-author attributes (confirmed absent from the diff). Re-ran the same test 5x with these changes fully reverted (git stash) and it still failed 1/5 times, confirming this is a pre-existing flake in the read-model tie-break logic, not a regression introduced here.

  • Notes:

    • No visual change yet — this stage is server-side flagging only; Stage 2 does the CSS merge.

    • Pre-existing flake WriteApiSmokeTest::testIncrementalApprovalMatchesFreshRebuildForTransitiveApprovalAndScoreRefresh (same-second timestamp tie-break on the activity page) is out of scope for this feature and was not introduced by this change; left as-is.

Stage 2 - Merge continuation runs visually with CSS

  • Changes:

    • public/assets/site.css: .post-card-permalink now defaults to opacity: 0 and reveals on .post-card:hover/:focus-within (applies to every post card, not just runs).

    • public/assets/content-interactions.css: added the continuation-run rules — a card immediately followed by .continuation loses its bottom border/margin (:has(+ .continuation)); .continuation itself loses its top margin and gains a faint dashed top separator (color-mix(in srgb, var(--line) 35%, transparent), theme-aware); .continuation > .meta is hidden; .continuation::before shows attr(data-time) in the left gutter on hover/focus; every card in a run except the last hides .post-card-actions until hover/focus-within.

  • Verification:

    • php brace-balance sanity check on both CSS files (equal {/} counts).

    • Seeded a throwaway thread (root + 6 quick guest continuations) into the local dev repo (state/local_repository, gitignored scratch data), ran ./v3 rebuild, served the app locally, and captured headless-Chromium screenshots at desktop width and phone width (390px): the root post and all 6 continuations render as one visually merged card with a single byline and a single action row at the bottom (on the last piece); dashed separators appear between pieces; no repeated headers/action rows. Confirmed via curl that data-time/continuation attributes are present on exactly the 6 intended posts.

    • Attempted an automated dark-theme screenshot (--blink-settings=preferredColorScheme=2 / --force-color-scheme=dark); theme switching in this app is done by client-side JS swapping a <link> to a separate theme-dark.css file (not a prefers-color-scheme media query or [data-theme] selector in the edited stylesheets), so a single-shot headless screenshot didn't exercise it. Confirmed by reading the theme files directly that both theme-light.css and theme-dark.css redefine --line/--ink-soft — the only custom properties the new rules reference — so the merge rules are theme-agnostic by construction; an interactive dark-theme screenshot (actually switching the theme) is deferred to Stage 4's dedicated cross-theme pass.

    • Cleaned up afterward: killed the temp dev server, hard-reset the scratch commit in state/local_repository, re-ran ./v3 rebuild to restore the original read model (confirmed post/thread counts back to baseline), removed screenshots.

    • php tests/run.php (full suite): 590 run, 584 passed, 6 failed — matches the tracked pre-existing baseline exactly, no new failures.

  • Notes:

    • Touch devices have no :hover; a tap/focus-reachable affordance for the per-run action-row reveal is deferred to Stage 4 (:focus-within already keeps it keyboard-reachable since the row isn't display: none).

    • :has() selector support wasn't independently verified in older browsers — acceptable for this stage since Stage 4 owns the full cross-browser/theme/touch acceptance pass.

Stage 3 - Collapse root card header to one meta line with a continuation-aware reply count

  • Changes:

    • templates/pages/thread.php: pre-computes continuation flags for all replies in one pass (before rendering the root card, so the true reply count is known up front), counts replies where the flag is false into $trueReplyCount, and passes it to thread_root_card.php; the reply-render loop now reuses the pre-computed flags array instead of recomputing inline.

    • templates/partials/thread_root_card.php: merged the three separate <p class="meta"> lines (byline, labels, agent marker) into one <p class="meta">, joined with " · ", and appended {N} reply/replies (only when $trueReplyCount > 0), matching the existing pluralization convention already used by thread_card.php's board/tag-list reply count. Did not invent a "like" text token in the merged line — no existing per-viewer like-state text exists anywhere else in this codebase to reuse, and the actual Like action remains unchanged in the action row below. Title/body dedup logic (from a prior slice) was untouched.

  • Verification:

    • php -l on both changed files — no syntax errors.

    • Reused the Stage 1 scratch script's temp repo (root + 6 quick continuations + 1 reply 28 minutes later) and asserted the root card's single meta line reads "...· 1 reply" — correctly counting only the true (non-continuation) reply, not the 6 merged pieces.

    • Ran a second scratch check against the existing parity_minimal_v1 fixture's root-001 thread: its one reply (reply-001, same guest author, 5 minutes after root) is itself a continuation of the root, so the merged meta line correctly shows no reply-count segment (0 true replies) while still showing "· Labels: bug, needs-review" for its thread labels — confirms the label-merge path works and that continuation detection is consistently applied to the fixture data already exercised by the existing test suite.

    • php tests/run.php (full suite): 590 run, 584 passed, 6 failed — identical to the tracked pre-existing baseline (including the WriteApiSmokeTest timestamp-tie-break flake noted in Stage 1, unrelated to this change); confirmed the existing assertStringContains('Labels: bug, needs-review', $thread) assertion (which only checks substring presence) still passes against the merged single-line format.

  • Notes:

    • Reintroduces a reply count on the root card that a prior, unrelated slice (thread_root_card_remove_reply_count) had deliberately removed — this is intentional per Step 2's scope (a continuation-aware count is a materially different feature than the flat count that was removed) and no test was found asserting the count's absence, so nothing needed to be reverted.

    • Board/tag list reply counts (thread_card.php) remain untouched, per Step 2 scope.

Stage 4 - Cross-theme, responsive, and touch pass

  • Changes:

    • Theme audit found 3 of 13 theme files whose theme-scoped selectors had higher CSS specificity than Stage 2's rules and would have silently overridden them: theme-arena.css and theme-word97.css both redeclare .card { padding: ... } and .stack > * + * { margin-top: ... } at :root[data-theme="X"] .stack > * + * specificity, which beats a bare .post-card.continuation; added matching :root[data-theme="arena"/"word97"] .post-card.continuation (margin-top: 0, padding-top override) and .post-card:has(+ .continuation) (padding-bottom override) rules to each. theme-sticker.css draws its card boundary with outline (not border), which can't be removed on one side only, so a fully seamless single-card illusion isn't achievable there; added a smaller (0.35rem vs 1.5rem) continuation margin so pieces still sit tightly stacked, and documented the outline limitation as a known, theme-specific tradeoff rather than a bug. The other 10 themes had no conflicting overrides.

    • content-interactions.css: added a @media (max-width: 640px) override repositioning .continuation::before's hover-timestamp from the -3.2rem left gutter (which doesn't exist at phone width — .shell only has 1rem of side padding there) to a small inline badge anchored at the card's own top-left corner.

    • Added a touch-reachable affordance for the per-run hidden action row: a small always-focusable <button class="post-card-actions-toggle">Actions</button> inserted as a sibling immediately before .post-card-actions in both post_card.php and thread_root_card.php. It's hidden by default and only shown inside @media (hover: none) (touch-primary devices), so hover-capable desktop/mouse users see no change from Stage 2. Tapping it focuses it, which satisfies the existing .post-card:has(+ .continuation):focus-within > .post-card-actions rule from Stage 2 — no new reveal logic needed, and no JS was added (the "no client-side JS" constraint from Step 3 was specifically about the continuation flag computation, which stays server-side; this is a plain focusable button with no script behavior of its own).

    • public/assets/site.css: no changes this stage (audited, no conflicts found there beyond what Stage 2 already handled).

  • Verification:

    • php -l on both changed templates; brace-balance check on all 4 changed CSS files — all clean.

    • Re-seeded the Stage 2 throwaway thread (root + 6 quick continuations) into the local dev repo, rebuilt the read model, served the app locally, and used headless Chromium to check: desktop width (900px) shows the merged card with no visible "Actions" toggles (correctly gated behind (hover: none) in this environment's default emulation — see caveat below); phone width (390px) renders correctly with no horizontal overflow from the repositioned gutter timestamp; navigating directly to #post-verify-cont-3 (a mid-run continuation) auto-scrolled to and highlighted that exact piece with the expected :target border-color change, confirming the deep-link/highlight requirement still holds on a merged piece.

    • Caveat: this headless Chromium environment reports (hover: none) as true by default regardless of window size (no real pointing device attached to the virtual display), so the toggle button rendered as always-visible in every screenshot taken here, including "desktop-width" ones — this is a property of the test environment, not the CSS: (hover: none)/(hover: hover) is a long-standing, universally-supported media feature, and a real desktop browser with an actual mouse will correctly report hover: hover and never show the toggle. Could not get a real mouse-vs-touch differential screenshot in this sandbox; flagged rather than silently assumed.

    • Cleaned up afterward: killed the temp dev server, hard-reset the scratch commit in state/local_repository, re-ran ./v3 rebuild to restore the original read model, removed screenshots and cookie jar.

    • php tests/run.php (full suite): 590 run, 583–584 passed depending on the pre-existing WriteApiSmokeTest flake (see Stage 1 notes) — no new failures beyond the tracked pre-existing baseline.

  • Notes:

    • The sticker theme's outline-based card boundary is a genuine, theme-specific visual limitation (pieces stay individually outlined rather than merging into one seamless card); acceptable given it's consistent with that theme's overall "everything gets a hard outline" aesthetic, and out of scope to redesign here.

    • This completes the acceptance-check items from improve_thread_view.txt: merged card with one byline/action row, hover-revealed time/actions per piece, separate agent-reply card, decluttered single-line header, working deep links — verified directly against the example thread shape (root + 6 quick same-author replies + trailing agent-style gap reply) across light theme, phone width, and (by code inspection, per Stage 2/3 notes) dark/other themes via the same CSS custom properties.

Stage 5 - Automated test coverage

  • Changes:

    • tests/LocalAppSmokeTest.php: added testThreadMergesSameAuthorQuickRepliesIntoContinuations(). Builds a temp copy of the parity_minimal_v1 fixture with a root post and 4 replies: two quick guest replies 5 and 7 minutes apart (both should merge as continuations), then two further guest replies each separated by a >15-minute gap from the previous post (both should count as true replies, not continuations). Asserts: the two quick replies get the continuation class and the correct data-time="HH:MM"; the two gapped replies do not get continuation; the root card and first continuation both carry data-author="guest"; both continuations' permalinks (<a class="post-card-permalink" href="/posts/...">) are still present and point at their own post ids; the root card's single merged meta line contains "2 replies" (the true count, excluding the 2 continuations).

  • Verification:

    • php -l tests/LocalAppSmokeTest.php — no syntax errors.

    • Ran the new test in isolation (php tests/run.php LocalAppSmokeTest::testThreadMergesSameAuthorQuickRepliesIntoContinuations) — passes.

    • php tests/run.php (full suite): 591 run (590 + this new test), 585 passed, 6 failed — same 5 long-tracked pre-existing failures plus the already-known WriteApiSmokeTest timestamp-tie-break flake from Stage 1 (this particular run happened to land on the "passing" side of that flake, hence "Newly recovered" in the run's own diff-against-last-run bookkeeping — not related to this stage's change). No new failures introduced.

  • Notes:

    • Did not attempt to fixture an agent-authored post or a distinct-named second author at the full-application level: an agent identity requires standing up AgentIdentityService bootstrap machinery, and this fixture repo's one pre-existing bootstrapped identity (openpgp:0168ff20eb09c3ea6193bd3c92a73aa7d20a0954) turned out to resolve to the display label "guest" too (its profile has no claimed username), so it wouldn't actually exercise a "different author" case. Both branches (agent-authored exclusion, and the basic same-vs-different-author string comparison) were already verified directly against the isThreadPostContinuation closure logic via a standalone unit-style check during Stage 1 and are simple, low-risk boolean branches; adding fixture machinery just to re-prove them at the full-HTTP-render level wasn't judged worth the added complexity.