Release Healer Man 0.1.28

This commit is contained in:
phenom committed 2026-09-10 14:14:02 -04:00
1 parent ed8b9e3719
commit 8c9809d3e0
23 files changed
+975 -55

No files matched your search

+33
View File
@@ -4,6 +4,39 @@ This file is the durable record of runtime behavior, regressions, and the
evidence required before calling a visual bug fixed. Update it whenever scene,
camera, input, physics, mob-rendering, or asset-loading code changes.
## 2026-09-10 Personal UI templates and desktop action bars
- Opened Wailing Caverns through the normal offline roster, dungeon, and role
selection screens, then opened Pause > Edit UI & templates.
- Verified five bars containing 12 buttons each, including 2 x 6 and 3 x 4
arrangements. Default stacking accounts for each bar's height. The editor
can collapse or dock on either side so drag handles remain reachable.
- Clicked Renew and pressed Shift+Q in keybind edit mode. Reassigned Shift+Q
to bar 5/button 12 and verified the previous keybind cleared. Assigned Renew
to that button through the ability picker; after saving, Shift+Q cast it and
displayed its cooldown. No spell fired during key capture.
- Dragged the minimap, hid party frames, saved/renamed/overwrote PC slot 1,
loaded it, and round-tripped all layouts through export text and import.
The imported JSON retained the minimap position, hidden party, bar geometry,
ability assignment, key chord, and template name. Restoring a hidden widget
works, and Cancel discards that visibility edit.
- At a 390 x 844 viewport, the editor remained within the viewport and the
Save UI button was visible and 36 CSS pixels high. The in-app browser's
screenshot preview scales the page unusually at this override; DOM bounds
confirmed the editor was 310 CSS pixels wide. This was responsive browser
verification, not an Android hardware playtest.
- Evidence: `playtest-artifacts/ui-layout-desktop.png` and
`playtest-artifacts/ui-layout-phone-viewport.png`. Restored the offline UI
to defaults and removed the temporary template via import after testing.
- Automated coverage includes migration to six slots, owner isolation,
cloud upload/hydration into a separate device cache with a mocked endpoint,
malformed imports, key conflicts, and input precedence. No production
account credentials were used for the cloud round-trip test.
- `npm run build` and the focused 67-test suite passed. The development server
was restarted after an existing watcher hit EBUSY on generated web-bootstrap
assets during a concurrent build; no runtime UI error was logged in the
subsequent playtest.
## 2026-08-18 GM Manastorm administration - COMPLETE
- Server-authorized `Phenom` access, revisioned SQLite configuration, safe
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.27",
"version": "0.1.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.27",
"version": "0.1.28",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.27",
"version": "0.1.28",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.27",
"versionCode": 1027
"versionName": "0.1.28",
"versionCode": 1028
}
+18 -1
View File
@@ -1,4 +1,5 @@
import { useEffect } from "react";
import "./ui/uiLayout.css";
import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { installInput, releasePointerLock } from "./game/inputManager";
import { CombatBridge } from "./game/CombatBridge";
@@ -26,12 +27,27 @@ import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge";
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
import { UiLayoutEditor } from "./ui/UiLayoutEditor";
import { EditableHudElement } from "./ui/EditableHudElement";
import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore";
import { actionKeyForEvent, desktopSlotForKey } from "./game/uiLayout";
function InputBridge() {
const overlay = useGameStore((state) => state.overlay);
const companionOpen = useGameStore((state) => state.companionOpen);
useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
handleCustomKey: (event) => {
const game = useGameStore.getState();
if (game.overlay === "ui-layout") return !["Tab", "Enter", "Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.code);
if (currentUiDevice() !== "pc" || game.overlay || game.companionOpen || useCombatStore.getState().pendingGroundTarget) return false;
const button = desktopSlotForKey(activeUiLayout(), actionKeyForEvent(event));
if (!button) return false;
if (!event.repeat && button.abilityId) useCombatStore.getState().castAbility(button.abilityId, game.playerPosition, Date.now());
game.setInputMode("keyboard");
return true;
},
togglePause: () => useGameStore.getState().togglePause(),
toggleMap: () => useGameStore.getState().toggleMap(),
toggleInventory: () => useGameStore.getState().toggleOverlay("inventory"),
@@ -98,10 +114,11 @@ export default function GameRuntime() {
<ManastormBridge />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
<Hud />
<DungeonInteractionPrompt />
<EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement>
<MapPanel />
<GameplayPanels />
<PauseMenu />
<UiLayoutEditor />
<GmReviewCard />
<GmWorldEditor />
</main>
+20 -3
View File
@@ -1,4 +1,5 @@
import type { CharacterProfile, PlayerSession } from "./types";
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
import {
contentCategoryForClass,
isRomClassId,
@@ -64,6 +65,7 @@ interface ProfileDatabase {
accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
uiPreferencesByOwner?: Record<string, UiPreferences>;
}
export interface AccountResult {
@@ -202,6 +204,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
accounts?: unknown;
rosters?: unknown;
presentationSettingsByOwner?: unknown;
uiPreferencesByOwner?: unknown;
}) | null;
if (!parsed || ![1, 2, 3, 4, 5].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
return emptyDatabase();
@@ -243,7 +246,9 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
return [ownerId, normalized];
}),
);
const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner };
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
const database: ProfileDatabase = { version: 5, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 5 || presentationSettingsMigrated) writeDatabase(database, storage);
return database;
} catch {
@@ -254,11 +259,23 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
storage?.setItem(DATABASE_KEY, JSON.stringify(database));
if (!storage || !rosterChangeListener) return;
for (const [ownerId, characters] of Object.entries(database.rosters)) {
rosterChangeListener(ownerId, characters);
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
rosterChangeListener(ownerId, database.rosters[ownerId] ?? []);
}
}
export function loadUiPreferences(ownerId: string, storage: StorageLike | null = browserLocalStorage()): UiPreferences {
return normalizeUiPreferences(readDatabase(storage).uiPreferencesByOwner?.[ownerId]);
}
export function saveUiPreferences(ownerId: string, value: unknown, storage: StorageLike | null = browserLocalStorage()): UiPreferences {
const database = readDatabase(storage);
const preferences = normalizeUiPreferences(value);
database.uiPreferencesByOwner = { ...database.uiPreferencesByOwner, [ownerId]: preferences };
writeDatabase(database, storage);
return preferences;
}
function bytesToBase64(bytes: Uint8Array): string {
let result = "";
for (let index = 0; index < bytes.length; index += 3) {
+21 -2
View File
@@ -1,8 +1,11 @@
import { Capacitor } from "@capacitor/core";
import { create } from "zustand";
import {
listCharacters,
replaceCharactersForOwner,
setRosterChangeListener,
loadUiPreferences,
saveUiPreferences,
type AccountResult,
} from "./accountRepository";
import type { CharacterProfile, PlayerSession } from "./types";
@@ -20,7 +23,7 @@ interface AuthResponse {
interface CloudSaveResponse {
revision: number;
updatedAt: number | null;
data: { version?: number; characters?: unknown };
data: { version?: number; characters?: unknown; uiPreferences?: unknown };
}
interface MeResponse {
@@ -39,6 +42,8 @@ let suppressUpload = false;
let pendingCharacters: CharacterProfile[] | null = null;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let saveQueue = Promise.resolve();
export const useCloudSaveStatus = create<{ state: "local" | "pending" | "synced" | "error"; message: string }>(() => ({ state: "local", message: "" }));
let saveSequence = 0;
export function onlineApiOrigin(): string {
const configured = String(import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, "");
@@ -100,18 +105,24 @@ function flushCloudSave(): void {
const characters = pendingCharacters;
pendingCharacters = null;
if (!session?.accessToken || !characters) return;
const uiPreferences = loadUiPreferences(session.ownerId);
const sequence = saveSequence;
saveQueue = saveQueue.then(async () => {
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
method: "PUT",
body: JSON.stringify({ data: { version: 1, characters } }),
body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }),
}, session.accessToken);
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." });
}).catch((error) => {
console.error("Healer Man cloud save failed", error);
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "error", message: "Saved on this device. Cloud upload failed; retry before switching devices." });
});
}
function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[]): void {
if (suppressUpload || !activeSession?.accessToken || activeSession.ownerId !== ownerId) return;
saveSequence += 1;
useCloudSaveStatus.setState({ state: "pending", message: "Uploading to your account…" });
pendingCharacters = characters.map((character) => ({ ...character }));
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
@@ -123,15 +134,23 @@ function queueCloudSave(ownerId: string, characters: readonly CharacterProfile[]
setRosterChangeListener(queueCloudSave);
export function activateOnlineSession(session: PlayerSession): void {
if (activeSession?.ownerId !== session.ownerId && saveTimer) {
clearTimeout(saveTimer); saveTimer = null; flushCloudSave();
}
activeSession = session.accessToken ? session : null;
}
export function retryCloudSave(): void {
if (activeSession) queueCloudSave(activeSession.ownerId, listCharacters(activeSession.ownerId));
}
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
if (!session.accessToken) return listCharacters(session.ownerId);
activateOnlineSession(session);
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
suppressUpload = true;
try {
saveUiPreferences(session.ownerId, cloud.data?.uiPreferences);
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
} finally {
suppressUpload = false;
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadUiPreferences, saveUiPreferences, setRosterChangeListener } from "./accountRepository";
import { normalizeUiPreferences } from "../game/uiLayout";
import type { PlayerSession } from "./types";
function storage() {
const values = new Map<string, string>();
return { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
}
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); setRosterChangeListener(null); });
describe("profile UI persistence", () => {
it("isolates owners and persists templates without requiring a character", () => {
const local = storage(); const ui = normalizeUiPreferences(null);
ui.slots.pc[1] = { name: "Healer", layout: ui.current.pc };
saveUiPreferences("one", ui, local);
expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer");
expect(loadUiPreferences("two", local).slots.pc[1]).toBeNull();
ui.slots.pc[1].name = "Unsaved change";
expect(loadUiPreferences("one", local).slots.pc[1]?.name).toBe("Healer");
});
it("uploads all six slots and restores them into a separate device cache", async () => {
vi.useFakeTimers();
const first = storage(); const second = storage();
vi.stubGlobal("window", { localStorage: first });
let cloudData: unknown = { version: 1, characters: [] };
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200, headers: { "Content-Type": "application/json" } });
}));
const client = await import("./onlineAccountClient");
const session: PlayerSession = { ownerId: "cloud-user", kind: "account", displayName: "Player", accessToken: "test-token" };
client.activateOnlineSession(session);
const ui = normalizeUiPreferences(null);
ui.slots.pc[2] = { name: "Desktop raid", layout: ui.current.pc };
ui.slots.mobile[1] = { name: "On the go", layout: ui.current.mobile };
saveUiPreferences(session.ownerId, ui);
await vi.advanceTimersByTimeAsync(501);
expect(client.useCloudSaveStatus.getState().state).toBe("synced");
vi.stubGlobal("window", { localStorage: second });
await client.hydrateOnlineSession(session);
expect(loadUiPreferences(session.ownerId)).toEqual(ui);
expect(loadUiPreferences("different-account").slots.pc[2]).toBeNull();
});
});
+1 -1
View File
@@ -45,7 +45,7 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
}
});
const onKeyDown = (event: KeyboardEvent) => {
if (event.repeat) return;
if (event.repeat || event.defaultPrevented) return;
const target = event.target as HTMLElement | null;
const isEditable = target?.matches("input, textarea, select, [contenteditable='true']") ?? false;
if (event.code === "KeyC" && !isEditable) {
+27
View File
@@ -66,6 +66,33 @@ afterEach(() => {
});
describe("gameplay input bindings", () => {
it("consumes custom action keys before movement, menus and legacy action bindings", () => {
stubInputEventTargets();
const nextActions = inputActionSpies();
nextActions.handleCustomKey = vi.fn(() => true);
const dispose = installInput(nextActions);
for (const code of ["KeyW", "KeyI", "Digit1"]) {
const event = new Event("keydown", { cancelable: true });
Object.defineProperties(event, { code: { value: code }, target: { value: null } });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
}
expect(nextActions.toggleInventory).not.toHaveBeenCalled();
expect(nextActions.castActionBinding).not.toHaveBeenCalled();
expect(nextActions.handleCustomKey).toHaveBeenCalledTimes(3);
dispose();
});
it("does not fire the old numbered bindings when desktop bars are enabled", () => {
stubInputEventTargets();
const nextActions = inputActionSpies();
nextActions.desktopActionBarsActive = () => true;
const dispose = installInput(nextActions);
const event = new Event("keydown", { cancelable: true });
Object.defineProperties(event, { code: { value: "Digit1" }, target: { value: null } });
window.dispatchEvent(event);
expect(nextActions.castActionBinding).not.toHaveBeenCalled();
dispose();
});
it("reserves I, P, N, and E for equipment and loot interactions", () => {
expect(keyboardCommandForCode("KeyI")).toBe("inventory");
expect(keyboardCommandForCode("KeyE")).toBe("loot");
+10 -1
View File
@@ -22,6 +22,9 @@ export interface InputSnapshot {
}
export interface InputActions {
/** Runs before built-in shortcuts. True consumes the key, including empty custom slots. */
handleCustomKey?: (event: KeyboardEvent) => boolean;
desktopActionBarsActive?: () => boolean;
togglePause: () => void;
toggleMap: () => void;
toggleInventory: () => void;
@@ -292,6 +295,12 @@ export function installInput(nextActions: InputActions): () => void {
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches("input, textarea, select, [contenteditable='true']")) return;
if (nextActions.handleCustomKey?.(event)) {
event.preventDefault();
event.stopImmediatePropagation();
keys.delete(event.code);
return;
}
if (["ShiftLeft", "ShiftRight", "AltLeft", "AltRight"].includes(event.code)) {
keyboardModifiers.add(event.code);
nextActions.setInputMode("keyboard");
@@ -312,7 +321,7 @@ export function installInput(nextActions: InputActions): () => void {
else nextActions.cancelGroundTarget();
return;
}
const actionControl = actionControlForKeyboardCode(event.code);
const actionControl = nextActions.desktopActionBarsActive?.() ? null : actionControlForKeyboardCode(event.code);
if (actionControl) {
if (nextActions.gameplayActionsBlocked()) return;
event.preventDefault();
+1 -1
View File
@@ -8,7 +8,7 @@ import type { GmReviewTarget } from "./gmContentReview";
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
export type InputMode = "keyboard" | "gamepad";
export type GameMode = "dungeon" | "manastorm";
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "gm" | null;
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "gm" | null;
interface PlayerSnapshot {
position: Vector3Tuple;
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences,
} from "./uiLayout";
describe("personal UI layouts", () => {
it("migrates old accounts to independent Mobile and PC layouts with exactly six empty slots", () => {
const ui = normalizeUiPreferences(undefined);
expect(ui.slots.mobile).toEqual([null, null, null]);
expect(ui.slots.pc).toEqual([null, null, null]);
ui.current.pc.elements.party.visible = false;
expect(ui.current.mobile.elements.party.visible).toBe(true);
expect(ui.current.pc.bars).toHaveLength(5);
expect(ui.current.pc.bars.every((bar) => bar.slots.length === 12)).toBe(true);
});
it("round trips named slots, positions, hidden elements, geometry, assignments, and key chords", () => {
const ui = normalizeUiPreferences(null);
const layout = ui.current.pc;
layout.wowBars = true; layout.barCount = 5;
layout.elements.party = { visible: false, position: { x: .32, y: .45 }, width: 200 };
layout.bars[4].columns = 3;
layout.bars[4].slots[11] = { abilityId: "test-heal", key: "Ctrl+Shift+KeyQ" };
ui.slots.pc[2] = { name: "Raid night", layout };
ui.slots.mobile[0] = { name: "Travel", layout: defaultUiLayout() };
expect(importUiPreferences(exportUiPreferences(ui))).toEqual(ui);
});
it("reassigns conflicts even from hidden bars, accepts modifiers and clears a binding", () => {
let layout = defaultUiLayout(); layout.wowBars = true; layout.barCount = 5;
layout = bindDesktopSlot(layout, 4, 11, "Digit1");
expect(layout.bars[0].slots[0].key).toBeNull();
expect(desktopSlotForKey(layout, "Digit1")).toBe(layout.bars[4].slots[11]);
layout.elements["bar-5"].visible = false;
expect(desktopSlotForKey(layout, "Digit1")).not.toBeNull();
layout.barCount = 4;
expect(desktopSlotForKey(layout, "Digit1")).toBeNull();
layout = bindDesktopSlot(layout, 4, 11, null);
expect(layout.bars[4].slots[11].key).toBeNull();
expect(actionKeyForEvent({ code: "KeyQ", ctrlKey: true, shiftKey: true, altKey: false, metaKey: false })).toBe("Ctrl+Shift+KeyQ");
expect(normalizeActionKey("Escape")).toBeNull();
expect(normalizeActionKey("ShiftLeft")).toBeNull();
});
it.each([
["unsupported versions", (data: any) => { data.version = 99; }],
["extra slots", (data: any) => { data.slots.mobile.push(null); }],
["invalid coordinates", (data: any) => { data.current.pc.elements.party.position = { x: "bad", y: 0 }; }],
["invalid bar geometry", (data: any) => { data.current.pc.bars[0].columns = 7; }],
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "Escape"; }],
["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }],
["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }],
])("rejects %s without mutating the original preferences", (_, mutate) => {
const ui = normalizeUiPreferences(null);
const original = exportUiPreferences(ui);
const data = JSON.parse(original); mutate(data);
expect(() => importUiPreferences(JSON.stringify(data))).toThrow();
expect(exportUiPreferences(ui)).toBe(original);
});
it("rejects malformed and oversized imports", () => {
expect(() => importUiPreferences("{")).toThrow("valid JSON");
expect(() => importUiPreferences(" ".repeat(256001))).toThrow("256 KB");
});
});
+178
View File
@@ -0,0 +1,178 @@
import { normalizeHudPosition, type NormalizedHudPosition } from "./hudLayout";
export type UiDevice = "mobile" | "pc";
export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"];
export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const;
export const HUD_ELEMENTS = {
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap",
player: "Player status", timeline: "Encounter timeline", target: "Enemy target",
friendly: "Friendly target", threat: "Threat meter", cast: "Cast bar",
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
manastorm: "Manastorm status", completion: "Dungeon completion", assets: "Asset notice",
objective: "Manastorm objective", messages: "Manastorm messages", cache: "Reward cache", feedback: "Action feedback",
"bar-1": "Action bar 1", "bar-2": "Action bar 2", "bar-3": "Action bar 3",
"bar-4": "Action bar 4", "bar-5": "Action bar 5",
} as const;
export type HudElementId = keyof typeof HUD_ELEMENTS;
export interface HudElementLayout {
visible: boolean;
position: NormalizedHudPosition | null;
width: number | null;
}
export interface DesktopActionSlot { abilityId: string | null; key: string | null }
export interface DesktopActionBar { columns: number; slots: DesktopActionSlot[] }
export interface UiLayout {
elements: Record<HudElementId, HudElementLayout>;
wowBars: boolean;
barCount: number;
bars: DesktopActionBar[];
}
export interface UiTemplate { name: string; layout: UiLayout }
export interface UiPreferences {
version: 1;
current: Record<UiDevice, UiLayout>;
slots: Record<UiDevice, (UiTemplate | null)[]>;
}
export function defaultUiLayout(): UiLayout {
return {
elements: Object.fromEntries(Object.keys(HUD_ELEMENTS).map((id) => [id, {
visible: true, position: null, width: null,
}])) as UiLayout["elements"],
wowBars: false,
barCount: 1,
bars: Array.from({ length: 5 }, (_, bar) => ({
columns: 12,
slots: Array.from({ length: 12 }, (_, slot) => ({
abilityId: null,
key: bar === 0 ? (slot < 9 ? `Digit${slot + 1}` : ["Digit0", "Minus", "Equal"][slot - 9]) : null,
})),
})),
};
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function bounded(value: unknown, fallback: number, min: number, max: number): number {
return typeof value === "number" && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
}
const KEY_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/;
export function normalizeActionKey(value: unknown): string | null {
if (typeof value !== "string") return null;
const parts = value.split("+");
const code = parts.pop()!;
if (!KEY_CODE.test(code) || parts.some((part) => !["Ctrl", "Alt", "Shift", "Meta"].includes(part))) return null;
return [...["Ctrl", "Alt", "Shift", "Meta"].filter((part) => parts.includes(part)), code].join("+");
}
export function actionKeyForEvent(event: Pick<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey">): string | null {
return normalizeActionKey([
...(event.ctrlKey ? ["Ctrl"] : []), ...(event.altKey ? ["Alt"] : []),
...(event.shiftKey ? ["Shift"] : []), ...(event.metaKey ? ["Meta"] : []), event.code,
].join("+"));
}
export function actionKeyLabel(key: string | null): string {
return key?.replace(/Key|Digit/g, "").replace("Minus", "−").replace("Equal", "=").replace("Numpad", "Num ") ?? "";
}
export function normalizeUiLayout(value: unknown): UiLayout {
const source = record(value);
const defaults = defaultUiLayout();
const elements = record(source.elements);
for (const id of Object.keys(HUD_ELEMENTS) as HudElementId[]) {
const element = record(elements[id]);
defaults.elements[id] = {
visible: element.visible !== false,
position: element.position ? normalizeHudPosition(element.position, { x: 0.5, y: 0.5 }) : null,
width: typeof element.width === "number" ? bounded(element.width, 200, 20, 2000) : null,
};
}
const used = new Set<string>();
defaults.bars = defaults.bars.map((fallback, index) => {
const bar = record(Array.isArray(source.bars) ? source.bars[index] : null);
return {
columns: BAR_COLUMNS.includes(bar.columns as 12) ? bar.columns as number : 12,
slots: fallback.slots.map((slot, slotIndex) => {
const raw = Array.isArray(bar.slots) ? record(bar.slots[slotIndex]) : slot;
const key = normalizeActionKey(raw.key);
const uniqueKey = key && !used.has(key) ? key : null;
if (uniqueKey) used.add(uniqueKey);
return {
key: uniqueKey,
abilityId: typeof raw.abilityId === "string" && raw.abilityId.length <= 200 ? raw.abilityId : null,
};
}),
};
});
return { ...defaults, wowBars: source.wowBars === true, barCount: Math.floor(bounded(source.barCount, 1, 1, 5)) };
}
export function normalizeUiPreferences(value: unknown): UiPreferences {
const source = record(value);
const current = record(source.current);
const slots = record(source.slots);
return {
version: 1,
current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) },
slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => {
const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null);
return typeof slot.name === "string" && slot.layout
? { name: slot.name.trim().slice(0, 40) || `Template ${index + 1}`, layout: normalizeUiLayout(slot.layout) }
: null;
})])) as UiPreferences["slots"],
};
}
/** A key has one owner, including hidden bars, so enabling a bar cannot add conflicts. */
export function bindDesktopSlot(layout: UiLayout, barIndex: number, slotIndex: number, key: string | null): UiLayout {
return { ...layout, bars: layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((slot, s) => ({
...slot, key: b === barIndex && s === slotIndex ? key : key && slot.key === key ? null : slot.key,
})) })) };
}
export function desktopSlotForKey(layout: UiLayout, key: string | null): DesktopActionSlot | null {
if (!layout.wowBars || !key) return null;
// Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them.
return layout.bars.slice(0, layout.barCount).flatMap((bar) => bar.slots).find((slot) => slot.key === key) ?? null;
}
export function exportUiPreferences(preferences: UiPreferences): string {
return JSON.stringify({ format: "healer-man-ui", ...normalizeUiPreferences(preferences) }, null, 2);
}
export function importUiPreferences(text: string): UiPreferences {
if (text.length > 256_000) throw new Error("UI files must be smaller than 256 KB.");
let source: Record<string, unknown>;
try { source = record(JSON.parse(text)); } catch { throw new Error("This file is not valid JSON."); }
if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
const validateLayout = (value: unknown) => {
const layout = record(value);
if (typeof layout.wowBars !== "boolean" || !Number.isInteger(layout.barCount) || Number(layout.barCount) < 1 || Number(layout.barCount) > 5
|| !Array.isArray(layout.bars) || layout.bars.length !== 5) throw new Error("Invalid action bar configuration.");
for (const id of Object.keys(HUD_ELEMENTS)) {
const element = record(record(layout.elements)[id]);
const position = record(element.position);
if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|| (element.width !== null && (typeof element.width !== "number" || !Number.isFinite(element.width) || element.width < 20 || element.width > 2000))) throw new Error("Invalid HUD position or visibility.");
}
const keys = new Set<string>();
for (const raw of layout.bars) {
const bar = record(raw);
if (!BAR_COLUMNS.includes(bar.columns as 12) || !Array.isArray(bar.slots) || bar.slots.length !== 12) throw new Error("Each bar needs exactly 12 buttons and a valid row/column layout.");
for (const rawSlot of bar.slots) {
const slot = record(rawSlot);
if (slot.abilityId !== null && (typeof slot.abilityId !== "string" || slot.abilityId.length > 200)) throw new Error("Invalid ability assignment.");
if (slot.key !== null && (!normalizeActionKey(slot.key) || keys.has(normalizeActionKey(slot.key)!))) throw new Error("Invalid or duplicate keybind.");
if (slot.key) keys.add(normalizeActionKey(slot.key)!);
}
}
};
for (const device of UI_DEVICES) {
validateLayout(record(source.current)[device]);
const slots = record(source.slots)[device];
if (!Array.isArray(slots) || slots.length !== 3) throw new Error("UI files must contain three Mobile and three PC slots.");
for (const slot of slots) {
if (slot === null) continue;
const template = record(slot);
if (typeof template.name !== "string" || !template.name.trim() || template.name.length > 40) throw new Error("Template names must be 1–40 characters.");
validateLayout(template.layout);
}
}
return normalizeUiPreferences(source);
}
+59
View File
@@ -0,0 +1,59 @@
import { create } from "zustand";
import { Capacitor } from "@capacitor/core";
import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
import { useShellStore } from "../app/shellStore";
import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
export function currentUiDevice(): UiDevice {
return Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
|| (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches && window.innerWidth < 1100)
? "mobile" : "pc";
}
interface UiLayoutState {
ownerId: string | null;
preferences: UiPreferences;
draft: UiLayout | null;
device: UiDevice;
mode: "layout" | "bindings";
selectedButton: { bar: number; slot: number } | null;
selectedElement: string | null;
begin: () => void;
updateDraft: (update: (layout: UiLayout) => UiLayout) => void;
persist: (preferences: UiPreferences) => void;
finish: (save: boolean) => void;
}
export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
ownerId: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(),
mode: "layout", selectedButton: null, selectedElement: null,
begin: () => {
const device = currentUiDevice();
set({ draft: get().preferences.current[device], device, mode: "layout", selectedButton: null });
},
updateDraft: (update) => set((state) => state.draft ? { draft: update(state.draft) } : state),
persist: (preferences) => {
const ownerId = get().ownerId;
if (!ownerId) throw new Error("Sign in or choose an offline profile before saving your UI.");
// Write first: storage errors leave the editor open with its draft intact.
const saved = saveUiPreferences(ownerId, preferences);
set({ preferences: saved });
},
finish: (save) => {
const { draft, device, preferences } = get();
if (save && draft) get().persist({ ...preferences, current: { ...preferences.current, [device]: draft } });
set({ draft: null, selectedButton: null, mode: "layout" });
},
}));
function activateOwner() {
const ownerId = useShellStore.getState().session?.ownerId ?? null;
useUiLayoutStore.setState({ ownerId, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
}
activateOwner();
useShellStore.subscribe((state, previous) => {
if (state.session !== previous.session) activateOwner();
});
export function activeUiLayout(): UiLayout {
const state = useUiLayoutStore.getState();
return state.draft ?? state.preferences.current[currentUiDevice()];
}
+1 -1
View File
@@ -40,7 +40,7 @@ function cooldownPercent(remaining = 0, duration = 0): number {
return Math.max(0, Math.min(100, (remaining / duration) * 100));
}
function ActionIcon({ item }: { item: ActionBarItem | null }) {
export function ActionIcon({ item }: { item: ActionBarItem | null }) {
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [item?.iconUrl]);
if (!item) return <span className="action-slot__icon" aria-hidden="true" />;
+37
View File
@@ -0,0 +1,37 @@
import type { CSSProperties } from "react";
import { useGameStore } from "../game/store";
import { useCombatStore } from "../game/combatStore";
import { actionKeyLabel, type HudElementId } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { EditableHudElement } from "./EditableHudElement";
import { ActionIcon, type ActionBarItem } from "./ActionBar";
export function DesktopActionBars({ items }: { items: Readonly<Record<string, ActionBarItem>> }) {
const ui = useUiLayoutStore();
const editing = useGameStore((state) => state.overlay === "ui-layout");
const blocked = useGameStore((state) => state.overlay !== null || state.companionOpen);
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
if (currentUiDevice() !== "pc" || !layout.wowBars) return null;
return <>{layout.bars.slice(0, layout.barCount).map((bar, b) => {
const offset = 30 + layout.bars.slice(0, b).reduce((total, previous) => total + (12 / previous.columns) * 46 + 10 + (editing ? 24 : 0), 0);
return <EditableHudElement id={`bar-${b + 1}` as HudElementId} key={b} intrinsicWidth={bar.columns * 46 + 8} bottomAnchor={offset + (12 / bar.columns) * 23 + (editing ? 16 : 4)}>
<section className="desktop-action-bar" aria-label={`Action bar ${b + 1}`} style={{ "--bar-columns": bar.columns, "--bar-offset": `${offset}px` } as CSSProperties}>
{bar.slots.map((button, s) => {
const item = button.abilityId ? items[button.abilityId] : null;
const selected = editing && ui.selectedButton?.bar === b && ui.selectedButton.slot === s;
return <button type="button" key={s} className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
aria-label={`Bar ${b + 1} button ${s + 1}: ${item?.name ?? "Empty"}${button.key ? `, ${actionKeyLabel(button.key)}` : ""}`}
aria-disabled={!editing && (!item || item.disabled || blocked)} aria-pressed={editing ? selected : undefined}
title={item ? `${item.name}\n${item.description ?? ""}\n${actionKeyLabel(button.key)}${item.lockedUntilLevel ? ` · Level ${item.lockedUntilLevel}` : ""}` : `Empty · ${actionKeyLabel(button.key)}`}
onClick={() => {
if (editing) { useUiLayoutStore.setState({ selectedButton: { bar: b, slot: s } }); return; }
if (!blocked && item && !item.disabled) useCombatStore.getState().castAbility(item.id, useGameStore.getState().playerPosition, Date.now());
}}>
<ActionIcon item={item ?? null} /><kbd>{actionKeyLabel(button.key)}</kbd>
{item && item.cooldownRemaining! > .05 && <span className="desktop-action-button__cooldown">{item.cooldownRemaining! < 10 ? item.cooldownRemaining!.toFixed(1) : Math.ceil(item.cooldownRemaining!)}</span>}
{editing && !item && <small>{s + 1}</small>}
</button>;
})}
</section>
</EditableHudElement>; })}</>;
}
+100
View File
@@ -0,0 +1,100 @@
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { useGameStore } from "../game/store";
const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
party: [.1, .3, 210], summons: [.27, .25, 150], minimap: [.9, .12, 160], player: [.9, .34, 160],
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160],
cast: [.5, .68, 330], actions: [.5, .86, 420], hints: [.5, .65, 420], experience: [.5, .99, 900],
reticle: [.5, .5, 20], loot: [.5, .3, 320], interaction: [.5, .6, 320], manastorm: [.5, .05, 400],
completion: [.5, .39, 360], assets: [.78, .6, 200], feedback: [.5, .71, 400],
objective: [.5, .1, 400], messages: [.5, .22, 350], cache: [.78, .42, 200],
};
/** Defaults remain in their original CSS flow until the player moves the widget. */
export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor }: {
id: HudElementId; children?: ReactNode; intrinsicWidth?: number; bottomAnchor?: number;
}) {
const overlay = useGameStore((state) => state.overlay);
const editing = overlay === "ui-layout";
const state = useUiLayoutStore();
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
const element = layout.elements[id];
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
const [host, setHost] = useState<Element | null>(null);
const drag = useRef<{ pointerId: number; dx: number; dy: number } | null>(null);
const [bounds, setBounds] = useState({ width: 0, height: 0, panelWidth: 0, panelHeight: 0 });
const positioned = editing || element.position !== null;
const fallback = FALLBACKS[id] ?? [.5, .35, 250];
const position = element.position ?? (bottomAnchor !== undefined
? { x: .5, y: 1 - bottomAnchor / Math.max(1, bounds.height) }
: origin ?? { x: fallback[0], y: fallback[1] });
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(".game-shell")); }, []);
useLayoutEffect(() => {
if (positioned || !ref.current) return;
const node = ref.current;
const measure = () => {
const child = node.firstElementChild?.getBoundingClientRect();
const host = node.closest(".game-shell")?.getBoundingClientRect();
if (child && host && child.width > 0 && child.height > 0) {
setOrigin({ x: (child.left - host.left + child.width / 2) / host.width,
y: (child.top - host.top + child.height / 2) / host.height, width: child.width });
}
};
measure();
const observer = new ResizeObserver(measure);
if (node.firstElementChild) observer.observe(node.firstElementChild);
window.addEventListener("resize", measure);
return () => { observer.disconnect(); window.removeEventListener("resize", measure); };
}, [positioned, children]);
useLayoutEffect(() => {
const node = ref.current;
const host = node?.closest(".game-shell");
if (!positioned || !node || !host) return;
const measure = () => setBounds({ width: host.clientWidth, height: host.clientHeight, panelWidth: node.offsetWidth, panelHeight: node.offsetHeight });
const observer = new ResizeObserver(measure);
observer.observe(node); observer.observe(host); measure();
return () => observer.disconnect();
}, [positioned, host]);
const updatePosition = (x: number, y: number) => state.updateDraft((draft) => ({ ...draft, elements: {
...draft.elements, [id]: { ...draft.elements[id], position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width },
} }));
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
const content = <div ref={ref} data-hud-element={id} style={style}
className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`}
onClickCapture={(event) => { if (editing && !id.startsWith("bar-")) { event.preventDefault(); event.stopPropagation(); useUiLayoutStore.setState({ selectedElement: id }); } }}>
{children}
{editing && <button type="button" className="editable-hud__handle" aria-label={`Move ${HUD_ELEMENTS[id]}`} title="Drag to move; arrow keys nudge"
onPointerDown={(event) => {
if (event.button !== 0) return;
event.preventDefault(); event.stopPropagation();
const rect = ref.current!.getBoundingClientRect();
drag.current = { pointerId: event.pointerId, dx: event.clientX - rect.left - rect.width / 2, dy: event.clientY - rect.top - rect.height / 2 };
event.currentTarget.setPointerCapture(event.pointerId);
useUiLayoutStore.setState({ selectedElement: id });
}}
onPointerMove={(event) => {
if (drag.current?.pointerId !== event.pointerId) return;
const host = ref.current!.closest(".game-shell")!.getBoundingClientRect();
updatePosition((event.clientX - host.left - drag.current.dx) / host.width, (event.clientY - host.top - drag.current.dy) / host.height);
}}
onPointerUp={() => { drag.current = null; }} onPointerCancel={() => { drag.current = null; }} onLostPointerCapture={() => { drag.current = null; }}
onKeyDown={(event) => {
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
if (!delta) return;
event.preventDefault(); event.stopPropagation();
updatePosition(position.x + delta[0] * .01, position.y + delta[1] * .01);
}}>{HUD_ELEMENTS[id]}{!element.visible ? " · Hidden" : ""}</button>}
</div>;
return positioned && host ? createPortal(content, host) : content;
}
+45 -32
View File
@@ -32,6 +32,9 @@ import { AuraStrip } from "./AuraStrip";
import { ManastormStatus } from "./ManastormStatus";
import { ThreatMeterWidget } from "./ThreatMeterWidget";
import { BossLootWindow } from "./BossLootWindow";
import { EditableHudElement } from "./EditableHudElement";
import { DesktopActionBars } from "./DesktopActionBars";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { SummonFrames } from "./SummonFrames";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { onlineAccountIdFromActor, onlinePlayerActorId } from "../game/onlineSessionRuntime";
@@ -139,6 +142,8 @@ export function DungeonCompletePrompt({
}
export function Hud() {
const ui = useUiLayoutStore();
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars;
const character = useShellStore((state) => state.activeCharacter);
const gameMode = useGameStore((state) => state.gameMode);
const areaName = useGameStore((state) => state.areaName);
@@ -216,10 +221,12 @@ export function Hud() {
const classColor = classById(classId).color;
const resourceTone = resourceProfileForClass(classId).type;
const actionItems = useMemo<ActionBarItems>(() => Object.fromEntries(ACTION_CONTROLS.map((control) => {
const abilityId = actionBindings[actionBindingId(actionLayer, control)];
const catalogAbility = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
if (!catalogAbility) return [control, null];
const assignedAbilities = useMemo(() => {
const used = new Set(Object.values(actionBindings));
if (desktopBars) for (const bar of (ui.draft ?? ui.preferences.current.pc).bars) for (const slot of bar.slots) used.add(slot.abilityId);
return abilities.filter((ability) => used.has(ability.id));
}, [abilities, actionBindings, desktopBars, ui.draft, ui.preferences]);
const abilityItems = useMemo<Record<string, ActionBarItem>>(() => Object.fromEntries(assignedAbilities.map((catalogAbility) => {
const learnedTalentRank = catalogAbility.talentEntryId === undefined
? undefined
: talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
@@ -254,8 +261,11 @@ export function Hud() {
lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined,
target: ability.target,
};
return [control, item];
})) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
return [catalogAbility.id, item];
})), [assignedAbilities, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
const actionItems = Object.fromEntries(ACTION_CONTROLS.map((control) => [control,
abilityItems[actionBindings[actionBindingId(actionLayer, control)] ?? ""] ?? null,
])) as ActionBarItems;
const castBinding = (control: (typeof ACTION_CONTROLS)[number]) => useCombatStore.getState().castActionBinding(
useGameStore.getState().actionLayer,
@@ -362,17 +372,17 @@ export function Hud() {
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
<ManastormStatus />
<BossLootWindow />
{dungeonComplete && health > 0 ? (
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
) : null}
{feedback?.kind === "loot" ? (
) : null}</EditableHudElement>
<EditableHudElement id="loot">{feedback?.kind === "loot" ? (
<section className="loot-reward" role="status" aria-live="polite">
<span aria-hidden="true">◆</span>
<p><strong>Loot acquired</strong><small>{feedback.message}</small><em>Press I to inspect and equip</em></p>
</section>
) : null}
<PartyFrames />
<SummonFrames now={clock} />
) : null}</EditableHudElement>
<EditableHudElement id="party"><PartyFrames /></EditableHudElement>
<EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement>
{pendingGroundTarget ? (
<section className="ground-target-reticle" role="status" aria-live="polite">
@@ -391,19 +401,19 @@ export function Hud() {
) : null}
<div className="hud-top-right">
<Minimap
<EditableHudElement id="minimap"><Minimap
position={playerPosition}
yaw={cameraYaw}
areaName={stagePresentation.areaName}
distanceTravelled={distance}
rotationMode={settings.minimapRotation}
onOpenMap={toggleMap}
/>
<PlayerStatusPanel now={clock} />
<EncounterTimeline now={clock} />
/></EditableHudElement>
<EditableHudElement id="player"><PlayerStatusPanel now={clock} /></EditableHudElement>
<EditableHudElement id="timeline"><EncounterTimeline now={clock} /></EditableHudElement>
</div>
{target && settings.showTargetFrame && (
<EditableHudElement id="target">{target && settings.showTargetFrame && (
<section className={`target-frame ${target.boss ? "target-frame--boss" : ""}`} aria-label={`Target ${target.name}`}>
<header><strong>{target.name}</strong><span>{target.boss ? "Boss" : `Level ${target.level}`}</span></header>
<div className="target-frame__track"><span style={{ width: `${targetPercent}%` }} /></div>
@@ -420,18 +430,18 @@ export function Hud() {
<AuraStrip targetId={target.id} className="aura-strip--target" />
<small>{Math.ceil(target.health)} / {target.maxHealth}</small>
</section>
)}
)}</EditableHudElement>
{target && settings.showThreatMeter && threatRows.length > 0 && (
<EditableHudElement id="threat">{target && settings.showThreatMeter && threatRows.length > 0 && (
<ThreatMeterWidget
targetName={target.name}
rows={threatRows}
position={settings.threatMeterPosition}
onPositionChange={updateThreatMeterPosition}
/>
)}
)}</EditableHudElement>
{selectedFriendly && settings.showTargetFrame && (
<EditableHudElement id="friendly">{selectedFriendly && settings.showTargetFrame && (
<section className="target-frame target-frame--friendly" aria-label={`Friendly target ${selectedFriendly.name}${selectedFriendly.self ? ", self" : ""}`}>
<header>
<strong>{selectedFriendly.name}</strong>
@@ -448,10 +458,10 @@ export function Hud() {
/>
<small>{Math.ceil(selectedFriendly.health)} / {selectedFriendly.maxHealth}</small>
</section>
)}
)}</EditableHudElement>
<div className="hud-bottom-stack">
{activeCast && castingAbility && (
<EditableHudElement id="cast">{activeCast && castingAbility && (
<section className={`cast-bar cast-bar--${activeCast.mode}`} aria-label={`${activeCast.mode === "channel" ? "Channeling" : "Casting"} ${castingAbility.name}`}>
<header><strong>{castingAbility.name}</strong><span>{castRemaining.toFixed(1)}s</span></header>
<div className="cast-bar__track">
@@ -459,9 +469,9 @@ export function Hud() {
</div>
<small>{activeCast.mode === "channel" ? "Channeling" : "Casting"}</small>
</section>
)}
)}</EditableHudElement>
{!activeCast && (
<EditableHudElement id="hints">{!activeCast && (
<div className={`control-hint ${hasMoved && cameraLookActive ? "control-hint--settled" : ""}`}>
{inputMode === "gamepad"
? "Left stick move | L1 / L2 layers | Face / R1 / R2 / D-pad abilities | R3 target | Start pause"
@@ -469,21 +479,24 @@ export function Hud() {
? "Release right mouse for cursor | WASD move | Arrow keys look | 1-8 abilities | Shift / Alt layers"
: "Right mouse / Arrow keys look | Click party frames to target | WASD move | Tab enemy"}
</div>
)}
)}</EditableHudElement>
<ActionBar
{!desktopBars && <EditableHudElement id="actions"><ActionBar
items={actionItems}
activeLayer={actionLayer}
onActivate={castBinding}
inputMode={inputMode}
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
/>
/></EditableHudElement>}
</div>
<ExperienceBar level={level} current={xp} required={xpToNext} />
<DesktopActionBars items={abilityItems} />
<EditableHudElement id="feedback">{desktopBars && settings.showFloatingCombatText && feedback?.message
? <div className="desktop-action-feedback" role="status">{feedback.message}</div> : null}</EditableHudElement>
<EditableHudElement id="experience"><ExperienceBar level={level} current={xp} required={xpToNext} /></EditableHudElement>
{cameraLookActive && <span className="reticle" aria-hidden="true" />}
<EditableHudElement id="reticle">{cameraLookActive && <span className="reticle" aria-hidden="true" />}</EditableHudElement>
{assetStatus === "proxy" && (
<EditableHudElement id="assets">{assetStatus === "proxy" && (
<div className="asset-notice" role="status">
<strong>Cave proxy active</strong>
<span>{assetMessage ?? `Add ${[
@@ -491,7 +504,7 @@ export function Hud() {
...dungeon.assets.collision,
].map((asset) => asset.fileName).join(" and ")}.`}</span>
</div>
)}
)}</EditableHudElement>
<LoadingOverlay />
{health <= 0 ? (
<DefeatedPrompt
+11 -8
View File
@@ -13,6 +13,7 @@ import {
import { useManastormStore } from "../game/manastormStore";
import { useGameStore } from "../game/store";
import { ManastormClientOverlay } from "./ManastormClientOverlay";
import { EditableHudElement } from "./EditableHudElement";
export function ManastormStatus() {
const gameMode = useGameStore((state) => state.gameMode);
@@ -56,7 +57,9 @@ export function ManastormStatus() {
return () => window.clearInterval(interval);
}, [encounter?.id, level, waveActive]);
if (gameMode !== "manastorm" || !encounter || status === "idle" || status === "failed") return null;
if (gameMode !== "manastorm" || !encounter || status === "idle" || status === "failed") return <>
<EditableHudElement id="manastorm" /><EditableHudElement id="objective" /><EditableHudElement id="messages" /><EditableHudElement id="cache" />
</>;
const dungeon = requireDungeonDefinition(activeDungeonId);
const stageLocation = encounter.location ?? dungeon.title;
@@ -85,7 +88,7 @@ export function ManastormStatus() {
return (
<>
<section className="manastorm-status" aria-label={`Manastorm level ${level}`}>
<EditableHudElement id="manastorm"><section className="manastorm-status" aria-label={`Manastorm level ${level}`}>
<div>
<span>{milestone ? "Milestone" : modeLabel}</span>
<strong>{level}</strong>
@@ -120,9 +123,9 @@ export function ManastormStatus() {
</span>
) : null}
</p>
</section>
<ManastormClientOverlay />
{phase === "loot" ? (
</section></EditableHudElement>
<EditableHudElement id="messages"><ManastormClientOverlay /></EditableHudElement>
<EditableHudElement id="objective">{phase === "loot" ? (
<button
type="button"
className={`manastorm-objective manastorm-objective--${status}`}
@@ -137,8 +140,8 @@ export function ManastormStatus() {
<span aria-hidden="true">{phase === "portal" ? "✦" : "⚔"}</span>
<strong>{objective}</strong>
</div>
)}
{rewardCache[0] ? (
)}</EditableHudElement>
<EditableHudElement id="cache">{rewardCache[0] ? (
<button
type="button"
className="manastorm-cache-open"
@@ -147,7 +150,7 @@ export function ManastormStatus() {
>
Open {rewardCache[0].cacheId.replace(/-/g, " ")}
</button>
) : null}
) : null}</EditableHudElement>
</>
);
}
+2
View File
@@ -42,6 +42,7 @@ function PauseDialog() {
{ id: "pause-bindings", run: () => openOverlay("bindings") },
{ id: "pause-talents", run: () => openOverlay("talents") },
{ id: "pause-options", run: () => openOverlay("options") },
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
...(gameMode === "manastorm"
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
@@ -77,6 +78,7 @@ function PauseDialog() {
{menuButton("pause-bindings", "Ability controls", () => openOverlay("bindings"))}
{menuButton("pause-talents", skillMenuLabel, () => openOverlay("talents"))}
{menuButton("pause-options", "Options", () => openOverlay("options"))}
{menuButton("pause-ui-layout", "Edit UI & templates", () => openOverlay("ui-layout"))}
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
{gameMode === "manastorm"
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore";
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
import { useCombatStore } from "../game/combatStore";
import { defaultActionBarForCharacter } from "../game/abilityCatalog";
import { useGameStore } from "../game/store";
import {
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
} from "../game/uiLayout";
import { useUiLayoutStore } from "../game/uiLayoutStore";
export function UiLayoutEditor() {
const open = useGameStore((state) => state.overlay === "ui-layout");
const ui = useUiLayoutStore();
const session = useShellStore((state) => state.session);
const cloudSave = useCloudSaveStatus();
const abilities = useCombatStore((state) => state.abilities);
const [tab, setTab] = useState<UiDevice>(ui.device);
const [name, setName] = useState("My UI");
const [slot, setSlot] = useState(0);
const [message, setMessage] = useState("");
const [collapsed, setCollapsed] = useState(false);
const [dockRight, setDockRight] = useState(false);
const [search, setSearch] = useState("");
const [pendingImport, setPendingImport] = useState<ReturnType<typeof importUiPreferences> | null>(null);
const [transferText, setTransferText] = useState<string | null>(null);
const file = useRef<HTMLInputElement>(null);
const wasOpen = useRef(false);
useEffect(() => {
if (open && !wasOpen.current) {
useUiLayoutStore.getState().begin();
setTab(useUiLayoutStore.getState().device); setMessage("");
}
if (!open && wasOpen.current) useUiLayoutStore.getState().finish(false);
wasOpen.current = open;
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
const state = useUiLayoutStore.getState();
if (event.target instanceof Element && event.target.closest("input, textarea, select, [contenteditable='true']")) return;
if (event.code === "Escape") {
event.preventDefault(); event.stopImmediatePropagation();
if (state.selectedButton) useUiLayoutStore.setState({ selectedButton: null });
else { state.finish(false); useGameStore.getState().openOverlay("pause"); }
return;
}
if (state.mode !== "bindings" || !state.selectedButton) return;
event.preventDefault(); event.stopImmediatePropagation();
if (event.repeat) return;
const key = event.code === "Backspace" || event.code === "Delete" ? null : actionKeyForEvent(event);
if (!key && event.code !== "Backspace" && event.code !== "Delete") return;
const { bar, slot: button } = state.selectedButton;
state.updateDraft((draft) => bindDesktopSlot(draft, bar, button, key));
setMessage(key ? `Bound bar ${bar + 1}, button ${button + 1}. Any previous assignment was cleared.` : "Keybind cleared.");
useUiLayoutStore.setState({ selectedButton: null });
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [open]);
if (!open || !ui.draft) return null;
const draft = ui.draft;
const attempt = (run: () => void) => { try { run(); } catch (error) { setMessage(error instanceof Error ? error.message : "Could not save this UI."); } };
const saveAndClose = () => attempt(() => { ui.finish(true); useGameStore.getState().closeOverlay(); });
const validName = name.trim().length > 0;
const chooseSlot = (device: UiDevice, index: number) => {
setTab(device); setSlot(index); setName(ui.preferences.slots[device][index]?.name ?? "My UI");
};
const selected = ui.selectedButton;
const setAbility = (abilityId: string | null) => {
if (!selected) return;
ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, index) => index !== selected.bar ? bar : {
...bar, slots: bar.slots.map((button, index) => index === selected.slot ? { ...button, abilityId } : button),
}) }));
};
return <aside className={`ui-layout-editor ${collapsed ? "ui-layout-editor--collapsed" : ""} ${dockRight ? "ui-layout-editor--right" : ""}`} aria-label="UI layout editor">
<header><div><small>PERSONAL INTERFACE · {ui.device === "pc" ? "PC" : "MOBILE"}</small><h2>Edit your UI</h2></div>
<button type="button" onClick={() => setDockRight(!dockRight)} aria-label={dockRight ? "Dock editor left" : "Dock editor right"}>{dockRight ? "←" : "→"}</button>
<button type="button" onClick={() => setCollapsed(!collapsed)} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button></header>
{!collapsed && <div className="ui-layout-editor__body">
<p>Drag a widget’s label to move it. Hidden and inactive widgets appear as placeholders. Use the list to show them again.</p>
<details open><summary>UI templates · 6 slots</summary>
<div className="ui-layout-editor__tabs">{(["mobile", "pc"] as const).map((device) => <button key={device} type="button" aria-pressed={tab === device} onClick={() => chooseSlot(device, 0)}>{device === "pc" ? "PC" : "Mobile"} · 3 slots</button>)}</div>
<div className="ui-layout-editor__slots">{ui.preferences.slots[tab].map((template, index) => <button type="button" key={index} aria-pressed={slot === index} onClick={() => chooseSlot(tab, index)}><small>Slot {index + 1}</small><strong>{template?.name ?? "Empty"}</strong></button>)}</div>
<label>Template name<input value={name} maxLength={40} onChange={(event) => setName(event.target.value)} /></label>
<div className="ui-layout-editor__row">
<button type="button" disabled={!validName || tab !== ui.device} onClick={() => attempt(() => {
ui.persist({ ...ui.preferences, slots: { ...ui.preferences.slots, [tab]: ui.preferences.slots[tab].map((template, index) => index === slot ? { name: name.trim(), layout: draft } : template) } });
setMessage(`Saved ${name.trim()} to ${tab === "pc" ? "PC" : "Mobile"} slot ${slot + 1}.`);
})}>{ui.preferences.slots[tab][slot] ? "Overwrite slot" : "Save to slot"}</button>
<button type="button" disabled={!validName || !ui.preferences.slots[tab][slot]} onClick={() => attempt(() => {
ui.persist({ ...ui.preferences, slots: { ...ui.preferences.slots, [tab]: ui.preferences.slots[tab].map((template, index) => template && index === slot ? { ...template, name: name.trim() } : template) } });
setMessage("Template renamed.");
})}>Rename</button>
<button type="button" disabled={!ui.preferences.slots[tab][slot] || tab !== ui.device} onClick={() => {
useUiLayoutStore.setState({ draft: ui.preferences.slots[tab][slot]!.layout, selectedButton: null }); setMessage("Template loaded into the editor. Save UI to apply.");
}}>Load</button>
</div>
{tab !== ui.device && <small>Load and save {tab === "pc" ? "PC" : "Mobile"} layouts on that device type. You can rename or export either set here.</small>}
<div className="ui-layout-editor__row">
<button type="button" onClick={() => {
const blob = new Blob([exportUiPreferences({ ...ui.preferences, current: { ...ui.preferences.current, [ui.device]: draft } })], { type: "application/json" });
const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "healer-man-ui.json"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
}}>Export all</button>
<button type="button" onClick={() => file.current?.click()}>Import file</button>
<button type="button" onClick={() => setTransferText(exportUiPreferences({ ...ui.preferences, current: { ...ui.preferences.current, [ui.device]: draft } }))}>Export text</button>
<button type="button" onClick={() => setTransferText("")}>Import text</button>
<input ref={file} type="file" accept=".json,application/json" hidden onChange={(event) => {
const chosen = event.target.files?.[0]; event.target.value = ""; if (!chosen) return;
if (chosen.size > 256_000) { setMessage("UI files must be smaller than 256 KB."); return; }
void chosen.text().then((text) => setPendingImport(importUiPreferences(text))).catch((error: Error) => setMessage(error.message));
}} />
</div>
{transferText !== null && <div>
<label>UI configuration JSON<textarea aria-label="UI configuration JSON" value={transferText} maxLength={256000} onChange={(event) => setTransferText(event.target.value)} /></label>
<small>Copy this text to transfer your UI, or paste an exported configuration to import it.</small>
<button type="button" onClick={() => attempt(() => { setPendingImport(importUiPreferences(transferText)); setTransferText(null); })}>Review import</button>
<button type="button" onClick={() => setTransferText(null)}>Close text</button>
</div>}
{pendingImport && <div className="ui-layout-editor__import"><p>Replace all six slots and both saved layouts with this file?</p><button type="button" onClick={() => attempt(() => {
ui.persist(pendingImport); useUiLayoutStore.setState({ draft: pendingImport.current[ui.device], selectedButton: null }); setPendingImport(null); setMessage("Imported all six slots.");
})}>Replace with imported UI</button><button type="button" onClick={() => setPendingImport(null)}>Keep existing UI</button></div>}
<small>{session?.accessToken ? "Templates save to your account and upload with cloud saves. Sign in on another device to load them." : "Offline profile: saved on this device. Use export/import to transfer your UI."}</small>
</details>
<details><summary>Elements · move / hide</summary>
{(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}>
<label><input type="checkbox" checked={draft.elements[id].visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], visible: event.target.checked } } }))} />{label}</label>
<button type="button" aria-label={`Reset ${label} position`} onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], position: null, width: null } } }))}>Reset</button>
</div>)}
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
</details>
{ui.device === "pc" && <details open><summary>PC action bars</summary>
<label className="ui-layout-editor__check"><input type="checkbox" checked={draft.wowBars} onChange={(event) => ui.updateDraft((layout) => {
const combat = useCombatStore.getState();
const defaults = defaultActionBarForCharacter(combat.classId, combat.secondaryClassId, combat.level);
const empty = layout.bars.every((bar) => bar.slots.every((button) => !button.abilityId));
return { ...layout, wowBars: event.target.checked, bars: empty && event.target.checked ? layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((button, s) => ({ ...button, abilityId: defaults[b * 12 + s] ?? null })) })) : layout.bars };
})} />Use WoW action bars</label>
{draft.wowBars && <>
<label>Visible bar count<select value={draft.barCount} onChange={(event) => { ui.updateDraft((layout) => ({ ...layout, barCount: Number(event.target.value) })); useUiLayoutStore.setState({ selectedButton: null }); }}>{[1, 2, 3, 4, 5].map((count) => <option key={count}>{count}</option>)}</select></label>
{draft.bars.slice(0, draft.barCount).map((bar, index) => <div className="ui-layout-editor__row" key={index}><span>Bar {index + 1}</span>
<label>Rows<select aria-label={`Bar ${index + 1} rows`} value={12 / bar.columns} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, b) => b === index ? { ...bar, columns: 12 / Number(event.target.value) } : bar) }))}>{BAR_COLUMNS.map((rows) => <option key={rows}>{rows}</option>)}</select></label>
<label>Columns<select aria-label={`Bar ${index + 1} columns`} value={bar.columns} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, bars: layout.bars.map((bar, b) => b === index ? { ...bar, columns: Number(event.target.value) } : bar) }))}>{BAR_COLUMNS.map((cols) => <option key={cols}>{cols}</option>)}</select></label>
</div>)}
<button type="button" aria-pressed={ui.mode === "bindings"} onClick={() => useUiLayoutStore.setState({ mode: ui.mode === "bindings" ? "layout" : "bindings", selectedButton: null })}>{ui.mode === "bindings" ? "Finish keybind editing" : "Keybind edit mode"}</button>
<p>{ui.mode === "bindings" ? "Click a bar button, then press a key or modifier + key. Backspace/Delete clears it; Escape cancels selection. Bound keys take priority over game shortcuts." : "Click a bar button to assign an ability."}</p>
{selected && <div className="ui-layout-editor__assignment"><strong>Bar {selected.bar + 1} · Button {selected.slot + 1}{ui.mode === "bindings" ? " · Press a key…" : ""}</strong>
<button type="button" onClick={() => setAbility(null)}>Clear ability</button>
<label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((ability) => <button key={ability.id} type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button>)}</div>
</div>}
</>}
</details>}
<p role="status">{message}</p>
{session?.accessToken && <p role="status">{cloudSave.message}{cloudSave.state === "error" && <button type="button" onClick={retryCloudSave}>Retry cloud save</button>}</p>}
</div>}
<footer><button type="button" onClick={() => { ui.finish(false); useGameStore.getState().closeOverlay(); }}>Cancel edits</button><button type="button" className="button--primary" onClick={saveAndClose}>Save UI</button></footer>
</aside>;
}
+139
View File
@@ -0,0 +1,139 @@
.editable-hud--default { display: contents; }
.editable-hud--hidden { display: none !important; }
.editable-hud--positioned {
position: absolute;
z-index: 12;
pointer-events: none;
max-height: calc(100% - 12px);
}
.editable-hud--positioned > :not(.editable-hud__handle) {
position: relative !important;
inset: auto !important;
transform: none !important;
scale: none !important;
margin: 0 !important;
width: 100%;
min-width: 0;
max-width: 100%;
}
.editable-hud--positioned:empty { display: none; }
.editable-hud--editing {
min-height: 38px;
outline: 1px dashed var(--gold);
border-radius: 5px;
background: rgba(9, 25, 18, .6);
pointer-events: auto;
}
.editable-hud--editing > :not(.editable-hud__handle) { pointer-events: none; }
.editable-hud--editing > .desktop-action-bar { pointer-events: auto; }
.editable-hud--ghost > :not(.editable-hud__handle) { opacity: .22; }
.editable-hud__handle {
position: absolute;
z-index: 3;
top: 0;
left: 0;
padding: 3px 7px;
border: 1px solid var(--gold);
border-radius: 4px;
background: #16251c;
color: #ffe3a0;
font-size: 11px;
cursor: grab;
touch-action: none;
}
.editable-hud__handle:active { cursor: grabbing; }
.editable-hud--editing:has(.desktop-action-bar) { padding-top: 24px; }
.hud-bottom-stack > .editable-hud--default > .action-bar {
position: relative; bottom: auto; left: auto; width: min(420px, calc(100vw - 24px)); margin-top: 20px; transform: none; scale: none;
}
.hud-bottom-stack > .editable-hud--default > :is(.cast-bar, .control-hint) {
position: relative; inset: auto; max-width: min(560px, calc(100vw - 24px)); transform: none; scale: none;
}
.desktop-action-bar {
position: absolute;
bottom: var(--bar-offset, 30px);
left: 50%;
transform: translateX(-50%);
display: grid;
grid-template-columns: repeat(var(--bar-columns), minmax(0, 1fr));
width: min(calc(var(--bar-columns) * 46px + 8px), calc(100vw - 16px));
gap: 3px;
padding: 4px;
border: 1px solid var(--edge);
border-radius: 5px;
background: rgba(4, 12, 8, .82);
pointer-events: auto;
}
.desktop-action-button {
position: relative;
aspect-ratio: 1;
min-width: 0;
overflow: hidden;
padding: 0;
border: 1px solid #807044;
border-radius: 3px;
color: var(--ink);
background: linear-gradient(145deg, #293c30, #070e0a);
box-shadow: inset 0 0 5px #000;
cursor: pointer;
}
.desktop-action-button .action-slot__icon { position: absolute; inset: 0; width: 100%; height: 100%; display: grid; place-items: center; font-size: 16px; }
.desktop-action-button .action-slot__icon img { width: 100%; height: 100%; object-fit: cover; }
.desktop-action-button > kbd { position: absolute; z-index: 2; top: 1px; right: 2px; max-width: 100%; font: bold 10px "Segoe UI", sans-serif; text-shadow: 0 1px 2px #000, 1px 0 2px #000; }
.desktop-action-button > small { position: absolute; bottom: 2px; left: 3px; color: var(--muted); }
.desktop-action-button.is-unavailable .action-slot__icon { opacity: .4; }
.desktop-action-button:is(:hover, :focus-visible, .is-selected) { border-color: #ffe3a0; outline: 2px solid var(--gold); outline-offset: -2px; }
.desktop-action-button__cooldown { position: absolute; inset: 0; display: grid; place-items: center; background: #0006; font-size: 15px; font-weight: bold; text-shadow: 0 1px 2px #000; }
.desktop-action-feedback { position: absolute; top: 65%; left: 50%; transform: translateX(-50%); max-width: 80%; color: #f3dfaa; text-align: center; text-shadow: 0 1px 3px #000; font-size: 12px; }
.ui-layout-editor {
position: absolute;
z-index: 80;
top: max(10px, env(safe-area-inset-top));
left: max(10px, env(safe-area-inset-left));
width: min(360px, calc(100% - 20px));
max-height: calc(100% - 20px);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--gold);
border-radius: 10px;
background: #0b1a14f5;
color: var(--ink);
box-shadow: var(--shadow);
font-size: 12px;
pointer-events: auto;
}
.ui-layout-editor > header { display: flex; align-items: center; justify-content: space-between; padding: 12px; border-bottom: 1px solid var(--edge); }
.ui-layout-editor--right { left: auto; right: max(10px, env(safe-area-inset-right)); }
.single-display-frame:has(.ui-layout-editor) > .single-context-toggle { display: none; }
.ui-layout-editor h2 { margin: 3px 0 0; color: #f3dfaa; font: 22px Georgia, serif; }
.ui-layout-editor small { color: var(--muted); }
.ui-layout-editor p { line-height: 1.5; margin: 8px 0; }
.ui-layout-editor__body { padding: 0 12px 12px; overflow-y: auto; overscroll-behavior: contain; }
.ui-layout-editor details { padding: 10px 0; border-bottom: 1px solid var(--edge); }
.ui-layout-editor summary { margin-bottom: 10px; color: #eed7a0; font-weight: bold; cursor: pointer; }
.ui-layout-editor button, .ui-layout-editor select, .ui-layout-editor input:not([type="checkbox"]) { border: 1px solid var(--edge); border-radius: 4px; padding: 7px; background: #172b20; color: var(--ink); font: inherit; }
.ui-layout-editor button { cursor: pointer; }
.ui-layout-editor button:disabled { opacity: .4; cursor: default; }
.ui-layout-editor button[aria-pressed="true"] { border-color: var(--gold); background: #3d3a22; }
.ui-layout-editor button:focus-visible, .ui-layout-editor input:focus-visible, .ui-layout-editor select:focus-visible { outline: 2px solid var(--gold); }
.ui-layout-editor label { display: flex; gap: 6px; align-items: center; margin: 8px 0; }
.ui-layout-editor input:not([type="checkbox"]) { min-width: 0; flex: 1; width: 100%; }
.ui-layout-editor textarea { width: 100%; min-width: 0; min-height: 100px; resize: vertical; color: var(--ink); background: var(--cave); border: 1px solid var(--edge); }
.ui-layout-editor__tabs, .ui-layout-editor__row, .ui-layout-editor > footer { display: flex; gap: 5px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
.ui-layout-editor__slots { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
.ui-layout-editor__slots button { min-width: 0; }
.ui-layout-editor__slots :is(strong, small) { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ui-layout-editor__element { display: flex; justify-content: space-between; align-items: center; gap: 5px; }
.ui-layout-editor__element.is-selected { background: #a2853426; }
.ui-layout-editor__element button { padding: 3px 6px; }
.ui-layout-editor__abilities { display: grid; max-height: 160px; overflow-y: auto; gap: 3px; }
.ui-layout-editor__assignment { margin-top: 10px; padding: 8px; border: 1px solid var(--edge); }
.ui-layout-editor > footer { margin: 0; padding: 10px 12px; justify-content: flex-end; border-top: 1px solid var(--edge); }
.ui-layout-editor > footer .button--primary { color: #0d170e; background: #c3d890; }
@media (max-width: 650px), (max-height: 500px) {
.ui-layout-editor { width: min(310px, calc(100% - 16px)); top: 8px; left: 8px; max-height: calc(100% - 16px); }
.ui-layout-editor--right { left: auto; right: 8px; }
.ui-layout-editor button { min-height: 36px; }
.editable-hud__handle { min-height: 30px; font-size: 10px; }
}