Device preview + responsive spacing in the Style panel
Style panel gets a real-viewport iframe preview (390/820/1280px) tied to the breakpoint tab, reloaded after each edit, and spacing/gap controls per breakpoint. The editor does not boot inside the preview frame. Mock and tests mirror the new k6n layout template. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cb7a7a1fab
commit
ec4212c113
7 files changed
+135
-8
No files matched your search
+6
-2
@@ -64,8 +64,12 @@ and every block with a numeric `columns`.
|
||||
|
||||
## Open points / suggestions for the CMS side
|
||||
|
||||
1. **Responsive spacing** is not possible today: no `gap/padding/margin` classes per breakpoint. If wanted: add `x-style` spacing
|
||||
fields to `$defs/breakpoint` and emit e.g. `k6n-gap-md@t` classes from `layout.gohtml`; the editor picks them up unchanged.
|
||||
1. **Responsive spacing** (done): `$defs/breakpoint` declares `gap`, `padding(Top|Bottom)`, `margin(Top|Bottom)`, `align`, `justify`; the k6n
|
||||
layout template emits `k6n-t-gap-md` / `k6n-m-pt-sm` classes (tablet ≤900px, mobile ≤620px). Columns are resolved in the template into
|
||||
`--k6n-cols` / `--k6n-cols-t` / `--k6n-cols-m` (defaults: tablet ≤2, mobile 1); cards/quotes/cases/projects/people read the same variables.
|
||||
**Device preview**: the Style panel's *Show … preview* opens the page in an iframe at 390/820/1280px (real viewport, so media queries apply),
|
||||
reloaded after each edit; the editor does not boot inside it (`window.name`). Only the k6n content root has this so far; the shared
|
||||
`data/templates/layout.gohtml` still uses the old class scheme.
|
||||
2. **Left/right padding and margin** have no fields (`paddingTop/Bottom` only), so those grips edit the all-sides field. Adding
|
||||
`paddingX/marginX` (`side: "x"`) gives them their own handles with no editor change.
|
||||
3. `InsertOp` in `@k6n/cms-editor` lacks the optional `id` the server accepts (the editor casts for the wrap batch).
|
||||
|
||||
+9
-2
@@ -53,6 +53,9 @@ export const schemas: Record<string, any> = {
|
||||
properties: {
|
||||
flow: { 'x-style': { kind: 'flow' }, type: 'string', enum: ['row', 'stack', 'actions'] },
|
||||
columns: { 'x-style': { kind: 'columns' }, type: 'integer', minimum: 1, maximum: 12 },
|
||||
gap: { 'x-style': { kind: 'gap', token: '--k6n-sp-{value}' }, type: 'string', enum: ['none', 'xs', 'sm', 'md', 'lg', 'xl'] },
|
||||
paddingTop: { 'x-style': { kind: 'spacing', box: 'padding', side: 'top', overrides: 'padding', token: '--k6n-sp-{value}' }, type: 'string', enum: ['none', 'xs', 'sm', 'md', 'lg', 'xl'] },
|
||||
paddingBottom: { 'x-style': { kind: 'spacing', box: 'padding', side: 'bottom', overrides: 'padding', token: '--k6n-sp-{value}' }, type: 'string', enum: ['none', 'xs', 'sm', 'md', 'lg', 'xl'] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
@@ -193,8 +196,12 @@ export function render(g: Graph, id: string): string {
|
||||
.map(([k, c]) => ` k6n-${c}-${d[k]}`)
|
||||
.join('')
|
||||
const r = d.responsive ?? {}
|
||||
const resp = [['mobile', 'm'], ['tablet', 't'], ['desktop', 'd']]
|
||||
.flatMap(([bp, c]) => [r[bp]?.flow ? ` k6n-layout--${c}-${r[bp].flow}` : '', r[bp]?.columns ? ` k6n-layout--${c}c-${r[bp].columns}` : ''])
|
||||
// Mirrors data/k6n/templates/layout.gohtml: tablet flow class only when it differs; spacing as k6n-<t|m>-<prop>-<size>.
|
||||
const norm = (f: string) => ({ vertical: 'stack', horizontal: 'row', grid: 'row' } as Record<string, string>)[f] ?? f
|
||||
const tf = r.tablet?.flow ? norm(r.tablet.flow) : flow
|
||||
const resp = (tf !== flow ? ` k6n-layout--t-${tf}` : '') +
|
||||
[['tablet', 't'], ['mobile', 'm']]
|
||||
.flatMap(([bp, c]) => [['gap', 'gap'], ['padding', 'p'], ['paddingTop', 'pt'], ['paddingBottom', 'pb']].filter(([k]) => r[bp]?.[k]).map(([k, n]) => ` k6n-${c}-${n}-${r[bp][k]}`))
|
||||
.join('')
|
||||
const tone = d.tone && d.tone !== 'none' ? ` k6n-${d.tone}` : ''
|
||||
const cells = g.children(id).map((e) => `<div class="k6n-layout__cell">${render(g, e.to)}</div>`).join('\n')
|
||||
|
||||
+3
-1
@@ -2,10 +2,12 @@
|
||||
// own loader only when the preview cookie is set (docs/onpage-editor.md §2).
|
||||
// It re-checks, so loading it by mistake never shows editor UI to visitors.
|
||||
import { boot } from './boot'
|
||||
import { PREVIEW_FRAME_NAME } from './style/DevicePreview'
|
||||
|
||||
export const previewEnabled = () => /(?:^|;\s*)cms_preview=1/.test(document.cookie)
|
||||
|
||||
if (previewEnabled()) boot()
|
||||
// The device-preview iframe shows the page only; it must not boot a second editor.
|
||||
if (previewEnabled() && window.name !== PREVIEW_FRAME_NAME) boot()
|
||||
|
||||
export { boot }
|
||||
export * from './registry'
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEditorState } from '../ui/hooks'
|
||||
import type { Breakpoint } from './model'
|
||||
|
||||
/** Real viewport widths: media queries only answer to an actual window, hence an iframe. */
|
||||
export const DEVICE_WIDTH: Record<Breakpoint | 'base', number> = { mobile: 390, tablet: 820, desktop: 1280, base: 1280 }
|
||||
/** Marks the preview frame so the editor does not boot inside it (see main.ts). */
|
||||
export const PREVIEW_FRAME_NAME = 'cms-device-preview'
|
||||
|
||||
const SHOWN = 380 // px the frame occupies on screen at most
|
||||
const HEIGHT = 520
|
||||
|
||||
/**
|
||||
* The page at the width of the selected breakpoint, floating bottom-left. It is the same URL, so it
|
||||
* renders the live store (cms_preview cookie) and reloads shortly after every edit. Because the
|
||||
* viewport is real, `@media` rules - including the layout's tablet/mobile column variables - apply.
|
||||
*/
|
||||
export function DevicePreview({ bp, onClose }: { bp?: Breakpoint; onClose(): void }) {
|
||||
const ref = useRef<HTMLIFrameElement>(null)
|
||||
const rev = useEditorState((s) => s.editRev)
|
||||
const first = useRef(true)
|
||||
const width = DEVICE_WIDTH[bp ?? 'base']
|
||||
const scale = Math.min(1, SHOWN / width)
|
||||
|
||||
useEffect(() => {
|
||||
if (first.current) {
|
||||
first.current = false
|
||||
return
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
try {
|
||||
ref.current?.contentWindow?.location.reload()
|
||||
} catch {
|
||||
/* cross-origin: nothing to reload */
|
||||
}
|
||||
}, 450)
|
||||
return () => clearTimeout(t)
|
||||
}, [rev])
|
||||
|
||||
const url = location.pathname + location.search
|
||||
return (
|
||||
<div
|
||||
className="cms-devicepreview"
|
||||
style={{ position: 'fixed', left: 16, bottom: 16, zIndex: 2147483000, background: '#161821', border: '1px solid #2a2d3a', borderRadius: 10, boxShadow: '0 8px 30px #0008', padding: 6, color: '#c3c6d4', font: '12px system-ui' }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 4px 6px' }}>
|
||||
<span>
|
||||
{bp ?? 'desktop'} preview · {width}px
|
||||
</span>
|
||||
<button type="button" aria-label="Close preview" onClick={onClose} style={{ background: 'none', border: 0, color: 'inherit', cursor: 'pointer' }}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ width: width * scale, height: HEIGHT, overflow: 'hidden', borderRadius: 6, background: '#fff' }}>
|
||||
<iframe
|
||||
ref={ref}
|
||||
name={PREVIEW_FRAME_NAME}
|
||||
title="Device preview"
|
||||
src={url}
|
||||
style={{ width, height: HEIGHT / scale, border: 0, transform: `scale(${scale})`, transformOrigin: 'top left' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { pxOf } from './tokens'
|
||||
import type { Scale } from './tokens'
|
||||
import { Icon } from '../ui/Icon'
|
||||
import { describeStyle, fieldForSide } from './model'
|
||||
import { DevicePreview } from './DevicePreview'
|
||||
|
||||
type Data = Record<string, any>
|
||||
|
||||
@@ -35,6 +36,7 @@ export function StylePanel({ node, schema, ctrl }: EditorProps) {
|
||||
const fields = useMemo(() => describeStyle(schema), [schema])
|
||||
const [data, setData] = useState<Data>(node.data ?? {})
|
||||
const [bp, setBp] = useState<Breakpoint | undefined>()
|
||||
const [preview, setPreview] = useState(false)
|
||||
const rev = useEditorState((s) => s.editRev)
|
||||
useEffect(() => {
|
||||
if (rev) void ctrl.node(node.id, true).then((n) => setData(n.data ?? {})).catch(() => {})
|
||||
@@ -64,7 +66,19 @@ export function StylePanel({ node, schema, ctrl }: EditorProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{bp && <p className="cms-muted">Overrides for {bp === 'mobile' ? 'small screens' : bp === 'tablet' ? 'tablets' : 'desktops'}; unset values inherit from “All sizes”.</p>}
|
||||
<div className="cms-style__preview">
|
||||
<button type="button" className={`cms-chip${preview ? ' is-on' : ''}`} aria-pressed={preview} onClick={() => setPreview((p) => !p)}>
|
||||
{preview ? 'Hide' : 'Show'} {bp ?? 'desktop'} preview
|
||||
</button>
|
||||
</div>
|
||||
{preview && <DevicePreview bp={bp} onClose={() => setPreview(false)} />}
|
||||
{bp && (
|
||||
<p className="cms-muted">
|
||||
Overrides for {bp === 'mobile' ? 'small screens (≤620px)' : bp === 'tablet' ? 'tablets (≤900px)' : 'desktops'}; unset values inherit from “All sizes”.
|
||||
{bp === 'tablet' && ' Default: at most 2 columns.'}
|
||||
{bp === 'mobile' && ' Default: one column.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{of('flow', 'columns', 'align', 'justify').length > 0 && (
|
||||
<Section title="Layout">
|
||||
@@ -85,6 +99,14 @@ export function StylePanel({ node, schema, ctrl }: EditorProps) {
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{bp && of('gap', 'spacing').length > 0 && (
|
||||
<Section title="Spacing">
|
||||
{of('gap', 'spacing').map((f) => (
|
||||
<Control key={f.name} f={f} value={get(f)} onChange={(v) => set(f, v)} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{of('tone', 'border').length > 0 && (
|
||||
<Section title="Surface">
|
||||
{of('tone', 'border').map((f) => (
|
||||
|
||||
@@ -84,7 +84,6 @@ describe('Style panel (schema-driven)', () => {
|
||||
await select('l1')
|
||||
await h.press('Style')
|
||||
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('[role=tab]')].find((t) => t.textContent === 'Tablet')!.click())
|
||||
expect(h.shadow.querySelector('[data-style-field="gap"]')).toBeNull() // spacing has no per-breakpoint form
|
||||
await act(async () => void chip('flow', 'Stack').click())
|
||||
await h.settle()
|
||||
expect(data('l1').responsive).toEqual({ tablet: { flow: 'stack' } })
|
||||
@@ -95,6 +94,33 @@ describe('Style panel (schema-driven)', () => {
|
||||
expect('responsive' in data('l1')).toBe(false)
|
||||
})
|
||||
|
||||
it('breakpoints can override gap, which renders as a k6n-<bp>-gap class', async () => {
|
||||
h = await setup()
|
||||
await h.settle()
|
||||
await select('l1')
|
||||
await h.press('Style')
|
||||
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('[role=tab]')].find((t) => t.textContent === 'Mobile')!.click())
|
||||
await act(async () => void chip('gap', 'xs').click())
|
||||
await h.settle()
|
||||
expect(data('l1').responsive).toEqual({ mobile: { gap: 'xs' } })
|
||||
expect(data('l1').gap).not.toBe('xs')
|
||||
expect(byId('l1').className).toContain('k6n-m-gap-xs')
|
||||
})
|
||||
|
||||
it('the device preview is an iframe named so the editor does not boot inside it', async () => {
|
||||
h = await setup()
|
||||
await h.settle()
|
||||
await select('l1')
|
||||
await h.press('Style')
|
||||
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('[role=tab]')].find((t) => t.textContent === 'Mobile')!.click())
|
||||
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('.cms-style__preview .cms-chip')][0].click())
|
||||
const f = h.shadow.querySelector('iframe')!
|
||||
expect(f.name).toBe('cms-device-preview')
|
||||
expect(f.style.width).toBe('390px')
|
||||
await act(async () => void h.shadow.querySelector<HTMLElement>('[aria-label="Close preview"]')!.click())
|
||||
expect(h.shadow.querySelector('iframe')).toBeNull()
|
||||
})
|
||||
|
||||
it('rapid clicks on one field coalesce into a single undo step', async () => {
|
||||
h = await setup()
|
||||
await h.settle()
|
||||
|
||||
+2
-1
@@ -25,7 +25,8 @@ describe('describeStyle', () => {
|
||||
expect(byName('paddingTop')).toMatchObject({ kind: 'spacing', box: 'padding', side: 'top', overrides: 'padding', token: '--k6n-sp-{value}', inferred: false })
|
||||
expect(byName('gap')).toMatchObject({ kind: 'gap', inferred: false })
|
||||
expect(byName('flow').responsive).toEqual(['mobile', 'tablet', 'desktop'])
|
||||
expect(byName('gap').responsive).toEqual([]) // spacing is not a breakpoint override (the template has no classes for it)
|
||||
expect(byName('gap').responsive).toEqual(['mobile', 'tablet', 'desktop'])
|
||||
expect(byName('padding').responsive).toEqual([]) // only declared per-side in the breakpoint def
|
||||
expect(isWrapperSchema(layout)).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user