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
|
||||
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"
|
||||
>
|
||||
<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 -->
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user