Appearance
Sets viewer sync indicator โ Design โ
Date: 2026-07-26 Status: Approved Supersedes the display decisions in: 2026-07-23-sync-visibility-design.md ยง"Client (SetSelector)"
1. Problem โ
The sets viewer (client/src/components/SetSelector.jsx) signals per-set sync state with two elements inside the set-name cell: a green CheckCircle2 icon before the name, and a percentage badge after it. The states render as:
| Set state | What the merchant sees |
|---|---|
| Never synced | nothing |
| 1โ99% synced | green โ and a 63% badge |
| 100% synced | green โ only |
Three defects follow from this:
- The checkmark means "started", not "done". It is gated on
syncedCount > 0, so a set that is 4% synced carries the same green โ as one that is fully synced. - 100% is never stated. The badge is suppressed by
syncedPercent < 100, so the only signal for "fully synced" is the absence of the percent badge. An absent element is not a signal merchants notice โ this is the reported confusion. - The treatment is off-system, partly by accident.
CheckCircle2is a thin-stroke lucide icon intext-green-600, foreign to the neo-brutalist design system. The badge passesvariant="secondary", which is not a Badge variant โBadge.jsxdefines onlydefault | outline | solid | surface(secondarybelongs to Button). cva contributes no classes for an unknown variant value anddefaultVariantsonly apply when the prop isundefined, so the percent badge currently renders with no chip styling at all: bare bold text beside the set name.
Ragged placement compounds it. Both markers sit in the name cell, so with variable-length set names the chips land at different horizontal positions down the list and the column cannot be scanned.
2. Goals โ
- "Fully synced" is stated positively, not implied by a missing element.
- One marker, one position, one meaning โ done vs. in progress is a change of chip, never the presence/absence of one.
- The marker belongs to the neo-brutalist system.
- The chip never contradicts the "Show only not 100% synced" filter.
Non-goals: changing how sync completion is measured (the Shopify collection-count ratio stays as specified on 2026-07-23), and sorting the list by sync state.
3. Design โ
3.1 New Sync column โ
Insert a column between "Set Name" and "Code":
โ NAME SYNC CODE RELEASED CARDS
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Bloomburrow โSYNCEDโ BLB 2024-08 261
โ Modern Horizons 3 โ63%โโโ MH3 2024-06 303
โ Duskmourn โ8%โโโโ DSK 2024-09 276
โ Foundations FDN 2024-11 281
โ Aetherdrift โ41%โโโ DFT 2025-02 291A plain <th> styled like its neighbours (px-4 py-3 border-b-2 border-black), not a SortableHeader. syncedPercent is computed after the DB query from a per-shop Shopify counts map, so server-side sorting would require a separate pipeline; the existing "Show only not 100% synced" checkbox already serves the need that sorting would. The table declares no colSpan anywhere โ both empty states render outside the <table> โ so this is a clean one-th/one-td insert with no other structural edits.
3.2 Chip states โ
One element, mutually exclusive:
| Condition | Renders |
|---|---|
syncedCount === 0 | nothing |
syncedPercent >= 100 | <Badge variant="solid" size="sm">Synced</Badge> |
| otherwise | <Badge variant="outline" size="sm">{syncedPercent}%</Badge> |
The branches are ordered on syncedCount first, so a barely-started set โ say 1 of 500 cards, which floors to 0 โ still renders an outline 0% chip rather than falling through to "nothing". "No chip" means strictly never synced.
solid (black fill, white text) and outline (2px foreground outline) are both real variants in Badge.jsx, which resolves defect 3's silent no-op. The CheckCircle2 render block is deleted along with its lucide-react import โ it is the file's only lucide usage.
3.3 Make syncedPercent === 100 mean complete โ
syncCompletionService.syncedPercent currently rounds:
js
return Math.min(100, Math.round((safe / expected) * 100));At 99.6% this reports 100, which under ยง3.2 would render a "Synced" chip on a set that computeFullySyncedCodes โ and therefore the onlyIncomplete filter โ still classifies as incomplete. The chip and the filter would visibly disagree.
Change Math.round โ Math.floor. Then syncedPercent === 100 holds exactly when count >= expected, which is precisely the predicate computeFullySyncedCodes uses. The client can branch on the single field without re-deriving the rule (ยง5.12: the server rule is the enforcement; the client references it).
Existing assertions in syncCompletionService.test.js are unaffected โ (50,100) โ 50, (1,3) โ 33, and (110,100) โ 100 are identical under floor.
Known edge, deliberately unchanged: when a set's cardCount is 0 or unknown, syncedPercent returns 100 if any products exist, while computeFullySyncedCodes excludes it (it requires expected > 0). Such a set shows a "Synced" chip yet still appears under the incomplete filter. This is the same claim the current code makes; reconciling it is a separate decision.
3.4 Tooltip โ
The deleted checkmark carried aria-label="Synced to your store". The chip replaces it with a title naming the actual counts and the caveat:
- Fully synced:
"261 of 261 cards synced (approximate)" - Partial:
"190 of 303 cards synced (approximate)"
The ratio is approximate by design โ per the 2026-07-23 spec, variants-mode syncs, promos, and tokens skew numerator against denominator. Stating the counts pre-empts "it says 94% but I synced the whole set" support tickets.
3.5 The two adjacent badges carrying the same defect โ
Defect 3 above is not confined to the sync badge. The Release Date column โ directly beside the new Sync column โ holds two more badges with variants that Badge.jsx does not define, so they render as bare bold text just as the percent badge did:
| Line | Badge | Variant passed | Replacement |
|---|---|---|---|
| ~395 | Coming Soon (Today! / Tomorrow / N days) | warning | surface |
| ~400 | Preview | secondary | outline |
Badge has no semantic colour variants at all, so there is no like-for-like substitute; the replacements are a visual choice. surface (primary fill) gives an unreleased set the emphasis its badge was clearly meant to carry, and outline keeps Preview quiet and consistent with the partial-sync chip sitting one column to its left.
Scope note: this widens the original spec, which covered only the sync indicator. It is included because shipping a correctly-styled chip immediately beside two unstyled ones would leave the row visually incoherent โ the same complaint that prompted this work. Approved 2026-07-26.
4. Per-game parity (ยง5.1) โ
Presentation-only and game-agnostic. SetSelector is the single renderer for MTG (/sets) and for Pokemon and Riftbound (/catalog/sets), and both endpoints already attach syncedCount/syncedPercent through the same loadSyncCompletion โ syncedPercent path. No plugin, per-game model, or importer is touched.
- mtg โ covered by the shared change
- pokemon โ covered by the shared change
- riftbound โ covered by the shared change
None exempt; there is no per-game branch to mirror.
5. Testing โ
SetSelector.test.jsx currently asserts nothing about sync state, so this is net-new coverage.
Client (client/src/components/SetSelector.test.jsx):
- Renders a "Synced" chip when
syncedPercentis 100. - Renders a
63%chip when partially synced. - Renders neither chip when
syncedCountis 0. - Regression guard: the variant passed to
Badgeis one the component defines, so a futurevariant="secondary"-style typo fails instead of silently rendering unstyled.
Server (server/services/syncCompletionService.test.js):
syncedPercent(299, 300) === 99โ floors rather than rounding to 100.
Environment note: client/node_modules is absent in a fresh worktree; run npm --prefix client install before npm run test:client.
6. Files touched โ
| File | Change |
|---|---|
client/src/components/SetSelector.jsx | New Sync column; single chip replacing check + conditional badge; drop lucide-react import |
client/src/components/SetSelector.test.jsx | New sync-indicator tests |
server/services/syncCompletionService.js | Math.round โ Math.floor in syncedPercent |
server/services/syncCompletionService.test.js | Floor-boundary case |
No API-shape change: syncedCount and syncedPercent already arrive inline from both list endpoints. Both enrichment sites call syncedPercent from server/services/syncCompletionService.js โ one in server/routes/api.js (the MTG /sets list) and one in server/routes/catalog.js (the game-aware /catalog/sets list). Deliberately file-scoped rather than line-scoped: these handlers were split out of routes/api.js mid-branch, and line numbers churn.
Nothing else reads syncedPercent: on the client it appears only in SetSelector.jsx and its test. So the ยง3.3 round โ floor change is display-only and carries no risk to sync, pricing, or billing paths.
