diff --git a/craft/src/components/basic/Footer.toHtml.test.ts b/craft/src/components/basic/Footer.toHtml.test.ts index c25d19a..c663e66 100644 --- a/craft/src/components/basic/Footer.toHtml.test.ts +++ b/craft/src/components/basic/Footer.toHtml.test.ts @@ -20,3 +20,14 @@ describe('Footer.toHtml text escaping (attacker-controlled `text` prop)', () => expect(html).toContain('© 2026 MySite. All rights reserved.'); }); }); + +describe('Footer (F4: box-model + animation + visibility props on craft.props)', () => { + test('craft.props includes animation/visibility defaults so the panel controls always render', () => { + const craftProps = (Footer as any).craft.props; + expect(craftProps).toHaveProperty('animation', 'none'); + expect(craftProps).toHaveProperty('animationDelay', '0'); + expect(craftProps).toHaveProperty('hideOnDesktop', false); + expect(craftProps).toHaveProperty('hideOnTablet', false); + expect(craftProps).toHaveProperty('hideOnMobile', false); + }); +}); diff --git a/craft/src/components/basic/Footer.tsx b/craft/src/components/basic/Footer.tsx index f8b87d0..b3dd2f3 100644 --- a/craft/src/components/basic/Footer.tsx +++ b/craft/src/components/basic/Footer.tsx @@ -6,6 +6,11 @@ import { escapeHtml } from '../../utils/escape'; interface FooterProps { text?: string; style?: CSSProperties; + hideOnDesktop?: boolean; + hideOnTablet?: boolean; + hideOnMobile?: boolean; + animation?: string; + animationDelay?: string; } export const Footer: UserComponent = ({ @@ -92,6 +97,11 @@ Footer.craft = { fontSize: '14px', padding: '24px 20px', }, + hideOnDesktop: false, + hideOnTablet: false, + hideOnMobile: false, + animation: 'none', + animationDelay: '0', }, rules: { canDrag: () => true, diff --git a/craft/src/components/basic/Logo.toHtml.test.ts b/craft/src/components/basic/Logo.toHtml.test.ts index 7cb8ad9..e6429f4 100644 --- a/craft/src/components/basic/Logo.toHtml.test.ts +++ b/craft/src/components/basic/Logo.toHtml.test.ts @@ -43,6 +43,29 @@ describe('Logo.toHtml image src/alt sanitization (type="image")', () => { }); }); +describe('Logo.toHtml download attribute (F3: link-to-file toggle)', () => { + test('download:true emits the download attribute', () => { + const { html } = toHtml({ href: '/resume.pdf', download: true, text: 'Resume' }, ''); + expect(html).toMatch(/ no download attribute emitted', () => { + const { html } = toHtml({ href: '/', text: 'MySite' }, ''); + expect(html).not.toContain('download'); + }); +}); + +describe('Logo (F4: box-model + animation + visibility props on craft.props)', () => { + test('craft.props includes animation/visibility defaults so the panel controls always render', () => { + const craftProps = (Logo as any).craft.props; + expect(craftProps).toHaveProperty('animation', 'none'); + expect(craftProps).toHaveProperty('animationDelay', '0'); + expect(craftProps).toHaveProperty('hideOnDesktop', false); + expect(craftProps).toHaveProperty('hideOnTablet', false); + expect(craftProps).toHaveProperty('hideOnMobile', false); + }); +}); + describe('Logo.toHtml text-logo styling sanitization', () => { test('a quote-breakout color does not escape the span style attribute', () => { const malicious = 'red" onmouseover="alert(1)'; diff --git a/craft/src/components/basic/Logo.tsx b/craft/src/components/basic/Logo.tsx index ef34a3d..ed39efa 100644 --- a/craft/src/components/basic/Logo.tsx +++ b/craft/src/components/basic/Logo.tsx @@ -12,11 +12,18 @@ interface LogoProps { imageSrc?: string; imageWidth?: string; href?: string; + /** Adds the `download` attribute to the exported anchor (F3: link to a file). */ + download?: boolean; fontFamily?: string; fontSize?: string; fontWeight?: string; color?: string; style?: CSSProperties; + hideOnDesktop?: boolean; + hideOnTablet?: boolean; + hideOnMobile?: boolean; + animation?: string; + animationDelay?: string; } /* ---------- Component ---------- */ @@ -27,6 +34,7 @@ export const Logo: UserComponent = ({ imageSrc = '', imageWidth = '120px', href = '/', + download = false, fontFamily = 'Inter, sans-serif', fontSize = '20px', fontWeight = '700', @@ -44,6 +52,7 @@ export const Logo: UserComponent = ({ { if (ref) connect(drag(ref)); }} href={href} + download={download || undefined} onClick={(e) => e.preventDefault()} style={{ textDecoration: 'none', @@ -87,7 +96,13 @@ Logo.craft = { fontSize: '20px', fontWeight: '700', color: undefined, + download: false, style: {}, + hideOnDesktop: false, + hideOnTablet: false, + hideOnMobile: false, + animation: 'none', + animationDelay: '0', } as LogoProps, rules: { canDrag: () => true, @@ -122,8 +137,9 @@ Logo.craft = { flexShrink: '0', ...props.style, }); + const downloadAttr = props.download ? ' download' : ''; return { - html: `${innerHtml}`, + html: `${innerHtml}`, }; }; diff --git a/craft/src/components/basic/Menu.toHtml.test.ts b/craft/src/components/basic/Menu.toHtml.test.ts index ee4ac32..e46bd00 100644 --- a/craft/src/components/basic/Menu.toHtml.test.ts +++ b/craft/src/components/basic/Menu.toHtml.test.ts @@ -31,6 +31,29 @@ describe('Menu.toHtml deterministic + unique scope ids (thread node id, no Math. }); }); +describe('Menu.toHtml download attribute (F3: link-to-file toggle)', () => { + test('a link with download:true emits the download attribute', () => { + const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1'); + expect(html).toMatch(/]* download[^>]*>Brochure<\/a>/); + }); + + test('a link without download does not emit the attribute', () => { + const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2'); + expect(html).not.toContain(' download'); + }); +}); + +describe('Menu (F4: box-model + animation + visibility props on craft.props)', () => { + test('craft.props includes animation/visibility defaults so the panel controls always render', () => { + const craftProps = (Menu as any).craft.props; + expect(craftProps).toHaveProperty('animation', 'none'); + expect(craftProps).toHaveProperty('animationDelay', '0'); + expect(craftProps).toHaveProperty('hideOnDesktop', false); + expect(craftProps).toHaveProperty('hideOnTablet', false); + expect(craftProps).toHaveProperty('hideOnMobile', false); + }); +}); + describe('Menu.toHtml XSS hardening (linkHoverColor into