diff --git a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx
index 31315853..bebdf390 100644
--- a/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx
+++ b/frontend/src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx
@@ -6,10 +6,12 @@ import LinksWidget from './LinksWidget';
import VendoredToolUi from '@toolui/VendoredToolUi';
import type { ShowUiPayload } from './showUiPayload';
import { useOpenUrlInBrowserCard } from './useOpenUrlInBrowserCard';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
/** One switch for every surface that renders a ShowUI payload (chat bubble, pill artifact); ambient = low-cost render for resting surfaces. */
function ShowUiWidgetView({ payload, ambient }: { payload: ShowUiPayload; ambient?: boolean }): React.ReactElement | null {
const openUrl = useOpenUrlInBrowserCard();
+ const c = useClaudeTokens();
if (payload.component === 'weather') return ;
if (payload.component === 'plan') return ;
if (payload.component === 'stats') return ;
@@ -50,8 +52,13 @@ function ShowUiWidgetView({ payload, ambient }: { payload: ShowUiPayload; ambien
return (
- {title &&
{title}
}
- {desc &&
{desc}
}
+ {/* Colour explicitly, never inherited. This header sits above a vendored card whose own
+ surface is dark, and with no colour of its own it took whatever the ancestor happened to
+ carry: on a light-mode app that is near-black text on a dark card, i.e. an invisible
+ title (screenshot 2026-08-31). Same class as ENG-419: a surface that fixes its own
+ background owes its text a token in the same place. */}
+ {title &&
{title}
}
+ {desc &&
{desc}
}
{widget}
diff --git a/frontend/src/app/pages/AgentChat/tool-ui/showUiHeaderContrast.test.ts b/frontend/src/app/pages/AgentChat/tool-ui/showUiHeaderContrast.test.ts
new file mode 100644
index 00000000..f41d2bed
--- /dev/null
+++ b/frontend/src/app/pages/AgentChat/tool-ui/showUiHeaderContrast.test.ts
@@ -0,0 +1,31 @@
+// The ShowUI host header (ENG-227) renders an agent-supplied title/description ABOVE a vendored
+// card. It set size and weight but no COLOUR, so it inherited whatever the ancestor carried: on a
+// light-mode app the inherited text is near-black, and the card underneath is dark, which is a
+// title you can only find by selecting it (Eric's screenshot, 2026-08-31).
+//
+// Same class as ENG-419: a surface that fixes its own background owes its text an explicit token in
+// the same place. Inheriting is the bug, not the styling.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const SRC = readFileSync('src/app/pages/AgentChat/tool-ui/ShowUiWidgetView.tsx', 'utf8');
+// The header block only; the rest of the file legitimately has uncoloured wrappers.
+const HEADER = SRC.slice(SRC.indexOf('if (!title && !desc) return widget;'), SRC.indexOf('return widget;', SRC.indexOf('if (!title && !desc)') + 40));
+
+test('the host header title sets its own colour from the theme', () => {
+ const title = HEADER.slice(HEADER.indexOf('{title &&'), HEADER.indexOf('{desc &&'));
+ assert.match(title, /color:\s*c\.text\.primary/, 'the title must not inherit its colour');
+});
+
+test('the description does too, and does not fake contrast with opacity', () => {
+ const desc = HEADER.slice(HEADER.indexOf('{desc &&'));
+ assert.match(desc, /color:\s*c\.text\.secondary/, 'the description must not inherit its colour');
+ assert.doesNotMatch(desc, /opacity:/,
+ 'opacity over an inherited colour keeps the bug and only dims it; use the secondary token');
+});
+
+test('the tokens actually come from the theme hook, not a literal', () => {
+ assert.match(SRC, /useClaudeTokens/, 'the component must read live theme tokens');
+ assert.doesNotMatch(HEADER, /color:\s*['"#]/, 'no hardcoded colour: it would break the other mode');
+});