+ Lists are saved in this browser only. A backup file holds every list from every rule
+ set — export it here, then import it on another computer to make that browser an exact
+ copy.
+
+
+
+
+ {
+ void pickFile(e.target.files?.[0]);
+ // Reset so picking the same file twice fires onChange again.
+ e.target.value = "";
+ }}
+ />
+
+ {error &&
{error}
}
+ {imported &&
Lists replaced from the backup.
}
+ {pending && (
+
setPending(null)}>
+
event.stopPropagation()}
+ >
+
+
Replace all lists?
+
+
+ {pending.fileName} holds {pending.lists.length} list
+ {pending.lists.length === 1 ? "" : "s"}. Importing it permanently removes the{" "}
+ {lists.length} list{lists.length === 1 ? "" : "s"} saved in this browser and replaces
+ them with the backup's.
+
+
+
+
+
+
+
+ )}
);
}
diff --git a/src/components/MarkdownContent.test.tsx b/src/components/MarkdownContent.test.tsx
index f716bcc..e7cb5df 100644
--- a/src/components/MarkdownContent.test.tsx
+++ b/src/components/MarkdownContent.test.tsx
@@ -17,4 +17,45 @@ describe("MarkdownContent", () => {
"https://example.com/",
);
});
+
+ it("renders bold, italic and code spans", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector("strong")).toHaveTextContent("hired");
+ expect(container.querySelector("em")).toHaveTextContent("any");
+ expect(container.querySelector("code")).toHaveTextContent("allowMercenaries");
+ expect(container.querySelector("p")).toHaveTextContent(
+ "Now hired into any army via allowMercenaries.",
+ );
+ });
+
+ it("formats inside headings and list items", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector("h3 strong")).toHaveTextContent("bold");
+ expect(container.querySelector("li strong")).toHaveTextContent("important");
+ });
+
+ it("nests other markup inside emphasis", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector("strong a")).toHaveAttribute("href", "https://example.com/");
+ });
+
+ it("leaves lone underscores and space-padded asterisks alone", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector("em")).toBeNull();
+ expect(container.querySelector("p")).toHaveTextContent(
+ "The unit_id field costs 2 * 3 points, or 4 * 5 upgraded.",
+ );
+ });
});
diff --git a/src/components/MarkdownContent.tsx b/src/components/MarkdownContent.tsx
index dfe0cdb..26327fe 100644
--- a/src/components/MarkdownContent.tsx
+++ b/src/components/MarkdownContent.tsx
@@ -1,18 +1,39 @@
import type { ReactNode } from "react";
+// Inline spans, tried in this order at each position: link, bold, italic,
+// code. Bold comes before italic so `**` isn't read as an empty italic. Only
+// `*` marks italic — a lone `_` is left alone so identifiers like unit_id
+// survive. Markers may not be padded with whitespace (Markdown's flanking
+// rule), which keeps prose like "2 * 3 and 4 * 5" from turning italic. Code
+// spans match last but their content is taken literally.
+const INLINE_PATTERN =
+ /\[([^\]]+)\]\((https?:\/\/[^)]+)\)|(\*\*|__)(?!\s)([\s\S]+?)(? cursor) nodes.push(text.slice(cursor, match.index));
- nodes.push(
-
- {match[1]}
- ,
- );
+ const key = match.index;
+ if (match[1] != null) {
+ nodes.push(
+
+ {match[1]}
+ ,
+ );
+ } else if (match[4] != null) {
+ // Emphasis can wrap other inline markup, so recurse into its content.
+ nodes.push({inlineContent(match[4])});
+ } else if (match[5] != null) {
+ nodes.push({inlineContent(match[5])});
+ } else {
+ nodes.push({match[6]});
+ }
cursor = match.index + match[0].length;
}
diff --git a/src/components/Roster.tsx b/src/components/Roster.tsx
index bc974eb..b84d803 100644
--- a/src/components/Roster.tsx
+++ b/src/components/Roster.tsx
@@ -4,7 +4,7 @@ import { getUnit, upgradesFor } from "../data/gameData";
import { isHired } from "../data/mercenaries";
import { armySizeMultiplier } from "../domain/armySize";
import { hiredCount } from "../domain/hiring";
-import { entryPoints, entryStands, totalPoints } from "../domain/lists";
+import { breakPoint, entryPoints, entryStands, totalPoints } from "../domain/lists";
import { getMagicItem, magicItemCost, type MagicItemData } from "../domain/magicItems";
import SpecialRules, { UnitDetailsDialog } from "./SpecialRules";
import UnitStats from "./UnitStats";
@@ -354,6 +354,9 @@ export default function Roster({
onChange={(e) => onRename(e.target.value)}
aria-label="List name"
/>
+
+ Breakpoint: {breakPoint(list)}
+ {army.name}
+ {error &&
{error}
}
-
diff --git a/src/components/MarkdownContent.test.tsx b/src/components/MarkdownContent.test.tsx
index e7cb5df..2be9a57 100644
--- a/src/components/MarkdownContent.test.tsx
+++ b/src/components/MarkdownContent.test.tsx
@@ -20,14 +20,15 @@ describe("MarkdownContent", () => {
it("renders bold, italic and code spans", () => {
const { container } = render(
- ,
+ ,
);
expect(container.querySelector("strong")).toHaveTextContent("hired");
expect(container.querySelector("em")).toHaveTextContent("any");
- expect(container.querySelector("code")).toHaveTextContent("allowMercenaries");
+ expect(container.querySelector("code")).toHaveTextContent("**literal**");
+ expect(container.querySelector("code strong")).toBeNull();
expect(container.querySelector("p")).toHaveTextContent(
- "Now hired into any army via allowMercenaries.",
+ "Now hired into any army via **literal**.",
);
});
@@ -48,6 +49,14 @@ describe("MarkdownContent", () => {
expect(container.querySelector("strong a")).toHaveAttribute("href", "https://example.com/");
});
+ it("nests bold text inside italic text", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector("em strong")).toHaveTextContent("inner");
+ });
+
it("leaves lone underscores and space-padded asterisks alone", () => {
const { container } = render(
,
diff --git a/src/components/MarkdownContent.tsx b/src/components/MarkdownContent.tsx
index 26327fe..4298cf3 100644
--- a/src/components/MarkdownContent.tsx
+++ b/src/components/MarkdownContent.tsx
@@ -7,7 +7,7 @@ import type { ReactNode } from "react";
// rule), which keeps prose like "2 * 3 and 4 * 5" from turning italic. Code
// spans match last but their content is taken literally.
const INLINE_PATTERN =
- /\[([^\]]+)\]\((https?:\/\/[^)]+)\)|(\*\*|__)(?!\s)([\s\S]+?)(?,
);
} else if (match[4] != null) {
+ nodes.push(
+
+ {inlineContent(match[4])}
+ ,
+ );
+ } else if (match[6] != null) {
// Emphasis can wrap other inline markup, so recurse into its content.
- nodes.push({inlineContent(match[4])});
- } else if (match[5] != null) {
- nodes.push({inlineContent(match[5])});
+ nodes.push({inlineContent(match[6])});
+ } else if (match[7] != null) {
+ nodes.push({inlineContent(match[7])});
} else {
- nodes.push({match[6]});
+ nodes.push({match[8]});
}
cursor = match.index + match[0].length;
}
diff --git a/src/domain/backup.test.ts b/src/domain/backup.test.ts
index 8da1aa0..80c7853 100644
--- a/src/domain/backup.test.ts
+++ b/src/domain/backup.test.ts
@@ -60,6 +60,25 @@ describe("backup", () => {
expect(restored[1].notes).toBeNull();
});
+ it("falls back for non-finite numeric values while keeping finite quantities", () => {
+ const file = JSON.parse(serializeBackup(sampleLists()));
+ file.lists[0].pointsLimit = "1e999";
+ file.lists[0].units[0].quantity = "1e999";
+ file.lists[0].units.push({ ...file.lists[0].units[0], quantity: 2 });
+
+ const restored = parseBackup(JSON.stringify(file))!;
+ expect(restored[0].pointsLimit).toBe(0);
+ expect(restored[0].units[0].quantity).toBe(1);
+ expect(restored[0].units[1].quantity).toBe(2);
+ });
+
+ it("rejects backups with duplicate list ids", () => {
+ const file = JSON.parse(serializeBackup(sampleLists()));
+ file.lists[1].id = file.lists[0].id;
+
+ expect(parseBackup(JSON.stringify(file))).toBeNull();
+ });
+
it("names the file by date", () => {
expect(backupFileName(new Date(2026, 7, 6))).toBe("warmuster-backup-2026-08-06.json");
});
diff --git a/src/domain/backup.ts b/src/domain/backup.ts
index 777b133..e8ab78d 100644
--- a/src/domain/backup.ts
+++ b/src/domain/backup.ts
@@ -47,6 +47,11 @@ function asStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.map(String) : [];
}
+function finiteNumber(value: unknown, fallback: number): number {
+ const number = Number(value);
+ return Number.isFinite(number) ? number : fallback;
+}
+
/** Coerce one entry of a backup's `lists` into a SavedList, or drop it if it
* isn't recognisably one. Mirrors the tolerance of the localStorage loader:
* unknown fields are dropped and missing optional ones get defaults, so a file
@@ -64,10 +69,10 @@ function sanitizeList(raw: unknown): SavedList | null {
ruleVersion: String(list.ruleVersion ?? ""),
army: list.army,
name: String(list.name ?? "Untitled"),
- pointsLimit: Number(list.pointsLimit) || 0,
+ pointsLimit: finiteNumber(list.pointsLimit, 0),
units: (Array.isArray(list.units) ? list.units : []).map((u) => ({
unitId: String(u?.unitId ?? ""),
- quantity: Math.max(1, Number(u?.quantity) || 1),
+ quantity: Math.max(1, finiteNumber(u?.quantity, 1)),
upgrades: asStringArray(u?.upgrades),
magicItems: asStringArray(u?.magicItems),
})),
@@ -100,5 +105,6 @@ export function parseBackup(text: string): SavedList[] | null {
if (file.kind !== BACKUP_KIND) return null;
if (file.backupVersion !== BACKUP_VERSION) return null;
if (!Array.isArray(file.lists)) return null;
- return file.lists.map(sanitizeList).filter((l): l is SavedList => l != null);
+ const lists = file.lists.map(sanitizeList).filter((l): l is SavedList => l != null);
+ return new Set(lists.map((list) => list.id)).size === lists.length ? lists : null;
}
diff --git a/src/storage/listRepository.ts b/src/storage/listRepository.ts
index 76be150..86a3b98 100644
--- a/src/storage/listRepository.ts
+++ b/src/storage/listRepository.ts
@@ -23,11 +23,13 @@ export function loadLists(): SavedList[] {
}
}
-export function saveLists(lists: SavedList[]): void {
+export function saveLists(lists: SavedList[]): boolean {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(lists));
+ return true;
} catch {
// Storage full or unavailable; the in-memory state stays authoritative.
+ return false;
}
}
@@ -46,9 +48,8 @@ export function deleteList(lists: SavedList[], id: string): SavedList[] {
/** Replace the whole collection — used by backup import, which mirrors another
* browser's lists rather than merging into these. */
-export function replaceAllLists(lists: SavedList[]): SavedList[] {
- saveLists(lists);
- return lists;
+export function replaceAllLists(lists: SavedList[]): boolean {
+ return saveLists(lists);
}
/** Saved lists belong to the rule set they were built against; the rail shows
diff --git a/src/styles.css b/src/styles.css
index c9c1e62..d1540d2 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1606,7 +1606,7 @@ textarea {
padding: 0;
margin: -1px;
overflow: hidden;
- clip: rect(0, 0, 0, 0);
+ clip-path: inset(50%);
white-space: nowrap;
border: 0;
}