From 0778d9baef42372cf11f9d90abceb4a6eb09e8dd Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 28 Jun 2026 00:01:10 +0800 Subject: [PATCH 1/3] feat(ui): govern chat bubble/row shell onto @maka/ui chat primitives PR1 of #332. Move the conversation-flow row/bubble *shell* onto a new `@maka/ui` `Message` + `Bubble` primitive pair (cva + data-slot), retiring the bespoke `.message.{role}` / `.maka-bubble-user` / `.maka-bubble-truncated` shell CSS with zero visual change. - packages/ui/src/primitives/chat.tsx: `Message` (row, role variants) keeps the authored `.maka-message-row` base and adds the role layout as utilities; `Bubble` (user/assistant) carries the user tint as utilities on the neutral `--chat-user-bg` token path (never primary/accent) and re-emits `.maka-bubble-assistant` so the Markdown prose (OUT of scope) is untouched. - Wire MessageBody, the committed user/system/assistant rows, the streaming row, and the streaming truncated pill onto the primitives. - Retire shell CSS: `.maka-bubble-user` (both rules, incl. the 640px width cap), `.message.user/.assistant/.system`, the dead `.message > span` role labels, `.maka-bubble-assistant-stack`, `.maka-bubble-truncated`. Re-anchor the still-hand-written turn lineage/footer + system `pre` rules (PR2) onto the `[data-slot="message"][data-role]` hook at identical specificity. Keep all `.maka-bubble-assistant` Markdown prose. - Add chat-primitive-cascade-contract.test.ts locking: shell selectors gone, prose preserved, turn layout re-anchored, neutral token path + 640px cap. --- .../chat-primitive-cascade-contract.test.ts | 81 +++++++++++++++++ apps/desktop/src/renderer/maka-tokens.css | 36 +------- .../src/renderer/styles/reasoning-panel.css | 21 +---- .../src/renderer/styles/tool-output.css | 47 ++-------- packages/ui/src/components.tsx | 39 ++++---- packages/ui/src/index.ts | 1 + packages/ui/src/primitives/chat.tsx | 91 +++++++++++++++++++ 7 files changed, 211 insertions(+), 105 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts create mode 100644 packages/ui/src/primitives/chat.tsx diff --git a/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts b/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts new file mode 100644 index 0000000000..12882f1818 --- /dev/null +++ b/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { REPO_ROOT, readAllRendererCss, stripCssComments } from './css-test-helpers.js'; + +/** + * Zero-visual governance contract for issue #332 PR1 — the chat + * conversation-flow row/bubble *shell* moved onto the `@maka/ui` `Message` / + * `Bubble` primitives. These assertions lock the two halves of "zero visual + * change": the bespoke shell CSS is retired, while the Markdown prose and the + * still-hand-written turn machinery (PR2) keep their exact layout. + */ +describe('chat primitive shell migration contract (#332 PR1)', () => { + it('retires the bespoke bubble/row shell selectors', async () => { + const css = stripCssComments(await readAllRendererCss()); + for (const selector of [ + '.maka-bubble-user', + '.maka-bubble-truncated', + '.maka-bubble-assistant-stack', + '.message.user', + '.message.assistant', + '.message.system', + '.message >', + '.message pre', + ]) { + assert.ok( + !css.includes(selector), + `retired shell selector "${selector}" still present in renderer CSS`, + ); + } + }); + + it('preserves the assistant Markdown prose (OUT of scope)', async () => { + const css = await readAllRendererCss(); + for (const selector of [ + '.maka-bubble-assistant {', + '.maka-bubble-assistant p', + '.maka-bubble-assistant pre', + '.maka-bubble-assistant table', + '.maka-bubble-assistant li.task-list-item', + ]) { + assert.ok(css.includes(selector), `prose rule "${selector}" must be preserved`); + } + }); + + it('keeps the row + re-anchors turn layout onto the Message primitive', async () => { + const css = await readAllRendererCss(); + // The centered reading column / entrance animation stay authored. + assert.ok(css.includes('.maka-message-row'), '.maka-message-row row base must stay'); + // Lineage row + footer (PR2, still hand-written) ride the primitive's + // data hook so their measure column survives until they migrate. + assert.ok( + css.includes('[data-slot="message"][data-role="assistant"] .maka-turn-footer'), + 'turn footer layout must be re-anchored to the Message primitive', + ); + assert.ok( + css.includes('[data-slot="message"][data-role="system"] pre'), + 'system note pre styling must be re-anchored to the Message primitive', + ); + }); + + it('keeps the user bubble on the neutral token path, never primary/accent', async () => { + const chatSrc = await readFile( + resolve(REPO_ROOT, 'packages', 'ui', 'src', 'primitives', 'chat.tsx'), + 'utf8', + ); + assert.ok( + chatSrc.includes('bg-[var(--chat-user-bg)]'), + 'user bubble must keep the --chat-user-bg token path', + ); + assert.ok( + chatSrc.includes('max-w-[min(100%,640px)]'), + 'user bubble width cap must match the retired .maka-bubble-user (min(100%,640px))', + ); + assert.ok( + !/bg-primary|bg-accent/.test(chatSrc), + 'user bubble must never switch to primary/accent backgrounds', + ); + }); +}); diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index eb2ec757d8..d0ff93cc2e 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -1132,12 +1132,6 @@ transform: rotate(45deg); } - .maka-bubble-assistant-stack { - display: flex; - flex-direction: column; - gap: 0; - } - /* The streaming bubble (out-of-band, before the in-progress turn is * fully persisted) sits at the bottom of the chat surface; give it the * same horizontal frame as a real turn message. */ @@ -1147,25 +1141,10 @@ previous turn while it lands. */ box-sizing: border-box; } - /* User message: a tinted, width-capped block anchored to the right. - PR-CHAT-CHROME-FOLLOWUP-0: the previous treatment was transparent - right-aligned text — for a long message it filled most of the - column, so the right-anchor was imperceptible. Re-tinting with the - existing `--chat-user-bg` token + a width cap makes "what the human - said" read clearly on the right at any length. The block shrink- - wraps to its content because `.message.user` aligns items to - flex-end (so short messages stay compact). Radius 10px matches the - code-block family and stays under the sharp-identity ceiling. */ - .maka-bubble-user { - background: var(--chat-user-bg); - color: var(--chat-user-foreground, var(--foreground)); - border-radius: 10px; - padding: 10px 14px; - line-height: 1.6; - max-width: min(100%, 78%); - white-space: pre-wrap; - word-wrap: break-word; - } + /* User message: a tinted, width-capped block anchored to the right — now + the `Bubble variant="user"` chat primitive (issue #332 PR1). It keeps the + neutral `--chat-user-bg` token path; the shell utilities live in + packages/ui/src/primitives/chat.tsx. */ /* Assistant: no bubble — just text on background, like an editor. The bubble itself flows naturally; child markdown elements (h*, p, ul, code blocks, tables, blockquotes) all need their own spacing/typography @@ -1443,13 +1422,6 @@ border-top: 1px solid var(--border); } - /* User content stays verbatim text, but shares the same no-bubble body - language as assistant messages. */ - .maka-bubble-user { - white-space: pre-wrap; - max-width: min(100%, 640px); - } - /* Hover-revealed copy button on assistant messages. The wrapper is position: relative so the button can anchor to the top-right corner. The hover/focus-within guard keeps it out of the way until the user diff --git a/apps/desktop/src/renderer/styles/reasoning-panel.css b/apps/desktop/src/renderer/styles/reasoning-panel.css index 763b212a20..480aef032e 100644 --- a/apps/desktop/src/renderer/styles/reasoning-panel.css +++ b/apps/desktop/src/renderer/styles/reasoning-panel.css @@ -108,23 +108,10 @@ cursor: help; } - /* PR-UI-Cx (@kenji msg cd09bcac) — "已截断" pill on the streaming - assistant bubble. Fires when `applyAssistantDelta` either tail-kept - a single oversize delta or head-capped the per-session total. Same - visual family as the reasoning-panel truncated pill, positioned as - a footer affordance under the bubble's Markdown body so it doesn't - compete with the running text. */ - .maka-bubble-truncated { - display: inline-block; - margin-top: 6px; - font-size: 10px; - color: var(--warning-text, var(--info-text)); - border: 1px solid oklch(from var(--warning) l c h / 0.24); - background: oklch(from var(--warning) l c h / 0.05); - border-radius: 4px; - padding: 0 5px; - cursor: help; - } + /* The streaming "已截断" pill (PR-UI-Cx, @kenji msg cd09bcac) moved onto the + `Bubble variant="assistant"` chat primitive as inline utilities (issue + #332 PR1); its sibling `.maka-reasoning-panel-truncated` pill below keeps + the same visual family. */ .maka-reasoning-panel-chevron { font-size: 12px; diff --git a/apps/desktop/src/renderer/styles/tool-output.css b/apps/desktop/src/renderer/styles/tool-output.css index 6207950242..432314921a 100644 --- a/apps/desktop/src/renderer/styles/tool-output.css +++ b/apps/desktop/src/renderer/styles/tool-output.css @@ -21,19 +21,6 @@ animation: none; } -.message > span { - display: block; - margin-bottom: 6px; - color: var(--foreground-50); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.01em; -} - -.message.user > span { color: var(--foreground-60); } -.message.assistant > span { color: var(--accent); } -.message.system > span { color: var(--info-text); } - /* PR-CHAT-CHROME-FOLLOWUP-0: the relative time is always visible now (it was `opacity: 0` until hover, which hid it on touch + from assistive tech). It stays quiet — 12px at the shared chrome size, @@ -49,28 +36,22 @@ } /* User turn meta row: quiet time + a copy affordance, right-aligned - beneath the message block (the parent `.message.user` aligns items - to flex-end, so this row shrink-wraps and hugs the right edge). */ + beneath the message block (the parent `Message role="user"` aligns its + items to flex-end, so this row shrink-wraps and hugs the right edge). */ .maka-message-meta { display: inline-flex; align-items: center; gap: 6px; } -.message.user { - display: flex; - flex-direction: column; - align-items: flex-end; - /* Separate the meta row from the bubble. Kept below the 8px turn - rhythm so the time + copy still read as belonging to the bubble - (proximity) rather than floating between turns. */ - gap: 6px; -} - +/* Turn summary / lineage / footer share the assistant turn's left-anchored + measure column. The `[data-role="assistant"]` parts ride the chat Message + primitive (issue #332 PR1) — the lineage row + footer are still hand-written + (PR2), so this rule keeps their layout until they migrate. */ .maka-turn > .maka-turn-summary, .maka-turn > .maka-turn-lineage-row, -.message.assistant .maka-turn-lineage-row, -.message.assistant .maka-turn-footer { +[data-slot="message"][data-role="assistant"] .maka-turn-lineage-row, +[data-slot="message"][data-role="assistant"] .maka-turn-footer { display: flex; max-width: var(--maka-chat-measure, 680px); width: 100%; @@ -79,20 +60,12 @@ justify-content: flex-start; } -.message.assistant, -.message.system { - max-width: var(--maka-chat-measure, 680px); - width: 100%; - margin-left: 0; - margin-right: auto; -} - -.message pre { +[data-slot="message"] pre { margin: 0; font: inherit; } -.message.system pre { +[data-slot="message"][data-role="system"] pre { display: inline-flex; border-radius: 999px; background: oklch(from var(--info) l c h / 0.08); diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index f856ef12c9..cfb493db3b 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -195,6 +195,7 @@ import { cn, } from './ui.js'; import { Alert, AlertAction, AlertDescription, AlertTitle } from './primitives/alert.js'; +import { Bubble, Message } from './primitives/chat.js'; import { Button as PrimitiveButton } from './primitives/button.js'; import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from './primitives/empty.js'; import { InputGroup, InputGroupAddon, InputGroupInput } from './primitives/input-group.js'; @@ -4374,7 +4375,7 @@ export function ChatView(props: { // text_complete. Wrapping here makes streaming structurally // identical to TurnView's committed turn.
-
+ {/* PR-UI-LAYOUT-42: Reasoning panel for Anthropic-style * extended thinking. Renders ABOVE the streaming * answer because thinking always precedes the @@ -4398,7 +4399,7 @@ export function ChatView(props: { onSettled={props.onStreamingSettled} /> )} -
+
)} {/* Defensive: if any tool ended up outside a turn (e.g. legacy @@ -4449,9 +4450,9 @@ const MessageBody = memo(function MessageBody(props: { role: string; text: strin // turn footer's copy (same primitive + `.maka-turn-footer-action`). return ( <> -
+ {props.text} -
+
{props.ts !== undefined && ( @@ -4464,9 +4465,9 @@ const MessageBody = memo(function MessageBody(props: { role: string; text: strin // Assistant / system body: open prose, no bubble. Per-turn timing lives in // the turn summary; copy + the other actions live in the turn footer. return ( -
+ -
+ ); }); @@ -4769,24 +4770,24 @@ function TurnView(props: {
)} {turn.user && ( -
-
+ )} {turn.notes.map((note) => ( -
-
+ ))} {turn.tools.length > 0 && (
@@ -4794,13 +4795,13 @@ function TurnView(props: {
)} {turn.assistant && ( -
-
+
{turn.assistantThinking && (
@@ -4872,7 +4873,7 @@ function TurnView(props: { assistantText={turn.assistant.text} /> )} -
+ )} ); @@ -5157,11 +5158,11 @@ function StreamingAssistantBubble(props: { text: string; live: boolean; truncate }, [props.live, catchingUp, props.onSettled]); return ( -
+ {props.truncated && (
)} -
+
); } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index eec5bc549e..37665d3b92 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -27,6 +27,7 @@ export * from './utils.js'; // consumers can `import { Alert, Empty, Sidebar, ... } from '@maka/ui'`. export * from './bot-brand.js'; export * from './primitives/alert.js'; +export * from './primitives/chat.js'; export * from './primitives/empty.js'; export * from './primitives/item.js'; export * from './primitives/spinner.js'; diff --git a/packages/ui/src/primitives/chat.tsx b/packages/ui/src/primitives/chat.tsx new file mode 100644 index 0000000000..e7d5dd6c49 --- /dev/null +++ b/packages/ui/src/primitives/chat.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { cn } from "../utils.js"; +import { cva, type VariantProps } from "class-variance-authority"; +import type React from "react"; + +/** + * Chat conversation-flow primitives (issue #332, PR1). + * + * `Message` is the per-turn row container; `Bubble` is the message body + * surface. They retire the bespoke `.message.{role}` / `.maka-bubble-user` + * shell CSS, moving the row/bubble *shell* onto the Tailwind substrate while + * leaving Markdown prose (`.maka-bubble-assistant *`, maka-tokens.css) and the + * turn machinery (summary / lineage / footer / markers — PR2) untouched. + * + * The row keeps the authored `.maka-message-row` base (centered reading column + * + entrance fade/animation + the `data-maka-visual-smoke` disable). That base + * lives in maka-tokens.css's `@layer components`, so the role utilities below + * (utilities layer) win over its `margin: 0 auto` for the left-anchored + * assistant/system rows. The neutral `--chat-user-bg` token path is preserved + * verbatim — the user bubble is never switched to `primary`/`accent`. + */ + +const messageVariants = cva("maka-message-row", { + defaultVariants: { role: "assistant" }, + variants: { + role: { + // `.message.user`: shrink-wrap column, body hugs the right edge. No + // margin override — the row stays centered (its `margin: 0 auto`). + user: "flex flex-col items-end gap-1.5", + // `.message.assistant` / `.message.system`: left-anchor inside the + // measure column (override the row's centering). + assistant: "ml-0 mr-auto", + system: "ml-0 mr-auto", + }, + }, +}); + +export interface MessageProps + extends React.ComponentPropsWithoutRef<"article"> { + role: "user" | "assistant" | "system"; +} + +export function Message({ + className, + role, + ...props +}: MessageProps): React.ReactElement { + return ( +
+ ); +} + +const bubbleVariants = cva("", { + defaultVariants: { variant: "assistant" }, + variants: { + variant: { + // `.maka-bubble-user`: tinted, width-capped, right-anchored block. + // Keeps the neutral `--chat-user-bg` token path (never primary/accent). + user: "max-w-[min(100%,640px)] whitespace-pre-wrap break-words rounded-lg bg-[var(--chat-user-bg)] px-3.5 py-2.5 leading-[1.6] text-[color:var(--chat-user-foreground,var(--foreground))]", + // Assistant / system: open prose, no bubble. Typography stays authored + // under `.maka-bubble-assistant` (Markdown prose, OUT of scope), so this + // variant re-emits that class as the styling hook. + assistant: "maka-bubble-assistant", + }, + }, +}); + +export interface BubbleProps extends React.ComponentPropsWithoutRef<"div"> { + variant: VariantProps["variant"]; +} + +export function Bubble({ + className, + variant, + ...props +}: BubbleProps): React.ReactElement { + return ( +
+ ); +} From 4ac84a455990aade2abef4cfdc924ed4e94ffb2d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 28 Jun 2026 00:32:35 +0800 Subject: [PATCH 2/3] =?UTF-8?q?refactor(ui):=20literalize=20user-bubble=20?= =?UTF-8?q?shell=20pixels=20+=20rename=20Message=20role=E2=86=92variant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on #334: - User bubble shell uses literal arbitrary utilities (rounded-[10px], px-[14px] py-[10px]) instead of the semantic scale (rounded-lg, px-3.5). The retired .maka-bubble-user hardcoded these pixels, so the literal is the faithful translation, immune to scale/token re-tuning, and self-evidently equal to the old computed style — the source-string contract becomes a complete proof without a render harness. (rounded-lg already resolves to 10px here via @theme inline --radius-lg, so this is not a regression fix — it removes the ambiguity the review flagged.) - Rename the Message `role` prop to `variant` so it never shadows the native HTML/ARIA `role` (still emitted to the DOM as data-role for the re-anchored turn rules); symmetric with Bubble's `variant`. - Strengthen the contract test to pin the literal radius/padding/leading/ width/token values and reject the semantic scale. --- .../chat-primitive-cascade-contract.test.ts | 37 +++++++++++++------ packages/ui/src/components.tsx | 8 ++-- packages/ui/src/primitives/chat.tsx | 25 +++++++++---- 3 files changed, 46 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts b/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts index 12882f1818..f7ee289edd 100644 --- a/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts +++ b/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts @@ -60,22 +60,35 @@ describe('chat primitive shell migration contract (#332 PR1)', () => { ); }); - it('keeps the user bubble on the neutral token path, never primary/accent', async () => { - const chatSrc = await readFile( + it('pins the user bubble shell to the retired .maka-bubble-user pixels', async () => { + const rawSrc = await readFile( resolve(REPO_ROOT, 'packages', 'ui', 'src', 'primitives', 'chat.tsx'), 'utf8', ); + // Strip comments so the assertions reflect real classNames, not prose that + // happens to name the scale utilities it is telling us to avoid. + const chatSrc = rawSrc.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); + // The shell values are LITERAL Tailwind arbitrary utilities, so each one + // compiles 1:1 to its declaration on a leaf element with nothing to + // resolve or override — asserting the class string here is equivalent to + // asserting the computed style, without a browser. Values mirror the + // retired `.maka-bubble-user` exactly (border-radius:10px; padding:10px + // 14px; line-height:1.6; max-width:min(100%,640px); --chat-user-bg). + for (const literal of [ + 'rounded-[10px]', + 'px-[14px]', + 'py-[10px]', + 'leading-[1.6]', + 'max-w-[min(100%,640px)]', + 'bg-[var(--chat-user-bg)]', + ]) { + assert.ok(chatSrc.includes(literal), `user bubble must keep the literal "${literal}"`); + } + // Never the semantic radius/spacing scale (would re-tune under a redesign) + // and never primary/accent (the neutral user-bubble token path is fixed). assert.ok( - chatSrc.includes('bg-[var(--chat-user-bg)]'), - 'user bubble must keep the --chat-user-bg token path', - ); - assert.ok( - chatSrc.includes('max-w-[min(100%,640px)]'), - 'user bubble width cap must match the retired .maka-bubble-user (min(100%,640px))', - ); - assert.ok( - !/bg-primary|bg-accent/.test(chatSrc), - 'user bubble must never switch to primary/accent backgrounds', + !/rounded-lg|bg-primary|bg-accent/.test(chatSrc), + 'user bubble must not use semantic rounded-lg or primary/accent backgrounds', ); }); }); diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index cfb493db3b..e5b55528de 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -4375,7 +4375,7 @@ export function ChatView(props: { // text_complete. Wrapping here makes streaming structurally // identical to TurnView's committed turn.
- + {/* PR-UI-LAYOUT-42: Reasoning panel for Anthropic-style * extended thinking. Renders ABOVE the streaming * answer because thinking always precedes the @@ -4771,7 +4771,7 @@ function TurnView(props: { )} {turn.user && ( @@ -4783,7 +4783,7 @@ function TurnView(props: { {turn.notes.map((note) => ( @@ -4796,7 +4796,7 @@ function TurnView(props: { )} {turn.assistant && ( { - role: "user" | "assistant" | "system"; + // The chat role. Named `variant` (not `role`) so it never shadows the native + // HTML/ARIA `role` attribute, which still flows through `...props`. Emitted + // to the DOM as `data-role` — the hook the turn lineage/footer and system + // `pre` rules anchor on. + variant: "user" | "assistant" | "system"; } export function Message({ className, - role, + variant, ...props }: MessageProps): React.ReactElement { return (
); @@ -61,8 +65,13 @@ const bubbleVariants = cva("", { variants: { variant: { // `.maka-bubble-user`: tinted, width-capped, right-anchored block. - // Keeps the neutral `--chat-user-bg` token path (never primary/accent). - user: "max-w-[min(100%,640px)] whitespace-pre-wrap break-words rounded-lg bg-[var(--chat-user-bg)] px-3.5 py-2.5 leading-[1.6] text-[color:var(--chat-user-foreground,var(--foreground))]", + // Values are LITERAL (`rounded-[10px]`, `px-[14px] py-[10px]`), not the + // design-system scale (`rounded-lg`, `px-3.5`): the retired CSS hardcoded + // these pixels, so the literal is the faithful, self-evidently-equal + // translation and immune to later scale/token re-tuning (the visual + // refresh, not this governance pass, owns adopting the scale). Keeps the + // neutral `--chat-user-bg` token path (never primary/accent). + user: "max-w-[min(100%,640px)] whitespace-pre-wrap break-words rounded-[10px] bg-[var(--chat-user-bg)] px-[14px] py-[10px] leading-[1.6] text-[color:var(--chat-user-foreground,var(--foreground))]", // Assistant / system: open prose, no bubble. Typography stays authored // under `.maka-bubble-assistant` (Markdown prose, OUT of scope), so this // variant re-emits that class as the styling hook. From 5f0cf189c6cb0f096b79f02b134fd5cd56f86cf3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 28 Jun 2026 10:08:17 +0800 Subject: [PATCH 3/3] refactor(ui): harden chat primitives against data-* override and pin user-bubble contract Address P3 review feedback on the #332 PR1 chat primitives (non-blocking robustness + governance precision): - Spread {...props} before the fixed data-slot/data-role/data-variant in Message/Bubble so the structural hooks the re-anchored selectors depend on always land last and can't be clobbered by a consumer. - Drop the unreachable defaultVariants from messageVariants/bubbleVariants (variant is a required prop; every call site passes it explicitly). - Tighten the user-bubble contract from contains-each-literal to an exact match on the whole variant class string, so a stray extra utility that would silently override the shell now fails the test. - Add a hook-free render-prop assertion in @maka/ui proving the primitives keep their own data-* over conflicting props. --- .../chat-primitive-cascade-contract.test.ts | 35 ++++++++----------- .../ui/src/__tests__/chat-primitives.test.ts | 30 ++++++++++++++++ packages/ui/src/primitives/chat.tsx | 9 ++--- 3 files changed, 50 insertions(+), 24 deletions(-) create mode 100644 packages/ui/src/__tests__/chat-primitives.test.ts diff --git a/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts b/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts index f7ee289edd..73e2a9f532 100644 --- a/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts +++ b/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts @@ -68,27 +68,22 @@ describe('chat primitive shell migration contract (#332 PR1)', () => { // Strip comments so the assertions reflect real classNames, not prose that // happens to name the scale utilities it is telling us to avoid. const chatSrc = rawSrc.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); - // The shell values are LITERAL Tailwind arbitrary utilities, so each one - // compiles 1:1 to its declaration on a leaf element with nothing to - // resolve or override — asserting the class string here is equivalent to - // asserting the computed style, without a browser. Values mirror the + // The shell values are LITERAL Tailwind arbitrary utilities, so the variant + // class string compiles 1:1 to its declarations on a leaf element with + // nothing to resolve or override — asserting the exact string here is + // equivalent to asserting the computed style, without a browser. Matching + // the WHOLE string (not just "contains each literal") also pins the set + // closed: a stray extra `rounded-[12px]` / `px-4` / second `max-w-*` that + // would silently override the shell makes this fail. Values mirror the // retired `.maka-bubble-user` exactly (border-radius:10px; padding:10px - // 14px; line-height:1.6; max-width:min(100%,640px); --chat-user-bg). - for (const literal of [ - 'rounded-[10px]', - 'px-[14px]', - 'py-[10px]', - 'leading-[1.6]', - 'max-w-[min(100%,640px)]', - 'bg-[var(--chat-user-bg)]', - ]) { - assert.ok(chatSrc.includes(literal), `user bubble must keep the literal "${literal}"`); - } - // Never the semantic radius/spacing scale (would re-tune under a redesign) - // and never primary/accent (the neutral user-bubble token path is fixed). - assert.ok( - !/rounded-lg|bg-primary|bg-accent/.test(chatSrc), - 'user bubble must not use semantic rounded-lg or primary/accent backgrounds', + // 14px; line-height:1.6; max-width:min(100%,640px); --chat-user-bg) and + // never the semantic scale (`rounded-lg`, `px-3.5`) or primary/accent. + const bubbleBlock = chatSrc.slice(chatSrc.indexOf('bubbleVariants')); + const userClass = bubbleBlock.match(/user:\s*"([^"]*)"/)?.[1]; + assert.equal( + userClass, + 'max-w-[min(100%,640px)] whitespace-pre-wrap break-words rounded-[10px] bg-[var(--chat-user-bg)] px-[14px] py-[10px] leading-[1.6] text-[color:var(--chat-user-foreground,var(--foreground))]', + 'user bubble variant must match the retired .maka-bubble-user pixels exactly', ); }); }); diff --git a/packages/ui/src/__tests__/chat-primitives.test.ts b/packages/ui/src/__tests__/chat-primitives.test.ts new file mode 100644 index 0000000000..85ee2030bc --- /dev/null +++ b/packages/ui/src/__tests__/chat-primitives.test.ts @@ -0,0 +1,30 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Bubble, Message } from '../primitives/chat.js'; + +// The re-anchored renderer selectors key off the primitives' own `data-slot` / +// `data-role` / `data-variant`, so a consumer must never be able to clobber +// them. Both primitives are hook-free pure functions, so calling them directly +// and inspecting the returned element's props proves the structural hooks win +// over conflicting props — no DOM, no renderer needed. +test('Message keeps its own data-slot/data-role over conflicting props', () => { + const el = Message({ + variant: 'assistant', + 'data-slot': 'spoofed', + 'data-role': 'user', + } as never); + const props = el.props as Record; + assert.equal(props['data-slot'], 'message'); + assert.equal(props['data-role'], 'assistant'); +}); + +test('Bubble keeps its own data-slot/data-variant over conflicting props', () => { + const el = Bubble({ + variant: 'user', + 'data-slot': 'spoofed', + 'data-variant': 'assistant', + } as never); + const props = el.props as Record; + assert.equal(props['data-slot'], 'bubble'); + assert.equal(props['data-variant'], 'user'); +}); diff --git a/packages/ui/src/primitives/chat.tsx b/packages/ui/src/primitives/chat.tsx index 6bae50d411..1f08ab2d4e 100644 --- a/packages/ui/src/primitives/chat.tsx +++ b/packages/ui/src/primitives/chat.tsx @@ -22,7 +22,6 @@ import type React from "react"; */ const messageVariants = cva("maka-message-row", { - defaultVariants: { variant: "assistant" }, variants: { variant: { // `.message.user`: shrink-wrap column, body hugs the right edge. No @@ -51,17 +50,19 @@ export function Message({ ...props }: MessageProps): React.ReactElement { return ( + // `{...props}` is spread first so the structural `data-*` hooks the + // re-anchored selectors depend on always land last and can't be clobbered + // by a consumer passing `data-slot` / `data-role`.
); } const bubbleVariants = cva("", { - defaultVariants: { variant: "assistant" }, variants: { variant: { // `.maka-bubble-user`: tinted, width-capped, right-anchored block. @@ -91,10 +92,10 @@ export function Bubble({ }: BubbleProps): React.ReactElement { return (
); }