收口前端平台组件库能力

新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
2026-06-10 10:24:18 +08:00
parent a4ee6ff698
commit 1ad25e30f8
226 changed files with 23364 additions and 7825 deletions

View File

@@ -1,23 +1,21 @@
import { AnimatePresence, motion } from 'motion/react';
import { useState } from 'react';
import { type ReactNode, useState } from 'react';
import { getCharacterById } from '../data/characterPresets';
import {
formatCurrency,
getInventoryItemValue,
} from '../data/economy';
import { formatCurrency, getInventoryItemValue } from '../data/economy';
import {
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
} from '../data/equipmentEffects';
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../data/inventoryEffects';
import {
buildInventoryItemDescription,
getInventoryTagLabels,
} from '../data/itemPresentation';
import {
getRarityLabel,
} from '../data/npcInteractions';
import { getRarityLabel } from '../data/npcInteractions';
import { StoryGenerationNpcUi } from '../hooks/rpg-runtime-story';
import {
GameState,
@@ -25,7 +23,16 @@ import {
RuntimeNpcGiftItemView,
RuntimeNpcTradeItemView,
} from '../types';
import { getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import {
getInventoryItemVisualSrc,
getNineSliceStyle,
UI_CHROME,
} from '../uiAssets';
import { PlatformDarkOptionCard } from './common/PlatformDarkOptionCard';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { PixelCloseButton } from './PixelCloseButton';
import { PixelIcon } from './PixelIcon';
@@ -43,6 +50,19 @@ function getItemVisualSrc(item: InventoryItem) {
return getInventoryItemVisualSrc(item);
}
function NpcModalEmptyState({ children }: { children: ReactNode }) {
return (
<PlatformEmptyState
surface="editorDark"
size="compact"
tone="soft"
className="py-6"
>
{children}
</PlatformEmptyState>
);
}
function buildTradeUseEffectText(
effect: ReturnType<typeof resolveInventoryItemUseEffect> | null,
) {
@@ -71,30 +91,35 @@ function TradeItemRow({
onClick: () => void;
}) {
return (
<button
type="button"
<PlatformDarkOptionCard
selected={selected}
tone="emerald"
padding="md"
onClick={onClick}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition ${
selected
? 'border-emerald-400/45 bg-emerald-500/10'
: 'border-white/8 bg-black/20 hover:border-white/15'
}`}
className="w-full"
>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-black/35">
<PixelIcon src={getItemVisualSrc(item)} className="h-7 w-7" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">{item.name ?? item.id}</div>
<div className="truncate text-sm font-medium text-white">
{item.name ?? item.id}
</div>
<div className="mt-1 text-[10px] text-zinc-500">
{item.category} / {getRarityLabel(item.rarity)} / {unitPrice} {currencyName}
{item.category} / {getRarityLabel(item.rarity)} / {unitPrice}{' '}
{currencyName}
</div>
</div>
<div className="rounded-full border border-white/10 bg-black/25 px-2 py-0.5 text-[10px] text-white">
<PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="px-2 py-0.5 text-white"
>
x{item.quantity}
</div>
</PlatformPillBadge>
</div>
</button>
</PlatformDarkOptionCard>
);
}
@@ -109,9 +134,18 @@ function TradeQuantityStepper({
}) {
const safeMax = Math.max(1, maxQuantity);
return (
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-2">
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="flex items-center justify-between"
data-testid="npc-trade-quantity-stepper"
>
<div>
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500"></div>
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-1 text-xs text-zinc-400"> {safeMax}</div>
</div>
<div className="flex items-center gap-2">
@@ -127,7 +161,9 @@ function TradeQuantityStepper({
>
-
</button>
<div className="min-w-[3rem] text-center text-sm font-semibold text-white">{quantity}</div>
<div className="min-w-[3rem] text-center text-sm font-semibold text-white">
{quantity}
</div>
<button
type="button"
onClick={() => onChange(quantity + 1)}
@@ -141,7 +177,7 @@ function TradeQuantityStepper({
+
</button>
</div>
</div>
</PlatformSubpanel>
);
}
@@ -151,51 +187,66 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
const currencyName = npcInteraction?.currencyName ?? '钱币';
const tradeModal = npcUi.tradeModal;
const tradeMode = tradeModal?.mode ?? 'buy';
const tradeItemViews: RuntimeNpcTradeItemView[] = tradeMode === 'buy'
? npcInteraction?.trade.buyItems ?? []
: npcInteraction?.trade.sellItems ?? [];
const tradeItemViews: RuntimeNpcTradeItemView[] =
tradeMode === 'buy'
? (npcInteraction?.trade.buyItems ?? [])
: (npcInteraction?.trade.sellItems ?? []);
const activeTradeView = tradeModal
? tradeItemViews.find(view =>
view.itemId === (tradeMode === 'buy'
? tradeModal.selectedNpcItemId
: tradeModal.selectedPlayerItemId),
) ?? null
? (tradeItemViews.find(
(view) =>
view.itemId ===
(tradeMode === 'buy'
? tradeModal.selectedNpcItemId
: tradeModal.selectedPlayerItemId),
) ?? null)
: null;
const activeTradeItem = activeTradeView?.item ?? null;
const activeTradeUnitPrice = activeTradeView?.unitPrice ?? 0;
const activeTradeMaxQuantity = activeTradeView?.maxQuantity ?? 0;
const activeTradeQuantity = tradeModal
? Math.max(1, Math.min(tradeModal.selectedQuantity, Math.max(1, activeTradeMaxQuantity)))
? Math.max(
1,
Math.min(
tradeModal.selectedQuantity,
Math.max(1, activeTradeMaxQuantity),
),
)
: 1;
const activeTradeTotalPrice = activeTradeUnitPrice * activeTradeQuantity;
const canConfirmTrade = Boolean(
activeTradeView &&
activeTradeView.canSubmit &&
activeTradeQuantity >= 1,
activeTradeView && activeTradeView.canSubmit && activeTradeQuantity >= 1,
);
const tradeItemList = tradeItemViews;
const tradeDetailItem = tradeDetail
? (tradeDetail.source === 'buy'
? npcInteraction?.trade.buyItems ?? []
: npcInteraction?.trade.sellItems ?? [])
.find(view => view.itemId === tradeDetail.itemId)?.item ?? null
? ((tradeDetail.source === 'buy'
? (npcInteraction?.trade.buyItems ?? [])
: (npcInteraction?.trade.sellItems ?? [])
).find((view) => view.itemId === tradeDetail.itemId)?.item ?? null)
: null;
const tradeDetailView = tradeDetail
? (tradeDetail.source === 'buy'
? npcInteraction?.trade.buyItems ?? []
: npcInteraction?.trade.sellItems ?? [])
.find(view => view.itemId === tradeDetail.itemId) ?? null
? ((tradeDetail.source === 'buy'
? (npcInteraction?.trade.buyItems ?? [])
: (npcInteraction?.trade.sellItems ?? [])
).find((view) => view.itemId === tradeDetail.itemId) ?? null)
: null;
const tradeDetailUseEffect = tradeDetailItem && gameState.playerCharacter
? resolveInventoryItemUseEffect(tradeDetailItem, gameState.playerCharacter)
const tradeDetailUseEffect =
tradeDetailItem && gameState.playerCharacter
? resolveInventoryItemUseEffect(
tradeDetailItem,
gameState.playerCharacter,
)
: null;
const tradeDetailEquipSlot = tradeDetailItem
? getEquipmentSlotFromItem(tradeDetailItem)
: null;
const tradeDetailEquipSlot = tradeDetailItem ? getEquipmentSlotFromItem(tradeDetailItem) : null;
const tradeDetailEffectText = buildTradeUseEffectText(tradeDetailUseEffect);
const giftCandidates: RuntimeNpcGiftItemView[] = npcUi.giftModal
? npcInteraction?.gift.items ?? []
? (npcInteraction?.gift.items ?? [])
: [];
const activeGiftView =
giftCandidates.find(item => item.itemId === npcUi.giftModal?.selectedItemId) ?? null;
giftCandidates.find(
(item) => item.itemId === npcUi.giftModal?.selectedItemId,
) ?? null;
const handleTradeItemClick = (view: RuntimeNpcTradeItemView) => {
if (tradeMode === 'buy') {
@@ -224,13 +275,15 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
exit={{ opacity: 0, scale: 0.96, y: 8 }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-4xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-zinc-500">
{npcInteraction?.npcName ?? tradeModal.encounter.npcName} / {currencyName}{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
{npcInteraction?.npcName ?? tradeModal.encounter.npcName} /
{currencyName}
{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
</div>
</div>
<PixelCloseButton
@@ -242,70 +295,88 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4">
{tradeModal.introText && (
<div className="mb-3 whitespace-pre-line rounded-xl border border-amber-300/15 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-50/90">
<PlatformStatusMessage
tone="warning"
surface="editorDark"
size="xs"
className="mb-3 whitespace-pre-line leading-relaxed"
>
{tradeModal.introText}
</div>
</PlatformStatusMessage>
)}
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]">
<div className="min-h-0 space-y-3">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
<PlatformDarkOptionCard
selected={tradeMode === 'buy'}
tone="emerald"
onClick={() => npcUi.setTradeMode('buy')}
className={`rounded-xl border px-3 py-2 text-sm transition ${
tradeMode === 'buy'
? 'border-emerald-400/45 bg-emerald-500/10 text-emerald-100'
: 'border-white/10 bg-black/20 text-zinc-300'
}`}
className="text-center text-sm"
>
</button>
<button
type="button"
</PlatformDarkOptionCard>
<PlatformDarkOptionCard
selected={tradeMode === 'sell'}
tone="sky"
onClick={() => npcUi.setTradeMode('sell')}
className={`rounded-xl border px-3 py-2 text-sm transition ${
tradeMode === 'sell'
? 'border-sky-400/45 bg-sky-500/10 text-sky-100'
: 'border-white/10 bg-black/20 text-zinc-300'
}`}
className="text-center text-sm"
>
</button>
</PlatformDarkOptionCard>
</div>
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400">
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="flex items-center justify-between text-xs text-zinc-400"
data-testid="npc-trade-list-summary"
>
<span>{tradeMode === 'buy' ? '对方库存' : '你的背包'}</span>
<span>{tradeItemList.length} </span>
</div>
</PlatformSubpanel>
<div className="max-h-[42vh] space-y-2 overflow-y-auto pr-1 scrollbar-hide">
{tradeItemList.length > 0 ? tradeItemList.map(view => (
<div key={view.itemId}>
<TradeItemRow
item={view.item}
selected={tradeMode === 'buy'
? tradeModal.selectedNpcItemId === view.itemId
: tradeModal.selectedPlayerItemId === view.itemId}
unitPrice={view.unitPrice}
currencyName={currencyName}
onClick={() => handleTradeItemClick(view)}
/>
{!view.canSubmit && view.reason && (
<div className="mt-1 px-1 text-[10px] text-rose-300">
{view.reason}
</div>
)}
</div>
)) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
{tradeMode === 'buy' ? '对方暂时没有可出售的物品。' : '你当前没有可出售的物品。'}
</div>
{tradeItemList.length > 0 ? (
tradeItemList.map((view) => (
<div key={view.itemId}>
<TradeItemRow
item={view.item}
selected={
tradeMode === 'buy'
? tradeModal.selectedNpcItemId === view.itemId
: tradeModal.selectedPlayerItemId ===
view.itemId
}
unitPrice={view.unitPrice}
currencyName={currencyName}
onClick={() => handleTradeItemClick(view)}
/>
{!view.canSubmit && view.reason && (
<div className="mt-1 px-1 text-[10px] text-rose-300">
{view.reason}
</div>
)}
</div>
))
) : (
<NpcModalEmptyState>
{tradeMode === 'buy'
? '对方暂时没有可出售的物品。'
: '你当前没有可出售的物品。'}
</NpcModalEmptyState>
)}
</div>
</div>
<div className="space-y-3">
<div className="rounded-xl border border-white/8 bg-black/20 p-3">
<PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
data-testid="npc-trade-detail-panel"
>
{activeTradeItem ? (
<div className="space-y-3">
<TradeQuantityStepper
@@ -314,26 +385,38 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
onChange={npcUi.setTradeQuantity}
/>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-sm text-zinc-200">
<PlatformSubpanel
surface="dark"
radius="xs"
padding="sm"
className="text-sm text-zinc-200"
data-testid="npc-trade-total-panel"
>
<div className="flex items-center justify-between gap-3">
<span>{tradeMode === 'buy' ? '购买总价' : '出售总价'}</span>
<span>
{tradeMode === 'buy' ? '购买总价' : '出售总价'}
</span>
<span className="font-semibold text-white">
{formatCurrency(activeTradeTotalPrice, gameState.worldType)}
{formatCurrency(
activeTradeTotalPrice,
gameState.worldType,
)}
</span>
</div>
{!activeTradeView?.canSubmit && activeTradeView?.reason && (
<div className="mt-2 text-xs text-rose-300">
{activeTradeView.reason}
</div>
)}
</div>
{!activeTradeView?.canSubmit &&
activeTradeView?.reason && (
<div className="mt-2 text-xs text-rose-300">
{activeTradeView.reason}
</div>
)}
</PlatformSubpanel>
</div>
) : (
<div className="px-2 py-8 text-center text-sm text-zinc-500">
</div>
)}
</div>
</PlatformSubpanel>
</div>
</div>
</div>
@@ -343,7 +426,10 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
type="button"
onClick={npcUi.closeTradeModal}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
@@ -352,7 +438,10 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
disabled={!canConfirmTrade}
onClick={npcUi.confirmTrade}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canConfirmTrade ? 'text-white' : 'text-zinc-600'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{tradeMode === 'buy' ? '确认购买' : '确认出售'}
</button>
@@ -375,7 +464,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
exit={{ opacity: 0, scale: 0.96, y: 8 }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
@@ -394,12 +483,18 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<div className="flex items-start gap-4">
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-black/25">
<PixelIcon src={getItemVisualSrc(tradeDetailItem)} className="h-12 w-12" />
<PixelIcon
src={getItemVisualSrc(tradeDetailItem)}
className="h-12 w-12"
/>
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-semibold text-white">{tradeDetailItem.name}</div>
<div className="text-base font-semibold text-white">
{tradeDetailItem.name}
</div>
<div className="mt-1 text-xs text-zinc-500">
{tradeDetailItem.category} / {getRarityLabel(tradeDetailItem.rarity)}
{tradeDetailItem.category} /{' '}
{getRarityLabel(tradeDetailItem.rarity)}
</div>
<div className="mt-2 space-y-1 text-sm text-zinc-300">
<div>: {tradeDetailItem.quantity}</div>
@@ -414,25 +509,54 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
</div>
<p className="text-sm leading-relaxed text-zinc-300">
{buildInventoryItemDescription(tradeDetailItem, tradeDetailUseEffect)}
{buildInventoryItemDescription(
tradeDetailItem,
tradeDetailUseEffect,
)}
</p>
<div className="grid grid-cols-2 gap-2 text-xs text-zinc-300">
<div className="rounded-lg border border-white/8 bg-black/20 px-3 py-2">
{tradeDetailEquipSlot ? `装备位:${getEquipmentSlotLabel(tradeDetailEquipSlot)}` : '不可装备'}
</div>
<div className="rounded-lg border border-white/8 bg-black/20 px-3 py-2">
{isInventoryItemUsable(tradeDetailItem) ? '可立即使用' : '不可即时使用'}
</div>
<div className="col-span-2 rounded-lg border border-white/8 bg-black/20 px-3 py-2">
{getInventoryTagLabels(tradeDetailItem.tags).join(' / ') || '无'}
</div>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
>
{tradeDetailEquipSlot
? `装备位:${getEquipmentSlotLabel(tradeDetailEquipSlot)}`
: '不可装备'}
</PlatformSubpanel>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
>
{isInventoryItemUsable(tradeDetailItem)
? '可立即使用'
: '不可即时使用'}
</PlatformSubpanel>
<PlatformSubpanel
as="div"
surface="dark"
radius="xs"
padding="row"
className="col-span-2"
>
{getInventoryTagLabels(tradeDetailItem.tags).join(' / ') ||
'无'}
</PlatformSubpanel>
</div>
{tradeDetailEffectText && (
<div className="rounded-lg border border-emerald-400/15 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-100">
<PlatformStatusMessage
tone="success"
surface="editorDark"
size="xs"
>
使{tradeDetailEffectText}
</div>
</PlatformStatusMessage>
)}
<div className="flex justify-end">
@@ -440,7 +564,10 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
type="button"
onClick={() => setTradeDetail(null)}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
@@ -464,12 +591,14 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
exit={{ opacity: 0, scale: 0.96, y: 8 }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-zinc-500">{npcUi.giftModal.encounter.npcName}</div>
<div className="mt-1 text-xs text-zinc-500">
{npcUi.giftModal.encounter.npcName}
</div>
</div>
<PixelCloseButton
onClick={npcUi.closeGiftModal}
@@ -480,47 +609,87 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
{npcUi.giftModal.introText && (
<div className="whitespace-pre-line rounded-xl border border-rose-300/15 bg-rose-500/10 px-3 py-2 text-xs leading-relaxed text-rose-50/90">
{npcUi.giftModal.introText}
</div>
)}
{giftCandidates.length > 0 ? giftCandidates.map(candidate => (
<button
key={candidate.itemId}
type="button"
onClick={() => npcUi.selectGiftItem(candidate.itemId)}
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.giftModal?.selectedItemId === candidate.itemId ? 'border-rose-400/60 bg-rose-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`}
<PlatformStatusMessage
tone="error"
surface="editorDark"
size="xs"
className="whitespace-pre-line leading-relaxed"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<PixelIcon src={getItemVisualSrc(candidate.item)} className="h-8 w-8" />
<div>
<div className="text-sm text-white">{candidate.item.name}</div>
<div className="mt-1 text-[10px] text-zinc-500">{candidate.item.category} / {getRarityLabel(candidate.item.rarity)}</div>
{!candidate.canSubmit && candidate.reason && (
<div className="mt-1 text-[10px] text-rose-200/80">
{candidate.reason}
{npcUi.giftModal.introText}
</PlatformStatusMessage>
)}
{giftCandidates.length > 0 ? (
giftCandidates.map((candidate) => (
<PlatformDarkOptionCard
key={candidate.itemId}
selected={
npcUi.giftModal?.selectedItemId === candidate.itemId
}
tone="rose"
radius="sm"
onClick={() => npcUi.selectGiftItem(candidate.itemId)}
className="w-full"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<PixelIcon
src={getItemVisualSrc(candidate.item)}
className="h-8 w-8"
/>
<div>
<div className="text-sm text-white">
{candidate.item.name}
</div>
)}
<div className="mt-1 text-[10px] text-zinc-500">
{candidate.item.category} /{' '}
{getRarityLabel(candidate.item.rarity)}
</div>
{!candidate.canSubmit && candidate.reason && (
<div className="mt-1 text-[10px] text-rose-200/80">
{candidate.reason}
</div>
)}
</div>
</div>
<PlatformPillBadge
tone="darkRose"
size="xxs"
className="px-2 py-0.5"
>
+{candidate.affinityGain}
</PlatformPillBadge>
</div>
<div className="rounded-full border border-rose-500/20 bg-rose-500/10 px-2 py-0.5 text-[10px] text-rose-100">
+{candidate.affinityGain}
</div>
</div>
</button>
)) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
</PlatformDarkOptionCard>
))
) : (
<NpcModalEmptyState>
</div>
</NpcModalEmptyState>
)}
</div>
<div className="flex justify-end gap-3 px-5 pb-5">
<button type="button" onClick={npcUi.closeGiftModal} className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200" style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
<button
type="button"
onClick={npcUi.closeGiftModal}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
<button type="button" disabled={!activeGiftView?.canSubmit} onClick={npcUi.confirmGift} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${activeGiftView?.canSubmit ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
<button
type="button"
disabled={!activeGiftView?.canSubmit}
onClick={npcUi.confirmGift}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${activeGiftView?.canSubmit ? 'text-white' : 'text-zinc-600'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
</div>
@@ -542,12 +711,16 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
exit={{ opacity: 0, scale: 0.96, y: 8 }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-zinc-500"></div>
<div className="text-sm font-semibold text-white">
</div>
<div className="mt-1 text-xs text-zinc-500">
</div>
</div>
<PixelCloseButton
onClick={npcUi.closeRecruitModal}
@@ -558,36 +731,69 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
{npcUi.recruitModal.introText && (
<div className="whitespace-pre-line rounded-xl border border-amber-300/15 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-50/90">
<PlatformStatusMessage
tone="warning"
surface="editorDark"
size="xs"
className="whitespace-pre-line leading-relaxed"
>
{npcUi.recruitModal.introText}
</div>
</PlatformStatusMessage>
)}
{gameState.companions.length > 0 ? gameState.companions.map(companion => {
const character = getCharacterById(companion.characterId);
if (!character) return null;
return (
<button
key={companion.npcId}
type="button"
onClick={() => npcUi.selectRecruitRelease(companion.npcId)}
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.recruitModal?.selectedReleaseNpcId === companion.npcId ? 'border-amber-400/60 bg-amber-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`}
>
<div className="text-sm text-white">{character.name}</div>
<div className="mt-1 text-[10px] text-zinc-500">{character.title}</div>
</button>
);
}) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
{gameState.companions.length > 0 ? (
gameState.companions.map((companion) => {
const character = getCharacterById(companion.characterId);
if (!character) return null;
return (
<PlatformDarkOptionCard
key={companion.npcId}
selected={
npcUi.recruitModal?.selectedReleaseNpcId ===
companion.npcId
}
tone="amber"
radius="sm"
onClick={() =>
npcUi.selectRecruitRelease(companion.npcId)
}
className="w-full"
>
<div className="text-sm text-white">{character.name}</div>
<div className="mt-1 text-[10px] text-zinc-500">
{character.title}
</div>
</PlatformDarkOptionCard>
);
})
) : (
<NpcModalEmptyState>
</div>
</NpcModalEmptyState>
)}
</div>
<div className="flex justify-end gap-3 px-5 pb-5">
<button type="button" onClick={npcUi.closeRecruitModal} className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200" style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
<button
type="button"
onClick={npcUi.closeRecruitModal}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
<button type="button" disabled={!npcUi.recruitModal.selectedReleaseNpcId} onClick={npcUi.confirmRecruit} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${npcUi.recruitModal.selectedReleaseNpcId ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
<button
type="button"
disabled={!npcUi.recruitModal.selectedReleaseNpcId}
onClick={npcUi.confirmRecruit}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${npcUi.recruitModal.selectedReleaseNpcId ? 'text-white' : 'text-zinc-600'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
</div>