From 770a30d7b8d0c802a67ea8c5bcc880744b8a442b Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Mon, 7 Sep 2026 14:45:11 +0100 Subject: [PATCH 1/2] fix(theme): complete the tenant token contract with a legibility floor Tenants set eight colours but the provider mapped only five shadcn tokens, leaving card, popover, muted, border, input, ring and every *-foreground at the light root defaults. Dark palettes rendered white text on white cards (LekkerWeed dashboard/orders) and pages that hardcoded grey text only worked because cards happened to be white. - lib/theme/tenant-tokens.ts derives the full token set from the palette, fills unset colours, computes button foregrounds by luminance and swaps any foreground below 3:1 for black or white. Unit-tested against the LekkerWeed, HealingBuds and white-on-white palettes. - Provider computes the variables in render and inlines them on the container, so they are server-rendered (no first-paint flash) and the editor preview matches live. - Nav, footer and section colour overrides (store layout + template renderer) go through the same check, re-validating inherited text when a section changes its background. - Input, Textarea, Select, Dialog and Sheet carry text-foreground. - Consultation steps, ID upload, settings, order detail, register, login and how-it-works use semantic tokens instead of text-gray-*/bg-white. - Branding form warns below 4.5:1 and says when the storefront will substitute. --- .../how-it-works/how-it-works-client.tsx | 4 +- nextjs_space/app/store/[slug]/layout.tsx | 36 ++- .../app/store/[slug]/login/login-form.tsx | 2 +- .../store/[slug]/orders/[orderId]/page.tsx | 10 +- .../app/store/[slug]/register/page.tsx | 4 +- .../app/store/[slug]/settings/page.tsx | 56 ++-- .../branding/tabs/colours-tab.tsx | 17 ++ .../branding/tabs/contrast-hint.tsx | 87 ++++++ .../branding/tabs/section-colour-panel.tsx | 2 + .../consultation/consultation-form.tsx | 4 +- .../consultation/id-upload-form.tsx | 4 +- .../consultation/steps/address-step.tsx | 10 +- .../consultation/steps/business-info-step.tsx | 4 +- .../steps/contact-details-step.tsx | 8 +- .../consultation/steps/id-upload-step.tsx | 10 +- .../steps/medical-conditions-step.tsx | 4 +- .../steps/medical-history-part1-step.tsx | 16 +- .../steps/medical-history-part2-step.tsx | 16 +- .../steps/shipping-address-step.tsx | 4 +- nextjs_space/components/template-renderer.tsx | 67 ++--- .../components/tenant-theme-provider.tsx | 210 +++---------- nextjs_space/components/ui/dialog.tsx | 2 +- nextjs_space/components/ui/input.tsx | 2 +- nextjs_space/components/ui/select.tsx | 2 +- nextjs_space/components/ui/sheet.tsx | 2 +- nextjs_space/components/ui/textarea.tsx | 2 +- nextjs_space/lib/theme/tenant-tokens.ts | 278 ++++++++++++++++++ nextjs_space/tests/unit/tenant-tokens.test.ts | 190 ++++++++++++ 28 files changed, 740 insertions(+), 313 deletions(-) create mode 100644 nextjs_space/app/tenant-admin/branding/tabs/contrast-hint.tsx create mode 100644 nextjs_space/lib/theme/tenant-tokens.ts create mode 100644 nextjs_space/tests/unit/tenant-tokens.test.ts diff --git a/nextjs_space/app/store/[slug]/how-it-works/how-it-works-client.tsx b/nextjs_space/app/store/[slug]/how-it-works/how-it-works-client.tsx index 3a6a6e99..5feb4df1 100644 --- a/nextjs_space/app/store/[slug]/how-it-works/how-it-works-client.tsx +++ b/nextjs_space/app/store/[slug]/how-it-works/how-it-works-client.tsx @@ -30,8 +30,8 @@ export function HowItWorksClient() { if (loading) { return ( -
-

{t("common.loading")}

+
+

{t("common.loading")}

); } diff --git a/nextjs_space/app/store/[slug]/layout.tsx b/nextjs_space/app/store/[slug]/layout.tsx index 179b11a9..574a1845 100644 --- a/nextjs_space/app/store/[slug]/layout.tsx +++ b/nextjs_space/app/store/[slug]/layout.tsx @@ -20,7 +20,7 @@ import { AutomatosWidgetWrapper } from "@/components/admin/AutomatosWidgetWrappe import { Ga4Tag } from "@/components/seo/ga4-tag"; import { parseTenantSettings } from "@/lib/tenant/tenant-settings"; import { sanitizeCss, extractGoogleFontsImports } from "@/lib/security/css-utils"; -import { hexToHsl } from "@/lib/color-utils"; +import { buildColorOverrideVars } from "@/lib/theme/tenant-tokens"; import type { Metadata } from "next"; import { buildStoreMetadata, @@ -263,20 +263,21 @@ async function renderTenantStore( pageContent: (activeTemplate?.pageContent as any) || defaults?.pageContent || {}, }; + // The palette the nav/footer override legibility check is made against — + // the same designSystem fallback order TenantThemeProvider uses. + const paletteBase = designSystem?.colors || settings.designSystem?.colors; + // Render navigation const renderNavigation = () => { // Data-driven: use section component from layout.json if (layout?.navigation) { const NavComponent = getSectionComponent(layout.navigation); if (NavComponent) { - const navOverrides = layout.navigationConfig?.colorOverrides; - const navStyle: CSSProperties | undefined = navOverrides - ? (Object.fromEntries( - Object.entries(navOverrides) - .filter(([, v]) => v && typeof v === 'string' && v.trim()) - .map(([k, v]) => [`--tenant-color-${k}`, (v as string).startsWith('#') ? hexToHsl(v as string) : v]) - ) as CSSProperties) - : undefined; + // Legibility-checked against the tenant palette (lib/theme/tenant-tokens) + const navVars = buildColorOverrideVars(layout.navigationConfig?.colorOverrides, { + base: paletteBase, + }); + const navStyle = Object.keys(navVars).length > 0 ? (navVars as CSSProperties) : undefined; return (
@@ -312,17 +313,14 @@ async function renderTenantStore( if (FooterComponent) { const isDarkFooter = layout.footer === 'FooterBrand' || layout.footer === 'FooterFull'; const darkDefaults: Record = isDarkFooter - ? { '--tenant-color-background': '220 15% 10%', '--tenant-color-text': '0 0% 100%', '--tenant-color-heading': '0 0% 100%', '--tenant-color-border': '0 0% 100%' } - : {}; - const footerOverrides = layout.footerConfig?.colorOverrides; - const overrideEntries = footerOverrides - ? Object.fromEntries( - Object.entries(footerOverrides) - .filter(([, v]) => v && typeof v === 'string' && v.trim()) - .map(([k, v]) => [`--tenant-color-${k}`, (v as string).startsWith('#') ? hexToHsl(v as string) : v]) - ) + ? { background: '220 15% 10%', text: '0 0% 100%', heading: '0 0% 100%', border: '0 0% 100%' } : {}; - const footerStyle = { ...darkDefaults, ...overrideEntries } as CSSProperties; + // Overrides layer on the dark defaults; both are legibility-checked + // against the tenant palette (lib/theme/tenant-tokens). + const footerStyle = buildColorOverrideVars(layout.footerConfig?.colorOverrides, { + defaults: darkDefaults, + base: paletteBase, + }) as CSSProperties; return (
0 ? footerStyle : undefined}> diff --git a/nextjs_space/app/store/[slug]/login/login-form.tsx b/nextjs_space/app/store/[slug]/login/login-form.tsx index d1c4f651..982097e6 100644 --- a/nextjs_space/app/store/[slug]/login/login-form.tsx +++ b/nextjs_space/app/store/[slug]/login/login-form.tsx @@ -22,7 +22,7 @@ function LoginFormInner({ businessName, logoUrl, basePath }: TenantLoginFormProp /> )}

Sign in to {businessName}

-

Welcome back! Please sign in to continue

+

Welcome back! Please sign in to continue

; default: - return ; + return ; } }; @@ -91,8 +91,8 @@ export default async function OrderConfirmationPage({ {getStatusIcon(order.paymentStatus)}

{status.title}

-

{status.message}

-

+

{status.message}

+

Order Number:{" "} {order.drGreenInvoiceNum || order.orderNumber}

@@ -149,11 +149,11 @@ export default async function OrderConfirmationPage({ return ( <>
- Subtotal + Subtotal {currency} {order.subtotal.toFixed(2)}
- Shipping + Shipping {currency} {order.shippingCost.toFixed(2)}
diff --git a/nextjs_space/app/store/[slug]/register/page.tsx b/nextjs_space/app/store/[slug]/register/page.tsx index 658a7ee5..436e855d 100644 --- a/nextjs_space/app/store/[slug]/register/page.tsx +++ b/nextjs_space/app/store/[slug]/register/page.tsx @@ -24,10 +24,10 @@ export default function RegisterRedirectPage() { }, [slug, router]); return ( -
+
-

Redirecting to eligibility check...

+

Redirecting to eligibility check...

); diff --git a/nextjs_space/app/store/[slug]/settings/page.tsx b/nextjs_space/app/store/[slug]/settings/page.tsx index fe46d6a7..c37eacb1 100644 --- a/nextjs_space/app/store/[slug]/settings/page.tsx +++ b/nextjs_space/app/store/[slug]/settings/page.tsx @@ -98,7 +98,7 @@ export default function SettingsPage() {
-

Loading...

+

Loading...

); @@ -109,21 +109,20 @@ export default function SettingsPage() { } return ( -
+
{/* Header */}
- Customer Dashboard -

+ Customer Dashboard +

Account Settings

-

Manage your account information

+

Manage your account information

{!isEditing && ( @@ -131,9 +130,9 @@ export default function SettingsPage() {
{/* Profile Information */} -
-

- +
+

+ Personal Information

@@ -151,7 +150,7 @@ export default function SettingsPage() { className="mt-1" /> ) : ( -
+
{formData.firstName || "Not set"}
)} @@ -169,7 +168,7 @@ export default function SettingsPage() { className="mt-1" /> ) : ( -
+
{formData.lastName || "Not set"}
)} @@ -179,11 +178,11 @@ export default function SettingsPage() {
-
- +
+ {user.primaryEmailAddress?.emailAddress}
-

+

Email cannot be changed

@@ -192,7 +191,7 @@ export default function SettingsPage() { {isEditing ? (
- +
) : ( -
- +
+ {formData.phone || "Not set"}
)} @@ -216,8 +215,8 @@ export default function SettingsPage() {
-
- +
+ {(user.publicMetadata?.role as string) || "PATIENT"}
@@ -247,9 +246,9 @@ export default function SettingsPage() {
{/* Address Information */} -
-

- +
+

+ Address

@@ -267,7 +266,7 @@ export default function SettingsPage() { placeholder="Street address" /> ) : ( -
+
{formData.addressLine1 || "Not set"}
)} @@ -286,7 +285,7 @@ export default function SettingsPage() { placeholder="Apartment, suite, etc." /> ) : ( -
+
{formData.addressLine2 || "Not set"}
)} @@ -305,7 +304,7 @@ export default function SettingsPage() { className="mt-1" /> ) : ( -
+
{formData.city || "Not set"}
)} @@ -323,7 +322,7 @@ export default function SettingsPage() { className="mt-1" /> ) : ( -
+
{formData.state || "Not set"}
)} @@ -341,7 +340,7 @@ export default function SettingsPage() { className="mt-1" /> ) : ( -
+
{formData.postalCode || "Not set"}
)} @@ -360,7 +359,7 @@ export default function SettingsPage() { className="mt-1" /> ) : ( -
+
{formData.country || "Not set"}
)} @@ -371,7 +370,6 @@ export default function SettingsPage() {
+ {/* Navigation & Footer Color Overrides */} @@ -214,6 +229,7 @@ export function ColoursTab({
))}
+
@@ -278,6 +294,7 @@ export function ColoursTab({
))}
+
diff --git a/nextjs_space/app/tenant-admin/branding/tabs/contrast-hint.tsx b/nextjs_space/app/tenant-admin/branding/tabs/contrast-hint.tsx new file mode 100644 index 00000000..647d2ffc --- /dev/null +++ b/nextjs_space/app/tenant-admin/branding/tabs/contrast-hint.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { AlertTriangle } from "lucide-react"; +import { + MIN_CONTRAST, + WARN_CONTRAST, + contrastRatio, + toHslChannels, +} from "@/lib/theme/tenant-tokens"; + +export interface ContrastPair { + label: string; + foreground?: string | null; + background?: string | null; +} + +interface ContrastWarning { + label: string; + ratio: number; + substituted: boolean; +} + +/** + * The pairs that fall below the readability guideline, and whether the + * storefront will substitute black or white for them (the legibility floor in + * lib/theme/tenant-tokens). Pairs with an unparseable colour are skipped. + */ +export function contrastWarnings(pairs: ContrastPair[]): ContrastWarning[] { + return pairs.flatMap(({ label, foreground, background }) => { + const fg = toHslChannels(foreground); + const bg = toHslChannels(background); + if (!fg || !bg) return []; + const ratio = contrastRatio(fg, bg); + return ratio < WARN_CONTRAST + ? [{ label, ratio, substituted: ratio < MIN_CONTRAST }] + : []; + }); +} + +/** + * The text-on-background pairs a nav, footer or section override changes. + * Colours the override leaves unset inherit from the brand palette, exactly as + * the storefront resolves them. Overrides that touch none of the three colours + * produce no pairs, so the brand-level warning is not repeated. + */ +export function overridePairs( + overrides: Record | undefined, + base: { textColor: string; headingColor: string; backgroundColor: string }, + scope: string, +): ContrastPair[] { + if (!overrides) return []; + const touched = ["background", "text", "heading"].some((key) => overrides[key]); + if (!touched) return []; + const background = overrides.background || base.backgroundColor; + return [ + { + label: `${scope} body text on its background`, + foreground: overrides.text || base.textColor, + background, + }, + { + label: `${scope} heading text on its background`, + foreground: overrides.heading || base.headingColor, + background, + }, + ]; +} + +export function ContrastHint({ pairs }: { pairs: ContrastPair[] }) { + const warnings = contrastWarnings(pairs); + if (warnings.length === 0) return null; + return ( +
    + {warnings.map((warning) => ( +
  • +
  • + ))} +
+ ); +} diff --git a/nextjs_space/app/tenant-admin/branding/tabs/section-colour-panel.tsx b/nextjs_space/app/tenant-admin/branding/tabs/section-colour-panel.tsx index 49fa3a68..d296aefc 100644 --- a/nextjs_space/app/tenant-admin/branding/tabs/section-colour-panel.tsx +++ b/nextjs_space/app/tenant-admin/branding/tabs/section-colour-panel.tsx @@ -2,6 +2,7 @@ import { X } from "lucide-react"; import { ColorPicker } from "./shared"; +import { ContrastHint, overridePairs } from "./contrast-hint"; import type { EditorFormData, SetFormData } from "./types"; /** Colour override groups — shared with ColoursTab global palette. Kept here @@ -134,6 +135,7 @@ export function SectionColourPanel({

))}
+
); } diff --git a/nextjs_space/components/consultation/consultation-form.tsx b/nextjs_space/components/consultation/consultation-form.tsx index a3542813..86114b54 100644 --- a/nextjs_space/components/consultation/consultation-form.tsx +++ b/nextjs_space/components/consultation/consultation-form.tsx @@ -213,10 +213,10 @@ export function ConsultationForm({ {/* Progress Bar */}
- + Step {currentStep} of {TOTAL_STEPS} - + {STEP_NAMES[currentStep - 1]}
diff --git a/nextjs_space/components/consultation/id-upload-form.tsx b/nextjs_space/components/consultation/id-upload-form.tsx index 16d74be6..b4361fac 100644 --- a/nextjs_space/components/consultation/id-upload-form.tsx +++ b/nextjs_space/components/consultation/id-upload-form.tsx @@ -211,10 +211,10 @@ export function IdUploadForm({ tenantSlug }: IdUploadFormProps) { {/* Progress Bar */}
- + Step {currentStep} of {TOTAL_STEPS} - + {STEP_NAMES[currentStep - 1]}
diff --git a/nextjs_space/components/consultation/steps/address-step.tsx b/nextjs_space/components/consultation/steps/address-step.tsx index 2ac8c3b8..438f10b2 100644 --- a/nextjs_space/components/consultation/steps/address-step.tsx +++ b/nextjs_space/components/consultation/steps/address-step.tsx @@ -56,10 +56,10 @@ export function AddressStep({ {/* Shipping Address Section */}
-

+

Shipping Address

-

Where should we deliver your order?

+

Where should we deliver your order?

@@ -158,15 +158,15 @@ export function AddressStep({
{/* Divider */} -
+
{/* Business Address Section */}
-

+

Business Information

-

+

Optional - Only complete if ordering for a business

diff --git a/nextjs_space/components/consultation/steps/business-info-step.tsx b/nextjs_space/components/consultation/steps/business-info-step.tsx index c54873bd..99817d95 100644 --- a/nextjs_space/components/consultation/steps/business-info-step.tsx +++ b/nextjs_space/components/consultation/steps/business-info-step.tsx @@ -34,10 +34,10 @@ export function BusinessInfoStep({ return (
-

+

Type of Business

-

+

Optional - Only complete if ordering for a business

diff --git a/nextjs_space/components/consultation/steps/contact-details-step.tsx b/nextjs_space/components/consultation/steps/contact-details-step.tsx index e7fc5e2e..eb849d5d 100644 --- a/nextjs_space/components/consultation/steps/contact-details-step.tsx +++ b/nextjs_space/components/consultation/steps/contact-details-step.tsx @@ -72,10 +72,10 @@ export function ContactDetailsStep({ return (
-

+

Contact Details

-

+

Please provide your personal information

@@ -304,7 +304,7 @@ export function ContactDetailsStep({ @@ -327,7 +327,7 @@ export function ContactDetailsStep({ diff --git a/nextjs_space/components/consultation/steps/id-upload-step.tsx b/nextjs_space/components/consultation/steps/id-upload-step.tsx index 8aee35eb..67a85ddb 100644 --- a/nextjs_space/components/consultation/steps/id-upload-step.tsx +++ b/nextjs_space/components/consultation/steps/id-upload-step.tsx @@ -66,10 +66,10 @@ export function IdUploadStep({ return (
-

+

Verify your identity

-

+

Upload a clear photo of a valid government ID (National ID, passport or driving licence). It must be your actual ID document — selfies or other photos will be rejected. @@ -110,16 +110,16 @@ export function IdUploadStep({