From 5d0caa972543aa4e33398ead1578f79ef915c130 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 9 Aug 2026 23:11:31 +0200 Subject: [PATCH] reverse_survivors: difficulty tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Haunting, Normal and Crusade scale enemy hp and speed, the survivor's health, your income, and the length of the night. Normal is exactly 1.0 on every multiplier — it is the identity tier, which is what lets every existing raw-value assertion keep passing untouched. Enemy speed now derives from a single helper, so difficulty and frenzy compose in one place rather than fighting at three call sites. Also fixes a bug this surfaced: handleRestart ignored the selected tier, so restarting after choosing Crusade silently reverted to Normal. The react-hooks purity rule flagged Date.now() in the new handler, and extracting restartSeed() exposed the omission. Endless mode is deliberately left out — fmtTime(Infinity) renders Infinity:NaN, which would cost a guard plus tests for a mode whose only content is the same game but longer. README rewritten for all four features, including the reversal of its old placement promise. 160 tests, 100% coverage. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VFyXexNjMQG9V7K8de17fc --- ...survivors-difficulty-tiers-2026-08-09.json | 15 ++++ ...survivors-difficulty-tiers-2026-08-09.json | 42 +++++++++++ reverse_survivors/README.md | 31 +++++++- reverse_survivors/src/App.test.tsx | 11 +++ reverse_survivors/src/App.tsx | 38 +++++++++- reverse_survivors/src/core/director.test.ts | 12 +++ reverse_survivors/src/core/director.ts | 4 +- reverse_survivors/src/core/sim.test.ts | 43 ++++++++++- reverse_survivors/src/core/sim.ts | 73 +++++++++++-------- reverse_survivors/src/core/types.ts | 31 ++++++++ reverse_survivors/src/styles.css | 15 ++++ 11 files changed, 274 insertions(+), 41 deletions(-) create mode 100644 docs/superpowers/contracts/reverse-survivors-difficulty-tiers-2026-08-09.json create mode 100644 docs/superpowers/evidence/reverse-survivors-difficulty-tiers-2026-08-09.json diff --git a/docs/superpowers/contracts/reverse-survivors-difficulty-tiers-2026-08-09.json b/docs/superpowers/contracts/reverse-survivors-difficulty-tiers-2026-08-09.json new file mode 100644 index 00000000..65a12ab3 --- /dev/null +++ b/docs/superpowers/contracts/reverse-survivors-difficulty-tiers-2026-08-09.json @@ -0,0 +1,15 @@ +{ + "title": "Reverse Survivors: difficulty tiers", + "objective": "Add three difficulty tiers - Haunting, Normal and Crusade - scaling enemy hp and speed, the survivor's health, director income, and the length of the night. Normal must be the exact identity tier so no existing behaviour or assertion changes.", + "acceptance_criteria": [ + "`npm run check` passes standalone: tsc --noEmit clean, eslint --max-warnings 0 clean, vitest at 100% on all four metrics.", + "Normal is exactly 1.0 on every multiplier, so all pre-existing raw-value assertions hold unchanged.", + "An explicit duration argument still overrides the tier's duration.", + "Changing difficulty restarts the game on the new tier, and restarting preserves the selected tier." + ], + "out_of_scope": [ + "Endless mode: fmtTime(Infinity) renders Infinity:NaN and would need a guard plus tests for a mode whose only content is the same game but longer.", + "localStorage persistence of the chosen tier: its catch arm is only reachable by stubbing a throwing setItem, which tests the mock rather than the game." + ], + "verifier": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage at 100%), plus an App-level test asserting the clock changes from 5:00 to 6:00 when Crusade is selected" +} diff --git a/docs/superpowers/evidence/reverse-survivors-difficulty-tiers-2026-08-09.json b/docs/superpowers/evidence/reverse-survivors-difficulty-tiers-2026-08-09.json new file mode 100644 index 00000000..7397e3b2 --- /dev/null +++ b/docs/superpowers/evidence/reverse-survivors-difficulty-tiers-2026-08-09.json @@ -0,0 +1,42 @@ +{ + "intent": "Add Haunting/Normal/Crusade difficulty tiers and document the whole four-part extension in the README.", + "scope": [ + "reverse_survivors/src/core/types.ts (DifficultyId, DifficultyConfig, DIFFICULTIES)", + "reverse_survivors/src/core/sim.ts (createInitialState parameter, enemySpeed helper, hp scaling), director.ts (regenRate rate)", + "reverse_survivors/src/App.tsx (tier selector), src/styles.css, README.md", + "Non-goal: endless mode and localStorage persistence, both deliberately excluded" + ], + "changes": [ + "Difficulty is a pure config object resolved once in createInitialState and stored on GameState, so no downstream code re-looks-it-up.", + "Normal is exactly 1.0 on every multiplier, which is what lets every pre-existing raw-hp assertion keep passing unchanged.", + "Enemy speed now derives from a single enemySpeed helper, so difficulty and frenzy compose in one place instead of fighting at three call sites.", + "regenRate gained an optional rate parameter defaulting to 1, keeping its existing call sites and assertions valid.", + "Fixed while wiring the selector: handleRestart ignored the chosen tier, so restarting after picking Crusade silently reverted to Normal. Surfaced by the react-hooks purity rule flagging Date.now() in the new handler, which led to extracting restartSeed().", + "README rewritten to cover the new units, powers, statuses and tiers, and to document the deliberate reversal of its former 'placement is never yours to choose' promise." + ], + "verification": [ + { + "command": "npm run check (typecheck + eslint --max-warnings 0 + vitest --coverage)", + "result": "pass", + "evidence": "160 tests passed. Coverage 100% on all four metrics: statements 531/531, branches 159/159, functions 101/101, lines 514/514." + }, + { + "command": "npm run build", + "result": "pass", + "evidence": "tsc --noEmit clean, vite build in ~72ms, 210.08 kB / 66.26 kB gzip." + }, + { + "command": "headless full-game playtest driving the real sim (vite-node)", + "result": "pass", + "evidence": "Banking energy then bursting wave+frenzy+ambush killed the survivor at t=53s with the warden's slow live (1.92s) and frenzy active (4.20s). A passive director loses on every tier; Crusade won at t=56s; Haunting and Normal went to the survivor. Same seed twice produced byte-identical outcomes." + } + ], + "risks": [ + "Tier multipliers are simulation-verified rather than human-playtested and may need retuning.", + "Pre-existing and unchanged here: with a trickle strategy the survivor out-kills a 6 energy/sec income, so banking and bursting is the effective approach." + ], + "rollback": [ + "git revert the commit; the game returns to a single fixed difficulty with no persisted state to migrate.", + "After rollback run `npm run check` in reverse_survivors/ and confirm the suite passes at 100% coverage." + ] +} diff --git a/reverse_survivors/README.md b/reverse_survivors/README.md index b5b570e8..736a4cda 100644 --- a/reverse_survivors/README.md +++ b/reverse_survivors/README.md @@ -14,14 +14,32 @@ npm run dev ``` - **Rusher / Stalker / Tank / Bomber** — single spawns at a random arena edge. +- **Splitter** — dies into three rushers. Killing it _creates_ pressure. +- **Artillery** — outranges the survivor (460 vs its 260) and holds station beyond + reach. Its shells **stifle**, stretching the survivor's reload. +- **Warden** — faster than the survivor and **mires** it on contact. - **Wave N** — a ring formation around the survivor; each call grows and costs more. -- **Colossus** (unlocks 1:00) and **Hivemind** (unlocks 2:00) — expensive, on cooldown. - The Hivemind births free rushers while it lives. +- **Colossus** (1:00), **Hivemind** (2:00) and **Leech** (3:00) — expensive, on + cooldown. The Hivemind births free rushers; the Leech's touch **unknits** flesh, + shutting off regeneration while it lives. +- **Ambush** places a unit right next to the survivor, at a premium. + **Frenzy** drives the whole horde faster and harder for six seconds. + **Rift** pins every spawn to one edge for ten, so you can mass a flank. - The survivor gains XP per kill and rolls an upgrade per level (damage, fire rate, speed, vitality, regen, multishot). Time favors it. Pressure early. +- **Haunting / Normal / Crusade** scale enemy hp and speed, the survivor's health, + your income, and the length of the night. -Where enemies appear is deliberately not yours to choose — the director picks -_what_ and _when_; the seeded RNG picks _where_. +Placement is purchasable, never free. By default the director picks _what_ and +_when_ while the seeded RNG picks _where_ — Ambush and Rift buy that choice back, +one at a premium and the other at a cooldown. + +> Earlier versions made the loss of placement control absolute. Ambush and Rift +> deliberately reverse that: the interesting decision turned out to be _what a +> placement is worth_, not being denied one. + +All three debuffs need contact — a survivor that kites cleanly takes none of them — +and they refresh rather than stack, so a horde can never chain-lock the intruder. ## Quality gates @@ -42,6 +60,11 @@ ESLint 10.8.0 · typescript-eslint 8.65.0 · TypeScript **6.0.3**. ## Architecture (what makes 100 % honest) +- `src/core/status.ts` — the survivor's debuff timers. `isOn`/`factor` are written + as arithmetic (`Math.min(1, Math.ceil(t))`) rather than comparisons, so the whole + status system contributes **zero branches** to the coverage gate; consumers just + multiply by the result. `applyStatus` uses `Math.max`, which is what makes + refresh-not-stack a structural guarantee rather than a tuned number. - `src/core/` — the whole game as pure-ish functions over a plain state object. Deterministic: seeded mulberry32 RNG lives _in_ the state; same seed + same action sequence ⇒ identical run. Behavior dispatch uses records instead of diff --git a/reverse_survivors/src/App.test.tsx b/reverse_survivors/src/App.test.tsx index dbce883d..f3709c40 100644 --- a/reverse_survivors/src/App.test.tsx +++ b/reverse_survivors/src/App.test.tsx @@ -60,6 +60,17 @@ describe('App', () => { expect(screen.getByText('30 / 400')).toBeInTheDocument() }) + it('restarts on a new tier when the difficulty is changed', () => { + const raf = installRaf() + render() + pump(raf, 0) + // Normal runs 5:00; Crusade runs 6:00, so the clock proves the tier took. + expect(screen.getByText(/\/ 5:00/)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Crusade' })) + expect(screen.getByText(/\/ 6:00/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Crusade' })).toHaveClass('diff-on') + }) + it('dispatches a director power from the bar', () => { const raf = installRaf() render( { state.director.energy = 400 }} />) diff --git a/reverse_survivors/src/App.tsx b/reverse_survivors/src/App.tsx index 235cef7e..05de4187 100644 --- a/reverse_survivors/src/App.tsx +++ b/reverse_survivors/src/App.tsx @@ -4,7 +4,8 @@ import { fmtTime } from './ui/format' import { createInitialState, step } from './core/sim' import type { HudSnapshot } from './core/snapshot' import { snapshotOf } from './core/snapshot' -import type { DirectorAction, GameState } from './core/types' +import type { DifficultyId, DirectorAction, GameState } from './core/types' +import { DIFFICULTIES, DIFFICULTY_ORDER } from './core/types' import type { Ctx2D } from './render/draw' import { draw } from './render/draw' import { CanvasStage } from './ui/CanvasStage' @@ -18,8 +19,18 @@ interface Props { boot?: (state: GameState) => void } -const createGame = (seed: number, boot: Props['boot']): GameState => { - const state = createInitialState(seed) +/** + * Fresh seed for a restart. Extracted so the impurity lives outside render — + * the react-hooks purity rule rejects `Date.now()` inline in a component body. + */ +const restartSeed = (): number => Date.now() >>> 0 + +const createGame = ( + seed: number, + boot: Props['boot'], + difficulty: DifficultyId = 'normal', +): GameState => { + const state = createInitialState(seed, undefined, difficulty) if (boot !== undefined) { boot(state) } @@ -27,6 +38,7 @@ const createGame = (seed: number, boot: Props['boot']): GameState => { } export const App = ({ seed = 20260802, boot }: Props): ReactElement => { + const [difficulty, setDifficulty] = useState('normal') const gameRef = useRef(createGame(seed, boot)) const [snap, setSnap] = useState(() => snapshotOf(createGame(seed, boot))) const ctxRef = useRef(null) @@ -47,7 +59,13 @@ export const App = ({ seed = 20260802, boot }: Props): ReactElement => { queueRef.current.push(action) } const handleRestart = (): void => { - gameRef.current = createGame(Date.now() >>> 0, undefined) + gameRef.current = createGame(Date.now() >>> 0, undefined, difficulty) + queueRef.current = [] + setSnap(snapshotOf(gameRef.current)) + } + const handleDifficulty = (id: DifficultyId): void => { + setDifficulty(id) + gameRef.current = createGame(restartSeed(), undefined, id) queueRef.current = [] setSnap(snapshotOf(gameRef.current)) } @@ -61,6 +79,18 @@ export const App = ({ seed = 20260802, boot }: Props): ReactElement => {

Reverse Survivors

You are the horde. The hero is the problem.

{fmtTime(snap.t)} / {fmtTime(snap.duration)}

+
+ {DIFFICULTY_ORDER.map((id) => ( + + ))} +
diff --git a/reverse_survivors/src/core/director.test.ts b/reverse_survivors/src/core/director.test.ts index 24823bb6..0f3c6708 100644 --- a/reverse_survivors/src/core/director.test.ts +++ b/reverse_survivors/src/core/director.test.ts @@ -301,3 +301,15 @@ describe('director powers', () => { expect(s.director.riftTimer).toBe(0) }) }) + +describe('difficulty economy', () => { + it('regen defaults to a rate of 1', () => { + expect(regenRate(0)).toBe(6) + expect(regenRate(30)).toBe(8) + }) + + it('regen scales with the tier rate', () => { + expect(regenRate(0, 1.25)).toBeCloseTo(7.5) + expect(regenRate(0, 0.85)).toBeCloseTo(5.1) + }) +}) diff --git a/reverse_survivors/src/core/director.ts b/reverse_survivors/src/core/director.ts index c4bcf661..d96290d2 100644 --- a/reverse_survivors/src/core/director.ts +++ b/reverse_survivors/src/core/director.ts @@ -10,13 +10,13 @@ import type { Vec } from './vec' import { clamp } from './vec' /** Energy per second: ramps every 30 seconds so pressure always grows. */ -export const regenRate = (t: number): number => 6 + 2 * Math.floor(t / 30) +export const regenRate = (t: number, rate = 1): number => (6 + 2 * Math.floor(t / 30)) * rate export const waveCost = (waveIndex: number): number => 60 + 25 * waveIndex export const tickDirector = (state: GameState, dt: number): void => { const d = state.director - d.energy = Math.min(ENERGY_CAP, d.energy + regenRate(state.t) * dt) + d.energy = Math.min(ENERGY_CAP, d.energy + regenRate(state.t, state.difficulty.energyRate) * dt) for (const boss of BOSS_ORDER) { d.bossCooldowns[boss] = Math.max(0, d.bossCooldowns[boss] - dt) } diff --git a/reverse_survivors/src/core/sim.test.ts b/reverse_survivors/src/core/sim.test.ts index ca3c5802..0235605a 100644 --- a/reverse_survivors/src/core/sim.test.ts +++ b/reverse_survivors/src/core/sim.test.ts @@ -5,7 +5,7 @@ import { } from './sim' import type { GameState, Projectile } from './types' import { - ARENA, FRENZY_DAMAGE, FRENZY_SPEED, GAME_DURATION, STATUS_POWER, + ARENA, DIFFICULTIES, FRENZY_DAMAGE, FRENZY_SPEED, GAME_DURATION, STATUS_POWER, } from './types' const CENTER = { x: ARENA.w / 2, y: ARENA.h / 2 } @@ -622,6 +622,47 @@ describe('frenzy', () => { }) }) +describe('difficulty tiers', () => { + it('defaults to normal, the identity tier', () => { + const s = fresh() + expect(s.difficulty).toBe(DIFFICULTIES.normal) + expect(s.survivor.maxHp).toBe(100) + }) + + it('accepts an explicit tier', () => { + const s = createInitialState(1, undefined, 'crusade') + expect(s.difficulty).toBe(DIFFICULTIES.crusade) + }) + + it('takes its duration from the tier when none is given', () => { + expect(createInitialState(1, undefined, 'haunting').duration) + .toBe(DIFFICULTIES.haunting.duration) + }) + + it('an explicit duration still wins over the tier', () => { + expect(createInitialState(1, 42, 'haunting').duration).toBe(42) + }) + + it('scales enemy hp', () => { + const s = createInitialState(1, undefined, 'crusade') + const e = makeEnemy(s, 'rusher', { x: 0, y: 0 }) + expect(e.hp).toBeCloseTo(14 * DIFFICULTIES.crusade.enemyHp, 5) + }) + + it('scales survivor hp', () => { + const s = createInitialState(1, undefined, 'haunting') + expect(s.survivor.maxHp).toBe(125) + }) + + it('scales enemy speed', () => { + const s = createInitialState(1, undefined, 'crusade') + const e = makeEnemy(s, 'rusher', { x: CENTER.x + 300, y: CENTER.y }) + step(s, [], 0.05) + const moved = CENTER.x + 300 - e.pos.x + expect(moved).toBeCloseTo(170 * DIFFICULTIES.crusade.enemySpeed * 0.05, 5) + }) +}) + describe('frenzy damage reaches every channel', () => { // Regression: frenzy multiplied contact damage only, so ranged and blast // units — including the bomber, whose whole purpose is damage — ignored it. diff --git a/reverse_survivors/src/core/sim.ts b/reverse_survivors/src/core/sim.ts index b8083e2c..fc576b44 100644 --- a/reverse_survivors/src/core/sim.ts +++ b/reverse_survivors/src/core/sim.ts @@ -2,11 +2,12 @@ import { applyAction, tickDirector } from './director' import { createRng, pick } from './rng' import { applyStatus, factor, tickStatuses } from './status' import type { - DirectorAction, Enemy, EnemyKind, EnemySpec, GameState, Survivor, UpgradeId, + DifficultyConfig, DifficultyId, DirectorAction, Enemy, EnemyKind, EnemySpec, GameState, + Survivor, UpgradeId, } from './types' import { - ARENA, CONTACT_COOLDOWN, ENERGY_START, ENEMY_SPECS, FRENZY_DAMAGE, FRENZY_SPEED, - GAME_DURATION, STATUS_POWER, UPGRADE_POOL, + ARENA, CONTACT_COOLDOWN, DIFFICULTIES, ENERGY_START, ENEMY_SPECS, FRENZY_DAMAGE, + FRENZY_SPEED, STATUS_POWER, UPGRADE_POOL, } from './types' import type { Vec } from './vec' import { clamp, dist, toward } from './vec' @@ -21,10 +22,10 @@ const CENTER: Vec = { x: ARENA.w / 2, y: ARENA.h / 2 } export const xpForLevel = (level: number): number => 20 + 15 * (level - 1) -const createSurvivor = (): Survivor => ({ +const createSurvivor = (cfg: DifficultyConfig): Survivor => ({ pos: { x: CENTER.x, y: CENTER.y }, - hp: 100, - maxHp: 100, + hp: 100 * cfg.survivorHp, + maxHp: 100 * cfg.survivorHp, speed: 150, regen: 0, damage: 8, @@ -38,28 +39,40 @@ const createSurvivor = (): Survivor => ({ statuses: { slow: 0, suppress: 0, bleed: 0 }, }) -export const createInitialState = (seed: number, duration = GAME_DURATION): GameState => ({ - status: 'running', - t: 0, - duration, - rng: createRng(seed), - survivor: createSurvivor(), - enemies: [], - projectiles: [], - director: { - energy: ENERGY_START, - waveIndex: 0, - bossCooldowns: { colossus: 0, hivemind: 0, leech: 0 }, - powerCooldowns: { ambush: 0, frenzy: 0, rift: 0 }, - frenzyTimer: 0, - riftTimer: 0, - riftEdge: 0, - ambushIndex: 0, - }, - upgrades: [], - nextId: 1, - outcomeTime: 0, -}) +export const createInitialState = ( + seed: number, + duration?: number, + difficulty: DifficultyId = 'normal', +): GameState => { + const cfg = DIFFICULTIES[difficulty] + return { + status: 'running', + difficulty: cfg, + t: 0, + duration: duration ?? cfg.duration, + rng: createRng(seed), + survivor: createSurvivor(cfg), + enemies: [], + projectiles: [], + director: { + energy: ENERGY_START, + waveIndex: 0, + bossCooldowns: { colossus: 0, hivemind: 0, leech: 0 }, + powerCooldowns: { ambush: 0, frenzy: 0, rift: 0 }, + frenzyTimer: 0, + riftTimer: 0, + riftEdge: 0, + ambushIndex: 0, + }, + upgrades: [], + nextId: 1, + outcomeTime: 0, + } +} + +/** Single place enemy speed is derived: difficulty and frenzy both land here. */ +export const enemySpeed = (state: GameState, kind: EnemyKind, frenzyMul: number): number => + ENEMY_SPECS[kind].speed * state.difficulty.enemySpeed * frenzyMul export const makeEnemy = (state: GameState, kind: EnemyKind, pos: Vec): Enemy => { const id = state.nextId @@ -68,7 +81,7 @@ export const makeEnemy = (state: GameState, kind: EnemyKind, pos: Vec): Enemy => id, kind, pos: { x: pos.x, y: pos.y }, - hp: ENEMY_SPECS[kind].hp, + hp: ENEMY_SPECS[kind].hp * state.difficulty.enemyHp, hitTimer: 0, fireTimer: 0, spawnTimer: ENEMY_SPECS[kind].spawnEvery, @@ -238,7 +251,7 @@ const enemiesStep = (state: GameState, dt: number): void => { const spec = ENEMY_SPECS[e.kind] e.hitTimer = Math.max(0, e.hitTimer - dt) const d = dist(e.pos, sv.pos) - const vel = MOVE_FNS[spec.move](e, sv, d, spec.speed * speedMul) + const vel = MOVE_FNS[spec.move](e, sv, d, enemySpeed(state, e.kind, speedMul)) e.pos.x = clamp(e.pos.x + vel.x * dt, spec.radius, ARENA.w - spec.radius) e.pos.y = clamp(e.pos.y + vel.y * dt, spec.radius, ARENA.h - spec.radius) diff --git a/reverse_survivors/src/core/types.ts b/reverse_survivors/src/core/types.ts index 1dd5fea0..38fd2370 100644 --- a/reverse_survivors/src/core/types.ts +++ b/reverse_survivors/src/core/types.ts @@ -156,6 +156,36 @@ export const BOSS_COOLDOWN: Record = { colossus: 25, hivemind: 30, leech: 28, } +export type DifficultyId = 'haunting' | 'normal' | 'crusade' + +export interface DifficultyConfig { + readonly label: string + readonly enemyHp: number + readonly enemySpeed: number + readonly survivorHp: number + readonly energyRate: number + readonly duration: number +} + +/** `normal` is exactly 1 across the board, so it is the identity tier. */ +export const DIFFICULTIES: Record = { + haunting: { + label: 'Haunting', + enemyHp: 0.85, enemySpeed: 0.95, survivorHp: 1.25, energyRate: 1.25, duration: 240, + }, + normal: { + label: 'Normal', + enemyHp: 1, enemySpeed: 1, survivorHp: 1, energyRate: 1, duration: GAME_DURATION, + }, + crusade: { + label: 'Crusade', + enemyHp: 1.2, enemySpeed: 1.05, survivorHp: 0.85, energyRate: 0.85, duration: 360, + }, +} +export const DIFFICULTY_ORDER: readonly [DifficultyId, ...DifficultyId[]] = [ + 'haunting', 'normal', 'crusade', +] + export type PowerKind = 'ambush' | 'frenzy' | 'rift' export const POWER_ORDER: readonly [PowerKind, ...PowerKind[]] = ['ambush', 'frenzy', 'rift'] export const POWER_NAMES: Record = { @@ -251,6 +281,7 @@ export type Status = 'running' | 'directorWon' | 'survivorWon' export interface GameState { status: Status + difficulty: DifficultyConfig t: number duration: number rng: Rng diff --git a/reverse_survivors/src/styles.css b/reverse_survivors/src/styles.css index 8ac77120..cc1e0e3c 100644 --- a/reverse_survivors/src/styles.css +++ b/reverse_survivors/src/styles.css @@ -259,3 +259,18 @@ body { letter-spacing: 0.06em; text-align: center; } + +.difficulty { display: flex; gap: 6px; justify-content: center; margin-top: 6px; } + +.diff { + background: none; + border: 1px solid var(--panel-edge); + border-radius: 4px; + color: var(--bone-dim); + cursor: pointer; + font-family: var(--font-data); + font-size: 0.7rem; + padding: 3px 10px; +} + +.diff-on { border-color: var(--holy); color: var(--holy); }