Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,8 @@ The sidebar shell expands/collapses by animating `--*-panel-width`. The panel sl
- `ModulePickerDropdown` — opens the module inserter modal
- `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.

**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`.

### Settings modal

`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.
Expand All @@ -591,6 +593,13 @@ Site-specific controls that were previously sections of this modal (Pages roster

**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.

**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:

- **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.
- **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.

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.

`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.

### Module Inserter
Expand Down Expand Up @@ -704,6 +713,7 @@ See [docs/features/plugin-system.md](features/plugin-system.md) for the plugin S
- `src/admin/shared/OpenLivePageButton/OpenLivePageButton.tsx` — toolbar "Open live page" icon button
- `src/admin/pages/site/hooks/useActiveLivePath.ts` — resolves `activeLivePath` for the Site editor (including template → previewed page/post mapping)
- `src/admin/modals/Settings/SettingsModal.tsx` — settings modal (4 sections: General, Shortcuts, Publishing, Preferences)
- `src/admin/modals/Settings/useSiteSettingsController.ts` — uniform site-settings source for General/Publishing (editor draft on the Site editor, `cmsAdapter` everywhere else)
- `src/admin/pages/site/store/slices/settingsSlice.ts` — settings modal state + adminUi bridge
- `src/admin/state/useSiteSummary.ts` — lightweight site name/favicon fetch for non-editor layouts
- `src/admin/layouts/AdminPageLayout/AdminPageLayout.tsx` — lightweight non-editor shell
Expand Down
26 changes: 17 additions & 9 deletions src/__tests__/toolbar/toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,13 +415,17 @@ describe('Toolbar — structural requirements', () => {
expect(src).toContain('data-testid="toolbar"')
})

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

// The editor-only buttons must be mounted from AdminCanvasLayout.
// The editor-only buttons must be mounted from AdminCanvasLayout, which
// must NOT re-mount the now-global SettingsButton.
const layoutSrc = readFileSync(
new URL('../../admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx', import.meta.url),
'utf-8',
)
expect(layoutSrc).toContain('ZoomControls')
expect(layoutSrc).toContain('PublishButton')
expect(layoutSrc).toContain('SettingsButton')
expect(layoutSrc).not.toContain('SettingsButton')
expect(layoutSrc).toContain('saveStatus={persistence.saveStatus}')
})

Expand Down
2 changes: 0 additions & 2 deletions src/admin/layouts/AdminCanvasLayout/AdminCanvasLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
* No env vars, no API keys, no endpoint configuration required (Constraint #385).
*/
import { Toolbar } from '@admin/pages/site/toolbar/Toolbar'
import { SettingsButton } from '@admin/pages/site/toolbar/SettingsButton'
import { ZoomControls } from '@admin/pages/site/toolbar/ZoomControls'
import { PublishButton } from '@admin/pages/site/toolbar/PublishButton'
import { useEditorSelectPreference } from '@admin/pages/site/preferences/editorPreferences'
Expand Down Expand Up @@ -212,7 +211,6 @@ export function AdminCanvasLayout() {
onSave={canSaveSite ? persistence.saveSite : undefined}
saveStatus={persistence.saveStatus}
/>
<SettingsButton />
</>
)}
/>
Expand Down
18 changes: 5 additions & 13 deletions src/admin/layouts/AdminPageLayout/AdminPageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@
* chunk — NOT `store-*` (editor) or any panel/canvas/modules code.
*/
import { lazy, Suspense, type ReactNode } from 'react'
import { Toolbar, ToolbarDivider } from '@site/toolbar/Toolbar'
import { SettingsButton } from '@site/toolbar/SettingsButton'
import { Toolbar } from '@site/toolbar/Toolbar'
import { AdminSectionNavigation } from '@admin/shared/AdminSectionNavigation'
import { SkeletonCards } from '@ui/components/Skeleton'
import { useEditorSelectPreference } from '@site/preferences/editorPreferences'
Expand Down Expand Up @@ -70,8 +69,9 @@ interface AdminPageLayoutProps {
actions?: ReactNode
/**
* Optional extra toolbar items rendered in the toolbar's right slot. The
* Settings cog is always appended automatically — pages should not pass
* their own SettingsButton here.
* Settings cog, "Open live page", and account menu are appended globally
* by the Toolbar shell itself — pages must not pass their own SettingsButton
* here.
*/
toolbarRightSlot?: ReactNode
/** Optional id for the H1 — useful for `aria-labelledby` on the body. */
Expand Down Expand Up @@ -118,14 +118,6 @@ export function AdminPageLayout({
const faviconUrl = useAdminUi((s) => s.siteFaviconUrl)
const settingsOpen = useAdminUi((s) => s.settingsOpen)

const rightSlot = (
<>
{toolbarRightSlot}
<ToolbarDivider />
<SettingsButton />
</>
)

return (
<div className={styles.shell} data-editor-density={density}>
<Toolbar
Expand All @@ -138,7 +130,7 @@ export function AdminPageLayout({
currentUser={currentUser}
/>
)}
rightSlot={rightSlot}
rightSlot={toolbarRightSlot}
/>

<main className={styles.body} aria-busy={loading || undefined}>
Expand Down
10 changes: 6 additions & 4 deletions src/admin/modals/Settings/sections/GeneralSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* skips that pattern.
*/
import { Suspense, lazy, useState } from 'react'
import { useEditorStore } from '@site/store/store'
import { useSiteSettingsController } from '../useSiteSettingsController'
import { useAsyncResource } from '@admin/lib/useAsyncResource'
import { Input, Textarea } from '@ui/components/Input'
import { Button } from '@ui/components/Button'
Expand All @@ -37,9 +37,11 @@ const MediaPickerModal = lazy(() =>
)

export function GeneralSection() {
const site = useEditorStore((state) => state.site)
const updateSiteName = useEditorStore((state) => state.updateSiteName)
const updateSiteSettings = useEditorStore((state) => state.updateSiteSettings)
const { site, error, updateSiteName, updateSiteSettings } = useSiteSettingsController()

if (error) {
return <p className={s.sectionDescription} role="alert">{error}</p>
}

if (!site) {
return <SkeletonBlock minHeight={200} ariaLabel="Loading site settings" />
Expand Down
9 changes: 6 additions & 3 deletions src/admin/modals/Settings/sections/PublishingSection.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
/**
* PublishingSection — self-hosted CMS publishing details.
*/
import { useEditorStore } from '@site/store/store'
import { useSiteSettingsController } from '../useSiteSettingsController'
import { resolveFrameworkPreferences } from '@core/framework'
import { Switch } from '@ui/components/Switch'
import { SkeletonBlock } from '@ui/components/Skeleton'
import s from '../SettingsModal.module.css'

export function PublishingSection() {
const site = useEditorStore((state) => state.site)
const updateFrameworkPreferences = useEditorStore((state) => state.updateFrameworkPreferences)
const { site, error, updateFrameworkPreferences } = useSiteSettingsController()

if (error) {
return <p className={s.sectionDescription} role="alert">{error}</p>
}

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