From bd9abfeabf62507e92d425ca4f803924fe7a0f71 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 03:58:57 +0000 Subject: [PATCH 1/7] feat(task T01): implement via codex --- .../settings/SettingsActivePanelStatus.tsx | 79 +++++++++++++++++++ .../settings/SettingsContentPanels.tsx | 48 +---------- .../__tests__/SettingsControls.test.tsx | 56 +++++++++++++ docs/dashboard/design-system-settings.md | 2 +- 4 files changed, 139 insertions(+), 46 deletions(-) create mode 100644 dashboard/src/v2/components/settings/SettingsActivePanelStatus.tsx diff --git a/dashboard/src/v2/components/settings/SettingsActivePanelStatus.tsx b/dashboard/src/v2/components/settings/SettingsActivePanelStatus.tsx new file mode 100644 index 0000000000..abdd14599e --- /dev/null +++ b/dashboard/src/v2/components/settings/SettingsActivePanelStatus.tsx @@ -0,0 +1,79 @@ +import type { FunctionComponent, JSX } from "preact"; +import type { SettingsPageState } from "../../hooks/use-settings-page-state.js"; + +const formatCategoryLabel = (category: SettingsPageState["activeCategory"]): string => ( + `${category.charAt(0).toUpperCase()}${category.slice(1)}` +); + +export const SettingsActivePanelStatus: FunctionComponent<{ + state: SettingsPageState; + sticky?: boolean; + stickyTop?: string; + className?: string; + style?: JSX.CSSProperties; +}> = ({ + state, + sticky = true, + stickyTop = "9.5rem", + className, + style, +}) => { + const { activeCategory, activeDirty, activeSaving, error, saveMessage, loading, resettingProject } = state; + const activeCategoryLabel = state.activeCategoryConfig?.label ?? formatCategoryLabel(activeCategory); + + const panelStatus = error + ? `${activeCategoryLabel} settings blocked: ${error}` + : resettingProject + ? `${activeCategoryLabel} project override reset is pending.` + : activeSaving + ? `${activeCategoryLabel} settings save is pending.` + : saveMessage + ? `${activeCategoryLabel} settings saved. ${saveMessage}` + : activeDirty + ? `${activeCategoryLabel} settings have local unsaved changes.` + : `${activeCategoryLabel} settings are saved.`; + const visibleSaveState = error + ? "Blocked" + : loading + ? "Loading" + : resettingProject + ? "Resetting" + : activeSaving + ? "Saving" + : activeDirty + ? "Unsaved changes" + : saveMessage + ? "Saved" + : "Saved"; + const statusStyle = sticky + ? ({ + ...style, + "--settings-active-panel-top": stickyTop, + } as JSX.CSSProperties) + : style; + const statusClassName = [ + sticky ? "sticky top-[var(--settings-active-panel-top)] z-20" : null, + "mb-3 flex min-w-0 flex-wrap items-center gap-2 overflow-visible rounded-[1rem] border border-[color:var(--border-hairline)] bg-[var(--surface-glass)] px-3 py-2 text-xs font-semibold text-slate-500 shadow-[var(--elevation-base)] backdrop-blur-2xl dark:text-slate-300", + className, + ].filter(Boolean).join(" "); + + return ( + <> +
+ {panelStatus} +
+
+ Active panel + {activeCategoryLabel} + + + {visibleSaveState} + +
+ + ); +}; diff --git a/dashboard/src/v2/components/settings/SettingsContentPanels.tsx b/dashboard/src/v2/components/settings/SettingsContentPanels.tsx index 549db6eafe..c2b189cc87 100644 --- a/dashboard/src/v2/components/settings/SettingsContentPanels.tsx +++ b/dashboard/src/v2/components/settings/SettingsContentPanels.tsx @@ -1,4 +1,4 @@ -import type { FunctionComponent, JSX } from "preact"; +import type { FunctionComponent } from "preact"; import type { SettingsPageState } from "../../hooks/use-settings-page-state.js"; import { SettingsGeneralPanel } from "./panels/SettingsGeneralPanel.js"; import { SettingsAppearancePanel } from "./panels/SettingsAppearancePanel.js"; @@ -12,6 +12,7 @@ import { SettingsMcpPanel } from "./panels/SettingsMcpPanel.js"; import { SettingsDangerPanel } from "./panels/SettingsDangerPanel.js"; import { ActionFeedbackRegion } from "../ui/ActionFeedbackRegion.js"; import { useInteractionTokens } from "../../lib/motion/tokens.js"; +import { SettingsActivePanelStatus } from "./SettingsActivePanelStatus.js"; export const SettingsContentPanels: FunctionComponent<{ state: SettingsPageState; @@ -19,35 +20,6 @@ export const SettingsContentPanels: FunctionComponent<{ }> = ({ state, stickyTop = "9.5rem" }) => { const { activeCategory, activeDirty, activeSaving, error, saveMessage, loading, resettingProject } = state; const tokens = useInteractionTokens(); - const activeCategoryLabel = state.activeCategoryConfig?.label ?? `${activeCategory.charAt(0).toUpperCase()}${activeCategory.slice(1)}`; - - const panelStatus = error - ? `${activeCategoryLabel} settings blocked: ${error}` - : resettingProject - ? `${activeCategoryLabel} project override reset is pending.` - : activeSaving - ? `${activeCategoryLabel} settings save is pending.` - : saveMessage - ? `${activeCategoryLabel} settings saved. ${saveMessage}` - : activeDirty - ? `${activeCategoryLabel} settings have local unsaved changes.` - : `${activeCategoryLabel} settings are saved.`; - const visibleSaveState = error - ? "Blocked" - : loading - ? "Loading" - : resettingProject - ? "Resetting" - : activeSaving - ? "Saving" - : activeDirty - ? "Unsaved changes" - : saveMessage - ? "Saved" - : "Saved"; - const stickyStyle = { - "--settings-active-panel-top": stickyTop, - } as JSX.CSSProperties; const renderPanel = () => { switch (activeCategory) { @@ -78,21 +50,7 @@ export const SettingsContentPanels: FunctionComponent<{ return (
-
- {panelStatus} -
-
- Active panel - {activeCategoryLabel} - - - {visibleSaveState} - -
+ { expect(discardButton).toHaveAttribute("aria-busy", "true"); }); + it("SettingsActivePanelStatus renders the sticky active panel save state contract", () => { + render( + + ); + + expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite"); + expect(screen.getByRole("status")).toHaveTextContent("AI Models settings have local unsaved changes."); + expect(screen.getByText("AI Models")).toBeInTheDocument(); + expect(screen.getByText("Unsaved changes")).toBeInTheDocument(); + const activePanelStrip = screen.getByText("Active panel").parentElement; + expect(activePanelStrip).toHaveAttribute("data-settings-sticky", "active-panel"); + expect(activePanelStrip).toHaveClass("sticky", "top-[var(--settings-active-panel-top)]", "flex-wrap", "overflow-visible"); + expect(activePanelStrip).toHaveStyle("--settings-active-panel-top: 112px"); + }); + + it("SettingsActivePanelStatus can render inline without duplicating status logic", () => { + render( + + ); + + expect(screen.getByRole("alert")).toHaveAttribute("aria-live", "assertive"); + expect(screen.getByRole("alert")).toHaveTextContent("General settings blocked: Save failed"); + expect(screen.getByText("Blocked")).toBeInTheDocument(); + const activePanelStrip = screen.getByText("Active panel").parentElement; + expect(activePanelStrip).not.toHaveAttribute("data-settings-sticky"); + expect(activePanelStrip).not.toHaveClass("sticky", "top-[var(--settings-active-panel-top)]"); + expect(activePanelStrip).not.toHaveStyle("--settings-active-panel-top: 9.5rem"); + }); + it("SettingsContentPanels renders dirty-to-saving-to-saved feedback while keeping values mounted", async () => { const { rerender } = render( { expect(activePanelStrip).toHaveAttribute("data-settings-sticky", "active-panel"); expect(activePanelStrip).toHaveClass("sticky", "top-[var(--settings-active-panel-top)]", "flex-wrap", "overflow-visible"); expect(activePanelStrip).toHaveStyle("--settings-active-panel-top: 9.5rem"); + const panelStatus = screen.getByText("General settings have local unsaved changes."); + expect(panelStatus).toHaveAttribute("role", "status"); + expect(panelStatus).toHaveAttribute("aria-live", "polite"); expect(screen.getByText("General panel values stay mounted").parentElement).toHaveAttribute("data-motion-contract", "enterExit"); expect(screen.getByText("General panel values stay mounted").parentElement).toHaveClass("motion-reduce:animate-none"); diff --git a/docs/dashboard/design-system-settings.md b/docs/dashboard/design-system-settings.md index 24db2d9c9d..5690a8cbf6 100644 --- a/docs/dashboard/design-system-settings.md +++ b/docs/dashboard/design-system-settings.md @@ -49,7 +49,7 @@ This document defines the visual patterns and rules for the Settings workspace. * Always rely on semantic CSS variables from `globals.css` and `tokens.css` via `[var(--variable-name)]` for colors, backgrounds, borders, and shadows instead of hardcoding Tailwind utility colors and shadow values. * Inline validation and character-counter feedback must use the shared `inlineValidation` and `controlFeedback` interaction tokens. Error text should be announced only after blur or an explicit submit/force-validation path, and helper text should not remain in `aria-describedby` while an error is active. -* Settings page save state lives at the active panel boundary. `SettingsContentPanels` sets `aria-busy` while loading, saving, or resetting; uses `ActionFeedbackRegion` for saved/dirty/saving/loading states; and keeps a durable visible active-panel/save-state line so reduced-motion users see the current category and outcome without relying on panel motion. Blocking errors switch the status stream to assertive alert copy. Do not replace field contents with loading placeholders during saves or background refreshes. +* Settings page save state lives at the active panel boundary. `SettingsContentPanels` sets `aria-busy` while loading, saving, or resetting; uses `ActionFeedbackRegion` for saved/dirty/saving/loading states; and renders `SettingsActivePanelStatus` for the durable visible active-panel/save-state line so reduced-motion users see the current category and outcome without relying on panel motion. Blocking errors switch the status stream to assertive alert copy. Do not replace field contents with loading placeholders during saves or background refreshes. * The Settings scope strip is sticky below the app shell and includes the System/Project radiogroup, selected-scope context, project-scope availability or inheritance summary, visible-category count, unsaved edits indicator, and saved badge. It must wrap naturally on narrow screens, preserve focus rings while pinned, and keep `settings-scope-context`, `settings-project-scope-disabled`, and the polite scope status region wired to the radiogroup. * The `SettingsContentPanels` active-panel/save-state strip is sticky beneath the scope strip. `SettingsPage` measures the wrapped scope strip height and passes the resulting offset into the panel strip so the two pinned surfaces do not overlap the app shell, each other, or the desktop `SettingsCategoryRail` at `lg:top-16`. * Save and background reload paths must preserve dirty drafts until the affected scope has actually saved or reset. If system settings save while project settings are dirty, project draft values remain mounted and are not replaced by an effective-settings refresh; failed project saves leave the draft visible for correction. From 9e07b03e2f76ffacf85eaedd2be51a9d4ce27ffd Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 03:59:49 +0000 Subject: [PATCH 2/7] feat(task T02): implement via codex --- dashboard/src/v2/SettingsPage.tsx | 98 +++---------- .../settings/SettingsScopeControls.tsx | 131 ++++++++++++++++++ .../__tests__/SettingsControls.test.tsx | 108 +++++++++++++++ docs-web/user/dashboard/settings.md | 2 + docs/dashboard/dashboard-guide.md | 2 +- docs/dashboard/design-system-settings.md | 4 +- 6 files changed, 260 insertions(+), 85 deletions(-) create mode 100644 dashboard/src/v2/components/settings/SettingsScopeControls.tsx diff --git a/dashboard/src/v2/SettingsPage.tsx b/dashboard/src/v2/SettingsPage.tsx index 7c032755ec..bc21d282c1 100644 --- a/dashboard/src/v2/SettingsPage.tsx +++ b/dashboard/src/v2/SettingsPage.tsx @@ -1,12 +1,13 @@ import type { FunctionComponent } from "preact"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import gsap from "gsap"; -import { Check, Compass, RefreshCw, Search, Settings, ShieldCheck, X, Zap } from "lucide-preact"; +import { Check, Compass, RefreshCw, Search, Settings, X, Zap } from "lucide-preact"; import { ActionButton } from "./components/settings/SettingsSurface.js"; import { ActionFeedbackRegion } from "./components/ui/ActionFeedbackRegion.js"; import { useSettingsPageState } from "./hooks/use-settings-page-state.js"; import { SettingsCategoryRail, CATEGORIES } from "./components/settings/SettingsCategoryRail.js"; import { SettingsContentPanels } from "./components/settings/SettingsContentPanels.js"; +import { SettingsScopeControls } from "./components/settings/SettingsScopeControls.js"; import { useReducedMotion } from "./hooks/use-reduced-motion.js"; import { useGsapInteractionTokens } from "./lib/motion/constants.js"; import { useInteractionTokens } from "./lib/motion/tokens.js"; @@ -89,7 +90,6 @@ export const SettingsPage: FunctionComponent = () => { const [panelStickyTop, setPanelStickyTop] = useState("9.5rem"); const resetProjectConfirm = useConfirmDialog(); const saveDisabledReasonId = "settings-save-disabled-reason"; - const scopeStatusId = "settings-scope-status"; const state = useSettingsPageState(CATEGORIES); const { @@ -337,86 +337,20 @@ export const SettingsPage: FunctionComponent = () => { data-settings-sticky="scope" className="sticky top-16 z-30 -mx-1 flex min-w-0 flex-wrap items-center gap-3 overflow-visible rounded-[1.5rem] border border-[color:var(--border-hairline)] bg-[var(--surface-glass)] px-1 py-2 shadow-[var(--elevation-base)] backdrop-blur-2xl" > -
- - -
-
- {scopeStatusText} -
- -
- {activeScope === "system" - ? "Editing live system defaults" - : selectedProject - ? `Editing overrides for ${selectedProject.name}` - : "Select a project to edit overrides"} -
- {projectSourceSummary ? ( -
- {projectSourceSummary} -
- ) : null} - {!selectedProject ? ( -
- Project scope unlocks after selecting a project. -
- ) : ( -
- Project scope is available for the selected project. -
- )} - -
- {filteredCategories.length} visible categor{filteredCategories.length === 1 ? "y" : "ies"} -
- - {activeDirty ? ( -
- Unsaved edits -
- ) : null} - {!activeDirty && !activeSaving && saveMessage && !error ? ( -
- - Saved -
- ) : null} +
diff --git a/dashboard/src/v2/components/settings/SettingsScopeControls.tsx b/dashboard/src/v2/components/settings/SettingsScopeControls.tsx new file mode 100644 index 0000000000..4822c1df8a --- /dev/null +++ b/dashboard/src/v2/components/settings/SettingsScopeControls.tsx @@ -0,0 +1,131 @@ +import type { JSX } from "preact"; +import { ShieldCheck } from "lucide-preact"; +import type { SettingsScope } from "../../hooks/use-settings-page-state.js"; +import type { Source } from "../../types.js"; + +export interface SettingsScopeControlsProps { + activeScope: SettingsScope; + setActiveScope: (scope: SettingsScope) => void | Promise; + selectedProject: Source | null; + scopeStatusText: string; + projectSourceSummary: string | null; + filteredCategoryCount: number; + isSearchActive: boolean; + activeDirty: boolean; + activeSaving: boolean; + saveMessage: string | null; + error: string | null; + interactionStyle: JSX.CSSProperties; +} + +const scopeStatusId = "settings-scope-status"; + +const contextChipClassName = "min-w-0 max-w-full break-words rounded-[1rem] border border-black/[0.06] bg-white/70 px-4 py-2 text-xs font-semibold text-slate-500 backdrop-blur-2xl sm:rounded-full dark:border-white/[0.06] dark:bg-void-800/60 dark:text-slate-300"; +const projectSummaryChipClassName = "min-w-0 max-w-full break-words rounded-[1rem] border border-slate-500/15 bg-slate-500/[0.06] px-4 py-2 text-xs font-semibold text-slate-600 backdrop-blur-2xl sm:rounded-full dark:border-slate-300/15 dark:bg-slate-300/[0.08] dark:text-slate-300"; +const projectUnavailableChipClassName = "min-w-0 max-w-full break-words rounded-[1rem] border border-amber-500/20 bg-amber-500/10 px-4 py-2 text-xs font-semibold text-amber-700 backdrop-blur-2xl sm:rounded-full dark:border-amber-300/20 dark:bg-amber-300/10 dark:text-amber-200"; + +export function SettingsScopeControls({ + activeScope, + setActiveScope, + selectedProject, + scopeStatusText, + projectSourceSummary, + filteredCategoryCount, + isSearchActive, + activeDirty, + activeSaving, + saveMessage, + error, + interactionStyle, +}: SettingsScopeControlsProps): JSX.Element { + const scopeButtonClassName = (scope: SettingsScope): string => `h-8 rounded-[1rem] px-4 py-2 text-xs font-bold uppercase tracking-[0.16em] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring-signal)] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-void-900 ${ + activeScope === scope + ? "bg-signal-500/[0.12] text-signal-700 dark:text-signal-300" + : "text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200" + }`; + + return ( + <> +
+ + +
+
+ {scopeStatusText} +
+ + {activeScope === "system" ? ( +
+ Editing live system defaults. +
+ ) : ( +
+ {selectedProject + ? `Editing overrides for ${selectedProject.name}` + : "Select a project to edit overrides"} +
+ )} + {projectSourceSummary ? ( +
+ {projectSourceSummary} +
+ ) : null} + {!selectedProject ? ( +
+ Project scope unlocks after selecting a project. +
+ ) : ( +
+ Project scope is available for the selected project. +
+ )} + + {isSearchActive ? ( +
+ {filteredCategoryCount} visible categor{filteredCategoryCount === 1 ? "y" : "ies"} +
+ ) : null} + + {activeDirty ? ( +
+ Unsaved edits +
+ ) : null} + {!activeDirty && !activeSaving && saveMessage && !error ? ( +
+ + Saved +
+ ) : null} + + ); +} diff --git a/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx b/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx index 4984a8c334..11666c07ab 100644 --- a/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx +++ b/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx @@ -12,16 +12,41 @@ import { TextInput, SecretInput, NumberInput, TextAreaInput, PillChoiceGroup, Se import { SettingsCategoryRail } from "../SettingsCategoryRail"; +import { SettingsScopeControls } from "../SettingsScopeControls"; import { ActionButton, NoticePanel } from "../SettingsSurface"; import { OverrideBadge } from "../panels/SharedPanelComponents"; import { SlidersHorizontal } from "lucide-preact"; import type { SettingsSearchMatches } from "../../../lib/settings-search-index"; +import type { Source } from "../../../types"; import userEvent from "@testing-library/user-event"; import { SettingsContentPanels } from "../SettingsContentPanels"; import { UnsavedChangesModal } from "../../ui/UnsavedChangesModal"; import { ProviderInstanceCard } from "../ProviderInstanceCard"; const defaultInnerHeight = window.innerHeight; +const interactionStyle = { transitionDuration: "200ms", transitionTimingFunction: "ease" }; +const genericProject = { + id: "project-1", + name: "Test Project", +} as Source; + +const renderSettingsScopeControls = (overrides: Partial[0]> = {}) => render( + {}} + selectedProject={genericProject} + scopeStatusText="System scope selected. Editing live system defaults." + projectSourceSummary={null} + filteredCategoryCount={10} + isSearchActive={false} + activeDirty={false} + activeSaving={false} + saveMessage={null} + error={null} + interactionStyle={interactionStyle} + {...overrides} + />, +); afterEach(() => { vi.restoreAllMocks(); @@ -251,6 +276,89 @@ vi.mock("../panels/SettingsGeneralPanel", () => ({ expect(screen.getByRole("radio", { name: "Project" })).toHaveAttribute("aria-checked", "false"); }); + it("SettingsScopeControls renders system scope without duplicated visible system context", () => { + renderSettingsScopeControls(); + + const group = screen.getByRole("radiogroup", { name: "Settings scope" }); + expect(group).toHaveAccessibleDescription( + "Editing live system defaults. Project scope is available for the selected project. System scope selected. Editing live system defaults.", + ); + expect(screen.getByRole("radio", { name: "System" })).toHaveAttribute("aria-checked", "true"); + expect(screen.getByRole("radio", { name: "Project" })).toHaveAttribute("aria-checked", "false"); + expect(screen.queryByText("System (selected)")).not.toBeInTheDocument(); + expect(screen.getByText("Editing live system defaults.")).toHaveClass("sr-only"); + expect(screen.queryByText(/visible categor/)).not.toBeInTheDocument(); + }); + + it("SettingsScopeControls keeps project unavailable guidance wired to the disabled radio", () => { + const setActiveScope = vi.fn(); + renderSettingsScopeControls({ + selectedProject: null, + setActiveScope, + scopeStatusText: "Project scope is unavailable until a project is selected.", + }); + + const projectRadio = screen.getByRole("radio", { name: "Project" }); + expect(projectRadio).toBeDisabled(); + expect(projectRadio).toHaveAccessibleDescription("Project scope unlocks after selecting a project."); + expect(screen.getByText("Project scope unlocks after selecting a project.")).toHaveAttribute("id", "settings-project-scope-disabled"); + + fireEvent.click(projectRadio); + expect(setActiveScope).not.toHaveBeenCalled(); + }); + + it("SettingsScopeControls renders project inheritance and saved state chips", () => { + renderSettingsScopeControls({ + activeScope: "project", + scopeStatusText: "Project scope selected. Editing overrides for Test Project.", + projectSourceSummary: "2 overridden settings and 8 inherited settings in this project scope.", + saveMessage: "Settings saved.", + }); + + expect(screen.getByRole("radio", { name: "Project" })).toHaveAttribute("aria-checked", "true"); + expect(screen.getByText("Editing overrides for Test Project")).toBeInTheDocument(); + expect(screen.getByText("2 overridden settings and 8 inherited settings in this project scope.")).toBeInTheDocument(); + expect(screen.getByText("Saved")).toBeInTheDocument(); + }); + + it("SettingsScopeControls renders unsaved edits without the saved badge", () => { + renderSettingsScopeControls({ + activeDirty: true, + saveMessage: "Settings saved.", + }); + + expect(screen.getByText("Unsaved edits")).toBeInTheDocument(); + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + }); + + it("SettingsScopeControls shows visible category count only while Smart Find is active", () => { + const { rerender } = renderSettingsScopeControls({ + filteredCategoryCount: 4, + isSearchActive: false, + }); + + expect(screen.queryByText("4 visible categories")).not.toBeInTheDocument(); + + rerender( + {}} + selectedProject={genericProject} + scopeStatusText="System scope selected. Editing live system defaults." + projectSourceSummary={null} + filteredCategoryCount={1} + isSearchActive + activeDirty={false} + activeSaving={false} + saveMessage={null} + error={null} + interactionStyle={interactionStyle} + />, + ); + + expect(screen.getByText("1 visible category")).toBeInTheDocument(); + }); + it("SelectInput keeps disabled reason visible and described by the control", () => { render( Sprint & Git`, directly below `Merge Gates & Autofix`, even though its persisted settings path remains `agents.qualityAssurance`. QA-labeled project agents remain prominent in the selector ordering, and disabled project selectors still communicate that built-in QA routing remains available. * Every visible `SectionCard` subcategory exposes card-level help controls in the header action area: an info icon that opens keyboard-accessible guidance and a docs icon that links to the exact `/docs/user/dashboard/settings#` anchor. These controls supplement row-level info affordances and must not replace field-specific help. @@ -50,7 +50,7 @@ This document defines the visual patterns and rules for the Settings workspace. * Always rely on semantic CSS variables from `globals.css` and `tokens.css` via `[var(--variable-name)]` for colors, backgrounds, borders, and shadows instead of hardcoding Tailwind utility colors and shadow values. * Inline validation and character-counter feedback must use the shared `inlineValidation` and `controlFeedback` interaction tokens. Error text should be announced only after blur or an explicit submit/force-validation path, and helper text should not remain in `aria-describedby` while an error is active. * Settings page save state lives at the active panel boundary. `SettingsContentPanels` sets `aria-busy` while loading, saving, or resetting; uses `ActionFeedbackRegion` for saved/dirty/saving/loading states; and keeps a durable visible active-panel/save-state line so reduced-motion users see the current category and outcome without relying on panel motion. Blocking errors switch the status stream to assertive alert copy. Do not replace field contents with loading placeholders during saves or background refreshes. -* The Settings scope strip is sticky below the app shell and includes the System/Project radiogroup, selected-scope context, project-scope availability or inheritance summary, visible-category count, unsaved edits indicator, and saved badge. It must wrap naturally on narrow screens, preserve focus rings while pinned, and keep `settings-scope-context`, `settings-project-scope-disabled`, and the polite scope status region wired to the radiogroup. +* The Settings scope strip is sticky below the app shell and includes the System/Project radiogroup, selected-scope context, project-scope availability or inheritance summary, unsaved edits indicator, and saved badge. It shows the visible-category count only while Smart Find is active; otherwise category-count context stays in the search/status regions instead of the sticky strip. The strip must wrap naturally on narrow screens, preserve focus rings while pinned, and keep `settings-scope-context`, `settings-project-scope-disabled`, and the polite scope status region wired to the radiogroup. * The `SettingsContentPanels` active-panel/save-state strip is sticky beneath the scope strip. `SettingsPage` measures the wrapped scope strip height and passes the resulting offset into the panel strip so the two pinned surfaces do not overlap the app shell, each other, or the desktop `SettingsCategoryRail` at `lg:top-16`. * Save and background reload paths must preserve dirty drafts until the affected scope has actually saved or reset. If system settings save while project settings are dirty, project draft values remain mounted and are not replaced by an effective-settings refresh; failed project saves leave the draft visible for correction. * Category rail buttons expose selected and pending state through active styling plus ARIA (`aria-current`, `aria-selected`, `aria-busy`) without extra visible status badges. Disabled state keeps visible disabled copy plus `aria-disabled`. Category movement uses explicit `selectionMovement` markers; panel entry uses `enterExit`; reduced-motion users receive the same static active styling, validation copy, busy state, and save outcome text without relying on animated movement. From 625bbcc93bd100efdf84bef272b349210e89d914 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 04:03:42 +0000 Subject: [PATCH 3/7] fix(ci): resolve failing checks on task/feature-codux-155-qs-ui-interactions-design-impr-t01-codex-mra47zxx --- .../dashboard-quality-regressions.test.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/dashboard/accessibility/dashboard-quality-regressions.test.tsx b/tests/dashboard/accessibility/dashboard-quality-regressions.test.tsx index e6b985d434..a40fc47826 100644 --- a/tests/dashboard/accessibility/dashboard-quality-regressions.test.tsx +++ b/tests/dashboard/accessibility/dashboard-quality-regressions.test.tsx @@ -692,10 +692,15 @@ describe("dashboard accessibility quality regressions", () => { const quicksprintPanel = readSource("dashboard/src/v2/components/quicksprint/QuicksprintPanel.tsx"); expect(quicksprintPanel).toMatch(/role="status"/); - const settings = readSource("dashboard/src/v2/components/settings/SettingsContentPanels.tsx"); - expect(settings).toMatch(/aria-busy=\{activeSaving \|\| loading \|\| resettingProject \? "true" : undefined\}/); - expect(settings).toMatch(/role=\{error \? "alert" : "status"\}/); - expect(settings).toMatch(/Current values remain visible/); + const settingsContentPanels = readSource("dashboard/src/v2/components/settings/SettingsContentPanels.tsx"); + expect(settingsContentPanels).toMatch(/aria-busy=\{activeSaving \|\| loading \|\| resettingProject \? "true" : undefined\}/); + expect(settingsContentPanels).toMatch(//); + expect(settingsContentPanels).toMatch(/Current values remain visible/); + + const settingsActivePanelStatus = readSource("dashboard/src/v2/components/settings/SettingsActivePanelStatus.tsx"); + expect(settingsActivePanelStatus).toMatch(/role=\{error \? "alert" : "status"\}/); + expect(settingsActivePanelStatus).toMatch(/aria-live=\{error \? "assertive" : "polite"\}/); + expect(settingsActivePanelStatus).toMatch(/data-settings-sticky=\{sticky \? "active-panel" : undefined\}/); const liveSessionViewModel = readSource("dashboard/src/v2/lib/live-session-view-model.ts"); expect(liveSessionViewModel).toMatch(/Stale Data/); From ff686a9ab46e2678285864f061637fcefc2e2eaf Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 04:32:36 +0000 Subject: [PATCH 4/7] feat(task T03): implement via codex --- dashboard/src/v2/SettingsPage.tsx | 85 +++++++------------ .../settings/SettingsActivePanelStatus.tsx | 4 +- .../settings/SettingsCategoryRail.tsx | 7 +- .../settings/SettingsContentPanels.tsx | 5 +- .../__tests__/SettingsControls.test.tsx | 21 +++-- docs-web/user/dashboard/settings.md | 2 +- docs/dashboard/design-system-settings.md | 6 +- 7 files changed, 58 insertions(+), 72 deletions(-) diff --git a/dashboard/src/v2/SettingsPage.tsx b/dashboard/src/v2/SettingsPage.tsx index bc21d282c1..37a5bba02a 100644 --- a/dashboard/src/v2/SettingsPage.tsx +++ b/dashboard/src/v2/SettingsPage.tsx @@ -7,6 +7,7 @@ import { ActionFeedbackRegion } from "./components/ui/ActionFeedbackRegion.js"; import { useSettingsPageState } from "./hooks/use-settings-page-state.js"; import { SettingsCategoryRail, CATEGORIES } from "./components/settings/SettingsCategoryRail.js"; import { SettingsContentPanels } from "./components/settings/SettingsContentPanels.js"; +import { SettingsActivePanelStatus } from "./components/settings/SettingsActivePanelStatus.js"; import { SettingsScopeControls } from "./components/settings/SettingsScopeControls.js"; import { useReducedMotion } from "./hooks/use-reduced-motion.js"; import { useGsapInteractionTokens } from "./lib/motion/constants.js"; @@ -79,7 +80,6 @@ export function focusFirstInvalidSettingsControl(root: ParentNode): string | nul export const SettingsPage: FunctionComponent = () => { const headerRef = useRef(null); const contentRef = useRef(null); - const scopeStickyRef = useRef(null); const contentTweenRef = useRef | null>(null); const mountedRef = useRef(true); const prefersReducedMotion = useReducedMotion(); @@ -87,7 +87,6 @@ export const SettingsPage: FunctionComponent = () => { const interactionTokens = useInteractionTokens(); const [pendingCategory, setPendingCategory] = useState(null); const [validationMessage, setValidationMessage] = useState(null); - const [panelStickyTop, setPanelStickyTop] = useState("9.5rem"); const resetProjectConfirm = useConfirmDialog(); const saveDisabledReasonId = "settings-save-disabled-reason"; @@ -191,35 +190,6 @@ export const SettingsPage: FunctionComponent = () => { return () => ctx.revert(); }, [prefersReducedMotion]); - useLayoutEffect(() => { - const scopeSticky = scopeStickyRef.current; - if (!scopeSticky) { - return; - } - - const appShellOffset = 64; - const stickyGap = 12; - let frameId = 0; - const updateStickyOffset = () => { - window.cancelAnimationFrame(frameId); - frameId = window.requestAnimationFrame(() => { - const nextOffset = `${Math.ceil(scopeSticky.getBoundingClientRect().height + appShellOffset + stickyGap)}px`; - setPanelStickyTop((currentOffset) => currentOffset === nextOffset ? currentOffset : nextOffset); - }); - }; - - updateStickyOffset(); - window.addEventListener("resize", updateStickyOffset); - const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateStickyOffset); - resizeObserver?.observe(scopeSticky); - - return () => { - window.cancelAnimationFrame(frameId); - window.removeEventListener("resize", updateStickyOffset); - resizeObserver?.disconnect(); - }; - }, []); - const switchCategory = useCallback((categoryId: typeof activeCategory): void => { if (!contentRef.current || categoryId === activeCategory) { return; @@ -331,27 +301,6 @@ export const SettingsPage: FunctionComponent = () => { title="Settings & Integration" subtitle="Tune the system baseline, then shape project-level behavior with faster wayfinding, denser controls, and focused routing workspaces." /> - -
- -
@@ -486,7 +435,32 @@ export const SettingsPage: FunctionComponent = () => {
-
+
+
+ + +
+ { settingsSearchMatches={settingsSearchMatches} onSwitchCategory={switchCategory} pendingCategory={pendingCategory} + className="lg:col-start-1 lg:row-span-2 lg:row-start-1" />
{ aria-label="Settings category panel" aria-busy={activeSaving || loading || resettingProject ? "true" : undefined} data-motion-contract="enterExit" - className="flex min-w-0 flex-col gap-5" + className="flex min-w-0 flex-col gap-5 lg:col-start-2 lg:row-start-2" >
{ />
- +
diff --git a/dashboard/src/v2/components/settings/SettingsActivePanelStatus.tsx b/dashboard/src/v2/components/settings/SettingsActivePanelStatus.tsx index abdd14599e..f4a16e2d94 100644 --- a/dashboard/src/v2/components/settings/SettingsActivePanelStatus.tsx +++ b/dashboard/src/v2/components/settings/SettingsActivePanelStatus.tsx @@ -52,8 +52,8 @@ export const SettingsActivePanelStatus: FunctionComponent<{ } as JSX.CSSProperties) : style; const statusClassName = [ - sticky ? "sticky top-[var(--settings-active-panel-top)] z-20" : null, - "mb-3 flex min-w-0 flex-wrap items-center gap-2 overflow-visible rounded-[1rem] border border-[color:var(--border-hairline)] bg-[var(--surface-glass)] px-3 py-2 text-xs font-semibold text-slate-500 shadow-[var(--elevation-base)] backdrop-blur-2xl dark:text-slate-300", + sticky ? "sticky top-[var(--settings-active-panel-top)] z-20 mb-3" : null, + "flex min-w-0 flex-wrap items-center gap-2 overflow-visible rounded-[1rem] border border-[color:var(--border-hairline)] bg-[var(--surface-glass)] px-3 py-2 text-xs font-semibold text-slate-500 shadow-[var(--elevation-base)] backdrop-blur-2xl dark:text-slate-300", className, ].filter(Boolean).join(" "); diff --git a/dashboard/src/v2/components/settings/SettingsCategoryRail.tsx b/dashboard/src/v2/components/settings/SettingsCategoryRail.tsx index 83f7d63823..1a238f159e 100644 --- a/dashboard/src/v2/components/settings/SettingsCategoryRail.tsx +++ b/dashboard/src/v2/components/settings/SettingsCategoryRail.tsx @@ -32,6 +32,7 @@ export interface SettingsCategoryRailProps { onSwitchCategory: (categoryId: CategoryId) => void; pendingCategory?: CategoryId | null; disabledCategoryReason?: string | null; + className?: string; } export const SettingsCategoryRail: FunctionComponent = ({ @@ -42,6 +43,7 @@ export const SettingsCategoryRail: FunctionComponent onSwitchCategory, pendingCategory = null, disabledCategoryReason = null, + className, }) => { const normalizedSearch = settingsSearch.trim().toLowerCase(); const railRef = useRef(null); @@ -126,7 +128,10 @@ export const SettingsCategoryRail: FunctionComponent onScroll={updateRailMetrics} style={railHeightStyle} data-motion-contract="selectionMovement" - className="scrollbar-hide flex min-w-0 flex-col gap-3 rounded-[1.75rem] border border-[color:var(--border-hairline)] bg-[var(--surface-glass)] p-3 backdrop-blur-2xl shadow-[var(--elevation-base)] lg:sticky lg:top-16 lg:max-h-[var(--settings-category-rail-available-height)] lg:overflow-y-auto lg:overscroll-contain" + className={[ + "scrollbar-hide flex min-w-0 flex-col gap-3 rounded-[1.75rem] border border-[color:var(--border-hairline)] bg-[var(--surface-glass)] p-3 backdrop-blur-2xl shadow-[var(--elevation-base)] lg:sticky lg:top-16 lg:max-h-[var(--settings-category-rail-available-height)] lg:overflow-y-auto lg:overscroll-contain", + className, + ].filter(Boolean).join(" ")} >
= ({ state, stickyTop = "9.5rem" }) => { + showActivePanelStatus?: boolean; +}> = ({ state, stickyTop = "9.5rem", showActivePanelStatus = true }) => { const { activeCategory, activeDirty, activeSaving, error, saveMessage, loading, resettingProject } = state; const tokens = useInteractionTokens(); @@ -50,7 +51,7 @@ export const SettingsContentPanels: FunctionComponent<{ return (
- + {showActivePanelStatus ? : null} { expect(screen.getByText("General panel values stay mounted")).toBeInTheDocument(); }); - it("SettingsContentPanels accepts the measured sticky offset from the settings scope strip", () => { + it("SettingsContentPanels can suppress the active panel strip for a shared command/status bar", () => { render( { /> ); - expect(screen.getByText("Active panel").parentElement).toHaveStyle("--settings-active-panel-top: 148px"); + expect(screen.queryByText("Active panel")).not.toBeInTheDocument(); + expect(screen.getByText("General panel values stay mounted")).toBeInTheDocument(); }); - it("SettingsPage keeps the scope controls in a sticky wrapping strip and passes its measured offset to the panel strip", () => { + it("SettingsPage keeps scope controls and active panel status in one sticky wrapping bar", () => { const source = readFileSync("dashboard/src/v2/SettingsPage.tsx", "utf8"); - expect(source).toContain('data-settings-sticky="scope"'); + expect(source).toContain('data-settings-sticky="settings-command-status"'); expect(source).toContain("sticky top-16 z-30"); - expect(source).toContain("flex min-w-0 flex-wrap"); - expect(source).toContain("scopeSticky.getBoundingClientRect().height + appShellOffset + stickyGap"); - expect(source).toContain(""); + expect(source).toContain("flex min-w-0 max-w-full flex-wrap"); + expect(source).toContain(""); + expect(source).not.toContain("scopeSticky.getBoundingClientRect()"); + expect(source).not.toContain("panelStickyTop"); }); it("SettingsContentPanels renders reset pending feedback while keeping values mounted", () => { diff --git a/docs-web/user/dashboard/settings.md b/docs-web/user/dashboard/settings.md index 69b89560e0..20aa5f009b 100644 --- a/docs-web/user/dashboard/settings.md +++ b/docs-web/user/dashboard/settings.md @@ -18,7 +18,7 @@ Switch scope with the selector at the top: - **Project** — applies to the active project. - **Sprint** — applies to the selected sprint within the active project. -The sticky scope row keeps the System/Project selector, project availability or inheritance context, and save state visible while you scroll. It shows the visible-category count only while Smart Find is active; when search is inactive, category-count context stays in the search/status announcements. +The sticky command/status row keeps the System/Project selector, project availability or inheritance context, active panel, and save state visible together while you scroll. It shows the visible-category count only while Smart Find is active; when search is inactive, category-count context stays in the search/status announcements. ## Categories diff --git a/docs/dashboard/design-system-settings.md b/docs/dashboard/design-system-settings.md index a28ec85e10..b59eb8d541 100644 --- a/docs/dashboard/design-system-settings.md +++ b/docs/dashboard/design-system-settings.md @@ -49,9 +49,9 @@ This document defines the visual patterns and rules for the Settings workspace. * Always rely on semantic CSS variables from `globals.css` and `tokens.css` via `[var(--variable-name)]` for colors, backgrounds, borders, and shadows instead of hardcoding Tailwind utility colors and shadow values. * Inline validation and character-counter feedback must use the shared `inlineValidation` and `controlFeedback` interaction tokens. Error text should be announced only after blur or an explicit submit/force-validation path, and helper text should not remain in `aria-describedby` while an error is active. -* Settings page save state lives at the active panel boundary. `SettingsContentPanels` sets `aria-busy` while loading, saving, or resetting; uses `ActionFeedbackRegion` for saved/dirty/saving/loading states; and renders `SettingsActivePanelStatus` for the durable visible active-panel/save-state line so reduced-motion users see the current category and outcome without relying on panel motion. Blocking errors switch the status stream to assertive alert copy. Do not replace field contents with loading placeholders during saves or background refreshes. -* The Settings scope strip is sticky below the app shell and includes the System/Project radiogroup, selected-scope context, project-scope availability or inheritance summary, unsaved edits indicator, and saved badge. It shows the visible-category count only while Smart Find is active; otherwise category-count context stays in the search/status regions instead of the sticky strip. The strip must wrap naturally on narrow screens, preserve focus rings while pinned, and keep `settings-scope-context`, `settings-project-scope-disabled`, and the polite scope status region wired to the radiogroup. -* The `SettingsContentPanels` active-panel/save-state strip is sticky beneath the scope strip. `SettingsPage` measures the wrapped scope strip height and passes the resulting offset into the panel strip so the two pinned surfaces do not overlap the app shell, each other, or the desktop `SettingsCategoryRail` at `lg:top-16`. +* Settings page save state lives at the active panel boundary. `SettingsContentPanels` sets `aria-busy` while loading, saving, or resetting and uses `ActionFeedbackRegion` for saved/dirty/saving/loading states. `SettingsActivePanelStatus` provides the durable visible active-panel/save-state line so reduced-motion users see the current category and outcome without relying on panel motion. Blocking errors switch the status stream to assertive alert copy. Do not replace field contents with loading placeholders during saves or background refreshes. +* The Settings command/status bar is sticky below the app shell and includes the System/Project radiogroup, selected-scope context, project-scope availability or inheritance summary, unsaved edits indicator, saved badge, and inline active-panel/save-state status. It shows the visible-category count only while Smart Find is active; otherwise category-count context stays in the search/status regions instead of the sticky bar. The bar must wrap naturally on narrow screens, preserve focus rings while pinned, and keep `settings-scope-context`, `settings-project-scope-disabled`, the polite scope status region, and the active-panel status announcement wired to the controls. +* `SettingsPage` owns the unified sticky command/status bar at `top-16` and renders `SettingsContentPanels` with the separate active-panel strip suppressed. The desktop `SettingsCategoryRail` remains `lg:sticky lg:top-16`; page content starts below the unified bar so the rail, command/status bar, and active panel do not overlap. * Save and background reload paths must preserve dirty drafts until the affected scope has actually saved or reset. If system settings save while project settings are dirty, project draft values remain mounted and are not replaced by an effective-settings refresh; failed project saves leave the draft visible for correction. * Category rail buttons expose selected and pending state through active styling plus ARIA (`aria-current`, `aria-selected`, `aria-busy`) without extra visible status badges. Disabled state keeps visible disabled copy plus `aria-disabled`. Category movement uses explicit `selectionMovement` markers; panel entry uses `enterExit`; reduced-motion users receive the same static active styling, validation copy, busy state, and save outcome text without relying on animated movement. * Scope switches announce the selected system/project context through a polite status region. Project scope must also expose visible inherited/overridden summary text while preserving the existing per-field badge semantics: inherited stays neutral, project overrides stay amber and resettable only when the active scope can clear them. From fbb912f9c1351ed4d96195ae6a00328057b0caa8 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 04:42:03 +0000 Subject: [PATCH 5/7] feat(task T04): implement via codex --- dashboard/src/v2/SettingsPage.tsx | 292 ++++++++++++------ .../__tests__/SettingsControls.test.tsx | 126 +++++++- docs-web/user/dashboard/settings.md | 2 +- docs/dashboard/dashboard-guide.md | 2 +- docs/dashboard/design-system-settings.md | 4 +- docs/settings/configuration-and-storage.md | 2 +- 6 files changed, 322 insertions(+), 106 deletions(-) diff --git a/dashboard/src/v2/SettingsPage.tsx b/dashboard/src/v2/SettingsPage.tsx index 37a5bba02a..9f333d3474 100644 --- a/dashboard/src/v2/SettingsPage.tsx +++ b/dashboard/src/v2/SettingsPage.tsx @@ -1,10 +1,10 @@ -import type { FunctionComponent } from "preact"; +import type { FunctionComponent, JSX, RefObject } from "preact"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import gsap from "gsap"; import { Check, Compass, RefreshCw, Search, Settings, X, Zap } from "lucide-preact"; import { ActionButton } from "./components/settings/SettingsSurface.js"; import { ActionFeedbackRegion } from "./components/ui/ActionFeedbackRegion.js"; -import { useSettingsPageState } from "./hooks/use-settings-page-state.js"; +import { useSettingsPageState, type Category, type CategoryId } from "./hooks/use-settings-page-state.js"; import { SettingsCategoryRail, CATEGORIES } from "./components/settings/SettingsCategoryRail.js"; import { SettingsContentPanels } from "./components/settings/SettingsContentPanels.js"; import { SettingsActivePanelStatus } from "./components/settings/SettingsActivePanelStatus.js"; @@ -17,7 +17,184 @@ import { PageHeader } from "./components/layout/PageHeader.js"; import { ConfirmDialog } from "./components/ui/ConfirmDialog.js"; import { UnsavedChangesModal } from "./components/ui/UnsavedChangesModal.js"; import { useConfirmDialog } from "./hooks/use-confirm-dialog.js"; -import { getSettingsSearchMatchPreview } from "./lib/settings-search-index.js"; +import { getSettingsSearchMatchPreview, type SettingsSearchMatches } from "./lib/settings-search-index.js"; + +interface SettingsSearchStatusDetails { + searchTerm: string; + resultCount: number; + matchingCategoryCount: number; + activeCategoryLabel: string; + activeMatchPreview: string[]; + smartFindPreview: string[]; +} + +export function getSettingsSearchStatusText({ + searchTerm, + resultCount, + matchingCategoryCount, + activeCategoryLabel, + activeMatchPreview, + smartFindPreview, +}: SettingsSearchStatusDetails): string { + const categoryLabel = matchingCategoryCount === 1 ? "matching category" : "matching categories"; + const resultLabel = resultCount === 1 ? "result" : "results"; + const activePreviewText = activeMatchPreview.length + ? ` Active matches: ${activeMatchPreview.join(", ")}.` + : ""; + const previewText = smartFindPreview.length + ? ` Match previews: ${smartFindPreview.join(", ")}.` + : " Match previews: none."; + const recoveryText = matchingCategoryCount === 0 + ? " Clear the search or try routing, provider, auth, CI, agent, or memory." + : ""; + + return `${resultCount} ${resultLabel} across ${matchingCategoryCount} ${categoryLabel} for "${searchTerm}". Active category: ${activeCategoryLabel}.${activePreviewText}${previewText}${recoveryText}`; +} + +export interface SettingsSmartFindSearchProps { + settingsSearch: string; + setSettingsSearch: (value: string) => void; + searchInputRef: RefObject; + filteredCategories: Category[]; + settingsSearchMatches: SettingsSearchMatches; + activeCategory: CategoryId; + activeCategoryConfig: Category; + onSwitchCategory: (categoryId: CategoryId) => void; + interactionStyle: JSX.CSSProperties; +} + +export const SettingsSmartFindSearch: FunctionComponent = ({ + settingsSearch, + setSettingsSearch, + searchInputRef, + filteredCategories, + settingsSearchMatches, + activeCategory, + activeCategoryConfig, + onSwitchCategory, + interactionStyle, +}) => { + const normalizedSearch = settingsSearch.trim(); + const isSearchActive = normalizedSearch.length > 0; + const smartFindPreview = useMemo(() => ( + filteredCategories + .flatMap((category) => getSettingsSearchMatchPreview(settingsSearchMatches[category.id], 2)) + .filter((match, index, matches) => matches.indexOf(match) === index) + .slice(0, 4) + ), [filteredCategories, settingsSearchMatches]); + const activeMatchPreview = getSettingsSearchMatchPreview(settingsSearchMatches[activeCategory], 3); + const smartFindMatchCount = useMemo(() => ( + Object.values(settingsSearchMatches).reduce((count, match) => ( + count + match.matchedLabels.length + match.matchedDescriptions.length + match.matchedTerms.length + ), 0) + ), [settingsSearchMatches]); + const quickCategories = useMemo(() => ( + (isSearchActive ? filteredCategories : CATEGORIES) + .filter((category) => !["general", "models", "sprint", "browser"].includes(category.id)) + .slice(0, 4) + ), [filteredCategories, isSearchActive]); + const activeSearchStatus = isSearchActive + ? getSettingsSearchStatusText({ + searchTerm: normalizedSearch, + resultCount: smartFindMatchCount, + matchingCategoryCount: filteredCategories.length, + activeCategoryLabel: activeCategoryConfig.label, + activeMatchPreview, + smartFindPreview, + }) + : null; + + return ( + <> +
+ + Smart Find +
+ +
+ + setSettingsSearch((event.currentTarget as HTMLInputElement).value)} + placeholder="Search categories, providers, CI, auth, prompts" + aria-describedby="settings-search-results" + className="w-full bg-transparent text-sm text-slate-700 outline-none placeholder:text-slate-400 dark:text-slate-200" + /> + {isSearchActive ? ( + + ) : ( +
+ / +
+ )} +
+
+ {isSearchActive ? ( + activeSearchStatus + ) : ( + <> + + {filteredCategories.length} settings categories available. + + Press slash to search settings. + + )} +
+ {isSearchActive && smartFindPreview.length > 0 ? ( +
+ {smartFindPreview.map((match) => ( + + {match} + + ))} +
+ ) : null} + {quickCategories.length > 0 ? ( +
+ {quickCategories.map((category) => ( + + ))} +
+ ) : null} + + ); +}; export function focusFirstInvalidSettingsControl(root: ParentNode): string | null { const controls = Array.from(root.querySelectorAll( @@ -117,23 +294,6 @@ export const SettingsPage: FunctionComponent = () => { } = state; const normalizedSearch = settingsSearch.trim(); - const smartFindPreview = useMemo(() => ( - filteredCategories - .flatMap((category) => getSettingsSearchMatchPreview(settingsSearchMatches[category.id], 2)) - .filter((match, index, matches) => matches.indexOf(match) === index) - .slice(0, 4) - ), [filteredCategories, settingsSearchMatches]); - const activeMatchPreview = getSettingsSearchMatchPreview(settingsSearchMatches[activeCategory], 3); - const smartFindMatchCount = useMemo(() => ( - Object.values(settingsSearchMatches).reduce((count, match) => ( - count + match.matchedLabels.length + match.matchedDescriptions.length + match.matchedTerms.length - ), 0) - ), [settingsSearchMatches]); - const quickCategories = useMemo(() => ( - (filteredCategories.length > 0 ? filteredCategories : CATEGORIES) - .filter((category) => !["general", "models", "sprint", "browser"].includes(category.id)) - .slice(0, 4) - ), [filteredCategories]); const scopeControlStyle = { transitionDuration: interactionTokens.controlFeedback.duration, transitionTimingFunction: interactionTokens.controlFeedback.ease, @@ -304,85 +464,17 @@ export const SettingsPage: FunctionComponent = () => {
-
- - Smart Find -
- -
- - setSettingsSearch((event.currentTarget as HTMLInputElement).value)} - placeholder="Search categories, providers, CI, auth, prompts" - aria-describedby="settings-search-results" - className="w-full bg-transparent text-sm text-slate-700 outline-none placeholder:text-slate-400 dark:text-slate-200" - /> - {normalizedSearch ? ( - - ) : ( -
- / -
- )} -
-
- {normalizedSearch - ? filteredCategories.length > 0 - ? `${smartFindMatchCount} result${smartFindMatchCount === 1 ? "" : "s"} across ${filteredCategories.length} categor${filteredCategories.length === 1 ? "y" : "ies"} for ${normalizedSearch}. Active: ${activeCategoryConfig.label}${activeMatchPreview.length ? ` (${activeMatchPreview.join(", ")})` : ""}.` - : `No settings match ${normalizedSearch}. Clear the search or try routing, provider, auth, CI, agent, or memory.` - : `${filteredCategories.length} settings categories available. Press slash to search.`} -
- {normalizedSearch && smartFindPreview.length > 0 ? ( -
- {smartFindPreview.map((match) => ( - - {match} - - ))} -
- ) : null} -
- {quickCategories.map((category) => ( - - ))} -
+
{activeScope === "project" ? ( { scopeStatusText={scopeStatusText} projectSourceSummary={projectSourceSummary} filteredCategoryCount={filteredCategories.length} - isSearchActive={Boolean(normalizedSearch)} + isSearchActive={normalizedSearch.length > 0} activeDirty={activeDirty} activeSaving={activeSaving} saveMessage={saveMessage} diff --git a/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx b/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx index 0e025cbe9d..1a01b17ffc 100644 --- a/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx +++ b/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ import { h } from "preact"; +import { useRef, useState } from "preact/hooks"; import { readFileSync } from "node:fs"; import { describe, it, expect, afterEach, vi } from "vitest"; import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/preact"; @@ -11,7 +12,7 @@ import { SprintKeyEditor } from "../SprintKeyEditor"; import { TextInput, SecretInput, NumberInput, TextAreaInput, PillChoiceGroup, SelectInput } from "../SettingsFormFields"; -import { SettingsCategoryRail } from "../SettingsCategoryRail"; +import { SettingsCategoryRail, CATEGORIES } from "../SettingsCategoryRail"; import { SettingsScopeControls } from "../SettingsScopeControls"; import { ActionButton, NoticePanel } from "../SettingsSurface"; import { OverrideBadge } from "../panels/SharedPanelComponents"; @@ -23,6 +24,7 @@ import { SettingsActivePanelStatus } from "../SettingsActivePanelStatus"; import { SettingsContentPanels } from "../SettingsContentPanels"; import { UnsavedChangesModal } from "../../ui/UnsavedChangesModal"; import { ProviderInstanceCard } from "../ProviderInstanceCard"; +import { SettingsSmartFindSearch } from "../../../SettingsPage"; const defaultInnerHeight = window.innerHeight; const interactionStyle = { transitionDuration: "200ms", transitionTimingFunction: "ease" }; @@ -49,6 +51,22 @@ const renderSettingsScopeControls = (overrides: Partial, ); +const renderSettingsSmartFindSearch = (overrides: Partial[0]> = {}) => { + const defaultProps: Parameters[0] = { + settingsSearch: "", + setSettingsSearch: () => {}, + searchInputRef: { current: null }, + filteredCategories: CATEGORIES, + settingsSearchMatches: {}, + activeCategory: "general", + activeCategoryConfig: CATEGORIES[0], + onSwitchCategory: () => {}, + interactionStyle, + }; + + return render(); +}; + afterEach(() => { vi.restoreAllMocks(); Object.defineProperty(window, "innerHeight", { configurable: true, value: defaultInnerHeight }); @@ -211,6 +229,112 @@ vi.mock("../panels/SettingsGeneralPanel", () => ({ expect(screen.getByText(/Keep the search field focused/)).toBeInTheDocument(); }); + it("SettingsSmartFindSearch keeps idle status quiet while preserving the category count for assistive technology", () => { + renderSettingsSmartFindSearch(); + + expect(screen.getByText("Press slash to search settings.")).toBeInTheDocument(); + expect(screen.getByText("10 settings categories available.")).toHaveClass("sr-only"); + expect(screen.queryByText("10 settings categories available. Press slash to search.")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Clear settings search" })).not.toBeInTheDocument(); + expect(screen.getByText("/")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Appearance" })).toBeInTheDocument(); + }); + + it("SettingsSmartFindSearch announces active matches with counts, active category, and previews", () => { + const modelsCategory = CATEGORIES.find((category) => category.id === "models")!; + const integrationsCategory = CATEGORIES.find((category) => category.id === "integrations")!; + const settingsSearchMatches: SettingsSearchMatches = { + models: { + categoryId: "models", + matchedLabels: ["Claude Code"], + matchedDescriptions: [], + matchedTerms: ["routing"], + }, + integrations: { + categoryId: "integrations", + matchedLabels: [], + matchedDescriptions: ["API keys"], + matchedTerms: [], + }, + }; + + renderSettingsSmartFindSearch({ + settingsSearch: "claude", + filteredCategories: [modelsCategory, integrationsCategory], + settingsSearchMatches, + activeCategory: "models", + activeCategoryConfig: modelsCategory, + }); + + expect(screen.getByRole("status")).toHaveTextContent( + '3 results across 2 matching categories for "claude". Active category: AI Models. Active matches: Claude Code, routing. Match previews: Claude Code, routing, API keys.', + ); + expect(screen.getByLabelText("Smart Find match previews")).toHaveTextContent("Claude Code"); + expect(screen.getByRole("button", { name: "Integrations" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Appearance" })).not.toBeInTheDocument(); + }); + + it("SettingsSmartFindSearch announces active no-match searches without hiding recovery context", () => { + renderSettingsSmartFindSearch({ + settingsSearch: "zzzz", + filteredCategories: [], + settingsSearchMatches: {}, + }); + + expect(screen.getByRole("status")).toHaveTextContent( + '0 results across 0 matching categories for "zzzz". Active category: General. Match previews: none. Clear the search or try routing, provider, auth, CI, agent, or memory.', + ); + expect(screen.queryByLabelText("Smart Find match previews")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Appearance" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Clear settings search" })).toBeInTheDocument(); + }); + + it("SettingsSmartFindSearch clear button restores focus and removes search-only chips", async () => { + const user = userEvent.setup(); + const integrationsCategory = CATEGORIES.find((category) => category.id === "integrations")!; + const settingsSearchMatches: SettingsSearchMatches = { + integrations: { + categoryId: "integrations", + matchedLabels: ["Claude Code"], + matchedDescriptions: [], + matchedTerms: [], + }, + }; + + const SmartFindHarness = () => { + const [settingsSearch, setSettingsSearch] = useState("claude"); + const searchInputRef = useRef(null); + const searchActive = settingsSearch.trim().length > 0; + + return ( + {}} + interactionStyle={interactionStyle} + /> + ); + }; + + render(); + + const searchInput = screen.getByRole("textbox", { name: "Search settings categories" }); + await user.click(screen.getByRole("button", { name: "Clear settings search" })); + + expect(searchInput).toHaveFocus(); + expect(searchInput).toHaveValue(""); + expect(screen.queryByRole("button", { name: "Clear settings search" })).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Smart Find match previews")).not.toBeInTheDocument(); + expect(screen.queryByText("Claude Code")).not.toBeInTheDocument(); + expect(screen.getByText("Press slash to search settings.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Appearance" })).toBeInTheDocument(); + }); + it("SettingsCategoryRail exposes pending and disabled category states without selected or pending badges", () => { cleanup(); const mockCategories = [ diff --git a/docs-web/user/dashboard/settings.md b/docs-web/user/dashboard/settings.md index 20aa5f009b..88de461cec 100644 --- a/docs-web/user/dashboard/settings.md +++ b/docs-web/user/dashboard/settings.md @@ -18,7 +18,7 @@ Switch scope with the selector at the top: - **Project** — applies to the active project. - **Sprint** — applies to the selected sprint within the active project. -The sticky command/status row keeps the System/Project selector, project availability or inheritance context, active panel, and save state visible together while you scroll. It shows the visible-category count only while Smart Find is active; when search is inactive, category-count context stays in the search/status announcements. +The sticky command/status row keeps the System/Project selector, project availability or inheritance context, active panel, and save state visible together while you scroll. It shows the visible-category count only while Smart Find is active; when search is inactive, the visible status stays to a quiet search prompt while the exact category total remains available to assistive technology. ## Categories diff --git a/docs/dashboard/dashboard-guide.md b/docs/dashboard/dashboard-guide.md index 425d673f66..8a47c1bfa0 100644 --- a/docs/dashboard/dashboard-guide.md +++ b/docs/dashboard/dashboard-guide.md @@ -631,7 +631,7 @@ Runtime scoping: - Project scope General settings expose the selected project's display name as an immediate metadata edit. Saving calls `PATCH /api/projects/:projectId` with the trimmed `name`, refreshes the project collection, and leaves the project id, settings overrides, tasks, and runtime history unchanged. - The `/config` page keeps the existing v2 settings shell and categories, but now binds them to real scoped settings instead of draft-only values - System scope only edits system-owned controls, while project scope only edits project-owned overrides for the selected project -- The Settings scope/status row stays sticky below the app shell while scrolling, keeping the System/Project selector, selected-scope context, project availability or inheritance summary, and save badges visible. The visible-category count appears there only while Smart Find is active; otherwise the search/status regions keep that context for screen readers. The active panel/save-state strip is also sticky and uses a measured top offset from the wrapped scope row so long project names and narrow layouts do not create overlapping pinned controls. +- The Settings scope/status row stays sticky below the app shell while scrolling, keeping the System/Project selector, selected-scope context, project availability or inheritance summary, and save badges visible. The visible-category count appears there only while Smart Find is active; otherwise the visible search status stays to a quiet prompt and keeps the exact category total for screen readers. The active panel/save-state strip is also sticky and uses a measured top offset from the wrapped scope row so long project names and narrow layouts do not create overlapping pinned controls. - The integrations view now owns provider API keys plus GitHub and GitLab tokens and GitHub workflow settings, rather than splitting those across separate categories - The integrations view uses a registry-style list with per-integration `Add` and `Manage` actions so additional integrations can be added without turning the page into one long form - Provider integrations are now instance-based: diff --git a/docs/dashboard/design-system-settings.md b/docs/dashboard/design-system-settings.md index b59eb8d541..7cd47951be 100644 --- a/docs/dashboard/design-system-settings.md +++ b/docs/dashboard/design-system-settings.md @@ -43,7 +43,7 @@ This document defines the visual patterns and rules for the Settings workspace. * Sprint & Git matches routing terms for branch naming, default/feature branches, merge gates, CI/autofix, execution runtime, Docker cleanup, QA, and quality assurance. * Browser Preview, Memory, Agents, and MCP must remain searchable through their user-facing terms: preview/container/port/proxy, memory/embedding/claims/remediation, prompt/template/instruction/markdown authoring, and MCP server/tool/stdio/http/SSE/built-in tool access. * Agents search terms also include persistent skills, skill storage, storage attachment, self-reflection, criteria, planning rating, and QA rating so users can find disabled-by-default configuration before enabling it. - * The Smart Find status text uses `role="status"` with `aria-live="polite"` and includes match previews so assistive technology users receive the same filtered-category context as sighted users. + * The Smart Find status text uses `role="status"` with `aria-live="polite"`. Idle copy stays to a quiet search affordance while preserving the exact category total for assistive technology; active searches include result counts, matching-category counts, active-category context, and match previews so assistive technology users receive the same filtered-category context as sighted users. ## Implementation details @@ -56,7 +56,7 @@ This document defines the visual patterns and rules for the Settings workspace. * Category rail buttons expose selected and pending state through active styling plus ARIA (`aria-current`, `aria-selected`, `aria-busy`) without extra visible status badges. Disabled state keeps visible disabled copy plus `aria-disabled`. Category movement uses explicit `selectionMovement` markers; panel entry uses `enterExit`; reduced-motion users receive the same static active styling, validation copy, busy state, and save outcome text without relying on animated movement. * Scope switches announce the selected system/project context through a polite status region. Project scope must also expose visible inherited/overridden summary text while preserving the existing per-field badge semantics: inherited stays neutral, project overrides stay amber and resettable only when the active scope can clear them. * The Settings category rail uses a 280px desktop column, starts directly with category rows rather than a visible title/instruction block, caps its height to the remaining viewport below its measured top edge, and scrolls internally with the shared hidden-scrollbar utility. When more categories remain below, a subtle bottom chevron affordance appears over a soft fade and disappears at the scroll end, so long category lists remain discoverable without visible scrollbars or page-bottom overflow. -* Smart Find keeps focus in the search field while typing and when the clear control is used. Result status remains polite and includes count/category context plus match previews; empty states include recovery terms instead of leaving the rail blank without explanation. +* Smart Find keeps focus in the search field while typing and when the clear control is used. Result status remains polite; idle status avoids visible category-count copy, while active searches include count/category context plus match previews. Empty states include recovery terms instead of leaving the rail blank without explanation. * Disabled save controls must preserve their stable button label while exposing the unavailable reason through `title`, visible adjacent copy, and `aria-describedby`. Do not append hidden disabled-reason text to the button name. Save, reset, and modal actions suppress duplicate activation while pending and keep current drafts mounted. * Invalid save attempts must not discard or replace drafts. The first invalid enabled control is scrolled into view inside the active panel, receives focus with scroll position preserved, keeps its helper/error ownership (`aria-describedby` / `aria-errormessage`), and surfaces actionable page-level feedback until the user fixes or dismisses it. * Provider instance feedback is local to the card and uses `ActionFeedbackRegion` with polite status for unsaved local changes/pending work and alert semantics for errors. The dashboard-login action exposes `aria-haspopup="dialog"`, `aria-expanded`, and `aria-busy` while the modal is open. Remove remains a two-step local confirmation before mutating the instance list, names the provider instance in the confirmation control, disables duplicate confirm/cancel activation while pending, and restores focus to the initiating remove control or the active settings panel fallback. diff --git a/docs/settings/configuration-and-storage.md b/docs/settings/configuration-and-storage.md index d57741540f..9f59acd2d0 100644 --- a/docs/settings/configuration-and-storage.md +++ b/docs/settings/configuration-and-storage.md @@ -216,7 +216,7 @@ Dashboard behavior: - project settings now render a per-setting override badge only when a control is actually overridden at project scope - settings UI path pickers can browse allowed local roots for custom container setup script paths. The local browser APIs are limited to the home directory, current working directory, and `CODE_UX_DIRECTORY_BROWSER_ROOTS`; `/api/local-files` returns navigation metadata plus directory and file names/absolute paths only, never file contents. - sprint override dialogs use the same field-level source metadata and show override badges only for sprint-local overrides -- the v2 settings page includes a quick-find field (keyboard shortcut `/`) that filters categories without changing the scoped settings model. Smart Find uses a centralized typed settings search index spanning category metadata, provider and integration labels, invocation routes, instruction templates, and important field synonyms, so provider searches such as `claude` surface both AI model routing and Integrations matches with visible match context. The search UI announces live result counts, active-category match previews, no-match recovery suggestions, and keyboard-friendly quick category chips. +- the v2 settings page includes a quick-find field (keyboard shortcut `/`) that filters categories without changing the scoped settings model. Smart Find uses a centralized typed settings search index spanning category metadata, provider and integration labels, invocation routes, instruction templates, and important field synonyms, so provider searches such as `claude` surface both AI model routing and Integrations matches with visible match context. Idle search copy stays quiet while keeping the exact category total available to assistive technology; active searches announce live result counts, matching-category counts, active-category context, match previews, no-match recovery suggestions, and keyboard-friendly quick category chips. - settings scope selection is a radiogroup with explicit selected state and disabled project-scope guidance when no project is selected. Save, project reset, dirty, saved, and error states are announced in the active settings panel while visible form values stay mounted during pending operations. - Settings category transitions use shared interaction motion tokens and snap directly to the selected category for reduced-motion users, avoiding intermediate fade states. - settings field controls expose field-level confidence through error text and ready-to-save cues where validation is available. Single-choice pill controls keep radiogroup/radio semantics and wire helper, valid, pending, and error copy through `aria-describedby`, `aria-errormessage`, and `aria-busy` instead of relying on visual styling alone. Numeric fields derive local min/max validation from their mounted control metadata; Save Changes focuses the first visible invalid field and blocks the patch request until the value is corrected. From a2a4dbea0cf900dc280bf9ad5005b1a17a691107 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 04:47:20 +0000 Subject: [PATCH 6/7] fix(ci): resolve failing checks on task/feature-codux-155-qs-ui-interactions-design-impr-t04-codex-mra5p1x6 --- tests/dashboard/v2/settings-page-state.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/dashboard/v2/settings-page-state.test.tsx b/tests/dashboard/v2/settings-page-state.test.tsx index 08bc1f932d..8c44173a30 100644 --- a/tests/dashboard/v2/settings-page-state.test.tsx +++ b/tests/dashboard/v2/settings-page-state.test.tsx @@ -263,7 +263,9 @@ describe("useSettingsPageState", () => { fireEvent.input(screen.getByLabelText("Search settings categories"), { target: { value: "this_should_not_exist_at_all" }, }); - const emptySearchStatus = screen.getByText(/No settings match this_should_not_exist_at_all\./); + const emptySearchStatus = screen.getByText( + '0 results across 0 matching categories for "this_should_not_exist_at_all". Active category: General. Match previews: none. Clear the search or try routing, provider, auth, CI, agent, or memory.', + ); expect(emptySearchStatus.closest('[role="status"]')).toBeInTheDocument(); fireEvent.input(screen.getByLabelText("Search settings categories"), { From 53c3d0054b5b774b40f1fa21d536eab3176c092b Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 04:55:34 +0000 Subject: [PATCH 7/7] feat(task T05): implement via codex --- .../__tests__/SettingsControls.test.tsx | 24 ++++++++++++------- .../content/docs/user-dashboard-settings.mdx | 2 ++ docs-web/user/dashboard/settings.md | 2 +- docs/dashboard/dashboard-guide.md | 2 +- docs/dashboard/design-system-settings.md | 7 +++--- docs/dashboard/interaction-patterns.md | 2 +- 6 files changed, 25 insertions(+), 14 deletions(-) diff --git a/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx b/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx index 1a01b17ffc..247de2a3d0 100644 --- a/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx +++ b/dashboard/src/v2/components/settings/__tests__/SettingsControls.test.tsx @@ -798,7 +798,7 @@ describe("SettingsControls Accessibility", () => { expect(activePanelStrip).not.toHaveStyle("--settings-active-panel-top: 9.5rem"); }); - it("SettingsContentPanels renders dirty-to-saving-to-saved feedback while keeping values mounted", async () => { + it("SettingsContentPanels renders its standalone sticky active-panel strip while keeping values mounted", async () => { const { rerender } = render( { expect(screen.getByText("General panel values stay mounted")).toBeInTheDocument(); }); - it("SettingsPage keeps scope controls and active panel status in one sticky wrapping bar", () => { + it("SettingsPage keeps scope controls and active panel status in one unified sticky wrapping bar", () => { const source = readFileSync("dashboard/src/v2/SettingsPage.tsx", "utf8"); + const commandStatusBarSource = source.match( + /"); + expect(commandStatusBarSource).toContain("sticky top-16 z-30"); + expect(commandStatusBarSource).toContain("flex min-w-0 max-w-full flex-wrap"); + expect(commandStatusBarSource).toContain("/); expect(source).not.toContain("scopeSticky.getBoundingClientRect()"); expect(source).not.toContain("panelStickyTop"); }); diff --git a/docs-web/content/docs/user-dashboard-settings.mdx b/docs-web/content/docs/user-dashboard-settings.mdx index 057e9a7d22..cc0240682a 100644 --- a/docs-web/content/docs/user-dashboard-settings.mdx +++ b/docs-web/content/docs/user-dashboard-settings.mdx @@ -18,6 +18,8 @@ Switch scope with the selector at the top: - **Project** — applies to the active project. - **Sprint** — applies to the selected sprint within the active project. +The sticky command/status bar keeps the System/Project selector, project availability or inheritance context, active panel, and save state visible together while you scroll. It shows the visible-category count only while Smart Find is active; when search is inactive, the visible status stays to a quiet search prompt while the exact category total remains available to assistive technology. The bar uses compact controls and chips instead of one long background card, so focus rings, contrast, wrapping, and saved/dirty cues stay clear on narrow screens. + ## Categories The category rail on the left includes: diff --git a/docs-web/user/dashboard/settings.md b/docs-web/user/dashboard/settings.md index 88de461cec..fbc322a6bb 100644 --- a/docs-web/user/dashboard/settings.md +++ b/docs-web/user/dashboard/settings.md @@ -18,7 +18,7 @@ Switch scope with the selector at the top: - **Project** — applies to the active project. - **Sprint** — applies to the selected sprint within the active project. -The sticky command/status row keeps the System/Project selector, project availability or inheritance context, active panel, and save state visible together while you scroll. It shows the visible-category count only while Smart Find is active; when search is inactive, the visible status stays to a quiet search prompt while the exact category total remains available to assistive technology. +The sticky command/status bar keeps the System/Project selector, project availability or inheritance context, active panel, and save state visible together while you scroll. It shows the visible-category count only while Smart Find is active; when search is inactive, the visible status stays to a quiet search prompt while the exact category total remains available to assistive technology. The bar uses compact controls and chips instead of one long background card, so focus rings, contrast, wrapping, and saved/dirty cues stay clear on narrow screens. ## Categories diff --git a/docs/dashboard/dashboard-guide.md b/docs/dashboard/dashboard-guide.md index 8a47c1bfa0..24cd169de4 100644 --- a/docs/dashboard/dashboard-guide.md +++ b/docs/dashboard/dashboard-guide.md @@ -631,7 +631,7 @@ Runtime scoping: - Project scope General settings expose the selected project's display name as an immediate metadata edit. Saving calls `PATCH /api/projects/:projectId` with the trimmed `name`, refreshes the project collection, and leaves the project id, settings overrides, tasks, and runtime history unchanged. - The `/config` page keeps the existing v2 settings shell and categories, but now binds them to real scoped settings instead of draft-only values - System scope only edits system-owned controls, while project scope only edits project-owned overrides for the selected project -- The Settings scope/status row stays sticky below the app shell while scrolling, keeping the System/Project selector, selected-scope context, project availability or inheritance summary, and save badges visible. The visible-category count appears there only while Smart Find is active; otherwise the visible search status stays to a quiet prompt and keeps the exact category total for screen readers. The active panel/save-state strip is also sticky and uses a measured top offset from the wrapped scope row so long project names and narrow layouts do not create overlapping pinned controls. +- The Settings command/status bar stays sticky below the app shell while scrolling, keeping the System/Project selector, selected-scope context, project availability or inheritance summary, active panel, and save state visible in one wrapping row. The visible-category count is search-only metadata and appears there only while Smart Find is active; otherwise the visible search status stays to a quiet prompt and keeps the exact category total for screen readers. The bar avoids a long background card behind the scope controls; each control or chip carries its own tokenized contrast, focus ring, and reduced-motion-safe status cue. - The integrations view now owns provider API keys plus GitHub and GitLab tokens and GitHub workflow settings, rather than splitting those across separate categories - The integrations view uses a registry-style list with per-integration `Add` and `Manage` actions so additional integrations can be added without turning the page into one long form - Provider integrations are now instance-based: diff --git a/docs/dashboard/design-system-settings.md b/docs/dashboard/design-system-settings.md index 7cd47951be..e878727b85 100644 --- a/docs/dashboard/design-system-settings.md +++ b/docs/dashboard/design-system-settings.md @@ -26,7 +26,7 @@ This document defines the visual patterns and rules for the Settings workspace. * Destructive actions in the Danger Zone (`Wipe Project`, `Wipe Database`) use the `danger` tone, yielding clear semantic `bg-status-red text-white` presentation. Panels themselves hint at danger via red-tinted borders and backgrounds. 4. **Metadata and Hierarchy**: - * Metadata chips (`visible categories` while Smart Find is active, `unsaved edits`) and badges leverage standard tokens to maintain visual rhythm. + * Metadata chips (`visible categories` while Smart Find is active, `unsaved edits`) and badges leverage standard tokens to maintain visual rhythm. `Visible categories` is search-only metadata and must not appear when Smart Find is idle. * Headers and contextual information (e.g., `SettingsHeader`) separate sections with thin borders (`--border-hairline`). * The Quality Assurance section belongs in `Settings > Sprint & Git`, directly below `Merge Gates & Autofix`, even though its persisted settings path remains `agents.qualityAssurance`. QA-labeled project agents remain prominent in the selector ordering, and disabled project selectors still communicate that built-in QA routing remains available. * Every visible `SectionCard` subcategory exposes card-level help controls in the header action area: an info icon that opens keyboard-accessible guidance and a docs icon that links to the exact `/docs/user/dashboard/settings#` anchor. These controls supplement row-level info affordances and must not replace field-specific help. @@ -50,8 +50,9 @@ This document defines the visual patterns and rules for the Settings workspace. * Always rely on semantic CSS variables from `globals.css` and `tokens.css` via `[var(--variable-name)]` for colors, backgrounds, borders, and shadows instead of hardcoding Tailwind utility colors and shadow values. * Inline validation and character-counter feedback must use the shared `inlineValidation` and `controlFeedback` interaction tokens. Error text should be announced only after blur or an explicit submit/force-validation path, and helper text should not remain in `aria-describedby` while an error is active. * Settings page save state lives at the active panel boundary. `SettingsContentPanels` sets `aria-busy` while loading, saving, or resetting and uses `ActionFeedbackRegion` for saved/dirty/saving/loading states. `SettingsActivePanelStatus` provides the durable visible active-panel/save-state line so reduced-motion users see the current category and outcome without relying on panel motion. Blocking errors switch the status stream to assertive alert copy. Do not replace field contents with loading placeholders during saves or background refreshes. -* The Settings command/status bar is sticky below the app shell and includes the System/Project radiogroup, selected-scope context, project-scope availability or inheritance summary, unsaved edits indicator, saved badge, and inline active-panel/save-state status. It shows the visible-category count only while Smart Find is active; otherwise category-count context stays in the search/status regions instead of the sticky bar. The bar must wrap naturally on narrow screens, preserve focus rings while pinned, and keep `settings-scope-context`, `settings-project-scope-disabled`, the polite scope status region, and the active-panel status announcement wired to the controls. -* `SettingsPage` owns the unified sticky command/status bar at `top-16` and renders `SettingsContentPanels` with the separate active-panel strip suppressed. The desktop `SettingsCategoryRail` remains `lg:sticky lg:top-16`; page content starts below the unified bar so the rail, command/status bar, and active panel do not overlap. +* The Settings command/status bar is one unified sticky surface below the app shell. It includes the System/Project radiogroup, selected-scope context, project-scope availability or inheritance summary, search-only visible-category metadata, unsaved edits indicator, saved badge, and inline active-panel/save-state status. It shows the visible-category count only while Smart Find is active; otherwise category-count context stays in the search/status regions instead of the sticky bar. The bar must wrap naturally on narrow screens, preserve focus rings while pinned, and keep `settings-scope-context`, `settings-project-scope-disabled`, the polite scope status region, and the active-panel status announcement wired to the controls. +* The unified command/status bar must not use a long page-level card or pill background behind the scope controls. The child scope selector, context chips, and active-panel status own their tokenized borders, glass fills, focus rings, wrapping, and reduced-motion-safe saved/dirty cues. +* `SettingsPage` owns the unified sticky command/status bar at `top-16` and renders `SettingsContentPanels` with its reusable active-panel strip suppressed. `SettingsContentPanels` may still render that sticky strip by default when used outside the full Settings page. The desktop `SettingsCategoryRail` remains `lg:sticky lg:top-16`; page content starts below the unified bar so the rail, command/status bar, and active panel do not overlap. * Save and background reload paths must preserve dirty drafts until the affected scope has actually saved or reset. If system settings save while project settings are dirty, project draft values remain mounted and are not replaced by an effective-settings refresh; failed project saves leave the draft visible for correction. * Category rail buttons expose selected and pending state through active styling plus ARIA (`aria-current`, `aria-selected`, `aria-busy`) without extra visible status badges. Disabled state keeps visible disabled copy plus `aria-disabled`. Category movement uses explicit `selectionMovement` markers; panel entry uses `enterExit`; reduced-motion users receive the same static active styling, validation copy, busy state, and save outcome text without relying on animated movement. * Scope switches announce the selected system/project context through a polite status region. Project scope must also expose visible inherited/overridden summary text while preserving the existing per-field badge semantics: inherited stays neutral, project overrides stay amber and resettable only when the active scope can clear them. diff --git a/docs/dashboard/interaction-patterns.md b/docs/dashboard/interaction-patterns.md index e4ef7bd0f8..9faae67406 100644 --- a/docs/dashboard/interaction-patterns.md +++ b/docs/dashboard/interaction-patterns.md @@ -60,7 +60,7 @@ Current refined dashboard surfaces use the interaction contracts as follows: | Sprint ledger | `controlFeedback`, `selectionMovement`, `listReorder`, `expansionCollapse`, `asyncFeedback` | Sort, filter, selection, and bulk-action changes are composed into one polite live-region message; selected and pending rows retain static badges; bulk delete uses `ConfirmDialog`; focus returns to the delete trigger or a ledger fallback after dialog teardown. | | Live runtime | `controlFeedback`, `enterExit`, `expansionCollapse`, `selectionMovement`, `listReveal`, `listReorder`, `asyncFeedback` | Reconnect, stale, refreshing, and recovering states keep the last runtime snapshot visible with polite live regions; disconnected transport and blocking errors are assertive; pending runtime actions remain focus-stable with `aria-disabled` plus activation suppression. Runtime force-complete and sprint pause/stop/delete controls require an explicit named confirmation before their side-effect handlers run. | | Browser preview, file, and diff workbench | `controlFeedback`, `enterExit`, `selectionMovement`, `listReveal`, `listReorder`, `asyncFeedback` | Preview launch/rebuild/stop/navigation/script/log operations expose visible async status; unavailable links remain keyboard reachable as disabled link controls with persistent reasons; stale iframe/log content remains mounted during refresh when useful content exists. | -| Settings workspace | `controlFeedback`, `selectionMovement`, `enterExit`, `inlineValidation`, `asyncFeedback` | Scope/category changes expose selected, pending, inherited, overridden, and disabled-reason text; saves use active-panel `aria-busy` plus `ActionFeedbackRegion`; provider removals use inline confirmation with cancel and focus restoration; fields preserve current draft values while loading or saving. | +| Settings workspace | `controlFeedback`, `selectionMovement`, `enterExit`, `inlineValidation`, `asyncFeedback` | The unified sticky command/status bar keeps scope selection, scope context, active panel, and save state visible together; visible-category metadata appears only while Smart Find is active. Scope/category changes expose selected, pending, inherited, overridden, and disabled-reason text; saves use active-panel `aria-busy` plus `ActionFeedbackRegion`; provider removals use inline confirmation with cancel and focus restoration; fields preserve current draft values while loading or saving. | | Global search | `enterExit`, `listReveal`, `controlFeedback`, `selectionMovement` | The input remains the combobox focus owner with `aria-activedescendant`; stale results remain available with `aria-busy`; unavailable rows expose a visible disabled reason and suppress pointer and keyboard activation; active rows are scrolled within the result container only. Running agent and preview-container dots use motion-safe animation only, with badge text, color, and static reduced-motion rings preserving status without pulse or ping motion. | | Memory workspace | `controlFeedback`, `selectionMovement`, `listReveal`, `listReorder`, `expansionCollapse`, `inlineValidation`, `asyncFeedback` | Search/filter/selection changes announce counts and selected state; background refresh or failed refresh keeps the last useful list visible; batch delete uses confirmation, optimistic feedback, retry, and focus restoration; reduced motion keeps badges, rings, and live-region copy for selected graph/list state. | | Task cards and active streams | `controlFeedback`, `selectionMovement`, `listReorder`, `asyncFeedback` | Status, dependency blockers, QA review, and PR/live metadata keep stable text equivalents; quick actions sit in the card footer and are visually revealed on hover or keyboard focus while remaining in the keyboard path with task-specific names. Low-value metadata such as the default `Auto` executor and pointer-only drag helper chip are omitted from visible card metadata, while screen-reader drag guidance, pending dispatch, `aria-busy`, disabled state, and reason text remain available. Task-board cards are keyed by stable card view-model identities so unrelated live events and filter announcements preserve mounted card controls instead of rerendering unchanged cards. Sprint selector running dots keep color, shadow, option labels, and selected/loading badges available when reduced motion disables pulse animation. |