You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
Pokémon
Trainer — Supporter
Trainer — Item (FR translation: Objet)
Trainer — Tool
Trainer — Stadium
Energy (curators are trusted not to promote basic energies; no data-level constraint)
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".
✅ 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):
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):
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.yamlaccess_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:
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.
Add a hotness int column on StapleCard, named-constant default. Semantics:
Constant — App\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 value — CardHotness::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 list — StapleCardController::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.
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).
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
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.
"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)
Note (rich-text editor via the rich_text_editor macro — same component the banned-card form uses)
On submit, controller calls StapleCardEnricher::createFromCode(string $setCode, string $cardNumber, int $hotness, ?string $note): StapleCardsynchronously (no Messenger dispatch — the round-trip to TCGdex is one or two HTTP calls, ~200-500ms, acceptable inline):
TcgdexApiClient::findCard($setCode, $cardNumber) — same call BannedCardEnricher::enrichPrinting() makes.
If null, fall back to findCardByNameInAliasedSet() (handles promo / Asian-set edge cases).
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.
CardIdentityResolver::expandPrintings($cardIdentity) (line 71) — fetches all sibling printings sharing the same identity from TCGdex and persists a CardPrinting row for each.
Create one StapleCardPrinting per resolved printing, attached to the new StapleCard parent.
Compute bucket via the priority rule from §1 (Ace Spec → pokemon → energy → trainer subtype) and store it on the parent.
Append to the bucket: position = max(position) + 1 for the matching bucket.
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 needed — CardIdentityResolver 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-cards → StapleCardController::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).
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.xlf — app.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.
✅ 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:
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.
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
New: F6.15 — Staple cards (editor-curated, per-bucket mosaics with hotness) in docs/features.md
Pattern source: F6.14 — Banned cards public page; F6.5 — Banned card list management
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:
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
hotnessfield added here (see §4) is the same axis #437 will eventually expose across all cards.Status
CardIdentity.ruleboxTypeschema column +App\Constants\RuleboxType(13 known rulebox mechanics) + Ace Spec auto-detection inCardIdentityResolver+ backfill of existing Ace Spec identities.Channel.enableStaplesflag + 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.phpandsrc/Entity/BannedCardPrinting.php:App\Entity\StapleCard(parent — one row perCardIdentity):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 tobucket)hotness(int, defaultCardHotness::STAPLE_THRESHOLD) — see §4representativePrinting(ManyToOne →CardPrinting, nullable) — optional override for the public imagenote(LONGTEXT, nullable) — Markdown explanation, edited via the rich-text editor (samerich_text_editormacro the banned-card form uses)deletedAt(nullable, soft-delete)createdAtApp\Entity\StapleCardPrinting(child — one row per(setCode, cardNumber)pair):id,stapleCard(ManyToOne,onDelete=CASCADE,inversedBy='printings')setCode,cardNumber(unique together)cardPrinting(ManyToOne →CardPrinting, nullable)createdAtCardIdentity.ruleboxTypealready exists from PR #533 — nullable string column populated byCardIdentityResolverfromTcgdexCard.rarity. Currently onlyRuleboxType::ACE_SPECis 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. Seesrc/Constants/RuleboxType.php.Bucket assignment priority (computed at staple enrichment, stored as
StapleCard.bucket):A given card lands in exactly one bucket. Ace Spec wins over the type-based buckets — an Ace Spec Item lives in
ace_spec, notitem. This is deliberate: editors curating Ace Specs want them gathered, not scattered across four type buckets.Reference files to clone the pattern from:
src/Entity/BannedCard.phpsrc/Entity/StapleCard.phpsrc/Entity/BannedCardPrinting.phpsrc/Entity/StapleCardPrinting.phpsrc/Repository/BannedCardRepository.phpsrc/Repository/StapleCardRepository.phpsrc/Repository/BannedCardPrintingRepository.phpsrc/Repository/StapleCardPrintingRepository.phpsrc/Service/BannedCardEnricher.phpsrc/Service/StapleCardEnricher.phpsrc/Service/BannedCardImageResolver.phpsrc/Service/StapleCardImageResolver.phpsrc/Form/BannedCardFormType.phpsrc/Form/StapleCardFormType.phpsrc/Controller/BannedCardController.phpsrc/Controller/StapleCardController.phpsrc/Controller/AdminBannedCardController.phpsrc/Controller/AdminStapleCardController.phptemplates/banned_card/list.html.twigtemplates/staple_card/list.html.twigtemplates/admin/banned_card/list.html.twigtemplates/admin/staple_card/list.html.twigtemplates/admin/banned_card/form.html.twigtemplates/admin/staple_card/form.html.twig2. 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:AdminStapleCardControllercarries#[IsGranted('ROLE_ARCHETYPE_EDITOR')](matches the class-level attribute onAdminArchetypeControllerat line 50).config/packages/security.yamlaccess_controlalready grants^/admin/archetypestoROLE_ARCHETYPE_EDITOR(line 67); add^/admin/staple-cardswith the same role.ROLE_TECHNICAL_ADMIN, matching the role boundary that gates re-enrich on banned cards and the archetypereenrichendpoint (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:
assets/shared/sortable-table.tsexposesinitSortableTable(elementId, label).AdminArchetypeController::reorder()(lines 169-184) and::reorderVariants()(lines 192-215) accept JSON body shaped{0: id1, 1: id2, ...}and writepositionin order.<tbody id="sortable-staples-{bucket}" data-reorder-url="/admin/staple-cards/reorder/{bucket}">per bucket, plus oneinitSortableTable('sortable-staples-{bucket}', 'staple')call fromassets/admin-staple-card-list.ts.Endpoint:
POST /admin/staple-cards/reorder/{bucket}wherebucket ∈ {pokemon, supporter, item, tool, stadium, energy, ace_spec}. Handler updatespositiononly on rows whosebucketcolumn matches the URL path and rejects payloads referencing rows in other buckets.4. Hotness rating — bridge to #437
Add a
hotnessint column onStapleCard, named-constant default. Semantics:App\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.CardHotness::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.StapleCardController::list()accepts an optional?minHotness=Nquery param. Default behavior with no param:WHERE hotness >= CardHotness::STAPLE_THRESHOLD. Explicit?minHotness=1shows everything including demoted entries.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.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.cardPrintingandStapleCard.cardIdentityunpopulated, 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 ofBannedCardEnricher::enrichAllActive(). Walks every activeStapleCardPrinting, callsenrichPrinting()on each, reparents by identity, and recomputesStapleCard.bucketfrom the (now-known)CardIdentity.ruleboxType / category / trainerType. Returns[linkedCount, unresolvedCardNames].Admin technical dashboard:
AdminTechnicalController(or wherever the banned-cards section lives — likely a sibling action).app.admin.technical.staple_cards.{title, description, current_count, reenrich_button, success, partial_success}.POST /admin/technical/staple-cards/reenrichwith CSRF token,#[IsGranted('ROLE_TECHNICAL_ADMIN')].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):App\Command\StapleCardsEnrichCommandwith#[AsCommand(name: 'app:staple-cards:enrich')].--forceto re-link printings even whencardPrintingis already set.Editor flow for adding a staple
/admin/staple-cards. The page shows seven tabs (one per bucket), each listing the staples already in that bucket — same active/history tab idiom astemplates/admin/banned_card/list.html.twig.LOR-093,LOR 093,LOR_093, etc., normalized server-side tosetCode+cardNumber)CardHotness::STAPLE_THRESHOLD)rich_text_editormacro — same component the banned-card form uses)StapleCardEnricher::createFromCode(string $setCode, string $cardNumber, int $hotness, ?string $note): StapleCardsynchronously (no Messenger dispatch — the round-trip to TCGdex is one or two HTTP calls, ~200-500ms, acceptable inline):TcgdexApiClient::findCard($setCode, $cardNumber)— same callBannedCardEnricher::enrichPrinting()makes.findCardByNameInAliasedSet()(handles promo / Asian-set edge cases).CardIdentityResolver::resolveFromTcgdexCard($tcgdexCard)(src/Service/CardIdentity/CardIdentityResolver.php:43) — finds or createsCardIdentity(already populatesruleboxTypeper PR ✨ feat(card): add CardIdentity.ruleboxType + Ace Spec detection #533) and the canonicalCardPrinting.CardIdentityResolver::expandPrintings($cardIdentity)(line 71) — fetches all sibling printings sharing the same identity from TCGdex and persists aCardPrintingrow for each.StapleCardPrintingper resolved printing, attached to the newStapleCardparent.bucketvia the priority rule from §1 (Ace Spec → pokemon → energy → trainer subtype) and store it on the parent.position = max(position) + 1for the matching bucket.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 needed —
CardIdentityResolveris 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:GET /{_locale}/staple-cards→StapleCardController::list(), requirements_locale: en|fr, priority 10. Accepts optional?minHotness=Nquery param; defaults toCardHotness::STAPLE_THRESHOLD.<section>per bucket, each renderingrow row-cols-3 row-cols-sm-4 row-cols-md-6 row-cols-lg-9 g-3with the bucket's card images.position ASC.StapleCardImageResolver(clone ofBannedCardImageResolver— same fallback chain through TCGdex CDN, PokemonTCG.io, andrepresentativePrintingoverride).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.enableStaplesboolean (mirroringChannel.enableBannedCardsfrom migrationVersion20260502120000):enable_staplesBOOLEAN NOT NULL DEFAULT FALSE to thechanneltable.StapleCardController::list()returns 404 when the current channel hasenableStaples = false.ChannelFormType(src/Form/ChannelFormType.php) gains a checkbox bound toenableStaples; admin templatestemplates/admin/channel/{new,edit}.html.twigadd aform_row(form.enableStaples)line alongside the existingenableBannedCards./staple-cardsrendered conditionally oncurrent_channel().enableStaples(same pattern banned-cards already uses attemplates/base.html.twig:122and:226).Files to create / modify (PR-2)
New:
src/Constants/CardHotness.php(single class with at leastSTAPLE_THRESHOLD = 5; future feat(card): expanded card watchlist with editor relevance ratings #437 work will populate this)src/Entity/StapleCard.php,src/Entity/StapleCardPrinting.phpsrc/Repository/StapleCardRepository.php,src/Repository/StapleCardPrintingRepository.phpsrc/Controller/StapleCardController.php(public)src/Controller/AdminStapleCardController.php(admin CRUD + reorder)src/Service/StapleCardEnricher.php,src/Service/StapleCardImageResolver.phpsrc/Form/StapleCardFormType.phpsrc/Command/StapleCardsEnrichCommand.phpstaple_card(parent) +staple_card_printing(child) tables, with composite index on(bucket, position)and a separate index on(bucket, hotness)channel.enable_staplesBOOL NOT NULL DEFAULT FALSEtemplates/staple_card/list.html.twig(public)templates/admin/staple_card/list.html.twig,templates/admin/staple_card/form.html.twigassets/admin-staple-card-list.ts(oneinitSortableTablecall per bucket)staple_card_listfor the public modal JS, if neededminHotness), admin CRUD + reorder + technical reenrich (CSRF + role gates), CLI commandModify:
config/packages/security.yaml— add^/admin/staple-cardsto theROLE_ARCHETYPE_EDITORaccess_control rulesrc/Controller/AdminTechnicalController.php— new section + reenrich actionsrc/Entity/Channel.php+src/Form/ChannelFormType.php+templates/admin/channel/{new,edit}.html.twig— newenableStaplesbooleantranslations/messages.en.xlfandmessages.fr.xlf—app.staple_card.*,app.admin.staple_card.*,app.admin.technical.staple_cards.*,app.channel.enable_stapleskeys (FR translation for "Item" bucket label is Objet)templates/base.html.twig— public navbar entry to/staple-cards, conditional oncurrent_channel().enableStaplesdocs/features.md— add F6.15 entry under Card Data & ValidationDecisions confirmed during grooming
ROLE_ARCHETYPE_EDITORfor CRUD/reorder;ROLE_TECHNICAL_ADMINfor the technical re-enrich button.isSpecialEnergyflag — curators are trusted not to promote basic energies.rarityfield carries"ACE SPEC Rare"for these cards. Solution:CardIdentity.ruleboxTypenullable string +App\Constants\RuleboxTypeconstants (13 known types, onlyACE_SPECauto-detected today). Bucket-assignment priority whereace_specwins over the type-based buckets.StapleCard.bucketdenormalized at enrichment time (rather than computing from(ruleboxType, category, trainerType)at query time) — simplifies the public list, reorder, and hotness-filter queries.Channel.enableStaplesflag.rich_text_editormacro (same as banned cards).deletedAt+ active/history tab pattern from banned cards.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.bucketfor each row to handle Ace Spec reclassifications.ruleboxTypeschema) 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:
"ACE SPEC Rare"(verified during PR ✨ feat(card): add CardIdentity.ruleboxType + Ace Spec detection #533).CardIdentity.ruleboxTypeis canonical, audit other features that branch oncategory/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
ROLE_ARCHETYPE_EDITORcan navigate to/admin/staple-cards, see seven buckets, and add a card by entering its set code + number, hotness, and (optional) Markdown note.cardName,bucket(via priority rule with Ace Spec winning whencardIdentity.ruleboxType === RuleboxType::ACE_SPEC), and creates oneStapleCardPrintingper sibling printing returned byCardIdentityResolver::expandPrintings.ace_specbucket, not theitembucket — 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).CardHotness::STAPLE_THRESHOLDwhen the form leaves the field at its placeholder; admin form lets editors override./{_locale}/staple-cardsrenders seven CSS grids in editor-ordered sequence with images served viaStapleCardImageResolver. Default filter ishotness >= CardHotness::STAPLE_THRESHOLD. The page returns 404 whencurrent_channel().enableStaplesis false.?minHotness=1on the public page shows demoted entries below the threshold.MarkdownRenderer.ROLE_TECHNICAL_ADMINcan click the re-enrich button on the admin technical dashboard; the action runsStapleCardEnricher::enrichAllActive(force: true)synchronously, recomputes bucket assignments, and flashes the linked count + any unresolved card names.symfony console app:staple-cards:enrich --forceproduces equivalent output to the dashboard button and exits with status 0 on success.messages.en.xlfandmessages.fr.xlf(with FR "Objet" for the Item bucket label); CImake lint-i18nis green.Feature reference
docs/features.mdruleboxTypein PR ✨ feat(card): add CardIdentity.ruleboxType + Ace Spec detection #533), F2.x — Archetype managementhotnessfield andCardHotness::STAPLE_THRESHOLDconstant are the seed for feat(card): expanded card watchlist with editor relevance ratings #437's cross-card rating axis)