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:
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."
|
||||
}
|
||||
@@ -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; }
|
||||
};
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user