fix(styles): NavStylePanel reads the per-device resolved style

Switches NavStylePanel from nodeProps.style to useResolvedStyle(nodeProps)
and adds OverrideDots on Margin and Padding, the conversion #45 applied
to the other panels. NavStylePanel has no style-based text-alignment
control (Menu alignment is a top-level prop), so no dot there.

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 12:46:16 -07:00
1 parent 9697dc95a3
commit d5bfb02911
2 files changed
+78 -3

No files matched your search

@@ -0,0 +1,70 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { useEditor } from '@craftjs/core';
import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness';
import { DeviceProvider } from '../../../state/DeviceContext';
import type { DeviceMode } from '../../../types';
import { NavStylePanel } from './NavStylePanel';
import { closeContinuousBurst } from './shared';
/**
* Integration follow-up (#45 review): NavStylePanel reads the per-device
* resolved style and shows override dots on Margin / Padding, the same
* conversion #45 applied to the other panels.
*/
let h: EditorHarness | null = null;
afterEach(() => { closeContinuousBurst(); h?.unmount(); h = null; });
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 state = (extra: Record<string, unknown> = {}) => JSON.stringify({
ROOT: node('Container', { tag: 'div', style: {} }, ['logo']),
logo: node('Logo', {
type: 'text', text: 'Acme',
style: { marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0', paddingTop: '8px', paddingRight: '8px', paddingBottom: '8px', paddingLeft: '8px' },
...extra,
}, [], { parent: 'ROOT' }),
});
const Host: React.FC = () => {
const { nodeProps } = useEditor((s) => ({ nodeProps: s.nodes.logo?.data.props || {} }));
return <NavStylePanel selectedId="logo" nodeProps={nodeProps} />;
};
function mount(device: DeviceMode, initialState = state()) {
h = renderEditorHarness({ initialState, wrap: (t) => <DeviceProvider initialDevice={device}>{t}</DeviceProvider> });
h.mountChild(<Host />);
}
const props = () => h!.query.node('logo').get().data.props;
const control = (label: string) =>
Array.from(h!.container.querySelectorAll('[data-testid="spacing-control"]'))
.find((el) => (el.querySelector('.guided-section-label')?.textContent || '').startsWith(label)) as HTMLElement;
const active = (label: string) => control(label).querySelector('button.preset-btn.active')?.textContent ?? null;
const click = (el: Element) => h!.act(() => { (el as HTMLElement).click(); });
describe('NavStylePanel per-device spacing (#45 follow-up)', () => {
test('mobile reads the mobile override, not the desktop style', () => {
mount('mobile', state({ styleMobile: { marginTop: '16px', marginRight: '16px', marginBottom: '16px', marginLeft: '16px' } }));
expect(active('Margin')).toBe('M');
expect(control('Margin').querySelector('[data-testid="override-dot"]')).not.toBeNull();
expect(control('Padding').querySelector('[data-testid="override-dot-inherited"]')).not.toBeNull();
});
test('a mobile Margin edit lands in styleMobile, reads back, and its dot resets it', () => {
mount('mobile');
expect(active('Margin')).toBe('None');
click(Array.from(control('Margin').querySelectorAll('button.preset-btn')).find((b) => b.textContent === 'M')!);
expect(props().styleMobile?.marginTop).toBe('16px');
expect(props().style.marginTop).toBe('0');
expect(active('Margin')).toBe('M');
click(control('Margin').querySelector('[data-testid="override-dot"]')!);
expect(props().styleMobile?.marginTop).toBeUndefined();
expect(active('Margin')).toBe('None');
});
test('desktop shows no dots', () => {
mount('desktop', state({ styleMobile: { marginTop: '16px' } }));
expect(h!.container.querySelector('.wsb-override-dot')).toBeNull();
});
});
@@ -26,6 +26,8 @@ import {
continuousSetProp,
discreteSetProp,
} from './shared';
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
import { AssetPicker } from '../../../ui/AssetPicker';
import { usePages } from '../../../state/PageContext';
import { PageData } from '../../../types';
@@ -329,7 +331,10 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
component's .craft.props), so -- unlike the Links/Colors sections above,
which are shared across a genuinely disparate prop schema -- these
sections render unconditionally rather than gating on presence. */
const style = nodeProps.style || {};
// #45: the per-device resolved style (desktop ⊕ tablet ⊕ mobile), so a
// tablet/mobile edit reads back what it wrote, plus override dots.
const style = useResolvedStyle(nodeProps) as Record<string, any>;
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
return (
<>
@@ -544,12 +549,12 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
previous behavior exactly). */}
<CollapsibleSection title="Spacing">
<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${capitalize(side)}`, v)}
/>
<SpacingControl
label="Padding"
label={<>Padding{dot(PADDING_KEYS)}</>}
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={(side, v) => setPropStyleContinuous(`padding${capitalize(side)}`, v)}
presets={SPACING_PRESETS}