feat: migrate syntax highlighting from Prism.js to Shiki

Replace Prism.js with Shiki for syntax highlighting with several key improvements:

- Lazy loading: Languages loaded on-demand via dynamic imports instead of bundling all upfront
- Broader language support: 200+ TextMate grammars vs 11 statically imported
- Singleton highlighter: Core languages (JS, TS, JSON, diff, bash) preloaded, others loaded on first use

New files:
- src/lib/shiki.ts: Shiki service with highlightCode(), normalizeLanguage(), language aliases
- src/hooks/useHighlightedCode.ts: React hook for async highlighting with loading states
- src/styles/shiki-theme.css: Grimoire dark theme matching previous minimalistic style

Updated components:
- SyntaxHighlight: Now uses Shiki with graceful loading/error states
- CodeSnippetRenderer/DetailRenderer: Simplified, removed manual language mapping
- MarkdownContent: Removed type casts, any language now supported

Removed:
- prismjs and @types/prismjs dependencies
- src/styles/prism-theme.css
This commit is contained in:
Claude
2026-01-02 11:01:27 +00:00
parent 7e34155ab8
commit 90d1607eec
11 changed files with 1148 additions and 1086 deletions

733
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -70,7 +70,6 @@
"js-yaml": "^4.1.1",
"lucide-react": "latest",
"media-chrome": "^4.17.2",
"prismjs": "^1.30.0",
"qrcode": "^1.5.4",
"react": "^19.2.1",
"react-dom": "^19.2.1",
@@ -82,6 +81,7 @@
"remark-gfm": "^4.0.1",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.3",
"shiki": "^3.20.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.5.5",
"tippy.js": "^6.3.7"
@@ -93,7 +93,6 @@
"@testing-library/react": "^16.3.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.10.1",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/shell-quote": "^1.7.5",

View File

@@ -1,45 +1,24 @@
import { useEffect, useRef } from "react";
import Prism from "prismjs";
// Core languages
import "prismjs/components/prism-diff";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-typescript";
import "prismjs/components/prism-jsx";
import "prismjs/components/prism-tsx";
import "prismjs/components/prism-bash";
import "prismjs/components/prism-json";
import "prismjs/components/prism-markdown";
import "prismjs/components/prism-css";
import "prismjs/components/prism-python";
import "prismjs/components/prism-yaml";
import { useHighlightedCode } from "@/hooks/useHighlightedCode";
import { cn } from "@/lib/utils";
interface SyntaxHighlightProps {
code: string;
language:
| "diff"
| "javascript"
| "typescript"
| "jsx"
| "tsx"
| "bash"
| "shell"
| "json"
| "markdown"
| "css"
| "python"
| "yaml";
language?: string | null;
className?: string;
showLineNumbers?: boolean;
}
/**
* Syntax highlighting component using Prism.js
* Matches Grimoire's dark theme using CSS custom properties
* Syntax highlighting component using Shiki with lazy language loading
*
* Languages are loaded on-demand - the first render of a new language
* will show a brief loading state while the grammar is fetched.
*
* @example
* ```tsx
* <SyntaxHighlight code={patchContent} language="diff" />
* <SyntaxHighlight code={jsonStr} language="json" />
* <SyntaxHighlight code={snippet} language="python" />
* ```
*/
export function SyntaxHighlight({
@@ -48,26 +27,45 @@ export function SyntaxHighlight({
className = "",
showLineNumbers = false,
}: SyntaxHighlightProps) {
const codeRef = useRef<HTMLElement>(null);
const { html, loading, error } = useHighlightedCode(code, language);
// Normalize language aliases
const normalizedLanguage = language === "shell" ? "bash" : language;
// Loading state - show code without highlighting
if (loading) {
return (
<pre
className={cn(
"shiki-loading overflow-x-auto max-w-full font-mono text-xs",
className,
)}
>
<code className="text-foreground/70">{code}</code>
</pre>
);
}
useEffect(() => {
// Check for browser environment (SSR safety)
if (typeof window === "undefined" || !codeRef.current) return;
// Highlight the code element
Prism.highlightElement(codeRef.current);
}, [code, normalizedLanguage]);
// Error state - fallback to plain code
if (error || !html) {
return (
<pre
className={cn(
"overflow-x-auto max-w-full font-mono text-xs",
className,
)}
>
<code>{code}</code>
</pre>
);
}
// Render highlighted HTML
return (
<pre
className={`language-${normalizedLanguage} ${showLineNumbers ? "line-numbers" : ""} overflow-x-auto max-w-full ${className}`.trim()}
>
<code ref={codeRef} className={`language-${normalizedLanguage}`}>
{code}
</code>
</pre>
<div
className={cn(
"shiki-container overflow-x-auto max-w-full [&_pre]:!bg-transparent [&_code]:text-xs [&_code]:font-mono",
showLineNumbers && "line-numbers",
className,
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View File

@@ -135,7 +135,7 @@ function CodeBlock({
return (
<div className="relative my-4">
{language ? (
<SyntaxHighlight code={code} language={language as any} />
<SyntaxHighlight code={code} language={language} />
) : (
<pre
className={`bg-muted p-4 border border-border rounded overflow-x-auto max-w-full ${isSingleLine ? "" : "pr-12"}`}

View File

@@ -80,41 +80,6 @@ export function Kind1337DetailRenderer({ event }: Kind1337DetailRendererProps) {
}
};
// Normalize language to supported Prism languages
const normalizedLanguage = useMemo(() => {
if (!language) return null;
const lang = language.toLowerCase();
// Map common language names to Prism identifiers
const languageMap: Record<string, string> = {
js: "javascript",
ts: "typescript",
py: "python",
sh: "bash",
shell: "bash",
yml: "yaml",
};
const mapped = languageMap[lang] || lang;
// Check if it's a supported language
const supported = [
"javascript",
"typescript",
"jsx",
"tsx",
"bash",
"json",
"markdown",
"css",
"python",
"yaml",
"diff",
];
return supported.includes(mapped) ? mapped : null;
}, [language]);
return (
<div className="flex flex-col gap-2 p-6">
{/* Header */}
@@ -195,29 +160,16 @@ export function Kind1337DetailRenderer({ event }: Kind1337DetailRendererProps) {
{/* Code Section */}
<div className="relative">
{normalizedLanguage ? (
<>
<SyntaxHighlight
code={event.content}
language={normalizedLanguage as any}
className="bg-muted p-4 pr-10 border border-border overflow-x-auto"
/>
<CodeCopyButton
onCopy={handleCopyCode}
copied={copied}
label="Copy code"
/>
</>
) : (
<pre className="text-xs font-mono bg-muted p-4 pr-10 border border-border overflow-x-auto">
<CodeCopyButton
onCopy={handleCopyCode}
copied={copied}
label="Copy code"
/>
<code>{event.content}</code>
</pre>
)}
<SyntaxHighlight
code={event.content}
language={language}
className="bg-muted p-4 pr-10 border border-border overflow-x-auto"
/>
<CodeCopyButton
onCopy={handleCopyCode}
copied={copied}
label="Copy code"
/>
</div>
</div>
);

View File

@@ -11,58 +11,6 @@ import {
import { Label } from "@/components/ui/label";
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
// Map common language names to Prism-supported languages
function mapLanguage(
lang: string | null | undefined,
):
| "javascript"
| "typescript"
| "jsx"
| "tsx"
| "bash"
| "json"
| "markdown"
| "css"
| "python"
| "yaml"
| "diff" {
if (!lang) return "javascript";
const normalized = lang.toLowerCase();
// Direct matches
if (
[
"javascript",
"typescript",
"jsx",
"tsx",
"bash",
"json",
"markdown",
"css",
"python",
"yaml",
"diff",
].includes(normalized)
) {
return normalized as any;
}
// Common aliases
const aliases: Record<string, string> = {
js: "javascript",
ts: "typescript",
sh: "bash",
shell: "bash",
py: "python",
md: "markdown",
yml: "yaml",
};
return (aliases[normalized] as any) || "javascript";
}
/**
* Renderer for Kind 1337 - Code Snippet (NIP-C0)
* Displays code snippet name, language, description, and preview in feed
@@ -109,7 +57,7 @@ export function Kind1337Renderer({ event }: BaseEventProps) {
<div className="relative">
<SyntaxHighlight
code={previewCode}
language={mapLanguage(language)}
language={language}
className="overflow-x-auto bg-muted/30 p-3 border border-border"
/>
</div>

View File

@@ -0,0 +1,51 @@
import { useState, useEffect } from "react";
import { highlightCode } from "@/lib/shiki";
interface UseHighlightedCodeResult {
html: string | null;
loading: boolean;
error: Error | null;
}
/**
* Hook to highlight code asynchronously with lazy language loading
*
* @example
* const { html, loading } = useHighlightedCode(code, "typescript")
* if (loading) return <pre>{code}</pre>
* return <div dangerouslySetInnerHTML={{ __html: html }} />
*/
export function useHighlightedCode(
code: string,
language: string | null | undefined,
): UseHighlightedCodeResult {
const [html, setHtml] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
highlightCode(code, language)
.then((result) => {
if (!cancelled) {
setHtml(result);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err : new Error(String(err)));
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [code, language]);
return { html, loading, error };
}

View File

@@ -1,8 +1,8 @@
/* Tailwind CSS v4 - CSS-first configuration */
@import "tailwindcss";
/* Prism syntax highlighting theme */
@import "./styles/prism-theme.css";
/* Shiki syntax highlighting theme */
@import "./styles/shiki-theme.css";
/* ==========================================================================
Theme Configuration (@theme)
@@ -45,562 +45,219 @@
@keyframes skeleton-pulse {
0%,
100% {
opacity: 1;
opacity: 0.4;
}
50% {
opacity: 0.5;
opacity: 0.7;
}
}
/* ==========================================================================
Color Tokens
These map runtime CSS variables (set by ThemeProvider) to Tailwind colors.
The runtime variables use HSL values WITHOUT the hsl() wrapper.
========================================================================== */
/* Core Colors */
/* Colors - Map Tailwind utilities to CSS variables */
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
/* Card */
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
/* Popover */
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
/* Primary */
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
/* Secondary */
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
/* Accent */
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
/* Muted */
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
/* Destructive */
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
/* Form Elements */
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
/* Status Colors */
--color-success: hsl(var(--success));
--color-warning: hsl(var(--warning));
--color-info: hsl(var(--info));
/* Nostr-specific Colors */
--color-zap: hsl(var(--zap));
--color-live: hsl(var(--live));
--color-highlight: hsl(var(--highlight));
/* Tooltip */
--color-tooltip: hsl(var(--tooltip));
--color-tooltip-foreground: hsl(var(--tooltip-foreground));
--color-sidebar: hsl(var(--sidebar));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
--color-sidebar-primary: hsl(var(--sidebar-primary));
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
--color-sidebar-accent: hsl(var(--sidebar-accent));
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
--color-sidebar-border: hsl(var(--sidebar-border));
--color-sidebar-ring: hsl(var(--sidebar-ring));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
}
/* ==========================================================================
Runtime Theme Variables
These are HSL values WITHOUT the hsl() wrapper, allowing alpha transparency.
Set dynamically by ThemeProvider via applyTheme().
========================================================================== */
:root {
/* Core colors - light theme defaults (overridden by ThemeProvider) */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
/* Status colors */
--success: 142 76% 36%;
--warning: 45 93% 47%;
--info: 199 89% 48%;
/* Nostr-specific colors */
--zap: 45 93% 40%;
--live: 0 72% 45%;
/* UI highlight (active user, self-references) */
--highlight: 25 90% 35%;
/* Tooltip colors */
--tooltip: 222.2 47.4% 11.2%;
--tooltip-foreground: 210 40% 98%;
/* Syntax highlighting */
--syntax-comment: 215.4 16.3% 46.9%;
--syntax-punctuation: 222.2 84% 30%;
--syntax-property: 222.2 47.4% 11.2%;
--syntax-string: 142 60% 30%;
--syntax-keyword: 270 80% 50%;
--syntax-function: 222.2 47.4% 11.2%;
--syntax-variable: 222.2 84% 4.9%;
--syntax-operator: 222.2 84% 20%;
/* Diff colors */
--diff-inserted: 142 60% 30%;
--diff-inserted-bg: 142 60% 50% / 0.15;
--diff-deleted: 0 70% 45%;
--diff-deleted-bg: 0 70% 50% / 0.15;
--diff-meta: 199 80% 40%;
--diff-meta-bg: 199 80% 50% / 0.1;
/* Scrollbar */
--scrollbar-thumb: 222.2 84% 4.9% / 0.2;
--scrollbar-thumb-hover: 222.2 84% 4.9% / 0.3;
--scrollbar-track: 0 0% 0% / 0;
/* Gradient colors (RGB values) */
--gradient-1: 234 179 8;
--gradient-2: 249 115 22;
--gradient-3: 147 51 234;
--gradient-4: 6 182 212;
}
/* Dark theme - applied via .dark class or ThemeProvider */
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 70%;
--accent: 270 100% 70%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 75% 75%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
/* Status colors */
--success: 142 76% 46%;
--warning: 38 92% 60%;
--info: 199 89% 58%;
/* Nostr-specific colors */
--zap: 45 93% 58%;
--live: 0 72% 51%;
/* UI highlight (active user, self-references) */
--highlight: 27 96% 61%;
/* Tooltip colors */
--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-property: 210 40% 98%;
--syntax-string: 215 20.2% 70%;
--syntax-keyword: 210 40% 98%;
--syntax-function: 210 40% 98%;
--syntax-variable: 210 40% 98%;
--syntax-operator: 210 40% 98%;
/* Diff colors */
--diff-inserted: 134 60% 76%;
--diff-inserted-bg: 145 63% 42% / 0.1;
--diff-deleted: 0 100% 76%;
--diff-deleted-bg: 0 100% 60% / 0.1;
--diff-meta: 190 77% 70%;
--diff-meta-bg: 190 77% 70% / 0.08;
/* Scrollbar */
--scrollbar-thumb: 0 0% 100% / 0.2;
--scrollbar-thumb-hover: 0 0% 100% / 0.3;
--scrollbar-track: 0 0% 0% / 0;
/* Gradient colors (RGB values) */
--gradient-1: 250 204 21;
--gradient-2: 251 146 60;
--gradient-3: 168 85 247;
--gradient-4: 34 211 238;
}
/* ==========================================================================
Custom Scrollbar Styling
========================================================================== */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--scrollbar-thumb)) hsl(var(--scrollbar-track));
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: hsl(var(--scrollbar-track));
}
*::-webkit-scrollbar-thumb {
background-color: hsl(var(--scrollbar-thumb));
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--scrollbar-thumb-hover));
}
/* ==========================================================================
Base Layer - Global Styles
Base Styles (@layer base)
These are low-specificity foundational styles.
========================================================================== */
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-mono;
/* iOS PWA safe area insets for notch support */
padding-top: env(safe-area-inset-top, 0);
padding-right: env(safe-area-inset-right, 0);
padding-bottom: env(safe-area-inset-bottom, 0);
padding-left: env(safe-area-inset-left, 0);
@apply bg-background text-foreground;
}
:root {
color-scheme: light;
--radius: 0.5rem;
}
.dark {
color-scheme: dark;
}
/* Force hardware acceleration for smooth scrolling */
.hardware-accelerated {
transform: translateZ(0);
will-change: transform;
backface-visibility: hidden;
}
}
/* ==========================================================================
Custom Utilities (v4 @utility syntax)
Theme Variables
Runtime CSS variables that power the design system.
These are toggled by adding/removing the .dark class on <html>.
========================================================================== */
@utility no-scrollbar {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
&::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar: 0 0% 98%;
--sidebar-foreground: 0 0% 9%;
--sidebar-primary: 0 0% 9%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 0 0% 94%;
--sidebar-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 89%;
--sidebar-ring: 0 0% 3.9%;
}
@utility hide-scrollbar {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
&::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
}
@utility text-grimoire-gradient {
background: linear-gradient(
to bottom,
rgb(var(--gradient-1)),
rgb(var(--gradient-2)),
rgb(var(--gradient-3)),
rgb(var(--gradient-4))
);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar: 0 0% 5%;
--sidebar-foreground: 0 0% 98%;
--sidebar-primary: 0 0% 98%;
--sidebar-primary-foreground: 0 0% 9%;
--sidebar-accent: 0 0% 12%;
--sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 14.9%;
--sidebar-ring: 0 0% 83.1%;
}
/* ==========================================================================
Third-Party Component Overrides
Component Styles
========================================================================== */
/* react-medium-image-zoom theme customization */
[data-rmiz-modal-overlay] {
background-color: hsl(var(--background) / 0.92) !important;
/* Custom scrollbar for dark theme */
.dark ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
[data-rmiz-modal-content] {
box-shadow: 0 0 40px hsl(var(--foreground) / 0.2);
}
/* ==========================================================================
React Mosaic Theme Customization
========================================================================== */
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme {
.dark ::-webkit-scrollbar-track {
background: hsl(var(--background));
}
/* Smooth animations for window resizing and repositioning */
/* Only animate during preset application, not manual resize/drag */
body.animating-layout
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-tile {
transition:
width 150ms cubic-bezier(0.25, 0.1, 0.25, 1),
height 150ms cubic-bezier(0.25, 0.1, 0.25, 1),
top 150ms cubic-bezier(0.25, 0.1, 0.25, 1),
left 150ms cubic-bezier(0.25, 0.1, 0.25, 1);
contain: layout; /* Isolate layout calculations for better performance */
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window
.mosaic-window-toolbar {
.dark ::-webkit-scrollbar-thumb {
background: hsl(var(--muted));
border: none;
border-bottom: 1px solid hsl(var(--border));
border-radius: 0;
color: hsl(var(--foreground));
height: 30px;
border-radius: 4px;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window
.mosaic-window-title {
color: hsl(var(--foreground));
font-family: inherit;
.dark ::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-window {
background: hsl(var(--background));
outline: none;
border-radius: 0 !important;
/* Mosaic component overrides */
.mosaic-root {
background: transparent !important;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-window * {
border-radius: 0 !important;
.mosaic-tile {
margin: 2px !important;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-window::before,
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-window::after {
display: none;
.mosaic-window {
border-radius: var(--radius) !important;
overflow: hidden;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme.bp4-dark .mosaic-window,
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme.bp4-dark .mosaic-preview {
box-shadow: none;
.mosaic-window-toolbar {
display: none !important;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window
.mosaic-window-body {
background: hsl(var(--background));
color: hsl(var(--foreground));
.mosaic-window-body {
background: transparent !important;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-window-controls {
color: hsl(var(--muted-foreground));
.mosaic-split {
background: transparent !important;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window-controls:hover {
color: hsl(var(--foreground));
.mosaic-split:hover {
background: hsl(var(--primary) / 0.2) !important;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window-toolbar
.separator {
border-left: 1px solid hsl(var(--border));
.mosaic-split.-row {
margin: 0 -2px !important;
width: 6px !important;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme
.mosaic-window-body-overlay {
background: hsl(var(--background));
.mosaic-split.-column {
margin: -2px 0 !important;
height: 6px !important;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-preview {
background: hsl(var(--accent) / 0.3);
border: 2px solid hsl(var(--primary));
outline: none;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-drop-target {
border: 2px solid var(--border);
outline: none;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-split:hover {
background: hsl(var(--primary));
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-split.-row {
width: 4px;
margin: 0 -2px;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-split.-column {
height: 4px;
margin: -2px 0;
}
/* Mobile: Wider split dividers for touch dragging */
@media (max-width: 767px) {
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-split.-row {
width: 12px;
margin: 0 -6px;
}
.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-split.-column {
height: 12px;
margin: -6px 0;
}
}
/* ==========================================================================
Accessibility: Focus Indicators
========================================================================== */
@layer base {
/* Focus-visible for buttons and interactive elements */
button:focus-visible,
a:focus-visible,
[role="button"]:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/* Focus-visible for input elements */
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 0;
}
/* Focus-visible for command launcher items */
[cmdk-item]:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: -2px;
}
/* Focus-visible for tab buttons */
.tabbar-button:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
}
/* ==========================================================================
TipTap Editor Styles
========================================================================== */
.ProseMirror {
min-height: 1.25rem;
line-height: 1.25rem;
position: relative;
}
.ProseMirror:focus {
outline: none;
}
.ProseMirror p {
margin: 0;
line-height: 1.25rem;
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: hsl(var(--muted-foreground));
pointer-events: none;
position: absolute;
left: 0;
top: 0;
}
/* Disable link navigation in editor - allow editing instead of clicking */
.ProseMirror a {
pointer-events: none;
}
/* Mention styles */
.ProseMirror .mention {
color: hsl(var(--primary));
background-color: hsl(var(--primary) / 0.1);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
text-decoration: none;
cursor: pointer;
font-weight: 500;
}
.ProseMirror .mention:hover {
background-color: hsl(var(--primary) / 0.2);
}
/* Emoji styles */
.ProseMirror .emoji-node {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.ProseMirror .emoji-image {
height: 1.2em;
width: auto;
vertical-align: middle;
object-fit: contain;
}
.ProseMirror .emoji-unicode {
font-size: 1.1em;
line-height: 1;
vertical-align: middle;
}
/* Nostr event preview styles */
.ProseMirror .nostr-event-preview {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
background-color: hsl(var(--primary) / 0.1);
border: 1px solid hsl(var(--primary) / 0.3);
border-radius: 0.25rem;
font-size: 0.75rem;
vertical-align: middle;
cursor: default;
transition: background-color 0.2s;
}
.ProseMirror .nostr-event-preview:hover {
background-color: hsl(var(--primary) / 0.15);
}
/* Hide scrollbar in RichEditor */
.rich-editor .ProseMirror {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.rich-editor .ProseMirror::-webkit-scrollbar {
display: none; /* Chrome/Safari/Opera */
/* Active split highlight */
.mosaic-split:active {
background: hsl(var(--primary) / 0.4) !important;
}

375
src/lib/shiki.ts Normal file
View File

@@ -0,0 +1,375 @@
import {
createHighlighterCore,
type HighlighterCore,
type ThemeRegistration,
} from "shiki/core";
import { createOnigurumaEngine } from "shiki/engine/oniguruma";
// Singleton highlighter instance
let highlighter: HighlighterCore | null = null;
let highlighterPromise: Promise<HighlighterCore> | null = null;
const loadedLanguages = new Set<string>();
/**
* Grimoire dark theme matching current minimalistic Prism styles
* Uses muted colors with primary accent for keywords/functions
*/
const grimoireTheme: ThemeRegistration = {
name: "grimoire-dark",
type: "dark",
colors: {
"editor.background": "#000000",
"editor.foreground": "#e5e5e5",
},
tokenColors: [
// Comments - muted
{
scope: ["comment", "punctuation.definition.comment"],
settings: { foreground: "#6b7280" },
},
// Strings - muted but slightly emphasized
{
scope: ["string", "string.quoted"],
settings: { foreground: "#9ca3af" },
},
// Keywords, operators - primary color
{
scope: [
"keyword",
"storage",
"storage.type",
"storage.modifier",
"keyword.operator",
"keyword.control",
],
settings: { foreground: "#a855f7" },
},
// Functions, methods - primary bold
{
scope: ["entity.name.function", "support.function", "meta.function-call"],
settings: { foreground: "#a855f7", fontStyle: "bold" },
},
// Classes, types - primary bold
{
scope: [
"entity.name.class",
"entity.name.type",
"support.class",
"support.type",
],
settings: { foreground: "#a855f7", fontStyle: "bold" },
},
// Numbers, constants - primary
{
scope: [
"constant",
"constant.numeric",
"constant.language",
"constant.character",
],
settings: { foreground: "#a855f7" },
},
// Variables, parameters - foreground
{
scope: ["variable", "variable.parameter", "variable.other"],
settings: { foreground: "#e5e5e5" },
},
// Punctuation - slightly muted
{
scope: ["punctuation", "meta.brace"],
settings: { foreground: "#b3b3b3" },
},
// Properties, attributes
{
scope: [
"variable.other.property",
"entity.other.attribute-name",
"support.type.property-name",
],
settings: { foreground: "#d4d4d4" },
},
// Tags (HTML/JSX)
{
scope: ["entity.name.tag", "support.class.component"],
settings: { foreground: "#a855f7" },
},
// JSON keys
{
scope: ["support.type.property-name.json"],
settings: { foreground: "#d4d4d4" },
},
// Diff - deleted (red)
{
scope: [
"markup.deleted",
"punctuation.definition.deleted",
"meta.diff.header.from-file",
],
settings: { foreground: "#ff8787" },
},
// Diff - inserted (green)
{
scope: [
"markup.inserted",
"punctuation.definition.inserted",
"meta.diff.header.to-file",
],
settings: { foreground: "#69db7c" },
},
// Diff - changed/range
{
scope: ["markup.changed", "meta.diff.range", "meta.diff.header"],
settings: { foreground: "#66d9ef" },
},
// Markdown headings
{
scope: ["markup.heading", "entity.name.section"],
settings: { foreground: "#a855f7", 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: "#a855f7" },
},
],
};
/**
* Language alias mapping (file extensions and common names to Shiki IDs)
*/
const LANGUAGE_ALIASES: Record<string, string> = {
// JavaScript family
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
mjs: "javascript",
cjs: "javascript",
// Python
py: "python",
pyw: "python",
// Ruby
rb: "ruby",
// Rust
rs: "rust",
// Go
go: "go",
// Shell
sh: "bash",
bash: "bash",
shell: "bash",
zsh: "bash",
fish: "fish",
// Config/Data
yml: "yaml",
yaml: "yaml",
toml: "toml",
ini: "ini",
// JSON
json: "json",
jsonc: "jsonc",
json5: "json5",
// Markdown
md: "markdown",
mdx: "mdx",
// CSS
css: "css",
scss: "scss",
sass: "sass",
less: "less",
// HTML/XML
html: "html",
htm: "html",
xml: "xml",
svg: "xml",
// SQL
sql: "sql",
// C family
c: "c",
h: "c",
cpp: "cpp",
"c++": "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
hxx: "cpp",
// C#
cs: "csharp",
csharp: "csharp",
// Java/JVM
java: "java",
kt: "kotlin",
kotlin: "kotlin",
scala: "scala",
groovy: "groovy",
// Apple
swift: "swift",
objc: "objective-c",
// PHP
php: "php",
// Lua
lua: "lua",
// Vim
vim: "viml",
// Docker
dockerfile: "dockerfile",
docker: "dockerfile",
// Make
makefile: "makefile",
make: "makefile",
// Diff/Patch
diff: "diff",
patch: "diff",
// Blockchain
sol: "solidity",
solidity: "solidity",
// Zig
zig: "zig",
// Functional
ex: "elixir",
exs: "elixir",
erl: "erlang",
hs: "haskell",
ml: "ocaml",
clj: "clojure",
cljs: "clojure",
// GraphQL
graphql: "graphql",
gql: "graphql",
// Protocol Buffers
proto: "protobuf",
// Nix
nix: "nix",
// Terraform
tf: "hcl",
hcl: "hcl",
// PowerShell
ps1: "powershell",
psm1: "powershell",
// R
r: "r",
// Perl
pl: "perl",
pm: "perl",
// LaTeX
tex: "latex",
latex: "latex",
// WASM
wat: "wasm",
wasm: "wasm",
};
/**
* Core languages to preload (most commonly used in Grimoire)
*/
const CORE_LANGUAGES = [
"javascript",
"typescript",
"json",
"diff",
"bash",
] as const;
/**
* Normalize language identifier to Shiki language ID
*/
export function normalizeLanguage(lang: string | null | undefined): string {
if (!lang) return "text";
const normalized = lang.toLowerCase().trim();
return LANGUAGE_ALIASES[normalized] || normalized;
}
/**
* Get or create the singleton highlighter instance
*/
export async function getHighlighter(): Promise<HighlighterCore> {
if (highlighter) return highlighter;
if (!highlighterPromise) {
highlighterPromise = createHighlighterCore({
themes: [grimoireTheme],
langs: [
import("shiki/langs/javascript.mjs"),
import("shiki/langs/typescript.mjs"),
import("shiki/langs/json.mjs"),
import("shiki/langs/diff.mjs"),
import("shiki/langs/bash.mjs"),
],
engine: createOnigurumaEngine(import("shiki/wasm")),
}).then((hl) => {
highlighter = hl;
CORE_LANGUAGES.forEach((l) => loadedLanguages.add(l));
return hl;
});
}
return highlighterPromise;
}
/**
* Load a language on demand
*/
async function loadLanguage(lang: string): Promise<boolean> {
if (lang === "text" || loadedLanguages.has(lang)) return true;
const hl = await getHighlighter();
try {
// Dynamic import for the language
const langModule = await import(`shiki/langs/${lang}.mjs`);
await hl.loadLanguage(langModule.default || langModule);
loadedLanguages.add(lang);
return true;
} catch {
// Language not available
console.warn(
`[shiki] Language "${lang}" not available, falling back to plaintext`,
);
return false;
}
}
/**
* Highlight code with lazy language loading
* Returns HTML string
*/
export async function highlightCode(
code: string,
language: string | null | undefined,
): Promise<string> {
const lang = normalizeLanguage(language);
const hl = await getHighlighter();
// Try to load the language if not already loaded
const loaded = await loadLanguage(lang);
const effectiveLang = loaded ? lang : "text";
return hl.codeToHtml(code, {
lang: effectiveLang,
theme: "grimoire-dark",
});
}
/**
* Check if a language is loaded
*/
export function isLanguageLoaded(lang: string): boolean {
return loadedLanguages.has(normalizeLanguage(lang));
}
/**
* Preload languages (e.g., before rendering known content)
*/
export async function preloadLanguages(langs: string[]): Promise<void> {
await getHighlighter();
await Promise.all(langs.map((l) => loadLanguage(normalizeLanguage(l))));
}

View File

@@ -1,144 +0,0 @@
/* Grimoire Prism Theme - Uses CSS theme variables */
code[class*="language-"],
pre[class*="language-"] {
color: hsl(var(--foreground));
background: none;
text-shadow: none;
font-family: "Oxygen Mono", monospace;
font-size: 0.75rem;
line-height: 1.5;
white-space: pre;
word-spacing: normal;
word-break: normal;
tab-size: 4;
hyphens: none;
}
/* Diff-specific tokens */
/* Deleted lines (red) - subtle background, no strikethrough */
.token.deleted {
color: hsl(var(--diff-deleted));
background: hsl(var(--diff-deleted-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
}
/* Added lines (green) - subtle background */
.token.inserted {
color: hsl(var(--diff-inserted));
background: hsl(var(--diff-inserted-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
}
/* Hunk headers (@@ -1,5 +1,7 @@) - cyan/blue */
.token.diff.coord,
.token.coord {
color: hsl(var(--diff-meta));
background: hsl(var(--diff-meta-bg));
display: block;
margin: 0 -1rem;
padding: 0 1rem;
font-weight: 600;
font-style: normal;
}
/* File headers (diff --git, ---, +++) */
.token.diff.range,
.token.prefix.unchanged,
.language-diff .token.unchanged {
color: hsl(var(--muted-foreground));
font-weight: normal;
}
/* Prefix characters (+/-) */
.language-diff .token.prefix {
font-weight: 700;
opacity: 0.7;
}
/* General syntax tokens */
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: hsl(var(--syntax-comment));
}
.token.punctuation {
color: hsl(var(--syntax-punctuation));
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol {
color: hsl(var(--syntax-property));
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin {
color: hsl(var(--syntax-string));
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: hsl(var(--syntax-operator));
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: hsl(var(--syntax-keyword));
}
.token.function,
.token.class-name {
color: hsl(var(--syntax-function));
font-weight: bold;
}
.token.regex,
.token.important,
.token.variable {
color: hsl(var(--syntax-variable));
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
/* Line highlighting */
pre[class*="language-"] > code {
display: block;
}
/* Optional: Line numbers support */
.line-numbers .line-numbers-rows {
border-right: 1px solid hsl(var(--border));
}
.line-numbers-rows > span:before {
color: hsl(var(--muted-foreground));
}

View File

@@ -0,0 +1,81 @@
/* Shiki syntax highlighting - Grimoire dark theme overrides */
/* Base container styling */
.shiki-container pre {
background: transparent !important;
margin: 0;
padding: 0;
}
.shiki-container code {
font-family: "Oxygen Mono", monospace;
font-size: 0.75rem;
line-height: 1.5;
white-space: pre;
word-spacing: normal;
word-break: normal;
tab-size: 4;
}
/* Ensure shiki's inline styles don't conflict */
.shiki-container .shiki {
background: transparent !important;
}
.shiki-container .shiki code {
background: transparent !important;
}
/* Loading state - subtle pulse animation */
.shiki-loading {
animation: shiki-pulse 1.5s ease-in-out infinite;
}
@keyframes shiki-pulse {
0%,
100% {
opacity: 0.7;
}
50% {
opacity: 0.5;
}
}
/* 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);
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);
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);
display: block;
margin: 0 -1rem;
padding: 0 1rem;
font-weight: 600;
}
/* Optional: Line numbers */
.shiki-container.line-numbers .line::before {
content: attr(data-line);
display: inline-block;
width: 2rem;
margin-right: 1rem;
text-align: right;
color: hsl(var(--muted-foreground));
border-right: 1px solid hsl(var(--border));
padding-right: 0.5rem;
}