Private/Get-SPMDeltaHtml.ps1

function Get-SPMDeltaHtml {
    <#
    .SYNOPSIS
        Renders a delta object (from Compare-SPPermissionScan) as a self-contained HTML page.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $Delta
    )

    $e = { param($s) [System.Net.WebUtility]::HtmlEncode([string]$s) }
    $sb = [System.Text.StringBuilder]::new()

    $null = $sb.AppendLine(@'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SharePoint Permission Delta</title>
<style>
:root { --border:#e2e8f0; --dim:#64748b; --text:#1e293b; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.5 "Segoe UI Variable Text","Segoe UI",system-ui,sans-serif; color:var(--text); background:#f8fafc; }
header { background:linear-gradient(135deg,#0b1f33 0%,#16344f 45%,#1f4e79 100%); color:#fff; padding:20px 26px 18px; position:relative; box-shadow:0 2px 18px rgba(11,31,51,.35); }
header::after { content:''; position:absolute; left:0; right:0; bottom:0; height:3px; background:linear-gradient(90deg,#5eead4,#38bdf8,#818cf8); opacity:.9; }
.kicker { font-size:11px; text-transform:uppercase; letter-spacing:.18em; color:#7dd3fc; font-weight:600; margin-bottom:2px; }
h1 { margin:0; font-size:22px; font-weight:650; }
.meta { margin-top:9px; display:flex; gap:8px; flex-wrap:wrap; }
.mchip { font-size:11.5px; color:rgba(255,255,255,.88); background:rgba(255,255,255,.08); border:1px solid rgba(255,255,255,.17); border-radius:999px; padding:3px 11px; }
main { padding:18px 26px; max-width:1100px; }
h2 { font-size:17px; margin:22px 0 4px; }
h3 { font-size:13px; margin:16px 0 6px; text-transform:uppercase; letter-spacing:.05em; color:var(--dim); }
.url { font-size:12px; color:var(--dim); }
table { border-collapse:collapse; background:#fff; border:1px solid var(--border); width:100%; margin-top:4px; }
th, td { padding:6px 12px; border-bottom:1px solid var(--border); text-align:left; font-size:13px; vertical-align:top; }
th { background:#f1f5f9; font-size:11px; text-transform:uppercase; letter-spacing:.04em; color:var(--dim); }
tr.add td { background:#f0fdf4; } tr.add td:first-child { box-shadow:inset 3px 0 0 #16a34a; }
tr.rem td { background:#fef2f2; } tr.rem td:first-child { box-shadow:inset 3px 0 0 #dc2626; }
tr.chg td { background:#fffbeb; } tr.chg td:first-child { box-shadow:inset 3px 0 0 #d97706; }
.ok { padding:16px; background:#f0fdf4; border:1px solid #bbf7d0; border-radius:8px; color:#166534; margin-top:8px; }
.chips { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0 4px; }
.chip { background:#fff; border:1px solid var(--border); border-radius:999px; padding:3px 12px; font-size:12px; color:var(--dim); }
.chip b { color:var(--text); }
code { font-size:11px; background:#f1f5f9; padding:1px 5px; border-radius:4px; }
.dim { color:var(--dim); }
</style>
</head>
<body>
<header>
  <div class="kicker">SPPermissionMatrix &middot; Delta Report</div>
  <h1>SharePoint Permission Delta</h1>
  <div class="meta">
'@
)
    $null = $sb.AppendLine(' <span class="mchip">Reference: scanned ' + (& $e $Delta.reference.scanDate) + '</span>')
    $null = $sb.AppendLine(' <span class="mchip">Current: scanned ' + (& $e $Delta.difference.scanDate) + '</span>')
    $null = $sb.AppendLine(' <span class="mchip">' + [int]$Delta.totalChanges + ' change(s)</span>')
    $null = $sb.AppendLine(' <span class="mchip">Generated ' + (& $e $Delta.generated) + '</span>')
    $null = $sb.AppendLine(' </div></header><main>')

    if ([int]$Delta.totalChanges -eq 0) {
        $null = $sb.AppendLine('<div class="ok">No permission changes detected between the two scans.</div>')
    }

    $addTable = {
        param([string]$Title, [object[]]$Rows, [string[]]$Headers, [scriptblock]$CellsOf, [string]$RowClass)
        if (-not $Rows -or @($Rows).Count -eq 0) { return }
        $null = $sb.AppendLine('<h3>' + (& $e $Title) + ' (' + @($Rows).Count + ')</h3><table><thead><tr>')
        foreach ($h in $Headers) { $null = $sb.Append('<th>' + (& $e $h) + '</th>') }
        $null = $sb.AppendLine('</tr></thead><tbody>')
        foreach ($r in @($Rows)) {
            $null = $sb.Append('<tr class="' + $RowClass + '">')
            foreach ($cell in @(& $CellsOf $r)) { $null = $sb.Append('<td>' + $cell + '</td>') }
            $null = $sb.AppendLine('</tr>')
        }
        $null = $sb.AppendLine('</tbody></table>')
    }

    foreach ($site in @($Delta.sites)) {
        $null = $sb.AppendLine('<h2>' + (& $e $site.title) + '</h2><div class="url">' + (& $e $site.url) + '</div>')

        if ($site.status -eq 'added') { $null = $sb.AppendLine('<div class="ok" style="background:#f0fdf4">Site is new in the current scan.</div>'); continue }
        if ($site.status -eq 'removed') { $null = $sb.AppendLine('<div class="ok" style="background:#fef2f2;border-color:#fecaca;color:#991b1b">Site is no longer present in the current scan.</div>'); continue }
        if ([int]$site.summary.total -eq 0) { $null = $sb.AppendLine('<div class="ok">No changes on this site.</div>'); continue }

        $null = $sb.AppendLine('<div class="chips"><span class="chip">changes <b>' + [int]$site.summary.total + '</b></span></div>')

        & $addTable 'New inheritance breaks' $site.newBreaks @('Where', 'Unique permissions now assigned') { param($r) @((& $e $r.node), (& $e $r.detail)) } 'chg'
        & $addTable 'Inheritance restored' $site.restoredInheritance @('Where') { param($r) @((& $e $r.node)) } 'chg'
        & $addTable 'Added permissions' $site.addedAssignments @('Where', 'Principal', 'Level') { param($r) @((& $e $r.node), (& $e $r.principal), (& $e $r.permission)) } 'add'
        & $addTable 'Removed permissions' $site.removedAssignments @('Where', 'Principal', 'Level') { param($r) @((& $e $r.node), (& $e $r.principal), (& $e $r.permission)) } 'rem'
        & $addTable 'Changed levels' $site.changedAssignments @('Where', 'Principal', 'From', 'To') { param($r) @((& $e $r.node), (& $e $r.principal), (& $e $r.from), (& $e $r.to)) } 'chg'
        & $addTable 'New rows (folders/libraries)' $site.addedNodes @('Title', 'Path') { param($r) @((& $e $r.title), '<code>' + (& $e $r.url) + '</code>') } 'add'
        & $addTable 'Removed rows (folders/libraries)' $site.removedNodes @('Title', 'Path') { param($r) @((& $e $r.title), '<code>' + (& $e $r.url) + '</code>') } 'rem'
        & $addTable 'New sharing links' $site.addedSharingLinks @('Link', 'People') { param($r) @((& $e $r.kind), [string][int]$r.people) } 'add'
        & $addTable 'Removed sharing links' $site.removedSharingLinks @('Link') { param($r) @((& $e $r.kind)) } 'rem'
        & $addTable 'Group membership changes' $site.membershipChanges @('Group', 'Added people', 'Removed people') {
            param($r)
            @((& $e $r.group),
                ($(if (@($r.added).Count) { (& $e (@($r.added) -join ', ')) } else { '<span class="dim">–</span>' })),
                ($(if (@($r.removed).Count) { (& $e (@($r.removed) -join ', ')) } else { '<span class="dim">–</span>' })))
        } 'chg'
    }

    $null = $sb.AppendLine('<p class="dim" style="margin-top:20px;font-size:12px">Assignment changes are reported where they are assigned (nodes with unique permissions); child rows inheriting from them change implicitly. Limited-access noise is filtered on both sides.</p>')
    $null = $sb.AppendLine('</main></body></html>')
    return $sb.ToString()
}