Add rules edition setting for condition tooltips (5e/5.5e)
All checks were successful
CI / check (push) Successful in 1m8s
CI / build-image (push) Successful in 16s

Introduce a settings modal (opened from the kebab menu) with a rules
edition selector for condition tooltip descriptions and a theme picker
replacing the inline cycle button. About half the conditions have
meaningful mechanical differences between editions.

- Add description5e field to ConditionDefinition with 5e (2014) text
- Add RulesEditionProvider context with localStorage persistence
- Create SettingsModal with Conditions and Theme sections
- Wire condition tooltips to edition-aware descriptions
- Fix 6 inaccurate 5.5e condition descriptions
- Update spec 003 with stories CC-3, CC-8 and FR-095–FR-102

Closes #12

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-24 17:08:41 +01:00
parent cfd4aef724
commit 4043612ccf
18 changed files with 663 additions and 82 deletions

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
CONDITION_DEFINITIONS,
getConditionDescription,
} from "../conditions.js";
function findCondition(id: string) {
const def = CONDITION_DEFINITIONS.find((d) => d.id === id);
if (!def) throw new Error(`Condition ${id} not found`);
return def;
}
describe("getConditionDescription", () => {
it("returns 5.5e description by default", () => {
const exhaustion = findCondition("exhaustion");
expect(getConditionDescription(exhaustion, "5.5e")).toBe(
exhaustion.description,
);
});
it("returns 5e description when edition is 5e", () => {
const exhaustion = findCondition("exhaustion");
expect(getConditionDescription(exhaustion, "5e")).toBe(
exhaustion.description5e,
);
});
it("every condition has both descriptions", () => {
for (const def of CONDITION_DEFINITIONS) {
expect(def.description).toBeTruthy();
expect(def.description5e).toBeTruthy();
}
});
it("conditions with identical rules share the same text", () => {
const blinded = findCondition("blinded");
expect(blinded.description).toBe(blinded.description5e);
});
it("conditions with different rules have different text", () => {
const exhaustion = findCondition("exhaustion");
expect(exhaustion.description).not.toBe(exhaustion.description5e);
});
});

View File

@@ -15,20 +15,32 @@ export type ConditionId =
| "stunned"
| "unconscious";
export type RulesEdition = "5e" | "5.5e";
export interface ConditionDefinition {
readonly id: ConditionId;
readonly label: string;
readonly description: string;
readonly description5e: string;
readonly iconName: string;
readonly color: string;
}
export function getConditionDescription(
def: ConditionDefinition,
edition: RulesEdition,
): string {
return edition === "5e" ? def.description5e : def.description;
}
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
{
id: "blinded",
label: "Blinded",
description:
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
description5e:
"Can't see. Auto-fail sight checks. Attacks have Disadvantage; attacks against have Advantage.",
iconName: "EyeOff",
color: "neutral",
},
@@ -37,6 +49,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
label: "Charmed",
description:
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
description5e:
"Can't attack or target the charmer with harmful abilities. Charmer has Advantage on social checks.",
iconName: "Heart",
color: "pink",
},
@@ -44,6 +58,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
id: "deafened",
label: "Deafened",
description: "Can't hear. Auto-fail hearing checks.",
description5e: "Can't hear. Auto-fail hearing checks.",
iconName: "EarOff",
color: "neutral",
},
@@ -51,7 +66,9 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
id: "exhaustion",
label: "Exhaustion",
description:
"Subtract exhaustion level from D20 Tests and Spell save DCs. Speed reduced by 5 ft. \u00D7 level. Removed by long rest (1 level) or death at 10 levels.",
"D20 Tests reduced by 2 \u00D7 exhaustion level.\nSpeed reduced by 5 ft. \u00D7 level.\nLong rest removes 1 level.\nDeath at 6 levels.",
description5e:
"L1: Disadvantage on ability checks\nL2: Speed halved\nL3: Disadvantage on attacks and saves\nL4: HP max halved\nL5: Speed 0\nL6: Death\nLong rest removes 1 level.",
iconName: "BatteryLow",
color: "amber",
},
@@ -60,6 +77,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
label: "Frightened",
description:
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
description5e:
"Disadvantage on ability checks and attacks while source of fear is in line of sight. Can't willingly move closer to the source.",
iconName: "Siren",
color: "orange",
},
@@ -67,7 +86,9 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
id: "grappled",
label: "Grappled",
description:
"Speed is 0 and can't benefit from bonuses to speed. Ends if grappler is Incapacitated or moved out of reach.",
"Speed 0. Disadvantage on attacks against targets other than the grappler. Grappler can drag you (extra movement cost). Ends if grappler is Incapacitated or you leave their reach.",
description5e:
"Speed 0. Ends if grappler is Incapacitated or moved out of reach.",
iconName: "Hand",
color: "neutral",
},
@@ -75,7 +96,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
id: "incapacitated",
label: "Incapacitated",
description:
"Can't take Actions, Bonus Actions, or Reactions. Concentration is broken.",
"Can't take Actions, Bonus Actions, or Reactions. Can't speak. Concentration is broken. Disadvantage on Initiative.",
description5e: "Can't take Actions or Reactions.",
iconName: "Ban",
color: "gray",
},
@@ -83,6 +105,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
id: "invisible",
label: "Invisible",
description:
"Can't be seen. Advantage on Initiative. Not affected by effects requiring sight (unless caster sees you). Attacks have Advantage; attacks against have Disadvantage.",
description5e:
"Impossible to see without magic or special sense. Heavily Obscured. Attacks have Advantage; attacks against have Disadvantage.",
iconName: "Ghost",
color: "violet",
@@ -92,6 +116,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
label: "Paralyzed",
description:
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
description5e:
"Incapacitated. Can't move or speak. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
iconName: "ZapOff",
color: "yellow",
},
@@ -100,6 +126,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
label: "Petrified",
description:
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
description5e:
"Turned to stone. Weight \u00D710. Incapacitated. Can't move or speak. Attacks against have Advantage. Auto-fail Str/Dex saves. Resistant to all damage. Immune to poison and disease.",
iconName: "Gem",
color: "slate",
},
@@ -107,6 +135,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
id: "poisoned",
label: "Poisoned",
description: "Disadvantage on attack rolls and ability checks.",
description5e: "Disadvantage on attack rolls and ability checks.",
iconName: "Droplet",
color: "green",
},
@@ -115,6 +144,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
label: "Prone",
description:
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
description5e:
"Can only crawl (costs extra movement). Disadvantage on attacks. Attacks within 5 ft. have Advantage; ranged attacks have Disadvantage. Standing up costs half movement.",
iconName: "ArrowDown",
color: "neutral",
},
@@ -123,6 +154,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
label: "Restrained",
description:
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
description5e:
"Speed is 0. Attacks have Disadvantage. Attacks against have Advantage. Disadvantage on Dex saves.",
iconName: "Link",
color: "neutral",
},
@@ -130,6 +163,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
id: "stunned",
label: "Stunned",
description:
"Incapacitated (can't act or speak). Auto-fail Str/Dex saves. Attacks against have Advantage.",
description5e:
"Incapacitated. Can't move. Can speak only falteringly. Auto-fail Str/Dex saves. Attacks against have Advantage.",
iconName: "Sparkles",
color: "yellow",
@@ -138,7 +173,9 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
id: "unconscious",
label: "Unconscious",
description:
"Incapacitated. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
description5e:
"Incapacitated. Speed 0. Can't move or speak. Unaware of surroundings. Drops held items, falls Prone. Auto-fail Str/Dex saves. Attacks against have Advantage. Hits within 5 ft. are critical.",
iconName: "Moon",
color: "indigo",
},

View File

@@ -10,6 +10,8 @@ export {
CONDITION_DEFINITIONS,
type ConditionDefinition,
type ConditionId,
getConditionDescription,
type RulesEdition,
VALID_CONDITION_IDS,
} from "./conditions.js";
export {