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:
2026-05-21 07:43:14 +02:00
parent cd118bd165
commit 2fd3c9ea66
2 changed files with 92 additions and 32 deletions

View File

@@ -57,9 +57,24 @@ interface WorkspaceDisplay {
id: string
initials: 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]
}
// TODO: replace with org type + metrics when WORKSPACE-DROPDOWN-SUB-CONTENT backend lands
const SUB_PLACEHOLDER = 'Organisatie'
function buildDisplay(org: Organisation): WorkspaceDisplay {
const words = org.name.trim().split(/\s+/)
@@ -72,6 +87,7 @@ function buildDisplay(org: Organisation): WorkspaceDisplay {
id: org.id,
initials,
name: org.name,
sub: SUB_PLACEHOLDER,
gradient: computeOrgGradient(org.id),
}
}
@@ -159,22 +175,28 @@ function inviteUser(): void {
:class="collapsed ? 'h-[56px] flex items-center px-4' : 'px-4 py-2'"
>
<!--
Collapsed: bare 32px rounded-lg button positioned at the same
`px-4` left offset as the brand logo. No trigger container, no
hover bg wider than the avatar — true mirror of the logo above.
The `.ws-logo .ws-logo-square` classes carry the scoped
inset-shadow (RFC §7.4) so the visual treatment matches the
avatar in the expanded trigger.
Collapsed: a bare click target (transparent, p-0, no box) wrapping
the EXACT SAME avatar span markup the expanded trigger uses, so the
32px square is byte-identical across states (same classes, same
gradient, same `.ws-logo-square` inset-shadow). P6-styling-switcher-sub
Fix A: previously the collapsed avatar styling lived directly on the
<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
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]})` }"
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"
:aria-label="`Workspace: ${current.name}`"
aria-haspopup="true"
@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>
<!--
@@ -199,7 +221,7 @@ function inviteUser(): void {
{{ current.initials }}
</span>
<!-- Meta: name (AD-2.5-W1 / P4: no sub). -->
<!-- Meta: name + placeholder sub line (P6-styling-switcher-sub). -->
<span
v-if="current"
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)]">
{{ current.name }}
</span>
<span class="sub truncate text-[11.5px] text-[var(--p-text-muted-color)]">
{{ current.sub }}
</span>
</span>
<Icon
@@ -251,12 +276,13 @@ function inviteUser(): void {
>{{ ws.initials }}</span>
<!--
Name only. AD-2.5-W1 option A: no .sub line on dropdown items
until WORKSPACE-DROPDOWN-SUB-CONTENT RFC lands with backend
(organisations.type enum + metrics endpoint).
Name + placeholder sub line (P6-styling-switcher-sub reverses
AD-2.5-W1 option A). Real org type + metrics still deferred
under WORKSPACE-DROPDOWN-SUB-CONTENT.
-->
<span>
<div class="name text-[14px] font-semibold text-[var(--p-text-color)]">{{ ws.name }}</div>
<span class="min-w-0">
<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>
<!-- Check mark for active org -->

View File

@@ -2,8 +2,10 @@
* WorkspaceSwitcher.spec.ts
*
* Locks two slices of contract:
* 1. AD-2.5-W1 + AD-2.5-W1 option A: NO sub line on trigger OR on any
* dropdown row.
* 1. Sub line (P6-styling-switcher-sub reverses AD-2.5-W1 option A):
* 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 —
* header (title + manage link), list (one .opt per org, .is-current
* + .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)', () => {
const wrapper = mountSwitcher({ orgs: [orgA] })
it('renders a placeholder sub line under the name in the expanded trigger', () => {
const wrapper = mountSwitcher({ orgs: [orgA], collapsed: false })
expect(wrapper.text()).not.toContain(orgA.role)
expect(wrapper.html()).not.toMatch(/workspace-sub|ws-sub|meta-sub/)
const sub = wrapper.find('.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', () => {
@@ -105,15 +129,17 @@ describe('WorkspaceSwitcher', () => {
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 })
// 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.
// no .meta. P6-styling-switcher-sub Fix A: the avatar square is now an
// 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('.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)', () => {
@@ -122,7 +148,7 @@ describe('WorkspaceSwitcher', () => {
// 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
const root = wrapper.find('button').element.parentElement
expect(root?.classList.contains('px-4')).toBe(true)
expect(root?.classList.contains('h-[56px]')).toBe(true)
@@ -179,14 +205,22 @@ describe('WorkspaceSwitcher', () => {
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()
// No .opt row carries a .sub descendant; no role string leaks into
// 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)
const subs = wrapper.findAll('.opt .sub')
// 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()
expect(listText).not.toContain('org_admin')