Skip to content

Commit 86f21c5

Browse files
committed
feat(platform): one settings body and one header per settings page
Organization and workspace Settings now render the same SettingsBody (Paper's 700px column, the ?row= reveal, and the page's title and description in context). The frame no longer draws the h1: every section renders exactly one header, SettingsPageHeader in its main view or DetailHeader in a sub-view, which takes the page's heading level. Workspace navigation entries gain a description, its duplicate frame CSS and negative margin go.
1 parent 8a1b530 commit 86f21c5

51 files changed

Lines changed: 714 additions & 338 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/* Every settings page reads at Paper's settings column, narrower than the dashboard's page column. */
2+
.body {
3+
display: flex;
4+
flex-direction: column;
5+
gap: var(--spacing-24);
6+
inline-size: 100%;
7+
max-inline-size: var(--container-app-settings);
8+
min-inline-size: 0;
9+
}
10+
11+
[data-desktop] .body {
12+
gap: var(--spacing-20);
13+
}
14+
15+
.header {
16+
display: grid;
17+
gap: var(--spacing-8);
18+
}
19+
20+
.header p {
21+
color: var(--ui-secondary);
22+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
'use client'
2+
3+
import { createContext, type ReactNode, useContext, useEffect, useId, useRef } from 'react'
4+
import { parseAsString, useQueryState } from 'nuqs'
5+
import styles from '@/app/(product)/_components/settings-parts/settings-page.module.css'
6+
import { SETTING_ROW_PARAM } from '@/platform/shared/settings-search'
7+
8+
const ROW_WAIT_MS = 4_000
9+
10+
/** A one-shot signal: nullable with no default, and stripped once answered. */
11+
const settingRowParser = parseAsString.withOptions({ history: 'replace', scroll: false })
12+
13+
/**
14+
* Brings the row a Settings search result named into view once the section renders it, which may
15+
* be a chunk and a read away. The param is stripped when the row is found or the wait lapses,
16+
* because a conditional row may never come, and so a second click on the same result reveals it again.
17+
*/
18+
function useSettingRowReveal() {
19+
const [row, setRow] = useQueryState(SETTING_ROW_PARAM, settingRowParser)
20+
const rootRef = useRef<HTMLElement>(null)
21+
useEffect(() => {
22+
const root = rootRef.current
23+
if (!row || !root) return
24+
const selector = `[data-setting-label="${CSS.escape(row)}"]`
25+
const observer = new MutationObserver(reveal)
26+
const timer = setTimeout(settle, ROW_WAIT_MS)
27+
function stop() {
28+
observer.disconnect()
29+
clearTimeout(timer)
30+
}
31+
function settle() {
32+
stop()
33+
void setRow(null)
34+
}
35+
function reveal() {
36+
const element = root?.querySelector(selector)
37+
if (!element) return
38+
element.scrollIntoView({ block: 'center' })
39+
settle()
40+
}
41+
observer.observe(root, { childList: true, subtree: true })
42+
reveal()
43+
return stop
44+
}, [row, setRow])
45+
return rootRef
46+
}
47+
48+
/** The level of a settings page's heading: `h1` where the page is the document, `h2` under a tab's own heading. */
49+
export type SettingsHeadingLevel = 'h1' | 'h2'
50+
51+
interface SettingsPage {
52+
title: string
53+
description?: string
54+
headingLevel: SettingsHeadingLevel
55+
/** Names the page's region after whichever header it renders. */
56+
headingId: string
57+
}
58+
59+
const SettingsPageContext = createContext<SettingsPage | null>(null)
60+
61+
/**
62+
* The level and id of the heading a sub-view's header renders: the page's own when it sits in a
63+
* {@link SettingsBody}, an unnamed `h2` outside one.
64+
*/
65+
export function useSettingsHeading(): { level: SettingsHeadingLevel; id?: string } {
66+
const page = useContext(SettingsPageContext)
67+
return page ? { level: page.headingLevel, id: page.headingId } : { level: 'h2' }
68+
}
69+
70+
interface SettingsBodyProps {
71+
title: string
72+
/** The page's one-line summary under its title, from the Settings navigation metadata. */
73+
description?: string
74+
headingLevel: SettingsHeadingLevel
75+
children: ReactNode
76+
}
77+
78+
/**
79+
* The one body every settings page, organization or workspace, renders in: Paper's settings column,
80+
* the reveal of a row a Settings search result named, and the page's title and description for
81+
* the open section's {@link SettingsPageHeader}. The body draws no heading of its own: the section
82+
* renders exactly one header, the page's or a sub-view's `DetailHeader`, so a sub-view never stacks
83+
* under the page title. The surrounding chrome (breadcrumb, tab bar, navigation) stays outside.
84+
*/
85+
export function SettingsBody({ title, description, headingLevel, children }: SettingsBodyProps) {
86+
const headingId = useId()
87+
const reveal = useSettingRowReveal()
88+
return (
89+
<SettingsPageContext.Provider value={{ title, description, headingLevel, headingId }}>
90+
<section ref={reveal} className={styles.body} aria-labelledby={headingId}>
91+
{children}
92+
</section>
93+
</SettingsPageContext.Provider>
94+
)
95+
}
96+
97+
/**
98+
* The page's title and description, as its navigation entry names them. A section renders it in
99+
* its main view and a sub-view renders `DetailHeader` instead, so the page has one header. Outside a
100+
* {@link SettingsBody} there is no page to name, so it renders nothing.
101+
*/
102+
export function SettingsPageHeader() {
103+
const page = useContext(SettingsPageContext)
104+
if (!page) return null
105+
return (
106+
<header className={styles.header}>
107+
{page.headingLevel === 'h1' ? (
108+
<h1 id={page.headingId} data-type='T076'>
109+
{page.title}
110+
</h1>
111+
) : (
112+
<h2 id={page.headingId} data-type='T076'>
113+
{page.title}
114+
</h2>
115+
)}
116+
{page.description && <p data-type='T067'>{page.description}</p>}
117+
</header>
118+
)
119+
}

‎apps/sim/app/(product)/_components/settings-parts/settings-parts.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
44
import { Button } from '@sim/emcn/button'
55
import { PageOverflow } from '@sim/emcn-platform/page-column'
66
import Link from 'next/link'
7+
import { useSettingsHeading } from '@/app/(product)/_components/settings-parts/settings-page'
78
import styles from '@/app/(product)/_components/settings-parts/settings-parts.module.css'
89

910
interface ChildrenProps {
@@ -91,7 +92,10 @@ interface DetailHeaderContent {
9192
type DetailHeaderProps = DetailHeaderContent &
9293
({ backHref: string; onBack?: never } | { onBack: () => void; backHref?: never })
9394

94-
/** A sub-view's way back, title and actions. */
95+
/**
96+
* A sub-view's way back, title and actions. It takes the place of the page's `SettingsPageHeader`,
97+
* so it renders the page's heading.
98+
*/
9599
export function DetailHeader({
96100
backLabel,
97101
backHref,
@@ -100,6 +104,7 @@ export function DetailHeader({
100104
description,
101105
children,
102106
}: DetailHeaderProps) {
107+
const heading = useSettingsHeading()
103108
return (
104109
<header className={styles.detailHeader}>
105110
<div className={styles.back}>
@@ -114,7 +119,15 @@ export function DetailHeader({
114119
</div>
115120
<div className={styles.detailTitleRow}>
116121
<div className={styles.detailTitle}>
117-
<h2 data-type='T093'>{title}</h2>
122+
{heading.level === 'h1' ? (
123+
<h1 id={heading.id} data-type='T093'>
124+
{title}
125+
</h1>
126+
) : (
127+
<h2 id={heading.id} data-type='T093'>
128+
{title}
129+
</h2>
130+
)}
118131
{description && (
119132
<p className={styles.muted} data-type='T060'>
120133
{description}

‎apps/sim/app/(product)/o/[organizationId]/settings/_components/sections/access-control/access-control-section.tsx‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { SettingsRow, SettingsSection } from '@sim/emcn/settings-section'
1313
import { FailureNotice } from '@sim/emcn/status'
1414
import { getErrorMessage } from '@sim/utils/errors'
1515
import Link from 'next/link'
16+
import { SettingsPageHeader } from '@/app/(product)/_components/settings-parts/settings-page'
1617
import {
1718
ResourceList,
1819
SectionStack,
@@ -132,14 +133,17 @@ export default function AccessControlSection({ organization }: SettingsSectionPr
132133
? permissionGroups.find((group) => group.id === params.groupId)
133134
: undefined
134135

135-
if (groups.isPending) return null
136+
if (groups.isPending) return params.groupId ? null : <SettingsPageHeader />
136137
if (groups.error && groups.data === undefined) {
137138
return (
138-
<FailureNotice
139-
message={getErrorMessage(groups.error, 'Failed to load permission groups')}
140-
retrying={groups.isFetching}
141-
onRetry={() => void groups.refetch()}
142-
/>
139+
<>
140+
<SettingsPageHeader />
141+
<FailureNotice
142+
message={getErrorMessage(groups.error, 'Failed to load permission groups')}
143+
retrying={groups.isFetching}
144+
onRetry={() => void groups.refetch()}
145+
/>
146+
</>
143147
)
144148
}
145149

@@ -180,6 +184,7 @@ export default function AccessControlSection({ organization }: SettingsSectionPr
180184

181185
return (
182186
<SectionStack>
187+
<SettingsPageHeader />
183188
{refreshError}
184189
{workspacesError}
185190
<SearchField

‎apps/sim/app/(product)/o/[organizationId]/settings/_components/sections/admin/admin-section.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { notFound } from 'next/navigation'
2+
import { SettingsPageHeader } from '@/app/(product)/_components/settings-parts/settings-page'
23
import type { SettingsSectionProps } from '@/app/(product)/o/[organizationId]/settings/_components/section-registry'
34
import { LiveAdminPanel } from '@/app/(product)/o/[organizationId]/settings/_components/sections/admin/live-admin-panel'
45
import { readScenario } from '@/platform/lib/navigation'
@@ -14,7 +15,17 @@ export default async function AdminSection({ organization, query }: SettingsSect
1415
import('@/platform/fixtures'),
1516
])
1617
const example = await loadAdminExample(readScenario(query.state))
17-
return <FixtureAdminPanel example={example} />
18+
return (
19+
<>
20+
<SettingsPageHeader />
21+
<FixtureAdminPanel example={example} />
22+
</>
23+
)
1824
}
19-
return <LiveAdminPanel benchmarkAvailable={organization.navFlags.benchmark} />
25+
return (
26+
<>
27+
<SettingsPageHeader />
28+
<LiveAdminPanel benchmarkAvailable={organization.navFlags.benchmark} />
29+
</>
30+
)
2031
}

‎apps/sim/app/(product)/o/[organizationId]/settings/_components/sections/audit-logs/audit-logs-section.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { SearchField } from '@sim/emcn/search-field'
1313
import { SettingsRow, SettingsSection } from '@sim/emcn/settings-section'
1414
import { FailureNotice, StatusTag } from '@sim/emcn/status'
1515
import { toast } from '@/lib/toast/sink'
16+
import { SettingsPageHeader } from '@/app/(product)/_components/settings-parts/settings-page'
1617
import {
1718
ActionRow,
1819
Muted,
@@ -152,6 +153,7 @@ export default function AuditLogsSection({ organization }: SettingsSectionProps)
152153

153154
return (
154155
<SectionStack>
156+
<SettingsPageHeader />
155157
<SectionToolbar
156158
grow={
157159
<SearchField

‎apps/sim/app/(product)/o/[organizationId]/settings/_components/sections/billing/billing-view.tsx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
BooleanRow,
1313
SavedSettingsSection,
1414
} from '@/app/(product)/_components/settings-parts/saved-settings'
15+
import { SettingsPageHeader } from '@/app/(product)/_components/settings-parts/settings-page'
1516
import type { SettingsSectionProps } from '@/app/(product)/o/[organizationId]/settings/_components/section-registry'
1617
import { AwsMarketplaceSubscription } from '@/app/(product)/o/[organizationId]/settings/_components/sections/billing/aws-marketplace'
1718
import styles from '@/app/(product)/o/[organizationId]/settings/_components/sections/billing/billing.module.css'
@@ -52,12 +53,19 @@ export function BillingView({
5253
subpath,
5354
}: Pick<SettingsSectionProps, 'organization' | 'subpath'>) {
5455
const billing = useBillingSection(organization)
55-
if (billing.status === 'pending') return null
56+
const creditUsage = subpath[0] === 'credit-usage'
57+
if (billing.status === 'pending') return creditUsage ? null : <SettingsPageHeader />
5658
if (billing.status === 'error')
57-
return <FailureNotice message={billing.error} onRetry={billing.retry} />
58-
if (subpath[0] === 'credit-usage') return <CreditUsage available={billing.showCreditUsage} />
59+
return (
60+
<>
61+
<SettingsPageHeader />
62+
<FailureNotice message={billing.error} onRetry={billing.retry} />
63+
</>
64+
)
65+
if (creditUsage) return <CreditUsage available={billing.showCreditUsage} />
5966
return (
6067
<>
68+
<SettingsPageHeader />
6169
<SettingsSurface>
6270
<SettingsRow
6371
label={billing.planTitle}

‎apps/sim/app/(product)/o/[organizationId]/settings/_components/sections/browser/browser-section.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { FailureNotice } from '@sim/emcn/status'
99
import { Switch } from '@sim/emcn/switch'
1010
import { useQueryStates } from 'nuqs'
1111
import { toast } from '@/lib/toast/sink'
12+
import { SettingsPageHeader } from '@/app/(product)/_components/settings-parts/settings-page'
1213
import { PasswordsView } from '@/app/(product)/o/[organizationId]/settings/_components/sections/browser/passwords-view'
1314
import {
1415
browserParsers,
@@ -150,6 +151,7 @@ function BrowserSettings({
150151

151152
return (
152153
<>
154+
<SettingsPageHeader />
153155
<SettingsSection title='General'>
154156
<SettingsRow
155157
label={ENABLED_LABEL}
@@ -270,8 +272,14 @@ export default function BrowserSection() {
270272
const openImport = params.browserImport === '1'
271273
const showPasswords = params.browserView === 'passwords' || openImport
272274

273-
if (failed) return <FailureNotice message='Could not load browser settings' onRetry={retry} />
274-
if (!data) return null
275+
if (failed)
276+
return (
277+
<>
278+
<SettingsPageHeader />
279+
<FailureNotice message='Could not load browser settings' onRetry={retry} />
280+
</>
281+
)
282+
if (!data) return showPasswords ? null : <SettingsPageHeader />
275283

276284
const setPreferences = (preferences: DesktopPreferences) =>
277285
setData((current) => current && { ...current, preferences })

‎apps/sim/app/(product)/o/[organizationId]/settings/_components/sections/connected-accounts/connected-accounts-section.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { SettingsPageHeader } from '@/app/(product)/_components/settings-parts/settings-page'
12
import type { SettingsSectionProps } from '@/app/(product)/o/[organizationId]/settings/_components/section-registry'
23
import { LiveCredentialGroups } from '@/app/(product)/o/[organizationId]/settings/_components/sections/connected-accounts/live-credential-groups'
34
import { readScenario } from '@/platform/lib/navigation'
@@ -15,13 +16,21 @@ export default async function ConnectedAccountsSection({
1516
import('@/platform/fixtures'),
1617
])
1718
const data = await loadCredentialGroupsData(readScenario(query.state))
18-
return <FixtureCredentialGroups data={data} />
19+
return (
20+
<>
21+
<SettingsPageHeader />
22+
<FixtureCredentialGroups data={data} />
23+
</>
24+
)
1925
}
2026
const organizationId = organization.organization.id
2127
const tab = typeof query.tab === 'string' ? query.tab : 'providers'
2228
const { readCredentialGroups } = await import('@/platform/lib/data/credential-groups')
2329
const { data, management } = await readCredentialGroups(organizationId, tab, query)
2430
return (
25-
<LiveCredentialGroups organizationId={organizationId} data={data} management={management} />
31+
<>
32+
<SettingsPageHeader />
33+
<LiveCredentialGroups organizationId={organizationId} data={data} management={management} />
34+
</>
2635
)
2736
}

‎apps/sim/app/(product)/o/[organizationId]/settings/_components/sections/data-drains/data-drains-section.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SearchField } from '@sim/emcn/search-field'
88
import { SettingsRow, SettingsSection } from '@sim/emcn/settings-section'
99
import { FailureNotice } from '@sim/emcn/status'
1010
import { getErrorMessage } from '@sim/utils/errors'
11+
import { SettingsPageHeader } from '@/app/(product)/_components/settings-parts/settings-page'
1112
import {
1213
ResourceList,
1314
SectionStack,
@@ -75,6 +76,7 @@ export default function DataDrainsSection({ organization }: SettingsSectionProps
7576

7677
return (
7778
<SectionStack>
79+
<SettingsPageHeader />
7880
<SectionToolbar
7981
grow={
8082
<SearchField

0 commit comments

Comments
 (0)