From 094acc69cd8daf28d8043baa86752d3925ccd4c3 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 00:19:48 +0800 Subject: [PATCH 01/30] refactor(ui): bump --type-body line-height to 160% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ui/src/styles/theme.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 3a87357e3..a9bbafd7e 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -49,7 +49,7 @@ --type-h1: 500 20px/130% var(--font-family-sans); --type-h2: 500 16px/150% var(--font-family-sans); --type-h3: 500 13px/150% var(--font-family-sans); - --type-body: 400 13px/150% var(--font-family-sans); + --type-body: 400 13px/160% var(--font-family-sans); --type-caption: 400 13px/130% var(--font-family-sans); --type-mono: 400 13px/150% var(--font-family-mono); --type-mono-small: 400 12px/150% var(--font-family-mono); From 41739ad0e32ab673fa9155ce0f194a8131c3cda3 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 00:21:20 +0800 Subject: [PATCH 02/30] refactor(ui): rewrite markdown.css per W3 lock 2026-05-10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/ui/src/components/markdown.css | 376 ++++++++++++++---------- 1 file changed, 225 insertions(+), 151 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 1fded2771..7c181048c 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -1,14 +1,14 @@ +/* Markdown body — W3 lock 2026-05-10 + Source of truth: docs/design/preview/markdown-body.html + Standard: docs/design/STANDARDS.md#L43 */ + [data-component="markdown"] { - /* Reset & Base Typography */ min-width: 0; - max-width: 100%; + max-width: 75ch; overflow-wrap: break-word; + font: var(--type-body); color: var(--fg-strong); - font-family: var(--font-family-sans); - font-size: var(--font-size-base); /* 13px */ - line-height: 160%; - /* Spacing for flow */ > *:first-child { margin-top: 0; } @@ -16,121 +16,270 @@ margin-bottom: 0; } - /* Headings: Same size, distinguished by color and spacing */ + /* §1 Headings · G mapping + All 4 tiers use --type-h3 (500 13/150); + hierarchy via fg color + margin only */ h1, - h2, - h3, - h4, + h2 { + font: var(--type-h3); + color: var(--fg-strong); + margin: var(--space-2xl) 0 var(--space-xs); + } + h2 { + margin-top: var(--space-xl); + } + h3 { + font: var(--type-h3); + color: var(--fg-base); + margin: var(--space-lg) 0 var(--space-xs); + text-transform: none; + letter-spacing: 0; + } + h4 { + font: var(--type-h3); + color: var(--fg-weak); + margin: var(--space-md) 0 var(--space-xs); + text-transform: none; + letter-spacing: 0; + } h5, h6 { - font-size: var(--font-size-hierarchy); - color: var(--fg-strong); - font-weight: var(--font-weight-medium); - margin-top: 0px; - margin-bottom: 24px; - line-height: var(--line-height-large); + font: var(--type-h3); + color: var(--fg-weak); + margin: var(--space-md) 0 var(--space-xs); } - /* Emphasis & Strong: Neutral strong color */ + /* §2 Paragraph + inline emphasis */ + p { + margin: 0 0 var(--space-md); + } + p:last-child { + margin-bottom: 0; + } strong, b { color: var(--fg-strong); font-weight: var(--font-weight-medium); } - - /* Paragraphs */ - p { - margin-bottom: 12px; + em, + i { + font-style: italic; + } + del, + s { + text-decoration: line-through; + color: var(--fg-weak); } - /* Links */ + /* §3 Links · ink-only quiet underline */ a { - color: var(--brand-primary); - text-decoration: none; - font-weight: inherit; + color: inherit; + text-decoration: underline; + text-decoration-color: var(--fg-weak); + text-decoration-thickness: 1px; + text-underline-offset: 3px; + transition: text-decoration-color var(--duration-fast) ease-out; + overflow-wrap: anywhere; } - a:hover { - text-decoration: underline; - text-underline-offset: 2px; + text-decoration-color: currentColor; } - - /* Lists */ - ul, - ol { - margin-top: 8px; - margin-bottom: 12px; - margin-left: 0; - padding-left: 32px; - list-style-position: outside; + /* 偏离: focus halo border-radius 2px (DESIGN.md radii 6/10/14/9999). + 理由: inline 链接焦点环紧贴文字行盒,4pt 网格不适用于 inline 焦点圆角; + 2px 是 inline-link 行业惯例 (Stripe / Linear / GitHub 同向) */ + a:focus-visible { + outline: none; + border-radius: 2px; + box-shadow: + 0 0 0 1px var(--brand-primary), + 0 0 0 3px rgba(255, 89, 16, 0.2); } - ul { - list-style-type: disc; + /* §4 Inline code · 复用 DESIGN.md「Code & diff」 */ + :not(pre) > code { + font: var(--type-mono-small); + background: var(--surface-sunken); + padding: 0 var(--space-xs); + border-radius: var(--radius-sm); + color: var(--fg-strong); + overflow-wrap: anywhere; + word-break: break-all; } + /* §5 Lists */ + ul, ol { - list-style-type: decimal; - padding-left: 2.25rem; + margin: 0 0 var(--space-md); + padding-left: 24px; + } + ul ul, + ul ol, + ol ul, + ol ol { + margin: var(--space-xs) 0; } - li { - margin-bottom: 8px; + margin: 0 0 var(--space-xs); } - - li > p:first-child { - display: inline; - margin: 0; + li:last-child { + margin-bottom: 0; } - - li > p + p { - display: block; - margin-top: 0.5rem; + li > p { + margin: 0 0 var(--space-xs); + } + li > p:last-child { + margin-bottom: 0; + } + ul { + list-style: disc; + } + ol { + list-style: decimal; } - li::marker { color: var(--fg-weak); } - /* Nested lists spacing */ - li > ul, - li > ol { - margin-top: 0.25rem; - margin-bottom: 0.25rem; - padding-left: 1rem; /* Minimal indent for nesting only */ - } - - li > ol { - padding-left: 1.75rem; + /* §6 Task list · 16px circle / circle-check svg, read-only */ + ul.task-list { + list-style: none; + padding-left: 0; + } + ul.task-list li { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding-left: 0; + margin: 0 0 var(--space-xs); + } + /* 偏离: svg margin-top 2px (DESIGN.md 4pt 网格). + 理由: 16px 图标对齐 13/160 文字基线的光学调整 ((20.8-16)/2 ≈ 2), + 不属布局间距 */ + ul.task-list li > svg { + flex: 0 0 16px; + width: 16px; + height: 16px; + color: var(--fg-strong); + margin-top: 2px; } - /* Blockquotes */ + /* §7 Blockquote · cream + radius-sm, no left line (avoids BAN 1) */ blockquote { - border-left: 2px solid var(--border-weak); - margin: 1.5rem 0; - padding-left: 0.5rem; - color: var(--fg-weak); - font-style: normal; + margin: 0 0 var(--space-md); + padding: var(--space-md) var(--space-lg); + background: var(--bg-cream); + border-radius: var(--radius-sm); + color: var(--fg-base); + } + blockquote > :first-child { + margin-top: 0; + } + blockquote > :last-child { + margin-bottom: 0; } - /* Horizontal Rule - Invisible spacing only */ + /* §8 Horizontal rule */ hr { - border: none; - height: 0; - margin: 40px 0; + border: 0; + border-top: 1px solid var(--border-weak); + margin: var(--space-2xl) 0; + } + + /* §9 Table */ + table { + border-collapse: collapse; + width: 100%; + margin: 0 0 var(--space-md); + font: var(--type-body); + } + thead th { + padding: var(--space-sm) var(--space-md); + background: var(--bg-cream); + border-bottom: 1px solid var(--border-weak); + font: var(--type-h3); + color: var(--fg-strong); + text-align: left; + vertical-align: top; + } + tbody td { + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--border-weaker); + color: var(--fg-base); + vertical-align: top; + } + tbody tr:last-child td { + border-bottom: 0; + } + th[data-numeric="true"], + td[data-numeric="true"] { + text-align: right; + font-variant-numeric: tabular-nums; } + /* §10 Image · 1px frame + radius-md (DESIGN.md「User content frame」) */ + img { + max-width: 100%; + height: auto; + display: block; + border: 1px solid var(--border-weak); + border-radius: var(--radius-md); + margin: var(--space-md) 0; + cursor: zoom-in; + } + + /* §11 details / summary · chev + summary text, chev rotates 90deg on open */ + details { + margin: 0 0 var(--space-md); + } + details > summary { + cursor: pointer; + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) 0; + list-style: none; + font-weight: var(--font-weight-medium); + color: var(--fg-strong); + user-select: none; + } + details > summary::-webkit-details-marker { + display: none; + } + details > summary > svg.chev { + flex: 0 0 16px; + width: 16px; + height: 16px; + color: var(--fg-weaker); + transition: transform var(--duration-base) ease-out; + } + details[open] > summary > svg.chev { + transform: rotate(90deg); + } + details > :not(summary) { + margin-left: 24px; + } + + /* §12 Fence code · defers to DESIGN.md L36「Code & diff」.codeblock truth. + pre + .shiki rules retained for code-surfaces compatibility. */ + pre { + margin: var(--space-md) 0; + overflow: auto; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } .shiki { - font-size: var(--font-size-x-small); - padding: 12px; - border-radius: 6px; + font: var(--type-mono-small); + padding: var(--space-md); + border-radius: var(--radius-sm); border: 0.5px solid var(--border-weak); } + /* Code copy-button overlay (fence shell affordance) */ [data-component="markdown-code"] { position: relative; } - [data-slot="markdown-copy-button"] { position: absolute; top: 4px; @@ -146,7 +295,6 @@ bottom: calc(100% + 4px); transform: translateX(-50%); z-index: 1000; - max-width: 320px; border-radius: var(--radius-sm); background: var(--surface-raised); @@ -154,113 +302,39 @@ padding: 2px 8px; border: 1px solid var(--border-weak, rgba(0, 0, 0, 0.07)); box-shadow: var(--shadow-floating); - pointer-events: none; white-space: nowrap; - font-family: var(--font-family-sans); font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); - opacity: 0; transition: opacity 0.15s ease; } } - [data-slot="markdown-copy-button"]:hover::after, [data-slot="markdown-copy-button"]:focus-visible::after { opacity: 1; } - [data-slot="markdown-copy-button"][data-variant="secondary"] { box-shadow: none; border: 1px solid var(--border-weak); } - [data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] { color: var(--icon-base); } - [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] { opacity: 1; } - [data-slot="markdown-copy-button"] [data-slot="check-icon"] { display: none; } - [data-slot="markdown-copy-button"][data-copied="true"] [data-slot="copy-icon"] { display: none; } - [data-slot="markdown-copy-button"][data-copied="true"] [data-slot="check-icon"] { display: inline-flex; } - - pre { - margin-top: 12px; - margin-bottom: 32px; - overflow: auto; - - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } - } - - :not(pre) > code { - font-family: var(--font-family-mono); - font-feature-settings: var(--font-family-mono--font-feature-settings); - color: var(--syntax-string); - font-weight: var(--font-weight-medium); - font-size: var(--font-size-x-small); - - /* padding: 2px 2px; */ - /* margin: 0 1.5px; */ - /* border-radius: 2px; */ - /* background: var(--surface-base); */ - /* box-shadow: 0 0 0 0.5px var(--border-weak); */ - } - - /* Tables */ - table { - width: 100%; - border-collapse: collapse; - margin: 24px 0; - font-size: var(--font-size-base); - display: block; - overflow-x: auto; - } - - th, - td { - /* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */ - border-bottom: 1px solid var(--border-weaker); - padding: 12px; - text-align: left; - vertical-align: top; - } - - th { - color: var(--fg-strong); - font-weight: var(--font-weight-medium); - border-bottom: 1px solid var(--border-weak); - } - - /* Images */ - img { - max-width: 100%; - height: auto; - border-radius: 4px; - margin: 1.5rem 0; - display: block; - } -} - -[data-component="markdown"] a.external-link:hover > code { - text-decoration: underline; - text-underline-offset: 2px; } From 4f3d66efb4d7a453730c42386a854a6c37c3c461 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 00:24:46 +0800 Subject: [PATCH 03/30] feat(ui): tighten markdown DOMPurify allowlist 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. --- packages/ui/src/components/markdown.test.ts | 37 +++++++++++++++++++++ packages/ui/src/components/markdown.tsx | 8 +++-- 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/markdown.test.ts diff --git a/packages/ui/src/components/markdown.test.ts b/packages/ui/src/components/markdown.test.ts new file mode 100644 index 000000000..a1432123c --- /dev/null +++ b/packages/ui/src/components/markdown.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { sanitizeConfig } from "./markdown" + +describe("DOMPurify whitelist config", () => { + test("forbids unsafe tags", () => { + expect(sanitizeConfig.FORBID_TAGS).toContain("script") + expect(sanitizeConfig.FORBID_TAGS).toContain("iframe") + expect(sanitizeConfig.FORBID_TAGS).toContain("style") + expect(sanitizeConfig.FORBID_TAGS).toContain("form") + expect(sanitizeConfig.FORBID_TAGS).toContain("input") + expect(sanitizeConfig.FORBID_TAGS).toContain("object") + expect(sanitizeConfig.FORBID_TAGS).toContain("embed") + }) + test("forbids unsafe text content", () => { + expect(sanitizeConfig.FORBID_CONTENTS).toContain("script") + expect(sanitizeConfig.FORBID_CONTENTS).toContain("iframe") + expect(sanitizeConfig.FORBID_CONTENTS).toContain("style") + }) + test("URI regex accepts http(s) / mailto / file / relative paths", () => { + const re = sanitizeConfig.ALLOWED_URI_REGEXP + expect(re.test("https://example.com")).toBe(true) + expect(re.test("http://example.com")).toBe(true) + expect(re.test("mailto:hi@x.com")).toBe(true) + expect(re.test("file:///tmp/x")).toBe(true) + expect(re.test("/abs/path")).toBe(true) + expect(re.test("./rel/path")).toBe(true) + expect(re.test("../up/path")).toBe(true) + expect(re.test("relative/path")).toBe(true) + expect(re.test("#anchor")).toBe(true) + }) + test("URI regex rejects javascript: / data: / vbscript:", () => { + const re = sanitizeConfig.ALLOWED_URI_REGEXP + expect(re.test("javascript:alert(1)")).toBe(false) + expect(re.test("data:text/html,foo")).toBe(false) + expect(re.test("vbscript:msgbox")).toBe(false) + }) +}) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 929635416..5136a0838 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -31,10 +31,13 @@ if (typeof window !== "undefined" && DOMPurify.isSupported) { const config = { USE_PROFILES: { html: true, mathMl: true }, SANITIZE_NAMED_PROPS: true, - FORBID_TAGS: ["style"], - FORBID_CONTENTS: ["style", "script"], + FORBID_TAGS: ["script", "iframe", "style", "form", "input", "object", "embed"], + FORBID_CONTENTS: ["script", "iframe", "style"], + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|file):|\/|\.{1,2}\/|#|[^:]*$)/i, } +export const sanitizeConfig = config + const iconPaths = { copy: '', check: '', @@ -45,6 +48,7 @@ function sanitize(html: string) { return DOMPurify.sanitize(html, config) } + function escape(text: string) { return text .replace(/&/g, "&") From f67b9b5b7b47eeb3a960ef52fc31ac51362c2dff Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 00:27:45 +0800 Subject: [PATCH 04/30] feat(ui): render markdown task list as 16px svg circles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit marked emits 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. --- packages/ui/bunfig.toml | 2 ++ packages/ui/happydom.ts | 3 +++ 2 files changed, 5 insertions(+) create mode 100644 packages/ui/bunfig.toml create mode 100644 packages/ui/happydom.ts diff --git a/packages/ui/bunfig.toml b/packages/ui/bunfig.toml new file mode 100644 index 000000000..363990451 --- /dev/null +++ b/packages/ui/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./happydom.ts"] diff --git a/packages/ui/happydom.ts b/packages/ui/happydom.ts new file mode 100644 index 000000000..ce010847d --- /dev/null +++ b/packages/ui/happydom.ts @@ -0,0 +1,3 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator" + +GlobalRegistrator.register() From 62188fc9c1e7705f755c2e7c76fa33f1df96de4e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 00:28:11 +0800 Subject: [PATCH 05/30] feat(ui): wire markdown task-list rewriter + tests 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. --- bun.lock | 9 +++--- packages/ui/package.json | 1 + packages/ui/src/components/markdown.test.ts | 27 ++++++++++++++++- packages/ui/src/components/markdown.tsx | 33 +++++++++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index a2d961196..04be846d3 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "2026.5.5", + "version": "2026.5.7", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -97,7 +97,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "2026.5.5", + "version": "2026.5.7", "dependencies": { "@opencode-ai/util": "workspace:*", "electron-context-menu": "4.1.2", @@ -145,7 +145,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "2026.5.5", + "version": "2026.5.7", "bin": { "opencode": "./bin/opencode", }, @@ -356,6 +356,7 @@ "virtua": "catalog:", }, "devDependencies": { + "@happy-dom/global-registrator": "20.0.11", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -371,7 +372,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "2026.5.5", + "version": "2026.5.7", "dependencies": { "@opencode-ai/core": "workspace:*", "zod": "catalog:", diff --git a/packages/ui/package.json b/packages/ui/package.json index 066e91b45..57eea5c33 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,6 +30,7 @@ "generate:tailwind": "bun run script/tailwind.ts" }, "devDependencies": { + "@happy-dom/global-registrator": "20.0.11", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", diff --git a/packages/ui/src/components/markdown.test.ts b/packages/ui/src/components/markdown.test.ts index a1432123c..b522a4fe0 100644 --- a/packages/ui/src/components/markdown.test.ts +++ b/packages/ui/src/components/markdown.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { sanitizeConfig } from "./markdown" +import { rewriteTaskListsForTest, sanitizeConfig } from "./markdown" describe("DOMPurify whitelist config", () => { test("forbids unsafe tags", () => { @@ -35,3 +35,28 @@ describe("DOMPurify whitelist config", () => { expect(re.test("vbscript:msgbox")).toBe(false) }) }) + +describe("task list svg rendering", () => { + test("replaces unchecked input with circle svg", () => { + document.body.innerHTML = '
  • read
' + const ul = document.querySelector("ul")! + rewriteTaskListsForTest(document.body) + expect(ul.classList.contains("task-list")).toBe(true) + expect(ul.querySelector("input")).toBeNull() + const svg = ul.querySelector("svg") + expect(svg).not.toBeNull() + expect(svg!.getAttribute("data-state")).toBe("unchecked") + }) + test("replaces checked input with circle-check svg", () => { + document.body.innerHTML = '
  • done
' + rewriteTaskListsForTest(document.body) + const svg = document.querySelector('svg[data-state="checked"]') + expect(svg).not.toBeNull() + expect(svg!.querySelector("path")).not.toBeNull() + }) + test("preserves label text after checkbox", () => { + document.body.innerHTML = '
  • read the spec
' + rewriteTaskListsForTest(document.body) + expect(document.body.textContent).toContain("read the spec") + }) +}) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 5136a0838..19ead305e 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -175,12 +175,45 @@ function markCodeLinks(root: HTMLDivElement) { } } +const taskListIcons = { + unchecked: + '', + checked: + '', +} + +function rewriteTaskLists(root: ParentNode) { + const inputs = Array.from( + root.querySelectorAll('li > input[type="checkbox"]'), + ) + for (const input of inputs) { + const li = input.parentElement + if (!(li instanceof HTMLLIElement)) continue + const ul = li.parentElement + if (ul instanceof HTMLUListElement) ul.classList.add("task-list") + const checked = input.hasAttribute("checked") + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") + svg.setAttribute("viewBox", "0 0 16 16") + svg.setAttribute("aria-hidden", "true") + svg.setAttribute("data-state", checked ? "checked" : "unchecked") + svg.innerHTML = checked ? taskListIcons.checked : taskListIcons.unchecked + input.replaceWith(svg) + const next = svg.nextSibling + if (next && next.nodeType === Node.TEXT_NODE && /^\s+/.test(next.textContent ?? "")) { + next.textContent = (next.textContent ?? "").replace(/^\s+/, "") + } + } +} + +export const rewriteTaskListsForTest = rewriteTaskLists + function decorate(root: HTMLDivElement, labels: CopyLabels) { const blocks = Array.from(root.querySelectorAll("pre")) for (const block of blocks) { ensureCodeWrapper(block, labels) } markCodeLinks(root) + rewriteTaskLists(root) } function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { From 76a99ebaf40973d5820bf529ba4249d16ed49c54 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 00:30:39 +0800 Subject: [PATCH 06/30] feat(ui): route markdown link clicks via desktop bridge resolveLinkAction classifies hrefs into external/reveal/anchor/block. setupLinkClicks intercepts clicks on rendered 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. --- packages/ui/src/components/markdown.test.ts | 35 +++++++- packages/ui/src/components/markdown.tsx | 90 ++++++++++++++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/markdown.test.ts b/packages/ui/src/components/markdown.test.ts index b522a4fe0..286e04071 100644 --- a/packages/ui/src/components/markdown.test.ts +++ b/packages/ui/src/components/markdown.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { rewriteTaskListsForTest, sanitizeConfig } from "./markdown" +import { resolveLinkAction, rewriteTaskListsForTest, sanitizeConfig } from "./markdown" describe("DOMPurify whitelist config", () => { test("forbids unsafe tags", () => { @@ -60,3 +60,36 @@ describe("task list svg rendering", () => { expect(document.body.textContent).toContain("read the spec") }) }) + +describe("link action routing", () => { + test("https → external", () => { + expect(resolveLinkAction("https://example.com")).toEqual({ kind: "external", url: "https://example.com" }) + }) + test("http → external", () => { + expect(resolveLinkAction("http://example.com")).toEqual({ kind: "external", url: "http://example.com" }) + }) + test("relative repo path → reveal", () => { + expect(resolveLinkAction("packages/ui/src/foo.ts")).toEqual({ + kind: "reveal", + path: "packages/ui/src/foo.ts", + }) + }) + test("absolute path → reveal", () => { + expect(resolveLinkAction("/Users/u/p/foo.ts")).toEqual({ kind: "reveal", path: "/Users/u/p/foo.ts" }) + }) + test("anchor-only stays default", () => { + expect(resolveLinkAction("#section")).toEqual({ kind: "anchor", url: "#section" }) + }) + test("javascript: rejected", () => { + expect(resolveLinkAction("javascript:alert(1)")).toEqual({ kind: "block" }) + }) + test("data: rejected", () => { + expect(resolveLinkAction("data:text/html,foo")).toEqual({ kind: "block" }) + }) + test("empty href blocks", () => { + expect(resolveLinkAction("")).toEqual({ kind: "block" }) + }) + test("trims surrounding whitespace", () => { + expect(resolveLinkAction(" https://x.com ")).toEqual({ kind: "external", url: "https://x.com" }) + }) +}) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 19ead305e..768fe152d 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -175,6 +175,69 @@ function markCodeLinks(root: HTMLDivElement) { } } +export type LinkAction = + | { kind: "external"; url: string } + | { kind: "reveal"; path: string } + | { kind: "anchor"; url: string } + | { kind: "block" } + +export function resolveLinkAction(href: string): LinkAction { + const trimmed = href.trim() + if (!trimmed) return { kind: "block" } + if (trimmed.startsWith("#")) return { kind: "anchor", url: trimmed } + if (/^https?:\/\//i.test(trimmed)) return { kind: "external", url: trimmed } + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return { kind: "block" } + return { kind: "reveal", path: trimmed } +} + +export type LinkActionHandlers = { + openExternal?: (url: string) => void + revealPath?: (path: string) => void +} + +function setupLinkClicks(root: HTMLDivElement, handlers: LinkActionHandlers) { + const handler = (event: MouseEvent) => { + if (event.defaultPrevented) return + const target = event.target + if (!(target instanceof Element)) return + const anchor = target.closest("a") + if (!(anchor instanceof HTMLAnchorElement)) return + if (anchor.closest('[data-slot="markdown-copy-button"]')) return + const href = anchor.getAttribute("href") ?? "" + const action = resolveLinkAction(href) + if (action.kind === "anchor") return + event.preventDefault() + if (action.kind === "block") return + if (action.kind === "external") { + if (handlers.openExternal) { + handlers.openExternal(action.url) + } else if (typeof window !== "undefined") { + window.open(action.url, "_blank", "noopener,noreferrer") + } + return + } + if (action.kind === "reveal") { + handlers.revealPath?.(action.path) + } + } + root.addEventListener("click", handler) + return () => root.removeEventListener("click", handler) +} + +function setupImageClicks(root: HTMLDivElement, openImage: (src: string) => void) { + const handler = (event: MouseEvent) => { + if (event.defaultPrevented) return + const target = event.target + if (!(target instanceof HTMLImageElement)) return + const src = target.getAttribute("src") ?? "" + if (!src) return + event.preventDefault() + openImage(src) + } + root.addEventListener("click", handler) + return () => root.removeEventListener("click", handler) +} + const taskListIcons = { unchecked: '', @@ -278,9 +341,21 @@ export function Markdown( streaming?: boolean class?: string classList?: Record + onLinkOpenExternal?: (url: string) => void + onLinkRevealPath?: (path: string) => void + onImageClick?: (src: string) => void }, ) { - const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"]) + const [local, others] = splitProps(props, [ + "text", + "cacheKey", + "streaming", + "class", + "classList", + "onLinkOpenExternal", + "onLinkRevealPath", + "onImageClick", + ]) const marked = useMarked() const i18n = useI18n() const [root, setRoot] = createSignal() @@ -321,6 +396,8 @@ export function Markdown( ) let copyCleanup: (() => void) | undefined + let linkCleanup: (() => void) | undefined + let imageCleanup: (() => void) | undefined createEffect(() => { const container = root() @@ -363,10 +440,21 @@ export function Markdown( copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), })) + if (!linkCleanup) { + linkCleanup = setupLinkClicks(container, { + openExternal: local.onLinkOpenExternal, + revealPath: local.onLinkRevealPath, + }) + } + if (local.onImageClick && !imageCleanup) { + imageCleanup = setupImageClicks(container, local.onImageClick) + } }) onCleanup(() => { if (copyCleanup) copyCleanup() + if (linkCleanup) linkCleanup() + if (imageCleanup) imageCleanup() }) return ( From 05c36fbc7020ed3d0da6aaaf1540d129f46ab614 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 00:31:24 +0800 Subject: [PATCH 07/30] feat(ui): default markdown links to window.api desktop bridge When no explicit onLinkOpenExternal/onLinkRevealPath handler is passed, fall back to window.api.openLink / showItemInFolder so existing call sites in message-part work without prop drilling. Web/storybook fallback (window.open) still applies when window.api is absent. --- packages/ui/src/components/markdown.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 768fe152d..204ba2c4f 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -208,16 +208,23 @@ function setupLinkClicks(root: HTMLDivElement, handlers: LinkActionHandlers) { if (action.kind === "anchor") return event.preventDefault() if (action.kind === "block") return + const desktop = typeof window !== "undefined" ? (window as unknown as { api?: { openLink?: (url: string) => void; showItemInFolder?: (path: string) => unknown } }).api : undefined if (action.kind === "external") { if (handlers.openExternal) { handlers.openExternal(action.url) + } else if (desktop?.openLink) { + desktop.openLink(action.url) } else if (typeof window !== "undefined") { window.open(action.url, "_blank", "noopener,noreferrer") } return } if (action.kind === "reveal") { - handlers.revealPath?.(action.path) + if (handlers.revealPath) { + handlers.revealPath(action.path) + } else if (desktop?.showItemInFolder) { + void desktop.showItemInFolder(action.path) + } } } root.addEventListener("click", handler) From bcd2125509ade3d70b7ae36155fd4476c988f539 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 00:33:07 +0800 Subject: [PATCH 08/30] docs(ui): add W3 markdown body storybook coverage 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. --- .../ui/src/components/markdown.stories.tsx | 105 ++++++++++++++---- 1 file changed, 86 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/components/markdown.stories.tsx b/packages/ui/src/components/markdown.stories.tsx index cae429486..88c7da22e 100644 --- a/packages/ui/src/components/markdown.stories.tsx +++ b/packages/ui/src/components/markdown.stories.tsx @@ -6,26 +6,18 @@ import { markdown } from "../storybook/fixtures" const docs = `### Overview Render sanitized Markdown with code blocks, inline code, and safe links. -Pair with \`Code\` for standalone code views. - -### API -- Required: \`text\` Markdown string. -- Uses the Marked context provider for parsing and sanitization. - -### Variants and states -- Code blocks include copy buttons when rendered. - -### Behavior -- Sanitizes HTML and auto-converts inline URL code to links. -- Adds copy buttons to code blocks. - -### Accessibility -- Copy buttons include aria-labels from i18n. -- TODO: confirm link target behavior in sanitized output. - -### Theming/tokens -- Uses \`data-component="markdown"\` and related slots for styling. +W3 lock 2026-05-10 (see docs/design/preview/markdown-body.html · STANDARDS.md#L43). +### Variants +- **Basic** — kitchen-sink fixture +- **W3.Headings** — H1-H4 G mapping (all 13px sans, hierarchy via fg color + margin) +- **W3.LinksInkOnly** — quiet underline, hover currentColor, focus brand ring +- **W3.TaskList** — 16px circle / circle-check svg, read-only +- **W3.Blockquote** — cream bg + radius-sm, no left line (avoids BAN 1) +- **W3.Table** — \`th data-numeric="true"\` for tabular-nums + right align +- **W3.Details** — chev rotates 90deg on open +- **W3.Math** — inline + block KaTeX +- **W3.HtmlWhitelist** — sub/sup/kbd/abbr/del; script/iframe stripped ` const story = create({ @@ -36,6 +28,73 @@ const story = create({ }, }) +const fixtures = { + headings: [ + "# H1 章节标题", + "正文段落示意。", + "## H2 子章节", + "fg-strong 同 H1, mt 减档区分。", + "### H3 三级", + "fg-base 弱一档。", + "#### H4 四级", + "fg-weak 最弱; agent 极少出现。", + ].join("\n"), + links: [ + "agent 输出常见三类链接", + "", + "外部: 见 [PawWork 仓库](https://github.com/Astro-Han/pawwork)", + "", + "本地: 配置在 [packages/ui/src/components/markdown.tsx](packages/ui/src/components/markdown.tsx)", + "", + "锚点: 跳到 [#section](#section)", + ].join("\n"), + tasks: [ + "## 任务清单", + "", + "- [x] 起 worktree", + "- [x] 写施工计划", + "- [ ] 跑 /crosscheck", + "- [ ] 开 PR", + ].join("\n"), + blockquote: [ + "> 引用语段以 cream 底 + radius-sm 表达, 不加左竖线 (避 BAN 1)。", + ">", + "> 嵌套也保持单一底色。", + "", + "正文继续。", + ].join("\n"), + table: [ + "| 文件 | 行数 | 增删 |", + "| --- | ---: | ---: |", + "| markdown.css | 340 | +225 -151 |", + "| markdown.tsx | 510 | +123 -2 |", + "| theme.css | 1 | +1 -1 |", + ].join("\n"), + details: [ + "
调试日志", + "", + "默认折叠, 点开看明细。chev 旋转 90deg。", + "", + "
", + ].join("\n"), + math: [ + "Inline 数学: $E = mc^2$ 出现在句中。", + "", + "Block 公式独立成段:", + "", + "$$", + "G = \\frac{\\sum |x_i - x_j|}{2 n^2 \\mu}", + "$$", + ].join("\n"), + htmlWhitelist: [ + "白名单: 2O / 2 / Cmd / API / 1.5", + "", + "", + "", + "上面 script/iframe 被 DOMPurify 砍掉, 不渲染。", + ].join("\n"), +} + export default { title: "UI/Markdown", id: "components-markdown", @@ -51,3 +110,11 @@ export default { } export const Basic = story.Basic +export const W3Headings = { ...story.Basic, args: { text: fixtures.headings } } +export const W3LinksInkOnly = { ...story.Basic, args: { text: fixtures.links } } +export const W3TaskList = { ...story.Basic, args: { text: fixtures.tasks } } +export const W3Blockquote = { ...story.Basic, args: { text: fixtures.blockquote } } +export const W3Table = { ...story.Basic, args: { text: fixtures.table } } +export const W3Details = { ...story.Basic, args: { text: fixtures.details } } +export const W3Math = { ...story.Basic, args: { text: fixtures.math } } +export const W3HtmlWhitelist = { ...story.Basic, args: { text: fixtures.htmlWhitelist } } From 79ef9fea23071c6de50f312154ee241508500b7e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 00:51:02 +0800 Subject: [PATCH 09/30] fix(ui): apply crosscheck round-1 P1/P2 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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

. 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. --- packages/ui/src/components/markdown.css | 19 +++--- packages/ui/src/components/markdown.test.ts | 54 +++++++++++++-- packages/ui/src/components/markdown.tsx | 73 ++++++++++++++++++--- 3 files changed, 122 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 7c181048c..3f7b444d2 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -140,28 +140,29 @@ color: var(--fg-weak); } - /* §6 Task list · 16px circle / circle-check svg, read-only */ - ul.task-list { + /* §6 Task list · 16px circle / circle-check svg, read-only. + Per-li opt-in (li.task-item) so a list mixing tasks and bullets keeps + the bullet on the non-task siblings. */ + li.task-item { list-style: none; + margin-left: -24px; padding-left: 0; - } - ul.task-list li { display: flex; align-items: flex-start; gap: var(--space-sm); - padding-left: 0; - margin: 0 0 var(--space-xs); } /* 偏离: svg margin-top 2px (DESIGN.md 4pt 网格). 理由: 16px 图标对齐 13/160 文字基线的光学调整 ((20.8-16)/2 ≈ 2), 不属布局间距 */ - ul.task-list li > svg { + li.task-item > svg { flex: 0 0 16px; width: 16px; height: 16px; color: var(--fg-strong); margin-top: 2px; } + /* If marked wraps loose-list content in

, the paragraph just inherits + normal margin handling. */ /* §7 Blockquote · cream + radius-sm, no left line (avoids BAN 1) */ blockquote { @@ -211,7 +212,9 @@ border-bottom: 0; } th[data-numeric="true"], - td[data-numeric="true"] { + td[data-numeric="true"], + th[align="right"], + td[align="right"] { text-align: right; font-variant-numeric: tabular-nums; } diff --git a/packages/ui/src/components/markdown.test.ts b/packages/ui/src/components/markdown.test.ts index 286e04071..b3515d93f 100644 --- a/packages/ui/src/components/markdown.test.ts +++ b/packages/ui/src/components/markdown.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { resolveLinkAction, rewriteTaskListsForTest, sanitizeConfig } from "./markdown" +import { resolveLinkAction, rewriteTaskListsForTest, sanitizeConfig, sanitizeForTest } from "./markdown" describe("DOMPurify whitelist config", () => { test("forbids unsafe tags", () => { @@ -7,10 +7,12 @@ describe("DOMPurify whitelist config", () => { expect(sanitizeConfig.FORBID_TAGS).toContain("iframe") expect(sanitizeConfig.FORBID_TAGS).toContain("style") expect(sanitizeConfig.FORBID_TAGS).toContain("form") - expect(sanitizeConfig.FORBID_TAGS).toContain("input") expect(sanitizeConfig.FORBID_TAGS).toContain("object") expect(sanitizeConfig.FORBID_TAGS).toContain("embed") }) + test("permits input only as GFM checkbox (handled via uponSanitizeElement hook)", () => { + expect(sanitizeConfig.FORBID_TAGS).not.toContain("input") + }) test("forbids unsafe text content", () => { expect(sanitizeConfig.FORBID_CONTENTS).toContain("script") expect(sanitizeConfig.FORBID_CONTENTS).toContain("iframe") @@ -37,13 +39,13 @@ describe("DOMPurify whitelist config", () => { }) describe("task list svg rendering", () => { - test("replaces unchecked input with circle svg", () => { + test("replaces unchecked input with circle svg + tags li", () => { document.body.innerHTML = '

  • read
' - const ul = document.querySelector("ul")! + const li = document.querySelector("li")! rewriteTaskListsForTest(document.body) - expect(ul.classList.contains("task-list")).toBe(true) - expect(ul.querySelector("input")).toBeNull() - const svg = ul.querySelector("svg") + expect(li.classList.contains("task-item")).toBe(true) + expect(li.querySelector("input")).toBeNull() + const svg = li.querySelector("svg") expect(svg).not.toBeNull() expect(svg!.getAttribute("data-state")).toBe("unchecked") }) @@ -59,6 +61,38 @@ describe("task list svg rendering", () => { rewriteTaskListsForTest(document.body) expect(document.body.textContent).toContain("read the spec") }) + test("does not tag sibling LI without checkbox", () => { + document.body.innerHTML = + '
  • task
  • plain bullet
' + rewriteTaskListsForTest(document.body) + const items = document.querySelectorAll("li") + expect(items[0]!.classList.contains("task-item")).toBe(true) + expect(items[1]!.classList.contains("task-item")).toBe(false) + }) + test("handles loose-list paragraph wrap", () => { + document.body.innerHTML = + '
  • loose item

' + const li = document.querySelector("li")! + rewriteTaskListsForTest(document.body) + expect(li.classList.contains("task-item")).toBe(true) + expect(li.querySelector("input")).toBeNull() + expect(li.querySelector("svg")).not.toBeNull() + }) + test("sanitize-then-decorate keeps GFM checkbox alive", () => { + const cleaned = sanitizeForTest( + '
  • task
', + ) + const root = document.createElement("div") + root.innerHTML = cleaned + rewriteTaskListsForTest(root) + const li = root.querySelector("li")! + expect(li.classList.contains("task-item")).toBe(true) + expect(li.querySelector("svg")).not.toBeNull() + }) + test("sanitize strips non-checkbox inputs", () => { + const cleaned = sanitizeForTest('') + expect(cleaned).not.toContain(" { @@ -68,6 +102,9 @@ describe("link action routing", () => { test("http → external", () => { expect(resolveLinkAction("http://example.com")).toEqual({ kind: "external", url: "http://example.com" }) }) + test("mailto: → external", () => { + expect(resolveLinkAction("mailto:hi@x.com")).toEqual({ kind: "external", url: "mailto:hi@x.com" }) + }) test("relative repo path → reveal", () => { expect(resolveLinkAction("packages/ui/src/foo.ts")).toEqual({ kind: "reveal", @@ -80,6 +117,9 @@ describe("link action routing", () => { test("anchor-only stays default", () => { expect(resolveLinkAction("#section")).toEqual({ kind: "anchor", url: "#section" }) }) + test("protocol-relative // blocks (cannot be local path)", () => { + expect(resolveLinkAction("//evil.com/x")).toEqual({ kind: "block" }) + }) test("javascript: rejected", () => { expect(resolveLinkAction("javascript:alert(1)")).toEqual({ kind: "block" }) }) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 204ba2c4f..fea29fd1c 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -26,12 +26,24 @@ if (typeof window !== "undefined" && DOMPurify.isSupported) { set.add("noreferrer") node.setAttribute("rel", Array.from(set).join(" ")) }) + // Allow only disabled checkbox inputs (GFM task list); strip every other + // input variant that DOMPurify's html profile would otherwise pass through. + DOMPurify.addHook("uponSanitizeElement", (node, data) => { + if (data.tagName !== "input") return + if (!(node instanceof HTMLInputElement)) return + const type = (node.getAttribute("type") ?? "").toLowerCase() + if (type !== "checkbox") { + node.parentNode?.removeChild(node) + return + } + node.setAttribute("disabled", "") + }) } const config = { USE_PROFILES: { html: true, mathMl: true }, SANITIZE_NAMED_PROPS: true, - FORBID_TAGS: ["script", "iframe", "style", "form", "input", "object", "embed"], + FORBID_TAGS: ["script", "iframe", "style", "form", "object", "embed"], FORBID_CONTENTS: ["script", "iframe", "style"], ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|file):|\/|\.{1,2}\/|#|[^:]*$)/i, } @@ -48,6 +60,8 @@ function sanitize(html: string) { return DOMPurify.sanitize(html, config) } +export const sanitizeForTest = sanitize + function escape(text: string) { return text @@ -185,7 +199,10 @@ export function resolveLinkAction(href: string): LinkAction { const trimmed = href.trim() if (!trimmed) return { kind: "block" } if (trimmed.startsWith("#")) return { kind: "anchor", url: trimmed } + // Protocol-relative URLs (//host/path) are remote-shaped — never reveal. + if (trimmed.startsWith("//")) return { kind: "block" } if (/^https?:\/\//i.test(trimmed)) return { kind: "external", url: trimmed } + if (/^mailto:/i.test(trimmed)) return { kind: "external", url: trimmed } if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return { kind: "block" } return { kind: "reveal", path: trimmed } } @@ -205,10 +222,27 @@ function setupLinkClicks(root: HTMLDivElement, handlers: LinkActionHandlers) { if (anchor.closest('[data-slot="markdown-copy-button"]')) return const href = anchor.getAttribute("href") ?? "" const action = resolveLinkAction(href) - if (action.kind === "anchor") return event.preventDefault() if (action.kind === "block") return - const desktop = typeof window !== "undefined" ? (window as unknown as { api?: { openLink?: (url: string) => void; showItemInFolder?: (path: string) => unknown } }).api : undefined + if (action.kind === "anchor") { + // Scroll within the markdown container instead of letting Electron's + // hash router consume the anchor change. + const id = action.url.slice(1) + if (id) { + const inner = root.querySelector(`#${CSS.escape(id)}`) + if (inner instanceof HTMLElement) inner.scrollIntoView({ block: "start" }) + } + return + } + const desktop = + typeof window !== "undefined" + ? (window as unknown as { + api?: { + openLink?: (url: string) => void + showItemInFolder?: (path: string) => unknown + } + }).api + : undefined if (action.kind === "external") { if (handlers.openExternal) { handlers.openExternal(action.url) @@ -227,8 +261,9 @@ function setupLinkClicks(root: HTMLDivElement, handlers: LinkActionHandlers) { } } } - root.addEventListener("click", handler) - return () => root.removeEventListener("click", handler) + // Capture phase so descendant stopPropagation cannot bypass routing. + root.addEventListener("click", handler, true) + return () => root.removeEventListener("click", handler, true) } function setupImageClicks(root: HTMLDivElement, openImage: (src: string) => void) { @@ -254,13 +289,12 @@ const taskListIcons = { function rewriteTaskLists(root: ParentNode) { const inputs = Array.from( - root.querySelectorAll('li > input[type="checkbox"]'), + root.querySelectorAll('input[type="checkbox"]'), ) for (const input of inputs) { - const li = input.parentElement + const li = input.closest("li") if (!(li instanceof HTMLLIElement)) continue - const ul = li.parentElement - if (ul instanceof HTMLUListElement) ul.classList.add("task-list") + li.classList.add("task-item") const checked = input.hasAttribute("checked") const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") svg.setAttribute("viewBox", "0 0 16 16") @@ -268,6 +302,12 @@ function rewriteTaskLists(root: ParentNode) { svg.setAttribute("data-state", checked ? "checked" : "unchecked") svg.innerHTML = checked ? taskListIcons.checked : taskListIcons.unchecked input.replaceWith(svg) + // marked may wrap the checkbox in

for loose lists. Hoist the svg up + // so the LI flexbox aligns icon and label in one row. + const wrapper = svg.parentElement + if (wrapper && wrapper !== li && wrapper.tagName === "P") { + li.insertBefore(svg, wrapper) + } const next = svg.nextSibling if (next && next.nodeType === Node.TEXT_NODE && /^\s+/.test(next.textContent ?? "")) { next.textContent = (next.textContent ?? "").replace(/^\s+/, "") @@ -277,6 +317,20 @@ function rewriteTaskLists(root: ParentNode) { export const rewriteTaskListsForTest = rewriteTaskLists +const chevSvg = + '' + +function ensureDetailsChev(root: ParentNode) { + const summaries = Array.from(root.querySelectorAll("details > summary")) + for (const summary of summaries) { + if (summary.querySelector("svg.chev")) continue + const wrap = document.createElement("template") + wrap.innerHTML = chevSvg + const svg = wrap.content.firstElementChild + if (svg) summary.insertBefore(svg, summary.firstChild) + } +} + function decorate(root: HTMLDivElement, labels: CopyLabels) { const blocks = Array.from(root.querySelectorAll("pre")) for (const block of blocks) { @@ -284,6 +338,7 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) { } markCodeLinks(root) rewriteTaskLists(root) + ensureDetailsChev(root) } function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { From c2d6ba115fd3b78de1dbe68cca23c1f38cf46377 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 01:21:43 +0800 Subject: [PATCH 10/30] fix(ui): in-document anchor links and tabular path wrapping 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. --- packages/ui/src/components/markdown.css | 1 - packages/ui/src/components/markdown.tsx | 10 +++++----- packages/ui/src/context/marked.tsx | 3 +++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 3f7b444d2..f77de5d28 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -103,7 +103,6 @@ border-radius: var(--radius-sm); color: var(--fg-strong); overflow-wrap: anywhere; - word-break: break-all; } /* §5 Lists */ diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index fea29fd1c..a45b647f4 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -225,13 +225,13 @@ function setupLinkClicks(root: HTMLDivElement, handlers: LinkActionHandlers) { event.preventDefault() if (action.kind === "block") return if (action.kind === "anchor") { - // Scroll within the markdown container instead of letting Electron's - // hash router consume the anchor change. const id = action.url.slice(1) - if (id) { - const inner = root.querySelector(`#${CSS.escape(id)}`) - if (inner instanceof HTMLElement) inner.scrollIntoView({ block: "start" }) + if (!id || id.toLowerCase() === "top") { + root.scrollIntoView({ block: "start" }) + return } + const inner = root.querySelector(`#${CSS.escape(id)}`) + if (inner instanceof HTMLElement) inner.scrollIntoView({ block: "start" }) return } const desktop = diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 482df0866..f566d489a 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -473,6 +473,9 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( renderer: { link({ href, title, text }) { const titleAttr = title ? ` title="${title}"` : "" + if (href.startsWith("#")) { + return `${text}` + } return `${text}` }, }, From ac8ec046887abb261c832645a026afcf25531cfa Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 01:36:40 +0800 Subject: [PATCH 11/30] fix(ui): route only true remote links through desktop handler; reframe table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ui/src/components/markdown.css | 16 +++++++++++++--- packages/ui/src/context/marked.tsx | 11 ++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index f77de5d28..d3746840b 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -185,17 +185,27 @@ margin: var(--space-2xl) 0; } - /* §9 Table */ + /* §9 Table — inset frame + Anchor the table inside the markdown stream with a 1 px outer frame + + radius-sm so it reads as an embedded printed panel. The frame is the + primary visual anchor; the header keeps W3 lock's --bg-cream (the only + surface token whose contrast direction is correct in both themes). + Header bottom uses border-base for a clear head/body split; row + dividers use border-weak so they survive in dark mode without going + loud. No zebra, no hover (W3 lock §9 ban). */ table { border-collapse: collapse; width: 100%; margin: 0 0 var(--space-md); font: var(--type-body); + border: 1px solid var(--border-weak); + border-radius: var(--radius-sm); + overflow: hidden; } thead th { padding: var(--space-sm) var(--space-md); background: var(--bg-cream); - border-bottom: 1px solid var(--border-weak); + border-bottom: 1px solid var(--border-base); font: var(--type-h3); color: var(--fg-strong); text-align: left; @@ -203,7 +213,7 @@ } tbody td { padding: var(--space-sm) var(--space-md); - border-bottom: 1px solid var(--border-weaker); + border-bottom: 1px solid var(--border-weak); color: var(--fg-base); vertical-align: top; } diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index f566d489a..a3ce574bd 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -473,10 +473,15 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( renderer: { link({ href, title, text }) { const titleAttr = title ? ` title="${title}"` : "" - if (href.startsWith("#")) { - return `${text}` + // The desktop shell's document-level handler grabs every .external-link + // and routes it to shell.openExternal. Only mark true remote links so + // hash anchors and repo paths fall through to the markdown component's + // own click handler (which knows how to scroll / reveal in Finder). + const remote = /^(?:https?:\/\/|mailto:)/i.test(href) + if (remote) { + return `${text}` } - return `${text}` + return `${text}` }, }, }, From f79d8a5ff9f28f1caf8eaf46a8da4dd34687b34e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 01:45:59 +0800 Subject: [PATCH 12/30] fix(ui): mirror link routing in main-process parser; switch table to editorial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../desktop-electron/src/main/markdown.ts | 11 +++++- packages/ui/src/components/markdown.css | 35 +++++++++++-------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/packages/desktop-electron/src/main/markdown.ts b/packages/desktop-electron/src/main/markdown.ts index b956f4876..8d0ebd633 100644 --- a/packages/desktop-electron/src/main/markdown.ts +++ b/packages/desktop-electron/src/main/markdown.ts @@ -4,7 +4,16 @@ const renderer = new marked.Renderer() renderer.link = ({ href, title, text }: Tokens.Link) => { const titleAttr = title ? ` title="${title}"` : "" - return `${text}` + // The desktop renderer's document-level handler grabs every .external-link + // and routes it to shell.openExternal. Only mark true remote links so hash + // anchors and repo paths fall through to the markdown component's own + // click handler (scroll / Finder reveal). Keep this in sync with the + // jsParser branch in packages/ui/src/context/marked.tsx. + const remote = /^(?:https?:\/\/|mailto:)/i.test(href) + if (remote) { + return `${text}` + } + return `${text}` } export function parseMarkdown(input: string) { diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index d3746840b..c7b8bbd77 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -185,34 +185,39 @@ margin: var(--space-2xl) 0; } - /* §9 Table — inset frame - Anchor the table inside the markdown stream with a 1 px outer frame + - radius-sm so it reads as an embedded printed panel. The frame is the - primary visual anchor; the header keeps W3 lock's --bg-cream (the only - surface token whose contrast direction is correct in both themes). - Header bottom uses border-base for a clear head/body split; row - dividers use border-weak so they survive in dark mode without going - loud. No zebra, no hover (W3 lock §9 ban). */ + /* §9 Table — editorial typographic + Borderless body, transparent header, anchored by a single firm rule + under the head row. This matches the borderless typographic pattern + (cf. Codex/ChatGPT, NYT story tables) and keeps the table grounded in + the prose flow rather than floating as a card. Header bottom uses + border-base so the head/body split is unambiguous in both themes; + row dividers use border-weak so they remain visible in dark mode. + No zebra, no hover (W3 lock §9 ban). */ table { border-collapse: collapse; width: 100%; - margin: 0 0 var(--space-md); + margin: var(--space-sm) 0 var(--space-md); font: var(--type-body); - border: 1px solid var(--border-weak); - border-radius: var(--radius-sm); - overflow: hidden; } thead th { - padding: var(--space-sm) var(--space-md); - background: var(--bg-cream); + padding: var(--space-sm) var(--space-md) var(--space-md); + background: transparent; border-bottom: 1px solid var(--border-base); font: var(--type-h3); color: var(--fg-strong); text-align: left; vertical-align: top; } + thead th:first-child, + tbody td:first-child { + padding-left: 0; + } + thead th:last-child, + tbody td:last-child { + padding-right: 0; + } tbody td { - padding: var(--space-sm) var(--space-md); + padding: var(--space-md); border-bottom: 1px solid var(--border-weak); color: var(--fg-base); vertical-align: top; From b420773ff03e047798f718ed6595fb1aa70100fa Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 01:54:56 +0800 Subject: [PATCH 13/30] fix(ui): tighten table padding to W3 lock densities; drop #top special case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ui/src/components/markdown.css | 36 +++++++++++-------------- packages/ui/src/components/markdown.tsx | 5 +--- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index c7b8bbd77..24450436c 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -185,39 +185,35 @@ margin: var(--space-2xl) 0; } - /* §9 Table — editorial typographic - Borderless body, transparent header, anchored by a single firm rule - under the head row. This matches the borderless typographic pattern - (cf. Codex/ChatGPT, NYT story tables) and keeps the table grounded in - the prose flow rather than floating as a card. Header bottom uses - border-base so the head/body split is unambiguous in both themes; - row dividers use border-weak so they remain visible in dark mode. - No zebra, no hover (W3 lock §9 ban). */ + /* §9 Table — W3 lock §L43 with two minor deviations declared + Header keeps W3 lock's --bg-cream surface (the only surface token + whose contrast direction is correct in both themes). Two deltas vs + L43 to make the table readable at the densities chat actually + produces: + 1. Header bottom uses --border-base (vs L43's --border-weaker) + so the head/body split survives in light mode where bg-cream + is only ~5% darker than bg-base. + 2. Row dividers use --border-weak (vs L43's --border-weaker) + so the separator is visible in dark mode without going loud. + Padding follows the lock-table preview pattern (sm × md). + Per L43: no zebra, no hover. */ table { border-collapse: collapse; width: 100%; - margin: var(--space-sm) 0 var(--space-md); + margin: 0 0 var(--space-md); font: var(--type-body); } thead th { - padding: var(--space-sm) var(--space-md) var(--space-md); - background: transparent; + padding: var(--space-sm) var(--space-md); + background: var(--bg-cream); border-bottom: 1px solid var(--border-base); font: var(--type-h3); color: var(--fg-strong); text-align: left; vertical-align: top; } - thead th:first-child, - tbody td:first-child { - padding-left: 0; - } - thead th:last-child, - tbody td:last-child { - padding-right: 0; - } tbody td { - padding: var(--space-md); + padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--border-weak); color: var(--fg-base); vertical-align: top; diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index a45b647f4..a9b0ca672 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -226,10 +226,7 @@ function setupLinkClicks(root: HTMLDivElement, handlers: LinkActionHandlers) { if (action.kind === "block") return if (action.kind === "anchor") { const id = action.url.slice(1) - if (!id || id.toLowerCase() === "top") { - root.scrollIntoView({ block: "start" }) - return - } + if (!id) return const inner = root.querySelector(`#${CSS.escape(id)}`) if (inner instanceof HTMLElement) inner.scrollIntoView({ block: "start" }) return From 228c03d41f20a7e787938f1c6b76aa6fc7036801 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 01:56:56 +0800 Subject: [PATCH 14/30] chore(ui): add temporary diagnostic logs to markdown link handler --- packages/ui/src/components/markdown.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index a9b0ca672..71b3f8ccf 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -213,15 +213,24 @@ export type LinkActionHandlers = { } function setupLinkClicks(root: HTMLDivElement, handlers: LinkActionHandlers) { + // eslint-disable-next-line no-console + console.log("[md/link] setupLinkClicks attached", { root }) const handler = (event: MouseEvent) => { if (event.defaultPrevented) return const target = event.target if (!(target instanceof Element)) return const anchor = target.closest("a") + // eslint-disable-next-line no-console + console.log("[md/link] click", { + target: target instanceof HTMLElement ? target.outerHTML.slice(0, 200) : String(target), + anchor: anchor ? anchor.outerHTML.slice(0, 200) : null, + }) if (!(anchor instanceof HTMLAnchorElement)) return if (anchor.closest('[data-slot="markdown-copy-button"]')) return const href = anchor.getAttribute("href") ?? "" const action = resolveLinkAction(href) + // eslint-disable-next-line no-console + console.log("[md/link] action", { href, action }) event.preventDefault() if (action.kind === "block") return if (action.kind === "anchor") { @@ -251,6 +260,12 @@ function setupLinkClicks(root: HTMLDivElement, handlers: LinkActionHandlers) { return } if (action.kind === "reveal") { + // eslint-disable-next-line no-console + console.log("[md/link] reveal", { + path: action.path, + hasHandler: Boolean(handlers.revealPath), + hasDesktopApi: Boolean(desktop?.showItemInFolder), + }) if (handlers.revealPath) { handlers.revealPath(action.path) } else if (desktop?.showItemInFolder) { From 21e5b1f25ef02495c157116c6a544d8535ef5b17 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 01:59:05 +0800 Subject: [PATCH 15/30] fix(desktop): resolve relative reveal paths to absolute in main process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/desktop-electron/src/main/ipc.ts | 9 +++++++-- packages/ui/src/components/markdown.tsx | 15 --------------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index a27218cea..39813141f 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -414,8 +414,13 @@ export function registerIpcHandlers(deps: Deps) { }) }) - ipcMain.handle("show-item-in-folder", (_event: IpcMainInvokeEvent, path: string) => { - shell.showItemInFolder(path) + ipcMain.handle("show-item-in-folder", (_event: IpcMainInvokeEvent, target: string) => { + // shell.showItemInFolder needs an absolute path; relative paths silently + // no-op on macOS. Resolve against the main-process cwd as a fallback when + // the renderer hands us a relative path (e.g. markdown body emitted by + // an assistant turn whose caller did not absolutize the link target). + const absolute = path.isAbsolute(target) ? target : path.resolve(process.cwd(), target) + shell.showItemInFolder(absolute) }) ipcMain.handle("stat-paths", async (_event: IpcMainInvokeEvent, paths: string[]) => { diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 71b3f8ccf..a9b0ca672 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -213,24 +213,15 @@ export type LinkActionHandlers = { } function setupLinkClicks(root: HTMLDivElement, handlers: LinkActionHandlers) { - // eslint-disable-next-line no-console - console.log("[md/link] setupLinkClicks attached", { root }) const handler = (event: MouseEvent) => { if (event.defaultPrevented) return const target = event.target if (!(target instanceof Element)) return const anchor = target.closest("a") - // eslint-disable-next-line no-console - console.log("[md/link] click", { - target: target instanceof HTMLElement ? target.outerHTML.slice(0, 200) : String(target), - anchor: anchor ? anchor.outerHTML.slice(0, 200) : null, - }) if (!(anchor instanceof HTMLAnchorElement)) return if (anchor.closest('[data-slot="markdown-copy-button"]')) return const href = anchor.getAttribute("href") ?? "" const action = resolveLinkAction(href) - // eslint-disable-next-line no-console - console.log("[md/link] action", { href, action }) event.preventDefault() if (action.kind === "block") return if (action.kind === "anchor") { @@ -260,12 +251,6 @@ function setupLinkClicks(root: HTMLDivElement, handlers: LinkActionHandlers) { return } if (action.kind === "reveal") { - // eslint-disable-next-line no-console - console.log("[md/link] reveal", { - path: action.path, - hasHandler: Boolean(handlers.revealPath), - hasDesktopApi: Boolean(desktop?.showItemInFolder), - }) if (handlers.revealPath) { handlers.revealPath(action.path) } else if (desktop?.showItemInFolder) { From 035cbd90f765238d1374f4cc0eda3e79a865ae35 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 02:01:06 +0800 Subject: [PATCH 16/30] chore(desktop): diagnostic log in show-item-in-folder ipc handler --- packages/desktop-electron/src/main/ipc.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 39813141f..6dd84eb6a 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -414,12 +414,22 @@ export function registerIpcHandlers(deps: Deps) { }) }) - ipcMain.handle("show-item-in-folder", (_event: IpcMainInvokeEvent, target: string) => { - // shell.showItemInFolder needs an absolute path; relative paths silently - // no-op on macOS. Resolve against the main-process cwd as a fallback when - // the renderer hands us a relative path (e.g. markdown body emitted by - // an assistant turn whose caller did not absolutize the link target). + ipcMain.handle("show-item-in-folder", async (_event: IpcMainInvokeEvent, target: string) => { const absolute = path.isAbsolute(target) ? target : path.resolve(process.cwd(), target) + let exists = false + try { + await fs.stat(absolute) + exists = true + } catch { + exists = false + } + // eslint-disable-next-line no-console + console.log("[ipc/show-item-in-folder]", { + target, + cwd: process.cwd(), + absolute, + exists, + }) shell.showItemInFolder(absolute) }) From 55a5d352783eb301895e4ba868ff00741a76d182 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 02:06:39 +0800 Subject: [PATCH 17/30] fix(ui): inject session workspace cwd into markdown reveal links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/desktop-electron/src/main/ipc.ts | 24 +++-------- packages/ui/src/components/message-part.tsx | 48 ++++++++++++++++++--- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 6dd84eb6a..5160f3983 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -414,23 +414,13 @@ export function registerIpcHandlers(deps: Deps) { }) }) - ipcMain.handle("show-item-in-folder", async (_event: IpcMainInvokeEvent, target: string) => { - const absolute = path.isAbsolute(target) ? target : path.resolve(process.cwd(), target) - let exists = false - try { - await fs.stat(absolute) - exists = true - } catch { - exists = false - } - // eslint-disable-next-line no-console - console.log("[ipc/show-item-in-folder]", { - target, - cwd: process.cwd(), - absolute, - exists, - }) - shell.showItemInFolder(absolute) + ipcMain.handle("show-item-in-folder", (_event: IpcMainInvokeEvent, target: string) => { + // shell.showItemInFolder needs an absolute path; relative resolves go + // through the renderer where the chat session knows its workspace cwd. + // process.cwd() in a packaged Electron main process is not the user's + // workspace (typically `/Users/` on macOS), so absolutizing here + // would silently reveal the wrong file. + shell.showItemInFolder(target) }) ipcMain.handle("stat-paths", async (_event: IpcMainInvokeEvent, paths: string[]) => { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 36b08f229..947c42c44 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -230,6 +230,42 @@ function createPacedValue(getValue: () => string, live?: () => boolean) { return value } +function isAbsoluteLikePath(p: string): boolean { + return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) +} + +function joinWorkspacePath(directory: string, relative: string): string { + const sep = directory.includes("\\") ? "\\" : "/" + const trimmed = directory.replace(/[\\/]+$/, "") + return `${trimmed}${sep}${relative}` +} + +function MessageMarkdown(props: { + text: string + cacheKey?: string + streaming?: boolean + class?: string +}) { + const data = useData() + const desktop = + typeof window !== "undefined" + ? (window as unknown as { api?: { showItemInFolder?: (path: string) => unknown } }).api + : undefined + return ( + { + const directory = data.directory + const absolute = isAbsoluteLikePath(p) || !directory ? p : joinWorkspacePath(directory, p) + if (desktop?.showItemInFolder) void desktop.showItemInFolder(absolute) + }} + /> + ) +} + function PacedMarkdown(props: { text: string; cacheKey: string; streaming: boolean }) { const value = createPacedValue( () => props.text, @@ -238,7 +274,7 @@ function PacedMarkdown(props: { text: string; cacheKey: string; streaming: boole return ( - + ) } @@ -1521,7 +1557,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {

- }> + }>
@@ -1563,7 +1599,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { return (
- }> + }>
@@ -1623,7 +1659,7 @@ ToolRegistry.register({ >
- +
@@ -1647,7 +1683,7 @@ ToolRegistry.register({ >
- +
@@ -1674,7 +1710,7 @@ ToolRegistry.register({ >
- +
From 4513bc309723cd46a8b585a637a5cf947ed08a5b Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 02:13:26 +0800 Subject: [PATCH 18/30] refactor(ui): switch resolveLinkAction to a denylist for unsafe schemes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ui/src/components/markdown.test.ts | 7 +++++++ packages/ui/src/components/markdown.tsx | 12 +++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/markdown.test.ts b/packages/ui/src/components/markdown.test.ts index b3515d93f..93d335667 100644 --- a/packages/ui/src/components/markdown.test.ts +++ b/packages/ui/src/components/markdown.test.ts @@ -126,6 +126,13 @@ describe("link action routing", () => { test("data: rejected", () => { expect(resolveLinkAction("data:text/html,foo")).toEqual({ kind: "block" }) }) + test("vbscript: rejected", () => { + expect(resolveLinkAction("vbscript:msgbox")).toEqual({ kind: "block" }) + }) + test("non-dangerous custom scheme routes external (sanitize allowlist gates the surface)", () => { + expect(resolveLinkAction("vscode://file/foo")).toEqual({ kind: "external", url: "vscode://file/foo" }) + expect(resolveLinkAction("tel:+15551234")).toEqual({ kind: "external", url: "tel:+15551234" }) + }) test("empty href blocks", () => { expect(resolveLinkAction("")).toEqual({ kind: "block" }) }) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index a9b0ca672..dc1dd28ec 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -201,9 +201,15 @@ export function resolveLinkAction(href: string): LinkAction { if (trimmed.startsWith("#")) return { kind: "anchor", url: trimmed } // Protocol-relative URLs (//host/path) are remote-shaped — never reveal. if (trimmed.startsWith("//")) return { kind: "block" } - if (/^https?:\/\//i.test(trimmed)) return { kind: "external", url: trimmed } - if (/^mailto:/i.test(trimmed)) return { kind: "external", url: trimmed } - if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return { kind: "block" } + // Block dangerous schemes outright; sanitize already strips most of these + // at the href level, but defense in depth keeps the routing predictable. + if (/^(?:javascript|data|vbscript):/i.test(trimmed)) return { kind: "block" } + // Any other scheme (https, mailto, vscode, tel, sms, git, ssh, …) routes + // to the external handler. Whether the scheme is actually surfaced to + // users is governed by the DOMPurify ALLOWED_URI_REGEXP allowlist, not + // here — which keeps schemes opt-in at sanitize time without forcing + // every new addition to also touch the click router. + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return { kind: "external", url: trimmed } return { kind: "reveal", path: trimmed } } From d74021fe58cfa3224ecbf7cbb72d1bcad8bb4782 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 18:56:11 +0800 Subject: [PATCH 19/30] fix(ui): land W3 markdown-body Phase 4 revisions and hand-test fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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 --- packages/ui/src/components/markdown.css | 106 +++++++++++--------- packages/ui/src/components/markdown.test.ts | 56 ++++++++++- packages/ui/src/components/markdown.tsx | 44 +++++++- 3 files changed, 155 insertions(+), 51 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 24450436c..1c95bef9a 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -4,7 +4,7 @@ [data-component="markdown"] { min-width: 0; - max-width: 75ch; + max-width: 100%; overflow-wrap: break-word; font: var(--type-body); color: var(--fg-strong); @@ -17,36 +17,33 @@ } /* §1 Headings · G mapping - All 4 tiers use --type-h3 (500 13/150); - hierarchy via fg color + margin only */ + All tiers share font + margin; hierarchy comes from fg color only. + Spacing follows the 8/12/16 semantic gradient: section-break (16 + above) > paragraph rhythm (12) > heading-to-content (8 below). + Every value is on the 4pt grid; the gradient itself encodes + "stick < breathe < break". */ h1, - h2 { + h2, + h3, + h4, + h5, + h6 { font: var(--type-h3); - color: var(--fg-strong); - margin: var(--space-2xl) 0 var(--space-xs); + margin: var(--space-lg) 0 var(--space-sm); + text-transform: none; + letter-spacing: 0; } + h1, h2 { - margin-top: var(--space-xl); + color: var(--fg-strong); } h3 { - font: var(--type-h3); color: var(--fg-base); - margin: var(--space-lg) 0 var(--space-xs); - text-transform: none; - letter-spacing: 0; - } - h4 { - font: var(--type-h3); - color: var(--fg-weak); - margin: var(--space-md) 0 var(--space-xs); - text-transform: none; - letter-spacing: 0; } + h4, h5, h6 { - font: var(--type-h3); color: var(--fg-weak); - margin: var(--space-md) 0 var(--space-xs); } /* §2 Paragraph + inline emphasis */ @@ -163,13 +160,17 @@ /* If marked wraps loose-list content in

, the paragraph just inherits normal margin handling. */ - /* §7 Blockquote · cream + radius-sm, no left line (avoids BAN 1) */ + /* §7 Blockquote · 2px border-weak left rule + fg-weak text only. + Industry-aligned (GitHub / Tailwind prose / Notion / shadcn — none + of them use a background). 2px matches shadcn's tightest end of the + industry band (2-4px). The neutral-grey rule is a documented + Markdown convention, not the coloured-stripe pattern impeccable + BAN 1 forbids. Reasoned 2026-05-10. */ blockquote { margin: 0 0 var(--space-md); - padding: var(--space-md) var(--space-lg); - background: var(--bg-cream); - border-radius: var(--radius-sm); - color: var(--fg-base); + padding: 0 var(--space-lg); + border-left: 2px solid var(--border-weak); + color: var(--fg-weak); } blockquote > :first-child { margin-top: 0; @@ -178,25 +179,23 @@ margin-bottom: 0; } - /* §8 Horizontal rule */ + /* §8 Horizontal rule · matches paragraph rhythm (12), not section + break (16). hr is a structural marker, not a heading; sits in the + paragraph-flow tier, doesn't steal visual weight. */ hr { border: 0; border-top: 1px solid var(--border-weak); - margin: var(--space-2xl) 0; + margin: var(--space-md) 0; } - /* §9 Table — W3 lock §L43 with two minor deviations declared - Header keeps W3 lock's --bg-cream surface (the only surface token - whose contrast direction is correct in both themes). Two deltas vs - L43 to make the table readable at the densities chat actually - produces: - 1. Header bottom uses --border-base (vs L43's --border-weaker) - so the head/body split survives in light mode where bg-cream - is only ~5% darker than bg-base. - 2. Row dividers use --border-weak (vs L43's --border-weaker) - so the separator is visible in dark mode without going loud. - Padding follows the lock-table preview pattern (sm × md). - Per L43: no zebra, no hover. */ + /* §9 Table — chat-density flat header (industry-aligned with GitHub / + Tailwind prose / shadcn / Linear / Stripe). Two signals: type-h3 + (font-weight 500) + 1px border-base bottom. No fill — cream surface + was visually noisy at our 3-4% delta and didn't match the chat + surface; deep-research 2026-05-10 confirmed flat-header is the + dominant chat / content-table pattern, with WCAG 1.4.3 satisfied + by text contrast alone. Row dividers use --border-weak for + visibility in both themes. No zebra, no hover. */ table { border-collapse: collapse; width: 100%; @@ -205,7 +204,6 @@ } thead th { padding: var(--space-sm) var(--space-md); - background: var(--bg-cream); border-bottom: 1px solid var(--border-base); font: var(--type-h3); color: var(--fg-strong); @@ -229,14 +227,16 @@ font-variant-numeric: tabular-nums; } - /* §10 Image · 1px frame + radius-md (DESIGN.md「User content frame」) */ + /* §10 Image · 1px frame + radius-md (DESIGN.md「User content frame」) + margin matches other block elements (mt:0 / mb:12) so heading-below + gradient (h mb 8 → img) stays at 8, not pushed to 12 by symmetric mt. */ img { max-width: 100%; height: auto; display: block; border: 1px solid var(--border-weak); border-radius: var(--radius-md); - margin: var(--space-md) 0; + margin: 0 0 var(--space-md); cursor: zoom-in; } @@ -271,11 +271,25 @@ details > :not(summary) { margin-left: 24px; } + /* summary is the heading of details — first content sits 8px below + (summary's padding-bottom), not pushed by its own margin-top. */ + details > summary + * { + margin-top: 0; + } - /* §12 Fence code · defers to DESIGN.md L36「Code & diff」.codeblock truth. - pre + .shiki rules retained for code-surfaces compatibility. */ + /* §12 KaTeX block math · normalized to other block elements (mt:0 / mb:12). + Without this, KaTeX default `.katex-display { margin: 1em 0 }` collapses + against heading mb 8 → 16 (breaks the 8/12/16 rhythm). */ + .katex-display { + margin: 0 0 var(--space-md); + } + + /* §13 Fence code · defers to DESIGN.md L36「Code & diff」.codeblock truth. + pre + .shiki rules retained for code-surfaces compatibility. + margin matches other block elements (mt:0 / mb:12) so heading-below + gradient (h mb 8 → pre) stays at 8, not pushed to 12 by symmetric mt. */ pre { - margin: var(--space-md) 0; + margin: 0 0 var(--space-md); overflow: auto; scrollbar-width: none; &::-webkit-scrollbar { @@ -311,7 +325,7 @@ max-width: 320px; border-radius: var(--radius-sm); background: var(--surface-raised); - color: var(--fg-on-brand); + color: var(--fg-strong); padding: 2px 8px; border: 1px solid var(--border-weak, rgba(0, 0, 0, 0.07)); box-shadow: var(--shadow-floating); diff --git a/packages/ui/src/components/markdown.test.ts b/packages/ui/src/components/markdown.test.ts index 93d335667..5a535905e 100644 --- a/packages/ui/src/components/markdown.test.ts +++ b/packages/ui/src/components/markdown.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test" -import { resolveLinkAction, rewriteTaskListsForTest, sanitizeConfig, sanitizeForTest } from "./markdown" +import { + forceOpenAllDetails, + preserveDetailsOpenState, + resolveLinkAction, + rewriteTaskListsForTest, + sanitizeConfig, + sanitizeForTest, +} from "./markdown" describe("DOMPurify whitelist config", () => { test("forbids unsafe tags", () => { @@ -140,3 +147,50 @@ describe("link action routing", () => { expect(resolveLinkAction(" https://x.com ")).toEqual({ kind: "external", url: "https://x.com" }) }) }) + +describe("forceOpenAllDetails (streaming UX)", () => { + test("opens all details elements", () => { + document.body.innerHTML = + "

Ax
By
" + forceOpenAllDetails(document.body) + const all = document.querySelectorAll("details") + expect(all[0]!.hasAttribute("open")).toBe(true) + expect(all[1]!.hasAttribute("open")).toBe(true) + }) + test("idempotent on already-open details", () => { + document.body.innerHTML = "
Ax
" + forceOpenAllDetails(document.body) + expect(document.querySelector("details")!.hasAttribute("open")).toBe(true) + }) + test("opens nested details too", () => { + document.body.innerHTML = + "
outer
innerx
" + forceOpenAllDetails(document.body) + const all = document.querySelectorAll("details") + expect(all[0]!.hasAttribute("open")).toBe(true) + expect(all[1]!.hasAttribute("open")).toBe(true) + }) +}) + +describe("preserveDetailsOpenState (user collapse survives re-render)", () => { + test("propagates open from fromEl to toEl", () => { + const fromEl = document.createElement("details") + fromEl.setAttribute("open", "") + const toEl = document.createElement("details") + preserveDetailsOpenState(fromEl, toEl) + expect(toEl.hasAttribute("open")).toBe(true) + }) + test("clears open on toEl when fromEl is collapsed by user", () => { + const fromEl = document.createElement("details") + const toEl = document.createElement("details") + toEl.setAttribute("open", "") // force-open default + preserveDetailsOpenState(fromEl, toEl) + expect(toEl.hasAttribute("open")).toBe(false) + }) + test("ignores non-details pairs", () => { + const fromEl = document.createElement("div") + const toEl = document.createElement("details") + preserveDetailsOpenState(fromEl, toEl) + expect(toEl.hasAttribute("open")).toBe(false) + }) +}) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index dc1dd28ec..5a616bfc3 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -320,6 +320,40 @@ function rewriteTaskLists(root: ParentNode) { export const rewriteTaskListsForTest = rewriteTaskLists +/** + * Force every
element open by default. + * + * Why: in chat output, an LLM emitting `
` is grouping related + * content (extended explanation, code example, expanded discussion), not + * hiding low-value reasoning. Auto-collapsing on stream completion would + * make the user re-click to see what they just saw stream in. Default-open + * also sidesteps marked's HTML-block parser instability on partial input + * (without `
` closed, code fences can briefly parse outside the + * subtree, making content "jump out" of a closed block). + * + * Pairs with `preserveDetailsOpenState` in the morphdom diff: this helper + * makes the *initial* state open; that one preserves the user's manual + * collapse across subsequent diffs. + */ +export function forceOpenAllDetails(root: ParentNode): void { + for (const d of root.querySelectorAll("details")) { + d.setAttribute("open", "") + } +} + +/** + * Preserve the user's
open state across morphdom diffs. + * After the user manually collapses a default-open details block, every + * subsequent re-render must keep it collapsed (otherwise i18n changes or + * other re-render triggers would flip it back to open via `forceOpenAllDetails`). + */ +export function preserveDetailsOpenState(fromEl: Element, toEl: Element): void { + if (fromEl instanceof HTMLDetailsElement && toEl instanceof HTMLDetailsElement) { + if (fromEl.hasAttribute("open")) toEl.setAttribute("open", "") + else toEl.removeAttribute("open") + } +} + const chevSvg = '' @@ -342,6 +376,7 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) { markCodeLinks(root) rewriteTaskLists(root) ensureDetailsChev(root) + forceOpenAllDetails(root) } function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { @@ -476,8 +511,8 @@ export function Markdown( } const labels = { - copy: i18n.t("ui.message.copy"), - copied: i18n.t("ui.message.copied"), + copy: i18n.t("ui.textField.copyToClipboard"), + copied: i18n.t("ui.textField.copied"), } const temp = document.createElement("div") temp.innerHTML = content @@ -495,6 +530,7 @@ export function Markdown( ) { setCopyState(toEl, labels, true) } + preserveDetailsOpenState(fromEl, toEl) if (fromEl.isEqualNode(toEl)) return false return true }, @@ -502,8 +538,8 @@ export function Markdown( if (!copyCleanup) copyCleanup = setupCodeCopy(container, () => ({ - copy: i18n.t("ui.message.copy"), - copied: i18n.t("ui.message.copied"), + copy: i18n.t("ui.textField.copyToClipboard"), + copied: i18n.t("ui.textField.copied"), })) if (!linkCleanup) { linkCleanup = setupLinkClicks(container, { From 08a474d11ede7f630e410dbc0577d827cf09ea9e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 18:56:32 +0800 Subject: [PATCH 20/30] feat(ui): three-dim hover on markdown links for stronger affordance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ui/src/components/markdown.css | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 1c95bef9a..5814fc4c8 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -68,18 +68,27 @@ color: var(--fg-weak); } - /* §3 Links · ink-only quiet underline */ + /* §3 Links · ink-only quiet underline. + Hover combines three underline-only signals (color + thickness + offset) + to clear NN/g's high-contrast bar without touching text color — brand + orange stays reserved for selected/active/running per DESIGN.md L120, + and inline reflow is avoided by mutating only underline properties. */ a { color: inherit; text-decoration: underline; text-decoration-color: var(--fg-weak); text-decoration-thickness: 1px; text-underline-offset: 3px; - transition: text-decoration-color var(--duration-fast) ease-out; + transition: + text-decoration-color var(--duration-fast) ease-out, + text-decoration-thickness var(--duration-fast) ease-out, + text-underline-offset var(--duration-fast) ease-out; overflow-wrap: anywhere; } a:hover { - text-decoration-color: currentColor; + text-decoration-color: var(--fg-strong); + text-decoration-thickness: 2px; + text-underline-offset: 4px; } /* 偏离: focus halo border-radius 2px (DESIGN.md radii 6/10/14/9999). 理由: inline 链接焦点环紧贴文字行盒,4pt 网格不适用于 inline 焦点圆角; From 0c328de2956a75c9c85e4c76fcf442b524ea3be5 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 19:13:57 +0800 Subject: [PATCH 21/30] refactor(ui): drop hover offset shift to remove visual jump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ui/src/components/markdown.css | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 5814fc4c8..455162c41 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -69,10 +69,9 @@ } /* §3 Links · ink-only quiet underline. - Hover combines three underline-only signals (color + thickness + offset) - to clear NN/g's high-contrast bar without touching text color — brand - orange stays reserved for selected/active/running per DESIGN.md L120, - and inline reflow is avoided by mutating only underline properties. */ + Hover 不动 offset(避免下划线下沉跳跃),不动 text color(DESIGN.md L120 + brand 保留给选中/激活/运行)— 仅 underline 自身二维加重,落 NN/g + 「color + shape」高对比度档。 */ a { color: inherit; text-decoration: underline; @@ -81,14 +80,12 @@ text-underline-offset: 3px; transition: text-decoration-color var(--duration-fast) ease-out, - text-decoration-thickness var(--duration-fast) ease-out, - text-underline-offset var(--duration-fast) ease-out; + text-decoration-thickness var(--duration-fast) ease-out; overflow-wrap: anywhere; } a:hover { text-decoration-color: var(--fg-strong); text-decoration-thickness: 2px; - text-underline-offset: 4px; } /* 偏离: focus halo border-radius 2px (DESIGN.md radii 6/10/14/9999). 理由: inline 链接焦点环紧贴文字行盒,4pt 网格不适用于 inline 焦点圆角; From 253afdbe44cc6a6135015fa6c6cd3fe08a978d18 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 19:32:47 +0800 Subject: [PATCH 22/30] =?UTF-8?q?chore(deps):=20bump=20dompurify=203.3.1?= =?UTF-8?q?=20=E2=86=92=203.4.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bun.lock | 4 ++-- packages/ui/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 04be846d3..e1d2a398f 100644 --- a/bun.lock +++ b/bun.lock @@ -336,7 +336,7 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "diff": "catalog:", - "dompurify": "3.3.1", + "dompurify": "3.4.2", "fuzzysort": "catalog:", "katex": "0.16.27", "luxon": "catalog:", @@ -1840,7 +1840,7 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "dompurify": ["dompurify@3.4.2", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA=="], "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 57eea5c33..fe863740e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -56,7 +56,7 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "diff": "catalog:", - "dompurify": "3.3.1", + "dompurify": "3.4.2", "fuzzysort": "catalog:", "katex": "0.16.27", "luxon": "catalog:", From 5ff7791421fded4216870ac3b30e15d16c6745b0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 19:32:56 +0800 Subject: [PATCH 23/30] fix(ui): recognize Windows UNC paths and strip ./ in reveal join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ui/src/components/message-part.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 947c42c44..3771640e5 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -231,13 +231,22 @@ function createPacedValue(getValue: () => string, live?: () => boolean) { } function isAbsoluteLikePath(p: string): boolean { - return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) + return ( + p.startsWith("/") || + /^[A-Za-z]:[\\/]/.test(p) || + // Windows UNC roots: \\server\share\file.txt + p.startsWith("\\\\") + ) } function joinWorkspacePath(directory: string, relative: string): string { const sep = directory.includes("\\") ? "\\" : "/" const trimmed = directory.replace(/[\\/]+$/, "") - return `${trimmed}${sep}${relative}` + // Drop a leading `./` (or `.\`) so the joined path doesn't end up as + // `/dir/./foo.ts`. `..` segments stay verbatim — both POSIX and + // Win32 shell.showItemInFolder resolve them at the OS layer. + const cleaned = relative.replace(/^\.[/\\]/, "") + return `${trimmed}${sep}${cleaned}` } function MessageMarkdown(props: { From 0a16f46eae5a9905c64c1c14481b13e6994b0443 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 19:33:28 +0800 Subject: [PATCH 24/30] fix(ui): keep markdown link/image handlers reactive to prop changes 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. --- packages/ui/src/components/markdown.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 5a616bfc3..81c7420f2 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -543,12 +543,12 @@ export function Markdown( })) if (!linkCleanup) { linkCleanup = setupLinkClicks(container, { - openExternal: local.onLinkOpenExternal, - revealPath: local.onLinkRevealPath, + openExternal: (url) => local.onLinkOpenExternal?.(url), + revealPath: (path) => local.onLinkRevealPath?.(path), }) } if (local.onImageClick && !imageCleanup) { - imageCleanup = setupImageClicks(container, local.onImageClick) + imageCleanup = setupImageClicks(container, (src) => local.onImageClick?.(src)) } }) From 7663c257b8a85e123bf44bce4cf9eefc8cc2dae1 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 19:33:41 +0800 Subject: [PATCH 25/30] fix(ui): show markdown copy-button on keyboard focus 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. --- packages/ui/src/components/markdown.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 455162c41..23d11b1d6 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -358,7 +358,8 @@ [data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] { color: var(--icon-base); } - [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] { + [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"], + [data-component="markdown-code"]:focus-within [data-slot="markdown-copy-button"] { opacity: 1; } [data-slot="markdown-copy-button"] [data-slot="check-icon"] { From e6d6faeb440923c4fc56c83bd55914c27475093e Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 19:34:40 +0800 Subject: [PATCH 26/30] fix(ui): wrap task-item label so nested blocks stay below the icon 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
    /

    , 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

    , not directly under the label. Two new tests lock both behaviors (nested ul stays in label wrapper; loose list

    firstChild has no leading space). --- packages/ui/src/components/markdown.css | 4 +++ packages/ui/src/components/markdown.test.ts | 26 ++++++++++++++++ packages/ui/src/components/markdown.tsx | 33 +++++++++++++++++---- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 23d11b1d6..1cddf8386 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -163,6 +163,10 @@ color: var(--fg-strong); margin-top: 2px; } + li.task-item > [data-slot="task-label"] { + flex: 1 1 0; + min-width: 0; + } /* If marked wraps loose-list content in

    , the paragraph just inherits normal margin handling. */ diff --git a/packages/ui/src/components/markdown.test.ts b/packages/ui/src/components/markdown.test.ts index 5a535905e..9da237fc2 100644 --- a/packages/ui/src/components/markdown.test.ts +++ b/packages/ui/src/components/markdown.test.ts @@ -85,6 +85,32 @@ describe("task list svg rendering", () => { expect(li.querySelector("input")).toBeNull() expect(li.querySelector("svg")).not.toBeNull() }) + test("groups label + nested blocks into a single flex sibling of the icon", () => { + document.body.innerHTML = + '

    • parent
      • nested
    ' + const li = document.querySelector("li")! + rewriteTaskListsForTest(document.body) + // li direct children must be exactly [svg, label-wrapper]; otherwise + // nested
      would render to the right of the icon as a flex sibling. + const direct = Array.from(li.children) + expect(direct).toHaveLength(2) + expect(direct[0]!.tagName.toLowerCase()).toBe("svg") + expect(direct[1]!.getAttribute("data-slot")).toBe("task-label") + // Nested ul moved into the label wrapper, not orphaned at li level. + expect(direct[1]!.querySelector("ul")).not.toBeNull() + expect(direct[1]!.textContent).toContain("parent") + expect(direct[1]!.textContent).toContain("nested") + }) + test("strips leading whitespace from loose-list paragraph first text", () => { + document.body.innerHTML = + '
      • loose label

      ' + rewriteTaskListsForTest(document.body) + const label = document.querySelector('[data-slot="task-label"]')! + // Inside loose list, the leading text lives in the first

      's firstChild. + const p = label.querySelector("p")! + expect(p.firstChild?.textContent?.startsWith(" ")).toBe(false) + expect(p.textContent).toBe("loose label") + }) test("sanitize-then-decorate keeps GFM checkbox alive", () => { const cleaned = sanitizeForTest( '

      • task
      ', diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 81c7420f2..48f84150b 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -307,13 +307,34 @@ function rewriteTaskLists(root: ParentNode) { input.replaceWith(svg) // marked may wrap the checkbox in

      for loose lists. Hoist the svg up // so the LI flexbox aligns icon and label in one row. - const wrapper = svg.parentElement - if (wrapper && wrapper !== li && wrapper.tagName === "P") { - li.insertBefore(svg, wrapper) + const pWrap = svg.parentElement + if (pWrap && pWrap !== li && pWrap.tagName === "P") { + li.insertBefore(svg, pWrap) } - const next = svg.nextSibling - if (next && next.nodeType === Node.TEXT_NODE && /^\s+/.test(next.textContent ?? "")) { - next.textContent = (next.textContent ?? "").replace(/^\s+/, "") + // Group everything after the svg into a label wrapper. Without this, + // nested

        or

        blocks in loose task items become flex siblings + // of the icon and render to its right instead of below the label. + const label = document.createElement("span") + label.dataset.slot = "task-label" + let post = svg.nextSibling + while (post) { + const nextPost = post.nextSibling + label.appendChild(post) + post = nextPost + } + li.appendChild(label) + // Strip leading whitespace surrounding the original ` text`. + // After hoist+wrap the leading text sits either directly inside the + // label (tight list) or inside its first element child (loose list). + const stripLeading = (node: Node | null | undefined) => { + if (node?.nodeType === Node.TEXT_NODE && /^\s+/.test(node.textContent ?? "")) { + node.textContent = (node.textContent ?? "").replace(/^\s+/, "") + } + } + const first = label.firstChild + stripLeading(first) + if (first?.nodeType === Node.ELEMENT_NODE) { + stripLeading((first as Element).firstChild) } } } From 970f1a5a31c909d62a05d34c0f9116fb7405d0f6 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 19:40:14 +0800 Subject: [PATCH 27/30] fix(ui): treat Windows drive-letter paths as reveal targets 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 (`:\` or `:/`) 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. --- packages/ui/src/components/markdown.test.ts | 4 ++++ packages/ui/src/components/markdown.tsx | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/ui/src/components/markdown.test.ts b/packages/ui/src/components/markdown.test.ts index 9da237fc2..16c309b0f 100644 --- a/packages/ui/src/components/markdown.test.ts +++ b/packages/ui/src/components/markdown.test.ts @@ -147,6 +147,10 @@ describe("link action routing", () => { test("absolute path → reveal", () => { expect(resolveLinkAction("/Users/u/p/foo.ts")).toEqual({ kind: "reveal", path: "/Users/u/p/foo.ts" }) }) + test("Windows drive-letter absolute path → reveal", () => { + expect(resolveLinkAction("C:\\repo\\file.ts")).toEqual({ kind: "reveal", path: "C:\\repo\\file.ts" }) + expect(resolveLinkAction("D:/code/foo.ts")).toEqual({ kind: "reveal", path: "D:/code/foo.ts" }) + }) test("anchor-only stays default", () => { expect(resolveLinkAction("#section")).toEqual({ kind: "anchor", url: "#section" }) }) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 48f84150b..1497da9e6 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -204,6 +204,10 @@ export function resolveLinkAction(href: string): LinkAction { // Block dangerous schemes outright; sanitize already strips most of these // at the href level, but defense in depth keeps the routing predictable. if (/^(?:javascript|data|vbscript):/i.test(trimmed)) return { kind: "block" } + // Windows absolute paths (`C:\path` / `D:/path`) shape-match a generic + // single-letter scheme followed by `:`. Catch them before the generic + // scheme regex below so they reveal instead of routing to a browser. + if (/^[a-z]:[\\/]/i.test(trimmed)) return { kind: "reveal", path: trimmed } // Any other scheme (https, mailto, vscode, tel, sms, git, ssh, …) routes // to the external handler. Whether the scheme is actually surfaced to // users is governed by the DOMPurify ALLOWED_URI_REGEXP allowlist, not From 9e09307cbdeaca7813d9ab502e876b6d4eb9e7f4 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 19:40:25 +0800 Subject: [PATCH 28/30] fix(ui): only show zoom-in cursor when onImageClick is wired 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. --- packages/ui/src/components/markdown.css | 6 +++++- packages/ui/src/components/markdown.tsx | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 1cddf8386..a6c17b5b5 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -239,7 +239,9 @@ /* §10 Image · 1px frame + radius-md (DESIGN.md「User content frame」) margin matches other block elements (mt:0 / mb:12) so heading-below - gradient (h mb 8 → img) stays at 8, not pushed to 12 by symmetric mt. */ + gradient (h mb 8 → img) stays at 8, not pushed to 12 by symmetric mt. + cursor: zoom-in only when the host wires onImageClick (data-image-click) + so the cursor doesn't promise interactivity that has no handler. */ img { max-width: 100%; height: auto; @@ -247,6 +249,8 @@ border: 1px solid var(--border-weak); border-radius: var(--radius-md); margin: 0 0 var(--space-md); + } + &[data-image-click] img { cursor: zoom-in; } diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 1497da9e6..04b1f7a86 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -586,6 +586,7 @@ export function Markdown( return (

        Date: Sun, 10 May 2026 21:05:27 +0800 Subject: [PATCH 29/30] fix(ui): reject protocol-relative URLs in DOMPurify allowlist 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. --- packages/ui/src/components/markdown.test.ts | 5 +++++ packages/ui/src/components/markdown.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/markdown.test.ts b/packages/ui/src/components/markdown.test.ts index 16c309b0f..94523ffaa 100644 --- a/packages/ui/src/components/markdown.test.ts +++ b/packages/ui/src/components/markdown.test.ts @@ -43,6 +43,11 @@ describe("DOMPurify whitelist config", () => { expect(re.test("data:text/html,foo")).toBe(false) expect(re.test("vbscript:msgbox")).toBe(false) }) + test("URI regex rejects protocol-relative // (defense-in-depth with click router)", () => { + const re = sanitizeConfig.ALLOWED_URI_REGEXP + expect(re.test("//evil.com/x")).toBe(false) + expect(sanitizeForTest('x')).not.toContain("href") + }) }) describe("task list svg rendering", () => { diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 04b1f7a86..3f7e93eca 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -45,7 +45,7 @@ const config = { SANITIZE_NAMED_PROPS: true, FORBID_TAGS: ["script", "iframe", "style", "form", "object", "embed"], FORBID_CONTENTS: ["script", "iframe", "style"], - ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|file):|\/|\.{1,2}\/|#|[^:]*$)/i, + ALLOWED_URI_REGEXP: /^(?!\/\/)(?:(?:https?|mailto|file):|\/|\.{1,2}\/|#|[^:]*$)/i, } export const sanitizeConfig = config From c2f8270edbb2752128964ed38f14da98476cb8fe Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sun, 10 May 2026 21:05:33 +0800 Subject: [PATCH 30/30] docs(ui): align W3 Blockquote story with current left-rule CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/ui/src/components/markdown.stories.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/markdown.stories.tsx b/packages/ui/src/components/markdown.stories.tsx index 88c7da22e..973d823c3 100644 --- a/packages/ui/src/components/markdown.stories.tsx +++ b/packages/ui/src/components/markdown.stories.tsx @@ -13,7 +13,7 @@ W3 lock 2026-05-10 (see docs/design/preview/markdown-body.html · STANDARDS.md#L - **W3.Headings** — H1-H4 G mapping (all 13px sans, hierarchy via fg color + margin) - **W3.LinksInkOnly** — quiet underline, hover currentColor, focus brand ring - **W3.TaskList** — 16px circle / circle-check svg, read-only -- **W3.Blockquote** — cream bg + radius-sm, no left line (avoids BAN 1) +- **W3.Blockquote** — 2px border-weak left rule + fg-weak text (Markdown 行业惯例,非 BAN 1 彩条) - **W3.Table** — \`th data-numeric="true"\` for tabular-nums + right align - **W3.Details** — chev rotates 90deg on open - **W3.Math** — inline + block KaTeX @@ -57,9 +57,9 @@ const fixtures = { "- [ ] 开 PR", ].join("\n"), blockquote: [ - "> 引用语段以 cream 底 + radius-sm 表达, 不加左竖线 (避 BAN 1)。", + "> 引用语段用 2px border-weak 左竖线 + fg-weak 字色, 无背景。", ">", - "> 嵌套也保持单一底色。", + "> 中性灰左线是 Markdown 行业惯例 (GitHub / Tailwind prose / Notion / shadcn), 不属 BAN 1 彩条。", "", "正文继续。", ].join("\n"),