fix(layout): WorkspaceSwitcher crewli-starter parity — no avatar jump, is-open bg, no resting border

Diagnosed against the crewli-starter SoT
(crewli-starter/src/components/layout/WorkspaceSwitcher.vue +
main.css .ws-switcher rules). Three issues Bert flagged in manual
smoke, one root cause:

ROOT CAUSE — the prior version swapped between TWO separate <button>
elements on collapse (a bare collapsed button vs a padded expanded
trigger) with different box models. Their vertical padding differed,
so the avatar's distance from the rail bottom changed by ~6px and
visibly jumped on collapse. crewli-starter instead renders ONE
`.trigger` button in both states and only hides `.meta` + `.chev`
(+ recentres) on collapse.

Fixes (all by adopting the single-trigger structure):
- Avatar no longer jumps: one `.trigger` button always; `py-2`
  vertical padding constant across states; only horizontal
  padding/justify changes on collapse. Avatar's vertical box is now
  identical collapsed vs expanded.
- No resting border: trigger is `border-transparent` at rest and on
  hover (hover only adds the grey bg), matching crewli-starter
  `.trigger:hover { background }`. The wrapper has only the `border-t`
  separator, no box border.
- is-open persistence: new `isOpen` ref synced from the PrimeVue
  Popover `@show`/`@hide` events. While the dropdown is open the
  trigger keeps the grey bg AND shows a visible border, matching
  crewli-starter `.ws-switcher.is-open .trigger { background;
  border-color }`. Persists until the popover closes (covers
  programmatic hide via selectOrg + outside-click dismissal).
- Hover padding: trigger `px-[10px] py-2` inside a `p-[10px]` wrapper
  reproduces crewli-starter's generous hover-card inset
  (`.ws-switcher { padding:10px } .trigger { padding:8px 10px }`).

Collapsed alignment preserved: wrapper p-[10px] + trigger
`justify-center px-0` centres the 32px avatar at 16px from the rail's
left edge — still aligned with the SidebarHeader brand logo (px-4).
At rest the collapsed trigger is transparent, so it still reads as a
bare square mirroring the header logo; hover/open add the card.

Specs: replaced the now-obsolete "bare avatar button / no .trigger"
+ "collapsed wrapper px-4" locks with crewli-starter-structure specs:
single .trigger in both states, collapsed centres the lone avatar +
hides .meta/.chev, py-2 constant (the no-jump lock), and is-open
keeps bg+border. Sub-line + dropdown specs unchanged.

Suite delta: 569 → 571 (+2). 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:
2026-05-21 22:31:44 +02:00
parent 2fd3c9ea66
commit 9f26215e54
2 changed files with 102 additions and 77 deletions

View File

@@ -120,6 +120,16 @@ const allOrgs = computed<WorkspaceDisplay[]>(() => {
const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
/**
* Tracks the popover open state so the trigger keeps the grey "active"
* background + visible border while the workspace dropdown is open
* (crewli-starter `.ws-switcher.is-open .trigger`). Synced from the
* PrimeVue Popover's @show/@hide events rather than mirroring the manual
* toggle, so programmatic hides (selectOrg) and outside-click dismissal
* both keep it accurate.
*/
const isOpen = ref(false)
function toggle(event: MouseEvent): void {
popoverRef.value?.toggle(event)
}
@@ -151,65 +161,46 @@ function inviteUser(): void {
<template>
<!--
Wrapper horizontal padding is `px-4` (16px) in BOTH states same
rhythm as the SidebarHeader brand row above (`h-[56px] px-4`).
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.
Collapsed: wrapper is `h-[56px] flex items-center px-4`. The bare
32px rounded-lg avatar sits at the px-4 offset, centred in the
64px rail by the equation (rail = square + 2 × px-4). True bottom
mirror of the brand square (see Option B in the brief).
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.
Expanded: wrapper is `px-4 py-2`. The full padded trigger (`w-full`
rounded card) is now inset 16px from both rail edges, giving
symmetric breathing room that matches the header's horizontal
rhythm — prior version used `p-2` (8px) and the card felt stuck
to the rail edges. Trade-off: with px-4 wrapper + p-2 internal
trigger padding the avatar sits ~24px from the rail edge in
expanded, ~8px deeper than the bare header logo at 16px — accepted
as deliberate (the expanded trigger reads as a distinct button
card; the bare-square mirror with the logo lives in the collapsed
state, which is unchanged).
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).
-->
<div
class="relative flex-shrink-0 border-t border-[var(--p-content-border-color)]"
:class="collapsed ? 'h-[56px] flex items-center px-4' : 'px-4 py-2'"
>
<div class="ws-switcher relative flex-shrink-0 border-t border-[var(--p-content-border-color)] p-[10px]">
<!--
Collapsed: a bare click target (transparent, p-0, no box) wrapping
the EXACT SAME avatar span markup the expanded trigger uses, so the
32px square is byte-identical across states (same classes, same
gradient, same `.ws-logo-square` inset-shadow). P6-styling-switcher-sub
Fix A: previously the collapsed avatar styling lived directly on the
<button>, which let user-agent button rendering diverge subtly from
the expanded <span> avatar. Extracting the shared span removes that
drift. The wrapping button only adds the click target + focus ring.
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.
-->
<button
v-if="collapsed && current"
class="flex-shrink-0 border-0 bg-transparent p-0 cursor-pointer rounded-lg inline-flex focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-2"
:aria-label="`Workspace: ${current.name}`"
aria-haspopup="true"
@click="toggle"
>
<span
class="ws-logo ws-logo-square w-8 h-8 flex-shrink-0 rounded-lg inline-flex items-center justify-center text-white font-bold text-[12px]"
:style="{ background: `linear-gradient(135deg, ${current.gradient[0]}, ${current.gradient[1]})` }"
>
{{ current.initials }}
</span>
</button>
<!--
Expanded trigger: full-width padded button with rounded hover bg,
avatar on the left, then name and chevron. With the wrapper at
px-4 and the trigger at internal p-2, the avatar sits ~24px from
the rail edge (intentional — see wrapper comment above). The
bare-square / logo mirror lives in the collapsed state.
-->
<button
v-else-if="!collapsed"
class="trigger flex w-full items-center gap-[10px] rounded-lg border border-transparent bg-transparent p-2 text-[var(--p-text-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)]"
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)]',
]"
aria-haspopup="true"
:aria-expanded="isOpen"
:aria-label="collapsed && current ? `Workspace: ${current.name}` : undefined"
@click="toggle"
>
<!-- Avatar square — shared treatment with SidebarHeader logo (h-8 w-8 rounded-lg). Gradient background is bespoke per organisation (dynamic hex pair, RFC §7.4 inline-style). -->
@@ -221,9 +212,9 @@ function inviteUser(): void {
{{ current.initials }}
</span>
<!-- Meta: name + placeholder sub line (P6-styling-switcher-sub). -->
<!-- Meta: name + placeholder sub line (hidden when collapsed). -->
<span
v-if="current"
v-if="!collapsed && current"
class="meta flex flex-1 min-w-0 flex-col text-left leading-[1.2]"
>
<span class="name truncate text-[13.5px] font-semibold text-[var(--p-text-color)]">
@@ -234,15 +225,21 @@ function inviteUser(): void {
</span>
</span>
<!-- Chevron: hidden when collapsed. -->
<Icon
v-if="!collapsed"
name="tabler-chevron-down"
:size="14"
class="flex-shrink-0 text-[var(--p-text-muted-color)]"
class="chev flex-shrink-0 text-[var(--p-text-muted-color)]"
/>
</button>
<!-- PrimeVue Popover — replaces crewli-starter's manual document.mousedown click-outside -->
<Popover ref="popoverRef">
<Popover
ref="popoverRef"
@show="isOpen = true"
@hide="isOpen = false"
>
<!-- Header -->
<div class="popover-head flex items-center justify-between border-b border-[var(--p-content-border-color)] px-[16px] py-[14px]">
<span class="title text-[15px] font-bold tracking-[-0.01em]">Workspaces</span>

View File

@@ -43,6 +43,7 @@ const globalStubs = {
Icon: { template: '<span class="icon-stub" />' },
Popover: {
name: 'Popover',
emits: ['show', 'hide'],
template: '<div class="popover-stub"><slot /></div>',
methods: {
toggle() {},
@@ -112,46 +113,73 @@ describe('WorkspaceSwitcher', () => {
})
// -------------------------------------------------------------------------
// P6-styling-switcher-align — expanded trigger uses rounded-lg + has no
// justify-center; collapsed renders a BARE avatar button (no .trigger
// container) mirroring the SidebarHeader brand square's offset.
// 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.
// -------------------------------------------------------------------------
it('expanded trigger uses rounded-lg (matches the brand square in SidebarHeader)', () => {
it('uses a single .trigger button in both states (no element swap on collapse)', () => {
const expanded = mountSwitcher({ collapsed: false })
const collapsed = mountSwitcher({ collapsed: true })
expect(expanded.find('.trigger').exists()).toBe(true)
expect(collapsed.find('.trigger').exists()).toBe(true)
// The avatar square lives inside the same .trigger in both states.
expect(expanded.find('.trigger .ws-logo').exists()).toBe(true)
expect(collapsed.find('.trigger .ws-logo').exists()).toBe(true)
})
it('trigger uses rounded-lg (matches the brand square in SidebarHeader)', () => {
const wrapper = mountSwitcher({ collapsed: false })
expect(wrapper.find('.trigger').classes()).toContain('rounded-lg')
})
it('expanded trigger has no justify-center (avatar stays anchored on the left)', () => {
it('expanded trigger has no justify-center (avatar + meta anchored on the left)', () => {
const wrapper = mountSwitcher({ collapsed: false })
expect(wrapper.find('.trigger').classes()).not.toContain('justify-center')
})
it('collapsed renders a bare avatar button (no .trigger container, no .meta)', () => {
it('collapsed trigger centres the lone avatar and hides .meta + .chev', () => {
const wrapper = mountSwitcher({ collapsed: true })
// The padded trigger container with name/chevron is gone — no .trigger,
// no .meta. P6-styling-switcher-sub Fix A: the avatar square is now an
// inner <span class="ws-logo"> wrapped by a bare transparent <button>
// (so the square markup is byte-identical to the expanded trigger).
expect(wrapper.find('.trigger').exists()).toBe(false)
const trigger = wrapper.find('.trigger')
expect(trigger.classes()).toContain('justify-center')
expect(wrapper.find('.meta').exists()).toBe(false)
expect(wrapper.find('button').exists()).toBe(true)
expect(wrapper.find('button .ws-logo').exists()).toBe(true)
expect(wrapper.find('.chev').exists()).toBe(false)
expect(trigger.find('.ws-logo').exists()).toBe(true)
})
it('collapsed wrapper uses px-4 (matches the SidebarHeader brand row offset)', () => {
const wrapper = mountSwitcher({ collapsed: 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 })
// The wrapper toggles to `h-[56px] flex items-center px-4` when
// collapsed so the avatar's left edge lands at 16px from the rail
// — identical to the header logo offset.
const root = wrapper.find('button').element.parentElement
expect(expanded.find('.trigger').classes()).toContain('py-2')
expect(collapsed.find('.trigger').classes()).toContain('py-2')
})
expect(root?.classList.contains('px-4')).toBe(true)
expect(root?.classList.contains('h-[56px]')).toBe(true)
it('open state keeps the trigger background + border (is-open persists hover bg)', 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')
// Simulate the Popover @show -> isOpen=true.
await wrapper.findComponent({ name: 'Popover' }).vm.$emit('show')
const trigger = wrapper.find('.trigger')
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)]')
})
// -------------------------------------------------------------------------