Editor: site-matched theme, Victor Mono font, no false TS errors
- Add spoon-dark/spoon-light Monaco themes built from the site's design tokens (teal --primary accent), switched by next-themes resolvedTheme - Use Victor Mono (with ligatures + italic comments) for the editor font - Disable Monaco's in-browser TS *semantic* diagnostics, which were false positives (no node_modules / path aliases in the browser) e.g. 'Cannot find module ~/server/auth'; keep real syntax-error reporting
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { Geist, Geist_Mono, Victor_Mono } from 'next/font/google';
|
||||
import { env } from '@/env';
|
||||
|
||||
import '@/app/styles.css';
|
||||
@@ -30,6 +30,13 @@ const geistMono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-geist-mono',
|
||||
});
|
||||
// Used by the workspace code editor (and, later, the terminal). Includes the
|
||||
// italic cursive style for comments via Monaco's italic token styling.
|
||||
const victorMono = Victor_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-victor-mono',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const RootLayout = ({
|
||||
children,
|
||||
@@ -44,7 +51,7 @@ const RootLayout = ({
|
||||
>
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${victorMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
|
||||
@@ -2,15 +2,26 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Button, Switch } from '@spoon/ui';
|
||||
|
||||
import type { MonacoLike } from './monaco-theme';
|
||||
import { languageForPath } from './languages';
|
||||
import {
|
||||
configureSpoonMonaco,
|
||||
remeasureFontsWhenReady,
|
||||
SPOON_DARK,
|
||||
SPOON_LIGHT,
|
||||
} from './monaco-theme';
|
||||
|
||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const EDITOR_FONT_FAMILY =
|
||||
"var(--font-victor-mono), 'Geist Mono', ui-monospace, SFMono-Regular, monospace";
|
||||
|
||||
type MonacoEditorInstance = {
|
||||
getModel?: () => unknown;
|
||||
};
|
||||
@@ -42,6 +53,8 @@ export const CodeEditor = ({
|
||||
const editorRef = useRef<MonacoEditorInstance | null>(null);
|
||||
const vimRef = useRef<VimMode | null>(null);
|
||||
const statusRef = useRef<HTMLDivElement | null>(null);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK;
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
@@ -115,14 +128,23 @@ export const CodeEditor = ({
|
||||
path={path}
|
||||
language={languageForPath(path)}
|
||||
value={content}
|
||||
theme='vs-dark'
|
||||
theme={editorTheme}
|
||||
beforeMount={(monaco) => {
|
||||
configureSpoonMonaco(monaco as unknown as MonacoLike);
|
||||
}}
|
||||
options={{
|
||||
readOnly,
|
||||
minimap: { enabled: false },
|
||||
fontFamily: EDITOR_FONT_FAMILY,
|
||||
fontLigatures: true,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
smoothScrolling: true,
|
||||
cursorSmoothCaretAnimation: 'on',
|
||||
padding: { top: 12, bottom: 12 },
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
quickSuggestions: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
@@ -131,8 +153,9 @@ export const CodeEditor = ({
|
||||
bracketPairColorization: { enabled: true },
|
||||
renderWhitespace: 'selection',
|
||||
}}
|
||||
onMount={(editor) => {
|
||||
onMount={(editor, monaco) => {
|
||||
editorRef.current = editor as MonacoEditorInstance;
|
||||
remeasureFontsWhenReady(monaco as unknown as MonacoLike);
|
||||
}}
|
||||
onChange={(next) => {
|
||||
const nextValue = next ?? '';
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
export const SPOON_DARK = 'spoon-dark';
|
||||
export const SPOON_LIGHT = 'spoon-light';
|
||||
|
||||
type ThemeRule = { token: string; foreground?: string; fontStyle?: string };
|
||||
type ThemeData = {
|
||||
base: 'vs' | 'vs-dark';
|
||||
inherit: boolean;
|
||||
rules: ThemeRule[];
|
||||
colors: Record<string, string>;
|
||||
};
|
||||
type DiagnosticsDefaults = {
|
||||
setDiagnosticsOptions: (options: {
|
||||
noSemanticValidation?: boolean;
|
||||
noSyntaxValidation?: boolean;
|
||||
noSuggestionDiagnostics?: boolean;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
// Minimal typed surface of the bits of the Monaco namespace we touch. Avoids
|
||||
// depending on monaco-editor's full (and, under our eslint program, unresolved)
|
||||
// type graph while keeping these calls fully type-checked.
|
||||
export type MonacoLike = {
|
||||
editor: {
|
||||
defineTheme: (name: string, data: ThemeData) => void;
|
||||
remeasureFonts: () => void;
|
||||
};
|
||||
languages: {
|
||||
typescript: {
|
||||
typescriptDefaults: DiagnosticsDefaults;
|
||||
javascriptDefaults: DiagnosticsDefaults;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Hex equivalents of the site's oklch design tokens (tools/tailwind/theme.css),
|
||||
// so the editor matches the rest of the app. Brand accent is the teal --primary.
|
||||
const dark = {
|
||||
bg: '#080e14', // --background
|
||||
surface: '#10171e', // --card
|
||||
surfaceAlt: '#192028', // --muted
|
||||
border: '#29313a', // --border
|
||||
fg: '#eef3f5', // --foreground
|
||||
fgDim: '#cdd6dc',
|
||||
muted: '#93a1a9', // --muted-foreground
|
||||
comment: '#6b7d88',
|
||||
teal: '#1fb895', // --primary
|
||||
mint: '#8fd6b4',
|
||||
cyan: '#5fd0e0',
|
||||
blue: '#6aa6ff',
|
||||
amber: '#e3b341',
|
||||
red: '#f3625d', // --destructive
|
||||
};
|
||||
|
||||
const light = {
|
||||
bg: '#f7fbfa', // --background
|
||||
surface: '#ffffff', // --card
|
||||
surfaceAlt: '#eaeff3', // --muted
|
||||
border: '#d4dce2', // --border
|
||||
fg: '#0d1218', // --foreground
|
||||
fgDim: '#26323c',
|
||||
muted: '#555f68', // --muted-foreground
|
||||
comment: '#6b7680',
|
||||
teal: '#007560', // --primary
|
||||
mint: '#2f8f6e',
|
||||
cyan: '#0f7d92',
|
||||
blue: '#2f6bd8',
|
||||
amber: '#9a6b00',
|
||||
red: '#d73337', // --destructive
|
||||
};
|
||||
|
||||
const hex = (value: string) => value.slice(1);
|
||||
|
||||
const themeData = (p: typeof dark, base: 'vs' | 'vs-dark'): ThemeData => ({
|
||||
base,
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: '', foreground: hex(p.fg) },
|
||||
{ token: 'comment', foreground: hex(p.comment), fontStyle: 'italic' },
|
||||
{ token: 'keyword', foreground: hex(p.teal) },
|
||||
{ token: 'keyword.control', foreground: hex(p.teal) },
|
||||
{ token: 'storage', foreground: hex(p.teal) },
|
||||
{ token: 'string', foreground: hex(p.mint) },
|
||||
{ token: 'string.key.json', foreground: hex(p.cyan) },
|
||||
{ token: 'string.value.json', foreground: hex(p.mint) },
|
||||
{ token: 'number', foreground: hex(p.amber) },
|
||||
{ token: 'constant', foreground: hex(p.amber) },
|
||||
{ token: 'regexp', foreground: hex(p.amber) },
|
||||
{ token: 'type', foreground: hex(p.cyan) },
|
||||
{ token: 'type.identifier', foreground: hex(p.cyan) },
|
||||
{ token: 'interface', foreground: hex(p.cyan) },
|
||||
{ token: 'namespace', foreground: hex(p.cyan) },
|
||||
{ token: 'function', foreground: hex(p.blue) },
|
||||
{ token: 'variable', foreground: hex(p.fgDim) },
|
||||
{ token: 'variable.parameter', foreground: hex(p.fgDim) },
|
||||
{ token: 'property', foreground: hex(p.fgDim) },
|
||||
{ token: 'operator', foreground: hex(p.muted) },
|
||||
{ token: 'delimiter', foreground: hex(p.muted) },
|
||||
{ token: 'tag', foreground: hex(p.teal) },
|
||||
{ token: 'attribute.name', foreground: hex(p.amber) },
|
||||
{ token: 'attribute.value', foreground: hex(p.mint) },
|
||||
{ token: 'metatag', foreground: hex(p.teal) },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': p.bg,
|
||||
'editor.foreground': p.fg,
|
||||
'editorCursor.foreground': p.teal,
|
||||
'editorLineNumber.foreground': p.border,
|
||||
'editorLineNumber.activeForeground': p.muted,
|
||||
'editor.lineHighlightBackground': p.surface,
|
||||
'editor.selectionBackground': `${p.teal}33`,
|
||||
'editor.inactiveSelectionBackground': `${p.teal}22`,
|
||||
'editor.findMatchBackground': `${p.teal}55`,
|
||||
'editor.findMatchHighlightBackground': `${p.teal}33`,
|
||||
'editorWhitespace.foreground': p.border,
|
||||
'editorIndentGuide.background1': p.surfaceAlt,
|
||||
'editorIndentGuide.activeBackground1': p.border,
|
||||
'editorGutter.background': p.bg,
|
||||
'editorWidget.background': p.surface,
|
||||
'editorWidget.border': p.border,
|
||||
'editorHoverWidget.background': p.surface,
|
||||
'editorHoverWidget.border': p.border,
|
||||
'editorSuggestWidget.background': p.surface,
|
||||
'editorSuggestWidget.border': p.border,
|
||||
'editorSuggestWidget.selectedBackground': p.surfaceAlt,
|
||||
'editorBracketMatch.background': `${p.teal}22`,
|
||||
'editorBracketMatch.border': p.teal,
|
||||
'editorError.foreground': p.red,
|
||||
'scrollbarSlider.background': `${p.border}aa`,
|
||||
'scrollbarSlider.hoverBackground': p.border,
|
||||
'scrollbarSlider.activeBackground': p.muted,
|
||||
},
|
||||
});
|
||||
|
||||
let configured = false;
|
||||
|
||||
/**
|
||||
* Defines the site-matched editor themes and quiets the in-browser TypeScript
|
||||
* service. Monaco's TS worker has no access to the project's node_modules or the
|
||||
* `~`/`@` path aliases, so its semantic diagnostics (e.g. "Cannot find module
|
||||
* '~/server/auth'") are always false positives here. We keep real syntax errors
|
||||
* and disable the unresolvable semantic noise. Runs once per page load.
|
||||
*/
|
||||
export const configureSpoonMonaco = (monaco: MonacoLike) => {
|
||||
monaco.editor.defineTheme(SPOON_DARK, themeData(dark, 'vs-dark'));
|
||||
monaco.editor.defineTheme(SPOON_LIGHT, themeData(light, 'vs'));
|
||||
if (configured) return;
|
||||
configured = true;
|
||||
for (const defaults of [
|
||||
monaco.languages.typescript.typescriptDefaults,
|
||||
monaco.languages.typescript.javascriptDefaults,
|
||||
]) {
|
||||
defaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: true,
|
||||
noSuggestionDiagnostics: true,
|
||||
noSyntaxValidation: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** Re-measures glyph widths once the web font finishes loading so they align. */
|
||||
export const remeasureFontsWhenReady = (monaco: MonacoLike) => {
|
||||
void document.fonts.ready.then(() => monaco.editor.remeasureFonts());
|
||||
};
|
||||
Reference in New Issue
Block a user