feat(ui): markdown renderer (slice 11a, issue #440)#514
Conversation
Aligns body text leading with the W3 markdown body lock (DESIGN.md sync, 2026-05-10). picker.css and avatar.css line-heights kept — those are component-local font-size-small / fixed dimensional values, not body text.
22 lock items: G mapping headings, ink-only quiet underline links, task list svg, cream blockquote (no left line), data-numeric tables, image frame, details/summary chev. Two declared optical 偏离 (focus halo 2px, task-list svg margin-top 2px) inline as comments. Source: docs/design/preview/markdown-body.html · STANDARDS.md#L43
FORBID_TAGS adds iframe/form/input/object/embed alongside script/style. ALLOWED_URI_REGEXP rejects javascript:/data:/vbscript: while accepting http(s), mailto, file, relative paths, and anchors. Config exposed for unit tests so we can assert without a DOM in the bun test env.
marked emits <input type=checkbox> for GFM task list syntax. Replace each checkbox with a 16px circle (unchecked) or circle-check (checked) SVG and tag the parent UL with .task-list so markdown.css picks up the W3 layout. Read-only — agent output, not user-editable. Adds happy-dom registrator preload to packages/ui so DOM-mutating logic can be unit tested without an Electron shell.
Companion to previous commit. Exports rewriteTaskListsForTest and sanitizeConfig from markdown.tsx, adds @happy-dom/global-registrator to ui dev deps so the test can mutate real DOM nodes.
resolveLinkAction classifies hrefs into external/reveal/anchor/block. setupLinkClicks intercepts clicks on rendered <a> and dispatches via onLinkOpenExternal/onLinkRevealPath props (consumer wires to window.api.openLink and showItemInFolder). javascript: / data: / non http(s) schemes blocked. Anchors keep default browser behavior. Bundles in setupImageClicks (T12) since the cleanup wiring shares the same effect; consumers pass onImageClick to receive image src on click.
When no explicit onLinkOpenExternal/onLinkRevealPath handler is passed, fall back to window.api.openLink / showItemInFolder so existing <Markdown> call sites in message-part work without prop drilling. Web/storybook fallback (window.open) still applies when window.api is absent.
Eight new stories (Headings/LinksInkOnly/TaskList/Blockquote/Table/ Details/Math/HtmlWhitelist) demonstrate each W3 lock area with sample content so future contributors can see the rendered surface without touching production.
P1 — drop "input" from FORBID_TAGS so GFM task-list checkboxes survive DOMPurify; an uponSanitizeElement hook strips any non-checkbox input. The previous config sanitized away the very element rewriteTaskLists needed to find, leaving raw checkboxes invisible in production. P1 — rewriteTaskLists now walks input[type=checkbox] descendants and locates the LI via closest(), covering loose-list output where marked wraps the checkbox in <p>. Tags the LI itself (not the parent UL) with .task-item so non-task siblings keep their bullet. P1 — resolveLinkAction blocks protocol-relative URLs (//host) before they fall into reveal, and routes mailto: to external instead of block. P2 — link click listener is registered in capture phase so descendant stopPropagation cannot bypass routing. Anchors scroll within the markdown container so Electron's hash router stays untouched. P2 — markdown.css table tabular-nums + right-align now also matches align=right (marked's default for | ---: | syntax). P2 — decorate() injects a chevron SVG into details > summary so the W3 chev rotation fires without authors writing the SVG by hand. Tests cover the new sanitize→decorate integration path.
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (6)
📝 WalkthroughWalkthroughThis PR enhances the markdown component with a complete styling redesign using design tokens, introduces link-action routing infrastructure, strengthens sanitization and content decoration (task lists, details chevrons), adds Message→desktop reveal wiring, preloads Happy DOM for Bun tests, and expands tests and Storybook W3 stories. ChangesMarkdown Component Enhancement
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request implements a major update to the Markdown component, including a complete CSS overhaul, enhanced DOMPurify sanitization, and support for GFM task lists, interactive link routing, and image handling. It also adds unit tests and expands the Storybook documentation. Review feedback points out that the URI regex blocks Windows file paths, event handlers in the SolidJS component may capture stale props, and the whitespace stripping logic for task lists fails on loose lists.
Three handtest findings on PR #514: - marked link renderer attached `target="_blank"` to every anchor including hash-only links like `#top`, so Electron treated them as new-window navigations to `localhost:5173/index.html#top` instead of in-document scroll. Skip the external-link decoration when href starts with `#`. - `setupLinkClicks` only handled anchors that resolved to an element with matching `id`. `#top` (the standard "scroll to top" idiom) had no such element, so the click silently no-op'd. Treat empty fragment and `#top` as "scroll markdown root to top". - `:not(pre) > code` set `word-break: break-all` which forced mid-character breaks like `markdo / wn.tsx` inside narrow table cells. Drop it and let `overflow-wrap: anywhere` decide. Tables with file-path columns now break at slashes when forced to wrap, which is far more readable.
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/ui/src/components/markdown.css`:
- Around line 221-230: The CSS currently applies cursor: zoom-in to all img
elements, making non-interactive images appear clickable; change this so only
images with a click handler show the zoom affordance by moving cursor: zoom-in
into a selector for a clickable class (e.g., .markdown-image--clickable img or
.md-image.clickable img) and update the component that renders images (the code
path that uses onImageClick) to add that class when onImageClick is provided;
this keeps img rules (max-width, border, radius) unchanged but ensures only
elements rendered with onImageClick advertise zoom.
In `@packages/ui/src/components/markdown.test.ts`:
- Around line 1-39: Update the dompurify dependency from 3.3.1 to 3.4.2 in
package.json to pull in security fixes, then reinstall dependencies (and update
the lockfile) and re-run the test suite; verify the existing tests referencing
sanitizeConfig (especially FORBID_TAGS, FORBID_CONTENTS and ALLOWED_URI_REGEXP)
still behave as expected and adjust any DOMPurify integration (hooks or config)
if its API/behavior changed between 3.3.1 and 3.4.2.
In `@packages/ui/src/components/markdown.tsx`:
- Around line 305-310: After hoisting the checkbox SVG out of the paragraph (the
svg, wrapper, li logic), wrap the remaining task-item content into a single
container element so block-level descendants (like nested ULs or multi-line
content) stay as children of that container rather than as separate sibling flex
items; specifically, after li.insertBefore(svg, wrapper) create a new wrapper
element (e.g., a div/span) and move all nodes that belonged to the original
wrapper (except the hoisted svg) into this new container, then insert that
container into li after the svg so the flex layout treats the checkbox and the
entire label/content as two items.
- Around line 204-207: The current scheme check
(/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) incorrectly classifies Windows absolute
paths like "C:\repo\file.ts" as URI schemes and returns kind "block"; update the
logic so that before returning { kind: "block" } you exclude Windows
drive-letter paths (e.g., detect /^[a-z]:[\\/]/i or /^[a-z]:$/i followed by
path) — if trimmed matches a drive-letter absolute path treat it as { kind:
"reveal", path: trimmed } instead; modify the branch around the scheme regex
(the code using variable trimmed and returning "block" vs "reveal") to perform
this extra check.
- Around line 46-48: ALLOWED_URI_REGEXP currently still allows protocol-relative
URLs via the `[^:]*$` branch, creating a mismatch with resolveLinkAction() which
blocks `//`; update the ALLOWED_URI_REGEXP so that the final branch also rejects
strings starting with `//` (i.e., ensure the alternative that allows no-colon
URIs does not match strings that begin with two slashes) so the sanitizer and
resolveLinkAction() are consistent when validating links.
- Around line 505-513: The click handlers are only set once so they close over
stale callbacks; update the effect around setupLinkClicks and setupImageClicks
to read the callback props (local.onLinkOpenExternal, local.onLinkRevealPath,
local.onImageClick) so Solid tracks them and re-runs when they change, and on
re-run call the existing linkCleanup/imageCleanup functions to remove previous
listeners before reassigning new ones; reference the linkCleanup and
imageCleanup variables and the setupLinkClicks/setupImageClicks helpers when
adding the teardown-and-rebind logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 6dcd722b-40e7-41f4-91f3-9a855fdcce2e
⛔ Files ignored due to path filters (1)
bun.lockis excluded by!**/*.lock
📒 Files selected for processing (9)
packages/ui/bunfig.tomlpackages/ui/happydom.tspackages/ui/package.jsonpackages/ui/src/components/markdown.csspackages/ui/src/components/markdown.stories.tsxpackages/ui/src/components/markdown.test.tspackages/ui/src/components/markdown.tsxpackages/ui/src/context/marked.tsxpackages/ui/src/styles/theme.css
…e table
Two systematic-debugging findings from the second handtest round.
Root cause for "[path](path) opens localhost:5173/packages/...":
The desktop renderer attaches a document-level bubble-phase click handler
that grabs every `a.external-link` and calls `platform.openLink` →
`shell.openExternal`. The markdown renderer was tagging *every* non-hash
link with `class="external-link" target="_blank"`, so relative repo paths
got handed to `shell.openExternal`, which resolved them against the dev
server URL and navigated. Even though `setupLinkClicks` runs in capture
phase and calls `preventDefault`, the desktop handler does not check
`defaultPrevented` and fires regardless. Fix: only mark `https?://` and
`mailto:` hrefs as `external-link`. Hash anchors and repo paths now
fall through cleanly to the markdown component's own click handler,
which resolves them via `resolveLinkAction` and calls `showItemInFolder`
or scrolls inside the markdown root. The desktop handler keeps owning
true remote links unchanged.
Table design — visible head/body split in both themes:
W3 lock §9 used `border-weaker` for row dividers and `bg-cream` for the
header. In dark mode the divider tier is below the eye threshold and
the cream is one warm tier above bg-base, so the table read as a wash
of weakly-separated rows ("看不清楚"). Reframe to an inset panel: 1 px
outer `border-weak` frame + `radius-sm` anchors the table as an embedded
printed panel; header bottom uses `border-base` for a strong head/body
split; row dividers move up one tier to `border-weak`. The header keeps
`bg-cream` because it's the only surface token whose contrast direction
is correct in both themes (`surface-sunken` is darker than `bg-base` in
dark mode). No zebra, no hover.
…editorial Two findings from the third handtest round. Root cause for "click still does nothing": The desktop build runs markdown through a main-process native parser at packages/desktop-electron/src/main/markdown.ts. That parser carries its own marked.Renderer with its own link override, which still tagged every link as `class="external-link" target="_blank"`. The earlier fix in the UI-side jsParser branch never took effect for the desktop, because the desktop never runs that branch. HMR reload could not have helped — it was always the wrong file. Mirror the host-only filter into the main-process renderer so hash anchors and repo paths stop being routed to shell.openExternal. Table redesign — editorial typographic: The inset frame still read as muddy because in light mode bg-cream is only ~5% darker than bg-base, so the header tint disappeared and the 1 px outer frame became the only signal. Rather than fight the surface tokens, drop the frame entirely. The table now sits borderless in the prose flow with a transparent header anchored by a single firm rule (`border-base`) under the head row, matching the typographic pattern used by Codex / ChatGPT / NYT story tables. Row dividers stay at `border-weak` so the body remains scannable in dark mode. First and last cells flush with body text so the table aligns to the column rather than indenting into a panel.
…l case Table padding: revert to the W3 lock §L43 cell density (sm × md = 8px × 12px), matching the lock-table preview reference. The earlier inset/ editorial iteration stretched the rows for breathing room, but at chat density that read as gappy. Header keeps --bg-cream per W3 lock; two minor token deviations remain (head/body split uses --border-base, row dividers use --border-weak) — both required for the table to read clearly in dark mode where the W3 lock's --border-weaker tier collapses into the surface. Outer frame removed. Anchor links: drop the `#top` -> scrollIntoView root special case. In chat the surrounding container scrolls, not the markdown root, so the behavior was just "align the answer's top with the viewport top" — not the document-top semantics users expect from #top. Anchor handler now only scrolls when the fragment matches a real id within the markdown body. Unmatched fragments preventDefault and no-op, which keeps Electron from navigating to localhost:5173/#anchor without claiming a feature that doesn't make sense in chat.
Phase 1 diagnostic logs (now removed) confirmed the renderer pipeline is
correct end-to-end: setupLinkClicks attaches, capture-phase click fires,
href resolves to {kind:'reveal', path:'packages/ui/...'}, fallback dials
window.api.showItemInFolder. The reveal still appeared to no-op because
shell.showItemInFolder requires an absolute path on macOS — relative
paths silently do nothing. Resolve relative reveal targets against the
main-process cwd before handing them to shell. Caller-provided
onLinkRevealPath remains the canonical channel for chat to inject the
session workspace cwd later; this only fixes the unscoped fallback.
Diagnostic logs (now removed) showed shell.showItemInFolder receiving a relative path (`packages/ui/src/components/markdown.tsx`) and process.cwd() in the Electron main process being `/Users/yuhan` — not the user's session workspace. Resolving against process.cwd in the main process therefore reveals the wrong file or no file at all. The renderer is the only layer that knows the chat session's workspace cwd (via useData().directory). Wrap the chat-side Markdown call sites in a MessageMarkdown component that injects an onLinkRevealPath handler joining the session directory with the relative path before invoking window.api.showItemInFolder. Main-process IPC reverts to assuming an absolute path — that's the contract for callers, and it stops the silent-wrong-file failure mode for unscoped fallbacks. Markdown component itself is unchanged; the new behavior only kicks in for the chat surface that has session context. Storybook and other out-of-chat callers continue to work as a transparent renderer.
Previously every scheme that wasn't https / mailto fell through to
{kind: "block"}, which forced the click router to grow a new branch
every time a scheme was added to the sanitize allowlist. Flip the
relationship: the click router now denies only the truly dangerous
schemes (javascript / data / vbscript) and routes everything else as
external. Whether a given scheme actually reaches the router stays
governed by the DOMPurify ALLOWED_URI_REGEXP allowlist — which is the
correct gate for "is this surface allowed at all". Adding a new scheme
(vscode://, ssh://, tel:, …) is now a one-line allowlist edit instead
of a coordinated change across two files.
Tests cover the new contract: vbscript stays blocked; vscode://file/foo
and tel:+15551234 now resolve to external, mirroring how a future
allowlist expansion would surface them.
Phase 4 W3 lock revisions (markdown.css): - §1 headings: unify all six tiers on type-h3, encode hierarchy via fg color only, replace ad-hoc margin per-tier with the 8/12/16 semantic gradient (stick < breathe < section-break) - §7 blockquote: drop cream fill + radius, switch to 2px fg-weak left rule + fg-weak text per industry consensus (GitHub / Tailwind prose / Notion / shadcn) so it stays legible at chat density - §9 table: drop cream header fill, switch to flat header (type-h3 + 1px border-base bottom) — chat-table dominant pattern, WCAG 1.4.3 passes on text contrast alone - §10 image / §13 fence code: normalize block margin to mt:0 mb:12 so heading-below gradient (h mb 8 → block) stays at 8 instead of collapsing to 12 via symmetric mt - §11 details: zero summary's first-content margin-top so summary + body sit 8px apart (driven by summary padding, not child margin) - §12 KaTeX block math: force mt:0 mb:12 to override KaTeX default symmetric 1em margin (otherwise heading mb 8 → katex mt 16 collapses to 16, breaking the 8/12/16 rhythm) - max-width: revert from 75ch back to 100% (75ch fought the session container's md:max-w-[800px] / 2xl:max-w-[1000px]) Streaming details UX (markdown.tsx + markdown.test.ts): - forceOpenAllDetails: every <details> renders open by default since LLM-emitted details groups are visible content, not hidden reasoning; also sidesteps marked's HTML-block parser instability on partial input mid-stream - preserveDetailsOpenState (morphdom callback): user-collapsed state survives subsequent re-renders so i18n changes / re-decorates don't flip user's collapse back open Hand-test fixes: - copy-button tooltip: color fg-on-brand (#fff) on surface-raised (#fff in light) was white-on-white invisible; switch to fg-strong - copy-button i18n: ui.message.copy/copied keys never existed in en.ts / zh.ts so solid-i18n's en[key] ?? String(key) fallback rendered the literal key path; switch to existing ui.textField.copyToClipboard / copied
Single underline-color shift (fg-weak → currentColor) was visually imperceptible — hand-test hit "looks like nothing happens on hover". NN/g classifies single-property color-only as low-contrast hover; for inline link affordance the recommended bar is color + shape combined. Hover now mutates three underline properties together: text-decoration-color: fg-weak → fg-strong text-decoration-thickness: 1px → 2px text-underline-offset: 3px → 4px 80ms ease-out per property (sits in NN/g's 80–150ms color-class window). Why not switch text color to brand orange: DESIGN.md L120 reserves --brand-primary for selected / active / running surfaces and explicitly forbids text fill. Brand orange on hover would also collide with the active-tab 1px brand underline pattern and produce a "neon flicker" in chat replies that contain multiple links per paragraph. All three changes are paint, not layout — no inline reflow, no line-box recalc, no surrounding text shift.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
packages/desktop-electron/src/main/markdown.ts (1)
7-12: ⚡ Quick winAvoid keeping another hand-synced link classifier here.
This branch now depends on
packages/desktop-electron/src/main/markdown.tsandpackages/ui/src/context/marked.tsxstaying behaviorally identical by convention. Given the recent routing fixes in this PR, a shared helper or a small contract test over the same href matrix would prevent the next drift bug.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/desktop-electron/src/main/markdown.ts` around lines 7 - 12, The current duplicate link-classification logic uses the regex assigned to the remote variable (/^(?:https?:\/\/|mailto:)/i.test(href)) and must be centralized to avoid drift; extract that regex/logic into a single exported helper (e.g., isRemoteLink(href)) in a shared util or the existing ui context module and replace the inline remote assignment in markdown.ts with an import and call to isRemoteLink(href), or alternatively add a small contract/unit test that asserts markdown.ts and packages/ui/src/context/marked.tsx produce identical results for a representative href matrix; update imports and tests accordingly so both consumers rely on the same implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/ui/src/components/markdown.css`:
- Around line 353-366: The copy button is currently made visible only when the
parent code block is hovered, so keyboard users who focus the button can land on
an almost-invisible control; update the CSS so the button is shown when it
receives keyboard focus by adding a rule that sets opacity: 1 for the button
itself on focus-visible (e.g., target the selector
[data-slot="markdown-copy-button"]:focus-visible and/or
[data-component="markdown-code"]
[data-slot="markdown-copy-button"]:focus-visible) so the same visible state used
for :hover is applied when the control is focused.
In `@packages/ui/src/components/message-part.tsx`:
- Around line 233-264: isAbsoluteLikePath and joinWorkspacePath currently miss
UNC roots and don't canonicalize ./ or ../ segments, so compute a single
normalized absolute path before calling desktop.showItemInFolder: update
isAbsoluteLikePath to treat UNC (leading "\\") as absolute, and replace
joinWorkspacePath + raw concatenation with a canonicalization step in the
onLinkRevealPath handler that resolves separators and removes ./ and ../ (use
Node/Electron path utilities like path.win32.normalize/path.resolve or a small
URL-style normalizer) to produce a platform-correct absolute path; ensure the
handler uses that normalized path (refer to isAbsoluteLikePath,
joinWorkspacePath, onLinkRevealPath, and desktop.showItemInFolder).
---
Nitpick comments:
In `@packages/desktop-electron/src/main/markdown.ts`:
- Around line 7-12: The current duplicate link-classification logic uses the
regex assigned to the remote variable (/^(?:https?:\/\/|mailto:)/i.test(href))
and must be centralized to avoid drift; extract that regex/logic into a single
exported helper (e.g., isRemoteLink(href)) in a shared util or the existing ui
context module and replace the inline remote assignment in markdown.ts with an
import and call to isRemoteLink(href), or alternatively add a small
contract/unit test that asserts markdown.ts and
packages/ui/src/context/marked.tsx produce identical results for a
representative href matrix; update imports and tests accordingly so both
consumers rely on the same implementation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 6d240ecb-05ff-4ca0-a44a-a71a4f2d3e64
📒 Files selected for processing (7)
packages/desktop-electron/src/main/ipc.tspackages/desktop-electron/src/main/markdown.tspackages/ui/src/components/markdown.csspackages/ui/src/components/markdown.test.tspackages/ui/src/components/markdown.tsxpackages/ui/src/components/message-part.tsxpackages/ui/src/context/marked.tsx
✅ Files skipped from review due to trivial changes (1)
- packages/desktop-electron/src/main/ipc.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/ui/src/context/marked.tsx
- packages/ui/src/components/markdown.tsx
Three-dim hover (color + thickness + offset) had the right reach but the offset 3→4 shift produced a "drop and thicken" double motion that read as a visible jump on every link traversal — reported as "stands out but not pretty" on hand-test. Cut to two-dim (color + thickness). Underline now intensifies in place: same baseline distance, just darker and thicker. Still clears NN/g's color + shape high-contrast bar, but the link no longer shifts position at hover time.
GitHub Advisory Database flags 3.3.1 with multiple MODERATE-severity sanitizer-bypass advisories that are all patched by 3.4.0+: - ADD_ATTR predicate skips URI validation (≤ 3.3.1) - USE_PROFILES prototype pollution allows event handlers (≤ 3.3.1) - mutation-XSS via re-contextualization (< 3.3.2) - SAFE_FOR_TEMPLATES bypass in RETURN_DOM (< 3.4.0) - prototype pollution to XSS via CUSTOM_ELEMENT_HANDLING (< 3.4.0) - ADD_TAGS function form bypasses FORBID_TAGS (≤ 3.3.3) 3.4.2 is the current latest. Markdown sanitizer test suite (31 tests) passes unchanged — our config only uses FORBID_TAGS / FORBID_CONTENTS / ALLOWED_URI_REGEXP, none of which are touched by the 3.4 API changes.
isAbsoluteLikePath previously missed UNC roots like \\server\share — those fell into joinWorkspacePath and got rewritten under the chat workspace, silently revealing the wrong location. Add a leading \\ check so they route as absolute reveals. joinWorkspacePath now drops a leading ./ (or .\) before joining; LLMs emit `./packages/foo.ts` style paths and the bare concatenation produced `/dir/./foo.ts` which only worked because POSIX shell.showItemInFolder happens to resolve it. .. segments stay verbatim — both POSIX and Win32 shell APIs resolve them correctly at the OS layer.
setupLinkClicks/setupImageClicks attach a single click listener once and its callback closes over the prop value at attach time. With Solid's mergeProps + the !linkCleanup / !imageCleanup gates the listener was never re-attached, so a parent component swapping onLinkOpenExternal / onLinkRevealPath / onImageClick after the first render kept calling the original (often undefined) handler. Wrap the call in a thin arrow function so the event handler reads the current reactive prop value at click time. Listeners stay attached exactly once; the prop just becomes a live read.
Code copy-button only revealed on hover; keyboard users could Tab onto an opacity:0 button, an invisible click target. Add :focus-within to the trigger so the button appears the moment focus enters the code block, matching :hover semantics for pointer users.
The task-item LI was a flex container with the icon as the first item and everything else as raw siblings. In loose lists or when the label contained a nested <ul>/<p>, those blocks became additional flex siblings of the icon and rendered to its right instead of underneath the label. Group everything after the svg into a single span[data-slot= "task-label"] so the LI's flex axis only sees [icon, label]. The label keeps its block layout internally; nested ul/p naturally flow below the first text line. CSS gives the label flex: 1 1 0 + min-width: 0 so long lines wrap instead of overflowing. Leading-whitespace strip now also covers the loose-list case where the first text node sits inside <p>, not directly under the label. Two new tests lock both behaviors (nested ul stays in label wrapper; loose list <p> firstChild has no leading space).
resolveLinkAction's generic scheme regex (`/^[a-z][a-z0-9+.-]*:/i`)
matches `C:\path\to\file.ts` because a single letter followed by `:` is
a valid scheme shape. That routed Windows absolute paths to the
external/browser handler instead of the file-reveal path, breaking
local-file reveal on Windows.
Catch the drive-letter form (`<letter>:\` or `<letter>:/`) before the
generic scheme test so it short-circuits to `{kind: "reveal", path}`.
Posix and protocol-relative cases stay unchanged. Test coverage added
for both `\` and `/` separators.
The image rule had `cursor: zoom-in` on every markdown img. onImageClick is optional in the component API, so when no host wired it, every image still advertised a zoom affordance that did nothing on click. Move the cursor rule onto a `&[data-image-click] img` nested selector and have the Markdown root expose `data-image-click` only when local.onImageClick is set. Images get the default cursor when no handler is wired; nothing else about the image style changes.
Bring sanitize allowlist in line with resolveLinkAction's `//host` block so the click-router rule and the DOMPurify gate enforce the same policy. Add lookahead for `//` and tighten the no-colon fallback; the previous allowlist let `//evil.com/x` pass into sanitized DOM even though the click router blocks navigation.
Story docs and fixture still described the earlier "cream bg, no left line" direction. The W3 lock landed on a 2px border-weak left rule with fg-weak text (Markdown industry convention, not BAN 1 — see markdown.css §7). Update the storybook copy to match the implementation so reviewers see the same surface twice.
|
crosscheck round 2 — fixes pushed. P2 #1 — DOMPurify URI allowlist accepts P2 #2 — W3 Blockquote story drift (c2f8270) P3 — desktop/UI marked renderer drift — leaving as a follow-up. Both files currently agree on UI typecheck + 35 unit tests green locally; awaiting CI. |
Summary
Lands the W3-locked markdown body rendering into PawWork's agent message flow. 22 visual lock items + 2 declared optical 偏离, sourced from
docs/design/preview/markdown-body.html(locked 2026-05-10) anddocs/design/STANDARDS.md#L43.Why
Slice 11a of issue #440 multi-slice rewrite. Previous renderer drifted from DESIGN.md on link color (brand orange instead of ink-only quiet underline), heading scale (multiple sizes instead of G mapping), blockquote (left stripe violating BAN 1), and missing task list / details / table tabular-nums treatments. This slice replaces the styling and post-processing in place, keeping the existing marked + DOMPurify + morphdom pipeline.
Related Issue
Part of #440 (multi-slice UI rewrite).
Human Review Status
Pending. Need a human eye to confirm the rendered surface matches the W3 lock — see
How To Verify.Review Focus
inputis intentionally not inFORBID_TAGS; anuponSanitizeElementhook strips every input that isn't a disabled checkbox so GFM task lists survive without opening a hole.resolveLinkActionclassification: protocol-relative//hostand unknown schemes block;mailto:routes external; relative paths route reveal. Click handler is in capture phase.border-radius: 2px(inline link convention) and task-list svgmargin-top: 2px(16px icon to 13/160 baseline ((20.8-16)/2 ≈ 2)).@happy-dom/global-registrator+bunfig.tomlpreload topackages/uiso DOM-mutating logic can be unit tested.Risk Notes
uponSanitizeElementhook runs alongside the existing anchorrelhook. Both registered conditionally ontypeof window !== "undefined" && DOMPurify.isSupported.<Markdown>component now binds three click listeners (copy / link / image). Capture-phase link routing means descendantstopPropagationcannot bypass.message-part.tsxare not modified — fallback towindow.api.openLink/showItemInFolderworks without prop drilling. Web/storybook fallback towindow.openfor external; reveal is no-op without desktop bridge.marked-katex-extension) was already wired incontext/marked.tsx; this PR did not change math handling.How To Verify
Manual desktop verification (
bun run dev:desktop) was not performed end-to-end in this slice. The agent loop has no way to interact with the rendered Electron window. Reviewer should boot dev:desktop, drive a session that emits markdown, and visually confirm:--fg-weak, hovercurrentColor, focus brand ring| ---: |syntax aligns right with tabular-nums<details>shows chevron rotating 90deg on open$..$and block$$..$$render via KaTeXScreenshots or Recordings
Eight new Storybook stories under
UI/Markdown(W3.Headings,W3.LinksInkOnly,W3.TaskList,W3.Blockquote,W3.Table,W3.Details,W3.Math,W3.HtmlWhitelist) provide static demos of each W3 area.Checklist
window.api.openLink/showItemInFolder, both already cross-platformdev, and my PR title and commit messages use Conventional Commits in EnglishSummary by CodeRabbit