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:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 11:28:44 -07:00
1 parent 2180f9a776
commit 5f99df23a7
24 files changed
+1006 -92

No files matched your search

+136 -3
View File
@@ -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="#"');
});
});