Skip to content

feat(card): editor-curated staple cards with per-type mosaics #532

@jbourdin

Description

@jbourdin

Goal

Introduce Staple Cards — an editor-curated list of cards that show up across many decks and archetypes (TCG term for the format's "must-haves"). Editors flag a card as a staple, and the public site displays one grid per bucket:

  1. Pokémon
  2. Trainer — Supporter
  3. Trainer — Item (FR translation: Objet)
  4. Trainer — Tool
  5. Trainer — Stadium
  6. Energy (curators are trusted not to promote basic energies; no data-level constraint)
  7. Ace Spec (any non-pokemon card flagged with rarity = "ACE SPEC Rare" — see §1)

Order within each grid is editor-controlled via drag-and-drop. The Ace Spec bucket sits last as the "specialty section".

This feature also lays the groundwork for issue #437 — Expanded card watchlist with editor relevance ratings: the hotness field added here (see §4) is the same axis #437 will eventually expose across all cards.

Status

  • PR-1 merged (#533) — CardIdentity.ruleboxType schema column + App\Constants\RuleboxType (13 known rulebox mechanics) + Ace Spec auto-detection in CardIdentityResolver + backfill of existing Ace Spec identities.
  • 🚧 PR-2 (this PR will be one cohesive feature PR) — staple entities + admin CRUD + reorder + public page + hotness filter + Channel.enableStaples flag + technical re-enrich button.

Why

Today the only "card meta" surface is the Banned Cards list (F6.14) — the negative baseline ("don't play these"). There is no curated counterpart for the positive baseline ("these are the toolbox most decks build around"). Editors regularly capture this knowledge in archetype prose; promoting it to a structured page makes the meta browsable and gives newcomers a fast onramp.

Architecture — mirror Banned Cards (F6.14), with five deltas

1. Mirror BannedCard / BannedCardPrinting model

Create the parent + child pair, exactly mirroring src/Entity/BannedCard.php and src/Entity/BannedCardPrinting.php:

App\Entity\StapleCard (parent — one row per CardIdentity):

  • id, cardIdentity (ManyToOne → CardIdentity, unique, onDelete=SET NULL)
  • cardName (string[100], denormalized display name)
  • bucket (string, enum-shaped: pokemon | supporter | item | tool | stadium | energy | ace_spec) — denormalized from card identity at enrichment time (see priority rule below)
  • position (int, 0-based, scoped to bucket)
  • hotness (int, default CardHotness::STAPLE_THRESHOLD) — see §4
  • representativePrinting (ManyToOne → CardPrinting, nullable) — optional override for the public image
  • note (LONGTEXT, nullable) — Markdown explanation, edited via the rich-text editor (same rich_text_editor macro the banned-card form uses)
  • deletedAt (nullable, soft-delete)
  • createdAt

App\Entity\StapleCardPrinting (child — one row per (setCode, cardNumber) pair):

  • id, stapleCard (ManyToOne, onDelete=CASCADE, inversedBy='printings')
  • setCode, cardNumber (unique together)
  • cardPrinting (ManyToOne → CardPrinting, nullable)
  • createdAt

CardIdentity.ruleboxType already exists from PR #533 — nullable string column populated by CardIdentityResolver from TcgdexCard.rarity. Currently only RuleboxType::ACE_SPEC is auto-detected; the other 12 constants (V/VMAX/VSTAR/ex/EX-classic/G/GX/BREAK/Mega/Mega-classic/Radiant/Prism Star) ship as documented constants for future detection PRs. See src/Constants/RuleboxType.php.

Bucket assignment priority (computed at staple enrichment, stored as StapleCard.bucket):

if cardIdentity.ruleboxType === RuleboxType::ACE_SPEC: bucket = 'ace_spec'
elif cardIdentity.category === 'pokemon':              bucket = 'pokemon'
elif cardIdentity.category === 'energy':               bucket = 'energy'
elif cardIdentity.trainerType === 'Supporter':         bucket = 'supporter'
elif cardIdentity.trainerType === 'Item':              bucket = 'item'
elif cardIdentity.trainerType === 'Tool':              bucket = 'tool'
elif cardIdentity.trainerType === 'Stadium':           bucket = 'stadium'

A given card lands in exactly one bucket. Ace Spec wins over the type-based buckets — an Ace Spec Item lives in ace_spec, not item. This is deliberate: editors curating Ace Specs want them gathered, not scattered across four type buckets.

Reference files to clone the pattern from:

Banned Cards Staple Cards (new)
src/Entity/BannedCard.php src/Entity/StapleCard.php
src/Entity/BannedCardPrinting.php src/Entity/StapleCardPrinting.php
src/Repository/BannedCardRepository.php src/Repository/StapleCardRepository.php
src/Repository/BannedCardPrintingRepository.php src/Repository/StapleCardPrintingRepository.php
src/Service/BannedCardEnricher.php src/Service/StapleCardEnricher.php
src/Service/BannedCardImageResolver.php src/Service/StapleCardImageResolver.php
src/Form/BannedCardFormType.php src/Form/StapleCardFormType.php
src/Controller/BannedCardController.php src/Controller/StapleCardController.php
src/Controller/AdminBannedCardController.php src/Controller/AdminStapleCardController.php
templates/banned_card/list.html.twig templates/staple_card/list.html.twig
templates/admin/banned_card/list.html.twig templates/admin/staple_card/list.html.twig
templates/admin/banned_card/form.html.twig templates/admin/staple_card/form.html.twig

2. Permission model — open editing to ROLE_ARCHETYPE_EDITOR

Banned Cards admin requires ROLE_ADMIN. Staples are curatorial content owned by the same group that edits archetypes (per F2.x and F18.x), so:

  • AdminStapleCardController carries #[IsGranted('ROLE_ARCHETYPE_EDITOR')] (matches the class-level attribute on AdminArchetypeController at line 50).
  • config/packages/security.yaml access_control already grants ^/admin/archetypes to ROLE_ARCHETYPE_EDITOR (line 67); add ^/admin/staple-cards with the same role.
  • The technical re-enrich action (see §5 below) requires ROLE_TECHNICAL_ADMIN, matching the role boundary that gates re-enrich on banned cards and the archetype reenrich endpoint (AdminArchetypeController:383).

3. Ordering — per-bucket position with drag-and-drop

Banned Cards has no ordering (sorted by effectiveDate DESC). Staples must be reorderable within each bucket — seven independent sort domains.

Reuse the existing SortableJS pattern from archetype reorder:

  • JS helper: assets/shared/sortable-table.ts exposes initSortableTable(elementId, label).
  • Controller pattern: AdminArchetypeController::reorder() (lines 169-184) and ::reorderVariants() (lines 192-215) accept JSON body shaped {0: id1, 1: id2, ...} and write position in order.
  • Admin template attaches one <tbody id="sortable-staples-{bucket}" data-reorder-url="/admin/staple-cards/reorder/{bucket}"> per bucket, plus one initSortableTable('sortable-staples-{bucket}', 'staple') call from assets/admin-staple-card-list.ts.

Endpoint: POST /admin/staple-cards/reorder/{bucket} where bucket ∈ {pokemon, supporter, item, tool, stadium, energy, ace_spec}. Handler updates position only on rows whose bucket column matches the URL path and rejects payloads referencing rows in other buckets.

4. Hotness rating — bridge to #437

Add a hotness int column on StapleCard, named-constant default. Semantics:

  • ConstantApp\Constants\CardHotness::STAPLE_THRESHOLD = 5. The default value for new staples and the default minimum for the public list filter both reference this constant. Whatever scale feat(card): expanded card watchlist with editor relevance ratings #437 ends up choosing (1-10, 0-100, named tiers), this constant moves with it; no call site needs to be updated.
  • Default new valueCardHotness::STAPLE_THRESHOLD. Every newly-created staple sits right at the threshold. Editors can promote (above threshold) cards that anchor most decks, or demote (below threshold, which would hide them from the default public view) niche staples.
  • Filter on the public listStapleCardController::list() accepts an optional ?minHotness=N query param. Default behavior with no param: WHERE hotness >= CardHotness::STAPLE_THRESHOLD. Explicit ?minHotness=1 shows everything including demoted entries.
  • Admin form — number input bound to hotness, with help text referencing the threshold constant. A future PR can promote this to a slider with named tiers once feat(card): expanded card watchlist with editor relevance ratings #437 confirms the scale.
  • Bridge to feat(card): expanded card watchlist with editor relevance ratings #437 — the value already lives in the schema from day-one. Issue feat(card): expanded card watchlist with editor relevance ratings #437 can later promote it to a cross-table CardIdentity.hotness (or a join table for multi-channel ratings) without renaming or migrating; staples become "high-hotness curated cards" once the broader rating system lands. The scale itself (1-10 vs 0-5 vs named enum) is a feat(card): expanded card watchlist with editor relevance ratings #437 decision; PR-2 commits only to the named constant for the staple threshold.

5. Technical re-enrich button

Content imports (SQL fixtures, CSV imports, manual DB edits) can leave StapleCardPrinting.cardPrinting and StapleCard.cardIdentity unpopulated, since they bypass the editor flow. Banned cards solves this with a re-enrich action on the admin technical dashboard; mirror it for staples.

StapleCardEnricher::enrichAllActive(bool $force = false): array{0: int, 1: list<string>} — clone of BannedCardEnricher::enrichAllActive(). Walks every active StapleCardPrinting, calls enrichPrinting() on each, reparents by identity, and recomputes StapleCard.bucket from the (now-known) CardIdentity.ruleboxType / category / trainerType. Returns [linkedCount, unresolvedCardNames].

Admin technical dashboard:

  • New section in AdminTechnicalController (or wherever the banned-cards section lives — likely a sibling action).
  • Translation keys: app.admin.technical.staple_cards.{title, description, current_count, reenrich_button, success, partial_success}.
  • Route: POST /admin/technical/staple-cards/reenrich with CSRF token, #[IsGranted('ROLE_TECHNICAL_ADMIN')].
  • Action: dispatches StapleCardEnricher::enrichAllActive(force: true) synchronously, flashes a success message with the linked count and any unresolved names, redirects back to the dashboard.

Companion CLI command (consistent with BannedCardsEnrichCommand):

  • Class: App\Command\StapleCardsEnrichCommand with #[AsCommand(name: 'app:staple-cards:enrich')].
  • Flag: --force to re-link printings even when cardPrinting is already set.
  • Useful for fixture-loaded test environments and one-off ops runs.

Editor flow for adding a staple

  1. Editor opens /admin/staple-cards. The page shows seven tabs (one per bucket), each listing the staples already in that bucket — same active/history tab idiom as templates/admin/banned_card/list.html.twig.
  2. "New staple" button → form with:
    • Card code field (single text input — accepts LOR-093, LOR 093, LOR_093, etc., normalized server-side to setCode + cardNumber)
    • Hotness (number input, default CardHotness::STAPLE_THRESHOLD)
    • Note (rich-text editor via the rich_text_editor macro — same component the banned-card form uses)
  3. On submit, controller calls StapleCardEnricher::createFromCode(string $setCode, string $cardNumber, int $hotness, ?string $note): StapleCard synchronously (no Messenger dispatch — the round-trip to TCGdex is one or two HTTP calls, ~200-500ms, acceptable inline):
    1. TcgdexApiClient::findCard($setCode, $cardNumber) — same call BannedCardEnricher::enrichPrinting() makes.
    2. If null, fall back to findCardByNameInAliasedSet() (handles promo / Asian-set edge cases).
    3. CardIdentityResolver::resolveFromTcgdexCard($tcgdexCard) (src/Service/CardIdentity/CardIdentityResolver.php:43) — finds or creates CardIdentity (already populates ruleboxType per PR ✨ feat(card): add CardIdentity.ruleboxType + Ace Spec detection #533) and the canonical CardPrinting.
    4. CardIdentityResolver::expandPrintings($cardIdentity) (line 71) — fetches all sibling printings sharing the same identity from TCGdex and persists a CardPrinting row for each.
    5. Create one StapleCardPrinting per resolved printing, attached to the new StapleCard parent.
    6. Compute bucket via the priority rule from §1 (Ace Spec → pokemon → energy → trainer subtype) and store it on the parent.
    7. Append to the bucket: position = max(position) + 1 for the matching bucket.
  4. Editor lands back on the bucket's tab; the new row sits at the bottom and can be dragged into place.

This is the same "code → identity → siblings" enrichment that powers banned cards, just driven by a single user-supplied code instead of a sync source. No new TCGdex code paths are neededCardIdentityResolver is already the canonical entry point.

Public display — per-bucket CSS grids

Banned Cards public page (F6.14) renders a Bootstrap CSS grid of card images (no server-side mosaic image — see templates/banned_card/list.html.twig). Staples should match for consistency:

  • New route: GET /{_locale}/staple-cardsStapleCardController::list(), requirements _locale: en|fr, priority 10. Accepts optional ?minHotness=N query param; defaults to CardHotness::STAPLE_THRESHOLD.
  • One <section> per bucket, each rendering row row-cols-3 row-cols-sm-4 row-cols-md-6 row-cols-lg-9 g-3 with the bucket's card images.
  • Within each section, cards are ordered by position ASC.
  • Card images via StapleCardImageResolver (clone of BannedCardImageResolver — same fallback chain through TCGdex CDN, PokemonTCG.io, and representativePrinting override).
  • Click a card → Bootstrap modal showing all printings, the editor's note, and the editor-supplied explanation rendered via MarkdownRenderer.

The word "mosaic" in the original ask refers to this visual grid, not the server-generated mosaic image used for decks (docs/technicalities/mosaic.md). If a true composite image is wanted later, it can be added as a follow-up; the CSS grid is the right v1 to match Banned Cards.

Per-channel visibility

The public staples page is gated behind a new Channel.enableStaples boolean (mirroring Channel.enableBannedCards from migration Version20260502120000):

  • Migration adds enable_staples BOOLEAN NOT NULL DEFAULT FALSE to the channel table.
  • StapleCardController::list() returns 404 when the current channel has enableStaples = false.
  • ChannelFormType (src/Form/ChannelFormType.php) gains a checkbox bound to enableStaples; admin templates templates/admin/channel/{new,edit}.html.twig add a form_row(form.enableStaples) line alongside the existing enableBannedCards.
  • Public navbar entry to /staple-cards rendered conditionally on current_channel().enableStaples (same pattern banned-cards already uses at templates/base.html.twig:122 and :226).

Files to create / modify (PR-2)

New:

  • src/Constants/CardHotness.php (single class with at least STAPLE_THRESHOLD = 5; future feat(card): expanded card watchlist with editor relevance ratings #437 work will populate this)
  • src/Entity/StapleCard.php, src/Entity/StapleCardPrinting.php
  • src/Repository/StapleCardRepository.php, src/Repository/StapleCardPrintingRepository.php
  • src/Controller/StapleCardController.php (public)
  • src/Controller/AdminStapleCardController.php (admin CRUD + reorder)
  • src/Service/StapleCardEnricher.php, src/Service/StapleCardImageResolver.php
  • src/Form/StapleCardFormType.php
  • src/Command/StapleCardsEnrichCommand.php
  • Migrations:
    • staple_card (parent) + staple_card_printing (child) tables, with composite index on (bucket, position) and a separate index on (bucket, hotness)
    • channel.enable_staples BOOL NOT NULL DEFAULT FALSE
  • templates/staple_card/list.html.twig (public)
  • templates/admin/staple_card/list.html.twig, templates/admin/staple_card/form.html.twig
  • assets/admin-staple-card-list.ts (one initSortableTable call per bucket)
  • Webpack entry staple_card_list for the public modal JS, if needed
  • Tests covering: enricher (single-create + enrich-all + reparent-by-identity + bucket assignment with Ace Spec priority), image resolver, public list filter (minHotness), admin CRUD + reorder + technical reenrich (CSRF + role gates), CLI command

Modify:

  • config/packages/security.yaml — add ^/admin/staple-cards to the ROLE_ARCHETYPE_EDITOR access_control rule
  • src/Controller/AdminTechnicalController.php — new section + reenrich action
  • src/Entity/Channel.php + src/Form/ChannelFormType.php + templates/admin/channel/{new,edit}.html.twig — new enableStaples boolean
  • translations/messages.en.xlf and messages.fr.xlfapp.staple_card.*, app.admin.staple_card.*, app.admin.technical.staple_cards.*, app.channel.enable_staples keys (FR translation for "Item" bucket label is Objet)
  • templates/base.html.twig — public navbar entry to /staple-cards, conditional on current_channel().enableStaples
  • docs/features.md — add F6.15 entry under Card Data & Validation

Decisions confirmed during grooming

  • Naming: "staple" (entity, route, translation key, table). FR UI label for the Item bucket is "Objet".
  • Permission: ROLE_ARCHETYPE_EDITOR for CRUD/reorder; ROLE_TECHNICAL_ADMIN for the technical re-enrich button.
  • Special Energy: collapsed into a single "Energy" bucket. No isSpecialEnergy flag — curators are trusted not to promote basic energies.
  • Ace Spec is rarity-driven, not trainerType-driven (realized in PR #533): the TCGdex rarity field carries "ACE SPEC Rare" for these cards. Solution: CardIdentity.ruleboxType nullable string + App\Constants\RuleboxType constants (13 known types, only ACE_SPEC auto-detected today). Bucket-assignment priority where ace_spec wins over the type-based buckets.
  • Bucket as a stored column: StapleCard.bucket denormalized at enrichment time (rather than computing from (ruleboxType, category, trainerType) at query time) — simplifies the public list, reorder, and hotness-filter queries.
  • Public visibility: gated by a new per-channel Channel.enableStaples flag.
  • Enrichment: synchronous, inline in the admin controller (no Messenger).
  • Note editor: rich-text via the existing rich_text_editor macro (same as banned cards).
  • Soft-delete: keep the deletedAt + active/history tab pattern from banned cards.
  • Hotness scale: deferred to feat(card): expanded card watchlist with editor relevance ratings #437. PR-2 commits only to a named constant CardHotness::STAPLE_THRESHOLD = 5, used as both the default value for new staples and the default minimum on the public list filter. The exact scale (1-10 vs 0-5 vs named enum) is whatever feat(card): expanded card watchlist with editor relevance ratings #437 decides; this constant moves with it.
  • Technical re-enrich: button on the admin technical dashboard + companion CLI command, for recovering from content imports that bypassed the editor flow. The enricher also recomputes bucket for each row to handle Ace Spec reclassifications.
  • PR structure: PR-1 (foundational ruleboxType schema) shipped as its own stacked PR because it benefits multiple features; PR-2 (everything else) lands as one cohesive PR so the feature can be browser-validated end-to-end.

Open questions

None remaining. Two implementation-time verifications:

  • The exact casing of the TCGdex rarity string for Ace Spec is "ACE SPEC Rare" (verified during PR ✨ feat(card): add CardIdentity.ruleboxType + Ace Spec detection #533).
  • Now that CardIdentity.ruleboxType is canonical, audit other features that branch on category / trainerType (deck validation, archetype display, banned cards) to see if any should opportunistically start respecting the new field — likely follow-ups, not part of this PR.

Acceptance criteria

  • An editor with ROLE_ARCHETYPE_EDITOR can navigate to /admin/staple-cards, see seven buckets, and add a card by entering its set code + number, hotness, and (optional) Markdown note.
  • On add, the system populates cardName, bucket (via priority rule with Ace Spec winning when cardIdentity.ruleboxType === RuleboxType::ACE_SPEC), and creates one StapleCardPrinting per sibling printing returned by CardIdentityResolver::expandPrintings.
  • An Ace Spec Item card lands in the ace_spec bucket, not the item bucket — verified via integration test using a known Ace Spec card (e.g. Unfair Stamp, which is already flagged via PR ✨ feat(card): add CardIdentity.ruleboxType + Ace Spec detection #533's backfill).
  • Newly-created staples default to CardHotness::STAPLE_THRESHOLD when the form leaves the field at its placeholder; admin form lets editors override.
  • Editor can drag-and-drop within a bucket to reorder; refresh preserves the order. Reorder respects bucket boundaries (cannot drag a Pokémon staple into the Stadium bucket).
  • Public page at /{_locale}/staple-cards renders seven CSS grids in editor-ordered sequence with images served via StapleCardImageResolver. Default filter is hotness >= CardHotness::STAPLE_THRESHOLD. The page returns 404 when current_channel().enableStaples is false.
  • ?minHotness=1 on the public page shows demoted entries below the threshold.
  • Clicking a public-grid card opens a Bootstrap modal listing all printings and rendering the editor's Markdown note via MarkdownRenderer.
  • A user with ROLE_TECHNICAL_ADMIN can click the re-enrich button on the admin technical dashboard; the action runs StapleCardEnricher::enrichAllActive(force: true) synchronously, recomputes bucket assignments, and flashes the linked count + any unresolved card names.
  • CLI: symfony console app:staple-cards:enrich --force produces equivalent output to the dashboard button and exits with status 0 on success.
  • Soft-deleted staples disappear from the public page and the admin "active" tab; appear in the admin "history" tab; can be restored.
  • All translation keys defined in messages.en.xlf and messages.fr.xlf (with FR "Objet" for the Item bucket label); CI make lint-i18n is green.
  • PHPStan level 10, PHPUnit, ESLint, twig-cs-fixer, and stylelint all green.

Feature reference

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions