diff --git a/craft/src/components/basic/ButtonLink.toHtml.test.ts b/craft/src/components/basic/ButtonLink.toHtml.test.ts index 82fe9fe..cc90303 100644 --- a/craft/src/components/basic/ButtonLink.toHtml.test.ts +++ b/craft/src/components/basic/ButtonLink.toHtml.test.ts @@ -52,3 +52,69 @@ describe('ButtonLink.toHtml text escaping (attacker-controlled `text` prop)', () expect(html).toContain('>Click Me'); }); }); + +describe('ButtonLink.toHtml hover state (scoped '; + const { html } = toHtml({ href: '#', text: 'x', hoverBg: malicious }, '', 'node-1'); + expect(html).not.toContain(''); + }); + + test('a rule-breakout attempt in hoverColor cannot inject a second selector/rule', () => { + const malicious = 'red;}body{background:red'; + const { html } = toHtml({ href: '#', text: 'x', hoverColor: malicious }, '', 'node-1'); + expect(html).not.toContain('}body{'); + expect(html).not.toContain(';}'); + // The whole export is still exactly one `; + } + return { - html: `${escapedText}`, + html: `${hoverCss}${escapedText}`, }; }; diff --git a/craft/src/components/basic/Heading.toHtml.test.ts b/craft/src/components/basic/Heading.toHtml.test.ts index 112481f..69dc9ab 100644 --- a/craft/src/components/basic/Heading.toHtml.test.ts +++ b/craft/src/components/basic/Heading.toHtml.test.ts @@ -56,3 +56,54 @@ describe('Heading.toHtml text escaping (attacker-controlled `text` prop)', () => expect(html).toBe('

Hello world

'); }); }); + +describe('Heading.toHtml typography depth (line-height/letter-spacing/transform/style/decoration)', () => { + test('line-height, letter-spacing, text-transform all flow into the style attribute', () => { + const { html } = toHtml({ + text: 'x', + level: 'h2', + style: { lineHeight: '1.25', letterSpacing: '0.05em', textTransform: 'uppercase' }, + }, ''); + expect(html).toContain('line-height:1.25'); + expect(html).toContain('letter-spacing:0.05em'); + expect(html).toContain('text-transform:uppercase'); + }); + + test('italic + underline toggles emit font-style and text-decoration', () => { + const { html } = toHtml({ + text: 'x', + level: 'h2', + style: { fontStyle: 'italic', textDecoration: 'underline' }, + }, ''); + expect(html).toContain('font-style:italic'); + expect(html).toContain('text-decoration:underline'); + }); + + test('a custom font-size (not one of the presets) still flows through', () => { + const { html } = toHtml({ text: 'x', level: 'h2', style: { fontSize: '42px' } }, ''); + expect(html).toContain('font-size:42px'); + }); +}); + +describe('Heading.craft.props exposes the box-model + animation/visibility rollout', () => { + test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => { + const props = (Heading as any).craft.props; + expect(props.animation).toBe(''); + expect(props.animationDelay).toBe('0'); + expect(props.hideOnDesktop).toBe(false); + expect(props.hideOnTablet).toBe(false); + expect(props.hideOnMobile).toBe(false); + }); + + test('style carries blank/default box-model + typography-depth keys', () => { + const style = (Heading as any).craft.props.style; + expect(style).toHaveProperty('marginTop'); + expect(style).toHaveProperty('paddingTop'); + expect(style).toHaveProperty('lineHeight'); + expect(style).toHaveProperty('letterSpacing'); + expect(style).toHaveProperty('textTransform'); + expect(style.border).toBe('none'); + expect(style.boxShadow).toBe('none'); + expect(style.opacity).toBe('1'); + }); +}); diff --git a/craft/src/components/basic/Heading.tsx b/craft/src/components/basic/Heading.tsx index 4f0accd..e1abcf9 100644 --- a/craft/src/components/basic/Heading.tsx +++ b/craft/src/components/basic/Heading.tsx @@ -100,7 +100,22 @@ Heading.craft = { fontFamily: 'Inter, sans-serif', color: '#1f2937', marginBottom: '16px', + lineHeight: '', + letterSpacing: '', + textTransform: '' as CSSProperties['textTransform'], + fontStyle: '' as CSSProperties['fontStyle'], + textDecoration: '', + marginTop: '', marginRight: '', marginLeft: '', + paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '', + border: 'none', + boxShadow: 'none', + opacity: '1', }, + animation: '', + animationDelay: '0', + hideOnDesktop: false, + hideOnTablet: false, + hideOnMobile: false, }, rules: { canDrag: () => true, diff --git a/craft/src/components/basic/TextBlock.toHtml.test.ts b/craft/src/components/basic/TextBlock.toHtml.test.ts index 433ff72..483a653 100644 --- a/craft/src/components/basic/TextBlock.toHtml.test.ts +++ b/craft/src/components/basic/TextBlock.toHtml.test.ts @@ -20,3 +20,48 @@ describe('TextBlock.toHtml text escaping (attacker-controlled `text` prop)', () expect(html).toBe('

Hello world

'); }); }); + +describe('TextBlock.toHtml typography depth (line-height/letter-spacing/transform/style/decoration)', () => { + test('line-height, letter-spacing, text-transform all flow into the style attribute', () => { + const { html } = toHtml({ + text: 'x', + style: { lineHeight: '1.75', letterSpacing: '-0.02em', textTransform: 'capitalize' }, + }, ''); + expect(html).toContain('line-height:1.75'); + expect(html).toContain('letter-spacing:-0.02em'); + expect(html).toContain('text-transform:capitalize'); + }); + + test('italic + underline toggles emit font-style and text-decoration', () => { + const { html } = toHtml({ text: 'x', style: { fontStyle: 'italic', textDecoration: 'underline' } }, ''); + expect(html).toContain('font-style:italic'); + expect(html).toContain('text-decoration:underline'); + }); + + test('a custom font-size (not one of the presets) still flows through', () => { + const { html } = toHtml({ text: 'x', style: { fontSize: '19px' } }, ''); + expect(html).toContain('font-size:19px'); + }); +}); + +describe('TextBlock.craft.props exposes the box-model + animation/visibility rollout', () => { + test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => { + const props = (TextBlock as any).craft.props; + expect(props.animation).toBe(''); + expect(props.animationDelay).toBe('0'); + expect(props.hideOnDesktop).toBe(false); + expect(props.hideOnTablet).toBe(false); + expect(props.hideOnMobile).toBe(false); + }); + + test('style carries blank/default box-model + typography-depth keys', () => { + const style = (TextBlock as any).craft.props.style; + expect(style).toHaveProperty('marginTop'); + expect(style).toHaveProperty('paddingTop'); + expect(style).toHaveProperty('letterSpacing'); + expect(style).toHaveProperty('textTransform'); + expect(style.border).toBe('none'); + expect(style.boxShadow).toBe('none'); + expect(style.opacity).toBe('1'); + }); +}); diff --git a/craft/src/components/basic/TextBlock.tsx b/craft/src/components/basic/TextBlock.tsx index f6c6d26..3a855d3 100644 --- a/craft/src/components/basic/TextBlock.tsx +++ b/craft/src/components/basic/TextBlock.tsx @@ -83,7 +83,21 @@ TextBlock.craft = { fontSize: '16px', lineHeight: '1.6', color: '#3f3f46', + letterSpacing: '', + textTransform: '' as CSSProperties['textTransform'], + fontStyle: '' as CSSProperties['fontStyle'], + textDecoration: '', + marginTop: '', marginRight: '', marginBottom: '', marginLeft: '', + paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '', + border: 'none', + boxShadow: 'none', + opacity: '1', }, + animation: '', + animationDelay: '0', + hideOnDesktop: false, + hideOnTablet: false, + hideOnMobile: false, }, rules: { canDrag: () => true, diff --git a/craft/src/panels/right/styles/ButtonStylePanel.tsx b/craft/src/panels/right/styles/ButtonStylePanel.tsx index e2970b6..4724b9f 100644 --- a/craft/src/panels/right/styles/ButtonStylePanel.tsx +++ b/craft/src/panels/right/styles/ButtonStylePanel.tsx @@ -4,6 +4,7 @@ import { BG_COLORS, RADIUS_PRESETS, SPACING_PRESETS, + SHADOW_PRESETS, } from '../../../constants/presets'; import { StylePanelProps, @@ -11,16 +12,49 @@ import { ColorSwatchGrid, PresetButtonGrid, TextInputField, + ColorPickerField, + CollapsibleSection, + SpacingControl, + SpacingSide, + BorderControl, + BorderValue, + buildBorderShorthand, + AnimationControl, + VisibilityControl, + sectionGap, + labelStyle, autoTextColor, useNodeProp, } from './shared'; +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into + * the {width,style,color} shape BorderControl edits. Only needs to + * round-trip values this same panel produced via buildBorderShorthand. */ +function parseBorderShorthand(v: string | undefined): BorderValue { + if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' }; + const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/); + if (!m) return { width: '', style: 'none', color: '#000000' }; + return { width: m[1], style: m[2], color: m[3] }; +} + +/** style.opacity is a CSS-length-free numeric string ("0.8") or blank + * (treated as fully opaque). Converts to a 0-100 integer for the UI. */ +function opacityPercent(v: unknown): number { + if (v === undefined || v === null || v === '') return 100; + const n = parseFloat(String(v)); + return Number.isFinite(n) ? Math.round(n * 100) : 100; +} + /* ---------- BUTTON ---------- */ export const ButtonStylePanel: React.FC = ({ selectedId, nodeProps }) => { const { actions } = useEditor(); const style: CSSProperties = nodeProps.style || {}; - const { setPropStyle } = useNodeProp(selectedId); + const { setProp, setPropStyle } = useNodeProp(selectedId); const setButtonColor = useCallback( (bgColor: string) => { @@ -61,6 +95,16 @@ export const ButtonStylePanel: React.FC = ({ selectedId, nodePr actions.setProp(selectedId, (props: any) => { props.href = v; }); }} /> +
+ +
Border Radius = ({ selectedId, nodePr onSelect={(v) => setPropStyle('padding', v)} />
+ + {/* Hover state -- rendered into a scoped