feat(site-builder): per-breakpoint style model, device context and responsive export
- utils/breakpoints.ts: single source for 767/768/991/992; head VISIBILITY/RESPONSIVE CSS built from it (flex-stacking rule moved from 768 to the 767 mobile boundary). - utils/responsive-style.ts: styleTablet/styleMobile partial overrides, cascade resolution, pure setters, scoped @media CSS per node. - state/DeviceContext.tsx: real DeviceProvider (owns the one-time mobile forcing from EditorShell); TopBar, overflow menu, ReportIssueModal and Canvas read useDevice() instead of props. - editor/RenderNode.tsx: resolves the effective style for the current device in one place and marks overridden nodes with data-wsb-overridden. Craft passes an opaque <DefaultRender/> to onRender, so the component element is rebuilt with the resolved style (cloneElement cannot work). - utils/html-export.ts: hidden nodes skipped; overrides emitted as one body-level <style data-wsb-responsive> block + data-wsb-r scope attr; injectAttrs skips leading <style> elements. - test-utils/editorHarness.tsx: optional onRender/wrap options. Refs #45 Refs #37 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2180f9a776
commit
5f99df23a7
24 files changed
+1006
-92
No files matched your search
@@ -1,5 +1,13 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { exportBodyHtml, exportToHtml, buildAnimationScript, ExportOptions } from './html-export';
|
||||
import {
|
||||
exportBodyHtml,
|
||||
exportToHtml,
|
||||
buildAnimationScript,
|
||||
ExportOptions,
|
||||
injectAttrs,
|
||||
RESPONSIVE_CSS_MINIFIED,
|
||||
VISIBILITY_CSS_MINIFIED,
|
||||
} from './html-export';
|
||||
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
|
||||
|
||||
/**
|
||||
@@ -562,9 +570,17 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
|
||||
displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {},
|
||||
},
|
||||
});
|
||||
// W0-breakpoints deliberately moved the flex-stacking rule from 768 to the
|
||||
// 767 mobile boundary. Undo exactly that one change before hashing so these
|
||||
// baselines still prove nothing ELSE in the head moved.
|
||||
const undo767 = (html: string) => {
|
||||
const from = '@media(max-width:767px){[style*=';
|
||||
expect(html.split(from).length).toBe(2);
|
||||
return html.replace(from, '@media(max-width:768px){[style*=');
|
||||
};
|
||||
|
||||
test('default design document hash matches the pre-move baseline', async () => {
|
||||
const html = exportToHtml(emptyRoot, { title: 'T' }).html;
|
||||
const html = undo767(exportToHtml(emptyRoot, { title: 'T' }).html);
|
||||
expect(html.length).toBe(2164);
|
||||
expect(await sha(html)).toBe('f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6');
|
||||
});
|
||||
@@ -576,7 +592,7 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
|
||||
headingFont: '"Playfair Display", serif',
|
||||
accentColor: 'red}</style><script>',
|
||||
};
|
||||
const html = exportToHtml(emptyRoot, { title: 'T', design }).html;
|
||||
const html = undo767(exportToHtml(emptyRoot, { title: 'T', design }).html);
|
||||
expect(html.length).toBe(2452);
|
||||
expect(await sha(html)).toBe('c561538b7ca64c8bac8b2cd02ff65053a866f7891ceeed97c956ea79153d1338');
|
||||
});
|
||||
@@ -588,3 +604,120 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-breakpoint overrides + hidden nodes (W0-breakpoints)', () => {
|
||||
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
|
||||
type: { resolvedName: type },
|
||||
isCanvas: type === 'Container',
|
||||
props,
|
||||
displayName: type,
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes,
|
||||
linkedNodes: {},
|
||||
...extra,
|
||||
});
|
||||
|
||||
const makeState = (headingProps: Record<string, unknown>, headingExtra: Record<string, unknown> = {}) =>
|
||||
JSON.stringify({
|
||||
ROOT: node('Container', { tag: 'div', style: {} }, ['sec1']),
|
||||
sec1: node('Container', { tag: 'section', style: { padding: '20px' } }, ['head1'], { parent: 'ROOT' }),
|
||||
head1: node('Heading', { text: 'Hello', level: 'h2', style: { fontSize: '40px' }, ...headingProps }, [], {
|
||||
parent: 'sec1',
|
||||
...headingExtra,
|
||||
}),
|
||||
});
|
||||
|
||||
test('a mobile override exports one scoped body <style> block + data-wsb-r on the <h2>', () => {
|
||||
const { html, css } = exportBodyHtml(makeState({ styleMobile: { fontSize: '24px' } }));
|
||||
expect(html.startsWith(
|
||||
'<style data-wsb-responsive>@media (max-width:767px){[data-wsb-r="head1"]{font-size:24px!important}}</style>',
|
||||
)).toBe(true);
|
||||
expect(html).toContain('<h2 style="font-size:40px" data-wsb-r="head1">Hello</h2>');
|
||||
expect(css).toBe('');
|
||||
});
|
||||
|
||||
test('the full document carries the block in the BODY, not the head', () => {
|
||||
const { html } = exportToHtml(makeState({ styleTablet: { textAlign: 'center' } }));
|
||||
const head = html.slice(0, html.indexOf('</head>'));
|
||||
const body = html.slice(html.indexOf('<body>'));
|
||||
expect(head).not.toContain('data-wsb');
|
||||
expect(body).toContain(
|
||||
'<style data-wsb-responsive>@media (max-width:991px){[data-wsb-r="head1"]{text-align:center!important}}</style>',
|
||||
);
|
||||
});
|
||||
|
||||
test('a state without overrides exports byte-identically to the pre-change exporter', () => {
|
||||
// Captured from the exporter BEFORE this task.
|
||||
const { html } = exportBodyHtml(makeState({}));
|
||||
expect(html).toBe('<div><section style="padding:20px"><h2 style="font-size:40px">Hello</h2></section></div>');
|
||||
expect(exportBodyHtml(makeState({ styleTablet: {}, styleMobile: { fontSize: '' } })).html).toBe(html);
|
||||
});
|
||||
|
||||
test('a node with hidden:true is omitted together with its subtree', () => {
|
||||
const state = JSON.parse(makeState({ styleMobile: { fontSize: '24px' } }));
|
||||
state.sec1.hidden = true;
|
||||
const { html } = exportBodyHtml(JSON.stringify(state));
|
||||
expect(html).toBe('<div></div>');
|
||||
// ...and its overrides do not leak a style block either.
|
||||
expect(html).not.toContain('data-wsb');
|
||||
});
|
||||
|
||||
test('a hidden leaf is omitted, siblings survive', () => {
|
||||
const { html } = exportBodyHtml(makeState({}, { hidden: true }));
|
||||
expect(html).toBe('<div><section style="padding:20px"></section></div>');
|
||||
});
|
||||
|
||||
test('RESPONSIVE_CSS_MINIFIED stacks flex rows at the 767 mobile boundary', () => {
|
||||
expect(RESPONSIVE_CSS_MINIFIED).toContain('@media(max-width:767px){');
|
||||
expect(RESPONSIVE_CSS_MINIFIED).not.toContain('768');
|
||||
expect(VISIBILITY_CSS_MINIFIED).toBe(
|
||||
'@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}',
|
||||
);
|
||||
const pretty = exportToHtml(makeState({}), { minifyCss: false }).html;
|
||||
expect(pretty).toContain('@media (max-width: 767px) {\n [style*="display: flex"]');
|
||||
expect(pretty).not.toContain('max-width: 768px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectAttrs skips leading <style> blocks (W0-breakpoints)', () => {
|
||||
test('attrs land on the element after a leading <style>', () => {
|
||||
expect(injectAttrs('<style>.b:hover{color:red}</style><a href="#">x</a>', ' data-x')).toBe(
|
||||
'<style>.b:hover{color:red}</style><a href="#" data-x>x</a>',
|
||||
);
|
||||
});
|
||||
|
||||
test('multiple leading style blocks are skipped', () => {
|
||||
expect(injectAttrs('<style>a{}</style><style media="x">b{}</style><div>y</div>', ' data-x')).toBe(
|
||||
'<style>a{}</style><style media="x">b{}</style><div data-x>y</div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('void tag after a style block stays well-formed', () => {
|
||||
expect(injectAttrs('<style>i{}</style><img src="a" />', ' data-x')).toBe('<style>i{}</style><img src="a" data-x/>');
|
||||
});
|
||||
|
||||
test('plain html unchanged in behaviour', () => {
|
||||
expect(injectAttrs('<div>x</div>', ' data-x')).toBe('<div data-x>x</div>');
|
||||
expect(injectAttrs('<img src="a" />', ' data-x')).toBe('<img src="a" data-x/>');
|
||||
expect(injectAttrs('<div>x</div>', '')).toBe('<div>x</div>');
|
||||
});
|
||||
|
||||
test('ButtonLink with hover + override: data-wsb-r lands on the <a>, not the <style>', () => {
|
||||
const state = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {} },
|
||||
displayName: 'Container', custom: {}, hidden: false, nodes: ['btn1'], linkedNodes: {},
|
||||
},
|
||||
btn1: {
|
||||
type: { resolvedName: 'ButtonLink' }, isCanvas: false, parent: 'ROOT',
|
||||
props: { text: 'Go', href: '#', hoverBg: '#000', style: {}, styleMobile: { width: '100%' } },
|
||||
displayName: 'ButtonLink', custom: {}, hidden: false, nodes: [], linkedNodes: {},
|
||||
},
|
||||
});
|
||||
const { html } = exportBodyHtml(state);
|
||||
expect(html).toMatch(/<a href="#"[^>]* data-wsb-r="btn1">Go<\/a>/);
|
||||
expect(html).not.toContain('<style data-wsb-r=');
|
||||
expect(html).toContain('</style><a href="#"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user