Skip to content

Feature/colorblind mode#4150

Open
noahschmal wants to merge 7 commits into
openfrontio:mainfrom
noahschmal:feature/colorblind-mode
Open

Feature/colorblind mode#4150
noahschmal wants to merge 7 commits into
openfrontio:mainfrom
noahschmal:feature/colorblind-mode

Conversation

@noahschmal
Copy link
Copy Markdown
Contributor

@noahschmal noahschmal commented Jun 3, 2026

Add approved & assigned issue number here:

Resolves #2549

Description:

Adds colorblind mode. Similar to dark mode, it exists as a toggle in settings. When enabled, it swaps the game's theme (which is refactored to extend from a theme base class) to use more colorblind-friendly colors and brightness variations. Borders are darkened, and terrarin is separated by lightness. Friendly/Foe colors and switched to blue/orange instead of red/green.

The theme refactor supports adding new themes without having to reimplement the color distribution system. New themes can extend the BaseTheme and supply the data, such as palettes, team-color variations, and terrain.

New setting:
Screenshot 2026-06-04 at 11 30 27 AM

New color palette:
Screenshot 2026-06-04 at 11 30 59 AM

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory

Please put your Discord username so you can be contacted if a bug or regression is found:

jetaviz

Adds an accessibility.colorblind flag to GraphicsOverridesSchema, a toggle
in the main Settings modal (persisted via graphicsOverrides), en.json
strings, and a generateRenderSettings hook where the colorblind color
overrides will be applied. No rendering changes yet.
Fill the generateRenderSettings colorblind hook with an Okabe-Ito
blue/orange palette: alt-view affiliation borders (self/ally blue, enemy
orange) and normal-view friend/enemy border tints (friendly blue, enemy
orange at a stronger ratio). Propagate the affiliation and mapOverlay
slices through applyGraphicsOverrides so the renderer picks them up.
Introduce an abstract BaseTheme that owns the color allocators and the
territory/team color dispatch (overridable); concrete themes supply only
color data (palettes, team-color variations, terrain) via hooks. Move the
hardcoded team-color switch out of ColorAllocator — now a pure
distinct-pool engine — into the theme, so team colors are theme-based.
Add ColorblindTheme. Light/dark render identically; team-color tests
relocated onto PastelTheme.
Give the colorblind theme its own visual tuning on top of the swapped
palettes:

- Borders derive from each territory's fill but are darkened *relative*
  to the fill's own lightness (l * 0.6) rather than by a fixed amount, so
  every boundary reads as a consistently darker line without dark fills
  collapsing to near-black.
- Terrain elevation bands are separated by lightness (dark plains -> mid
  highland -> near-white mountain) instead of the green->brown->gray hue
  ramp, which is hard to distinguish under red-green CVD.
- Friend/foe border tint ratios raised to 0.85 so the blue/orange
  relationship cue dominates the darkened border instead of relying on
  subtle hue.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 3, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e15ec7a9-6d92-4f72-8f08-ca17c0b528fa

📥 Commits

Reviewing files that changed from the base of the PR and between d150974 and 3dd56ed.

📒 Files selected for processing (2)
  • resources/lang/en.json
  • src/client/hud/layers/GraphicsSettingsModal.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • resources/lang/en.json

Walkthrough

Adds a Colorblind Mode toggle and accessibility flag, applies colorblind-safe affiliation/map tints in render overrides, refactors theme infrastructure into BaseTheme and a simplified ColorAllocator, introduces ColorblindTheme and colorblind palettes, updates ThemeProvider wiring, and adjusts color-related tests.

Changes

Colorblind Mode Feature

Layer / File(s) Summary
User settings and render overrides
resources/lang/en.json, src/client/UserSettingModal.ts, src/client/render/gl/GraphicsOverrides.ts, src/client/render/gl/RenderOverrides.ts, src/client/hud/layers/GraphicsSettingsModal.ts
Adds localization and a Colorblind Mode toggle in user and graphics settings; extends graphics overrides with accessibility.colorblind; when enabled, applyGraphicsOverrides applies Okabe–Ito affiliation borders and stronger map overlay tints.
BaseTheme and allocator refactor
src/client/theme/BaseTheme.ts, src/client/theme/ColorAllocator.ts, src/client/theme/PastelTheme.ts, src/client/theme/ThemeProvider.ts
Introduces BaseTheme with shared palette hooks, LAB-based structure color derivation, spawn/border helpers, and per-category allocators; simplifies ColorAllocator to theme-agnostic ID assignment; adapts PastelTheme to extend BaseTheme; ThemeProvider manages the colorblind theme instance.
Colorblind palette and theme
src/client/theme/Colors.ts, src/client/theme/ColorblindTheme.ts
Adds a 32-color colorblindColors generator plus Okabe–Ito-derived cb* team color arrays and implements ColorblindTheme that uses those palettes and colorblind-safe terrain/border coloring.
Tests and validation
tests/Colors.test.ts
Updates tests to assert PastelTheme team-color mapping and per-player determinism and to confirm ColorblindTheme differs for at least one team; updates selectDistinctColor index usage.

Sequence Diagram

sequenceDiagram
  participant User
  participant UserSettingModal
  participant GraphicsSettingsModal
  participant GraphicsOverrides
  participant ThemeProvider
  participant RenderOverrides
  participant ColorblindTheme
  User->>UserSettingModal: toggles Colorblind Mode
  UserSettingModal->>GraphicsOverrides: setGraphicsOverrides({accessibility:{colorblind}})
  User->>GraphicsSettingsModal: toggles in Graphics modal
  User->>ThemeProvider: requests current theme
  ThemeProvider->>ColorblindTheme: returns when accessibility.colorblind
  GraphicsOverrides->>RenderOverrides: applyGraphicsOverrides(accessibility.colorblind)
  RenderOverrides->>RenderOverrides: set Okabe–Ito affiliation/mapOverlay colors
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

UI/UX

Suggested reviewers

  • Celant

Poem

🎨 Golden-angle hues in careful rows,
Borders deepen where contrast grows.
BaseTheme keeps palettes neat and planned,
Colorblind mode guides a steadier hand.
Tests nod true — clearer maps expand.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Out of Scope Changes check ❓ Inconclusive The PR implements substantial theme architecture refactoring (BaseTheme, ColorAllocator changes) that supports colorblind mode but may extend beyond the minimal scope required by the linked issue. Verify that the theme refactoring (BaseTheme abstraction, ColorAllocator API changes) aligns with team architectural goals for supporting future themes.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Feature/colorblind mode' clearly and concisely describes the main feature being added to the codebase.
Description check ✅ Passed The description is well-detailed and directly related to the changeset, explaining the colorblind mode feature, theme refactoring, implementation details, and UI updates.
Linked Issues check ✅ Passed The PR fully addresses issue #2549 by implementing a colorblind mode toggle with colorblind-friendly color palettes, brightness variations, and terrain separation for better accessibility.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/theme/PastelTheme.ts (1)

73-110: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add exhaustive check for terrain type switch.

If TerrainType enum gains new values, the function could return undefined despite the Colord return type. TypeScript's control-flow analysis doesn't catch this without an explicit default or exhaustive check.

Suggested fix
       case TerrainType.Mountain:
         return colord({
           r: 230 + mag / 2,
           g: 230 + mag / 2,
           b: 230 + mag / 2,
         });
+      default: {
+        const _exhaustive: never = gm.terrainType(tile);
+        return this.shore; // fallback
+      }
     }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/theme/PastelTheme.ts` around lines 73 - 110, The switch in
terrainColor(gm: GameMap, tile: TileRef): Colord does not exhaustively handle
all TerrainType values and can return undefined if the enum gains members;
update the function to include an explicit exhaustive/default branch (e.g., a
default case or a final throw) that either returns a safe Colord fallback or
throws a descriptive error including the unexpected terrain value, so the
function always returns a Colord and TypeScript's control-flow is satisfied.
🧹 Nitpick comments (3)
src/client/theme/BaseTheme.ts (2)

117-128: 💤 Low value

Orphaned comment creates confusion.

The comment on lines 126-127 appears after break; but describes the else if branch below it. Move it to the correct location.

Suggested fix
     if (loopCount > maxIterations) {
       // Prevent runaway loops
       console.warn(`Infinite loop detected during structure color calculation.
         Light color: ${colord(lightLAB).toRgbString()},
         Dark color: ${colord(darkLAB).toRgbString()},
         Contrast: ${contrast}`);
       break;
-
-      // Increase the light color if the "loop limit" has been reach
-      // (probably due to the dark color already being as dark as it can be)
     } else if (loopCount > loopLimit) {
+      // Increase the light color if the loop limit has been reached
+      // (probably because the dark color is already as dark as it can be)
       lightLAB.l = this.clamp(lightLAB.l + luminanceChange);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/theme/BaseTheme.ts` around lines 117 - 128, The orphaned comment
after the break in the contrast-adjustment while loop should be moved to
directly precede the else if (loopCount > loopLimit) branch so it describes that
branch correctly; locate the while loop in BaseTheme.ts (the block using
variables contrast, contrastTarget, loopCount, maxIterations, loopLimit and the
else if (loopCount > loopLimit) clause) and cut the comment currently placed
immediately after break; then paste it above the else if (loopCount > loopLimit)
condition so the comment clearly explains the "Increase the light color..."
behavior for loopLimit.

17-51: 🏗️ Heavy lift

Consider composition over inheritance for theme architecture.

The abstract class pattern creates a three-level hierarchy (BaseTheme → PastelTheme → ColorblindTheme). A composition approach with separate palette and behavior objects would be more flexible and easier to test:

interface ThemePalettes {
  humanPalette: Colord[];
  botPalette: Colord[];
  nationPalette: Colord[];
  fallbackPalette: Colord[];
  teamColorVariations: (team: Team) => Colord[];
}

interface ThemeBehaviors {
  borderColor: (territoryColor: Colord) => Colord;
  terrainColor: (gm: GameMap, tile: TileRef) => Colord;
}

This lets you mix and match palettes with behaviors without deep inheritance chains. For example, a future "dark colorblind" theme becomes trivial.

That said, the current implementation works and ships the feature. This is a good-to-have improvement for a follow-up.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/theme/BaseTheme.ts` around lines 17 - 51, The BaseTheme
inheritance creates rigid three-level hierarchies; refactor to composition by
extracting palette and behavior responsibilities into plain interfaces (e.g.,
ThemePalettes and ThemeBehaviors) and have a Theme implementation compose those
instead of subclassing BaseTheme: move palette providers (humanPalette,
botPalette, nationPalette, fallbackPalette, teamColorVariations) into a
ThemePalettes object and move rendering/behavior methods (borderColor,
terrainColor, spawn highlight logic that uses
_spawnHighlightColor/_spawnHighlightSelfColor/_spawnHighlightTeamColor/_spawnHighlightEnemyColor
and teamPlayerColors) into a ThemeBehaviors object; replace the BaseTheme
constructor wiring of humanColorAllocator, botColorAllocator,
nationColorAllocator to accept palettes from the injected ThemePalettes and
ensure existing consumers of BaseTheme now receive a composed Theme instance
that delegates to the palettes/behaviors, keeping the ColorAllocator usage and
teamPlayerColors map intact for backward compatibility.
tests/Colors.test.ts (1)

86-114: ⚡ Quick win

Consider adding a test for ColorblindTheme color differences.

The tests verify the theme API contract through PastelTheme, which is good. Since ColorblindTheme overrides the palette methods to provide CVD-safe colors, consider adding a test that verifies ColorblindTheme returns different colors than PastelTheme for at least one team. This would confirm the colorblind theme actually applies a distinct palette.

📝 Example test to add
test("ColorblindTheme uses different colors than PastelTheme", () => {
  const pastel = new PastelTheme();
  const colorblind = new ColorblindTheme();
  const pastelBlue = pastel.teamColor(ColoredTeams.Blue);
  const colorblindBlue = colorblind.teamColor(ColoredTeams.Blue);
  expect(pastelBlue.isEqual(colorblindBlue)).toBe(false);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Colors.test.ts` around lines 86 - 114, Add a test that instantiates
PastelTheme and ColorblindTheme and asserts that
ColorblindTheme.teamColor(ColoredTeams.Blue) is different from
PastelTheme.teamColor(ColoredTeams.Blue) (use the Color.isEqual method and
expect(...).toBe(false)); place the test alongside the existing PastelTheme
tests so it verifies ColorblindTheme actually provides a distinct palette.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/theme/ColorblindTheme.ts`:
- Around line 76-101: The terrainColor method in ColorblindTheme does not handle
all TerrainType variants and can return undefined for new enum values; update
terrainColor (in class ColorblindTheme) to include an exhaustive/default branch
(similar to PastelTheme) that either returns a safe fallback Colord (e.g., a
neutral color like this.shore or a defined default color) or throws a
descriptive error for unknown TerrainType, ensuring every switch path returns a
Colord.

---

Outside diff comments:
In `@src/client/theme/PastelTheme.ts`:
- Around line 73-110: The switch in terrainColor(gm: GameMap, tile: TileRef):
Colord does not exhaustively handle all TerrainType values and can return
undefined if the enum gains members; update the function to include an explicit
exhaustive/default branch (e.g., a default case or a final throw) that either
returns a safe Colord fallback or throws a descriptive error including the
unexpected terrain value, so the function always returns a Colord and
TypeScript's control-flow is satisfied.

---

Nitpick comments:
In `@src/client/theme/BaseTheme.ts`:
- Around line 117-128: The orphaned comment after the break in the
contrast-adjustment while loop should be moved to directly precede the else if
(loopCount > loopLimit) branch so it describes that branch correctly; locate the
while loop in BaseTheme.ts (the block using variables contrast, contrastTarget,
loopCount, maxIterations, loopLimit and the else if (loopCount > loopLimit)
clause) and cut the comment currently placed immediately after break; then paste
it above the else if (loopCount > loopLimit) condition so the comment clearly
explains the "Increase the light color..." behavior for loopLimit.
- Around line 17-51: The BaseTheme inheritance creates rigid three-level
hierarchies; refactor to composition by extracting palette and behavior
responsibilities into plain interfaces (e.g., ThemePalettes and ThemeBehaviors)
and have a Theme implementation compose those instead of subclassing BaseTheme:
move palette providers (humanPalette, botPalette, nationPalette,
fallbackPalette, teamColorVariations) into a ThemePalettes object and move
rendering/behavior methods (borderColor, terrainColor, spawn highlight logic
that uses
_spawnHighlightColor/_spawnHighlightSelfColor/_spawnHighlightTeamColor/_spawnHighlightEnemyColor
and teamPlayerColors) into a ThemeBehaviors object; replace the BaseTheme
constructor wiring of humanColorAllocator, botColorAllocator,
nationColorAllocator to accept palettes from the injected ThemePalettes and
ensure existing consumers of BaseTheme now receive a composed Theme instance
that delegates to the palettes/behaviors, keeping the ColorAllocator usage and
teamPlayerColors map intact for backward compatibility.

In `@tests/Colors.test.ts`:
- Around line 86-114: Add a test that instantiates PastelTheme and
ColorblindTheme and asserts that ColorblindTheme.teamColor(ColoredTeams.Blue) is
different from PastelTheme.teamColor(ColoredTeams.Blue) (use the Color.isEqual
method and expect(...).toBe(false)); place the test alongside the existing
PastelTheme tests so it verifies ColorblindTheme actually provides a distinct
palette.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9d87b54f-df31-44b9-8f22-edfab5725b22

📥 Commits

Reviewing files that changed from the base of the PR and between 48609fa and 4d12a1a.

📒 Files selected for processing (11)
  • resources/lang/en.json
  • src/client/UserSettingModal.ts
  • src/client/render/gl/GraphicsOverrides.ts
  • src/client/render/gl/RenderOverrides.ts
  • src/client/theme/BaseTheme.ts
  • src/client/theme/ColorAllocator.ts
  • src/client/theme/ColorblindTheme.ts
  • src/client/theme/Colors.ts
  • src/client/theme/PastelTheme.ts
  • src/client/theme/ThemeProvider.ts
  • tests/Colors.test.ts

Comment thread src/client/theme/ColorblindTheme.ts
@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management Jun 3, 2026
- Add JSDoc docstrings across the theme classes/methods, ColorAllocator
  helpers, RenderOverrides functions, and the colorblind settings methods
  to satisfy the docstring-coverage threshold.
- Add an exhaustiveness guard to the terrainColor switch in PastelTheme
  and ColorblindTheme so a new TerrainType becomes a compile error rather
  than a silent undefined return.
- Fix an orphaned comment in BaseTheme.structureColors.
- Add a test verifying ColorblindTheme applies a palette distinct from
  PastelTheme.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/theme/ColorAllocator.ts (1)

68-90: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Return type says number | null, but null is never returned.

The function only throws (when there are no assigned colors) or returns maxIndex (a number). So null is impossible, which makes the ?? 0 guard at the call site (line 54) dead code and the type a bit confusing to read. Suggest dropping | null to match real behavior.

♻️ Make the type match behavior
-export function selectDistinctColorIndex(
-  availableColors: Colord[],
-  assignedColors: Colord[],
-): number | null {
+export function selectDistinctColorIndex(
+  availableColors: Colord[],
+  assignedColors: Colord[],
+): number {

Then the call site can drop the ?? 0:

-      selectedIndex =
-        selectDistinctColorIndex(this.availableColors, assignedColors) ?? 0;
+      selectedIndex = selectDistinctColorIndex(
+        this.availableColors,
+        assignedColors,
+      );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/theme/ColorAllocator.ts` around lines 68 - 90, The function
selectDistinctColorIndex declares a return type of number | null but never
returns null (it throws when assignedColors is empty and otherwise returns
maxIndex), which mismatches its behavior; update the signature to return number
(remove | null) and adjust any call sites (e.g., where ?? 0 is used)
accordingly, ensuring selectDistinctColorIndex, its parameters availableColors
and assignedColors, and its returned maxIndex are consistently typed as number
throughout the codebase.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/client/theme/ColorAllocator.ts`:
- Around line 68-90: The function selectDistinctColorIndex declares a return
type of number | null but never returns null (it throws when assignedColors is
empty and otherwise returns maxIndex), which mismatches its behavior; update the
signature to return number (remove | null) and adjust any call sites (e.g.,
where ?? 0 is used) accordingly, ensuring selectDistinctColorIndex, its
parameters availableColors and assignedColors, and its returned maxIndex are
consistently typed as number throughout the codebase.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a089c44c-e4a3-4460-b149-fc98e8044316

📥 Commits

Reviewing files that changed from the base of the PR and between 4d12a1a and b5c9679.

📒 Files selected for processing (7)
  • src/client/UserSettingModal.ts
  • src/client/render/gl/RenderOverrides.ts
  • src/client/theme/BaseTheme.ts
  • src/client/theme/ColorAllocator.ts
  • src/client/theme/ColorblindTheme.ts
  • src/client/theme/PastelTheme.ts
  • tests/Colors.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/client/UserSettingModal.ts
  • tests/Colors.test.ts
  • src/client/theme/PastelTheme.ts
  • src/client/theme/BaseTheme.ts

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jun 3, 2026
The function only throws or returns a number index, so the `| null`
return type and the `?? 0` guard at the call site were dead. Narrow the
return type to `number` and remove the now-unnecessary fallback and the
null assertion in the test.
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jun 3, 2026
@iiamlewis iiamlewis added the approved Approved for a PR, if you assigned to the issue. label Jun 4, 2026
@iiamlewis iiamlewis added this to the v32 milestone Jun 4, 2026
Copy link
Copy Markdown
Collaborator

@evanpelle evanpelle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also add the colorblind setting to the in game settings as well

Colorblind mode lives under graphicsOverrides.accessibility, so add it to
the in-game GraphicsSettingsModal under a new "Accessibility" section,
reusing the existing colorblind_label/desc strings. Toggling it patches
the accessibility overrides like the other graphics toggles, which the
ClientGameRunner graphics listener applies live.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Approved for a PR, if you assigned to the issue.

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

Add Colorblind Mode / Accessibility Options

3 participants