From 8dc87c80b0deaf8153158e769b3c17b79a7068b7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 21 Oct 2026 14:29:20 -0700 Subject: [PATCH 1/2] feat(app): try header separators and spacing from Knobs --- .../app/e2e/regression/session-header.spec.ts | 17 +-- packages/app/src/session/header/separator.tsx | 127 ++++++++++++++++++ .../src/session/session-identity-header.css | 29 ++-- .../src/session/session-identity-header.tsx | 27 +--- 4 files changed, 155 insertions(+), 45 deletions(-) create mode 100644 packages/app/src/session/header/separator.tsx diff --git a/packages/app/e2e/regression/session-header.spec.ts b/packages/app/e2e/regression/session-header.spec.ts index fda461702fd6..be6438591eb1 100644 --- a/packages/app/e2e/regression/session-header.spec.ts +++ b/packages/app/e2e/regression/session-header.spec.ts @@ -229,28 +229,15 @@ test.describe("location", () => { header.evaluate((element) => { const icon = element.querySelector('svg:has(use[href="#opencode-v2-icon-outline-worktree"])') const before = icon?.previousElementSibling?.getBoundingClientRect() - const separator = element.querySelector('[data-slot="session-title-separator"]') - const after = separator?.getBoundingClientRect() - const title = element.querySelector("h1") const avatar = element .querySelector('[data-slot="session-location-trigger"]') ?.firstElementChild?.getBoundingClientRect() const bounds = icon?.getBoundingClientRect() - return bounds && before && after && avatar && separator && title - ? [ - before.left - avatar.right, - bounds.left - before.right, - after.left - bounds.right, - title.getBoundingClientRect().left + - parseFloat(getComputedStyle(title).paddingLeft) - - after.right + - parseFloat(getComputedStyle(separator).paddingRight), - ] - : [] + return bounds && before && avatar ? [before.left - avatar.right, bounds.left - before.right] : [] }), ) - .toEqual([10, 8, 8, 10]) + .toEqual([10, 8]) const location = header.locator('[data-slot="session-location-trigger"]') await location.hover() await expect(location).toHaveCSS("padding-inline-start", "4px") diff --git a/packages/app/src/session/header/separator.tsx b/packages/app/src/session/header/separator.tsx new file mode 100644 index 000000000000..0fc0aa77439c --- /dev/null +++ b/packages/app/src/session/header/separator.tsx @@ -0,0 +1,127 @@ +import { motionTuning } from "@opencode/ui/motion-tuning" +import { Match, Switch } from "solid-js" +import { createStore } from "solid-js/store" + +const group = "Experiments" + +const glyph = motionTuning.choice("sessionHeader.separator", { + label: "Header separator", + group, + value: "divider", + labels: { divider: "Divider", slash: "Slash", chevron: "Chevron", bold: "Bold chevron", dot: "Dot" }, +}) + +const size = motionTuning.number("sessionHeader.separator.size", { + label: "Separator size", + group, + value: 14, + min: 6, + max: 24, + step: 1, + unit: "px", +}) + +const weight = motionTuning.number("sessionHeader.separator.weight", { + label: "Separator weight", + group, + value: 1, + min: 0.5, + max: 3, + step: 0.25, + unit: "px", +}) + +const opacity = motionTuning.number("sessionHeader.separator.opacity", { + label: "Separator opacity", + group, + value: 1, + min: 0.2, + max: 1, + step: 0.05, +}) + +const gap = motionTuning.number("sessionHeader.separator.gap", { + label: "Separator gap (ink to ink)", + group, + value: 10, + min: 4, + max: 20, + step: 0.5, + unit: "px", +}) + +const read = () => ({ glyph: glyph(), size: size(), weight: weight(), opacity: opacity(), gap: gap() }) + +const [tuning, setTuning] = createStore(read()) + +motionTuning.subscribe(() => setTuning(read())) + +/** + * Sits between breadcrumb segments with `gap` of visible space on both sides. `before` and `after` are the space the + * neighbouring segments already leave between their ink and this element, so the margins make up the rest. + */ +export function SessionHeaderSeparator(props: { before?: number; after?: number }) { + // The ink of every glyph fills its box, so the gap is exact; the text beside it keeps ~0.5px of side bearing. + const width = () => { + if (tuning.glyph === "divider") return tuning.weight + + if (tuning.glyph === "dot") return tuning.size / 4 + + return tuning.size * 0.45 + } + + const stroke = () => tuning.weight * (tuning.glyph === "bold" ? 2 : 1) + + return ( + + ) +} diff --git a/packages/app/src/session/session-identity-header.css b/packages/app/src/session/session-identity-header.css index 3f00df15df09..22232567e22c 100644 --- a/packages/app/src/session/session-identity-header.css +++ b/packages/app/src/session/session-identity-header.css @@ -42,9 +42,17 @@ padding-inline: 0; } - .session-header-location > .session-location-separator[data-variant="slash"] { - /* Together with the row's 2px gap and the title's 4px padding, leave 10px before the title. */ - padding-inline: 0 4px; + /* The icon-only trigger sits 4px from its icon and the row adds a 4px gap; the avatar's 2px margin counts too. */ + .session-header-location > .session-header-separator { + --separator-before: 10px; + } + + .session-header-location > .session-header-separator:dir(rtl) { + --separator-before: 9px; + } + + .session-header-location:has([data-slot="session-location-worktree"]) > .session-header-separator { + --separator-before: 9px; } /* Keep the names available to assistive tech while the icons leave room for the title. */ @@ -85,11 +93,16 @@ top: 48px; } -/* The "/" does not mirror and its side bearings differ, so each direction gets its own padding to centre the ink between the segments. */ -.session-location-separator { - padding-inline: 3.5px 3px; +.session-header-separator { + display: flex; + flex-shrink: 0; + align-items: center; + /* The neighbouring segments' own padding counts toward the gap; 0.5px is the text's side bearing. */ + margin-inline: calc(var(--separator-gap) - var(--separator-before, 6px) - 0.5px) + calc(var(--separator-gap) - var(--separator-after, 6px) - 0.5px); } -.session-location-separator:dir(rtl) { - padding-inline: 3.25px 3.5px; +.session-header-separator[data-glyph="chevron"]:dir(rtl) > svg, +.session-header-separator[data-glyph="bold"]:dir(rtl) > svg { + transform: scaleX(-1); } diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx index ec7f7cc752f9..c479c5131c10 100644 --- a/packages/app/src/session/session-identity-header.tsx +++ b/packages/app/src/session/session-identity-header.tsx @@ -29,6 +29,7 @@ import { isProjectDirectory, workspaceDirectories } from "@/workspaces/paths" import { sessionHref } from "@/shell/routes/session" import { showToast } from "@/shell/notifications/toast" import { sessionTitle } from "./title" +import { SessionHeaderSeparator } from "./header/separator" import { SessionWorkingIndicator } from "./header/session-working-indicator" import { CollapsedSidebarToggleSpace } from "@/shell/sidebar-toggle" import "./session-identity-header.css" @@ -341,13 +342,7 @@ export function SessionLocation(props: { - + ) @@ -408,32 +403,20 @@ export function SessionAncestorTrail(props: { data-session-id={ancestor.id} title={ancestor.title} class="max-w-[min(200px,40vw)] shrink-0 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted" - classList={{ "ps-1": index() === 0, "ps-2": index() > 0 }} + classList={{ "ps-1": index() === 0 }} onClick={() => open(props.sessionID, ancestor.id)} > {ancestor.title} - + )} - + ) From bf69ad01abf25cf5a4bf82243399c9ae4ae445f4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 21 Oct 2026 14:49:27 -0700 Subject: [PATCH 2/2] test(app): keep the narrow separator visible and hidden from assistive tech --- packages/app/e2e/regression/session-header.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/app/e2e/regression/session-header.spec.ts b/packages/app/e2e/regression/session-header.spec.ts index be6438591eb1..73c987221b51 100644 --- a/packages/app/e2e/regression/session-header.spec.ts +++ b/packages/app/e2e/regression/session-header.spec.ts @@ -238,6 +238,10 @@ test.describe("location", () => { }), ) .toEqual([10, 8]) + const separator = header.locator('[data-slot="session-title-separator"]') + await expect(separator).toBeVisible() + await expect(separator).toHaveText("") + await expect(separator).toHaveAttribute("aria-hidden", "true") const location = header.locator('[data-slot="session-location-trigger"]') await location.hover() await expect(location).toHaveCSS("padding-inline-start", "4px")