theme: terminal and editor follow the app's color scheme, re-theming in place
The app resolves light/dark from prefers-color-scheme and the user's choice (body[data-ds-dark-theme]), but xterm and CodeMirror were pinned to hardcoded dark themes — a light-mode user got a dark card inside a light panel. New theme.ts reads the resolved scheme + token values and notifies on flips (the presenter toggles the body attribute); the terminal now takes its surface colors from the tokens and a curated one-dark/one-light ANSI palette, and the editor gets a token-driven surface with the same curated syntax palettes. Both re-theme in place on a scheme flip: the terminal re-applies its theme, the editor reconfigures a Compartment holding only the scheme-dependent extensions, so the document, undo history and scroll survive. one-dark is dropped (superseded by the curated highlight style).
This commit is contained in:
+1
-1
@@ -48,8 +48,8 @@
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"node-pty": "^1.1.0",
|
||||
|
||||
Generated
+3
-13
@@ -65,12 +65,12 @@ importers:
|
||||
'@codemirror/state':
|
||||
specifier: ^6.7.1
|
||||
version: 6.7.1
|
||||
'@codemirror/theme-one-dark':
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3
|
||||
'@codemirror/view':
|
||||
specifier: ^6.43.8
|
||||
version: 6.43.8
|
||||
'@lezer/highlight':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0(@xterm/xterm@5.4.0)
|
||||
@@ -204,9 +204,6 @@ packages:
|
||||
'@codemirror/state@6.7.1':
|
||||
resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==}
|
||||
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
|
||||
|
||||
'@codemirror/view@6.43.8':
|
||||
resolution: {integrity: sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==}
|
||||
|
||||
@@ -1175,13 +1172,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.3
|
||||
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/state': 6.7.1
|
||||
'@codemirror/view': 6.43.8
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
||||
'@codemirror/view@6.43.8':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.1
|
||||
|
||||
@@ -12,10 +12,11 @@ import clsx from 'clsx'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView as CodeMirrorView, keymap } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { languageForPath } from './lang.ts'
|
||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
@@ -42,6 +43,12 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<CodeMirrorView | null>(null)
|
||||
const savingRef = useRef(false)
|
||||
/** The theme compartment of the current view (reconfigured on scheme flip). */
|
||||
const themeCompRef = useRef<CmThemeCompartment | null>(null)
|
||||
/** The app's resolved color scheme; the editor re-themes in place on flips. */
|
||||
const [dark, setDark] = useState(() => isDarkScheme())
|
||||
|
||||
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -77,12 +84,16 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
// Create the CodeMirror editor once the file is loaded. The view owns the
|
||||
// document; React only tracks dirty/draft state through the update
|
||||
// listener. For markdown the view stays mounted while previewing (hidden),
|
||||
// so unsaved edits survive the preview/edit toggle.
|
||||
// so unsaved edits survive the preview/edit toggle. The theme + syntax
|
||||
// colors live in a compartment so a scheme flip reconfigures only that
|
||||
// part — the document, undo history and scroll position survive.
|
||||
useEffect(() => {
|
||||
if (load.status !== 'ready' || (load.kind !== 'text' && load.kind !== 'md')) return
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
const language = languageForPath(path)
|
||||
const themeComp = new CmThemeCompartment()
|
||||
themeCompRef.current = themeComp
|
||||
const state = EditorState.create({
|
||||
doc: load.content,
|
||||
extensions: [
|
||||
@@ -90,13 +101,9 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
history(),
|
||||
EditorState.tabSize.of(2),
|
||||
CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }),
|
||||
CodeMirrorView.theme({
|
||||
'&': { height: '100%', fontSize: '13px', backgroundColor: 'transparent' },
|
||||
'.cm-scroller': { overflow: 'auto', fontFamily: '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace' },
|
||||
'.cm-content': { caretColor: 'var(--dsw-alias-label-primary)' },
|
||||
}),
|
||||
cmSurfaceTheme,
|
||||
themeComp.of(dark),
|
||||
...(language !== null ? [language] : []),
|
||||
oneDark,
|
||||
CodeMirrorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
setDraft(update.state.doc.toString())
|
||||
@@ -119,9 +126,22 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
themeCompRef.current = null
|
||||
}
|
||||
// The keymap's save() reads live refs; scope/path are stable for a
|
||||
// tab's lifetime, and the dark flip is handled by the reconfigure
|
||||
// effect below (recreating the view here would drop the draft).
|
||||
}, [load])
|
||||
|
||||
// Scheme flip: re-theme in place (the compartment holds only the
|
||||
// scheme-dependent extensions; everything else is untouched).
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
const themeComp = themeCompRef.current
|
||||
if (view === null || themeComp === null) return
|
||||
view.dispatch({ effects: themeComp.reconfigure(dark) })
|
||||
}, [dark])
|
||||
|
||||
// The editor may have been display:none while previewing; re-measure when
|
||||
// it becomes visible again (CodeMirror sizes itself on reveal).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -9,17 +9,57 @@
|
||||
* attempts, so the banner never spins forever.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal } from 'xterm'
|
||||
import { Terminal, type ITheme } from 'xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import 'xterm/css/xterm.css'
|
||||
import { t } from './locales.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { sidebarStore } from './state.ts'
|
||||
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive unreasoned failures before showing the error banner. */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/**
|
||||
* Curated ANSI palettes for the terminal. The surface colors (background,
|
||||
* foreground, cursor, selection) ride the theme tokens so the terminal
|
||||
* blends with the panel in both schemes; the 16 ANSI colors are the same
|
||||
* designed palettes the app's code surfaces use (one-dark family for dark,
|
||||
* one-light family for light), read live so a scheme flip re-themes in
|
||||
* place.
|
||||
*/
|
||||
const ANSI_DARK: Record<string, string> = {
|
||||
black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
|
||||
blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
|
||||
brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
|
||||
brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2', brightWhite: '#ffffff',
|
||||
}
|
||||
|
||||
const ANSI_LIGHT: Record<string, string> = {
|
||||
black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
|
||||
blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
|
||||
brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
|
||||
brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
|
||||
brightCyan: '#0997b3', brightWhite: '#fafafa',
|
||||
}
|
||||
|
||||
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
|
||||
function xtermTheme(): ITheme {
|
||||
const dark = isDarkScheme()
|
||||
const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
|
||||
const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
|
||||
return {
|
||||
background,
|
||||
foreground,
|
||||
cursor: foreground,
|
||||
cursorAccent: background,
|
||||
selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
|
||||
...(dark ? ANSI_DARK : ANSI_LIGHT),
|
||||
}
|
||||
}
|
||||
|
||||
export function TerminalView(props: { scope: SessionScope; tabId: string }) {
|
||||
const { scope, tabId } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
@@ -34,15 +74,22 @@ export function TerminalView(props: { scope: SessionScope; tabId: string }) {
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
allowTransparency: true,
|
||||
convertEol: false,
|
||||
scrollback: 4000,
|
||||
theme: xtermTheme(),
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(host)
|
||||
fit.fit()
|
||||
// Re-theme in place when the app's scheme flips (tokens + palette).
|
||||
const applyTheme = (): void => {
|
||||
term.options.theme = xtermTheme()
|
||||
term.refresh(0, term.rows - 1)
|
||||
}
|
||||
const schemeSub = subscribeColorScheme(applyTheme)
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
let closed = false
|
||||
@@ -125,6 +172,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string }) {
|
||||
closed = true
|
||||
window.clearTimeout(retry)
|
||||
observer.disconnect()
|
||||
schemeSub()
|
||||
inputSub.dispose()
|
||||
// The close frame tells the host the owning tab is GONE (immediate
|
||||
// quota release). A bare unmount — conversation switch, re-render,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* CodeMirror 6 theme pieces for the sidebar editor. The editor surface
|
||||
* (background, caret, gutter) rides the DSH theme tokens so it blends with
|
||||
* the panel in both schemes; only the syntax token colors need concrete
|
||||
* values, and those come from the same designed palettes the app's code
|
||||
* surfaces use — the one-dark family for dark, the one-light family for
|
||||
* light. The scheme flip reconfigures these via a compartment (see
|
||||
* EditorView), so the document, undo history and scroll survive re-theming.
|
||||
*/
|
||||
import { Compartment } from '@codemirror/state'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags, type Tag } from '@lezer/highlight'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
/** Token-driven surface shared by both schemes (pure CSS values). */
|
||||
export const cmSurfaceTheme = EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '13px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--dsw-alias-label-primary)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily: 'var(--ds-font-family-code)',
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--dsw-alias-label-primary)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--dsw-alias-label-tertiary)',
|
||||
border: 'none',
|
||||
},
|
||||
})
|
||||
|
||||
/** Scheme-specific surface tints (selection, active line). */
|
||||
function cmSurfaceTint(dark: boolean): ReturnType<typeof EditorView.theme> {
|
||||
return EditorView.theme({
|
||||
'.cm-selectionBackground, .cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
|
||||
},
|
||||
'.cm-activeLine, .cm-activeLineGutter': {
|
||||
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** One syntax rule: a tag (or tag set) mapped to a concrete color/style. */
|
||||
interface HighlightRule {
|
||||
tag: Tag | readonly Tag[]
|
||||
color?: string
|
||||
fontStyle?: string
|
||||
}
|
||||
|
||||
/** one-dark syntax palette (mirrors @codemirror/theme-one-dark). */
|
||||
const HIGHLIGHTS_DARK: HighlightRule[] = [
|
||||
{ tag: tags.comment, color: '#5c6370', fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: '#c678dd' },
|
||||
{ tag: tags.string, color: '#98c379' },
|
||||
{ tag: tags.number, color: '#d19a66' },
|
||||
{ tag: tags.bool, color: '#d19a66' },
|
||||
{ tag: tags.atom, color: '#d19a66' },
|
||||
{ tag: tags.typeName, color: '#e5c07b' },
|
||||
{ tag: tags.className, color: '#e5c07b' },
|
||||
{ tag: tags.propertyName, color: '#e06c75' },
|
||||
{ tag: tags.function(tags.variableName), color: '#61afef' },
|
||||
{ tag: tags.variableName, color: '#e06c75' },
|
||||
{ tag: tags.operator, color: '#56b6c2' },
|
||||
{ tag: tags.tagName, color: '#e06c75' },
|
||||
{ tag: tags.attributeName, color: '#d19a66' },
|
||||
{ tag: tags.heading, color: '#e06c75', fontStyle: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: '#61afef', fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: '#e5c07b' },
|
||||
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
|
||||
]
|
||||
|
||||
/** one-light syntax palette (the light counterpart of one-dark). */
|
||||
const HIGHLIGHTS_LIGHT: HighlightRule[] = [
|
||||
{ tag: tags.comment, color: '#a0a1a7', fontStyle: 'italic' },
|
||||
{ tag: tags.keyword, color: '#a626a4' },
|
||||
{ tag: tags.string, color: '#50a14f' },
|
||||
{ tag: tags.number, color: '#986801' },
|
||||
{ tag: tags.bool, color: '#0184bc' },
|
||||
{ tag: tags.atom, color: '#0184bc' },
|
||||
{ tag: tags.typeName, color: '#c18401' },
|
||||
{ tag: tags.className, color: '#c18401' },
|
||||
{ tag: tags.propertyName, color: '#e45649' },
|
||||
{ tag: tags.function(tags.variableName), color: '#c18401' },
|
||||
{ tag: tags.variableName, color: '#e45649' },
|
||||
{ tag: tags.operator, color: '#383a42' },
|
||||
{ tag: tags.tagName, color: '#e45649' },
|
||||
{ tag: tags.attributeName, color: '#986801' },
|
||||
{ tag: tags.heading, color: '#e45649', fontStyle: 'bold' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontStyle: 'bold' },
|
||||
{ tag: tags.link, color: '#4078f2', fontStyle: 'underline' },
|
||||
{ tag: tags.meta, color: '#c18401' },
|
||||
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
|
||||
]
|
||||
|
||||
/** The scheme-dependent extension pair (surface tint + syntax highlight). */
|
||||
function cmThemeExtensions(dark: boolean): Array<ReturnType<typeof EditorView.theme>> {
|
||||
return [
|
||||
cmSurfaceTint(dark),
|
||||
syntaxHighlighting(HighlightStyle.define(dark ? HIGHLIGHTS_DARK : HIGHLIGHTS_LIGHT)),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* A Compartment holding the two scheme-dependent extensions. Created once
|
||||
* per editor view; a scheme flip dispatches `reconfigure(dark)` on it, so
|
||||
* the document, undo history, scroll and keymaps survive re-theming.
|
||||
*/
|
||||
export class CmThemeCompartment {
|
||||
private readonly compartment = new Compartment()
|
||||
|
||||
/** `of(...)` payload for EditorState.create. */
|
||||
of(dark: boolean): ReturnType<Compartment['of']> {
|
||||
return this.compartment.of(cmThemeExtensions(dark))
|
||||
}
|
||||
|
||||
/** Reconfigure for a new scheme. */
|
||||
reconfigure(dark: boolean): ReturnType<Compartment['reconfigure']> {
|
||||
return this.compartment.reconfigure(cmThemeExtensions(dark))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Live theme access for surfaces that cannot consume the token colors
|
||||
* directly — xterm's palette and CodeMirror's theme extensions need concrete
|
||||
* values, but the app's scheme flips at runtime (ui-layout's ThemePresenter
|
||||
* projects prefers-color-scheme and the user's choice onto
|
||||
* body[data-ds-dark-theme] and html { color-scheme }). This module reads the
|
||||
* resolved scheme and token values, and notifies subscribers on flips, so
|
||||
* the terminal and the editor re-theme in place instead of freezing in the
|
||||
* scheme they happened to be created under.
|
||||
*/
|
||||
|
||||
/** Whether the app shell resolved to the dark scheme.
|
||||
*
|
||||
* The presenter sets `html { color-scheme }` together with the body palette
|
||||
* attribute, so a set color-scheme means the decision is authoritative (an
|
||||
* absent attribute is then LIGHT even when the OS prefers dark — the user
|
||||
* chose light). Before the presenter has run, fall back to the OS media
|
||||
* query as the best guess.
|
||||
*/
|
||||
export function isDarkScheme(): boolean {
|
||||
if (typeof document === 'undefined') return true
|
||||
const decided = document.documentElement.style.colorScheme !== ''
|
||||
if (decided) return document.body.hasAttribute('data-ds-dark-theme')
|
||||
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
/** One token's computed value on <body> ('' while the theme has not applied). */
|
||||
export function tokenValue(name: string): string {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return getComputedStyle(document.body).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to color-scheme flips (the presenter toggles the body
|
||||
* attribute). The callback fires after the attribute changed; re-read the
|
||||
* scheme inside it.
|
||||
* @returns the disposer.
|
||||
*/
|
||||
export function subscribeColorScheme(callback: () => void): () => void {
|
||||
if (typeof document === 'undefined') return () => {}
|
||||
const observer = new MutationObserver(() => { callback() })
|
||||
observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
|
||||
return () => { observer.disconnect() }
|
||||
}
|
||||
Reference in New Issue
Block a user