Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 478 additions & 0 deletions THIRD-PARTY-NOTICES.md

Large diffs are not rendered by default.

Binary file added apps/tools/src/assets/skill-stats/activation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/tools/src/assets/skill-stats/adrenaline.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/tools/src/assets/skill-stats/energy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/tools/src/assets/skill-stats/overcast.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/tools/src/assets/skill-stats/recharge.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/tools/src/assets/skill-stats/sacrifice.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions apps/tools/src/components/SkillDetails.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import SkillDetails from "./SkillDetails.vue";
import { ELITE_FIXTURE_SKILLS } from "../elite-fixture";

describe("shared skill details", () => {
it("labels the real stat icons and preserves fractional seconds and health percentages", () => {
const wrapper = mount(SkillDetails, { props: { skill: { ...ELITE_FIXTURE_SKILLS[0]!,
energyCost: 5, adrenalineCost: 8, activationSeconds: 0.25, rechargeSeconds: 12,
healthCost: 10, overcast: 5, aftercastSeconds: 0.75 } } });
expect(wrapper.findAll('.skill-stats img').map(icon => icon.attributes('alt'))).toEqual([
'Energy', 'Adrenaline', 'Activation', 'Recharge', 'Health sacrifice', 'Overcast',
]);
expect(wrapper.findAll('.skill-stats dd').map(value => value.text())).toEqual(['5', '8', '0.25s', '12s', '10%', '5', '0.75s']);
expect(wrapper.findAll('.skill-stats img').every(icon => icon.attributes('src')?.includes('.png'))).toBe(true);
expect(wrapper.text()).toContain('Warrior');
wrapper.unmount();
});
it("omits zero mechanics and explains unavailable descriptions without inventing values", () => {
const wrapper = mount(SkillDetails, { props: { skill: { ...ELITE_FIXTURE_SKILLS[0]!,
energyCost: 0, adrenalineCost: 0, activationSeconds: 0, rechargeSeconds: 0,
healthCost: 0, overcast: 0, aftercastSeconds: 0, description: '' } } });
expect(wrapper.find('.skill-stats').exists()).toBe(false);
expect(wrapper.text()).toContain('Description is unavailable from this installed client.');
wrapper.unmount();
});
});
87 changes: 40 additions & 47 deletions apps/tools/src/components/SkillDetails.vue
Original file line number Diff line number Diff line change
@@ -1,58 +1,51 @@
<!-- Shared skill information for Builds and Elite Skills; actions belong to each consumer. -->
<script setup lang="ts">
import { computed } from "vue";
import type { SkillPresentation } from "../skill-catalog";
defineProps<{ skill: SkillPresentation }>();
import { PROFESSIONS } from "../../../../src/shared/builds/heroes";
import energyIcon from "../assets/skill-stats/energy.png";
import adrenalineIcon from "../assets/skill-stats/adrenaline.png";
import activationIcon from "../assets/skill-stats/activation.png";
import rechargeIcon from "../assets/skill-stats/recharge.png";
import sacrificeIcon from "../assets/skill-stats/sacrifice.png";
import overcastIcon from "../assets/skill-stats/overcast.png";
import "../styles/skill-details.css";
const props = defineProps<{ skill: SkillPresentation }>();
const stats = computed(() => [
{ label: "Energy", value: props.skill.energyCost, suffix: "", icon: energyIcon },
{ label: "Adrenaline", value: props.skill.adrenalineCost, suffix: "", icon: adrenalineIcon },
{ label: "Activation", value: props.skill.activationSeconds, suffix: "s", icon: activationIcon },
{ label: "Recharge", value: props.skill.rechargeSeconds, suffix: "s", icon: rechargeIcon },
{ label: "Health sacrifice", value: props.skill.healthCost, suffix: "%", icon: sacrificeIcon },
{ label: "Overcast", value: props.skill.overcast, suffix: "", icon: overcastIcon },
].filter(stat => stat.value !== 0));
function hideBrokenIcon(event: Event): void {
if (event.target instanceof HTMLImageElement) event.target.hidden = true;
}
</script>
<template>
<div class="skill-details">
<div class="inspector-identity">
<span
class="ui-slot skill"
:data-elite="skill.elite ? '' : undefined"
:data-profession="skill.profession"
:data-icon-missing="skill.iconUrl ? undefined : ''"
>
<img
v-if="skill.iconUrl"
:src="skill.iconUrl"
alt=""
draggable="false"
@error="hideBrokenIcon"
>
<span class="skill-fallback" aria-hidden="true">
{{ skill.name.split(" ").map((part) => part[0]).join("").slice(0, 3) }}
</span>
</span>
<span>
<strong>{{ skill.name }}</strong>
<small>
{{ skill.profession ?? "PvE" }}
<template v-if="skill.attribute"> · {{ skill.attribute.replace(/([a-z])([A-Z])/gu, "$1 $2") }}</template>
</small>
</span>
</div>
<div class="mechanic-list">
<span v-if="skill.elite"><strong>Elite</strong><small>One per bar</small></span>
<span v-if="skill.availability === 'player-only-pve'">
<strong>Player only</strong><small>Heroes cannot equip this skill</small>
</span>
<span v-if="skill.energyCost"><strong>{{ skill.energyCost }}</strong><small>Energy</small></span>
<span v-if="skill.adrenalineCost"><strong>{{ skill.adrenalineCost }}</strong><small>Adrenaline</small></span>
<span v-if="skill.healthCost"><strong>{{ skill.healthCost }}%</strong><small>Health</small></span>
<span v-if="skill.overcast"><strong>{{ skill.overcast }}</strong><small>Overcast</small></span>
<span v-if="skill.activationSeconds"><strong>{{ skill.activationSeconds }}s</strong><small>Activation</small></span>
<span v-if="skill.aftercastSeconds"><strong>{{ skill.aftercastSeconds }}s</strong><small>Aftercast</small></span>
<span v-if="skill.rechargeSeconds"><strong>{{ skill.rechargeSeconds }}s</strong><small>Recharge</small></span>
</div>
<div v-if="skill.description" class="skill-description">
<strong>Description</strong>
<p>{{ skill.description }}</p>
</div>
<p v-else class="description-unavailable">
Description is unavailable from this installed client.
</p>
<div class="inspector-identity">
<span class="ui-slot skill" :data-elite="skill.elite ? '' : undefined" :data-profession="skill.profession"
:data-icon-missing="skill.iconUrl ? undefined : ''">
<img v-if="skill.iconUrl" :src="skill.iconUrl" alt="" draggable="false" @error="hideBrokenIcon">
<span class="skill-fallback" aria-hidden="true">{{ skill.name.split(" ").map(part => part[0]).join("").slice(0, 3) }}</span>
</span>
<span>
<strong>{{ skill.name }}</strong>
<small>{{ skill.profession ? PROFESSIONS[skill.profession].name : "PvE" }}<template v-if="skill.attribute"> · {{ skill.attribute.replace(/([a-z])([A-Z])/gu, "$1 $2") }}</template></small>
<small v-if="skill.elite" class="skill-elite-label">Elite · One per bar</small>
</span>
</div>
<dl v-if="stats.length || skill.aftercastSeconds" class="skill-stats" aria-label="Skill costs and timings">
<div v-for="stat in stats" :key="stat.label" :title="`${stat.label}: ${stat.value}${stat.suffix}`">
<dt><img :src="stat.icon" :alt="stat.label" width="20" height="20" draggable="false"></dt>
<dd>{{ stat.value }}{{ stat.suffix }}</dd>
</div>
<div v-if="skill.aftercastSeconds" class="skill-aftercast"><dt>Aftercast</dt><dd>{{ skill.aftercastSeconds }}s</dd></div>
</dl>
<p v-if="skill.availability === 'player-only-pve'" class="skill-restriction">Player only · Heroes cannot equip this skill</p>
<div v-if="skill.description" class="skill-description"><p>{{ skill.description }}</p></div>
<p v-else class="description-unavailable">Description is unavailable from this installed client.</p>
</div>
</template>
18 changes: 1 addition & 17 deletions apps/tools/src/styles/catalogue.css
Original file line number Diff line number Diff line change
Expand Up @@ -338,22 +338,6 @@
.inspector-identity strong { color: var(--ui-text-bright); }
.inspector-identity small { color: var(--ui-text-muted); }

.mechanic-list {
display: flex;
flex-wrap: wrap;
gap: var(--ui-space-2);
}

.mechanic-list > span {
display: grid;
min-width: 70px;
padding: var(--ui-space-2);
background: var(--ui-well-fill);
}

.mechanic-list strong { color: var(--ui-text-bright); }
.mechanic-list small { color: var(--ui-text-faint); }

.skill-description {
display: grid;
gap: var(--ui-space-1);
Expand Down Expand Up @@ -426,7 +410,7 @@
.catalogue-tools { grid-template-columns: 1fr; }
.catalogue-count { display: none; }
.catalogue-workspace > .workspace-heading p,
.skill-inspector .mechanic-list,
.skill-inspector .skill-stats,
.skill-inspector .skill-description,
.skill-inspector .description-unavailable { display: none; }
.skill-inspector {
Expand Down
14 changes: 14 additions & 0 deletions apps/tools/src/styles/skill-details.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* Shared, compact mechanics use the same locally bundled icons in both skill inspectors. */
.skill-details { display: grid; gap: var(--ui-space-3); }
.skill-details .inspector-identity { grid-template-columns: 48px minmax(0, 1fr); }
.skill-details .inspector-identity .skill { width: 48px; height: 48px; }
.skill-details .inspector-identity strong { font-size: var(--ui-font-size-lg); line-height: 1.2; overflow-wrap: anywhere; }
.skill-details .inspector-identity small { font-size: var(--ui-font-size-sm); line-height: 1.4; }
.skill-details .inspector-identity .skill-elite-label { color: var(--ui-accent); }
.skill-stats { display: flex; flex-wrap: wrap; align-items: center; gap: var(--ui-space-2) var(--ui-space-4); margin: 0; }
.skill-stats > div { display: inline-flex; align-items: center; gap: var(--ui-space-1); }
.skill-stats dt, .skill-stats dd { margin: 0; }
.skill-stats img { display: block; width: 20px; height: 20px; object-fit: contain; }
.skill-stats dd { color: var(--ui-text-bright); font-variant-numeric: tabular-nums; }
.skill-aftercast { font-size: var(--ui-font-size-sm); color: var(--ui-text-muted); }
.skill-restriction { margin: 0; font-size: var(--ui-font-size-sm); color: var(--ui-text-muted); }
2 changes: 1 addition & 1 deletion apps/tools/tests/workbench.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ test("uses the inline catalogue for filters, mechanics, elite replacement, and k
await expect(page.locator(".skill-result .ui-chip").filter({ hasText: "Elite" }).first()).toBeVisible();
await page.getByRole("searchbox", { name: "Search skills" }).fill("Cry of Frustration");
await page.locator(".skill-result").first().click();
await expect(page.getByText("Recharge", { exact: true })).toBeVisible();
await expect(page.getByRole("img", { name: "Recharge", exact: true })).toBeVisible();
await expect(page.locator(".skill-description")).toContainText(
"Cry of Frustration demonstrates the client-owned skill description",
);
Expand Down
4 changes: 3 additions & 1 deletion docs/elite-skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ Hover or focus a skill for a preview. Open it for full details and capture notes
The sticky detail header keeps **Back to skills** and **Close** visible. Back retains
the current search and filters.
The Builds skill inspector opens the same details through **Find capture locations**.
Both interfaces read descriptions, mechanics, and icons from the installed client.
Both interfaces read descriptions, mechanics, and skill artwork from the installed client.
The shared detail component shows locally bundled Tango-style cost and timing icons
with accessible labels. See [icon attribution](../THIRD-PARTY-NOTICES.md#guild-wars-wiki-stat-icons).

Track a skill to save it for this character. **Track this boss** also selects
one active capture location. Closing the planner leaves only tracked markers.
Expand Down
Loading