Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 17:09:16 -04:00
1 parent 246b4832b0
commit 3e30af4c98
8 files changed
+309 -119

No files matched your search

+16
View File
@@ -3683,3 +3683,19 @@ Vite's existing large-chunk advisory remains informational.
- All 12 release-manager unit tests and Python compilation passed. The real
Windows Tk smoke launch also initialized the default-on mode and closed
cleanly.
## Thor character shell redesign (2026-09-20)
- The `960 x 540` top-display roster now keeps its character stage on the left,
a 300-pixel saved-character rail on the right, and the primary Continue action
inside a 62-pixel bottom bar. Both creator steps retain the shared live model
preview and fit without document overflow.
- All 47 avatar/rendering tests and the full production build passed. Browser
automation created a Human Priest, exercised both creator steps, and verified
the roster geometry without page errors. Evidence is in
`playtest-artifacts/character-shell/01-creator-path.png`,
`playtest-artifacts/character-shell/02-creator-appearance.png`, and
`playtest-artifacts/character-shell/03-character-select.png`.
- A production Wailing Caverns smoke run rendered the player and all four party
members with intact bodies and starter weapon silhouettes. Evidence is in
`playtest-artifacts/character-shell/04-in-dungeon.png`.
+12
View File
@@ -3,6 +3,7 @@ import { CLASSES } from "../app/characterCatalog";
import {
SPECIALIZATION_DEFINITIONS,
specializationById,
specializationIconUrl,
specializationsForClass,
} from "./specializationCatalog";
@@ -35,4 +36,15 @@ describe("specialization registry", () => {
expect(coa.every((definition) => ["tank", "healer", "damage"].includes(definition.role))).toBe(true);
expect(specializationById("barbarian:ancestry")?.role).toBe("damage");
});
it("uses dedicated classic spec icons with safe class-icon fallbacks", () => {
expect(specializationIconUrl("priest", "priest:holy"))
.toBe("/assets/ui/talents/icons/spell_holy_guardianspirit.png");
expect(specializationIconUrl("druid", "druid:feral-cat"))
.toBe("/assets/ui/talents/icons/ability_racial_bearform.png");
expect(specializationIconUrl("barbarian", "barbarian:ancestry"))
.toBe("/assets/ui/spells/classicon128_barbarian.png");
expect(specializationIconUrl("priest", "mage:frost"))
.toBe("/assets/ui/spells/classicon128_priest.png");
});
});
+46
View File
@@ -1,6 +1,7 @@
import {
baseClassFor,
classById,
classIconUrl,
type ClassId,
} from "../app/characterCatalog";
import type { ItemStats, NumericItemStatKey } from "./itemStats";
@@ -136,6 +137,40 @@ export const SPECIALIZATION_DEFINITIONS: readonly SpecializationDefinition[] = O
const BY_ID = new Map(SPECIALIZATION_DEFINITIONS.map((definition) => [definition.id, definition]));
const CLASSIC_SPECIALIZATION_ICON_BASENAME: Readonly<Record<string, string>> = Object.freeze({
"warrior:arms": "ability_rogue_eviscerate",
"warrior:fury": "ability_warrior_innerrage",
"warrior:protection": "inv_shield_06",
"paladin:holy": "spell_holy_holybolt",
"paladin:protection": "spell_holy_devotionaura",
"paladin:retribution": "spell_holy_auraoflight",
"hunter:beast-mastery": "ability_hunter_beasttaming",
"hunter:marksmanship": "ability_marksmanship",
"hunter:survival": "ability_hunter_swiftstrike",
"rogue:assassination": "ability_rogue_eviscerate",
"rogue:combat": "ability_backstab",
"rogue:subtlety": "ability_stealth",
"priest:discipline": "spell_holy_wordfortitude",
"priest:holy": "spell_holy_guardianspirit",
"priest:shadow": "spell_shadow_shadowwordpain",
"death-knight:blood": "spell_deathknight_bloodpresence",
"death-knight:frost": "spell_deathknight_frostpresence",
"death-knight:unholy": "spell_deathknight_unholypresence",
"shaman:elemental": "spell_nature_lightning",
"shaman:enhancement": "spell_nature_lightningshield",
"shaman:restoration": "spell_nature_magicimmunity",
"mage:arcane": "spell_holy_magicalsentry",
"mage:fire": "spell_fire_firebolt02",
"mage:frost": "spell_frost_frostbolt02",
"warlock:affliction": "spell_shadow_deathcoil",
"warlock:demonology": "spell_shadow_metamorphosis",
"warlock:destruction": "spell_shadow_rainoffire",
"druid:balance": "spell_nature_starfall",
"druid:feral-bear": "ability_racial_bearform",
"druid:feral-cat": "ability_racial_bearform",
"druid:restoration": "spell_nature_healingtouch",
});
export function specializationsForClass(classId: ClassId): readonly SpecializationDefinition[] {
return SPECIALIZATION_DEFINITIONS.filter((definition) => definition.classId === classId);
}
@@ -144,6 +179,17 @@ export function specializationById(id: string | null | undefined): Specializatio
return id ? BY_ID.get(id) ?? null : null;
}
/** Returns the dedicated WoW talent-tree icon when available, otherwise the class icon. */
export function specializationIconUrl(classId: ClassId, specializationId: string | null | undefined): string {
const specialization = specializationById(specializationId);
const iconBasename = specialization?.classId === classId
? CLASSIC_SPECIALIZATION_ICON_BASENAME[specialization.id]
: undefined;
return iconBasename
? `/assets/ui/talents/icons/${iconBasename}.png`
: classIconUrl(classId);
}
export function normalizeSpecializationId(classId: ClassId, value: unknown): string | null {
if (typeof value !== "string") return null;
const definition = specializationById(value);
+140 -42
View File
@@ -2129,47 +2129,76 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.character-preview--compact figcaption small { font-size: 7px; }
/* Character select */
.character-select-screen { display: grid; grid-template-columns: 250px minmax(250px, 1fr) 260px; grid-template-rows: 54px minmax(0, 1fr) 25px; gap: 0; padding-bottom: max(8px, env(safe-area-inset-bottom)); }
.roster-header { grid-column: 1 / 4; display: flex; align-items: center; gap: 17px; border-bottom: 1px solid var(--front-line); }
.character-select-screen {
--roster-gold: #d2b16d;
--roster-ice: #9fd9df;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 34%);
grid-template-rows: 58px minmax(0, 1fr) 68px;
gap: 12px;
padding-bottom: max(10px, env(safe-area-inset-bottom));
background:
linear-gradient(90deg, rgba(2,7,12,.42), rgba(2,7,12,.18) 58%, rgba(2,7,12,.75)),
linear-gradient(180deg, rgba(2,7,12,.36), rgba(2,7,12,.86)),
url("./assets/ui/login/frozen-gateway.webp") center 44% / cover no-repeat,
#071019;
}
.character-select-screen::before, .character-create-screen::before {
position: absolute;
inset: 0;
z-index: -1;
background: radial-gradient(circle at 39% 42%, rgba(108,197,207,.1), transparent 28%), linear-gradient(90deg, rgba(1,4,7,.28), transparent 42%, rgba(1,4,7,.48));
content: "";
pointer-events: none;
}
.character-select-screen .front-grain, .character-create-screen .front-grain { opacity: .1; mix-blend-mode: screen; }
.roster-header { grid-column: 1 / 3; display: flex; align-items: center; gap: 17px; padding: 0 4px 5px; border-bottom: 1px solid rgba(210,177,109,.34); }
.roster-header > div:nth-child(2) { display: flex; flex: 1; flex-direction: column; }
.roster-header > div:nth-child(2) span { color: var(--front-dim); font-size: 8px; text-transform: uppercase; letter-spacing: .13em; }
.roster-header > div:nth-child(2) strong { font: 18px Georgia, serif; }
.roster-header > button, .creator-header > button { padding: 7px 10px; border: 1px solid var(--front-line); border-radius: 7px; background: transparent; color: var(--front-dim); cursor: pointer; font-size: 9px; }
.roster-list { grid-column: 1; grid-row: 2; min-height: 0; padding: 12px 12px 18px 0; overflow: auto; border-right: 1px solid var(--front-line); }
.roster-list__heading { display: flex; justify-content: space-between; margin: 0 5px 8px; color: var(--front-dim); font-size: 8px; text-transform: uppercase; letter-spacing: .12em; }
.roster-character { display: grid; width: 100%; min-height: 54px; grid-template-columns: 38px 1fr auto; align-items: center; gap: 8px; margin-bottom: 6px; padding: 7px; border: 1px solid transparent; border-radius: 9px; background: rgba(255,255,255,.025); color: var(--front-cream); cursor: pointer; text-align: left; }
.roster-character.is-active { border-color: rgba(204, 223, 170, 0.26); background: linear-gradient(100deg, rgba(107, 146, 82, .19), rgba(255,255,255,.025)); }
.roster-character > span { display: grid; width: 36px; height: 36px; place-items: center; border: 1px solid var(--portrait-accent); border-radius: 50%; background: color-mix(in srgb, var(--portrait-accent), transparent 82%); color: #e7eddb; font-size: 9px; font-weight: 800; }
.roster-header > div:nth-child(2) span { color: #b8cbc9; font-size: 8px; text-transform: uppercase; letter-spacing: .16em; }
.roster-header > div:nth-child(2) strong { color: #f4eddd; font: 21px Georgia, serif; }
.roster-header > button, .creator-header > button { padding: 8px 12px; border: 1px solid rgba(210,177,109,.28); border-radius: 4px; background: rgba(8,17,21,.72); color: #d8d2c3; cursor: pointer; font-size: 9px; }
.roster-list { grid-column: 2; grid-row: 2; display: grid; min-height: 0; grid-template-rows: auto minmax(0, 1fr) auto; padding: 14px; border: 1px solid rgba(210,177,109,.35); border-radius: 4px 4px 10px 10px; overflow: hidden; background: linear-gradient(155deg, rgba(25,35,38,.94), rgba(7,14,18,.96)); box-shadow: 0 18px 40px rgba(0,0,0,.38), inset 0 1px 0 rgba(255,255,255,.04); }
.roster-list__heading { display: flex; justify-content: space-between; margin: 0 2px 11px; padding-bottom: 8px; border-bottom: 1px solid rgba(210,177,109,.2); color: #b9c8c5; font-size: 8px; text-transform: uppercase; letter-spacing: .15em; }
.roster-list__heading b { color: var(--roster-gold); }
.roster-list__entries { min-height: 0; padding-right: 3px; overflow-y: auto; scrollbar-width: thin; }
.roster-character { display: grid; width: 100%; min-height: 61px; grid-template-columns: 42px 1fr auto; align-items: center; gap: 9px; margin-bottom: 7px; padding: 8px; border: 1px solid rgba(194,213,190,.08); border-radius: 4px; background: linear-gradient(100deg, rgba(255,255,255,.035), rgba(255,255,255,.012)); color: var(--front-cream); cursor: pointer; text-align: left; transition: border-color 150ms ease, background 150ms ease, transform 150ms ease; }
.roster-character:hover { border-color: rgba(159,217,223,.3); background: rgba(95,143,148,.1); transform: translateX(-2px); }
.roster-character.is-active { border-color: rgba(210,177,109,.68); background: linear-gradient(100deg, rgba(141,105,38,.32), rgba(74,116,122,.12)); box-shadow: inset 3px 0 0 var(--roster-gold), 0 0 18px rgba(111,190,198,.08); }
.roster-character > span { display: grid; width: 40px; height: 40px; place-items: center; overflow: hidden; border: 1px solid var(--portrait-accent); border-radius: 6px; background: color-mix(in srgb, var(--portrait-accent), transparent 78%); box-shadow: inset 0 0 14px color-mix(in srgb, var(--portrait-accent), transparent 78%), 0 2px 8px rgba(0,0,0,.48); }
.roster-character__icon img { width: 100%; height: 100%; object-fit: cover; }
.roster-character i { display: flex; min-width: 0; flex-direction: column; font-style: normal; }
.roster-character i strong { overflow: hidden; font: 14px Georgia, serif; text-overflow: ellipsis; white-space: nowrap; }
.roster-character i small, .roster-character em { color: var(--front-dim); font-size: 7px; font-style: normal; }
.roster-new { display: flex; width: 100%; min-height: 51px; align-items: center; gap: 9px; padding: 7px 10px; border: 1px dashed rgba(200, 222, 173, .23); border-radius: 9px; background: transparent; color: var(--front-cream); cursor: pointer; text-align: left; }
.roster-new b { color: var(--front-green-bright); font: 24px Georgia, serif; }
.roster-character i strong { overflow: hidden; font: 16px Georgia, serif; text-overflow: ellipsis; white-space: nowrap; }
.roster-character i small, .roster-character em { color: #a9b8b6; font-size: 7px; font-style: normal; }
.roster-new { display: flex; width: 100%; min-height: 54px; align-items: center; gap: 10px; margin-top: 9px; padding: 8px 11px; border: 1px dashed rgba(210,177,109,.4); border-radius: 4px; background: rgba(119,91,38,.08); color: var(--front-cream); cursor: pointer; text-align: left; }
.roster-new b { color: var(--roster-gold); font: 26px Georgia, serif; }
.roster-new span { display: flex; flex-direction: column; }
.roster-new span strong { font-size: 10px; }
.roster-new span small { color: var(--front-dim); font-size: 7px; }
.roster-new span strong { font-size: 11px; }
.roster-new span small { color: #a9b8b6; font-size: 7px; }
.roster-empty { display: grid; min-height: 155px; place-content: center; justify-items: center; color: var(--front-dim); text-align: center; }
.roster-empty span { color: var(--front-green-bright); font: 32px Georgia, serif; }
.roster-empty span { color: var(--roster-gold); font: 32px Georgia, serif; }
.roster-empty strong { color: var(--front-cream); font: 15px Georgia, serif; }
.roster-empty small { max-width: 160px; margin: 4px 0 12px; font-size: 8px; line-height: 1.4; }
.roster-stage { position: relative; grid-column: 2; grid-row: 2; min-width: 0; min-height: 0; }
.roster-stage-continue { position: absolute; right: 12px; bottom: 14px; left: 12px; z-index: 4; display: none; justify-content: center; }
.roster-character-info { position: absolute; right: 0; bottom: 12px; left: 0; z-index: 2; display: none; text-align: center; }
.roster-character-info p { margin: 0; color: var(--front-dim); font-size: 8px; text-transform: uppercase; }
.roster-character-info h1 { margin: 2px 0; font: 26px Georgia, serif; }
.roster-stage { position: relative; grid-column: 1; grid-row: 2; min-width: 0; min-height: 0; overflow: hidden; border: 1px solid rgba(159,217,223,.2); border-radius: 10px 4px 4px 10px; background: radial-gradient(circle at 54% 40%, rgba(100,190,200,.18), transparent 34%), linear-gradient(145deg, rgba(4,12,17,.5), rgba(3,8,12,.83)); box-shadow: inset 0 0 70px rgba(28,65,72,.14), 0 18px 45px rgba(0,0,0,.3); }
.roster-stage::after { position: absolute; inset: 8px; z-index: 0; border: 1px solid rgba(210,177,109,.12); border-radius: 7px 2px 2px 7px; content: ""; pointer-events: none; }
.roster-stage .character-preview { z-index: 1; }
.roster-stage .character-preview figcaption { display: none; }
.roster-stage .character-preview__viewport { bottom: 8px; }
.roster-character-info { position: absolute; top: 18px; bottom: 18px; left: 18px; z-index: 3; display: flex; width: min(225px, 43%); flex-direction: column; justify-content: flex-end; padding: 18px; border-left: 2px solid rgba(210,177,109,.62); background: linear-gradient(90deg, rgba(3,9,13,.92), rgba(3,9,13,.62) 72%, transparent); text-align: left; pointer-events: none; }
.roster-character-info p { margin: 0; color: var(--roster-gold); font-size: 8px; letter-spacing: .14em; text-transform: uppercase; }
.roster-character-info h1 { margin: 4px 0 2px; color: #f4eddd; font: 34px Georgia, serif; line-height: .95; }
.roster-character-info strong, .roster-character-info small { display: block; }
.roster-character-info strong { font-size: 10px; }
.roster-character-info small { max-width: 250px; margin: 4px auto 0; color: var(--front-dim); font-size: 8px; }
.roster-details { grid-column: 3; grid-row: 2; align-self: stretch; padding: 22px 4px 20px 16px; border-left: 1px solid var(--front-line); }
.roster-details h2 { margin: 7px 0 17px; font: 22px Georgia, serif; line-height: 1.05; }
.roster-details > p:not(.front-kicker):not(.front-notice) { color: var(--front-dim); font-size: 10px; line-height: 1.5; }
.roster-details dl, .companion-panel dl { margin: 0 0 14px; }
.roster-details dl div, .companion-panel dl div { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px solid rgba(203, 219, 180, .09); }
.roster-details dt, .companion-panel dt { color: var(--front-dim); font-size: 8px; text-transform: uppercase; }
.roster-details dd, .companion-panel dd { margin: 0; font-size: 9px; text-align: right; }
.roster-enter { width: 100%; margin-top: 13px; }
.roster-details .text-action { display: block; margin: 10px auto 0; }
.character-select-screen > .controller-legend { grid-column: 1 / 4; grid-row: 3; position: static; align-self: end; justify-self: end; }
.roster-character-info strong { font-size: 11px; }
.roster-character-info > small { margin-top: 7px; color: #b8c5c3; font-size: 8px; line-height: 1.45; }
.roster-character-info dl { margin: 15px 0 0; }
.roster-character-info dl div { display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 8px; padding: 5px 0; border-top: 1px solid rgba(210,177,109,.13); }
.roster-character-info dt { color: #91a5a4; font-size: 7px; letter-spacing: .08em; text-transform: uppercase; }
.roster-character-info dd { overflow: hidden; margin: 0; font-size: 8px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
.roster-footer { grid-column: 1 / 3; grid-row: 3; display: flex; align-items: center; gap: 13px; padding: 10px 0 0; border-top: 1px solid rgba(210,177,109,.28); }
.roster-footer__status { min-width: 0; flex: 1; color: #aebdbb; font-size: 8px; letter-spacing: .06em; text-transform: uppercase; }
.roster-footer__status .front-notice { margin: 3px 0 0; }
.roster-footer .text-action { flex: 0 0 auto; }
.roster-enter { width: min(310px, 38%); min-height: 48px; border-radius: 4px; }
.character-select-screen > .controller-legend { display: none; }
.front-modal-backdrop { position: absolute; inset: 0; z-index: 50; display: grid; place-items: center; padding: 20px; background: rgba(1,5,3,.76); backdrop-filter: blur(8px); }
.front-confirm { width: min(390px, 100%); padding: 25px; border: 1px solid var(--front-line); border-radius: 14px; background: #0a1812; box-shadow: 0 30px 80px #000; }
.front-confirm h2 { margin: 7px 0; font: 27px Georgia, serif; }
@@ -2530,6 +2559,71 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.customization-row input[type="range"] { width: 100%; accent-color: var(--front-green); }
.appearance-actions { display: grid; max-width: 610px; grid-template-columns: 1fr 95px 180px; align-items: center; gap: 8px; margin-top: 10px; }
/* Character shell polish */
.companion-panel dl { margin: 0 0 14px; }
.companion-panel dl div { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px solid rgba(203,219,180,.09); }
.companion-panel dt { color: var(--front-dim); font-size: 8px; text-transform: uppercase; }
.companion-panel dd { margin: 0; font-size: 9px; text-align: right; }
.character-create-screen {
--creator-gold: #d2b16d;
--creator-ice: #9fd9df;
grid-template-columns: 265px minmax(245px, 1fr) 300px;
grid-template-rows: 58px 3px minmax(0, 1fr) 66px;
background:
linear-gradient(90deg, rgba(2,7,12,.67), rgba(2,7,12,.16) 47%, rgba(2,7,12,.72)),
linear-gradient(180deg, rgba(2,7,12,.42), rgba(2,7,12,.88)),
url("./assets/ui/login/frozen-gateway.webp") center 44% / cover no-repeat,
#071019;
}
.creator-header { padding: 0 4px 5px; border-bottom-color: rgba(210,177,109,.34); }
.creator-header > div:nth-child(2) span { color: #b8cbc9; letter-spacing: .16em; }
.creator-header > div:nth-child(2) strong { color: #f4eddd; font-size: 20px; }
.creator-progress { gap: 5px; padding-top: 1px; }
.creator-progress i { background: rgba(255,255,255,.08); }
.creator-progress i.is-complete { background: linear-gradient(90deg, #8d713b, var(--creator-gold), #72b6bd); box-shadow: 0 0 10px rgba(123,202,210,.3); }
.creator-category-tabs button { min-height: 34px; border-color: rgba(210,177,109,.22); border-radius: 3px; background: rgba(7,15,19,.8); }
.creator-category-tabs button.is-active { border-color: rgba(210,177,109,.72); color: #f1d99a; background: linear-gradient(180deg, rgba(144,105,35,.32), rgba(79,62,31,.2)); box-shadow: inset 0 -2px 0 var(--creator-gold); }
.creator-races, .creator-classes {
margin-top: 12px;
margin-bottom: 10px;
padding: 13px;
border: 1px solid rgba(210,177,109,.28);
border-radius: 4px;
background: linear-gradient(155deg, rgba(24,34,37,.92), rgba(6,13,17,.95));
box-shadow: 0 16px 36px rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.035);
}
.creator-races { margin-right: 10px; overflow-y: auto; }
.creator-classes { margin-left: 10px; }
.creator-races > header, .creator-classes > header { padding-bottom: 8px; border-bottom: 1px solid rgba(210,177,109,.16); }
.creator-races > header > span, .creator-classes > header > span { color: var(--creator-gold); }
.race-option, .class-option { border-radius: 3px; border-color: rgba(194,213,190,.06); background: rgba(255,255,255,.025); }
.race-option:hover, .class-option:hover { border-color: rgba(159,217,223,.28); background: rgba(80,132,138,.1); }
.race-option.is-active, .class-option.is-active { border-color: rgba(210,177,109,.62); background: linear-gradient(100deg, rgba(141,105,38,.28), rgba(73,119,124,.1)); box-shadow: inset 2px 0 0 var(--creator-gold); }
.gender-picker button { border-radius: 3px; border-color: rgba(210,177,109,.2); background: rgba(5,12,16,.56); }
.gender-picker button.is-active { border-color: rgba(159,217,223,.4); background: rgba(77,132,138,.2); color: #d7eef0; }
.creator-preview-stage { margin: 12px 0 10px; overflow: hidden; border: 1px solid rgba(159,217,223,.2); border-radius: 4px; background: radial-gradient(circle at 50% 40%, rgba(97,184,193,.18), transparent 36%), rgba(3,9,13,.48); box-shadow: inset 0 0 55px rgba(29,75,81,.13); }
.creator-preview-stage::after { position: absolute; inset: 8px; z-index: 0; border: 1px solid rgba(210,177,109,.12); border-radius: 2px; content: ""; pointer-events: none; }
.creator-preview-title { position: absolute; top: 14px; left: 15px; z-index: 4; display: flex; flex-direction: column; padding-left: 8px; border-left: 2px solid var(--creator-gold); pointer-events: none; }
.creator-preview-title small { color: var(--creator-gold); font-size: 7px; letter-spacing: .15em; text-transform: uppercase; }
.creator-preview-title strong { color: #f4eddd; font: 14px Georgia, serif; }
.creator-preview-stage .character-preview figcaption { bottom: 36px; }
.preview-rotate { bottom: 7px; }
.preview-rotate button { border-radius: 3px; border-color: rgba(210,177,109,.3); background: rgba(5,13,17,.86); }
.class-summary { padding: 8px; border-left: 2px solid rgba(159,217,223,.5); background: rgba(5,13,17,.52); }
.creator-footer { padding-top: 9px; border-top-color: rgba(210,177,109,.28); }
.creator-footer__selection { display: flex; min-width: 0; flex: 1; flex-direction: column; }
.creator-footer__selection small { color: #93a8a7; font-size: 7px; letter-spacing: .12em; text-transform: uppercase; }
.creator-footer__selection strong { overflow: hidden; color: #e8dec7; font: 14px Georgia, serif; text-overflow: ellipsis; text-transform: capitalize; white-space: nowrap; }
.creator-footer .front-button { min-height: 48px; border-radius: 4px; }
.appearance-preview-stage { margin: 12px 10px 10px 0; border: 1px solid rgba(159,217,223,.2); border-radius: 4px; background: radial-gradient(circle at 50% 40%, rgba(97,184,193,.18), transparent 36%), rgba(3,9,13,.54); }
.appearance-preview-stage .character-preview figcaption { bottom: 34px; }
.appearance-preview-stage > p { display: none; }
.appearance-controls { margin: 12px 0 10px 10px; padding: 18px; border: 1px solid rgba(210,177,109,.28); border-radius: 4px; background: linear-gradient(155deg, rgba(24,34,37,.94), rgba(6,13,17,.96)); box-shadow: 0 16px 36px rgba(0,0,0,.3); }
.appearance-controls header h1 { color: #f4eddd; font-size: 30px; }
.customization-row { border-radius: 3px; border-color: rgba(210,177,109,.12); background: rgba(255,255,255,.025); }
.appearance-actions { position: sticky; bottom: -8px; z-index: 4; padding: 9px 0 0; background: linear-gradient(180deg, transparent, rgba(7,14,18,.98) 25%); }
.appearance-actions .front-button { min-height: 48px; border-radius: 4px; }
/* Companion / lower Thor screen */
.companion-panel { display: grid; grid-template-rows: 45px minmax(0, 1fr) 45px; padding: 12px 14px; }
.companion-panel > header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--front-line); }
@@ -2612,7 +2706,8 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.companion-mini-roster button { display: grid; width: 100%; min-height: 39px; grid-template-columns: 29px 1fr auto; align-items: center; gap: 7px; margin-bottom: 4px; padding: 5px; border: 1px solid transparent; border-radius: 7px; background: rgba(255,255,255,.025); color: var(--front-cream); cursor: pointer; text-align: left; }
.companion-mini-roster button.is-active { border-color: var(--front-line); background: rgba(99,142,79,.17); }
.companion-mini-roster button:disabled { cursor: not-allowed; opacity: .42; }
.companion-mini-roster button > span { display: grid; width: 27px; height: 27px; place-items: center; border: 1px solid var(--front-line); border-radius: 50%; font-size: 7px; }
.companion-mini-roster button > span { display: grid; width: 27px; height: 27px; place-items: center; overflow: hidden; border: 1px solid var(--front-line); border-radius: 5px; background: rgba(0,0,0,.35); }
.companion-character-icon img { width: 100%; height: 100%; object-fit: cover; }
.companion-mini-roster button strong { font-size: 9px; }
.companion-mini-roster button small { color: var(--front-dim); font-size: 7px; }
.companion-mini-roster > p { color: var(--front-dim); font-size: 9px; }
@@ -2722,12 +2817,10 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
}
@media (max-width: 900px) {
.character-select-screen { grid-template-columns: 235px 1fr; }
.character-select-screen { grid-template-columns: minmax(0, 1fr) 300px; }
.roster-header { grid-column: 1 / 3; }
.roster-details { display: none; }
.roster-stage-continue { display: flex; }
.character-select-screen > .controller-legend { grid-column: 1 / 3; }
.character-create-screen { grid-template-columns: 245px 1fr 270px; }
.character-create-screen { grid-template-columns: 235px minmax(220px, 1fr) 270px; }
.main-menu-screen { grid-template-columns: minmax(225px, 31%) minmax(0, 1fr); column-gap: 16px; }
.main-menu-mode { min-height: 72px; }
.dungeon-select-screen { grid-template-columns: 255px minmax(0, 1fr); column-gap: 14px; }
@@ -2748,15 +2841,20 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
.login-form__actions .front-button small { font-size: 7px; }
.login-form footer, .login-lore { display: none; }
.controller-legend { display: none; }
.character-select-screen { display: grid; grid-template-columns: 43% 57%; grid-template-rows: 48px minmax(0,1fr); }
.character-select-screen { display: grid; grid-template-columns: 43% 57%; grid-template-rows: 48px minmax(0,1fr) 62px; gap: 7px; }
.roster-header { grid-column: 1 / 3; }
.roster-header > div:nth-child(2) strong { font-size: 14px; }
.roster-header > button { display: none; }
.roster-list { padding-right: 7px; }
.roster-list { padding: 9px 7px 9px 9px; }
.roster-character { grid-template-columns: 31px 1fr; }
.roster-character > span { width: 29px; height: 29px; }
.roster-character > em { display: none; }
.roster-stage .character-preview figcaption { bottom: 62px; }
.roster-character-info { top: auto; right: 8px; bottom: 8px; left: 8px; width: auto; padding: 10px; }
.roster-character-info > small, .roster-character-info dl { display: none; }
.roster-character-info h1 { font-size: 24px; }
.roster-footer { grid-column: 1 / 3; padding-top: 6px; }
.roster-footer__status { display: none; }
.roster-enter { width: min(280px, 62%); margin-left: auto; }
.character-avatar { transform: perspective(600px) rotateY(var(--avatar-turn)) scale(.78); }
.main-menu-screen { grid-template-columns: 1fr; grid-template-rows: 48px 86px minmax(0, 1fr); gap: 0; }
.main-menu-header { grid-column: 1; grid-row: 1; }
+2
View File
@@ -153,6 +153,7 @@ export function CharacterCreateScreen() {
<p>{race.description}</p>
</section>
<main className="creator-preview-stage">
<div className="creator-preview-title"><small>Live preview</small><strong>{race.name} {characterClass.name}</strong></div>
<CharacterPreview draft={draft} />
<div className="preview-rotate">
<ControllerButton controlId="rotate-left" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => updateDraft({ rotation: draft.rotation - 12 })} aria-label="Rotate character left">&#8634;</ControllerButton>
@@ -205,6 +206,7 @@ export function CharacterCreateScreen() {
)}
</section>
<footer className="creator-footer">
<div className="creator-footer__selection"><small>Selected path</small><strong>{race.name} · {characterClass.name} · {draft.gender}</strong></div>
<ControllerButton controlId="creator-back" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={cancel}>Back to roster</ControllerButton>
<ControllerButton controlId="creator-next" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary" type="button" onClick={() => setStep("appearance")}><span>Customize &amp; name</span><small>Next step</small></ControllerButton>
</footer>
+68 -66
View File
@@ -3,6 +3,7 @@ import { CLASSES, PLAYABLE_RACES, classById, contentCategoryForClass, FACTIONS,
import { contentCategoryById } from "../app/contentCategories";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { specializationById, specializationIconUrl } from "../game/specializationCatalog";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { CharacterPreview } from "./CharacterPreview";
import { ControllerButton } from "./ControllerButton";
@@ -55,78 +56,79 @@ export function CharacterSelectScreen() {
<header className="roster-header">
<BrandMark compact />
<div><span>{session?.kind === "offline" ? "Offline expedition" : `Local account / ${session?.displayName}`}</span><strong>Choose your adventurer</strong></div>
<button type="button" onClick={signOut}>Sign out</button>
<ControllerButton controlId="sign-out" selectedId={controller.selectedId} select={controller.select} type="button" onClick={signOut}>Sign out</ControllerButton>
</header>
<main className="roster-stage">
<CharacterPreview character={selected} />
{selected && race && characterClass && (
<section className="roster-character-info">
<p>{FACTIONS[race.faction].name} / {selected.gender}</p>
<h1>{selected.name}</h1>
<strong style={{ color: characterClass.color }}>{race.name} {characterClass.name}{selected.secondaryClassId ? ` / ${classById(selected.secondaryClassId).name}` : ""}</strong>
<small>{characterClass.description}</small>
<dl>
<div><dt>Level</dt><dd>{selected.level}</dd></div>
<div><dt>Last camp</dt><dd>{selected.location}</dd></div>
<div><dt>Role</dt><dd>{characterClass.role}</dd></div>
</dl>
</section>
)}
</main>
<aside className="roster-list" aria-label="Saved characters">
<div className="roster-list__heading"><span>Characters</span><b>{characters.length} / 10</b></div>
{characters.map((character) => {
const raceDefinition = raceById(character.raceId);
const classDefinition = classById(character.classId);
const categoryDefinition = contentCategoryById(character.categoryId ?? contentCategoryForClass(character.classId));
return (
<ControllerButton
key={character.id}
controlId={`character-${character.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={`roster-character ${character.id === selectedId ? "is-active" : ""}`}
type="button"
onClick={() => select(character.id)}
>
<span style={{ "--portrait-accent": raceDefinition.accent } as CSSProperties}>{raceDefinition.sigil}</span>
<i><strong>{character.name}</strong><small>{categoryDefinition.label} / Level {character.level} {classDefinition.name}{character.secondaryClassId ? ` + ${classById(character.secondaryClassId).name}` : ""}</small></i>
<em>{formatLastPlayed(character.lastPlayedAt)}</em>
</ControllerButton>
);
})}
{!characters.length && <div className="roster-empty"><span>+</span><strong>No adventurers yet</strong><small>Create one to enter a dungeon.</small></div>}
<div className="roster-list__heading"><span>Your characters</span><b>{characters.length} / 10</b></div>
<div className="roster-list__entries">
{characters.map((character) => {
const classDefinition = classById(character.classId);
const specialization = specializationById(character.specializationId);
const categoryDefinition = contentCategoryById(character.categoryId ?? contentCategoryForClass(character.classId));
return (
<ControllerButton
key={character.id}
controlId={`character-${character.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={`roster-character ${character.id === selectedId ? "is-active" : ""}`}
type="button"
onClick={() => select(character.id)}
>
<span
className="roster-character__icon"
style={{ "--portrait-accent": classDefinition.color } as CSSProperties}
title={`${specialization?.displayName ?? classDefinition.name} icon`}
>
<img src={specializationIconUrl(character.classId, character.specializationId)} alt="" aria-hidden="true" />
</span>
<i><strong>{character.name}</strong><small>{categoryDefinition.label} / Level {character.level} {classDefinition.name}{character.secondaryClassId ? ` + ${classById(character.secondaryClassId).name}` : ""}</small></i>
<em>{formatLastPlayed(character.lastPlayedAt)}</em>
</ControllerButton>
);
})}
{!characters.length && <div className="roster-empty"><span>+</span><strong>No adventurers yet</strong><small>Create one to enter a dungeon.</small></div>}
</div>
<ControllerButton controlId="new-character" selectedId={controller.selectedId} select={controller.select} className="roster-new" type="button" onClick={openCreator}>
<b>+</b><span><strong>Create character</strong><small>{PLAYABLE_RACES.length} races / {CLASSES.length} classes</small></span>
</ControllerButton>
</aside>
<main className="roster-stage">
<CharacterPreview character={selected} />
{selected && race && characterClass && (
<>
<section className="roster-character-info">
<p>{FACTIONS[race.faction].name} / {selected.gender}</p>
<h1>{selected.name}</h1>
<strong style={{ color: characterClass.color }}>{race.name} {characterClass.name}{selected.secondaryClassId ? ` / ${classById(selected.secondaryClassId).name}` : ""}</strong>
<small>{characterClass.description}</small>
</section>
<ControllerButton
controlId="continue-character"
selectedId={controller.selectedId}
select={controller.select}
className="front-button front-button--primary roster-stage-continue"
type="button"
onClick={continueWithCharacter}
>Continue as {selected.name}</ControllerButton>
</>
)}
</main>
<aside className="roster-details">
<p className="front-kicker">Expedition record</p>
{selected && race && characterClass ? (
<>
<h2>{selected.location}</h2>
<dl>
<div><dt>Level</dt><dd>{selected.level}</dd></div>
<div><dt>Content</dt><dd>{contentCategoryById(selected.categoryId ?? contentCategoryForClass(selected.classId)).name}</dd></div>
<div><dt>Faction</dt><dd>{FACTIONS[race.faction].name}</dd></div>
<div><dt>Class role</dt><dd>{characterClass.role}</dd></div>
<div><dt>Next step</dt><dd>Choose a game mode</dd></div>
</dl>
<ControllerButton controlId="continue-character" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary roster-enter" type="button" onClick={continueWithCharacter}>
<span>Continue</span><small>Open the main menu</small>
</ControllerButton>
<ControllerButton controlId="delete-character" selectedId={controller.selectedId} select={controller.select} className="text-action text-action--danger" type="button" onClick={() => setConfirmDelete(true)}>Delete character</ControllerButton>
</>
) : (
<><h2>New expedition</h2><p>Create a character using the options loaded from your wow335a client.</p></>
)}
{notice && <p className="front-notice" role="status">{notice}</p>}
</aside>
<footer className="roster-footer">
<div className="roster-footer__status">
<span>{selected && race && characterClass
? `${contentCategoryById(selected.categoryId ?? contentCategoryForClass(selected.classId)).name} · ${FACTIONS[race.faction].name}`
: "Select an adventurer or forge a new one"}</span>
{notice && <p className="front-notice" role="status">{notice}</p>}
</div>
{selected && <ControllerButton controlId="delete-character" selectedId={controller.selectedId} select={controller.select} className="text-action text-action--danger" type="button" onClick={() => setConfirmDelete(true)}>Delete</ControllerButton>}
<ControllerButton
controlId="continue-character"
selectedId={controller.selectedId}
select={controller.select}
className="front-button front-button--primary roster-enter"
type="button"
disabled={!selected}
onClick={continueWithCharacter}
>
<span>{selected ? `Continue as ${selected.name}` : "Choose a character"}</span><small>{selected ? "Enter the adventure menu" : "Select from your roster"}</small>
</ControllerButton>
</footer>
<ControllerLegend />
{confirmDelete && selected && (
<div className="front-modal-backdrop">
+7 -1
View File
@@ -11,6 +11,7 @@ import {
import { useGameStore } from "../game/store";
import type { GameOverlay } from "../game/store";
import { ACTION_CONTROLS } from "../game/actionBindings";
import { specializationById, specializationIconUrl } from "../game/specializationCatalog";
import {
DUNGEON_CATALOG,
DUNGEON_CATEGORIES,
@@ -176,7 +177,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
<div className="companion-mini-roster" aria-label="Character quick select">
{snapshot.characters.map((character) => (
<button key={character.id} type="button" className={character.id === snapshot.selectedCharacterId ? "is-active" : ""} onClick={() => send({ name: "selectCharacter", characterId: character.id })}>
<span>{raceById(character.raceId).sigil}</span><strong>{character.name}</strong><small>Lv {character.level}</small>
<span
className="companion-character-icon"
title={`${specializationById(character.specializationId)?.displayName ?? classById(character.classId).name} icon`}
>
<img src={specializationIconUrl(character.classId, character.specializationId)} alt="" aria-hidden="true" />
</span><strong>{character.name}</strong><small>Lv {character.level}</small>
</button>
))}
{!snapshot.characters.length && <p>No characters saved yet.</p>}
+18 -10
View File
@@ -118,16 +118,24 @@
.login-form__actions .front-button--primary { grid-column: auto; }
.login-form__actions .front-button small { display: none; }
.login-form footer { display: none; }
.character-select-screen { grid-template-columns: 220px minmax(0, 1fr); }
.character-select-screen { grid-template-columns: minmax(0, 1fr) 300px; grid-template-rows: 52px minmax(0, 1fr) 62px; gap: 8px; }
.roster-header, .character-select-screen > .controller-legend { grid-column: 1 / 3; }
.roster-details { display: none; }
.roster-stage-continue { display: flex; }
.character-create-screen { grid-template-columns: 42% 58%; grid-template-rows: 47px 3px minmax(0, 1fr) 58px; }
.creator-header, .creator-progress, .creator-footer { grid-column: 1 / 3; }
.roster-list { padding: 10px; }
.roster-character { min-height: 58px; }
.roster-character-info { width: min(215px, 42%); padding: 13px; }
.roster-character-info h1 { font-size: 30px; }
.roster-footer { padding-top: 6px; }
.character-create-screen { grid-template-columns: 225px minmax(220px, 1fr) 260px; grid-template-rows: 50px 3px minmax(0, 1fr) 60px; }
.creator-header, .creator-progress, .creator-footer { grid-column: 1 / 4; }
.creator-races { grid-column: 1; }
.creator-preview-stage, .appearance-preview-stage { display: none; }
.creator-classes { grid-column: 2; }
.appearance-controls { grid-column: 1 / 3; }
.creator-preview-stage { display: block; grid-column: 2; }
.creator-classes { grid-column: 3; }
.creator-races, .creator-classes { margin-top: 8px; margin-bottom: 7px; padding: 9px; }
.creator-races { margin-right: 6px; }
.creator-classes { margin-left: 6px; }
.creator-preview-stage { margin: 8px 0 7px; }
.appearance-preview-stage { display: block; grid-column: 1; }
.appearance-controls { grid-column: 2 / 4; }
.creator-footer { gap: 8px; }
.dungeon-select-screen { grid-template-columns: 260px minmax(0, 1fr); gap: 10px; }
.dungeon-select-detail { grid-template-columns: 1fr; gap: 10px; }
@@ -136,8 +144,8 @@
.dungeon-role-option { min-height: 48px; }
.front-surface--top :is(.race-option > span, .class-option strong, .roster-character small, .main-menu-mode small) { font-size: 12px; }
.race-option > b { font-size: 10px; }
.creator-classes { grid-template-rows: auto minmax(100px, 1fr) auto; overflow-y: auto; }
.class-summary { max-height: 155px; overflow-y: auto; }
.creator-classes { grid-template-rows: auto minmax(90px, 1fr) auto auto; overflow-y: auto; }
.class-summary { max-height: 100px; overflow-y: auto; }
.class-option small { font-size: 11px; }
.game-panel :is(p, small, label, dt, dd, em), .game-panel :is(button, select, input) { font-size: 12px; }
.game-panel :is(button, select, input), .pause-menu__actions .button { min-height: 44px; }