From 15c895e18067307a507073abe3603970689e4d8c Mon Sep 17 00:00:00 2001 From: m5r Date: Wed, 12 Aug 2026 22:40:50 +0200 Subject: [PATCH] milestone 23 s3: convert features/settings/SettingsPage.tsx to stylex --- .../features/settings/SettingsPage.test.tsx | 6 +- web/src/features/settings/SettingsPage.tsx | 202 +++++++++++++----- 2 files changed, 159 insertions(+), 49 deletions(-) diff --git a/web/src/features/settings/SettingsPage.test.tsx b/web/src/features/settings/SettingsPage.test.tsx index da56685..3ac40df 100644 --- a/web/src/features/settings/SettingsPage.test.tsx +++ b/web/src/features/settings/SettingsPage.test.tsx @@ -130,7 +130,11 @@ test("a changed field enables Save and the PUT body is exactly the diff", async test("enum and boolean fields diff as their own types", async () => { await renderPage(); const logging = screen.getByRole("group", { name: "Logging" }); - fireEvent.change(within(logging).getByLabelText("level"), { target: { value: "debug" } }); + // A RAC Select names its trigger with the current value and then the label, and + // carries the options only while the listbox is open. + fireEvent.click(within(logging).getByRole("button", { name: /level$/ })); + fireEvent.click(await screen.findByRole("option", { name: "debug" })); + await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()); fireEvent.click(within(logging).getByLabelText("hide_domains")); fireEvent.click(saveButton()); await waitFor(() => expect(putBodies).toHaveLength(1)); diff --git a/web/src/features/settings/SettingsPage.tsx b/web/src/features/settings/SettingsPage.tsx index ee02e0e..bac8627 100644 --- a/web/src/features/settings/SettingsPage.tsx +++ b/web/src/features/settings/SettingsPage.tsx @@ -1,12 +1,15 @@ import { useState, type FormEvent } from "react"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import * as stylex from "@stylexjs/stylex"; import InlineError from "@/lib/InlineError"; import { settingsPutMutation, settingsQuery } from "@/lib/queries"; import { buildSettingsPatch } from "@/lib/settingsDiff"; import type { Settings, SettingsPatch } from "@/lib/types"; import { raiseRestartBanner } from "./restartBanner"; import { READ_ONLY_HINT, useReadOnlyConfig } from "./authority"; -import { focusRing } from "@/ui/classes"; +import Select from "@/ui/Select"; +import { styles as shared } from "@/ui/styles"; +import { colors } from "@/ui/tokens.stylex"; /** True when the patch touches anything besides the write-only `web.password` (ruling 11). */ export function patchRequiresRestart(patch: SettingsPatch): boolean { @@ -139,8 +142,121 @@ const SECTIONS: readonly AnySectionDef[] = [ }), ]; -const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300"; -const fieldInputClass = `rounded border border-zinc-300 bg-white px-2 py-1 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`; +const DARK = "@media (prefers-color-scheme: dark)"; + +const styles = stylex.create({ + heading: { + fontSize: "1.5rem", + lineHeight: "2rem", + fontWeight: 600, + }, + intro: { + marginTop: "0.25rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: colors.textMuted, + }, + form: { + marginTop: "1rem", + maxWidth: "48rem", + }, + /** A `fieldset` has a browser default border and padding; the layout wants neither. */ + sections: { + display: "flex", + flexDirection: "column", + gap: "1.5rem", + borderStyle: "none", + margin: 0, + padding: 0, + }, + section: { + borderRadius: "0.25rem", + borderWidth: 1, + borderStyle: "solid", + borderColor: colors.border, + padding: "1rem", + }, + legend: { + paddingInline: "0.25rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + fontWeight: 600, + }, + /** One column on a phone, two from `sm`, as before. */ + fieldGrid: { + display: "grid", + gap: "0.75rem", + gridTemplateColumns: { + default: "repeat(1, minmax(0, 1fr))", + "@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))", + }, + }, + label: { + fontSize: "0.875rem", + lineHeight: "1.25rem", + color: { + default: "oklch(37% 0.013 285.805)", + [DARK]: "oklch(87.1% 0.006 286.286)", + }, + }, + checkboxRow: { + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + field: { + display: "flex", + flexDirection: "column", + gap: "0.25rem", + }, + fieldInput: { + borderRadius: "0.25rem", + borderWidth: 1, + borderStyle: "solid", + borderColor: colors.borderStrong, + backgroundColor: colors.surfaceRaised, + color: colors.text, + paddingInline: "0.5rem", + paddingBlock: "0.25rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + }, + derived: { + color: colors.textMuted, + }, + /** Both notices span the whole grid so the wrapped sentence stays readable. */ + spanRow: { + gridColumn: { default: null, "@media (min-width: 640px)": "span 2 / span 2" }, + fontSize: "0.875rem", + lineHeight: "1.25rem", + }, + passwordNotice: { + color: { default: "oklch(55.5% 0.163 48.998)", [DARK]: "oklch(82.8% 0.189 84.429)" }, + }, + mismatchNotice: { + color: colors.danger, + }, + submitRow: { + display: "flex", + alignItems: "center", + gap: "0.75rem", + }, + save: { + borderStyle: "none", + borderRadius: "0.25rem", + paddingInline: "1rem", + paddingBlock: "0.375rem", + fontSize: "0.875rem", + lineHeight: "1.25rem", + fontWeight: 500, + backgroundColor: { + default: colors.primary, + ":disabled": "oklch(87.1% 0.006 286.286)", + [DARK]: { default: colors.primary, ":disabled": "oklch(27.4% 0.006 286.033)" }, + }, + color: { default: colors.primaryText, ":disabled": "oklch(55.2% 0.016 285.938)" }, + }, +}); function FieldRow({ section, @@ -156,15 +272,15 @@ function FieldRow({ const id = `${section}.${def.key}`; if (def.kind === "boolean") { return ( -
+
onChange(e.target.checked)} - className={focusRing} + {...stylex.props(shared.focusRing)} /> -
@@ -172,30 +288,20 @@ function FieldRow({ } if (Array.isArray(def.kind)) { return ( -
- - -
+ onChange(e.target.valueAsNumber)} - className={fieldInputClass} + {...stylex.props(styles.fieldInput, shared.focusRing)} />
); } return ( -
-