editor: friendlier panels - collapsible rail, remembered state, no more overlap

Rail sections (Outline/Blocks/Nodes) collapse via their headers like
Experiments already did, so long pages stay navigable. Rail open state
and section toggles persist in localStorage across reloads (the QA
preview flow reloads constantly). The assistant panel shifts left while
the dev panel is open instead of stacking exactly underneath it, and
the rail/dev/assistant widths cap at the viewport on small screens.

Tests: rail-sections harness (collapse, reload persistence, shifted
assistant); harness boots with a fresh profile unless keepStorage is
passed; in-memory localStorage shim for the origin-less jsdom.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
mats committed 2026-11-07 12:37:09 +01:00
1 parent 5786712078
commit bb875a0956
10 files changed
+185 -20

No files matched your search

+4 -1
View File
@@ -20,7 +20,10 @@ export interface Harness {
}
/** `stale`: the DOM is the *published* page, and the store has moved on since (the base-page problem). */
export async function setup(opts: { token?: string; auth?: boolean; stale?: boolean; noPreviewApi?: boolean } = {}): Promise<Harness> {
export async function setup(opts: { token?: string; auth?: boolean; stale?: boolean; noPreviewApi?: boolean; keepStorage?: boolean } = {}): Promise<Harness> {
// Fresh profile by default: remembered panel state must not leak between tests.
// Pass keepStorage to simulate a reload with the same profile.
if (!opts.keepStorage) localStorage.clear()
const cms = createMockCms({ token: opts.token, auth: opts.auth })
document.head.innerHTML = ''
window.open = () => null
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { act } from 'react'
import { setup } from './harness'
import type { Harness } from './harness'
let h: Harness
beforeEach(() => localStorage.clear())
afterEach(() => {
h?.cleanup()
delete (navigator as any).gpu
})
const q = (sel: string) => h.shadow.querySelector<HTMLElement>(sel)!
const qa = (sel: string) => [...h.shadow.querySelectorAll<HTMLElement>(sel)]
describe('rail sections', () => {
it('outline, blocks and nodes collapse via their headers', async () => {
h = await setup()
await h.settle()
await h.press('Panel')
await h.settle()
await h.settle()
expect(qa('.cms-outline').length).toBeGreaterThan(0)
// collapse outline: the tree leaves the DOM, palette stays
// (NB: the section itself shares the aria-label, so target the toggle button)
await act(async () => void q('button[aria-label="Outline"]').click())
await h.settle()
expect(qa('.cms-outline').length).toBe(0)
expect(q('[aria-label="Search blocks"]')).not.toBeNull()
// collapse blocks too
await act(async () => void q('button[aria-label="Blocks"]').click())
await h.settle()
expect(h.shadow.querySelector('[aria-label="Search blocks"]')).toBeNull()
// and back open
await act(async () => void q('button[aria-label="Blocks"]').click())
await h.settle()
expect(q('[aria-label="Search blocks"]')).not.toBeNull()
await act(async () => void q('button[aria-label="Outline"]').click())
await h.settle()
expect(qa('.cms-outline').length).toBeGreaterThan(0)
})
it('rail open state and collapsed sections survive a reload', async () => {
h = await setup()
await h.settle()
await h.press('Panel')
await h.settle()
await act(async () => void q('button[aria-label="Blocks"]').click()) // collapse blocks
await h.settle()
expect(localStorage.getItem('cms-onpage.rail')).toBe('true')
expect(localStorage.getItem('cms-onpage.sec.blocks')).toBe('false')
// reload with the same profile: rail reopens, blocks stays collapsed
h.cleanup()
h = await setup({ keepStorage: true })
await h.settle()
await h.settle()
expect(q('.cms-rail')).not.toBeNull()
expect(h.shadow.querySelector('[aria-label="Search blocks"]')).toBeNull()
expect(qa('.cms-outline').length).toBeGreaterThan(0)
})
it('assistant sits beside the dev panel instead of stacking under it', async () => {
;(navigator as any).gpu = {} // aiSupported() checks for a WebGPU API
h = await setup()
await h.settle()
await act(async () => void h.editor.ctrl.store.set({ assistant: true, dev: true }))
await h.settle()
expect(q('.cms-dev')).not.toBeNull()
expect(q('.cms-assistant.cms-assistant--shifted')).not.toBeNull()
await act(async () => void h.editor.ctrl.store.set({ dev: false }))
await h.settle()
expect(h.shadow.querySelector('.cms-assistant--shifted')).toBeNull()
})
})
+16
View File
@@ -1,5 +1,21 @@
// jsdom has no layout: give the few APIs the editor touches harmless defaults.
if (!('scrollIntoView' in Element.prototype)) (Element.prototype as any).scrollIntoView = () => {}
// This jsdom runs origin-less, so there is no localStorage: provide an
// in-memory one so UI persistence (rail/panel state) behaves like a browser.
if (typeof (globalThis as any).localStorage === 'undefined') {
const store = new Map<string, string>()
;(globalThis as any).localStorage = {
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
setItem: (k: string, v: string) => void store.set(k, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
key: (i: number) => [...store.keys()][i] ?? null,
get length() {
return store.size
},
}
}
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
// Store updates arrive from fetch/timers outside act(); the harness settles them explicitly.