diff --git a/docs/styling.md b/docs/styling.md index 6cbbd87..403d61c 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -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). diff --git a/mock/fixtures.ts b/mock/fixtures.ts index dce2b27..a1e5dd6 100644 --- a/mock/fixtures.ts +++ b/mock/fixtures.ts @@ -53,6 +53,9 @@ export const schemas: Record = { 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,9 +196,13 @@ 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}` : '']) - .join('') + // Mirrors data/k6n/templates/layout.gohtml: tablet flow class only when it differs; spacing as k6n---. + const norm = (f: string) => ({ vertical: 'stack', horizontal: 'row', grid: 'row' } as Record)[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) => `
${render(g, e.to)}
`).join('\n') return `
\n${cells}\n
` diff --git a/src/main.ts b/src/main.ts index 76b010f..af0922a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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' diff --git a/src/style/DevicePreview.tsx b/src/style/DevicePreview.tsx new file mode 100644 index 0000000..071329c --- /dev/null +++ b/src/style/DevicePreview.tsx @@ -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 = { 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(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 ( +
+
+ + {bp ?? 'desktop'} preview · {width}px + + +
+
+