style(layout): align WorkspaceSwitcher avatar to the header logo recipe

Manual smoke after the logo-anchor fix (63300e5f) showed the footer
WorkspaceSwitcher used different spacing and a boxed avatar vs the
header logo. Applied the identical alignment recipe so the workspace
avatar mirrors the brand logo in both states (Option B from the
brief — bare square when collapsed, padded trigger only when
expanded).

Changes (WorkspaceSwitcher.vue only):
- Wrapper toggles `h-[56px] flex items-center px-4` when collapsed
  (mirrors the SidebarHeader brand row) and stays `p-2` when
  expanded (room for the padded trigger).
- Collapsed: a BARE rounded-lg avatar button at the same 16px left
  offset as the header logo. No `.trigger` container, no rounded
  hover-bg box wider than the avatar — the button IS the visible
  square (`.ws-logo .ws-logo-square w-8 h-8 rounded-lg`). True
  top/bottom mirror of the brand square.
- Expanded: unchanged padded `.trigger` button with avatar + name +
  chevron + hover bg. Avatar's left offset stays at 16px from the
  rail (wrapper p-2 + trigger p-2) so the expanded avatar also
  lines up vertically with the header logo.

Same alignment equation as the header recipe:

    rail_collapsed (64px) = square (32px) + 2 × px-4 (2 × 16px)

In both states the avatar's left edge sits at x=16px from the
rail's left — identical to the brand logo above. Vertical line
down the left side now reads as a single column of squares.

Desktop only. Mobile drawer chrome stays as MOBILE-SHELL-PARITY.

Tests adapted:
- `expanded trigger uses rounded-lg` (was tested in both states; the
  collapsed render no longer has a `.trigger` container).
- `expanded trigger has no justify-center` (split from the
  prior two-state assertion).
- New: `collapsed renders a bare avatar button (no .trigger
  container, just .ws-logo)` — locks the bare-square contract.
- New: `collapsed wrapper uses px-4` — locks the
  centring-equation invariant (rail=square+2×px-4) against
  accidental wrapper-padding regressions.

Suite delta: 563 → 564 (+1 net: +2 new collapsed-shape asserts,
−1 redundant two-state assert).

vue-tsc clean. Scoped ESLint clean (0 errors, pre-existing
warnings only). Manual smoke pending Bert — draw a vertical line
down the rail's left edge and verify the brand square and the
workspace square left edges sit on it in both states; in collapsed
mode verify the avatar is a bare square (no boxed button), same
visual treatment as the bare logo above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 01:08:41 +02:00
parent 63300e5fc9
commit a9c5746e12
2 changed files with 71 additions and 29 deletions

View File

@@ -134,23 +134,52 @@ function inviteUser(): void {
</script> </script>
<template> <template>
<div class="relative flex-shrink-0 border-t border-[var(--p-content-border-color)] p-2"> <!--
Wrapper height + horizontal padding mirror the SidebarHeader brand
row (`h-[56px] px-4`) in collapsed mode so the workspace square
appears as a true bottom mirror of the brand square: same 32px
rounded-lg square at the same 16px left offset from the rail,
centred in the 64px collapsed rail by the same equation
(rail = square + 2 × px-4). In expanded mode the wrapper relaxes
to `p-2` so the full trigger (avatar + name + chevron + rounded
hover bg) fits naturally.
Plan 2.5 P6-styling-switcher-align Option B: collapsed renders
a BARE clickable avatar (no trigger box, no hover-bg container)
so it visually mirrors the bare brand logo above. Expanded keeps
the padded trigger box for the name/chevron/hover affordance.
-->
<div
class="relative flex-shrink-0 border-t border-[var(--p-content-border-color)]"
:class="collapsed ? 'h-[56px] flex items-center px-4' : 'p-2'"
>
<!-- <!--
Trigger button. Collapsed: bare 32px rounded-lg button positioned at the same
Plan 2.5 P6-styling-fix: avatar stays anchored on collapse the `px-4` left offset as the brand logo. No trigger container, no
`justify-center` switch is gone (it caused the same flex-recentre hover bg wider than the avatar true mirror of the logo above.
slide as the SidebarHeader logo did). The trigger is ALWAYS The `.ws-logo .ws-logo-square` classes carry the scoped
left-aligned; the name + chevron sit to the RIGHT of the avatar inset-shadow (RFC §7.4) so the visual treatment matches the
and disappear via v-if on collapse without shifting the avatar. avatar in the expanded trigger.
Combined with the WorkspaceSwitcher wrapper's `p-2` (was
`p-[10px]`) the avatar's left offset is 8px (wrapper) + 8px
(trigger) = 16px identical to SidebarHeader's `px-4` logo
offset, so the brand square and the workspace avatar are
vertically aligned in the collapsed rail.
Radius is `rounded-lg` (8px) — matches the brand mark; crisper
than the prior `rounded-xl` per design review.
--> -->
<button <button
v-if="collapsed && current"
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] border-0 cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--p-primary-color)] focus-visible:outline-offset-2"
:style="{ background: `linear-gradient(135deg, ${current.gradient[0]}, ${current.gradient[1]})` }"
:aria-label="`Workspace: ${current.name}`"
aria-haspopup="true"
@click="toggle"
>
{{ current.initials }}
</button>
<!--
Expanded trigger: full-width padded button with rounded hover bg,
avatar on the left (at wrapper p-2 + trigger p-2 = 16px from rail,
matching the collapsed avatar's offset and the header logo above),
then name and chevron.
-->
<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 border-transparent bg-transparent p-2 text-[var(--p-text-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)]"
aria-haspopup="true" aria-haspopup="true"
@click="toggle" @click="toggle"
@@ -164,9 +193,9 @@ function inviteUser(): void {
{{ current.initials }} {{ current.initials }}
</span> </span>
<!-- Meta: name (hidden in collapsed mode). AD-2.5-W1 / P4: no sub. --> <!-- Meta: name (AD-2.5-W1 / P4: no sub). -->
<span <span
v-if="!collapsed && current" v-if="current"
class="meta flex flex-1 min-w-0 flex-col text-left leading-[1.2]" 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)]"> <span class="name truncate text-[13.5px] font-semibold text-[var(--p-text-color)]">
@@ -175,7 +204,6 @@ function inviteUser(): void {
</span> </span>
<Icon <Icon
v-if="!collapsed"
name="tabler-chevron-down" name="tabler-chevron-down"
:size="14" :size="14"
class="flex-shrink-0 text-[var(--p-text-muted-color)]" class="flex-shrink-0 text-[var(--p-text-muted-color)]"

View File

@@ -88,30 +88,44 @@ describe('WorkspaceSwitcher', () => {
}) })
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// P6-followup-styling — trigger uses rounded-xl + collapsed = avatar only // 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.
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
it('trigger uses rounded-lg (matches the brand square in SidebarHeader)', () => { it('expanded trigger uses rounded-lg (matches the brand square in SidebarHeader)', () => {
const wrapper = mountSwitcher() const wrapper = mountSwitcher({ collapsed: false })
expect(wrapper.find('.trigger').classes()).toContain('rounded-lg') expect(wrapper.find('.trigger').classes()).toContain('rounded-lg')
}) })
it('trigger has no justify-center class in either state (avatar stays anchored on collapse)', () => { it('expanded trigger has no justify-center (avatar stays anchored on the left)', () => {
const expanded = mountSwitcher({ collapsed: false }) const wrapper = mountSwitcher({ collapsed: false })
expect(expanded.find('.trigger').classes()).not.toContain('justify-center') expect(wrapper.find('.trigger').classes()).not.toContain('justify-center')
const collapsed = mountSwitcher({ collapsed: true })
expect(collapsed.find('.trigger').classes()).not.toContain('justify-center')
}) })
it('collapsed trigger renders avatar only, hides name + chevron', () => { it('collapsed renders a bare avatar button (no .trigger container, just .ws-logo)', () => {
const wrapper = mountSwitcher({ collapsed: true }) const wrapper = mountSwitcher({ collapsed: true })
expect(wrapper.find('.ws-logo').exists()).toBe(true) // The padded trigger container with name/chevron is gone — no .trigger,
// no .meta. The avatar IS the button (.ws-logo + .ws-logo-square on the
// bare <button>), so it visually mirrors the bare SidebarHeader logo.
expect(wrapper.find('.trigger').exists()).toBe(false)
expect(wrapper.find('.meta').exists()).toBe(false) expect(wrapper.find('.meta').exists()).toBe(false)
expect(wrapper.find('button.ws-logo').exists()).toBe(true)
})
it('collapsed wrapper uses px-4 (matches the SidebarHeader brand row offset)', () => {
const wrapper = 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.ws-logo').element.parentElement
expect(root?.classList.contains('px-4')).toBe(true)
expect(root?.classList.contains('h-[56px]')).toBe(true)
}) })
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------