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 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 { env } from '@/env';
|
||||||
|
|
||||||
import '@/app/styles.css';
|
import '@/app/styles.css';
|
||||||
@@ -30,6 +30,13 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
variable: '--font-geist-mono',
|
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 = ({
|
const RootLayout = ({
|
||||||
children,
|
children,
|
||||||
@@ -44,7 +51,7 @@ const RootLayout = ({
|
|||||||
>
|
>
|
||||||
<html lang='en' suppressHydrationWarning>
|
<html lang='en' suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} ${victorMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute='class'
|
attribute='class'
|
||||||
|
|||||||
@@ -2,15 +2,26 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
import { Button, Switch } from '@spoon/ui';
|
import { Button, Switch } from '@spoon/ui';
|
||||||
|
|
||||||
|
import type { MonacoLike } from './monaco-theme';
|
||||||
import { languageForPath } from './languages';
|
import { languageForPath } from './languages';
|
||||||
|
import {
|
||||||
|
configureSpoonMonaco,
|
||||||
|
remeasureFontsWhenReady,
|
||||||
|
SPOON_DARK,
|
||||||
|
SPOON_LIGHT,
|
||||||
|
} from './monaco-theme';
|
||||||
|
|
||||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const EDITOR_FONT_FAMILY =
|
||||||
|
"var(--font-victor-mono), 'Geist Mono', ui-monospace, SFMono-Regular, monospace";
|
||||||
|
|
||||||
type MonacoEditorInstance = {
|
type MonacoEditorInstance = {
|
||||||
getModel?: () => unknown;
|
getModel?: () => unknown;
|
||||||
};
|
};
|
||||||
@@ -42,6 +53,8 @@ export const CodeEditor = ({
|
|||||||
const editorRef = useRef<MonacoEditorInstance | null>(null);
|
const editorRef = useRef<MonacoEditorInstance | null>(null);
|
||||||
const vimRef = useRef<VimMode | null>(null);
|
const vimRef = useRef<VimMode | null>(null);
|
||||||
const statusRef = useRef<HTMLDivElement | null>(null);
|
const statusRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
@@ -115,14 +128,23 @@ export const CodeEditor = ({
|
|||||||
path={path}
|
path={path}
|
||||||
language={languageForPath(path)}
|
language={languageForPath(path)}
|
||||||
value={content}
|
value={content}
|
||||||
theme='vs-dark'
|
theme={editorTheme}
|
||||||
|
beforeMount={(monaco) => {
|
||||||
|
configureSpoonMonaco(monaco as unknown as MonacoLike);
|
||||||
|
}}
|
||||||
options={{
|
options={{
|
||||||
readOnly,
|
readOnly,
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
|
fontFamily: EDITOR_FONT_FAMILY,
|
||||||
|
fontLigatures: true,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
lineHeight: 1.6,
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
wordWrap: 'on',
|
wordWrap: 'on',
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
|
smoothScrolling: true,
|
||||||
|
cursorSmoothCaretAnimation: 'on',
|
||||||
|
padding: { top: 12, bottom: 12 },
|
||||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||||
quickSuggestions: true,
|
quickSuggestions: true,
|
||||||
suggestOnTriggerCharacters: true,
|
suggestOnTriggerCharacters: true,
|
||||||
@@ -131,8 +153,9 @@ export const CodeEditor = ({
|
|||||||
bracketPairColorization: { enabled: true },
|
bracketPairColorization: { enabled: true },
|
||||||
renderWhitespace: 'selection',
|
renderWhitespace: 'selection',
|
||||||
}}
|
}}
|
||||||
onMount={(editor) => {
|
onMount={(editor, monaco) => {
|
||||||
editorRef.current = editor as MonacoEditorInstance;
|
editorRef.current = editor as MonacoEditorInstance;
|
||||||
|
remeasureFontsWhenReady(monaco as unknown as MonacoLike);
|
||||||
}}
|
}}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
const nextValue = 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