From 832527592d475c09732ba99a6441aba92d7a5f2c Mon Sep 17 00:00:00 2001 From: serafin-garcia Date: Mon, 20 Jul 2026 18:08:19 -0700 Subject: [PATCH] feat(open-knowledge): settings scope badges and sidebar search (#2756) * feat(open-knowledge): settings scope badges and sidebar search Add a User/Project scope badge to the plugin settings panels (markdownlint is project scope, Themes is user scope), so it is clear where a change is stored, plus a search box pinned at the top of the settings sidebar. The search indexes every section, the schema settings, and all markdownlint rules (only while the plugin is enabled), reusing the command palette's matchesCommandQuery and splitTextByQueryMatches helpers and the cmdk Command primitives. Results navigate: sections switch panels, a setting scrolls to and briefly flashes its field, and a markdownlint rule opens the markdownlint panel pre-filtered to that rule. The section list scrolls beneath the pinned search box. Closes PRD-7377. * chore(open-knowledge): drop tracker-ticket refs from test comments md-audit comment-discipline forbids tracker tickets in source comments. * test(open-knowledge): migrate touched settings tests off bun:test The new check-no-new-bun-test-imports guard (merged from main) forbids bun:test imports in changed files. Convert the settings test files this PR touches to native vitest: mock.module -> vi.doMock (non-hoisted, so the existing dynamic-import + mutable-mock-state pattern is unchanged), spyOn -> vi.spyOn. Behavior is identical; full settings DOM + unit + e2e suites pass. * chore(open-knowledge): bump combined JS size budget 3.35->3.36 MB The settings scope-badge + search feature adds a small amount of JS (search index, ScopeBadge, sidebar search UI), which tipped the 'all JS chunks combined' gzip budget over by 131 B (3,350,131 vs 3,350,000). Bump the limit modestly (~10 kB headroom). The main app bundle (476/482 kB) and CSS (54.9/55 kB) budgets are unchanged and still pass. * test(server): okignore drift guard allows repo-specific suffix The dogfood root .okignore gained repo-specific docs-MDX exclusions (#2768), which broke the byte-equality drift guard for every OK PR (the change was path-filtered out of OK validation, so it merged unseen). Keep the guard's teeth: the scaffold template must remain a byte-exact prefix of the committed file (sliced compare so drift still prints a real diff); repo-specific patterns may follow. * test(server): migrate init-project.test.ts off bun:test Touched by the drift-guard fix, so the no-new-bun-test guard requires the vitest import (import-line-only change; no mock usage in this file). * chore(server): fix import sort in init-project.test.ts biome organizeImports orders 'vitest' differently than the removed 'bun:test' specifier. * review: settings search a11y + cleanups from PR review - ScopeBadge: tabIndex + focus ring so the scope tooltip is keyboard-reachable (Radix opens on focus); pinned by a dom test - Settings search: polite sr-only live region announcing the result count - Drop the dead nonce from fieldFlash (object identity already re-fires the flash effect; the nonce is only load-bearing for ruleQuery's prop-threaded primitive keys) - Rename SettingsDialogSearch.dom.test.tsx -> SettingsDialogShell.search.dom.test.tsx per the Component.aspect convention - Wire settings-search.e2e.ts into the CI test:e2e subset GitOrigin-RevId: b896f20cc74882bd5fafc0f3380e0aaf542d5f25 --- .../settings-scope-badges-and-search.md | 9 + packages/app/package.json | 2 +- .../settings/LintingSection.dom.test.tsx | 9 +- .../components/settings/LintingSection.tsx | 28 +- .../settings/ScopeBadge.dom.test.tsx | 70 +++++ .../src/components/settings/ScopeBadge.tsx | 39 +++ .../settings/SettingsDialogBody.dom.test.tsx | 9 +- .../SettingsDialogBody.sections.dom.test.tsx | 50 ++-- .../settings/SettingsDialogBody.tsx | 86 +++--- .../settings/SettingsDialogShell.dom.test.tsx | 20 +- ...tingsDialogShell.install-flag.dom.test.tsx | 19 +- ...ingsDialogShell.link-previews.dom.test.tsx | 24 +- .../SettingsDialogShell.search.dom.test.tsx | 231 +++++++++++++++ .../SettingsDialogShell.terminal.dom.test.tsx | 20 +- .../settings/SettingsDialogShell.tsx | 273 ++++++++++++++++-- .../markdownlint-rule-browser.dom.test.tsx | 37 +++ .../settings/markdownlint-rule-browser.tsx | 19 +- .../components/settings/settings-fields.ts | 73 +++++ .../settings/settings-search-index.test.ts | 134 +++++++++ .../settings/settings-search-index.ts | 98 +++++++ .../settings/settings-sidebar-types.ts | 24 ++ packages/app/src/globals.css | 25 ++ packages/app/src/locales/en/messages.json | 7 + packages/app/src/locales/en/messages.po | 47 ++- packages/app/src/locales/pseudo/messages.json | 7 + packages/app/src/locales/pseudo/messages.po | 47 ++- .../app/tests/stress/settings-search.e2e.ts | 135 +++++++++ 27 files changed, 1375 insertions(+), 167 deletions(-) create mode 100644 .changeset/settings-scope-badges-and-search.md create mode 100644 packages/app/src/components/settings/ScopeBadge.dom.test.tsx create mode 100644 packages/app/src/components/settings/ScopeBadge.tsx create mode 100644 packages/app/src/components/settings/SettingsDialogShell.search.dom.test.tsx create mode 100644 packages/app/src/components/settings/settings-fields.ts create mode 100644 packages/app/src/components/settings/settings-search-index.test.ts create mode 100644 packages/app/src/components/settings/settings-search-index.ts create mode 100644 packages/app/src/components/settings/settings-sidebar-types.ts create mode 100644 packages/app/tests/stress/settings-search.e2e.ts diff --git a/.changeset/settings-scope-badges-and-search.md b/.changeset/settings-scope-badges-and-search.md new file mode 100644 index 000000000..43c49def7 --- /dev/null +++ b/.changeset/settings-scope-badges-and-search.md @@ -0,0 +1,9 @@ +--- +"@inkeep/open-knowledge": minor +--- + +Settings now tells you where a plugin's changes are stored and lets you search across the whole dialog. + +Plugin panels carry a scope badge: markdownlint shows a "Project" badge (its rules are committed to config.yml and shared with your team) and Themes shows a "User" badge (personal to your device). Hover either badge for a one-line explanation of where the change is saved. + +A search box is pinned at the top of the settings sidebar. Type to find any section, any Preferences/Theme setting, or any markdownlint rule by name, id, or alias, then jump straight to it: a section result switches panels, a setting result scrolls to and briefly highlights the field, and a markdownlint rule result opens the markdownlint panel already filtered to that rule. markdownlint rules only appear in search while the plugin is enabled. The search box stays put while the section list scrolls beneath it. diff --git a/packages/app/package.json b/packages/app/package.json index 3517ef553..c9eed66ae 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -26,7 +26,7 @@ "measure:fuzz": "bash scripts/measure-fuzz.sh", "measure:stress": "bash scripts/measure-stress.sh", "perf:compare": "bash scripts/perf-compare.sh", - "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/lint-config-toggle.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts tests/stress/command-palette-parity.e2e.ts", + "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/lint-config-toggle.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts tests/stress/command-palette-parity.e2e.ts tests/stress/settings-search.e2e.ts", "test:e2e:install-browsers": "playwright install chromium webkit firefox", "test:visual": "playwright test --config playwright.visual.config.ts", "test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots", diff --git a/packages/app/src/components/settings/LintingSection.dom.test.tsx b/packages/app/src/components/settings/LintingSection.dom.test.tsx index fc19c22c4..709d1a0d4 100644 --- a/packages/app/src/components/settings/LintingSection.dom.test.tsx +++ b/packages/app/src/components/settings/LintingSection.dom.test.tsx @@ -5,10 +5,10 @@ * editor's write calls + gated visibility of controls. */ -import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; import type { Config, ConfigBinding } from '@inkeep/open-knowledge-core'; import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { TooltipProvider } from '@/components/ui/tooltip'; // Radix primitives reach for DOM globals the jsdom preload doesn't expose; @@ -36,7 +36,7 @@ let mockProjectConfig: Config | null = null; let mockProjectSynced = true; let mockProjectBinding: ConfigBinding | null = null; -mock.module('@/lib/config-provider', () => ({ +vi.doMock('@/lib/config-provider', () => ({ useConfigContext: () => ({ userBinding: null, userSynced: false, @@ -73,7 +73,7 @@ function projectDataWithMarkdownlintRules( }, }; } -mock.module('@/editor/lint-config-client', () => ({ +vi.doMock('@/editor/lint-config-client', () => ({ emitLintConfigChanged: () => {}, subscribeToLintConfigChanged: () => () => {}, runLintAudit: async () => null, @@ -198,6 +198,9 @@ describe('MarkdownlintPluginSection', () => { expect(screen.getByTestId('settings-linting-markdownlint-rules')).toBeDefined(); expect(screen.getByTestId('markdownlint-rule-search')).toBeDefined(); expect(screen.getByTestId('markdownlint-rule-row-MD001')).toBeDefined(); + // markdownlint is a project-scope plugin — the header carries a Project badge. + expect(screen.getByTestId('settings-scope-badge-project')).toBeDefined(); + expect(screen.queryByTestId('settings-scope-badge-user')).toBeNull(); }); test('names the project config file in the description when one is present', () => { diff --git a/packages/app/src/components/settings/LintingSection.tsx b/packages/app/src/components/settings/LintingSection.tsx index b4cfde86a..040ddf396 100644 --- a/packages/app/src/components/settings/LintingSection.tsx +++ b/packages/app/src/components/settings/LintingSection.tsx @@ -24,6 +24,7 @@ import { useConfigContext } from '@/lib/config-provider'; import { dispatchExternalLinkClick } from '@/lib/external-link'; import { LINT_PLUGIN_META } from './lint-plugin-meta'; import { MarkdownlintRuleBrowser } from './markdownlint-rule-browser'; +import { ScopeBadge } from './ScopeBadge'; /** Project-scope content-rules config + a `contentRules`-patch writer. Shared by the sections. */ function useLinterConfig() { @@ -194,31 +195,46 @@ export function UserPluginsManageSection({ userBinding }: { userBinding: ConfigB function PluginSectionHeader({ titleId, title, + scope, children, }: { titleId: string; title: string; + /** When set, renders a User/Project scope badge beside the title. */ + scope?: 'user' | 'project'; children: ReactNode; }) { return (
-

- {title} -

+
+

+ {title} +

+ {scope ? : null} +

{children}

); } /** markdownlint plugin: the full-catalog rule browser. */ -export function MarkdownlintPluginSection() { +export function MarkdownlintPluginSection({ + initialRuleQuery, +}: { + /** Seeds the rule browser's search when the settings search jumps to a rule. */ + initialRuleQuery?: { query: string; nonce: number } | null; +} = {}) { return (
- + Flag common markdown issues in the editor. Powered by{' '} - +
); } diff --git a/packages/app/src/components/settings/ScopeBadge.dom.test.tsx b/packages/app/src/components/settings/ScopeBadge.dom.test.tsx new file mode 100644 index 000000000..442b76a3e --- /dev/null +++ b/packages/app/src/components/settings/ScopeBadge.dom.test.tsx @@ -0,0 +1,70 @@ +/** + * DOM tests for ScopeBadge — the User/Project indicator shown in plugin panel + * headers. Asserts the visible label and the scope-specific tooltip copy. + */ + +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test } from 'vitest'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { ScopeBadge } from './ScopeBadge'; + +// Radix Tooltip reaches for globals jsdom's preload doesn't expose. +type GlobalWithShims = typeof globalThis & { ResizeObserver?: unknown }; +const g = globalThis as GlobalWithShims; +if (g.ResizeObserver === undefined) { + class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } + g.ResizeObserver = NoopResizeObserver; +} + +function renderBadge(scope: 'user' | 'project') { + return render( + + + , + ); +} + +describe('ScopeBadge', () => { + afterEach(() => cleanup()); + + test('user scope renders a "User" badge', () => { + renderBadge('user'); + const badge = screen.getByTestId('settings-scope-badge-user'); + expect(badge.textContent).toBe('User'); + expect(screen.queryByTestId('settings-scope-badge-project')).toBeNull(); + }); + + test('project scope renders a "Project" badge', () => { + renderBadge('project'); + const badge = screen.getByTestId('settings-scope-badge-project'); + expect(badge.textContent).toBe('Project'); + expect(screen.queryByTestId('settings-scope-badge-user')).toBeNull(); + }); + + test('user tooltip explains it is stored in user config', async () => { + renderBadge('user'); + await userEvent.hover(screen.getByTestId('settings-scope-badge-user')); + const tooltip = await screen.findAllByRole('tooltip'); + expect(within(tooltip[0]).getByText(/user config/i)).toBeDefined(); + }); + + test('project tooltip explains it is shared via git', async () => { + renderBadge('project'); + await userEvent.hover(screen.getByTestId('settings-scope-badge-project')); + const tooltip = await screen.findAllByRole('tooltip'); + expect(within(tooltip[0]).getByText(/config\.yml/i)).toBeDefined(); + }); + + test('badge is keyboard-focusable and focus opens the tooltip', async () => { + renderBadge('user'); + await userEvent.tab(); + expect(document.activeElement).toBe(screen.getByTestId('settings-scope-badge-user')); + const tooltip = await screen.findAllByRole('tooltip'); + expect(within(tooltip[0]).getByText(/user config/i)).toBeDefined(); + }); +}); diff --git a/packages/app/src/components/settings/ScopeBadge.tsx b/packages/app/src/components/settings/ScopeBadge.tsx new file mode 100644 index 000000000..d37585198 --- /dev/null +++ b/packages/app/src/components/settings/ScopeBadge.tsx @@ -0,0 +1,39 @@ +/** + * User/Project scope indicator for a plugin settings panel header. The + * "Plugins" sidebar group mixes scopes — markdownlint is project-scope (shared + * via config.yml + the native `.markdownlint.*`), Themes is user-scope + * (personal, device-local) — so the panel header alone can't tell you where a + * change lands. This badge makes the scope (and, via its tooltip, where the + * change is stored) explicit. Used ONLY in plugin panel headers; the other + * settings sections are already categorized by their labeled sidebar group. + */ +import { Trans } from '@lingui/react/macro'; +import { Badge } from '@/components/ui/badge'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; + +export function ScopeBadge({ scope }: { scope: 'user' | 'project' }) { + return ( + + + {/* tabIndex makes the badge keyboard-focusable so the tooltip's + storage/sharing explanation is reachable without a pointer (Radix + opens tooltips on focus and wires aria-describedby). */} + + {scope === 'user' ? User : Project} + + + + {scope === 'user' ? ( + Personal to this device — stored in your user config, not shared. + ) : ( + Shared with everyone on this project — committed to config.yml via git. + )} + + + ); +} diff --git a/packages/app/src/components/settings/SettingsDialogBody.dom.test.tsx b/packages/app/src/components/settings/SettingsDialogBody.dom.test.tsx index 516ef9c18..954538609 100644 --- a/packages/app/src/components/settings/SettingsDialogBody.dom.test.tsx +++ b/packages/app/src/components/settings/SettingsDialogBody.dom.test.tsx @@ -1,4 +1,3 @@ -import { afterEach, describe, expect, test } from 'bun:test'; import { CONFIG_DOC_NAME_PROJECT, CONFIG_DOC_NAME_USER, @@ -11,6 +10,7 @@ import { act, cleanup, render, screen, waitFor, within } from '@testing-library/ import userEvent from '@testing-library/user-event'; import { ThemeProvider, useTheme } from 'next-themes'; import type { ReactNode } from 'react'; +import { afterEach, describe, expect, test } from 'vitest'; import { TooltipProvider } from '@/components/ui/tooltip'; import { ConfigContext, type ConfigContextValue } from '@/lib/config-context'; import { emitConfigValidationRejected } from '@/lib/config-validation-events'; @@ -366,6 +366,13 @@ describe('SettingsDialogBody color-palette picker — optimistic mode flip', () cleanup(); }); + test('the Themes plugin header shows a User scope badge (user-scope plugin)', () => { + const { binding } = makeBinding(); + renderThemePluginWithTheme(binding); + expect(screen.getByTestId('settings-scope-badge-user')).toBeDefined(); + expect(screen.queryByTestId('settings-scope-badge-project')).toBeNull(); + }); + test('selecting a light palette (Catppuccin Latte) flips next-themes to light', async () => { const user = userEvent.setup(); const { binding } = makeBinding(); diff --git a/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx b/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx index 89f76505a..30bdd11f8 100644 --- a/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx +++ b/packages/app/src/components/settings/SettingsDialogBody.sections.dom.test.tsx @@ -1,6 +1,6 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test'; import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { createContext, type ReactNode, use, useState } from 'react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { renderLinguiTemplate } from '@/test-utils/lingui-mock'; type SyncStatus = { @@ -49,12 +49,12 @@ const actualCore = await import('@inkeep/open-knowledge-core'); import * as actualLinguiMacro from '@lingui/react/macro'; -mock.module('@inkeep/open-knowledge-core', () => ({ +vi.doMock('@inkeep/open-knowledge-core', () => ({ ...actualCore, SHOW_INSTALL_SKILL: true, })); -mock.module('@lingui/react/macro', () => ({ +vi.doMock('@lingui/react/macro', () => ({ ...actualLinguiMacro, Plural: ({ value, one, other }: { value: number; one: string; other: string }) => ( <>{(value === 1 ? one : other).replace('#', String(value))} @@ -63,7 +63,7 @@ mock.module('@lingui/react/macro', () => ({ useLingui: () => ({ t: renderLinguiTemplate }), })); -mock.module('@lingui/core/macro', () => ({ +vi.doMock('@lingui/core/macro', () => ({ ...actualLinguiMacro, msg: renderLinguiTemplate, plural: (value: number, options: { one: string; other: string }) => @@ -71,7 +71,7 @@ mock.module('@lingui/core/macro', () => ({ t: renderLinguiTemplate, })); -mock.module('@/components/ui/button', () => ({ +vi.doMock('@/components/ui/button', () => ({ Button: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (