style(layout): unify workspace avatar with header logo (size/offset/centering parity)

Holistic parity pass after repeated piecemeal switcher tweaks kept
diverging from the header logo. Side-by-side audit found size, offset
and collapsed-centering already matched (both 32px at 16px); the real
divergences were (a) the inset-shadow opacity (logo 8% vs avatar 10%)
sourced from TWO separate scoped-CSS blocks (drift risk), and (b) the
hover-bg inset was only 8px ("stuck against the edge").

- Shared brand-square recipe: both the SidebarHeader logo and the
  WorkspaceSwitcher avatar now use the SAME Tailwind utilities
  `w-8 h-8 rounded-lg shadow-[inset_0_-2px_0_#00000014]`. Single
  source (the utility classes) so size/radius/shadow can't drift
  again. The two per-component scoped `.mark` / `.ws-logo-square`
  box-shadow rules are deleted (the dropdown's larger
  `.ws-logo-square-lg` stays scoped — out of scope). Only the gradient
  differs by design (brand teal vs per-org).

- Breathing room: the avatar's horizontal offset is pinned at 16px by
  the collapsed rail (64px = 32px square + 2×16px → the only offset
  that centres the square AND matches the logo's px-4). Within that
  fixed 16px, the budget is split inset + internal padding: wrapper
  px-3 (12px hover-bg inset for breathing room, was 8px) + trigger
  px-1 (4px internal). Vertical is unconstrained → py-2 both for a
  generous hover-bg height. The offset stays 16px so logo↔avatar
  parity and the no-jump invariant are preserved; only the hover-bg
  inset grew from 8px to 12px.

Note: the prompt's 20px-offset option is incompatible with the fixed
64px collapsed rail (20+32+20≠64 → breaks centring + reintroduces a
jump), so the 16px-offset / 12px-inset path was taken per the brief's
stated alternative.

Specs: new cross-component parity spec mounts BOTH components and
asserts the avatar + logo share the exact w-8/h-8/rounded-lg/shadow
utilities; padding spec updated to px-3 wrapper + px-1 trigger.
Borderless + hover/open-bg + sub specs retained.

Suite delta: 571 → 572 (+1). 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 23:12:06 +02:00
parent 9f56fb1112
commit 30da66456a
3 changed files with 83 additions and 44 deletions

View File

@@ -81,8 +81,15 @@ function handleCollapseClick(): void {
2 × 16px padding). No justify-content switching.
-->
<div class="flex items-center gap-[10px] h-[56px] px-4 border-b border-[var(--p-content-border-color)]">
<!-- Brand square — shared treatment with WorkspaceSwitcher avatar (h-8 w-8 rounded-lg). -->
<span class="brand-mark mark w-8 h-8 flex-shrink-0 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 inline-flex items-center justify-center text-white font-bold text-sm">
<!--
Brand square. Size + radius + inset-shadow are the SHARED brand-square
recipe — identical Tailwind utilities to the WorkspaceSwitcher avatar
(`w-8 h-8 rounded-lg shadow-[inset_0_-2px_0_#00000014]`) so the two
squares cannot drift (single source = the utility classes, not
per-component scoped CSS). Only the gradient differs by design: the
brand mark is always Crewli teal; the workspace avatar is per-org.
-->
<span class="brand-mark mark w-8 h-8 flex-shrink-0 rounded-lg shadow-[inset_0_-2px_0_#00000014] bg-gradient-to-br from-primary-500 to-primary-700 inline-flex items-center justify-center text-white font-bold text-sm">
C
</span>
@@ -143,15 +150,3 @@ function handleCollapseClick(): void {
</div>
</div>
</template>
<style scoped>
/*
* The .mark inset box-shadow (inset 0 -2px 0 rgba(0,0,0,0.08)) has no
* Tailwind equivalent — Tailwind's shadow utilities only support outer shadows
* and cannot express inset directional shadows at this opacity. Last resort per
* RFC §7.4.
*/
.mark {
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 8%);
}
</style>

View File

@@ -184,27 +184,42 @@ function inviteUser(): void {
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-2">
<!--
Breathing-room budget (P6-styling-switcher-parity): the avatar's
horizontal offset is pinned at 16px (collapsed rail 64px = 32px square
+ 2 × 16px → the only offset that centres the square AND matches the
header logo's px-4). Within that fixed 16px, the offset splits into
(hover-bg inset) + (internal padding): wrapper px-3 (12px inset for
breathing room was 8px, read as stuck to the edge) + trigger px-1
(4px internal). Vertical is unconstrained, so py-2 both for a
generous hover-bg height.
-->
<div class="ws-switcher relative flex-shrink-0 border-t border-[var(--p-content-border-color)] px-3 py-2">
<!--
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.
Popover @show/@hide). px-1 py-2 internal padding; the avatar lands
at wrapper px-3 (12) + trigger px-1 (4) = 16px from the rail edge,
identical to the SidebarHeader logo. Because the bg is the button's
own bg (not a shifting layer) it never moves the avatar.
-->
<button
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="trigger flex w-full items-center gap-[10px] rounded-lg bg-transparent px-1 py-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"
@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). -->
<!--
Avatar square — SHARED brand-square recipe with the SidebarHeader
logo: identical `w-8 h-8 rounded-lg shadow-[inset_0_-2px_0_#00000014]`
utilities (single source = the utility classes, so the two squares
cannot drift). Only the gradient is per-org (dynamic hex pair, inline).
-->
<span
v-if="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]"
class="ws-logo ws-logo-square w-8 h-8 flex-shrink-0 rounded-lg shadow-[inset_0_-2px_0_#00000014] 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 }}
@@ -326,18 +341,11 @@ function inviteUser(): void {
<style scoped>
/**
* ws-logo-square — width/height moved to Tailwind (w-8 h-8 on the element).
* Tailwind has no inset directional box-shadow utility at this granularity →
* scoped CSS last resort per RFC §7.4.
*/
.ws-logo-square {
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);
}
/**
* ws-logo-square-lg — width/height moved to Tailwind (w-9 h-9 on the element).
* Same box-shadow exception as above (inset shadow has no Tailwind equivalent).
* Background gradient set via inline :style on the element (dynamic hex pair).
* ws-logo-square-lg — the larger (w-9 h-9, 36px) avatar variant used ONLY
* in the dropdown rows. Its inset box-shadow stays scoped here (dropdown
* panel, out of the brand-square-parity scope). The trigger avatar +
* SidebarHeader logo share the `shadow-[inset_0_-2px_0_#00000014]`
* Tailwind utility instead (single source, can't drift).
*/
.ws-logo-square-lg {
box-shadow: inset 0 -2px 0 rgb(0 0 0 / 10%);

View File

@@ -20,6 +20,7 @@ import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import { useAuthStore } from '@/stores/useAuthStore'
import WorkspaceSwitcher from '@/components-v2/layout/WorkspaceSwitcher.vue'
import SidebarHeader from '@/components-v2/layout/SidebarHeader.vue'
import type { Organisation, User } from '@/types/auth'
const userFixture: User = {
@@ -137,16 +138,18 @@ describe('WorkspaceSwitcher', () => {
expect(wrapper.find('.trigger').classes()).toContain('rounded-lg')
})
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.
it('trigger padding is identical across states — avatar stays at 16px (no jump)', () => {
// The jump fix: the trigger keeps the SAME padding in BOTH states (no
// px swap, no justify-center). wrapper px-3 (12) + trigger px-1 (4) =
// a constant 16px avatar offset from the rail edge — no horizontal OR
// vertical shift on collapse.
const expanded = mountSwitcher({ collapsed: false })
const collapsed = mountSwitcher({ collapsed: true })
expect(expanded.find('.trigger').classes()).toContain('p-2')
expect(collapsed.find('.trigger').classes()).toContain('p-2')
expect(expanded.find('.trigger').classes()).toContain('px-1')
expect(expanded.find('.trigger').classes()).toContain('py-2')
expect(collapsed.find('.trigger').classes()).toContain('px-1')
expect(collapsed.find('.trigger').classes()).toContain('py-2')
// No state-conditional horizontal padding / centring remains.
for (const w of [expanded, collapsed]) {
@@ -157,11 +160,13 @@ describe('WorkspaceSwitcher', () => {
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')
// Wrapper carries px-3 (12px breathing-room inset) in both states;
// 12 (wrapper) + 4 (trigger) = 16px avatar offset, matching the logo.
expect(expanded.find('.ws-switcher').classes()).toContain('px-3')
expect(collapsed.find('.ws-switcher').classes()).toContain('px-3')
})
it('collapsed hides .meta + .chev; the avatar stays in the same p-2 trigger', () => {
it('collapsed hides .meta + .chev; the avatar stays in the same trigger', () => {
const wrapper = mountSwitcher({ collapsed: true })
const trigger = wrapper.find('.trigger')
@@ -201,6 +206,37 @@ describe('WorkspaceSwitcher', () => {
expect(wrapper.find('.trigger').classes()).not.toContain('bg-surface-100')
})
// -------------------------------------------------------------------------
// Brand-square parity with the SidebarHeader logo. The avatar and the
// header logo MUST share the exact same size / radius / inset-shadow
// Tailwind utilities so they render as identical squares and cannot
// drift. This spec mounts BOTH components and compares the class sets.
// -------------------------------------------------------------------------
it('workspace avatar shares the SidebarHeader logo brand-square recipe (size/radius/shadow)', () => {
const ws = mountSwitcher({ orgs: [orgA], collapsed: false })
setActivePinia(createPinia())
const headerWrapper = mount(SidebarHeader, {
global: { stubs: { Icon: { template: '<span class="icon-stub" />' } } },
})
const avatar = ws.find('.ws-logo')
const logo = headerWrapper.find('.brand-mark')
expect(avatar.exists()).toBe(true)
expect(logo.exists()).toBe(true)
// The shared brand-square recipe: identical Tailwind utilities on both.
const SHARED = ['w-8', 'h-8', 'rounded-lg', 'shadow-[inset_0_-2px_0_#00000014]']
for (const cls of SHARED) {
expect(avatar.classes(), `avatar missing ${cls}`).toContain(cls)
expect(logo.classes(), `logo missing ${cls}`).toContain(cls)
}
})
// -------------------------------------------------------------------------
// Fix 5 — dropdown panel structure
// -------------------------------------------------------------------------