Guidelines

Filter-vs-Metric Guideline

**Version: v1** **Applies to: any consumer template or page composing `KpiCard`, `FilterChip`, or any canon "summary tile" primitive.**

v1

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 KpiCard shipping with onclick / onkeydown selecting a filter is misusing the primitive — the metric shape is unfit for the filter job.
  • A KpiCard whose 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 FilterChip showing 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:

  1. 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.
  2. Accessibility regression. KpiCard is 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.
  3. Drift from canon contract. The canon KpiCard documentation 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