From 1072cf10cdadbd8ae5e4d307abc0f8b375ad6d9c Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Wed, 24 Jun 2026 07:45:24 -0400 Subject: [PATCH] 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 --- apps/next/src/app/layout.tsx | 11 +- .../agent-workspace/code-editor.tsx | 27 ++- .../agent-workspace/monaco-theme.ts | 163 ++++++++++++++++++ 3 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 apps/next/src/components/agent-workspace/monaco-theme.ts diff --git a/apps/next/src/app/layout.tsx b/apps/next/src/app/layout.tsx index 7c0d8d2..4afb928 100644 --- a/apps/next/src/app/layout.tsx +++ b/apps/next/src/app/layout.tsx @@ -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 = ({ > 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(null); const vimRef = useRef(null); const statusRef = useRef(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 ?? ''; diff --git a/apps/next/src/components/agent-workspace/monaco-theme.ts b/apps/next/src/components/agent-workspace/monaco-theme.ts new file mode 100644 index 0000000..9aad274 --- /dev/null +++ b/apps/next/src/components/agent-workspace/monaco-theme.ts @@ -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; +}; +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()); +};