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:
@@ -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>
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user