From c59f80d9d76d9be92ef103bc2b81d7da421c8cbc Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 12 Jul 2026 11:10:28 +0800 Subject: [PATCH 1/4] fix(ui): pin Markdown heading ladder to a 0.15em step (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #546 Phase B ladder chased integer px targets at the 13px body base (19/16/14/13), but h3 (1.0769em, +8% over body) was too close to body copy — long answers relied on weight alone to distinguish H3 sections (#739). Re-pin to a uniform 0.15em step (1.45/1.30/1.15/1) so each tier clears the one below by +15% (CDP-verified as the minimum distinguishable gap) and hierarchy reads by size, not weight alone. The em values are design ratios, not px/13 rounding, so a future body-base change does not require re-pinning the ladder the way the #546/#616 integer-px chase did. h4 stays at 1em (GitHub's 1em h4 convention). MARKDOWN-PROSE-HEADING-HIERARCHY-0 locks the step (>=0.15em between tiers + h4=1em), not specific em values — a future patch may widen the step but must not narrow it back to the #546 shape. CDP-verified: 18.85/16.9/14.95/13px. The table-width part of #739 is not re-changed: #626 already moved prose tables to width:max-content + max-width:100% + overflow-wrap:break-word, CDP-verified (short/path/CJK/4-col/40-char hash/inline-code all fit 680px and a 520px narrow column), and the fit invariants are already covered by TABLE-A11Y-SEMANTICS-0 + PROSE-POLISH-13PX-0. Refs #739 --- .../__tests__/markdown-prose-contract.test.ts | 46 +++++++++++++++++++ apps/desktop/src/renderer/styles/prose.css | 25 ++++++---- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts b/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts index df8c1afe2f5..a47584a0446 100644 --- a/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts +++ b/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts @@ -408,3 +408,49 @@ describe('TABLE-A11Y-SEMANTICS-0 contract (#618 item 5)', () => { assert.match(wrapper!.decls, /max-width:\s*100%/, 'the wrapper caps at the prose measure so wide tables scroll instead of stretching the bubble'); }); }); + +describe('MARKDOWN-PROSE-HEADING-HIERARCHY-0 contract (#739)', () => { + // Issue #739: the #546 Phase B ladder (1.4615 / 1.2308 / 1.0769 / 1) chased + // integer px targets at the 13px base, but h3 (1.0769em, +8% over body) was + // too close to body copy — long answers relied on weight alone to + // distinguish H3 sections. #739 re-pins the ladder to a uniform 0.15em + // step (1.45 / 1.30 / 1.15 / 1) so each tier clears the one below by +15%, + // CDP-verified as the minimum distinguishable gap. + // + // This contract locks the STEP, not specific em values: a future patch + // may widen the step (e.g. 0.20em for a roomier ladder), but must not + // narrow it back to the #546 shape where h3-h4 = 0.08em and hierarchy + // collapses back onto weight. h4 stays at 1em (GitHub's 1em h4 + // convention: a heading via weight + secondary color, not size). + // + // Source of truth: issue #739 acceptance ("H2/H3 sections clearly + // distinguishable from body copy") + CDP comparison of +8% / +15% / +23% + // h3 tiers — the +15% step was the smallest gap that read as a section. + const HEADING_STEP_EM = 0.15; + + const headingEm = (blocks: ReturnType, sel: string): number => { + const b = blocks.find(({ selectors }) => selectors === sel); + assert.ok(b, `expected ${sel} rule in prose.css`); + const m = b!.decls.match(/font-size:\s*([\d.]+)em/); + assert.ok(m, `expected a font-size em declaration on ${sel}; got ${b!.decls}`); + return parseFloat(m![1]); + }; + +it('each heading tier clears the one below by >=0.15em so hierarchy reads by size, not weight alone', async () => { + const css = stripCssComments(await readFile(PROSE_CSS, 'utf8')); + const blocks = cssBlocks(css); + const h1 = headingEm(blocks, '.maka-prose h1'); + const h2 = headingEm(blocks, '.maka-prose h2'); + const h3 = headingEm(blocks, '.maka-prose h3'); + const h4 = headingEm(blocks, '.maka-prose h4'); + // Compare in centi-em (Math.round(x * 100)) so 1.15 - 1.0 does not fail + // on float noise (0.14999999... < 0.15). 1.15em -> 115, 1.0em -> 100, + // gap 15 >= 15. + const cemi = (em: number) => Math.round(em * 100); + const step = cemi(HEADING_STEP_EM); + assert.equal(cemi(h4), 100, 'h4 sits at 1em and reads as a heading via weight + secondary color (GitHub 1em h4 convention)'); + assert.ok(cemi(h3) - cemi(h4) >= step, `h3 (${h3}em) must clear body by >=${HEADING_STEP_EM}em so it reads as a section, not body; got ${h3 - h4}em (the #546 ladder's 0.0769em gap was the #739 defect)`); + assert.ok(cemi(h2) - cemi(h3) >= step, `h2 (${h2}em) must clear h3 by >=${HEADING_STEP_EM}em; got ${h2 - h3}em`); + assert.ok(cemi(h1) - cemi(h2) >= step, `h1 (${h1}em) must clear h2 by >=${HEADING_STEP_EM}em; got ${h1 - h2}em`); + }); +}); diff --git a/apps/desktop/src/renderer/styles/prose.css b/apps/desktop/src/renderer/styles/prose.css index 97396a5df33..c2c2a7f87a9 100644 --- a/apps/desktop/src/renderer/styles/prose.css +++ b/apps/desktop/src/renderer/styles/prose.css @@ -130,15 +130,22 @@ .maka-prose h2 { letter-spacing: var(--tracking-normal); } -/* Heading ladder re-pinned for the 13px body base (#546 Phase B): the old - em values were derived for 15px (22/19/16/14) and landed on fractional - pixels at 13 (19.07/16.47/13.87/12.13), with h3 only 0.87px above body - and h4 *below* body. Integer targets now: 19 / 16 / 14 / 13. h4 sits at - 1em and reads as a heading via weight + secondary color (same convention - as GitHub's 1em h4). */ -.maka-prose h1 { font-size: 1.4615em; } -.maka-prose h2 { font-size: 1.2308em; } -.maka-prose h3 { font-size: 1.0769em; } +/* Heading ladder pinned to a 0.15em step for the 13px body base (#739). + #546 Phase B derived the ladder from the old 15px scale and chased + integer px targets 19 / 16 / 14 / 13 — but at the 13px body base h3 + (1.0769em, +8% over body) was too close to body copy, so long answers + relied on weight alone to distinguish H3 sections (#739). + Re-pinned to a uniform 0.15em step (1.45 / 1.30 / 1.15 / 1) so each + tier clears the one below by +15% — CDP-verified as the minimum + distinguishable gap — and hierarchy reads by size, not weight alone. + The em values are design ratios (not px/13 rounding), so a future + body-base change does not require re-pinning the ladder the way the + #546 / #616 integer-px chase did. h4 stays at 1em and reads as a heading + via weight + secondary color (GitHub's 1em h4 convention). + MARKDOWN-PROSE-HEADING-HIERARCHY-0 locks the step. */ +.maka-prose h1 { font-size: 1.45em; } +.maka-prose h2 { font-size: 1.30em; } +.maka-prose h3 { font-size: 1.15em; } .maka-prose h4 { font-size: 1em; color: var(--foreground-secondary); } .maka-prose ul, .maka-prose ol { From 73a2c8d1b435118ef5f54cf25d841f1271f5ae9a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 12 Jul 2026 11:50:53 +0800 Subject: [PATCH 2/4] fix(ui): render bare markdown elements so prose.css reaches the DOM (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streamdown's default components tag every markdown element with Tailwind utilities (h1 "text-3xl", h3 "text-xl", th/td "px-4 py-2 text-sm", thead "bg-muted/80", blockquote "border-l-4", ul "list-disc", ...) in the `utilities` layer, overriding prose.css's `components`-layer rules. The heading ladder and table padding from the previous commit never reached the rendered DOM — CDP-verified h3 was 16.25px (text-xl) not 14.95px, and th/td padding was 8px/16px (px-4 py-2) not 6px/12px. The issue's "16px cell padding" was the real render all along; my earlier CDP probe used hand-built HTML that bypassed Streamdown's utilities. Render bare semantic elements via a bareElement helper (h1-h6, p, ul, ol, li, blockquote, hr, strong, thead, tbody, tr, th, td) so prose.css owns all markdown typography. For `code`, distinguish inline (drop Streamdown's bg-muted/px-1.5/py-0.5/text-sm so prose.css's inline-code pill applies) from block (keep `language-*` for rehype-highlight). Elements without a prose.css rule fall back to the browser semantic default. Add MARKDOWN-PROSE-RENDER-OWNER-0 locking the bare overrides. Fix the heading-step contract's centi-em rounding (toFixed(4) rejects a 0.1451em gap, not rounded to 15) and correct the "each tier +15%" comment to "fixed 0.15em step" (the relative gap widens per tier: +15% over body for h3, +13% over h3 for h2, +12% over h2 for h1). CDP-verified on the real MarkdownBody render: h1-h4 = 18.85/16.9/14.95/ 13px (uniform 0.15em step), th/td padding 6px/12px, thead frameless, inline-code = prose.css pill (0.92em, 0/6px padding, foreground-5 bg), block code retains language-* for hljs. Addresses codex review of PR #744 (P2 utility-layer override, P3 contract rounding + comment) at the root — the markdown-body components seam — rather than patching prose.css values that never reached the DOM. Refs #739 --- .../__tests__/markdown-prose-contract.test.ts | 57 ++++++++++++---- apps/desktop/src/renderer/styles/prose.css | 6 +- packages/ui/src/markdown-body.tsx | 65 +++++++++++++++++-- 3 files changed, 107 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts b/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts index a47584a0446..34b10bf99b2 100644 --- a/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts +++ b/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts @@ -414,8 +414,10 @@ describe('MARKDOWN-PROSE-HEADING-HIERARCHY-0 contract (#739)', () => { // integer px targets at the 13px base, but h3 (1.0769em, +8% over body) was // too close to body copy — long answers relied on weight alone to // distinguish H3 sections. #739 re-pins the ladder to a uniform 0.15em - // step (1.45 / 1.30 / 1.15 / 1) so each tier clears the one below by +15%, - // CDP-verified as the minimum distinguishable gap. + // step (1.45 / 1.30 / 1.15 / 1): each tier clears the one below by a fixed + // 0.15em increment (≈2px at the 13px base; the relative gap widens per tier + // — +15% over body for h3, +13% over h3 for h2, +12% over h2 for h1), + // CDP-verified as the minimum distinguishable step. // // This contract locks the STEP, not specific em values: a future patch // may widen the step (e.g. 0.20em for a roomier ladder), but must not @@ -425,7 +427,7 @@ describe('MARKDOWN-PROSE-HEADING-HIERARCHY-0 contract (#739)', () => { // // Source of truth: issue #739 acceptance ("H2/H3 sections clearly // distinguishable from body copy") + CDP comparison of +8% / +15% / +23% - // h3 tiers — the +15% step was the smallest gap that read as a section. + // h3 tiers — the 0.15em step was the smallest gap that read as a section. const HEADING_STEP_EM = 0.15; const headingEm = (blocks: ReturnType, sel: string): number => { @@ -436,21 +438,50 @@ describe('MARKDOWN-PROSE-HEADING-HIERARCHY-0 contract (#739)', () => { return parseFloat(m![1]); }; -it('each heading tier clears the one below by >=0.15em so hierarchy reads by size, not weight alone', async () => { + it('each heading tier clears the one below by >=0.15em so hierarchy reads by size, not weight alone', async () => { const css = stripCssComments(await readFile(PROSE_CSS, 'utf8')); const blocks = cssBlocks(css); const h1 = headingEm(blocks, '.maka-prose h1'); const h2 = headingEm(blocks, '.maka-prose h2'); const h3 = headingEm(blocks, '.maka-prose h3'); const h4 = headingEm(blocks, '.maka-prose h4'); - // Compare in centi-em (Math.round(x * 100)) so 1.15 - 1.0 does not fail - // on float noise (0.14999999... < 0.15). 1.15em -> 115, 1.0em -> 100, - // gap 15 >= 15. - const cemi = (em: number) => Math.round(em * 100); - const step = cemi(HEADING_STEP_EM); - assert.equal(cemi(h4), 100, 'h4 sits at 1em and reads as a heading via weight + secondary color (GitHub 1em h4 convention)'); - assert.ok(cemi(h3) - cemi(h4) >= step, `h3 (${h3}em) must clear body by >=${HEADING_STEP_EM}em so it reads as a section, not body; got ${h3 - h4}em (the #546 ladder's 0.0769em gap was the #739 defect)`); - assert.ok(cemi(h2) - cemi(h3) >= step, `h2 (${h2}em) must clear h3 by >=${HEADING_STEP_EM}em; got ${h2 - h3}em`); - assert.ok(cemi(h1) - cemi(h2) >= step, `h1 (${h1}em) must clear h2 by >=${HEADING_STEP_EM}em; got ${h1 - h2}em`); + // Compare via toFixed(4) — exact enough to reject a 0.1451em gap, but + // robust to float noise (1.15 - 1.0 = 0.14999... rounds to 0.1500). A + // centi-em Math.round(x*100) check would let 1.1451em through (115 vs + // 100, gap "15"), understating the threshold. + const gap = (a: number, b: number) => parseFloat((a - b).toFixed(4)); + assert.equal(h4, 1, 'h4 sits at 1em and reads as a heading via weight + secondary color (GitHub 1em h4 convention)'); + assert.ok(gap(h3, h4) >= HEADING_STEP_EM, `h3 (${h3}em) must clear body by >=${HEADING_STEP_EM}em so it reads as a section, not body; got ${gap(h3, h4)}em (the #546 ladder's 0.0769em gap was the #739 defect)`); + assert.ok(gap(h2, h3) >= HEADING_STEP_EM, `h2 (${h2}em) must clear h3 by >=${HEADING_STEP_EM}em; got ${gap(h2, h3)}em`); + assert.ok(gap(h1, h2) >= HEADING_STEP_EM, `h1 (${h1}em) must clear h2 by >=${HEADING_STEP_EM}em; got ${gap(h1, h2)}em`); + }); +}); + +describe('MARKDOWN-PROSE-RENDER-OWNER-0 contract (#739)', () => { + // Streamdown's default components tag every markdown element with Tailwind + // utility classes (h1 "text-3xl", h3 "text-xl", th/td "px-4 py-2 text-sm", + // thead "bg-muted/80", tbody "divide-y", blockquote "border-l-4", ul + // "list-disc", ...). Those sit in the `utilities` cascade layer and override + // prose.css's `components`-layer markdown rules, so the .maka-prose layer + // never reached the rendered DOM — the heading ladder and table padding + // declared in prose.css were silently overwritten (#739). markdown-body.tsx + // renders bare semantic elements via bareElement so prose.css owns all + // markdown typography. This contract locks the bare override for the + // heading + table-structure elements so a future patch does not silently + // drop it and let Streamdown's utilities back in (which would re-break the + // heading ladder and table fit the moment Tailwind generates those classes + // from elsewhere in the codebase). + const BARE_OVERRIDDEN = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'ul', 'ol', 'li', 'blockquote', 'hr', 'strong', 'thead', 'tbody', 'tr', 'th', 'td'] as const; + + it('markdown-body overrides heading + table-structure elements with bareElement so prose.css reaches the DOM', async () => { + const src = await readFile(MARKDOWN_BODY, 'utf8'); + assert.match(src, /function bareElement/, 'markdown-body.tsx must define a bareElement helper that strips Streamdown utility classes so prose.css reaches the rendered markdown (#739)'); + for (const tag of BARE_OVERRIDDEN) { + assert.match( + src, + new RegExp(`\\b${tag}:\\s*bareElement\\(['"]${tag}['"]\\)`), + `markdown-body.tsx components prop must override ${tag} with bareElement('${tag}') so prose.css rules reach the rendered ${tag} instead of Streamdown's Tailwind utilities`, + ); + } }); }); diff --git a/apps/desktop/src/renderer/styles/prose.css b/apps/desktop/src/renderer/styles/prose.css index c2c2a7f87a9..7d05917bd99 100644 --- a/apps/desktop/src/renderer/styles/prose.css +++ b/apps/desktop/src/renderer/styles/prose.css @@ -136,8 +136,10 @@ (1.0769em, +8% over body) was too close to body copy, so long answers relied on weight alone to distinguish H3 sections (#739). Re-pinned to a uniform 0.15em step (1.45 / 1.30 / 1.15 / 1) so each - tier clears the one below by +15% — CDP-verified as the minimum - distinguishable gap — and hierarchy reads by size, not weight alone. + tier clears the one below by a fixed 0.15em step (≈2px at the 13px + body base; the relative gap widens per tier — +15% over body for h3, + +13% over h3 for h2, +12% over h2 for h1), CDP-verified as the minimum + distinguishable step — and hierarchy reads by size, not weight alone. The em values are design ratios (not px/13 rounding), so a future body-base change does not require re-pinning the ladder the way the #546 / #616 integer-px chase did. h4 stays at 1em and reads as a heading diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx index 668d6da2091..3c2d9d29ffb 100644 --- a/packages/ui/src/markdown-body.tsx +++ b/packages/ui/src/markdown-body.tsx @@ -16,7 +16,7 @@ import { useContext, type ReactNode } from 'react'; import * as React from 'react'; -import { defaultRehypePlugins, defaultRemarkPlugins, Streamdown } from 'streamdown'; +import { defaultRehypePlugins, defaultRemarkPlugins, Streamdown, type ExtraProps } from 'streamdown'; import rehypeHighlight from 'rehype-highlight'; import remarkBreaks from 'remark-breaks'; import { Check, Copy } from './icons.js'; @@ -61,6 +61,24 @@ const MARKDOWN_REHYPE_PLUGINS = [ ], ]; +/** + * Streamdown tags every markdown element with Tailwind utility classes + * (h1 `text-3xl`, h3 `text-xl`, th/td `px-4 py-2 text-sm`, thead `bg-muted/80`, + * tbody `divide-y`, blockquote `border-l-4`, ul `list-disc`, ...). Those sit in + * the `utilities` cascade layer and override prose.css's `components`-layer + * markdown styles, so the .maka-prose layer never reaches the rendered DOM + * (#739: heading ladder and table padding declared in prose.css were silently + * overwritten). Render bare semantic elements instead: prose.css owns all + * markdown typography, and elements without a prose.css rule (h5/h6/strong/ + * sup/sub) fall back to the browser's semantic default (strong→bold, + * em→italic), which is correct. `node` (react-markdown's AST node) and + * `className` (Streamdown's utility) are dropped so neither leaks to the DOM. + */ +function bareElement(Tag: K) { + return ({ node: _node, className: _className, children, ...rest }: React.JSX.IntrinsicElements[K] & ExtraProps) => + React.createElement(Tag, rest, children); +} + export function MarkdownBody(props: { text: string; streaming?: boolean }) { return ( code` in CSS. - code: ({ children, className, ...rest }) => ( - - {children} - - ), + // + // #739: Streamdown tags inline code with Tailwind utilities + // (`rounded bg-muted px-1.5 py-0.5 font-mono text-sm`) that sit in the + // `utilities` layer and override prose.css's `components`-layer + // inline-code pill. Block code instead carries a `language-*` class + // from rehype-highlight that the code-block chrome + hljs need. Pass + // through only the block-code language class; drop inline-code + // utilities so prose.css styles the pill. + code: ({ children, className, ...rest }) => { + const blockLanguage = + typeof className === 'string' && /language-/.test(className) ? className : undefined; + return ( + + {children} + + ); + }, // Wrap block code with a language pill header + copy affordance. // The pill is from an external design reference (40-markdown-deep §7a) — surfaces the // detected language so users can verify hljs got it right. @@ -107,6 +137,29 @@ export function MarkdownBody(props: { text: string; streaming?: boolean }) { {children}
), + // #739: render bare heading + table-structure elements so prose.css + // (heading ladder, frameless table, cell padding) actually applies — + // Streamdown's default components tag them with Tailwind utilities that + // sit in the `utilities` layer and override the `components`-layer + // prose.css rules. See bareElement above. + h1: bareElement('h1'), + h2: bareElement('h2'), + h3: bareElement('h3'), + h4: bareElement('h4'), + h5: bareElement('h5'), + h6: bareElement('h6'), + p: bareElement('p'), + ul: bareElement('ul'), + ol: bareElement('ol'), + li: bareElement('li'), + blockquote: bareElement('blockquote'), + hr: bareElement('hr'), + strong: bareElement('strong'), + thead: bareElement('thead'), + tbody: bareElement('tbody'), + tr: bareElement('tr'), + th: bareElement('th'), + td: bareElement('td'), }} > {props.text} From 312893868b7bc6d45483acedf9e0638f6cbe4a19 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 12 Jul 2026 12:15:48 +0800 Subject: [PATCH 3/4] fix(ui): preserve HAST className and functional logic in bare markdown overrides (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-review of PR #744 found the previous bare-override-all was too broad and dropped the wrong thing: - bareElement destructured `className` off, but the className react-markdown forwards to a components override is the HAST node's *semantic* class (remark-gfm's `contains-task-list`/`task-list-item`, rehype-highlight's `language-*`), NOT Streamdown's utilities — the utilities are merged inside Streamdown's default component via `r(utility, t)`, which an override replaces entirely. Dropping className lost GFM task-list classes, so prose.css's `.maka-prose ul.contains-task-list` rules stopped matching. bareElement now preserves className and drops only `node` (the AST node that would otherwise leak as `node="[object Object]"`). - Overriding p/ol/section broke Streamdown's functional logic: default p unwraps a lone image (a bare p produced invalid `

…

`), and ol/section clean streaming footnotes. h5/h6 have no prose.css rule, so stripping them lost Streamdown's heading styling (Tailwind preflight resets h1-h6 to inherit). The override set is now scoped to elements whose ONLY Streamdown default is the utility merge: h1-h4, ul, li, thead, tbody, tr, th, td. p/ol/section/h5/h6 are left on Streamdown's default renderer. - Daily Review renders outside the chat bubble (no .maka-prose ancestor), so stripping utilities left its heading/table/blockquote with neither Streamdown utilities nor prose.css. Its section-body div now carries .maka-prose itself — heading/table/blockquote get a prose-css owner, while the section-body's own unlayered CSS still wins for p/ul/ol (components layer loses to unlayered), so Daily Review list/paragraph styling is unchanged. - The inline-code inline/block split was redundant: react-markdown forwards the HAST className (inline code has none, block code has `language-*`), so the pre-existing `` already left inline code to prose.css and block code to hljs. Reverted to that and dropped the misleading comment. MARKDOWN-PROSE-RENDER-OWNER-0 now locks both the bare-override set and the NOT-override set (p/ol/section/h5/h6), plus the Daily Review .maka-prose owner, so a future patch cannot silently drop either side. CDP-verified on the real MarkdownBody render: chat h1-h4 = 18.85/16.9/ 14.95/13px, h5/h6 keep Streamdown's semibold styling, task-list retains contains-task-list/task-list-item (prose.css task-list rules match), lone images stay outside

, th/td padding 6px/12px; Daily Review h2 = 16.9px (prose.css heading) while p keeps section-body color/size (unlayered wins). Addresses codex re-review of PR #744 (P2 HAST className + Daily Review owner; P3 h5/h6, p image unwrap, ol/section footnote cleanup). Refs #739 --- .../__tests__/markdown-prose-contract.test.ts | 22 ++++- packages/ui/src/daily-review-panel.tsx | 2 +- packages/ui/src/markdown-body.tsx | 84 +++++++++---------- 3 files changed, 64 insertions(+), 44 deletions(-) diff --git a/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts b/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts index 34b10bf99b2..181abff60b8 100644 --- a/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts +++ b/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts @@ -471,7 +471,8 @@ describe('MARKDOWN-PROSE-RENDER-OWNER-0 contract (#739)', () => { // drop it and let Streamdown's utilities back in (which would re-break the // heading ladder and table fit the moment Tailwind generates those classes // from elsewhere in the codebase). - const BARE_OVERRIDDEN = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'ul', 'ol', 'li', 'blockquote', 'hr', 'strong', 'thead', 'tbody', 'tr', 'th', 'td'] as const; + const BARE_OVERRIDDEN = ['h1', 'h2', 'h3', 'h4', 'ul', 'li', 'thead', 'tbody', 'tr', 'th', 'td'] as const; + const NOT_OVERRIDDEN = ['p', 'ol', 'section', 'h5', 'h6'] as const; it('markdown-body overrides heading + table-structure elements with bareElement so prose.css reaches the DOM', async () => { const src = await readFile(MARKDOWN_BODY, 'utf8'); @@ -484,4 +485,23 @@ describe('MARKDOWN-PROSE-RENDER-OWNER-0 contract (#739)', () => { ); } }); + + it('markdown-body does NOT override p/ol/section/h5/h6 (functional logic or no prose.css rule)', async () => { + const src = await readFile(MARKDOWN_BODY, 'utf8'); + for (const tag of NOT_OVERRIDDEN) { + assert.ok( + !new RegExp(`\\b${tag}:\\s*bareElement\\(['"]${tag}['"]\\)`).test(src), + `markdown-body.tsx must NOT override ${tag} with bareElement — p/ol/section carry Streamdown functional logic (p unwraps a lone image; ol/section clean streaming footnotes), and h5/h6 have no prose.css rule so stripping would lose Streamdown's heading styling`, + ); + } + }); + + it('Daily Review section-body carries .maka-prose so heading/table get a prose-css owner outside the chat bubble', async () => { + const src = await readFile(resolve(REPO_ROOT, 'packages', 'ui', 'src', 'daily-review-panel.tsx'), 'utf8'); + assert.match( + src, + /maka-daily-review-archive-section-body[^"]* maka-prose|maka-prose[^"]* maka-daily-review-archive-section-body/, + 'daily-review-panel.tsx must put .maka-prose on the section-body div so heading/table/blockquote get prose.css styling — they have no Streamdown utility fallback now that markdown-body strips utilities, and Daily Review has no chat-bubble .maka-prose ancestor (#739)', + ); + }); }); diff --git a/packages/ui/src/daily-review-panel.tsx b/packages/ui/src/daily-review-panel.tsx index 6c10077453d..d0f56f65a1c 100644 --- a/packages/ui/src/daily-review-panel.tsx +++ b/packages/ui/src/daily-review-panel.tsx @@ -692,7 +692,7 @@ function DailyReviewArchiveBody(props: { archive: DailyReviewArchive | null; loa {/* Reports are LLM-generated markdown — bullet lists and inline code rendered as flat pre-wrap text read as mush. Reuse the shared Markdown pipeline (same one chat uses). */} -

+
diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx index 3c2d9d29ffb..77299430e5e 100644 --- a/packages/ui/src/markdown-body.tsx +++ b/packages/ui/src/markdown-body.tsx @@ -62,20 +62,30 @@ const MARKDOWN_REHYPE_PLUGINS = [ ]; /** - * Streamdown tags every markdown element with Tailwind utility classes - * (h1 `text-3xl`, h3 `text-xl`, th/td `px-4 py-2 text-sm`, thead `bg-muted/80`, - * tbody `divide-y`, blockquote `border-l-4`, ul `list-disc`, ...). Those sit in - * the `utilities` cascade layer and override prose.css's `components`-layer - * markdown styles, so the .maka-prose layer never reaches the rendered DOM - * (#739: heading ladder and table padding declared in prose.css were silently - * overwritten). Render bare semantic elements instead: prose.css owns all - * markdown typography, and elements without a prose.css rule (h5/h6/strong/ - * sup/sub) fall back to the browser's semantic default (strong→bold, - * em→italic), which is correct. `node` (react-markdown's AST node) and - * `className` (Streamdown's utility) are dropped so neither leaks to the DOM. + * Streamdown's default components merge Tailwind utility classes into every + * markdown element (h1 "text-3xl", h3 "text-xl", th/td "px-4 py-2 text-sm", + * thead "bg-muted/80", ul "list-disc", ...) via an internal `r(utility, t)` + * call. Those utilities sit in the `utilities` cascade layer and override + * prose.css's `components`-layer markdown rules, so the .maka-prose layer + * never reaches the rendered DOM (#739: the heading ladder and table padding + * declared in prose.css were silently overwritten). + * + * A `components` override REPLACES the default component, so Streamdown's + * internal utility merge never runs. The `className` react-markdown forwards + * here is the HAST node's semantic class (remark-gfm's `contains-task-list`/ + * `task-list-item`, rehype-highlight's `language-*`) — NOT Streamdown's + * utilities. So render the element with its HAST className preserved and only + * `node` (the AST node, which would otherwise leak to the DOM as + * `node="[object Object]"`) dropped. prose.css then styles the bare element. + * + * Only apply this to elements whose ONLY Streamdown default is the utility + * merge — p, ol, and section carry functional logic (p unwraps a lone image, + * ol/section clean streaming footnotes) and must stay on Streamdown's default + * renderer; h5/h6 have no prose.css rule and would lose all heading styling + * if stripped. See the components prop below for the exact override set. */ function bareElement(Tag: K) { - return ({ node: _node, className: _className, children, ...rest }: React.JSX.IntrinsicElements[K] & ExtraProps) => + return ({ node: _node, children, ...rest }: React.JSX.IntrinsicElements[K] & ExtraProps) => React.createElement(Tag, rest, children); } @@ -105,24 +115,15 @@ export function MarkdownBody(props: { text: string; streaming?: boolean }) { ), // Inline `code` keeps the bubble's foreground color; only block code - // gets the framed treatment via `pre > code` in CSS. - // - // #739: Streamdown tags inline code with Tailwind utilities - // (`rounded bg-muted px-1.5 py-0.5 font-mono text-sm`) that sit in the - // `utilities` layer and override prose.css's `components`-layer - // inline-code pill. Block code instead carries a `language-*` class - // from rehype-highlight that the code-block chrome + hljs need. Pass - // through only the block-code language class; drop inline-code - // utilities so prose.css styles the pill. - code: ({ children, className, ...rest }) => { - const blockLanguage = - typeof className === 'string' && /language-/.test(className) ? className : undefined; - return ( - - {children} - - ); - }, + // gets the framed treatment via `pre > code` in CSS. react-markdown + // forwards the HAST className here (inline code has none; block code + // carries `language-*` from rehype-highlight), so passing it through + // styles block code for hljs and leaves inline code to prose.css. + code: ({ children, className, ...rest }) => ( + + {children} + + ), // Wrap block code with a language pill header + copy affordance. // The pill is from an external design reference (40-markdown-deep §7a) — surfaces the // detected language so users can verify hljs got it right. @@ -137,24 +138,23 @@ export function MarkdownBody(props: { text: string; streaming?: boolean }) { {children}
), - // #739: render bare heading + table-structure elements so prose.css - // (heading ladder, frameless table, cell padding) actually applies — - // Streamdown's default components tag them with Tailwind utilities that - // sit in the `utilities` layer and override the `components`-layer - // prose.css rules. See bareElement above. + // #739: render bare heading + table-structure + list elements so + // prose.css (heading ladder, frameless table, cell padding, task-list) + // actually applies — Streamdown's default components tag them with + // Tailwind utilities in the `utilities` layer that override the + // `components`-layer prose.css rules. bareElement preserves the HAST + // className (task-list, language-*) so prose.css's `.maka-prose + // ul.contains-task-list` rules still match. p/ol/section are NOT + // overridden — Streamdown's default p unwraps a lone image, and + // ol/section clean streaming footnotes; stripping them breaks that + // logic. h5/h6 are NOT overridden — prose.css has no rule for them, so + // stripping would lose Streamdown's heading styling. See bareElement. h1: bareElement('h1'), h2: bareElement('h2'), h3: bareElement('h3'), h4: bareElement('h4'), - h5: bareElement('h5'), - h6: bareElement('h6'), - p: bareElement('p'), ul: bareElement('ul'), - ol: bareElement('ol'), li: bareElement('li'), - blockquote: bareElement('blockquote'), - hr: bareElement('hr'), - strong: bareElement('strong'), thead: bareElement('thead'), tbody: bareElement('tbody'), tr: bareElement('tr'), From 1406a3f846c6b32790d6ca77427d678e80b6bc41 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 12 Jul 2026 12:49:48 +0800 Subject: [PATCH 4/4] fix(ui): override blockquote + lock HAST className preservation (#739 P3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round-3 PASS with two P3: - blockquote was the one utility-only element left on Streamdown's default renderer; its `text-muted-foreground italic` utilities sit in the utilities layer and override prose.css's `--foreground-secondary` blockquote color. Added `blockquote: bareElement('blockquote')` for consistency with the h1-h4/ul/li/thead/tbody/tr/th/td set (all utility-only, no functional logic). - The RENDER-OWNER text-shape contract only checked `tag: bareElement('tag')` strings, so changing bareElement to drop `className` again (the round-2 regression) would still pass. Added a render assertion in markdown-body.test.ts that renders a GFM task-list and asserts the output carries `contains-task-list` / `task-list-item` — the HAST classes prose.css's `.maka-prose ul.contains-task-list` rules match on. This locks the HAST-className preservation that the text contract cannot. BARE_OVERRIDDEN now includes blockquote. All tests green: ui 104, desktop contract 18, desktop suite 2318, typecheck clean. Refs #739 --- .../main/__tests__/markdown-prose-contract.test.ts | 2 +- packages/ui/src/__tests__/markdown-body.test.ts | 14 ++++++++++++++ packages/ui/src/markdown-body.tsx | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts b/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts index 181abff60b8..2bbb21d815c 100644 --- a/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts +++ b/apps/desktop/src/main/__tests__/markdown-prose-contract.test.ts @@ -471,7 +471,7 @@ describe('MARKDOWN-PROSE-RENDER-OWNER-0 contract (#739)', () => { // drop it and let Streamdown's utilities back in (which would re-break the // heading ladder and table fit the moment Tailwind generates those classes // from elsewhere in the codebase). - const BARE_OVERRIDDEN = ['h1', 'h2', 'h3', 'h4', 'ul', 'li', 'thead', 'tbody', 'tr', 'th', 'td'] as const; + const BARE_OVERRIDDEN = ['h1', 'h2', 'h3', 'h4', 'blockquote', 'ul', 'li', 'thead', 'tbody', 'tr', 'th', 'td'] as const; const NOT_OVERRIDDEN = ['p', 'ol', 'section', 'h5', 'h6'] as const; it('markdown-body overrides heading + table-structure elements with bareElement so prose.css reaches the DOM', async () => { diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 189cd4a51ca..f14fe4cc9e4 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -37,3 +37,17 @@ it('preserves allowlisted Maka navigation links through sanitization', () => { assert.match(markup, /data-maka-uri-kind="settings"/); assert.doesNotMatch(markup, /Blocked URL/); }); + +it('preserves GFM task-list HAST classes so prose.css task-list rules match (#739 round-2 guard)', () => { + const markup = renderToStaticMarkup(createElement(MarkdownBody, { + text: '- [x] done\n- [ ] todo', + })); + // bareElement must preserve the HAST className react-markdown forwards + // (remark-gfm's contains-task-list / task-list-item). Dropping it — the + // round-2 regression — makes prose.css's `.maka-prose ul.contains-task-list` + // rules stop matching, so task-list checkboxes fall back to the UA default + // and list markers reappear. This render assertion locks the HAST-className + // contract that the text-shape contract alone cannot catch. + assert.match(markup, /class="contains-task-list"/, 'bareElement must preserve the HAST className remark-gfm sets on the task-list
    ; dropping it (round-2 regression) makes prose.css .maka-prose ul.contains-task-list rules stop matching'); + assert.match(markup, /class="task-list-item"/, 'bareElement must preserve the HAST className remark-gfm sets on task-list
  • items'); +}); diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx index 77299430e5e..019a87c87fe 100644 --- a/packages/ui/src/markdown-body.tsx +++ b/packages/ui/src/markdown-body.tsx @@ -153,6 +153,7 @@ export function MarkdownBody(props: { text: string; streaming?: boolean }) { h2: bareElement('h2'), h3: bareElement('h3'), h4: bareElement('h4'), + blockquote: bareElement('blockquote'), ul: bareElement('ul'), li: bareElement('li'), thead: bareElement('thead'),