fix(sitesmith): contrast repair resolves the real background (gradients, shorthand, section props)

Contrast auto-repair read only style.backgroundColor / bgColor, so white text on a
gradient hero (style.background) was "repaired" against the white page colour.

- Resolve style.background (solid shorthand, gradient, url), HeroSimple bgGradient*/
  bgColor defaults, CallToAction bgValue, BackgroundSection bgColor, opaque image overlay.
- Gradients: every stop is checked, worst stop decides; unparseable stops (color-mix,
  unresolved token, translucent, layered) = not checked. An unresolvable background no
  longer falls through to an outer/page colour.
- A repair is applied only if it reaches the target and strictly beats the old colour
  against the worst stop.
- clamp() font sizes use their minimum for the large-text test.
- Patch path: ancestor background now comes from effectiveBackgroundOf (which learns
  style.background shorthand and section props) instead of a private walker.
- Tests: real ttm-likes and ols-home responses as fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 15:31:34 -08:00
1 parent c71a6b64a6
commit 96274a6aa8
7 files changed
+2283 -39

No files matched your search

@@ -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."
}
@@ -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."
}
+4 -9
View File
@@ -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<string, any>;
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<string, any>, parent) ?? undefined : undefined;
return { props, ancestorBg };
} catch { return undefined; }
};
+192
View File
@@ -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<string, unknown> = {}) => n('Heading', { text: 'Hi', style: { color, fontSize: '16px', ...extra } });
const on = (bg: Record<string, unknown>, 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<string, any>;
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([]);
});
});
+169 -29
View File
@@ -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<string, any>; 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<string, any>, 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<string, any>, 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<string, any>, 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<string, any>;
// 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<string, any>, design);
const b = ownBg(node.type?.resolvedName ?? 'Component', node.props as Record<string, any>, 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<string, any>, 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<string, any>, 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<string, any>, 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;
@@ -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);
+8 -1
View File
@@ -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<string, any> } }
@@ -27,7 +28,13 @@ export function effectiveBackgroundOf(nodes: Record<string, NodeLike>, id: strin
const style = (props.style ?? {}) as Record<string, unknown>;
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;
}