From e04e2868c371764ffcb453786649b22ccc4a9fa0 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Fri, 3 Jul 2026 01:58:00 +0800 Subject: [PATCH] docs(compound): message bubble empty-content and card-type exclusion pattern Documents the G1 (:empty never matches Vue root), F4-A (card-bearing type exclusion via messageType prop + Set), and pure-function extraction pattern for testability without @vue/test-utils. --- ...bubble-empty-and-card-exclusion-pattern.md | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 docs/solutions/design-patterns/message-bubble-empty-and-card-exclusion-pattern.md 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) + }) +}) +```