style(layout): logo stays anchored on collapse + squarer corners
Refines the prior sidebar styling commit (8e166512) after manual smoke:
- Brand logo + workspace avatar: rounded-xl -> rounded-lg (crisper
square per design review; both stay unified at the same radius).
- Logo no longer jumps on collapse. The previous code toggled
`justify-between` ⇄ `justify-center` on the header row, which
re-centred the logo against the parent's width — and the parent
width animates from 256px to 64px over 200ms, so the logo slid
from x≈112px (centred in the expanded rail) to x=16px (centred in
the collapsed rail). Visible jump.
Fix: the brand row is now ALWAYS `px-4` and left-aligned. The
logo's horizontal offset (16px from the rail's left edge) is
identical in expanded and collapsed states. Why this still looks
centred when the rail collapses:
rail_collapsed (64px) = logo (32px) + 2 × px-4 (2 × 16px)
With those numbers aligned, a stationary left-aligned logo IS
visually centred in the 64px-wide collapsed rail. The width
transition then "slides the rail closed around" the anchored
logo. Wordmark + Beta badge sit to the RIGHT of the logo and
v-if-disappear on collapse; their absence doesn't shift the logo
because they were never to its left.
- Toggle chevron placement:
- Expanded: collapse chevron (◀) inline at the right of the brand
row, pushed by `ms-auto` (NOT by justify-between forcing the
layout to recentre the logo).
- Collapsed: a SECOND row below the brand row holds a centred
expand chevron (▶) button. Replaces the prior tucked-chip that
overlapped the logo. No overlap, no overhang needed against the
aside's `overflow-hidden`.
- WorkspaceSwitcher trigger: same anchor-on-the-left treatment
applied. The `justify-center` switch on collapse is gone (it
caused an identical avatar slide). Wrapper padding `p-[10px]` ->
`p-2` so the avatar's left offset (wrapper 8 + trigger 8 = 16px)
matches the SidebarHeader logo (px-4 = 16px) — the brand square
and the workspace square are now vertically aligned in the
collapsed rail.
Desktop only. Mobile drawer chrome tracked separately as
MOBILE-SHELL-PARITY.
Tests adapted:
- WorkspaceSwitcher.spec.ts: trigger-rounded assertion bumped
rounded-xl -> rounded-lg; +1 spec locks "trigger never carries
justify-center" (avatar-anchored invariant).
- SidebarHeader.spec.ts: collapsed-behaviour spec rewritten — was
asserting `justify-center`, now asserts the row carries `px-4`
WITHOUT either justify-center OR justify-between (the actual
anchor contract); +1 spec confirms the expand chevron lives in a
SIBLING row of the brand mark (no overlap).
Suite delta: 561 -> 563 (+2). vue-tsc clean. Scoped ESLint clean
(0 errors, pre-existing warnings only).
Manual smoke pending Bert: collapse/expand slowly and watch the
logo — must NOT move horizontally. Confirm rounded-lg looks crisp
(not pebble-soft). Confirm expand chevron sits in its own row
below the logo, no overlap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,21 +12,39 @@
|
||||
* - Desktop (>=lg): toggle the persistent sidebar between expanded/collapsed
|
||||
* → call toggleSidebar().
|
||||
*
|
||||
* Plan 2.5 P6-followup-styling: the collapse-toggle is decoupled from the
|
||||
* brand group so the logo stays centered in the collapsed rail.
|
||||
* - Expanded: justify-between row — [brand group] [collapse chevron].
|
||||
* - Collapsed: justify-center row — [logo only], with the expand chevron
|
||||
* as a small floating circle absolute-positioned at the rail's right
|
||||
* edge (tucked over the logo's right side with a solid background
|
||||
* so the visual reads as an intentional chip, not an overlap). The
|
||||
* aside's `overflow-hidden` (intentional, for the width transition)
|
||||
* prevents true overhang past the rail edge — the tucked pattern is
|
||||
* the visual compromise that keeps the affordance discoverable.
|
||||
* Plan 2.5 P6-styling-fix: the brand logo must NOT move horizontally on
|
||||
* collapse. Prior versions toggled `justify-between` ⇄ `justify-center`
|
||||
* which re-centred the logo against the parent's width — and the parent
|
||||
* width animates over 200ms, so the logo slid from x≈112px (centred in
|
||||
* the 256px-wide expanded rail) to x=16px (centred in the 64px-wide
|
||||
* collapsed rail). Visible jump.
|
||||
*
|
||||
* The logo square shares its size + radius with WorkspaceSwitcher's
|
||||
* avatar square (h-8 w-8 rounded-xl) so the collapsed rail reads as
|
||||
* symmetric: brand-square at top, workspace-square at bottom, bracketing
|
||||
* the nav icons.
|
||||
* Fix: the header row is ALWAYS left-aligned with constant `px-4`
|
||||
* (16px) horizontal padding. The logo sits flush against the left
|
||||
* padding in both states. The "centering equation" is what makes the
|
||||
* collapsed rail still look centred:
|
||||
*
|
||||
* rail_collapsed (64px) = logo (32px) + 2 × px-4 (2 × 16px)
|
||||
*
|
||||
* With those numbers aligned, a stationary left-aligned logo IS
|
||||
* visually centred in the collapsed rail. The width animation then
|
||||
* "slides the rail closed around" the anchored logo — exactly what
|
||||
* we want. Wordmark + Beta badge sit to the RIGHT of the logo and
|
||||
* disappear via v-if on collapse; their absence doesn't shift the
|
||||
* logo because they were never to its left.
|
||||
*
|
||||
* Toggle chevron placement also changes shape based on state:
|
||||
* - Expanded: collapse chevron (◀) sits inline on the right of the
|
||||
* brand row, pushed there by `ms-auto`. With wordmark + badge
|
||||
* present, this is the rightmost element in the row.
|
||||
* - Collapsed: a SECOND row below the brand row holds a centred
|
||||
* expand chevron (▶) button. This avoids the previous tucked-chip
|
||||
* overlap and respects the aside's `overflow-hidden`. The second
|
||||
* row only renders when collapsed.
|
||||
*
|
||||
* The logo + workspace avatar (WorkspaceSwitcher) share size + radius
|
||||
* (h-8 w-8 rounded-lg) so the collapsed rail reads as symmetric:
|
||||
* brand square at the top, workspace square at the bottom.
|
||||
*
|
||||
* <style scoped> covers the inset box-shadow on .mark only (no Tailwind
|
||||
* utility at this granularity, per RFC §7.4 last-resort).
|
||||
@@ -55,20 +73,20 @@ function handleCollapseClick(): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center h-[56px] border-b border-[var(--p-content-border-color)] flex-shrink-0 relative transition-[padding,justify-content] duration-200"
|
||||
:class="shell.sidebarCollapsed ? 'justify-center px-0' : 'justify-between px-4'"
|
||||
>
|
||||
<div class="flex flex-col flex-shrink-0">
|
||||
<!--
|
||||
Expanded: brand group on the left (logo + wordmark + Beta pill).
|
||||
Collapsed: only the logo square, centered in the rail.
|
||||
Brand row — ALWAYS px-4, ALWAYS left-aligned. The logo's left
|
||||
offset (16px from rail) is identical in both expanded and
|
||||
collapsed states (centering equation: 64px rail = 32px logo +
|
||||
2 × 16px padding). No justify-content switching.
|
||||
-->
|
||||
<div class="flex items-center gap-[10px]">
|
||||
<!-- Brand square — shared treatment with WorkspaceSwitcher avatar (h-8 w-8 rounded-xl). -->
|
||||
<span class="brand-mark mark w-8 h-8 flex-shrink-0 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 inline-flex items-center justify-center text-white font-bold text-sm">
|
||||
<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">
|
||||
C
|
||||
</span>
|
||||
|
||||
<!-- Wordmark + Beta pill: to the RIGHT of the logo; absent when collapsed (don't affect logo's left position). -->
|
||||
<span
|
||||
v-if="!shell.sidebarCollapsed"
|
||||
class="brand-name font-bold text-base tracking-[-0.01em] whitespace-nowrap text-[var(--p-text-color)]"
|
||||
@@ -82,41 +100,47 @@ function handleCollapseClick(): void {
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
|
||||
<!--
|
||||
Expanded-only collapse chevron — pushed to the right via ms-auto.
|
||||
Sits to the RIGHT of the wordmark + badge, so its presence/absence
|
||||
also doesn't affect the logo's left position.
|
||||
-->
|
||||
<button
|
||||
v-if="!shell.sidebarCollapsed"
|
||||
class="ms-auto w-7 h-7 border-0 bg-transparent text-[var(--p-text-muted-color)] rounded-[var(--p-border-radius)] inline-flex items-center justify-center transition-[background,color] duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] cursor-pointer"
|
||||
aria-label="Collapse sidebar"
|
||||
type="button"
|
||||
@click="handleCollapseClick"
|
||||
>
|
||||
<Icon
|
||||
name="tabler-chevron-left"
|
||||
:size="16"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Expanded: inline collapse chevron on the right (justify-between sibling). -->
|
||||
<button
|
||||
v-if="!shell.sidebarCollapsed"
|
||||
class="w-7 h-7 border-0 bg-transparent text-[var(--p-text-muted-color)] rounded-[var(--p-border-radius)] inline-flex items-center justify-center transition-[background,color] duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] cursor-pointer"
|
||||
aria-label="Collapse sidebar"
|
||||
type="button"
|
||||
@click="handleCollapseClick"
|
||||
>
|
||||
<Icon
|
||||
name="tabler-chevron-left"
|
||||
:size="16"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!--
|
||||
Collapsed: expand chevron as a small floating chip at the rail's
|
||||
right edge. Solid background + border so the slight overlap with
|
||||
the centered logo reads as a tucked-in affordance rather than a
|
||||
collision. Decoupled from the brand group so toggling does NOT
|
||||
shift the logo's centering.
|
||||
Collapsed-only expand row — centred chevron button in its own row
|
||||
directly below the brand row, before the nav starts. No overlap
|
||||
with the logo, no overhang past the aside's `overflow-hidden`.
|
||||
-->
|
||||
<button
|
||||
v-else
|
||||
class="absolute end-0 top-1/2 -translate-y-1/2 w-6 h-6 rounded-full border border-[var(--p-content-border-color)] bg-[var(--p-content-background)] text-[var(--p-text-muted-color)] inline-flex items-center justify-center transition-[background,color] duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] cursor-pointer shadow-sm"
|
||||
aria-label="Expand sidebar"
|
||||
type="button"
|
||||
@click="handleCollapseClick"
|
||||
<div
|
||||
v-if="shell.sidebarCollapsed"
|
||||
class="flex justify-center py-2 border-b border-[var(--p-content-border-color)]"
|
||||
>
|
||||
<Icon
|
||||
name="tabler-chevron-right"
|
||||
:size="12"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 border-0 bg-transparent text-[var(--p-text-muted-color)] rounded-[var(--p-border-radius)] inline-flex items-center justify-center transition-[background,color] duration-150 hover:bg-[var(--p-content-hover-background)] hover:text-[var(--p-text-color)] cursor-pointer"
|
||||
aria-label="Expand sidebar"
|
||||
type="button"
|
||||
@click="handleCollapseClick"
|
||||
>
|
||||
<Icon
|
||||
name="tabler-chevron-right"
|
||||
:size="16"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -134,28 +134,31 @@ function inviteUser(): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex-shrink-0 border-t border-[var(--p-content-border-color)] p-[10px]">
|
||||
<div class="relative flex-shrink-0 border-t border-[var(--p-content-border-color)] p-2">
|
||||
<!--
|
||||
Trigger button.
|
||||
Plan 2.5 P6-followup-styling: rounded-xl (matches the brand square
|
||||
in SidebarHeader), p-2 padding, hover background — replaces the
|
||||
sharper rounded-[var(--p-border-radius)] / px-[10px] py-[8px] state.
|
||||
Collapsed variant centres the avatar only, hiding the name and
|
||||
chevron, so the collapsed rail reads as a vertical mirror: brand
|
||||
square at the top, avatar square at the bottom.
|
||||
Plan 2.5 P6-styling-fix: avatar stays anchored on collapse — the
|
||||
`justify-center` switch is gone (it caused the same flex-recentre
|
||||
slide as the SidebarHeader logo did). The trigger is ALWAYS
|
||||
left-aligned; the name + chevron sit to the RIGHT of the avatar
|
||||
and disappear via v-if on collapse without shifting the avatar.
|
||||
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
|
||||
class="trigger flex w-full items-center gap-[10px] rounded-xl border border-transparent bg-transparent p-2 text-[var(--p-text-color)] transition-colors duration-150 hover:bg-[var(--p-content-hover-background)]"
|
||||
:class="[
|
||||
collapsed ? 'justify-center' : '',
|
||||
]"
|
||||
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"
|
||||
@click="toggle"
|
||||
>
|
||||
<!-- Avatar square — shared treatment with SidebarHeader logo (h-8 w-8 rounded-xl). Gradient background is bespoke per organisation (dynamic hex pair, RFC §7.4 inline-style). -->
|
||||
<!-- 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). -->
|
||||
<span
|
||||
v-if="current"
|
||||
class="ws-logo ws-logo-square w-8 h-8 flex-shrink-0 rounded-xl 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 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 }}
|
||||
|
||||
@@ -194,12 +194,13 @@ describe('SidebarHeader', () => {
|
||||
expect(btn.attributes('aria-label')).toBe('Collapse sidebar')
|
||||
})
|
||||
|
||||
// P6-followup-styling: when collapsed, the brand-name (wordmark) is
|
||||
// hidden and the row centres the logo. The expand button is a
|
||||
// separate absolute-positioned sibling — its presence must not push
|
||||
// the logo off-centre. This spec locks the "logo stays centred"
|
||||
// contract by asserting the row's flex alignment + wordmark absence.
|
||||
it('collapsed: hides the Crewli wordmark and centres the row', async () => {
|
||||
// P6-styling-fix — the brand row is ALWAYS left-aligned with constant
|
||||
// px-4 padding (no justify-content switch on collapse). The logo's
|
||||
// horizontal position is therefore identical in expanded vs collapsed
|
||||
// states: 16px from the rail's left edge, which IS the visual centre
|
||||
// when the rail narrows to w-16 (64px = 32px logo + 2 × 16px padding).
|
||||
// This is what eliminates the previous slide-from-x=112 jump.
|
||||
it('collapsed: hides the Crewli wordmark, brand row keeps constant px-4 alignment', async () => {
|
||||
const wrapper = mountHeader()
|
||||
const shell = useShellUiStore()
|
||||
|
||||
@@ -210,9 +211,36 @@ describe('SidebarHeader', () => {
|
||||
expect(wrapper.find('.brand-name').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('Crewli')
|
||||
|
||||
// The header row toggles to justify-center when collapsed.
|
||||
const row = wrapper.find('div')
|
||||
// No justify-center / justify-between toggling — those classes
|
||||
// caused the prior collapse-time slide. The brand row should NOT
|
||||
// carry either when collapsed.
|
||||
const brandRow = wrapper.find('.brand-mark').element.parentElement
|
||||
|
||||
expect(row.classes()).toContain('justify-center')
|
||||
expect(brandRow?.classList.contains('justify-center')).toBe(false)
|
||||
expect(brandRow?.classList.contains('justify-between')).toBe(false)
|
||||
expect(brandRow?.classList.contains('px-4')).toBe(true)
|
||||
})
|
||||
|
||||
it('collapsed: expand chevron is rendered in a row below the brand row (no overlap)', async () => {
|
||||
const wrapper = mountHeader()
|
||||
const shell = useShellUiStore()
|
||||
|
||||
shell.sidebarCollapsed = true
|
||||
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Two buttons would mean both expand AND collapse chevrons render;
|
||||
// we want exactly one — the expand chevron, in its own row.
|
||||
const buttons = wrapper.findAll('button[aria-label]')
|
||||
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0].attributes('aria-label')).toBe('Expand sidebar')
|
||||
|
||||
// The expand button is NOT a child of the brand row — it lives in a
|
||||
// sibling row below, so the brand row's logo cannot overlap it.
|
||||
const expandBtn = buttons[0].element
|
||||
const brandMark = wrapper.find('.brand-mark').element
|
||||
|
||||
expect(expandBtn.parentElement).not.toBe(brandMark.parentElement)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,10 +91,20 @@ describe('WorkspaceSwitcher', () => {
|
||||
// P6-followup-styling — trigger uses rounded-xl + collapsed = avatar only
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('trigger uses rounded-xl (not the sharper PrimeVue var-radius)', () => {
|
||||
it('trigger uses rounded-lg (matches the brand square in SidebarHeader)', () => {
|
||||
const wrapper = mountSwitcher()
|
||||
|
||||
expect(wrapper.find('.trigger').classes()).toContain('rounded-xl')
|
||||
expect(wrapper.find('.trigger').classes()).toContain('rounded-lg')
|
||||
})
|
||||
|
||||
it('trigger has no justify-center class in either state (avatar stays anchored on collapse)', () => {
|
||||
const expanded = mountSwitcher({ collapsed: false })
|
||||
|
||||
expect(expanded.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', () => {
|
||||
|
||||
Reference in New Issue
Block a user