From 84e45926a9170a01df00cb03f911f3cc85ca1334 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 12:34:17 -0700 Subject: [PATCH] fix(chat): lifecycle-guaranteed root token injection (production regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ensureChatRootStyles()` injects the chat lib's root CSS custom properties — `--ngaf-chat-bg`, `--ngaf-chat-surface`, `--ngaf-chat-radius-input`, `--ngaf-chat-sidenav-width-expanded`, `--ngaf-chat-font-family`, the edge-claim primitive, the data-ngaf-chat-theme attribute mappings, and reduced-motion overrides. Every host-encapsulated chat lib style references these via `var()`. It was wired as a module-evaluation side effect (auto-call at the bottom of chat-tokens.ts). The published artifact's `sideEffects` glob was `["**/chat-tokens.ts", "**/*.css"]` — the chat-tokens.ts glob matches the *source* tree (workspace TS paths in this repo) but matches nothing in the bundled `dist/libs/chat/fesm2022/ngaf-chat.mjs` artifact. When a consumer's bundler does aggressive production tree-shaking it sees no side-effect-marked files in the published package and treats the entire bundle as side-effect-free. Result: the module-eval call is dropped, the style element is never injected, and every chat surface renders without chrome — sidenav has no width, input has no border, chips have no pill background, suggestions look like plain text on the page bg. This shipped in production at https://demo.cacheplane.ai but doesn't reproduce locally (dev mode skips tree-shaking; workspace TS paths match the source glob). Verified live: manual injection of the root tokens via browser console restored every chrome element instantly. Fix: - Call `ensureChatRootStyles()` from the constructors of every top-level chat composition (`ChatComponent`, `ChatPopupComponent`, `ChatSidebarComponent`, `ChatDebugComponent`). The function is idempotent (early-returns if the style element already exists). Constructors are reachable from user code, so bundlers can't tree-shake the call. - Extend `libs/chat/package.json` `sideEffects` to also match `./fesm2022/ngaf-chat.mjs` — defense-in-depth for any consumer bundler that DOES respect the field. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/chat/package.json | 1 + .../chat-debug/chat-debug.component.ts | 5 +++++ .../chat-popup/chat-popup.component.ts | 5 ++++- .../chat-sidebar/chat-sidebar.component.ts | 5 ++++- .../src/lib/compositions/chat/chat.component.ts | 15 ++++++++++++++- 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/libs/chat/package.json b/libs/chat/package.json index 639803cad..38afa54bc 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -45,6 +45,7 @@ }, "sideEffects": [ "**/chat-tokens.ts", + "./fesm2022/ngaf-chat.mjs", "**/*.css" ] } diff --git a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts index e54960198..7b30c3acc 100644 --- a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts +++ b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts @@ -16,6 +16,7 @@ import { import { NgTemplateOutlet } from '@angular/common'; import type { AgentWithHistory } from '../../agent'; import { CHAT_DEBUG_TOKENS } from './chat-debug-tokens'; +import { ensureChatRootStyles } from '../../styles/chat-tokens'; import { ChatDebugControlsDirective } from './chat-debug-controls.directive'; import { ChatDebugInspectorDirective } from './chat-debug-inspector.directive'; import { TimelineInspectorComponent } from './inspectors/timeline-inspector.component'; @@ -399,6 +400,10 @@ export class ChatDebugComponent { private readonly hostEl: ElementRef = inject(ElementRef); constructor() { + // Inject chat lib root CSS custom properties so the theme-attribute + // mappings + edge-claim primitive are in the document, even when + // chat-debug is mounted without a sibling chat composition. + ensureChatRootStyles(); // Restore once from storage on construction; inputs seed the fallback. // `storageKey` is read-once: rebinding it at runtime is not supported. const restore = createPersistence(this.storageKey()); diff --git a/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts index c4970b3ec..06e9d3fb9 100644 --- a/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts +++ b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts @@ -6,7 +6,7 @@ import type { ViewRegistry } from '@ngaf/render'; import { ChatComponent } from '../chat/chat.component'; import type { ChatSelectOption } from '../../primitives/chat-select/chat-select.component'; import { ChatLauncherButtonComponent } from '../../primitives/chat-launcher-button/chat-launcher-button.component'; -import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_HOST_TOKENS, ensureChatRootStyles } from '../../styles/chat-tokens'; @Component({ selector: 'chat-popup', @@ -106,6 +106,9 @@ export class ChatPopupComponent { private readonly document = inject(DOCUMENT); constructor() { + // Inject chat lib root CSS custom properties — see ChatComponent + // for the full rationale. Idempotent + lifecycle-guaranteed. + ensureChatRootStyles(); effect(() => { // Re-bind whenever shortcut/closeOnEscape change. const shortcut = this.shortcut(); diff --git a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts index 6fdeed391..a791b9fc2 100644 --- a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts +++ b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts @@ -6,7 +6,7 @@ import type { ViewRegistry } from '@ngaf/render'; import { ChatComponent } from '../chat/chat.component'; import { ChatLauncherButtonComponent } from '../../primitives/chat-launcher-button/chat-launcher-button.component'; import type { ChatSelectOption } from '../../primitives/chat-select/chat-select.component'; -import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_HOST_TOKENS, ensureChatRootStyles } from '../../styles/chat-tokens'; @Component({ selector: 'chat-sidebar', @@ -104,6 +104,9 @@ export class ChatSidebarComponent { readonly forkRequested = output(); constructor() { + // Inject chat lib root CSS custom properties — see ChatComponent + // for the full rationale. Idempotent + lifecycle-guaranteed. + ensureChatRootStyles(); // Publish the right-edge claim while the panel is open. Peer panels // (e.g. chat-debug) read --ngaf-chat-occupy-right to leave room. effect(() => { diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 4af9adfc9..c87452a16 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -33,7 +33,7 @@ import { createContentClassifier, type ContentClassifier } from '../../streaming import { createPartialArgsBridge, type PartialArgsBridge } from '../../a2ui/partial-args-bridge'; import { createA2uiSurfaceStore, type A2uiSurfaceStore } from '../../a2ui/surface-store'; import { messageContent } from '../shared/message-utils'; -import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_HOST_TOKENS, ensureChatRootStyles } from '../../styles/chat-tokens'; import type { ChatRenderEvent } from './chat-render-event'; import { CHAT_LIFECYCLE, type ChatLifecycle } from '../../lifecycle'; @@ -395,6 +395,19 @@ export class ChatComponent { private static readonly PIN_TOLERANCE_PX = 150; constructor() { + // Inject the chat lib's root CSS custom properties (--ngaf-chat-bg, + // --ngaf-chat-surface, --ngaf-chat-radius-input, etc.) the first + // time any chat composition is constructed. The module-eval side + // effect that previously handled this is unreliable under + // aggressive production tree-shaking — bundlers that don't see + // the source `chat-tokens.ts` path in the published artifact's + // `sideEffects` glob drop the call entirely, leaving consumers + // with zero token defaults (sidenav has no width, input has no + // border, chips have no chrome — everything renders as plain + // text on the page background). Calling from a constructor that + // is unconditionally reachable from user code defeats that + // tree-shaking and is idempotent. */ + ensureChatRootStyles(); effect(() => { if (this.eventsSubscribed) return; let agent: ReturnType;