diff --git a/craft/src/utils/__fixtures__/sitesmith-ols-home-response.json b/craft/src/utils/__fixtures__/sitesmith-ols-home-response.json new file mode 100644 index 0000000..fcfaed5 --- /dev/null +++ b/craft/src/utils/__fixtures__/sitesmith-ols-home-response.json @@ -0,0 +1,959 @@ +{ + "type": "replace", + "scope": "page", + "pages": [ + { + "name": "Home", + "tree": { + "type": { + "resolvedName": "Container" + }, + "props": { + "tag": "div", + "aiName": "Root", + "node_id": "ai-root-1" + }, + "nodes": [ + { + "type": { + "resolvedName": "Section" + }, + "props": { + "aiName": "Hero", + "node_id": "ai-hero-1", + "anchorId": "home", + "innerMaxWidth": "1120px", + "style": { + "backgroundColor": "var(--wsb-secondary)", + "paddingTop": "112px", + "paddingBottom": "112px", + "paddingLeft": "24px", + "paddingRight": "24px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Hero eyebrow", + "node_id": "ai-hero-eyebrow-2", + "text": "OLS Digital", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "13px", + "fontWeight": "600", + "letterSpacing": "0.12em", + "textTransform": "uppercase", + "color": "var(--wsb-accent)", + "marginBottom": "16px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Hero heading", + "node_id": "ai-hero-heading-3", + "text": "Digital brands built on honesty", + "level": "h1", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "clamp(2.25rem, 6vw, 4rem)", + "fontWeight": "700", + "lineHeight": "1.1", + "letterSpacing": "-0.02em", + "color": "var(--wsb-bg)", + "maxWidth": "760px", + "marginBottom": "24px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Hero lead", + "node_id": "ai-hero-lead-4", + "text": "OLS Digital is a privately held company that acquires and grows online brands, giving each one the support to thrive while keeping its own identity.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "19px", + "lineHeight": "1.6", + "color": "var(--wsb-bg)", + "maxWidth": "640px", + "marginBottom": "40px" + } + } + }, + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Hero buttons", + "node_id": "ai-hero-buttons-7", + "style": { + "display": "flex", + "flexWrap": "wrap", + "gap": "16px", + "padding": "0px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "ButtonLink" + }, + "props": { + "aiName": "Explore brands button", + "node_id": "ai-hero-btn-primary-5", + "text": "Explore our brands", + "href": "#brands", + "style": { + "backgroundColor": "var(--wsb-brand-1)", + "color": "#ffffff", + "padding": "14px 28px", + "borderRadius": "var(--wsb-button-radius)", + "fontWeight": "600", + "display": "inline-block" + } + } + }, + { + "type": { + "resolvedName": "ButtonLink" + }, + "props": { + "aiName": "Get in touch button", + "node_id": "ai-hero-btn-secondary-6", + "text": "Get in touch", + "href": "#contact", + "style": { + "backgroundColor": "transparent", + "color": "var(--wsb-bg)", + "border": "1px solid var(--wsb-bg)", + "padding": "14px 28px", + "borderRadius": "var(--wsb-button-radius)", + "fontWeight": "600", + "display": "inline-block" + } + } + } + ] + } + ] + }, + { + "type": { + "resolvedName": "Section" + }, + "props": { + "aiName": "About", + "node_id": "ai-about-8", + "anchorId": "about", + "innerMaxWidth": "1120px", + "style": { + "backgroundColor": "var(--wsb-bg)", + "paddingTop": "96px", + "paddingBottom": "96px", + "paddingLeft": "24px", + "paddingRight": "24px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "ColumnLayout" + }, + "props": { + "aiName": "About columns", + "node_id": "ai-about-columns-9", + "columns": 2, + "split": "40-60", + "gap": "64px", + "style": { + "display": "flex", + "flexDirection": "row", + "alignItems": "flex-start" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "About intro", + "node_id": "ai-about-intro-10", + "style": { + "padding": "0px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "About eyebrow", + "node_id": "ai-about-eyebrow-11", + "text": "Who we are", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "13px", + "fontWeight": "600", + "letterSpacing": "0.12em", + "textTransform": "uppercase", + "color": "var(--wsb-primary)", + "marginBottom": "16px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "About heading", + "node_id": "ai-about-heading-12", + "text": "One parent company, many independent brands", + "level": "h2", + "animation": "fade-in", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "clamp(2rem, 5vw, 2.5rem)", + "lineHeight": "1.2", + "color": "var(--wsb-text)" + } + } + } + ] + }, + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "About copy", + "node_id": "ai-about-copy-13", + "style": { + "padding": "0px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "About text 1", + "node_id": "ai-about-text-14", + "text": "We acquire and grow digital-first businesses. Every brand in the group keeps its own name, voice and customers, so the people who rely on it see no change except steady improvement.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "17px", + "lineHeight": "1.7", + "color": "var(--wsb-text)", + "maxWidth": "640px", + "marginBottom": "24px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "About text 2", + "node_id": "ai-about-text-15", + "text": "Behind each brand sits shared operational support, so teams can focus on serving their customers well rather than on running the back office.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "17px", + "lineHeight": "1.7", + "color": "var(--wsb-muted)", + "maxWidth": "640px" + } + } + } + ] + } + ] + } + ] + }, + { + "type": { + "resolvedName": "Section" + }, + "props": { + "aiName": "Brands", + "node_id": "ai-brands-16", + "anchorId": "brands", + "innerMaxWidth": "1120px", + "style": { + "backgroundColor": "color-mix(in srgb, var(--wsb-primary) 6%, var(--wsb-bg))", + "paddingTop": "96px", + "paddingBottom": "96px", + "paddingLeft": "24px", + "paddingRight": "24px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Brands heading", + "node_id": "ai-brands-heading-17", + "text": "Our brands", + "level": "h2", + "animation": "slide-up", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "clamp(2rem, 5vw, 2.5rem)", + "lineHeight": "1.2", + "color": "var(--wsb-text)", + "textAlign": "center", + "marginBottom": "16px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Brands lead", + "node_id": "ai-brands-lead-18", + "text": "Two brands, each focused on doing one job properly.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "18px", + "lineHeight": "1.6", + "color": "var(--wsb-muted)", + "textAlign": "center", + "maxWidth": "560px", + "marginLeft": "auto", + "marginRight": "auto", + "marginBottom": "56px" + } + } + }, + { + "type": { + "resolvedName": "ColumnLayout" + }, + "props": { + "aiName": "Brand columns", + "node_id": "ai-brand-columns-19", + "columns": 2, + "split": "50-50", + "gap": "32px", + "style": { + "display": "flex", + "flexDirection": "row" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "AnHonestHost card", + "node_id": "ai-ahh-card-20", + "style": { + "padding": "40px 32px", + "backgroundColor": "var(--wsb-bg)", + "border": "1px solid var(--wsb-border)", + "borderRadius": "var(--wsb-radius)" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "Icon" + }, + "props": { + "aiName": "AnHonestHost icon", + "node_id": "ai-ahh-icon-21", + "icon": "fa-solid fa-server", + "size": "28px", + "color": "var(--wsb-primary)", + "bgShape": "circle", + "bgSize": "56px", + "bgColor": "color-mix(in srgb, var(--wsb-primary) 12%, transparent)" + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "AnHonestHost heading", + "node_id": "ai-ahh-heading-22", + "text": "AnHonestHost", + "level": "h3", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "24px", + "lineHeight": "1.3", + "color": "var(--wsb-text)", + "marginTop": "24px", + "marginBottom": "8px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "AnHonestHost text", + "node_id": "ai-ahh-text-23", + "text": "Transparent, no-nonsense web hosting. Clear terms and straight answers, with nothing hidden.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "16px", + "lineHeight": "1.7", + "color": "var(--wsb-muted)" + } + } + } + ] + }, + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "StreamerTools card", + "node_id": "ai-st-card-24", + "style": { + "padding": "40px 32px", + "backgroundColor": "var(--wsb-bg)", + "border": "1px solid var(--wsb-border)", + "borderRadius": "var(--wsb-radius)" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "Icon" + }, + "props": { + "aiName": "StreamerTools icon", + "node_id": "ai-st-icon-25", + "icon": "fa-solid fa-video", + "size": "28px", + "color": "var(--wsb-primary)", + "bgShape": "circle", + "bgSize": "56px", + "bgColor": "color-mix(in srgb, var(--wsb-primary) 12%, transparent)" + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "StreamerTools heading", + "node_id": "ai-st-heading-26", + "text": "StreamerTools", + "level": "h3", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "24px", + "lineHeight": "1.3", + "color": "var(--wsb-text)", + "marginTop": "24px", + "marginBottom": "8px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "StreamerTools text", + "node_id": "ai-st-text-27", + "text": "Practical tools and resources for streamers and content creators, built to help them grow their channels.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "16px", + "lineHeight": "1.7", + "color": "var(--wsb-muted)" + } + } + } + ] + } + ] + } + ] + }, + { + "type": { + "resolvedName": "Section" + }, + "props": { + "aiName": "Values", + "node_id": "ai-values-28", + "anchorId": "values", + "innerMaxWidth": "1120px", + "style": { + "backgroundColor": "var(--wsb-primary)", + "paddingTop": "96px", + "paddingBottom": "96px", + "paddingLeft": "24px", + "paddingRight": "24px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Values eyebrow", + "node_id": "ai-values-eyebrow-29", + "text": "What we stand for", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "13px", + "fontWeight": "600", + "letterSpacing": "0.12em", + "textTransform": "uppercase", + "color": "var(--wsb-accent)", + "marginBottom": "16px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Values heading", + "node_id": "ai-values-heading-30", + "text": "Three values behind every brand", + "level": "h2", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "clamp(2rem, 5vw, 2.5rem)", + "lineHeight": "1.2", + "color": "var(--wsb-bg)", + "maxWidth": "640px", + "marginBottom": "56px" + } + } + }, + { + "type": { + "resolvedName": "ColumnLayout" + }, + "props": { + "aiName": "Value columns", + "node_id": "ai-value-columns-31", + "columns": 3, + "split": "33-33-33", + "gap": "48px", + "style": { + "display": "flex", + "flexDirection": "row" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Honesty", + "node_id": "ai-value-honesty-32", + "style": { + "paddingTop": "24px", + "borderTop": "1px solid var(--wsb-accent)" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Honesty number", + "node_id": "ai-value-honesty-num-33", + "text": "01", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "32px", + "fontWeight": "700", + "color": "var(--wsb-accent)", + "marginBottom": "8px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Honesty heading", + "node_id": "ai-value-honesty-head-34", + "text": "Honesty", + "level": "h3", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "24px", + "lineHeight": "1.3", + "color": "var(--wsb-bg)", + "marginBottom": "8px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Honesty text", + "node_id": "ai-value-honesty-text-35", + "text": "We say what we mean and do what we say, with customers and with each other.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "17px", + "lineHeight": "1.7", + "color": "var(--wsb-bg)" + } + } + } + ] + }, + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Quality", + "node_id": "ai-value-quality-36", + "style": { + "paddingTop": "24px", + "borderTop": "1px solid var(--wsb-accent)" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Quality number", + "node_id": "ai-value-quality-num-37", + "text": "02", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "32px", + "fontWeight": "700", + "color": "var(--wsb-accent)", + "marginBottom": "8px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Quality heading", + "node_id": "ai-value-quality-head-38", + "text": "Quality", + "level": "h3", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "24px", + "lineHeight": "1.3", + "color": "var(--wsb-bg)", + "marginBottom": "8px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Quality text", + "node_id": "ai-value-quality-text-39", + "text": "We invest in making each product and service work well before we make it bigger.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "17px", + "lineHeight": "1.7", + "color": "var(--wsb-bg)" + } + } + } + ] + }, + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "People first", + "node_id": "ai-value-people-40", + "style": { + "paddingTop": "24px", + "borderTop": "1px solid var(--wsb-accent)" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "People number", + "node_id": "ai-value-people-num-41", + "text": "03", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "32px", + "fontWeight": "700", + "color": "var(--wsb-accent)", + "marginBottom": "8px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "People heading", + "node_id": "ai-value-people-head-42", + "text": "People first", + "level": "h3", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "24px", + "lineHeight": "1.3", + "color": "var(--wsb-bg)", + "marginBottom": "8px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "People text", + "node_id": "ai-value-people-text-43", + "text": "Customers, creators and teams come before metrics in every decision we make.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "17px", + "lineHeight": "1.7", + "color": "var(--wsb-bg)" + } + } + } + ] + } + ] + } + ] + }, + { + "type": { + "resolvedName": "Section" + }, + "props": { + "aiName": "Contact", + "node_id": "ai-contact-44", + "anchorId": "contact", + "innerMaxWidth": "1120px", + "style": { + "backgroundColor": "var(--wsb-bg)", + "paddingTop": "96px", + "paddingBottom": "96px", + "paddingLeft": "24px", + "paddingRight": "24px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "ColumnLayout" + }, + "props": { + "aiName": "Contact columns", + "node_id": "ai-contact-columns-45", + "columns": 2, + "split": "40-60", + "gap": "64px", + "style": { + "display": "flex", + "flexDirection": "row", + "alignItems": "flex-start" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Contact intro", + "node_id": "ai-contact-intro-46", + "style": { + "padding": "0px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Contact eyebrow", + "node_id": "ai-contact-eyebrow-47", + "text": "Contact", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "13px", + "fontWeight": "600", + "letterSpacing": "0.12em", + "textTransform": "uppercase", + "color": "var(--wsb-primary)", + "marginBottom": "16px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Contact heading", + "node_id": "ai-contact-heading-48", + "text": "Talk to OLS Digital", + "level": "h2", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "clamp(2rem, 5vw, 2.5rem)", + "lineHeight": "1.2", + "color": "var(--wsb-text)", + "marginBottom": "24px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Contact text", + "node_id": "ai-contact-text-49", + "text": "Questions about our brands, or interested in joining the group? Send us a message and we will get back to you.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "17px", + "lineHeight": "1.7", + "color": "var(--wsb-muted)", + "maxWidth": "440px", + "marginBottom": "24px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Contact email", + "node_id": "ai-contact-email-50", + "text": "info@olsdigital.com", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "18px", + "fontWeight": "600", + "lineHeight": "1.6", + "color": "var(--wsb-text)" + } + } + } + ] + }, + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Contact form wrap", + "node_id": "ai-contact-form-wrap-51", + "style": { + "padding": "0px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "ContactForm" + }, + "props": { + "aiName": "Contact form", + "node_id": "ai-contact-form-52", + "fields": [ + { + "label": "Name", + "name": "name", + "placeholder": "Your name", + "required": true, + "type": "text" + }, + { + "label": "Email", + "name": "email", + "placeholder": "you@example.com", + "required": true, + "type": "email" + }, + { + "label": "Message", + "name": "message", + "placeholder": "How can we help?", + "required": true, + "type": "textarea" + } + ], + "submitText": "Send message", + "submitColor": "var(--wsb-brand-1)", + "inputBg": "#ffffff", + "inputBorder": "var(--wsb-border)", + "labelColor": "var(--wsb-text)", + "successMessage": "Thank you. We will be in touch soon." + } + } + ] + } + ] + } + ] + } + ] + } + } + ], + "message": "Built a five-section home page: dark hero, company story, brand cards for AnHonestHost and StreamerTools, a values band, and a contact form with your email. Set the form's delivery address in the form settings." +} \ No newline at end of file diff --git a/craft/src/utils/__fixtures__/sitesmith-ttm-likes-response.json b/craft/src/utils/__fixtures__/sitesmith-ttm-likes-response.json new file mode 100644 index 0000000..22e114f --- /dev/null +++ b/craft/src/utils/__fixtures__/sitesmith-ttm-likes-response.json @@ -0,0 +1,938 @@ +{ + "type": "replace", + "scope": "page", + "pages": [ + { + "name": "Home", + "tree": { + "type": { + "resolvedName": "Container" + }, + "props": { + "tag": "div", + "aiName": "Root", + "node_id": "ai-root-1" + }, + "nodes": [ + { + "type": { + "resolvedName": "Section" + }, + "props": { + "aiName": "Hero", + "node_id": "ai-hero-1", + "anchorId": "home", + "innerMaxWidth": "1120px", + "style": { + "background": "linear-gradient(135deg, color-mix(in srgb, var(--wsb-secondary) 45%, var(--wsb-text)), var(--wsb-text))", + "paddingTop": "112px", + "paddingBottom": "112px", + "paddingLeft": "24px", + "paddingRight": "24px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "ColumnLayout" + }, + "props": { + "aiName": "Hero columns", + "node_id": "ai-hero-columns-2", + "columns": 2, + "split": "60-40", + "gap": "64px", + "style": { + "display": "flex", + "flexDirection": "row", + "alignItems": "center" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Hero text", + "node_id": "ai-hero-text-3", + "style": { + "padding": "0px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Hero eyebrow", + "node_id": "ai-hero-eyebrow-4", + "text": "Tabletop Misfits", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "13px", + "fontWeight": "600", + "letterSpacing": "0.12em", + "textTransform": "uppercase", + "color": "#ffffff", + "marginBottom": "16px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Hero heading", + "node_id": "ai-hero-heading-5", + "text": "Pull up a chair. Tales await.", + "level": "h1", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "clamp(2.25rem, 6vw, 4rem)", + "fontWeight": "800", + "lineHeight": "1.1", + "letterSpacing": "-0.02em", + "color": "#ffffff", + "marginBottom": "24px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Hero lead", + "node_id": "ai-hero-lead-6", + "text": "A group of friends telling stories around the table and lifting up the TTRPG community. New shows every week.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "19px", + "lineHeight": "1.6", + "color": "#ffffff", + "maxWidth": "520px", + "marginBottom": "32px" + } + } + }, + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Hero buttons", + "node_id": "ai-hero-buttons-7", + "style": { + "display": "flex", + "flexWrap": "wrap", + "gap": "16px", + "padding": "0px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "ButtonLink" + }, + "props": { + "aiName": "Meet the shows button", + "node_id": "ai-hero-btn-1-8", + "text": "Meet our shows", + "href": "#shows", + "style": { + "backgroundColor": "var(--wsb-brand-1)", + "color": "#ffffff", + "padding": "14px 28px", + "borderRadius": "var(--wsb-button-radius)", + "fontWeight": "700", + "display": "inline-block" + } + } + }, + { + "type": { + "resolvedName": "ButtonLink" + }, + "props": { + "aiName": "Say hello button", + "node_id": "ai-hero-btn-2-9", + "text": "Say hello", + "href": "#contact", + "style": { + "backgroundColor": "transparent", + "color": "#ffffff", + "border": "1px solid #ffffff", + "padding": "14px 28px", + "borderRadius": "var(--wsb-button-radius)", + "fontWeight": "600", + "display": "inline-block" + } + } + } + ] + } + ] + }, + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Hero dice", + "node_id": "ai-hero-dice-10", + "hideOnMobile": true, + "style": { + "padding": "0px", + "textAlign": "center" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "Icon" + }, + "props": { + "aiName": "Hero d20 icon", + "node_id": "ai-hero-icon-11", + "icon": "fa-solid fa-dice-d20", + "size": "192px", + "color": "var(--wsb-brand-1)" + } + } + ] + } + ] + } + ] + }, + { + "type": { + "resolvedName": "Section" + }, + "props": { + "aiName": "Shows", + "node_id": "ai-shows-12", + "anchorId": "shows", + "innerMaxWidth": "1120px", + "style": { + "backgroundColor": "var(--wsb-text)", + "paddingTop": "96px", + "paddingBottom": "96px", + "paddingLeft": "24px", + "paddingRight": "24px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Shows eyebrow", + "node_id": "ai-shows-eyebrow-13", + "text": "On the table", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "13px", + "fontWeight": "600", + "letterSpacing": "0.12em", + "textTransform": "uppercase", + "color": "var(--wsb-accent)", + "marginBottom": "16px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Shows heading", + "node_id": "ai-shows-heading-14", + "text": "Four shows, one table", + "level": "h2", + "animation": "slide-up", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "clamp(2rem, 5vw, 2.5rem)", + "lineHeight": "1.2", + "color": "#ffffff", + "marginBottom": "48px" + } + } + }, + { + "type": { + "resolvedName": "ColumnLayout" + }, + "props": { + "aiName": "Show cards", + "node_id": "ai-show-cards-15", + "columns": 4, + "split": "25-25-25-25", + "gap": "24px", + "style": { + "display": "flex", + "flexDirection": "row" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Tavern Talks card", + "node_id": "ai-tavern-card-16", + "style": { + "padding": "32px", + "backgroundColor": "color-mix(in srgb, #ffffff 8%, var(--wsb-text))", + "border": "1px solid color-mix(in srgb, #ffffff 14%, transparent)", + "borderRadius": "var(--wsb-radius)" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Tavern label", + "node_id": "ai-tavern-label-17", + "text": "Interview show", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "13px", + "fontWeight": "600", + "letterSpacing": "0.12em", + "textTransform": "uppercase", + "color": "var(--wsb-accent)", + "marginBottom": "16px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Tavern heading", + "node_id": "ai-tavern-heading-18", + "text": "Tavern Talks", + "level": "h3", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "22px", + "lineHeight": "1.3", + "color": "#ffffff", + "marginBottom": "8px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Tavern text", + "node_id": "ai-tavern-text-19", + "text": "Grab a drink and listen in on friendly chats from around the TTRPG world.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "16px", + "lineHeight": "1.7", + "color": "#ffffff" + } + } + } + ] + }, + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Dress to Quest card", + "node_id": "ai-dtq-card-20", + "style": { + "padding": "32px", + "backgroundColor": "color-mix(in srgb, #ffffff 8%, var(--wsb-text))", + "border": "1px solid color-mix(in srgb, #ffffff 14%, transparent)", + "borderRadius": "var(--wsb-radius)" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Dress to Quest label", + "node_id": "ai-dtq-label-21", + "text": "Icewind Dale campaign", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "13px", + "fontWeight": "600", + "letterSpacing": "0.12em", + "textTransform": "uppercase", + "color": "var(--wsb-accent)", + "marginBottom": "16px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Dress to Quest heading", + "node_id": "ai-dtq-heading-22", + "text": "Dress to Quest", + "level": "h3", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "22px", + "lineHeight": "1.3", + "color": "#ffffff", + "marginBottom": "8px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Dress to Quest text", + "node_id": "ai-dtq-text-23", + "text": "Costumes on, cold winds blowing, adventure ahead.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "16px", + "lineHeight": "1.7", + "color": "#ffffff" + } + } + } + ] + }, + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Power Daddies card", + "node_id": "ai-pd-card-24", + "style": { + "padding": "32px", + "backgroundColor": "color-mix(in srgb, #ffffff 8%, var(--wsb-text))", + "border": "1px solid color-mix(in srgb, #ffffff 14%, transparent)", + "borderRadius": "var(--wsb-radius)" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Power Daddies label", + "node_id": "ai-pd-label-25", + "text": "Power Rangers TTRPG", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "13px", + "fontWeight": "600", + "letterSpacing": "0.12em", + "textTransform": "uppercase", + "color": "var(--wsb-accent)", + "marginBottom": "16px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Power Daddies heading", + "node_id": "ai-pd-heading-26", + "text": "Power Daddies: Guardians of the Grid", + "level": "h3", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "22px", + "lineHeight": "1.3", + "color": "#ffffff", + "marginBottom": "8px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Power Daddies text", + "node_id": "ai-pd-text-27", + "text": "It's go-go-roll-the-dice time.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "16px", + "lineHeight": "1.7", + "color": "#ffffff" + } + } + } + ] + }, + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Night of the Wyrm card", + "node_id": "ai-wyrm-card-28", + "style": { + "padding": "32px", + "backgroundColor": "color-mix(in srgb, #ffffff 8%, var(--wsb-text))", + "border": "1px solid color-mix(in srgb, #ffffff 14%, transparent)", + "borderRadius": "var(--wsb-radius)" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Wyrm label", + "node_id": "ai-wyrm-label-29", + "text": "Vampire and Werewolf", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "13px", + "fontWeight": "600", + "letterSpacing": "0.12em", + "textTransform": "uppercase", + "color": "var(--wsb-accent)", + "marginBottom": "16px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Wyrm heading", + "node_id": "ai-wyrm-heading-30", + "text": "Night of the Wyrm", + "level": "h3", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "22px", + "lineHeight": "1.3", + "color": "#ffffff", + "marginBottom": "8px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Wyrm text", + "node_id": "ai-wyrm-text-31", + "text": "A mash-up where the night gets complicated.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "16px", + "lineHeight": "1.7", + "color": "#ffffff" + } + } + } + ] + } + ] + } + ] + }, + { + "type": { + "resolvedName": "Section" + }, + "props": { + "aiName": "About", + "node_id": "ai-about-32", + "anchorId": "about", + "innerMaxWidth": "1120px", + "style": { + "backgroundColor": "var(--wsb-bg)", + "paddingTop": "96px", + "paddingBottom": "96px", + "paddingLeft": "24px", + "paddingRight": "24px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "ColumnLayout" + }, + "props": { + "aiName": "About columns", + "node_id": "ai-about-columns-33", + "columns": 2, + "split": "40-60", + "gap": "64px", + "style": { + "display": "flex", + "flexDirection": "row", + "alignItems": "flex-start" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "About intro", + "node_id": "ai-about-intro-34", + "style": { + "padding": "0px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "About eyebrow", + "node_id": "ai-about-eyebrow-35", + "text": "Who we are", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "13px", + "fontWeight": "600", + "letterSpacing": "0.12em", + "textTransform": "uppercase", + "color": "var(--wsb-brand-1)", + "marginBottom": "16px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "About heading", + "node_id": "ai-about-heading-36", + "text": "Friends, dice and good stories", + "level": "h2", + "animation": "fade-in", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "clamp(2rem, 5vw, 2.5rem)", + "lineHeight": "1.2", + "color": "var(--wsb-text)" + } + } + } + ] + }, + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "About copy", + "node_id": "ai-about-copy-37", + "style": { + "padding": "0px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "About text 1", + "node_id": "ai-about-text-38", + "text": "Tabletop Misfits is a group of friends dedicated to telling tales around the table. Every episode is about the fun of playing together, and the stories that come out of it.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "17px", + "lineHeight": "1.7", + "color": "var(--wsb-text)", + "maxWidth": "640px", + "marginBottom": "24px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "About text 2", + "node_id": "ai-about-text-39", + "text": "We also love shining a light on others in the TTRPG community, so there is always room for one more seat at the table.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "17px", + "lineHeight": "1.7", + "color": "var(--wsb-muted)", + "maxWidth": "640px" + } + } + } + ] + } + ] + } + ] + }, + { + "type": { + "resolvedName": "BackgroundSection" + }, + "props": { + "aiName": "Community band", + "node_id": "ai-community-40", + "anchorId": "community", + "bgColor": "var(--wsb-brand-1)", + "innerMaxWidth": "760px", + "style": { + "paddingTop": "112px", + "paddingBottom": "112px", + "paddingLeft": "24px", + "paddingRight": "24px", + "textAlign": "center" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "Icon" + }, + "props": { + "aiName": "Community icon", + "node_id": "ai-community-icon-41", + "icon": "fa-solid fa-users", + "size": "32px", + "color": "#ffffff" + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Community heading", + "node_id": "ai-community-heading-42", + "text": "Every misfit deserves a seat at the table", + "level": "h2", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "clamp(2rem, 5vw, 2.5rem)", + "lineHeight": "1.2", + "color": "#ffffff", + "marginTop": "24px", + "marginBottom": "24px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Community text", + "node_id": "ai-community-text-43", + "text": "We are here to tell tales and to uplift others in the TTRPG community. Come for the stories, stay for the friends.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "18px", + "lineHeight": "1.7", + "color": "#ffffff" + } + } + } + ] + }, + { + "type": { + "resolvedName": "Section" + }, + "props": { + "aiName": "Contact", + "node_id": "ai-contact-44", + "anchorId": "contact", + "innerMaxWidth": "1120px", + "style": { + "backgroundColor": "var(--wsb-bg)", + "paddingTop": "96px", + "paddingBottom": "96px", + "paddingLeft": "24px", + "paddingRight": "24px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "ColumnLayout" + }, + "props": { + "aiName": "Contact columns", + "node_id": "ai-contact-columns-45", + "columns": 2, + "split": "40-60", + "gap": "64px", + "style": { + "display": "flex", + "flexDirection": "row", + "alignItems": "flex-start" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Contact intro", + "node_id": "ai-contact-intro-46", + "style": { + "padding": "0px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Contact eyebrow", + "node_id": "ai-contact-eyebrow-47", + "text": "Collaborations and sponsors", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "13px", + "fontWeight": "600", + "letterSpacing": "0.12em", + "textTransform": "uppercase", + "color": "var(--wsb-brand-1)", + "marginBottom": "16px" + } + } + }, + { + "type": { + "resolvedName": "Heading" + }, + "props": { + "aiName": "Contact heading", + "node_id": "ai-contact-heading-48", + "text": "Let's tell a tale together", + "level": "h2", + "style": { + "fontFamily": "var(--wsb-heading-font)", + "fontSize": "clamp(2rem, 5vw, 2.5rem)", + "lineHeight": "1.2", + "color": "var(--wsb-text)", + "marginBottom": "24px" + } + } + }, + { + "type": { + "resolvedName": "TextBlock" + }, + "props": { + "aiName": "Contact text", + "node_id": "ai-contact-text-49", + "text": "Interested in a brand collaboration or sponsoring the show? Or just want to say hi? Send us a note and we'll get back to you.", + "style": { + "fontFamily": "var(--wsb-body-font)", + "fontSize": "17px", + "lineHeight": "1.7", + "color": "var(--wsb-muted)", + "maxWidth": "440px" + } + } + } + ] + }, + { + "type": { + "resolvedName": "Container" + }, + "props": { + "aiName": "Contact form wrap", + "node_id": "ai-contact-form-wrap-50", + "style": { + "padding": "0px" + } + }, + "nodes": [ + { + "type": { + "resolvedName": "ContactForm" + }, + "props": { + "aiName": "Contact form", + "node_id": "ai-contact-form-51", + "fields": [ + { + "label": "Name", + "name": "name", + "placeholder": "Your name", + "required": true, + "type": "text" + }, + { + "label": "Email", + "name": "email", + "placeholder": "you@example.com", + "required": true, + "type": "email" + }, + { + "label": "Message", + "name": "message", + "placeholder": "Tell us what you have in mind", + "required": true, + "type": "textarea" + } + ], + "submitText": "Send message", + "submitColor": "var(--wsb-brand-1)", + "inputBg": "var(--wsb-bg)", + "inputBorder": "var(--wsb-border)", + "labelColor": "var(--wsb-text)", + "successMessage": "Thanks for reaching out! We'll be in touch soon." + } + } + ] + } + ] + } + ] + } + ] + } + } + ], + "message": "Built a five-section home page inspired by your reference: a dark gradient hero with a big headline, a row of four show cards on a dark band, an about split, a brand-red community band and a collaboration form. I used your real show names but left out schedule days and times (and a Watch Live link) since I don't have them; tell me when the shows air and where to watch, and I'll add them. Set the form's delivery address in its settings." +} \ No newline at end of file diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index d8c8513..900c202 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -6,6 +6,7 @@ import { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../type import { sanitizeAiTree, buildNodeTree } from './craft-tree'; import { cleanAiResponse } from '../sitesmith/clean-ai-response'; import { useSiteDesign, type SiteDesign } from '../state/SiteDesignContext'; +import { effectiveBackgroundOf } from './effective-background'; import { repairResponseContrast, type ContrastAdjustment, type PatchContext } from './contrast-repair'; // Re-exported for existing callers/tests that import sanitizeAiTree/ @@ -46,15 +47,9 @@ export function canvasNodeContext(query: any): PatchContext['nodeContext'] { const id = findNodeIdByAiNodeId(query, aiNodeId); if (!id) return undefined; const props = { ...(query.node(id).get().data.props ?? {}) } as Record; - let ancestorBg: string | undefined; - let cur: string | null | undefined = query.node(id).get().data.parent; - while (cur) { - const p = query.node(cur).get().data.props ?? {}; - const bg = p.style?.backgroundColor ?? p.bgColor; - if (typeof bg === 'string' && bg.trim() && !/gradient|url\(/i.test(bg)) { ancestorBg = bg; break; } - if ((p.bgType && p.bgType !== 'color' && p.bgType !== 'solid') || p.bgImage) break; - cur = query.node(cur).get().data.parent; - } + // Same resolver as the style panels (gradients / images come back verbatim -> "not checked"). + const parent = query.node(id).get().data.parent as string | null | undefined; + const ancestorBg = parent ? effectiveBackgroundOf(query.getNodes() as Record, parent) ?? undefined : undefined; return { props, ancestorBg }; } catch { return undefined; } }; diff --git a/craft/src/utils/contrast-repair.test.ts b/craft/src/utils/contrast-repair.test.ts index a6d1136..7760441 100644 --- a/craft/src/utils/contrast-repair.test.ts +++ b/craft/src/utils/contrast-repair.test.ts @@ -4,6 +4,8 @@ import { contrastRatio } from './publish-checks'; import { parseColor } from './color'; import { resolveTokenValue } from './tokens'; import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext'; +import ttmLikes from './__fixtures__/sitesmith-ttm-likes-response.json'; +import olsHome from './__fixtures__/sitesmith-ols-home-response.json'; import type { SitesmithResponse, SerializedTreeNode } from '../types/sitesmith'; const D = { ...DEFAULT_SITE_DESIGN, primaryColor: '#1e3a8a', textColor: '#111827', backgroundColor: '#ffffff', mutedTextColor: '#6b7280' }; @@ -159,3 +161,193 @@ describe('repairResponseContrast', () => { .toBe('1 text colour adjusted for readability; 1 image overlay added so text stays readable (2 gradient/image areas not checked)'); }); }); + +describe('background resolution (Sitesmith eval bug C)', () => { + const heading = (color: string, extra: Record = {}) => n('Heading', { text: 'Hi', style: { color, fontSize: '16px', ...extra } }); + const on = (bg: Record, color: string, name = 'Section') => replace(n(name, bg, [heading(color)])); + const colourOf = (r: SitesmithResponse) => (firstTree(r).nodes![0].props.style as any).color as string; + + test('style.background solid shorthand colour counts as the background', () => { + const out = repairResponseContrast(on({ style: { background: '#1e3a8a' } }, '#334155'), D); + expect(out.adjustments).toHaveLength(1); + expect(ratio(colourOf(out.response), '#1e3a8a')).toBeGreaterThanOrEqual(4.5); + }); + + test('white text on a dark style.background gradient is left alone (not repaired against the white page)', () => { + const out = repairResponseContrast(on({ style: { background: 'linear-gradient(135deg, #1e1b4b, #0f172a)' } }, '#ffffff'), D); + expect(out.adjustments).toEqual([]); + expect(colourOf(out.response)).toBe('#ffffff'); + }); + + test('gradient: the WORST stop decides; a repair must pass against every stop', () => { + // white on [dark, mid-grey #888]: #888 vs white = 3.5 -> fails body text; only black passes against both stops? black vs #1e1b4b fails -> none. + const none = repairResponseContrast(on({ style: { background: 'linear-gradient(#1e1b4b, #888888)' } }, '#ffffff'), D); + expect(none.adjustments).toEqual([]); + expect(colourOf(none.response)).toBe('#ffffff'); + expect(none.notChecked).toBe(1); // cannot be satisfied across all stops -> declined, counted + // white on [dark, light-blue]: fails on the light stop; light stops are all >= 4.5 against black, dark stop is not -> declined too; + // but a pair of two mid stops that one colour satisfies is repaired + const ok = repairResponseContrast(on({ style: { background: 'linear-gradient(90deg, #f1f5f9, #e2e8f0)' } }, '#ffffff'), D); + expect(ok.adjustments).toHaveLength(1); + const to = colourOf(ok.response); + expect(ratio(to, '#f1f5f9')).toBeGreaterThanOrEqual(4.5); + expect(ratio(to, '#e2e8f0')).toBeGreaterThanOrEqual(4.5); + expect(ok.adjustments[0].ratioBefore!).toBeLessThan(1.3); // worst stop (#e2e8f0), not the first + }); + + test('gradient with angle, positions and radial shape is parsed', () => { + const g = 'radial-gradient(circle at 30% 20%, #0f172a 0%, #1e293b 60%, #020617 100%)'; + expect(repairResponseContrast(on({ style: { background: g } }, '#ffffff'), D).adjustments).toEqual([]); + const bad = repairResponseContrast(on({ style: { background: 'linear-gradient(to right, #0f172a 10%, #1e293b 90%)' } }, '#334155'), D); + expect(bad.adjustments).toHaveLength(1); + }); + + test('unparseable gradient stops (color-mix, unresolved token, alpha) -> not checked, never repaired', () => { + for (const g of [ + 'linear-gradient(135deg, color-mix(in srgb, var(--wsb-secondary) 45%, var(--wsb-text)), var(--wsb-text))', + 'linear-gradient(#000000, var(--wsb-brand-9))', + 'linear-gradient(rgba(0,0,0,0.2), #ffffff)', + 'linear-gradient(#000000, #ffffff), url(a.jpg)', + ]) { + const out = repairResponseContrast(on({ style: { background: g } }, '#777777'), D); + expect(out.adjustments, g).toEqual([]); + expect(out.notChecked, g).toBe(1); + expect(colourOf(out.response)).toBe('#777777'); + } + }); + + test('an unresolvable background colour is "not checked", not silently the page background', () => { + for (const bg of ['color-mix(in srgb, #ffffff 8%, var(--wsb-text))', 'var(--wsb-brand-3)', 'rgba(0,0,0,0.5)']) { + const out = repairResponseContrast(on({ style: { backgroundColor: bg } }, '#ffffff'), D); + expect(out.adjustments, bg).toEqual([]); + expect(out.notChecked, bg).toBe(1); + } + // ...and it shadows an outer flat background (text sits on the inner one) + const nested = replace(n('Section', { style: { backgroundColor: '#ffffff' } }, [n('Container', { style: { backgroundColor: 'var(--wsb-brand-3)' } }, [heading('#ffffff')])])); + expect(repairResponseContrast(nested, D).adjustments).toEqual([]); + }); + + test('image backgrounds are not checked for text colour (style.backgroundImage / url() shorthand / bgImage)', () => { + for (const props of [ + { style: { backgroundImage: 'url(a.jpg)' } }, + { style: { background: "url('a.jpg') center/cover no-repeat" } }, + { style: { backgroundColor: '#ffffff', backgroundImage: 'url(a.jpg)' } }, + ]) { + const out = repairResponseContrast(on(props, '#ffffff'), D); + expect(out.adjustments, JSON.stringify(props)).toEqual([]); + expect(out.notChecked, JSON.stringify(props)).toBe(1); + } + }); + + test('HeroSimple gradient props: worst stop, named pair', () => { + const dark = repairResponseContrast(replace(n('HeroSimple', { bgType: 'gradient', bgGradientFrom: '#0f172a', bgGradientTo: '#1e3a8a', textColor: '#ffffff' })), D); + expect(dark.adjustments).toEqual([]); + expect(dark.notChecked).toBe(0); + const weak = repairResponseContrast(replace(n('HeroSimple', { bgType: 'gradient', bgGradientFrom: '#0f172a', bgGradientTo: '#1e3a8a', textColor: '#334155' })), D); + expect(weak.adjustments).toHaveLength(1); + const p = firstTree(weak.response).props as any; + expect(ratio(p.textColor, '#0f172a')).toBeGreaterThanOrEqual(4.5); + expect(ratio(p.textColor, '#1e3a8a')).toBeGreaterThanOrEqual(4.5); + }); + + test('HeroSimple with no gradient props uses the component defaults (primary -> secondary); defaults are resolved from the design', () => { + const out = repairResponseContrast(replace(n('HeroSimple', { bgType: 'gradient', textColor: '#ffffff' })), { ...D, primaryColor: '#1e3a8a', secondaryColor: '#0f172a' }); + expect(out.adjustments).toEqual([]); + }); + + test('HeroSimple bgColor default is the Text token when the prop is absent', () => { + // default bg = text token (#111827): dark-grey text is genuinely low contrast there + const out = repairResponseContrast(replace(n('HeroSimple', { textColor: '#334155' })), D); + expect(out.adjustments).toHaveLength(1); + }); + + test('CallToAction bgValue gradient / colour', () => { + expect(repairResponseContrast(replace(n('CallToAction', { bgType: 'gradient', bgValue: 'linear-gradient(135deg,#0f172a,#1e3a8a)', textColor: '#ffffff' })), D).adjustments).toEqual([]); + const c = repairResponseContrast(replace(n('CallToAction', { bgType: 'color', bgValue: '#0f172a', textColor: '#334155' })), D); + expect(c.adjustments).toHaveLength(1); + }); + + test('image with an opaque overlay is fully determined by the overlay colour; a translucent one is not checked', () => { + const opaque = repairResponseContrast(replace(n('HeroSimple', { bgType: 'image', bgImage: 'a.jpg', overlayColor: '#0f172a', overlayOpacity: 100, textColor: '#ffffff' })), D); + expect(opaque.adjustments).toEqual([]); + const opaqueBad = repairResponseContrast(replace(n('HeroSimple', { bgType: 'image', bgImage: 'a.jpg', overlayColor: '#0f172a', overlayOpacity: 100, textColor: '#334155' })), D); + expect(opaqueBad.adjustments.map((a) => a.kind)).toEqual(['text-colour']); + const bs = repairResponseContrast(replace(n('BackgroundSection', { bgImage: 'a.jpg', overlayColor: '#0f172a', overlayOpacity: 1 }, [heading('#334155')])), D); + expect(bs.adjustments.map((a) => a.kind)).toEqual(['text-colour']); + const soft = repairResponseContrast(replace(n('HeroSimple', { bgType: 'image', bgImage: 'a.jpg', overlayColor: '#000000', overlayOpacity: 60, textColor: '#ffffff' })), D); + expect(soft.adjustments).toEqual([]); + expect(soft.notChecked).toBe(1); + }); + + test('update_props: a gradient / unreadable ancestor background is not checked, never judged against the page colour', () => { + const resp: SitesmithResponse = { type: 'patch', message: 'm', ops: [{ op: 'update_props', node_id: 'x', props: { style: { color: '#ffffff' } } }] }; + // white passes on a dark gradient (nothing to do, not "unchecked") + const dark = repairResponseContrast(resp, D, { nodeContext: () => ({ props: {}, ancestorBg: 'linear-gradient(135deg, #0f172a, #1e3a8a)' }) }); + expect(dark.adjustments).toEqual([]); + expect(dark.notChecked).toBe(0); + for (const ancestorBg of ['url(a.jpg)', 'color-mix(in srgb, red, blue)', 'var(--wsb-brand-4)']) { + const out = repairResponseContrast(resp, D, { nodeContext: () => ({ props: {}, ancestorBg }) }); + expect(out.adjustments, ancestorBg).toEqual([]); + expect(out.notChecked, ancestorBg).toBe(1); + } + }); + + test('an already-passing pair is untouched, including on a gradient', () => { + const out = repairResponseContrast(on({ style: { background: 'linear-gradient(#0f172a, #1e3a8a)' } }, '#f8fafc'), D); + expect(out.adjustments).toEqual([]); + expect(out.notChecked).toBe(0); + }); + + test('a repair must strictly improve and reach the target, else the text is left alone', () => { + // no colour reaches 4.5 against both a pure black and a pure white stop + const out = repairResponseContrast(on({ style: { background: 'linear-gradient(#000000, #ffffff)' } }, '#777777'), D); + expect(out.adjustments).toEqual([]); + expect(colourOf(out.response)).toBe('#777777'); + // nearestPassing never returns a colour below the target against any stop + expect(nearestPassing(parseColor('#777777')!, [parseColor('#000000')!, parseColor('#ffffff')!] as any, 4.5, D)).toBeNull(); + }); + + test('large-text size given as clamp() uses its minimum (guaranteed lower bound)', () => { + // white on #8b5cf6 = 4.23: fails body text, passes large (>= 3) + const big = replace(n('Container', { style: { backgroundColor: '#8b5cf6' } }, [n('Heading', { text: 'x', style: { color: '#ffffff', fontSize: 'clamp(2.25rem, 6vw, 4rem)' } })])); + expect(repairResponseContrast(big, D).adjustments).toEqual([]); + const smallMin = replace(n('Container', { style: { backgroundColor: '#8b5cf6' } }, [n('Heading', { text: 'x', style: { color: '#ffffff', fontSize: 'clamp(1rem, 6vw, 4rem)' } })])); + expect(repairResponseContrast(smallMin, D).adjustments).toHaveLength(1); + }); +}); + +describe('Sitesmith eval fixtures (real model responses)', () => { + const brand = { label: 'Brand red', hex: '#d4372c', brand: true, slot: 1 }; + const designs = { + 'default design, no brand swatch': { ...DEFAULT_SITE_DESIGN, swatches: [] }, + 'default design + brand red swatch (live editor)': { ...DEFAULT_SITE_DESIGN, swatches: [brand] }, + 'ols design injected': { ...DEFAULT_SITE_DESIGN, primaryColor: '#1f3a5f', secondaryColor: '#101a2b', accentColor: '#d9a24a', backgroundColor: '#fbfaf7', textColor: '#14181f', mutedTextColor: '#4b5563', swatches: [brand] }, + } as Record; + + test('ttm-likes: white text on the dark gradient hero (and on unresolvable bgs) is NOT repaired, under any design', () => { + for (const [name, d] of Object.entries(designs)) { + const out = repairResponseContrast(ttmLikes as unknown as SitesmithResponse, d); + expect(out.adjustments, name).toEqual([]); + expect(out.notChecked, name).toBeGreaterThan(0); + expect(JSON.stringify(out.response), name).toBe(JSON.stringify(ttmLikes)); + } + }); + + test('ols-home: 14 repairs in the live editor design -> the 12 genuinely low-contrast ones remain, 2 clamp() headings (pair passes as large text) no longer repaired', () => { + const out = repairResponseContrast(olsHome as unknown as SitesmithResponse, designs['default design + brand red swatch (live editor)']); + expect(out.adjustments.map((a) => a.text)).toEqual([ + 'OLS Digital', 'OLS Digital is a privately held company…', 'Get in touch', 'Who we are', 'What we stand for', '01', + 'We say what we mean and do what we say,…', '02', 'We invest in making each product and se…', '03', + 'Customers, creators and teams come befo…', 'Contact', + ]); + // every remaining repair is real: before < target, after strictly better and >= target + for (const a of out.adjustments) { + expect(a.ratioAfter!).toBeGreaterThan(a.ratioBefore!); + expect(a.ratioBefore!).toBeLessThan(a.text!.length <= 2 ? 3 : 4.5); + } + }); + + test('ols-home under the design that was injected into the request: nothing to repair', () => { + expect(repairResponseContrast(olsHome as unknown as SitesmithResponse, designs['ols design injected']).adjustments).toEqual([]); + }); +}); diff --git a/craft/src/utils/contrast-repair.ts b/craft/src/utils/contrast-repair.ts index 118dd62..f8bce9b 100644 --- a/craft/src/utils/contrast-repair.ts +++ b/craft/src/utils/contrast-repair.ts @@ -7,10 +7,18 @@ * by the nearest passing site token or black/white. No model call. Pure: the input * response is never mutated. * - * What is checked: a node's own text colour against its own flat background, or - * against the nearest ancestor's flat background (the page background at the root). - * What is NOT checked (reported as such): gradients, background images and videos. + * What is checked: a node's own text colour against its own background, or against the + * nearest ancestor's (the page background at the root). The background is a flat colour + * (style.backgroundColor, a solid style.background shorthand, bgColor, HeroSimple / + * BackgroundSection defaults) or a gradient (style.background, HeroSimple bgGradient*, + * CallToAction bgValue) in which case the text is judged against EVERY stop and the worst + * one decides; a repair has to pass against all stops. + * What is NOT checked (reported as such): background images/videos (unless an opaque overlay + * colour fully determines them), gradients with a stop we cannot parse (color-mix(), an + * unresolved token, translucent colours, named colours), and any other background value we + * cannot resolve. An unresolvable background never falls through to an outer one. * Text over an image needs an overlay: a missing/weak overlay is strengthened. + * A repair is applied only if it reaches the target AND is strictly better than the old colour. */ import type { SiteDesign } from '../state/SiteDesignContext'; import type { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith'; @@ -42,8 +50,9 @@ export interface PatchContext { nodeContext?: (aiNodeId: string) => { props: Record; ancestorBg?: string } | undefined; } +/** `flat`: one or more opaque colours the text sits on (several = gradient stops; the worst one decides). */ type Bg = - | { kind: 'flat'; rgb: RGB } + | { kind: 'flat'; stops: RGB[] } | { kind: 'image'; node: SerializedTreeNode } | { kind: 'unknown' }; @@ -73,7 +82,9 @@ const quote = (s: string) => (s.length > 40 ? s.slice(0, 39) + '…' : s); function pxSize(v: unknown): number | null { if (typeof v === 'number') return v; if (typeof v !== 'string') return null; - const m = /^([\d.]+)(px|rem|em)$/.exec(v.trim()); + // clamp(min, preferred, max): the minimum is the guaranteed lower bound at every viewport. + const cl = /^clamp\(\s*([^,()]+?)\s*,/i.exec(v.trim()); + const m = /^([\d.]+)(px|rem|em)$/.exec(cl ? cl[1] : v.trim()); if (!m) return null; return m[2] === 'px' ? parseFloat(m[1]) : parseFloat(m[1]) * 16; } @@ -112,28 +123,150 @@ function candidates(design: SiteDesign): Candidate[] { return out; } -/** Nearest (in RGB) candidate that passes `need` against `bg`; black/white always exist so one passes at 3:1 and, for any bg, at least one passes 4.5:1. */ -export function nearestPassing(fg: RGB, bg: RGB, need: number, design: SiteDesign): Candidate | null { +/** Lowest contrast of `fg` against any of `bg` (a single colour or the stops of a gradient). */ +function worstRatio(fg: RGB, bg: RGB | RGB[]): number { + const stops = isStops(bg) ? bg : [bg]; + return Math.min(...stops.map((b) => contrastRatio(fg, b))); +} +const isStops = (bg: RGB | RGB[]): bg is RGB[] => Array.isArray(bg[0]); + +/** Nearest (in RGB) candidate that passes `need` against `bg` (every stop when given several), or null. Against one colour black/white always exist so one passes at 3:1 and at 4.5:1. */ +export function nearestPassing(fg: RGB, bg: RGB | RGB[], need: number, design: SiteDesign): Candidate | null { let best: Candidate | null = null; let bestD = Infinity; for (const c of candidates(design)) { - if (contrastRatio(c.rgb, bg) < need) continue; + if (worstRatio(c.rgb, bg) < need) continue; const d = dist(c.rgb, fg); if (d < bestD) { best = c; bestD = d; } } return best; } -function ownBg(props: Record, design: SiteDesign, namedBg?: string): Bg | 'none' { - if (props.bgType && props.bgType !== 'color' && props.bgType !== 'solid') { - return props.bgType === 'image' ? { kind: 'image', node: undefined as unknown as SerializedTreeNode } : { kind: 'unknown' }; +const BLANK = new Set(['', 'transparent', 'none', 'inherit', 'initial', 'unset']); +const UNKNOWN: Bg = { kind: 'unknown' }; +const IMAGE: Bg = { kind: 'image', node: undefined as unknown as SerializedTreeNode }; + +/** Alpha of a literal colour (1 when opaque or not a literal). */ +function alphaOf(v: string): number { + const t = v.trim().toLowerCase(); + let m = /^#([0-9a-f]{4}|[0-9a-f]{8})$/.exec(t); + if (m) return m[1].length === 4 ? parseInt(m[1][3] + m[1][3], 16) / 255 : parseInt(m[1].slice(6), 16) / 255; + m = /^rgba?\([^)]*[,/]\s*([\d.]+%?)\s*\)$/.exec(t); + if (m) return m[1].endsWith('%') ? parseFloat(m[1]) / 100 : parseFloat(m[1]); + return 1; +} + +/** A single literal colour or token -> opaque RGB, else null (translucent colours are NOT opaque). */ +function opaqueRgb(v: string, design: SiteDesign): RGB | null { + const resolved = resolveTokenValue(v.trim(), design); + if (alphaOf(resolved) < 1) return null; + return parseColor(resolved); +} + +function splitTopLevel(s: string): string[] { + const out: string[] = []; + let depth = 0; let cur = ''; + for (const ch of s) { + if (ch === '(') depth++; + if (ch === ')') depth--; + if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; } else cur += ch; } - if (props.bgImage) return { kind: 'image', node: undefined as unknown as SerializedTreeNode }; - const raw = (namedBg ? props[namedBg] : undefined) ?? props.style?.backgroundColor ?? props.bgColor; - if (typeof raw !== 'string' || !raw.trim()) return 'none'; - if (/gradient|url\(/i.test(raw)) return { kind: 'unknown' }; - const rgb = toRgb(raw, design); - return rgb ? { kind: 'flat', rgb } : 'none'; + out.push(cur.trim()); + return out; +} + +const POS = '-?[\\d.]+(?:%|px|em|rem|deg|turn)?'; +const GRAD_HEAD = /^(to\s|-?[\d.]+(?:deg|grad|rad|turn)\b|circle\b|ellipse\b|closest-|farthest-|at\s|from\s|in\s)/i; + +/** Every colour stop of ONE css gradient; unknown when there is any stop we cannot read exactly. */ +function parseGradient(raw: string, design: SiteDesign): Bg { + if ((raw.match(/gradient\(|url\(/gi) ?? []).length !== 1) return UNKNOWN; // layered / mixed backgrounds + const m = /^\s*(?:repeating-)?(?:linear|radial|conic)-gradient\(/i.exec(raw); + if (!m) return UNKNOWN; + let depth = 1; let end = -1; + for (let i = m[0].length; i < raw.length; i++) { + if (raw[i] === '(') depth++; + else if (raw[i] === ')' && --depth === 0) { end = i; break; } + } + if (end < 0 || raw.slice(end + 1).trim() !== '') return UNKNOWN; + const args = splitTopLevel(raw.slice(m[0].length, end)); + if (args.length && GRAD_HEAD.test(args[0])) args.shift(); + const stops: RGB[] = []; + for (const a of args) { + if (new RegExp(`^${POS}$`).test(a)) continue; // colour hint + const colour = a.replace(new RegExp(`(?:\\s+${POS}){1,2}$`), '').trim(); + const rgb = colour ? opaqueRgb(colour, design) : null; + if (!rgb) return UNKNOWN; + stops.push(rgb); + } + return stops.length ? { kind: 'flat', stops } : UNKNOWN; +} + +/** One CSS background value (colour, token, shorthand, gradient, url) -> Bg, or 'none' when it paints nothing. */ +function cssBg(raw: unknown, design: SiteDesign): Bg | 'none' { + if (typeof raw !== 'string') return 'none'; + const t = raw.trim(); + if (BLANK.has(t.toLowerCase())) return 'none'; + if (/gradient\(/i.test(t)) return parseGradient(t, design); + if (/url\(/i.test(t)) return IMAGE; + const rgb = opaqueRgb(t, design); + return rgb ? { kind: 'flat', stops: [rgb] } : UNKNOWN; +} + +/** Overlay that fully hides the image (opacity 100%) and has a literal colour -> that colour; else null. */ +function opaqueOverlay(name: string, props: Record, design: SiteDesign): RGB | null { + if (!(name in OVERLAY_SCALE)) return null; + const scale = OVERLAY_SCALE[name]; + const o = Number(props.overlayOpacity ?? (scale === 1 ? 0.4 : 0)); + if (!(o >= scale)) return null; + return opaqueRgb(typeof props.overlayColor === 'string' && props.overlayColor.trim() ? props.overlayColor : '#000000', design); +} + +/** Components whose own bgColor prop falls back to the Text token when absent (component defaults). */ +const BGCOLOR_DEFAULTS_TO_TEXT = new Set(['HeroSimple', 'BackgroundSection']); + +/** + * What the node paints behind its own content, or 'none'. + * `namedBg` is the prop that is the colour behind the checked element: the section's own colour prop + * (bgColor / backgroundColor), or an element-specific colour (buttonBgColor, ctaColor ...) that + * falls back to the section's own background when it is not set. + */ +function ownBg(name: string, props: Record, design: SiteDesign, namedBg?: string): Bg | 'none' { + if (namedBg && namedBg !== 'bgColor' && namedBg !== 'backgroundColor') { + const own = cssBg(props[namedBg], design); + if (own !== 'none') return own; + } + const style = (props.style ?? {}) as Record; + // Layers that paint over a background colour: image / gradient in style. + for (const v of [style.backgroundImage, style.background]) { + if (typeof v === 'string' && /gradient\(|url\(/i.test(v)) return cssBg(v, design) as Bg; + } + const type = props.bgType; + if (type === 'gradient') { + if (typeof props.bgValue === 'string') return cssBg(props.bgValue, design) as Bg; // CallToAction + if (name !== 'HeroSimple') return UNKNOWN; + const from = props.bgGradientFrom ?? tokenRef('primary'); + const to = props.bgGradientTo ?? tokenRef('secondary'); + const a = cssBg(from, design); const b = cssBg(to, design); + if (a === 'none' || b === 'none' || a.kind !== 'flat' || b.kind !== 'flat') return UNKNOWN; + return { kind: 'flat', stops: [...a.stops, ...b.stops] }; + } + if (type === 'image' || props.bgImage) { + const o = opaqueOverlay(name, props, design); + return o ? { kind: 'flat', stops: [o] } : IMAGE; + } + if (type && type !== 'color' && type !== 'solid') return UNKNOWN; // video / unknown kinds + // Flat colour: named prop > style.backgroundColor > solid style.background shorthand > bgColor / bgValue. + const sources: unknown[] = [ + namedBg === 'bgColor' || namedBg === 'backgroundColor' ? props[namedBg] : undefined, + style.backgroundColor, style.background, props.bgColor, type === 'color' ? props.bgValue : undefined, + ]; + for (const v of sources) { + const b = cssBg(v, design); + if (b !== 'none') return b; + } + if (BGCOLOR_DEFAULTS_TO_TEXT.has(name)) return cssBg(tokenRef('text'), design); + return 'none'; } function overlayPercent(node: SerializedTreeNode): number { @@ -147,7 +280,7 @@ function overlayPercent(node: SerializedTreeNode): number { function resolveBg(chain: SerializedTreeNode[], design: SiteDesign, rootBg: Bg): Bg { for (let i = chain.length - 1; i >= 0; i--) { const node = chain[i]; - const b = ownBg(node.props as Record, design); + const b = ownBg(node.type?.resolvedName ?? 'Component', node.props as Record, design); if (b === 'none') continue; if (b.kind === 'image') return { kind: 'image', node }; return b; @@ -162,9 +295,11 @@ class Repairer { private rootBg: Bg; constructor(private design: SiteDesign, rootBgOverride?: string) { - const raw = rootBgOverride ?? design.backgroundColor; - const rgb = toRgb(raw, design) ?? toRgb(design.backgroundColor, design); - this.rootBg = rgb ? { kind: 'flat', rgb } : { kind: 'unknown' }; + // An override is the ancestor background of a patched node (it may be a gradient / image / unreadable: + // then the text is not checked, never judged against the page colour). + const fromCtx = rootBgOverride !== undefined ? cssBg(rootBgOverride, design) : 'none'; + const page = cssBg(design.backgroundColor, design); + this.rootBg = fromCtx !== 'none' ? fromCtx : page !== 'none' ? page : UNKNOWN; } tree(root: SerializedTreeNode, ancestors: SerializedTreeNode[] = []): void { @@ -181,13 +316,13 @@ class Repairer { this.checkPair(node, name, chain, props, 'style.color', undefined, props.style); } // 3) Sections with an image background: text on top needs an overlay. - if (props.bgType === 'image' || props.bgImage) this.checkOverlay(node, name, props); + if ((props.bgType === 'image' || props.bgImage) && !opaqueOverlay(name, props, this.design)) this.checkOverlay(node, name, props); for (const c of node.nodes ?? []) this.tree(c, [...chain, node]); } private bgFor(node: SerializedTreeNode, chain: SerializedTreeNode[], props: Record, namedBg: string | undefined): Bg { - const own = ownBg(props, this.design, namedBg); + const own = ownBg(node.type?.resolvedName ?? 'Component', props, this.design, namedBg); if (own !== 'none') { if (own.kind === 'image') return { kind: 'image', node }; return own; @@ -207,13 +342,16 @@ class Repairer { if (bg.kind === 'image') { // Image behind the text: ensure an overlay on the node that owns the image. if (bg.node) this.checkOverlay(bg.node, bg.node.type?.resolvedName ?? 'Section', bg.node.props as Record, fg); + else this.notChecked++; // an image we only know from context (a patched node's ancestor) return; } const need = isLargeText(style ?? props.style) ? AA_LARGE : AA; - const before = contrastRatio(fg, bg.rgb); + const before = worstRatio(fg, bg.stops); if (before >= need) return; - const pick = nearestPassing(fg, bg.rgb, need, this.design); - if (!pick) return; + const pick = nearestPassing(fg, bg.stops, need, this.design); + const after = pick ? worstRatio(pick.rgb, bg.stops) : 0; + // Never make text worse: only apply a colour that reaches the target and strictly beats the old one. + if (!pick || after < need || !(after > before)) { this.notChecked++; return; } const target = fgKey === 'style.color' ? props.style : props; const key = fgKey === 'style.color' ? 'color' : fgKey; const from = String(target[key]); @@ -223,15 +361,17 @@ class Repairer { text: quote(plain(props.text ?? props.html ?? props.heading ?? props.label ?? '')) || undefined, from, to: pick.value, ratioBefore: Math.round(before * 100) / 100, - ratioAfter: Math.round(contrastRatio(pick.rgb, bg.rgb) * 100) / 100, + ratioAfter: Math.round(after * 100) / 100, }); } private checkOverlay(node: SerializedTreeNode, name: string, props: Record, fgHint?: RGB): void { if (this.overlaid.has(node)) return; - if (!(name in OVERLAY_SCALE)) { this.notChecked++; return; } // an image we cannot add an overlay to + // Each image area is counted once, however many text elements sit on it. + const skip = () => { this.overlaid.add(node); this.notChecked++; }; + if (!(name in OVERLAY_SCALE)) { skip(); return; } // an image we cannot add an overlay to const fg = fgHint ?? toRgb(props.textColor, this.design); - if (overlayPercent(node) >= MIN_OVERLAY) { this.notChecked++; return; } + if (overlayPercent(node) >= MIN_OVERLAY) { skip(); return; } if (!fg && name !== 'HeroSimple' && name !== 'CallToAction') { this.notChecked++; return; } const scale = OVERLAY_SCALE[name]; const light = fg ? (0.299 * fg[0] + 0.587 * fg[1] + 0.114 * fg[2]) / 255 > 0.5 : true; diff --git a/craft/src/utils/effective-background.test.ts b/craft/src/utils/effective-background.test.ts index 5a3f10a..b48f699 100644 --- a/craft/src/utils/effective-background.test.ts +++ b/craft/src/utils/effective-background.test.ts @@ -38,6 +38,19 @@ describe('effectiveBackgroundOf', () => { }); }); +describe('effectiveBackgroundOf: shorthand and section props', () => { + test('solid style.background shorthand counts; gradient/url still win over a colour', () => { + expect(effectiveBackgroundOf({ ROOT: n(null, {}), t: n('ROOT', { style: { background: '#123456' } }) }, 't')).toBe('#123456'); + expect(effectiveBackgroundOf({ t: n(null, { style: { background: 'linear-gradient(#000,#111)', backgroundColor: '#fff' } }) }, 't')).toMatch(/gradient/); + }); + test('HeroSimple / CallToAction / BackgroundSection props', () => { + expect(effectiveBackgroundOf({ h: n(null, { bgType: 'gradient', bgGradientFrom: '#000', bgGradientTo: '#111' }) }, 'h')).toBe('linear-gradient(#000, #111)'); + expect(effectiveBackgroundOf({ c: n(null, { bgType: 'gradient', bgValue: 'linear-gradient(#000,#111)' }) }, 'c')).toBe('linear-gradient(#000,#111)'); + expect(effectiveBackgroundOf({ h: n(null, { bgType: 'image', bgImage: 'a.jpg' }) }, 'h')).toMatch(/url\(/); + expect(effectiveBackgroundOf({ b: n(null, { bgColor: '#0f172a' }), t: n('b', {}) }, 't')).toBe('#0f172a'); + }); +}); + describe('isLargeText (WCAG: >=24px, or >=18.66px bold)', () => { test('px / rem / em', () => { expect(isLargeText('24px', '400')).toBe(true); diff --git a/craft/src/utils/effective-background.ts b/craft/src/utils/effective-background.ts index 2b04797..668e1bd 100644 --- a/craft/src/utils/effective-background.ts +++ b/craft/src/utils/effective-background.ts @@ -7,7 +7,8 @@ * 3. null (the caller falls back to the site Background token). * A gradient / image on the way is returned verbatim so the check reports * "not checked" instead of guessing. No stacking of translucent layers, no - * overlay inference, no computed styles. + * overlay inference, no computed styles. Section components (HeroSimple, CallToAction, + * BackgroundSection) report their bgGradientFrom/To, bgValue, bgImage and bgColor props the same way. */ interface NodeLike { data?: { parent?: string | null; props?: Record } } @@ -27,7 +28,13 @@ export function effectiveBackgroundOf(nodes: Record, id: strin const style = (props.style ?? {}) as Record; for (const v of [style.backgroundImage, style.background]) if (paintsImage(v)) return v; if (!blank(style.backgroundColor)) return String(style.backgroundColor); + if (!blank(style.background)) return String(style.background); // solid shorthand (image/gradient handled above) if (!blank(props.backgroundColor)) return String(props.backgroundColor); + // Section components keep their background in props, not style. + if (props.bgType === 'gradient' && typeof props.bgValue === 'string' && paintsImage(props.bgValue)) return props.bgValue; + if (props.bgType === 'gradient') return `linear-gradient(${props.bgGradientFrom ?? 'var(--wsb-primary)'}, ${props.bgGradientTo ?? 'var(--wsb-secondary)'})`; + if (props.bgType === 'image' || props.bgType === 'video' || (typeof props.bgImage === 'string' && props.bgImage)) return `url(${props.bgImage ?? ''})`; + if (!blank(props.bgColor)) return String(props.bgColor); if (paintsImage(props.backgroundColor)) return props.backgroundColor; cur = nodes[cur].data?.parent; }