style(layout): re-add placeholder workspace sub line + unify avatar across states
Reverses AD-2.5-W1 option A (no-sub) after visual review against the
crewli-starter SoT — the two-line layout reads better. Adds a
light-grey placeholder sub line under the workspace name (expanded
trigger + dropdown items); collapsed stays bare-avatar-only. No
backend: the placeholder is a neutral static string ('Organisatie'),
real org type + metrics still deferred under
WORKSPACE-DROPDOWN-SUB-CONTENT. The org object exposes no field that
reads well as a subtitle today (id/name/slug/role only; role is an
access identifier, not a description), so a neutral string is used
rather than fabricated metrics or the role string P4 originally
removed.
Fix A — avatar unified across collapsed/expanded. The collapsed
avatar styling previously lived directly on the <button>, letting
user-agent button rendering diverge subtly from the expanded <span>
avatar. The collapsed render now wraps the EXACT SAME avatar span
markup (same classes, gradient, .ws-logo-square inset-shadow) in a
bare transparent p-0 button — the 32px square is byte-identical
across states; only the surrounding context differs.
Fix B — sub line re-added to WorkspaceDisplay (cleanly typed as
`sub: string`, sourced from a SUB_PLACEHOLDER const with a TODO
pointing at the deferred backend). Rendered light-grey
(text-[var(--p-text-muted-color)], matching the in-component muted
text) at text-[11.5px] in the trigger and text-[12.5px] in dropdown
rows, mirroring the pre-P4 sizes. Collapsed renders no sub.
Specs: reversed the P4/P5 no-sub locks to sub-present assertions
(trigger sub present + honest-placeholder/not-role; dropdown sub on
every row + no role leak; collapsed-no-sub via `.meta .sub`).
Updated the collapsed-bare-avatar spec for the new span-in-button
structure (.ws-logo moved from button to inner span).
Suite delta: 566 → 569 (+3). vue-tsc clean. Scoped ESLint clean
(0 errors). No backend, no fabricated data.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -57,9 +57,24 @@ interface WorkspaceDisplay {
|
|||||||
id: string
|
id: string
|
||||||
initials: string
|
initials: string
|
||||||
name: string
|
name: string
|
||||||
|
/**
|
||||||
|
* Light-grey second line under the name. Plan 2.5 P6-styling-switcher-sub
|
||||||
|
* re-adds the two-line layout (reverses AD-2.5-W1 option A) after visual
|
||||||
|
* review against the crewli-starter SoT. The value is a neutral
|
||||||
|
* PLACEHOLDER — the real org type + metrics (e.g. "Festival · 12 days ·
|
||||||
|
* 14 stages") require the organisations.type enum + a metrics endpoint,
|
||||||
|
* which stay deferred under WORKSPACE-DROPDOWN-SUB-CONTENT. The org
|
||||||
|
* object exposes no field that reads well as a subtitle today (only
|
||||||
|
* id/name/slug/role; role is an access identifier, not a description),
|
||||||
|
* so a static neutral string is used rather than fabricated metrics.
|
||||||
|
*/
|
||||||
|
sub: string
|
||||||
gradient: [string, string]
|
gradient: [string, string]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: replace with org type + metrics when WORKSPACE-DROPDOWN-SUB-CONTENT backend lands
|
||||||
|
const SUB_PLACEHOLDER = 'Organisatie'
|
||||||
|
|
||||||
function buildDisplay(org: Organisation): WorkspaceDisplay {
|
function buildDisplay(org: Organisation): WorkspaceDisplay {
|
||||||
const words = org.name.trim().split(/\s+/)
|
const words = org.name.trim().split(/\s+/)
|
||||||
|
|
||||||
@@ -72,6 +87,7 @@ function buildDisplay(org: Organisation): WorkspaceDisplay {
|
|||||||
id: org.id,
|
id: org.id,
|
||||||
initials,
|
initials,
|
||||||
name: org.name,
|
name: org.name,
|
||||||
|
sub: SUB_PLACEHOLDER,
|
||||||
gradient: computeOrgGradient(org.id),
|
gradient: computeOrgGradient(org.id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,22 +175,28 @@ function inviteUser(): void {
|
|||||||
:class="collapsed ? 'h-[56px] flex items-center px-4' : 'px-4 py-2'"
|
:class="collapsed ? 'h-[56px] flex items-center px-4' : 'px-4 py-2'"
|
||||||
>
|
>
|
||||||
<!--
|
<!--
|
||||||
Collapsed: bare 32px rounded-lg button positioned at the same
|
Collapsed: a bare click target (transparent, p-0, no box) wrapping
|
||||||
`px-4` left offset as the brand logo. No trigger container, no
|
the EXACT SAME avatar span markup the expanded trigger uses, so the
|
||||||
hover bg wider than the avatar — true mirror of the logo above.
|
32px square is byte-identical across states (same classes, same
|
||||||
The `.ws-logo .ws-logo-square` classes carry the scoped
|
gradient, same `.ws-logo-square` inset-shadow). P6-styling-switcher-sub
|
||||||
inset-shadow (RFC §7.4) so the visual treatment matches the
|
Fix A: previously the collapsed avatar styling lived directly on the
|
||||||
avatar in the expanded trigger.
|
<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.
|
||||||
-->
|
-->
|
||||||
<button
|
<button
|
||||||
v-if="collapsed && current"
|
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"
|
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"
|
||||||
:style="{ background: `linear-gradient(135deg, ${current.gradient[0]}, ${current.gradient[1]})` }"
|
|
||||||
:aria-label="`Workspace: ${current.name}`"
|
:aria-label="`Workspace: ${current.name}`"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
>
|
>
|
||||||
{{ current.initials }}
|
<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>
|
</button>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -199,7 +221,7 @@ function inviteUser(): void {
|
|||||||
{{ current.initials }}
|
{{ current.initials }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Meta: name (AD-2.5-W1 / P4: no sub). -->
|
<!-- Meta: name + placeholder sub line (P6-styling-switcher-sub). -->
|
||||||
<span
|
<span
|
||||||
v-if="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]"
|
||||||
@@ -207,6 +229,9 @@ function inviteUser(): void {
|
|||||||
<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)]">
|
||||||
{{ current.name }}
|
{{ current.name }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="sub truncate text-[11.5px] text-[var(--p-text-muted-color)]">
|
||||||
|
{{ current.sub }}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Icon
|
<Icon
|
||||||
@@ -251,12 +276,13 @@ function inviteUser(): void {
|
|||||||
>{{ ws.initials }}</span>
|
>{{ ws.initials }}</span>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Name only. AD-2.5-W1 option A: no .sub line on dropdown items
|
Name + placeholder sub line (P6-styling-switcher-sub reverses
|
||||||
until WORKSPACE-DROPDOWN-SUB-CONTENT RFC lands with backend
|
AD-2.5-W1 option A). Real org type + metrics still deferred
|
||||||
(organisations.type enum + metrics endpoint).
|
under WORKSPACE-DROPDOWN-SUB-CONTENT.
|
||||||
-->
|
-->
|
||||||
<span>
|
<span class="min-w-0">
|
||||||
<div class="name text-[14px] font-semibold text-[var(--p-text-color)]">{{ ws.name }}</div>
|
<div class="name truncate text-[14px] font-semibold text-[var(--p-text-color)]">{{ ws.name }}</div>
|
||||||
|
<div class="sub mt-[2px] truncate text-[12.5px] text-[var(--p-text-muted-color)]">{{ ws.sub }}</div>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Check mark for active org -->
|
<!-- Check mark for active org -->
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
* WorkspaceSwitcher.spec.ts
|
* WorkspaceSwitcher.spec.ts
|
||||||
*
|
*
|
||||||
* Locks two slices of contract:
|
* Locks two slices of contract:
|
||||||
* 1. AD-2.5-W1 + AD-2.5-W1 option A: NO sub line on trigger OR on any
|
* 1. Sub line (P6-styling-switcher-sub reverses AD-2.5-W1 option A):
|
||||||
* dropdown row.
|
* a placeholder sub line renders under the name in the expanded
|
||||||
|
* trigger AND on each dropdown row; the collapsed trigger stays
|
||||||
|
* bare-avatar-only (no sub).
|
||||||
* 2. Plan 2.5 P5 Fix 5: dropdown panel structure per crewli-starter —
|
* 2. Plan 2.5 P5 Fix 5: dropdown panel structure per crewli-starter —
|
||||||
* header (title + manage link), list (one .opt per org, .is-current
|
* header (title + manage link), list (one .opt per org, .is-current
|
||||||
* + .check-mark on active), footer (two buttons).
|
* + .check-mark on active), footer (two buttons).
|
||||||
@@ -71,14 +73,36 @@ describe('WorkspaceSwitcher', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// AD-2.5-W1 — no sub on trigger
|
// P6-styling-switcher-sub — placeholder sub line (reverses option A)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
it('does not render a sub line on the trigger (AD-2.5-W1)', () => {
|
it('renders a placeholder sub line under the name in the expanded trigger', () => {
|
||||||
const wrapper = mountSwitcher({ orgs: [orgA] })
|
const wrapper = mountSwitcher({ orgs: [orgA], collapsed: false })
|
||||||
|
|
||||||
expect(wrapper.text()).not.toContain(orgA.role)
|
const sub = wrapper.find('.meta .sub')
|
||||||
expect(wrapper.html()).not.toMatch(/workspace-sub|ws-sub|meta-sub/)
|
|
||||||
|
expect(sub.exists()).toBe(true)
|
||||||
|
expect(sub.text().length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the trigger sub is a neutral placeholder, NOT the org role', () => {
|
||||||
|
const wrapper = mountSwitcher({ orgs: [orgA], collapsed: false })
|
||||||
|
|
||||||
|
// The placeholder must not leak the access-control role string —
|
||||||
|
// that was the original AD-2.5-W1 concern; the reversal restores the
|
||||||
|
// line but with honest placeholder content, not org.role.
|
||||||
|
expect(wrapper.find('.meta .sub').text()).not.toContain(orgA.role)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render a sub line in the collapsed trigger (bare avatar only)', () => {
|
||||||
|
const wrapper = mountSwitcher({ orgs: [orgA], collapsed: true })
|
||||||
|
|
||||||
|
// The trigger sub lives inside `.meta`, which only the expanded
|
||||||
|
// trigger renders. The dropdown's `.opt .sub` rows render inline
|
||||||
|
// (Popover stubbed) regardless of collapse state, so assert against
|
||||||
|
// the trigger-scoped `.meta .sub` rather than a bare `.sub`.
|
||||||
|
expect(wrapper.find('.meta').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.meta .sub').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders the workspace name on the trigger', () => {
|
it('renders the workspace name on the trigger', () => {
|
||||||
@@ -105,15 +129,17 @@ describe('WorkspaceSwitcher', () => {
|
|||||||
expect(wrapper.find('.trigger').classes()).not.toContain('justify-center')
|
expect(wrapper.find('.trigger').classes()).not.toContain('justify-center')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('collapsed renders a bare avatar button (no .trigger container, just .ws-logo)', () => {
|
it('collapsed renders a bare avatar button (no .trigger container, no .meta)', () => {
|
||||||
const wrapper = mountSwitcher({ collapsed: true })
|
const wrapper = mountSwitcher({ collapsed: true })
|
||||||
|
|
||||||
// The padded trigger container with name/chevron is gone — no .trigger,
|
// 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
|
// no .meta. P6-styling-switcher-sub Fix A: the avatar square is now an
|
||||||
// bare <button>), so it visually mirrors the bare SidebarHeader logo.
|
// 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)
|
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)
|
expect(wrapper.find('button').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('button .ws-logo').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('collapsed wrapper uses px-4 (matches the SidebarHeader brand row offset)', () => {
|
it('collapsed wrapper uses px-4 (matches the SidebarHeader brand row offset)', () => {
|
||||||
@@ -122,7 +148,7 @@ describe('WorkspaceSwitcher', () => {
|
|||||||
// The wrapper toggles to `h-[56px] flex items-center px-4` when
|
// 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
|
// collapsed so the avatar's left edge lands at 16px from the rail
|
||||||
// — identical to the header logo offset.
|
// — identical to the header logo offset.
|
||||||
const root = wrapper.find('button.ws-logo').element.parentElement
|
const root = wrapper.find('button').element.parentElement
|
||||||
|
|
||||||
expect(root?.classList.contains('px-4')).toBe(true)
|
expect(root?.classList.contains('px-4')).toBe(true)
|
||||||
expect(root?.classList.contains('h-[56px]')).toBe(true)
|
expect(root?.classList.contains('h-[56px]')).toBe(true)
|
||||||
@@ -179,14 +205,22 @@ describe('WorkspaceSwitcher', () => {
|
|||||||
expect(buttons[1].text()).toContain('Invite')
|
expect(buttons[1].text()).toContain('Invite')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not render sub lines on any dropdown row (AD-2.5-W1 option A)', () => {
|
it('renders a placeholder sub line on every dropdown row (reverses option A)', () => {
|
||||||
const wrapper = mountSwitcher()
|
const wrapper = mountSwitcher()
|
||||||
|
|
||||||
// No .opt row carries a .sub descendant; no role string leaks into
|
const subs = wrapper.findAll('.opt .sub')
|
||||||
// the dropdown text (org_admin, org_member, event_manager are the
|
|
||||||
// backing roles for the 3-org fixture).
|
|
||||||
expect(wrapper.find('.opt .sub').exists()).toBe(false)
|
|
||||||
|
|
||||||
|
// One sub per org row (3-org fixture).
|
||||||
|
expect(subs).toHaveLength(3)
|
||||||
|
for (const sub of subs)
|
||||||
|
expect(sub.text().length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dropdown sub lines do NOT leak the org role (honest placeholder)', () => {
|
||||||
|
const wrapper = mountSwitcher()
|
||||||
|
|
||||||
|
// The reversal restores the line but with neutral placeholder
|
||||||
|
// content — the access-control role strings must not appear.
|
||||||
const listText = wrapper.find('.list').text()
|
const listText = wrapper.find('.list').text()
|
||||||
|
|
||||||
expect(listText).not.toContain('org_admin')
|
expect(listText).not.toContain('org_admin')
|
||||||
|
|||||||
Reference in New Issue
Block a user