Skip to content

Epic: Add an election-scoped source Comparisons page #116

Description

@shaug

Planned 2026-07-29. Interactive reference prototype (real August 2026 primary data; column pickers, filters, and difference highlighting live): docs/design/COMPARE_PROTOTYPE_2026-07-29.html (committed via #125 — open directly in a browser), also published at https://claude.ai/code/artifact/2de0c1fd-9879-4ea8-a8d4-222989e8bd22

Note: the prototype was built before #115's site-wide UI pass landed. Treat it as the structural and behavioral reference — column model, cell states, difference semantics, codec — not as the visual spec. Where it disagrees with the codebase's post-#115 conventions (shared shell, tokens, typography, component language), the codebase wins; update layouts and chrome to match the current conventions as you implement.

Outcome

Let a voter place two or three endorsement signals side by side — a single source, a
whole category, or the full audited panel — and see, race by race, where they agree
and where they split, with a shareable link for every configuration. The page answers
questions the guide's single-consensus view cannot: How does The Stranger compare to
The Times? Where does Labor split from the Democratic Party? Where does The Urbanist
leave the general consensus?

This issue is the parent outcome and design tracker. Implementation is owned by the
native child graph below; it must not be delivered as one monolithic PR.

Product contract

  • The audited consensus remains the published baseline everywhere. The All sources
    column reproduces the published audited values exactly at the display-model level;
    it is never recomputed client-side.
  • The page is descriptive, not prescriptive: it renders picks, shares, and source
    counts. It never renders a grade, a recommendation, or an insufficiency suppression
    — small denominators are disclosed (1 of 9 sources), never hidden.
  • The Seattle Times (and any future comparison-role signal) is selectable as a column,
    is always visibly badged as comparison-only, and can never contribute to any
    aggregate. panelAdmission()'s hard exclusion remains the enforced backstop.
  • Rows are races, grouped and ordered exactly as the Endorsements page groups them
    (ballot sections), with the guide's existing race filters plus a Differences only
    toggle.
  • Column order is meaningful: the first column is the baseline the other columns'
    agree/differ presentation is measured against.
  • At most 3 columns in v1; narrow viewports show fewer without changing the
    configured state, and a shared link's difference row-set never depends on the
    viewer's viewport.
  • Complete state lives in the URL fragment. No account, personal data, or
    server-side preference storage.
  • Default view: All sources | The Stranger | The Seattle Times.
  • Structural implementation follows the planning prototype; visual polish is
    explicitly deferred to a separate site-wide pass (successor to UI polish pass: one cohesive site (brand, shell, tokens, meters, comparison bars) #115).

Design decisions

  • The page lives at /e/<election-id>/compare/ with a Compare item in the shared
    nav between Endorsements and Sources.
  • Column identity reuses the immutable Add stable source and category identities for personalization #75 transport codes verbatim: source codes
    (strn, stim), category codes (Gdem, Glab).
  • gall is the reserved sentinel for the full audited panel. The lowercase-g
    prefix — already reserved out of the source-code namespace and never a category
    prefix — becomes the sentinel namespace; gmin (the viewer's lens selection) is
    reserved for a future child and must not be accepted in v1.
  • The fragment codec mirrors lens-url.mjs: fixed-width 4-character tokens joined
    with no separator, URLSearchParams-shaped fragment, full version binding
    (panel, ph, data, scoring). One deliberate divergence, documented in the
    codec: the token string is order-preserving (first token = baseline column),
    where the lens's sel is canonically sorted.
  • A category column is the published equal-weight scoring contract applied to the
    category's current membership: each explicitly endorsing member counts once, a
    split allocation divides exactly, non-affirmative states stay out of the
    denominator, and eligibility follows PersonalizationRace.eligible_source_codes.
    Category rules follow current membership, exactly like the lens.
  • A comparison-role category column (Gcmp, single member) renders its member's
    direct stance and carries the comparison-only badge; it never tallies.
  • Cell agreement is set intersection over leading picks: a co-endorsement or tie
    agrees with a column whose lead set intersects it. A row differs when any two
    data-bearing configured columns have disjoint lead sets — computed over the full
    configured column set even when width hides a column.
  • All non-affirmative cell states (no_endorsement, not_covered, unavailable,
    unverified) render as one identical blank. A source absent from a race's
    eligible set (a legislative-district organization outside its district) renders
    as a distinct muted Outside district state, from published eligibility only.
  • Race labels link to the guide's existing race-detail permalinks; the compare page
    reproduces no audit trail of its own.
  • Preset links (e.g., The Stranger vs. The Times) are pure fragment URLs — UI
    sugar over the codec, no separate mechanism.

Child issues

Native dependency graph

#117 -> #118
#117 -> #119
#117 -> #120
#118 + #119 + #120 -> #121
#121 -> #122
#122 -> #123
#123 -> epic completion

Only #117 is initially ready. #118, #119, and #120 can proceed in parallel after #117.
#118 additionally coordinates with the #115 shared shell, which is in progress.

Epic acceptance criteria

  • The default page load server-renders All sources | The Stranger | The Seattle Times and is readable without JavaScript.
  • Every All sources cell equals the published audited consensus for that race
    at the display-model level, proven by an exact-match validation over all races.
  • Category cells reproduce the published scoring contract for member sources,
    including split allocations, ties, and district eligibility.
  • Selecting every comparison-role code in every slot can never change any
    aggregate cell — proven by a test that forces the scenario.
  • A same-version shared URL replays columns, order, filters, and the differences
    toggle exactly; the difference row-set is identical across viewport widths.
  • A cross-version or unknown-token link degrades deterministically with a
    persistent disclosure, never a broken or silently different table.
  • Blanks are normalized: no rendering distinguishes no_endorsement from
    not_covered, unavailable, or unverified; Outside district renders only
    from published eligibility.
  • Accessibility (table semantics, labeled controls, non-color difference
    signals, aria-live status), mobile two-column behavior, browser, rendering,
    release, and hosting-stage validation pass.

Non-goals

  • A My sources / custom-collection column (gmin is reserved, not implemented).
  • User-configurable weights, grades, or any scoring beyond the published contract.
  • Print/PDF output for the comparison view.
  • Sorting or ranking rows by disagreement.
  • Traffic-adaptive presets (a future consideration; v1 presets are static links).
  • Removing or changing the Times comparison on the Endorsements page (possible
    follow-up this page enables; explicitly out of scope here).
  • Any visual-polish work beyond the shared shell — deferred to a separate pass.
  • More than 3 columns.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: publicationHTML, PDF, exports, or release packagingdata: endorsementsEndorsement discovery, evidence, or normalizationtype: featureProduct or pipeline capability

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions