Skip to content

Commit 6988c18

Browse files
DavidBabinecclaude
andauthored
fix(editor): make Settings modal and toolbar trailer truly global (CoreBunch#67)
The Settings modal is openable from every admin route, but its General and Publishing sections read site data straight off the heavy editor store, which is only hydrated on the Site editor. On Content / Data / Media / Plugins / Users / Account those sections rendered a permanent skeleton — the modal was "global" in name only. Separately, the settings cog was injected per-layout: Site, Plugins/Users/Account, and Content had it, but Media and Data did not. Fix both: - Hoist SettingsButton into the Toolbar shell so the trailer (Settings → Open live page → Account) is identical on every route, the same way the left nav is. Remove the per-layout/per-page injections (AdminCanvasLayout, AdminPageLayout, ContentToolbar) and the now-dead ToolbarDivider. - Add useSiteSettingsController: one uniform site-settings source that delegates to the editor draft on the Site editor (joins autosave) and loads/saves via cmsAdapter (shell-only write + site-reload event) everywhere else. General and Publishing consume it instead of the editor store directly, so they work on every admin page without forcing editor-store hydration into the light layouts. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b5846a2 commit 6988c18

10 files changed

Lines changed: 283 additions & 58 deletions

File tree

docs/editor.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,8 @@ The sidebar shell expands/collapses by animating `--*-panel-width`. The panel sl
574574
- `ModulePickerDropdown` — opens the module inserter modal
575575
- `OpenLivePageButton` (`src/admin/shared/OpenLivePageButton/`) — toolbar icon (always visible, not Site-editor-only) that opens the live site in a new tab. Target URL is read from `adminUi.activeLivePath`: active document's public path when an editor is open, site root (`/`) otherwise. Tooltip changes between "Open live page" (active path) and "Open live site" (null). Component stays outside `src/admin/pages/site/` so it mounts on every admin route without touching the editor graph.
576576

577+
**Global trailer.** `Toolbar.tsx` renders a fixed trailer at the right end of every admin route, regardless of which layout mounted it or what the caller passes in `rightSlot`: `SettingsButton``OpenLivePageButton``AccountMenuButton`. These are not layout- or page-owned — the settings cog, live-page link, and account menu are identical everywhere, the same way the left nav is. `SettingsButton` reads only the tiny `adminUi` store, so hosting it in the shell keeps the editor toolchain out of the lightweight admin bundles. Layouts use `rightSlot` only for surface-specific controls *before* the trailer (e.g. `ZoomControls` + `PublishButton` on the Site editor, the Uploads toggle on Media); pages must never inject their own `SettingsButton`.
578+
577579
### Settings modal
578580

579581
`src/admin/modals/Settings/SettingsModal.tsx`. Shares the visual language of the Spotlight palette and Module Inserter: a `--panel-*`-token shell, `--editor-surface-2` rail with categorical rail-tint icon chips, accent-bar section header, card-group rows (`--editor-surface-2` fills, `--panel-radius` corners, 1px gaps showing the darker panel surface through) for section content, and an Esc keycap affordance. Backdrop click and Esc both close — there is no dedicated close button.
@@ -591,6 +593,13 @@ Site-specific controls that were previously sections of this modal (Pages roster
591593

592594
**State bridge**: settings modal open/close state is mirrored between two stores. `adminUi` (`src/admin/state/adminUi.ts`) is the source the modal reads — this lets `SettingsButton` work on non-editor admin pages without pulling in the editor store. `settingsSlice` in the editor store mirrors that state via `bindSettingsBridgeStoreApi` so editor-side consumers (spotlight commands, tests) can open/navigate settings without knowing about `adminUi`. A re-entrance guard (`bridgeReentrancyGuard`) prevents the two-way sync from looping.
593595

596+
**Data source — `useSiteSettingsController`** (`src/admin/modals/Settings/useSiteSettingsController.ts`): the General and Publishing sections edit fields of the persisted `SiteDocument` (`name`, `settings.*`, framework preferences), but where that document lives depends on the route. The modal is global, so a section cannot just read the editor store — that store is only hydrated on the Site editor (`AdminCanvasLayout`). The controller hides the split behind one uniform shape:
597+
598+
- **Site editor** (editor store holds a live draft): delegate to the editor-store mutations. Settings edits join the unsaved draft and persist through the editor's autosave / Save pipeline alongside page-tree edits — never clobbered.
599+
- **Every other admin page** (no in-memory draft): a standalone Zustand store loads the document once via `cmsAdapter`, edits a local copy, and persists immediately with a shell-only `saveSite` (empty dirty sets, so pages / components / layouts are left untouched). After each save it refreshes the `adminUi` site summary and fires `CMS_SITE_RELOAD_EVENT` so the toolbar brand and `useSiteSummary` re-sync. There is no Save button on those pages, so writes commit on blur / toggle.
600+
601+
Because the controller is imported only by the lazy section components, the editor-store import it carries stays inside the `SettingsModal` chunk and never enters the eager graph of the lightweight layouts. This is what makes the modal *actually* global — before it, General and Publishing rendered a permanent skeleton anywhere outside the Site editor.
602+
594603
`CanvasNotch` (`src/admin/pages/site/canvas/CanvasNotch.tsx`) owns the canvas-local insertion chrome. Its quick insert buttons are resolved from each admin's server-side `module-inserter` user preference; the default favorites are Container, Text, and Image. The full module inserter is the management surface for those favorites, so any insertable module, layout preset, or Visual Component can be pinned into the notch without adding a separate settings panel. In Visual Component mode, `CanvasRoot` mounts `VisualComponentModeControl` below the notch so the current component name, rename action, and page-return action stay attached to the canvas rather than the global toolbar. In live mode the notch accepts a `peek` prop — it parks above the top edge (clipped by `overflow:hidden`) and rolls down on hover/`:focus-within` so it does not overlay the page header; a slim `peekHandle` strip remains as the hover target.
595604

596605
### Module Inserter
@@ -704,6 +713,7 @@ See [docs/features/plugin-system.md](features/plugin-system.md) for the plugin S
704713
- `src/admin/shared/OpenLivePageButton/OpenLivePageButton.tsx` — toolbar "Open live page" icon button
705714
- `src/admin/pages/site/hooks/useActiveLivePath.ts` — resolves `activeLivePath` for the Site editor (including template → previewed page/post mapping)
706715
- `src/admin/modals/Settings/SettingsModal.tsx` — settings modal (4 sections: General, Shortcuts, Publishing, Preferences)
716+
- `src/admin/modals/Settings/useSiteSettingsController.ts` — uniform site-settings source for General/Publishing (editor draft on the Site editor, `cmsAdapter` everywhere else)
707717
- `src/admin/pages/site/store/slices/settingsSlice.ts` — settings modal state + adminUi bridge
708718
- `src/admin/state/useSiteSummary.ts` — lightweight site name/favicon fetch for non-editor layouts
709719
- `src/admin/layouts/AdminPageLayout/AdminPageLayout.tsx` — lightweight non-editor shell

src/__tests__/toolbar/toolbar.test.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -415,13 +415,17 @@ describe('Toolbar — structural requirements', () => {
415415
expect(src).toContain('data-testid="toolbar"')
416416
})
417417

418-
it('Toolbar is a prop-driven shell — editor-only buttons live in AdminCanvasLayout', () => {
419-
// After the Toolbar refactor, Toolbar.tsx itself no longer references
420-
// editor-specific sub-components (ZoomControls / PublishButton /
421-
// SettingsButton / save status). Those are passed in via the
422-
// `rightSlot` prop by AdminCanvasLayout, which keeps Toolbar shareable
423-
// with AdminPageLayout (Plugins / Users / Account) without dragging
424-
// the editor store into the non-editor admin bundle.
418+
it('Toolbar is a prop-driven shell — EDITOR-only buttons live in AdminCanvasLayout, global trailer lives in the shell', () => {
419+
// The Toolbar shell owns the GLOBAL trailer (SettingsButton +
420+
// OpenLivePageButton + AccountMenuButton) so the settings cog, live-page
421+
// link, and account menu are identical on every admin route. SettingsButton
422+
// reads the tiny `adminUi` store, so hosting it in the shell does NOT drag
423+
// the editor store into non-editor bundles.
424+
//
425+
// EDITOR-only sub-components (ZoomControls / PublishButton / save status)
426+
// stay out of the shell — they are passed in via the `rightSlot` prop by
427+
// AdminCanvasLayout, which keeps the toolbar shareable with the lightweight
428+
// layouts.
425429
const { readFileSync } = require('fs')
426430
const toolbarSrc = readFileSync(
427431
new URL('../../admin/pages/site/toolbar/Toolbar.tsx', import.meta.url),
@@ -435,15 +439,19 @@ describe('Toolbar — structural requirements', () => {
435439
expect(toolbarSrc).not.toContain("from './ZoomControls'")
436440
expect(toolbarSrc).not.toContain("from './PublishButton'")
437441
expect(toolbarSrc).not.toContain('saveStatus={saveStatus}')
442+
// The global trailer — including the settings cog — IS owned by the shell.
443+
expect(toolbarSrc).toContain("from './SettingsButton'")
444+
expect(toolbarSrc).toContain('<SettingsButton />')
438445

439-
// The editor-only buttons must be mounted from AdminCanvasLayout.
446+
// The editor-only buttons must be mounted from AdminCanvasLayout, which
447+
// must NOT re-mount the now-global SettingsButton.
440448
const layoutSrc = readFileSync(
441449
new URL('../../admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx', import.meta.url),
442450
'utf-8',
443451
)
444452
expect(layoutSrc).toContain('ZoomControls')
445453
expect(layoutSrc).toContain('PublishButton')
446-
expect(layoutSrc).toContain('SettingsButton')
454+
expect(layoutSrc).not.toContain('SettingsButton')
447455
expect(layoutSrc).toContain('saveStatus={persistence.saveStatus}')
448456
})
449457

src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
* No env vars, no API keys, no endpoint configuration required (Constraint #385).
3939
*/
4040
import { Toolbar } from '@admin/pages/site/toolbar/Toolbar'
41-
import { SettingsButton } from '@admin/pages/site/toolbar/SettingsButton'
4241
import { ZoomControls } from '@admin/pages/site/toolbar/ZoomControls'
4342
import { PublishButton } from '@admin/pages/site/toolbar/PublishButton'
4443
import { useEditorSelectPreference } from '@admin/pages/site/preferences/editorPreferences'
@@ -212,7 +211,6 @@ export function AdminCanvasLayout() {
212211
onSave={canSaveSite ? persistence.saveSite : undefined}
213212
saveStatus={persistence.saveStatus}
214213
/>
215-
<SettingsButton />
216214
</>
217215
)}
218216
/>

src/admin/layouts/AdminPageLayout/AdminPageLayout.tsx

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@
2828
* chunk — NOT `store-*` (editor) or any panel/canvas/modules code.
2929
*/
3030
import { lazy, Suspense, type ReactNode } from 'react'
31-
import { Toolbar, ToolbarDivider } from '@site/toolbar/Toolbar'
32-
import { SettingsButton } from '@site/toolbar/SettingsButton'
31+
import { Toolbar } from '@site/toolbar/Toolbar'
3332
import { AdminSectionNavigation } from '@admin/shared/AdminSectionNavigation'
3433
import { SkeletonCards } from '@ui/components/Skeleton'
3534
import { useEditorSelectPreference } from '@site/preferences/editorPreferences'
@@ -70,8 +69,9 @@ interface AdminPageLayoutProps {
7069
actions?: ReactNode
7170
/**
7271
* Optional extra toolbar items rendered in the toolbar's right slot. The
73-
* Settings cog is always appended automatically — pages should not pass
74-
* their own SettingsButton here.
72+
* Settings cog, "Open live page", and account menu are appended globally
73+
* by the Toolbar shell itself — pages must not pass their own SettingsButton
74+
* here.
7575
*/
7676
toolbarRightSlot?: ReactNode
7777
/** Optional id for the H1 — useful for `aria-labelledby` on the body. */
@@ -118,14 +118,6 @@ export function AdminPageLayout({
118118
const faviconUrl = useAdminUi((s) => s.siteFaviconUrl)
119119
const settingsOpen = useAdminUi((s) => s.settingsOpen)
120120

121-
const rightSlot = (
122-
<>
123-
{toolbarRightSlot}
124-
<ToolbarDivider />
125-
<SettingsButton />
126-
</>
127-
)
128-
129121
return (
130122
<div className={styles.shell} data-editor-density={density}>
131123
<Toolbar
@@ -138,7 +130,7 @@ export function AdminPageLayout({
138130
currentUser={currentUser}
139131
/>
140132
)}
141-
rightSlot={rightSlot}
133+
rightSlot={toolbarRightSlot}
142134
/>
143135

144136
<main className={styles.body} aria-busy={loading || undefined}>

src/admin/modals/Settings/sections/GeneralSection.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* skips that pattern.
1313
*/
1414
import { Suspense, lazy, useState } from 'react'
15-
import { useEditorStore } from '@site/store/store'
15+
import { useSiteSettingsController } from '../useSiteSettingsController'
1616
import { useAsyncResource } from '@admin/lib/useAsyncResource'
1717
import { Input, Textarea } from '@ui/components/Input'
1818
import { Button } from '@ui/components/Button'
@@ -37,9 +37,11 @@ const MediaPickerModal = lazy(() =>
3737
)
3838

3939
export function GeneralSection() {
40-
const site = useEditorStore((state) => state.site)
41-
const updateSiteName = useEditorStore((state) => state.updateSiteName)
42-
const updateSiteSettings = useEditorStore((state) => state.updateSiteSettings)
40+
const { site, error, updateSiteName, updateSiteSettings } = useSiteSettingsController()
41+
42+
if (error) {
43+
return <p className={s.sectionDescription} role="alert">{error}</p>
44+
}
4345

4446
if (!site) {
4547
return <SkeletonBlock minHeight={200} ariaLabel="Loading site settings" />

src/admin/modals/Settings/sections/PublishingSection.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
/**
22
* PublishingSection — self-hosted CMS publishing details.
33
*/
4-
import { useEditorStore } from '@site/store/store'
4+
import { useSiteSettingsController } from '../useSiteSettingsController'
55
import { resolveFrameworkPreferences } from '@core/framework'
66
import { Switch } from '@ui/components/Switch'
77
import { SkeletonBlock } from '@ui/components/Skeleton'
88
import s from '../SettingsModal.module.css'
99

1010
export function PublishingSection() {
11-
const site = useEditorStore((state) => state.site)
12-
const updateFrameworkPreferences = useEditorStore((state) => state.updateFrameworkPreferences)
11+
const { site, error, updateFrameworkPreferences } = useSiteSettingsController()
12+
13+
if (error) {
14+
return <p className={s.sectionDescription} role="alert">{error}</p>
15+
}
1316

1417
if (!site) {
1518
return <SkeletonBlock minHeight={200} ariaLabel="Loading site settings" />

0 commit comments

Comments
 (0)