diff --git a/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue b/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue index 7609633ce7..81e1930d64 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue +++ b/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue @@ -223,11 +223,44 @@ const cleanupCustomItems = () => { }; const handleGlobalKeyDown = (event) => { - // ESCAPE: always close popover or menu - if (event.key === 'Escape' && isOpen.value) { + // SD-2747: ESCAPE / ArrowLeft dismiss the menu. When the menu was opened by the + // slash hotkey the original `/` was preventDefault'd, so we reinsert it at the + // anchor — matches Google Docs' trigger-menu behavior. When the menu was + // opened by right-click no keystroke was suppressed and dismissal must NOT + // mutate the document. + // + // AIDEV-NOTE: SD-2747 P2. The gate is `pluginState.trigger === 'slash'`; without + // this, pressing Escape on a right-click context menu inserts an unwanted `/` + // at the click position. ArrowLeft is included here because the hidden search + // input owns focus while the menu is open, so the PM plugin's matching branch + // never fires in practice — every dismissal in the live flow comes through + // this handler. + if ((event.key === 'Escape' || event.key === 'ArrowLeft') && isOpen.value) { event.preventDefault(); event.stopPropagation(); - closeMenu(); + const pluginState = ContextMenuPluginKey.getState(props.editor?.state); + const anchorPos = pluginState?.anchorPos; + const trigger = pluginState?.trigger; + closeMenu({ restoreCursor: false }); + + if (trigger === 'slash' && props.editor && anchorPos !== null && anchorPos !== undefined) { + const tr = props.editor.state.tr.insertText('/', anchorPos); + const insertedAt = anchorPos + 1; + tr.setSelection(props.editor.state.selection.constructor.near(tr.doc.resolve(insertedAt))); + props.editor.dispatch(tr); + } + props.editor?.focus?.(); + return; + } + + // SD-2747: BACKSPACE / DELETE dismisses the menu without inserting the slash. Focus is on + // the hidden search input while the menu is open, so the PM plugin's handleKeyDown does + // not see these keys — we have to handle them here. Empty search means an explicit + // dismissal; with a typed filter we let the input handle the deletion normally. + if ((event.key === 'Backspace' || event.key === 'Delete') && isOpen.value && !searchQuery.value) { + event.preventDefault(); + event.stopPropagation(); + closeMenu({ restoreCursor: true }); props.editor?.focus?.(); return; } @@ -590,6 +623,16 @@ onBeforeUnmount(() => { @keydown.stop /> + +
+ + + + @@ -648,6 +695,39 @@ onBeforeUnmount(() => { overflow-y: auto; } +.context-menu-search-header { + display: flex; + align-items: baseline; + gap: 4px; + padding: 6px 10px; + border-bottom: 1px solid var(--sd-ui-menu-border, #eee); + background: var(--sd-ui-menu-header-bg, #fafafa); + font-size: 11px; + color: var(--sd-ui-menu-text-muted, #888); +} + +.context-menu-search-header-label { + flex-shrink: 0; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 10px; +} + +.context-menu-search-header-value { + font-family: var(--sd-ui-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + color: var(--sd-ui-menu-text, #47484a); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.context-menu-empty { + padding: 10px 10px; + color: var(--sd-ui-menu-text-muted, #888); + font-style: italic; + text-align: center; +} + .context-menu-search { padding: 0.5rem; border-bottom: 1px solid var(--sd-ui-menu-border, #eee); diff --git a/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.js b/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.js index 77c1f214c9..9a8387030b 100644 --- a/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.js +++ b/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.js @@ -83,7 +83,6 @@ export function findContainingBlockAncestor(element) { * Configuration options for ContextMenu * @typedef {Object} ContextMenuOptions * @property {boolean} [disabled] - Disable the context menu entirely (inherited from editor.options.disableContextMenu) - * @property {number} [cooldownMs=5000] - Cooldown duration in milliseconds to prevent rapid re-opening * @category Options */ @@ -97,6 +96,11 @@ export function findContainingBlockAncestor(element) { * @property {string} [menuPosition.left] - Left position in pixels (e.g., "100px") * @property {string} [menuPosition.top] - Top position in pixels (e.g., "28px") * @property {boolean} disabled - Whether the menu functionality is disabled + * @property {'slash'|'rightClick'|null} trigger - SD-2747: which gesture opened the menu. + * The slash-keystroke open path preventDefaults `/`, so dismissal owes the user a + * literal `/` at anchorPos. The right-click path suppresses no keystroke, so + * dismissal must NOT mutate the document. Every dismissal branch (PM + Vue) reads + * this field to decide whether to reinsert. */ /** @@ -119,7 +123,6 @@ const MENU_OFFSET_X = 0; // Horizontal offset for slash trigger (aligned with cu const MENU_OFFSET_Y = 28; // Vertical offset for slash trigger const CONTEXT_MENU_OFFSET_X = 10; // Small offset for right-click const CONTEXT_MENU_OFFSET_Y = 10; // Small offset for right-click -const SLASH_COOLDOWN_MS = 5000; // Cooldown period to prevent rapid re-opening /** * @module ContextMenu @@ -146,10 +149,6 @@ export const ContextMenu = Extension.create({ return []; } - // Cooldown flag and timeout for slash trigger - let slashCooldown = false; - let slashCooldownTimeout = null; - /** * Check if the context menu is disabled via editor options * @returns {boolean} True if menu is disabled @@ -167,6 +166,7 @@ export const ContextMenu = Extension.create({ anchorPos: null, menuPosition: null, disabled: isMenuDisabled(), + trigger: null, ...value, }); @@ -307,12 +307,17 @@ export const ContextMenu = Extension.create({ top: `${top + offsetY}px`, }; - // Update state + // Update state. SD-2747 P2: `trigger` distinguishes slash-keystroke + // opens (dismissal reinserts `/`) from right-click opens (dismissal + // is non-mutating). `isRightClick` was computed above from the + // presence of clientX/clientY in the meta payload — the same + // signal the positioning code uses. const newState = { ...value, open: true, anchorPos: meta.pos, menuPosition, + trigger: isRightClick ? 'rightClick' : 'slash', }; // Emit event after state update @@ -327,7 +332,7 @@ export const ContextMenu = Extension.create({ case 'close': { editor.emit('contextMenu:close'); - return ensureStateShape({ ...value, open: false, anchorPos: null }); + return ensureStateShape({ ...value, open: false, anchorPos: null, trigger: null }); } default: @@ -365,11 +370,6 @@ export const ContextMenu = Extension.create({ destroy() { window.removeEventListener('scroll', updatePosition, true); window.removeEventListener('resize', updatePosition); - // Clear cooldown timeout if exists - if (slashCooldownTimeout) { - clearTimeout(slashCooldownTimeout); - slashCooldownTimeout = null; - } }, }; }, @@ -390,11 +390,6 @@ export const ContextMenu = Extension.create({ } const pluginState = this.getState(view.state); - // If cooldown is active and slash is pressed, allow default behavior - if (event.key === '/' && slashCooldown) { - return false; // Let browser handle it - } - if (event.key === '/' && !pluginState.open) { const { $cursor } = view.state.selection; if (!$cursor) return false; @@ -408,14 +403,6 @@ export const ContextMenu = Extension.create({ event.preventDefault(); - // Set cooldown - slashCooldown = true; - if (slashCooldownTimeout) clearTimeout(slashCooldownTimeout); - slashCooldownTimeout = setTimeout(() => { - slashCooldown = false; - slashCooldownTimeout = null; - }, SLASH_COOLDOWN_MS); - // Only dispatch state update - event will be emitted in apply() view.dispatch( view.state.tr.setMeta(ContextMenuPluginKey, { @@ -426,23 +413,35 @@ export const ContextMenu = Extension.create({ return true; } - if (pluginState.open && (event.key === 'Escape' || event.key === 'ArrowLeft')) { - // Store current state before closing - const { anchorPos } = pluginState; + if (!pluginState.open) { + return false; + } - // Close menu - view.dispatch( - view.state.tr.setMeta(ContextMenuPluginKey, { - type: 'close', - }), - ); + // SD-2747: Backspace / Delete dismisses the menu without inserting any character. + // The user pressed `/` to open it; that `/` was preventDefault'd above and never + // entered the document, so there is nothing to remove on the doc side — just close. + if (event.key === 'Backspace' || event.key === 'Delete') { + event.preventDefault(); + view.dispatch(view.state.tr.setMeta(ContextMenuPluginKey, { type: 'close' })); + return true; + } + + // SD-2747: Escape (or ArrowLeft) closes the menu. For slash-triggered opens + // we reinsert a literal `/` at the anchor — matches Google Docs, where the + // slash stays visible when the user dismisses the menu without picking an + // item. For right-click opens no slash was suppressed, so dismissal must + // NOT mutate the document. The `trigger` field on the plugin state + // disambiguates the two paths (SD-2747 P2). + if (event.key === 'Escape' || event.key === 'ArrowLeft') { + const { anchorPos, trigger } = pluginState; + event.preventDefault(); + view.dispatch(view.state.tr.setMeta(ContextMenuPluginKey, { type: 'close' })); - // Restore cursor position and focus - if (anchorPos !== null) { - const tr = view.state.tr.setSelection( - view.state.selection.constructor.near(view.state.doc.resolve(anchorPos)), - ); - view.dispatch(tr); + if (trigger === 'slash' && anchorPos !== null) { + const insertTr = view.state.tr.insertText('/', anchorPos); + const insertedAt = anchorPos + 1; + insertTr.setSelection(view.state.selection.constructor.near(insertTr.doc.resolve(insertedAt))); + view.dispatch(insertTr); view.focus(); } return true; diff --git a/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.test.js b/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.test.js index 659ecd0833..58bba62567 100644 --- a/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.test.js +++ b/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.test.js @@ -180,147 +180,227 @@ describe('ContextMenu extension', () => { expect(editor.emit).toHaveBeenCalledWith('contextMenu:close'); }); - describe('cooldown mechanism', () => { - it('prevents reopening menu during cooldown period', () => { - const baseDoc = doc(p()); - const initialSelection = TextSelection.create(baseDoc, 1); - let state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection }); - - const editor = { - options: {}, - emit: vi.fn(), - view: null, - }; - - const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); - state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection, plugins: [plugin] }); - + describe('dismissal behavior (SD-2747)', () => { + const makeView = (initial) => { + let state = initial; const view = { - state, + get state() { + return state; + }, dispatch: vi.fn((tr) => { state = state.apply(tr); - view.state = state; }), focus: vi.fn(), - dom: { - getBoundingClientRect: () => ({ left: 0, top: 0 }), - }, + dom: { getBoundingClientRect: () => ({ left: 0, top: 0 }) }, coordsAtPos: () => ({ left: 20, top: 30 }), }; + return view; + }; - editor.view = view; + const openMenu = (plugin, view) => { + plugin.props.handleKeyDown.call(plugin, view, { key: '/', preventDefault: vi.fn() }); + }; - // Open menu first time - const openEvent1 = { key: '/', preventDefault: vi.fn() }; - const opened1 = plugin.props.handleKeyDown.call(plugin, view, openEvent1); - expect(opened1).toBe(true); + it('closes the menu when Backspace is pressed', () => { + const baseDoc = doc(p()); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); + editor.view = view; + openMenu(plugin, view); expect(ContextMenuPluginKey.getState(view.state).open).toBe(true); - // Close menu - const closeEvent = { key: 'Escape', preventDefault: vi.fn() }; - plugin.props.handleKeyDown.call(plugin, view, closeEvent); + const event = { key: 'Backspace', preventDefault: vi.fn() }; + const handled = plugin.props.handleKeyDown.call(plugin, view, event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); + }); + + it('closes the menu when Delete is pressed', () => { + const baseDoc = doc(p()); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); + editor.view = view; + openMenu(plugin, view); + + const event = { key: 'Delete', preventDefault: vi.fn() }; + const handled = plugin.props.handleKeyDown.call(plugin, view, event); - // Try to open menu again immediately (should be blocked by cooldown) - const openEvent2 = { key: '/', preventDefault: vi.fn() }; - const opened2 = plugin.props.handleKeyDown.call(plugin, view, openEvent2); - expect(opened2).toBe(false); // Should return false during cooldown - expect(openEvent2.preventDefault).not.toHaveBeenCalled(); - expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); // Should remain closed + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); }); - it('allows reopening menu after cooldown expires', async () => { - vi.useFakeTimers(); + it('inserts a literal slash at the anchor position when dismissed with Escape', () => { + const baseDoc = doc(p()); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); + editor.view = view; + openMenu(plugin, view); + expect(view.state.doc.textContent).toBe(''); + + const event = { key: 'Escape', preventDefault: vi.fn() }; + plugin.props.handleKeyDown.call(plugin, view, event); + + expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); + expect(view.state.doc.textContent).toBe('/'); + }); + it('does not insert any character when dismissed with Backspace', () => { const baseDoc = doc(p()); - const initialSelection = TextSelection.create(baseDoc, 1); - let state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection }); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); + editor.view = view; + openMenu(plugin, view); - const editor = { - options: {}, - emit: vi.fn(), - view: null, - }; + plugin.props.handleKeyDown.call(plugin, view, { key: 'Backspace', preventDefault: vi.fn() }); + + expect(view.state.doc.textContent).toBe(''); + }); + it('reopens the menu when the slash is pressed again immediately after dismissal', () => { + const baseDoc = doc(p()); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); - state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection, plugins: [plugin] }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); + editor.view = view; + openMenu(plugin, view); + plugin.props.handleKeyDown.call(plugin, view, { key: 'Backspace', preventDefault: vi.fn() }); + expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); + + const event = { key: '/', preventDefault: vi.fn() }; + const handled = plugin.props.handleKeyDown.call(plugin, view, event); + + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(ContextMenuPluginKey.getState(view.state).open).toBe(true); + }); + + it('ignores Backspace when the menu is not open', () => { + const baseDoc = doc(p()); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); + editor.view = view; + + const handled = plugin.props.handleKeyDown.call(plugin, view, { key: 'Backspace', preventDefault: vi.fn() }); + expect(handled).toBe(false); + }); + }); + + // SD-2747 P2: the menu can be opened two ways — by typing `/` (the slash + // keystroke is preventDefault'd, so the menu owes the user a literal `/` + // when they dismiss without picking) and by right-click (no slash was + // suppressed, so dismissal must NOT mutate the document). The plugin must + // record which trigger fired and every dismissal path (PM and Vue) must + // gate the slash-reinsert on that flag. + describe('open-trigger source tracking (SD-2747 P2)', () => { + const makeView = (initial) => { + let state = initial; const view = { - state, + get state() { + return state; + }, dispatch: vi.fn((tr) => { state = state.apply(tr); - view.state = state; }), focus: vi.fn(), - dom: { - getBoundingClientRect: () => ({ left: 0, top: 0 }), - }, + dom: { getBoundingClientRect: () => ({ left: 0, top: 0 }) }, coordsAtPos: () => ({ left: 20, top: 30 }), }; + return view; + }; + it('records trigger="slash" when the menu opens via the keyboard hotkey', () => { + const baseDoc = doc(p()); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); editor.view = view; - // Open and close menu plugin.props.handleKeyDown.call(plugin, view, { key: '/', preventDefault: vi.fn() }); - plugin.props.handleKeyDown.call(plugin, view, { key: 'Escape', preventDefault: vi.fn() }); - - // Fast forward past cooldown period (5000ms) - vi.advanceTimersByTime(5000); - - // Should be able to open again after cooldown - const openEvent = { key: '/', preventDefault: vi.fn() }; - const opened = plugin.props.handleKeyDown.call(plugin, view, openEvent); - expect(opened).toBe(true); - expect(openEvent.preventDefault).toHaveBeenCalled(); - expect(ContextMenuPluginKey.getState(view.state).open).toBe(true); - vi.useRealTimers(); + expect(ContextMenuPluginKey.getState(view.state).trigger).toBe('slash'); }); - it('clears cooldown timeout on plugin destroy', () => { - vi.useFakeTimers(); - + it('records trigger="rightClick" when the menu opens via clientX/clientY', () => { const baseDoc = doc(p()); - const initialSelection = TextSelection.create(baseDoc, 1); - let state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection }); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); + editor.view = view; - const editor = { - options: {}, - emit: vi.fn(), - view: null, - }; + view.dispatch( + view.state.tr.setMeta(ContextMenuPluginKey, { + type: 'open', + pos: 1, + clientX: 100, + clientY: 50, + }), + ); + expect(ContextMenuPluginKey.getState(view.state).trigger).toBe('rightClick'); + }); + + it('clears trigger on close', () => { + const baseDoc = doc(p()); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); - state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection, plugins: [plugin] }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); + editor.view = view; + plugin.props.handleKeyDown.call(plugin, view, { key: '/', preventDefault: vi.fn() }); + view.dispatch(view.state.tr.setMeta(ContextMenuPluginKey, { type: 'close' })); - const view = { - state, - dispatch: vi.fn((tr) => { - state = state.apply(tr); - view.state = state; - }), - focus: vi.fn(), - dom: { - getBoundingClientRect: () => ({ left: 0, top: 0 }), - }, - coordsAtPos: () => ({ left: 20, top: 30 }), - }; + expect(ContextMenuPluginKey.getState(view.state).trigger).toBe(null); + }); + it('Escape on a right-click-opened menu closes the menu without inserting a slash', () => { + // Bug A from Luccas's review: pressing Escape on a context menu that + // was opened via right-click previously inserted `/` at the click + // position, mutating the document. The dismissal must be a no-op + // beyond closing. + const baseDoc = doc(p()); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); editor.view = view; - const viewLifecycle = plugin.spec.view?.(view); + view.dispatch(view.state.tr.setMeta(ContextMenuPluginKey, { type: 'open', pos: 1, clientX: 100, clientY: 50 })); - // Open and close to trigger cooldown - plugin.props.handleKeyDown.call(plugin, view, { key: '/', preventDefault: vi.fn() }); plugin.props.handleKeyDown.call(plugin, view, { key: 'Escape', preventDefault: vi.fn() }); - // Destroy should clear the timeout - viewLifecycle?.destroy?.(); + expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); + expect(view.state.doc.textContent).toBe(''); + }); - // This test mainly ensures no memory leaks - we can't easily verify the timeout is cleared - // but the destroy() call should not throw - expect(true).toBe(true); + it('ArrowLeft on a right-click-opened menu closes the menu without inserting a slash', () => { + const baseDoc = doc(p()); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); + editor.view = view; + view.dispatch(view.state.tr.setMeta(ContextMenuPluginKey, { type: 'open', pos: 1, clientX: 100, clientY: 50 })); - vi.useRealTimers(); + plugin.props.handleKeyDown.call(plugin, view, { key: 'ArrowLeft', preventDefault: vi.fn() }); + + expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); + expect(view.state.doc.textContent).toBe(''); }); }); diff --git a/packages/superdoc/src/assets/styles/helpers/variables.css b/packages/superdoc/src/assets/styles/helpers/variables.css index b078a53f7a..d8ec54adb3 100644 --- a/packages/superdoc/src/assets/styles/helpers/variables.css +++ b/packages/superdoc/src/assets/styles/helpers/variables.css @@ -119,6 +119,10 @@ --sd-ui-menu-item-hover-bg: var(--sd-ui-hover-bg); --sd-ui-menu-item-active-bg: var(--sd-ui-active-bg); --sd-ui-menu-item-active-text: var(--sd-ui-action); + /* SD-2747: context menu's slash-filter search header + empty-state styling */ + --sd-ui-menu-header-bg: #fafafa; + --sd-ui-menu-text-muted: #888; + --sd-ui-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; /* UI: tools */ --sd-ui-tools-gap: 6px;