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 @@ -1855,7 +1855,8 @@ pre.cv-math-source {
border: 0.5px solid var(--agent-border);
border-radius: 8px;
background: var(--acpmux-base, var(--agent-page-bg));
box-shadow: var(--ui-popup-shadow, 0 4px 14px var(--agent-shadow, rgb(0 0 0 / 18%)));
/* Its border is its one rim (it takes the accent on focus), so the shadow brings no hairline. */
box-shadow: var(--ui-popup-drop, 0 4px 14px var(--agent-shadow, rgb(0 0 0 / 18%)));
font-family: var(--cv-font);
font-size: var(--text-body);
}
Expand Down
4 changes: 2 additions & 2 deletions webviews/src/agent-session/acpmux/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion webviews/src/pages/home-channels/ledger.css
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,8 @@
/* A fixed height: filtering shrinks the list, never the dialog, so the key hint stays put. */
height: min(520px, 72vh);
padding: 0;
border: 1px solid var(--lg-line);
/* One rim: the hairline in --ui-popup-shadow (ui/popupSurface.css), no border over it. */
border: 0;
border-radius: 12px;
background: var(--surface-popup);
box-shadow: var(--ui-popup-shadow);
Expand Down
2 changes: 2 additions & 0 deletions webviews/src/pages/shared/desktop.css
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ input[type="submit"] {
--agent-border-strong: var(--agent-text, CanvasText) !important;
--acpmux-pill-edge: var(--agent-text, CanvasText) !important;
--cmux-diff-border: var(--cmux-diff-fg, CanvasText) !important;
/* A popup's one hairline edge (ui/popupSurface.css) in its own ink. */
--ui-popup-edge: currentColor;
}

:root:not([data-borders="none"]) :is(.acpmux-shell, .ui-positioner) {
Expand Down
16 changes: 13 additions & 3 deletions webviews/src/ui/popupSurface.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
--ui-popup-radius: 8px;
--ui-popup-padding: 4px;
/* The native popup shadow (PopupStyle.swift, `0 2px 8px` black at 12%) and a hairline edge: one
elevation in pages and in the app chrome. popup-surface.test.ts holds the two in step. */
elevation in pages and in the app chrome. popup-surface.test.ts holds the two in step. The
hairline is a popup's only edge (popup-one-hairline.test.ts); Increase Contrast sets its color
(pages/shared/desktop.css). `--ui-popup-drop` is the shadow alone, for a floating bar that
draws its own border. */
--ui-popup-drop: 0 2px 8px color-mix(in srgb, black 12%, transparent);
--ui-popup-shadow:
0 2px 8px color-mix(in srgb, black 12%, transparent),
inset 0 0 0 0.5px color-mix(in srgb, currentColor 12%, transparent);
var(--ui-popup-drop),
inset 0 0 0 0.5px var(--ui-popup-edge, color-mix(in srgb, currentColor 12%, transparent));
--ui-row-height: 28px;
--ui-row-radius: 5px;
--ui-row-padding-inline: 8px;
Expand All @@ -19,6 +23,12 @@
--ui-popup-close: 80ms;
}

/* appearance.borders none (data-borders on the root, applyAgentTheme) clears the rim in place, as it
clears the composer's; the popup's fill keeps it apart. */
:root[data-borders="none"] {
--ui-popup-edge: transparent;
}

@keyframes ui-popup-open {
from {
opacity: 0;
Expand Down
3 changes: 2 additions & 1 deletion webviews/src/ui/ui.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
overflow: auto;
overscroll-behavior: contain;
padding: var(--ui-popup-padding);
border: 1px solid var(--ui-line);
/* One rim: the hairline in --ui-popup-shadow (ui/popupSurface.css), no border over it. */
border: 0;
border-radius: var(--ui-popup-radius);
background: var(--surface-popup);
color: var(--ui-fg);
Expand Down
169 changes: 169 additions & 0 deletions webviews/test/popup-one-hairline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Coherence passes #20188 and #20213 (2026-10-13) gave the pane's menus and popovers
// `--acpmux-popup-elevation`: a 0.5px composer-edge hairline over `--ui-popup-shadow`, which already
// draws a 0.5px hairline of its own. Those popups drew two edges. Every web menu and popover draws
// exactly one: the hairline in `--ui-popup-shadow` (ui/popupSurface.css).
//
// The check reads the stylesheets: each `box-shadow` that uses a popup token is expanded through
// the popup tokens' definitions and must hold one inset hairline layer, with no border in the same
// rule (a page's `.ui-popup` and the chat find bar drew a border over that hairline).
import { describe, expect, test } from "bun:test";
import fs from "node:fs";
import path from "node:path";

const source = path.resolve(import.meta.dir, "../src");

const sheets = (dir: string): string[] =>
fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) return sheets(file);
return entry.name.endsWith(".css") ? [file] : [];
});

const css = sheets(source).map((file) => ({
file: path.relative(source, file),
text: fs.readFileSync(file, "utf8").replace(/\/\*[\s\S]*?\*\//g, ""),
}));

// The elevations that carry the popup hairline, and the drop shadow alone (no hairline).
const RIM_TOKENS = ["--ui-popup-shadow", "--acpmux-popup-elevation"];
const POPUP_TOKENS = [...RIM_TOKENS, "--ui-popup-drop"];

// Every definition of a popup token, across every sheet.
const definitions = new Map<string, string[]>();
for (const { text } of css) {
for (const token of POPUP_TOKENS) {
for (const match of text.matchAll(new RegExp(`${token}\\s*:\\s*([^;}]+)`, "g"))) {
definitions.set(token, [...(definitions.get(token) ?? []), match[1].trim()]);
}
}
}

// The argument list of the call that opens at `open` (the index of its "(").
const closing = (text: string, open: number) => {
let depth = 0;
for (let i = open; i < text.length; i++) {
if (text[i] === "(") depth++;
else if (text[i] === ")" && --depth === 0) return i;
}
throw new Error(`unbalanced parentheses in ${text}`);
};

// Expands `var(--popup-token[, fallback])` with the token's first definition; other variables stay.
const expand = (value: string, seen: string[] = []): string => {
let out = "";
let rest = value;
for (;;) {
const at = rest.search(/var\(\s*--/);
if (at < 0) return out + rest;
const end = closing(rest, rest.indexOf("(", at));
const call = rest.slice(at, end + 1);
const name = call.match(/var\(\s*(--[\w-]+)/)![1];
const definition = definitions.get(name)?.[0];
if (POPUP_TOKENS.includes(name) && definition && !seen.includes(name)) {
out += rest.slice(0, at) + expand(definition, [...seen, name]);
} else {
out += rest.slice(0, end + 1);
}
rest = rest.slice(end + 1);
}
};

// The top-level comma-separated layers of a box-shadow (commas inside calls do not split).
const layers = (value: string) => {
const parts: string[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < value.length; i++) {
if (value[i] === "(") depth++;
else if (value[i] === ")") depth--;
else if (value[i] === "," && depth === 0) {
parts.push(value.slice(start, i).trim());
start = i + 1;
}
}
return [...parts, value.slice(start).trim()];
};
const hairlines = (value: string) =>
layers(expand(value)).filter((layer) => /^inset\s+0\s+0\s+0\s+[\d.]*\d(px)?\b/.test(layer));

// A border drawn by the rule is an edge too: any `border`/`border-width` that is not 0 or none.
const borders = (body: string) =>
[...body.matchAll(/(?:^|[;{\s])border(?:-width)?\s*:\s*([^;}]+)/g)]
.map((match) => match[1].trim())
.filter((value) => !/^(0(px)?|none)(\s*!important)?$/.test(value));

// Each box-shadow that names a popup token, with the selector and the borders of its rule.
const uses = css.flatMap(({ file, text }) =>
[...text.matchAll(/box-shadow\s*:\s*([^;}]+)/g)]
.filter((match) => POPUP_TOKENS.some((token) => match[1].includes(`var(${token}`)))
.map((match) => {
const open = text.lastIndexOf("{", match.index);
const start = Math.max(text.lastIndexOf("}", open), text.lastIndexOf(";", open)) + 1;
const body = text.slice(open + 1, text.indexOf("}", match.index));
return {
file,
selector: text.slice(start, open).trim().replace(/\s+/g, " "),
value: match[1].trim(),
borders: borders(body),
};
}),
);

describe("web popups draw one hairline edge", () => {
test("every popup elevation definition holds exactly one hairline", () => {
for (const token of RIM_TOKENS) {
for (const definition of definitions.get(token) ?? []) {
expect({ token, definition, hairlines: hairlines(definition).length }).toEqual({
token,
definition,
hairlines: 1,
});
}
}
});

test("every menu and popover draws exactly one edge: the hairline, no border", () => {
expect(uses.length).toBeGreaterThan(0);
for (const use of uses) {
const edges = hairlines(use.value).length + use.borders.length;
expect({ ...use, edges }).toEqual({ ...use, edges: 1 });
}
});

// appearance.borders none (data-borders on the root) clears the rim, as it clears the composer's.
test("appearance.borders none clears the popup hairline", () => {
const cleared = css.some(({ text }) =>
[...text.matchAll(/:root\[data-borders=("none"|none)\]\s*\{([^}]*)\}/g)].some((match) =>
/--ui-popup-edge\s*:\s*transparent/.test(match[2]),
),
);
expect(cleared).toBe(true);
});

// The popups the coherence passes moved onto the shared elevation; each must still be covered.
test("the pane's menus and popovers are all checked", () => {
const selectors = uses.map((use) => use.selector).join("\n");
for (const popup of [
".ui-popup.ui-context-menu",
":where(.ui-positioner)>.ui-popup",
".acpmux-context-pop",
".acpmux-menu",
".acpmux-slash-menu",
".acpmux-omni",
".acpmux-mp",
".acpmux-access-menu",
".acpmux-effort-menu",
".acpmux-chat-menu-popover",
".acpmux-pr-status",
".acpmux-summary-card",
".lg-palette",
".acpmux-file-menu-list",
".nt-popover",
".acpmux-mp-cascade .acpmux-mp-sub",
".acpmux-find__bar",
".ui-popup",
]) {
expect(selectors).toContain(popup);
}
});
});
Loading