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
2 changes: 2 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type Connect, createLink } from "./features/onboarding/link";
import { Onboarding } from "./features/onboarding/Onboarding";
import type { Host } from "./lib/host";
import { useCloseBehavior } from "./shell/closing";
import { useMotionRoot } from "./shell/motionRoot";
import { TitleBar } from "./shell/TitleBar";
import { useThemeRoot } from "./shell/theme";
import { Workspace } from "./shell/Workspace";
Expand All @@ -13,6 +14,7 @@ import { Toaster } from "./ui/toast";

export function App({ host, connect }: { host: Host; connect: Connect }) {
useThemeRoot();
useMotionRoot();
useZoomRoot(host);
useCloseBehavior(host);
const [link] = useState(() => createLink(host, connect));
Expand Down
42 changes: 42 additions & 0 deletions app/src/features/account/AppearanceSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Settings, "Appearance": theme, size and motion (spec 15.3).

import { useT } from "../../i18n";
import { prefs, usePref } from "../../shell/prefs";
import { setTheme, type ThemeChoice, useTheme } from "../../shell/theme";
import { DEFAULT_ZOOM, setZoom, useZoom, ZOOM_LEVELS, type ZoomLevel } from "../../shell/zoom";
import { Choices } from "../../ui/Choices";
import { Field, Section, Toggle } from "./settingsParts";

export function AppearanceSettings() {
const t = useT();
const s = t.account.settings;
const { choice: theme } = useTheme();
const zoom = useZoom();
const lessMotion = usePref(prefs.lessMotion);
const themes: { value: ThemeChoice; label: string }[] = [
{ value: "system", label: s.themes.system },
{ value: "light", label: s.themes.light },
{ value: "dark", label: s.themes.dark },
];
const sizes = ZOOM_LEVELS.map((level) => ({
value: level,
label: t.shell.zoom.level(Math.round(level * 100), level === DEFAULT_ZOOM),
}));

return (
<Section title={s.appearance}>
<Field label={s.theme}>
<Choices label={s.theme} value={theme} options={themes} onChange={setTheme} />
</Field>
<Field label={s.size} hint={s.sizeHint}>
<Choices<ZoomLevel> label={s.size} value={zoom} options={sizes} onChange={setZoom} />
</Field>
<Toggle
label={s.lessMotion}
hint={s.lessMotionHint}
checked={lessMotion}
onChange={prefs.lessMotion.set}
/>
</Section>
);
}
40 changes: 0 additions & 40 deletions app/src/features/account/BackgroundSettings.tsx

This file was deleted.

28 changes: 28 additions & 0 deletions app/src/features/account/ChatSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Settings, "Chat": how a message is sent, and whether the panel beside
// the chat follows what the bot starts doing (spec 15.1).

import { useT } from "../../i18n";
import { prefs, usePref } from "../../shell/prefs";
import { Section, Toggle } from "./settingsParts";

export function ChatSettings() {
const s = useT().account.settings;
const enterSends = usePref(prefs.enterSends);
const followBot = usePref(prefs.followBot);
return (
<Section title={s.chat}>
<Toggle
label={s.enterSends}
hint={enterSends ? s.enterSendsOn : s.enterSendsOff}
checked={enterSends}
onChange={prefs.enterSends.set}
/>
<Toggle
label={s.followBot}
hint={followBot ? s.followBotOn : s.followBotOff}
checked={followBot}
onChange={prefs.followBot.set}
/>
</Section>
);
}
59 changes: 59 additions & 0 deletions app/src/features/account/GeneralSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Settings, "General": the language, and what Botloft does in the
// background (spec 15.1).

import {
LOCALES,
type LocaleChoice,
setLocaleChoice,
systemLocale,
useLocale,
useT,
} from "../../i18n";
import { prefs, usePref } from "../../shell/prefs";
import { Select } from "../../ui/Select";
import { Section, Toggle } from "./settingsParts";
import { useDaemonSettings } from "./useDaemonSettings";

export function GeneralSettings() {
const t = useT();
const s = t.account.settings;
const whenClosed = usePref(prefs.whenClosed);
const { settings, change } = useDaemonSettings();
const startWithWindows = settings?.startWithWindows ?? true;
const { choice: locale } = useLocale();
const systemName = LOCALES.find((entry) => entry.id === systemLocale())?.name ?? "English";
const languages: { value: LocaleChoice; label: string }[] = [
{ value: "system", label: t.shell.language.system(systemName) },
...LOCALES.map((entry) => ({ value: entry.id as LocaleChoice, label: entry.name })),
];

return (
<>
<Section title={s.language}>
<Select label={s.language} value={locale} options={languages} onChange={setLocaleChoice} />
</Section>
<Section title={s.background}>
<Toggle
label={s.keepWorking}
hint={whenClosed === "keep" ? s.keepWorkingOn : s.keepWorkingOff}
checked={whenClosed === "keep"}
onChange={(on) => prefs.whenClosed.set(on ? "keep" : "stop")}
/>
<Toggle
label={s.startWithWindows}
hint={startWithWindows ? s.startWithWindowsOn : s.startWithWindowsOff}
checked={startWithWindows}
disabled={!settings}
onChange={(on) => change({ startWithWindows: on })}
/>
<Toggle
label={s.keepAwake}
hint={s.keepAwakeHint}
checked={settings?.keepAwake ?? true}
disabled={!settings}
onChange={(on) => change({ keepAwake: on })}
/>
</Section>
</>
);
}
115 changes: 60 additions & 55 deletions app/src/features/account/SettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,66 +1,71 @@
// Settings (spec 15.1): whether Botloft works in the background, the
// theme, size and language, which used to sit in the title bar, and the
// versions for when something needs reporting.
// Settings (spec 15.1), in parts listed on the left: general (what
// Botloft does in the background, the language), the chat, the look, and
// the versions for when something needs reporting.

import {
LOCALES,
type LocaleChoice,
setLocaleChoice,
systemLocale,
useLocale,
useT,
} from "../../i18n";
import { setTheme, type ThemeChoice, useTheme } from "../../shell/theme";
import { DEFAULT_ZOOM, setZoom, useZoom, ZOOM_LEVELS, type ZoomLevel } from "../../shell/zoom";
import { Choices } from "../../ui/Choices";
import { Info, type LucideIcon, MessageSquare, Palette, SlidersHorizontal } from "lucide-react";
import { useId, useState } from "react";
import { useT } from "../../i18n";
import { Dialog } from "../../ui/Dialog";
import { AboutSettings } from "./AboutSettings";
import { BackgroundSettings } from "./BackgroundSettings";
import { Field, Section } from "./settingsParts";
import { AppearanceSettings } from "./AppearanceSettings";
import { ChatSettings } from "./ChatSettings";
import { GeneralSettings } from "./GeneralSettings";

export function SettingsDialog({ onClose }: { onClose(): void }) {
const t = useT();
const s = t.account.settings;
const { choice: theme } = useTheme();
const zoom = useZoom();
const { choice: locale } = useLocale();
const systemName = LOCALES.find((entry) => entry.id === systemLocale())?.name ?? "English";
type Page = "general" | "chat" | "appearance" | "about";

const PAGES: { id: Page; icon: LucideIcon }[] = [
{ id: "general", icon: SlidersHorizontal },
{ id: "chat", icon: MessageSquare },
{ id: "appearance", icon: Palette },
{ id: "about", icon: Info },
];

const themes: { value: ThemeChoice; label: string }[] = [
{ value: "system", label: s.themes.system },
{ value: "light", label: s.themes.light },
{ value: "dark", label: s.themes.dark },
];
const sizes = ZOOM_LEVELS.map((level) => ({
value: level,
label: t.shell.zoom.level(Math.round(level * 100), level === DEFAULT_ZOOM),
}));
const languages: { value: LocaleChoice; label: string }[] = [
{ value: "system", label: t.shell.language.system(systemName) },
...LOCALES.map((entry) => ({ value: entry.id as LocaleChoice, label: entry.name })),
];
export function SettingsDialog({ onClose }: { onClose(): void }) {
const s = useT().account.settings;
const [page, setPage] = useState<Page>("general");
const id = useId();

return (
<Dialog title={s.title} onClose={onClose} width="lg">
<div className="flex flex-col gap-6">
<BackgroundSettings />
<Section title={s.appearance}>
<Field label={s.theme}>
<Choices label={s.theme} value={theme} options={themes} onChange={setTheme} />
</Field>
<Field label={s.size} hint={s.sizeHint}>
<Choices<ZoomLevel> label={s.size} value={zoom} options={sizes} onChange={setZoom} />
</Field>
</Section>
<Section title={s.language}>
<Choices
label={s.language}
value={locale}
options={languages}
onChange={setLocaleChoice}
/>
</Section>
<AboutSettings />
<div className="flex min-h-[27rem] gap-5">
<div
role="tablist"
aria-label={s.pages}
aria-orientation="vertical"
className="flex w-40 shrink-0 flex-col gap-0.5"
>
{PAGES.map(({ id: each, icon: Icon }) => {
const selected = each === page;
return (
<button
key={each}
id={`${id}-${each}`}
type="button"
role="tab"
aria-selected={selected}
aria-controls={`${id}-panel`}
onClick={() => setPage(each)}
className={`flex h-8 items-center gap-2 rounded-lg px-2.5 text-left font-medium text-sm transition-colors ${
selected ? "bg-sunken text-ink" : "text-ink-soft hover:bg-sunken hover:text-ink"
}`}
>
<Icon aria-hidden size={15} className="shrink-0" />
{s[each]}
</button>
);
})}
</div>
<div
id={`${id}-panel`}
role="tabpanel"
aria-labelledby={`${id}-${page}`}
className="flex min-w-0 flex-1 flex-col gap-6"
>
{page === "general" && <GeneralSettings />}
{page === "chat" && <ChatSettings />}
{page === "appearance" && <AppearanceSettings />}
{page === "about" && <AboutSettings />}
</div>
</div>
</Dialog>
);
Expand Down
11 changes: 9 additions & 2 deletions app/src/features/account/account.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,18 @@ describe("account area", () => {
await openAccount();
fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
const dialog = screen.getByRole("dialog", { name: "Settings" });
fireEvent.click(within(dialog).getByRole("tab", { name: "Appearance" }));
fireEvent.click(within(dialog).getByRole("radio", { name: "Dark" }));
expect(document.documentElement.dataset.theme).toBe("dark");
fireEvent.click(within(dialog).getByRole("radio", { name: "Español" }));
fireEvent.click(within(dialog).getByRole("tab", { name: "General" }));
fireEvent.click(
within(dialog).getByRole("button", { name: "Language: System language: English" }),
);
fireEvent.click(within(dialog).getByRole("option", { name: "Español" }));
expect(screen.getByRole("dialog", { name: "Configuración" })).toBeDefined();
fireEvent.click(screen.getByRole("radio", { name: /Idioma del sistema/ }));
fireEvent.click(screen.getByRole("button", { name: "Idioma: Español" }));
fireEvent.click(screen.getByRole("option", { name: /Idioma del sistema/ }));
fireEvent.click(screen.getByRole("tab", { name: "About" }));
expect(screen.getByRole("dialog", { name: "Settings" }).textContent).toContain("Botloft 0.1.0");
});

Expand Down
Loading
Loading