diff --git a/docs/solutions/design-patterns/message-bubble-empty-and-card-exclusion-pattern.md b/docs/solutions/design-patterns/message-bubble-empty-and-card-exclusion-pattern.md
new file mode 100644
index 0000000..9a31f0e
--- /dev/null
+++ b/docs/solutions/design-patterns/message-bubble-empty-and-card-exclusion-pattern.md
@@ -0,0 +1,243 @@
+---
+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 ``),
+so `:empty` **never matches**. Use a JS computed property that checks the
+underlying data fields instead.
+
+```vue
+
+
+
+
+
+
+
+
+
+
+```
+
+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` 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([
+ '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
+
+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
+
+
+```
+
+### After (working — JS computed via pure function)
+
+```vue
+
+
+
+
+
+
+
+```
+
+### 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([
+ '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 {
+ 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)
+ })
+})
+```