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", 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(); diff --git a/src/plugins/codePreview/displayMathTag.webkit.test.ts b/src/plugins/codePreview/displayMathTag.webkit.test.ts new file mode 100644 index 000000000..a8c8c4f40 --- /dev/null +++ b/src/plugins/codePreview/displayMathTag.webkit.test.ts @@ -0,0 +1,97 @@ +/** + * 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 + * `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 + * 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 "./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); + }); + } +}); 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%; +}