Skip to content

Feature/colorblind mode#4150

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

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

Conversation

@noahschmal
Copy link
Copy Markdown
Contributor

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.

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: 213ac237-d765-4c9b-99f2-8b739c51d108

📥 Commits

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

📒 Files selected for processing (2)
  • src/client/theme/ColorAllocator.ts
  • tests/Colors.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/Colors.test.ts

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
Adds localization and a Colorblind Mode toggle in 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 gains colorblind theme wiring.
Colorblind palette and theme
src/client/theme/Colors.ts, src/client/theme/ColorblindTheme.ts
Adds a 32-color colorblindColors generator plus Okabe–Ito-derived 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 GraphicsOverrides
  participant ThemeProvider
  participant RenderOverrides
  participant ColorblindTheme
  User->>UserSettingModal: toggles Colorblind Mode
  UserSettingModal->>GraphicsOverrides: setGraphicsOverrides({accessibility:{colorblind}})
  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

  • openfrontio/OpenFrontIO#4114: Both PRs touch the rendering-override pipeline—src/client/render/gl/RenderOverrides.ts and its applyGraphicsOverrides integration—so the main PR’s new accessibility.colorblind styling is layered onto the same override mechanism introduced/refactored in the dark-mode PR.

Suggested labels

UI/UX, Feature

Suggested reviewers

  • evanpelle

Poem

🎨 Golden-angle hues in careful rows,
Borders deepen where contrast grows.
BaseTheme keeps palettes neat and planned,
Colorblind mode gives a steadier hand.
Tests agree — a clearer land.

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (1 warning, 2 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.
Title check ❓ Inconclusive The title 'Feature/colorblind mode' is related to the main change but uses generic branch naming convention rather than describing the actual feature impact. Consider using a more descriptive title like 'Add colorblind mode with accessibility-friendly theme and color palettes' to better summarize the feature from the developer's perspective.
Out of Scope Changes check ❓ Inconclusive The PR introduces a comprehensive theme refactoring (BaseTheme, ColorAllocator changes) that extends beyond the minimal colorblind mode feature, though it provides necessary infrastructure for supporting multiple themes. Clarify whether the BaseTheme refactoring and ColorAllocator modifications are essential requirements from #2549 or if they represent scope creep that could be separated into a preparatory refactor PR.
✅ Passed checks (2 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the colorblind mode feature, theme refactoring, and implementation details related to the changeset.
Linked Issues check ✅ Passed The PR successfully implements colorblind mode as requested in #2549, providing alternative color palettes and visual adjustments to help players with color vision deficiency distinguish teammates and game elements.

✏️ 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

Add Colorblind Mode / Accessibility Options

1 participant