Private/ConvertTo-DhBrandCss.ps1

<#
    Brand colours and font (DashHtml 3.0.0).

    New-DhDashboard -PrimaryColor / -SecondaryColor / -AccentColor / -FontFamily
    are applied as one extra :root block placed after a theme's own :root block,
    in both the light and the dark <style> tag, so nothing in the structural CSS
    or the runtime changes. This file is the one place that turns the brand into
    tokens:

      Primary --accent-primary, --chart-1 (the colour as given in light mode),
                --accent-primary-fg (black or white, whichever reads better),
                --text-accent, --nav-active-fg, --nav-link-hover-fg (a shade of
                the same hue that clears 4.5:1 on every background the theme
                defines), the row hover / selection tints, the nav tints, the
                strong border and the glow.
      Secondary --bg-header (the header band) and --accent-secondary, with the
                header text black or white. Defaults to a darker shade of Primary.
                --bg-header-end, the gradient's other stop, is the Primary hue as
                bright as the header text allows (5.1:1, so muted text keeps 4.5:1).
      Accent --chart-2 and --header-line (the line under the header).
      Font --font-ui and --font-display: the family first, the theme's own
                stack after it.

    Dark mode uses lighter shades of the same hues, found the same way, and a deep
    tint of Secondary for the band (at least 1.30 against the page, the 2.7.0 band
    rule). Status colours (ok / warn / danger / nodata), the RAG and range ramps
    are never touched: severity keeps its meaning in every brand.

    Contrast that cannot be reached is a warning, never an error (rule D4).
#>


function ConvertFrom-DhCssColor {
    <# '#rgb' / '#rrggbb' / '#rrggbbaa' / 'rgba(r,g,b,a)' -> @(r, g, b, a) with r/g/b in 0..1; $null otherwise. #>
    param([string] $Value)
    $v = $Value.Trim()
    if ($v -match '^#([0-9a-fA-F]{3})$') {
        $h = $Matches[1]
        return @(([Convert]::ToInt32("$($h[0])$($h[0])", 16) / 255), ([Convert]::ToInt32("$($h[1])$($h[1])", 16) / 255), ([Convert]::ToInt32("$($h[2])$($h[2])", 16) / 255), 1.0)
    }
    if ($v -match '^#([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$') {
        $h = $Matches[1]
        $a = if ($Matches[2]) { [Convert]::ToInt32($Matches[2], 16) / 255 } else { 1.0 }
        return @(([Convert]::ToInt32($h.Substring(0, 2), 16) / 255), ([Convert]::ToInt32($h.Substring(2, 2), 16) / 255), ([Convert]::ToInt32($h.Substring(4, 2), 16) / 255), $a)
    }
    if ($v -match '^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$') {
        $a = if ($Matches[4]) { [double]$Matches[4] } else { 1.0 }
        return @(([double]$Matches[1] / 255), ([double]$Matches[2] / 255), ([double]$Matches[3] / 255), $a)
    }
    return $null
}

function ConvertTo-DhHexColor {
    param([double[]] $Rgb)
    '#' + (($Rgb[0..2] | ForEach-Object { '{0:X2}' -f [int][math]::Round([math]::Min(1.0, [math]::Max(0.0, $_)) * 255) }) -join '')
}

function ConvertTo-DhRgbaCss {
    param([double[]] $Rgb, [double] $Alpha)
    $c = $Rgb[0..2] | ForEach-Object { [int][math]::Round($_ * 255) }
    "rgba($($c[0]),$($c[1]),$($c[2]),$($Alpha.ToString([Globalization.CultureInfo]::InvariantCulture)))"
}

function ConvertTo-DhHsl {
    <# rgb (0..1) -> @(h 0..360, s 0..1, l 0..1) #>
    param([double[]] $Rgb)
    $r, $g, $b = $Rgb[0], $Rgb[1], $Rgb[2]
    $max = [math]::Max($r, [math]::Max($g, $b)); $min = [math]::Min($r, [math]::Min($g, $b))
    $l = ($max + $min) / 2; $d = $max - $min
    if ($d -eq 0) { return @(0.0, 0.0, $l) }
    $s = if ($l -gt 0.5) { $d / (2 - $max - $min) } else { $d / ($max + $min) }
    $h = if ($max -eq $r) { (($g - $b) / $d) + $(if ($g -lt $b) { 6 } else { 0 }) }
         elseif ($max -eq $g) { (($b - $r) / $d) + 2 }
         else { (($r - $g) / $d) + 4 }
    return @(($h * 60), $s, $l)
}

function ConvertFrom-DhHsl {
    param([double] $H, [double] $S, [double] $L)
    if ($S -eq 0) { return @($L, $L, $L, 1.0) }
    $q = if ($L -lt 0.5) { $L * (1 + $S) } else { $L + $S - $L * $S }
    $p = 2 * $L - $q
    $hk = $H / 360
    $out = foreach ($t in @(($hk + 1/3), $hk, ($hk - 1/3))) {
        $tt = $t; if ($tt -lt 0) { $tt += 1 }; if ($tt -gt 1) { $tt -= 1 }
        if ($tt -lt 1/6) { $p + ($q - $p) * 6 * $tt }
        elseif ($tt -lt 1/2) { $q }
        elseif ($tt -lt 2/3) { $p + ($q - $p) * (2/3 - $tt) * 6 }
        else { $p }
    }
    return @($out[0], $out[1], $out[2], 1.0)
}

function Get-DhRelativeLuminance {
    <# WCAG 2.x relative luminance of an opaque rgb (0..1). #>
    param([double[]] $Rgb)
    # Plain arithmetic, no pipeline: this runs a few thousand times per brand.
    $r = $Rgb[0]; $g = $Rgb[1]; $b = $Rgb[2]
    $r = if ($r -le 0.03928) { $r / 12.92 } else { [math]::Pow(($r + 0.055) / 1.055, 2.4) }
    $g = if ($g -le 0.03928) { $g / 12.92 } else { [math]::Pow(($g + 0.055) / 1.055, 2.4) }
    $b = if ($b -le 0.03928) { $b / 12.92 } else { [math]::Pow(($b + 0.055) / 1.055, 2.4) }
    return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b
}

function Get-DhContrastRatio {
    param([double[]] $A, [double[]] $B)
    $la = Get-DhRelativeLuminance $A; $lb = Get-DhRelativeLuminance $B
    return ([math]::Max($la, $lb) + 0.05) / ([math]::Min($la, $lb) + 0.05)
}

function Merge-DhColor {
    <# $Fg at $Alpha over an opaque $Bg. #>
    param([double[]] $Fg, [double[]] $Bg, [double] $Alpha)
    return @(($Fg[0] * $Alpha + $Bg[0] * (1 - $Alpha)), ($Fg[1] * $Alpha + $Bg[1] * (1 - $Alpha)), ($Fg[2] * $Alpha + $Bg[2] * (1 - $Alpha)), 1.0)
}

function Find-DhShade {
    <#
        The shade of $Rgb's hue (lightness moved towards black or white) closest to
        the original that reaches $Min contrast against every colour in
        $Backgrounds. Returns @{ Rgb; Ok; Ratio }; Ok is $false when even the
        extreme does not reach it (the extreme is returned).
    #>

    param([double[]] $Rgb, [object[]] $Backgrounds, [double] $Min, [ValidateSet('darker','lighter')] [string] $Direction)
    $hsl = ConvertTo-DhHsl $Rgb
    $worst = { param($c) $m = [double]::MaxValue; foreach ($bg in $Backgrounds) { $r = Get-DhContrastRatio $c $bg; if ($r -lt $m) { $m = $r } }; $m }
    $ratio = & $worst $Rgb
    if ($ratio -ge $Min) { return @{ Rgb = $Rgb; Ok = $true; Ratio = $ratio } }
    $l = $hsl[2]
    $step = if ($Direction -eq 'darker') { -0.01 } else { 0.01 }
    $cand = $Rgb
    while (($Direction -eq 'darker' -and $l -gt 0) -or ($Direction -eq 'lighter' -and $l -lt 1)) {
        $l = [math]::Min(1.0, [math]::Max(0.0, $l + $step))
        $cand = ConvertFrom-DhHsl $hsl[0] $hsl[1] $l
        $ratio = & $worst $cand
        if ($ratio -ge $Min) { return @{ Rgb = $cand; Ok = $true; Ratio = $ratio } }
    }
    return @{ Rgb = $cand; Ok = $false; Ratio = $ratio }
}

function Get-DhThemeTokens {
    <# '--name: value;' pairs of a theme's :root block, as an ordered name -> value map. #>
    param([string] $RootCss)
    $t = [ordered]@{}
    foreach ($m in [regex]::Matches($RootCss, '--([a-z0-9-]+)\s*:\s*([^;]+);')) { $t[$m.Groups[1].Value] = $m.Groups[2].Value.Trim() }
    return $t
}

function ConvertTo-DhBrandCss {
    <#
    .SYNOPSIS
        The brand override :root block for one theme variant, and the contrast
        warnings it raises.

    .PARAMETER Brand
        The report's Brand map: PrimaryColor, SecondaryColor, AccentColor (hex,
        each optional) and FontFamily.

    .PARAMETER Theme
        The internal theme name (DefaultLight, CompanyDark ...). Its own tokens
        are the backgrounds the derived colours are checked against.

    .OUTPUTS
        @{ Css = [string]; Warnings = [string[]] } Css is '' when nothing applies.
    #>

    param(
        [Parameter(Mandatory)] [System.Collections.IDictionary] $Brand,
        [Parameter(Mandatory)] [string] $Theme
    )

    $tok  = Get-DhThemeTokens (Get-DhThemeCss -Theme $Theme -RootOnly)
    $dark = $Theme -like '*Dark'
    $mode = if ($dark) { 'dark' } else { 'light' }
    $dir  = if ($dark) { 'lighter' } else { 'darker' }
    $warn = [System.Collections.Generic.List[string]]::new()
    $out  = [System.Collections.Generic.List[string]]::new()
    $col  = { param($name) if ($tok.Contains($name)) { ConvertFrom-DhCssColor $tok[$name] } else { $null } }
    $opaque = { param($c, $under) if ($null -eq $c) { $null } elseif ($c[3] -lt 1) { Merge-DhColor $c $under $c[3] } else { $c } }
    $hex  = { param($c) ConvertTo-DhHexColor $c }
    $fmt  = { param([double] $r) $r.ToString('0.00', [Globalization.CultureInfo]::InvariantCulture) }

    $page    = & $col 'bg-page'
    $surface = & $col 'bg-surface'
    $table   = & $col 'bg-table'
    if (-not $table) { $table = $surface }
    $navBg   = & $col 'nav-bg'
    if (-not $navBg) { $navBg = $surface }
    # Every background accent-coloured text can land on (the 2.7.0 token contract, rule 2).
    $textBgs = @(foreach ($n in 'bg-surface', 'bg-page', 'bg-row-alt', 'bg-thead', 'chart-container-bg', 'bg-table') {
        $c = & $col $n; if ($c) { , (& $opaque $c $page) }   # the comma keeps each colour one array
    })
    $white = @(1.0, 1.0, 1.0, 1.0)
    $ink   = ConvertFrom-DhCssColor '#10151B'
    $pickFg = { param($bg) if ((Get-DhContrastRatio $white $bg) -ge (Get-DhContrastRatio $ink $bg)) { $white } else { $ink } }

    $primary = if ($Brand.Contains('PrimaryColor') -and $Brand.PrimaryColor) { ConvertFrom-DhCssColor $Brand.PrimaryColor } else { $null }
    if ($primary) {
        # Light: the colour as given. Dark: a lighter shade of it that reads on the page.
        $accent = $primary
        if ($dark) { $accent = (Find-DhShade $primary $textBgs 4.5 'lighter').Rgb }
        elseif ((Get-DhContrastRatio $accent $surface) -lt 4.5) {
            $warn.Add("-PrimaryColor $($Brand.PrimaryColor) measures $(& $fmt (Get-DhContrastRatio $accent $surface)):1 on the $mode surface; links and accent-coloured values need 4.5:1 - a darker shade reads better (the menu and accent text use one automatically).")
        }
        $txt = Find-DhShade $accent $textBgs 4.5 $dir
        if (-not $txt.Ok) { $warn.Add("-PrimaryColor: no shade of the hue reaches 4.5:1 on every $mode background (best $(& $fmt $txt.Ratio):1).") }
        $aAct = if ($dark) { 0.14 } else { 0.08 }
        $aHov = if ($dark) { 0.10 } else { 0.06 }
        $navBgs = @($navBg, (Merge-DhColor $accent $navBg $aAct), (Merge-DhColor $accent $navBg $aHov))
        $nav = Find-DhShade $accent $navBgs 4.5 $dir
        if (-not $nav.Ok) { $warn.Add("-PrimaryColor: the selected menu item measures $(& $fmt $nav.Ratio):1 in $mode mode (needs 4.5:1).") }
        $hover = Merge-DhColor $accent $table $(if ($dark) { 0.24 } else { 0.14 })
        $sel   = Merge-DhColor $accent $table $(if ($dark) { 0.16 } else { 0.09 })
        $textPrimary = & $opaque (& $col 'text-primary') $page
        if ($textPrimary -and (Get-DhContrastRatio $textPrimary $hover) -lt 4.5) {
            $warn.Add("-PrimaryColor: row text on the hover tint measures $(& $fmt (Get-DhContrastRatio $textPrimary $hover)):1 in $mode mode (needs 4.5:1).")
        }
        $out.Add(" /* -PrimaryColor $($Brand.PrimaryColor) */")
        $out.Add(" --accent-primary: $(& $hex $accent);")
        $out.Add(" --accent-primary-fg: $(& $hex (& $pickFg $accent));")
        $out.Add(" --text-accent: $(& $hex $txt.Rgb);")
        $out.Add(" --chart-1: $(& $hex $accent);")
        $out.Add(" --nav-active-fg: $(& $hex $nav.Rgb);")
        $out.Add(" --nav-link-hover-fg: $(& $hex $nav.Rgb);")
        $out.Add(" --nav-active-bg: $(ConvertTo-DhRgbaCss $accent $aAct);")
        $out.Add(" --nav-link-hover-bg: $(ConvertTo-DhRgbaCss $accent $aHov);")
        $out.Add(" --nav-active-border: $(ConvertTo-DhRgbaCss $accent $(if ($dark) { 0.35 } else { 0.40 }));")
        $out.Add(" --bg-row-hover: $(& $hex $hover);")
        $out.Add(" --bg-row-sel: $(& $hex $sel);")
        $out.Add(" --border-strong: $(ConvertTo-DhRgbaCss $accent $(if ($dark) { 0.27 } else { 0.33 }));")
        $out.Add(" --shadow-glow: 0 0 $(if ($dark) { 16 } else { 12 })px $(ConvertTo-DhRgbaCss $accent $(if ($dark) { 0.13 } else { 0.15 }));")
    }

    # Secondary: given, or a darker shade of Primary so one colour rebrands the whole report.
    $second = if ($Brand.Contains('SecondaryColor') -and $Brand.SecondaryColor) { ConvertFrom-DhCssColor $Brand.SecondaryColor } else { $null }
    $secondLabel = if ($second) { "-SecondaryColor $($Brand.SecondaryColor)" } else { '' }
    if (-not $second -and $primary) {
        $h = ConvertTo-DhHsl $primary
        $second = ConvertFrom-DhHsl $h[0] $h[1] ($h[2] * 0.72)
        $secondLabel = "-SecondaryColor (from -PrimaryColor) $(& $hex $second)"
    }
    if ($second) {
        $band = $second
        if ($dark) {
            # A deep tint of the hue, just lighter than the page (2.7.0 band rule: >= 1.30).
            $h = ConvertTo-DhHsl $second
            $l = 0.10
            do { $band = ConvertFrom-DhHsl $h[0] ([math]::Min([double]$h[1], 0.6)) $l; $l += 0.01 }
            while ((Get-DhContrastRatio $band $page) -lt 1.30 -and $l -lt 0.5)
        }
        $headFg = & $pickFg $band
        $headRatio = Get-DhContrastRatio $headFg $band
        if ($headRatio -lt 4.5) { $warn.Add("${secondLabel}: header text measures $(& $fmt $headRatio):1 on the $mode header band (needs 4.5:1).") }
        # The gradient end: the Primary hue (else Secondary's), as bright as the header text
        # allows - 5.1:1 against it, so the 94% muted text still clears 4.5:1.
        $endSrc = if ($primary) { $primary } else { $second }
        $hEnd = ConvertTo-DhHsl $endSrc
        $bandL = (ConvertTo-DhHsl $band)[2]
        $end = $band
        if ($dark) {
            for ($l = $bandL; $l -le 0.34; $l += 0.005) {
                $c = ConvertFrom-DhHsl $hEnd[0] ([math]::Min([double]$hEnd[1], 0.65)) $l
                if ((Get-DhContrastRatio $headFg $c) -ge 5.1) { $end = $c } else { break }
            }
        } else {
            $endDir = if ($headFg[0] -gt 0.5) { 'darker' } else { 'lighter' }
            $end = (Find-DhShade $endSrc @(, $headFg) 5.1 $endDir).Rgb
            if ((Get-DhContrastRatio $headFg $end) -lt 4.5) { $end = $band }
        }
        # Deep colour on the left, as in every theme: swap when the brand put it on the right.
        $endDeeper = (Get-DhRelativeLuminance $end) -lt (Get-DhRelativeLuminance $band)
        if ($endDeeper -eq ($headFg[0] -gt 0.5)) { $band, $end = $end, $band }
        $sec = if ($dark) { (Find-DhShade $second @(, $surface) 3.0 'lighter').Rgb } else { $second }
        $out.Add(" /* $secondLabel */")
        $out.Add(" --bg-header: $(& $hex $band);")
        $out.Add(" --bg-header-end: $(& $hex $end);")
        $out.Add(" --accent-secondary: $(& $hex $sec);")
        $out.Add(" --header-fg: $(& $hex $headFg);")
        $out.Add(" --header-fg-muted: $(ConvertTo-DhRgbaCss $headFg 0.94);")
    }

    $third = if ($Brand.Contains('AccentColor') -and $Brand.AccentColor) { ConvertFrom-DhCssColor $Brand.AccentColor } else { $null }
    if ($third) {
        $chartBg = & $opaque (& $col 'chart-container-bg') $page
        if (-not $chartBg) { $chartBg = $surface }
        $c2 = if ($dark) { (Find-DhShade $third @(, $chartBg) 3.0 'lighter').Rgb } else { $third }
        if ((Get-DhContrastRatio $c2 $chartBg) -lt 3.0) { $warn.Add("-AccentColor $($Brand.AccentColor) measures $(& $fmt (Get-DhContrastRatio $c2 $chartBg)):1 on the $mode chart background (chart marks need 3:1).") }
        $out.Add(" /* -AccentColor $($Brand.AccentColor) */")
        $out.Add(" --chart-2: $(& $hex $c2);")
        $out.Add(" --header-line: $(& $hex $third);")
    }

    if ($Brand.Contains('FontFamily') -and $Brand.FontFamily) {
        $out.Add(" /* -FontFamily $($Brand.FontFamily) */")
        foreach ($n in 'font-ui', 'font-display') {
            $stack = if ($tok.Contains($n)) { $tok[$n] } else { "'Segoe UI', system-ui, sans-serif" }
            $out.Add(" --${n}: '$($Brand.FontFamily)', $stack;")
        }
    }

    $css = if ($out.Count) { ":root {`n /* DashHtml 3.0.0 brand ($mode) */`n" + ($out -join "`n") + "`n}`n" } else { '' }
    return @{ Css = $css; Warnings = @($warn) }
}

function Get-DhBrandFontFace {
    <# The @font-face rule for an embedded -FontFile, or '' when there is none. #>
    param([System.Collections.IDictionary] $Brand)
    if (-not ($Brand.Contains('FontData') -and $Brand.FontData -and $Brand.FontFamily)) { return '' }
    $fmt = switch ($Brand.FontMime) { 'font/woff2' { 'woff2' } 'font/woff' { 'woff' } 'font/otf' { 'opentype' } default { 'truetype' } }
    "@font-face { font-family: '$($Brand.FontFamily)'; src: url(data:$($Brand.FontMime);base64,$($Brand.FontData)) format('$fmt'); font-display: swap; }"
}