diff --git a/src/client/SideCardSection.module.css b/src/client/SideCardSection.module.css index 54aba59..3212fc5 100644 --- a/src/client/SideCardSection.module.css +++ b/src/client/SideCardSection.module.css @@ -24,12 +24,11 @@ flex-direction: column; gap: 14px; width: 100%; - /* Tall inventories (7 tab cards + 9 viewer cards + general rows) must - never be clipped by the settings shell: fill the shell's options area - and scroll inside the section when the content outgrows the panel. */ - height: 100%; - min-height: 0; - overflow-y: auto; + max-width: 760px; + /* The DSH settings shell's `.options` area owns vertical scrolling. Keep + this page a plain content column like Plugins → Plugin list so its + scrollbar stays at the shell edge instead of being inset by the options + area's right padding. */ } /* One-line section intro (the DSH section heading+intro recipe). */ @@ -417,10 +416,13 @@ width: min(460px, 100%); } -/* Popup rows: the same settings-row recipe with switch controls. */ +/* Popup rows: each setting gets the same outer chrome as a plugin-list + PluginCard (l2 hairline, 12px radius, layer-3 fill). The row's title, + description and control geometry stay unchanged inside that frame. */ .popupRows { display: flex; flex-direction: column; + gap: 10px; width: 100%; } @@ -429,16 +431,16 @@ align-items: center; justify-content: space-between; gap: 16px; - padding: 14px 2px; - border-bottom: 1px solid var(--dsw-alias-border-l2); + min-width: 0; + padding: 14px 16px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-alias-bg-layer-3); + transition: border-color 0.16s, background 0.16s; } .popupRow:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - -.popupRows > :last-child { - border-bottom: none; + border-color: var(--dsw-alias-label-dimmed); } /* The popup's Done button: the settings primary-button recipe diff --git a/src/client/index.tsx b/src/client/index.tsx index 69af743..216fdcc 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -20,6 +20,7 @@ import { RenderBoundary } from './RenderBoundary.tsx' import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx' import { registerLinkInterception } from './link-intercept.ts' import { registerImeGuard } from './ime-guard.ts' +import { registerSettingsNavIcon } from './settings-nav-icon.ts' import { loadPrefs } from './prefs.ts' import { SideCardSection } from './SideCardSection.tsx' import { api } from './api.ts' @@ -213,6 +214,16 @@ export function apply(ctx: Context): void { 'dsh-better-sidebar: IME composition guard', ) + // DSH 0.1.x does not yet carry an icon through the settings.section + // registration contract: its shell renders a generic gear for every + // external section. Mark only this plugin's localized nav row so + // layout.css can paint the requested Side card SVG; the disposer clears + // the marker for HMR / plugin disable. + ctx.effect( + () => registerSettingsNavIcon(() => t('settingsNav')), + 'dsh-better-sidebar: settings navigation icon', + ) + // The "Side card" settings section: appears in the DSH Settings shell // once the shell's declaration is on the ledger (slots.inject waits for // it); the section reads/writes the prefs through the plugin's own diff --git a/src/client/layout.css b/src/client/layout.css index 4b3a677..b1c483f 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -51,6 +51,25 @@ body[data-dsh-sidebar-dragging] #root > div[data-slot="root"] > div > div:nth-ch transition: none; } +/* DSH 0.1.x gives external settings sections a generic gear and exposes no + icon field in the settings.section contract. settings-nav-icon.ts marks + only this plugin's localized row; render the requested Lucide + gallery-horizontal-end SVG as a currentColor mask so it follows the native + nav hover/active colors without changing the shell's 16px icon rhythm. */ +[data-dsh-better-sidebar-settings-nav] > svg:first-child { + display: none; +} + +[data-dsh-better-sidebar-settings-nav]::before { + content: ''; + flex: none; + width: 16px; + height: 16px; + background: currentColor; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 7v10'/%3E%3Cpath d='M6 5v14'/%3E%3Crect width='12' height='18' x='10' y='3' rx='2'/%3E%3C/svg%3E") center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 7v10'/%3E%3Cpath d='M6 5v14'/%3E%3Crect width='12' height='18' x='10' y='3' rx='2'/%3E%3C/svg%3E") center / contain no-repeat; +} + @media (prefers-reduced-motion: reduce) { #root, #root > div[data-slot="root"] > div > div:nth-child(2) { diff --git a/src/client/settings-nav-icon.ts b/src/client/settings-nav-icon.ts new file mode 100644 index 0000000..e2b574e --- /dev/null +++ b/src/client/settings-nav-icon.ts @@ -0,0 +1,45 @@ +/** + * Mark this plugin's row in the DSH settings navigation so its bundled CSS + * can replace the shell's fallback gear with the Side card glyph. + * + * DSH 0.1.x projects only `id`, `order`, and `label` from a + * `settings.section` registration, then chooses icons inside the settings + * shell from a closed list of built-in ids. Until that public contract grows + * an icon field, the plugin identifies only its own localized row after the + * dialog mounts. The marker owns no shell structure and is removed on fiber + * disposal, so the adaptation remains HMR-safe. + */ + +export const SETTINGS_NAV_MARKER = 'data-dsh-better-sidebar-settings-nav' + +/** + * Keep the marker on the settings-nav button whose visible text is this + * plugin's current localized section label. + * @param label - locale-aware label resolver used by the section registration. + * @returns disposer that disconnects observation and removes owned markers. + */ +export function registerSettingsNavIcon(label: () => string): () => void { + let disposed = false + + const sync = (): void => { + if (disposed) return + const currentLabel = label().trim() + const buttons = document.querySelectorAll('[role="dialog"] nav button') + for (const button of buttons) { + const matches = currentLabel.length > 0 && button.textContent?.trim() === currentLabel + if (matches) button.setAttribute(SETTINGS_NAV_MARKER, '') + else button.removeAttribute(SETTINGS_NAV_MARKER) + } + } + + sync() + const observer = new MutationObserver(sync) + observer.observe(document.body, { childList: true, subtree: true, characterData: true }) + + return () => { + disposed = true + observer.disconnect() + document.querySelectorAll(`[${SETTINGS_NAV_MARKER}]`) + .forEach(element => { element.removeAttribute(SETTINGS_NAV_MARKER) }) + } +} diff --git a/tests/settings-nav-icon.spec.ts b/tests/settings-nav-icon.spec.ts new file mode 100644 index 0000000..74f4839 --- /dev/null +++ b/tests/settings-nav-icon.spec.ts @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +/** Settings-navigation icon marker tests (including delayed dialog mount). */ +import { afterEach, describe, expect, it } from 'vitest' +import { registerSettingsNavIcon, SETTINGS_NAV_MARKER } from '../src/client/settings-nav-icon.ts' + +function navButton(label: string): HTMLButtonElement { + const button = document.createElement('button') + button.innerHTML = `${label}` + return button +} + +function dialogWith(...buttons: HTMLButtonElement[]): HTMLElement { + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + const nav = document.createElement('nav') + nav.append(...buttons) + dialog.append(nav) + return dialog +} + +async function mutationTick(): Promise { + await new Promise(resolve => { setTimeout(resolve, 0) }) +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('registerSettingsNavIcon', () => { + it('marks only the matching settings row and preserves the shell icon DOM', () => { + const general = navButton('General') + const sideCard = navButton('Side card') + document.body.append(dialogWith(general, sideCard)) + + const dispose = registerSettingsNavIcon(() => 'Side card') + + expect(general.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false) + expect(sideCard.hasAttribute(SETTINGS_NAV_MARKER)).toBe(true) + expect(sideCard.querySelector('[data-fallback="gear"]')).not.toBeNull() + dispose() + expect(sideCard.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false) + }) + + it('marks a dialog mounted later and follows a localized label change', async () => { + let label = 'Side card' + const dispose = registerSettingsNavIcon(() => label) + const english = navButton('Side card') + const chinese = navButton('侧边卡片') + const dialog = dialogWith(english, chinese) + document.body.append(dialog) + await mutationTick() + + expect(english.hasAttribute(SETTINGS_NAV_MARKER)).toBe(true) + expect(chinese.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false) + + label = '侧边卡片' + chinese.querySelector('span')!.textContent = '侧边卡片 ' + await mutationTick() + expect(english.hasAttribute(SETTINGS_NAV_MARKER)).toBe(false) + expect(chinese.hasAttribute(SETTINGS_NAV_MARKER)).toBe(true) + + dispose() + }) +})