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 e3130f22..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,12 +78,46 @@ 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"); 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(--editor-caret-color, var(--accent)) 20%, transparent) !important;", ); }); @@ -912,33 +918,117 @@ describe("editor stylesheet", () => { expect(iconStyles).toContain("pointer-events: none"); }); - it("uses one quieter theme color for Markdown syntax characters", () => { + it("keeps Markdown markers 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 darkSyntaxStart = styles.indexOf( + '.markdown-paper[data-editor-theme="dark"],', + ); + const darkSyntaxEnd = styles.indexOf("\n }", darkSyntaxStart); + const darkSyntax = styles.slice( + darkSyntaxStart, + darkSyntaxEnd, + ); + 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(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,', ); - expect(syntaxRule).toContain( + 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( "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);", ); }); + 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";