fix(site-builder): remaining style panels show per-device values and dots

SectionType, Social, Pricing, Form and the generic props editor already
wrote tablet/mobile overrides through the device-aware useNodeProp setters
but still read nodeProps.style, so a mobile padding/margin/alignment edit
looked like a no-op and had no reset dot. They now read useResolvedStyle()
and show OverrideDots on Padding, Margin and Text Alignment.

NavStylePanel has the same gap but is outside this task's files.

Refs #45

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 11:57:17 -07:00
1 parent c2a90cd7a7
commit c472560fb5
6 files changed
+104 -19

No files matched your search

@@ -28,6 +28,8 @@ import {
useNodeProp,
continuousSetProp,
} from './shared';
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
/* The full sanitizeInputType (utils/escape.ts) allowlist, plus the two
fake "types" (textarea/select) that take their own ContactForm render
@@ -175,7 +177,10 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
const { actions } = useEditor();
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
const style = nodeProps.style || {};
// Effective value for the current device (#45): the setters write
// tablet/mobile overrides, so the fields must read them back.
const style = useResolvedStyle(nodeProps) as Record<string, any>;
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
// ContactForm only: FormContainer/SubscribeForm have no destinationType prop,
// so their relay controls stay exactly as they were.
const isWebhook = nodeProps.destinationType === 'webhook';
@@ -484,7 +489,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<SectionLabel>Padding{dot(PADDING_KEYS)}</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
</div>
<div className="guided-section">
@@ -498,12 +503,12 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
they all spread `style` onto their root element. */}
<CollapsibleSection title="Spacing, Border & Effects">
<SpacingControl
label="Margin"
label={<>Margin{dot(MARGIN_KEYS)}</>}
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={(side, v) => setPropStyleContinuous(`margin${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
/>
<SpacingControl
label="Padding (per side)"
label={<>Padding (per side){dot(PADDING_KEYS)}</>}
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={(side, v) => setPropStyleContinuous(`padding${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
/>
@@ -16,6 +16,8 @@ import {
sectionGap,
useNodeProp,
} from './shared';
import { useResolvedStyle, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
import { ArrayItemFieldsEditor } from './ArrayItemFields';
import { HtmlCodeField } from './HtmlCodeField';
@@ -35,7 +37,10 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
const stringProps = allProps.filter(([key, val]) => typeof val === 'string' && !/color/i.test(key));
const arrayProps = allProps.filter(([_, val]) => Array.isArray(val));
const style = nodeProps.style || {};
// Effective value for the current device (#45): the setters write
// tablet/mobile overrides, so the fields must read them back.
const style = useResolvedStyle(nodeProps) as Record<string, any>;
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
const hasCodeProp = typeof nodeProps.code === 'string';
return (
@@ -117,11 +122,11 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
<ColorSwatchGrid colors={TEXT_COLORS} activeValue={style.color as string} onSelect={(v: string) => setStyleValue('color', v)} />
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<SectionLabel>Padding{dot(PADDING_KEYS)}</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setStyleValue('padding', v)} />
</div>
<div className="guided-section">
<SectionLabel>Text Alignment</SectionLabel>
<SectionLabel>Text Alignment{dot('textAlign')}</SectionLabel>
<div className="preset-grid align-grid">
{(['left', 'center', 'right'] as const).map((a) => (
<button key={a} className={`preset-btn ${style.textAlign === a ? 'active' : ''}`} onClick={() => setStyleValue('textAlign', a)} title={a}>
@@ -21,6 +21,8 @@ import {
VisibilityControl,
continuousSetProp,
} from './shared';
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
/** Parses a border shorthand string ("2px solid #hex") produced by
* buildBorderShorthand() back into its parts for round-tripping through
@@ -54,7 +56,10 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
const plans: any[] = Array.isArray(nodeProps.plans) ? nodeProps.plans : [];
const currentBullet = nodeProps.bulletType || 'check';
const style = nodeProps.style || {};
// Effective value for the current device (#45): the setters write
// tablet/mobile overrides, so the fields must read them back.
const style = useResolvedStyle(nodeProps) as Record<string, any>;
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
const updatePlan = useCallback((planIndex: number, field: string, value: any) => {
continuousSetProp(actions, selectedId, `plan:${planIndex}:${field}`, (props: any) => {
@@ -244,7 +249,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
{/* Box model: margin, padding, border, shadow, opacity */}
<CollapsibleSection title="Spacing & Border">
<SpacingControl
label="Margin"
label={<>Margin{dot(MARGIN_KEYS)}</>}
value={{
top: style.marginTop as string, right: style.marginRight as string,
bottom: style.marginBottom as string, left: style.marginLeft as string,
@@ -252,7 +257,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
onChange={(side, v) => setPropStyleContinuous(`margin${capSide(side)}`, v)}
/>
<SpacingControl
label="Padding"
label={<>Padding{dot(PADDING_KEYS)}</>}
value={{
top: style.paddingTop as string, right: style.paddingRight as string,
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
@@ -23,6 +23,8 @@ import {
AnimationControl,
VisibilityControl,
} from './shared';
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
import { FeaturesEditor } from './FeaturesEditor';
import { ArrayItemFieldsEditor } from './ArrayItemFields';
@@ -45,7 +47,10 @@ const capSide = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
const style = nodeProps.style || {};
// Effective value for the current device (#45): the setters write
// tablet/mobile overrides, so the fields must read them back.
const style = useResolvedStyle(nodeProps) as Record<string, any>;
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
// Find all string/number/boolean props
const SKIP_PROPS = new Set([
@@ -135,11 +140,11 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<SectionLabel>Padding{dot(PADDING_KEYS)}</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
</div>
<div className="guided-section">
<SectionLabel>Text Alignment</SectionLabel>
<SectionLabel>Text Alignment{dot('textAlign')}</SectionLabel>
<div className="preset-grid align-grid">
{(['left', 'center', 'right'] as const).map((a) => (
<button key={a} className={`preset-btn ${style.textAlign === a ? 'active' : ''}`} onClick={() => setPropStyle('textAlign', a)} title={a}>
@@ -157,7 +162,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
{/* Box model: margin, padding, border, shadow, opacity */}
<CollapsibleSection title="Spacing & Border">
<SpacingControl
label="Margin"
label={<>Margin{dot(MARGIN_KEYS)}</>}
value={{
top: style.marginTop as string, right: style.marginRight as string,
bottom: style.marginBottom as string, left: style.marginLeft as string,
@@ -165,7 +170,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
onChange={(side, v) => setPropStyleContinuous(`margin${capSide(side)}`, v)}
/>
<SpacingControl
label="Padding"
label={<>Padding{dot(PADDING_KEYS)}</>}
value={{
top: style.paddingTop as string, right: style.paddingRight as string,
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
@@ -26,6 +26,8 @@ import {
VisibilityControl,
continuousSetProp,
} from './shared';
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
/** Parses a border shorthand string ("2px solid #hex") produced by
* buildBorderShorthand() back into its parts for round-tripping through
@@ -51,7 +53,10 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
const { actions } = useEditor();
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
const style = nodeProps.style || {};
// Effective value for the current device (#45): the setters write
// tablet/mobile overrides, so the fields must read them back.
const style = useResolvedStyle(nodeProps) as Record<string, any>;
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
return (
<>
@@ -253,7 +258,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<SectionLabel>Padding{dot(PADDING_KEYS)}</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
</div>
</CollapsibleSection>
@@ -261,7 +266,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
{/* Box model: margin, padding (per-side), border, shadow, opacity */}
<CollapsibleSection title="Spacing & Border">
<SpacingControl
label="Margin"
label={<>Margin{dot(MARGIN_KEYS)}</>}
value={{
top: style.marginTop as string, right: style.marginRight as string,
bottom: style.marginBottom as string, left: style.marginLeft as string,
@@ -269,7 +274,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
onChange={(side, v) => setPropStyleContinuous(`margin${capSide(side)}`, v)}
/>
<SpacingControl
label="Padding"
label={<>Padding{dot(PADDING_KEYS)}</>}
value={{
top: style.paddingTop as string, right: style.paddingRight as string,
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
@@ -9,6 +9,11 @@ import type { DeviceMode } from '../../../types';
import { GuidedStyles } from '../GuidedStyles';
import { TextStylePanel } from './TextStylePanel';
import { ContainerStylePanel } from './ContainerStylePanel';
import { SocialStylePanel } from './SocialStylePanel';
import { SectionTypePanel } from './SectionTypePanel';
import { PricingStylePanel } from './PricingStylePanel';
import { FormStylePanel } from './FormStylePanel';
import { GenericPropsEditor } from './GenericPropsEditor';
import {
useNodeProp,
closeContinuousBurst,
@@ -315,6 +320,61 @@ describe('DeviceBanner in GuidedStyles', () => {
});
});
describe('every useNodeProp panel reads back what it writes per device (fix round 1)', () => {
// Panels that edit responsive keys through the device-aware setters must
// show the RESOLVED value and a dot, or a mobile edit looks like a no-op.
const blockState = () =>
JSON.stringify({
ROOT: node('Container', { tag: 'div', style: {} }, ['blk']),
blk: node('Container', { tag: 'div', style: { padding: '8px', marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0', textAlign: 'left' } }, [], { parent: 'ROOT' }),
});
const withType = (P: React.FC<any>, typeName: string): React.FC<any> => (p) => <P {...p} typeName={typeName} />;
const panels: [string, React.FC<any>, { preset: boolean; spacing: boolean; align?: string }][] = [
['SocialStylePanel', SocialStylePanel, { preset: true, spacing: true }],
['SectionTypePanel', withType(SectionTypePanel, 'Accordion'), { preset: true, spacing: true, align: 'Text Alignment' }],
['PricingStylePanel', PricingStylePanel, { preset: false, spacing: true }],
['FormStylePanel', FormStylePanel, { preset: true, spacing: true }],
['GenericPropsEditor', withType(GenericPropsEditor, 'Thing'), { preset: true, spacing: false, align: 'Text Alignment' }],
];
const marginControl = () =>
Array.from(h!.container.querySelectorAll('[data-testid="spacing-control"]'))
.find((el) => (el.querySelector('.guided-section-label')?.textContent || '').startsWith('Margin')) as HTMLElement;
test.each(panels)('%s: mobile edits land in styleMobile and the panel shows them with a dot', (_n, Panel, has) => {
mountPanel('mobile', Panel, 'blk', blockState());
if (has.preset) {
expect(activePreset('Padding')).toBe('S');
expect(hollowDot('Padding')).not.toBeNull();
click(presetBtn('Padding', 'L'));
expect(props('blk').styleMobile?.padding).toBe('24px');
expect(props('blk').style.padding).toBe('8px');
expect(activePreset('Padding')).toBe('L');
expect(filledDot('Padding')).not.toBeNull();
}
if (has.spacing) {
const m = marginControl();
expect(m.querySelector('button.preset-btn.active')?.textContent).toBe('None');
click(Array.from(m.querySelectorAll('button.preset-btn')).find((b) => b.textContent === 'M')!);
expect(props('blk').styleMobile?.marginTop).toBe('16px');
expect(props('blk').style.marginTop).toBe('0');
expect(marginControl().querySelector('button.preset-btn.active')?.textContent).toBe('M');
const dot = marginControl().querySelector('[data-testid="override-dot"]') as HTMLElement;
expect(dot).not.toBeNull();
click(dot);
expect(props('blk').styleMobile?.marginTop).toBeUndefined();
expect(marginControl().querySelector('button.preset-btn.active')?.textContent).toBe('None');
}
if (has.align) {
click(section(has.align).querySelector('button[title="center"]')!);
expect(props('blk').styleMobile?.textAlign).toBe('center');
expect(props('blk').style.textAlign).toBe('left');
expect(section(has.align).querySelector('button.preset-btn.active')?.getAttribute('title')).toBe('center');
expect(filledDot(has.align)).not.toBeNull();
}
});
});
describe('end to end: export', () => {
test('a mobile font-size edit exports as a scoped max-width:767px rule', () => {
mountPanel('mobile');