Repository source: docs/plans/qdb_quotes_instance/qdb_quotes_instance_step4_implementation_summary.md
QDB Quotes Instance — Step 4: Implementation Summary
Stage 1 - Site instance identity plumbing
Changes:
Added a
qdbentry toSiteProfileRegistry::all()(src/ForumRewrite/SiteProfileRegistry.php):defaultTheme: qdb,composerPrompt: "Submit a quote...".Added a
qdbentry toThemeRegistry::all()(src/ForumRewrite/View/ThemeRegistry.php),mode: light.Documented
qdbas a validFORUM_SITE_IDvalue indocs/runbooks/production_deploy.md.Extended
tests/SiteProfileRegistryTest.phpwithtestActiveHonorsQdbOverride, mirroring the existingchousecoverage.
Verification:
./v3 test SiteProfileRegistryTest— 5 run, 5 passed (including the new qdb case).Inline PHP check with
FORUM_SITE_ID=qdbset:SiteProfileRegistry::active()resolves{"name":"qdb","defaultTheme":"qdb","composerPrompt":"Submit a quote..."};ThemeRegistry::explicitNames()includesqdb;ThemeRegistry::stylesheetPaths()['qdb']resolves to/assets/theme-qdb.css.
Notes:
The
/assets/theme-qdb.cssfile referenced above does not exist yet — that's Stage 2. No error occurs from its absence yet since nothing serves the stylesheet until a page actually loads under this theme.
Stage 2 - Classic-QDB-accurate theme stylesheet
Changes:
Added
public/assets/theme-qdb.css: variable block (:root[data-theme="qdb"],.theme-menu__option[data-theme-option="qdb"]) using the archived palette (#ffffffpage,#000000ink,#c08000line/active/button chrome),--code-fontset to Courier New/Lucida Console for the quote body font Stage 4 will use, theme swatch, and scoped chrome overrides (.site-header,.eyebrow,.card,.nav-link.is-active) synthesizing the orange title-bar look from existing markup per the theme guide's "CSS-only illusion" approach.Updated
tests/LocalAppSmokeTest.php's theme allow-list assertion (var allowed = [...]) and added adata-theme-option="qdb"presence assertion, per the theme guide's required two updates.
Verification:
./v3 test LocalAppSmokeTest— 113 run, 110 passed; the 3 failures (testAnonymousPublicBoardDoesNotStartViewerSession,testPostAndActivityLinkAdjacentSignatureFiles,testSqliteViewerRouteUsesToolsShellAndPublishedSource) are pre-existing long-standing failures per the suite's own test-run history tracking, unrelated toSiteProfileRegistry/ThemeRegistry/theme CSS.Confirmed
testApplicationRendersCoreRoutes(the test containing the updated allow-list/theme-option assertions) passed.
Notes:
No template changes were needed —
ThemeRegistryalready feeds the popover markup, cycle button, anti-FOUC allow-list, andlayout.php's stylesheet-swap map automatically, per the theme guide.
Stage 3 - Upvote/downvote tag weights
Changes:
Added
upvote => 1anddownvote => -1toTagScore::scoredTags()(src/ForumRewrite/TagScore.php), alongside the existinglike/flagentries.Added
tests/TagScoreTest.php(new, registered intests/run.php) covering the two new tags, the two existing tags' weights staying unchanged, and an unknown-tag case.
Verification:
./v3 test TagScoreTest— 4 run, 4 passed.Read-path confirmation (no code change needed):
LocalWriteService::normalizeThreadTag()/normalizePostTag()validate againstTagScore::isScoredTag()directly — adding the two tags there is what makes the write API accept them at all, not just what scores them.ReadModelBuilder/IncrementalReadModelUpdateriterateTagScore::scoredTags()generically when computingscore_total/post_score_total, so no reducer change was needed either.
Notes:
Resolved the Stage 3 risk flagged in Step 3 (approval gating): confirmed in code (
LocalWriteService::isApprovedIdentity()plus the score-reduction loops inReadModelBuilder/IncrementalReadModelUpdater) that a reaction record is always written regardless of the voter's approval status, but the displayedscore_total/post_score_totalonly moves for reactions from identities already markedis_approvedin theprofilestable. This is pre-existing behavior thatlike/flagalready have today — upvote/downvote inherit it unchanged rather than introducing a new limitation. Net effect: a drive-by visitor's upvote/downvote is recorded but will not visibly move the score unless/until their identity is approved. This is a real gap from "anonymous QDB-style voting" as originally imagined, but closing it would mean changing shared approval-gating behavior used by every other reaction on the site — out of scope for this feature per the plan's "reuse, don't fork" rule. Logged as a separate plan:docs/plans/anonymous_reaction_scoring/anonymous_reaction_scoring_step1_solution_assessment.md(uncommitted, not part of this branch).
Stage 4 - Quote-list index rendering
Changes:
New
templates/partials/quote_card.php:#IDpermalink to/threads/<id>, adata-role="thread-score"node (reusingthread_reactions.js's existingsetThreadScore/AJAX wiring as-is), the full root-post body ($br($thread['root_post_body'])— no truncation, no subject), and upvote/downvote/flag buttons. Root<article>carries bothclass="post-card"anddata-thread-reactions-rootso the existingbindThreadReactions/bindPostReactionsinitializers (both already inthread_reactions.js) pick it up with zero JS changes. Upvote/downvote usedata-action="apply-thread-tag"(thread-labels family, scores intoscore_total) and flag usesdata-action="apply-post-tag"(post-reactions family) — mirroring the existing split inthread_root_card.phpexactly.templates/pages/board.phpbranches per-thread on a new$isQdbInstanceflag: rendersquote_card.phpinstead ofthread_card.phpfor this instance only.BoardPageController(src/ForumRewrite/Http/BoardPageController.php): now takesrepositoryRootand the viewer-identity-hint resolver closure (mirroringForteBoardController's existing constructor pattern); when the active site profile (SiteConfig::siteName()) isqdb, bulk-computes the viewer's prior upvote/downvote/flag state via the existing genericViewerTagLookup::threadTags()/postTags()(no new lookup code, tag name passed as a parameter) and passes it to the template. No-op (empty arrays, no extra lookups) for every other site profile.Application::boardPageController()updated to pass the two new constructor args.
Verification:
./v3 test LocalAppSmokeTest— same 113 run / 110 passed as Stage 2, no new failures.Full suite (
./v3 test) run twice, once with Stage 4 changes and once with themgit stash-ed back to the Stage 3 state, to isolate cause: both runs produced the identical 6 failures (confirmed pre-existing/order-dependent flakiness unrelated to this feature, not a Stage 4 regression).Manual render check: bootstrapped a temp repo from
tests/fixtures/parity_minimal_v1, ran the realApplicationwithFORUM_SITE_ID=qdbagainst/?view=all&sort=newest, and confirmed the rendered<article class="card post-card quote-card">contains the correct#thread-zenmemes-rulespermalink,Score: 0, full untruncated body text, and correctly-wired upvote/downvote/flag buttons (data-action/data-tag/aria-pressedall correct).Did not re-prove the full write-and-rescoring pipeline specifically for
upvote/downvoteend-to-end (doing so standalone requires bootstrapping a real approved identity, which existing test helpers do but aren't trivially reusable outside the test harness) — relying instead on: (a)TagScoreTestproving the weights, and (b) existing passing tests (e.g.testIncrementalApprovalMatchesFreshRebuildForTransitiveApprovalAndScoreRefresh) proving the exact same write/read-model code path already works correctly forlike, whichupvote/downvoteshare unchanged.
Notes:
The score display still reads "Score: N" (the existing shared JS's hardcoded label), not classic QDB's bare "(N)" — a cosmetic gap, deliberately left as-is per Stage 4's plan to avoid touching shared JS beyond Stage 5's already-scoped fix. Easy follow-up if wanted.
No optimistic instant-score-preview on upvote/downvote clicks (the existing JS's
setOptimisticThreadReactionStateonly special-casestag === "like") — the score still updates correctly once the server responds, just without the instant flashlikegets. Left as-is, same reasoning.
Stage 5 - Generalize thread-reaction feedback copy
Changes:
bindThreadReactions()inpublic/assets/thread_reactions.js: replaced the hardcoded"Liked."/"Already liked."feedback strings with${appliedLabel}./Already ${appliedLabel.toLowerCase()}., derived from the button's existingdata-applied-labelattribute. For theliketag this produces byte-identical text to before ("Liked."/"Already liked."); forupvote/downvoteit now produces correct, tag-specific text instead of always saying "Liked."Added
tests/BrowserSigningNormalizationTest::testThreadReactionFeedbackCopyFollowsAppliedLabelForNonLikeTags, proving theupvotetag produces "Already upvoted." (thewrote_record=nobranch) rather than the old hardcoded "Already liked." — this is the one branch with no prior test coverage at all for any tag.
Verification:
./v3 test BrowserSigningNormalizationTest— 64 run, 62 passed; the new test passes, and all existingtestThreadReaction*/testPostReaction*tests (including the two asserting exact"Liked."/"Already liked."text) still pass unchanged. The 2 failures are the same pre-existing long-standing ones (testThreadSubmitRendersPendingShellBeforeApiResponseAndNavigatesOnSuccess/testInlineReplySubmitRendersPendingCardBeforeApiResponseAndNavigatesOnSuccess), unrelated tothread_reactions.js.
Notes:
Correction to the Step 3 plan: Stage 5 originally also called for "regenerate the fingerprinted copies of
thread_reactions.js." That assumption was wrong — perdocs/specs/asset_fingerprinting_and_css_split_v1.md, fingerprinted asset URLs (/assets/name.<hash>.js) are computed from the live source file's content hash at request time (AssetFingerprint::fingerprintedPath()), and the hashed filenames are.gitignored build artifacts, not committed/hand-maintained copies. Editing the single canonicalpublic/assets/thread_reactions.jsis the entire change; there is no separate regeneration step.bindPostReactions()(used by the Flag button) was already using a properpostReactionMessage(appliedLabel, wroteRecord)helper, not hardcoded text — it needed no change. OnlybindThreadReactions()(used by Like/Upvote/Downvote) had the hardcoded bug.
Stage 6 - End-to-end verification
Changes: none (verification-only stage).
Verification:
Visual/structural match: bootstrapped a temp repo from
tests/fixtures/parity_minimal_v1, rendered/?view=all&sort=newestunderFORUM_SITE_ID=qdb, confirmed the renderedquote_card.phpmarkup matches the blended classic-QDB/qdb.us reference shape (#ID, score, full body, upvote/downvote/flag) — see Stage 4.Click-through: rendered
/threads/thread-zenmemes-rulesunder the sameqdbprofile and confirmed it's the ordinary, unmodified discussion-thread view (thread-root-cardpresent, a reply-compose form present, noquote-cardmarkup) — the thread/reply system needed zero changes, as planned.No regression: rendered the board (
/?view=all&sort=newest) with noFORUM_SITE_IDset (defaultzenmemesprofile) and confirmed it still usesthread-cardmarkup, notquote-card— the new instance's rendering path is fully gated behind the site profile check.Full suite (
./v3 test): 622 run, 617 passed, 5 failed — all 5 are the same pre-existing long-standing failures tracked since before this feature (none newly introduced); 2 tests recovered since Stage 4's baseline run (one of which,testIncrementalApprovalMatchesFreshRebuildForTransitiveApprovalAndScoreRefresh, was confirmed in Stage 4 to be pre-existing order-dependent flakiness, not caused by this feature).Upvote/downvote/flag updating the score without a reload and persisting across reload: not re-verified end-to-end against a real approved identity in this pass (would require bootstrapping a full browser-signed identity + approval flow outside the test harness) — resting on the Stage 4 code-path argument (upvote/downvote/flag share the exact write/read-model/AJAX code path already proven for
likeby passing tests) plus the Stage 5 regression test for the feedback-copy piece specifically.
Notes:
The Stage 3 approval-gating risk was resolved with certainty back in Stage 3 (see that section) — re-confirmed here, nothing new to add.
Feature is functionally complete per the Step 2 success criteria: new instance stands up via site-profile + vhost env vars alone, index visually matches the blended reference, every quote's
#IDreaches a fully working unmodified discussion thread, and vote score is wired through the existing reaction system with zero schema changes.
Post-ship rename: bashorg → qdb
The site profile/theme were originally built and committed under the name
bashorg(stages above describe that original implementation). After Stage 6, the operator asked to drop bash.org branding (they own the QDB brand but not bash.org) and useqdbinstead.This doc, the Step 1-3 planning docs, and all code/tests/docs were updated in a follow-up commit:
SiteProfileRegistry/ThemeRegistryentries,theme-bashorg.css→theme-qdb.css,$isBashorgInstance→$isQdbInstance, test names/literals, anddocs/runbooks/production_deploy.md.The git history up through Stage 6's commits still literally says
bashorg(that was the real name at the time); this summary was rewritten afterward for consistency with the renamed code, not to rewrite history.