--- 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) }) }) ```