Filter-vs-Metric Guideline
Version: v1
Applies to: any consumer template or page composing KpiCard, FilterChip, or any canon "summary tile" primitive.
This guideline encodes a load-bearing semantic rule the upcoming structural primitive-shape gate will enforce. PRs touching files that use KpiCard / FilterChip cite Guideline: filter-vs-metric@v1 in the PR body so persona reviewers (ADR-115) verify against this exact version.
Rule
KpiCard is a metric primitive — it shows a single quantity, optionally annotated with semantic tone (LIVE green, BUILDING orange, ERROR red). FilterChip is a filter primitive — it shows a selectable categorical option that toggles a query parameter.
Concretely:
- A
KpiCardshipping withonclick/onkeydownselecting a filter is misusing the primitive — the metric shape is unfit for the filter job. - A
KpiCardwhose tone (color, border, fill) signals BOTH "this metric IS the LIVE state" AND "I have LIVE selected" is signal-conflated and the operator cannot disambiguate at a glance. - A
FilterChipshowing the count for its category is fine (categories typically display their cardinality) — the primitive is still a chip, it just embeds the count.
When the page needs filterable categories that each show a count, the canon-tier shape is FilterChip[] with count prop, fed by the same data source the KPIs read. The KPIs stay as metrics; the chips do the filtering. The two primitives never collapse.
Why
Today's empirical evidence: ai-frontend/src/lib/components/canon/ProjectsListTemplate.svelte documents (lines 38-46) that "the four lifecycle KPIs and three class KPIs are click affordances that toggle a single-active filter, exclusive within their group". Seven KpiCard instances were wired with role="button" + aria-pressed + onclick handlers. Each card's semantic tone (LIVE = green, BUILDING = orange, ARCHIVED = grey) was simultaneously its identity ("this is the LIVE column") AND its selection state ("I have LIVE selected"). When LIVE was selected, the LIVE card's border went from green-tone-at-rest to green-tone-selected — visually indistinguishable to the operator, who could only tell selection state by reading the URL.
Three properties make this failure mode load-bearing:
- Signal conflation. The same visual channel (border color, fill tone) carries two orthogonal semantics. Operators cannot disambiguate "this IS the LIVE state" from "I have LIVE selected" — the channel is overloaded.
- Accessibility regression.
KpiCardis a tile primitive — its accessibility tree is "summary statistic".role="button"overrides this but doesn't replace the semantic-tone announcement; screen-readers report "LIVE, 12, button" which is ambiguous in both directions. - Drift from canon contract. The canon
KpiCarddocumentation on canon.gyrum.ai/components/kpi-card promises "static metric tile" — consumers wiring it as an interactive filter shipped a contract the canon never offered. The next consumer reads the canon docs and finds the contract reads one way and the production usage reads another.
The fix: use the existing FilterChip primitive (V94 shipped DataTableOrganism with FilterChip[] + onFilterSelect as the canonical shape — warp#3618). KPIs sit at the top as static metrics; chips sit below (or in a toolbar row) as the filter affordances. The two layers compose; neither tries to do the other's job.
Examples — passes
KPIs as metrics + chips as filters (the canonical shape per V94 / warp#3618):
<KpiCard label="Total projects" value={totals.all} />
<KpiCard label="Live" value={totals.live} tone="ok" />
<KpiCard label="Building" value={totals.building} tone="warn" />
<div class="filter-row">
<FilterChip
label="Live"
count={totals.live}
selected={filter === 'live'}
onclick={() => setFilter('live')}
/>
<FilterChip
label="Building"
count={totals.building}
selected={filter === 'building'}
onclick={() => setFilter('building')}
/>
</div>
FilterChip with embedded count (categories can show their cardinality):
<FilterChip label="Bug" count={byKind.bug} selected={kind === 'bug'} />
KpiCard purely as a metric, no interaction:
<KpiCard label="Live deployments" value={deploys.live} tone="ok" />
Examples — fails
KpiCard wired as a button toggling a filter (today's lived failure):
<!-- FAILS — KpiCard is a metric primitive; using it as a filter affordance
conflates semantic tone (LIVE = green) with selection state -->
<KpiCard
label="Live"
value={totals.live}
tone="ok"
role="button"
aria-pressed={filter === 'live'}
onclick={() => setFilter('live')}
/>
Tone-as-selection (visually indistinguishable):
<!-- FAILS — when filter === 'live', tone flips from 'ok' (rest) to 'ok-selected'
which is visually a slightly-different green; operator can't disambiguate -->
<KpiCard
label="Live"
value={totals.live}
tone={filter === 'live' ? 'ok-selected' : 'ok'}
onclick={() => setFilter('live')}
/>
KpiCard group documenting "exclusive single-active filter" semantics:
<!--
FAILS — the docstring acknowledges these tiles are doing filter work.
That acknowledgement IS the signal that the primitive choice is wrong.
-->
<!-- "The four lifecycle KPIs and three class KPIs are click affordances
that toggle a single-active filter, exclusive within their group." -->
<KpiCard ... />
<KpiCard ... />
FilterChip styled to look like a metric tile (signal-conflation from the other direction):
<!-- FAILS — visually claiming to be a metric while semantically being a filter -->
<FilterChip
label="Live"
count={totals.live}
style="font-size: var(--text-3xl); padding: var(--space-4);"
/>
Path forward when canon doesn't have what you need
Path A — in-PR (warp#3100)
If the design needs a "metric that's also a filter" semantic — e.g. a single tile that shows the count AND switches the active filter on click — this is a NEW canon-tier primitive shape, not a misuse of KpiCard. Author the new primitive (MetricFilterTile? CategoryTile?) in the same PR per warp#3100. Register it in canon-manifest.yaml's owns: block. Document the primitive's visual contract (how does selection state look distinct from semantic tone?) on canon.gyrum.ai's component detail page in the same diff.
Path B — sister ticket
If the new primitive shape is atom-tier (no current consumer expresses the shape end-to-end) and the current consumer can ship with the canonical KpiCard + FilterChip separation, use that separation in the current PR and file a sister ticket for the new primitive. Tag the current PR with the sister ticket only if the separation is genuinely worse than the future combined primitive.
Audited override
gyrum-review-pr --skip-filter-vs-metric "<reason ≥ 30 chars>" — logged to ~/.gyrum/admin-overrides.log. Use ONLY when the consumer is a one-off dev-tooling surface (operator-facing dashboard, debug panel) where the cost of duplicate visual channels is acceptable. The reason MUST name (a) why this surface is one-off, (b) why the canonical separation costs more than it earns here, and (c) the audit-log line referring to the operator's acknowledgement.
PR body citation
Required line in the PR body when using KpiCard or FilterChip:
Guideline: filter-vs-metric@v1
Revision process
This document is versioned. Updates ride gyrum-revise-guideline filter-vs-metric (separate devtools wrapper, separate ticket).
References
- css-is-canon-tier-work principle — parent principle (visual contract IS canon-tier)
- warp#3672 — this guideline's live ticket
- warp#3618 — V94 / DataTableOrganism — canonical shape with
FilterChip[]+onFilterSelect - warp#3100 — canon-extension Path-A / Path-B
- warp#974 — smoke-spec gate
- ADR-115 — principle-aware reviewers
- ADR-117 — module guidelines
- canon site: https://canon.gyrum.ai/components/kpi-card
- canon site: https://canon.gyrum.ai/components/filter-chip