Release v0.1.17 2026-07-19
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRpgRoguelikeRun, reduceRpgRoguelikeRun } from "../../game/rpgRoguelike";
|
||||
import { PartyRoleBadge } from "./PartyRoleBadge";
|
||||
import { RpgRunOverlay } from "./RpgRunOverlay";
|
||||
import { RpgRunTacticalPanel } from "./RpgRunTacticalPanel";
|
||||
|
||||
describe("RPG roguelike party role UI", () => {
|
||||
it("renders clear visual and accessible Tank/DPS badges", () => {
|
||||
const tank = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Tank" }));
|
||||
const damage = renderToStaticMarkup(createElement(PartyRoleBadge, { role: "Damage" }));
|
||||
|
||||
expect(tank).toContain('class="rpg-role-badge is-tank"');
|
||||
expect(tank).toContain('aria-label="Role: Tank"');
|
||||
expect(damage).toContain('class="rpg-role-badge is-damage"');
|
||||
expect(damage).toContain('aria-label="Role: DPS"');
|
||||
});
|
||||
|
||||
it("shows role and composition in current-party lists on both displays", () => {
|
||||
let run = createRpgRoguelikeRun({ seed: 73 });
|
||||
const damage = run.partyDraft!.offers.find((candidate) => candidate.role === "Damage")!;
|
||||
run = reduceRpgRoguelikeRun(run, { type: "party-recruit", candidateId: damage.candidateId });
|
||||
run = reduceRpgRoguelikeRun(run, { type: "party-next-wave" });
|
||||
|
||||
const props = { run, onAction: () => undefined };
|
||||
const main = renderToStaticMarkup(createElement(RpgRunOverlay, props));
|
||||
const tactical = renderToStaticMarkup(createElement(RpgRunTacticalPanel, props));
|
||||
|
||||
expect(main).toContain('aria-label="Current party, 1 of 4: 0 Tanks · 1 DPS"');
|
||||
expect(main).toMatch(/class="rpg-picked-chip[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
|
||||
expect(main).toContain('aria-label="Role: DPS"');
|
||||
|
||||
expect(tactical).toContain('aria-label="Current party: 0 Tanks · 1 DPS"');
|
||||
expect(tactical).toMatch(/class="rpg-card-action[^"]*"[^>]*aria-label="Remove [^"]+, DPS"/);
|
||||
expect(tactical).toContain('aria-label="Role: DPS"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PartyRole } from "../../game/rpgRoguelike";
|
||||
import { partyRolePresentation } from "../../game/rpgRoguelike";
|
||||
|
||||
export function PartyRoleBadge({ role }: { readonly role: PartyRole }) {
|
||||
const presentation = partyRolePresentation(role);
|
||||
return (
|
||||
<span
|
||||
className={`rpg-role-badge is-${presentation.className}`}
|
||||
aria-label={`Role: ${presentation.label}`}
|
||||
>
|
||||
<i aria-hidden="true">{presentation.icon}</i>
|
||||
<b>{presentation.label}</b>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
PARTY_RECRUITS_PER_WAVE,
|
||||
SPELL_DRAFT_WAVE_COUNT,
|
||||
SPELL_PICKS_PER_WAVE,
|
||||
partyCompositionLabel,
|
||||
partyRolePresentation,
|
||||
rpgFocusId,
|
||||
} from "../../game/rpgRoguelike";
|
||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
|
||||
@@ -17,6 +19,8 @@ import {
|
||||
currentBoss,
|
||||
FocusButton,
|
||||
GearCard,
|
||||
GearStatComparison,
|
||||
gearComparisonLabel,
|
||||
PartyCard,
|
||||
rewardSummary,
|
||||
RoutePips,
|
||||
@@ -26,6 +30,7 @@ import {
|
||||
type RpgRunUiContext,
|
||||
type RpgRunUiProps,
|
||||
} from "./RpgRunUiShared";
|
||||
import { PartyRoleBadge } from "./PartyRoleBadge";
|
||||
import "./rpgRoguelike.css";
|
||||
|
||||
function DraftFooter({ context, focusId, action, disabled, label, hint }: {
|
||||
@@ -89,15 +94,15 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
|
||||
className="rpg-card-action"
|
||||
disabled={recruited && !canRemove}
|
||||
pressed={recruited}
|
||||
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}`}
|
||||
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${partyRolePresentation(candidate.role).label}`}
|
||||
><span>{recruited ? canRemove ? "Remove" : "Locked" : "Recruit"}</span></FocusButton>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}`}>
|
||||
<strong>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</strong>
|
||||
<div className="rpg-picked-strip" aria-label={`Current party, ${run.roster.length} of ${MAX_ACTIVE_ROSTER}: ${partyCompositionLabel(run.roster)}`}>
|
||||
<strong><span>Party {run.roster.length}/{MAX_ACTIVE_ROSTER}</span><small>{partyCompositionLabel(run.roster)}</small></strong>
|
||||
{run.roster.map((member) => (
|
||||
<FocusButton
|
||||
key={member.instanceId}
|
||||
@@ -106,8 +111,13 @@ function PartyDraft({ context }: { context: RpgRunUiContext }) {
|
||||
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
|
||||
className={`rpg-picked-chip rarity-${member.rarity}`}
|
||||
disabled={!canRemove}
|
||||
label={`Remove ${member.name}`}
|
||||
><i style={{ background: member.color }} />{member.name}<small>{member.className}</small><b>×</b></FocusButton>
|
||||
label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
|
||||
>
|
||||
<i style={{ background: member.color }} />
|
||||
<span className="rpg-picked-copy"><strong>{member.name}</strong><small>{member.className}</small></span>
|
||||
<PartyRoleBadge role={member.role} />
|
||||
<b className="rpg-picked-remove" aria-hidden="true">×</b>
|
||||
</FocusButton>
|
||||
))}
|
||||
{Array.from({ length: Math.max(0, MAX_ACTIVE_ROSTER - run.roster.length) }, (_, index) => <i key={index} className="rpg-empty-chip">Open</i>)}
|
||||
</div>
|
||||
@@ -268,8 +278,11 @@ function Rewards({ context }: { context: RpgRunUiContext }) {
|
||||
command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }}
|
||||
className="rpg-reward-card"
|
||||
style={runAccentStyle(summary.accent)}
|
||||
label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}
|
||||
>
|
||||
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p><b>Claim</b>
|
||||
<i>{summary.icon}</i><small>{summary.eyebrow}</small><h3>{choice.label}</h3><p>{summary.detail}</p>
|
||||
{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}
|
||||
<b>Claim</b>
|
||||
</FocusButton>
|
||||
);
|
||||
})}
|
||||
@@ -304,7 +317,7 @@ function Shop({ context }: { context: RpgRunUiContext }) {
|
||||
command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }}
|
||||
className="rpg-card-action"
|
||||
disabled={disabled}
|
||||
label={offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price}`}
|
||||
label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}
|
||||
><span>{offer.sold ? "Sold" : run.currency < offer.price ? "Need gold" : "Buy"}</span></FocusButton>
|
||||
} />
|
||||
);
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
PARTY_RECRUITS_PER_WAVE,
|
||||
SPELL_DRAFT_WAVE_COUNT,
|
||||
SPELL_PICKS_PER_WAVE,
|
||||
partyCompositionLabel,
|
||||
partyRolePresentation,
|
||||
rpgFocusId,
|
||||
} from "../../game/rpgRoguelike";
|
||||
import { DEFAULT_CONTROLLER_GLYPHS } from "../../input/controllerGlyphs";
|
||||
@@ -18,6 +20,8 @@ import {
|
||||
currentBoss,
|
||||
FocusButton,
|
||||
GearCard,
|
||||
GearStatComparison,
|
||||
gearComparisonLabel,
|
||||
PartyCard,
|
||||
rewardSummary,
|
||||
RoutePips,
|
||||
@@ -33,8 +37,11 @@ import "./rpgRoguelike.css";
|
||||
function TacticalParty({ context, interactive = false }: { context: RpgRunUiContext; interactive?: boolean }) {
|
||||
const { run } = context;
|
||||
return (
|
||||
<section className="rpg-tactical-section">
|
||||
<header><h3>Party</h3><span>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</span></header>
|
||||
<section className="rpg-tactical-section" aria-label={`Current party: ${partyCompositionLabel(run.roster)}`}>
|
||||
<header>
|
||||
<h3>Party</h3>
|
||||
<span className="rpg-party-composition"><b>{partyCompositionLabel(run.roster)}</b><small>{run.roster.filter((member) => member.hp > 0).length}/{run.roster.length} standing</small></span>
|
||||
</header>
|
||||
<div className="rpg-tactical-party-grid">
|
||||
{run.roster.map((member) => (
|
||||
<PartyCard key={member.instanceId} member={member} compact action={interactive ? (
|
||||
@@ -43,7 +50,7 @@ function TacticalParty({ context, interactive = false }: { context: RpgRunUiCont
|
||||
focusId={rpgFocusId.partyMember(member.instanceId)}
|
||||
command={{ type: "run-action", action: { type: "party-remove", memberId: member.instanceId } }}
|
||||
className="rpg-card-action"
|
||||
label={`Remove ${member.name}`}
|
||||
label={`Remove ${member.name}, ${partyRolePresentation(member.role).label}`}
|
||||
><span>Remove</span></FocusButton>
|
||||
) : undefined} />
|
||||
))}
|
||||
@@ -116,6 +123,7 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
|
||||
{draft.offers.map((candidate) => {
|
||||
const recruited = run.roster.some((member) => member.instanceId === candidate.candidateId);
|
||||
const disabled = (recruited && !canRemove) || (!recruited && !canRecruit);
|
||||
const role = partyRolePresentation(candidate.role);
|
||||
return (
|
||||
<FocusButton
|
||||
key={candidate.candidateId}
|
||||
@@ -126,9 +134,10 @@ function TacticalPartyDraft({ context }: { context: RpgRunUiContext }) {
|
||||
style={runAccentStyle(candidate.color)}
|
||||
disabled={disabled}
|
||||
pressed={recruited}
|
||||
label={`${recruited ? "Remove" : "Recruit"} ${candidate.name}, ${role.label}`}
|
||||
>
|
||||
<i>{candidate.role === "Tank" ? "⬡" : "⚔"}</i>
|
||||
<span><small>{candidate.rarity} · {candidate.role}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
|
||||
<i>{role.icon}</i>
|
||||
<span><small>{candidate.rarity} · {role.label}</small><strong>{candidate.name}</strong><em>{candidate.className}</em></span>
|
||||
<b>HP {candidate.stats.maxHp}<small>ST {candidate.stats.singleTarget.toFixed(2)} · AOE {candidate.stats.areaDamage.toFixed(2)}</small></b>
|
||||
<u>{recruited ? canRemove ? "Remove" : "Locked" : disabled ? "Full" : "Recruit"}</u>
|
||||
</FocusButton>
|
||||
@@ -322,8 +331,8 @@ function TacticalRewards({ context }: { context: RpgRunUiContext }) {
|
||||
{chest.choices.map((choice) => {
|
||||
const summary = rewardSummary(choice);
|
||||
return (
|
||||
<FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)}>
|
||||
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p></span><b>Claim</b>
|
||||
<FocusButton key={choice.id} context={context} focusId={rpgFocusId.rewardChoice(choice.id)} command={{ type: "run-action", action: { type: "reward-choose", choiceId: choice.id } }} className="rpg-tactical-reward" style={runAccentStyle(summary.accent)} label={choice.kind === "run-gear" ? `Claim ${choice.item.name}. ${gearComparisonLabel(context.run, choice.item)}` : `Claim ${choice.label}. ${summary.detail}`}>
|
||||
<i>{summary.icon}</i><span><small>{summary.eyebrow}</small><strong>{choice.label}</strong><p>{summary.detail}</p>{choice.kind === "run-gear" && <GearStatComparison run={context.run} item={choice.item} />}</span><b>Claim</b>
|
||||
</FocusButton>
|
||||
);
|
||||
})}
|
||||
@@ -345,7 +354,7 @@ function TacticalShop({ context }: { context: RpgRunUiContext }) {
|
||||
<div className="rpg-tactical-shop-grid">
|
||||
{shop.offers.map((offer) => (
|
||||
<GearCard key={offer.id} run={run} item={offer.item} price={offer.price} action={
|
||||
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`Buy ${offer.item.name}`}>
|
||||
<FocusButton context={context} focusId={rpgFocusId.shopOffer(offer.id)} command={{ type: "run-action", action: { type: "shop-buy", offerId: offer.id } }} className="rpg-card-action" disabled={offer.sold || run.currency < offer.price} label={`${offer.sold ? `${offer.item.name} sold` : `Buy ${offer.item.name} for ${offer.price} gold`}. ${gearComparisonLabel(run, offer.item)}`}>
|
||||
<span>{offer.sold ? "Sold" : "Buy"}</span>
|
||||
</FocusButton>
|
||||
} />
|
||||
|
||||
@@ -10,9 +10,10 @@ import type {
|
||||
RpgRoguelikeRunState,
|
||||
RunGearItem,
|
||||
} from "../../game/rpgRoguelike";
|
||||
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT } from "../../game/rpgRoguelike";
|
||||
import { BOSSES_PER_ACT, TOTAL_BOSS_COUNT, compareRunGear } from "../../game/rpgRoguelike";
|
||||
import type { RpgUiCommand } from "../../game/rpgRoguelike";
|
||||
import { normalizeRpgFocusId } from "../../game/rpgRoguelike";
|
||||
import { PartyRoleBadge } from "./PartyRoleBadge";
|
||||
|
||||
export interface RpgRunUiProps {
|
||||
readonly run: RpgRoguelikeRunState;
|
||||
@@ -94,11 +95,14 @@ export function FocusButton({
|
||||
while (parent && (parent.closest(".rpg-run-overlay") || parent.closest(".rpg-run-tactical"))) {
|
||||
const childRect = button.getBoundingClientRect();
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
if (parent.scrollHeight > parent.clientHeight) {
|
||||
const overflow = getComputedStyle(parent);
|
||||
const canScrollY = /^(auto|scroll|overlay)$/.test(overflow.overflowY);
|
||||
const canScrollX = /^(auto|scroll|overlay)$/.test(overflow.overflowX);
|
||||
if (canScrollY && parent.scrollHeight > parent.clientHeight) {
|
||||
if (childRect.top < parentRect.top) parent.scrollTop -= parentRect.top - childRect.top;
|
||||
else if (childRect.bottom > parentRect.bottom) parent.scrollTop += childRect.bottom - parentRect.bottom;
|
||||
}
|
||||
if (parent.scrollWidth > parent.clientWidth) {
|
||||
if (canScrollX && parent.scrollWidth > parent.clientWidth) {
|
||||
if (childRect.left < parentRect.left) parent.scrollLeft -= parentRect.left - childRect.left;
|
||||
else if (childRect.right > parentRect.right) parent.scrollLeft += childRect.right - parentRect.right;
|
||||
}
|
||||
@@ -207,7 +211,7 @@ export function PartyCard({
|
||||
className={`rpg-party-card rarity-${entry.rarity} ${selected ? "is-picked" : ""} ${compact ? "is-compact" : ""}`.trim()}
|
||||
style={runAccentStyle(entry.color)}
|
||||
>
|
||||
<div className="rpg-card-kicker"><span>{entry.rarity}</span><b>{entry.role}</b></div>
|
||||
<div className="rpg-card-kicker"><span>{entry.rarity}</span><PartyRoleBadge role={entry.role} /></div>
|
||||
<h3>{entry.name}</h3>
|
||||
<p>{entry.className}</p>
|
||||
{!compact && (
|
||||
@@ -265,6 +269,39 @@ export function gearOwnerName(run: RpgRoguelikeRunState, item: RunGearItem): str
|
||||
return run.roster.find((member) => member.instanceId === item.ownerId)?.name ?? "Companion";
|
||||
}
|
||||
|
||||
export function gearComparisonLabel(run: RpgRoguelikeRunState, item: RunGearItem): string {
|
||||
const comparison = compareRunGear(run.equipment, item);
|
||||
const ownerName = gearOwnerName(run, item);
|
||||
const currentRank = comparison.currentItem ? `plus ${comparison.currentItem.enhancement}` : "none";
|
||||
const change = comparison.delta >= 0
|
||||
? `Gain ${comparison.delta} percentage points`
|
||||
: `Lose ${Math.abs(comparison.delta)} percentage points`;
|
||||
return `${ownerName} ${comparison.effectLabel}. Current gear: ${currentRank}, ${comparison.currentValue} percent. Replacement: plus ${item.enhancement}, ${comparison.replacementValue} percent. ${change}.`;
|
||||
}
|
||||
|
||||
export function GearStatComparison({ run, item }: {
|
||||
readonly run: RpgRoguelikeRunState;
|
||||
readonly item: RunGearItem;
|
||||
}) {
|
||||
const ownerName = gearOwnerName(run, item);
|
||||
const comparison = compareRunGear(run.equipment, item);
|
||||
const currentRank = comparison.currentItem ? `+${comparison.currentItem.enhancement}` : "none";
|
||||
const delta = `${comparison.delta >= 0 ? "+" : ""}${comparison.delta} pts`;
|
||||
return (
|
||||
<span
|
||||
className="rpg-gear-comparison"
|
||||
aria-label={gearComparisonLabel(run, item)}
|
||||
>
|
||||
<strong>{ownerName} · {comparison.effectLabel}<em>{delta}</em></strong>
|
||||
<span>
|
||||
<span><small>Current · {currentRank}</small><b>+{comparison.currentValue}%</b></span>
|
||||
<i aria-hidden="true">→</i>
|
||||
<span><small>Replacement · +{item.enhancement}</small><b>+{comparison.replacementValue}%</b></span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function GearCard({ run, item, price, action }: {
|
||||
readonly run: RpgRoguelikeRunState;
|
||||
readonly item: RunGearItem;
|
||||
@@ -277,7 +314,8 @@ export function GearCard({ run, item, price, action }: {
|
||||
<div>
|
||||
<small>{gearOwnerName(run, item)} · {item.slotId}</small>
|
||||
<h3>{item.name}</h3>
|
||||
<p>+{item.statValue} {titleCase(item.statId)}{price !== undefined ? ` · ◆ ${price}` : ""}</p>
|
||||
{price !== undefined && <p className="rpg-gear-price">◆ {price}</p>}
|
||||
<GearStatComparison run={run} item={item} />
|
||||
</div>
|
||||
{action}
|
||||
</article>
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
|
||||
.rpg-card-kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
color: var(--rarity-color, #e7e9ec);
|
||||
@@ -249,6 +250,41 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rpg-role-badge {
|
||||
min-width: 44px;
|
||||
padding: 2px 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rpg-role-badge.is-tank {
|
||||
color: #9bd9ff;
|
||||
background: rgba(65, 145, 199, 0.2);
|
||||
}
|
||||
|
||||
.rpg-role-badge.is-damage {
|
||||
color: #ffc27c;
|
||||
background: rgba(204, 111, 55, 0.19);
|
||||
}
|
||||
|
||||
.rpg-role-badge > i {
|
||||
font-size: 9px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.rpg-role-badge > b {
|
||||
color: inherit;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.rpg-party-card h3,
|
||||
.rpg-spell-card h3,
|
||||
.rpg-gear-card h3 {
|
||||
@@ -389,6 +425,18 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rpg-picked-strip > strong {
|
||||
width: 108px;
|
||||
flex: 0 0 108px;
|
||||
}
|
||||
|
||||
.rpg-picked-strip > strong > small {
|
||||
color: var(--rpg-gold);
|
||||
font-size: 8px;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rpg-picked-chip,
|
||||
.rpg-empty-chip,
|
||||
.rpg-spellbook-chip {
|
||||
@@ -401,9 +449,12 @@
|
||||
|
||||
.rpg-picked-chip {
|
||||
position: relative;
|
||||
padding: 4px 18px 4px 10px;
|
||||
padding: 4px 6px 4px 10px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -414,18 +465,24 @@
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.rpg-picked-chip > small {
|
||||
.rpg-picked-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.rpg-picked-copy > strong,
|
||||
.rpg-picked-copy > small {
|
||||
overflow: hidden;
|
||||
color: var(--rpg-muted);
|
||||
font-size: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rpg-picked-chip > b {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
.rpg-picked-copy > strong { color: var(--rpg-ink); font-size: 10px; }
|
||||
.rpg-picked-copy > small { color: var(--rpg-muted); font-size: 8px; }
|
||||
|
||||
.rpg-picked-chip > .rpg-picked-remove {
|
||||
color: #82958e;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rpg-empty-chip {
|
||||
@@ -870,7 +927,7 @@
|
||||
}
|
||||
|
||||
.rpg-gear-card {
|
||||
min-height: 72px;
|
||||
min-height: 112px;
|
||||
padding: 8px 8px 22px 43px;
|
||||
}
|
||||
|
||||
@@ -904,6 +961,12 @@
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.rpg-gear-card .rpg-gear-price {
|
||||
margin: 2px 0 0;
|
||||
color: var(--rpg-gold);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rpg-shop-side-list {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -1118,6 +1181,22 @@
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.rpg-party-composition {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.rpg-party-composition > b {
|
||||
color: var(--rpg-gold);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.rpg-party-composition > small {
|
||||
color: var(--rpg-muted);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.rpg-tactical-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -1400,7 +1479,7 @@ button.rpg-tactical-spell {
|
||||
}
|
||||
|
||||
.rpg-tactical-reward {
|
||||
min-height: 94px;
|
||||
min-height: 130px;
|
||||
padding: 9px 64px 9px 49px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -1457,12 +1536,88 @@ button.rpg-tactical-spell {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison {
|
||||
min-width: 0;
|
||||
margin-top: 7px;
|
||||
padding-top: 6px;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: #dce8e2;
|
||||
border-top: 1px solid var(--rpg-line);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison > strong {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
color: #dce8e2;
|
||||
font-family: "Rajdhani", "Avenir Next Condensed", sans-serif;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison > strong > em {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 4px;
|
||||
color: #9de1b8;
|
||||
border-radius: 2px;
|
||||
background: rgba(70, 167, 108, 0.15);
|
||||
font-size: 8px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison > span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison > span > span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison small,
|
||||
.rpg-tactical-reward .rpg-gear-comparison small {
|
||||
overflow: hidden;
|
||||
color: #82958e;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison b {
|
||||
color: #f1f7f4;
|
||||
font-size: 12px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.rpg-gear-comparison > span > i {
|
||||
color: #75bceb;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.rpg-run-tactical .rpg-tactical-shop-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.rpg-run-tactical .rpg-gear-card {
|
||||
min-height: 80px;
|
||||
min-height: 112px;
|
||||
}
|
||||
|
||||
.rpg-service-row {
|
||||
@@ -1724,6 +1879,15 @@ button.rpg-tactical-spell {
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
.rpg-reward-grid {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rpg-reward-card {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.rpg-picked-strip,
|
||||
.rpg-spellbook-strip {
|
||||
overflow-x: auto;
|
||||
@@ -1735,6 +1899,10 @@ button.rpg-tactical-spell {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.rpg-picked-chip {
|
||||
min-width: 142px;
|
||||
}
|
||||
|
||||
.rpg-shop-layout {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
@@ -1751,6 +1919,12 @@ button.rpg-tactical-spell {
|
||||
.rpg-tactical-offer > b {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rpg-party-composition {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
/* Single-display browser fallback gets a usable full-height draft surface. */
|
||||
|
||||
Reference in New Issue
Block a user