diff --git a/frontend/STYLE_GUIDE.md b/frontend/STYLE_GUIDE.md
index 82a03025..6a185a52 100644
--- a/frontend/STYLE_GUIDE.md
+++ b/frontend/STYLE_GUIDE.md
@@ -1772,10 +1772,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 e3bac5e1..85c84d76 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 9970e497..013be7d7 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 03b045d0..5c339361 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 5afeaadb..6e87c094 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 8ac373ee..989d0d70 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 c21ce41a..c6a3f822 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 bd26933d..823c0dca 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;
}
}