From 3dc6d867a1843198e5374dcaf3a1174035008d93 Mon Sep 17 00:00:00 2001 From: MuRong Date: Wed, 29 Jul 2026 11:35:40 +0800 Subject: [PATCH 1/2] fix(editor): improve dark theme contrast Closes #601 --- .../components/MarkdownSourceEditor.test.tsx | 27 +++++++ .../src/components/MarkdownSourceEditor.tsx | 4 +- packages/app/src/styles.css | 30 +++++++- packages/app/src/styles.test.ts | 75 +++++++++++++++---- 4 files changed, 118 insertions(+), 18 deletions(-) diff --git a/packages/app/src/components/MarkdownSourceEditor.test.tsx b/packages/app/src/components/MarkdownSourceEditor.test.tsx index e3130f22..a7c30066 100644 --- a/packages/app/src/components/MarkdownSourceEditor.test.tsx +++ b/packages/app/src/components/MarkdownSourceEditor.test.tsx @@ -83,6 +83,33 @@ describe("MarkdownSourceEditor", () => { expect(handleChange).toHaveBeenCalledWith("# Changed"); }); + it("keeps the theme-aware drawn selection above CodeMirror's light fallback", () => { + render( + {}} + /> + ); + + const themeStyles = Array.from( + document.head.querySelectorAll("style"), + (style) => style.textContent ?? "", + ).filter( + (styles) => + styles.includes(".cm-selectionBackground") && + styles.includes("var(--accent)"), + ); + + expect(themeStyles.length).toBeGreaterThan(0); + expect( + themeStyles.some((styles) => + styles.includes( + "background-color: color-mix(in srgb, var(--accent) 22%, transparent) !important;", + ), + ), + ).toBe(true); + }); + it("keeps source scrolling vertical without pane-level horizontal scroll", () => { const { container } = render( { } }); - it("keeps CodeMirror's drawn selection background visible", () => { + it("keeps CodeMirror's preview selection theme-aware and readable", () => { const styles = readFileSync(`${process.cwd()}/src/styles.css`, "utf8"); + const selectionStart = styles.indexOf( + ".markdown-paper .cm-selectionBackground {", + ); + const selectionEnd = styles.indexOf("\n }", selectionStart); + const selectionRule = styles.slice(selectionStart, selectionEnd); - expect(styles).not.toContain( - ".markdown-paper .cm-selectionBackground {\n background: transparent !important;", + expect(selectionStart).toBeGreaterThanOrEqual(0); + expect(selectionRule).toContain( + "background: color-mix(in srgb, var(--accent) 20%, transparent) !important;", ); }); @@ -912,28 +918,67 @@ describe("editor stylesheet", () => { expect(iconStyles).toContain("pointer-events: none"); }); - it("uses one quieter theme color for Markdown syntax characters", () => { + it("keeps visual Markdown markers quiet while source markers stay readable in dark themes", () => { const styles = readFileSync(`${process.cwd()}/src/styles.css`, "utf8"); - const syntaxColorStart = styles.indexOf( + const visualSyntaxColorStart = styles.indexOf( "--editor-markdown-syntax-color:", ); - const syntaxRuleStart = styles.indexOf( - ".markdown-paper .cm-markra-syntax-character,", + const sourceSyntaxColorStart = styles.indexOf( + "--source-markdown-syntax-color:", + ); + const darkSourceSyntaxStart = styles.indexOf( + '[data-theme="dark"] .markdown-source-paper,', + ); + const darkSourceSyntaxEnd = styles.indexOf("\n }", darkSourceSyntaxStart); + const darkSourceSyntax = styles.slice( + darkSourceSyntaxStart, + darkSourceSyntaxEnd, + ); + const visualSyntaxRuleStart = styles.indexOf( + ".markdown-paper .cm-markra-syntax-character {", + ); + const visualSyntaxRuleEnd = styles.indexOf( + "\n }", + visualSyntaxRuleStart, + ); + const visualSyntaxRule = styles.slice( + visualSyntaxRuleStart, + visualSyntaxRuleEnd, + ); + const sourceSyntaxRuleStart = styles.indexOf( + ".markdown-source-paper .cm-markra-syntax-character {", + ); + const sourceSyntaxRuleEnd = styles.indexOf( + "\n }", + sourceSyntaxRuleStart, + ); + const sourceSyntaxRule = styles.slice( + sourceSyntaxRuleStart, + sourceSyntaxRuleEnd, ); - const syntaxRuleEnd = styles.indexOf("\n }", syntaxRuleStart); - const syntaxRule = styles.slice(syntaxRuleStart, syntaxRuleEnd); - expect(syntaxColorStart).toBeGreaterThanOrEqual(0); - expect(styles.slice(syntaxColorStart, syntaxColorStart + 180)).toContain( + expect(visualSyntaxColorStart).toBeGreaterThanOrEqual(0); + expect( + styles.slice(visualSyntaxColorStart, visualSyntaxColorStart + 180), + ).toContain( "color-mix(in srgb, var(--text-md-char) 72%, var(--editor-paper-bg, var(--bg-primary)))", ); - expect(syntaxRuleStart).toBeGreaterThanOrEqual(0); - expect(syntaxRule).toContain( - ".markdown-source-paper .cm-markra-syntax-character", + expect(sourceSyntaxColorStart).toBeGreaterThanOrEqual(0); + expect( + styles.slice(sourceSyntaxColorStart, sourceSyntaxColorStart + 120), + ).toContain("var(--editor-markdown-syntax-color)"); + expect(darkSourceSyntaxStart).toBeGreaterThanOrEqual(0); + expect(darkSourceSyntax).toContain( + "color-mix(in srgb, var(--text-md-char) 30%, var(--text-primary))", ); - expect(syntaxRule).toContain( + expect(visualSyntaxRuleStart).toBeGreaterThanOrEqual(0); + expect(visualSyntaxRule).toContain( "color: var(--editor-markdown-syntax-color) !important", ); + expect(sourceSyntaxRuleStart).toBeGreaterThanOrEqual(0); + expect(sourceSyntaxRule).toContain( + "color: var(--source-markdown-syntax-color) !important", + ); expect(styles).toContain( ".markdown-paper .markra-md-delimiter {\n @apply text-(--editor-markdown-syntax-color);", ); From d39ec9c3d16ab4d293a0579057a247aa8ac6fa15 Mon Sep 17 00:00:00 2001 From: MuRong Date: Wed, 29 Jul 2026 12:07:29 +0800 Subject: [PATCH 2/2] fix(editor): cover remaining dark contrast gaps --- .../src/components/JsonCodeEditor.test.tsx | 45 +++++++++++++ .../app/src/components/JsonCodeEditor.tsx | 16 +++++ .../components/MarkdownSourceEditor.test.tsx | 8 +++ .../src/components/MarkdownSourceEditor.tsx | 2 + packages/app/src/styles.css | 35 +++++++--- packages/app/src/styles.test.ts | 67 ++++++++++++++++--- packages/editor/src/codemirror/highlight.ts | 26 +++++++ packages/editor/src/codemirror/index.ts | 4 ++ 8 files changed, 182 insertions(+), 21 deletions(-) create mode 100644 packages/app/src/components/JsonCodeEditor.test.tsx diff --git a/packages/app/src/components/JsonCodeEditor.test.tsx b/packages/app/src/components/JsonCodeEditor.test.tsx new file mode 100644 index 00000000..1f712d69 --- /dev/null +++ b/packages/app/src/components/JsonCodeEditor.test.tsx @@ -0,0 +1,45 @@ +import { render, waitFor } from "@testing-library/react"; +import { JsonCodeEditor } from "./JsonCodeEditor"; + +describe("JsonCodeEditor", () => { + it("uses theme-aware token and selection colors", async () => { + const { container } = render( + {}} + value={'{"endpoint":"https://example.test","enabled":true,"retries":2,"optional":null}'} + /> + ); + + await waitFor(() => { + expect(container.querySelector(".cm-markra-json-property")).toBeInTheDocument(); + }); + + const tokenText = (selector: string) => + Array.from( + container.querySelectorAll(selector), + (element) => element.textContent ?? "", + ).join(""); + + expect(tokenText(".cm-markra-json-property")).toContain('"endpoint"'); + expect(tokenText(".cm-markra-json-string")).toContain("https://example.test"); + expect(tokenText(".cm-markra-json-literal")).toContain("true"); + expect(tokenText(".cm-markra-json-literal")).toContain("2"); + expect(tokenText(".cm-markra-json-literal")).toContain("null"); + + const themeStyles = Array.from( + document.head.querySelectorAll("style"), + (style) => style.textContent ?? "", + ).filter((styles) => styles.includes(".cm-markra-json-property")); + + expect(themeStyles.some((styles) => styles.includes("var(--text-heading)"))).toBe(true); + expect(themeStyles.some((styles) => styles.includes("var(--text-primary)"))).toBe(true); + expect( + themeStyles.some((styles) => + styles.includes( + "background-color: color-mix(in srgb, var(--accent) 22%, transparent) !important;", + ), + ), + ).toBe(true); + }); +}); diff --git a/packages/app/src/components/JsonCodeEditor.tsx b/packages/app/src/components/JsonCodeEditor.tsx index 1edacc38..25650f7a 100644 --- a/packages/app/src/components/JsonCodeEditor.tsx +++ b/packages/app/src/components/JsonCodeEditor.tsx @@ -4,6 +4,7 @@ import { linter, lintGutter } from "@codemirror/lint"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { basicSetup } from "codemirror"; +import { jsonSyntaxHighlighting } from "@markra/editor/codemirror"; export function JsonCodeEditor({ label, @@ -29,6 +30,7 @@ export function JsonCodeEditor({ basicSetup, lintGutter(), json(), + jsonSyntaxHighlighting, linter(jsonParseLinter()), EditorView.lineWrapping, EditorView.contentAttributes.of({ @@ -75,11 +77,25 @@ export function JsonCodeEditor({ ".cm-line": { padding: "0" }, + ".cm-markra-json-literal": { + color: "color-mix(in srgb, var(--link-color) 55%, var(--text-heading)) !important" + }, + ".cm-markra-json-property": { + color: "var(--text-heading) !important" + }, + ".cm-markra-json-string": { + color: "var(--text-primary) !important" + }, ".cm-scroller": { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', lineHeight: "1.25rem" }, + ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { + // CodeMirror's focused light fallback is more specific than the + // app theme and otherwise obscures JSON tokens in dark mode. + backgroundColor: "color-mix(in srgb, var(--accent) 22%, transparent) !important" + }, ".cm-tooltip": { backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-default)", diff --git a/packages/app/src/components/MarkdownSourceEditor.test.tsx b/packages/app/src/components/MarkdownSourceEditor.test.tsx index a7c30066..a1400859 100644 --- a/packages/app/src/components/MarkdownSourceEditor.test.tsx +++ b/packages/app/src/components/MarkdownSourceEditor.test.tsx @@ -51,6 +51,7 @@ describe("MarkdownSourceEditor", () => { "const answer = 42;", "```", "- Item", + '[link](https://example.test "synthetic title")', "==highlight==" ].join("\n"); const handleChange = vi.fn(); @@ -77,6 +78,13 @@ describe("MarkdownSourceEditor", () => { ).join(""); expect(syntaxCharacters).toContain("#"); expect(syntaxCharacters).toContain("===="); + const sourceMetadata = Array.from( + container.querySelectorAll(".cm-markra-source-metadata"), + (element) => element.textContent ?? "", + ).join(""); + expect(sourceMetadata).toContain("ts"); + expect(sourceMetadata).toContain("https://example.test"); + expect(sourceMetadata).toContain("synthetic title"); replaceCodeMirrorDoc(view, "# Changed"); diff --git a/packages/app/src/components/MarkdownSourceEditor.tsx b/packages/app/src/components/MarkdownSourceEditor.tsx index bda26c6d..a7afcff8 100644 --- a/packages/app/src/components/MarkdownSourceEditor.tsx +++ b/packages/app/src/components/MarkdownSourceEditor.tsx @@ -6,6 +6,7 @@ import { minimalSetup } from "codemirror"; import { t, type AppLanguage, type SearchRange } from "@markra/shared"; import { codeMirrorTypewriterMode, + markdownSourceSyntaxHighlighting, markdownSyntaxHighlighting, markraHighlight, reconfigureCodeMirrorVimMode @@ -295,6 +296,7 @@ export function MarkdownSourceEditor({ extensions: [markraHighlight] }), markdownSyntaxHighlighting, + markdownSourceSyntaxHighlighting, EditorView.lineWrapping, contentAttributesCompartmentRef.current.of(markdownSourceContentAttributes(sourceLabel, readOnly)), editableCompartmentRef.current.of(EditorView.editable.of(!readOnly)), diff --git a/packages/app/src/styles.css b/packages/app/src/styles.css index 13f6873e..ffe16324 100644 --- a/packages/app/src/styles.css +++ b/packages/app/src/styles.css @@ -235,7 +235,7 @@ --text-primary: #d4d4d4; --text-heading: #e0e0e0; - --text-secondary: #808080; + --text-secondary: #858585; --text-md-char: #555555; --border-default: #333333; @@ -326,7 +326,7 @@ --text-primary: #abb2bf; --text-heading: #e6edf3; - --text-secondary: #5c6370; + --text-secondary: #8f96a3; --text-md-char: #4b5263; --border-default: #3b4048; @@ -366,7 +366,7 @@ --text-primary: #abb2bf; --text-heading: #e6edf3; - --text-secondary: #7f848e; + --text-secondary: #8f96a3; --text-md-char: #5c6370; --border-default: #3e4451; @@ -825,8 +825,19 @@ --source-markdown-syntax-color: var(--editor-markdown-syntax-color); } - /* Source markers carry document structure, so dark themes keep them - readable while visual-editor markers remain intentionally quiet. */ + /* Revealed Markdown markers are editable content. Keep them at the + theme's primary text contrast instead of reusing muted chrome tokens. */ + .markdown-paper[data-editor-theme="dark"], + .markdown-paper[data-editor-theme="github-dark"], + .markdown-paper[data-editor-theme="night"], + .markdown-paper[data-editor-theme="one-dark"], + .markdown-paper[data-editor-theme="one-dark-pro"], + .markdown-paper[data-editor-theme="solarized-dark"], + .markdown-paper[data-editor-theme="nord"], + .markdown-paper[data-editor-theme="catppuccin-mocha"] { + --editor-markdown-syntax-color: var(--editor-text-primary); + } + [data-theme="dark"] .markdown-source-paper, [data-theme="github-dark"] .markdown-source-paper, [data-theme="night"] .markdown-source-paper, @@ -835,7 +846,7 @@ [data-theme="solarized-dark"] .markdown-source-paper, [data-theme="nord"] .markdown-source-paper, [data-theme="catppuccin-mocha"] .markdown-source-paper { - --source-markdown-syntax-color: color-mix(in srgb, var(--text-md-char) 30%, var(--text-primary)); + --source-markdown-syntax-color: var(--text-primary); } [data-theme="dark"] .markdown-paper, @@ -885,6 +896,10 @@ color: var(--source-markdown-syntax-color) !important; } + .markdown-source-paper .cm-markra-source-metadata { + color: var(--text-primary) !important; + } + .markdown-paper .cm-cursor, .markdown-paper .cm-dropCursor { border-left-color: var(--editor-caret-color) !important; @@ -2044,7 +2059,7 @@ --editor-paper-bg: #282c34; --editor-text-primary: #abb2bf; --editor-text-heading: #e6edf3; - --editor-text-secondary: #5c6370; + --editor-text-secondary: #8f96a3; --editor-border: #3b4048; --editor-border-strong: #4b5263; --editor-bg-secondary: #21252b; @@ -2094,7 +2109,7 @@ --editor-paper-bg: #282c34; --editor-text-primary: #abb2bf; --editor-text-heading: #e6edf3; - --editor-text-secondary: #7f848e; + --editor-text-secondary: #8f96a3; --editor-border: #3e4451; --editor-border-strong: #4b5263; --editor-bg-secondary: #21252b; @@ -4472,13 +4487,13 @@ .markdown-paper::selection, .markdown-paper *::selection { - background: color-mix(in srgb, var(--accent) 20%, transparent); + background: color-mix(in srgb, var(--editor-caret-color, var(--accent)) 20%, transparent); } /* CodeMirror draws selections in a separate layer, so its focused light fallback must not override the theme-aware native selection color. */ .markdown-paper .cm-selectionBackground { - background: color-mix(in srgb, var(--accent) 20%, transparent) !important; + background: color-mix(in srgb, var(--editor-caret-color, var(--accent)) 20%, transparent) !important; } .editor-content-slot[data-document-search-open="true"] .markdown-paper, diff --git a/packages/app/src/styles.test.ts b/packages/app/src/styles.test.ts index 9d7ea7db..2fa6824b 100644 --- a/packages/app/src/styles.test.ts +++ b/packages/app/src/styles.test.ts @@ -106,7 +106,7 @@ describe("editor stylesheet", () => { expect(selectionStart).toBeGreaterThanOrEqual(0); expect(selectionRule).toContain( - "background: color-mix(in srgb, var(--accent) 20%, transparent) !important;", + "background: color-mix(in srgb, var(--editor-caret-color, var(--accent)) 20%, transparent) !important;", ); }); @@ -918,7 +918,7 @@ describe("editor stylesheet", () => { expect(iconStyles).toContain("pointer-events: none"); }); - it("keeps visual Markdown markers quiet while source markers stay readable in dark themes", () => { + it("keeps Markdown markers readable in dark themes", () => { const styles = readFileSync(`${process.cwd()}/src/styles.css`, "utf8"); const visualSyntaxColorStart = styles.indexOf( "--editor-markdown-syntax-color:", @@ -926,13 +926,13 @@ describe("editor stylesheet", () => { const sourceSyntaxColorStart = styles.indexOf( "--source-markdown-syntax-color:", ); - const darkSourceSyntaxStart = styles.indexOf( - '[data-theme="dark"] .markdown-source-paper,', + const darkSyntaxStart = styles.indexOf( + '.markdown-paper[data-editor-theme="dark"],', ); - const darkSourceSyntaxEnd = styles.indexOf("\n }", darkSourceSyntaxStart); - const darkSourceSyntax = styles.slice( - darkSourceSyntaxStart, - darkSourceSyntaxEnd, + const darkSyntaxEnd = styles.indexOf("\n }", darkSyntaxStart); + const darkSyntax = styles.slice( + darkSyntaxStart, + darkSyntaxEnd, ); const visualSyntaxRuleStart = styles.indexOf( ".markdown-paper .cm-markra-syntax-character {", @@ -967,9 +967,17 @@ describe("editor stylesheet", () => { expect( styles.slice(sourceSyntaxColorStart, sourceSyntaxColorStart + 120), ).toContain("var(--editor-markdown-syntax-color)"); - expect(darkSourceSyntaxStart).toBeGreaterThanOrEqual(0); - expect(darkSourceSyntax).toContain( - "color-mix(in srgb, var(--text-md-char) 30%, var(--text-primary))", + expect(darkSyntaxStart).toBeGreaterThanOrEqual(0); + expect(darkSyntax).toContain( + "--editor-markdown-syntax-color: var(--editor-text-primary)", + ); + expect(darkSyntax).not.toContain(".markdown-source-paper"); + const darkSourceSyntaxStart = styles.indexOf( + '[data-theme="dark"] .markdown-source-paper,', + ); + const darkSourceSyntaxEnd = styles.indexOf("\n }", darkSourceSyntaxStart); + expect(styles.slice(darkSourceSyntaxStart, darkSourceSyntaxEnd)).toContain( + "--source-markdown-syntax-color: var(--text-primary)", ); expect(visualSyntaxRuleStart).toBeGreaterThanOrEqual(0); expect(visualSyntaxRule).toContain( @@ -984,6 +992,43 @@ describe("editor stylesheet", () => { ); }); + it("keeps secondary text readable in the low-contrast dark themes", () => { + const styles = readFileSync(`${process.cwd()}/src/styles.css`, "utf8"); + const sharedDarkStart = styles.indexOf('[data-theme="dark"],'); + const sharedDarkEnd = styles.indexOf("\n }", sharedDarkStart); + const oneDarkStart = styles.indexOf('[data-theme="one-dark"] {'); + const oneDarkEnd = styles.indexOf("\n }", oneDarkStart); + const oneDarkProStart = styles.indexOf('[data-theme="one-dark-pro"] {'); + const oneDarkProEnd = styles.indexOf("\n }", oneDarkProStart); + const oneDarkEditorStart = styles.indexOf( + ':root:not([data-theme="dark"]) .markdown-paper[data-editor-theme="one-dark"] {', + ); + const oneDarkEditorEnd = styles.indexOf("\n }", oneDarkEditorStart); + const oneDarkProEditorStart = styles.indexOf( + ':root:not([data-theme="dark"]) .markdown-paper[data-editor-theme="one-dark-pro"] {', + ); + const oneDarkProEditorEnd = styles.indexOf( + "\n }", + oneDarkProEditorStart, + ); + + expect(styles.slice(sharedDarkStart, sharedDarkEnd)).toContain( + "--text-secondary: #858585;", + ); + expect(styles.slice(oneDarkStart, oneDarkEnd)).toContain( + "--text-secondary: #8f96a3;", + ); + expect(styles.slice(oneDarkProStart, oneDarkProEnd)).toContain( + "--text-secondary: #8f96a3;", + ); + expect(styles.slice(oneDarkEditorStart, oneDarkEditorEnd)).toContain( + "--editor-text-secondary: #8f96a3;", + ); + expect( + styles.slice(oneDarkProEditorStart, oneDarkProEditorEnd), + ).toContain("--editor-text-secondary: #8f96a3;"); + }); + it("removes CodeMirror's inline baseline around standalone image editors", () => { const styles = readFileSync(`${process.cwd()}/src/styles.css`, "utf8"); const imageLineStart = styles.indexOf( diff --git a/packages/editor/src/codemirror/highlight.ts b/packages/editor/src/codemirror/highlight.ts index 2255776e..336f017a 100644 --- a/packages/editor/src/codemirror/highlight.ts +++ b/packages/editor/src/codemirror/highlight.ts @@ -25,6 +25,32 @@ export const markdownSyntaxHighlighting = syntaxHighlighting( ]), ); +export const markdownSourceSyntaxHighlighting = syntaxHighlighting( + HighlightStyle.define([ + { + class: "cm-markra-source-metadata", + tag: [tags.url, tags.labelName, tags.string], + }, + ]), +); + +export const jsonSyntaxHighlighting = syntaxHighlighting( + HighlightStyle.define([ + { + class: "cm-markra-json-property", + tag: tags.propertyName, + }, + { + class: "cm-markra-json-string", + tag: tags.string, + }, + { + class: "cm-markra-json-literal", + tag: [tags.number, tags.bool, tags.null], + }, + ]), +); + export const markraHighlight: MarkdownExtension = { defineNodes: [ "Highlight", diff --git a/packages/editor/src/codemirror/index.ts b/packages/editor/src/codemirror/index.ts index ee2cfd61..0fbbfbdd 100644 --- a/packages/editor/src/codemirror/index.ts +++ b/packages/editor/src/codemirror/index.ts @@ -3,6 +3,8 @@ import { codeFolding } from "@codemirror/language"; import type { Extension } from "@codemirror/state"; import { GFM } from "@lezer/markdown"; import { + jsonSyntaxHighlighting, + markdownSourceSyntaxHighlighting, markdownSyntaxHighlighting, markraHighlight, } from "./highlight.ts"; @@ -135,6 +137,8 @@ export type { } from "./image.ts"; export { imagePreviewPlugin, resolveSafeImageSource } from "./image.ts"; export { + jsonSyntaxHighlighting, + markdownSourceSyntaxHighlighting, markdownSyntaxHighlighting, markraHighlight, } from "./highlight.ts";