From 94207ba8ebd9cdc6a6b5b5740f3229c8900ae779 Mon Sep 17 00:00:00 2001 From: George Papagapitos Date: Wed, 22 Jul 2026 13:58:55 -0500 Subject: [PATCH] feat(collection): condition + language visible where the copies live (E134) Condition and language were settable at add-time and condition is a filter facet, but no row at any density ever displayed them - a collector tracking LP/damaged copies had to open the edit dialog per card. - CardRow meta line gains deviation chips: LP/MP/HP/DMG (full word as title/aria) and non-English language names. Norms are unmarked - NM and English render nothing (imports stamp nm/en on nearly every copy; an always-on chip is noise). Chips follow CN's density rule (return >=768px in compact). - CardPreview compact header gains the language span beside the existing condition. - Symbol Key (collection + binder contexts - binder list rows render the same chips) gains 'Condition - Near Mint unmarked' with the four deviation chips. - STYLE_GUIDE: field budgets updated + detail-when-present / norms-unmarked ruling. Closes the Collection-UX program (E127-E134). --- frontend/STYLE_GUIDE.md | 13 +++++- frontend/src/components/CardPreview.test.tsx | 26 ++++++++++++ frontend/src/components/CardPreview.tsx | 14 +++++++ frontend/src/components/Legend.test.tsx | 31 ++++++++++++++ frontend/src/components/Legend.tsx | 16 ++++++++ .../src/components/shared/CardRow.test.tsx | 41 ++++++++++++++++++- frontend/src/components/shared/CardRow.tsx | 37 ++++++++++++++++- frontend/src/styles/collection.css | 24 ++++++++++- 8 files changed, 195 insertions(+), 7 deletions(-) diff --git a/frontend/STYLE_GUIDE.md b/frontend/STYLE_GUIDE.md index a40c30f0f..302def9ed 100644 --- a/frontend/STYLE_GUIDE.md +++ b/frontend/STYLE_GUIDE.md @@ -1762,10 +1762,19 @@ trading one out, not by squeezing: | Density | Fields | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **List** (66px) | thumb · name · foil · deck/binder badges · type glyph · rarity chip + set code + CN · mana · qty · value | -| **Compact** (32px) | name · type glyph · rarity chip · set code · mana · qty · value — CN returns ≥768px; foil/deck/binder badges return ≥1024px | +| **List** (66px) | thumb · name · foil · deck/binder badges · type glyph · rarity chip + set code + CN · mana · qty · value · condition + language chips (deviations only — detail-when-present, like the page chip) | +| **Compact** (32px) | name · type glyph · rarity chip · set code · mana · qty · value — CN returns ≥768px; foil/deck/binder badges return ≥1024px; condition/language follow CN (return ≥768px) | | **Grid** (tile) | art + qty badge + corner deck/binder badges + **rarity chip** (top-right corner, every tile); a **set-code chip** (bottom-left, next to qty) appears **only when the same card name has >1 printing** in the current rows — art is the identity until it's ambiguous | +**Detail-when-present.** Per-copy chips (the binder page chip, condition, +language) render **only when that specific copy carries the value** — no +dash, no "Not set" placeholder; absence renders nothing, keeping rows quiet +by default and dense only where there's something to say. **Norms are +unmarked, deviations are chipped**: Near Mint and English never render a +chip (imports stamp `nm`/`en` on nearly every copy, so an always-on chip is +noise, not signal) — the row only speaks when a copy is LP/MP/HP/DMG or +non-English. The Key's Condition section says so in its title. + **Touch rule.** Hover-revealed information (titles/tooltips on glyphs, hover peeks) is **enhancement-only** — on coarse pointers it doesn't exist, so nothing may be _only_ reachable via hover. Every hover affordance needs a tap path; diff --git a/frontend/src/components/CardPreview.test.tsx b/frontend/src/components/CardPreview.test.tsx index e3bac5e1c..85c84d761 100644 --- a/frontend/src/components/CardPreview.test.tsx +++ b/frontend/src/components/CardPreview.test.tsx @@ -102,6 +102,32 @@ describe('CardPreview printing identity (T36)', () => { expect(screen.queryByText('Foil')).toBeNull(); expect(screen.queryByText('Oil slick')).toBeNull(); }); + + it('shows the condition when set', () => { + renderPreview(mk({ condition: 'lp' })); + const chip = screen.getByLabelText('Condition lp'); + expect(chip.textContent).toContain('LP'); + }); + + it('omits the condition token when unset', () => { + renderPreview(mk({})); + expect(screen.queryByLabelText(/^Condition/)).toBeNull(); + }); + + it('shows the full language name for a non-English printing', () => { + renderPreview(mk({ language: 'ja' })); + const chip = screen.getByLabelText('Language Japanese'); + expect(chip.textContent).toContain('Japanese'); + }); + + it('omits the language token for English or an unset language', () => { + const unset = renderPreview(mk({})); + expect(screen.queryByLabelText(/^Language/)).toBeNull(); + unset.unmount(); + + renderPreview(mk({ language: 'en' })); + expect(screen.queryByLabelText(/^Language/)).toBeNull(); + }); }); describe('CardPreview turn (sideways layouts)', () => { diff --git a/frontend/src/components/CardPreview.tsx b/frontend/src/components/CardPreview.tsx index 9970e4971..013be7d7f 100644 --- a/frontend/src/components/CardPreview.tsx +++ b/frontend/src/components/CardPreview.tsx @@ -32,6 +32,7 @@ import { formatMoney } from '../lib/format-money'; import { formatPricedDate } from '../lib/price-freshness'; import { CardImageFrame } from './CardImageFrame'; import { foilFinishLabel } from '../lib/foil-style'; +import { LANGUAGE_OPTIONS } from './PrintingPicker'; import { ManaCost } from './ManaCost'; import { useLockBodyScroll } from '../lib/use-lock-body-scroll'; import { useCenteredSlide } from '../lib/use-centered-slide'; @@ -803,6 +804,19 @@ export function CardPreview({ {current.condition.toUpperCase()} )} + {current.language && + current.language !== 'en' && + (() => { + const label = + LANGUAGE_OPTIONS.find((o) => o.value === current.language)?.label ?? + current.language.toUpperCase(); + return ( + + {' · '} + {label} + + ); + })()} {(['altered', 'proxy', 'misprint'] as const) .filter((flag) => current[flag]) .map((flag) => ( diff --git a/frontend/src/components/Legend.test.tsx b/frontend/src/components/Legend.test.tsx index 03b045d0e..5c3393616 100644 --- a/frontend/src/components/Legend.test.tsx +++ b/frontend/src/components/Legend.test.tsx @@ -72,6 +72,37 @@ describe('Legend (context-aware symbol key)', () => { expect(screen.queryByText('Mana Rock')).toBeNull(); }); + it('collection: explains the deviation abbreviations with the real chip — NM is unmarked', () => { + renderLegend('collection'); + openKey(); + expect(screen.getByText('Condition — Near Mint unmarked')).toBeTruthy(); + for (const [abbr, word] of [ + ['LP', 'Lightly Played'], + ['MP', 'Moderately Played'], + ['HP', 'Heavily Played'], + ['DMG', 'Damaged'], + ]) { + expect(screen.getByText(abbr)).toBeTruthy(); + expect(screen.getByText(word)).toBeTruthy(); + } + // Rows never chip NM, so the Key doesn't list it either. + expect(screen.queryByText('NM')).toBeNull(); + expect(document.querySelectorAll('.card-list-condition').length).toBe(4); + }); + + it('binder: has the Condition section (binder rows render the same chips)', () => { + renderLegend('binder'); + openKey(); + expect(screen.getByText('Condition — Near Mint unmarked')).toBeTruthy(); + expect(document.querySelectorAll('.card-list-condition').length).toBe(4); + }); + + it('deck: no Condition section (deck rows are name-aggregated, no per-copy chips)', () => { + renderLegend('deck'); + openKey(); + expect(screen.queryByText('Condition')).toBeNull(); + }); + it('binder: keeps the slot-border color entries on top of the shared sections', () => { renderLegend('binder'); openKey(); diff --git a/frontend/src/components/Legend.tsx b/frontend/src/components/Legend.tsx index 5afeaadb6..6e87c094f 100644 --- a/frontend/src/components/Legend.tsx +++ b/frontend/src/components/Legend.tsx @@ -10,6 +10,9 @@ import { FoilBadge } from './FoilBadge'; import { DeckBadge } from './DeckBadge'; import { makeDeckAllocationInfo } from '@/lib/allocations'; import { BinderBadge } from './BinderBadge'; +import { ConditionChip } from './shared/CardRow'; +import { CONDITION_OPTIONS } from './PrintingPicker'; +import type { Condition } from '@/types'; /** * Context-aware symbol key — the "Key" popover that teaches the app's glyph @@ -220,6 +223,19 @@ export function LegendContent({ context }: { context: LegendContext }) { /> )} + {/* Binder list rows render the same CardRow chips, so the binder Key + carries the section too (glyph-literacy rule: Key entry per surface). */} + {(context === 'collection' || context === 'binder') && ( +
+ {CONDITION_OPTIONS.filter((o) => o.value !== '' && o.value !== 'nm').map((o) => ( + } + word={o.label as string} + /> + ))} +
+ )} {context === 'binder' && (
diff --git a/frontend/src/components/shared/CardRow.test.tsx b/frontend/src/components/shared/CardRow.test.tsx index 8ac373eea..989d0d709 100644 --- a/frontend/src/components/shared/CardRow.test.tsx +++ b/frontend/src/components/shared/CardRow.test.tsx @@ -25,11 +25,11 @@ function card(overrides: Partial = {}): EnrichedCard { }; } -function renderRow(qty: number) { +function renderRow(qty: number, overrides: Partial = {}) { return render( Card actions} @@ -55,4 +55,41 @@ describe('CardRow', () => { expect(multipleQty?.textContent).toBe('×4'); expect(multipleQty?.getAttribute('aria-hidden')).toBe('false'); }); + + it('renders a condition chip with the short abbreviation and full-word title/aria-label', () => { + const { container } = renderRow(1, { condition: 'lp' }); + const chip = container.querySelector('.card-list-condition'); + expect(chip?.textContent).toBe('LP'); + expect(chip?.getAttribute('title')).toBe('Lightly Played'); + expect(chip?.getAttribute('aria-label')).toBe('Lightly Played'); + }); + + it("abbreviates 'damaged' condition to DMG", () => { + const { container } = renderRow(1, { condition: 'damaged' }); + expect(container.querySelector('.card-list-condition')?.textContent).toBe('DMG'); + }); + + it('renders no condition chip when the copy has no condition set', () => { + const { container } = renderRow(1); + expect(container.querySelector('.card-list-condition')).toBeNull(); + }); + + it('renders no condition chip for Near Mint — deviations only, NM is the unmarked norm', () => { + const { container } = renderRow(1, { condition: 'nm' }); + expect(container.querySelector('.card-list-condition')).toBeNull(); + }); + + it('renders no language chip for English or an unset language', () => { + const noLang = renderRow(1); + expect(noLang.container.querySelector('.card-list-language')).toBeNull(); + noLang.unmount(); + + const english = renderRow(1, { language: 'en' }); + expect(english.container.querySelector('.card-list-language')).toBeNull(); + }); + + it('renders the full language name for a non-English printing', () => { + const { container } = renderRow(1, { language: 'ja' }); + expect(container.querySelector('.card-list-language')?.textContent).toBe('Japanese'); + }); }); diff --git a/frontend/src/components/shared/CardRow.tsx b/frontend/src/components/shared/CardRow.tsx index c21ce41a3..c6a3f822d 100644 --- a/frontend/src/components/shared/CardRow.tsx +++ b/frontend/src/components/shared/CardRow.tsx @@ -1,6 +1,6 @@ import type { ComponentProps, ReactNode } from 'react'; import { Check } from 'lucide-react'; -import type { EnrichedCard } from '../../types'; +import type { Condition, EnrichedCard } from '../../types'; import type { AllocationInfo } from '../../lib/allocations'; import { FoilBadge } from '../FoilBadge'; import { DeckBadge } from '../DeckBadge'; @@ -8,10 +8,33 @@ import { BinderBadge } from '../BinderBadge'; import { RarityBadge } from './RarityBadge'; import { ManaCost } from '../ManaCost'; import { TypeIcon } from './ManaSymbol'; +import { CONDITION_OPTIONS, LANGUAGE_OPTIONS } from '../PrintingPicker'; import { getCardType } from '../../lib/card-types'; import { getColorKey, COLOR_INFO } from '../../lib/colors'; import { formatMoney } from '../../lib/format-money'; +/** 'damaged' abbreviates to DMG for the row chip; the rest are already short. */ +export function conditionShort(condition: Condition): string { + return condition === 'damaged' ? 'DMG' : condition.toUpperCase(); +} + +/** + * Quiet per-copy condition chip — the short abbreviation with the full word + * as the accessible label/tooltip. Exported so the Symbol Key can render + * this exact chip (T36 pattern: the Key can't drift from what it explains). + */ +export function ConditionChip({ condition }: { condition: Condition }) { + // Options are plain-string labels in practice; SelectOption widens to + // ReactNode for menu items generally, but title/aria-label need a string. + const label = + (CONDITION_OPTIONS.find((o) => o.value === condition)?.label as string) ?? condition; + return ( + + {conditionShort(condition)} + + ); +} + interface CardRowProps { card: EnrichedCard; /** Copies this row stands for; `×qty` shows only when >1. */ @@ -124,6 +147,18 @@ export function CardRow({ p.{pageNum} )} + {/* Deviations only — NM is the unmarked norm (imports stamp nm on + nearly every copy; an always-on chip is noise, not signal), the + same way English never renders a language chip. */} + {card.condition && card.condition !== 'nm' && ( + + )} + {card.language && card.language !== 'en' && ( + + {LANGUAGE_OPTIONS.find((o) => o.value === card.language)?.label ?? + card.language.toUpperCase()} + + )} {card.manaCost ? ( diff --git a/frontend/src/styles/collection.css b/frontend/src/styles/collection.css index bd26933d8..823c0dca9 100644 --- a/frontend/src/styles/collection.css +++ b/frontend/src/styles/collection.css @@ -1021,6 +1021,22 @@ color: var(--text-muted); } +/* Per-copy condition/language chips — detail-when-present, like + .card-list-page: they render only when the copy carries the value, nothing + otherwise. Condition is factual (not a status pill), so it stays as quiet + text rather than a colored badge; the full word rides as title/aria-label. */ +.card-list-condition { + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: 600; + color: var(--text-muted); +} + +.card-list-language { + font-size: var(--text-xs); + color: var(--text-muted); +} + /* Primary card-type glyph in a collection list/compact row's meta line (E30). A single mana-font type icon (creature / instant / land / …) — the most space-efficient at-a-glance type signal, so it stays visible even on @@ -1991,12 +2007,16 @@ i.ss.set-symbol--mythic { below 768px. */ .collection-list.is-compact .card-list-deck-badge, .collection-list.is-compact .card-list-binder-badge, -.collection-list.is-compact .card-list-cn { +.collection-list.is-compact .card-list-cn, +.collection-list.is-compact .card-list-condition, +.collection-list.is-compact .card-list-language { display: none; } @media (min-width: 768px) { - .collection-list.is-compact .card-list-cn { + .collection-list.is-compact .card-list-cn, + .collection-list.is-compact .card-list-condition, + .collection-list.is-compact .card-list-language { display: inline-block; } }