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:
Gabriel Brown
2026-06-24 07:45:24 -04:00
parent ae90681d9b
commit 1072cf10cd
3 changed files with 197 additions and 4 deletions
+9 -2
View File
@@ -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());
};