Releases: Shepdesign/hooked-on-facets
Hooked on Facets 1.0.0
What's Changed
- chore(release): prep 1.0.0 — docs, branding, i18n, distribution readiness by @Shepdesign in #15
- ci: auto-publish releases and sync the wiki from /docs by @Shepdesign in #16
Full Changelog: https://github.com/Shepdesign/hooked-on-facets/commits/v1.0.0
v0.13.1-alpha — counts query optimization
A focused performance patch on top of v0.13.0-alpha.
Changed
Drill-down counts query — 7.5× faster
The counts behind the /filter endpoint grouped by (facet_value, facet_display) — two 191-char VARCHARs — which bloated the aggregation temp table. Since the value→display mapping is 1:1 (slug→name, ID→title, or value === display for meta), the query now GROUP BY facet_value alone and takes the display via MIN(facet_display):
- Identical output — byte-for-byte the same buckets (verified on the live 100k stack: same 21 category buckets, same counts).
- 454ms → 60ms on the counts query, taking
resolve()p95 from ~465ms → ~63ms uncached on 100k products. COUNT(DISTINCT object_id)is kept (notCOUNT(*)) so a multi-row-per-object meta facet still counts each object once.
Together with the v0.13.0 result-set cache, the /filter path is now fast on both cold (~63ms) and repeat (sub-ms) hits. SQL shape is locked by a unit test.
Note:
EXPLAINreported both the old and new queries as "Using temporary; Using filesort" — only wall-clock measurement exposed the 7.5× gap. Measure, don't trust the plan.
Verification
- PHP: 121 tests / 269 assertions;
php -lclean - JS: 35 tests; build clean · Markdown: lint clean
- Live stack: identical buckets confirmed;
resolve()re-benchmarked at ~63ms p95
Upgrade
git pull && composer install && npm installNo schema change, no reindex, no behavior change — purely a faster query.
Full changelog: CHANGELOG.md
v0.13.0-alpha — enhancements: SEO, analytics, resolver cache
The first round of enhancements on top of the feature-complete plugin. Three additions, each verified end-to-end on the live 100k-product stack.
What's new
SEO for filtered pages
Faceted URLs bloat crawl budget and spawn near-duplicate pages, and general SEO plugins don't understand the ?hof[*] query shape — so HOF owns the faceted signals via a new SeoManager:
rel=canonicalto the filter-stripped base URL (deferred when Yoast / Rank Math / AIOSEO / SEOPress is active, to avoid a double tag).noindex,followonce a configurable number of facets are stacked (default 2 — single-facet landing pages stay indexable while the combinatorial long tail doesn't), emitted via the corewp_robotsfilter so it composes with other plugins.- An active-filters title suffix so an indexed single-facet page reads meaningfully in search results.
Settings persist to hof_seo, exposed via a new admin SEO screen and GET|POST /seo-settings. Decision logic is pure and unit-tested (12 cases).
Analytics dashboard
The telemetry recorder now captures per-facet/value usage and zero-result filter combinations — one signal per /filter action, buffered in memory and flushed at shutdown (with caps), so it never adds request latency. snapshot() gains p50/p95/p99 resolver latency.
The admin Dashboard renders most-used facets (usage bars), "filters that find nothing," the latency percentiles, and a dead-facets callout (configured but never applied by a shopper). Covered by 6 tests.
Resolver result-set cache
resolve() and resolve_ids() now cache their output in the object cache, keyed by (index version, filter state). The Indexer bumps hof_index_version on every write, so invalidation is O(1) — a bump orphans every stale key.
Benchmarking on the live 100k stack drove the scope: resolve_ids() was already ~8ms p95, but resolve() — IDs + N drill-down counts behind /filter — was ~465ms, the real bottleneck. Cached repeat hits are sub-millisecond. Most effective with a persistent object cache (Redis / Memcached); kill switch hof_resolver_cache_enabled. Covered by 4 tests.
Deliberately not done: LIMIT/OFFSET pushdown into resolve_ids() (unsafe — QueryHook needs the full ID set for post__in) and mysqli_poll parallel legs (deferred per the Phase-1 note).
Verification
- PHP: 120 tests / 265 assertions;
php -lclean - JS: 35 tests; production Vite build clean
- Markdown: lint clean
- Live stack: cache 465ms → sub-ms on repeat; version invalidation confirmed on
reindex_object; SEO canonical/robots/title verified
Upgrade
git pull && composer install && npm installNo schema change (hof_db_version 1.2.0). No reindex needed. All three features are additive — SEO defaults are conservative, the cache is transparent and version-invalidated, analytics capture is passive.
Full changelog: CHANGELOG.md
v0.12.0-alpha — wow-kit complete: spin wheel, saved bin, matrix + AND resolver + multisite
The feature-complete milestone. The two designed-but-unbuilt "wow-kit" facets ship, the matrix returns on real intersection semantics, and the plugin goes multisite-aware. Every planned facet (16 types), source (WooCommerce, ACF, Meta Box, Pods), and capability is now in.
What's new
Spin-the-wheel facet (spin_the_wheel)
A gamified single-select picker: a cosmetic conic-gradient dial over a real, keyboard- and screen-reader-accessible radiogroup. Spin lands on a random value (or pick one directly); it degrades to a plain single-select with JS off. Same hof[<name>]=value URL shape as radio, so the resolver treats it identically. Covered by spin.test.js (6 cases).
Saved-bin facet (saved_bin)
A drag-and-drop / click comparison bin. Shoppers pin items — via the new [hof_bin_button id="…"] shortcode or any draggable data-hof-bin-add element — into a per-site localStorage bin, then a Show only saved toggle filters results to the bin. It's the only facet that filters by object ID rather than an index value, riding a new reserved resolver key _bin_ids (a plain ID intersection, parallel to Visual DNA's _visual_ids). Covered by bin.test.js (8 cases).
AND-within-facet resolver
A facet can now require an object to carry every selected value, not just one (settings.match = 'all', an any/all control on multi-value facets in the admin). It composes naturally with the existing INTERSECT engine: instead of one IN-list leg, the facet emits one single-value equality leg per value, and INTERSECT ANDs them — each a covering-index scan with no DISTINCT, so AND mode is at least as fast as OR.
Intersection matrix (matrix) — un-retired
The Venn/UpSet matrix returns. It was originally shelved for an OR/AND semantic mismatch and unreadable selection state; it's back on the new AND-within-facet semantics (which it defaults to), paired with an explicit selected-state dot, a per-row count bar, and the active-filters chip strip — the readability gap the first version lacked.
Multisite support
The index table and facet options were already per-blog, so this is a lifecycle concern: Activator::activate( $network_wide ) installs across every existing site on network activation, install_new_site() (on wp_initialize_site) seeds tables for sites added later while HOF is network-active, the plugins_loaded auto-heal self-installs any blog whose schema is behind, and uninstall.php runs its opt-in cleanup per site (checking the removal gate per site, so one site opting in never wipes a sibling). Now validated on a live WP_ALLOW_MULTISITE network — stable.
Verification
- PHP: 98 tests / 221 assertions;
php -lclean across the tree - JS: 35 tests (refresh, swiper, spin, bin); production Vite build clean
- Markdown: lint clean
- Live stack (MySQL 8, 100k+ indexed rows): the AND resolver returned AND = 150 = exact co-occurrence count (vs OR = 10,000), and all three new server-rendered facets render without warnings
Known limitations (deferred, gated on a live third-party environment)
- Breakdance native placement — Element-Studio bundle format needs a live Breakdance install to author + validate; placement works today via
[hof_facet]. - Divi Visual Builder — the native module is authored; its VB render path needs a live Divi install to validate. Theme Builder (main-query) placement already works.
- Pods table /
wp_podsrelstorage — the indexer reads postmeta; themeta_in_use()gate cleanly skips table-stored Pods fields rather than mis-suggesting them. - Time-of-day faceting — intentionally not a facet type; a raw seconds-range is poor UX and a dedicated clock picker isn't warranted yet.
Upgrade
git pull && composer install && npm installNo schema change since v0.11.0-alpha (hof_db_version 1.2.0). No reindex needed. New facets and the AND mode are opt-in per facet; existing configs are unaffected.
Full changelog: CHANGELOG.md
v0.11.0-alpha — custom-field sources: ACF, Meta Box & Pods
The custom-field source line lands: HOF now inspects ACF, Meta Box, and Pods fields and one-click-suggests facet configs — backed by an indexer that resolves ID-references to human labels and normalizes dates. Also includes the Divi bridge and an Action Scheduler-backed background reindex.
What's new
Source integrations — suggestion providers
Each integration inspects its plugin's registered fields and proposes ready-to-use facet configs over GET /integrations/{woocommerce,acf,metabox,pods}/suggest, gated by an is_active() check and a shared meta_in_use() data filter so only fields with real data are offered.
- ACF — maps every meaningful field type to a display: select→dropdown, multi-select/checkbox→checkbox, radio/button_group→radio, true_false→toggle, number/range→range, text/textarea/email/url→search, taxonomy (Save Terms on)→taxonomy facet, relationship/post_object→
resolve='post', user→resolve='user', Save-Terms-off taxonomy→resolve='term', date_picker/date_time_picker→date_range. - Meta Box — reads the
meta_boxregistry; scalar/select/date/taxonomy types pluspost/user(resolve) andtaxonomy_advanced(comma-separated term IDs →resolve='term'). - Pods — post-type Pod fields, including
pickrelationships mapped bypick_object(post→post, user→user, taxonomy→term). Themeta_in_use()gate cleanly skips table-stored Pods fields this indexer can't serve.
Indexer — the resolve mechanism
- ID → label resolution for meta facets whose values are IDs:
'post'(post title, viawp_posts),'user'(display name,wp_users), and'term'(term name +term_id,wp_terms) — resolved in one batched query per facet per reindex batch. - Serialized-array adapter — explodes ACF checkbox / multi-select arrays into one bucket per scalar value.
- Comma-split —
extract_ids()splits comma-separated ID strings so Meta Boxtaxonomy_advancedlands (a no-op for the plain-ID cases). - ACF date normalization —
date_picker's compactYmdis parsed to a UTC-midnight epoch (round-trip validated), sodate_rangefacets scale correctly. - Action Scheduler-backed background reindex — chunked, self-chaining full rebuilds under the
hooked-on-facetsgroup (with awp_cronfallback), so large catalogs reindex without blocking a request.
Divi bridge
Divi core ships no scoped per-loop query filter, so the bridge splits Divi's two surfaces: Theme Builder archive/index templates run the main query (already filtered through QueryHook), and a Blog module on a page uses the global hof_divi_query_args() helper from the developer's own scoped pre_get_posts. Placement gets a native ET_Builder_Module (slug et_pb_hof_facet); the [hof_facet] shortcode remains a fallback.
Known limitations (deferred)
- Divi Visual Builder — the native module is authored; its VB server-render path needs validation against a live Divi install.
- Pods table /
wp_podsrelstorage, ACF/Meta Box time-of-day fields — deferred (see plan.md).
Upgrade
git pull && composer install && npm installSchema bump to hof_db_version 1.2.0 runs automatically on load (the plugins_loaded auto-heal). A reindex picks up newly-suggested fields; suggestion providers are additive and never alter existing facets.
Full changelog: CHANGELOG.md
v0.10.0-alpha — third page builder bridge: Breakdance
Third page-builder bridge. HOF now filters Breakdance Post Loop Builder loops too — though Breakdance forced a different shape than its predecessors.
What's new
Breakdance bridge
Breakdance is the exception. Elementor gave us elementor/query/{id} and Bricks gave us bricks/posts/query_vars — scoped, documented filters to bind a specific loop. Breakdance documents no equivalent: its Post Loop Builder is customized in the builder, and the only PHP injection point is the user-authored Array Query. So the binding is a documented recipe, not a hook.
Loop binding. Set a Post Loop Builder's Query type to Array Query and return the HOF helper, passing your loop's own args:
return hof_breakdance_query_args( [
'post_type' => 'product',
'posts_per_page' => 12,
] );When the request carries ?hof[*] filters, the returned args gain a post__in of the matching IDs (or [0] when nothing matches, so the loop shows "no results" rather than everything). With no HOF filters active, your base args are returned unchanged — HOF wins on post__in; you keep control of post type, ordering, and per-page.
Placement. Drop the existing [hof_facet] shortcode into a Breakdance Shortcode/Code element.
The logic lives in Breakdance::query_args() (unit-testable with a mocked resolver); the global hof_breakdance_query_args() is a thin delegate, loaded unconditionally from register_hooks() (an inert function definition until Array Query calls it). 7 PHPUnit cases; full suite 27/27.
Known limitations
- No native placement element yet. Breakdance elements are Element-Studio directory bundles registered via
registerSaveLocation(), not a registerable PHP class. Authoring that format needs validation against a live Breakdance install, so it's deferred — placement uses the shortcode for now. - One manual step per loop. Unlike Elementor (Query ID) and Bricks (CSS class), the Array Query recipe means pasting one line into each loop you want filtered. That's the cost of Breakdance lacking a scoped query hook; if it ships one, the binding moves there and this recipe becomes a fallback.
Upgrade
git pull && composer install && npm installNo DB changes. No reindex needed. The helper is inert until invoked from a Breakdance Array Query — safe to deploy on non-Breakdance sites.
v0.9.0-alpha — second page builder bridge: Bricks
Second page builder bridge lands. HOF now filters Bricks Builder query loops the same way it filters Gutenberg Query Loops and Elementor Loop Grids — explicit, opt-in, no magic detection. Inert when Bricks isn't installed.
What's new
Bricks bridge
Two halves, both behind a single Bootable service that no-ops cleanly when Bricks isn't active.
Facet placement element. Drop the new Hooked Facet element anywhere on a Bricks page and set the slug of a facet you created in the HOF admin. Mirror of the hof/facet Gutenberg block, the [hof_facet] shortcode, and the Elementor widget — all four surfaces render via the same Renderer service, so markup is identical no matter where the facet sits.
Loop binding via CSS class. On the query-loop element you want filtered (Style → CSS classes), add the class hof. HOF hooks bricks/posts/query_vars and applies post__in directly from the URL's ?hof[*] state. A class (rather than the element's CSS ID) is the binding key because it's repeatable across loops and doesn't force a unique HTML id. Multiple bound loops or a different convention:
add_filter( 'hof_bricks_query_ids', fn() => [ 'shop', 'recipes' ] );Why the boot timing differs from Elementor: Bricks ships as a theme, loaded on after_setup_theme — after HOF's plugins_loaded:5 boot — so \Bricks\Elements doesn't exist yet when the bridge wires up. The passive bricks/posts/query_vars filter registers at boot regardless (it's inert until Bricks fires it during a render); element registration defers to init:11 (after Bricks registers its own elements) and gates on the class being present. If Bricks isn't active, that callback returns early — no element, no cost.
Ships with 11 PHPUnit cases: hook registration, element registration, CSS-class matching (incl. multi-class strings and array tolerance), the URL-filter no-op paths, post__in application, the [0] no-results sentinel, telemetry recording, and the filterable binding list.
Minor: CI
Removed the advanced CodeQL workflow (.github/workflows/codeql.yml) in favor of the repo's CodeQL default setup. The advanced config's SARIF upload was rejected on every code-touching PR — "CodeQL analyses from advanced configurations cannot be processed when the default setup is enabled" — leaving a persistent (non-blocking) red check. Default setup already scans javascript-typescript and actions. To keep the broader security-extended query suites the old workflow ran, switch default setup's query suite to extended in Settings → Code security.
Upgrade
git pull && composer install && npm installNo DB changes. No reindex needed. The bridge is inert if Bricks isn't loaded — safe to deploy on non-Bricks sites.
Not yet verified on a live Bricks install — the _cssClasses binding is fully unit-tested but hasn't been smoke-tested against a running Bricks query loop. Worth a quick check before relying on it in production.
v0.8.0-alpha — polish pass: theming foundation, admin clarity, pagination
Six commits' worth of design-system foundation, admin polish, and a new pagination facet. No DB changes; safe to deploy in place.
Highlights
New: pagination facet
Drop [hof_facet name="pages"] (or the Gutenberg block) into your sidebar/footer for a numbered « 1 2 3 … 12 » nav with iOS-17 pill chrome. Server-rendered for SEO + no-JS fallback; JS upgrades clicks to pushState + refresh so the result region swaps in place without a full page reload. All ?hof[*] filters survive pagination. Configurable from the editor: per-page override, neighbors visible (0–5), first/last + prev/next toggles.
New: design system foundation
50 new --hof-* CSS variable tokens across every facet — brand colors, radius scale (xs / sm / md / lg / xl / pill), font sizes, focus rings (hard + soft), shared input/label/count/option chrome, named shadow scale. Per-facet specifics filled in where the shared tokens can't cover it.
// Override the brand color and every facet picks it up:
add_filter( 'hof_public_css_tokens', function ( $t ) {
$t['--hof-primary'] = '#0ea5e9';
$t['--hof-radius-md'] = '12px';
return $t;
} );Cascade bug fixed: hof_public_css_tokens was effectively dead before this release because the inline <style> block fired at wp_head:5, before the bundled stylesheet enqueued at :8. The bundle's defaults silently overrode any filter value for any token the bundle defined (which was most of them). Now bound at :20, so filter overrides actually win. If you've been wondering why your filter overrides "didn't do anything" — they should start working after this upgrade.
Admin polish
- Editor preview no longer blows up at narrow widths. Container queries replaced the 960px media query that fired at the wrong moment (the editor sits inside a 240px facet list inside a 168px nav inside WP admin's own sidebar — viewport-only queries couldn't see the actual cramped space). Preview now
clamp(240px, 32cqi, 320px)and collapses to single-column at 640px container width. - Display picker reorganized: options grouped by what shoppers do (Select / Range / Boolean / Text / Visual / Navigation / Display-only) instead of one flat list.
- Three relabels for clarity (internal slugs unchanged — stored configs keep working):
- Ask → Natural-language search
- Visual DNA → Color match (drop an image)
- View (no source) → Display-only (no filter)
- Swatch tiles get a hover/focus tooltip showing full term + count. Crucial for the long-tail end where tiles shrink to 36px and the label below truncates.
Removed: 2D slider
The 2D slider display landed as a visual skeleton (ca9831a) and a resize-handle iteration (b28468b), then got shelved during the natural-language search pivot. Never reached working interactive filtering. Removed entirely — public/src/two-d-slider.js (229 lines) + ~125 CSS rules + the Renderer method + admin editor refs + REST/validation entries. Saved configs that pointed at two_d_slider don't crash — they fall through to checkbox via Renderer's default arm.
Upgrade
git pull && composer install && npm installNo DB changes. No reindex needed. Build the bundle after deploy: npm run build.
Honest gaps
Three places that still need human eyes — all called out in their respective commits:
- Visual diff against pre-0.8 not done. CSS bundle's defaults claim to be byte-identical to before but only the test gates verify code correctness, not pixel correctness.
- Pagination renderer has no automated tests (depends on
$wp_queryglobal + WP function helpers that aren't worth the Brain Monkey setup for a UI component). - Sidebar widgets that render before the main query may see
$wp_query->found_postsas 0 and silently render no pagination. Acceptable for v1.
v0.7.0-alpha — first page builder bridge: Elementor
First page builder bridge lands. HOF now filters Elementor Loop Grids the same way it filters Gutenberg Query Loops — explicit, opt-in, no magic detection. Brought a PHPUnit harness along with it.
What's new
Elementor bridge
Two halves, both behind a single Bootable service that no-ops cleanly when Elementor isn't installed.
Facet placement widget. Drop the new Hooked Facet widget anywhere on an Elementor page, set the slug of a facet you created in the HOF admin. Mirror of the hof/facet Gutenberg block and the [hof_facet] shortcode — all three surfaces render via the same Renderer service, so markup is identical no matter where the facet sits.
Loop binding via Query ID. On the Loop Grid / Posts / Products widget you want filtered, set Advanced → Query ID to hof. HOF hooks elementor/query/hof and applies post__in directly from the URL's ?hof[*] state. Multiple bound loops or a different naming convention:
add_filter( 'hof_elementor_query_ids', fn() => [ 'shop', 'recipes' ] );Why Query ID instead of piggybacking on pre_get_posts: Elementor calls $query->query($args) after the elementor/query/{id} action fires, and WP_Query::query() resets query_vars from $args — so setting an opt-in flag like hof_facet_target on the query would be discarded. Mutating post__in directly is robust and matches Elementor's own documented pattern.
PHPUnit harness
First PHP test suite. PHPUnit 11 + Brain Monkey + Mockery. No live WordPress — WP functions stub per-test. Run with composer test. Tests live under tests/php/; the bridge ships with 9 tests covering presence-guard, widget registration, the Query ID filter (incl. dedupe + sanitize), bind_query's no-op paths, post__in application, the [0] no-results sentinel, and telemetry recording. CI gates on the suite — the previous composer run-script test --if-it-exists was a no-op due to an invalid flag; that's fixed too.
Minor refactor (testability)
Two narrow interfaces extracted from final services so consumers can mock past them:
HookedOnFacets\Filter\IdResolver— justresolve_ids()HookedOnFacets\Telemetry\LoopHookRecorder— justrecord_loop_hook()
Resolver and Recorder implement them. The DI container still resolves to the concrete classes — only consumer type hints changed. SHEPDESIGN.md documents the convention.
Upgrade
git pull && composer install && npm installNo DB changes. No reindex needed. The bridge is inert if Elementor isn't loaded — safe to deploy on non-Elementor sites.
v0.6.1-alpha — test harness + CI hygiene
Tooling-only patch release — no plugin behavior changed. Safe to skip if you're already on 0.6.0-alpha and not contributing.
What's new
First JS test suite. Vitest 4 + jsdom 29 wired into the project (npm test / npm run test:watch). 15 tests covering:
- Swipe-deck runtime (
public/src/swiper.js): commit/skip via click + keyboard, deck-position math, Card vs GriddeckDepth, reset coalescing into a single change event, done-state + button-disable when the deck empties. - URL-state reconciliation (
patchSwiperinpublic/src/refresh.js): count patching, server-included → right-swiped + hidden + checked sync, popstate restore when the server clears a value we'd locally right-swiped, preservation of local left-swipes.
Markdown lint is actually gating now. The Markdown · Lint CI job had continue-on-error: true set since it landed, quietly hiding 122 markdownlint findings. This release:
- Adds
.markdownlint.jsonthat turns off the rules that fight this project's writing voice — MD013 (long prose lines are intentional), MD060 (padded tables read better than|col|col|), MD034 (bare GitHub URLs are fine inline). - Fixes the 21 real readability issues MD022/MD032 flagged — missing blank lines around headings glued to lists in
plan.md,SECURITY.md,SHEPDESIGN.md. - Drops
continue-on-error: trueso future regressions can't sneak through.
Minor production tweaks (testability)
These are the only non-doc/non-test changes to runtime code:
initSwiper(root, { signal })— accepts anAbortSignalfor listener teardown. Standard pattern; needed because vi.resetModules clears the module cache but leaks document-level listeners the prior copy registered.patchSwiperis now a named export frompublic/src/refresh.js— was a module-private helper; now reachable from tests without going through fetch + DOMParser.
Upgrade
git pull && npm installNo DB changes. No reindex needed. No admin-visible changes.