diff --git a/.claude/launch.json b/.claude/launch.json index 772d3ad9..9d93db14 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,12 @@ "runtimeExecutable": "cmd.exe", "runtimeArgs": ["/c", "pnpm", "dev"], "port": 5173 + }, + { + "name": "storybook", + "runtimeExecutable": "cmd.exe", + "runtimeArgs": ["/c", "pnpm", "--filter", "inkweave-web", "storybook"], + "port": 6006 } ] } diff --git a/.gitignore b/.gitignore index 6ee40837..9be6dd5a 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,8 @@ docs/reviews/ # Pre-code HTML/CSS design mockups — local-only design work, never committed apps/web/public/mockups/ +# Design handoff reference (Claude Design exports) — local-only, never committed +apps/web/reveals page redesign/ # Tool / plugin local state — managed by their installers, not tracked .agents/ diff --git a/apps/web/e2e/E2E_TESTS.md b/apps/web/e2e/E2E_TESTS.md index d5158629..1a47b54b 100644 --- a/apps/web/e2e/E2E_TESTS.md +++ b/apps/web/e2e/E2E_TESTS.md @@ -192,15 +192,16 @@ Regression guard for issue #268 (skeleton-loading UI). Each test intercepts `/da | VotePage renders pair + score picker skeleton while queue loads | CompactHeader visible; `[aria-label="Loading vote pair"]` visible | | InDepthVotePage renders pair + form skeleton while pair data loads | CompactHeader visible; `[aria-label="Loading vote pair and form"]` visible | -## `reveals-page.spec.ts` — 5 tests (4 desktop, 1 mobile) +## `reveals-page.spec.ts` — 6 tests (5 desktop, 1 mobile) | Test | What it verifies | |---|---| -| renders hero and franchise tiers at /reveals | All 4 tier headings (Monsters, Inc., Up, Turning Red, Returning) render | +| renders the tracker: hero, six ink trackers, and franchise cards | sr-only `h1`, the "Attack of the Vine!" logo, 6 `ink-tracker-tile`s, the "Ink board" section, and the 3 "View … cards" franchise buttons all render | | desktop nav shows Reveals entry with NEW badge | `/` has a Reveals link with a "NEW" badge child | | mobile nav shows Reveals tab | On mobile, `/browse`'s bottom nav has a "Set 13 reveals" link | | promo modal appears on landing page and not on /reveals | `role="complementary" name=/Set 13 reveals/` visible on `/`, absent on `/reveals` | -| tier card click opens the card overview modal | Clicking a card tile on `/reveals` opens the modal; URL stays `/reveals` | +| mosaic card click opens the card overview modal | Clicking a `reveal-card-slot` on `/reveals` opens the modal; URL stays `/reveals` | +| franchise card click opens the franchise cards modal | Clicking "View Monsters, Inc. cards" opens the `dialog`; a card-tile inside opens the overview modal on top | ## Patterns diff --git a/apps/web/e2e/tests/reveals-page.spec.ts b/apps/web/e2e/tests/reveals-page.spec.ts index 6b019acf..3c9a570c 100644 --- a/apps/web/e2e/tests/reveals-page.spec.ts +++ b/apps/web/e2e/tests/reveals-page.spec.ts @@ -35,15 +35,25 @@ test.describe('Reveals page (flag on)', () => { } }); - test('renders hero and franchise tiers at /reveals', async ({page}, testInfo) => { + test('renders the tracker: hero, six ink trackers, and franchise cards', async ({page}, testInfo) => { if (testInfo.project.name.startsWith('mobile-')) test.skip(); await page.goto('/reveals'); - await expect(page.getByRole('heading', {name: 'Monsters, Inc.', exact: true})).toBeVisible(); - await expect(page.getByRole('heading', {name: 'Up', exact: true})).toBeVisible(); - await expect(page.getByRole('heading', {name: 'Turning Red', exact: true})).toBeVisible(); - await expect(page.getByRole('heading', {name: /Returning franchises/i})).toBeVisible(); + // Page identity (the visible title is the set logo image; the h1 is sr-only). + await expect(page.getByRole('heading', {level: 1, name: /Attack of the Vine/i})).toHaveCount(1); + await expect(page.getByAltText('Attack of the Vine!')).toBeVisible(); + + // Six ink tracker tiles (one per ink). + await expect(page.getByTestId('ink-tracker-tile')).toHaveCount(6); + + // The featured ink board. + await expect(page.getByText('Ink board', {exact: true})).toBeVisible(); + + // The three new-franchise cards (open their cards modal on click). + await expect(page.getByRole('button', {name: 'View Monsters, Inc. cards'})).toBeVisible(); + await expect(page.getByRole('button', {name: 'View Up cards'})).toBeVisible(); + await expect(page.getByRole('button', {name: 'View Turning Red cards'})).toBeVisible(); }); test('desktop nav shows Reveals entry with NEW badge', async ({page}, testInfo) => { @@ -82,21 +92,48 @@ test.describe('Reveals page (flag on)', () => { ).toHaveCount(0); }); - test('tier card click opens the card overview modal', async ({page}, testInfo) => { + test('mosaic card click opens the card overview modal', async ({page}, testInfo) => { if (testInfo.project.name.startsWith('mobile-')) test.skip(); await page.goto('/reveals'); - // Early reveal season: previewCards.json may still have cards: [] (no tiles to - // click). Skip until at least one Set 13 card is curated, mirroring the - // season-ended skip in beforeEach so the suite stays green across the lifecycle. - const tileCount = await page.getByTestId('card-tile').count(); - test.skip(tileCount === 0, 'No reveal cards curated yet (previewCards.json cards: []).'); - const firstTile = page.getByTestId('card-tile').first(); - await expect(firstTile).toBeVisible(); - await firstTile.click(); + // The mosaic renders after the card data loads (async), so wait for the first + // revealed slot rather than snapshotting the count. Early reveal season: + // previewCards.json may still have cards: [] (no slots) — skip then, mirroring + // the season-ended skip in beforeEach so the suite stays green across the lifecycle. + const firstSlot = page.getByTestId('reveal-card-slot').first(); + const hasSlots = await firstSlot + .waitFor({state: 'visible', timeout: 10000}) + .then(() => true) + .catch(() => false); + test.skip(!hasSlots, 'No reveal cards curated yet (previewCards.json cards: []).'); + await firstSlot.click(); // Card click opens the global modal (URL stays at /reveals). // Modal open = React mount + per-card synergy fetch + visibility transition; slow CI // webkit can exceed 5s before the overlay-visible class settles. await expect(page.getByTestId('card-overview-modal')).toBeVisible({timeout: 15000}); }); + + test('franchise card click opens the franchise cards modal', async ({page}, testInfo) => { + if (testInfo.project.name.startsWith('mobile-')) test.skip(); + + await page.goto('/reveals'); + await page.getByRole('button', {name: 'View Monsters, Inc. cards'}).click(); + + const modal = page.getByRole('dialog', {name: 'Monsters, Inc. cards'}); + await expect(modal).toBeVisible({timeout: 15000}); + + // A card inside the franchise modal opens the shared card overview modal on top. + // The grid renders a tick after the dialog opens, so wait for the first tile + // rather than snapshotting the count. If the franchise has no revealed cards + // yet (early season), the grid stays empty and we skip the card-click assertion. + const cardTiles = modal.getByTestId('card-tile'); + const hasCards = await cardTiles + .first() + .waitFor({state: 'visible', timeout: 5000}) + .then(() => true) + .catch(() => false); + test.skip(!hasCards, 'No cards revealed for this franchise yet.'); + await cardTiles.first().click(); + await expect(page.getByTestId('card-overview-modal')).toBeVisible({timeout: 15000}); + }); }); diff --git a/apps/web/public/card-images-preview/13001-sm.avif b/apps/web/public/card-images-preview/13001-sm.avif new file mode 100644 index 00000000..b0965907 Binary files /dev/null and b/apps/web/public/card-images-preview/13001-sm.avif differ diff --git a/apps/web/public/card-images-preview/13001.avif b/apps/web/public/card-images-preview/13001.avif new file mode 100644 index 00000000..3242b2b3 Binary files /dev/null and b/apps/web/public/card-images-preview/13001.avif differ diff --git a/apps/web/public/card-images-preview/13003-sm.avif b/apps/web/public/card-images-preview/13003-sm.avif new file mode 100644 index 00000000..764731e4 Binary files /dev/null and b/apps/web/public/card-images-preview/13003-sm.avif differ diff --git a/apps/web/public/card-images-preview/13003.avif b/apps/web/public/card-images-preview/13003.avif new file mode 100644 index 00000000..9e96e8f1 Binary files /dev/null and b/apps/web/public/card-images-preview/13003.avif differ diff --git a/apps/web/public/card-images-preview/13032-sm.avif b/apps/web/public/card-images-preview/13032-sm.avif new file mode 100644 index 00000000..3f39dd87 Binary files /dev/null and b/apps/web/public/card-images-preview/13032-sm.avif differ diff --git a/apps/web/public/card-images-preview/13032.avif b/apps/web/public/card-images-preview/13032.avif new file mode 100644 index 00000000..4f7aeebd Binary files /dev/null and b/apps/web/public/card-images-preview/13032.avif differ diff --git a/apps/web/public/card-images-preview/13033-sm.avif b/apps/web/public/card-images-preview/13033-sm.avif index 45eac018..85b804c1 100644 Binary files a/apps/web/public/card-images-preview/13033-sm.avif and b/apps/web/public/card-images-preview/13033-sm.avif differ diff --git a/apps/web/public/card-images-preview/13033.avif b/apps/web/public/card-images-preview/13033.avif index 2c1c8401..9e701366 100644 Binary files a/apps/web/public/card-images-preview/13033.avif and b/apps/web/public/card-images-preview/13033.avif differ diff --git a/apps/web/public/card-images-preview/13035-sm.avif b/apps/web/public/card-images-preview/13035-sm.avif new file mode 100644 index 00000000..9e1b769a Binary files /dev/null and b/apps/web/public/card-images-preview/13035-sm.avif differ diff --git a/apps/web/public/card-images-preview/13035.avif b/apps/web/public/card-images-preview/13035.avif new file mode 100644 index 00000000..accd34f6 Binary files /dev/null and b/apps/web/public/card-images-preview/13035.avif differ diff --git a/apps/web/public/card-images-preview/13036-sm.avif b/apps/web/public/card-images-preview/13036-sm.avif new file mode 100644 index 00000000..8dbec115 Binary files /dev/null and b/apps/web/public/card-images-preview/13036-sm.avif differ diff --git a/apps/web/public/card-images-preview/13036.avif b/apps/web/public/card-images-preview/13036.avif new file mode 100644 index 00000000..959ac8c7 Binary files /dev/null and b/apps/web/public/card-images-preview/13036.avif differ diff --git a/apps/web/public/card-images-preview/13037-sm.avif b/apps/web/public/card-images-preview/13037-sm.avif index 1435b99f..62aaaf8a 100644 Binary files a/apps/web/public/card-images-preview/13037-sm.avif and b/apps/web/public/card-images-preview/13037-sm.avif differ diff --git a/apps/web/public/card-images-preview/13037.avif b/apps/web/public/card-images-preview/13037.avif index 8c32da9f..d2db65d9 100644 Binary files a/apps/web/public/card-images-preview/13037.avif and b/apps/web/public/card-images-preview/13037.avif differ diff --git a/apps/web/public/card-images-preview/13039-sm.avif b/apps/web/public/card-images-preview/13039-sm.avif new file mode 100644 index 00000000..c6d31e4e Binary files /dev/null and b/apps/web/public/card-images-preview/13039-sm.avif differ diff --git a/apps/web/public/card-images-preview/13039.avif b/apps/web/public/card-images-preview/13039.avif new file mode 100644 index 00000000..8d0ecf79 Binary files /dev/null and b/apps/web/public/card-images-preview/13039.avif differ diff --git a/apps/web/public/card-images-preview/13041-sm.avif b/apps/web/public/card-images-preview/13041-sm.avif new file mode 100644 index 00000000..91083010 Binary files /dev/null and b/apps/web/public/card-images-preview/13041-sm.avif differ diff --git a/apps/web/public/card-images-preview/13041.avif b/apps/web/public/card-images-preview/13041.avif new file mode 100644 index 00000000..919377ec Binary files /dev/null and b/apps/web/public/card-images-preview/13041.avif differ diff --git a/apps/web/public/card-images-preview/13043-sm.avif b/apps/web/public/card-images-preview/13043-sm.avif new file mode 100644 index 00000000..10733b13 Binary files /dev/null and b/apps/web/public/card-images-preview/13043-sm.avif differ diff --git a/apps/web/public/card-images-preview/13043.avif b/apps/web/public/card-images-preview/13043.avif new file mode 100644 index 00000000..9c1a25bc Binary files /dev/null and b/apps/web/public/card-images-preview/13043.avif differ diff --git a/apps/web/public/card-images-preview/13048-sm.avif b/apps/web/public/card-images-preview/13048-sm.avif new file mode 100644 index 00000000..6ea9845c Binary files /dev/null and b/apps/web/public/card-images-preview/13048-sm.avif differ diff --git a/apps/web/public/card-images-preview/13048.avif b/apps/web/public/card-images-preview/13048.avif new file mode 100644 index 00000000..19b7090e Binary files /dev/null and b/apps/web/public/card-images-preview/13048.avif differ diff --git a/apps/web/public/card-images-preview/13050-sm.avif b/apps/web/public/card-images-preview/13050-sm.avif new file mode 100644 index 00000000..4aaadd3b Binary files /dev/null and b/apps/web/public/card-images-preview/13050-sm.avif differ diff --git a/apps/web/public/card-images-preview/13050.avif b/apps/web/public/card-images-preview/13050.avif new file mode 100644 index 00000000..ba4dfdf1 Binary files /dev/null and b/apps/web/public/card-images-preview/13050.avif differ diff --git a/apps/web/public/card-images-preview/13057-sm.avif b/apps/web/public/card-images-preview/13057-sm.avif new file mode 100644 index 00000000..c3ae380b Binary files /dev/null and b/apps/web/public/card-images-preview/13057-sm.avif differ diff --git a/apps/web/public/card-images-preview/13057.avif b/apps/web/public/card-images-preview/13057.avif new file mode 100644 index 00000000..91b6f43d Binary files /dev/null and b/apps/web/public/card-images-preview/13057.avif differ diff --git a/apps/web/public/card-images-preview/13058-sm.avif b/apps/web/public/card-images-preview/13058-sm.avif index c52ff11d..2be2a9b8 100644 Binary files a/apps/web/public/card-images-preview/13058-sm.avif and b/apps/web/public/card-images-preview/13058-sm.avif differ diff --git a/apps/web/public/card-images-preview/13058.avif b/apps/web/public/card-images-preview/13058.avif index 387aa520..9e9d8fdc 100644 Binary files a/apps/web/public/card-images-preview/13058.avif and b/apps/web/public/card-images-preview/13058.avif differ diff --git a/apps/web/public/card-images-preview/13064-sm.avif b/apps/web/public/card-images-preview/13064-sm.avif new file mode 100644 index 00000000..1ebb2e8a Binary files /dev/null and b/apps/web/public/card-images-preview/13064-sm.avif differ diff --git a/apps/web/public/card-images-preview/13064.avif b/apps/web/public/card-images-preview/13064.avif new file mode 100644 index 00000000..cd9971c1 Binary files /dev/null and b/apps/web/public/card-images-preview/13064.avif differ diff --git a/apps/web/public/card-images-preview/13065-sm.avif b/apps/web/public/card-images-preview/13065-sm.avif index 7ea08bdb..d77ce758 100644 Binary files a/apps/web/public/card-images-preview/13065-sm.avif and b/apps/web/public/card-images-preview/13065-sm.avif differ diff --git a/apps/web/public/card-images-preview/13065.avif b/apps/web/public/card-images-preview/13065.avif index 64b19ebf..5c382b66 100644 Binary files a/apps/web/public/card-images-preview/13065.avif and b/apps/web/public/card-images-preview/13065.avif differ diff --git a/apps/web/public/card-images-preview/13066-sm.avif b/apps/web/public/card-images-preview/13066-sm.avif new file mode 100644 index 00000000..d08941b6 Binary files /dev/null and b/apps/web/public/card-images-preview/13066-sm.avif differ diff --git a/apps/web/public/card-images-preview/13066.avif b/apps/web/public/card-images-preview/13066.avif new file mode 100644 index 00000000..ef4cf9d6 Binary files /dev/null and b/apps/web/public/card-images-preview/13066.avif differ diff --git a/apps/web/public/card-images-preview/13071-sm.avif b/apps/web/public/card-images-preview/13071-sm.avif new file mode 100644 index 00000000..730be23f Binary files /dev/null and b/apps/web/public/card-images-preview/13071-sm.avif differ diff --git a/apps/web/public/card-images-preview/13071.avif b/apps/web/public/card-images-preview/13071.avif new file mode 100644 index 00000000..45f38a82 Binary files /dev/null and b/apps/web/public/card-images-preview/13071.avif differ diff --git a/apps/web/public/card-images-preview/13084-sm.avif b/apps/web/public/card-images-preview/13084-sm.avif new file mode 100644 index 00000000..3f4a99c3 Binary files /dev/null and b/apps/web/public/card-images-preview/13084-sm.avif differ diff --git a/apps/web/public/card-images-preview/13084.avif b/apps/web/public/card-images-preview/13084.avif new file mode 100644 index 00000000..e83bc069 Binary files /dev/null and b/apps/web/public/card-images-preview/13084.avif differ diff --git a/apps/web/public/card-images-preview/13088-sm.avif b/apps/web/public/card-images-preview/13088-sm.avif index 102d4f58..52d34111 100644 Binary files a/apps/web/public/card-images-preview/13088-sm.avif and b/apps/web/public/card-images-preview/13088-sm.avif differ diff --git a/apps/web/public/card-images-preview/13088.avif b/apps/web/public/card-images-preview/13088.avif index 4ef9ceaa..f622cbe0 100644 Binary files a/apps/web/public/card-images-preview/13088.avif and b/apps/web/public/card-images-preview/13088.avif differ diff --git a/apps/web/public/card-images-preview/13092-sm.avif b/apps/web/public/card-images-preview/13092-sm.avif new file mode 100644 index 00000000..213feeb0 Binary files /dev/null and b/apps/web/public/card-images-preview/13092-sm.avif differ diff --git a/apps/web/public/card-images-preview/13092.avif b/apps/web/public/card-images-preview/13092.avif new file mode 100644 index 00000000..aa6aaa3d Binary files /dev/null and b/apps/web/public/card-images-preview/13092.avif differ diff --git a/apps/web/public/card-images-preview/13103-sm.avif b/apps/web/public/card-images-preview/13103-sm.avif index ed17cbfc..7f9a2f50 100644 Binary files a/apps/web/public/card-images-preview/13103-sm.avif and b/apps/web/public/card-images-preview/13103-sm.avif differ diff --git a/apps/web/public/card-images-preview/13103.avif b/apps/web/public/card-images-preview/13103.avif index bbabc9f0..2f1c2211 100644 Binary files a/apps/web/public/card-images-preview/13103.avif and b/apps/web/public/card-images-preview/13103.avif differ diff --git a/apps/web/public/card-images-preview/13106-sm.avif b/apps/web/public/card-images-preview/13106-sm.avif new file mode 100644 index 00000000..a6a15f91 Binary files /dev/null and b/apps/web/public/card-images-preview/13106-sm.avif differ diff --git a/apps/web/public/card-images-preview/13106.avif b/apps/web/public/card-images-preview/13106.avif new file mode 100644 index 00000000..1909abe2 Binary files /dev/null and b/apps/web/public/card-images-preview/13106.avif differ diff --git a/apps/web/public/card-images-preview/13108-sm.avif b/apps/web/public/card-images-preview/13108-sm.avif index 238705aa..e6c6c9b7 100644 Binary files a/apps/web/public/card-images-preview/13108-sm.avif and b/apps/web/public/card-images-preview/13108-sm.avif differ diff --git a/apps/web/public/card-images-preview/13108.avif b/apps/web/public/card-images-preview/13108.avif index 84303c08..12d0e12e 100644 Binary files a/apps/web/public/card-images-preview/13108.avif and b/apps/web/public/card-images-preview/13108.avif differ diff --git a/apps/web/public/card-images-preview/13119-sm.avif b/apps/web/public/card-images-preview/13119-sm.avif index efb07873..f5630e2c 100644 Binary files a/apps/web/public/card-images-preview/13119-sm.avif and b/apps/web/public/card-images-preview/13119-sm.avif differ diff --git a/apps/web/public/card-images-preview/13119.avif b/apps/web/public/card-images-preview/13119.avif index 57d6c64a..f5794ce6 100644 Binary files a/apps/web/public/card-images-preview/13119.avif and b/apps/web/public/card-images-preview/13119.avif differ diff --git a/apps/web/public/card-images-preview/13122-sm.avif b/apps/web/public/card-images-preview/13122-sm.avif index 4298221d..ea4149d3 100644 Binary files a/apps/web/public/card-images-preview/13122-sm.avif and b/apps/web/public/card-images-preview/13122-sm.avif differ diff --git a/apps/web/public/card-images-preview/13122.avif b/apps/web/public/card-images-preview/13122.avif index dc463d2f..8b69f42a 100644 Binary files a/apps/web/public/card-images-preview/13122.avif and b/apps/web/public/card-images-preview/13122.avif differ diff --git a/apps/web/public/card-images-preview/13128-sm.avif b/apps/web/public/card-images-preview/13128-sm.avif index 19324053..ea58c581 100644 Binary files a/apps/web/public/card-images-preview/13128-sm.avif and b/apps/web/public/card-images-preview/13128-sm.avif differ diff --git a/apps/web/public/card-images-preview/13128.avif b/apps/web/public/card-images-preview/13128.avif index 7031f1c4..b7542b5e 100644 Binary files a/apps/web/public/card-images-preview/13128.avif and b/apps/web/public/card-images-preview/13128.avif differ diff --git a/apps/web/public/card-images-preview/13129-sm.avif b/apps/web/public/card-images-preview/13129-sm.avif index 35f9a6c6..8915dda7 100644 Binary files a/apps/web/public/card-images-preview/13129-sm.avif and b/apps/web/public/card-images-preview/13129-sm.avif differ diff --git a/apps/web/public/card-images-preview/13129.avif b/apps/web/public/card-images-preview/13129.avif index 169dd4bb..d565d4c0 100644 Binary files a/apps/web/public/card-images-preview/13129.avif and b/apps/web/public/card-images-preview/13129.avif differ diff --git a/apps/web/public/card-images-preview/13130-sm.avif b/apps/web/public/card-images-preview/13130-sm.avif new file mode 100644 index 00000000..64e85d32 Binary files /dev/null and b/apps/web/public/card-images-preview/13130-sm.avif differ diff --git a/apps/web/public/card-images-preview/13130.avif b/apps/web/public/card-images-preview/13130.avif new file mode 100644 index 00000000..36ec70a8 Binary files /dev/null and b/apps/web/public/card-images-preview/13130.avif differ diff --git a/apps/web/public/card-images-preview/13134-sm.avif b/apps/web/public/card-images-preview/13134-sm.avif new file mode 100644 index 00000000..3f1aff69 Binary files /dev/null and b/apps/web/public/card-images-preview/13134-sm.avif differ diff --git a/apps/web/public/card-images-preview/13134.avif b/apps/web/public/card-images-preview/13134.avif new file mode 100644 index 00000000..37dd1de9 Binary files /dev/null and b/apps/web/public/card-images-preview/13134.avif differ diff --git a/apps/web/public/card-images-preview/13142-sm.avif b/apps/web/public/card-images-preview/13142-sm.avif index b7cd9211..3da1f427 100644 Binary files a/apps/web/public/card-images-preview/13142-sm.avif and b/apps/web/public/card-images-preview/13142-sm.avif differ diff --git a/apps/web/public/card-images-preview/13142.avif b/apps/web/public/card-images-preview/13142.avif index 5b4029ef..fade9099 100644 Binary files a/apps/web/public/card-images-preview/13142.avif and b/apps/web/public/card-images-preview/13142.avif differ diff --git a/apps/web/public/card-images-preview/13148-sm.avif b/apps/web/public/card-images-preview/13148-sm.avif new file mode 100644 index 00000000..401c2539 Binary files /dev/null and b/apps/web/public/card-images-preview/13148-sm.avif differ diff --git a/apps/web/public/card-images-preview/13148.avif b/apps/web/public/card-images-preview/13148.avif new file mode 100644 index 00000000..2176f4f2 Binary files /dev/null and b/apps/web/public/card-images-preview/13148.avif differ diff --git a/apps/web/public/card-images-preview/13151-sm.avif b/apps/web/public/card-images-preview/13151-sm.avif new file mode 100644 index 00000000..a0ff16fb Binary files /dev/null and b/apps/web/public/card-images-preview/13151-sm.avif differ diff --git a/apps/web/public/card-images-preview/13151.avif b/apps/web/public/card-images-preview/13151.avif new file mode 100644 index 00000000..9f433ef2 Binary files /dev/null and b/apps/web/public/card-images-preview/13151.avif differ diff --git a/apps/web/public/card-images-preview/13158-sm.avif b/apps/web/public/card-images-preview/13158-sm.avif new file mode 100644 index 00000000..f9cdfdf5 Binary files /dev/null and b/apps/web/public/card-images-preview/13158-sm.avif differ diff --git a/apps/web/public/card-images-preview/13158.avif b/apps/web/public/card-images-preview/13158.avif new file mode 100644 index 00000000..30feb0ae Binary files /dev/null and b/apps/web/public/card-images-preview/13158.avif differ diff --git a/apps/web/public/card-images-preview/13161-sm.avif b/apps/web/public/card-images-preview/13161-sm.avif index f7c47d74..a67fa840 100644 Binary files a/apps/web/public/card-images-preview/13161-sm.avif and b/apps/web/public/card-images-preview/13161-sm.avif differ diff --git a/apps/web/public/card-images-preview/13161.avif b/apps/web/public/card-images-preview/13161.avif index 91224d02..b1df2ee0 100644 Binary files a/apps/web/public/card-images-preview/13161.avif and b/apps/web/public/card-images-preview/13161.avif differ diff --git a/apps/web/public/card-images-preview/13162-sm.avif b/apps/web/public/card-images-preview/13162-sm.avif index 9eff81b8..532d98ab 100644 Binary files a/apps/web/public/card-images-preview/13162-sm.avif and b/apps/web/public/card-images-preview/13162-sm.avif differ diff --git a/apps/web/public/card-images-preview/13162.avif b/apps/web/public/card-images-preview/13162.avif index 2575b2db..0f37854e 100644 Binary files a/apps/web/public/card-images-preview/13162.avif and b/apps/web/public/card-images-preview/13162.avif differ diff --git a/apps/web/public/card-images-preview/13169-sm.avif b/apps/web/public/card-images-preview/13169-sm.avif index 9ed6773f..5a66c757 100644 Binary files a/apps/web/public/card-images-preview/13169-sm.avif and b/apps/web/public/card-images-preview/13169-sm.avif differ diff --git a/apps/web/public/card-images-preview/13169.avif b/apps/web/public/card-images-preview/13169.avif index 5ab9df74..a9a448cd 100644 Binary files a/apps/web/public/card-images-preview/13169.avif and b/apps/web/public/card-images-preview/13169.avif differ diff --git a/apps/web/public/card-images-preview/13173-sm.avif b/apps/web/public/card-images-preview/13173-sm.avif new file mode 100644 index 00000000..f455a8b4 Binary files /dev/null and b/apps/web/public/card-images-preview/13173-sm.avif differ diff --git a/apps/web/public/card-images-preview/13173.avif b/apps/web/public/card-images-preview/13173.avif new file mode 100644 index 00000000..2ffeeb38 Binary files /dev/null and b/apps/web/public/card-images-preview/13173.avif differ diff --git a/apps/web/public/card-images-preview/13174-sm.avif b/apps/web/public/card-images-preview/13174-sm.avif index f472df41..516f8f37 100644 Binary files a/apps/web/public/card-images-preview/13174-sm.avif and b/apps/web/public/card-images-preview/13174-sm.avif differ diff --git a/apps/web/public/card-images-preview/13174.avif b/apps/web/public/card-images-preview/13174.avif index dafaf023..5cc99c03 100644 Binary files a/apps/web/public/card-images-preview/13174.avif and b/apps/web/public/card-images-preview/13174.avif differ diff --git a/apps/web/public/card-images-preview/13180-sm.avif b/apps/web/public/card-images-preview/13180-sm.avif new file mode 100644 index 00000000..24a0770c Binary files /dev/null and b/apps/web/public/card-images-preview/13180-sm.avif differ diff --git a/apps/web/public/card-images-preview/13180.avif b/apps/web/public/card-images-preview/13180.avif new file mode 100644 index 00000000..5d287a1c Binary files /dev/null and b/apps/web/public/card-images-preview/13180.avif differ diff --git a/apps/web/public/card-images-preview/13186-sm.avif b/apps/web/public/card-images-preview/13186-sm.avif new file mode 100644 index 00000000..3b6c49da Binary files /dev/null and b/apps/web/public/card-images-preview/13186-sm.avif differ diff --git a/apps/web/public/card-images-preview/13186.avif b/apps/web/public/card-images-preview/13186.avif new file mode 100644 index 00000000..15db4767 Binary files /dev/null and b/apps/web/public/card-images-preview/13186.avif differ diff --git a/apps/web/public/card-images-preview/13187-sm.avif b/apps/web/public/card-images-preview/13187-sm.avif index 5e94393e..b68889ed 100644 Binary files a/apps/web/public/card-images-preview/13187-sm.avif and b/apps/web/public/card-images-preview/13187-sm.avif differ diff --git a/apps/web/public/card-images-preview/13187.avif b/apps/web/public/card-images-preview/13187.avif index ff0bc759..36270548 100644 Binary files a/apps/web/public/card-images-preview/13187.avif and b/apps/web/public/card-images-preview/13187.avif differ diff --git a/apps/web/public/card-images-preview/13195-sm.avif b/apps/web/public/card-images-preview/13195-sm.avif new file mode 100644 index 00000000..7bc3f21a Binary files /dev/null and b/apps/web/public/card-images-preview/13195-sm.avif differ diff --git a/apps/web/public/card-images-preview/13195.avif b/apps/web/public/card-images-preview/13195.avif new file mode 100644 index 00000000..94ba9592 Binary files /dev/null and b/apps/web/public/card-images-preview/13195.avif differ diff --git a/apps/web/public/card-images-preview/13196-sm.avif b/apps/web/public/card-images-preview/13196-sm.avif new file mode 100644 index 00000000..1a15a874 Binary files /dev/null and b/apps/web/public/card-images-preview/13196-sm.avif differ diff --git a/apps/web/public/card-images-preview/13196.avif b/apps/web/public/card-images-preview/13196.avif new file mode 100644 index 00000000..2e597b8d Binary files /dev/null and b/apps/web/public/card-images-preview/13196.avif differ diff --git a/apps/web/public/card-images-preview/13203-sm.avif b/apps/web/public/card-images-preview/13203-sm.avif new file mode 100644 index 00000000..dfb750b8 Binary files /dev/null and b/apps/web/public/card-images-preview/13203-sm.avif differ diff --git a/apps/web/public/card-images-preview/13203.avif b/apps/web/public/card-images-preview/13203.avif new file mode 100644 index 00000000..d0fa541e Binary files /dev/null and b/apps/web/public/card-images-preview/13203.avif differ diff --git a/apps/web/public/card-images-preview/13204-sm.avif b/apps/web/public/card-images-preview/13204-sm.avif new file mode 100644 index 00000000..37fde737 Binary files /dev/null and b/apps/web/public/card-images-preview/13204-sm.avif differ diff --git a/apps/web/public/card-images-preview/13204.avif b/apps/web/public/card-images-preview/13204.avif new file mode 100644 index 00000000..c5d408fe Binary files /dev/null and b/apps/web/public/card-images-preview/13204.avif differ diff --git a/apps/web/public/card-images-preview/13900-sm.avif b/apps/web/public/card-images-preview/13900-sm.avif deleted file mode 100644 index 234eb986..00000000 Binary files a/apps/web/public/card-images-preview/13900-sm.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13900.avif b/apps/web/public/card-images-preview/13900.avif deleted file mode 100644 index 83d6ae53..00000000 Binary files a/apps/web/public/card-images-preview/13900.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13912-sm.avif b/apps/web/public/card-images-preview/13912-sm.avif deleted file mode 100644 index b62ca9f7..00000000 Binary files a/apps/web/public/card-images-preview/13912-sm.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13912.avif b/apps/web/public/card-images-preview/13912.avif deleted file mode 100644 index 1f9fa23d..00000000 Binary files a/apps/web/public/card-images-preview/13912.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13914-sm.avif b/apps/web/public/card-images-preview/13914-sm.avif deleted file mode 100644 index 72d9a279..00000000 Binary files a/apps/web/public/card-images-preview/13914-sm.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13914.avif b/apps/web/public/card-images-preview/13914.avif deleted file mode 100644 index 1498fa69..00000000 Binary files a/apps/web/public/card-images-preview/13914.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13920-sm.avif b/apps/web/public/card-images-preview/13920-sm.avif deleted file mode 100644 index 1cd26f5f..00000000 Binary files a/apps/web/public/card-images-preview/13920-sm.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13920.avif b/apps/web/public/card-images-preview/13920.avif deleted file mode 100644 index 486bce60..00000000 Binary files a/apps/web/public/card-images-preview/13920.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13926-sm.avif b/apps/web/public/card-images-preview/13926-sm.avif deleted file mode 100644 index 0bcf716f..00000000 Binary files a/apps/web/public/card-images-preview/13926-sm.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13926.avif b/apps/web/public/card-images-preview/13926.avif deleted file mode 100644 index 91d0b410..00000000 Binary files a/apps/web/public/card-images-preview/13926.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13933-sm.avif b/apps/web/public/card-images-preview/13933-sm.avif deleted file mode 100644 index c27d8eb2..00000000 Binary files a/apps/web/public/card-images-preview/13933-sm.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13933.avif b/apps/web/public/card-images-preview/13933.avif deleted file mode 100644 index 34000cbe..00000000 Binary files a/apps/web/public/card-images-preview/13933.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13948-sm.avif b/apps/web/public/card-images-preview/13948-sm.avif deleted file mode 100644 index 38b859ff..00000000 Binary files a/apps/web/public/card-images-preview/13948-sm.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13948.avif b/apps/web/public/card-images-preview/13948.avif deleted file mode 100644 index eb3f7acd..00000000 Binary files a/apps/web/public/card-images-preview/13948.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13952-sm.avif b/apps/web/public/card-images-preview/13952-sm.avif deleted file mode 100644 index 6bf51d08..00000000 Binary files a/apps/web/public/card-images-preview/13952-sm.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13952.avif b/apps/web/public/card-images-preview/13952.avif deleted file mode 100644 index 8c4f4086..00000000 Binary files a/apps/web/public/card-images-preview/13952.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13975-sm.avif b/apps/web/public/card-images-preview/13975-sm.avif deleted file mode 100644 index bfba8055..00000000 Binary files a/apps/web/public/card-images-preview/13975-sm.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13975.avif b/apps/web/public/card-images-preview/13975.avif deleted file mode 100644 index 835c3866..00000000 Binary files a/apps/web/public/card-images-preview/13975.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13977-sm.avif b/apps/web/public/card-images-preview/13977-sm.avif deleted file mode 100644 index 5258805e..00000000 Binary files a/apps/web/public/card-images-preview/13977-sm.avif and /dev/null differ diff --git a/apps/web/public/card-images-preview/13977.avif b/apps/web/public/card-images-preview/13977.avif deleted file mode 100644 index 42400102..00000000 Binary files a/apps/web/public/card-images-preview/13977.avif and /dev/null differ diff --git a/apps/web/public/data/previewCards.json b/apps/web/public/data/previewCards.json index 73a42f21..81c1de2a 100644 --- a/apps/web/public/data/previewCards.json +++ b/apps/web/public/data/previewCards.json @@ -1819,7 +1819,7 @@ "version": "Created by the Vine", "fullName": "Gaston - Created by the Vine", "cost": 2, - "color": "Sapphire", + "color": "Ruby", "inkwell": true, "type": "Character", "subtypes": [ @@ -3082,7 +3082,7 @@ } }, { - "id": 13900, + "id": 13195, "name": "Maximus", "version": "Relentless Stallion", "fullName": "Maximus - Relentless Stallion", @@ -3110,6 +3110,8 @@ "willpower": 5, "lore": 1, "setCode": "13", + "number": 195, + "rarity": "Rare", "franchise": "Tangled", "images": { "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/8-PD1-EN-13-Maximus-Relentless-Stallion-Prerelease-Promo-Lorcana-Player.jpg.webp", @@ -3117,43 +3119,7 @@ } }, { - "id": 13912, - "name": "Pocahontas", - "version": "Guiding the Tribe", - "fullName": "Pocahontas - Guiding the Tribe", - "cost": 2, - "color": "Amber", - "inkwell": true, - "type": "Character", - "subtypes": [ - "Storyborn", - "Hero", - "Princess" - ], - "fullText": "STAY CLOSE When you play this character, you may play a character with cost 1 for free.", - "fullTextSections": [ - "STAY CLOSE When you play this character, you may play a character with cost 1 for free." - ], - "abilities": [ - { - "effect": "When you play this character, you may play a character with cost 1 for free.", - "fullText": "STAY CLOSE When you play this character, you may play a character with cost 1 for free.", - "name": "STAY CLOSE", - "type": "triggered" - } - ], - "strength": 2, - "willpower": 3, - "lore": 1, - "setCode": "13", - "franchise": "Pocahontas", - "images": { - "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/3-PD1-EN-13-Pocahontas-Guiding-the-Tribe-Prerelease-Promo-Lorcana-Player.jpg.webp", - "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/3-PD1-EN-13-Pocahontas-Guiding-the-Tribe-Prerelease-Promo-Lorcana-Player.jpg.webp" - } - }, - { - "id": 13914, + "id": 13148, "name": "Belle", "version": "Always Reading", "fullName": "Belle - Always Reading", @@ -3182,6 +3148,8 @@ "willpower": 3, "lore": 1, "setCode": "13", + "number": 148, + "rarity": "Common", "franchise": "Beauty and the Beast", "images": { "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/13-P4-EN-13-Belle-Always-Reading-Promo-LQ-Lorcana-Player.jpg.webp", @@ -3189,7 +3157,7 @@ } }, { - "id": 13920, + "id": 13001, "name": "Woody", "version": "Helping a Friend", "fullName": "Woody - Helping a Friend", @@ -3218,6 +3186,8 @@ "willpower": 4, "lore": 2, "setCode": "13", + "number": 1, + "rarity": "Rare", "franchise": "Toy Story", "images": { "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/60-P3-EN-13-Woody-Helping-a-Friend-Event-Promo-LQ-Lorcana-Player.jpg.webp", @@ -3225,209 +3195,837 @@ } }, { - "id": 13926, - "name": "Buzz Lightyear", - "version": "Providing Cover", - "fullName": "Buzz Lightyear - Providing Cover", - "cost": 4, - "color": "Emerald", - "inkwell": false, + "id": 13057, + "name": "Morph", + "version": "Little Imitator", + "fullName": "Morph - Little Imitator", + "cost": 2, + "color": "Amethyst", + "inkwell": true, "type": "Character", "subtypes": [ "Storyborn", - "Captain", - "Hero", - "Toy" + "Alien", + "Ally" ], - "fullText": "ACTION FIGURE When you play this character, choose one of the following. If you have another Toy character in play, choose both instead:\n\n• You may return an action card with cost 2 or less from your discard to your hand.\n\n• You may play an action with cost 2 or less for free.", + "fullText": "ADVANCED MIMICRY You can shift any character on top of this character. (This includes all Shift variants.)", "fullTextSections": [ - "ACTION FIGURE When you play this character, choose one of the following. If you have another Toy character in play, choose both instead:\n\n• You may return an action card with cost 2 or less from your discard to your hand.\n\n• You may play an action with cost 2 or less for free." + "ADVANCED MIMICRY You can shift any character on top of this character. (This includes all Shift variants.)" ], "abilities": [ { - "effect": "When you play this character, choose one of the following. If you have another Toy character in play, choose both instead: • You may return an action card with cost 2 or less from your discard to your hand. • You may play an action with cost 2 or less for free.", - "fullText": "ACTION FIGURE When you play this character, choose one of the following. If you have another Toy character in play, choose both instead:\n\n• You may return an action card with cost 2 or less from your discard to your hand.\n\n• You may play an action with cost 2 or less for free.", - "name": "ACTION FIGURE", - "type": "triggered" + "effect": "You can shift any character on top of this character. (This includes all Shift variants.)", + "fullText": "ADVANCED MIMICRY You can shift any character on top of this character. (This includes all Shift variants.)", + "name": "ADVANCED MIMICRY", + "type": "static" } ], - "strength": 4, + "strength": 1, "willpower": 2, - "lore": 2, + "lore": 1, "setCode": "13", - "franchise": "Toy Story", + "number": 57, + "rarity": "Uncommon", + "franchise": "Treasure Planet", "images": { - "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/5-PD1-EN-13-Buzz-Lightyear-Providing-Cover-Prerelease-Promo-Lorcana-Player.jpg.webp", - "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/5-PD1-EN-13-Buzz-Lightyear-Providing-Cover-Prerelease-Promo-Lorcana-Player.jpg.webp" + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/9-P4-EN-13-Morph-Little-Imitator-Weekly-Play-Promo-Lorcana-Player.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/9-P4-EN-13-Morph-Little-Imitator-Weekly-Play-Promo-Lorcana-Player.jpg.webp" } }, { - "id": 13933, - "name": "Vixey", - "version": "Expert Fisher", - "fullName": "Vixey - Expert Fisher", - "cost": 3, + "id": 13039, + "name": "Maleficent", + "version": "Exultant Spellcaster", + "fullName": "Maleficent - Exultant Spellcaster", + "cost": 1, "color": "Amethyst", "inkwell": true, "type": "Character", "subtypes": [ "Storyborn", - "Ally" + "Sorcerer", + "Villain" + ], + "strength": 2, + "willpower": 2, + "lore": 1, + "setCode": "13", + "number": 39, + "rarity": "Common", + "franchise": "Sleeping Beauty", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/4-DIS-EN-13-Maleficent-Exultant-Spellcaster-Disney-Parks-Promo-Lorcana-Player.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/4-DIS-EN-13-Maleficent-Exultant-Spellcaster-Disney-Parks-Promo-Lorcana-Player.jpg.webp" + } + }, + { + "id": 13032, + "name": "If I Didn't Have You", + "fullName": "If I Didn't Have You", + "cost": 3, + "color": "Amber", + "inkwell": true, + "type": "Action", + "subtypes": [ + "Song", + "Action" ], - "fullText": "STEALING IN When you play this character, if you have a character with Evasive in play, you may return chosen character, item, or location with cost 2 or less to their player’s hand.", + "fullText": "(A character with cost 3 or more can ⟳ to sing this song for free.)\nYou and another chosen player each draw 2 cards.", "fullTextSections": [ - "STEALING IN When you play this character, if you have a character with Evasive in play, you may return chosen character, item, or location with cost 2 or less to their player’s hand." + "(A character with cost 3 or more can ⟳ to sing this song for free.)", + "You and another chosen player each draw 2 cards." ], "abilities": [ { - "effect": "When you play this character, if you have a character with Evasive in play, you may return chosen character, item, or location with cost 2 or less to their player’s hand.", - "fullText": "STEALING IN When you play this character, if you have a character with Evasive in play, you may return chosen character, item, or location with cost 2 or less to their player’s hand.", - "name": "STEALING IN", - "type": "triggered" + "effect": "A character with cost 3 or more can ⟳ to sing this song for free.", + "fullText": "(A character with cost 3 or more can ⟳ to sing this song for free.)", + "type": "static" } ], + "setCode": "13", + "number": 32, + "rarity": "Common", + "franchise": "Monsters, Inc.", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/14-P4-EN-13-If-I-Didnt-Have-You-Promo-LQ-Lorcana-Player.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/14-P4-EN-13-If-I-Didnt-Have-You-Promo-LQ-Lorcana-Player.jpg.webp" + } + }, + { + "id": 13003, + "name": "Isabela Madrigal", + "version": "Kind Cultivator", + "fullName": "Isabela Madrigal - Kind Cultivator", + "cost": 2, + "color": "Amber", + "inkwell": true, + "type": "Character", + "subtypes": [ + "Storyborn", + "Ally", + "Madrigal" + ], "strength": 2, "willpower": 4, "lore": 1, "setCode": "13", - "franchise": "The Fox and the Hound", + "number": 3, + "rarity": "Common", + "franchise": "Encanto", "images": { - "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/4-PD1-EN-13-Vixey-Expert-Fisher-Prerelease-Promo-Lorcana-Player.jpg.webp", - "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/4-PD1-EN-13-Vixey-Expert-Fisher-Prerelease-Promo-Lorcana-Player.jpg.webp" + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/3-207-EN-13-Isabela-Madrigal-Kind-Cultivator-Lorcana-Player-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/3-207-EN-13-Isabela-Madrigal-Kind-Cultivator-Lorcana-Player-430x600.jpg.webp" } }, { - "id": 13948, - "name": "Morph", - "version": "Little Imitator", - "fullName": "Morph - Little Imitator", - "cost": 2, + "id": 13048, + "name": "Pain", + "version": "Running with Scissors", + "fullName": "Pain - Running with Scissors", + "cost": 4, "color": "Amethyst", "inkwell": true, "type": "Character", "subtypes": [ "Storyborn", - "Alien", "Ally" ], - "fullText": "ADVANCED MIMICRY You can shift any character on top of this character. (This includes all Shift variants.)", + "fullText": "MULTIPURPOSE TOOL When you play this character, if you have a character card named Panic in your discard, gain 2 lore.", "fullTextSections": [ - "ADVANCED MIMICRY You can shift any character on top of this character. (This includes all Shift variants.)" + "MULTIPURPOSE TOOL When you play this character, if you have a character card named Panic in your discard, gain 2 lore." ], "abilities": [ { - "effect": "You can shift any character on top of this character. (This includes all Shift variants.)", - "fullText": "ADVANCED MIMICRY You can shift any character on top of this character. (This includes all Shift variants.)", - "name": "ADVANCED MIMICRY", - "type": "static" + "effect": "When you play this character, if you have a character card named Panic in your discard, gain 2 lore.", + "fullText": "MULTIPURPOSE TOOL When you play this character, if you have a character card named Panic in your discard, gain 2 lore.", + "name": "MULTIPURPOSE TOOL", + "type": "triggered" } ], - "strength": 1, - "willpower": 2, + "strength": 5, + "willpower": 3, "lore": 1, "setCode": "13", - "franchise": "Treasure Planet", + "number": 48, + "rarity": "Common", + "franchise": "Hercules", "images": { - "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/9-P4-EN-13-Morph-Little-Imitator-Weekly-Play-Promo-Lorcana-Player.jpg.webp", - "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/9-P4-EN-13-Morph-Little-Imitator-Weekly-Play-Promo-Lorcana-Player.jpg.webp" + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/48-207-EN-13-Pain-Running-with-Scissors-Lorcana-Player-1-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/48-207-EN-13-Pain-Running-with-Scissors-Lorcana-Player-1-430x600.jpg.webp" } }, { - "id": 13952, - "name": "Maleficent", - "version": "Exultant Spellcaster", - "fullName": "Maleficent - Exultant Spellcaster", - "cost": 1, + "id": 13041, + "name": "Panic", + "version": "Hammer Enthusiast", + "fullName": "Panic - Hammer Enthusiast", + "cost": 3, "color": "Amethyst", "inkwell": true, "type": "Character", "subtypes": [ "Storyborn", - "Sorcerer", - "Villain" + "Ally" ], - "strength": 2, + "fullText": "Rush\n(This character can challenge the turn they’re played.)", + "fullTextSections": [ + "Rush", + "(This character can challenge the turn they’re played.)" + ], + "abilities": [ + { + "type": "keyword", + "keyword": "Rush", + "fullText": "Rush" + }, + { + "effect": "This character can challenge the turn they’re played.", + "fullText": "(This character can challenge the turn they’re played.)", + "type": "static" + } + ], + "strength": 3, + "willpower": 1, + "lore": 2, + "setCode": "13", + "number": 41, + "rarity": "Common", + "franchise": "Hercules", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/41-207-EN-13-Panic-Hammer-Enthusiast-Lorcana-Player-1-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/41-207-EN-13-Panic-Hammer-Enthusiast-Lorcana-Player-1-430x600.jpg.webp" + } + }, + { + "id": 13151, + "name": "Alpha", + "version": "Leader of the Pack", + "fullName": "Alpha - Leader of the Pack", + "cost": 2, + "color": "Sapphire", + "inkwell": true, + "type": "Character", + "subtypes": [ + "Storyborn", + "Ally" + ], + "fullText": "WHO WANTS A TREAT? Whenever you play an item, chosen character gets +1 and Resist +1 this turn. (Damage dealt to them is reduced by 1.)", + "fullTextSections": [ + "WHO WANTS A TREAT? Whenever you play an item, chosen character gets +1 and Resist +1 this turn. (Damage dealt to them is reduced by 1.)" + ], + "abilities": [ + { + "effect": "Whenever you play an item, chosen character gets +1 and Resist +1 this turn. (Damage dealt to them is reduced by 1.)", + "fullText": "WHO WANTS A TREAT? Whenever you play an item, chosen character gets +1 and Resist +1 this turn. (Damage dealt to them is reduced by 1.)", + "name": "WHO WANTS A TREAT", + "type": "triggered" + } + ], + "strength": 3, "willpower": 2, "lore": 1, "setCode": "13", - "franchise": "Sleeping Beauty", + "number": 151, + "rarity": "Rare", + "franchise": "Up", "images": { - "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/4-DIS-EN-13-Maleficent-Exultant-Spellcaster-Disney-Parks-Promo-Lorcana-Player.jpg.webp", - "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/4-DIS-EN-13-Maleficent-Exultant-Spellcaster-Disney-Parks-Promo-Lorcana-Player.jpg.webp" + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/Alpha-Leader-of-the-Pack-German-LQ-Lorcana-Player-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/Alpha-Leader-of-the-Pack-German-LQ-Lorcana-Player-430x600.jpg.webp" } }, { - "id": 13975, - "name": "Merlin", - "version": "Envisioning the Future", - "fullName": "Merlin - Envisioning the Future", - "cost": 4, - "color": "Sapphire", - "inkwell": false, + "id": 13196, + "name": "Omnidroid", + "version": "Ultimate Learning Version", + "fullName": "Omnidroid - Ultimate Learning Version", + "cost": 8, + "color": "Steel", + "inkwell": true, "type": "Character", "subtypes": [ "Dreamborn", - "Mentor", - "Sorcerer" + "Robot" ], - "fullText": "MINOR TRICKERY When you play this character, you may draw a card from the bottom of your deck.\nAGE OF INCONVENIENCE When this character is banished, put this card from your discard on the bottom of your deck.", + "fullText": "Shift 6 ⬡ (You may pay 6 ⬡ to play this on top of one of your characters named Omnidroid.)\nResist +2 (Damage dealt to this character is reduced by 2.)\nRETURN ON INVESTMENT When you shift this character onto a character, you may return all cards under this one to your hand.", "fullTextSections": [ - "MINOR TRICKERY When you play this character, you may draw a card from the bottom of your deck.", - "AGE OF INCONVENIENCE When this character is banished, put this card from your discard on the bottom of your deck." + "Shift 6 ⬡ (You may pay 6 ⬡ to play this on top of one of your characters named Omnidroid.)", + "Resist +2 (Damage dealt to this character is reduced by 2.)", + "RETURN ON INVESTMENT When you shift this character onto a character, you may return all cards under this one to your hand." ], "abilities": [ { - "effect": "When you play this character, you may draw a card from the bottom of your deck.", - "fullText": "MINOR TRICKERY When you play this character, you may draw a card from the bottom of your deck.", - "name": "MINOR TRICKERY", - "type": "triggered" + "type": "keyword", + "keyword": "Shift", + "fullText": "Shift 6 ⬡ (You may pay 6 ⬡ to play this on top of one of your characters named Omnidroid.)", + "keywordValue": "6 ⬡" + }, + { + "type": "keyword", + "keyword": "Resist", + "fullText": "Resist +2 (Damage dealt to this character is reduced by 2.)", + "keywordValue": "+2" }, { - "effect": "When this character is banished, put this card from your discard on the bottom of your deck.", - "fullText": "AGE OF INCONVENIENCE When this character is banished, put this card from your discard on the bottom of your deck.", - "name": "AGE OF INCONVENIENCE", + "effect": "When you shift this character onto a character, you may return all cards under this one to your hand.", + "fullText": "RETURN ON INVESTMENT When you shift this character onto a character, you may return all cards under this one to your hand.", + "name": "RETURN ON INVESTMENT", "type": "triggered" } ], - "strength": 1, - "willpower": 4, + "strength": 8, + "willpower": 8, "lore": 2, "setCode": "13", - "franchise": "The Sword in the Stone", + "number": 196, + "rarity": "Rare", + "franchise": "The Incredibles", "images": { - "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/7-PD1-EN-13-Merlin-Envisioning-the-Future-Prerelease-Promo-Lorcana-Player.jpg.webp", - "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/7-PD1-EN-13-Merlin-Envisioning-the-Future-Prerelease-Promo-Lorcana-Player.jpg.webp" + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/Omnidroid-Ultimate-Learning-Version-Japanese-LQ-Lorcana-Player-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/Omnidroid-Ultimate-Learning-Version-Japanese-LQ-Lorcana-Player-430x600.jpg.webp" } }, { - "id": 13977, - "name": "If I Didn't Have You", - "fullName": "If I Didn't Have You", - "cost": 3, + "id": 13036, + "name": "Powhatan's Staff", + "fullName": "Powhatan's Staff", + "cost": 1, "color": "Amber", "inkwell": true, - "type": "Action", + "type": "Item", "subtypes": [ - "Song", - "Action" + "Item" ], - "fullText": "(A character with cost 3 or more can ⟳ to sing this song for free.)\nYou and another chosen player each draw 2 cards.", + "fullText": "STEP FORWARD ⟳, 1 ⬡ – The next character you play this turn enters play exerted and gains Bodyguard until the start of your next turn. (An opposing character who challenges one of your characters must choose one with Bodyguard if able.)", "fullTextSections": [ - "(A character with cost 3 or more can ⟳ to sing this song for free.)", - "You and another chosen player each draw 2 cards." + "STEP FORWARD ⟳, 1 ⬡ – The next character you play this turn enters play exerted and gains Bodyguard until the start of your next turn. (An opposing character who challenges one of your characters must choose one with Bodyguard if able.)" ], "abilities": [ { - "effect": "A character with cost 3 or more can ⟳ to sing this song for free.", - "fullText": "(A character with cost 3 or more can ⟳ to sing this song for free.)", - "type": "static" + "effect": "⟳, 1 ⬡ – The next character you play this turn enters play exerted and gains Bodyguard until the start of your next turn. (An opposing character who challenges one of your characters must choose one with Bodyguard if able.)", + "fullText": "STEP FORWARD ⟳, 1 ⬡ – The next character you play this turn enters play exerted and gains Bodyguard until the start of your next turn. (An opposing character who challenges one of your characters must choose one with Bodyguard if able.)", + "name": "STEP FORWARD", + "type": "activated" } ], "setCode": "13", - "franchise": "Monsters, Inc.", + "number": 36, + "rarity": "Uncommon", + "franchise": "Pocahontas", "images": { - "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/14-P4-EN-13-If-I-Didnt-Have-You-Promo-LQ-Lorcana-Player.jpg.webp", - "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/14-P4-EN-13-If-I-Didnt-Have-You-Promo-LQ-Lorcana-Player.jpg.webp" + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/36-207-EN-13-Powhatans-Staff-Lorcana-Player-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/36-207-EN-13-Powhatans-Staff-Lorcana-Player-430x600.jpg.webp" + } + }, + { + "id": 13050, + "name": "Merida", + "version": "Wisp Conjurer", + "fullName": "Merida - Wisp Conjurer", + "cost": 4, + "color": "Amethyst", + "inkwell": false, + "type": "Character", + "subtypes": [ + "Dreamborn", + "Hero", + "Princess", + "Sorcerer" + ], + "fullText": "FOCUSED ENERGY This character may enter play exerted to draw a card.\nBECKON During your turn, whenever another character of yours enters play exerted, you may draw a card.", + "fullTextSections": [ + "FOCUSED ENERGY This character may enter play exerted to draw a card.", + "BECKON During your turn, whenever another character of yours enters play exerted, you may draw a card." + ], + "abilities": [ + { + "effect": "This character may enter play exerted to draw a card.", + "fullText": "FOCUSED ENERGY This character may enter play exerted to draw a card.", + "name": "FOCUSED ENERGY", + "type": "static" + }, + { + "effect": "During your turn, whenever another character of yours enters play exerted, you may draw a card.", + "fullText": "BECKON During your turn, whenever another character of yours enters play exerted, you may draw a card.", + "name": "BECKON", + "type": "static" + } + ], + "strength": 3, + "willpower": 3, + "lore": 1, + "setCode": "13", + "number": 50, + "rarity": "Legendary", + "franchise": "Brave", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/50-207-EN-13-Merida-Wisp-Conjurer-Lorcana-Player-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/50-207-EN-13-Merida-Wisp-Conjurer-Lorcana-Player-430x600.jpg.webp" + } + }, + { + "id": 13071, + "name": "Magical Hunny Staff", + "fullName": "Magical Hunny Staff", + "cost": 1, + "color": "Amethyst", + "inkwell": true, + "type": "Item", + "subtypes": [ + "Hunny", + "Item" + ], + "fullText": "GIFT OF THE HIVE Once during your turn, you may pay 1 ⬡ to give chosen character of yours the Hunny classification until the start of your next turn.\nSPELL OF SWIFTNESS ⟳, 2 ⬡ – Chosen Hunny character of yours gains Evasive until the start of your next turn. (Only characters with Evasive can challenge them.)", + "fullTextSections": [ + "GIFT OF THE HIVE Once during your turn, you may pay 1 ⬡ to give chosen character of yours the Hunny classification until the start of your next turn.", + "SPELL OF SWIFTNESS ⟳, 2 ⬡ – Chosen Hunny character of yours gains Evasive until the start of your next turn. (Only characters with Evasive can challenge them.)" + ], + "abilities": [ + { + "effect": "Once during your turn, you may pay 1 ⬡ to give chosen character of yours the Hunny classification until the start of your next turn.", + "fullText": "GIFT OF THE HIVE Once during your turn, you may pay 1 ⬡ to give chosen character of yours the Hunny classification until the start of your next turn.", + "name": "GIFT OF THE HIVE", + "type": "triggered" + }, + { + "effect": "⟳, 2 ⬡ – Chosen Hunny character of yours gains Evasive until the start of your next turn. (Only characters with Evasive can challenge them.)", + "fullText": "SPELL OF SWIFTNESS ⟳, 2 ⬡ – Chosen Hunny character of yours gains Evasive until the start of your next turn. (Only characters with Evasive can challenge them.)", + "name": "SPELL OF SWIFTNESS", + "type": "activated" + } + ], + "setCode": "13", + "number": 71, + "rarity": "Rare", + "franchise": "Winnie the Pooh", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/71-207-EN-13-Magical-Hunny-Staff-Lorcana-Player-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/71-207-EN-13-Magical-Hunny-Staff-Lorcana-Player-430x600.jpg.webp" + } + }, + { + "id": 13084, + "name": "Winifred", + "version": "Exasperated Elephant", + "fullName": "Winifred - Exasperated Elephant", + "cost": 6, + "color": "Emerald", + "inkwell": true, + "type": "Character", + "subtypes": [ + "Storyborn", + "Ally" + ], + "fullText": "Ward\n(Opponents can’t choose this character except to challenge.)", + "fullTextSections": [ + "Ward", + "(Opponents can’t choose this character except to challenge.)" + ], + "abilities": [ + { + "type": "keyword", + "keyword": "Ward", + "fullText": "Ward" + }, + { + "effect": "Opponents can’t choose this character except to challenge.", + "fullText": "(Opponents can’t choose this character except to challenge.)", + "type": "static" + } + ], + "strength": 6, + "willpower": 6, + "lore": 2, + "setCode": "13", + "number": 84, + "rarity": "Common", + "franchise": "The Jungle Book", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/84-207-EN-13-Winifred-Exasperated-Elephant-Lorcana-Player-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/84-207-EN-13-Winifred-Exasperated-Elephant-Lorcana-Player-430x600.jpg.webp" + } + }, + { + "id": 13092, + "name": "Dr. Bushroot", + "version": "Evil Botanist", + "fullName": "Dr. Bushroot - Evil Botanist", + "cost": 5, + "color": "Emerald", + "inkwell": true, + "type": "Character", + "subtypes": [ + "Storyborn", + "Super", + "Villain" + ], + "fullText": "Ward\n(Opponents can’t choose this character except to challenge.)\nFAIR IS FAIR Whenever this character is challenged, chosen opponent chooses and discards a card.", + "fullTextSections": [ + "Ward", + "(Opponents can’t choose this character except to challenge.)", + "FAIR IS FAIR Whenever this character is challenged, chosen opponent chooses and discards a card." + ], + "abilities": [ + { + "type": "keyword", + "keyword": "Ward", + "fullText": "Ward" + }, + { + "effect": "Opponents can’t choose this character except to challenge.", + "fullText": "(Opponents can’t choose this character except to challenge.)", + "type": "static" + }, + { + "effect": "Whenever this character is challenged, chosen opponent chooses and discards a card.", + "fullText": "FAIR IS FAIR Whenever this character is challenged, chosen opponent chooses and discards a card.", + "name": "FAIR IS FAIR", + "type": "triggered" + } + ], + "strength": 4, + "willpower": 3, + "lore": 3, + "setCode": "13", + "number": 92, + "rarity": "Rare", + "franchise": "Darkwing Duck", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/92-207-EN-13-Dr.-Bushroot-Evil-Botanist-Lorcana-Player-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/92-207-EN-13-Dr.-Bushroot-Evil-Botanist-Lorcana-Player-430x600.jpg.webp" + } + }, + { + "id": 13130, + "name": "Captain Hook", + "version": "Conniving Pirate", + "fullName": "Captain Hook - Conniving Pirate", + "cost": 2, + "color": "Ruby", + "inkwell": true, + "type": "Character", + "subtypes": [ + "Storyborn", + "Captain", + "Pirate", + "Villain" + ], + "fullText": "HAVE AT YOU Whenever this character challenges another character, gain 1 lore.", + "fullTextSections": [ + "HAVE AT YOU Whenever this character challenges another character, gain 1 lore." + ], + "abilities": [ + { + "effect": "Whenever this character challenges another character, gain 1 lore.", + "fullText": "HAVE AT YOU Whenever this character challenges another character, gain 1 lore.", + "name": "HAVE AT YOU", + "type": "triggered" + } + ], + "strength": 3, + "willpower": 2, + "lore": 1, + "setCode": "13", + "number": 130, + "rarity": "Uncommon", + "franchise": "Peter Pan", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/130-207-EN-13-Captain-Hook-Conniving-Pirate-Lorcana-Player-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/130-207-EN-13-Captain-Hook-Conniving-Pirate-Lorcana-Player-430x600.jpg.webp" + } + }, + { + "id": 13158, + "name": "Maid Marian", + "version": "Created by the Vine", + "fullName": "Maid Marian - Created by the Vine", + "cost": 4, + "color": "Sapphire", + "inkwell": false, + "type": "Character", + "subtypes": [ + "Floodborn", + "Princess", + "Vineling" + ], + "fullText": "INKFLOW Whenever one of your Floodborn characters is banished, you may put the top card of your deck into your inkwell facedown and exerted.", + "fullTextSections": [ + "INKFLOW Whenever one of your Floodborn characters is banished, you may put the top card of your deck into your inkwell facedown and exerted." + ], + "abilities": [ + { + "effect": "Whenever one of your Floodborn characters is banished, you may put the top card of your deck into your inkwell facedown and exerted.", + "fullText": "INKFLOW Whenever one of your Floodborn characters is banished, you may put the top card of your deck into your inkwell facedown and exerted.", + "name": "INKFLOW", + "type": "triggered" + } + ], + "strength": 3, + "willpower": 4, + "lore": 1, + "setCode": "13", + "number": 158, + "rarity": "Rare", + "franchise": "Robin Hood", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/158-207-EN-13-Maid-Marian-Created-by-the-Vine-Lorcana-Player-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/158-207-EN-13-Maid-Marian-Created-by-the-Vine-Lorcana-Player-430x600.jpg.webp" + } + }, + { + "id": 13173, + "name": "Translation Collar", + "fullName": "Translation Collar", + "cost": 2, + "color": "Sapphire", + "inkwell": true, + "type": "Item", + "subtypes": [ + "Item" + ], + "fullText": "YOU ARE MY FRIEND ⟳, 1 ⬡ – Chosen character gets +1 ◊ and gains Support this turn. (Whenever they quest, you may add their to another chosen character’s this turn.)", + "fullTextSections": [ + "YOU ARE MY FRIEND ⟳, 1 ⬡ – Chosen character gets +1 ◊ and gains Support this turn. (Whenever they quest, you may add their to another chosen character’s this turn.)" + ], + "abilities": [ + { + "effect": "⟳, 1 ⬡ – Chosen character gets +1 ◊ and gains Support this turn. (Whenever they quest, you may add their to another chosen character’s this turn.)", + "fullText": "YOU ARE MY FRIEND ⟳, 1 ⬡ – Chosen character gets +1 ◊ and gains Support this turn. (Whenever they quest, you may add their to another chosen character’s this turn.)", + "name": "YOU ARE MY FRIEND", + "type": "activated" + } + ], + "setCode": "13", + "number": 173, + "rarity": "Rare", + "franchise": "Up", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/173-207-EN-13-Translation-Collar-Lorcana-Player-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/173-207-EN-13-Translation-Collar-Lorcana-Player-430x600.jpg.webp" + } + }, + { + "id": 13203, + "name": "Windstorm", + "fullName": "Windstorm", + "cost": 4, + "color": "Steel", + "inkwell": true, + "type": "Action", + "subtypes": [ + "Action" + ], + "fullText": "Deal 1 damage to each opposing character and location. Then, deal 2 damage to each opposing character with Evasive and each opposing location with Evasive.", + "fullTextSections": [ + "Deal 1 damage to each opposing character and location. Then, deal 2 damage to each opposing character with Evasive and each opposing location with Evasive." + ], + "setCode": "13", + "number": 203, + "rarity": "Rare", + "franchise": "Up", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/203-207-EN-13-Windstorm-Lorcana-Player-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/203-207-EN-13-Windstorm-Lorcana-Player-430x600.jpg.webp" + } + }, + { + "id": 13204, + "name": "Discarded Armor", + "fullName": "Discarded Armor", + "cost": 1, + "color": "Steel", + "inkwell": true, + "type": "Item", + "subtypes": [ + "Item" + ], + "fullText": "FOUND EQUIPMENT ⟳ – If you discarded a card this turn, chosen character of yours gains Resist +1 until the start of your next turn. (Damage dealt to them is reduced by 1.)", + "fullTextSections": [ + "FOUND EQUIPMENT ⟳ – If you discarded a card this turn, chosen character of yours gains Resist +1 until the start of your next turn. (Damage dealt to them is reduced by 1.)" + ], + "abilities": [ + { + "effect": "⟳ – If you discarded a card this turn, chosen character of yours gains Resist +1 until the start of your next turn. (Damage dealt to them is reduced by 1.)", + "fullText": "FOUND EQUIPMENT ⟳ – If you discarded a card this turn, chosen character of yours gains Resist +1 until the start of your next turn. (Damage dealt to them is reduced by 1.)", + "name": "FOUND EQUIPMENT", + "type": "activated" + } + ], + "setCode": "13", + "number": 204, + "rarity": "Uncommon", + "franchise": "Mulan", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/204-207-EN-13-Discarded-Armor-Lorcana-Player-430x600.jpg.webp", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/204-207-EN-13-Discarded-Armor-Lorcana-Player-430x600.jpg.webp" + } + }, + { + "id": 13035, + "name": "Nobody Like U", + "fullName": "Nobody Like U", + "cost": 5, + "color": "Amber", + "inkwell": false, + "type": "Action", + "subtypes": [ + "Song", + "Action" + ], + "fullText": "Sing Together 5 (Any number of your or your teammates’ characters with total cost 5 or more may ⟳ to sing this song for free.)\nPlay a character with cost 4 or less for free.", + "fullTextSections": [ + "Sing Together 5 (Any number of your or your teammates’ characters with total cost 5 or more may ⟳ to sing this song for free.)", + "Play a character with cost 4 or less for free." + ], + "abilities": [ + { + "type": "keyword", + "keyword": "Sing Together", + "fullText": "Sing Together 5 (Any number of your or your teammates’ characters with total cost 5 or more may ⟳ to sing this song for free.)", + "keywordValue": "5" + } + ], + "setCode": "13", + "number": 35, + "rarity": "Rare", + "franchise": "Turning Red", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/35-207-EN-13-Nobody-Like-U-LQ-Lorcana-Player-430x600.jpg", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/35-207-EN-13-Nobody-Like-U-LQ-Lorcana-Player-430x600.jpg" + } + }, + { + "id": 13134, + "name": "Red Moon Ritual", + "fullName": "Red Moon Ritual", + "cost": 7, + "color": "Ruby", + "inkwell": false, + "type": "Action", + "subtypes": [ + "Song", + "Action" + ], + "fullText": "Sing Together 7 (Any number of your or your teammates’ characters with total cost 7 or more may ⟳ to sing this song for free.)\nBanish chosen character.", + "fullTextSections": [ + "Sing Together 7 (Any number of your or your teammates’ characters with total cost 7 or more may ⟳ to sing this song for free.)", + "Banish chosen character." + ], + "abilities": [ + { + "type": "keyword", + "keyword": "Sing Together", + "fullText": "Sing Together 7 (Any number of your or your teammates’ characters with total cost 7 or more may ⟳ to sing this song for free.)", + "keywordValue": "7" + } + ], + "setCode": "13", + "number": 134, + "rarity": "Uncommon", + "franchise": "Turning Red", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/134-207-EN-13-Red-Moon-Ritual-LQ-Lorcana-Player-430x600.jpg", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/134-207-EN-13-Red-Moon-Ritual-LQ-Lorcana-Player-430x600.jpg" + } + }, + { + "id": 13186, + "name": "Megavolt", + "version": "Electrical Menace", + "fullName": "Megavolt - Electrical Menace", + "cost": 4, + "color": "Steel", + "inkwell": true, + "type": "Character", + "subtypes": [ + "Storyborn", + "Super", + "Villain" + ], + "fullText": "FORCE FIELD While you have no cards in your hand, this character gains Resist +2. (Damage dealt to them is reduced by 2.)", + "fullTextSections": [ + "FORCE FIELD While you have no cards in your hand, this character gains Resist +2. (Damage dealt to them is reduced by 2.)" + ], + "abilities": [ + { + "effect": "While you have no cards in your hand, this character gains Resist +2. (Damage dealt to them is reduced by 2.)", + "fullText": "FORCE FIELD While you have no cards in your hand, this character gains Resist +2. (Damage dealt to them is reduced by 2.)", + "name": "FORCE FIELD", + "type": "static" + } + ], + "strength": 1, + "willpower": 4, + "lore": 3, + "setCode": "13", + "number": 186, + "rarity": "Uncommon", + "franchise": "Darkwing Duck", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/186-207-EN-13-Megavolt-Electrical-Menace-LQ-Lorcana-Player-430x600.jpg", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/186-207-EN-13-Megavolt-Electrical-Menace-LQ-Lorcana-Player-430x600.jpg" + } + }, + { + "id": 13043, + "name": "Abu", + "version": "Wise Sultan", + "fullName": "Abu - Wise Sultan", + "cost": 1, + "color": "Amethyst", + "inkwell": true, + "type": "Character", + "subtypes": [ + "Storyborn", + "Ally" + ], + "fullText": "RULER FOR A DAY When this character quests, banish him.", + "fullTextSections": [ + "RULER FOR A DAY When this character quests, banish him." + ], + "abilities": [ + { + "effect": "When this character quests, banish him.", + "fullText": "RULER FOR A DAY When this character quests, banish him.", + "name": "RULER FOR A DAY", + "type": "triggered" + } + ], + "strength": 2, + "willpower": 2, + "lore": 2, + "setCode": "13", + "number": 43, + "rarity": "Uncommon", + "franchise": "Aladdin", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/43-207-IT-13-Abu-Wise-Sultan-Italian-LQ-Lorcana-Player-430x600.jpg", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/43-207-IT-13-Abu-Wise-Sultan-Italian-LQ-Lorcana-Player-430x600.jpg" + } + }, + { + "id": 13180, + "name": "Stitch", + "version": "Protector of Frogs", + "fullName": "Stitch - Protector of Frogs", + "cost": 1, + "color": "Steel", + "inkwell": true, + "type": "Character", + "subtypes": [ + "Storyborn", + "Alien", + "Hero" + ], + "strength": 1, + "willpower": 3, + "lore": 1, + "setCode": "13", + "number": 180, + "rarity": "Common", + "franchise": "Lilo & Stitch", + "images": { + "thumbnail": "https://lorcanaplayer.com/wp-content/uploads/2026/06/180-207-EN-13-Stitch-Protector-of-Frogs-LQ-Lorcana-Player-430x600.jpg", + "full": "https://lorcanaplayer.com/wp-content/uploads/2026/06/180-207-EN-13-Stitch-Protector-of-Frogs-LQ-Lorcana-Player-430x600.jpg" } } ] diff --git a/apps/web/src/assets/common.svg b/apps/web/src/assets/common.svg new file mode 100644 index 00000000..e9ecacf3 --- /dev/null +++ b/apps/web/src/assets/common.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/assets/legendary.svg b/apps/web/src/assets/legendary.svg new file mode 100644 index 00000000..149bc193 --- /dev/null +++ b/apps/web/src/assets/legendary.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/assets/rare.svg b/apps/web/src/assets/rare.svg new file mode 100644 index 00000000..1164e195 --- /dev/null +++ b/apps/web/src/assets/rare.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/assets/super_rare.svg b/apps/web/src/assets/super_rare.svg new file mode 100644 index 00000000..d6624541 --- /dev/null +++ b/apps/web/src/assets/super_rare.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/assets/uncommon.svg b/apps/web/src/assets/uncommon.svg new file mode 100644 index 00000000..2172ad4b --- /dev/null +++ b/apps/web/src/assets/uncommon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/features/reveals/CardMosaic.stories.tsx b/apps/web/src/features/reveals/CardMosaic.stories.tsx new file mode 100644 index 00000000..8d404da5 --- /dev/null +++ b/apps/web/src/features/reveals/CardMosaic.stories.tsx @@ -0,0 +1,65 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import type {Ink, LorcanaCard} from 'inkweave-synergy-engine'; +import {CardMosaic} from './CardMosaic'; + +const meta: Meta = { + title: 'Features/Reveals/CardMosaic', + component: CardMosaic, + parameters: {backgrounds: {default: 'dark'}, layout: 'centered'}, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +// Real committed Set 13 preview ids, so the slot images load in Storybook. +const IMG_IDS = [ + 13002, 13005, 13006, 13007, 13009, 13017, 13021, 13024, 13026, 13028, 13029, 13030, 13031, 13033, + 13034, 13037, 13038, 13040, 13044, 13049, 13051, 13052, 13058, 13059, 13060, 13061, 13065, 13067, + 13068, 13074, 13075, 13079, 13082, 13083, +]; + +// Each ink's first collector number (mirrors CardMosaic's INK_BASE), so mock +// setNumbers land in-range and the cards place on the 38-slot board. +const INK_BASE: Record = { + Amber: 1, + Amethyst: 38, + Emerald: 74, + Ruby: 113, + Sapphire: 148, + Steel: 178, +}; + +function cards(n: number, ink: Ink = 'Amber'): LorcanaCard[] { + return IMG_IDS.slice(0, n).map( + (id, i) => + ({ + id: String(id), + name: 'Card', + fullName: `Card ${id}`, + cost: (i % 9) + 1, + ink, + inkwell: true, + type: 'Character', + imageUrl: `/card-images-preview/${id}.avif`, + setCode: '13', + // Anchor on the ink's base so cards fill from the first slot of the board. + setNumber: INK_BASE[ink] + i, + }) as LorcanaCard, + ); +} + +export const Partial: Story = { + args: {ink: 'Amber', cards: cards(20), onOpen: () => {}}, +}; + +export const Complete: Story = { + args: {ink: 'Emerald', cards: cards(34, 'Emerald'), onOpen: () => {}}, +}; + +export const Empty: Story = { + args: {ink: 'Sapphire', cards: []}, +}; + +export const Mobile: Story = { + args: {ink: 'Ruby', cards: cards(20, 'Ruby'), compact: true, onOpen: () => {}}, +}; diff --git a/apps/web/src/features/reveals/CardMosaic.tsx b/apps/web/src/features/reveals/CardMosaic.tsx new file mode 100644 index 00000000..7b579aae --- /dev/null +++ b/apps/web/src/features/reveals/CardMosaic.tsx @@ -0,0 +1,150 @@ +import {useState} from 'react'; +import type {Ink, LorcanaCard} from 'inkweave-synergy-engine'; +import {CardSlot} from './CardSlot'; + +/** Slots per row of the diamond; sums to BOARD_SLOTS (38). Grown from the old 34 + * so each card can sit at its true collector-number position (Set 13 numbers each + * ink across ~37-38 numbers — the 34 app cards plus the excluded enchanted/iconic + * that leave gaps). */ +const ROWS = [4, 7, 8, 8, 7, 4] as const; +const BOARD_SLOTS = 38; + +/** How many random revealed slots burst in on each ink switch. */ +const POP_COUNT = 5; + +/** + * First collector number of each ink's block. A card's slot is `number - base`, + * so the lowest-numbered card lands at (or near) slot 0 and the rest read across + * in true set order, leaving fallback gaps for unrevealed numbers. Amber anchors + * on the set's #1 (so unrevealed low numbers show as leading gaps); the others + * anchor on their first revealed card, since their exact block start isn't + * knowable from the revealed subset alone. + */ +const INK_BASE: Record = { + Amber: 1, + Amethyst: 38, + Emerald: 74, + Ruby: 113, + Sapphire: 148, + Steel: 178, +}; + +function pickRandom(pool: number[], n: number): Set { + const a = [...pool]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return new Set(a.slice(0, n)); +} + +/** + * Lay the revealed cards into the 38 slots by collector number. In-range numbered + * cards (number-base ∈ [0,37]) claim their exact slot. Anything that can't be + * placed absolutely — cards numbered outside the block (e.g. a mis-tagged outlier) + * or with no number at all — fills the remaining gaps from the end so every + * revealed card still shows. + */ +/** The card's absolute slot from its collector number, or -1 if it can't claim one. */ +function slotFor(card: LorcanaCard, base: number): number { + if (card.setNumber == null) return -1; + const slot = card.setNumber - base; + return slot >= 0 && slot < BOARD_SLOTS ? slot : -1; +} + +/** Empty slot indices, highest first — where leftover cards spill in. */ +function emptySlotsFromEnd(placed: (LorcanaCard | undefined)[]): number[] { + const empty: number[] = []; + for (let i = BOARD_SLOTS - 1; i >= 0; i--) { + if (placed[i] == null) empty.push(i); + } + return empty; +} + +function placeCards(ink: Ink, cards: LorcanaCard[]): (LorcanaCard | undefined)[] { + const placed: (LorcanaCard | undefined)[] = new Array(BOARD_SLOTS).fill(undefined); + const base = INK_BASE[ink]; + const leftovers: LorcanaCard[] = []; + + for (const card of cards) { + const slot = slotFor(card, base); + if (slot >= 0 && placed[slot] == null) { + placed[slot] = card; + } else { + leftovers.push(card); + } + } + + // Spill outliers / numberless cards into the remaining gaps from the end. + const empty = emptySlotsFromEnd(placed); + leftovers.forEach((card, i) => { + if (i < empty.length) placed[empty[i]] = card; + }); + return placed; +} + +interface CardMosaicProps { + ink: Ink; + /** Revealed cards for this ink (placed by collector number). */ + cards: LorcanaCard[]; + /** Opens the card modal when a revealed slot is clicked. */ + onOpen?: (card: LorcanaCard) => void; + /** Mobile sizing: 46×64 slots / 5px gaps instead of 58×80 / 7px. */ + compact?: boolean; +} + +/** + * The featured ink board's diamond: six centered rows [4,7,8,8,7,4] of CardSlots + * (BOARD_SLOTS = 38). Each revealed card sits at its true collector-number slot + * (see placeCards), so unrevealed numbers show as fallback gaps and the board + * reads in set order. Lives in an overflow-x rail so it never clips on narrow + * screens. + */ +export function CardMosaic({ink, cards, onOpen, compact = false}: CardMosaicProps) { + const placed = placeCards(ink, cards); + const slotW = compact ? 46 : 58; + const slotH = compact ? 64 : 80; + const gap = compact ? 5 : 7; + + // Pick the random slots to pop once per mount. The parent keys this component + // by ink, so switching colors re-mounts it and a fresh set bursts in each time. + const [poppedSlots] = useState(() => { + const filled: number[] = []; + for (let s = 0; s < BOARD_SLOTS; s++) { + if (placed[s] != null) filled.push(s); + } + return pickRandom(filled, POP_COUNT); + }); + + let slot = 0; + const rows = ROWS.map((width, ri) => { + const cells = []; + for (let k = 0; k < width; k++) { + const s = slot++; + cells.push( + , + ); + } + return ( +
+ {cells} +
+ ); + }); + + return ( +
+
+ {rows} +
+
+ ); +} diff --git a/apps/web/src/features/reveals/CardSlot.stories.tsx b/apps/web/src/features/reveals/CardSlot.stories.tsx new file mode 100644 index 00000000..b1cba790 --- /dev/null +++ b/apps/web/src/features/reveals/CardSlot.stories.tsx @@ -0,0 +1,54 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import type {Ink, LorcanaCard} from 'inkweave-synergy-engine'; +import {CardSlot} from './CardSlot'; + +const meta: Meta = { + title: 'Features/Reveals/CardSlot', + component: CardSlot, + parameters: {backgrounds: {default: 'dark'}}, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +function card(id: number, ink: Ink, cost: number, rarity: string): LorcanaCard { + return { + id: String(id), + name: 'Card', + fullName: `Card ${id}`, + cost, + ink, + inkwell: true, + type: 'Character', + imageUrl: `/card-images-preview/${id}.avif`, + rarity, + setCode: '13', + } as LorcanaCard; +} + +export const Revealed: Story = { + args: {ink: 'Amber', card: card(13002, 'Amber', 3, 'Rare'), onOpen: () => {}}, +}; + +export const Unrevealed: Story = { + args: {ink: 'Amethyst'}, +}; + +export const Mobile: Story = { + args: {ink: 'Ruby', card: card(13129, 'Ruby', 7, 'Super Rare'), width: 46, height: 64, onOpen: () => {}}, +}; + +// A mosaic row mixing revealed (varied rarities) and unrevealed slots. +export const Row: Story = { + render: () => ( +
+ {}} /> + {}} /> + + {}} /> + + {}} /> + {}} /> +
+ ), +}; diff --git a/apps/web/src/features/reveals/CardSlot.tsx b/apps/web/src/features/reveals/CardSlot.tsx new file mode 100644 index 00000000..8b644340 --- /dev/null +++ b/apps/web/src/features/reveals/CardSlot.tsx @@ -0,0 +1,125 @@ +import {useState, type CSSProperties} from 'react'; +import type {Ink, LorcanaCard} from 'inkweave-synergy-engine'; +import {InkIcon} from '../../shared/components/InkIcon'; +import {smallImageUrl} from '../cards/loader'; +import {inkRgba} from './inkTint'; +import './reveals.css'; + +interface CardSlotProps { + ink: Ink; + /** The revealed card. Omit for an unrevealed (placeholder) slot. */ + card?: LorcanaCard; + /** Slot width in px — 58 on desktop, 46 on mobile. */ + width?: number; + /** Slot height in px — 80 on desktop, 64 on mobile. */ + height?: number; + /** Called when a revealed slot is activated (opens the card modal). */ + onOpen?: (card: LorcanaCard) => void; + /** When true, the slot plays the cellPop animation (a switch-in burst). */ + animate?: boolean; +} + +/** + * Revealed tiles are bright with an ink glow so they stand out; unrevealed tiles + * are dimmer (subtler border, recessed shadow) so the board visibly lights up as + * cards drop. Pulled out so the `revealed` branches don't add to CardSlot. + */ +function slotTileStyle(ink: Ink, revealed: boolean, width: number, height: number): CSSProperties { + return { + width, + height, + borderRadius: 6, + position: 'relative', + overflow: 'hidden', + flex: '0 0 auto', + border: `1.5px solid ${inkRgba(ink, revealed ? 0.85 : 0.3)}`, + boxShadow: revealed + ? `0 2px 7px rgba(0, 0, 0, 0.45), 0 0 9px ${inkRgba(ink, 0.4)}` + : 'inset 0 2px 8px rgba(0, 0, 0, 0.55)', + }; +} + +/** The tile contents: ink-art base, the real image (or ink-symbol fallback), and a top sheen. */ +function SlotFace({ink, revealed, imgUrl, compact, onImgError}: {ink: Ink; revealed: boolean; imgUrl?: string; compact: boolean; onImgError: () => void}) { + const art = revealed + ? `radial-gradient(120% 80% at 50% 18%, ${inkRgba(ink, 0.62)}, ${inkRgba(ink, 0.14)} 70%, rgba(8, 8, 14, 0.9))` + : `radial-gradient(120% 80% at 50% 18%, ${inkRgba(ink, 0.26)}, ${inkRgba(ink, 0.06)} 70%, rgba(8, 8, 14, 0.95))`; + return ( + <> + {/* Ink-art base (also the graceful fallback behind a revealed image). */} + + {imgUrl ? ( + + ) : ( + + + + )} + {/* Top sheen. */} + + + ); +} + +/** + * One slot in the diamond mosaic. Every slot is an ink-art tile (radial gradient + * + ink border + glow + top sheen). A revealed slot fills the tile with the real + * card image and opens the card modal on click; an unrevealed slot shows the ink + * symbol. No cost or rarity pips — the slot stays clean. + */ +export function CardSlot({ink, card, width = 58, height = 80, onOpen, animate = false}: CardSlotProps) { + const [imgFailed, setImgFailed] = useState(false); + const compact = width < 54; + const revealed = !!card; + const imgUrl = card && !imgFailed ? smallImageUrl(card) : undefined; + const popClass = animate ? 'reveal-cellpop' : undefined; + const tileStyle = slotTileStyle(ink, revealed, width, height); + const face = ( + setImgFailed(true)} /> + ); + + if (card && onOpen) { + return ( + + ); + } + + return ( +
+ {face} +
+ ); +} diff --git a/apps/web/src/features/reveals/FranchiseCardsModal.stories.tsx b/apps/web/src/features/reveals/FranchiseCardsModal.stories.tsx new file mode 100644 index 00000000..5deaac6b --- /dev/null +++ b/apps/web/src/features/reveals/FranchiseCardsModal.stories.tsx @@ -0,0 +1,49 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import type {LorcanaCard} from 'inkweave-synergy-engine'; +import {FranchiseCardsModal} from './FranchiseCardsModal'; +import {FRANCHISES} from './franchise'; + +const meta: Meta = { + title: 'Features/Reveals/FranchiseCardsModal', + component: FranchiseCardsModal, + parameters: {backgrounds: {default: 'dark'}, layout: 'fullscreen'}, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +const IMG_IDS = [13074, 13075, 13079, 13082, 13083, 13088, 13093, 13095, 13102, 13103, 13108]; + +const cards: LorcanaCard[] = IMG_IDS.map( + (id, i) => + ({ + id: String(id), + name: 'Card', + fullName: `Monsters Card ${id}`, + cost: (i % 9) + 1, + ink: 'Emerald', + inkwell: true, + type: 'Character', + imageUrl: `/card-images-preview/${id}.avif`, + setCode: '13', + setNumber: i + 1, + }) as LorcanaCard, +); + +export const Default: Story = { + args: { + franchise: FRANCHISES[0], + cards, + onClose: () => {}, + onCardClick: () => {}, + }, +}; + +export const Empty: Story = { + args: { + franchise: FRANCHISES[2], + cards: [], + onClose: () => {}, + onCardClick: () => {}, + }, +}; diff --git a/apps/web/src/features/reveals/FranchiseCardsModal.tsx b/apps/web/src/features/reveals/FranchiseCardsModal.tsx new file mode 100644 index 00000000..7b203df5 --- /dev/null +++ b/apps/web/src/features/reveals/FranchiseCardsModal.tsx @@ -0,0 +1,129 @@ +import {useEffect} from 'react'; +import {createPortal} from 'react-dom'; +import type {LorcanaCard} from 'inkweave-synergy-engine'; +import {CardGrid} from '../cards/components/CardGrid'; +import {FONTS, INK_COLORS, Z_INDEX} from '../../shared/constants'; +import {useScrollLock} from '../../shared/hooks'; +import type {FranchiseConfig} from './franchise'; +import {inkRgba} from './inkTint'; + +interface FranchiseCardsModalProps { + franchise: FranchiseConfig; + /** The revealed cards belonging to this franchise. */ + cards: LorcanaCard[]; + onClose: () => void; + /** Opens a card's detail (the shared card modal) on top of this one. */ + onCardClick: (card: LorcanaCard) => void; +} + +/** + * A focused overlay listing one franchise's revealed cards in a grid. Replaces + * the old Tracker/Franchises toggle: a click on a franchise card opens this. + * Dismiss via backdrop click or Escape. Sits one z-index below the shared + * card-detail modal's backdrop so clicking a card layers its detail cleanly on + * top while this stays behind. Portals to body to escape stacking contexts. + */ +export function FranchiseCardsModal({franchise, cards, onClose, onCardClick}: FranchiseCardsModalProps) { + useScrollLock(true); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onClose]); + + const inkText = INK_COLORS[franchise.ink].text; + + return createPortal( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -- backdrop dismiss; Escape handled via document listener +
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- click boundary so panel clicks don't dismiss */} +
e.stopPropagation()} + style={{ + width: '100%', + maxWidth: 960, + maxHeight: '85vh', + display: 'flex', + flexDirection: 'column', + background: '#12121f', + border: `1px solid ${inkRgba(franchise.ink, 0.4)}`, + borderRadius: 16, + overflow: 'hidden', + boxShadow: `0 24px 80px rgba(0, 0, 0, 0.6), 0 0 40px ${inkRgba(franchise.ink, 0.12)}`, + }} + > +
+
+
+ New this set +
+

+ {franchise.label} +

+
+
+ + {cards.length} card{cards.length === 1 ? '' : 's'} + + +
+
+ +
+ +
+
+
, + document.body, + ); +} diff --git a/apps/web/src/features/reveals/FranchiseTier.stories.tsx b/apps/web/src/features/reveals/FranchiseTier.stories.tsx deleted file mode 100644 index fb1037e1..00000000 --- a/apps/web/src/features/reveals/FranchiseTier.stories.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react-vite'; -import {MemoryRouter} from 'react-router-dom'; -import type {LorcanaCard} from 'inkweave-synergy-engine'; -import {FranchiseTier} from './FranchiseTier'; -import type {RevealTier} from './useRevealCards'; - -function make(id: string, name: string, overrides: Partial = {}): LorcanaCard { - return { - id, - name, - version: '', - fullName: name, - cost: 3, - ink: 'Amber', - inkwell: true, - type: 'Character', - classifications: [], - keywords: [], - text: '', - strength: 2, - willpower: 2, - lore: 1, - imageUrl: '', - setCode: '13', - setNumber: 1, - ...overrides, - }; -} - -const monstersIncTier: RevealTier = { - id: 'monsters-inc', - label: 'Monsters, Inc.', - logoUrl: '/art/franchises/monsters-inc.webp', - cards: Array.from({length: 12}, (_, i) => make(`t${i}`, `Toy ${i}`, {ink: 'Amber'})), -}; - -const oneCardTier: RevealTier = { - id: 'turning-red', - label: 'Turning Red', - logoUrl: '/art/franchises/turning-red.webp', - cards: [make('b1', 'Mei', {ink: 'Ruby'})], -}; - -const emptyTier: RevealTier = { - id: 'returning', - label: 'Returning franchises in Attack of the Vine!', - cards: [], -}; - -const meta: Meta = { - title: 'Features/Reveals/FranchiseTier', - component: FranchiseTier, - parameters: {layout: 'fullscreen', backgrounds: {default: 'dark'}}, - decorators: [ - (Story) => ( - - - - ), - ], - tags: ['autodocs'], -}; -export default meta; -type Story = StoryObj; - -export const MonstersIncTier: Story = {args: {tier: monstersIncTier, priorityCount: 6}}; -export const OneCardTier: Story = {args: {tier: oneCardTier}}; -export const EmptyTier: Story = {args: {tier: emptyTier}}; diff --git a/apps/web/src/features/reveals/FranchiseTier.tsx b/apps/web/src/features/reveals/FranchiseTier.tsx deleted file mode 100644 index 96a8d8e8..00000000 --- a/apps/web/src/features/reveals/FranchiseTier.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import {useEffect, useState} from 'react'; -import type {LorcanaCard} from 'inkweave-synergy-engine'; -import {CardGrid} from '../cards/components/CardGrid'; -import {COLORS, FONTS, FONT_SIZES, SPACING} from '../../shared/constants'; -import {useCardModal} from '../../shared/contexts/CardModalContext'; -import type {RevealTier} from './useRevealCards'; - -const ANIMATE_IN_MS = 240; -const ANIMATE_EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)'; - -interface FranchiseTierProps { - tier: RevealTier; - /** Number of initial tiles to load eagerly (priority). Use on the first above-fold tier only. */ - priorityCount?: number; -} - -function prefersReducedMotion(): boolean { - return ( - typeof window !== 'undefined' && - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ); -} - -export function FranchiseTier({tier, priorityCount = 0}: FranchiseTierProps) { - const {openCardModal} = useCardModal(); - const reduced = prefersReducedMotion(); - const [mounted, setMounted] = useState(reduced); - - useEffect(() => { - if (reduced) return; - const id = requestAnimationFrame(() => setMounted(true)); - return () => cancelAnimationFrame(id); - }, [reduced]); - - const handleSelect = (card: LorcanaCard) => openCardModal(card.id); - - return ( -
-
- {tier.logoUrl ? ( - {tier.label} - ) : ( -

- {tier.label} -

- )} - {tier.logoUrl && ( -

- {tier.label} -

- )} -
- -
- ); -} diff --git a/apps/web/src/features/reveals/Hero.stories.tsx b/apps/web/src/features/reveals/Hero.stories.tsx deleted file mode 100644 index 2b850f9d..00000000 --- a/apps/web/src/features/reveals/Hero.stories.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react-vite'; -import {Hero} from './Hero'; - -const meta: Meta = { - title: 'Features/Reveals/Hero', - component: Hero, - parameters: {layout: 'fullscreen', backgrounds: {default: 'dark'}}, - tags: ['autodocs'], -}; -export default meta; -type Story = StoryObj; - -export const PreRelease: Story = { - args: {phase: 'pre-release', days: 17}, -}; - -export const PreReleaseLive: Story = { - args: {phase: 'pre-release-live', days: 5}, -}; - -export const OneDay: Story = { - args: {phase: 'pre-release', days: 1}, -}; - -export const Released: Story = { - args: {phase: 'released', days: 0}, -}; diff --git a/apps/web/src/features/reveals/Hero.tsx b/apps/web/src/features/reveals/Hero.tsx deleted file mode 100644 index 315d2cc4..00000000 --- a/apps/web/src/features/reveals/Hero.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import {useEffect, useState} from 'react'; -import {COLORS, FONTS, FONT_SIZES, SPACING} from '../../shared/constants'; -import {useResponsive} from '../../shared/hooks'; -import type {RevealPhase} from './useRevealPhase'; - -const SET_LOGO = '/art/sets/attack-of-the-vine.png'; -const SUBTITLE = 'New IPs coming to Lorcana'; -const ANIMATE_IN_MS = 240; -const ANIMATE_EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)'; - -interface HeroProps { - phase: RevealPhase; - days: number; -} - -function prefersReducedMotion(): boolean { - return ( - typeof window !== 'undefined' && - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ); -} - -function countdownLabel(phase: RevealPhase, days: number): string | null { - if (phase === 'pre-release') { - return days === 1 ? '1 day until pre-release' : `${days} days until pre-release`; - } - if (phase === 'pre-release-live') { - const tail = days === 1 ? '1 day to wide release' : `${days} days to wide release`; - return `Pre-release live · ${tail}`; - } - return null; -} - -export function Hero({phase, days}: HeroProps) { - const reduced = prefersReducedMotion(); - const {isMobile} = useResponsive(); - const [mounted, setMounted] = useState(reduced); - - useEffect(() => { - if (reduced) return; - const id = requestAnimationFrame(() => setMounted(true)); - return () => cancelAnimationFrame(id); - }, [reduced]); - - const label = countdownLabel(phase, days); - - const countdownStyle: React.CSSProperties = { - fontFamily: FONTS.hero, - fontSize: FONT_SIZES.xxl, - color: COLORS.primary, - textShadow: `0 0 12px ${COLORS.primary}80, 0 0 24px ${COLORS.primary}40`, - letterSpacing: 0.5, - }; - - return ( -
-
- Attack of the Vine! - {label && !isMobile && ( -
- {label} -
- )} - {label && isMobile && ( -
- {label} -
- )} -
-

- {SUBTITLE} -

- -
- ); -} diff --git a/apps/web/src/features/reveals/InkBoard.stories.tsx b/apps/web/src/features/reveals/InkBoard.stories.tsx new file mode 100644 index 00000000..ec1edffd --- /dev/null +++ b/apps/web/src/features/reveals/InkBoard.stories.tsx @@ -0,0 +1,87 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import type {Ink, LorcanaCard} from 'inkweave-synergy-engine'; +import {InkBoard} from './InkBoard'; +import type {InkProgress} from './useRevealProgress'; + +const meta: Meta = { + title: 'Features/Reveals/InkBoard', + component: InkBoard, + parameters: {backgrounds: {default: 'dark'}, layout: 'padded'}, + tags: ['autodocs'], + // The board fills its container; on the real page that's the 1180px-max + // content column, so preview it constrained rather than full-bleed. + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +const IMG_IDS = [ + 13002, 13005, 13006, 13007, 13009, 13017, 13021, 13024, 13026, 13028, 13029, 13030, 13031, 13033, + 13034, 13037, 13038, 13040, 13044, 13049, 13051, 13052, 13058, 13059, 13060, 13061, 13065, 13067, + 13068, 13074, 13075, 13079, 13082, 13083, +]; + +// Each ink's first collector number (mirrors CardMosaic's INK_BASE), so mock +// setNumbers land in-range and the cards place on the 38-slot board. +const INK_BASE: Record = { + Amber: 1, + Amethyst: 38, + Emerald: 74, + Ruby: 113, + Sapphire: 148, + Steel: 178, +}; + +function progress(ink: Ink, count: number, rarityCounts: Record): InkProgress { + const cards = IMG_IDS.slice(0, count).map( + (id, i) => + ({ + id: String(id), + name: 'Card', + fullName: `Card ${id}`, + cost: (i % 9) + 1, + ink, + inkwell: true, + type: 'Character', + imageUrl: `/card-images-preview/${id}.avif`, + setCode: '13', + setNumber: INK_BASE[ink] + i, + }) as LorcanaCard, + ); + return {ink, count, cards, rarityCounts}; +} + +export const Partial: Story = { + args: { + progress: progress('Amber', 20, {common: 6, uncommon: 5, rare: 6, 'super rare': 3, legendary: 1}), + onOpen: () => {}, + }, +}; + +export const Complete: Story = { + args: { + progress: progress('Emerald', 34, {common: 12, uncommon: 9, rare: 8, 'super rare': 3, legendary: 2}), + onOpen: () => {}, + }, +}; + +export const Early: Story = { + args: { + progress: progress('Steel', 8, {common: 2, uncommon: 4, rare: 1, 'super rare': 1, legendary: 0}), + onOpen: () => {}, + }, +}; + +export const Mobile: Story = { + args: { + progress: progress('Ruby', 15, {common: 3, uncommon: 3, rare: 5, 'super rare': 3, legendary: 1}), + compact: true, + onOpen: () => {}, + }, +}; diff --git a/apps/web/src/features/reveals/InkBoard.tsx b/apps/web/src/features/reveals/InkBoard.tsx new file mode 100644 index 00000000..c2641372 --- /dev/null +++ b/apps/web/src/features/reveals/InkBoard.tsx @@ -0,0 +1,153 @@ +import './reveals.css'; +import type {Ink, LorcanaCard} from 'inkweave-synergy-engine'; +import {INK_COLORS, FONTS} from '../../shared/constants'; +import {InkIcon} from '../../shared/components/InkIcon'; +import {CardMosaic} from './CardMosaic'; +import {RarityBreakdown} from './RarityBreakdown'; +import {PER_INK} from './setComposition'; +import {inkRgba} from './inkTint'; +import type {InkProgress} from './useRevealProgress'; + +interface InkBoardProps { + progress: InkProgress; + /** Opens the card modal when a revealed slot is clicked. */ + onOpen?: (card: LorcanaCard) => void; + /** Mobile sizing. */ + compact?: boolean; +} + +/** + * The board header: a floating ink badge, the "INK BOARD" eyebrow + ink name, and + * the `count / 34` with a "COLOR COMPLETE" ribbon at full. Held to its own + * component so its `compact`/`done` branches don't pile onto InkBoard. + */ +function BoardHeader({ink, count, compact}: {ink: Ink; count: number; compact: boolean}) { + const done = count >= PER_INK; + const inkText = INK_COLORS[ink].text; + const badgeSize = compact ? 52 : 70; + const symbolSize = compact ? 32 : 44; + + return ( +
+
+ + + +
+ +
+
+ Ink board +
+
+ {ink} +
+
+ +
+
+ {count} + / {PER_INK} +
+ {done ? ( +
+ Color complete +
+ ) : ( +
+ Revealed so far +
+ )} +
+
+ ); +} + +/** + * The featured board for one ink: a header (badge + name + count, with a + * "COLOR COMPLETE" ribbon at 34), the diamond mosaic, and the rarity breakdown. + * The panel glow and the bloom behind the header both scale with fill (count/34), + * so a fuller board visibly radiates more. + */ +export function InkBoard({progress, onOpen, compact = false}: InkBoardProps) { + const {ink, count, cards, rarityCounts} = progress; + const fill = count / PER_INK; + + return ( +
+ {/* Radial bloom behind the header; intensity scales with fill. */} +
+ +
+ + +
+ {/* key={ink} re-mounts the mosaic on each color switch so a fresh set of + random slots bursts in (see CardMosaic's pop logic). */} + +
+ + +
+
+ ); +} diff --git a/apps/web/src/features/reveals/InkTrackerStrip.stories.tsx b/apps/web/src/features/reveals/InkTrackerStrip.stories.tsx new file mode 100644 index 00000000..c7a73800 --- /dev/null +++ b/apps/web/src/features/reveals/InkTrackerStrip.stories.tsx @@ -0,0 +1,39 @@ +import {useState} from 'react'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import type {Ink} from 'inkweave-synergy-engine'; +import {InkTrackerStrip} from './InkTrackerStrip'; + +const meta: Meta = { + title: 'Features/Reveals/InkTrackerStrip', + component: InkTrackerStrip, + parameters: {backgrounds: {default: 'dark'}, layout: 'padded'}, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +const INKS: {ink: Ink; count: number}[] = [ + {ink: 'Amber', count: 20}, + {ink: 'Amethyst', count: 16}, + {ink: 'Emerald', count: 14}, + {ink: 'Ruby', count: 15}, + {ink: 'Sapphire', count: 14}, + {ink: 'Steel', count: 8}, +]; + +function Interactive({compact}: {compact?: boolean}) { + const [selected, setSelected] = useState('Amethyst'); + return ( +
+ +
+ ); +} + +export const Default: Story = { + render: () => , +}; + +export const Mobile: Story = { + render: () => , +}; diff --git a/apps/web/src/features/reveals/InkTrackerStrip.tsx b/apps/web/src/features/reveals/InkTrackerStrip.tsx new file mode 100644 index 00000000..66d8f225 --- /dev/null +++ b/apps/web/src/features/reveals/InkTrackerStrip.tsx @@ -0,0 +1,38 @@ +import type {Ink} from 'inkweave-synergy-engine'; +import {InkTrackerTile} from './InkTrackerTile'; + +interface InkTrackerStripProps { + /** Per-ink counts in display order (ALL_INKS). */ + inks: {ink: Ink; count: number}[]; + selected: Ink; + onSelect: (ink: Ink) => void; + /** Mobile sizing: fixed 2 columns instead of auto-fit. */ + compact?: boolean; +} + +/** + * The six ink tracker tiles. Wraps responsively (auto-fit on desktop, a fixed + * 2-column grid on mobile); the selected tile features its ink's board below. + */ +export function InkTrackerStrip({inks, selected, onSelect, compact = false}: InkTrackerStripProps) { + return ( +
+ {inks.map(({ink, count}) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/features/reveals/InkTrackerTile.stories.tsx b/apps/web/src/features/reveals/InkTrackerTile.stories.tsx new file mode 100644 index 00000000..2e5fd97f --- /dev/null +++ b/apps/web/src/features/reveals/InkTrackerTile.stories.tsx @@ -0,0 +1,27 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {InkTrackerTile} from './InkTrackerTile'; + +const meta: Meta = { + title: 'Features/Reveals/InkTrackerTile', + component: InkTrackerTile, + parameters: {backgrounds: {default: 'dark'}, layout: 'centered'}, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +export const Selected: Story = { + args: {ink: 'Amethyst', count: 16, selected: true, onSelect: () => {}}, +}; + +export const Unselected: Story = { + args: {ink: 'Amber', count: 20, selected: false, onSelect: () => {}}, +}; + +export const Complete: Story = { + args: {ink: 'Emerald', count: 34, selected: false, onSelect: () => {}}, +}; + +export const Mobile: Story = { + args: {ink: 'Ruby', count: 15, selected: true, compact: true, onSelect: () => {}}, +}; diff --git a/apps/web/src/features/reveals/InkTrackerTile.tsx b/apps/web/src/features/reveals/InkTrackerTile.tsx new file mode 100644 index 00000000..2f7fe0a4 --- /dev/null +++ b/apps/web/src/features/reveals/InkTrackerTile.tsx @@ -0,0 +1,76 @@ +import type {Ink} from 'inkweave-synergy-engine'; +import {INK_COLORS, EASING} from '../../shared/constants'; +import {PER_INK} from './setComposition'; +import {ProgressRing} from './ProgressRing'; +import {inkRgba} from './inkTint'; + +interface InkTrackerTileProps { + ink: Ink; + count: number; + selected: boolean; + onSelect: (ink: Ink) => void; + /** Mobile sizing: 64px ring instead of 82px. */ + compact?: boolean; +} + +/** + * Symmetric (transparent→tinted) glow for the tile: a constant border width plus + * a shadow whose alpha fades in/out so the selection lights up cleanly rather + * than snapping from `none` (whose 0→22px blur growth "pops"). Pulled out so its + * `selected`/`compact` branches don't pile onto the tile's complexity. + */ +function selectionShadow(ink: Ink, selected: boolean, compact: boolean): string { + const blur = compact ? 18 : 22; + const innerBlur = compact ? 14 : 18; + return `0 0 ${blur}px ${inkRgba(ink, selected ? 0.22 : 0)}, inset 0 0 ${innerBlur}px ${inkRgba(ink, selected ? 0.06 : 0)}`; +} + +/** The ink name over its `count / 34` (or a gold "Complete" at 34). */ +function TileLabel({ink, count, selected, compact}: {ink: Ink; count: number; selected: boolean; compact: boolean}) { + const done = count >= PER_INK; + return ( + <> +
+ {ink} +
+
+ {done ? 'Complete' : `${count} / ${PER_INK}`} +
+ + ); +} + +/** + * A selectable tracker tile: the ink's progress ring over its name and + * `count / 34` (or a gold "Complete" at 34). Selecting it features that ink's + * board below; the selected tile gets an ink-tinted border + glow. + */ +export function InkTrackerTile({ink, count, selected, onSelect, compact = false}: InkTrackerTileProps) { + return ( + + ); +} diff --git a/apps/web/src/features/reveals/NewFranchises.stories.tsx b/apps/web/src/features/reveals/NewFranchises.stories.tsx new file mode 100644 index 00000000..0c5a2e6c --- /dev/null +++ b/apps/web/src/features/reveals/NewFranchises.stories.tsx @@ -0,0 +1,31 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {NewFranchises} from './NewFranchises'; + +const meta: Meta = { + title: 'Features/Reveals/NewFranchises', + component: NewFranchises, + parameters: {backgrounds: {default: 'dark'}, layout: 'padded'}, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Mobile: Story = { + args: {compact: true}, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/apps/web/src/features/reveals/NewFranchises.tsx b/apps/web/src/features/reveals/NewFranchises.tsx new file mode 100644 index 00000000..932fb0f6 --- /dev/null +++ b/apps/web/src/features/reveals/NewFranchises.tsx @@ -0,0 +1,115 @@ +import './reveals.css'; +import {FRANCHISES, type FranchiseConfig} from './franchise'; +import {INK_COLORS, FONTS} from '../../shared/constants'; +import {inkRgba} from './inkTint'; + +interface NewFranchisesProps { + /** Opens the franchise's cards modal when its card is clicked. */ + onSelect?: (franchise: FranchiseConfig) => void; + compact?: boolean; +} + +/** + * One debut-franchise card: real key art over an ink-tinted bloom, a "NEW THIS + * SET" pill in that ink, the name, and a blurb. A button that opens the + * franchise's cards modal. Held to its own component so the per-card `compact` + * branches don't pile onto NewFranchises' complexity. + */ +function FranchiseCard({franchise, onSelect, compact}: {franchise: FranchiseConfig; onSelect?: (f: FranchiseConfig) => void; compact: boolean}) { + const inkText = INK_COLORS[franchise.ink].text; + return ( + + ); +} + +/** + * The "new to the Inkverse" section: one card per debut franchise, each opening + * that franchise's cards modal (via `onSelect`). + */ +export function NewFranchises({onSelect, compact = false}: NewFranchisesProps) { + return ( +
+
+
+ New to the Inkverse +
+

+ Three new franchises +

+
+ +
+ {FRANCHISES.map((f) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/features/reveals/ProgressRing.stories.tsx b/apps/web/src/features/reveals/ProgressRing.stories.tsx new file mode 100644 index 00000000..8c599f18 --- /dev/null +++ b/apps/web/src/features/reveals/ProgressRing.stories.tsx @@ -0,0 +1,44 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {ProgressRing} from './ProgressRing'; +import {ALL_INKS} from '../../shared/constants'; + +const meta: Meta = { + title: 'Features/Reveals/ProgressRing', + component: ProgressRing, + parameters: {backgrounds: {default: 'dark'}}, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {ink: 'Amethyst', count: 16}, +}; + +export const Complete: Story = { + args: {ink: 'Emerald', count: 34}, +}; + +export const Mobile: Story = { + args: {ink: 'Amber', count: 20, size: 64}, +}; + +// Representative live Set 13 counts across all six inks. +const COUNTS: Record = { + Amber: 20, + Amethyst: 15, + Emerald: 14, + Ruby: 15, + Sapphire: 14, + Steel: 8, +}; + +export const AllInks: Story = { + render: () => ( +
+ {ALL_INKS.map((ink) => ( + + ))} +
+ ), +}; diff --git a/apps/web/src/features/reveals/ProgressRing.tsx b/apps/web/src/features/reveals/ProgressRing.tsx new file mode 100644 index 00000000..7411460b --- /dev/null +++ b/apps/web/src/features/reveals/ProgressRing.tsx @@ -0,0 +1,56 @@ +import type {Ink} from 'inkweave-synergy-engine'; +import {InkIcon} from '../../shared/components/InkIcon'; +import {PER_INK} from './setComposition'; +import {inkRgb, inkRgba} from './inkTint'; + +interface ProgressRingProps { + ink: Ink; + /** Revealed count for this ink. */ + count: number; + /** Denominator (defaults to the per-ink board size). */ + total?: number; + /** Outer diameter in px — 82 on desktop, 64 on mobile. */ + size?: number; +} + +/** + * Circular progress ring for an ink: a conic sweep filled to `count/total`, + * masked to a thin ring, with the ink symbol in the centre (the running count + * lives on the tile below the ring, so it isn't repeated here). The sweep starts + * at 12 o'clock (`from -90deg`) and the glow tracks the ink colour. + */ +export function ProgressRing({ink, count, total = PER_INK, size = 82}: ProgressRingProps) { + const pct = Math.max(0, Math.min(1, count / total)); + const deg = pct * 360; + const rgb = inkRgb(ink); + const symbolSize = Math.round(size * 0.44); + + return ( +
+
+
+ + + +
+
+ ); +} diff --git a/apps/web/src/features/reveals/RarityBreakdown.stories.tsx b/apps/web/src/features/reveals/RarityBreakdown.stories.tsx new file mode 100644 index 00000000..d920b032 --- /dev/null +++ b/apps/web/src/features/reveals/RarityBreakdown.stories.tsx @@ -0,0 +1,23 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {RarityBreakdown} from './RarityBreakdown'; + +const meta: Meta = { + title: 'Features/Reveals/RarityBreakdown', + component: RarityBreakdown, + parameters: {backgrounds: {default: 'dark'}, layout: 'padded'}, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +export const Partial: Story = { + args: {rarityCounts: {common: 6, uncommon: 5, rare: 6, 'super rare': 3, legendary: 1}}, +}; + +export const Complete: Story = { + args: {rarityCounts: {common: 12, uncommon: 9, rare: 8, 'super rare': 3, legendary: 2}}, +}; + +export const Mobile: Story = { + args: {rarityCounts: {common: 4, uncommon: 3, rare: 2, 'super rare': 1, legendary: 0}, compact: true}, +}; diff --git a/apps/web/src/features/reveals/RarityBreakdown.tsx b/apps/web/src/features/reveals/RarityBreakdown.tsx new file mode 100644 index 00000000..3f40dfb7 --- /dev/null +++ b/apps/web/src/features/reveals/RarityBreakdown.tsx @@ -0,0 +1,72 @@ +import {RARITIES, type RarityConfig} from './rarity'; +import {RaritySymbol} from './RaritySymbol'; + +interface RarityBreakdownProps { + /** Revealed count per rarity key (from useRevealProgress). */ + rarityCounts: Record; + /** Mobile sizing: fixed 3 columns / smaller gems. */ + compact?: boolean; +} + +/** + * One vertical stat chip: the rarity symbol over the revealed count over the + * name. Held to its own component so the per-chip `has`/`compact` conditionals + * don't pile onto RarityBreakdown's complexity. + */ +function RarityChip({rarity, revealed, compact}: {rarity: RarityConfig; revealed: number; compact: boolean}) { + const has = revealed > 0; + return ( +
+
+ +
+
+ {revealed} +
+
+ {rarity.name} +
+
+ ); +} + +/** + * Per-rarity tally for an ink board: each of the five rarities shows its real + * symbol, the actual number of revealed cards of that rarity, and its name. The + * count is the live tally from the data (no hardcoded total / denominator — the + * set's real composition differs from the old 12/9/8/3/2 assumption). + * + * The divider spans the full board, but the five chips are held to a centered + * band so they read as a tidy row. + */ +export function RarityBreakdown({rarityCounts, compact = false}: RarityBreakdownProps) { + return ( +
+
+ {RARITIES.map((r) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/features/reveals/RaritySymbol.stories.tsx b/apps/web/src/features/reveals/RaritySymbol.stories.tsx new file mode 100644 index 00000000..a6c147f0 --- /dev/null +++ b/apps/web/src/features/reveals/RaritySymbol.stories.tsx @@ -0,0 +1,39 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {RaritySymbol} from './RaritySymbol'; +import {RARITIES} from './rarity'; + +const meta: Meta = { + title: 'Features/Reveals/RaritySymbol', + component: RaritySymbol, + parameters: {backgrounds: {default: 'dark'}}, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {rarity: 'super rare', size: 32}, +}; + +export const AllRarities: Story = { + render: () => ( +
+ {RARITIES.map((r) => ( +
+ + + {r.name} + +
+ ))} +
+ ), +}; diff --git a/apps/web/src/features/reveals/RaritySymbol.tsx b/apps/web/src/features/reveals/RaritySymbol.tsx new file mode 100644 index 00000000..bb56d92c --- /dev/null +++ b/apps/web/src/features/reveals/RaritySymbol.tsx @@ -0,0 +1,41 @@ +import commonSvg from '../../assets/common.svg'; +import uncommonSvg from '../../assets/uncommon.svg'; +import rareSvg from '../../assets/rare.svg'; +import superRareSvg from '../../assets/super_rare.svg'; +import legendarySvg from '../../assets/legendary.svg'; + +/** Real Lorcana rarity symbols, keyed by `card.rarity` (lowercased — see rarity.ts). */ +const RARITY_SYMBOLS: Record = { + common: commonSvg, + uncommon: uncommonSvg, + rare: rareSvg, + 'super rare': superRareSvg, + legendary: legendarySvg, +}; + +interface RaritySymbolProps { + /** Rarity key (e.g. "super rare"). */ + rarity: string; + /** Box size in px; the symbol is fit inside without distortion. */ + size: number; +} + +/** + * The official rarity glyph for a card. Renders the vendored SVG as-is + * (monochrome for common/uncommon, the detailed gem artwork for the rest), + * fit inside a square box so mixed aspect ratios share a footprint. + */ +export function RaritySymbol({rarity, size}: RaritySymbolProps) { + const src = RARITY_SYMBOLS[rarity]; + if (!src) return null; + return ( + + ); +} diff --git a/apps/web/src/features/reveals/RevealHero.stories.tsx b/apps/web/src/features/reveals/RevealHero.stories.tsx new file mode 100644 index 00000000..626f8bd2 --- /dev/null +++ b/apps/web/src/features/reveals/RevealHero.stories.tsx @@ -0,0 +1,19 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {RevealHero} from './RevealHero'; + +const meta: Meta = { + title: 'Features/Reveals/RevealHero', + component: RevealHero, + parameters: {backgrounds: {default: 'dark'}, layout: 'fullscreen'}, + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {countdownDays: 21, releaseDate: 'July 24, 2026', totalRevealed: 86, franchiseCount: 3}, +}; + +export const Mobile: Story = { + args: {countdownDays: 21, releaseDate: 'July 24, 2026', totalRevealed: 86, franchiseCount: 3, compact: true}, +}; diff --git a/apps/web/src/features/reveals/RevealHero.tsx b/apps/web/src/features/reveals/RevealHero.tsx new file mode 100644 index 00000000..9ac37492 --- /dev/null +++ b/apps/web/src/features/reveals/RevealHero.tsx @@ -0,0 +1,119 @@ +import {SET_TOTAL} from './setComposition'; + +const SET_LOGO = '/art/sets/attack-of-the-vine.png'; + +interface RevealHeroProps { + /** Days until pre-release. */ + countdownDays: number; + /** Formatted release date, e.g. "July 24, 2026". */ + releaseDate: string; + /** Unique cards revealed so far. */ + totalRevealed: number; + /** New franchises this set. */ + franchiseCount: number; + /** Total cards in the set (denominator). */ + totalCards?: number; + compact?: boolean; +} + +/** + * The reveals page header: the set logo over two stat panels (a gold countdown + * panel and a neutral revealed/franchises panel). Deliberately minimal — no + * intro copy or progress bar; the six trackers below carry the progress story. + */ +export function RevealHero({ + countdownDays, + releaseDate, + totalRevealed, + franchiseCount, + totalCards = SET_TOTAL, + compact = false, +}: RevealHeroProps) { + const labelStyle = { + fontWeight: 500, + fontSize: compact ? 9 : 11, + letterSpacing: 1.4, + textTransform: 'uppercase' as const, + color: '#90a1b9', + marginTop: 5, + }; + const bigStat = {fontWeight: 700, fontSize: compact ? 24 : 30, lineHeight: 1}; + + return ( +
+ Attack of the Vine! + +
+ {/* Countdown panel */} +
+
+
+ {countdownDays} + days +
+
Until pre-release
+
+
+
+
{releaseDate}
+
Set release
+
+
+ + {/* Revealed + franchises panel */} +
+
+
+ {totalRevealed} + / {totalCards} +
+
Cards revealed
+
+
+
{franchiseCount}
+
New franchises
+
+
+
+
+ ); +} diff --git a/apps/web/src/features/reveals/__tests__/useRevealProgress.test.ts b/apps/web/src/features/reveals/__tests__/useRevealProgress.test.ts new file mode 100644 index 00000000..ded22290 --- /dev/null +++ b/apps/web/src/features/reveals/__tests__/useRevealProgress.test.ts @@ -0,0 +1,72 @@ +import {describe, it, expect, vi} from 'vitest'; +import {renderHook} from '@testing-library/react'; +import type {LorcanaCard} from 'inkweave-synergy-engine'; +import {useRevealProgress} from '../useRevealProgress'; + +vi.mock('../../../shared/contexts/CardDataContext', () => ({ + useCardDataContext: () => ({cards: mockCards, isLoading: false, error: null}), +})); + +function make(id: string, overrides: Partial = {}): LorcanaCard { + return { + id, + name: id, + version: '', + fullName: id, + cost: 1, + ink: 'Amber', + inkwell: true, + type: 'Character', + classifications: [], + keywords: [], + text: '', + strength: 1, + willpower: 1, + lore: 1, + imageUrl: '', + setCode: '13', + setNumber: 1, + ...overrides, + }; +} + +const mockCards: LorcanaCard[] = [ + make('a1', {ink: 'Amber', rarity: 'Common'}), + make('a2', {ink: 'Amber', rarity: 'Super Rare'}), + make('e1', {ink: 'Emerald', rarity: 'Rare'}), + make('d1', {ink: 'Amber', ink2: 'Emerald', rarity: 'Legendary'}), // dual-ink + make('s1', {ink: 'Steel', rarity: undefined}), // revealed but no rarity yet + make('x1', {ink: 'Ruby', setCode: '11'}), // not Set 13 → excluded +]; + +describe('useRevealProgress', () => { + it('sums per-ink counts to the unique total revealed (Set 13 only)', () => { + const {result} = renderHook(() => useRevealProgress()); + const {inks, totalRevealed} = result.current; + expect(totalRevealed).toBe(5); // a1, a2, e1, d1, s1 — x1 excluded + expect(inks.reduce((s, p) => s + p.count, 0)).toBe(totalRevealed); + expect(result.current.byInk.Ruby.count).toBe(0); // x1 is set 11 + }); + + it('buckets a dual-ink card to its primary ink only', () => { + const {result} = renderHook(() => useRevealProgress()); + // d1 is Amber-Emerald → counts toward Amber (a1, a2, d1 = 3), not Emerald (e1 = 1). + expect(result.current.byInk.Amber.count).toBe(3); + expect(result.current.byInk.Emerald.count).toBe(1); + }); + + it('tallies rarity from the real card.rarity, skipping cards without one', () => { + const {result} = renderHook(() => useRevealProgress()); + expect(result.current.byInk.Amber.rarityCounts).toEqual({ + common: 1, + 'super rare': 1, + legendary: 1, + }); + expect(result.current.byInk.Steel.rarityCounts).toEqual({}); // s1 has no rarity + }); + + it('derives overallPct from the unique total against the 204-card set', () => { + const {result} = renderHook(() => useRevealProgress()); + expect(result.current.overallPct).toBe(Math.round((5 / 204) * 100)); // 2 + }); +}); diff --git a/apps/web/src/features/reveals/franchise.ts b/apps/web/src/features/reveals/franchise.ts index 5032d6e8..88751067 100644 --- a/apps/web/src/features/reveals/franchise.ts +++ b/apps/web/src/features/reveals/franchise.ts @@ -1,4 +1,4 @@ -import type {LorcanaCard} from 'inkweave-synergy-engine'; +import type {Ink, LorcanaCard} from 'inkweave-synergy-engine'; export type FranchiseId = 'monsters-inc' | 'up' | 'turning-red'; @@ -7,12 +7,38 @@ export interface FranchiseConfig { label: string; /** Value to match against `card.franchise`. */ match: string; + /** + * Canonical ink used to tint the franchise card's bloom glow on the reveals + * page. These franchises span multiple inks in the data, so this is a curated + * association (design intent), not derived from the card pool. + */ + ink: Ink; + /** One-line description shown on the reveals page's new-franchises card. */ + blurb: string; } export const FRANCHISES: readonly FranchiseConfig[] = [ - {id: 'monsters-inc', label: 'Monsters, Inc.', match: 'Monsters, Inc.'}, - {id: 'up', label: 'Up', match: 'Up'}, - {id: 'turning-red', label: 'Turning Red', match: 'Turning Red'}, + { + id: 'monsters-inc', + label: 'Monsters, Inc.', + match: 'Monsters, Inc.', + ink: 'Emerald', + blurb: 'Sulley, Mike and the laughter that powers a whole city.', + }, + { + id: 'up', + label: 'Up', + match: 'Up', + ink: 'Sapphire', + blurb: 'Carl, Russell and the house that flew on a thousand balloons.', + }, + { + id: 'turning-red', + label: 'Turning Red', + match: 'Turning Red', + ink: 'Ruby', + blurb: 'Mei Lee, her friends, and the red panda within.', + }, ]; /** diff --git a/apps/web/src/features/reveals/index.ts b/apps/web/src/features/reveals/index.ts index b736d8d9..a5b19c8c 100644 --- a/apps/web/src/features/reveals/index.ts +++ b/apps/web/src/features/reveals/index.ts @@ -9,6 +9,14 @@ export type {RevealDates} from './revealDates'; export {RevealsGate} from './RevealsGate'; export {useRevealCards} from './useRevealCards'; export type {RevealTier, UseRevealCardsReturn} from './useRevealCards'; -export {Hero} from './Hero'; -export {FranchiseTier} from './FranchiseTier'; +export {useRevealProgress} from './useRevealProgress'; +export type {RevealProgress, InkProgress} from './useRevealProgress'; +export {PER_INK, SET_TOTAL} from './setComposition'; +export {RARITIES, rarityConfigOf} from './rarity'; +export type {RarityConfig} from './rarity'; export {RevealsPromoCard} from './RevealsPromoCard'; +export {RevealHero} from './RevealHero'; +export {InkTrackerStrip} from './InkTrackerStrip'; +export {InkBoard} from './InkBoard'; +export {NewFranchises} from './NewFranchises'; +export {FranchiseCardsModal} from './FranchiseCardsModal'; diff --git a/apps/web/src/features/reveals/inkTint.ts b/apps/web/src/features/reveals/inkTint.ts new file mode 100644 index 00000000..28704f41 --- /dev/null +++ b/apps/web/src/features/reveals/inkTint.ts @@ -0,0 +1,26 @@ +import type {Ink} from 'inkweave-synergy-engine'; +import {INK_COLORS} from '../../shared/constants'; + +/** + * The reveals design tints everything with `rgba(inkRGB, α)` built from each + * ink's canonical colour — which is exactly `INK_COLORS[ink].border` (the same + * hex the design handoff lists as the "border = canonical RGB"). These helpers + * derive those tints from the theme so the colours stay single-sourced. + */ + +/** "245, 158, 11" — the ink's canonical RGB triplet (from its theme border hex). */ +export function inkRgb(ink: Ink): string { + const hex = INK_COLORS[ink].border.replace('#', ''); + const n = parseInt(hex, 16); + return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`; +} + +/** `rgba(r, g, b, α)` tint for the ink. */ +export function inkRgba(ink: Ink, alpha: number): string { + return `rgba(${inkRgb(ink)}, ${alpha})`; +} + +/** `rgb(r, g, b)` solid for the ink. */ +export function inkRgbSolid(ink: Ink): string { + return `rgb(${inkRgb(ink)})`; +} diff --git a/apps/web/src/features/reveals/rarity.ts b/apps/web/src/features/reveals/rarity.ts new file mode 100644 index 00000000..6d4c0265 --- /dev/null +++ b/apps/web/src/features/reveals/rarity.ts @@ -0,0 +1,28 @@ +export interface RarityConfig { + /** Lowercased match key against `card.rarity` (e.g. "super rare"). */ + key: string; + name: string; +} + +/** + * The five in-app rarities, in display order. Enchanted and Iconic exist in print + * but are not in the Inkweave card database, so they are excluded from the board + * and breakdown. `key` is compared case-insensitively against `card.rarity`; the + * glyph is rendered by RaritySymbol from the vendored SVGs. The board's breakdown + * shows actual revealed counts per rarity (the set's real composition differs + * from the old 12/9/8/3/2 assumption), so no per-rarity total is stored here. + */ +export const RARITIES: readonly RarityConfig[] = [ + {key: 'common', name: 'Common'}, + {key: 'uncommon', name: 'Uncommon'}, + {key: 'rare', name: 'Rare'}, + {key: 'super rare', name: 'Super rare'}, + {key: 'legendary', name: 'Legendary'}, +]; + +/** Look up a rarity config from a raw `card.rarity` string (case-insensitive). */ +export function rarityConfigOf(rarity: string | undefined): RarityConfig | undefined { + if (!rarity) return undefined; + const key = rarity.trim().toLowerCase(); + return RARITIES.find((r) => r.key === key); +} diff --git a/apps/web/src/features/reveals/reveals.css b/apps/web/src/features/reveals/reveals.css new file mode 100644 index 00000000..afa8e4e5 --- /dev/null +++ b/apps/web/src/features/reveals/reveals.css @@ -0,0 +1,64 @@ +/* Reveals-page animations. Prefixed to avoid clashing with global keyframes. */ + +@keyframes reveal-floatY { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-4px); + } +} + +@keyframes reveal-ribbonShine { + 0% { + background-position: -180% 0; + } + 100% { + background-position: 280% 0; + } +} + +/* A card slot popping in — used for the burst of random cards on each ink switch. */ +@keyframes reveal-cellPop { + 0% { + transform: scale(0.5); + opacity: 0; + } + 60% { + transform: scale(1.08); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.reveal-cellpop { + animation: reveal-cellPop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; +} + +/* Clickable new-franchise card: border + shadow live here (not inline) so the + hover state can override them. */ +.reveal-franchise-card { + cursor: pointer; + border: 1px solid #24243a; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} +.reveal-franchise-card:hover { + transform: translateY(-3px); + border-color: #3a3a55; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55); +} +.reveal-franchise-card:focus-visible { + outline: 2px solid #d4af37; + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .reveal-anim, + .reveal-cellpop { + animation: none !important; + } +} diff --git a/apps/web/src/features/reveals/setComposition.ts b/apps/web/src/features/reveals/setComposition.ts new file mode 100644 index 00000000..ad8b1117 --- /dev/null +++ b/apps/web/src/features/reveals/setComposition.ts @@ -0,0 +1,14 @@ +/** + * Set-composition constants for the reveals tracker. + * + * A Lorcana Core set is 204 cards. The tracker stylizes this as six per-ink + * boards of 34 slots each (6 × 34 = 204). These are fixed denominators: the + * board renders 34 slots per ink and fills them as cards reveal, so the page + * works correctly even mid-season with only part of the set revealed. + * + * If a completed set ever diverges from this canonical split, revisit these + * (and the per-rarity totals in rarity.ts) rather than deriving from the live, + * incomplete card data. + */ +export const PER_INK = 34; +export const SET_TOTAL = 204; // PER_INK * 6 diff --git a/apps/web/src/features/reveals/useRevealProgress.ts b/apps/web/src/features/reveals/useRevealProgress.ts new file mode 100644 index 00000000..77c91490 --- /dev/null +++ b/apps/web/src/features/reveals/useRevealProgress.ts @@ -0,0 +1,80 @@ +import type {Ink, LorcanaCard} from 'inkweave-synergy-engine'; +import {useCardDataContext} from '../../shared/contexts/CardDataContext'; +import {ALL_INKS} from '../../shared/constants'; +import {PER_INK, SET_TOTAL} from './setComposition'; +import {rarityConfigOf} from './rarity'; + +const REVEAL_SET_CODE = '13'; + +export interface InkProgress { + ink: Ink; + /** Revealed cards counting toward this ink board, capped at PER_INK. */ + count: number; + /** The revealed cards bucketed to this ink (uncapped; drives the mosaic slots). */ + cards: LorcanaCard[]; + /** Revealed count per rarity key (see rarity.ts), from each card's real rarity. */ + rarityCounts: Record; +} + +export interface RevealProgress { + byInk: Record; + /** ALL_INKS order, for rendering the six trackers/segments deterministically. */ + inks: InkProgress[]; + /** Unique revealed cards in the set (drives the hero "cards revealed" stat). */ + totalRevealed: number; + /** round(totalRevealed / SET_TOTAL * 100). */ + overallPct: number; + loading: boolean; +} + +/** + * Which ink board(s) a card counts toward. + * + * Primary-ink only: a dual-ink card (e.g. the Set 13 Team cards "Amber-Emerald") + * counts toward its first ink alone. This keeps the six 34-slot boards summing + * cleanly to SET_TOTAL (6 × 34 = 204) and each revealed card in exactly one + * board. To instead show dual-ink cards in both boards, return + * `card.ink2 ? [card.ink, card.ink2] : [card.ink]` — but note that breaks the + * 34/ink denominators and double-counts in per-ink sums. + */ +function cardInks(card: LorcanaCard): Ink[] { + return [card.ink]; +} + +/** + * Per-ink Set 13 reveal progress derived from the real card data. Replaces the + * prototype's synthetic `revealPct`. Drives the rings, the diamond mosaic fill, + * the rarity breakdown, the hero stat, and the overall progress bar — all from + * one source so they never disagree. + */ +export function useRevealProgress(): RevealProgress { + const {cards, isLoading} = useCardDataContext(); + + const revealed = cards.filter((c) => c.setCode === REVEAL_SET_CODE); + + const byInk = {} as Record; + for (const ink of ALL_INKS) { + byInk[ink] = {ink, count: 0, cards: [], rarityCounts: {}}; + } + + for (const card of revealed) { + for (const ink of cardInks(card)) { + const bucket = byInk[ink]; + if (!bucket) continue; + bucket.cards.push(card); + const rarity = rarityConfigOf(card.rarity); + if (rarity) { + bucket.rarityCounts[rarity.key] = (bucket.rarityCounts[rarity.key] ?? 0) + 1; + } + } + } + for (const ink of ALL_INKS) { + byInk[ink].count = Math.min(byInk[ink].cards.length, PER_INK); + } + + const inks = ALL_INKS.map((ink) => byInk[ink]); + const totalRevealed = revealed.length; + const overallPct = Math.round((totalRevealed / SET_TOTAL) * 100); + + return {byInk, inks, totalRevealed, overallPct, loading: isLoading}; +} diff --git a/apps/web/src/pages/RevealsPage.tsx b/apps/web/src/pages/RevealsPage.tsx index b9aa7646..cac936c3 100644 --- a/apps/web/src/pages/RevealsPage.tsx +++ b/apps/web/src/pages/RevealsPage.tsx @@ -1,24 +1,107 @@ import {useEffect, useState} from 'react'; +import type {Ink, LorcanaCard} from 'inkweave-synergy-engine'; import {CompactHeader, ErrorBoundary, EtherealBackground} from '../shared/components'; import {COLORS, FONTS, FONT_SIZES, SPACING} from '../shared/constants'; import {useResponsive} from '../shared/hooks'; +import {useCardModal} from '../shared/contexts/CardModalContext'; import { - FranchiseTier, - Hero, + FRANCHISES, + FranchiseCardsModal, + InkBoard, + InkTrackerStrip, + NewFranchises, + RevealHero, fetchRevealDates, useCountdown, useRevealCards, - useRevealPhase, + useRevealProgress, + type FranchiseConfig, type RevealDates, + type RevealProgress, + type RevealTier, } from '../features/reveals'; -const FIRST_TIER_PRIORITY_COUNT = 6; +const CONTENT_MAX_WIDTH = 1180; + +function formatReleaseDate(date: Date): string { + return date.toLocaleDateString('en-US', {month: 'long', day: 'numeric'}); +} + +/** The revealed cards for a franchise (empty when none is selected). */ +function cardsForFranchise(tiers: RevealTier[], franchise: FranchiseConfig | null): LorcanaCard[] { + if (!franchise) return []; + return tiers.find((t) => t.id === franchise.id)?.cards ?? []; +} + +const srOnly: React.CSSProperties = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, +}; + +const messageStyle: React.CSSProperties = { + padding: SPACING.lg, + textAlign: 'center', + fontFamily: FONTS.body, + fontSize: FONT_SIZES.base, +}; + +interface RevealsBodyProps { + loading: boolean; + error: Error | null; + progress: RevealProgress; + selectedInk: Ink; + onSelectInk: (ink: Ink) => void; + onOpen: (card: LorcanaCard) => void; + onSelectFranchise: (franchise: FranchiseConfig) => void; + compact: boolean; +} + +/** + * The page body below the hero: an error/loading message, or the tracker (ink + * strip + featured board + new-franchises). Early returns keep its branches off + * RevealsPage. + */ +function RevealsBody({loading, error, progress, selectedInk, onSelectInk, onOpen, onSelectFranchise, compact}: RevealsBodyProps) { + if (error) { + return ( +

+ Could not load reveal cards. Please try again later. +

+ ); + } + if (loading) { + return

Loading reveal cards…

; + } + return ( + <> +
+ +
+
+ +
+
+ +
+ + ); +} export function RevealsPage() { const {isMobile} = useResponsive(); - const phase = useRevealPhase(); const {tiers, loading, error} = useRevealCards(); + const progress = useRevealProgress(); + const {openCardModal} = useCardModal(); const [dates, setDates] = useState(null); + const [selectedInk, setSelectedInk] = useState('Amber'); + const [selectedFranchise, setSelectedFranchise] = useState(null); useEffect(() => { let cancelled = false; @@ -30,54 +113,46 @@ export function RevealsPage() { }; }, []); - const target = dates - ? phase === 'pre-release' - ? dates.prereleaseDate - : dates.releaseDate - : null; - const {days} = useCountdown(target); + const {days} = useCountdown(dates?.prereleaseDate ?? null); + const releaseDate = dates ? formatReleaseDate(dates.releaseDate) : ''; + const handleOpen = (card: LorcanaCard) => openCardModal(card.id); + const sidePad = isMobile ? SPACING.lg : 36; return ( -
- - {error && ( -

- Could not load reveal cards. Please try again later. -

- )} - {loading && !error && ( -

- Loading reveal cards… -

- )} - {!loading && - !error && - tiers.map((tier, index) => ( - - ))} +
+

Attack of the Vine — Set 13 reveals

+
+ + +
+ + {selectedFranchise && ( + setSelectedFranchise(null)} + onCardClick={handleOpen} + /> + )} ); } diff --git a/packages/synergy-engine/src/types/card.ts b/packages/synergy-engine/src/types/card.ts index 507badcb..4e10716f 100644 --- a/packages/synergy-engine/src/types/card.ts +++ b/packages/synergy-engine/src/types/card.ts @@ -36,4 +36,5 @@ export interface LorcanaCard { setCode?: string; setNumber?: number; franchise?: string; // Set only on preview cards (e.g., "Toy Story", "The Incredibles", "Brave") + rarity?: string; // "Common" | "Uncommon" | "Rare" | "Super Rare" | "Legendary" | "Enchanted" } diff --git a/packages/synergy-engine/src/utils/cardTransformer.ts b/packages/synergy-engine/src/utils/cardTransformer.ts index fac49468..662ca1cf 100644 --- a/packages/synergy-engine/src/utils/cardTransformer.ts +++ b/packages/synergy-engine/src/utils/cardTransformer.ts @@ -131,6 +131,7 @@ export function transformCard(raw: LorcanaJSONCard): LorcanaCard | null { setCode: raw.setCode, setNumber: raw.number, franchise: raw.franchise, + rarity: raw.rarity, }; } diff --git a/scripts/convert-preview-images.mjs b/scripts/convert-preview-images.mjs index 0118a394..2d68ac34 100644 --- a/scripts/convert-preview-images.mjs +++ b/scripts/convert-preview-images.mjs @@ -33,9 +33,21 @@ const QUALITY = 50; const ACCEPTED_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp']); async function convert(id, srcPath) { + // Location cards print landscape (wider than tall); the slots and grid are + // portrait, so rotate landscape sources 270° (counter-clockwise) before the + // portrait resize. Otherwise `fit: cover` crops them to a sideways centre + // strip. Character/item/action sources are already portrait and pass through + // untouched. The rotation is baked into the AVIF (not applied at display time) + // and the production build copies these AVIFs byte-for-byte, so it carries to + // prod without any further handling. + const meta = await sharp(srcPath).metadata(); + const isLandscape = (meta.width ?? 0) > (meta.height ?? 0); + for (const s of SIZES) { const outPath = path.join(OUT_DIR, `${id}${s.suffix}.avif`); - await sharp(srcPath) + const pipeline = sharp(srcPath); + if (isLandscape) pipeline.rotate(270); + await pipeline .resize(s.width, s.height, {fit: 'cover'}) .avif({quality: QUALITY}) .toFile(outPath);