1874 lines
57 KiB
Svelte
1874 lines
57 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import cardSummary from '$lib/data/cards-summary.json';
|
|
import { skillAtLevel, sortCardsByActive, type CardSummary, type CardType, type HighestStat, type SkillLevel } from '$lib/models/card-list';
|
|
import type { CardListPreferences, CardListSortKey, CardSkillLevel, SortDirection, UserData } from '$lib/models/user-data';
|
|
import { loadBrowserData, saveBrowserData } from '$lib/storage/browser-storage';
|
|
|
|
type SkillConditionStatus = '일반' | '조건부';
|
|
type ActiveProbability = '높음' | '중간';
|
|
|
|
const allCards = cardSummary.cards as CardSummary[];
|
|
const cards = allCards.filter((card) => card.rarity === 5);
|
|
const typeOptions: CardType[] = ['큐트', '퓨어', '해피'];
|
|
const typeIconPaths: Record<CardType, string> = {
|
|
큐트: '/icons/types/cute.webp',
|
|
퓨어: '/icons/types/pure.webp',
|
|
해피: '/icons/types/happy.webp'
|
|
};
|
|
const statOptions: HighestStat[] = ['퍼포먼스', '테크닉', '센스'];
|
|
const skillConditionOptions: SkillConditionStatus[] = ['일반', '조건부'];
|
|
const activeProbabilityOptions: ActiveProbability[] = ['높음', '중간'];
|
|
const cardVariantOptions = ['일반', ...new Set(cards.map((card) => card.versionName).filter(Boolean))];
|
|
const generationOptions = [...new Set(cards.flatMap((card) => generationMemberships(card.generation)))];
|
|
|
|
let selectedTypes = [...typeOptions];
|
|
let selectedStats = [...statOptions];
|
|
let selectedActiveConditions = [...skillConditionOptions];
|
|
let selectedSpecialConditions = [...skillConditionOptions];
|
|
let selectedActiveProbabilities = [...activeProbabilityOptions];
|
|
let selectedCardVariants = [...cardVariantOptions];
|
|
let selectedGenerations = [...generationOptions];
|
|
let sortKey: CardListSortKey | null = null;
|
|
let sortDirection: SortDirection = 'asc';
|
|
let expandedCardId: string | null = null;
|
|
let selectedSkillLevel: CardSkillLevel = 1;
|
|
let collection: UserData['collection'] = {};
|
|
let memberBoards: UserData['memberBoards'] = {};
|
|
let boardCards: UserData['boardCards'] = {};
|
|
let savedDecks: UserData['savedDecks'] = [];
|
|
let memoryCount = 0;
|
|
let memberPowerUpPercent = 0;
|
|
let storageReady = false;
|
|
let currentUserData: UserData;
|
|
|
|
$: currentUserData = {
|
|
schemaVersion: 1,
|
|
updatedAt: new Date().toISOString(),
|
|
memoryCount,
|
|
memberPowerUpPercent,
|
|
collection,
|
|
memberBoards,
|
|
boardCards,
|
|
savedDecks,
|
|
preferences: currentPreferences()
|
|
};
|
|
|
|
$: if (storageReady) {
|
|
try {
|
|
saveBrowserData(currentUserData);
|
|
} catch {}
|
|
}
|
|
|
|
onMount(() => {
|
|
try {
|
|
applyUserData(loadBrowserData(defaultPreferences()));
|
|
storageReady = true;
|
|
} catch {}
|
|
});
|
|
|
|
$: filteredCards = sortCardsByActive(
|
|
cards.filter((card) =>
|
|
selectedTypes.includes(card.type)
|
|
&& selectedStats.includes(card.highestStat)
|
|
&& matchesSkillCondition(selectedActiveConditions, card.details.active ?? [])
|
|
&& selectedSpecialConditions.includes(skillConditionStatus(card.details.special))
|
|
&& matchesActiveProbability(selectedActiveProbabilities, card.details.active ?? [])
|
|
&& selectedCardVariants.includes(cardVariantStatus(card))
|
|
&& generationMemberships(card.generation).some((generation) => selectedGenerations.includes(generation))
|
|
),
|
|
sortKey,
|
|
sortDirection,
|
|
selectedSkillLevel
|
|
);
|
|
|
|
$: allSelected = selectedTypes.length === typeOptions.length
|
|
&& selectedStats.length === statOptions.length
|
|
&& selectedActiveConditions.length === skillConditionOptions.length
|
|
&& selectedSpecialConditions.length === skillConditionOptions.length
|
|
&& selectedActiveProbabilities.length === activeProbabilityOptions.length
|
|
&& selectedCardVariants.length === cardVariantOptions.length
|
|
&& selectedGenerations.length === generationOptions.length;
|
|
|
|
$: allDeselected = selectedTypes.length === 0
|
|
&& selectedStats.length === 0
|
|
&& selectedActiveConditions.length === 0
|
|
&& selectedSpecialConditions.length === 0
|
|
&& selectedActiveProbabilities.length === 0
|
|
&& selectedCardVariants.length === 0
|
|
&& selectedGenerations.length === 0;
|
|
|
|
$: if (expandedCardId && !filteredCards.some((card) => card.id === expandedCardId)) {
|
|
expandedCardId = null;
|
|
}
|
|
|
|
function selectAll() {
|
|
selectedTypes = [...typeOptions];
|
|
selectedStats = [...statOptions];
|
|
selectedActiveConditions = [...skillConditionOptions];
|
|
selectedSpecialConditions = [...skillConditionOptions];
|
|
selectedActiveProbabilities = [...activeProbabilityOptions];
|
|
selectedCardVariants = [...cardVariantOptions];
|
|
selectedGenerations = [...generationOptions];
|
|
}
|
|
|
|
function selectNone() {
|
|
selectedTypes = [];
|
|
selectedStats = [];
|
|
selectedActiveConditions = [];
|
|
selectedSpecialConditions = [];
|
|
selectedActiveProbabilities = [];
|
|
selectedCardVariants = [];
|
|
selectedGenerations = [];
|
|
}
|
|
|
|
function defaultPreferences(): CardListPreferences {
|
|
return {
|
|
types: [...typeOptions],
|
|
stats: [...statOptions],
|
|
activeConditions: [...skillConditionOptions],
|
|
specialConditions: [...skillConditionOptions],
|
|
activeProbabilities: [...activeProbabilityOptions],
|
|
cardVariants: [...cardVariantOptions],
|
|
generations: [...generationOptions],
|
|
sortKey: null,
|
|
sortDirection: 'asc',
|
|
skillLevel: 1
|
|
};
|
|
}
|
|
|
|
function currentPreferences(): CardListPreferences {
|
|
return {
|
|
types: [...selectedTypes],
|
|
stats: [...selectedStats],
|
|
activeConditions: [...selectedActiveConditions],
|
|
specialConditions: [...selectedSpecialConditions],
|
|
activeProbabilities: [...selectedActiveProbabilities],
|
|
cardVariants: [...selectedCardVariants],
|
|
generations: [...selectedGenerations],
|
|
sortKey,
|
|
sortDirection,
|
|
skillLevel: selectedSkillLevel
|
|
};
|
|
}
|
|
|
|
function applyUserData(data: UserData) {
|
|
selectedTypes = data.preferences.types.filter((value): value is CardType => typeOptions.includes(value as CardType));
|
|
selectedStats = data.preferences.stats.filter((value): value is HighestStat => statOptions.includes(value as HighestStat));
|
|
selectedActiveConditions = data.preferences.activeConditions.filter((value): value is SkillConditionStatus => skillConditionOptions.includes(value as SkillConditionStatus));
|
|
selectedSpecialConditions = data.preferences.specialConditions.filter((value): value is SkillConditionStatus => skillConditionOptions.includes(value as SkillConditionStatus));
|
|
selectedActiveProbabilities = data.preferences.activeProbabilities.filter((value): value is ActiveProbability => activeProbabilityOptions.includes(value as ActiveProbability));
|
|
selectedCardVariants = data.preferences.cardVariants.filter((value) => cardVariantOptions.includes(value));
|
|
selectedGenerations = [...new Set(data.preferences.generations.flatMap(generationMemberships))]
|
|
.filter((value) => generationOptions.includes(value));
|
|
sortKey = data.preferences.sortKey;
|
|
sortDirection = data.preferences.sortDirection;
|
|
selectedSkillLevel = data.preferences.skillLevel;
|
|
collection = data.collection;
|
|
memberBoards = data.memberBoards;
|
|
boardCards = data.boardCards;
|
|
savedDecks = data.savedDecks;
|
|
memoryCount = data.memoryCount;
|
|
memberPowerUpPercent = data.memberPowerUpPercent;
|
|
}
|
|
|
|
function cardVariantStatus(card: CardSummary): string {
|
|
return card.versionName || '일반';
|
|
}
|
|
|
|
function generationMemberships(generation: string): string[] {
|
|
return generation.split(/\s*\/\s*/).filter(Boolean).map(generationLabel);
|
|
}
|
|
|
|
function generationLabel(generation: string): string {
|
|
return generation
|
|
.replace(/^hololive EN /, '')
|
|
.replace(/^hololive ID /, 'ID ')
|
|
.replace(/^hololive /, '')
|
|
.replace(/^DEV_IS /, '')
|
|
.replace(/GAMERS/g, '게이머즈');
|
|
}
|
|
|
|
function skillConditionStatus(levels: SkillLevel[]): SkillConditionStatus {
|
|
return levels.some((level) => Boolean(level.condition)) ? '조건부' : '일반';
|
|
}
|
|
|
|
function matchesSkillCondition(selected: SkillConditionStatus[], levels: SkillLevel[]): boolean {
|
|
return levels.length === 0
|
|
? selected.length === skillConditionOptions.length
|
|
: selected.includes(skillConditionStatus(levels));
|
|
}
|
|
|
|
function activeProbabilityStatus(levels: SkillLevel[]): ActiveProbability | null {
|
|
if (levels.length === 0) return null;
|
|
return levels.some((level) => level.probabilityPercent === 55) ? '높음' : '중간';
|
|
}
|
|
|
|
function matchesActiveProbability(selected: ActiveProbability[], levels: SkillLevel[]): boolean {
|
|
const status = activeProbabilityStatus(levels);
|
|
return status === null
|
|
? selected.length === activeProbabilityOptions.length
|
|
: selected.includes(status);
|
|
}
|
|
|
|
function toggleCard(cardId: string) {
|
|
expandedCardId = expandedCardId === cardId ? null : cardId;
|
|
}
|
|
|
|
function toggleSort(nextSortKey: CardListSortKey) {
|
|
if (sortKey === nextSortKey) {
|
|
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
|
return;
|
|
}
|
|
|
|
sortKey = nextSortKey;
|
|
sortDirection = 'asc';
|
|
}
|
|
|
|
function sortButtonLabel(key: CardListSortKey, label: string) {
|
|
if (sortKey !== key) return `${label} 오름차순 정렬`;
|
|
return `${label} ${sortDirection === 'asc' ? '내림차순' : '오름차순'}으로 변경`;
|
|
}
|
|
|
|
function secondsLabel(seconds: number | null | undefined) {
|
|
return seconds == null ? '—' : `${seconds}초`;
|
|
}
|
|
|
|
function probabilityLabel(probabilityPercent: number | null | undefined) {
|
|
if (probabilityPercent === 55) return '높음';
|
|
if (probabilityPercent === 46) return '중간';
|
|
return probabilityPercent == null ? '—' : `${probabilityPercent}%`;
|
|
}
|
|
|
|
function activeEffectLabel(active: SkillLevel | undefined) {
|
|
return active?.effect || active?.description || '—';
|
|
}
|
|
|
|
function descriptionSegments(description: string, highlightNumbers = false) {
|
|
type DescriptionIcon = CardType | HighestStat;
|
|
type DescriptionSegment = { text: string; highlight?: boolean; attribute?: string; icon?: DescriptionIcon; bold?: boolean; target?: boolean; numeric?: boolean };
|
|
const segments: DescriptionSegment[] = [];
|
|
const pattern = /\[highlight\](.*?)\[\/highlight\]|\[attribute=(cute|pure|happy)\](.*?)\[\/attribute\]/g;
|
|
description = description.replace(/\[\/highlight\](%)/g, '$1[/highlight]');
|
|
let cursor = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
function pushSegment(text: string, options: Omit<DescriptionSegment, 'text' | 'icon' | 'bold' | 'target' | 'numeric'> = {}) {
|
|
const targetNames = '(?:ID\\s*)?\\d+기생|GAMERS|holoX|Myth|Promise|Advent|ReGLOSS|Justice|FLOW GLOW';
|
|
const targetExpression = `(?:${targetNames})|(?:큐트|퓨어|해피)\\s*타입|자신|전원|멤버`;
|
|
const keywords = new RegExp(`(${targetExpression}|스코어 서포트 효과|전체 파라미터|스킬 발동률|퍼포먼스|테크닉|센스|스코어|라이프|\\d+(?:\\.\\d+)?(?:%|초|명|콤보|회)?|큐트|퓨어|해피)`, 'g');
|
|
const isTarget = new RegExp(`^(?:${targetExpression})$`);
|
|
const isNumeric = /^\d+(?:\.\d+)?(?:%|초|명|콤보|회)?$/;
|
|
let keywordCursor = 0;
|
|
let keywordMatch: RegExpExecArray | null;
|
|
|
|
while ((keywordMatch = keywords.exec(text)) !== null) {
|
|
if (keywordMatch.index > keywordCursor) {
|
|
segments.push({ text: text.slice(keywordCursor, keywordMatch.index), ...options });
|
|
}
|
|
const keyword = keywordMatch[0];
|
|
const typeIcon = typeOptions.find((type) => keyword.startsWith(type));
|
|
const statIcon = statOptions.find((stat) => keyword === stat);
|
|
const numeric = isNumeric.test(keyword);
|
|
segments.push({
|
|
text: keyword,
|
|
...options,
|
|
icon: typeIcon || statIcon,
|
|
bold: true,
|
|
target: isTarget.test(keyword),
|
|
numeric,
|
|
highlight: options.highlight || (numeric && highlightNumbers)
|
|
});
|
|
keywordCursor = keywords.lastIndex;
|
|
}
|
|
|
|
if (keywordCursor < text.length) segments.push({ text: text.slice(keywordCursor), ...options });
|
|
}
|
|
|
|
while ((match = pattern.exec(description)) !== null) {
|
|
if (match.index > cursor) pushSegment(description.slice(cursor, match.index));
|
|
if (match[1] !== undefined) pushSegment(match[1], { highlight: true });
|
|
if (match[3] !== undefined) pushSegment(match[3], { attribute: match[2] });
|
|
cursor = pattern.lastIndex;
|
|
}
|
|
|
|
if (cursor < description.length) pushSegment(description.slice(cursor));
|
|
return segments;
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>카드 목록 · 홀로도리 계산기</title>
|
|
<meta name="description" content="홀로드림스 카드의 등급, 타입, 이름, 최고 스탯 목록" />
|
|
</svelte:head>
|
|
|
|
<main>
|
|
<header class="page-header">
|
|
<div>
|
|
<h1>카드 목록</h1>
|
|
<p class="description">카드의 기본 정보를 한눈에 확인하세요.</p>
|
|
</div>
|
|
<div class="header-side">
|
|
<p class="total">
|
|
<strong>{filteredCards.length}</strong>
|
|
{#if filteredCards.length === cards.length}
|
|
장
|
|
{:else}
|
|
<span>/ {cards.length}장</span>
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
<section class="filters" aria-labelledby="filter-heading">
|
|
<div class="filter-header">
|
|
<h2 id="filter-heading">출력 조건</h2>
|
|
<div class="filter-actions">
|
|
<button class="filter-action" type="button" onclick={selectNone} disabled={allDeselected}>모두 취소</button>
|
|
<button class="filter-action" type="button" onclick={selectAll} disabled={allSelected}>모두 선택</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filter-groups">
|
|
<fieldset>
|
|
<legend>타입</legend>
|
|
<div class="filter-options">
|
|
{#each typeOptions as type}
|
|
<label class:checked={selectedTypes.includes(type)}>
|
|
<input type="checkbox" bind:group={selectedTypes} value={type} aria-label={type} />
|
|
<span class="checkmark" aria-hidden="true">✓</span>
|
|
<img class="type-icon" src={typeIconPaths[type]} alt="" title={type} />
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>주스탯</legend>
|
|
<div class="filter-options">
|
|
{#each statOptions as stat}
|
|
<label class:checked={selectedStats.includes(stat)}>
|
|
<input type="checkbox" bind:group={selectedStats} value={stat} aria-label={stat} />
|
|
<span class="checkmark" aria-hidden="true">✓</span>
|
|
<span
|
|
class:stat-icon-performance={stat === '퍼포먼스'}
|
|
class:stat-icon-technique={stat === '테크닉'}
|
|
class:stat-icon-sense={stat === '센스'}
|
|
class="stat-icon"
|
|
aria-hidden="true"
|
|
title={stat}
|
|
></span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>액티브 확률</legend>
|
|
<div class="filter-options">
|
|
{#each activeProbabilityOptions as probability}
|
|
<label class:checked={selectedActiveProbabilities.includes(probability)}>
|
|
<input type="checkbox" bind:group={selectedActiveProbabilities} value={probability} />
|
|
<span class="checkmark" aria-hidden="true">✓</span>
|
|
<span>{probability}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>액티브 조건</legend>
|
|
<div class="filter-options">
|
|
{#each skillConditionOptions as conditionStatus}
|
|
<label class:checked={selectedActiveConditions.includes(conditionStatus)}>
|
|
<input type="checkbox" bind:group={selectedActiveConditions} value={conditionStatus} />
|
|
<span class="checkmark" aria-hidden="true">✓</span>
|
|
<span>{conditionStatus}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>스페셜 조건</legend>
|
|
<div class="filter-options">
|
|
{#each skillConditionOptions as conditionStatus}
|
|
<label class:checked={selectedSpecialConditions.includes(conditionStatus)}>
|
|
<input type="checkbox" bind:group={selectedSpecialConditions} value={conditionStatus} />
|
|
<span class="checkmark" aria-hidden="true">✓</span>
|
|
<span>{conditionStatus}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>이격</legend>
|
|
<div class="filter-options">
|
|
{#each cardVariantOptions as variantStatus}
|
|
<label class:checked={selectedCardVariants.includes(variantStatus)}>
|
|
<input type="checkbox" bind:group={selectedCardVariants} value={variantStatus} />
|
|
<span class="checkmark" aria-hidden="true">✓</span>
|
|
<span>{variantStatus}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset class="generation-filter">
|
|
<legend>기수</legend>
|
|
<div class="filter-options">
|
|
{#each generationOptions as generation}
|
|
<label class:checked={selectedGenerations.includes(generation)}>
|
|
<input type="checkbox" bind:group={selectedGenerations} value={generation} />
|
|
<span class="checkmark" aria-hidden="true">✓</span>
|
|
<span>{generationLabel(generation)}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="table-panel" aria-label="카드 목록">
|
|
<div class="list-header">
|
|
<span>타입</span>
|
|
<span>주스탯</span>
|
|
<span>기수</span>
|
|
<span>이름</span>
|
|
<button class="sort-button" type="button" class:active={sortKey === 'coolTime'} aria-label={sortButtonLabel('coolTime', '주기')} onclick={() => toggleSort('coolTime')}>
|
|
<span>주기</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'coolTime' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
|
|
</button>
|
|
<button class="sort-button" type="button" class:active={sortKey === 'probability'} aria-label={sortButtonLabel('probability', '확률')} onclick={() => toggleSort('probability')}>
|
|
<span>확률</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'probability' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
|
|
</button>
|
|
<button class="sort-button" type="button" class:active={sortKey === 'duration'} aria-label={sortButtonLabel('duration', '지속')} onclick={() => toggleSort('duration')}>
|
|
<span>지속</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'duration' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
|
|
</button>
|
|
<button class="sort-button" type="button" class:active={sortKey === 'effect'} aria-label={sortButtonLabel('effect', '효과')} onclick={() => toggleSort('effect')}>
|
|
<span>효과</span><span class="sort-indicator" aria-hidden="true">{sortKey === 'effect' ? (sortDirection === 'asc' ? '↑' : '↓') : '↕'}</span>
|
|
</button>
|
|
<div class="header-level-control">
|
|
<span>개화</span>
|
|
<div class="level-toggle header-level-toggle" aria-label="목록에 표시할 스킬 개화 상태">
|
|
<button type="button" class:active={selectedSkillLevel === 1} aria-pressed={selectedSkillLevel === 1} onclick={() => selectedSkillLevel = 1}>명함</button>
|
|
<button type="button" class:active={selectedSkillLevel === 2} aria-pressed={selectedSkillLevel === 2} onclick={() => selectedSkillLevel = 2}>5 개화</button>
|
|
</div>
|
|
</div>
|
|
<span></span>
|
|
</div>
|
|
|
|
{#if filteredCards.length === 0}
|
|
<div class="empty">선택한 조건에 맞는 카드가 없습니다.</div>
|
|
{:else}
|
|
<div class="card-list">
|
|
{#each filteredCards as card (card.id)}
|
|
{@const expanded = expandedCardId === card.id}
|
|
{@const passive = skillAtLevel(card.details.passive, selectedSkillLevel)}
|
|
{@const active = skillAtLevel(card.details.active, selectedSkillLevel)}
|
|
{@const special = skillAtLevel(card.details.special, selectedSkillLevel)}
|
|
|
|
<article class:expanded class="card-entry">
|
|
<div class="card-summary">
|
|
<button
|
|
class="summary-expand-button"
|
|
type="button"
|
|
aria-expanded={expanded}
|
|
aria-controls={`detail-${card.id}`}
|
|
aria-label={`${card.name} 상세 정보 ${expanded ? '접기' : '펼치기'}`}
|
|
onclick={() => toggleCard(card.id)}
|
|
></button>
|
|
<span class="summary-cell type-cell">
|
|
<span class="mobile-label">타입</span>
|
|
<img class="type-icon" src={typeIconPaths[card.type]} alt={card.type} title={card.type} />
|
|
</span>
|
|
<span class="summary-cell stat-cell">
|
|
<span class="mobile-label">주스탯</span>
|
|
<span class:stat-performance={card.highestStat === '퍼포먼스'} class:stat-technique={card.highestStat === '테크닉'} class:stat-sense={card.highestStat === '센스'} class="stat-badge" aria-label={card.highestStat} title={card.highestStat}>
|
|
<span
|
|
class:stat-icon-performance={card.highestStat === '퍼포먼스'}
|
|
class:stat-icon-technique={card.highestStat === '테크닉'}
|
|
class:stat-icon-sense={card.highestStat === '센스'}
|
|
class="stat-icon"
|
|
aria-hidden="true"
|
|
></span>
|
|
</span>
|
|
</span>
|
|
<span class="summary-cell generation-cell">
|
|
<span class="mobile-label">기수</span>
|
|
<span class="generation-name">{generationLabel(card.generation)}</span>
|
|
</span>
|
|
<span class="summary-cell name-cell">
|
|
<span class="mobile-label">이름</span>
|
|
<span class="card-identity">
|
|
<span class="card-name">{card.name}</span>
|
|
{#if card.versionName}<span class="version-badge">{card.versionName}</span>{/if}
|
|
</span>
|
|
</span>
|
|
<span class="summary-cell active-cycle-cell active-summary-cell">
|
|
<span class="mobile-label">주기</span>
|
|
<span class="active-value">{secondsLabel(active?.coolTimeSeconds)}</span>
|
|
</span>
|
|
<span class="summary-cell active-probability-cell active-summary-cell">
|
|
<span class="mobile-label">확률</span>
|
|
<span class="active-value">{probabilityLabel(active?.probabilityPercent)}</span>
|
|
</span>
|
|
<span class="summary-cell active-duration-cell active-summary-cell">
|
|
<span class="mobile-label">지속</span>
|
|
<span class="active-value">{secondsLabel(active?.durationSeconds)}</span>
|
|
</span>
|
|
<span class="summary-cell active-effect-cell active-summary-cell">
|
|
<span class="mobile-label">효과</span>
|
|
<span class="active-effect" title={activeEffectLabel(active)}>{activeEffectLabel(active)}</span>
|
|
</span>
|
|
<span class="summary-cell level-cell">
|
|
<span class="skill-level-label">{selectedSkillLevel === 1 ? '명함' : '5 개화'}</span>
|
|
</span>
|
|
<span class="chevron" aria-hidden="true"></span>
|
|
</div>
|
|
|
|
{#if expanded}
|
|
<div class="card-details" id={`detail-${card.id}`}>
|
|
<div class="skill-grid">
|
|
<section class="skill-card passive-card">
|
|
<header>
|
|
<h4>패시브</h4>
|
|
</header>
|
|
{#if passive}
|
|
<p class="skill-description">
|
|
{#each descriptionSegments(passive.description) as segment}
|
|
<span class:highlight={segment.highlight} class:semantic-bold={segment.bold} class:numeric-value={segment.numeric} class:target-chip={segment.target} class:description-stat-term={segment.icon && statOptions.includes(segment.icon as HighestStat)} class:description-stat-performance={segment.icon === '퍼포먼스'} class:description-stat-technique={segment.icon === '테크닉'} class:description-stat-sense={segment.icon === '센스'} class:attribute-cute={segment.attribute === 'cute'} class:attribute-pure={segment.attribute === 'pure'} class:attribute-happy={segment.attribute === 'happy'} class:keyword-with-icon={segment.icon}>
|
|
{#if segment.icon && typeIconPaths[segment.icon as CardType]}<img class="description-type-icon" src={typeIconPaths[segment.icon as CardType]} alt="" aria-hidden="true" />{/if}
|
|
{#if segment.icon && statOptions.includes(segment.icon as HighestStat)}<span class="description-stat-icon stat-icon" class:stat-icon-performance={segment.icon === '퍼포먼스'} class:stat-icon-technique={segment.icon === '테크닉'} class:stat-icon-sense={segment.icon === '센스'} aria-hidden="true"></span>{/if}
|
|
{segment.text}
|
|
</span>
|
|
{/each}
|
|
</p>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="skill-card active-card">
|
|
<header>
|
|
<h4>액티브</h4>
|
|
{#if active?.condition}<span class="condition-badge">조건부</span>{/if}
|
|
</header>
|
|
{#if active}
|
|
<dl class="skill-spec active-spec" aria-label="액티브 스킬 발동 정보">
|
|
<div>
|
|
<dt>주기</dt>
|
|
<dd>{active.coolTimeSeconds !== null ? `${active.coolTimeSeconds}초` : '—'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>확률</dt>
|
|
<dd>{probabilityLabel(active.probabilityPercent)}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>지속</dt>
|
|
<dd>{active.durationSeconds !== null ? `${active.durationSeconds}초` : '—'}</dd>
|
|
</div>
|
|
</dl>
|
|
<div class="effect-breakdown">
|
|
{#if active.condition}
|
|
<div class="effect-block condition-effect">
|
|
<p class="effect-label">조건</p>
|
|
<p class="skill-description">
|
|
{#each descriptionSegments(active.condition) as segment}
|
|
<span class:highlight={segment.highlight} class:semantic-bold={segment.bold} class:numeric-value={segment.numeric} class:target-chip={segment.target} class:description-stat-term={segment.icon && statOptions.includes(segment.icon as HighestStat)} class:description-stat-performance={segment.icon === '퍼포먼스'} class:description-stat-technique={segment.icon === '테크닉'} class:description-stat-sense={segment.icon === '센스'} class:attribute-cute={segment.attribute === 'cute'} class:attribute-pure={segment.attribute === 'pure'} class:attribute-happy={segment.attribute === 'happy'} class:keyword-with-icon={segment.icon}>
|
|
{#if segment.icon && typeIconPaths[segment.icon as CardType]}<img class="description-type-icon" src={typeIconPaths[segment.icon as CardType]} alt="" aria-hidden="true" />{/if}
|
|
{#if segment.icon && statOptions.includes(segment.icon as HighestStat)}<span class="description-stat-icon stat-icon" class:stat-icon-performance={segment.icon === '퍼포먼스'} class:stat-icon-technique={segment.icon === '테크닉'} class:stat-icon-sense={segment.icon === '센스'} aria-hidden="true"></span>{/if}
|
|
{segment.text}
|
|
</span>
|
|
{/each}
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
<div class:result-effect={active.condition} class="effect-block">
|
|
<p class="effect-label">{active.condition ? '조건 충족 시 효과' : '효과'}</p>
|
|
<p class="skill-description">
|
|
{#each descriptionSegments(active.effect || active.description, selectedSkillLevel === 2) as segment}
|
|
<span class:highlight={segment.highlight} class:semantic-bold={segment.bold} class:numeric-value={segment.numeric} class:target-chip={segment.target} class:description-stat-term={segment.icon && statOptions.includes(segment.icon as HighestStat)} class:description-stat-performance={segment.icon === '퍼포먼스'} class:description-stat-technique={segment.icon === '테크닉'} class:description-stat-sense={segment.icon === '센스'} class:attribute-cute={segment.attribute === 'cute'} class:attribute-pure={segment.attribute === 'pure'} class:attribute-happy={segment.attribute === 'happy'} class:keyword-with-icon={segment.icon}>
|
|
{#if segment.icon && typeIconPaths[segment.icon as CardType]}<img class="description-type-icon" src={typeIconPaths[segment.icon as CardType]} alt="" aria-hidden="true" />{/if}
|
|
{#if segment.icon && statOptions.includes(segment.icon as HighestStat)}<span class="description-stat-icon stat-icon" class:stat-icon-performance={segment.icon === '퍼포먼스'} class:stat-icon-technique={segment.icon === '테크닉'} class:stat-icon-sense={segment.icon === '센스'} aria-hidden="true"></span>{/if}
|
|
{segment.text}
|
|
</span>
|
|
{/each}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="skill-card special-card">
|
|
<header>
|
|
<h4>스페셜</h4>
|
|
{#if special?.condition}<span class="condition-badge">조건부</span>{/if}
|
|
</header>
|
|
{#if special}
|
|
<dl class="skill-spec special-spec" aria-label="스페셜 스킬 발동 정보">
|
|
<div>
|
|
<dt>지속</dt>
|
|
<dd>{special.durationSeconds !== null ? `${special.durationSeconds}초` : '—'}</dd>
|
|
</div>
|
|
</dl>
|
|
<div class="skill-effects">
|
|
{#if special.condition}
|
|
<div class="effect-block condition-effect">
|
|
<p class="effect-label">조건</p>
|
|
<p class="skill-description">
|
|
{#each descriptionSegments(special.condition) as segment}
|
|
<span class:highlight={segment.highlight} class:semantic-bold={segment.bold} class:numeric-value={segment.numeric} class:target-chip={segment.target} class:description-stat-term={segment.icon && statOptions.includes(segment.icon as HighestStat)} class:description-stat-performance={segment.icon === '퍼포먼스'} class:description-stat-technique={segment.icon === '테크닉'} class:description-stat-sense={segment.icon === '센스'} class:attribute-cute={segment.attribute === 'cute'} class:attribute-pure={segment.attribute === 'pure'} class:attribute-happy={segment.attribute === 'happy'} class:keyword-with-icon={segment.icon}>
|
|
{#if segment.icon && typeIconPaths[segment.icon as CardType]}<img class="description-type-icon" src={typeIconPaths[segment.icon as CardType]} alt="" aria-hidden="true" />{/if}
|
|
{#if segment.icon && statOptions.includes(segment.icon as HighestStat)}<span class="description-stat-icon stat-icon" class:stat-icon-performance={segment.icon === '퍼포먼스'} class:stat-icon-technique={segment.icon === '테크닉'} class:stat-icon-sense={segment.icon === '센스'} aria-hidden="true"></span>{/if}
|
|
{segment.text}
|
|
</span>
|
|
{/each}
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
<div class:result-effect={special.condition} class="effect-block">
|
|
<p class="effect-label">{special.condition ? '조건 충족 시 효과' : '효과'}</p>
|
|
<p class="skill-description">
|
|
{#each descriptionSegments(special.effect || special.description, selectedSkillLevel === 2) as segment}
|
|
<span class:highlight={segment.highlight} class:semantic-bold={segment.bold} class:numeric-value={segment.numeric} class:target-chip={segment.target} class:description-stat-term={segment.icon && statOptions.includes(segment.icon as HighestStat)} class:description-stat-performance={segment.icon === '퍼포먼스'} class:description-stat-technique={segment.icon === '테크닉'} class:description-stat-sense={segment.icon === '센스'} class:attribute-cute={segment.attribute === 'cute'} class:attribute-pure={segment.attribute === 'pure'} class:attribute-happy={segment.attribute === 'happy'} class:keyword-with-icon={segment.icon}>
|
|
{#if segment.icon && typeIconPaths[segment.icon as CardType]}<img class="description-type-icon" src={typeIconPaths[segment.icon as CardType]} alt="" aria-hidden="true" />{/if}
|
|
{#if segment.icon && statOptions.includes(segment.icon as HighestStat)}<span class="description-stat-icon stat-icon" class:stat-icon-performance={segment.icon === '퍼포먼스'} class:stat-icon-technique={segment.icon === '테크닉'} class:stat-icon-sense={segment.icon === '센스'} aria-hidden="true"></span>{/if}
|
|
{segment.text}
|
|
</span>
|
|
{/each}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="skill-card costume-card">
|
|
<header>
|
|
<span class="skill-symbol" aria-hidden="true">의</span>
|
|
<h4>의상 효과</h4>
|
|
</header>
|
|
{#if card.details.costume}
|
|
<p class="skill-description">
|
|
{#each descriptionSegments(card.details.costume.description) as segment}
|
|
<span class:highlight={segment.highlight} class:semantic-bold={segment.bold} class:numeric-value={segment.numeric} class:target-chip={segment.target} class:description-stat-term={segment.icon && statOptions.includes(segment.icon as HighestStat)} class:description-stat-performance={segment.icon === '퍼포먼스'} class:description-stat-technique={segment.icon === '테크닉'} class:description-stat-sense={segment.icon === '센스'} class:attribute-cute={segment.attribute === 'cute'} class:attribute-pure={segment.attribute === 'pure'} class:attribute-happy={segment.attribute === 'happy'} class:keyword-with-icon={segment.icon}>
|
|
{#if segment.icon && typeIconPaths[segment.icon as CardType]}<img class="description-type-icon" src={typeIconPaths[segment.icon as CardType]} alt="" aria-hidden="true" />{/if}
|
|
{#if segment.icon && statOptions.includes(segment.icon as HighestStat)}<span class="description-stat-icon stat-icon" class:stat-icon-performance={segment.icon === '퍼포먼스'} class:stat-icon-technique={segment.icon === '테크닉'} class:stat-icon-sense={segment.icon === '센스'} aria-hidden="true"></span>{/if}
|
|
{segment.text}
|
|
</span>
|
|
{/each}
|
|
</p>
|
|
{:else}
|
|
<p class="skill-description empty-effect">의상 효과 없음</p>
|
|
{/if}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</article>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
</main>
|
|
|
|
<style>
|
|
:global(*) {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:global(html) {
|
|
background: #f5f7fb;
|
|
}
|
|
|
|
:global(body) {
|
|
margin: 0;
|
|
min-width: 320px;
|
|
min-height: 100vh;
|
|
color: #1d2433;
|
|
background:
|
|
radial-gradient(circle at 8% 0%, rgb(255 213 232 / 48%), transparent 26rem),
|
|
radial-gradient(circle at 94% 4%, rgb(193 233 255 / 52%), transparent 28rem),
|
|
#f5f7fb;
|
|
font-family: Pretendard, "Noto Sans KR", "Apple SD Gothic Neo", "Segoe UI", sans-serif;
|
|
}
|
|
|
|
main {
|
|
width: min(var(--page-max-width), calc(100% - var(--page-gutter)));
|
|
margin: 0 auto;
|
|
padding: var(--page-content-top) 0 80px;
|
|
}
|
|
|
|
.page-header {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: space-between;
|
|
gap: 24px;
|
|
margin-bottom: 36px;
|
|
}
|
|
|
|
h1 {
|
|
margin: 0;
|
|
font-size: var(--page-title-size);
|
|
line-height: 1.15;
|
|
letter-spacing: -0.045em;
|
|
}
|
|
|
|
.description {
|
|
margin: 10px 0 0;
|
|
color: #687185;
|
|
font-size: var(--page-description-size);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.total {
|
|
flex: 0 0 auto;
|
|
margin: 0;
|
|
padding: 11px 17px;
|
|
border: 1px solid rgb(255 255 255 / 72%);
|
|
border-radius: 999px;
|
|
color: #566074;
|
|
background: rgb(255 255 255 / 68%);
|
|
box-shadow: 0 8px 30px rgb(55 72 105 / 8%);
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
|
|
.total strong {
|
|
margin-right: 3px;
|
|
color: #20283a;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.total span {
|
|
color: #8a92a2;
|
|
}
|
|
|
|
.header-side {
|
|
display: grid;
|
|
justify-items: end;
|
|
gap: 9px;
|
|
}
|
|
|
|
|
|
.filters {
|
|
margin-bottom: 20px;
|
|
padding: 22px 24px 24px;
|
|
border: 1px solid rgb(255 255 255 / 72%);
|
|
border-radius: 20px;
|
|
background: rgb(255 255 255 / 72%);
|
|
box-shadow: 0 14px 42px rgb(55 72 105 / 8%);
|
|
backdrop-filter: blur(14px);
|
|
}
|
|
|
|
.filter-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
margin-bottom: 19px;
|
|
}
|
|
|
|
h2 {
|
|
margin: 0;
|
|
font-size: 17px;
|
|
letter-spacing: -0.025em;
|
|
}
|
|
|
|
.filter-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.filter-action {
|
|
padding: 7px 12px;
|
|
border: 1px solid #dce1ea;
|
|
border-radius: 9px;
|
|
color: #566074;
|
|
background: #fff;
|
|
font: inherit;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
transition: border-color 150ms ease, color 150ms ease, background 150ms ease;
|
|
}
|
|
|
|
.filter-action:hover:not(:disabled) {
|
|
border-color: #ef8bb5;
|
|
color: #d83f82;
|
|
background: #fff8fb;
|
|
}
|
|
|
|
.filter-action:disabled {
|
|
opacity: 0.42;
|
|
cursor: default;
|
|
}
|
|
|
|
.filter-groups {
|
|
display: grid;
|
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
.generation-filter {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.filter-groups > fieldset:not(.generation-filter) .filter-options {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.filter-groups > fieldset:not(.generation-filter) .filter-options label {
|
|
width: 100%;
|
|
}
|
|
|
|
fieldset {
|
|
min-width: 0;
|
|
margin: 0;
|
|
padding: 0;
|
|
border: 0;
|
|
}
|
|
|
|
legend {
|
|
margin-bottom: 10px;
|
|
padding: 0;
|
|
color: #737d90;
|
|
font-size: 14px;
|
|
font-weight: 750;
|
|
}
|
|
|
|
.filter-options {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.filter-options label {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
min-height: 34px;
|
|
padding: 6px 11px 6px 8px;
|
|
border: 1px solid #e0e4ec;
|
|
border-radius: 10px;
|
|
color: #7a8394;
|
|
background: #fafbfc;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
transition: border-color 150ms ease, color 150ms ease, background 150ms ease, box-shadow 150ms ease;
|
|
}
|
|
|
|
.filter-options label:hover {
|
|
border-color: #cbd2df;
|
|
background: #fff;
|
|
}
|
|
|
|
.filter-options label.checked {
|
|
border-color: #f3a5c6;
|
|
color: #c93475;
|
|
background: #fff3f8;
|
|
box-shadow: inset 0 0 0 1px rgb(239 85 150 / 7%);
|
|
}
|
|
|
|
.filter-options input {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
overflow: hidden;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.checkmark {
|
|
display: grid;
|
|
width: 18px;
|
|
height: 18px;
|
|
place-items: center;
|
|
border: 1px solid #cfd5df;
|
|
border-radius: 6px;
|
|
color: transparent;
|
|
background: #fff;
|
|
font-size: 13px;
|
|
line-height: 1;
|
|
}
|
|
|
|
label.checked .checkmark {
|
|
border-color: #ef5596;
|
|
color: #fff;
|
|
background: #ef5596;
|
|
}
|
|
|
|
.filter-options input:focus-visible + .checkmark {
|
|
outline: 3px solid rgb(73 134 231 / 25%);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.table-panel {
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
border: 1px solid #e3e7ef;
|
|
border-radius: 22px;
|
|
background: rgb(255 255 255 / 90%);
|
|
box-shadow: 0 22px 60px rgb(55 72 105 / 11%);
|
|
backdrop-filter: blur(16px);
|
|
}
|
|
|
|
.list-header,
|
|
.card-summary {
|
|
display: grid;
|
|
grid-template-columns: 62px 72px 110px minmax(160px, 1fr) 66px 66px 66px minmax(160px, 1.15fr) 190px 32px;
|
|
align-items: center;
|
|
}
|
|
|
|
.list-header {
|
|
min-width: 1260px;
|
|
min-height: 54px;
|
|
padding: 0 24px;
|
|
border-bottom: 1px solid #e8ebf1;
|
|
color: #7b8496;
|
|
background: #fafbfc;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.list-header .sort-button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
gap: 5px;
|
|
width: fit-content;
|
|
padding: 6px 7px;
|
|
border: 0;
|
|
border-radius: 7px;
|
|
color: inherit;
|
|
background: transparent;
|
|
font: inherit;
|
|
cursor: pointer;
|
|
transition: color 150ms ease, background 150ms ease;
|
|
}
|
|
|
|
.list-header .sort-button:hover,
|
|
.list-header .sort-button.active {
|
|
color: #d83f82;
|
|
background: #fff0f7;
|
|
}
|
|
|
|
.list-header .sort-button:focus-visible {
|
|
outline: 3px solid rgb(73 134 231 / 25%);
|
|
outline-offset: 1px;
|
|
}
|
|
|
|
.sort-indicator {
|
|
color: #a4adbb;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.list-header .sort-button.active .sort-indicator {
|
|
color: currentcolor;
|
|
}
|
|
|
|
.card-entry {
|
|
min-width: 1260px;
|
|
border-bottom: 1px solid #edf0f5;
|
|
}
|
|
|
|
.card-entry:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.card-entry.expanded {
|
|
background: #fcfdff;
|
|
}
|
|
|
|
.card-summary {
|
|
position: relative;
|
|
width: 100%;
|
|
min-height: 58px;
|
|
padding: 0 24px;
|
|
border: 0;
|
|
color: #343d50;
|
|
background: transparent;
|
|
font: inherit;
|
|
font-size: 15px;
|
|
text-align: left;
|
|
transition: background 150ms ease;
|
|
}
|
|
|
|
.summary-expand-button {
|
|
position: absolute;
|
|
z-index: 1;
|
|
inset: 0;
|
|
width: 100%;
|
|
border: 0;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.card-summary:hover,
|
|
.card-summary:has(.summary-expand-button:focus-visible) {
|
|
background: #fafbff;
|
|
}
|
|
|
|
.card-summary:has(.summary-expand-button:focus-visible) {
|
|
outline: 3px solid rgb(73 134 231 / 23%);
|
|
outline-offset: -3px;
|
|
}
|
|
|
|
.summary-cell {
|
|
position: relative;
|
|
z-index: 2;
|
|
min-width: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.name-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.active-summary-cell {
|
|
padding-right: 8px;
|
|
}
|
|
|
|
.active-value,
|
|
.active-effect {
|
|
color: #4d586d;
|
|
font-size: 13px;
|
|
font-weight: 750;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.active-effect {
|
|
display: block;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.level-cell {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.header-level-control {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
}
|
|
|
|
.header-level-toggle {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.skill-level-label {
|
|
padding: 4px 9px;
|
|
border-radius: 999px;
|
|
color: #b43d72;
|
|
background: #fff0f7;
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.mobile-label {
|
|
display: none;
|
|
}
|
|
|
|
.chevron {
|
|
position: relative;
|
|
z-index: 2;
|
|
width: 10px;
|
|
height: 10px;
|
|
justify-self: end;
|
|
border-right: 2px solid #9aa2b1;
|
|
border-bottom: 2px solid #9aa2b1;
|
|
transform: rotate(45deg) translate(-2px, -2px);
|
|
transition: transform 180ms ease, border-color 180ms ease;
|
|
}
|
|
|
|
.expanded .chevron {
|
|
border-color: #df4b8a;
|
|
transform: rotate(225deg) translate(-1px, -1px);
|
|
}
|
|
|
|
.card-details {
|
|
padding: 24px;
|
|
border-top: 1px solid #e7eaf1;
|
|
background:
|
|
linear-gradient(135deg, rgb(255 246 250 / 90%), rgb(247 251 255 / 92%)),
|
|
#fafcff;
|
|
}
|
|
|
|
.level-toggle {
|
|
display: inline-flex;
|
|
flex: 0 0 auto;
|
|
padding: 1px;
|
|
border: 1px solid #dfe3eb;
|
|
border-radius: 9px;
|
|
background: #fff;
|
|
}
|
|
|
|
.level-toggle button {
|
|
min-width: 50px;
|
|
padding: 3px 7px;
|
|
border: 0;
|
|
border-radius: 7px;
|
|
color: #7a8394;
|
|
background: transparent;
|
|
font: inherit;
|
|
font-size: 13px;
|
|
font-weight: 750;
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.level-toggle button.active {
|
|
color: #fff;
|
|
background: #e64f8f;
|
|
box-shadow: 0 4px 12px rgb(230 79 143 / 22%);
|
|
}
|
|
|
|
.level-toggle button:focus-visible {
|
|
outline: 3px solid rgb(73 134 231 / 25%);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.skill-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 12px;
|
|
}
|
|
|
|
.skill-card {
|
|
min-width: 0;
|
|
padding: 18px;
|
|
border: 1px solid #e3e7ef;
|
|
border-radius: 15px;
|
|
background: rgb(255 255 255 / 88%);
|
|
}
|
|
|
|
.skill-card header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
margin-bottom: 13px;
|
|
}
|
|
|
|
.skill-symbol {
|
|
display: grid;
|
|
width: 27px;
|
|
height: 27px;
|
|
place-items: center;
|
|
border-radius: 8px;
|
|
color: #fff;
|
|
font-size: 13px;
|
|
font-weight: 850;
|
|
}
|
|
|
|
.skill-card h4 {
|
|
margin: 0;
|
|
color: #343d50;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.condition-badge {
|
|
margin-left: auto;
|
|
padding: 4px 8px;
|
|
border-radius: 999px;
|
|
color: #3165c9;
|
|
background: #edf3ff;
|
|
font-size: 12px;
|
|
font-weight: 850;
|
|
}
|
|
|
|
.costume-card .skill-symbol {
|
|
background: #d99a27;
|
|
}
|
|
|
|
.skill-spec {
|
|
display: grid;
|
|
margin: -3px 0 11px;
|
|
overflow: hidden;
|
|
border: 1px solid #dfe5ef;
|
|
border-radius: 9px;
|
|
background: #f7f9fc;
|
|
}
|
|
|
|
.active-spec {
|
|
grid-template-columns: repeat(3, 1fr);
|
|
}
|
|
|
|
.special-spec {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.skill-spec div {
|
|
display: grid;
|
|
grid-template-columns: auto auto;
|
|
align-items: baseline;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
padding: 7px 8px;
|
|
}
|
|
|
|
.skill-spec div + div {
|
|
border-left: 1px solid #dfe5ef;
|
|
}
|
|
|
|
.skill-spec dt,
|
|
.skill-spec dd {
|
|
margin: 0;
|
|
}
|
|
|
|
.skill-spec dt {
|
|
color: #7b8495;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.skill-spec dd {
|
|
color: #34445f;
|
|
font-size: 13px;
|
|
font-weight: 850;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.effect-breakdown {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.effect-block {
|
|
padding: 10px 11px;
|
|
border: 1px solid #e7eaf0;
|
|
border-radius: 10px;
|
|
background: #f8f9fc;
|
|
}
|
|
|
|
.condition-effect {
|
|
border-color: #dfe8fb;
|
|
background: #f5f8ff;
|
|
}
|
|
|
|
.result-effect {
|
|
border-color: #f4d8e5;
|
|
background: #fff7fa;
|
|
}
|
|
|
|
.effect-label {
|
|
margin: 0 0 4px;
|
|
color: #8a93a4;
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.condition-effect .effect-label {
|
|
color: #5376b8;
|
|
}
|
|
|
|
.result-effect .effect-label {
|
|
color: #c24a7c;
|
|
}
|
|
|
|
.skill-description {
|
|
margin: 0;
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.skill-description {
|
|
color: #505a6e;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.keyword-with-icon {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.semantic-bold {
|
|
color: #30394c;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.numeric-value {
|
|
padding: 0 2px;
|
|
color: #30394c;
|
|
font-weight: 850;
|
|
font-variant-numeric: tabular-nums;
|
|
background: linear-gradient(transparent 62%, rgb(48 57 76 / 18%) 0);
|
|
}
|
|
|
|
.target-chip {
|
|
margin: 0 2px;
|
|
padding: 1px 5px;
|
|
border: 1px solid #dbe3f0;
|
|
border-radius: 5px;
|
|
color: #405678;
|
|
background: #f0f4fa;
|
|
box-decoration-break: clone;
|
|
-webkit-box-decoration-break: clone;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.description-stat-term {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
margin: 0 2px;
|
|
padding: 1px 5px 1px 3px;
|
|
border-radius: 5px;
|
|
line-height: 1.45;
|
|
vertical-align: -2px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.description-stat-performance {
|
|
color: #b94358;
|
|
background: #fff0f2;
|
|
}
|
|
|
|
.description-stat-technique {
|
|
color: #4167b5;
|
|
background: #eef3ff;
|
|
}
|
|
|
|
.description-stat-sense {
|
|
color: #7048ad;
|
|
background: #f5efff;
|
|
}
|
|
|
|
.description-type-icon,
|
|
.description-stat-icon {
|
|
display: inline-block;
|
|
width: 16px;
|
|
height: 16px;
|
|
margin-right: 3px;
|
|
vertical-align: -3px;
|
|
}
|
|
|
|
.description-type-icon {
|
|
object-fit: contain;
|
|
}
|
|
|
|
.description-stat-term .description-stat-icon {
|
|
margin-right: 0;
|
|
vertical-align: 0;
|
|
}
|
|
|
|
.empty-effect {
|
|
color: #969dab;
|
|
}
|
|
|
|
.highlight {
|
|
padding: 0 2px;
|
|
color: #30394c;
|
|
font-weight: 850;
|
|
background: linear-gradient(transparent 65%, rgb(230 79 143 / 24%) 0);
|
|
}
|
|
|
|
.numeric-value.highlight {
|
|
color: #30394c;
|
|
background: linear-gradient(transparent 62%, rgb(230 79 143 / 28%) 0);
|
|
}
|
|
|
|
.attribute-cute,
|
|
.attribute-pure,
|
|
.attribute-happy {
|
|
font-weight: 800;
|
|
}
|
|
|
|
.attribute-cute {
|
|
color: #df3f84;
|
|
}
|
|
|
|
.attribute-pure {
|
|
color: #258f9a;
|
|
}
|
|
|
|
.attribute-happy {
|
|
color: #c57b10;
|
|
}
|
|
|
|
.empty {
|
|
display: grid;
|
|
height: 180px;
|
|
place-items: center;
|
|
color: #858e9f;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: 26px;
|
|
border-radius: 999px;
|
|
font-size: 13px;
|
|
font-weight: 750;
|
|
}
|
|
|
|
.type-icon {
|
|
display: block;
|
|
width: 26px;
|
|
height: 26px;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.card-name {
|
|
color: #20283a;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.generation-name {
|
|
color: #6f788a;
|
|
font-size: 13px;
|
|
font-weight: 750;
|
|
}
|
|
|
|
.card-identity {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.version-badge {
|
|
flex: 0 0 auto;
|
|
padding: 2px 7px;
|
|
border-radius: 999px;
|
|
color: #287f91;
|
|
background: #e8f8fb;
|
|
font-size: 12px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.stat-badge {
|
|
padding: 3px 9px;
|
|
}
|
|
|
|
.stat-icon {
|
|
display: inline-block;
|
|
width: 19px;
|
|
height: 19px;
|
|
background-color: currentcolor;
|
|
mask-position: center;
|
|
mask-repeat: no-repeat;
|
|
mask-size: contain;
|
|
}
|
|
|
|
.stat-icon-performance {
|
|
color: #d95368;
|
|
mask-image: url('/icons/stats/performance.webp');
|
|
}
|
|
|
|
.stat-icon-technique {
|
|
color: #416fd1;
|
|
mask-image: url('/icons/stats/technique.webp');
|
|
}
|
|
|
|
.stat-icon-sense {
|
|
color: #8057c7;
|
|
mask-image: url('/icons/stats/sense.webp');
|
|
}
|
|
|
|
.stat-performance {
|
|
color: #d95368;
|
|
background: #fff0f2;
|
|
}
|
|
|
|
.stat-technique {
|
|
color: #416fd1;
|
|
background: #eef3ff;
|
|
}
|
|
|
|
.stat-sense {
|
|
color: #8057c7;
|
|
background: #f5efff;
|
|
}
|
|
|
|
/* Default dark theme */
|
|
:global(html) {
|
|
color-scheme: dark;
|
|
background: #0d1119;
|
|
}
|
|
|
|
:global(body) {
|
|
color: #e8ebf2;
|
|
background:
|
|
radial-gradient(circle at 8% 0%, rgb(230 79 143 / 14%), transparent 28rem),
|
|
radial-gradient(circle at 94% 4%, rgb(65 111 209 / 13%), transparent 30rem),
|
|
#0d1119;
|
|
}
|
|
|
|
.description,
|
|
.total,
|
|
legend,
|
|
.skill-spec dt,
|
|
.effect-label,
|
|
.empty {
|
|
color: #969fb0;
|
|
}
|
|
|
|
.total {
|
|
border-color: rgb(255 255 255 / 9%);
|
|
background: rgb(25 31 43 / 76%);
|
|
box-shadow: 0 8px 30px rgb(0 0 0 / 22%);
|
|
}
|
|
|
|
.total strong,
|
|
.card-name,
|
|
.skill-card h4,
|
|
.semantic-bold,
|
|
.numeric-value,
|
|
.highlight,
|
|
.numeric-value.highlight {
|
|
color: #edf0f6;
|
|
}
|
|
|
|
.generation-name {
|
|
color: #aab3c3;
|
|
}
|
|
|
|
.total span {
|
|
color: #747e90;
|
|
}
|
|
|
|
|
|
.filters {
|
|
border-color: rgb(255 255 255 / 8%);
|
|
background: rgb(22 28 39 / 84%);
|
|
box-shadow: 0 14px 42px rgb(0 0 0 / 24%);
|
|
}
|
|
|
|
.filter-action {
|
|
border-color: #343c4b;
|
|
color: #b5bdcb;
|
|
background: #1d2430;
|
|
}
|
|
|
|
.filter-action:hover:not(:disabled) {
|
|
border-color: #c85b8b;
|
|
color: #f08db7;
|
|
background: #2a1c27;
|
|
}
|
|
|
|
.filter-options label {
|
|
border-color: #303746;
|
|
color: #a4adbc;
|
|
background: #181e29;
|
|
}
|
|
|
|
.filter-options label:hover {
|
|
border-color: #465064;
|
|
background: #202735;
|
|
}
|
|
|
|
.filter-options label.checked {
|
|
border-color: #a94c75;
|
|
color: #f08db7;
|
|
background: #2a1b26;
|
|
box-shadow: inset 0 0 0 1px rgb(239 85 150 / 10%);
|
|
}
|
|
|
|
.checkmark {
|
|
border-color: #485163;
|
|
background: #111722;
|
|
}
|
|
|
|
.table-panel {
|
|
border-color: #2b3240;
|
|
background: rgb(22 27 37 / 94%);
|
|
box-shadow: 0 22px 60px rgb(0 0 0 / 30%);
|
|
}
|
|
|
|
.list-header {
|
|
border-color: #2b3240;
|
|
color: #8993a5;
|
|
background: #131923;
|
|
}
|
|
|
|
.list-header .sort-button:hover,
|
|
.list-header .sort-button.active {
|
|
color: #f08db7;
|
|
background: #2a1b26;
|
|
}
|
|
|
|
.active-value,
|
|
.active-effect {
|
|
color: #c2cad8;
|
|
}
|
|
|
|
.skill-level-label {
|
|
color: #f08db7;
|
|
background: #2a1b26;
|
|
}
|
|
|
|
.card-entry {
|
|
border-color: #282f3d;
|
|
}
|
|
|
|
.card-entry.expanded {
|
|
background: #191f2a;
|
|
}
|
|
|
|
.card-summary {
|
|
color: #cbd1dc;
|
|
}
|
|
|
|
.card-summary:hover,
|
|
.card-summary:has(.summary-expand-button:focus-visible) {
|
|
background: #1d2431;
|
|
}
|
|
|
|
.chevron {
|
|
border-color: #737d8f;
|
|
}
|
|
|
|
.card-details {
|
|
border-color: #303746;
|
|
background:
|
|
linear-gradient(135deg, rgb(47 27 40 / 62%), rgb(22 30 44 / 74%)),
|
|
#151b25;
|
|
}
|
|
|
|
.level-toggle {
|
|
border-color: #394252;
|
|
background: #111722;
|
|
}
|
|
|
|
.level-toggle button {
|
|
color: #9ca6b7;
|
|
}
|
|
|
|
.skill-card {
|
|
border-color: #343c4b;
|
|
background: rgb(27 33 45 / 92%);
|
|
}
|
|
|
|
.condition-badge {
|
|
color: #8fb1f5;
|
|
background: #202d47;
|
|
}
|
|
|
|
.skill-spec {
|
|
border-color: #343d4d;
|
|
background: #141a24;
|
|
}
|
|
|
|
.skill-spec div + div {
|
|
border-color: #343d4d;
|
|
}
|
|
|
|
.skill-spec dd {
|
|
color: #dbe1ec;
|
|
}
|
|
|
|
.effect-block {
|
|
border-color: #353d4c;
|
|
background: #171d28;
|
|
}
|
|
|
|
.condition-effect {
|
|
border-color: #344564;
|
|
background: #182235;
|
|
}
|
|
|
|
.result-effect {
|
|
border-color: #5a3347;
|
|
background: #291a24;
|
|
}
|
|
|
|
.condition-effect .effect-label {
|
|
color: #86a7e8;
|
|
}
|
|
|
|
.result-effect .effect-label {
|
|
color: #e184ad;
|
|
}
|
|
|
|
.skill-description {
|
|
color: #b9c1cf;
|
|
}
|
|
|
|
.numeric-value {
|
|
background: linear-gradient(transparent 62%, rgb(255 255 255 / 20%) 0);
|
|
}
|
|
|
|
.highlight,
|
|
.numeric-value.highlight {
|
|
background: linear-gradient(transparent 62%, rgb(230 79 143 / 42%) 0);
|
|
}
|
|
|
|
.target-chip {
|
|
border-color: #465268;
|
|
color: #c4d0e3;
|
|
background: #242d3b;
|
|
}
|
|
|
|
.description-stat-performance,
|
|
.stat-performance {
|
|
color: #f08091;
|
|
background: #3a2028;
|
|
}
|
|
|
|
.description-stat-technique,
|
|
.stat-technique {
|
|
color: #82a3ef;
|
|
background: #1f2b48;
|
|
}
|
|
|
|
.description-stat-sense,
|
|
.stat-sense {
|
|
color: #b896ef;
|
|
background: #302447;
|
|
}
|
|
|
|
.version-badge {
|
|
color: #7dd2df;
|
|
background: #17343b;
|
|
}
|
|
|
|
.empty-effect {
|
|
color: #778193;
|
|
}
|
|
|
|
@media (max-width: 1100px) {
|
|
.list-header,
|
|
.card-summary {
|
|
grid-template-columns: 62px 72px 110px minmax(160px, 1fr) 66px 66px 66px minmax(160px, 1.15fr) 190px 32px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 680px) {
|
|
main {
|
|
width: min(100% - 24px, 1120px);
|
|
padding: var(--page-content-top) 0 48px;
|
|
}
|
|
|
|
.page-header {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.description {
|
|
display: none;
|
|
}
|
|
|
|
.total {
|
|
margin: 0;
|
|
}
|
|
|
|
.header-side {
|
|
width: 100%;
|
|
justify-items: start;
|
|
}
|
|
|
|
.filters {
|
|
padding: 18px;
|
|
border-radius: 18px;
|
|
}
|
|
|
|
.filter-groups {
|
|
grid-template-columns: 1fr;
|
|
gap: 18px;
|
|
}
|
|
|
|
.table-panel {
|
|
border-radius: 18px;
|
|
}
|
|
|
|
.list-header {
|
|
display: none;
|
|
}
|
|
|
|
.card-list {
|
|
padding: 5px 16px;
|
|
}
|
|
|
|
.card-entry {
|
|
min-width: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.card-summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
min-width: 0;
|
|
gap: 14px 12px;
|
|
padding: 19px 0;
|
|
padding-right: 32px;
|
|
position: relative;
|
|
}
|
|
|
|
.card-summary:hover,
|
|
.card-summary:has(.summary-expand-button:focus-visible) {
|
|
background: transparent;
|
|
}
|
|
|
|
.summary-cell {
|
|
display: block;
|
|
}
|
|
|
|
.mobile-label {
|
|
display: block;
|
|
margin-bottom: 7px;
|
|
color: #9299a8;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.name-cell {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.active-summary-cell {
|
|
padding-right: 0;
|
|
}
|
|
|
|
.active-effect-cell {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.active-effect {
|
|
overflow: visible;
|
|
text-overflow: clip;
|
|
white-space: normal;
|
|
}
|
|
|
|
.level-cell {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.level-cell:empty {
|
|
display: none;
|
|
}
|
|
|
|
.card-name {
|
|
font-size: 17px;
|
|
}
|
|
|
|
.chevron {
|
|
position: absolute;
|
|
top: 50%;
|
|
right: 5px;
|
|
}
|
|
|
|
.card-details {
|
|
margin: 0 -16px;
|
|
padding: 20px 16px 22px;
|
|
}
|
|
|
|
.skill-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.skill-card {
|
|
padding: 16px;
|
|
}
|
|
}
|
|
</style>
|