---
module: frontend/chat
date: 2026-07-03
problem_type: design_pattern
component: frontend_stimulus
severity: medium
applies_when:
  - "Implementing message bubble styling for Vue chat components"
  - "Deciding between CSS :empty selector and JS computed for empty-content detection"
  - "Excluding card-bearing message types from bubble chrome"
  - "Making Vue computed properties unit-testable without @vue/test-utils"
tags:
  - vue
  - css
  - message-bubble
  - testing
  - computed-properties
  - design-pattern
related_components:
  - assistant
  - testing_framework
---

# Message Bubble Empty-Content Detection & Card-Type Exclusion Pattern

## Context

When implementing Scheme B (neutral grayscale) assistant message bubbles in the
chat UI, three interacting concerns surfaced that the naive CSS-only approach
could not solve:

1. **Empty assistant bubbles**: Pre-stream and tool-call-only messages render
   an empty bubble rectangle — visually broken.
2. **Card-bearing double chrome**: Assistant messages rendered as dedicated
   cards (ErrorCard, BoardConclusionCard, etc.) already carry their own
   background/border/padding. Wrapping them in an assistant bubble produces
   double chrome.
3. **Testability**: Project convention uses pure-TS vitest tests
   (`tests/unit/**/*.test.ts`) without `@vue/test-utils`. Computed properties
   embedded in Vue SFCs are not directly unit-testable.

## Guidance

### 1. Never use `:empty` CSS selector for Vue component emptiness

Vue components always render a root element (even `<template><div></div></template>`),
so `:empty` **never matches**. Use a JS computed property that checks the
underlying data fields instead.

```vue
<!-- MessageShell.vue -->
<template>
  <div :class="[..., { 'message-shell--empty': isEmpty }]">
    <slot />
  </div>
</template>

<script setup lang="ts">
interface Props {
  isEmpty?: boolean  // computed by parent, passed as prop
}
</script>

<style scoped>
.message-shell--assistant.message-shell--empty .message-shell__content {
  background: transparent;
  border: none;
  padding: 0;
}
</style>
```

Parent computes emptiness from message data:

```ts
// ChatMessage.vue
const isBubbleEmpty = computed(() => isAssistantBubbleEmpty(props.message))
```

### 2. Exclude card-bearing types via `messageType` prop, not `:has()` selector

Maintain a `Set<string>` of card-bearing message types and check membership via
a computed. Pass `messageType` as a prop rather than using `:has()` CSS selector
(better browser support, testable, no CSS selector complexity).

```ts
// bubbleUtils.ts (pure function — testable)
const CARD_BEARING_TYPES = new Set<string>([
  'board_conclusion', 'team_plan', 'debate_started', 'debate_argument',
  'debate_summary', 'debate_resolved', 'collaboration_graph',
  'review_result', 'risk_flagged', 'error',
])

export function isCardBearingType(type?: string): boolean {
  return type ? CARD_BEARING_TYPES.has(type) : false
}
```

```vue
<!-- MessageShell.vue -->
const isCardBearing = computed(() => isCardBearingType(props.messageType))
```

```css
/* F4-A: card-bearing types skip bubble chrome */
.message-shell--assistant.message-shell--card .message-shell__content {
  background: transparent;
  border: none;
  border-radius: 0;
  padding: 0;
}
```

**Important**: `error` type must be in the exclusion set — `ErrorCard.vue` ships
full chrome (background + border + border-radius + padding). Without exclusion,
the assistant bubble wraps ErrorCard producing double-background regression.

### 3. Extract computed logic to pure functions for testability

When project convention avoids `@vue/test-utils`, extract computed logic to
pure functions in a `helpers/` module. The Vue computed becomes a thin wrapper
preserving reactivity:

```ts
// bubbleUtils.ts
export function isAssistantBubbleEmpty(message: IChatMessage): boolean {
  if (message.role !== 'assistant') return false
  return (
    !message.content &&
    !message.thinking &&
    (!message.tool_calls || message.tool_calls.length === 0)
  )
}

// ChatMessage.vue — thin reactive wrapper
import { isAssistantBubbleEmpty } from './helpers/bubbleUtils'
const isBubbleEmpty = computed(() => isAssistantBubbleEmpty(props.message))
```

This pattern lets you test the logic with plain vitest:

```ts
// bubbleUtils.test.ts
it('returns true for assistant with no content, no thinking, no tool_calls', () => {
  const msg = makeMsg({ role: 'assistant', content: '' })
  expect(isAssistantBubbleEmpty(msg)).toBe(true)
})
```

## Why This Matters

- **`:empty` failure mode is silent**: CSS doesn't error, the selector just
  never matches. Empty bubbles render with full chrome — visually broken but
  not detectable without manual inspection. A P0 review finding.
- **Double-chrome regression**: ErrorCard wrapping was missed in initial F4-A
  planning (only 9 types listed). The `error` type was added during code review
  (P2 finding) after spotting ErrorCard's full chrome.
- **Test coverage of decisions**: G1 (`:empty` replacement) and F4-A
  (card exclusion) are P0 decisions in the plan. Without extracted pure
  functions, these decisions have no unit test coverage — only visual
  verification. Extracting to `bubbleUtils.ts` added 36 tests covering all
  edge cases.

## When to Apply

- **Any Vue component where `:empty` seems tempting**: Don't use it. Vue
  components render root elements. Use JS computed.
- **Card-vs-bubble styling decisions**: When a component renders different
  "chrome" levels (full card vs. bubble vs. plain), use a `messageType` prop
  + Set lookup. Avoid `:has()` — it couples styling to DOM structure.
- **Computed properties with non-trivial logic**: If the logic is a P0/P1
  decision (G1, F4-A) or has edge cases (undefined fields, empty arrays),
  extract to a pure function. The Vue computed should be a one-line wrapper.
- **Projects without `@vue/test-utils`**: Pure-function extraction is the
  primary path to computed-property test coverage.

## Examples

### Before (broken — `:empty` never matches)

```vue
<!-- AssistantText always renders a root div, so :empty never matches -->
<style scoped>
.message-shell--assistant .message-shell__content:empty {
  display: none;
}
</style>
```

### After (working — JS computed via pure function)

```vue
<template>
  <MessageShell :is-empty="isBubbleEmpty" :message-type="spec.type">
    <AssistantText :content="message.content" />
  </MessageShell>
</template>

<script setup lang="ts">
import { isAssistantBubbleEmpty } from './helpers/bubbleUtils'
const isBubbleEmpty = computed(() => isAssistantBubbleEmpty(props.message))
</script>
```

### Card-bearing exclusion — complete type list

```ts
// All 10 types that ship their own chrome and must skip the bubble:
const CARD_BEARING_TYPES = new Set<string>([
  'board_conclusion',      // BoardConclusionCard — full chrome
  'team_plan',             // TeamPlanCard — full chrome
  'debate_started',        // DebateBannerCard — full chrome (plan typo'd as debate_banner)
  'debate_argument',       // DebateArgumentCard — partial chrome
  'debate_summary',        // DebateSummaryCard — partial chrome
  'debate_resolved',       // DebateResolvedCard — partial chrome
  'collaboration_graph',   // CollaborationGraph — partial chrome
  'review_result',         // ReviewResultCard — partial chrome
  'risk_flagged',          // RiskFlaggedCard — partial chrome
  'error',                 // ErrorCard — full chrome (added in code review P2)
])
```

### Test file structure (pure-TS, no @vue/test-utils)

```ts
// tests/unit/helpers/bubbleUtils.test.ts
import { describe, expect, it } from 'vitest'
import { isCardBearingType, isAssistantBubbleEmpty } from '@/components/chat/helpers/bubbleUtils'
import type { IChatMessage } from '@/api/types'

function makeMsg(overrides: Partial<IChatMessage> = {}): IChatMessage {
  return { id: 'm1', role: 'assistant', content: '', timestamp: '...', ...overrides }
}

describe('isCardBearingType', () => {
  it.each(['board_conclusion', 'error', 'team_plan'])('returns true for %s', (type) => {
    expect(isCardBearingType(type)).toBe(true)
  })
  it('returns false for undefined', () => {
    expect(isCardBearingType(undefined)).toBe(false)
  })
})
```
