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 @@ -580,7 +580,7 @@ export default agentPaneEntry({
},
},
"style-switch": {
note: "The New Tab style switch (Leo 2026-10-13): the five style names in one quiet row under the page, the current one on a small fill that slides to a pick. A pick shows in place and is saved as tabs.newTabTemplate.",
note: "The New Tab style dots (Leo 2026-10-14): one small round dot per style under the page, the current one in the accent and the accent dot sliding to a pick. A pointer over a dot shows its name; a pick shows the picked name for a moment, then fades it, and is saved as tabs.newTabTemplate.",
ready: newTab({ template: "default" }),
snapshot: noChat(manySessions(4)),
play: async (ctx) => {
Expand Down
58 changes: 46 additions & 12 deletions webviews/src/agent-session/acpmux/newtab/StyleSwitch.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
// The New Tab page's style switch (Leo 2026-10-13, cx-txe89): the old template dots looked,
// clicked and moved badly, but choosing the style was good. One quiet row of the style names under
// the page: native radios (the browser owns arrows and focus), the current one marked by a small
// fill that slides to a new pick. No capsule, no popup.
import { useLayoutEffect, useRef, useState } from "react";
// The New Tab page's style switch (Leo 2026-10-14, after cx-txe89): one small round dot per page
// style under the page, the current one in the accent (a dot that slides to a new pick) and the rest
// dim. A pointer over a dot shows its name over it; a pick shows the picked name for a moment, then
// fades it. Native radios carry the names (the browser owns arrows and focus) and fill each dot's
// hit target, larger than the dot. The row never changes size or place, so nothing shifts.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { ChoiceGroup } from "../../../ui/ChoiceGroup";
import { FOCUS_LOCATION_EVENT } from "../NewTabPage";
import { useNt } from "./strings";
import { PAGE_STYLES, type PageStyle } from "./templates";

/// How long a pick's name stays before it fades.
const PICKED_NAME_MS = 1100;

export function StyleSwitch({
current,
onPick,
Expand All @@ -20,31 +24,59 @@ export function StyleSwitch({
const nt = useNt();
const group = useRef<HTMLDivElement>(null);
const mark = useRef<HTMLSpanElement>(null);
const label = useRef<HTMLSpanElement>(null);
const pointed = useRef(0);
// The mark slides only after its first placement, so the page never opens with a slide.
const [settled, setSettled] = useState(false);
// The dot whose name shows: the one pointed at, else the one just picked.
const [hovered, setHovered] = useState<PageStyle | undefined>();
const [picked, setPicked] = useState<PageStyle | undefined>();
const named = hovered ?? picked;
// The last name shown stays in place while it fades.
const [shown, setShown] = useState<PageStyle | undefined>();
if (named && named !== shown) setShown(named);

useEffect(() => {
if (!picked) return;
const timer = setTimeout(() => setPicked(undefined), PICKED_NAME_MS);
return () => clearTimeout(timer);
}, [picked]);

useLayoutEffect(() => {
const option = group.current?.querySelector<HTMLElement>(`[data-style="${current}"]`);
const fill = mark.current;
if (!option || !fill) return;
fill.style.width = `${option.offsetWidth}px`;
fill.style.transform = `translateX(${option.offsetLeft}px)`;
if (!option || !mark.current) return;
mark.current.style.transform = `translateX(${option.offsetLeft}px)`;
const view = option.ownerDocument.defaultView;
if (settled || !view) return;
const frame = view.requestAnimationFrame(() => setSettled(true));
return () => view.cancelAnimationFrame(frame);
}, [current, settled, nt]);
}, [current, settled]);

useLayoutEffect(() => {
const option = shown && group.current?.querySelector<HTMLElement>(`[data-style="${shown}"]`);
if (option && label.current) label.current.style.left = `${option.offsetLeft + option.offsetWidth / 2}px`;
}, [shown]);

return (
<ChoiceGroup label={nt("templates")} className="nt-style" onSubmit={onCancel} onCancel={onCancel}>
<div ref={group} className="nt-style-options" data-settled={settled ? "" : undefined}>
<div
ref={group}
className="nt-style-options"
data-settled={settled ? "" : undefined}
onPointerOut={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setHovered(undefined);
}}
>
<span ref={label} className="nt-style-name" aria-hidden="true" data-shown={named ? "" : undefined}>
{shown ? nt(`template.${shown}`) : ""}
</span>
<span ref={mark} className="nt-style-mark" aria-hidden="true" />
{PAGE_STYLES.map((style) => (
<label
key={style}
className="nt-style-option"
data-style={style}
onPointerOver={() => setHovered(style)}
onPointerDown={() => (pointed.current = performance.now())}
>
<input
Expand All @@ -58,6 +90,9 @@ export function StyleSwitch({
const fromKeys = performance.now() - pointed.current > 500;
const input = event.currentTarget;
onPick(style);
setPicked(style);
// Picked by keys, the new dot's name shows, not the one a resting pointer is over.
if (fromKeys) setHovered(undefined);
// After the pick's commit, where a page that mounts for it (Classic, or a screen
// from Classic) has focused its field: arrow keys keep moving through the styles; a
// click goes back to the field.
Expand All @@ -67,7 +102,6 @@ export function StyleSwitch({
);
}}
/>
<span aria-hidden="true">{nt(`template.${style}`)}</span>
</label>
))}
</div>
Expand Down
29 changes: 17 additions & 12 deletions webviews/src/agent-session/acpmux/newtab/screen.css
Original file line number Diff line number Diff line change
Expand Up @@ -257,19 +257,24 @@
width: 24px;
height: 24px;
}
/* The style switch (StyleSwitch.tsx): the style names in one quiet row under the page, the current
one on a small fill that slides to a new pick. Out of the flow, in the pages' bottom padding, so
the pages lay out (and scroll) as without it. */
/* The style dots (StyleSwitch.tsx, Leo 2026-10-14): a 24px target per style around a 6px round dot,
the rest dim on the text ink and the current one under the accent dot that slides to a pick. A
name shows over a dot while pointed at, and over a pick for a moment; it is out of the row's flow,
so the row never moves. The radio fills the target, unseen (the pane does not load ui.css). The
row sits out of the flow, in the pages' bottom padding, so the pages lay out as without it. */
.nt-style{position:absolute;bottom:2px;left:50%;transform:translateX(-50%);max-width:calc(100% - 32px);margin:0;padding:0;border:0;min-width:0;font-family:system-ui;-webkit-font-smoothing:antialiased}
.nt-style-options{position:relative;display:flex;gap:2px}
.nt-style-mark{position:absolute;top:0;left:0;height:100%;border-radius:5px;background:color-mix(in srgb,var(--agent-accent,var(--agent-text)) 10%,transparent);pointer-events:none}
.nt-style-options[data-settled] .nt-style-mark{transition:transform var(--agent-motion-move,190ms) var(--agent-ease-out,ease-out),width var(--agent-motion-move,190ms) var(--agent-ease-out,ease-out)}
.nt-style-option{position:relative;padding:3px 8px;border-radius:5px;color:var(--acpmux-composer-dim);font-size:var(--text-detail);line-height:var(--text-detail--line-height);white-space:nowrap;cursor:default;transition:color var(--agent-motion-hover,80ms) var(--agent-ease-out,ease-out)}
.nt-style-option:hover,.nt-style-option:has(:checked){color:var(--agent-text)}
/* The radio is for keys and screen readers only; the pane does not load ui.css's .ui-sr-only, so a native dot drew beside every style (cg-design-judge). */
.nt-style-input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
.nt-style-option:has(:focus-visible){outline:var(--cmux-focus-ring-width,1.5px) solid var(--cmux-focus-ring-color,color-mix(in srgb,var(--agent-text) 40%,transparent));outline-offset:-1px}
@media (prefers-reduced-motion:reduce){.nt-style-options[data-settled] .nt-style-mark,.nt-style-option{transition:none}}
.nt-style-options{position:relative;display:flex}
.nt-style-option{position:relative;display:flex;align-items:center;justify-content:center;width:24px;height:24px;cursor:default}
.nt-style-option::after{content:"";width:6px;height:6px;border-radius:50%;background:color-mix(in srgb,var(--agent-text) 26%,transparent);transition:background-color var(--agent-motion-hover,80ms) var(--agent-ease-out,ease-out)}
.nt-style-option:hover::after{background:color-mix(in srgb,var(--agent-text) 50%,transparent)}
.nt-style-option:has(:focus-visible)::before{content:"";position:absolute;width:14px;height:14px;border-radius:50%;outline:var(--cmux-focus-ring-width,1.5px) solid var(--cmux-focus-ring-color,color-mix(in srgb,var(--agent-text) 40%,transparent))}
.nt-style-input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;appearance:none;cursor:default}
.nt-style-mark{position:absolute;top:0;left:0;display:flex;align-items:center;justify-content:center;width:24px;height:24px;pointer-events:none}
.nt-style-mark::after{content:"";width:6px;height:6px;border-radius:50%;background:var(--agent-accent,var(--agent-text))}
.nt-style-options[data-settled] .nt-style-mark{transition:transform var(--agent-motion-move,190ms) var(--agent-ease-out,ease-out)}
.nt-style-name{position:absolute;bottom:100%;left:50%;transform:translateX(-50%);padding-bottom:2px;color:var(--acpmux-composer-dim);font-size:var(--text-detail);line-height:var(--text-detail--line-height);white-space:nowrap;pointer-events:none;opacity:0;transition:opacity var(--agent-motion-out,160ms) var(--agent-ease-out,ease-out)}
.nt-style-name[data-shown]{opacity:1;transition-duration:var(--agent-motion-hover,80ms)}
@media (prefers-reduced-motion:reduce){.nt-style-options[data-settled] .nt-style-mark,.nt-style-option::after{transition:none}}

/* The browser omnibar's pointer and hover (OmnibarPointer, cx-d0d.80): the field row is the
text area, so the I-beam shows over all of it (a press there focuses the field, focusFromRow);
Expand Down
97 changes: 97 additions & 0 deletions webviews/test/new-tab-style-dots.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// The New Tab style switch is a row of dots (Leo 2026-10-14: "what happened to the dots at the
// bottom, why's there text"). One small round dot per style, the current one in the accent and the
// rest dim; no radio circles and no names at rest; each dot's hit target is larger than the dot; and
// the row keeps one size and place whichever style is current.
//
// Before: #20336 drew the five names as a row of text. The pane does not load ui.css, so the
// radios' .ui-sr-only never applied and each drew a native circle beside its name.
//
// This renders the switch's markup with the pane's real New Tab stylesheet in Chromium and reads
// what it draws.
import { readFileSync } from "node:fs";
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
import path from "node:path";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { chromium, type Browser } from "playwright";
import { StyleSwitch } from "../src/agent-session/acpmux/newtab/StyleSwitch";
import { PAGE_STYLES, type PageStyle } from "../src/agent-session/acpmux/newtab/templates";
import { requireBrowserLane } from "./support/requireBrowserLane";

await requireBrowserLane("new-tab-style-dots.test.ts", async () => {
setDefaultTimeout(60_000);
try {
await (await chromium.launch({ headless: true })).close();
} catch {
console.warn("new-tab-style-dots: skipping (run `bunx playwright install chromium`)");
describe.skip("style dots", () => test("chromium", () => {}));
return;
}
const screen = readFileSync(path.resolve(import.meta.dir, "../src/agent-session/acpmux/newtab/screen.css"), "utf8");
let browser: Browser | undefined;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
});
afterAll(async () => {
await browser?.close();
});

/// What the switch draws with `current` picked: its row's box, each option's hit box and dot, and
/// whatever of it is visible besides the dots.
async function drawn(current: PageStyle) {
const page = await browser!.newPage({ viewport: { width: 900, height: 400 } });
const markup = renderToStaticMarkup(createElement(StyleSwitch, { current, onPick() {}, onCancel() {} }));
await page.setContent(
`<style>:root{--agent-text:#202020;--agent-accent:#0a84ff;--acpmux-composer-dim:#808080}${screen}` +
`body{margin:0}.page{position:relative;height:300px}</style><div class="page">${markup}</div>`,
);
const result = await page.evaluate(() => {
const box = (node: Element) => {
const rect = node.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
};
const row = document.querySelector("fieldset")!;
const options = [...document.querySelectorAll<HTMLInputElement>("input[type=radio]")].map((input) => {
const option = input.closest("label")!;
const dot = getComputedStyle(option, "::after");
return {
hit: box(input),
dot: { width: dot.width, height: dot.height, radius: dot.borderRadius },
radioShows: getComputedStyle(input).opacity !== "0" && box(input).width > 0,
};
});
// Text a person can see at rest: any text node whose element is visible.
const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT);
const text: string[] = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const element = node.parentElement!;
const style = getComputedStyle(element);
if (node.textContent!.trim() && style.opacity !== "0" && style.visibility !== "hidden")
text.push(node.textContent!.trim());
}
return { row: box(row), options, text };
});
await page.close();
return result;
}

test("five round dots, no radio circles and no names at rest, each a target larger than its dot", async () => {
const { options, text } = await drawn("threads");
expect(options.length).toBe(PAGE_STYLES.length);
expect(text).toEqual([]);
for (const option of options) {
expect(option.radioShows).toBe(false);
expect(option.dot.width).toBe(option.dot.height);
expect(Number.parseFloat(option.dot.width)).toBeGreaterThanOrEqual(5);
expect(Number.parseFloat(option.dot.width)).toBeLessThanOrEqual(8);
expect(option.dot.radius).toBe("50%");
expect(option.hit.width).toBeGreaterThanOrEqual(20);
expect(option.hit.height).toBeGreaterThanOrEqual(20);
}
});

test("the row keeps one size and place whichever style is current", async () => {
const rows = await Promise.all(PAGE_STYLES.map(async (style) => (await drawn(style)).row));
for (const row of rows) expect(row).toEqual(rows[0]!);
});
});
Loading
Loading