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 }) {
),
+ // #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 }) {
),
- // #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'),