Skip to content

Commit 38e7309

Browse files
claudeDragonnZhang
authored andcommitted
Add an "Increase contrast" accessibility setting to Appearance
1 parent 0911124 commit 38e7309

14 files changed

Lines changed: 260 additions & 3 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* HighContrastContext
3+
*
4+
* App-wide "Increase contrast" accessibility preference — the natural companion
5+
* to `ReduceMotionContext`, living beside it in Settings → Appearance → Interface.
6+
*
7+
* When enabled it sets `data-high-contrast="true"` on `<html>`. The global CSS
8+
* block in `index.css` gated on `:root[data-high-contrast='true']` then raises
9+
* the contrast of the theme tokens — strengthening `--border`, `--input`,
10+
* `--muted-foreground`, `--foreground-dimmed` and the focus `--ring`, and adding
11+
* a visible focus outline. Because every theme derives those tokens from
12+
* `--foreground` (which flips per light/dark), the alpha-based overrides work in
13+
* both light and dark and across preset themes. The theme system injects its
14+
* colors as a `:root { … }` stylesheet rule, so the higher-specificity
15+
* `:root[data-high-contrast='true']` selector wins without `!important`.
16+
*
17+
* The preference is persisted in `localStorage` (renderer-only, no backend),
18+
* mirroring the other lightweight UI prefs in `lib/local-storage.ts`.
19+
*/
20+
21+
import {
22+
createContext,
23+
useContext,
24+
useState,
25+
useEffect,
26+
useCallback,
27+
type ReactNode,
28+
} from 'react'
29+
import * as storage from '@/lib/local-storage'
30+
31+
interface HighContrastContextType {
32+
highContrast: boolean
33+
setHighContrast: (value: boolean) => void
34+
}
35+
36+
const HighContrastContext = createContext<HighContrastContextType | null>(null)
37+
38+
const HIGH_CONTRAST_ATTR = 'data-high-contrast'
39+
40+
/** Reflect the preference onto <html> so the global CSS overrides can react. */
41+
function applyHighContrastAttribute(enabled: boolean): void {
42+
const root = document.documentElement
43+
if (enabled) {
44+
root.setAttribute(HIGH_CONTRAST_ATTR, 'true')
45+
} else {
46+
root.removeAttribute(HIGH_CONTRAST_ATTR)
47+
}
48+
}
49+
50+
export function HighContrastProvider({ children }: { children: ReactNode }) {
51+
const [highContrast, setHighContrastState] = useState<boolean>(() =>
52+
storage.get<boolean>(storage.KEYS.highContrast, false),
53+
)
54+
55+
// Keep the DOM attribute in sync (also covers the initial value on mount).
56+
useEffect(() => {
57+
applyHighContrastAttribute(highContrast)
58+
}, [highContrast])
59+
60+
const setHighContrast = useCallback((value: boolean) => {
61+
setHighContrastState(value)
62+
storage.set(storage.KEYS.highContrast, value)
63+
applyHighContrastAttribute(value)
64+
}, [])
65+
66+
return (
67+
<HighContrastContext.Provider value={{ highContrast, setHighContrast }}>
68+
{children}
69+
</HighContrastContext.Provider>
70+
)
71+
}
72+
73+
export function useHighContrast(): HighContrastContextType {
74+
const ctx = useContext(HighContrastContext)
75+
if (!ctx) {
76+
throw new Error('useHighContrast must be used within a HighContrastProvider')
77+
}
78+
return ctx
79+
}

apps/electron/src/renderer/index.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,3 +1406,35 @@ html.dark[data-scenic] .fullscreen-overlay-background {
14061406
.chat-text-scope {
14071407
font-size: calc(1em * var(--chat-font-scale));
14081408
}
1409+
1410+
/* Increase contrast: when the user enables "Increase contrast" in Appearance
1411+
settings, raise the contrast of the low-alpha theme tokens app-wide. Every
1412+
theme derives these from `--foreground` (which flips per light/dark), so the
1413+
alpha-based overrides work in both modes and across preset themes. The theme
1414+
system injects its colors as a plain `:root { … }` rule, so this
1415+
higher-specificity `:root[data-high-contrast='true']` selector wins without
1416+
`!important`. `--hc-enabled` is a marker the e2e assertion reads to confirm
1417+
the block actually applied. */
1418+
:root[data-high-contrast='true'] {
1419+
--hc-enabled: 1;
1420+
1421+
/* Stronger borders, dividers and input outlines. */
1422+
--border: oklch(from var(--foreground) l c h / 0.28);
1423+
--input: oklch(from var(--foreground) l c h / 0.38);
1424+
1425+
/* De-dim secondary/"muted" and unfocused text so it stays readable. */
1426+
--muted-foreground: var(--foreground-80);
1427+
--foreground-dimmed: var(--foreground-90);
1428+
--md-bullets: var(--foreground-80);
1429+
--md-counters: var(--foreground-80);
1430+
1431+
/* More visible focus ring. */
1432+
--ring: oklch(from var(--foreground) l c h / 0.6);
1433+
--ring-width: 2px;
1434+
}
1435+
1436+
/* A clearly visible keyboard-focus outline for interactive elements. */
1437+
:root[data-high-contrast='true'] :focus-visible {
1438+
outline: 2px solid var(--ring);
1439+
outline-offset: 1px;
1440+
}

apps/electron/src/renderer/lib/local-storage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const KEYS = {
6464
reduceMotion: 'reduce-motion', // Minimize animations/transitions app-wide
6565
conversationWidth: 'conversation-width', // Chat reading-column width: comfortable | wide | full
6666
chatTextSize: 'chat-text-size', // Scale conversation text without resizing the app chrome
67+
highContrast: 'high-contrast', // Raise contrast of borders, dividers, and muted text app-wide
6768

6869
// What's New
6970
whatsNewLastSeenVersion: 'whats-new-last-seen-version',

apps/electron/src/renderer/main.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ThemeProvider } from './context/ThemeContext'
99
import { ReduceMotionProvider } from './context/ReduceMotionContext'
1010
import { ConversationWidthProvider } from './context/ConversationWidthContext'
1111
import { ChatTextSizeProvider } from './context/ChatTextSizeContext'
12+
import { HighContrastProvider } from './context/HighContrastContext'
1213
import { windowWorkspaceIdAtom } from './atoms/sessions'
1314
import { Toaster } from '@/components/ui/sonner'
1415
import { PetWindowController } from '@/components/pet/PetWindowController'
@@ -112,9 +113,11 @@ function Root() {
112113
<ReduceMotionProvider>
113114
<ConversationWidthProvider>
114115
<ChatTextSizeProvider>
115-
<App />
116-
<Toaster />
117-
<PetWindowController />
116+
<HighContrastProvider>
117+
<App />
118+
<Toaster />
119+
<PetWindowController />
120+
</HighContrastProvider>
118121
</ChatTextSizeProvider>
119122
</ConversationWidthProvider>
120123
</ReduceMotionProvider>

apps/electron/src/renderer/pages/settings/AppearanceSettingsPage.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useTheme } from '@/context/ThemeContext'
1717
import { useReduceMotion } from '@/context/ReduceMotionContext'
1818
import { useConversationWidth } from '@/context/ConversationWidthContext'
1919
import { useChatTextSize, type ChatTextSize } from '@/context/ChatTextSizeContext'
20+
import { useHighContrast } from '@/context/HighContrastContext'
2021
import { useAppShellContext } from '@/context/AppShellContext'
2122
import { routes } from '@/lib/navigate'
2223
import { FolderOpen, Monitor, RefreshCw, Sun, Moon } from 'lucide-react'
@@ -149,6 +150,8 @@ export default function AppearanceSettingsPage() {
149150
const { conversationWidth, setConversationWidth } = useConversationWidth()
150151
// Chat text size (renderer-only preference, persisted in localStorage)
151152
const { chatTextSize, setChatTextSize } = useChatTextSize()
153+
// Increase contrast toggle (renderer-only preference, persisted in localStorage)
154+
const { highContrast, setHighContrast } = useHighContrast()
152155

153156
// Pet companion settings + custom pets (synced via shared Jotai atoms)
154157
const {
@@ -424,6 +427,13 @@ export default function AppearanceSettingsPage() {
424427
]}
425428
/>
426429
</SettingsRow>
430+
<SettingsToggle
431+
label={t("settings.appearance.increaseContrast")}
432+
description={t("settings.appearance.increaseContrastDesc")}
433+
checked={highContrast}
434+
onCheckedChange={setHighContrast}
435+
testId="high-contrast-toggle"
436+
/>
427437
</SettingsCard>
428438
</SettingsSection>
429439

docs/loop/feature-ledger.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ log, not the system of record.
3333

3434
| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
3535
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
36+
| high-contrast | "Increase contrast" accessibility setting in Appearance | macOS Accessibility "Increase contrast" / Windows contrast themes / VS Code High Contrast | frontend-only | in-progress | [#66](https://github.com/modelstudioai/openwork/issues/66) || loop/high-contrast | 2026-07-07 | Companion to merged reduce-motion. Renderer-only pref (`craft-high-contrast` localStorage) → `HighContrastProvider` context → `data-high-contrast` on `<html>` → CSS overrides of theme tokens (`--border`/`--input`/`--muted-foreground`/`--ring`) gated on `:root[data-high-contrast='true']` (higher specificity beats theme `:root` injection). Toggle in Appearance→Interface below Reduce motion; 2 i18n keys ×7 locales. |
3637
| chat-text-size | "Chat text size" setting (Small / Default / Large) in Appearance | Claude Desktop "Chat font" + anthropics/claude-code #50543/#48887; ChatGPT desktop font-size requests | frontend-only | pr-open | [#64](https://github.com/modelstudioai/openwork/issues/64) | [#65](https://github.com/modelstudioai/openwork/pull/65) | loop/chat-text-size | 2026-07-07 | Renderer-only pref (localStorage `craft-chat-text-size`) reflected onto `<html>` as `data-chat-text-size` + `--chat-font-scale` CSS var (0.9/1/1.15). Transcript container gets `.chat-text-scope` with `font-size: calc(1em * var(--chat-font-scale))` — em-relative ⇒ neutral at Default, scales only conversation text (chrome untouched). New `ChatTextSizeProvider`; segmented control in Appearance→Interface; `SettingsSegmentedControl` gained optional `testId`/`data-value`; 5 new i18n keys ×7 locales. typecheck/`bun test` **zero-delta vs main** (11 pre-existing tsc errors + 56 pre-existing fail-lines byte-identical); renderer build ✅; i18n parity ✅. CDP assertion authored (drives control, asserts attr/CSS-var/localStorage + probe computed font-size ratio ~1.15/0.9); **could not execute** — Electron runtime binary download is org-egress-policy 403 (same block as #51). |
3738
| conversation-width | "Conversation width" (Comfortable / Wide / Full) setting in Appearance | Claude Desktop wider-chat / ChatGPT desktop width / Codex & VS Code content width | frontend-only | pr-open | [#62](https://github.com/modelstudioai/openwork/issues/62) | [#63](https://github.com/modelstudioai/openwork/pull/63) | loop/conversation-width | 2026-07-06 | Renderer-only pref (localStorage `craft-conversation-width`). New `ConversationWidthProvider` in `main.tsx` sets `data-conversation-width` on `<html>` + drives CSS var `--chat-content-max-width` (840px/1100px/none). Transcript container (`ChatDisplay`) + composer (`ChatInputZone`) read it via `max-width: var(--chat-content-max-width, 840px)`; fallback keeps shared web viewer unchanged. Segmented control in Appearance→Interface; added `testId` to `SettingsSegmentedControl`; 5 new i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; i18n parity ✅. CDP assertion `e2e/assertions/conversation-width.assert.ts` included; **could not run locally** (egress 403 blocks Electron binary + `libsignal-node`/`eslint-config` git-tarball deps — same env blocker as #51). |
3839
| composer-word-count | Live word/character/line count indicator in the chat composer | Codex/Claude desktop composer counters + editor status bars | frontend-only | pr-open | [#60](https://github.com/modelstudioai/openwork/issues/60) | [#61](https://github.com/modelstudioai/openwork/pull/61) | loop/composer-word-count | 2026-07-06 | Pure `computeComposerCounts` module (+unit tests) → `ComposerCountIndicator` in the composer toolbar, derived from the existing `input` string; hidden when empty / in compact mode. 3 new plural-neutral i18n keys (`chat.composer{Words,Characters,Lines}`) in all 7 locales. typecheck:all zero-delta (11 pre-existing baseline errors, none in touched files); unit tests 10/10; i18n parity OK; renderer build ✅; assertion transpiles. **CDP could not run locally**: the e2e build 403s on the Electron binary download and the `libsignal` WhatsApp-worker dep (same egress block as prior loop PRs); `composer-count.assert.ts` included for CI/reviewer. |
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Feature assertion: the "Increase contrast" toggle in Settings → Appearance
3+
* actually applies and persists an app-wide high-contrast preference.
4+
*
5+
* Drives the real UI over CDP entirely in the draft/no-session state (no seeded
6+
* conversation, no backend connection): opens Settings → Appearance, flips the
7+
* toggle, and asserts the observable effects — the switch state, the
8+
* `data-high-contrast` attribute on <html>, the persisted localStorage value,
9+
* AND that the high-contrast CSS actually applies (the `--hc-enabled` custom
10+
* property computes to `1` on :root only while enabled). Toggling twice proves
11+
* it both applies and reverts, not merely renders.
12+
*/
13+
14+
import type { Assertion } from '../runner';
15+
16+
const SETTINGS_NAV = '[data-testid="nav:settings"]';
17+
const APPEARANCE_NAV = '[data-testid="settings-nav-appearance"]';
18+
const TOGGLE = '[data-testid="high-contrast-toggle"]';
19+
const STORAGE_KEY = 'craft-high-contrast';
20+
21+
/** Read the toggle's aria-checked ("true" | "false" | null). */
22+
function ariaCheckedExpr(): string {
23+
return `(() => {
24+
const el = document.querySelector(${JSON.stringify(TOGGLE)});
25+
return el ? el.getAttribute('aria-checked') : null;
26+
})()`;
27+
}
28+
29+
/** True when <html> carries the high-contrast marker attribute. */
30+
function htmlMarkedExpr(): string {
31+
return `document.documentElement.getAttribute('data-high-contrast') === 'true'`;
32+
}
33+
34+
/** True when the high-contrast CSS block actually matched (marker custom prop). */
35+
function cssAppliedExpr(): string {
36+
return `getComputedStyle(document.documentElement).getPropertyValue('--hc-enabled').trim() === '1'`;
37+
}
38+
39+
/** The persisted localStorage value for the preference. */
40+
function storedValueExpr(): string {
41+
return `window.localStorage.getItem(${JSON.stringify(STORAGE_KEY)})`;
42+
}
43+
44+
const assertion: Assertion = {
45+
name: 'increase-contrast toggle applies and persists an app-wide preference',
46+
async run(app) {
47+
const { session } = app;
48+
49+
// App fully mounted.
50+
await session.waitForFunction(
51+
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
52+
{ timeoutMs: 30000, message: 'app did not mount' },
53+
);
54+
55+
// Open Settings → Appearance (real user path).
56+
await session.click(SETTINGS_NAV, { timeoutMs: 15000 });
57+
await session.click(APPEARANCE_NAV, { timeoutMs: 15000 });
58+
59+
// The toggle is the feature under test — its presence is the first signal.
60+
await session.waitForSelector(TOGGLE, {
61+
timeoutMs: 15000,
62+
message: 'increase-contrast toggle did not render',
63+
});
64+
65+
// Initial state: off, no marker on <html>, CSS not applied, not persisted true.
66+
const initialChecked = await session.evaluate<string | null>(ariaCheckedExpr());
67+
if (initialChecked !== 'false') {
68+
throw new Error(`expected toggle off initially, saw aria-checked=${initialChecked}`);
69+
}
70+
if (await session.evaluate<boolean>(htmlMarkedExpr())) {
71+
throw new Error('expected no data-high-contrast attribute before enabling');
72+
}
73+
if (await session.evaluate<boolean>(cssAppliedExpr())) {
74+
throw new Error('expected --hc-enabled to be unset before enabling');
75+
}
76+
77+
// Enable → toggle on, <html> marked, CSS applied, persisted true.
78+
await session.click(TOGGLE);
79+
await session.waitForFunction(
80+
`${ariaCheckedExpr()} === 'true'`,
81+
{ timeoutMs: 5000, message: 'toggle did not switch on' },
82+
);
83+
await session.waitForFunction(htmlMarkedExpr(), {
84+
timeoutMs: 5000,
85+
message: 'data-high-contrast was not applied to <html> when enabled',
86+
});
87+
await session.waitForFunction(cssAppliedExpr(), {
88+
timeoutMs: 5000,
89+
message: 'high-contrast CSS did not apply (--hc-enabled !== 1) when enabled',
90+
});
91+
const storedOn = await session.evaluate<string | null>(storedValueExpr());
92+
if (storedOn !== 'true') {
93+
throw new Error(`expected persisted "true" after enabling, saw ${JSON.stringify(storedOn)}`);
94+
}
95+
96+
// Disable → toggle off, marker removed, CSS reverted, persisted false.
97+
await session.click(TOGGLE);
98+
await session.waitForFunction(
99+
`${ariaCheckedExpr()} === 'false'`,
100+
{ timeoutMs: 5000, message: 'toggle did not switch off' },
101+
);
102+
await session.waitForFunction(`!(${htmlMarkedExpr()})`, {
103+
timeoutMs: 5000,
104+
message: 'data-high-contrast was not removed from <html> when disabled',
105+
});
106+
await session.waitForFunction(`!(${cssAppliedExpr()})`, {
107+
timeoutMs: 5000,
108+
message: 'high-contrast CSS did not revert (--hc-enabled still 1) when disabled',
109+
});
110+
const storedOff = await session.evaluate<string | null>(storedValueExpr());
111+
if (storedOff !== 'false') {
112+
throw new Error(`expected persisted "false" after disabling, saw ${JSON.stringify(storedOff)}`);
113+
}
114+
},
115+
};
116+
117+
export default assertion;

packages/shared/src/i18n/locales/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,8 @@
796796
"settings.appearance.conversationWidthDesc": "Legt fest, wie breit der Chatverlauf und das Eingabefeld sind.",
797797
"settings.appearance.conversationWidthFull": "Voll",
798798
"settings.appearance.conversationWidthWide": "Breit",
799+
"settings.appearance.increaseContrast": "Kontrast erhöhen",
800+
"settings.appearance.increaseContrastDesc": "Ränder, Trennlinien und sekundären Text für bessere Lesbarkeit verstärken.",
799801
"settings.appearance.pet": "Begleiter",
800802
"settings.appearance.petCustom": "Eigene Begleiter",
801803
"settings.appearance.petCustomHint": "Füge einen Ordner mit pet.json und einem 8x9-Spritesheet hinzu und aktualisiere dann.",

packages/shared/src/i18n/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,8 @@
796796
"settings.appearance.conversationWidthDesc": "Set how wide the chat transcript and composer are.",
797797
"settings.appearance.conversationWidthFull": "Full",
798798
"settings.appearance.conversationWidthWide": "Wide",
799+
"settings.appearance.increaseContrast": "Increase contrast",
800+
"settings.appearance.increaseContrastDesc": "Strengthen borders, dividers, and secondary text for better readability.",
799801
"settings.appearance.pet": "Pet",
800802
"settings.appearance.petCustom": "Custom pets",
801803
"settings.appearance.petCustomHint": "Add a pet folder with pet.json and an 8x9 spritesheet, then refresh.",

packages/shared/src/i18n/locales/es.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,8 @@
796796
"settings.appearance.conversationWidthDesc": "Define el ancho de la transcripción del chat y del compositor.",
797797
"settings.appearance.conversationWidthFull": "Completo",
798798
"settings.appearance.conversationWidthWide": "Ancho",
799+
"settings.appearance.increaseContrast": "Aumentar contraste",
800+
"settings.appearance.increaseContrastDesc": "Refuerza los bordes, separadores y el texto secundario para mejorar la legibilidad.",
799801
"settings.appearance.pet": "Mascota",
800802
"settings.appearance.petCustom": "Mascotas personalizadas",
801803
"settings.appearance.petCustomHint": "Añade una carpeta con pet.json y un spritesheet de 8x9, luego actualiza.",

0 commit comments

Comments
 (0)