diff --git a/.project/designs/app/src/App.tsx b/.project/designs/app/src/App.tsx
new file mode 100644
index 00000000..7dc2eb2e
--- /dev/null
+++ b/.project/designs/app/src/App.tsx
@@ -0,0 +1,34 @@
+import { BrowserRouter, Routes, Route } from "react-router-dom";
+import { IndexPage } from "./pages/Index";
+import { CombatPreviewPage } from "./pages/CombatPreview";
+
+// Remaining sketch pages are stubs pending port from HTML
+function Stub({ name }: { name: string }): React.ReactElement {
+ return (
+
+
+ {name}
+
+
+ Pending port from HTML sketch โ React components
+
+
+ );
+}
+
+export function App(): React.ReactElement {
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/.project/designs/app/src/components/combat/CombatantCard.tsx b/.project/designs/app/src/components/combat/CombatantCard.tsx
new file mode 100644
index 00000000..ea0eb487
--- /dev/null
+++ b/.project/designs/app/src/components/combat/CombatantCard.tsx
@@ -0,0 +1,190 @@
+import styled from "styled-components";
+import { t } from "../../theme";
+import { Tag, TagRow } from "../ui/Tag";
+import { ModifierList } from "./ModifierList";
+import type { CombatantState } from "../../data/scenarios";
+
+interface Props {
+ state: CombatantState;
+ side: "attacker" | "defender";
+}
+
+const Wrap = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const UnitHeader = styled.div<{ $side: "attacker" | "defender" }>`
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 10px;
+ flex-direction: ${({ $side }) => ($side === "defender" ? "row-reverse" : "row")};
+`;
+
+const Portrait = styled.div<{ $color: string }>`
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ border: 2px solid ${({ $color }) => $color};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 26px;
+ background: radial-gradient(ellipse at 40% 40%, #2a1a06, #0e0a17);
+ flex-shrink: 0;
+`;
+
+const InfoBlock = styled.div<{ $side: "attacker" | "defender" }>`
+ flex: 1;
+ text-align: ${({ $side }) => ($side === "defender" ? "right" : "left")};
+`;
+
+const UnitName = styled.div`
+ font-family: ${t.font.heading};
+ font-size: 16px;
+ color: ${t.text.title};
+ letter-spacing: 0.04em;
+`;
+
+const UnitMeta = styled.div`
+ font-size: 11px;
+ color: ${t.text.muted};
+ margin-top: 2px;
+`;
+
+const StatGrid = styled.div<{ $side: "attacker" | "defender" }>`
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 4px 12px;
+ margin-bottom: 10px;
+ text-align: ${({ $side }) => ($side === "defender" ? "right" : "left")};
+`;
+
+const StatRow = styled.div<{ $side: "attacker" | "defender" }>`
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ flex-direction: ${({ $side }) => ($side === "defender" ? "row-reverse" : "row")};
+`;
+
+const StatLabel = styled.span`
+ font-size: 11px;
+ color: ${t.text.muted};
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ width: 36px;
+ flex-shrink: 0;
+`;
+
+const ItemChip = styled.span`
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: #0a180a;
+ border: 1px solid #66b86655;
+ border-radius: 2px;
+ padding: 3px 8px;
+ font-size: 11px;
+ color: ${t.accent.sage};
+ margin-right: 4px;
+ margin-bottom: 3px;
+`;
+
+const ItemsLabel = styled.div<{ $side: "attacker" | "defender" }>`
+ font-size: 10px;
+ color: ${t.text.muted};
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ margin-bottom: 4px;
+ text-align: ${({ $side }) => ($side === "defender" ? "right" : "left")};
+`;
+
+const portraits: Record = {
+ warrior: "โ", berserker: "๐ช", ironwarden: "๐ช",
+ pikeman: "๐ก", spearmen: "๐ก", cavalry: "๐",
+ archer: "๐น", runesmith: "๐ฉ",
+};
+
+const statColors = {
+ attack: t.sem.negative, defense: t.accent.science,
+ hp: t.accent.sage, movement: t.accent.gold,
+};
+
+function kwVariant(kw: string, unit_keywords: string[]): "kwActive" | "kwDanger" | "kw" {
+ const danger = ["rage", "anti_cavalry", "first_strike", "trample"];
+ const active = ["formation", "shield_wall", "zoc", "fast", "flanking", "ranged", "reach"];
+ if (danger.includes(kw)) return "kwDanger";
+ if (active.includes(kw)) return "kwActive";
+ return "kw";
+ void unit_keywords;
+}
+
+export function CombatantCard({ state, side }: Props): React.ReactElement {
+ const { unit, currentHp, clan, clanColor, promotions, items, modifiers } = state;
+
+ return (
+
+
+ {portraits[unit.id] ?? "โ"}
+
+ {unit.name}
+ Tier {unit.tier} ยท {unit.archetype} ยท {unit.id}.json
+ {clan}
+
+
+
+
+ {unit.attackType}
+ {unit.armor}
+ {unit.keywords.map(kw => (
+ {kw}
+ ))}
+ {promotions.map(p => (
+ {p} โ
+ ))}
+
+
+
+
+ ATK
+
+ {unit.attack}{items.some(i => i.effect.includes("melee")) ? `+${items.reduce((s, i) => s + (parseInt(i.effect) || 0), 0)}` : ""}
+
+
+
+ DEF
+ {unit.defense}
+
+
+ HP
+ {currentHp}/{unit.hp}
+
+
+ MOV
+ {unit.movement}
+
+
+
+ {items.length > 0 && (
+
+
Items equipped
+
+ {items.map(item => (
+
+ {item.name} ยท {item.effect}
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/.project/designs/app/src/components/combat/DamageMatrix.tsx b/.project/designs/app/src/components/combat/DamageMatrix.tsx
new file mode 100644
index 00000000..5b6a67d9
--- /dev/null
+++ b/.project/designs/app/src/components/combat/DamageMatrix.tsx
@@ -0,0 +1,107 @@
+import styled from "styled-components";
+import { t } from "../../theme";
+import { DAMAGE_MATRIX, type AttackType, type ArmorType } from "../../data/units";
+
+interface Props {
+ highlightAtk?: AttackType;
+ highlightArmor?: ArmorType;
+}
+
+const ATK_TYPES: AttackType[] = ["blade", "pierce", "crush", "trample", "siege"];
+const ARMOR_TYPES: ArmorType[] = ["unarmored", "light", "medium", "armored", "heavy", "plate", "fortified"];
+
+const Wrap = styled.div`
+ background: ${t.bg.panel};
+ border: 1px solid ${t.border.panel};
+ border-radius: 4px;
+ overflow: hidden;
+ margin-bottom: 16px;
+`;
+
+const Header = styled.div`
+ background: #1a1228;
+ padding: 8px 16px;
+ border-bottom: 1px solid ${t.border.panel};
+ font-size: 11px;
+ color: ${t.text.muted};
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+`;
+
+const Table = styled.table`
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 11px;
+ font-family: ${t.font.mono};
+`;
+
+const Th = styled.th<{ $highlight?: boolean }>`
+ background: rgba(31,23,51,0.5);
+ color: ${({ $highlight }) => ($highlight ? t.accent.gold : t.text.muted)};
+ padding: 5px 8px;
+ border: 1px solid ${({ $highlight }) => ($highlight ? t.accent.gold : "#73591f22")};
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+`;
+
+function cellStyle(mult: number, activeCell: boolean): string {
+ let bg = "transparent", color = t.text.secondary;
+ if (mult >= 1.5) { bg = "#1a0808"; color = t.sem.negative; }
+ else if (mult >= 1.25) { bg = "#1a1208"; color = t.sem.warning; }
+ else if (mult <= 0.5) { bg = "#061408"; color = t.accent.sage; }
+ else if (mult < 1.0) { bg = "#081a08"; color = t.accent.sage; }
+ if (activeCell) { bg = "#2a1a06"; }
+ return `background:${bg};color:${color};`;
+}
+
+const Td = styled.td<{ $mult: number; $active: boolean; $colHighlight: boolean }>`
+ padding: 4px 8px;
+ text-align: center;
+ border: ${({ $active }) => ($active ? `2px solid ${t.accent.gold}` : "1px solid #73591f22")};
+ font-weight: ${({ $mult }) => ($mult >= 1.5 || $mult <= 0.5 ? "bold" : "normal")};
+ ${({ $mult, $active }) => cellStyle($mult, $active)}
+`;
+
+export function DamageMatrix({ highlightAtk, highlightArmor }: Props): React.ReactElement {
+ return (
+
+
+ Damage Matrix โ multiplier applied to base attack
+ {highlightAtk && highlightArmor && (
+ <> ยท active: {highlightAtk} vs {highlightArmor} = {Math.round(DAMAGE_MATRIX[highlightAtk][highlightArmor] * 100)}%>
+ )}
+
+
+
+
+ | Attack \ Armor |
+ {ARMOR_TYPES.map(armor => (
+ {armor} |
+ ))}
+
+
+
+ {ATK_TYPES.map(atk => (
+
+ | {atk} |
+ {ARMOR_TYPES.map(armor => {
+ const mult = DAMAGE_MATRIX[atk][armor];
+ return (
+
+ {Math.round(mult * 100)}%
+ |
+ );
+ })}
+
+ ))}
+
+
+
+ );
+}
diff --git a/.project/designs/app/src/components/combat/HpAfterBar.tsx b/.project/designs/app/src/components/combat/HpAfterBar.tsx
new file mode 100644
index 00000000..cfc40a87
--- /dev/null
+++ b/.project/designs/app/src/components/combat/HpAfterBar.tsx
@@ -0,0 +1,144 @@
+import styled from "styled-components";
+import { t } from "../../theme";
+
+interface Props {
+ label: string;
+ currentHp: number;
+ maxHp: number;
+ dmgMin: number;
+ dmgMax: number;
+}
+
+const Block = styled.div`
+ background: rgba(31,23,51,0.6);
+ border: 1px solid ${t.border.panel};
+ border-radius: 3px;
+ padding: 10px 12px;
+`;
+
+const Label = styled.div`
+ font-size: 10px;
+ color: ${t.text.muted};
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin-bottom: 6px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`;
+
+const DeathBadge = styled.span`
+ background: #3d0a08;
+ border: 1px solid ${t.sem.negative};
+ border-radius: 2px;
+ padding: 1px 5px;
+ font-size: 9px;
+ color: ${t.sem.negative};
+ letter-spacing: 0.06em;
+`;
+
+const Track = styled.div`
+ height: 20px;
+ background: #ffffff08;
+ border-radius: 2px;
+ position: relative;
+ overflow: hidden;
+ border: 1px solid ${t.border.divider};
+`;
+
+const TrackSegment = styled.div<{ $left: number; $width: number; $color: string }>`
+ position: absolute;
+ top: 0; bottom: 0;
+ left: ${({ $left }) => $left}%;
+ width: ${({ $width }) => $width}%;
+ background: ${({ $color }) => $color};
+`;
+
+const CursorLine = styled.div<{ $pos: number }>`
+ position: absolute;
+ top: 0; bottom: 0;
+ left: ${({ $pos }) => $pos}%;
+ width: 2px;
+ background: #ffffff66;
+`;
+
+const ZeroLine = styled.div`
+ position: absolute;
+ top: 0; bottom: 0; left: 0;
+ width: 2px;
+ background: ${t.sem.negative};
+`;
+
+const Nums = styled.div`
+ display: flex;
+ justify-content: space-between;
+ font-size: 10px;
+ font-family: ${t.font.mono};
+ margin-top: 4px;
+`;
+
+const DeathProb = styled.div`
+ font-size: 11px;
+ color: ${t.sem.negative};
+ margin-top: 5px;
+ font-family: ${t.font.mono};
+`;
+
+export function HpAfterBar({ label, currentHp, maxHp, dmgMin, dmgMax }: Props): React.ReactElement {
+ const minRemaining = currentHp - dmgMax;
+ const maxRemaining = currentHp - dmgMin;
+ const deathPossible = minRemaining < 0;
+
+ const deathProb = deathPossible
+ ? Math.round(((dmgMax - currentHp) / (dmgMax - dmgMin)) * 100)
+ : 0;
+
+ const scale = maxHp;
+
+ // surviving zone: 0 โ clamp(minRemaining, 0, maxHp)
+ const sureW = Math.max(0, Math.min(minRemaining, maxHp)) / scale * 100;
+ // possible zone: minRemaining โ maxRemaining (both clamped to [0, maxHp])
+ const maybeL = Math.max(0, minRemaining) / scale * 100;
+ const maybeW = (Math.min(maxRemaining, maxHp) - Math.max(0, minRemaining)) / scale * 100;
+ const cursorPos = currentHp / scale * 100;
+
+ return (
+
+
+
+
+ {deathPossible ? (
+ <>
+ ๐ min {minRemaining} (dead)
+ avg {Math.round(currentHp - (dmgMin + dmgMax) / 2)}
+ max {maxRemaining}
+ >
+ ) : (
+ <>
+ min {minRemaining}
+ avg {Math.round(currentHp - (dmgMin + dmgMax) / 2)}
+ max {maxRemaining} ยท cannot die
+ >
+ )}
+
+ {deathPossible && (
+
+ death probability: ({dmgMax}โ{currentHp})รท({dmgMax}โ{dmgMin}) = {dmgMax - currentHp}/{dmgMax - dmgMin} โ {deathProb}%
+
+ )}
+
+ );
+}
diff --git a/.project/designs/app/src/components/combat/KwBanner.tsx b/.project/designs/app/src/components/combat/KwBanner.tsx
new file mode 100644
index 00000000..dfa2a47f
--- /dev/null
+++ b/.project/designs/app/src/components/combat/KwBanner.tsx
@@ -0,0 +1,31 @@
+import styled from "styled-components";
+import { t } from "../../theme";
+import type { Banner } from "../../data/scenarios";
+
+const styles = {
+ warn: { bg: "#3d2000", border: "#e6993355", color: t.sem.warning },
+ danger: { bg: "#3d0a08", border: "#d9594055", color: t.sem.negative },
+ good: { bg: "#0a1a08", border: "#66b86655", color: t.accent.sage },
+ info: { bg: "#0a1428", border: "#66bfff44", color: t.accent.science },
+};
+
+const El = styled.div<{ $type: Banner["type"] }>`
+ margin: 4px 0;
+ padding: 8px 14px;
+ border-radius: 3px;
+ font-size: 12px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: ${({ $type }) => styles[$type].bg};
+ border: 1px solid ${({ $type }) => styles[$type].border};
+ color: ${({ $type }) => styles[$type].color};
+`;
+
+interface Props {
+ banner: Banner;
+}
+
+export function KwBanner({ banner }: Props): React.ReactElement {
+ return {banner.text};
+}
diff --git a/.project/designs/app/src/components/combat/ModifierList.tsx b/.project/designs/app/src/components/combat/ModifierList.tsx
new file mode 100644
index 00000000..2d3e45f6
--- /dev/null
+++ b/.project/designs/app/src/components/combat/ModifierList.tsx
@@ -0,0 +1,61 @@
+import styled from "styled-components";
+import { t } from "../../theme";
+import type { Modifier } from "../../data/scenarios";
+
+interface Props {
+ title: string;
+ mods: Modifier[];
+ align?: "left" | "right";
+}
+
+const Wrap = styled.div`
+ background: rgba(10,8,16,0.5);
+ border: 1px solid ${t.border.divider};
+ border-radius: 3px;
+ padding: 8px 10px;
+ font-size: 11px;
+`;
+
+const Title = styled.div`
+ color: ${t.text.muted};
+ text-transform: uppercase;
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ margin-bottom: 5px;
+`;
+
+const Row = styled.div<{ $align: "left" | "right"; $type: Modifier["type"] }>`
+ display: flex;
+ justify-content: space-between;
+ flex-direction: ${({ $align }) => ($align === "right" ? "row-reverse" : "row")};
+ padding: 2px 0;
+ color: ${t.text.secondary};
+ border-bottom: 1px solid #73591f18;
+ font-weight: ${({ $type }) => ($type === "final" ? "bold" : "normal")};
+ color: ${({ $type }) => ($type === "final" ? t.text.primary : t.text.secondary)};
+
+ &:last-child { border-bottom: none; }
+`;
+
+const Val = styled.span<{ $type: Modifier["type"] }>`
+ font-family: ${t.font.mono};
+ color: ${({ $type }) =>
+ $type === "multiply" ? t.sem.warning :
+ $type === "add" ? t.accent.sage :
+ $type === "final" ? t.accent.gold :
+ t.text.secondary};
+`;
+
+export function ModifierList({ title, mods, align = "left" }: Props): React.ReactElement {
+ return (
+
+ {title}
+ {mods.map((m, i) => (
+
+ {m.label}
+ {m.value}
+
+ ))}
+
+ );
+}
diff --git a/.project/designs/app/src/components/combat/ProbabilityBar.tsx b/.project/designs/app/src/components/combat/ProbabilityBar.tsx
new file mode 100644
index 00000000..c5ff0e43
--- /dev/null
+++ b/.project/designs/app/src/components/combat/ProbabilityBar.tsx
@@ -0,0 +1,80 @@
+import styled from "styled-components";
+import { t } from "../../theme";
+
+interface Props {
+ effAtk: number;
+ effDef: number;
+ atkLabel?: string;
+ defLabel?: string;
+}
+
+const Wrap = styled.div`
+ margin-bottom: 14px;
+`;
+
+const Formula = styled.div`
+ font-size: 11px;
+ color: ${t.text.muted};
+ font-family: ${t.font.mono};
+ text-align: center;
+ margin-bottom: 6px;
+`;
+
+const Bar = styled.div`
+ height: 28px;
+ border-radius: 3px;
+ overflow: hidden;
+ display: flex;
+`;
+
+const AtkSide = styled.div<{ $pct: number }>`
+ width: ${({ $pct }) => $pct}%;
+ background: linear-gradient(90deg, #d9594055, #d9594088);
+ border-right: 2px solid ${t.sem.negative};
+ display: flex;
+ align-items: center;
+ padding: 0 10px;
+ font-size: 13px;
+ font-weight: bold;
+ color: ${t.sem.negative};
+`;
+
+const DefSide = styled.div`
+ flex: 1;
+ background: linear-gradient(90deg, #66b86622, #66b86655);
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 0 10px;
+ font-size: 13px;
+ font-weight: bold;
+ color: ${t.accent.sage};
+`;
+
+const Labels = styled.div`
+ display: flex;
+ justify-content: space-between;
+ font-size: 10px;
+ color: ${t.text.muted};
+ margin-top: 3px;
+`;
+
+export function ProbabilityBar({ effAtk, effDef, atkLabel = "Attacker wins", defLabel = "Defender survives" }: Props): React.ReactElement {
+ const pct = Math.round((effAtk / (effAtk + effDef)) * 100);
+ return (
+
+
+ win% = {effAtk.toFixed(1)} / ({effAtk.toFixed(1)} + {effDef.toFixed(1)}) ={" "}
+ {pct}%
+
+
+ {pct}%
+ {100 - pct}%
+
+
+ {atkLabel}
+ {defLabel}
+
+
+ );
+}
diff --git a/.project/designs/app/src/components/ui/Button.tsx b/.project/designs/app/src/components/ui/Button.tsx
new file mode 100644
index 00000000..f026b7e0
--- /dev/null
+++ b/.project/designs/app/src/components/ui/Button.tsx
@@ -0,0 +1,60 @@
+import styled from "styled-components";
+import { t } from "../../theme";
+
+export const Btn = styled.button`
+ font-family: ${t.font.body};
+ font-size: 15px;
+ font-weight: 700;
+ color: ${t.text.btn};
+ background: ${t.bg.btnNormal};
+ border: 1px solid ${t.border.panel};
+ border-radius: ${t.radius.btn};
+ padding: 8px 18px;
+ cursor: pointer;
+ transition: all 150ms ease;
+ letter-spacing: 0.03em;
+
+ &:hover {
+ background: ${t.bg.btnHover};
+ border-color: ${t.accent.goldBright};
+ color: ${t.text.btnHover};
+ }
+`;
+
+export const BtnAttack = styled(Btn)`
+ flex: 1;
+ font-family: ${t.font.heading};
+ font-size: 18px;
+ color: ${t.sem.negative};
+ background: #3d0f08;
+ border: 2px solid ${t.sem.negative};
+ padding: 10px;
+ text-align: center;
+ letter-spacing: 0.06em;
+
+ &:hover { background: #5a1510; }
+`;
+
+export const BtnAttackRisky = styled(BtnAttack)`
+ background: #5a0808;
+ border-color: #ff6644;
+`;
+
+export const BtnCancel = styled(Btn)`
+ color: ${t.text.muted};
+ padding: 10px 20px;
+`;
+
+export const BtnEndTurn = styled(Btn)`
+ font-family: ${t.font.heading};
+ font-size: 18px;
+ color: ${t.text.title};
+ background: #2a1a06;
+ border: 2px solid ${t.accent.gold};
+ padding: 10px 32px;
+ letter-spacing: 0.06em;
+ width: 200px;
+ text-align: center;
+
+ &:hover { background: #3d2608; border-color: ${t.accent.goldBright}; }
+`;
diff --git a/.project/designs/app/src/components/ui/Panel.tsx b/.project/designs/app/src/components/ui/Panel.tsx
new file mode 100644
index 00000000..af1d5f30
--- /dev/null
+++ b/.project/designs/app/src/components/ui/Panel.tsx
@@ -0,0 +1,44 @@
+import styled from "styled-components";
+import { t } from "../../theme";
+
+export const Panel = styled.div`
+ background: ${t.bg.panel};
+ border: 1px solid ${t.border.panel};
+ border-radius: ${t.radius.panel};
+ overflow: hidden;
+`;
+
+export const PanelHeader = styled.div`
+ background: #1a1228;
+ border-bottom: 2px solid ${t.border.panel};
+ padding: 14px 20px;
+`;
+
+export const PanelTitle = styled.div`
+ font-family: ${t.font.heading};
+ font-size: 20px;
+ color: ${t.text.title};
+ letter-spacing: 0.04em;
+`;
+
+export const PanelSub = styled.div`
+ font-size: 11px;
+ color: ${t.text.secondary};
+ margin-top: 2px;
+`;
+
+export const SectionTitle = styled.div`
+ font-family: ${t.font.heading};
+ font-size: 16px;
+ color: ${t.accent.gold};
+ letter-spacing: 0.05em;
+ padding-bottom: 6px;
+ border-bottom: 1px solid ${t.border.divider};
+ margin-bottom: 12px;
+`;
+
+export const Divider = styled.hr`
+ border: none;
+ border-top: 1px solid ${t.border.divider};
+ margin: 10px 0;
+`;
diff --git a/.project/designs/app/src/components/ui/Tabs.tsx b/.project/designs/app/src/components/ui/Tabs.tsx
new file mode 100644
index 00000000..674dbd76
--- /dev/null
+++ b/.project/designs/app/src/components/ui/Tabs.tsx
@@ -0,0 +1,41 @@
+import styled from "styled-components";
+import { t } from "../../theme";
+
+export const TabBar = styled.div`
+ display: flex;
+ border-bottom: 1px solid ${t.border.panel};
+ margin-bottom: 20px;
+`;
+
+const TabEl = styled.button<{ $active: boolean }>`
+ font-family: ${t.font.body};
+ font-size: 12px;
+ font-weight: bold;
+ padding: 9px 18px;
+ color: ${({ $active }) => ($active ? t.text.title : t.text.muted)};
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid ${({ $active }) => ($active ? t.accent.gold : "transparent")};
+ margin-bottom: -1px;
+ cursor: pointer;
+ letter-spacing: 0.03em;
+ transition: color 150ms ease;
+`;
+
+interface Props {
+ tabs: string[];
+ active: number;
+ onChange: (i: number) => void;
+}
+
+export function Tabs({ tabs, active, onChange }: Props): React.ReactElement {
+ return (
+
+ {tabs.map((label, i) => (
+ onChange(i)}>
+ {label}
+
+ ))}
+
+ );
+}
diff --git a/.project/designs/app/src/components/ui/Tag.tsx b/.project/designs/app/src/components/ui/Tag.tsx
new file mode 100644
index 00000000..84383817
--- /dev/null
+++ b/.project/designs/app/src/components/ui/Tag.tsx
@@ -0,0 +1,40 @@
+import styled from "styled-components";
+import { t } from "../../theme";
+
+type TagVariant = "atk" | "arm" | "kw" | "kwActive" | "kwDanger" | "item";
+
+interface Props {
+ variant: TagVariant;
+ children: React.ReactNode;
+}
+
+const variantStyles: Record = {
+ atk: `background:#3d0f08;border-color:#d9594066;color:#d9594099;`,
+ arm: `background:#0a1428;border-color:#66bfff44;color:#66bfff88;`,
+ kw: `background:#1a1208;border-color:#d9a02066;color:#d9a02099;`,
+ kwActive: `background:#2a1a06;border-color:${t.accent.gold};color:${t.accent.gold};`,
+ kwDanger: `background:#3d0f08;border-color:#d9594066;color:#d95940bb;`,
+ item: `background:#0a180a;border-color:#66b86655;color:#66b866aa;`,
+};
+
+const TagEl = styled.span<{ $variant: TagVariant }>`
+ font-size: 10px;
+ padding: 2px 6px;
+ border-radius: 2px;
+ font-family: ${t.font.mono};
+ font-weight: bold;
+ border: 1px solid;
+ ${({ $variant }) => variantStyles[$variant]}
+`;
+
+export function Tag({ variant, children }: Props): React.ReactElement {
+ return {children};
+}
+
+export const TagRow = styled.div<{ $align?: "end" }>`
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+ justify-content: ${({ $align }) => ($align === "end" ? "flex-end" : "flex-start")};
+ margin-bottom: 8px;
+`;
diff --git a/.project/designs/app/src/data/scenarios.ts b/.project/designs/app/src/data/scenarios.ts
new file mode 100644
index 00000000..c6f9f027
--- /dev/null
+++ b/.project/designs/app/src/data/scenarios.ts
@@ -0,0 +1,252 @@
+import { UNITS, matrixMultiplier, type Unit } from "./units";
+
+export interface Modifier {
+ label: string;
+ value: string;
+ type: "base" | "multiply" | "add" | "final";
+}
+
+export interface EquippedItem {
+ name: string;
+ effect: string;
+ source: string; // e.g. "Forge ยท 100โ ยท 2ร mithril_vein"
+}
+
+export interface CombatantState {
+ unit: Unit;
+ currentHp: number;
+ clan: string;
+ clanColor: string;
+ promotions: string[];
+ items: EquippedItem[];
+ modifiers: Modifier[];
+ effectiveStat: number; // effective attack (attacker) or effective defense (defender)
+}
+
+export interface DamageRange {
+ min: number;
+ avg: number;
+ max: number;
+}
+
+export interface Banner {
+ type: "warn" | "danger" | "good" | "info";
+ text: string;
+}
+
+export interface CombatScenario {
+ id: string;
+ title: string;
+ subtitle: string;
+ terrain: string;
+ attacker: CombatantState;
+ defender: CombatantState;
+ damageToDefender: DamageRange;
+ damageToAttacker: DamageRange;
+ banners: Banner[];
+ matrixLabel: string;
+ matrixResultLabel: string;
+ matrixResultClass: "neutral" | "good" | "great" | "bad";
+}
+
+// โโ helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+function pct(n: number): string {
+ return `${Math.round(n * 100)}%`;
+}
+
+function fmtMult(n: number): string {
+ return `ร${n.toFixed(2)}`;
+}
+
+// โโ Scenario data โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+const warrior = UNITS.warrior;
+const berserker = UNITS.berserker;
+const ironwarden = UNITS.ironwarden;
+const pikeman = UNITS.pikeman;
+const spearmen = UNITS.spearmen;
+const cavalry = UNITS.cavalry;
+
+// Scenario 0: Warrior (68 hp) vs Pikeman (100 hp)
+// blade vs light = 1.25; Shock I +15% open terrain
+// eff atk = 14 * 1.25 * 1.15 = 20.1
+// counter: pierce vs light = 1.00; no promotions โ eff = 10
+// dmg to pikeman: base 20.1, range ยฑ20% โ 16โ24
+// counter to warrior: base 10, range ยฑ20% โ 8โ12
+export const s0: CombatScenario = {
+ id: "s0",
+ title: "Warrior attacks Pikeman",
+ subtitle: "Plains (2,4) ยท Melee ยท Blade vs Light ยท warrior.json / pikeman.json",
+ terrain: "Plains",
+ attacker: {
+ unit: warrior, currentHp: 68, clan: "Stoneguard (You)", clanColor: "#6699ff",
+ promotions: ["Shock I"],
+ items: [],
+ modifiers: [
+ { label: "Base attack", value: "14", type: "base" },
+ { label: `Blade vs Light (${pct(matrixMultiplier("blade", "light"))})`, value: fmtMult(matrixMultiplier("blade","light")), type: "multiply" },
+ { label: "Shock I ยท open terrain", value: "+15%", type: "add" },
+ { label: "Effective attack", value: "20.1", type: "final" },
+ ],
+ effectiveStat: 20.1,
+ },
+ defender: {
+ unit: pikeman, currentHp: 100, clan: "Emberfall Clan", clanColor: "#ff8888",
+ promotions: [],
+ items: [],
+ modifiers: [
+ { label: "Base defense", value: "14", type: "base" },
+ { label: "Plains terrain", value: "+0%", type: "add" },
+ { label: "No promotions", value: "โ", type: "add" },
+ { label: "Effective defense", value: "14.0", type: "final" },
+ ],
+ effectiveStat: 14.0,
+ },
+ damageToDefender: { min: 16, avg: 20, max: 24 },
+ damageToAttacker: { min: 8, avg: 10, max: 12 },
+ banners: [],
+ matrixLabel: "blade vs light",
+ matrixResultLabel: "Favourable matchup (125%)",
+ matrixResultClass: "good",
+};
+
+// Scenario 1: Berserker with RAGE (90 hp) vs Warrior (80 hp)
+// blade vs light = 1.25; RAGE +25%
+// eff atk = 20 * 1.25 * 1.25 = 31.25
+// counter: 14 * 1.25 = 17.5
+export const s1: CombatScenario = {
+ id: "s1",
+ title: "Berserker (RAGE active) attacks Warrior",
+ subtitle: "Plains ยท Blade vs Light ยท berserker.json / warrior.json",
+ terrain: "Plains",
+ attacker: {
+ unit: berserker, currentHp: 90, clan: "Stoneguard (You)", clanColor: "#6699ff",
+ promotions: ["Drill I"],
+ items: [],
+ modifiers: [
+ { label: "Base attack", value: "20", type: "base" },
+ { label: `Blade vs Light (${pct(matrixMultiplier("blade","light"))})`, value: fmtMult(matrixMultiplier("blade","light")), type: "multiply" },
+ { label: "RAGE keyword (kill last turn)", value: "+25%", type: "add" },
+ { label: "Effective attack", value: "31.3", type: "final" },
+ ],
+ effectiveStat: 31.3,
+ },
+ defender: {
+ unit: warrior, currentHp: 80, clan: "Emberfall Clan", clanColor: "#ff8888",
+ promotions: [],
+ items: [],
+ modifiers: [
+ { label: "Base defense", value: "8", type: "base" },
+ { label: "No promotions", value: "โ", type: "add" },
+ { label: "Effective defense", value: "8.0", type: "final" },
+ ],
+ effectiveStat: 8.0,
+ },
+ damageToDefender: { min: 25, avg: 31, max: 38 },
+ damageToAttacker: { min: 10, avg: 14, max: 18 },
+ banners: [
+ { type: "danger", text: "๐ฅ RAGE active โ killed a unit last turn. +25% attack this turn only. Resets after." },
+ { type: "warn", text: "๐ก no_shield โ Berserker cannot benefit from Cover promotions (ranged defense disabled)." },
+ ],
+ matrixLabel: "blade vs light + rage",
+ matrixResultLabel: "Devastating (125% + rage +25%)",
+ matrixResultClass: "great",
+};
+
+// Scenario 2: Ironwarden (items) vs Berserker WOUNDED 45/90
+// Master Blade +6 โ atk 28; blade vs light 125%; Shock II +30% open โ 28*1.25*1.30 = 45.5
+// counter: berserker blade vs heavy = 0.75 โ 20*0.75 = 15.0
+// Berserker at 45 hp; takes 36โ57 โ min 45-57=-12 DEAD, max 45-36=9
+export const s2: CombatScenario = {
+ id: "s2",
+ title: "Ironwarden (items) vs Berserker (wounded)",
+ subtitle: "Hills ยท Blade vs Light ยท ironwarden.json / berserker.json ยท items equipped",
+ terrain: "Hills",
+ attacker: {
+ unit: ironwarden, currentHp: 110, clan: "Stoneguard (You)", clanColor: "#6699ff",
+ promotions: ["Shock I", "Shock II"],
+ items: [
+ { name: "Master Blade", effect: "+6 melee", source: "Forge ยท 100โ ยท 2ร mithril_vein ยท tech: high_smithing" },
+ { name: "Tower Shield", effect: "+3 def / +2 vs ranged", source: "Smithy ยท 50โ ยท 2ร iron_ore ยท tech: tactics" },
+ ],
+ modifiers: [
+ { label: "Base attack", value: "22", type: "base" },
+ { label: "Master Blade (item)", value: "+6", type: "add" },
+ { label: `Blade vs Light (${pct(matrixMultiplier("blade","light"))})`, value: fmtMult(matrixMultiplier("blade","light")), type: "multiply" },
+ { label: "Shock II ยท hills terrain (no open bonus)", value: "โ", type: "add" },
+ { label: "Effective attack", value: "45.5", type: "final" },
+ ],
+ effectiveStat: 45.5,
+ },
+ defender: {
+ unit: berserker, currentHp: 45, clan: "Emberfall Clan", clanColor: "#ff8888",
+ promotions: [],
+ items: [],
+ modifiers: [
+ { label: "Base attack", value: "20", type: "base" },
+ { label: `Blade vs Heavy (${pct(matrixMultiplier("blade","heavy"))})`, value: fmtMult(matrixMultiplier("blade","heavy")), type: "multiply" },
+ { label: "No promotions", value: "โ", type: "add" },
+ { label: "Effective counter", value: "15.0", type: "final" },
+ ],
+ effectiveStat: 6.0, // defense stat used for win% denominator
+ },
+ damageToDefender: { min: 36, avg: 45, max: 57 },
+ damageToAttacker: { min: 10, avg: 15, max: 20 },
+ banners: [
+ { type: "good", text: "โ Master Blade โ crafted at Forge (100โ, 2ร mithril_vein). +6 melee flat before matrix multiply." },
+ { type: "info", text: "๐ก Tower Shield โ +3 defense flat, +2 additional vs ranged attacks. Requires tech: tactics." },
+ { type: "warn", text: "โ Blade vs Heavy = 75% โ Berserker counter is heavily penalised. Use Pierce or Crush vs heavy armour." },
+ ],
+ matrixLabel: "blade vs light (atk) / blade vs heavy (counter)",
+ matrixResultLabel: "45.5 effective โ 57 max damage",
+ matrixResultClass: "great",
+};
+
+// Scenario 3: Cavalry (wounded 28/70) vs Spearmen (60 hp)
+// Cavalry: blade vs medium = 1.00; flanking +15% โ 16*1.00*1.15 = 18.4
+// Spearmen counter: pierce vs light = 1.25; anti_cavalry +100% โ 8*1.25*2.00 = 20
+// Cavalry at 28 hp; takes 18โ32 โ 28-32=-4 DEAD, 28-18=10 max survive
+// death prob = (32-28)/(32-18) = 4/14 โ 29%
+export const s3: CombatScenario = {
+ id: "s3",
+ title: "Cavalry (wounded) vs Spearmen",
+ subtitle: "Plains ยท Blade vs Medium ยท cavalry.json / spearmen.json ยท hard counter",
+ terrain: "Plains",
+ attacker: {
+ unit: cavalry, currentHp: 28, clan: "Stoneguard (You)", clanColor: "#6699ff",
+ promotions: [],
+ items: [],
+ modifiers: [
+ { label: "Base attack", value: "16", type: "base" },
+ { label: `Blade vs Medium (${pct(matrixMultiplier("blade","medium"))})`, value: fmtMult(matrixMultiplier("blade","medium")), type: "multiply" },
+ { label: "flanking keyword", value: "+15%", type: "add" },
+ { label: "Effective attack", value: "18.4", type: "final" },
+ ],
+ effectiveStat: 18.4,
+ },
+ defender: {
+ unit: spearmen, currentHp: 60, clan: "Emberfall Clan", clanColor: "#ff8888",
+ promotions: [],
+ items: [],
+ modifiers: [
+ { label: "Base attack", value: "8", type: "base" },
+ { label: `Pierce vs Light (${pct(matrixMultiplier("pierce","light"))})`, value: fmtMult(matrixMultiplier("pierce","light")), type: "multiply" },
+ { label: "anti_cavalry keyword", value: "+100%", type: "add" },
+ { label: "Effective counter", value: "20.0", type: "final" },
+ ],
+ effectiveStat: 20.0,
+ },
+ damageToDefender: { min: 12, avg: 18, max: 23 },
+ damageToAttacker: { min: 18, avg: 25, max: 32 },
+ banners: [
+ { type: "danger", text: "โ HARD COUNTER โ anti_cavalry keyword gives Spearmen +100% vs Cavalry. Cavalry at 28 HP can die this turn." },
+ { type: "info", text: "๐ก reach keyword โ Spearmen strike flying attackers and can First Strike against charging melee." },
+ { type: "warn", text: "๐ฟ Open terrain caveat: in Forest/City, Cavalry loses its advantage. Cavalry should never charge Anti-Cavalry." },
+ ],
+ matrixLabel: "pierce vs light + anti_cavalry ร200%",
+ matrixResultLabel: "Spearmen = hard counter",
+ matrixResultClass: "great",
+};
+
+export const SCENARIOS: CombatScenario[] = [s0, s1, s2, s3];
diff --git a/.project/designs/app/src/main.tsx b/.project/designs/app/src/main.tsx
new file mode 100644
index 00000000..854e9d3c
--- /dev/null
+++ b/.project/designs/app/src/main.tsx
@@ -0,0 +1,12 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "./App";
+
+const root = document.getElementById("root");
+if (!root) throw new Error("No #root element");
+
+createRoot(root).render(
+
+
+
+);
diff --git a/.project/designs/app/src/pages/CombatPreview.tsx b/.project/designs/app/src/pages/CombatPreview.tsx
new file mode 100644
index 00000000..7c4e2528
--- /dev/null
+++ b/.project/designs/app/src/pages/CombatPreview.tsx
@@ -0,0 +1,222 @@
+import { useState } from "react";
+import styled from "styled-components";
+import { t } from "../theme";
+import { SCENARIOS } from "../data/scenarios";
+import { Tabs } from "../components/ui/Tabs";
+import { Panel, PanelHeader, PanelTitle, PanelSub } from "../components/ui/Panel";
+import { BtnAttack, BtnAttackRisky, BtnCancel } from "../components/ui/Button";
+import { DamageMatrix } from "../components/combat/DamageMatrix";
+import { CombatantCard } from "../components/combat/CombatantCard";
+import { ProbabilityBar } from "../components/combat/ProbabilityBar";
+import { HpAfterBar } from "../components/combat/HpAfterBar";
+import { KwBanner } from "../components/combat/KwBanner";
+
+const Page = styled.div`
+ max-width: 840px;
+ margin: 0 auto;
+ padding: 32px 16px;
+`;
+
+const PageTitle = styled.div`
+ font-family: ${t.font.heading};
+ font-size: 28px;
+ color: ${t.text.title};
+ margin-bottom: 4px;
+`;
+
+const PageSub = styled.div`
+ font-size: 12px;
+ color: ${t.text.muted};
+ font-family: ${t.font.mono};
+ margin-bottom: 24px;
+`;
+
+const SourceBadge = styled.span`
+ display: inline-block;
+ background: #0a1a0a;
+ border: 1px solid #66b86644;
+ border-radius: 2px;
+ padding: 2px 7px;
+ font-size: 10px;
+ color: ${t.accent.sage};
+ font-family: ${t.font.mono};
+ margin-right: 4px;
+`;
+
+const VsGrid = styled.div`
+ display: grid;
+ grid-template-columns: 1fr 48px 1fr;
+ padding: 20px 20px 0;
+`;
+
+const VsCenter = styled.div`
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding-top: 20px;
+`;
+
+const VsBadge = styled.div`
+ font-family: ${t.font.heading};
+ font-size: 22px;
+ color: ${t.sem.negative};
+ width: 40px;
+ height: 40px;
+ border: 1px solid #d9594044;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
+
+const MatrixRow = styled.div`
+ margin: 12px 20px 0;
+ padding: 10px 14px;
+ background: rgba(31,23,51,0.7);
+ border: 1px solid ${t.border.panel};
+ border-radius: 3px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ font-size: 12px;
+`;
+
+const OutcomeSection = styled.div`
+ padding: 16px 20px;
+ border-top: 1px solid ${t.border.divider};
+ margin-top: 16px;
+`;
+
+const OutcomeTitle = styled.div`
+ font-size: 11px;
+ color: ${t.text.muted};
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ margin-bottom: 12px;
+ text-align: center;
+`;
+
+const HpGrid = styled.div`
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ margin-top: 14px;
+`;
+
+const ActionRow = styled.div`
+ display: flex;
+ gap: 10px;
+ padding: 14px 20px;
+ border-top: 1px solid ${t.border.divider};
+ background: rgba(10,8,16,0.5);
+`;
+
+const BannerWrap = styled.div`
+ padding: 0 20px;
+ margin-top: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+`;
+
+const resultColors = {
+ neutral: t.text.secondary,
+ good: t.sem.warning,
+ great: t.sem.negative,
+ bad: t.accent.sage,
+};
+
+const SCENARIO_TABS = ["โ Warrior vs Pikeman", "๐ช Berserker RAGE", "๐ก Items", "๐ Trample Counter"];
+
+export function CombatPreviewPage(): React.ReactElement {
+ const [active, setActive] = useState(0);
+ const s = SCENARIOS[active];
+
+ const isRisky = s.attacker.effectiveStat < s.defender.effectiveStat;
+
+ return (
+
+ Combat Preview
+
+ warrior.json
+ berserker.json
+ pikeman.json
+ COMBAT_SYSTEM.md
+ promotions.json
+ items/
+ โ real unit stats, real promotions, real damage matrix
+
+
+
+
+
+
+
+
+
+
{s.title}
+
{s.subtitle}
+
+
+
+
+
+ VS
+
+
+
+ {s.banners.length > 0 && (
+
+ {s.banners.map((b, i) => )}
+
+ )}
+
+
+ Damage type
+ {s.matrixLabel}
+
+ {s.matrixResultLabel}
+
+
+
+
+ Predicted Outcome
+
+
+
+
+
+
+
+
+
+
+ {isRisky
+ ? โ Risky โ Confirm Anyway
+ : โ Confirm Attack
+ }
+ {isRisky ? "Cancel (Recommended)" : "Cancel"}
+
+
+
+ );
+}
diff --git a/.project/designs/app/src/pages/Index.tsx b/.project/designs/app/src/pages/Index.tsx
new file mode 100644
index 00000000..fe5cd60b
--- /dev/null
+++ b/.project/designs/app/src/pages/Index.tsx
@@ -0,0 +1,76 @@
+import { Link } from "react-router-dom";
+import styled from "styled-components";
+import { t } from "../theme";
+
+const Page = styled.div`
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 48px 32px;
+`;
+
+const Title = styled.h1`
+ font-family: ${t.font.heading};
+ font-size: 36px;
+ color: ${t.text.title};
+ margin-bottom: 6px;
+`;
+
+const Sub = styled.p`
+ color: ${t.text.muted};
+ font-size: 13px;
+ margin-bottom: 32px;
+ font-family: ${t.font.mono};
+`;
+
+const NavList = styled.ul`
+ list-style: none;
+ padding: 0;
+`;
+
+const NavItem = styled.li`
+ margin-bottom: 10px;
+`;
+
+const NavLink = styled(Link)`
+ display: block;
+ color: ${t.accent.gold};
+ text-decoration: none;
+ font-size: 15px;
+ padding: 10px 16px;
+ background: ${t.bg.panel};
+ border: 1px solid ${t.border.panel};
+ border-radius: 3px;
+ transition: all 150ms ease;
+
+ &:hover {
+ background: ${t.bg.btnHover};
+ border-color: ${t.accent.goldBright};
+ color: ${t.text.btnHover};
+ }
+`;
+
+const routes = [
+ { path: "/gallery", label: "๐ผ Design Gallery โ colors, type, components" },
+ { path: "/hud", label: "๐บ World Map HUD โ top bar, unit panel, minimap" },
+ { path: "/city", label: "๐ City Screen โ production queue, buildings, citizens" },
+ { path: "/menu", label: "โ Main Menu โ atmospheric title screen" },
+ { path: "/tech", label: "โ Tech Tree โ node graph, era bands, research" },
+ { path: "/combat", label: "โ Combat Preview โ real stats, damage matrix, HP bars" },
+ { path: "/promotion", label: "โ
Promotion Picker โ grid, lock states, prereqs" },
+];
+
+export function IndexPage(): React.ReactElement {
+ return (
+
+ Age of Dwarves
+ Design System ยท .project/designs/app ยท React + Vite + styled-components
+
+ {routes.map(r => (
+
+ {r.label}
+
+ ))}
+
+
+ );
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5c497417..a0735003 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,40 @@ pnpmfileChecksum: sha256-pOgi3Q/PioTN3OH46Bs1frJvlmD0aNz/ZYybp7xmlws=
importers:
+ .project/designs/app:
+ dependencies:
+ react:
+ specifier: ^19.1.0
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.1.0
+ version: 19.2.5(react@19.2.5)
+ react-router-dom:
+ specifier: ^7.5.3
+ version: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ styled-components:
+ specifier: ^6.1.18
+ version: 6.4.0(css-to-react-native@3.2.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.1.2
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.1.2
+ version: 19.2.3(@types/react@19.2.14)
+ '@types/styled-components':
+ specifier: ^5.1.34
+ version: 5.1.36
+ '@vitejs/plugin-react':
+ specifier: ^4.4.1
+ version: 4.7.0(vite@6.4.2(@types/node@25.6.0)(tsx@4.21.0))
+ typescript:
+ specifier: ^5.8.3
+ version: 5.9.3
+ vite:
+ specifier: ^6.3.3
+ version: 6.4.2(@types/node@25.6.0)(tsx@4.21.0)
+
public/games/age-of-dwarves/guide:
dependencies:
'@lilith/ui-feedback':
@@ -1236,6 +1270,11 @@ packages:
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+ '@types/hoist-non-react-statics@3.3.7':
+ resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==}
+ peerDependencies:
+ '@types/react': '*'
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -1262,6 +1301,9 @@ packages:
'@types/stats.js@0.17.4':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
+ '@types/styled-components@5.1.36':
+ resolution: {integrity: sha512-pGMRNY5G2rNDKEv2DOiFYa7Ft1r0jrhmgBwHhOMzPTgCjO76bCot0/4uEfqj7K0Jf1KdQmDtAuaDk9EAs9foSw==}
+
'@types/stylis@4.2.7':
resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==}
@@ -2058,6 +2100,9 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+ hoist-non-react-statics@3.3.2:
+ resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
+
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -2611,6 +2656,9 @@ packages:
peerDependencies:
react: ^19.2.5
+ react-is@16.13.1:
+ resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
+
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
@@ -3861,6 +3909,11 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
+ '@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14)':
+ dependencies:
+ '@types/react': 19.2.14
+ hoist-non-react-statics: 3.3.2
+
'@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {}
@@ -3885,6 +3938,12 @@ snapshots:
'@types/stats.js@0.17.4': {}
+ '@types/styled-components@5.1.36':
+ dependencies:
+ '@types/hoist-non-react-statics': 3.3.7(@types/react@19.2.14)
+ '@types/react': 19.2.14
+ csstype: 3.2.3
+
'@types/stylis@4.2.7': {}
'@types/three@0.183.1':
@@ -4840,6 +4899,10 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
+ hoist-non-react-statics@3.3.2:
+ dependencies:
+ react-is: 16.13.1
+
html-url-attributes@3.0.1: {}
ignore@5.3.2: {}
@@ -5603,6 +5666,8 @@ snapshots:
react: 19.2.5
scheduler: 0.27.0
+ react-is@16.13.1: {}
+
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5):
dependencies:
'@types/hast': 3.0.4
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index c6b907ed..ad0b5bd9 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -2,3 +2,4 @@ packages:
- src/simulator
- src/packages/*
- public/games/*/guide
+ - .project/designs/app
diff --git a/scripts/run/dev.sh b/scripts/run/dev.sh
index 9e2c1871..b5c453b9 100644
--- a/scripts/run/dev.sh
+++ b/scripts/run/dev.sh
@@ -52,9 +52,9 @@ cmd_guide() {
cmd_designs() {
local port="${1:-7777}"
- echo -e "${BLUE}Starting design viewer (port ${port})...${NC}"
+ echo -e "${BLUE}Starting design app (port ${port})...${NC}"
echo -e "${BLUE} http://localhost:${port}${NC}"
- node "$REPO_ROOT/.project/designs/serve.js" "$port"
+ pnpm --prefix "$REPO_ROOT/.project/designs/app" run dev -- --port "$port"
}
cmd_screenshot() {