style(layout): borderless WorkspaceSwitcher with hover/open background; fix avatar jump
Matches the crewli-starter SoT and fixes the recurring collapse jump at
its root cause. The prior structures left a residual avatar shift:
- the original split put the avatar at 24px expanded (wrapper px-4 +
card p-2) vs 16px collapsed (bare square) — an 8px horizontal jump;
- the interim single-trigger variant used wrapper p-[10px] + trigger
px-[10px] expanded (~20px) vs justify-center collapsed (16px) — a
~4px residual horizontal shift.
Unified both states to a single symmetric structure:
avatar offset = wrapper px-2 (8px) + trigger p-2 (8px) = 16px
16px from the rail's left edge in BOTH states — identical to the
SidebarHeader brand logo. Because the padding is symmetric (8 + 8 each
side) and the collapsed rail is 64px = 16 + 32 + 16, the left-aligned
avatar is also visually centred when collapsed — no justify-center,
no px swap, no horizontal shift; constant vertical padding, no vertical
shift. The jump is gone at the root.
Borderless: the trigger has NO border in any state (the prior is-open
border is dropped per the starter screenshots). The only divider is the
wrapper's border-t between the switcher and the nav. The grey
background is the sole fill — transparent at rest, grey on hover, and
grey while the popover is open (isOpen wired to Popover @show/@hide).
The trigger's p-2 gives the grey background generous padding around the
avatar+text, matching the starter's hover treatment, and since it is
the button's own background it never moves the content.
Specs reworked: trigger p-2 identical across states (no px swap / no
justify-center — the no-jump lock), wrapper carries p-2, trigger is
borderless at rest AND while open, open-state grey background applies
on @show and clears on @hide. Single-.trigger / rounded-lg / collapsed-
hides-meta+chev / sub-line specs retained.
Suite delta: 571 → 571 (specs reworked, count unchanged). vue-tsc
clean. Scoped ESLint clean (0 errors). Desktop only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -161,43 +161,41 @@ function inviteUser(): void {
|
||||
|
||||
<template>
|
||||
<!--
|
||||
Crewli-starter-faithful structure (P6-styling-switcher-sub follow-up).
|
||||
ONE `.trigger` button renders in BOTH states — collapsing only hides
|
||||
`.meta` + `.chev` (v-if) and recentres the lone avatar. This is the
|
||||
key fix for the vertical "avatar jump": the prior version swapped
|
||||
between two separate <button> elements (a bare collapsed button vs a
|
||||
padded expanded trigger) with different box models, so the avatar's
|
||||
distance from the rail bottom differed by ~6px and visibly jumped on
|
||||
collapse. With a single trigger and constant vertical padding
|
||||
(`py-2` always), the avatar stays put.
|
||||
Unified borderless structure (P6-styling-switcher-hover, crewli-starter
|
||||
SoT). ONE `.trigger` button renders in BOTH states; collapsing only
|
||||
hides `.meta` + `.chev` (v-if). The button keeps the SAME padding in
|
||||
both states — this is what kills the avatar jump:
|
||||
|
||||
Wrapper: `border-t` separator + `p-[10px]` ALWAYS (constant vertical
|
||||
padding → no jump; matches crewli-starter `.ws-switcher { padding:
|
||||
10px }`). No border around the wrapper itself.
|
||||
avatar offset = wrapper px-2 (8px) + trigger p-2 (8px) = 16px
|
||||
|
||||
Centring equation (collapsed): wrapper p-[10px] gives the trigger a
|
||||
44px content width inside the 64px rail; the trigger's `justify-center
|
||||
px-0` centres the 32px avatar → 16px from the rail's left edge,
|
||||
aligned with the SidebarHeader brand logo above (also 16px).
|
||||
16px from the rail's left edge in BOTH collapsed and expanded —
|
||||
identical to the SidebarHeader brand logo (px-4). Because the
|
||||
horizontal padding is symmetric (8 + 8 each side) and the rail
|
||||
collapses to 64px = 16 + 32 (avatar) + 16, the left-aligned avatar
|
||||
is ALSO visually centred when collapsed — no justify-center swap, no
|
||||
horizontal shift. Vertical padding is likewise constant, so no
|
||||
vertical shift either. The earlier 24px-expanded / 16px-collapsed
|
||||
split (and the later px-[10px]/justify-center variant) both left a
|
||||
residual shift; this symmetric structure removes it entirely.
|
||||
|
||||
Borderless: the trigger has NO border in any state (the prior
|
||||
is-open border is dropped per the starter screenshots). The only
|
||||
divider is the wrapper's `border-t` separating the switcher from the
|
||||
nav above. The grey background is the sole fill, on hover OR while
|
||||
the popover is open.
|
||||
-->
|
||||
<div class="ws-switcher relative flex-shrink-0 border-t border-[var(--p-content-border-color)] p-[10px]">
|
||||
<div class="ws-switcher relative flex-shrink-0 border-t border-[var(--p-content-border-color)] p-2">
|
||||
<!--
|
||||
Trigger — single button for both states (crewli-starter `.trigger`).
|
||||
Resting: transparent border + transparent bg. Hover: grey bg, no
|
||||
border. Open (is-open): grey bg KEPT + visible border, persisting
|
||||
until the popover closes — crewli-starter
|
||||
`.ws-switcher.is-open .trigger { background; border-color }`.
|
||||
`py-2` is constant; only horizontal padding / justify changes on
|
||||
collapse, so the avatar never shifts vertically.
|
||||
Trigger — single button for both states. Transparent at rest; grey
|
||||
on hover; grey KEPT while the popover is open (isOpen, wired to the
|
||||
Popover @show/@hide). The `p-2` gives the grey background generous
|
||||
padding around the avatar+text (crewli-starter hover treatment),
|
||||
and because the background is the button's own bg (not a separate
|
||||
shifting layer) it never moves the avatar.
|
||||
-->
|
||||
<button
|
||||
class="trigger flex w-full items-center gap-[10px] rounded-lg border bg-transparent py-2 text-[var(--p-text-color)] transition-[background,border-color] duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-2"
|
||||
:class="[
|
||||
collapsed ? 'justify-center px-0' : 'px-[10px]',
|
||||
isOpen
|
||||
? 'is-open border-[var(--p-content-border-color)] bg-[var(--p-content-hover-background)]'
|
||||
: 'border-transparent hover:bg-[var(--p-content-hover-background)]',
|
||||
]"
|
||||
class="trigger flex w-full items-center gap-[10px] rounded-lg bg-transparent p-2 text-[var(--p-text-color)] transition-colors duration-150 hover:bg-surface-100 dark:hover:bg-surface-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-2"
|
||||
:class="{ 'bg-surface-100 dark:bg-surface-800': isOpen }"
|
||||
aria-haspopup="true"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-label="collapsed && current ? `Workspace: ${current.name}` : undefined"
|
||||
|
||||
@@ -113,10 +113,10 @@ describe('WorkspaceSwitcher', () => {
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Crewli-starter-faithful structure: ONE .trigger button in both states.
|
||||
// Collapsing hides .meta + .chev and centres the lone avatar; it does NOT
|
||||
// swap to a separate bare button (that swap caused the vertical avatar
|
||||
// jump). The avatar square renders inside the same .trigger always.
|
||||
// Unified borderless structure (P6-styling-switcher-hover, crewli-starter
|
||||
// SoT). ONE .trigger button in both states with IDENTICAL padding
|
||||
// (wrapper p-2 + trigger p-2 = avatar at 16px in both states → no jump).
|
||||
// No border on the trigger; grey background on hover OR while open.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('uses a single .trigger button in both states (no element swap on collapse)', () => {
|
||||
@@ -137,49 +137,68 @@ describe('WorkspaceSwitcher', () => {
|
||||
expect(wrapper.find('.trigger').classes()).toContain('rounded-lg')
|
||||
})
|
||||
|
||||
it('expanded trigger has no justify-center (avatar + meta anchored on the left)', () => {
|
||||
const wrapper = mountSwitcher({ collapsed: false })
|
||||
it('trigger padding (p-2) is identical across states — avatar stays at 16px (no jump)', () => {
|
||||
// The jump fix: the trigger keeps p-2 in BOTH states (no px swap, no
|
||||
// justify-center). With the wrapper at p-2, the avatar offset is a
|
||||
// constant 8 + 8 = 16px from the rail edge — no horizontal OR vertical
|
||||
// shift on collapse.
|
||||
const expanded = mountSwitcher({ collapsed: false })
|
||||
const collapsed = mountSwitcher({ collapsed: true })
|
||||
|
||||
expect(wrapper.find('.trigger').classes()).not.toContain('justify-center')
|
||||
expect(expanded.find('.trigger').classes()).toContain('p-2')
|
||||
expect(collapsed.find('.trigger').classes()).toContain('p-2')
|
||||
|
||||
// No state-conditional horizontal padding / centring remains.
|
||||
for (const w of [expanded, collapsed]) {
|
||||
const cls = w.find('.trigger').classes()
|
||||
|
||||
expect(cls).not.toContain('justify-center')
|
||||
expect(cls).not.toContain('px-0')
|
||||
expect(cls).not.toContain('px-[10px]')
|
||||
}
|
||||
|
||||
// Wrapper carries p-2 too (the other half of the 16px offset).
|
||||
expect(expanded.find('.ws-switcher').classes()).toContain('p-2')
|
||||
})
|
||||
|
||||
it('collapsed trigger centres the lone avatar and hides .meta + .chev', () => {
|
||||
it('collapsed hides .meta + .chev; the avatar stays in the same p-2 trigger', () => {
|
||||
const wrapper = mountSwitcher({ collapsed: true })
|
||||
|
||||
const trigger = wrapper.find('.trigger')
|
||||
|
||||
expect(trigger.classes()).toContain('justify-center')
|
||||
expect(wrapper.find('.meta').exists()).toBe(false)
|
||||
expect(wrapper.find('.chev').exists()).toBe(false)
|
||||
expect(trigger.find('.ws-logo').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('trigger vertical padding (py-2) is constant across states (no avatar jump)', () => {
|
||||
// The vertical-jump fix: py-2 must be present in BOTH states so the
|
||||
// avatar's vertical box is identical. Only horizontal padding/justify
|
||||
// changes on collapse.
|
||||
const expanded = mountSwitcher({ collapsed: false })
|
||||
const collapsed = mountSwitcher({ collapsed: true })
|
||||
|
||||
expect(expanded.find('.trigger').classes()).toContain('py-2')
|
||||
expect(collapsed.find('.trigger').classes()).toContain('py-2')
|
||||
})
|
||||
|
||||
it('open state keeps the trigger background + border (is-open persists hover bg)', async () => {
|
||||
it('the trigger is borderless in every state (no card outline)', async () => {
|
||||
const wrapper = mountSwitcher({ collapsed: false })
|
||||
|
||||
// Resting: transparent border, no is-open marker.
|
||||
expect(wrapper.find('.trigger').classes()).toContain('border-transparent')
|
||||
expect(wrapper.find('.trigger').classes()).not.toContain('is-open')
|
||||
const restingHasBorder = wrapper.find('.trigger').classes().some(c => c.startsWith('border'))
|
||||
|
||||
// Simulate the Popover @show -> isOpen=true.
|
||||
expect(restingHasBorder).toBe(false)
|
||||
|
||||
// Still borderless while open — the grey background is the only fill.
|
||||
await wrapper.findComponent({ name: 'Popover' }).vm.$emit('show')
|
||||
|
||||
const trigger = wrapper.find('.trigger')
|
||||
const openHasBorder = wrapper.find('.trigger').classes().some(c => c.startsWith('border'))
|
||||
|
||||
expect(trigger.classes()).toContain('is-open')
|
||||
expect(trigger.classes()).toContain('bg-[var(--p-content-hover-background)]')
|
||||
expect(trigger.classes()).toContain('border-[var(--p-content-border-color)]')
|
||||
expect(openHasBorder).toBe(false)
|
||||
})
|
||||
|
||||
it('open state applies the grey background and keeps it until hide', async () => {
|
||||
const wrapper = mountSwitcher({ collapsed: false })
|
||||
|
||||
// Resting: no open-state background marker.
|
||||
expect(wrapper.find('.trigger').classes()).not.toContain('bg-surface-100')
|
||||
|
||||
// @show -> isOpen=true -> grey background applied.
|
||||
await wrapper.findComponent({ name: 'Popover' }).vm.$emit('show')
|
||||
expect(wrapper.find('.trigger').classes()).toContain('bg-surface-100')
|
||||
|
||||
// @hide -> isOpen=false -> background removed.
|
||||
await wrapper.findComponent({ name: 'Popover' }).vm.$emit('hide')
|
||||
expect(wrapper.find('.trigger').classes()).not.toContain('bg-surface-100')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user