From 56acb870e83260f7824d7cfab7a8c5430a6d823c Mon Sep 17 00:00:00 2001 From: xiaolai Date: Sun, 13 Sep 2026 17:21:57 +0800 Subject: [PATCH 1/5] fix(terminal): commit ASCII text from an orphan compositionend (#1375) On WebKitGTK with fcitx5, compositionstart never fires (the instrumented trace in #948), so every IME commit arrives as an orphan compositionend. WebKit's Editor::setComposition inserts the confirmed text as insertFromComposition with isComposing=true, then dispatches compositionend with no start guard. Neither the gate's input path nor xterm's _inputEvent takes an insertFromComposition insert, so the orphan end is the only carrier. The gate committed an orphan end's e.data only when it was non-ASCII. Chinese commits got through; the raw Latin text Rime's Enter confirms (claude, raw pinyin) was dropped, and the textarea clear left xterm's finalizer nothing to send. An orphan end now commits its e.data even when ASCII, unless xterm's keydown path already wrote that keystroke in the same task (the same write-derived ownership rule input() uses; the claim is read, not spent). The F2 hazard the old rule guarded, a stale textarea, stays excluded because an orphan end never reads the textarea diff. Tests replay the WebKitGTK sequence: raw Latin and pinyin commit, CJK commits once, same word in a later task commits again, a same-task re-fire does not, a keystroke xterm already wrote is not doubled, an expired claim does not block, and a started composition's ASCII result still commits. Mutating the ownership check fails exactly the no-double case. The flicker and misplaced candidate window reported in the same issue are not addressed here. Refs #1375 --- src/components/Terminal/imeGateMachine.ts | 24 ++-- .../Terminal/setupImeCompositionGate.test.ts | 109 ++++++++++++++++-- 2 files changed, 119 insertions(+), 14 deletions(-) diff --git a/src/components/Terminal/imeGateMachine.ts b/src/components/Terminal/imeGateMachine.ts index 614fa22ef..1dc584f7d 100644 --- a/src/components/Terminal/imeGateMachine.ts +++ b/src/components/Terminal/imeGateMachine.ts @@ -15,7 +15,8 @@ * State: * - composing/started/startLen — the composition cycle and its textarea * snapshot. `started` guards an ORPHAN compositionend (fcitx5/rime - * #659/#948) from trusting a stale snapshot. + * #659/#948) from trusting a stale snapshot; the orphan's own e.data is + * still committed, ASCII included (#1375), unless xterm wrote it. * - echoText — the text just committed; an insert restating it within the * same macrotask is the IME echoing the commit, not a fresh keystroke. * The HOST clears it on the next macrotask (`clearEcho`) — timers are an @@ -123,12 +124,21 @@ export function createImeGateMachine(): ImeGateMachine { const textareaDiff = wasStarted ? textareaValue.slice(startLen) : ""; started = false; let text = resolveCommit({ eventData, textareaDiff }); - // A REAL composition result must be committed even when ASCII — T2 - // blocked xterm's keydown path, so nothing else delivers it (D3.1). - // Gated on e.data being NON-EMPTY: empty data is a CANCELLED composition - // (Escape), and the textarea still holds the preedit at that instant — - // falling back to the diff typed the raw pinyin into the shell. - if (!text && wasStarted && eventData) text = eventData; + // A composition result must be committed even when ASCII — T2 blocked + // xterm's keydown path, so nothing else delivers it (D3.1). Gated on + // e.data being NON-EMPTY: empty data is a CANCELLED composition (Escape), + // and the textarea still holds the preedit at that instant — falling back + // to the diff typed the raw pinyin into the shell. + // + // An ORPHAN end qualifies too (#1375). WebKitGTK + fcitx5 never fires + // compositionstart, so every commit arrives as an orphan end — including + // the raw Latin text Rime's Enter confirms — and the insert before it is + // `insertFromComposition`, which neither input() nor xterm takes. With no + // composition behind it, an orphan end answers input()'s ownership + // question: skip it only when xterm's keydown path already wrote this + // keystroke. The claim is read, not spent, so a paired insert that is + // still coming stays dropped too. + if (!text && eventData && (wasStarted || !externalWrote)) text = eventData; // T3: always clear so xterm's setTimeout(0) finalizer reads "". if (text && !isEcho(text)) { return committed(text, { clearTextarea: true, stopEvent: false }); diff --git a/src/components/Terminal/setupImeCompositionGate.test.ts b/src/components/Terminal/setupImeCompositionGate.test.ts index bee87cd0f..16dd6523c 100644 --- a/src/components/Terminal/setupImeCompositionGate.test.ts +++ b/src/components/Terminal/setupImeCompositionGate.test.ts @@ -73,11 +73,11 @@ describe("setupImeCompositionGate — commit decisions", () => { expect(commits).toEqual(["你好"]); }); - it("orphan compositionend (no start) ignores a stale textarea, commits only non-ASCII e.data (F2)", () => { + it("orphan compositionend (no start) ignores a stale textarea, commits only e.data (F2)", () => { const { textarea, commits } = makeHarness(); textarea.value = "stale pasted text"; // never part of a composition - fireComposition(textarea, "compositionend", "?"); // ASCII e.data, no start - expect(commits).toEqual([]); // must NOT commit the stale textarea + fireComposition(textarea, "compositionend", "?"); // e.data, no start + expect(commits).toEqual(["?"]); // the event's own text — never the stale textarea }); it("orphan compositionend commits fresh non-ASCII e.data (fcitx5/rime fresh commit)", () => { @@ -96,11 +96,10 @@ describe("setupImeCompositionGate — commit decisions", () => { expect(commits).toEqual(["abc"]); }); - it("still ignores ASCII from an ORPHAN compositionend (no real composition)", () => { - // The D3.1 fallback is gated on a real compositionstart; an orphan ASCII end - // must NOT commit (would inject stale/garbage — F2). + it("commits nothing for an orphan compositionend with empty data", () => { const { textarea, commits } = makeHarness(); - fireComposition(textarea, "compositionend", "abc"); // no start + textarea.value = "stale"; + fireComposition(textarea, "compositionend", ""); // no start, no data expect(commits).toEqual([]); }); @@ -144,6 +143,102 @@ describe("setupImeCompositionGate — commit decisions", () => { }); }); +// #1375. WebKitGTK + fcitx5 never fires compositionstart (#948's instrumented +// trace), so EVERY commit is an orphan end. WebKit's Editor::setComposition +// inserts the confirmed text as `insertFromComposition` with isComposing=true, +// THEN dispatches compositionend — no start guard. Rime's Enter commits the raw +// Latin preedit through exactly that path; dropping ASCII orphans lost it while +// CJK commits got through, and nothing else carries it: the insert is not +// `insertText`, so neither the gate's input path nor xterm's _inputEvent takes it. +describe("setupImeCompositionGate — WebKitGTK orphan commits (#1375)", () => { + beforeEach(() => { + vi.useFakeTimers(); + document.body.innerHTML = ""; + }); + afterEach(() => vi.useRealTimers()); + + /** The event sequence WebKitGTK produces for one fcitx5 commit. */ + function fireWebKitGtkCommit(ta: HTMLTextAreaElement, text: string) { + ta.value += text; + ta.dispatchEvent( + new InputEvent("input", { + data: text, + inputType: "insertFromComposition", + isComposing: true, + bubbles: true, + }), + ); + fireComposition(ta, "compositionend", text); + } + + it("commits raw Latin text that Rime's Enter confirms", () => { + const { textarea, commits } = makeHarness(); + fireImeKeydown(textarea); // the Enter the IME consumed + fireWebKitGtkCommit(textarea, "claude"); + expect(commits).toEqual(["claude"]); + }); + + it("commits raw pinyin confirmed with Enter", () => { + const { textarea, commits } = makeHarness(); + fireWebKitGtkCommit(textarea, "nihao"); + expect(commits).toEqual(["nihao"]); + }); + + it("commits a CJK commit exactly once through the same sequence (#948)", () => { + const { textarea, commits } = makeHarness(); + fireWebKitGtkCommit(textarea, "你好"); + expect(commits).toEqual(["你好"]); + }); + + it("clears the textarea so xterm's composition finalizer reads nothing", () => { + const { textarea } = makeHarness(); + fireWebKitGtkCommit(textarea, "claude"); + expect(textarea.value).toBe(""); + }); + + it("commits the same word again when it is confirmed in a later task", () => { + const { textarea, commits } = makeHarness(); + fireWebKitGtkCommit(textarea, "ls"); + vi.advanceTimersByTime(1); + fireWebKitGtkCommit(textarea, "ls"); + expect(commits).toEqual(["ls", "ls"]); + }); + + it("does not re-commit an orphan end re-fired in the same task", () => { + const { textarea, commits } = makeHarness(); + fireWebKitGtkCommit(textarea, "claude"); + fireComposition(textarea, "compositionend", "claude"); + expect(commits).toEqual(["claude"]); + }); + + it("does not double a character xterm's keydown path already wrote", () => { + const { textarea, handle, commits } = makeHarness(); + firePlainKeydown(textarea, "a", 65); + handle.noteExternalWrite("a"); // xterm forwarded this keystroke + fireComposition(textarea, "compositionend", "a"); + expect(commits).toEqual([]); + }); + + it("commits once the previous keystroke's write claim has expired", () => { + const { textarea, handle, commits } = makeHarness(); + handle.noteExternalWrite("a"); + vi.advanceTimersByTime(1); // a different task: that write cannot own this commit + fireWebKitGtkCommit(textarea, "claude"); + expect(commits).toEqual(["claude"]); + }); + + it("still lets a real composition commit ASCII while xterm holds a claim", () => { + // A STARTED composition's result is the IME's by construction (D3.1); the + // write claim only arbitrates inserts with no composition behind them. + const { textarea, handle, commits } = makeHarness(); + fireComposition(textarea, "compositionstart"); + handle.noteExternalWrite("a"); + textarea.value = "abc"; + fireComposition(textarea, "compositionend", "abc"); + expect(commits).toEqual(["abc"]); + }); +}); + describe("setupImeCompositionGate — ASCII claimed by an IME keydown (#1176)", () => { beforeEach(() => { vi.useFakeTimers(); From 70939b006194f1163f8505b261da94d4a83f01d3 Mon Sep 17 00:00:00 2001 From: xiaolai Date: Sun, 13 Sep 2026 20:06:43 +0800 Subject: [PATCH 2/5] chore: bump version to 0.9.73 --- package.json | 2 +- server/mcp/package.json | 2 +- server/mcp/src/cli.ts | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 6934fc6d7..8de2015e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vmark", "private": true, - "version": "0.9.72", + "version": "0.9.73", "license": "ISC", "description": "The plain-text workspace where humans and AI collaborate", "type": "module", diff --git a/server/mcp/package.json b/server/mcp/package.json index 6b9fd3c86..c886b5d9e 100644 --- a/server/mcp/package.json +++ b/server/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@vmark/mcp-server", - "version": "0.9.72", + "version": "0.9.73", "description": "MCP server for VMark — the plain-text workspace where humans and AI collaborate", "type": "module", "main": "dist/index.js", diff --git a/server/mcp/src/cli.ts b/server/mcp/src/cli.ts index fc6386f73..71488a5f6 100644 --- a/server/mcp/src/cli.ts +++ b/server/mcp/src/cli.ts @@ -23,7 +23,7 @@ * lockstep with the app is the five-file `sed` in the bump procedure * (`.claude/rules/40-version-bump.md`). Edit it only through that procedure. */ -const VERSION = '0.9.72'; +const VERSION = '0.9.73'; /** * WHY `process.exitCode` AND NOT `process.exit()` ON THESE PATHS. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5270c2e4b..4efc3b107 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6418,7 +6418,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vmark" -version = "0.9.72" +version = "0.9.73" dependencies = [ "base64 0.23.1", "block2 0.6.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4abafe65b..729d89b43 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vmark" -version = "0.9.72" +version = "0.9.73" description = "The plain-text workspace where humans and AI collaborate" authors = ["Xiaolai"] license = "ISC" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 413523ada..1da2dfa3c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "VMark", - "version": "0.9.72", + "version": "0.9.73", "identifier": "app.vmark", "build": { "beforeDevCommand": "pnpm dev", From a40cf9508671fb80d507c672d6e59b1379e1d71d Mon Sep 17 00:00:00 2001 From: xiaolai Date: Sun, 13 Sep 2026 20:14:43 +0800 Subject: [PATCH 3/5] fix(math): widen every .katex-display so the tag reaches the block edge (#1402) A display equation with \tag{1} rendered its number on top of the last term. KaTeX places the tag with position: absolute; right: 0 inside .katex-html, which fills .katex-display. VMark's math previews are flex containers, so .katex-display becomes a flex item and shrink-wraps to the equation; right: 0 then resolves to the equation's own edge. This is #1376 again. That fix set width: 100% under .math-block-preview, a class no renderer produces: every $$ block is a $$math$$ code block drawn by codePreview into .code-block-preview.latex-preview (rendered) or .code-block-live-preview (editing), both flex. Its test pinned the dead selector and stayed green while users still saw the overlap. The rule now lives unscoped in styles/katexFixes.css, which both main.tsx and the export CSS bundle load, so every display-math container gets it. The redundant scoped width in latex.css is removed. Centring is unchanged: KaTeX centres display math with its own text-align: center. displayMathTag.test.ts asserts the class instead of a selector: the unscoped rule exists and is loaded by app and export; the flex containers are derived from the renderers that create them; no stylesheet under src gives .katex-display another width; no rule re-places the tag by hand. Against the old CSS the unscoped-rule assertion failed. Closes #1402 Refs #1376 --- src/plugins/latex/displayMathTag.test.ts | 137 ++++++++++++++++------- src/plugins/latex/latex.css | 8 -- src/styles/katexFixes.css | 17 ++- 3 files changed, 112 insertions(+), 50 deletions(-) diff --git a/src/plugins/latex/displayMathTag.test.ts b/src/plugins/latex/displayMathTag.test.ts index 8cb405da7..b71aede09 100644 --- a/src/plugins/latex/displayMathTag.test.ts +++ b/src/plugins/latex/displayMathTag.test.ts @@ -1,18 +1,14 @@ // @vitest-environment node /** * `\tag{…}` on display math must sit at the right edge of the BLOCK, not of - * the equation (#1376). + * the equation (#1376, #1402). * - * The mechanism, from KaTeX's own stylesheet: - * - * .katex-display > .katex > .katex-html { display: block; position: relative } - * .katex-display > .katex > .katex-html > .katex-tag { position: absolute; right: 0 } - * - * So the tag is placed against the right edge of `.katex-html`, which fills + * The mechanism, from KaTeX's own stylesheet: the tag is placed with + * `position: absolute; right: 0` inside `.katex-html`, which fills * `.katex-display`. Everywhere else that works, because `.katex-display` is a * plain block filling its container. * - * VMark's preview is a FLEX container (`display: flex; justify-content: + * VMark's math previews are FLEX containers (`display: flex; justify-content: * center`), which makes `.katex-display` a flex ITEM — and a flex item with * `width: auto` shrink-wraps to its content. The block then ends where the * equation ends, `right: 0` resolves to the equation's own right edge, and the @@ -20,64 +16,125 @@ * `[\Delta V(9)]`. * * `width: 100%` restores the block's full width. Centring is unaffected — - * KaTeX centres display math itself with `text-align: center`, which is how it - * behaves outside a flex parent; `justify-content` was never what centred it. + * KaTeX centres display math itself with `text-align: center`. + * + * Why this suite asserts a CLASS, not a selector. The #1376 fix scoped the + * rule to `.math-block-preview`, a class no renderer produces: every `$$…$$` + * block is a `$$math$$` code block drawn by codePreview into + * `.code-block-preview.latex-preview` (rendered) or + * `.code-block-live-preview` (editing). The old test pinned the dead selector + * and stayed green while users still saw the overlap (#1402). So the fix is + * now one unscoped rule in the stylesheet both the app and export load, the + * containers are derived from the renderers that create them, and every + * stylesheet is scanned for a rule that would narrow the block again. * * A layout assertion is not available here: jsdom computes no geometry, so a * test that rendered the equation and measured the tag would pass on a broken - * stylesheet. The stylesheet is therefore the subject, in the same shape as + * stylesheet. The stylesheets are therefore the subject, in the same shape as * `src/test/reducedMotionGlobal.test.ts`. * - * @coordinates-with latex.css — the rule under test + * @coordinates-with styles/katexFixes.css — the rule under test + * @coordinates-with plugins/codePreview/code-preview.css — the flex containers * @module plugins/latex/displayMathTag.test */ import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; - -const raw = readFileSync("src/plugins/latex/latex.css", "utf8"); +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; /** * Comments are stripped BEFORE any rule is matched, not after. * * A body matched with `[^}]*` ends at the first `}` in the file, and a CSS - * comment may contain one — the comment on the rule under test cites - * `` `\tag{…}` ``, whose closing brace truncated the body and failed the - * assertion against the very fix that had just been applied. + * comment may contain one — a comment citing `` `\tag{…}` `` truncated the + * body and failed the assertion against the very fix that had just been + * applied. */ -const css = raw.replace(/\/\*[\s\S]*?\*\//g, ""); +function readCss(path: string): string { + return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); +} -/** The body of the first rule whose selector matches. */ -function ruleBody(selector: string): string { +/** The body of the first rule whose ENTIRE selector is `selector`. */ +function ruleBody(css: string, selector: string): string | null { const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const match = new RegExp(`(?:^|\\})\\s*${escaped}\\s*\\{([^}]*)\\}`, "m").exec(css); - expect(match, `no rule for \`${selector}\``).not.toBeNull(); - return match![1]; + const match = new RegExp(`(?:^|[}{;])\\s*${escaped}\\s*\\{([^}]*)\\}`, "m").exec(css); + return match ? match[1] : null; } -describe("display-math \\tag placement (#1376)", () => { - it("the preview centres with flex, which is what shrink-wraps the block", () => { - // Pinned because it is the PREMISE of the fix below, not decoration: if the - // preview ever stops being a flex container, `width: 100%` is no longer - // load-bearing and this whole test should be revisited rather than kept - // passing out of habit. - const preview = ruleBody(".math-block-preview"); - expect(preview).toMatch(/display:\s*flex/); - }); +/** Every stylesheet under `src/`. */ +function allStylesheets(): string[] { + return (readdirSync("src", { recursive: true }) as string[]) + .filter((p) => p.endsWith(".css")) + .map((p) => join("src", p)); +} - it("gives .katex-display the full block width so the tag reaches the edge", () => { - const rule = ruleBody(".math-block-preview .katex-display"); +const KATEX_FIXES = "src/styles/katexFixes.css"; +const CODE_PREVIEW_CSS = "src/plugins/codePreview/code-preview.css"; + +describe("display-math \\tag placement (#1376, #1402)", () => { + it("gives every .katex-display the full block width, unscoped", () => { + const body = ruleBody(readCss(KATEX_FIXES), ".katex-display"); + expect( + body, + "a container-scoped copy is how the #1376 fix landed on a class nothing renders", + ).not.toBeNull(); expect( - rule, + body, "a flex item shrink-wraps without an explicit width, which puts `right: 0` " + - "on the equation's edge instead of the block's — that is #1376", - ).toMatch(/width:\s*100%/); + "on the equation's edge instead of the block's", + ).toMatch(/(?:^|[;\s])width\s*:\s*100%/); + }); + + it("is loaded by both the app and the export bundle", () => { + expect(readFileSync("src/main.tsx", "utf8")).toContain('import "./styles/katexFixes.css"'); + expect(readFileSync("src/export/editorCSSBundle.ts", "utf8")).toContain( + "@/styles/katexFixes.css?raw", + ); + }); + + it("no stylesheet narrows .katex-display again", () => { + const offenders: string[] = []; + for (const path of allStylesheets()) { + for (const [, selector, body] of readCss(path).matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + if (!selector.includes(".katex-display")) continue; + const width = /(?:^|[;\s])width\s*:\s*([^;]+)/.exec(body); + if (width && width[1].trim() !== "100%") { + offenders.push(`${path}: ${selector.trim()} { width: ${width[1].trim()} }`); + } + } + } + expect(offenders).toEqual([]); + }); + + describe("the premise: display math renders inside flex containers", () => { + // Pinned because it is WHY the rule above is load-bearing. The containers + // are taken from the renderers that create them, so a pinned selector can + // never again be one nothing produces. If a container stops being flex, + // revisit the fix rather than keep this passing out of habit. + const codePreviewCss = readCss(CODE_PREVIEW_CSS); + + it("the rendered preview is a flex .code-block-preview.latex-preview", () => { + const renderer = readFileSync("src/plugins/codePreview/renderers/renderLatex.ts", "utf8"); + expect(renderer).toContain('"code-block-preview latex-preview"'); + expect(ruleBody(codePreviewCss, ".code-block-preview.latex-preview")).toMatch( + /display:\s*flex/, + ); + }); + + it("the editing preview is a flex .code-block-live-preview", () => { + const helpers = readFileSync("src/plugins/codePreview/previewHelpers.ts", "utf8"); + expect(helpers).toContain("code-block-live-preview ${"); + expect(ruleBody(codePreviewCss, ".code-block-live-preview")).toMatch(/display:\s*flex/); + }); }); it("does not try to fix it by overriding KaTeX's tag positioning", () => { - // The tempting alternative is to re-place `.katex-tag` by hand. That fights + // The tempting alternative is to re-place the tag by hand. That fights // KaTeX's own layout, breaks `leqno` (which flips the tag to the left), and // has to be re-tuned whenever KaTeX changes. Widening the block leaves // KaTeX's rule doing exactly what it was written to do. - expect(css).not.toMatch(/\.katex-tag\s*\{/); + const offenders = allStylesheets().filter((path) => + /\.katex-tag\b|\.katex-html\s*>\s*\.tag\b|\.katex-display[^{]*\.tag\b/.test(readCss(path)), + ); + expect(offenders).toEqual([]); }); }); diff --git a/src/plugins/latex/latex.css b/src/plugins/latex/latex.css index ce4b3534c..1ba8d0d96 100644 --- a/src/plugins/latex/latex.css +++ b/src/plugins/latex/latex.css @@ -153,14 +153,6 @@ .math-block-preview .katex-display { margin: 0; - /* #1376. KaTeX places the tag from `\tag` with `position: absolute; right: 0` - inside `.katex-html`, which fills `.katex-display`. The preview above is a - flex container, so `.katex-display` is a flex ITEM and shrink-wraps to the - equation without an explicit width — `right: 0` then resolves to the - equation's own right edge and the tag lands on the last term. - Centring is unaffected: KaTeX centres display math with its own - `text-align: center`, which is what centres it outside a flex parent. */ - width: 100%; } .math-block-placeholder { diff --git a/src/styles/katexFixes.css b/src/styles/katexFixes.css index 970d3d22c..3b7e922e1 100644 --- a/src/styles/katexFixes.css +++ b/src/styles/katexFixes.css @@ -1,7 +1,8 @@ /** - * KaTeX Fixes for Tailwind v4 + * KaTeX Fixes * - * Must be imported immediately after katex.min.css. + * Must be imported immediately after katex.min.css. Loaded by the app + * (main.tsx) and by the export CSS bundle, so a fix here reaches both. * * Tailwind v4's preflight sets `* { border: 0 solid }` which breaks * KaTeX fraction lines and other elements that expect browser defaults. @@ -24,3 +25,15 @@ .katex .hline { border-bottom-width: var(--border-thin); } + +/* `\tag{…}` must sit at the right edge of the BLOCK (#1376, #1402). KaTeX + places the tag with `position: absolute; right: 0` inside `.katex-html`, + which fills `.katex-display`. VMark's math previews are flex containers, so + `.katex-display` becomes a flex item and shrink-wraps to the equation — + `right: 0` then lands on the last term. Full width restores the block; + centring is KaTeX's own `text-align: center`. Unscoped on purpose: a + container-scoped copy is how the first fix landed on a class nothing + renders (displayMathTag.test.ts). */ +.katex-display { + width: 100%; +} From 5fcd1e0f712d12d7ecc9cbdb32129e7f2322a0de Mon Sep 17 00:00:00 2001 From: xiaolai Date: Sun, 13 Sep 2026 20:29:31 +0800 Subject: [PATCH 4/5] test(math): measure the display-math tag position in real WebKit (#1402) The node-tier displayMathTag.test.ts can only read stylesheets, because jsdom computes no layout. That is how #1376 shipped as fixed while users still saw the overlap: its rule and its test both named a class nothing renders. This real-WebKit test loads the stylesheets the app loads (katex.min.css, katexFixes.css, code-preview.css), renders through the preview's own renderLatex and sanitizeKatex, and measures inside both containers codePreview creates (.code-block-preview.latex-preview and .code-block-live-preview): the tag's right edge meets the container's content edge and its left edge clears the equation. The failure is constructed, not assumed: one case forces .katex-display back to width: auto and asserts the probe sees the overlap, so a measurement that always reported "at the edge" cannot make the other cases pass vacuously. KaTeX 0.18 names the elements .katex-tag and .katex-base; the first run failed loudly on the older .tag / .base selectors before measuring anything. Refs #1402 Refs #1376 --- .../latex/displayMathTag.webkit.test.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/plugins/latex/displayMathTag.webkit.test.ts diff --git a/src/plugins/latex/displayMathTag.webkit.test.ts b/src/plugins/latex/displayMathTag.webkit.test.ts new file mode 100644 index 000000000..74cccfec7 --- /dev/null +++ b/src/plugins/latex/displayMathTag.webkit.test.ts @@ -0,0 +1,91 @@ +/** + * Real-WebKit tier — a display equation's `\tag` sits at the right edge of + * the BLOCK, not on the last term (#1376, #1402). + * + * jsdom computes no layout, so the node-tier `displayMathTag.test.ts` can only + * read stylesheets. That is how #1376 shipped as fixed while users still saw + * the overlap: its rule and its test both named a class nothing renders. This + * file measures the real thing — the stylesheets the app loads, the renderer + * and sanitizer the preview uses, inside the containers codePreview creates. + * + * **The failure is constructed, not assumed.** The first case forces + * `.katex-display` back to `width: auto` and asserts the probe SEES the + * overlap. Without it, a measurement that always reported "at the edge" would + * make every other assertion vacuously true. + */ +import "katex/dist/katex.min.css"; +import "@/styles/katexFixes.css"; +import "@/plugins/codePreview/code-preview.css"; +import { describe, it, expect, afterEach } from "vitest"; +import { renderLatex } from "@/plugins/latex"; +import { sanitizeKatex } from "@/utils/sanitize"; + +/** Short enough that a shrink-wrapped block leaves the tag on the equation. */ +const EQUATION = "\\sigma_{\\mathrm{c}}=f_{\\mathrm{c}}\\tag{4}"; +const CONTAINER_WIDTH = 600; +/** Sub-pixel rounding allowance for edge comparisons. */ +const EDGE_TOLERANCE = 2; + +interface Geometry { + /** Right edge of the container's content box. */ + contentRight: number; + /** Right edge of the tag. */ + tagRight: number; + /** Left edge of the tag. */ + tagLeft: number; + /** Right edge of the equation body (the last `.katex-base`). */ + equationRight: number; +} + +async function renderInto(className: string, narrowBlock = false): Promise { + const container = document.createElement("div"); + container.className = className; + container.style.width = `${CONTAINER_WIDTH}px`; + container.innerHTML = sanitizeKatex(await renderLatex(EQUATION)); + document.body.appendChild(container); + + const display = container.querySelector(".katex-display"); + expect(display, "renderLatex must produce display-mode output").not.toBeNull(); + if (narrowBlock) display!.style.width = "auto"; + + // KaTeX 0.18 class names: the tag is `.katex-tag`, each equation run a + // `.katex-base` (older releases used bare `.tag` / `.base`). + const tag = container.querySelector(".katex-html > .katex-tag"); + expect(tag, "KaTeX must render the \\tag element").not.toBeNull(); + const bases = container.querySelectorAll(".katex-html > .katex-base"); + expect(bases.length).toBeGreaterThan(0); + + const box = container.getBoundingClientRect(); + const style = getComputedStyle(container); + const tagBox = tag!.getBoundingClientRect(); + return { + contentRight: box.right - parseFloat(style.paddingRight) - parseFloat(style.borderRightWidth), + tagRight: tagBox.right, + tagLeft: tagBox.left, + equationRight: bases[bases.length - 1].getBoundingClientRect().right, + }; +} + +describe("display-math \\tag placement in real WebKit", () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it("the probe sees the overlap when the block shrink-wraps (constructed failure)", async () => { + const g = await renderInto("code-block-preview latex-preview", true); + // The tag starts before the equation ends — it is drawn on top of it. + expect(g.tagLeft).toBeLessThan(g.equationRight); + expect(g.contentRight - g.tagRight).toBeGreaterThan(EDGE_TOLERANCE); + }); + + for (const [label, className] of [ + ["the rendered preview", "code-block-preview latex-preview"], + ["the editing preview", "code-block-live-preview latex-live-preview"], + ] as const) { + it(`${label}: the tag reaches the block edge and clears the equation`, async () => { + const g = await renderInto(className); + expect(Math.abs(g.contentRight - g.tagRight)).toBeLessThanOrEqual(EDGE_TOLERANCE); + expect(g.tagLeft).toBeGreaterThanOrEqual(g.equationRight); + }); + } +}); From 82843910c831dbe2434eabf67dac795411a7f57a Mon Sep 17 00:00:00 2001 From: xiaolai Date: Sun, 13 Sep 2026 20:38:10 +0800 Subject: [PATCH 5/5] test(math): move the WebKit tag test into codePreview (plugin isolation) CI's lint:deps rejected the test at src/plugins/latex/: importing plugins/codePreview/code-preview.css from the latex plugin is a cross-plugin edge under the plugin-isolation rule. The test measures codePreview's containers, and codePreview is the fence-preview hub the rule licenses to import the latex plugin, so it now lives beside code-preview.css and imports it relatively. Content is unchanged apart from the header, which records why it lives there. The test was committed after the local check:predelta run, and only a hand-picked subset of gates saw it; lint:deps was not in that subset. Refs #1402 --- .../displayMathTag.webkit.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) rename src/plugins/{latex => codePreview}/displayMathTag.webkit.test.ts (82%) diff --git a/src/plugins/latex/displayMathTag.webkit.test.ts b/src/plugins/codePreview/displayMathTag.webkit.test.ts similarity index 82% rename from src/plugins/latex/displayMathTag.webkit.test.ts rename to src/plugins/codePreview/displayMathTag.webkit.test.ts index 74cccfec7..a8c8c4f40 100644 --- a/src/plugins/latex/displayMathTag.webkit.test.ts +++ b/src/plugins/codePreview/displayMathTag.webkit.test.ts @@ -2,11 +2,17 @@ * Real-WebKit tier — a display equation's `\tag` sits at the right edge of * the BLOCK, not on the last term (#1376, #1402). * - * jsdom computes no layout, so the node-tier `displayMathTag.test.ts` can only - * read stylesheets. That is how #1376 shipped as fixed while users still saw - * the overlap: its rule and its test both named a class nothing renders. This - * file measures the real thing — the stylesheets the app loads, the renderer - * and sanitizer the preview uses, inside the containers codePreview creates. + * jsdom computes no layout, so the node-tier + * `plugins/latex/displayMathTag.test.ts` can only read stylesheets. That is + * how #1376 shipped as fixed while users still saw the overlap: its rule and + * its test both named a class nothing renders. This file measures the real + * thing — the stylesheets the app loads, the renderer and sanitizer the + * preview uses, inside the containers codePreview creates. + * + * It lives in codePreview, not latex: the containers it measures are + * codePreview's, and codePreview is the fence-preview hub that the + * `plugin-isolation` dependency rule licenses to import the latex plugin. From + * `plugins/latex/` the same imports are a cross-plugin violation. * * **The failure is constructed, not assumed.** The first case forces * `.katex-display` back to `width: auto` and asserts the probe SEES the @@ -15,7 +21,7 @@ */ import "katex/dist/katex.min.css"; import "@/styles/katexFixes.css"; -import "@/plugins/codePreview/code-preview.css"; +import "./code-preview.css"; import { describe, it, expect, afterEach } from "vitest"; import { renderLatex } from "@/plugins/latex"; import { sanitizeKatex } from "@/utils/sanitize";