feat(app): try header separators and spacing from Knobs (#57925)

This commit is contained in:
Kit Langton
2026-10-21 14:59:31 -07:00
committed by GitHub
Unverified
parent 4091a02613
commit cf562213a3
4 changed files with 159 additions and 45 deletions
@@ -229,28 +229,19 @@ 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 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")
@@ -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 (
<span
data-slot="session-title-separator"
data-glyph={tuning.glyph}
class="session-header-separator text-v2-text-text-faint"
style={{
"--separator-gap": `${tuning.gap}px`,
"--separator-before": props.before === undefined ? undefined : `${props.before}px`,
"--separator-after": props.after === undefined ? undefined : `${props.after}px`,
opacity: tuning.opacity,
}}
aria-hidden="true"
>
<Switch>
<Match when={tuning.glyph === "divider"}>
<span class="block rounded-full bg-current" style={{ width: `${width()}px`, height: `${tuning.size}px` }} />
</Match>
<Match when={tuning.glyph === "dot"}>
<span class="block rounded-full bg-current" style={{ width: `${width()}px`, height: `${width()}px` }} />
</Match>
<Match when={tuning.glyph === "slash"}>
<svg
class="block fill-none stroke-current"
width={tuning.size * 0.4}
height={tuning.size}
viewBox={`0 0 ${tuning.size * 0.4} ${tuning.size}`}
stroke-width={tuning.weight}
stroke-linecap="round"
>
<path
d={`M${tuning.weight / 2} ${tuning.size - tuning.weight / 2}L${tuning.size * 0.4 - tuning.weight / 2} ${tuning.weight / 2}`}
/>
</svg>
</Match>
<Match when={true}>
<svg
class="block fill-none stroke-current"
width={width()}
height={tuning.size}
viewBox={`0 0 ${width()} ${tuning.size}`}
stroke-width={stroke()}
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d={`M${stroke() / 2} ${stroke() / 2}L${width() - stroke() / 2} ${tuning.size / 2}L${stroke() / 2} ${tuning.size - stroke() / 2}`}
/>
</svg>
</Match>
</Switch>
</span>
)
}
@@ -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);
}
@@ -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: {
</Menu.Portal>
</Menu>
<Show when={props.trailing}>
<span
data-slot="session-title-separator"
class="session-location-separator shrink-0 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
<SessionHeaderSeparator />
</Show>
</>
)
@@ -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)}
>
<bdi dir="auto">{ancestor.title}</bdi>
</button>
<Show when={index() < ancestors().length - 1}>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] shrink-0 ps-2 pe-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
<SessionHeaderSeparator before={0} after={0} />
</Show>
</>
)}
</For>
</div>
<Show when={props.trailing}>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] shrink-0 ps-2 pe-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
<SessionHeaderSeparator before={0} />
</Show>
</div>
)