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
8 changes: 8 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -3511,6 +3511,14 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`.
replaces it under a Back to settings control. The dialog's accessible name stays **Settings**
on every page; the list is a vertical `tablist` with one Tab stop, and the arrows walk it
(beside an open page they turn the page too). Escape closes the dialog from any page.
- Each page shows first what people change (set by the install counts of which settings differ
from their defaults) and folds the rest under one quiet line at its end, as herdr-app keeps its
Settings to a dozen rows and leaves the rest to its settings file: **More settings**, and
**Experimental** for what is still being shaped (`SettingsFold`, a native `details`). The line
counts the settings in it that this device moved off their defaults ("2 changed", in `--accent`),
so a choice never hides without a trace. A search shows the rows inside as if unfolded, a link or
the palette naming a group inside opens the fold, and a group there keeps its own name
(`typography-more`, `composer-more`, `file-viewer-more`, `chat-experimental`, ...).
- A search field (`Search settings`) sits over the list of pages, as Zed's and VS Code's settings
searches do: typed words (case and full-width forms folded) keep only the settings whose label
and description hold every word, or every setting of a group whose title holds them, from all
Expand Down
4 changes: 4 additions & 0 deletions changelog.d/settings-folds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Changed
- Each Settings page shows the settings most people change first and folds the rest under **More
settings** and **Experimental** at its end; the folded line says how many of them you changed,
and Settings' search and links still reach every row.
6 changes: 3 additions & 3 deletions scripts/file-text-size-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ try {
// a long file's source is virtualized: its rows, in a shadow root, take the size and the row estimate matches
// them, so its last line is drawn at the end; a size changed while it is open re-measures the rows
{
const { page, errors, close } = await open(browser, width, { fileFontSize: 24 }, "&file=long.log&settings=appearance&section=typography");
const { page, errors, close } = await open(browser, width, { fileFontSize: 24 }, "&file=long.log&settings=appearance&section=typography-more");
try {
const rowAt = (): Promise<{ host: number; row: number; last: boolean }> => page.evaluate(() => {
const host = document.querySelector<HTMLElement>(".virtual-source")!;
Expand Down Expand Up @@ -200,7 +200,7 @@ try {
const dialog = page.locator(".settings-dialog");
for (let step = 0; step < 10; step++) await dialog.getByRole("button", { name: "Increase chat font size", exact: true }).click();
assert.equal(await lineOf(), before, `${width}px: a chat size change leaves a chosen file size's source as it was`);
await page.goto(`${origin}/herdr-web-ui/demo/app/?pane=${encodeURIComponent(panes.web)}&file=long.log&settings=appearance&section=typography`);
await page.goto(`${origin}/herdr-web-ui/demo/app/?pane=${encodeURIComponent(panes.web)}&file=long.log&settings=appearance&section=typography-more`);
await page.waitForFunction(() => (document.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0) > 0);
const uiScale = (): Promise<string> => page.evaluate(() => document.documentElement.style.getPropertyValue("--ui-scale"));
const uiBefore = await uiScale();
Expand All @@ -217,7 +217,7 @@ try {
}

// the control: a step sets a size of its own, kept for the device; Reset drops it
const { page, errors, close } = await open(browser, width, { chatFontSize: 16 }, "&settings=appearance&section=typography");
const { page, errors, close } = await open(browser, width, { chatFontSize: 16 }, "&settings=appearance&section=typography-more");
try {
const dialog = page.locator(".settings-dialog");
const stepper = dialog.getByRole("group", { name: "File text size", exact: true });
Expand Down
2 changes: 1 addition & 1 deletion scripts/files-documents-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ async function desktop(browser: Browser, theme: "light" | "dark"): Promise<void>
async function phone(browser: Browser, theme: "light" | "dark"): Promise<void> {
// the setting, on its Settings page
{
const { page, errors, close } = await open(browser, 390, { theme }, "&settings=chat");
const { page, errors, close } = await open(browser, 390, { theme }, "&settings=chat&section=file-viewer-more");
try {
const toggle = page.getByRole("switch", { name: "Documents in Files" });
await toggle.scrollIntoViewIfNeeded();
Expand Down
2 changes: 1 addition & 1 deletion scripts/files-panel-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ async function desktop(browser: Browser, theme: "light" | "dark"): Promise<void>
async function phone(browser: Browser, theme: "light" | "dark"): Promise<void> {
// the setting, on its Settings page
{
const { page, errors, close } = await open(browser, 390, { theme }, "&settings=chat");
const { page, errors, close } = await open(browser, 390, { theme }, "&settings=chat&section=file-viewer-more");
try {
const toggle = page.getByRole("switch", { name: "Files beside the pane" });
await toggle.scrollIntoViewIfNeeded();
Expand Down
4 changes: 2 additions & 2 deletions scripts/interface-font-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ try {
try {
if (evidence) mkdirSync(evidence, { recursive: true });
for (const width of [390, 1440]) {
const { page, errors, close } = await open(browser, width, {}, "&settings=appearance&section=typography");
const { page, errors, close } = await open(browser, width, {}, "&settings=appearance&section=typography-more");
try {
const dialog = page.locator(".settings-dialog");
const select = dialog.getByLabel("Interface font", { exact: true });
Expand Down Expand Up @@ -94,7 +94,7 @@ try {
await page.reload();
await page.locator(".conn-live").waitFor({ state: "attached" });
assert.equal(await token(page), `"Fixture UI Font", "Hiragino Sans", ${BUILT_IN}`, "a reload keeps the typed list");
await page.goto(`${origin}/herdr-web-ui/demo/app/?pane=${encodeURIComponent(panes.web)}&settings=appearance&section=typography`);
await page.goto(`${origin}/herdr-web-ui/demo/app/?pane=${encodeURIComponent(panes.web)}&settings=appearance&section=typography-more`);
await select.waitFor();
assert.equal(await select.inputValue(), "custom", "and Settings says it is a typed list");

Expand Down
4 changes: 4 additions & 0 deletions scripts/security-summary-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { join } from "node:path";
import { chromium, devices, type Browser, type BrowserContext, type Page } from "playwright-core";
import { buildDemoApp } from "./demo-build.ts";
import { chromePath } from "./chrome-path.ts";
import { openSettingsFolds } from "./settings-page.ts";
import { DEMO_FILE_PREVIEW_SCRIPT } from "./demo-file-preview.ts";
import { KO } from "../src/lib/i18n.ko.ts";

Expand Down Expand Up @@ -119,6 +120,9 @@ async function open(context: BrowserContext, variant: string, settings = true, l
await page.locator(".conn-live").waitFor({ state: "attached" });
assert.equal(await page.evaluate(() => (window as unknown as { heldOpeningTurn: boolean }).heldOpeningTurn), true, `${variant}: the demo's opening turn is held`);
if (!settings) return page;
// the Access facts sit in the page's More settings fold
await page.locator(".settings-body:not([hidden])").waitFor();
await openSettingsFolds(page);
await page.locator(SECTION).waitFor();
await page.locator(SECTION).scrollIntoViewIfNeeded();
if (variant in STATUS) await page.locator(`${SECTION} [role='status']`, { hasText: label(STATUS[variant]!) }).waitFor();
Expand Down
11 changes: 10 additions & 1 deletion scripts/settings-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,14 @@ export async function openSettingsPage(page: Page, name: string, backName = "Bac
const tab = dialog.getByRole("tab", { name, exact: true });
if (await tab.count() === 0) await dialog.getByRole("button", { name: backName, exact: true }).click();
await tab.click();
await dialog.getByRole("tabpanel", { name, exact: true }).waitFor();
const panel = dialog.getByRole("tabpanel", { name, exact: true });
await panel.waitFor();
// a page keeps its rarely changed rows folded (SettingsFold): open them, as a reader looking for one does
await openSettingsFolds(page);
}

/** Opens the folded groups (SettingsFold: More settings, Experimental) of the Settings page in view, as a reader looking for one of their rows does. */
export async function openSettingsFolds(page: Page): Promise<void> {
const closed = page.locator(".settings-body:not([hidden]) details.settings-fold:not([open]) > summary");
while (await closed.count() > 0) await closed.first().click();
}
59 changes: 53 additions & 6 deletions scripts/settings-pages-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ const MATRIX: readonly (readonly ["en" | "ko" | "ja" | "zh", string, string, str
["en", "phone", "devices", "-"],
["en", "OpenAI API key", "voice", "openai-key"],
["en", "Return inserts a new line", "chat", "composer"],
["en", "Send queued messages automatically", "chat", "composer"],
["en", "Suggestion chip", "chat", "composer"],
["en", "Send queued messages automatically", "chat", "composer-more"],
["en", "Suggestion chip", "chat", "composer-more"],
["en", "Show above the message box", "chat", "quick-replies"],
["ko", "인터페이스 글자 크기", "appearance", "typography"],
["ko", "채팅 글자 크기", "chat", "-"],
Expand All @@ -88,7 +88,7 @@ const MATRIX: readonly (readonly ["en" | "ko" | "ja" | "zh", string, string, str
["ko", "OpenAI API 키", "voice", "openai-key"],
["ko", "단축키", "shortcuts", "-"],
["ko", "알림", "alerts", "-"],
["ko", "대기 메시지 자동 전송", "chat", "composer"],
["ko", "대기 메시지 자동 전송", "chat", "composer-more"],
["ko", "입력창 위에 표시", "chat", "quick-replies"],
["ja", "インターフェースの文字サイズ", "appearance", "typography"],
["ja", "チャットのフォントサイズ", "chat", "-"],
Expand All @@ -98,7 +98,7 @@ const MATRIX: readonly (readonly ["en" | "ko" | "ja" | "zh", string, string, str
["ja", "OpenAI API キー", "voice", "openai-key"],
["ja", "ショートカット", "shortcuts", "-"],
["ja", "通知", "alerts", "-"],
["ja", "送信待ちメッセージを自動で送信", "chat", "composer"],
["ja", "送信待ちメッセージを自動で送信", "chat", "composer-more"],
["ja", "入力欄の上に表示", "chat", "quick-replies"],
["zh", "界面字号", "appearance", "typography"],
["zh", "聊天字号", "chat", "-"],
Expand All @@ -108,7 +108,7 @@ const MATRIX: readonly (readonly ["en" | "ko" | "ja" | "zh", string, string, str
["zh", "OpenAI API 密钥", "voice", "openai-key"],
["zh", "快捷键", "shortcuts", "-"],
["zh", "提醒", "alerts", "-"],
["zh", "自动发送排队消息", "chat", "composer"],
["zh", "自动发送排队消息", "chat", "composer-more"],
["zh", "显示在输入框上方", "chat", "quick-replies"],
];

Expand Down Expand Up @@ -383,7 +383,8 @@ interface HeldVoice { voiceAsked: () => number; releaseVoice: () => void; groupO
* they scrolled.
*/
async function heldGroupLanding(browser: import("playwright-core").Browser, url: string): Promise<void> {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, locale: "en" });
// short enough that the OpenAI key's group starts below the fold, so a landing has to scroll to it
const context = await browser.newContext({ viewport: { width: 390, height: 480 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, locale: "en" });
try {
await context.addInitScript((settings) => localStorage.setItem("ovis:settings", settings), JSON.stringify(SETTINGS));
const page = await context.newPage();
Expand Down Expand Up @@ -552,6 +553,51 @@ async function dialogSearch(browser: import("playwright-core").Browser, url: str
console.log("PASS Settings' search at 1440 and 390px: settings of every page found and changed in place, groups by name, pages opened, nothing matching said, Escape empties first");
}

/**
* The folded part of a page (SettingsFold), at 1440 and 390: closed on opening, with the count of what this
* device changed in it on its line; a link to a group inside opens it on that group; a search finds its rows.
*/
async function foldedGroups(browser: import("playwright-core").Browser, url: string): Promise<void> {
for (const width of [1440, 390] as const) {
const context = await browser.newContext(width === 390 ? { viewport: { width, height: 844 }, isMobile: true, hasTouch: true, locale: "en" } : { viewport: { width, height: 900 }, locale: "en" });
try {
await context.addInitScript((settings) => localStorage.setItem("ovis:settings", settings), JSON.stringify({ ...SETTINGS, sidebarPeek: true }));
const page = await context.newPage();
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(url);
await page.locator(".conn-live").waitFor({ state: "attached" });
await openSettings(page);
const dialog = dialogOf(page);
await dialog.getByRole("tab", { name: "Appearance", exact: true }).click();
const panel = dialog.getByRole("tabpanel", { name: "Appearance", exact: true });
const more = panel.locator("details.settings-fold[data-fold='more']");
assert.equal(await more.evaluate((fold) => (fold as HTMLDetailsElement).open), false, `${width}px: More settings starts folded`);
assert.equal(await panel.getByRole("switch", { name: "Show the sidebar on hover", exact: true }).isVisible(), false, `${width}px: its rows are out of the first view`);
assert.equal(await more.locator(".settings-fold-changed").textContent(), "1 changed", `${width}px: the line counts what this device changed inside`);
await more.locator("> summary").click();
await panel.getByRole("switch", { name: "Show the sidebar on hover", exact: true }).waitFor();

// a search finds a folded row with the rest
await closeSettings(page);
await openSettings(page);
await dialog.getByRole("searchbox", { name: "Search settings", exact: true }).fill("sidebar on hover");
await dialog.getByRole("region", { name: "Search results", exact: true }).getByRole("switch", { name: "Show the sidebar on hover", exact: true }).waitFor();
await closeSettings(page);

// a link to a group inside a fold opens it on that group
await page.goto(`${url}?settings=chat&section=composer-more`);
const queued = page.getByRole("switch", { name: "Send queued messages automatically", exact: true });
await queued.waitFor();
assert.equal(await page.locator(".settings-body:not([hidden]) [data-section='composer-more']").evaluate((group) => group.closest("details")?.open === true && document.activeElement === group), true, `${width}px: the link opened the fold and focused its group`);
assert.deepEqual(errors, []);
} finally {
await context.close();
}
}
console.log("PASS folded groups at 1440 and 390px: closed on opening with a count of what changed, opened by a click, a link to a group inside and a search");
}

try {
await buildDemoApp(app);
await buildDevelopmentDemoApp(devApp);
Expand Down Expand Up @@ -1136,6 +1182,7 @@ try {
await lateCloseStep(browser, url);
await settingsSearch(browser, url);
await dialogSearch(browser, url);
await foldedGroups(browser, url);
await heldGroupLanding(browser, devUrl);
} finally {
await browser.close();
Expand Down
3 changes: 3 additions & 0 deletions scripts/sidebar-split-access-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ try {
const appearance = page.locator(".settings-dialog").getByRole("tab", { name: "Appearance", exact: true });
await appearance.focus();
await page.keyboard.press("Enter");
// the switch sits in the page's More settings fold: the keyboard opens it too
await page.locator(".settings-body:not([hidden]) details.settings-fold[data-fold='more'] > summary").focus();
await page.keyboard.press("Enter");
const grouping = page.getByRole("switch", { name: "Group agents by state", exact: true });
await grouping.focus();
await page.keyboard.press("Space");
Expand Down
2 changes: 2 additions & 0 deletions scripts/terminal-images-demo-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { chromium, type Page } from "playwright-core";
import panes from "../site/demo/fixtures/panes.json";
import { buildDemoApp } from "./demo-build.ts";
import { chromePath } from "./chrome-path.ts";
import { openSettingsFolds } from "./settings-page.ts";
import { shownRows } from "./shown-rows.ts";

interface ImagesFixture { print(data: string): void }
Expand Down Expand Up @@ -92,6 +93,7 @@ const print = async (page: Page, data: string, marker: string) => {
const setPictures = async (page: Page, on: boolean) => {
await page.getByRole("button", { name: "Settings", exact: true }).first().click();
await page.getByRole("navigation", { name: "Settings" }).getByText("Terminal", { exact: true }).click();
await openSettingsFolds(page);
const toggle = page.getByRole("switch", { name: "Pictures in the terminal" });
if ((await toggle.getAttribute("aria-checked")) !== String(on)) await toggle.click();
assert.equal(await toggle.getAttribute("aria-checked"), String(on));
Expand Down
2 changes: 1 addition & 1 deletion scripts/text-file-edit-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ try {
await off.viewer.getByRole("button", { name: "Show source", exact: true }).waitFor();
assert.equal(await off.viewer.getByRole("button", { name: "Edit file", exact: true }).count(), 0, `${tag}: no Edit while the setting is off`);
// Settings → Chat → File viewer holds the switch, off
await off.page.goto(`${origin}/?pane=${encodeURIComponent(pane)}&settings=chat`);
await off.page.goto(`${origin}/?pane=${encodeURIComponent(pane)}&settings=chat&section=file-viewer-more`);
const toggle = off.page.locator(".settings-dialog").getByRole("switch", { name: "Edit text files", exact: true });
await toggle.scrollIntoViewIfNeeded();
assert.equal(await toggle.getAttribute("aria-checked"), "false", `${tag}: Edit text files is off by default`);
Expand Down
56 changes: 56 additions & 0 deletions src/components/SettingsControls.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,62 @@
font-weight: var(--fw-semibold);
}

/* a page's folded groups (SettingsFold): one quiet line at the page's end, a caption's size and colour, that
turns its chevron and shows the groups under it with the page's own spacing */
.settings-fold > summary {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: var(--control-h);
padding: 0 var(--space-1);
color: var(--text-dim);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
list-style: none;
cursor: pointer;
border-radius: var(--radius-md);
}

.settings-fold > summary::-webkit-details-marker {
display: none;
}

.settings-fold > summary:hover,
.settings-fold > summary:focus-visible {
color: var(--text-strong);
}

.settings-fold-chevron {
flex: 0 0 auto;
width: 16px;
height: 16px;
transition: transform var(--dur-fast) var(--ease-out);
}

.settings-fold[open] > summary .settings-fold-chevron {
transform: rotate(90deg);
}

.settings-fold-changed {
margin-left: auto;
color: var(--accent);
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
}

.settings-fold-body {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding-top: var(--space-3);
}

@media (prefers-reduced-motion: reduce) {
.settings-fold-chevron {
transition: none;
}
}

.settings-note {
margin: 0;
padding: 0 var(--space-1);
Expand Down
Loading
Loading