public/Set-WtwColor.ps1

function Set-WtwColor {
    <#
    .SYNOPSIS
        Set or display the Peacock color for a workspace.
    .DESCRIPTION
        Assigns a hex color or auto-selects a maximum-contrast color for a workspace.
        When called without a Color argument, displays the current color assignment.
        Updates both colors.json and the registry, then syncs the workspace file
        unless --no-sync is specified.
    .PARAMETER Name
        Target workspace or repo name. Defaults to the current working directory.
    .PARAMETER Color
        Hex color (e.g. '#689b59' or 689b59) or 'random' for automatic contrast selection.
    .PARAMETER NoSync
        Skip re-syncing the workspace file after the color change.
    .EXAMPLE
        wtw color auth random
        Assigns a maximum-contrast color to the "auth" workspace.
    .EXAMPLE
        wtw color auth '#689b59'
        Sets the "auth" workspace color to the specified hex value.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string] $Name,

        [Parameter(Position = 1)]
        [string] $Color,

        [Alias('ns')]
        [switch] $NoSync
    )

    # Detect from cwd if no name given
    if (-not $Name) {
        $Name = Resolve-WtwCurrentTarget
        if (-not $Name) {
            Write-Error "Not inside a registered repo. Specify a target or cd into a repo."
            return
        }
        Write-Host " Detected: $Name" -ForegroundColor DarkGray
    }

    $target = Resolve-WtwTarget $Name
    if (-not $target) { return }

    $colorKey = if ($target.TaskName) { "$($target.RepoName)/$($target.TaskName)" } else { "$($target.RepoName)/main" }
    $colors = Get-WtwColors

    # Show current color if no color arg
    if (-not $Color) {
        $current = $null
        if ($colors.assignments.PSObject.Properties.Name -contains $colorKey) {
            $current = $colors.assignments.$colorKey
        }
        if ($current) {
            Write-Host ''
            Write-WtwColorSwatch " $colorKey" $current
            Write-Host " Tip: in PowerShell, '#rrggbb' must be quoted. Use 689b59 or '#689b59'." -ForegroundColor DarkGray
            Write-Host ''
        } else {
            Write-Host " No color assigned for $colorKey" -ForegroundColor DarkGray
            Write-Host " Tip: in PowerShell, '#rrggbb' must be quoted. Use 689b59 or '#689b59'." -ForegroundColor DarkGray
        }
        return
    }

    # Resolve color
    if ($Color -eq 'random') {
        $newColor = Find-WtwContrastColor $colors -ExcludeKey $colorKey
        Write-Host " Picked: $newColor" -ForegroundColor DarkGray
    } elseif ($Color -match '^#?[0-9a-fA-F]{6}$') {
        $newColor = if ($Color.StartsWith('#')) { $Color } else { "#$Color" }
    } else {
        Write-Error "Invalid color '$Color'. Use '#rrggbb' or 'random'."
        return
    }

    # Save to colors.json
    $colors.assignments | Add-Member -NotePropertyName $colorKey -NotePropertyValue $newColor -Force
    Save-WtwColors $colors

    # Also update registry worktree entry if applicable
    if ($target.WorktreeEntry) {
        $target.WorktreeEntry | Add-Member -NotePropertyName 'color' -NotePropertyValue $newColor -Force
        $registry = Get-WtwRegistry
        $registry.repos.$($target.RepoName).worktrees.$($target.TaskName) = $target.WorktreeEntry
        Save-WtwRegistry $registry
    }

    Write-Host ''
    Write-WtwColorSwatch " $colorKey" $newColor

    # Sync workspace unless --no-sync
    if (-not $NoSync) {
        $wsFile = $null
        if ($target.WorktreeEntry) {
            $wsFile = $target.WorktreeEntry.workspace
        } else {
            $wsFile = $target.RepoEntry.templateWorkspace
        }

        if ($wsFile -and (Test-Path $wsFile)) {
            Write-Host " Syncing workspace..." -ForegroundColor DarkGray
            Sync-WtwWorkspace -Target $wsFile -ColorSource Json
        } else {
            Write-Host " No workspace file to sync." -ForegroundColor DarkGray
        }
    }
    Write-Host ''
}

function Find-WtwContrastColor {
    <#
    .SYNOPSIS
        Pick a color maximally distant from all currently assigned colors.
    #>

    param(
        [PSObject] $Colors,
        [string] $ExcludeKey  # Don't count this key's current color as "in use"
    )

    # Collect assigned colors, remembering the excluded key's current color
    $assigned = @()
    $excludedColor = $null
    foreach ($prop in $Colors.assignments.PSObject.Properties) {
        if ($ExcludeKey -and $prop.Name -eq $ExcludeKey) {
            $excludedColor = $prop.Value
            continue
        }
        $assigned += $prop.Value
    }

    if ($assigned.Count -eq 0 -and -not $excludedColor) {
        # Nothing assigned at all — pick first from palette
        return @($Colors.palette)[0]
    }

    # Use assigned colors + excluded color as repulsion points
    # (excluded color: we want distance from it so "random" gives something visibly different)
    $repulsionSet = @($assigned)
    if ($excludedColor) { $repulsionSet += $excludedColor }
    # Use a loop instead of pipeline to avoid array unrolling
    $assignedRgb = @()
    foreach ($hex in $repulsionSet) { $assignedRgb += , (ConvertTo-WtwRgbArray $hex) }

    # Candidates: full palette + generated hue samples for broader coverage
    $candidates = @()
    foreach ($c in @($Colors.palette)) { $candidates += $c }

    # Generate 72 evenly-spaced hues at two saturation/lightness levels
    for ($h = 0; $h -lt 360; $h += 5) {
        $candidates += Convert-HslToHex $h 0.75 0.45
        $candidates += Convert-HslToHex $h 0.90 0.55
    }

    # Remove the excluded key's current color from candidates so "random" always gives a new color
    if ($excludedColor) {
        $excludedLower = $excludedColor.ToLower()
        $candidates = $candidates | Where-Object { $_.ToLower() -ne $excludedLower }
    }

    # Score each candidate: minimum perceptual distance to any assigned color
    $best = $null
    $bestScore = -1

    foreach ($c in $candidates) {
        $rgb = ConvertTo-WtwRgbArray $c
        $minDist = [double]::MaxValue
        foreach ($a in $assignedRgb) {
            $d = Get-PerceptualDistance $rgb $a
            if ($d -lt $minDist) { $minDist = $d }
        }
        if ($minDist -gt $bestScore) {
            $bestScore = $minDist
            $best = $c
        }
    }

    return $best
}

function ConvertTo-WtwRgbArray {
    param([string] $Hex)
    $Hex = $Hex.TrimStart('#')
    return @(
        [convert]::ToInt32($Hex.Substring(0, 2), 16),
        [convert]::ToInt32($Hex.Substring(2, 2), 16),
        [convert]::ToInt32($Hex.Substring(4, 2), 16)
    )
}

function Get-PerceptualDistance {
    <#
    .SYNOPSIS
        Weighted Euclidean distance in RGB — approximates human perception.
        Based on the "redmean" formula from compuphase.
    #>

    param([int[]] $A, [int[]] $B)
    $rmean = ($A[0] + $B[0]) / 2.0
    $dr = $A[0] - $B[0]
    $dg = $A[1] - $B[1]
    $db = $A[2] - $B[2]
    return [math]::Sqrt(
        (2 + $rmean / 256.0) * $dr * $dr +
        4 * $dg * $dg +
        (2 + (255 - $rmean) / 256.0) * $db * $db
    )
}

function Convert-HslToHex {
    param([double] $H, [double] $S, [double] $L)

    $c = (1 - [math]::Abs(2 * $L - 1)) * $S
    $x = $c * (1 - [math]::Abs(($H / 60) % 2 - 1))
    $m = $L - $c / 2

    $r1 = 0; $g1 = 0; $b1 = 0
    if ($H -lt 60) { $r1 = $c; $g1 = $x; $b1 = 0 }
    elseif ($H -lt 120) { $r1 = $x; $g1 = $c; $b1 = 0 }
    elseif ($H -lt 180) { $r1 = 0; $g1 = $c; $b1 = $x }
    elseif ($H -lt 240) { $r1 = 0; $g1 = $x; $b1 = $c }
    elseif ($H -lt 300) { $r1 = $x; $g1 = 0; $b1 = $c }
    else { $r1 = $c; $g1 = 0; $b1 = $x }

    $r = [int](($r1 + $m) * 255)
    $g = [int](($g1 + $m) * 255)
    $b = [int](($b1 + $m) * 255)

    return "#$(ConvertTo-HexComponent $r)$(ConvertTo-HexComponent $g)$(ConvertTo-HexComponent $b)"
}

function Write-WtwColorSwatch {
    <#
    .SYNOPSIS
        Print a label, hex value, and a colored block swatch using ANSI true-color.
    #>

    param(
        [string] $Label,
        [string] $Hex
    )
    $h = $Hex.TrimStart('#')
    $r = [convert]::ToInt32($h.Substring(0, 2), 16)
    $g = [convert]::ToInt32($h.Substring(2, 2), 16)
    $b = [convert]::ToInt32($h.Substring(4, 2), 16)
    $swatch = "`e[48;2;${r};${g};${b}m `e[0m"   # 4-char block with background color
    Write-Host "${Label} = ${Hex} ${swatch}"
}