fix: improve dark theme contrast for syntax highlighting

- Add CSS variables for syntax highlighting instead of hardcoded colors
- Add --syntax-constant and --syntax-tag variables to light and dark themes
- Use high contrast colors for dark mode (bright green strings, purple keywords)
- Simplify Shiki transformer to output CSS classes instead of inline styles
- Remove unused parameters from transformer callback
This commit is contained in:
Claude
2026-01-30 09:47:34 +00:00
parent 177e6e798a
commit b27c6d5932
3 changed files with 160 additions and 193 deletions

View File

@@ -164,6 +164,8 @@
--syntax-function: 222.2 47.4% 11.2%;
--syntax-variable: 222.2 84% 4.9%;
--syntax-operator: 222.2 84% 20%;
--syntax-constant: 199 89% 48%;
--syntax-tag: 142 76% 36%;
/* Diff colors */
--diff-inserted: 142 60% 30%;
@@ -223,15 +225,17 @@
--tooltip: 217.2 32.6% 30%;
--tooltip-foreground: 210 40% 98%;
/* Syntax highlighting */
--syntax-comment: 215 20.2% 70%;
--syntax-punctuation: 210 40% 70%;
/* Syntax highlighting - high contrast for dark backgrounds */
--syntax-comment: 215 20% 55%;
--syntax-punctuation: 210 30% 75%;
--syntax-property: 210 40% 98%;
--syntax-string: 215 20.2% 70%;
--syntax-keyword: 210 40% 98%;
--syntax-string: 140 70% 65%;
--syntax-keyword: 270 100% 80%;
--syntax-function: 210 40% 98%;
--syntax-variable: 210 40% 98%;
--syntax-operator: 210 40% 98%;
--syntax-constant: 199 90% 70%;
--syntax-tag: 140 70% 65%;
/* Diff colors */
--diff-inserted: 134 60% 76%;

View File

@@ -1,7 +1,7 @@
import {
createHighlighterCore,
type HighlighterCore,
type ThemeRegistration,
type ShikiTransformer,
} from "shiki/core";
import { createOnigurumaEngine } from "shiki/engine/oniguruma";
@@ -12,28 +12,83 @@ const loadedLanguages = new Set<string>();
const failedLanguages = new Set<string>();
/**
* Grimoire dark theme - minimalistic grayscale with high contrast
* Uses bright grays for readability with color only for diff semantics
* Transformer that adds CSS classes based on token scopes
* This allows us to style tokens with CSS variables instead of inline colors
*/
const grimoireDarkTheme: ThemeRegistration = {
name: "grimoire-dark",
type: "dark",
const classTransformer: ShikiTransformer = {
name: "class-transformer",
span(node) {
// Map inline colors to semantic CSS classes
// This allows us to style tokens with CSS variables instead of hardcoded colors
const style = node.properties?.style as string | undefined;
if (!style) return;
// Remove inline style and add class based on token type
delete node.properties.style;
// Add base class
node.properties.className = node.properties.className || [];
const classes = node.properties.className as string[];
classes.push("shiki-token");
// Detect token type from the original style color
// These colors come from our theme definitions
if (style.includes("#8b949e") || style.includes("comment")) {
classes.push("shiki-comment");
} else if (style.includes("#a5d6ff") || style.includes("string")) {
classes.push("shiki-string");
} else if (style.includes("#79c0ff") || style.includes("constant")) {
classes.push("shiki-constant");
} else if (style.includes("#7ee787") || style.includes("tag")) {
classes.push("shiki-tag");
} else if (style.includes("#ffa198") || style.includes("deleted")) {
classes.push("shiki-deleted");
} else if (
style.includes("#f0f0f0") ||
style.includes("#e6edf3") ||
style.includes("keyword") ||
style.includes("function")
) {
classes.push("shiki-keyword");
} else if (style.includes("#c9d1d9") || style.includes("punctuation")) {
classes.push("shiki-punctuation");
}
},
pre(node) {
// Remove background color from pre, let CSS handle it
if (node.properties?.style) {
const style = node.properties.style as string;
node.properties.style = style.replace(/background-color:[^;]+;?/g, "");
}
},
code(node) {
// Remove color from code element
if (node.properties?.style) {
delete node.properties.style;
}
},
};
/**
* Minimal theme - we'll override colors via CSS
* Using high-contrast colors as fallback if CSS fails
*/
const minimalTheme = {
name: "grimoire",
type: "dark" as const,
colors: {
"editor.background": "#0d1117",
"editor.background": "transparent",
"editor.foreground": "#e6edf3",
},
tokenColors: [
// Comments - readable gray
{
scope: ["comment", "punctuation.definition.comment"],
settings: { foreground: "#8b949e" },
},
// Strings - distinct but not colorful
{
scope: ["string", "string.quoted"],
settings: { foreground: "#a5d6ff" },
},
// Keywords, operators - bright
{
scope: [
"keyword",
@@ -45,12 +100,10 @@ const grimoireDarkTheme: ThemeRegistration = {
],
settings: { foreground: "#f0f0f0" },
},
// Functions, methods - foreground bold
{
scope: ["entity.name.function", "support.function", "meta.function-call"],
settings: { foreground: "#e6edf3", fontStyle: "bold" },
settings: { foreground: "#e6edf3" },
},
// Classes, types - foreground bold
{
scope: [
"entity.name.class",
@@ -58,9 +111,8 @@ const grimoireDarkTheme: ThemeRegistration = {
"support.class",
"support.type",
],
settings: { foreground: "#f0f0f0", fontStyle: "bold" },
settings: { foreground: "#f0f0f0" },
},
// Numbers, constants - bright
{
scope: [
"constant",
@@ -70,17 +122,14 @@ const grimoireDarkTheme: ThemeRegistration = {
],
settings: { foreground: "#79c0ff" },
},
// Variables, parameters - foreground
{
scope: ["variable", "variable.parameter", "variable.other"],
settings: { foreground: "#e6edf3" },
},
// Punctuation - visible
{
scope: ["punctuation", "meta.brace"],
settings: { foreground: "#c9d1d9" },
},
// Properties, attributes
{
scope: [
"variable.other.property",
@@ -89,17 +138,14 @@ const grimoireDarkTheme: ThemeRegistration = {
],
settings: { foreground: "#e6edf3" },
},
// Tags (HTML/JSX)
{
scope: ["entity.name.tag", "support.class.component"],
settings: { foreground: "#7ee787" },
},
// JSON keys
{
scope: ["support.type.property-name.json"],
settings: { foreground: "#a5d6ff" },
},
// Diff - deleted (red)
{
scope: [
"markup.deleted",
@@ -108,7 +154,6 @@ const grimoireDarkTheme: ThemeRegistration = {
],
settings: { foreground: "#ffa198" },
},
// Diff - inserted (green)
{
scope: [
"markup.inserted",
@@ -117,17 +162,14 @@ const grimoireDarkTheme: ThemeRegistration = {
],
settings: { foreground: "#7ee787" },
},
// Diff - changed/range
{
scope: ["markup.changed", "meta.diff.range", "meta.diff.header"],
settings: { foreground: "#a5d6ff" },
},
// Markdown headings
{
scope: ["markup.heading", "entity.name.section"],
settings: { foreground: "#f0f0f0", fontStyle: "bold" },
settings: { foreground: "#f0f0f0" },
},
// Markdown bold/italic
{
scope: ["markup.bold"],
settings: { fontStyle: "bold" },
@@ -136,12 +178,10 @@ const grimoireDarkTheme: ThemeRegistration = {
scope: ["markup.italic"],
settings: { fontStyle: "italic" },
},
// Markdown links
{
scope: ["markup.underline.link"],
settings: { foreground: "#a5d6ff" },
},
// Markdown code
{
scope: ["markup.inline.raw", "markup.raw"],
settings: { foreground: "#a5d6ff" },
@@ -149,138 +189,6 @@ const grimoireDarkTheme: ThemeRegistration = {
],
};
/**
* Grimoire light theme - minimalistic grayscale for light backgrounds
*/
const grimoireLightTheme: ThemeRegistration = {
name: "grimoire-light",
type: "light",
colors: {
"editor.background": "#ffffff",
"editor.foreground": "#1a1a1a",
},
tokenColors: [
// Comments - muted
{
scope: ["comment", "punctuation.definition.comment"],
settings: { foreground: "#6b7280" },
},
// Strings - muted but slightly emphasized
{
scope: ["string", "string.quoted"],
settings: { foreground: "#4b5563" },
},
// Keywords, operators - emphasized dark gray
{
scope: [
"keyword",
"storage",
"storage.type",
"storage.modifier",
"keyword.operator",
"keyword.control",
],
settings: { foreground: "#374151" },
},
// Functions, methods - foreground bold
{
scope: ["entity.name.function", "support.function", "meta.function-call"],
settings: { foreground: "#1a1a1a", fontStyle: "bold" },
},
// Classes, types - foreground bold
{
scope: [
"entity.name.class",
"entity.name.type",
"support.class",
"support.type",
],
settings: { foreground: "#1a1a1a", fontStyle: "bold" },
},
// Numbers, constants - emphasized dark gray
{
scope: [
"constant",
"constant.numeric",
"constant.language",
"constant.character",
],
settings: { foreground: "#374151" },
},
// Variables, parameters - foreground
{
scope: ["variable", "variable.parameter", "variable.other"],
settings: { foreground: "#1a1a1a" },
},
// Punctuation - slightly muted
{
scope: ["punctuation", "meta.brace"],
settings: { foreground: "#4b5563" },
},
// Properties, attributes
{
scope: [
"variable.other.property",
"entity.other.attribute-name",
"support.type.property-name",
],
settings: { foreground: "#374151" },
},
// Tags (HTML/JSX)
{
scope: ["entity.name.tag", "support.class.component"],
settings: { foreground: "#374151" },
},
// JSON keys
{
scope: ["support.type.property-name.json"],
settings: { foreground: "#374151" },
},
// Diff - deleted (red)
{
scope: [
"markup.deleted",
"punctuation.definition.deleted",
"meta.diff.header.from-file",
],
settings: { foreground: "#dc2626" },
},
// Diff - inserted (green)
{
scope: [
"markup.inserted",
"punctuation.definition.inserted",
"meta.diff.header.to-file",
],
settings: { foreground: "#16a34a" },
},
// Diff - changed/range
{
scope: ["markup.changed", "meta.diff.range", "meta.diff.header"],
settings: { foreground: "#0891b2" },
},
// Markdown headings
{
scope: ["markup.heading", "entity.name.section"],
settings: { foreground: "#1a1a1a", fontStyle: "bold" },
},
// Markdown bold/italic
{
scope: ["markup.bold"],
settings: { fontStyle: "bold" },
},
{
scope: ["markup.italic"],
settings: { fontStyle: "italic" },
},
// Markdown links
{
scope: ["markup.underline.link"],
settings: { foreground: "#2563eb" },
},
],
};
/**
* Language alias mapping (file extensions and common names to Shiki IDs)
*/
@@ -436,7 +344,7 @@ export async function getHighlighter(): Promise<HighlighterCore> {
if (!highlighterPromise) {
highlighterPromise = createHighlighterCore({
themes: [grimoireDarkTheme, grimoireLightTheme],
themes: [minimalTheme],
langs: [
import("shiki/langs/javascript.mjs"),
import("shiki/langs/typescript.mjs"),
@@ -482,18 +390,9 @@ async function loadLanguage(lang: string): Promise<boolean> {
}
}
/**
* Detect if dark mode is currently active
*/
function isDarkMode(): boolean {
if (typeof document === "undefined") return true;
return document.documentElement.classList.contains("dark");
}
/**
* Highlight code with lazy language loading
* Returns HTML string
* Automatically uses the appropriate theme based on current color scheme
* Returns HTML string with CSS classes for styling via CSS variables
*/
export async function highlightCode(
code: string,
@@ -506,12 +405,10 @@ export async function highlightCode(
const loaded = await loadLanguage(lang);
const effectiveLang = loaded ? lang : "text";
// Select theme based on current color scheme
const theme = isDarkMode() ? "grimoire-dark" : "grimoire-light";
return hl.codeToHtml(code, {
lang: effectiveLang,
theme,
theme: "grimoire",
transformers: [classTransformer],
});
}

View File

@@ -1,6 +1,15 @@
/* Shiki syntax highlighting - Grimoire dark theme overrides */
/* ==========================================================================
Shiki Syntax Highlighting - CSS Variable Based Theming
Token colors are defined using CSS variables from the theme system,
allowing automatic adaptation to light/dark mode.
========================================================================== */
/* Base container styling */
.shiki-container {
color: hsl(var(--foreground));
}
.shiki-container pre {
background: transparent !important;
margin: 0;
@@ -15,6 +24,7 @@
word-spacing: normal;
word-break: normal;
tab-size: 4;
color: hsl(var(--foreground));
}
/* Ensure shiki's inline styles don't conflict */
@@ -26,7 +36,59 @@
background: transparent !important;
}
/* Loading state - subtle pulse animation */
/* ==========================================================================
Token Classes - Styled with CSS Variables
========================================================================== */
/* Base token - inherits foreground color */
.shiki-token {
color: hsl(var(--syntax-variable));
}
/* Comments */
.shiki-comment {
color: hsl(var(--syntax-comment));
}
/* Strings */
.shiki-string {
color: hsl(var(--syntax-string));
}
/* Keywords (if, else, return, function, etc.) */
.shiki-keyword {
color: hsl(var(--syntax-keyword));
}
/* Constants and numbers */
.shiki-constant {
color: hsl(var(--syntax-constant));
}
/* Punctuation */
.shiki-punctuation {
color: hsl(var(--syntax-punctuation));
}
/* Tags (HTML/JSX) */
.shiki-tag {
color: hsl(var(--syntax-tag));
}
/* Diff - deleted lines */
.shiki-deleted {
color: hsl(var(--diff-deleted));
}
/* Diff - inserted lines */
.shiki-inserted {
color: hsl(var(--diff-inserted));
}
/* ==========================================================================
Loading State
========================================================================== */
.shiki-loading {
animation: shiki-pulse 1.5s ease-in-out infinite;
}
@@ -41,34 +103,38 @@
}
}
/* ==========================================================================
Diff Block Backgrounds
========================================================================== */
/* Diff-specific styling - block-level backgrounds for inserted/deleted */
/* These target the line spans that Shiki generates for diff content */
.shiki-container .line:has(.diff.add),
.shiki-container .line:has([style*="color:#69db7c"]) {
background: rgba(52, 199, 89, 0.1);
.shiki-container .line:has(.shiki-deleted) {
background: hsl(var(--diff-deleted-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
}
.shiki-container .line:has(.diff.remove),
.shiki-container .line:has([style*="color:#ff8787"]) {
background: rgba(255, 59, 48, 0.1);
.shiki-container .line:has(.shiki-inserted) {
background: hsl(var(--diff-inserted-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
}
/* Hunk headers (@@ lines) - cyan background */
.shiki-container .line:has([style*="color:#66d9ef"]) {
background: rgba(102, 217, 239, 0.08);
/* Hunk headers (@@ lines) */
.shiki-container .line:has([class*="meta"]) {
background: hsl(var(--diff-meta-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
font-weight: 600;
}
/* Optional: Line numbers */
/* ==========================================================================
Line Numbers (optional)
========================================================================== */
.shiki-container.line-numbers .line::before {
content: attr(data-line);
display: inline-block;