Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions frontend/STYLE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/components/CardPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/components/CardPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -803,6 +804,19 @@ export function CardPreview({
{current.condition.toUpperCase()}
</span>
)}
{current.language &&
current.language !== 'en' &&
(() => {
const label =
LANGUAGE_OPTIONS.find((o) => o.value === current.language)?.label ??
current.language.toUpperCase();
return (
<span className="card-preview-condition" aria-label={`Language ${label}`}>
{' · '}
{label}
</span>
);
})()}
{(['altered', 'proxy', 'misprint'] as const)
.filter((flag) => current[flag])
.map((flag) => (
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/components/Legend.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/components/Legend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -220,6 +223,19 @@ export function LegendContent({ context }: { context: LegendContext }) {
/>
</Section>
)}
{/* 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') && (
<Section title="Condition — Near Mint unmarked">
{CONDITION_OPTIONS.filter((o) => o.value !== '' && o.value !== 'nm').map((o) => (
<Entry
key={o.value}
glyph={<ConditionChip condition={o.value as Condition} />}
word={o.label as string}
/>
))}
</Section>
)}
{context === 'binder' && (
<Section title="Slot border">
<SlotItem label="Mythic" cls="mythic" />
Expand Down
41 changes: 39 additions & 2 deletions frontend/src/components/shared/CardRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ function card(overrides: Partial<EnrichedCard> = {}): EnrichedCard {
};
}

function renderRow(qty: number) {
function renderRow(qty: number, overrides: Partial<EnrichedCard> = {}) {
return render(
<MemoryRouter>
<CardRow
card={card()}
card={card(overrides)}
qty={qty}
allocations={[]}
menu={<button type="button">Card actions</button>}
Expand All @@ -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');
});
});
37 changes: 36 additions & 1 deletion frontend/src/components/shared/CardRow.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
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';
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 (
<span className="card-list-condition" title={label} aria-label={label}>
{conditionShort(condition)}
</span>
);
}

interface CardRowProps {
card: EnrichedCard;
/** Copies this row stands for; `×qty` shows only when >1. */
Expand Down Expand Up @@ -124,6 +147,18 @@ export function CardRow({
p.{pageNum}
</span>
)}
{/* 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' && (
<ConditionChip condition={card.condition} />
)}
{card.language && card.language !== 'en' && (
<span className="card-list-language">
{LANGUAGE_OPTIONS.find((o) => o.value === card.language)?.label ??
card.language.toUpperCase()}
</span>
)}
</div>
</div>
{card.manaCost ? (
Expand Down
24 changes: 22 additions & 2 deletions frontend/src/styles/collection.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
Expand Down
Loading