Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,41 @@ export async function verifyWorkspaceLocales({ browser, url, installApi, outputD
await page.getByTestId("personal-goal-home").waitFor();
assert.equal(await page.locator("html").getAttribute("lang"), "en");
assert.equal(await page.evaluate(() => localStorage.getItem("loopx-pw-locale")), "en");
await page.getByRole("button", {name: "Settings", exact: true}).click();
await page.getByRole("button", {name: /Appearance/}).click();
const themes = page.locator('.personal-settings-choice-group');
const themeOptions = themes.getByRole('radio');
await page.screenshot({path: resolve(outputDir, 'settings-choice-desktop.png'), animations: 'disabled'});
assert.equal(await themes.locator('[role="radio"][tabindex="0"]').count(), 1, 'Only the selected theme is a Tab stop');
await themeOptions.nth(0).focus(); await page.keyboard.press('ArrowRight');
assert.equal(await themeOptions.nth(1).getAttribute('aria-checked'), 'true');
assert(await themeOptions.nth(1).evaluate(element => element === document.activeElement));
await page.keyboard.press('End');
assert.equal(await themeOptions.nth(2).getAttribute('aria-checked'), 'true');
await page.keyboard.press('ArrowRight');
assert.equal(await themeOptions.nth(0).getAttribute('aria-checked'), 'true', 'Arrow navigation wraps');
await page.keyboard.press('ArrowLeft');
assert.equal(await themeOptions.nth(2).getAttribute('aria-checked'), 'true');
await page.keyboard.press('Home');
assert.equal(await themeOptions.nth(0).getAttribute('aria-checked'), 'true');
await page.keyboard.press('Tab');
assert(!await themes.evaluate(element => element.contains(document.activeElement)), 'Tab leaves the group');
await page.setViewportSize({width: 960, height: 640});
await themeOptions.nth(0).focus();
assert(await themeOptions.nth(0).evaluate(element => getComputedStyle(element).outlineStyle !== 'none'));
await page.screenshot({path: resolve(outputDir, 'settings-choice-minimum.png'), animations: 'disabled'});
await page.getByRole('button', {name: /Language/}).click();
const languages = page.locator('.personal-language-options');
const english = languages.getByRole('radio', {name: /English/});
await english.focus(); await page.keyboard.press('ArrowDown');
assert.equal(await page.evaluate(() => localStorage.getItem('loopx-pw-locale')), 'zh-CN');
assert.equal(await languages.locator('[role="radio"][tabindex="0"]').count(), 1);
assert(await languages.locator('[aria-checked="true"]').evaluate(element => element === document.activeElement));
await page.keyboard.press('ArrowUp');
assert.equal(await page.evaluate(() => localStorage.getItem('loopx-pw-locale')), 'en');
await page.reload({waitUntil: 'networkidle'});
assert.equal(await english.getAttribute('aria-checked'), 'true', 'Keyboard selection survives reload');
assert.equal(api.turnRequests.length, 0, 'Preferences never start an Agent');
console.log("workspace locale: Settings choices survive reload in both directions: ok");
} finally {
await page.close();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {ContextHelp} from "./context-help";
import {GoalAcceptanceContractSection} from "./goal-acceptance-contract";
import {GoalObjectiveSettings} from "./goal-objective-settings";
import {goalAcceptanceContractCopy} from "./delivery-review-copy";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import type {SettingsTarget} from "./workspace-settings-route";
import { ArrowLeft, Bot, Check, Clock3, KeyRound, Languages, Palette, ServerCog, Settings2 } from "lucide-react";

Expand All @@ -24,6 +24,21 @@ import type { PersonalWorkspaceCallbacks, WorkspaceGoal, WorkspaceGoalNotificati
import type { WorkspaceTheme } from "./workspace-theme";
import type { AttentionNotificationSupport } from "./attention-notifications";

// Preference cards share a single Tab stop and radio-group keyboard behavior.
function movePreferenceSelection(event: KeyboardEvent<HTMLDivElement>) {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const options = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('button[role="radio"]:not(:disabled)')];
const index = options.indexOf(event.target as HTMLButtonElement);
if (index < 0) return;
const next = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1
: event.key === "ArrowRight" || event.key === "ArrowDown" ? (index + 1) % options.length
: event.key === "ArrowLeft" || event.key === "ArrowUp" ? (index + options.length - 1) % options.length : -1;
if (next < 0) return;
event.preventDefault();
options[next].focus();
options[next].click();
}

const attentionNotificationSupportCopy = {
default: "settings.attentionNotificationsDefault",
denied: "settings.attentionNotificationsDenied",
Expand Down Expand Up @@ -274,16 +289,16 @@ export function WorkspaceSettingsPage({

{tab === "appearance" ? (
<section className="personal-detail-card personal-appearance-settings">
<div className="personal-settings-choice-group" role="radiogroup" aria-label={t("settings.workspaceTheme")}>
<button aria-checked={theme === "loopx"} onClick={() => onThemeChange("loopx")} role="radio" type="button">
<div className="personal-settings-choice-group" onKeyDown={movePreferenceSelection} role="radiogroup" aria-label={t("settings.workspaceTheme")}>
<button aria-checked={theme === "loopx"} tabIndex={theme === "loopx" ? 0 : -1} onClick={() => onThemeChange("loopx")} role="radio" type="button">
<span className="personal-settings-theme-swatch is-loopx" />
<strong>{t("settings.themeLoopx")}</strong>
</button>
<button aria-checked={theme === "paper"} onClick={() => onThemeChange("paper")} role="radio" type="button">
<button aria-checked={theme === "paper"} tabIndex={theme === "paper" ? 0 : -1} onClick={() => onThemeChange("paper")} role="radio" type="button">
<span className="personal-settings-theme-swatch is-paper" />
<strong>{t("settings.themeDefault")}</strong>
</button>
<button aria-checked={theme === "brutal"} onClick={() => onThemeChange("brutal")} role="radio" type="button">
<button aria-checked={theme === "brutal"} tabIndex={theme === "brutal" ? 0 : -1} onClick={() => onThemeChange("brutal")} role="radio" type="button">
<span className="personal-settings-theme-swatch is-brutal" />
<strong>{t("settings.themeHighContrast")}</strong>
</button>
Expand Down Expand Up @@ -311,10 +326,11 @@ export function WorkspaceSettingsPage({

{tab === "language" ? (
<section className="personal-settings-card">
<div aria-label={t("settings.language")} className="personal-language-options" role="radiogroup">
<div aria-label={t("settings.language")} className="personal-language-options" onKeyDown={movePreferenceSelection} role="radiogroup">
{localeOptions.map((option) => (
<button
aria-checked={locale === option.value}
tabIndex={locale === option.value ? 0 : -1}
className={locale === option.value ? "is-selected" : ""}
key={option.value}
onClick={() => setLocale(option.value)}
Expand Down
Loading