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 patternsrc/components/spPicker/spPicker.tsx—SpecialistSelectiongrid patternsrc/components/tables/SpecialistWingsGallery.tsx— detail content (badges, action buttons)src/components/tables/SpecialistWingsTable.tsx—SpecialistWingsRowDefinitiontypesrc/components/utils.tsx—useQueryId,getIconLinksrc/components/items/Item.tsx—getItemById,ItemLinksrc/components/cards/Card.tsx—CardLink
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.