Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions apps/electron/src/renderer/context/HighContrastContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* HighContrastContext
*
* App-wide "Increase contrast" accessibility preference — the natural companion
* to `ReduceMotionContext`, living beside it in Settings → Appearance → Interface.
*
* When enabled it sets `data-high-contrast="true"` on `<html>`. The global CSS
* block in `index.css` gated on `:root[data-high-contrast='true']` then raises
* the contrast of the theme tokens — strengthening `--border`, `--input`,
* `--muted-foreground`, `--foreground-dimmed` and the focus `--ring`, and adding
* a visible focus outline. Because every theme derives those tokens from
* `--foreground` (which flips per light/dark), the alpha-based overrides work in
* both light and dark and across preset themes. The theme system injects its
* colors as a `:root { … }` stylesheet rule, so the higher-specificity
* `:root[data-high-contrast='true']` selector wins without `!important`.
*
* The preference is persisted in `localStorage` (renderer-only, no backend),
* mirroring the other lightweight UI prefs in `lib/local-storage.ts`.
*/

import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from 'react'
import * as storage from '@/lib/local-storage'

interface HighContrastContextType {
highContrast: boolean
setHighContrast: (value: boolean) => void
}

const HighContrastContext = createContext<HighContrastContextType | null>(null)

const HIGH_CONTRAST_ATTR = 'data-high-contrast'

/** Reflect the preference onto <html> so the global CSS overrides can react. */
function applyHighContrastAttribute(enabled: boolean): void {
const root = document.documentElement
if (enabled) {
root.setAttribute(HIGH_CONTRAST_ATTR, 'true')
} else {
root.removeAttribute(HIGH_CONTRAST_ATTR)
}
}

export function HighContrastProvider({ children }: { children: ReactNode }) {
const [highContrast, setHighContrastState] = useState<boolean>(() =>
storage.get<boolean>(storage.KEYS.highContrast, false),
)

// Keep the DOM attribute in sync (also covers the initial value on mount).
useEffect(() => {
applyHighContrastAttribute(highContrast)
}, [highContrast])

const setHighContrast = useCallback((value: boolean) => {
setHighContrastState(value)
storage.set(storage.KEYS.highContrast, value)
applyHighContrastAttribute(value)
}, [])

return (
<HighContrastContext.Provider value={{ highContrast, setHighContrast }}>
{children}
</HighContrastContext.Provider>
)
}

export function useHighContrast(): HighContrastContextType {
const ctx = useContext(HighContrastContext)
if (!ctx) {
throw new Error('useHighContrast must be used within a HighContrastProvider')
}
return ctx
}
32 changes: 32 additions & 0 deletions apps/electron/src/renderer/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1386,3 +1386,35 @@ html.dark[data-scenic] .fullscreen-overlay-background {
transition-delay: 0ms !important;
scroll-behavior: auto !important;
}

/* Increase contrast: when the user enables "Increase contrast" in Appearance
settings, raise the contrast of the low-alpha theme tokens app-wide. Every
theme derives these from `--foreground` (which flips per light/dark), so the
alpha-based overrides work in both modes and across preset themes. The theme
system injects its colors as a plain `:root { … }` rule, so this
higher-specificity `:root[data-high-contrast='true']` selector wins without
`!important`. `--hc-enabled` is a marker the e2e assertion reads to confirm
the block actually applied. */
:root[data-high-contrast='true'] {
--hc-enabled: 1;

/* Stronger borders, dividers and input outlines. */
--border: oklch(from var(--foreground) l c h / 0.28);
--input: oklch(from var(--foreground) l c h / 0.38);

/* De-dim secondary/"muted" and unfocused text so it stays readable. */
--muted-foreground: var(--foreground-80);
--foreground-dimmed: var(--foreground-90);
--md-bullets: var(--foreground-80);
--md-counters: var(--foreground-80);

/* More visible focus ring. */
--ring: oklch(from var(--foreground) l c h / 0.6);
--ring-width: 2px;
}

/* A clearly visible keyboard-focus outline for interactive elements. */
:root[data-high-contrast='true'] :focus-visible {
outline: 2px solid var(--ring);
outline-offset: 1px;
}
1 change: 1 addition & 0 deletions apps/electron/src/renderer/lib/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const KEYS = {
// Appearance
showConnectionIcons: 'show-connection-icons',
reduceMotion: 'reduce-motion', // Minimize animations/transitions app-wide
highContrast: 'high-contrast', // Raise contrast of borders, dividers, and muted text app-wide

// What's New
whatsNewLastSeenVersion: 'whats-new-last-seen-version',
Expand Down
9 changes: 6 additions & 3 deletions apps/electron/src/renderer/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Provider as JotaiProvider, useAtomValue } from 'jotai'
import App from './App'
import { ThemeProvider } from './context/ThemeContext'
import { ReduceMotionProvider } from './context/ReduceMotionContext'
import { HighContrastProvider } from './context/HighContrastContext'
import { windowWorkspaceIdAtom } from './atoms/sessions'
import { Toaster } from '@/components/ui/sonner'
import { PetWindowController } from '@/components/pet/PetWindowController'
Expand Down Expand Up @@ -108,9 +109,11 @@ function Root() {
return (
<ThemeProvider activeWorkspaceId={workspaceId}>
<ReduceMotionProvider>
<App />
<Toaster />
<PetWindowController />
<HighContrastProvider>
<App />
<Toaster />
<PetWindowController />
</HighContrastProvider>
</ReduceMotionProvider>
</ThemeProvider>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { HeaderMenu } from '@/components/ui/HeaderMenu'
import { EditPopover, EditButton, getEditConfig } from '@/components/ui/EditPopover'
import { useTheme } from '@/context/ThemeContext'
import { useReduceMotion } from '@/context/ReduceMotionContext'
import { useHighContrast } from '@/context/HighContrastContext'
import { useAppShellContext } from '@/context/AppShellContext'
import { routes } from '@/lib/navigate'
import { FolderOpen, Monitor, RefreshCw, Sun, Moon } from 'lucide-react'
Expand Down Expand Up @@ -143,6 +144,9 @@ export default function AppearanceSettingsPage() {
// Reduce motion toggle (renderer-only preference, persisted in localStorage)
const { reduceMotion, setReduceMotion } = useReduceMotion()

// Increase contrast toggle (renderer-only preference, persisted in localStorage)
const { highContrast, setHighContrast } = useHighContrast()

// Pet companion settings + custom pets (synced via shared Jotai atoms)
const {
pets,
Expand Down Expand Up @@ -387,6 +391,13 @@ export default function AppearanceSettingsPage() {
onCheckedChange={setReduceMotion}
testId="reduce-motion-toggle"
/>
<SettingsToggle
label={t("settings.appearance.increaseContrast")}
description={t("settings.appearance.increaseContrastDesc")}
checked={highContrast}
onCheckedChange={setHighContrast}
testId="high-contrast-toggle"
/>
</SettingsCard>
</SettingsSection>

Expand Down
3 changes: 2 additions & 1 deletion docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows reduce-motion + `prefers-reduced-motion` | frontend-only | pr-open | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-03 | Renderer-only pref (localStorage) applied app-wide via `<MotionConfig reducedMotion>` + `data-reduce-motion` on `<html>` + global CSS guard. Off ⇒ `reducedMotion="user"` (still honors OS). New `ReduceMotionProvider` in `main.tsx`; toggle in Appearance→Interface; 2 new i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; i18n parity ✅. CDP assertion included; **could not run locally** (egress 403s Electron binary download). |
| high-contrast | "Increase contrast" accessibility setting in Appearance | macOS Accessibility "Increase contrast" / Windows contrast themes / VS Code High Contrast | frontend-only | pr-open | [#66](https://github.com/modelstudioai/openwork/issues/66) | [#67](https://github.com/modelstudioai/openwork/pull/67) | 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; works light+dark since tokens derive from `--foreground`). Toggle in Appearance→Interface below Reduce motion; 2 i18n keys ×7 locales. typecheck:all/`bun test` zero-delta vs main (11 electron / 56 test pre-existing failures byte-identical); i18n parity OK; renderer build ✅ (verified rule in bundled CSS). CDP assertion written (`high-contrast.assert.ts`); **not run locally** — egress 403 blocks Electron binary download (same env block as #51). |
| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows reduce-motion + `prefers-reduced-motion` | frontend-only | merged | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-07 | **Merged** into `main` (2026-07-06). Renderer-only pref (localStorage) applied app-wide via `<MotionConfig reducedMotion>` + `data-reduce-motion` on `<html>` + global CSS guard. Off ⇒ `reducedMotion="user"` (still honors OS). |
| composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude/ChatGPT/Codex desktop composer maximize | frontend-only | pr-open | [#48](https://github.com/modelstudioai/openwork/issues/48) | [#49](https://github.com/modelstudioai/openwork/pull/49) | loop/composer-expand | 2026-07-03 | Opened by a prior run. Adds `isComposerExpanded` toggle in `FreeFormInput`; 2 new i18n keys. Awaiting review. |
| scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the chat transcript | Claude Code / ChatGPT / Codex desktop | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-02 | Opened by a prior run. Floating jump button in `ChatDisplay` + `seed()` harness hook. Awaiting review. |
| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-03 | **Merged** into `main` (2026-07-02). `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). |
Expand Down
117 changes: 117 additions & 0 deletions e2e/assertions/high-contrast.assert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Feature assertion: the "Increase contrast" toggle in Settings → Appearance
* actually applies and persists an app-wide high-contrast preference.
*
* Drives the real UI over CDP entirely in the draft/no-session state (no seeded
* conversation, no backend connection): opens Settings → Appearance, flips the
* toggle, and asserts the observable effects — the switch state, the
* `data-high-contrast` attribute on <html>, the persisted localStorage value,
* AND that the high-contrast CSS actually applies (the `--hc-enabled` custom
* property computes to `1` on :root only while enabled). Toggling twice proves
* it both applies and reverts, not merely renders.
*/

import type { Assertion } from '../runner';

const SETTINGS_NAV = '[data-testid="nav:settings"]';
const APPEARANCE_NAV = '[data-testid="settings-nav-appearance"]';
const TOGGLE = '[data-testid="high-contrast-toggle"]';
const STORAGE_KEY = 'craft-high-contrast';

/** Read the toggle's aria-checked ("true" | "false" | null). */
function ariaCheckedExpr(): string {
return `(() => {
const el = document.querySelector(${JSON.stringify(TOGGLE)});
return el ? el.getAttribute('aria-checked') : null;
})()`;
}

/** True when <html> carries the high-contrast marker attribute. */
function htmlMarkedExpr(): string {
return `document.documentElement.getAttribute('data-high-contrast') === 'true'`;
}

/** True when the high-contrast CSS block actually matched (marker custom prop). */
function cssAppliedExpr(): string {
return `getComputedStyle(document.documentElement).getPropertyValue('--hc-enabled').trim() === '1'`;
}

/** The persisted localStorage value for the preference. */
function storedValueExpr(): string {
return `window.localStorage.getItem(${JSON.stringify(STORAGE_KEY)})`;
}

const assertion: Assertion = {
name: 'increase-contrast toggle applies and persists an app-wide preference',
async run(app) {
const { session } = app;

// App fully mounted.
await session.waitForFunction(
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
{ timeoutMs: 30000, message: 'app did not mount' },
);

// Open Settings → Appearance (real user path).
await session.click(SETTINGS_NAV, { timeoutMs: 15000 });
await session.click(APPEARANCE_NAV, { timeoutMs: 15000 });

// The toggle is the feature under test — its presence is the first signal.
await session.waitForSelector(TOGGLE, {
timeoutMs: 15000,
message: 'increase-contrast toggle did not render',
});

// Initial state: off, no marker on <html>, CSS not applied, not persisted true.
const initialChecked = await session.evaluate<string | null>(ariaCheckedExpr());
if (initialChecked !== 'false') {
throw new Error(`expected toggle off initially, saw aria-checked=${initialChecked}`);
}
if (await session.evaluate<boolean>(htmlMarkedExpr())) {
throw new Error('expected no data-high-contrast attribute before enabling');
}
if (await session.evaluate<boolean>(cssAppliedExpr())) {
throw new Error('expected --hc-enabled to be unset before enabling');
}

// Enable → toggle on, <html> marked, CSS applied, persisted true.
await session.click(TOGGLE);
await session.waitForFunction(
`${ariaCheckedExpr()} === 'true'`,
{ timeoutMs: 5000, message: 'toggle did not switch on' },
);
await session.waitForFunction(htmlMarkedExpr(), {
timeoutMs: 5000,
message: 'data-high-contrast was not applied to <html> when enabled',
});
await session.waitForFunction(cssAppliedExpr(), {
timeoutMs: 5000,
message: 'high-contrast CSS did not apply (--hc-enabled !== 1) when enabled',
});
const storedOn = await session.evaluate<string | null>(storedValueExpr());
if (storedOn !== 'true') {
throw new Error(`expected persisted "true" after enabling, saw ${JSON.stringify(storedOn)}`);
}

// Disable → toggle off, marker removed, CSS reverted, persisted false.
await session.click(TOGGLE);
await session.waitForFunction(
`${ariaCheckedExpr()} === 'false'`,
{ timeoutMs: 5000, message: 'toggle did not switch off' },
);
await session.waitForFunction(`!(${htmlMarkedExpr()})`, {
timeoutMs: 5000,
message: 'data-high-contrast was not removed from <html> when disabled',
});
await session.waitForFunction(`!(${cssAppliedExpr()})`, {
timeoutMs: 5000,
message: 'high-contrast CSS did not revert (--hc-enabled still 1) when disabled',
});
const storedOff = await session.evaluate<string | null>(storedValueExpr());
if (storedOff !== 'false') {
throw new Error(`expected persisted "false" after disabling, saw ${JSON.stringify(storedOff)}`);
}
},
};

export default assertion;
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,8 @@
"settings.appearance.richToolDescriptions": "Ausführliche Werkzeugbeschreibungen",
"settings.appearance.reduceMotion": "Bewegung reduzieren",
"settings.appearance.reduceMotionDesc": "Animationen und Übergänge in der gesamten App minimieren.",
"settings.appearance.increaseContrast": "Kontrast erhöhen",
"settings.appearance.increaseContrastDesc": "Ränder, Trennlinien und sekundären Text für bessere Lesbarkeit verstärken.",
"settings.appearance.pet": "Begleiter",
"settings.appearance.petDesc": "Ein Begleiter, der auf die Aktivität des Agents reagiert.",
"settings.appearance.petEnabled": "Begleiter anzeigen",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,8 @@
"settings.appearance.richToolDescriptions": "Rich tool descriptions",
"settings.appearance.reduceMotion": "Reduce motion",
"settings.appearance.reduceMotionDesc": "Minimize animations and transitions throughout the app.",
"settings.appearance.increaseContrast": "Increase contrast",
"settings.appearance.increaseContrastDesc": "Strengthen borders, dividers, and secondary text for better readability.",
"settings.appearance.pet": "Pet",
"settings.appearance.petDesc": "A companion that reacts to what the agent is doing.",
"settings.appearance.petEnabled": "Show pet companion",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,8 @@
"settings.appearance.richToolDescriptions": "Descripciones detalladas de herramientas",
"settings.appearance.reduceMotion": "Reducir movimiento",
"settings.appearance.reduceMotionDesc": "Minimiza las animaciones y transiciones en toda la aplicación.",
"settings.appearance.increaseContrast": "Aumentar contraste",
"settings.appearance.increaseContrastDesc": "Refuerza los bordes, separadores y el texto secundario para mejorar la legibilidad.",
"settings.appearance.pet": "Mascota",
"settings.appearance.petDesc": "Un compañero que reacciona a lo que hace el agente.",
"settings.appearance.petEnabled": "Mostrar mascota",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,8 @@
"settings.appearance.richToolDescriptions": "Részletes eszközleírások",
"settings.appearance.reduceMotion": "Mozgás csökkentése",
"settings.appearance.reduceMotionDesc": "Az animációk és átmenetek minimalizálása az egész alkalmazásban.",
"settings.appearance.increaseContrast": "Kontraszt növelése",
"settings.appearance.increaseContrastDesc": "A szegélyek, elválasztók és másodlagos szöveg erősítése a jobb olvashatóságért.",
"settings.appearance.pet": "Kabala",
"settings.appearance.petDesc": "Egy társ, aki reagál arra, amit az ügynök csinál.",
"settings.appearance.petEnabled": "Kabala megjelenítése",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,8 @@
"settings.appearance.richToolDescriptions": "リッチなツール説明",
"settings.appearance.reduceMotion": "モーションを減らす",
"settings.appearance.reduceMotionDesc": "アプリ全体のアニメーションとトランジションを最小限にします。",
"settings.appearance.increaseContrast": "コントラストを上げる",
"settings.appearance.increaseContrastDesc": "境界線・区切り線・補助テキストを強調して読みやすくします。",
"settings.appearance.pet": "ペット",
"settings.appearance.petDesc": "エージェントの動きに反応するコンパニオン。",
"settings.appearance.petEnabled": "ペットを表示",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/i18n/locales/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,8 @@
"settings.appearance.richToolDescriptions": "Rozbudowane opisy narzędzi",
"settings.appearance.reduceMotion": "Ogranicz ruch",
"settings.appearance.reduceMotionDesc": "Ogranicz animacje i przejścia w całej aplikacji.",
"settings.appearance.increaseContrast": "Zwiększ kontrast",
"settings.appearance.increaseContrastDesc": "Wzmocnij obramowania, separatory i tekst pomocniczy dla lepszej czytelności.",
"settings.appearance.pet": "Maskotka",
"settings.appearance.petDesc": "Towarzysz reagujący na to, co robi agent.",
"settings.appearance.petEnabled": "Pokaż maskotkę",
Expand Down
Loading