Skip to content

Commit a08fa1d

Browse files
authored
feat(settings): adds navigation TOC and section grouping (#106)
* feat(settings): add section IDs and reorder into logical groups - Add optional id prop to Section component for scroll-spy targeting - Assign stable IDs to all 12 Section usages in SettingsPage - Reorder sections into 4 logical groups: Data Sources, Display, Integrations, Account - Add group heading dividers between groups - Export SETTINGS_PAGE_SECTION_IDS constant for test sync - Fix existing tests to use getByRole('heading') for disambiguation * test(settings): adds SettingsTOC unit tests * fix(settings): addresses review findings for SettingsTOC - Replaces Map.groupBy (ES2024) with manual reduce for browser compat - Promotes displayedActiveId to createMemo - Adds scrollend listener cleanup on unmount - Adds aria-current='location' to active TOC items - Adds aria-label to mobile toggle button - Increases mobile touch target (btn-sm → btn, full width) - Bumps mobile dropdown z-index from z-30 to z-40 - Removes redundant 'relative' class on mobile container - Scopes mobile toggle test query with within() * docs: adds settings navigation section to user guide * fix(settings): clears scrollingTo immediately for instant scroll When prefers-reduced-motion is active, scrollIntoView uses behavior 'instant' which completes synchronously. The scrollend event may not fire in all browsers for instant scrolls, leaving scrollingTo stuck. Uses requestAnimationFrame to clear in the next frame instead. * fix(settings): fixes indentation, reduced-motion gap, adds rAF test - Fixes closing tag indentation in two-column layout wrapper - Adds animate-pulse to prefers-reduced-motion override - Adds test verifying requestAnimationFrame is used for instant scroll * fix(settings): restores window property mocks in afterEach Object.defineProperty on window.innerHeight, scrollY, and scrollHeight leaked between tests. Captures original values and restores in afterEach to prevent cross-test contamination. * fix(settings): uses JS-driven smooth scroll for browser compat Native scrollIntoView({behavior: 'smooth'}) can be overridden by browser settings (Vivaldi, some Chromium forks disable smooth scroll at the engine level). Replaces with requestAnimationFrame-driven eased scroll that works regardless of browser scroll settings. Still uses scrollIntoView for instant scroll (reduced-motion). * fix(settings): prevents header oscillation during smooth scroll The scroll listener that shrinks/expands the header was firing during programmatic TOC scrolling, causing a feedback loop: scroll crosses 50px threshold → header resizes → content shifts → scroll position changes → header resizes again. Uses a data-scrollLock attribute to suppress header state updates during TOC-driven scroll animations. Dispatches a synthetic scroll event on completion to sync final state. * fix(settings): adds hysteresis to header scroll threshold Shrink at scrollY > 50, expand only at scrollY < 10. The 40px dead zone absorbs the ~32px content shift from header padding change, preventing the layout feedback loop where header resize triggers scroll position change which re-triggers header resize. * fix(settings): updates TOC registry for Dependencies section Adds 'dependencies' to SETTINGS_SECTIONS (13 entries) to match upstream's new Dependencies section in SettingsPage.
1 parent c6d1263 commit a08fa1d

7 files changed

Lines changed: 701 additions & 117 deletions

File tree

docs/USER_GUIDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,14 @@ Jira uses rotating refresh tokens — each refresh invalidates the previous toke
635635

636636
---
637637

638+
## Settings Navigation
639+
640+
The Settings page organizes its sections into four groups: **Data Sources** (Orgs & Repos, Tracked Users, Refresh, API Usage), **Display** (Appearance, Tabs, Custom Tabs), **Integrations** (GitHub Actions, Notifications, MCP Relay, Jira), and **Account** (Data).
641+
642+
On desktop (1024px+), a sticky sidebar on the left shows a table of contents with all sections grouped and highlighted as you scroll. Click any item to jump to that section. On smaller screens, a dropdown bar appears below the header showing the current section name — tap it to open a section picker.
643+
644+
The header shrinks as you scroll to maximize content space.
645+
638646
## Settings Reference
639647

640648
Settings are saved automatically to `localStorage` and persist across sessions. All settings can be exported as a JSON file via **Settings > Data > Export**.

src/app/components/settings/Section.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { JSX, Show } from "solid-js";
22

3-
export default function Section(props: { title: string; description?: string; children: JSX.Element }) {
3+
export default function Section(props: { id?: string; title: string; description?: string; children: JSX.Element }) {
44
return (
5-
<div class="card bg-base-100 border border-base-300">
5+
<div id={props.id} class="card bg-base-100 border border-base-300">
66
<div class="bg-base-200 px-4 py-2 rounded-t-lg border-b border-base-300">
77
<h2 class="text-sm font-semibold text-base-content">{props.title}</h2>
88
<Show when={props.description}>

src/app/components/settings/SettingsPage.tsx

Lines changed: 152 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { getUsageSnapshot, getUsageResetAt, resetUsageData, checkAndResetIfExpir
1818
import OrgSelector from "../onboarding/OrgSelector";
1919
import RepoSelector from "../onboarding/RepoSelector";
2020
import Section from "./Section";
21+
import SettingsTOC from "./SettingsTOC";
2122
import SettingRow from "./SettingRow";
2223
import ThemePicker from "./ThemePicker";
2324
import DensityPicker from "./DensityPicker";
@@ -31,6 +32,22 @@ import type { RepoRef } from "../../services/api";
3132

3233
const VALID_JIRA_CLIENT_ID_RE = /^[A-Za-z0-9_-]+$/;
3334

35+
export const SETTINGS_PAGE_SECTION_IDS = [
36+
"orgs-repos",
37+
"tracked-users",
38+
"refresh",
39+
"api-usage",
40+
"appearance",
41+
"tabs",
42+
"custom-tabs",
43+
"actions",
44+
"notifications",
45+
"mcp-relay",
46+
"jira",
47+
"dependencies",
48+
"data",
49+
] as const;
50+
3451
export default function SettingsPage() {
3552
const navigate = useNavigate();
3653

@@ -64,6 +81,18 @@ export default function SettingsPage() {
6481
}
6582
});
6683

84+
const [scrolled, setScrolled] = createSignal(false);
85+
onMount(() => {
86+
const onScroll = () => {
87+
if (document.documentElement.dataset.scrollLock) return;
88+
const y = window.scrollY;
89+
if (scrolled() && y < 10) setScrolled(false);
90+
else if (!scrolled() && y > 50) setScrolled(true);
91+
};
92+
window.addEventListener("scroll", onScroll, { passive: true });
93+
onCleanup(() => window.removeEventListener("scroll", onScroll));
94+
});
95+
6796
onMount(() => checkAndResetIfExpired());
6897
const usageSnapshot = createMemo(() => getUsageSnapshot());
6998

@@ -365,8 +394,8 @@ export default function SettingsPage() {
365394
return (
366395
<div class="bg-base-200 min-h-screen">
367396
{/* Page header */}
368-
<div class="border-b border-base-300 bg-base-100">
369-
<div class="mx-auto max-w-3xl px-4 py-6 sm:px-6">
397+
<div class="settings-header sticky top-0 z-40 border-b border-base-300 bg-base-100">
398+
<div class={`mx-auto max-w-5xl px-4 sm:px-6 transition-[padding] duration-200 ${scrolled() ? "py-2" : "py-6"}`}>
370399
<div class="flex items-center gap-3">
371400
<a
372401
href="/dashboard"
@@ -391,9 +420,14 @@ export default function SettingsPage() {
391420
</div>
392421
</div>
393422

394-
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 flex flex-col gap-6">
423+
<div class="mx-auto max-w-5xl px-4 py-8 sm:px-6">
424+
<div class="flex flex-col lg:flex-row gap-6 lg:gap-8">
425+
<SettingsTOC />
426+
<div class="settings-content flex-1 min-w-0 flex flex-col gap-6 max-w-3xl">
427+
{/* ── Data Sources ─────────────────────────────────────────────────── */}
428+
<p class="text-xs font-semibold uppercase tracking-wider text-base-content/40 px-1">Data Sources</p>
395429
{/* Section 1: Orgs & Repos */}
396-
<Section title="Organizations & Repositories">
430+
<Section id="orgs-repos" title="Organizations & Repositories">
397431
<div class="flex flex-col gap-3 px-4 py-3">
398432
<div class="flex items-center justify-between">
399433
<div>
@@ -492,7 +526,7 @@ export default function SettingsPage() {
492526
</Section>
493527

494528
{/* Section 2: Tracked Users */}
495-
<Section title="Tracked Users">
529+
<Section id="tracked-users" title="Tracked Users">
496530
<div class="flex flex-col gap-3 px-4 py-3">
497531
<p class="text-xs text-base-content/60">
498532
Track another GitHub user's issues and pull requests alongside yours.
@@ -505,7 +539,7 @@ export default function SettingsPage() {
505539
</Section>
506540

507541
{/* Section 3: Refresh */}
508-
<Section title="Refresh">
542+
<Section id="refresh" title="Refresh">
509543
<SettingRow
510544
label="Refresh interval"
511545
description="How often to poll GitHub for new data"
@@ -544,7 +578,7 @@ export default function SettingsPage() {
544578
</Section>
545579

546580
{/* Section 4: API Usage */}
547-
<Section title="API Usage">
581+
<Section id="api-usage" title="API Usage">
548582
<div class="px-4 py-3 flex flex-col gap-3">
549583
<Show
550584
when={usageSnapshot().length > 0}
@@ -608,8 +642,105 @@ export default function SettingsPage() {
608642
</div>
609643
</Section>
610644

611-
{/* Section 5: GitHub Actions */}
612-
<Section title="GitHub Actions">
645+
{/* ── Display ────────────────────────────────────────────────────── */}
646+
<p class="text-xs font-semibold uppercase tracking-wider text-base-content/40 px-1">Display</p>
647+
{/* Section 5: Appearance */}
648+
<Section id="appearance" title="Appearance">
649+
<div class="px-4 py-2 border-b border-base-300">
650+
<p class="text-sm font-medium text-base-content mb-2">Theme</p>
651+
<ThemePicker />
652+
</div>
653+
<div class="px-4 py-2 border-b border-base-300">
654+
<p class="text-sm font-medium text-base-content mb-2">View density</p>
655+
<DensityPicker />
656+
</div>
657+
<SettingRow
658+
label="Items per page"
659+
description="Number of items to show in each tab"
660+
>
661+
<select
662+
value={String(config.itemsPerPage)}
663+
onChange={(e) => {
664+
saveWithFeedback({ itemsPerPage: Number(e.currentTarget.value) });
665+
}}
666+
class="select select-sm"
667+
>
668+
{itemsPerPageOptions.map((opt) => (
669+
<option value={String(opt.value)}>{opt.label}</option>
670+
))}
671+
</select>
672+
</SettingRow>
673+
</Section>
674+
675+
{/* Section 6: Tabs */}
676+
<Section id="tabs" title="Tabs">
677+
<SettingRow
678+
label="Default tab"
679+
description="Tab shown when opening the dashboard fresh"
680+
>
681+
<select
682+
value={config.defaultTab}
683+
onChange={(e) => {
684+
saveWithFeedback({ defaultTab: e.currentTarget.value as Config["defaultTab"] });
685+
}}
686+
class="select select-sm"
687+
>
688+
{tabOptions().map((opt) => (
689+
<option value={opt.value}>{opt.label}</option>
690+
))}
691+
</select>
692+
</SettingRow>
693+
<SettingRow
694+
label="Remember last tab"
695+
description="Return to the last active tab on revisit"
696+
>
697+
<input
698+
type="checkbox"
699+
role="switch"
700+
aria-checked={config.rememberLastTab}
701+
aria-label="Remember last tab"
702+
checked={config.rememberLastTab}
703+
onChange={(e) => saveWithFeedback({ rememberLastTab: e.currentTarget.checked })}
704+
class="toggle toggle-primary"
705+
/>
706+
</SettingRow>
707+
<SettingRow
708+
label="Enable tracked items"
709+
description="Show a Tracked tab to pin issues and PRs for quick access"
710+
>
711+
<input
712+
type="checkbox"
713+
role="switch"
714+
aria-checked={config.enableTracking}
715+
aria-label="Enable tracked items"
716+
checked={config.enableTracking}
717+
onChange={(e) => {
718+
const val = e.currentTarget.checked;
719+
saveWithFeedback({
720+
enableTracking: val,
721+
...(!val && config.defaultTab === "tracked" ? { defaultTab: "issues" as const } : {}),
722+
});
723+
if (!val && viewState.lastActiveTab === "tracked") {
724+
updateViewState({ lastActiveTab: "issues" });
725+
}
726+
}}
727+
class="toggle toggle-primary"
728+
/>
729+
</SettingRow>
730+
</Section>
731+
732+
{/* Section 7: Custom Tabs */}
733+
<Section id="custom-tabs" title="Custom Tabs" description="Create custom views with saved filters and scoping">
734+
<CustomTabsSection
735+
availableOrgs={[...new Set(config.selectedRepos.map((r) => r.owner))]}
736+
availableRepos={config.selectedRepos}
737+
/>
738+
</Section>
739+
740+
{/* ── Integrations ──────────────────────────────────────────────── */}
741+
<p class="text-xs font-semibold uppercase tracking-wider text-base-content/40 px-1">Integrations</p>
742+
{/* Section 8: GitHub Actions */}
743+
<Section id="actions" title="GitHub Actions">
613744
<SettingRow
614745
label="Show Actions tab"
615746
description="Show the Actions tab and track workflow runs. Disable to reduce API usage and simplify the dashboard."
@@ -676,8 +807,8 @@ export default function SettingsPage() {
676807
</SettingRow>
677808
</Section>
678809

679-
{/* Section 6: Notifications */}
680-
<Section title="Notifications">
810+
{/* Section 9: Notifications */}
811+
<Section id="notifications" title="Notifications">
681812
<SettingRow
682813
label="Enable notifications"
683814
description="Show browser notifications for new activity"
@@ -771,101 +902,9 @@ export default function SettingsPage() {
771902
</SettingRow>
772903
</Section>
773904

774-
{/* Section 7: Appearance */}
775-
<Section title="Appearance">
776-
<div class="px-4 py-2 border-b border-base-300">
777-
<p class="text-sm font-medium text-base-content mb-2">Theme</p>
778-
<ThemePicker />
779-
</div>
780-
<div class="px-4 py-2 border-b border-base-300">
781-
<p class="text-sm font-medium text-base-content mb-2">View density</p>
782-
<DensityPicker />
783-
</div>
784-
<SettingRow
785-
label="Items per page"
786-
description="Number of items to show in each tab"
787-
>
788-
<select
789-
value={String(config.itemsPerPage)}
790-
onChange={(e) => {
791-
saveWithFeedback({ itemsPerPage: Number(e.currentTarget.value) });
792-
}}
793-
class="select select-sm"
794-
>
795-
{itemsPerPageOptions.map((opt) => (
796-
<option value={String(opt.value)}>{opt.label}</option>
797-
))}
798-
</select>
799-
</SettingRow>
800-
</Section>
801-
802-
{/* Section 8: Tabs */}
803-
<Section title="Tabs">
804-
<SettingRow
805-
label="Default tab"
806-
description="Tab shown when opening the dashboard fresh"
807-
>
808-
<select
809-
value={config.defaultTab}
810-
onChange={(e) => {
811-
saveWithFeedback({ defaultTab: e.currentTarget.value as Config["defaultTab"] });
812-
}}
813-
class="select select-sm"
814-
>
815-
{tabOptions().map((opt) => (
816-
<option value={opt.value}>{opt.label}</option>
817-
))}
818-
</select>
819-
</SettingRow>
820-
<SettingRow
821-
label="Remember last tab"
822-
description="Return to the last active tab on revisit"
823-
>
824-
<input
825-
type="checkbox"
826-
role="switch"
827-
aria-checked={config.rememberLastTab}
828-
aria-label="Remember last tab"
829-
checked={config.rememberLastTab}
830-
onChange={(e) => saveWithFeedback({ rememberLastTab: e.currentTarget.checked })}
831-
class="toggle toggle-primary"
832-
/>
833-
</SettingRow>
834-
<SettingRow
835-
label="Enable tracked items"
836-
description="Show a Tracked tab to pin issues and PRs for quick access"
837-
>
838-
<input
839-
type="checkbox"
840-
role="switch"
841-
aria-checked={config.enableTracking}
842-
aria-label="Enable tracked items"
843-
checked={config.enableTracking}
844-
onChange={(e) => {
845-
const val = e.currentTarget.checked;
846-
saveWithFeedback({
847-
enableTracking: val,
848-
...(!val && config.defaultTab === "tracked" ? { defaultTab: "issues" as const } : {}),
849-
});
850-
if (!val && viewState.lastActiveTab === "tracked") {
851-
updateViewState({ lastActiveTab: "issues" });
852-
}
853-
}}
854-
class="toggle toggle-primary"
855-
/>
856-
</SettingRow>
857-
</Section>
858-
859-
{/* Section 9: Custom Tabs */}
860-
<Section title="Custom Tabs" description="Create custom views with saved filters and scoping">
861-
<CustomTabsSection
862-
availableOrgs={[...new Set(config.selectedRepos.map((r) => r.owner))]}
863-
availableRepos={config.selectedRepos}
864-
/>
865-
</Section>
866-
867905
{/* Section 10: MCP Server Relay */}
868906
<Section
907+
id="mcp-relay"
869908
title="MCP Server Relay"
870909
description="Allow a local MCP server to read dashboard data. Enable this if you use Claude Code or another AI client with the GitHub Tracker MCP server."
871910
>
@@ -920,7 +959,7 @@ export default function SettingsPage() {
920959
</Section>
921960

922961
{/* Section 11: Jira Cloud Integration */}
923-
<Section title="Jira Cloud Integration">
962+
<Section id="jira" title="Jira Cloud Integration">
924963
<Show
925964
when={isJiraAuthenticated()}
926965
fallback={
@@ -1132,8 +1171,8 @@ export default function SettingsPage() {
11321171
</Show>
11331172
</Section>
11341173

1135-
{/* Dependencies */}
1136-
<Section title="Dependencies">
1174+
{/* Section 12: Dependencies */}
1175+
<Section id="dependencies" title="Dependencies">
11371176
<SettingRow
11381177
label="Dependencies tab"
11391178
description="Auto-show a Dependencies tab when dependency bot PRs are detected"
@@ -1162,8 +1201,10 @@ export default function SettingsPage() {
11621201
</SettingRow>
11631202
</Section>
11641203

1165-
{/* Data */}
1166-
<Section title="Data">
1204+
{/* ── Account ─────────────────────────────────────────────────── */}
1205+
<p class="text-xs font-semibold uppercase tracking-wider text-base-content/40 px-1">Account</p>
1206+
{/* Section 13: Data */}
1207+
<Section id="data" title="Data">
11671208
{/* Authentication method */}
11681209
<SettingRow
11691210
label="Authentication"
@@ -1275,6 +1316,8 @@ export default function SettingsPage() {
12751316
</button>
12761317
</SettingRow>
12771318
</Section>
1319+
</div>
1320+
</div>
12781321

12791322
<footer class="mt-8 border-t border-base-300 pt-4 pb-8 text-xs text-base-content/50 text-center">
12801323
<div class="flex items-center justify-center gap-3">

0 commit comments

Comments
 (0)