Skip to main content

Wings Picker Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace the flat card-grid SpecialistWingsGallery with a picker-style WingsShowcase component following the SP Picker / Pet Showcase pattern.

Architecture: New wingsPicker/ directory with WingsShowcase.tsx (component) and WingsShowcase.module.css (styles). The component renders a filter bar, a scrollable icon grid for selection, and a detail panel below. Reuses existing data types (SpecialistWingsRowDefinition) and shared utilities (useQueryId, getItemById, getIconLink, ItemLink, CardLink).

Tech Stack: React, TypeScript, CSS Modules, Docusaurus MDX


Task 1: Create CSS Module

Files:

  • Create: src/components/wingsPicker/WingsShowcase.module.css

Step 1: Write the CSS module

Follow the shared picker design system (see PetShowcase.module.css, CostumeWingsShowcase.module.css). Use cyan/purple gradient for identity.

.page {
background: linear-gradient(135deg, rgba(103, 232, 249, 0.08), rgba(192, 132, 252, 0.08));
border: 1px solid var(--oly-card-border);
border-radius: 18px;
padding: 16px;
}

.title {
font-size: 18px;
font-weight: 700;
color: var(--ifm-heading-color);
}

.subtitle {
color: var(--ifm-color-emphasis-600);
font-size: 12px;
}

.panel {
background: var(--oly-card-bg);
border: 1px solid var(--oly-card-border);
border-radius: 14px;
padding: 12px;
}

.input {
border: 1px solid var(--oly-input-border);
background: var(--oly-input-bg);
color: var(--oly-input-text);
border-radius: 999px;
height: 36px;
padding: 0 14px;
font-size: 13px;
}

.pill {
font-size: .75rem;
font-weight: 600;
padding: .35rem .75rem;
border-radius: 999px;
border: 1px solid var(--oly-card-border);
background: transparent;
color: var(--ifm-color-emphasis-600);
cursor: pointer;
transition: all .15s ease;
}

.pill:hover {
border-color: var(--ifm-color-emphasis-300);
}

.pillActive {
border-color: var(--ifm-color-primary);
background: var(--ifm-color-primary);
color: white;
}

.card {
border: 1px solid transparent;
border-radius: 12px;
padding: 10px;
transition: border-color 0.15s ease, background-color 0.15s ease;
}

.card:hover {
border-color: var(--ifm-color-emphasis-300);
background: var(--ifm-color-emphasis-200);
}

.cardSelected {
border-color: var(--ifm-color-primary);
background: var(--ifm-color-emphasis-200);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}

.sectionTitle {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--ifm-color-emphasis-600);
}

.previewFrame {
width: 100%;
aspect-ratio: 1;
max-width: 320px;
border-radius: 12px;
background: var(--ifm-background-surface-color);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
}

.previewFrame img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}

.buffBadge,
.visualBadge {
font-size: .7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: .25rem .5rem;
border-radius: .4rem;
line-height: 1;
white-space: nowrap;
display: inline-block;
}

.buffBadge {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white;
}

.visualBadge {
background: linear-gradient(135deg, #a855f7 0%, #7c3aed 100%);
color: white;
}

.obtainList {
display: flex;
gap: .35rem;
flex-wrap: wrap;
}

.obtainBadge {
font-size: .7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: .35rem .6rem;
background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
color: white;
border-radius: .4rem;
line-height: 1;
white-space: nowrap;
text-decoration: none;
display: inline-block;
}

[data-theme='dark'] .obtainBadge {
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
}

.obtainBadgeClickable {
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}

.obtainBadgeClickable:hover {
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}

.actionButtons {
display: flex;
flex-wrap: wrap;
gap: .5rem;
margin-top: .75rem;
}

.shopButton {
font-size: .75rem;
font-weight: 600;
color: white;
padding: .5rem 1rem;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border-radius: .5rem;
border: none;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: .4rem;
transition: all .2s ease;
}

.shopButton:hover {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
transform: translateY(-1px);
color: white;
}

.fortuneWheelButton {
font-size: .75rem;
font-weight: 600;
color: white;
padding: .5rem 1rem;
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
border-radius: .5rem;
border: none;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all .2s ease;
box-shadow: 0 1px 4px rgba(139, 92, 246, 0.35);
}

.fortuneWheelButton:hover {
background: linear-gradient(135deg, #9d6fff 0%, #8b5cf6 100%);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(139, 92, 246, 0.5);
color: white;
}

.coinIcon {
width: 16px;
height: 16px;
flex-shrink: 0;
}

.divider {
border: none;
border-top: 1px solid var(--oly-card-border);
margin: .5rem 0;
}

Step 2: Commit

git add src/components/wingsPicker/WingsShowcase.module.css
git commit -m "feat(wings): add WingsShowcase CSS module"

Task 2: Create WingsShowcase Component

Files:

  • Create: src/components/wingsPicker/WingsShowcase.tsx

Reference files (read before implementing):

  • src/components/petPicker/PetShowcase.tsx — overall structure pattern
  • src/components/spPicker/spPicker.tsxSpecialistSelection grid pattern
  • src/components/tables/SpecialistWingsGallery.tsx — detail content (badges, action buttons)
  • src/components/tables/SpecialistWingsTable.tsxSpecialistWingsRowDefinition type
  • src/components/utils.tsxuseQueryId, getIconLink
  • src/components/items/Item.tsxgetItemById, ItemLink
  • src/components/cards/Card.tsxCardLink

Step 1: Write the component

import React, { useRef, useState, useMemo } from "react"
import { getItemById, ItemLink } from "../items/Item"
import { CardLink } from "../cards/Card"
import { getIconLink, useQueryId } from "../utils"
import { SpecialistWingsRowDefinition } from "../tables/SpecialistWingsTable"
import styles from "./WingsShowcase.module.css"

type WingType = 'all' | 'buff' | 'visual'

interface WingsShowcaseProps {
buffWings: SpecialistWingsRowDefinition[]
visualWings: SpecialistWingsRowDefinition[]
}

type WingEntry = SpecialistWingsRowDefinition & { type: 'buff' | 'visual' }

export default function WingsShowcase({ buffWings, visualWings }: WingsShowcaseProps) {
const input = useRef<HTMLInputElement>()
const [filterText, setFilterText] = useState("")
const [activeType, setActiveType] = useState<WingType>('all')
const [selectedId, setSelectedId] = useQueryId({ label: "wing" })

const allWings: WingEntry[] = useMemo(() => [
...buffWings.map(w => ({ ...w, type: 'buff' as const })),
...visualWings.map(w => ({ ...w, type: 'visual' as const }))
], [buffWings, visualWings])

const filtered = useMemo(() => {
let result = allWings

if (activeType !== 'all') {
result = result.filter(w => w.type === activeType)
}

const needle = filterText.trim().toLowerCase()
if (needle) {
result = result.filter(w => {
const item = getItemById(w.itemId)
return (
item?.name?.toLowerCase().includes(needle) ||
w.overridenItemName?.toLowerCase().includes(needle)
)
})
}

return result.sort((a, b) => {
const nameA = (a.overridenItemName || getItemById(a.itemId)?.name || "").toLowerCase()
const nameB = (b.overridenItemName || getItemById(b.itemId)?.name || "").toLowerCase()
return nameA.localeCompare(nameB)
})
}, [allWings, activeType, filterText])

const selectedWing = allWings.find(w => w.itemId === selectedId) ?? filtered[0]
const selectedItem = selectedWing ? getItemById(selectedWing.itemId) : null
const selectedName = selectedWing?.overridenItemName || selectedItem?.name || ""
const previewUrl = selectedWing?.morph
? `https://cdn.olympusgg.com/images/wings/${selectedWing.morph}_15.${selectedWing.animated !== false ? "webp" : "png"}`
: null

const filters: { value: WingType; label: string }[] = [
{ value: 'all', label: 'All Wings' },
{ value: 'buff', label: 'Buff' },
{ value: 'visual', label: 'Visual Only' },
]

return (
<div className={styles.page}>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className={styles.title}>Specialist Wings</div>
<div className={styles.subtitle}>Pick a wing and review its details.</div>
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex gap-1">
{filters.map(f => (
<button
key={f.value}
className={`${styles.pill} ${activeType === f.value ? styles.pillActive : ""}`}
onClick={() => setActiveType(f.value)}
>
{f.label}
</button>
))}
</div>
<input
ref={input}
type="text"
className={`${styles.input} min-w-[220px]`}
placeholder="Search wings..."
onChange={() => setFilterText(input.current?.value ?? "")}
/>
</div>
</div>

<div className="mt-4 space-y-4">
<div className={styles.panel}>
<div className="flex items-center justify-between pb-2">
<div className={styles.sectionTitle}>Choose a Wing</div>
</div>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5 lg:grid-cols-7 max-h-[240px] overflow-auto">
{filtered.map((wing) => {
const item = getItemById(wing.itemId)
if (!item) return null
const name = wing.overridenItemName || item.name || ""

return (
<button
key={wing.itemId}
className={`${styles.card} ${wing.itemId === selectedWing?.itemId ? styles.cardSelected : ""} flex flex-col items-center gap-2 text-left`}
onClick={() => setSelectedId(wing.itemId)}
>
<div className="h-16 w-16 flex items-center justify-center">
<img
src={getIconLink(item.iconId)}
className="max-w-14 max-h-14 object-contain"
alt={name}
/>
</div>
<span className="text-xs text-center text-[var(--ifm-font-color-base)]">
{name}
</span>
</button>
)
})}
</div>
</div>

{selectedWing && selectedItem ? (
<div className="space-y-4">
<div className={styles.panel}>
<div className="flex flex-col gap-4 md:flex-row">
{previewUrl && (
<div className={styles.previewFrame}>
<img
src={previewUrl}
alt={selectedName}
loading="lazy"
onError={(e) => { e.currentTarget.style.display = 'none' }}
/>
</div>
)}
<div className="flex flex-col gap-3 flex-1">
<div className="flex items-center gap-3">
<div>
<div className="text-base font-semibold text-[var(--ifm-heading-color)]">
<ItemLink id={selectedWing.itemId} hideText={!!selectedWing.overridenItemName} />
{selectedWing.overridenItemName && (
<span className="ml-1">{selectedWing.overridenItemName}</span>
)}
</div>
<div className="mt-1">
{selectedWing.type === 'buff' ? (
<span className={styles.buffBadge}>Buff</span>
) : (
<span className={styles.visualBadge}>Visual Only</span>
)}
</div>
</div>
</div>

{selectedWing.cardId && (
<>
<hr className={styles.divider} />
<div>
<div className={styles.sectionTitle}>Buff Card</div>
<div className="pt-1">
<CardLink id={selectedWing.cardId} />
</div>
</div>
</>
)}

{selectedWing.obtainedFrom && selectedWing.obtainedFrom.length > 0 && (
<>
<hr className={styles.divider} />
<div>
<div className={styles.sectionTitle}>Obtained From</div>
<div className={`${styles.obtainList} pt-2`}>
{selectedWing.obtainedFrom.map((method, idx) => {
const isRandomBox = selectedWing.shopId && method === 'Random Box'
const isFortuneWheel = method === 'Fortune Wheel'
const isJackpot = method === 'Fortune Wheel Jackpot'

if (isRandomBox) {
return (
<a
key={idx}
href={`https://dashboard.olympusgg.com/shop/${selectedWing.shopId}`}
target="_blank"
rel="noopener noreferrer"
className={`${styles.obtainBadge} ${styles.obtainBadgeClickable}`}
>
{method}
</a>
)
}

if (isFortuneWheel || isJackpot) {
return (
<a
key={idx}
href="https://dashboard.olympusgg.com/fortune-wheel"
target="_blank"
rel="noopener noreferrer"
className={`${styles.obtainBadge} ${styles.obtainBadgeClickable}`}
>
{method}
</a>
)
}

return <div key={idx} className={styles.obtainBadge}>{method}</div>
})}
</div>
</div>
</>
)}

<div className={styles.actionButtons}>
{selectedWing.price && selectedWing.shopId && (
<a
href={`https://dashboard.olympusgg.com/shop/${selectedWing.shopId}`}
target="_blank"
rel="noopener noreferrer"
className={styles.shopButton}
>
<img src="/img/olympus-coin.svg" alt="OC" className={styles.coinIcon} />
Purchase {selectedWing.price} OC
</a>
)}
{selectedWing.obtainedFrom && (selectedWing.obtainedFrom.includes('Fortune Wheel') || selectedWing.obtainedFrom.includes('Fortune Wheel Jackpot')) && (
<a
href="https://dashboard.olympusgg.com/fortune-wheel"
target="_blank"
rel="noopener noreferrer"
className={styles.fortuneWheelButton}
>
Spin Fortune Wheel
</a>
)}
</div>
</div>
</div>
</div>
</div>
) : (
<div className={styles.subtitle}>Select a wing to preview details.</div>
)}
</div>
</div>
)
}

Step 2: Commit

git add src/components/wingsPicker/WingsShowcase.tsx
git commit -m "feat(wings): add WingsShowcase picker component"

Task 3: Update MDX Page

Files:

  • Modify: docs/information/specialists/specialists-wings.md

Step 1: Update the page to use the new component

Replace the import and usage of SpecialistWingsGallery with WingsShowcase. The admonitions can be simplified since the picker detail panel now shows this info contextually.

---
id: wings
slug: wings
title: Specialist Wings
description: Browse the complete collection of Specialist Wings on Olympus. Discover powerful buff wings and stunning visual wings with detailed stats, obtaining methods, and purchase options.
hide_title: true
image: /img/socials/specialist-wings.webp
keywords:
- specialist wings
- SP wings
- buff wings
- visual wings
- specialist cards
---

import WingsShowcase from "@site/src/components/wingsPicker/WingsShowcase"
import config from "@site/config"

# **Specialist Wings**

:::warning[Important Note]
If you want the same wings for multiple specialists (e.g., Rose Wings for both Renegade and Berserker), you'll need to purchase separate copies for each SP.
:::

<WingsShowcase
buffWings={config.specialistWings.buffWings}
visualWings={config.specialistWings.visualWings}
/>

Step 2: Commit

git add docs/information/specialists/specialists-wings.md
git commit -m "feat(wings): switch page to WingsShowcase picker"

Task 4: Visual Verification

Step 1: Start dev server and verify

npm start

Navigate to /wings and verify:

  • Filter pills (All / Buff / Visual) work
  • Search input filters the icon grid
  • Clicking a wing shows detail panel with image, item link, badge, card link, obtained-from badges, and action buttons
  • URL updates with ?wing= parameter
  • Responsive layout works at mobile widths
  • First wing is auto-selected on load

Step 2: Commit any fixes needed


Task 5: Clean Up (Optional)

The old SpecialistWingsGallery.tsx and SpecialistWingsGallery.module.css can be removed if no other pages reference them. Check with:

grep -r "SpecialistWingsGallery" src/ docs/ --include="*.ts" --include="*.tsx" --include="*.md" --include="*.mdx"

If only the old page referenced it, delete both files and commit.