Skip to content

feat(filters): add the facet group kind and the global filter contract - #1847

Merged
BigSimmo merged 4 commits into
mainfrom
claude/filter-contract-global
Aug 12, 2026
Merged

feat(filters): add the facet group kind and the global filter contract#1847
BigSimmo merged 4 commits into
mainfrom
claude/filter-contract-global

Conversation

@BigSimmo

@BigSimmo BigSimmo commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

Step one of the cross-mode filter rollout: the contract, with no rendered output changed. kind defaults to lens, which is what all seven existing call sites already are, so this commit touches none of them and every mode renders exactly as before.

An audit of all ten filter surfaces found the same dimension rendered three different ways, two modes whose "filter" discards the search instead of narrowing it, and a mode whose footer counts items its filters do not govern. The fix is agreeing what a group means before deciding how it looks.

  • ResultFilterGroup is now discriminated by kind.
    • lens (default) — options partition the result set, one-of-N, role="radiogroup" with the existing roving tabindex. Differentials' All/Presentations/Diagnoses; medication's Best/Indication/Safety/Monitor.
    • facet — independent constraints that accumulate, many-of-N, OR within / AND across, role="group" with aria-pressed and one tab stop per option. Formulation's domains; documents' tag groups.
  • Arrow keys are deliberately not bound for facets. The single tab stop is right for a lens precisely because arrowing replaces; for a facet it would silently accumulate constraints the reader never asked for, and every toggle must be individually reachable.
  • There is no navigate kind, deliberately. Services' quick filters and factsheets' categories call router.push and replace the query — choosing one discards the search and its results with no warning and no undo. A control labelled "Filter" must not do that; those move to the composer as suggested searches in a later PR.
  • Counts keep the documents predicate ("how many would I have if I ticked this as well"), which is safe only alongside the companion rule that option lists are derived from the data, never declared. That is what stops a permanently empty option — formulation's Biological / Social / Cultural, which match 0 of 12 mechanisms — ever reaching the component.
  • docs/filter-contract.md carries the rules, the scope-segment condition, the density thresholds and the rollout order.

A defect the tests caught while being written

The selected-count badge initially sat inside the labelling <h3>, so a facet group's accessible name became "Domain 1" — the selection count leaking into the dimension's name and changing on every toggle. The id is now on the label text alone, with the badge carrying its own sr-only "N selected". The DOM test asserts the name is Domain.

Two defects documented but not fixed here

  • Specifiers' footer counts what its filters do not govern. results is narrowed by family/diagnosis; catalogueMatches (up to 24 of 585 DSM items) is not; footerNote reports the sum (specifiers-home-page.tsx:191-199). Fixed when specifiers adopts.
  • SearchScopeFilters (src/lib/search-scope.ts) is a second, invisible filter surface — 20 keys applied server-side at retrieval, editable from no panel. Reconciling it is tracked separately and is not a prerequisite for adoption.

Verification

  • npm run verify:pr-localfully green, no failures
- completed: check:runtime, check:installed-lock-parity, format:changed, sitemap:check, docs:check-index,
  docs:check-inventory, docs:check-scripts, docs:check-links, check:branch-review-ledger,
  check:outstanding-issues, check:github-actions, check:ci-scope, check:gitleaks-pinned, check:ci-triage,
  check:pr-policy, check:gate-manifest, check:pr-mergeability, check:verification-plan,
  check:codex-autofix-workflow, lint, typecheck, test, build, check:rag:fixtures
- failed: (none)
- not reached: (none)
  • Four new DOM tests in tests/search-results-header-band.dom.test.tsx, 50 passed in that file: facets render as a pressed-toggle group and accumulate; every facet gets its own tab stop; a zero-yield facet stays focusable, explained and unselectable (aria-disabled, never disabled); lens and facet render side by side with their own semantics.
  • Zero call sites changedgit diff --stat origin/main over all seven mode files is empty. The diff is the component, its tests and the contract doc.

UI verification not run: no call site adopts the new kind yet, so there is no rendered change to verify. verify:ui belongs on the first per-mode adoption PR.

Risk and rollout

  • Risk: Low. Purely additive types and one new renderer that nothing reaches yet. The one edit to existing behaviour is resultFilterGroup() now stamping kind: "lens" explicitly, which is the value the type already defaulted to.
  • Rollback: revert the commit; nothing depends on the new exports.
  • Provider or production effects: None.

Rollout order per docs/filter-contract.md: contract (this PR) → one PR per mode to adopt the right kind, derive its option list and retire its desktop rail → services and factsheets evict their query-replacing presets → documents last, porting its find-a-filter and collapse up into the shared component as the >20-option tier.


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added multi-select facet filters alongside existing single-select filters.
    • Filter selections now show active counts and support independent toggling.
    • Added clearer explanations when filters produce no results.
    • Improved mixed filter views, including accessible controls and preserved touch-friendly interactions.
  • Documentation

    • Added guidance describing filter behavior, accessibility semantics, empty states, and rollout considerations.

Step one of the cross-mode filter rollout: the contract, with no
rendered output changed. `kind` defaults to "lens", which is what all
seven existing call sites are, so none of them are touched by this
commit and every mode renders exactly as before.

An audit of all ten filter surfaces found the same dimension rendered
three different ways, two modes whose "filter" discarded the search
instead of narrowing it, and a mode whose footer counts items its
filters do not govern. The fix is agreeing what a group MEANS before
deciding how it looks, so ResultFilterGroup is now discriminated:

- lens (default) — the options partition the result set, one-of-N,
  role="radiogroup" with the existing roving tabindex.
- facet — independent constraints that accumulate, many-of-N, OR within
  and AND across, role="group" with aria-pressed and one tab stop per
  option. Arrow keys are deliberately not bound: arrowing replaces for a
  lens, but would silently accumulate constraints for a facet.

There is no navigate kind, deliberately. Options that replace the query
rather than narrowing the result set do not belong in a control called
Filter; those move to the composer as suggested searches.

Counts stay on option.hint and keep the documents predicate ("how many
would I have if I ticked this as well"), which is safe only alongside
the companion rule that option lists are derived from the data rather
than declared — that is what stops a permanently empty option, such as
formulation's Biological/Social/Cultural, ever reaching the component.

docs/filter-contract.md carries the rules, the scope-segment condition,
the density thresholds and the rollout order.

The DOM tests caught a real defect while being written: the
selected-count badge sat inside the labelling heading, so a facet
group's accessible name became "Domain 1" and changed on every toggle.
The id is now on the label text alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011btGFwWKYFWDs5McQkqz9J
@supabase

supabase Bot commented Aug 12, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project sjrfecxgysukkwxsowpy because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 26 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 52a5aa46-062e-4369-bbc9-b8754fee5da6

📥 Commits

Reviewing files that changed from the base of the PR and between 01ece2b and 5617013.

📒 Files selected for processing (1)
  • src/components/clinical-dashboard/result-filter-control.tsx
📝 Walkthrough

Walkthrough

The filter system now supports exclusive lens groups and cumulative facet groups. Facet controls use independent pressed states, selection counts, and accessible zero-result handling. Documentation defines the shared contract, and DOM tests cover mixed rendering and keyboard behavior.

Changes

Filter contract and UI behavior

Layer / File(s) Summary
Filter types and contract
src/components/clinical-dashboard/result-filter-control.tsx, docs/filter-contract.md, AGENTS.md
Filter groups now use typed lens and facet variants. Constructors and documentation define selection, accessibility, count, and rendering semantics.
Facet rendering and sheet routing
src/components/clinical-dashboard/result-filter-control.tsx
Facet groups render independently toggleable aria-pressed controls with selected counts and zero-result explanations. Lens groups retain radio rendering.
Facet validation and review records
tests/search-results-header-band.dom.test.tsx, docs/branch-review-ledger.md
DOM tests cover multi-selection, tab stops, disabled zero-result options, and mixed group semantics. The review ledger records the change and verification.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ResultFilterSheet
  participant FilterFacetGroup
  participant onToggle
  User->>ResultFilterSheet: Open filter sheet
  ResultFilterSheet->>FilterFacetGroup: Render facet group
  User->>FilterFacetGroup: Toggle an option
  FilterFacetGroup->>onToggle: Pass selected value
Loading

Possibly related PRs

Suggested reviewers: claude, copilot

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed and covers the summary, verification, risk, rollback, and rollout, but it omits the required Clinical Governance Preflight section. Add the Clinical Governance Preflight section and mark each applicable control, including search behavior, source access, privacy, and clinical decision-support impact.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main changes: the facet group kind and the global filter contract.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/filter-contract-global

Comment @coderabbitai help to get the list of available commands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011btGFwWKYFWDs5McQkqz9J
@github-actions

Copy link
Copy Markdown
Contributor

CI triage

CI failed on this PR. Automated classification of the 2 failed job(s):

  • Static PR checksneeds investigation: inspect the failing step and uploaded diagnostics; rerun only after classifying the cause.
  • PR requiredneeds investigation: inspect the failing step and uploaded diagnostics; rerun only after classifying the cause.

Compared with main CI run #9872 (success).

Classification is evidence routing, not permission to ignore a failure. Exact quarantined Playwright identities remain governed by the flake ledger.

@BigSimmo

Copy link
Copy Markdown
Owner Author

@copilot resolve the merge conflicts on this branch.

…t-global

# Conflicts:
#	docs/branch-review-ledger.md
auto-merge was automatically disabled August 12, 2026 12:17

Head branch was pushed to by a user without write access

Copilot AI commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts on this branch.

Resolved and pushed in commit 01ece2b8.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/clinical-dashboard/result-filter-control.tsx`:
- Line 459: Replace the arbitrary `rounded-[0.25rem]` utility in the result
filter control’s class list with the closest existing Tailwind radius token or
repository semantic radius class, preserving the component’s current visual
intent and avoiding new hardcoded design values.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 892feea4-f292-4e1a-a399-7fb7bf3f60c3

📥 Commits

Reviewing files that changed from the base of the PR and between b3b1202 and 01ece2b.

📒 Files selected for processing (5)
  • AGENTS.md
  • docs/branch-review-ledger.md
  • docs/filter-contract.md
  • src/components/clinical-dashboard/result-filter-control.tsx
  • tests/search-results-header-band.dom.test.tsx

Comment thread src/components/clinical-dashboard/result-filter-control.tsx Outdated
CodeRabbit review, and it is right: --radius-xs is exactly 0.25rem, so
rounded-xs is the token equivalent of the arbitrary value. rounded-xs is
already used in production (ui/choice.tsx, therapy-compass/ui.tsx), and
this was the only non-mockup file carrying the arbitrary form — the
other hits are mockups, which are exempt from the token gate. Copied in
from the mockup study, where it was legal; it is not here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011btGFwWKYFWDs5McQkqz9J

Copy link
Copy Markdown
Owner Author

Recording why this merges without a ## Clinical Governance Preflight, since the CodeRabbit pre-merge check flags its absence and that decision should be auditable rather than silent.

The check is a false positive, and scripts/pr-policy.mjs says so explicitly. The repo's own gate — the PR policy job — passed on this head, because classifyPullRequestFiles returns clinicalRisk: false for this diff. The classifier excludes exactly this case by design (pr-policy.mjs:52-57):

Presentation surfaces (pages + components) are clinical-risk only when they touch access control, privacy, patient data, or document upload/download — NOT merely because a UI file lives under a clinically-named directory (the whole src/components/clinical-dashboard tree) or is named after a search/answer/source feature whose logic actually lives in src/lib.

That comment describes this PR precisely: src/components/clinical-dashboard/result-filter-control.tsx matches none of auth|permission|privacy|security|upload|download|patient, and the diff touches no supabase/, src/app/api/, src/lib/**, or src/data|data/ path. The other four files are a doc, a test, the review ledger, and the next dev generated AGENTS.md block.

Substantively there is nothing to attest: the change is additive types plus one renderer that no call site reaches yet, and git diff --stat origin/main over all seven mode files is empty. Clinical output, source access and privacy are untouched by construction. The preflight arrives with the per-mode adoption PRs, where rendered clinical surfaces actually change.

The other CodeRabbit finding — the arbitrary rounded-[0.25rem] — was correct and is fixed in 5617013. --radius-xs is exactly 0.25rem, rounded-xs is already used elsewhere in production, and this was the only non-mockup file carrying the arbitrary form; it came in from the mockup study, where the token gate does not apply.


Generated by Claude Code

@BigSimmo
BigSimmo enabled auto-merge August 12, 2026 12:32
@BigSimmo
BigSimmo merged commit f66691f into main Aug 12, 2026
26 checks passed
@BigSimmo
BigSimmo deleted the claude/filter-contract-global branch August 12, 2026 12:40
BigSimmo pushed a commit that referenced this pull request Aug 12, 2026
…ft alias

CI Static PR checks failure: --text-soft is a decoration-only
compatibility alias that production must not consume, and
check:design-system-contract ratchets its consumer count at zero
(textSoftConsumers increased from 0 to 1).

--text-muted is what ResultFilterSheet already uses for option.hint, so
this also makes the desktop rail and the phone sheet render the same
count the same way, which was the point of adding the slot.

Same root cause as the radius fix on #1847: lifted from the mockup
study, where the token gates do not apply. Also merges origin/main to
clear the 26-commit staleness that failed PR mergeability.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011btGFwWKYFWDs5McQkqz9J
BigSimmo pushed a commit that referenced this pull request Aug 12, 2026
Prerequisite for the filter contract rollout (docs/filter-contract.md,
PR #1847). The four one-of-N mode rails that are about to converge onto
this primitive — differentials, medication, applications, specifiers —
all carry a per-option count, and the primitive had nowhere to put one.
Baking it into `label` would fold the number into the truncating span
and lose the tabular alignment.

Landing it separately because it depends on none of the contract work,
and it makes each adoption a pure call-site change.

Two things worth knowing:

- The hint needs an explicit aria-label. Adjacent label and hint spans
  concatenate to "All62" in the computed accessible name, and a text
  node separator does not survive — the name computation normalises
  inter-element whitespace away. The button is therefore named
  "All (62)", the shape the differentials rail already used. Both
  behaviours are pinned by tests, including that a hintless option's
  name does not drift.
- No .design-sync regeneration is needed, contrary to expectation:
  dtsPropsFor.SegmentedControl references SegmentedControlOption<string>
  by name rather than expanding it, so adding a field leaves the pinned
  props string unchanged. Verified — all 56 design-sync tests pass.

Note for the adoption PRs: ResultFilterSheet renders option.hint the
same adjacent-span way, so its options announce "Crisis12" today. Same
fix, tracked with the rollout rather than smuggled in here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011btGFwWKYFWDs5McQkqz9J
BigSimmo pushed a commit that referenced this pull request Aug 12, 2026
docs/outstanding-issues.md #170 conflicted because both branches rewrote that
row's Detail cell — the file deliberately carries no merge driver (union was
tried and removed, #133) so overlapping edits fail loudly rather than
concatenate. Neither side was a superset: main uniquely explained the closed
one-of-N/many-of-N defect and the "unlike #1847" stop nuance, this branch
uniquely carried the formulation facet work. Resolved by taking main's file and
re-applying a combined row through npm run issues:update rather than
hand-editing or taking one side wholesale.

result-filter-control.tsx auto-merged cleanly and both changes survive: PR
#1857's two aria-label fixes (the "All8" concatenation) and this branch's
exported ResultFilterFacetChips plus the narrowed builder return type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011btGFwWKYFWDs5McQkqz9J
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants