Skip to content

The Edge of the Cyber World See the latest

Apps

Secret protection must scale with software

Today, one in three pull requests on GitHub involves an AI agent. A year ago, that number was fewer than one in 10. If that pace holds, within the next two years, most of the code pushed to GitHub could be written by an agent. Much of it may never be fully read by a human.

If developers and agents move faster, we have a responsibility to ensure protection keeps up with the accelerated rate of code creation. That means preventing more leaks before they happen and making the response to exposures that remain less dependent on manual human effort.

This is a pivotal point for leaked secrets. Developers aren’t becoming more careless; they’re being outpaced. The tools that let developers create more software should also take on more of the work of protecting it.

In this essay, I share the nine quarters of data behind that claim. I also introduce the fine-tuned classifier we built with Microsoft Applied Sciences to extend push protection to unstructured secrets. The model assesses a whole set of candidate secrets in less than two milliseconds and could more than double the number of secrets that we can prevent.

Outpaced, not careless

A new secret appears in publicly visible code about once every two seconds, doubling yearly for the past three years. Public discourse is quick to jump to the idea that AI made developers careless.

Between Q2 2024 and Q2 2026, screened pushes grew 2.84 times while pushes carrying credentials grew 2.59 times. Across nine complete quarters of data, we found no statistically detectable trend regarding per-push prevalence. At the same time, we found data suggesting that, more than ever, developers understand the risk of accidental exposures and are less willing to accept that risk. Over the same period, the share of push-path blocks overridden by developers fell linearly from 6.63% to 3.93%. These figures challenge the common claim that agents are causing developers to become more careless.

.ghchart {
/* Theme tokens */
–ghc-text: #1f2328;
–ghc-muted: #656d76;
–ghc-grid: #d0d7de;
–ghc-axis: #59636e;
–ghc-card-bg: #ffffff;
–ghc-card-border: #d0d7de;
–ghc-divider: #b6bfb8;
–ghc-guide: #afb8c1;

/* GitHub brand font — matches the github.blog stack (Mona Sans, with
the blog’s metric fallback) so it renders identically in-context. */
font-family: “Mona Sans”, “Mona Sans Fallback”, -apple-system, “system-ui”,
“Segoe UI”, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;

position: relative;
box-sizing: border-box;
width: 100%;
max-width: 699px;
margin: 0 auto;
color: var(–ghc-text);
line-height: 1.4;
}

.ghchart *,
.ghchart *::before,
.ghchart *::after { box-sizing: border-box; }

/* —————————————————————— */
/* Header + legend */
/* —————————————————————— */
.ghchart__header {
display: flex;
justify-content: flex-end;
align-items: center;
min-height: 24px;
margin-bottom: 4px;
}

.ghchart__legend {
display: flex;
align-items: center;
gap: 20px;
}

.ghchart__legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: clamp(14px, 1.9vw, 17px);
font-weight: 600;
color: var(–ghc-muted);
white-space: nowrap;
}

.ghchart__swatch {
width: 14px;
height: 14px;
border-radius: 4px;
flex: 0 0 auto;
}

/* —————————————————————— */
/* Plot (SVG) */
/* —————————————————————— */
.ghchart__plot {
display: block;
width: 100%;
height: auto;
overflow: visible;
touch-action: pan-y;
}

.ghchart__gridline {
stroke: var(–ghc-grid);
stroke-width: 1;
stroke-dasharray: 2 6;
stroke-linecap: round;
opacity: 0.9;
}

.ghchart__axislabel {
fill: var(–ghc-axis);
font-size: 22px;
font-weight: 500;
}
.ghchart__axislabel–x { font-weight: 600; }

.ghchart__line {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}

.ghchart__dot { transition: r 0.12s ease; }

.ghchart__guide {
stroke: var(–ghc-guide);
stroke-width: 1.5;
stroke-dasharray: 4 4;
opacity: 0;
transition: opacity 0.12s ease;
}

.ghchart__hit { fill: transparent; }

/* —————————————————————— */
/* Title + divider */
/* —————————————————————— */
.ghchart__title {
/* Matches the github.blog H2 (.wp-block-heading) exactly */
display: block;
font-size: 32px;
line-height: 40px;
font-weight: 700;
letter-spacing: -0.18px;
color: var(–ghc-text);
}

.ghchart__rule {
height: 1.5px;
border-radius: 1.5px;
background: var(–ghc-divider);
margin: 10px 0 18px;
}

/* —————————————————————— */
/* Bar chart: subtitle, panels, bars, caption */
/* —————————————————————— */
.ghchart__subtitle {
margin: -6px 0 20px;
font-size: clamp(13px, 1.6vw, 15px);
line-height: 1.5;
color: var(–ghc-muted);
}

.ghchart__panel { margin-top: 30px; }
.ghchart__panel:first-of-type { margin-top: 6px; }

.ghchart__panel-title {
margin: 0 0 6px;
font-size: clamp(15px, 1.9vw, 18px);
font-weight: 700;
letter-spacing: -0.01em;
color: var(–ghc-text);
}

.ghchart__plot–bar { touch-action: pan-y; }

.ghchart__axisline {
stroke: var(–ghc-axis);
stroke-width: 1.25;
opacity: 0.35;
}

.ghchart__bar { transition: opacity 0.12s ease; }
.ghchart__barhit { fill: transparent; }

/* Always-on value labels above selected bars. */
.ghchart__barvalue {
fill: var(–ghc-text);
font-size: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
pointer-events: none;
}

/* Anchor annotation: dotted reference line + floating pill. */
.ghchart__anno-line {
stroke: #8b949e;
stroke-width: 1.5;
stroke-dasharray: 4 3;
stroke-linecap: round;
pointer-events: none;
}
.ghchart__anno-chip {
fill: #ffebe9;
stroke: #ffcecb;
stroke-width: 1;
pointer-events: none;
}
.ghchart__anno-chip-text {
fill: #a0111f;
font-weight: 700;
pointer-events: none;
}

.ghchart__caption {
margin-top: 22px;
padding-top: 12px;
border-top: 1px solid var(–ghc-card-border);
font-size: 12.5px;
line-height: 1.55;
color: var(–ghc-muted);
}
.ghchart__caption b { color: var(–ghc-text); font-weight: 700; }
.ghchart__caption code {
font-family: ui-monospace, SFMono-Regular, “SF Mono”, Menlo, Consolas, monospace;
font-size: 0.92em;
padding: 1px 5px;
background: #f6f8fa;
border: 1px solid var(–ghc-card-border);
border-radius: 5px;
}
.ghchart__tag {
display: inline-block;
font-size: 10px;
font-weight: 650;
letter-spacing: 0.03em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid #aceebb;
color: #0a5223;
background: #dafbe1;
white-space: nowrap;
vertical-align: middle;
}

/* —————————————————————— */
/* Calendar heatmap (GitHub contribution-graph style) */
/* —————————————————————— */
.ghheat__eyebrow { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; }
.ghheat__eyedot {
width: 9px; height: 9px; border-radius: 50%;
background: #cf222e; box-shadow: 0 0 0 3px rgba(207, 34, 46, 0.14);
flex: 0 0 auto;
}
.ghheat__eyetext {
font-size: 12px; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: #cf222e;
}
/* Subtitle here is a prominent lede, not the muted variant. */
.ghheat__subtitle {
color: var(–ghc-text);
font-size: clamp(14px, 1.7vw, 16px);
margin: 2px 0 8px;
}
.ghheat__subtitle b { font-weight: 700; }

.ghheat__panel { margin-top: 36px; }
.ghheat__phead {
display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap;
margin-bottom: 16px;
}
.ghheat__badge {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%;
color: #fff; font-size: 12px; font-weight: 700;
flex: 0 0 auto; align-self: center;
}
.ghheat__ptitle {
font-size: clamp(16px, 2vw, 19px); font-weight: 700;
letter-spacing: -0.01em; color: var(–ghc-text);
}
.ghheat__pdesc { font-size: 14px; color: var(–ghc-muted); }

.ghheat__body { display: flex; gap: 30px; align-items: flex-start; flex-wrap: wrap; }
.ghheat__gridwrap { flex: 1 1 340px; min-width: 260px; }
.ghheat__grid { display: block; width: 100%; height: auto; overflow: visible; }
.ghheat__cell {
stroke: rgba(27, 31, 36, 0.06);
stroke-width: 1;
transition: stroke 0.1s ease;
}
.ghheat__cell:hover { stroke: #24292f; stroke-width: 1.4; }
.ghheat__cell–pad { opacity: 0; pointer-events: none; }
.ghheat__wd, .ghheat__month {
fill: var(–ghc-muted); font-size: 11px; font-weight: 600;
}

.ghheat__side { flex: 1 1 250px; min-width: 230px; }
.ghheat__stat { display: flex; align-items: baseline; gap: 7px; }
.ghheat__statbig {
font-size: clamp(26px, 4vw, 34px); font-weight: 800; letter-spacing: -0.02em;
line-height: 1;
}
.ghheat__statsmall { font-size: 15px; font-weight: 600; color: var(–ghc-muted); }
.ghheat__note {
margin: 10px 0 16px; font-size: 14px; line-height: 1.55; color: var(–ghc-muted);
}
.ghheat__note b { color: var(–ghc-text); font-weight: 700; }
.ghheat__legend {
display: flex; align-items: center; gap: 5px;
font-size: 12.5px; color: var(–ghc-muted); flex-wrap: wrap;
}
.ghheat__sw { width: 12px; height: 12px; border-radius: 3px; display: inline-block; flex: 0 0 auto; }
.ghheat__sw–empty { border: 1px solid #d0d7de; }
.ghheat__legunit { margin-left: 2px; }
.ghheat__extra {
display: flex; align-items: flex-start; gap: 7px; margin-top: 14px;
font-size: 12px; line-height: 1.5; color: var(–ghc-muted); max-width: 330px;
}
.ghheat__extra .ghheat__sw { margin-top: 2px; }

/* —————————————————————— */
/* Tooltip */
/* —————————————————————— */
.ghchart__tooltip {
position: absolute;
z-index: 5;
top: 0;
left: 0;
min-width: 128px;
max-width: 260px;
padding: 10px 12px;
background: var(–ghc-card-bg);
border: 1px solid var(–ghc-card-border);
border-radius: 12px;
/* Layered drop shadow from Primer Brand’s ActionMenu (.ActionMenu__menu) */
box-shadow:
0px 100px 80px rgba(0, 0, 0, 0.01),
0px 41px 33px rgba(0, 0, 0, 0.02),
0px 22px 17px rgba(0, 0, 0, 0.02),
0px 12px 10px rgba(0, 0, 0, 0.03),
0px 6px 5px rgba(0, 0, 0, 0.04),
0px 2px 2px rgba(0, 0, 0, 0.07);
pointer-events: none;
opacity: 0;
transform: translate(-50%, -100%);
/* Fade in on show and out on hide; quickly ease left/top when tracking
from one point to the next. The position ease is suppressed on the
first appearance (see chart.js) so the tooltip fades in place rather
than sliding in from its previous spot. */
transition: opacity 0.12s ease,
left 0.1s cubic-bezier(0.4, 0, 0.2, 1),
top 0.1s cubic-bezier(0.4, 0, 0.2, 1);
will-change: opacity, transform, left, top;
}
.ghchart__tooltip[data-visible=”true”] { opacity: 1; }

.ghchart__tt-title {
font-size: 13px;
font-weight: 700;
color: var(–ghc-text);
margin-bottom: 6px;
}

.ghchart__tt-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
line-height: 1.6;
color: var(–ghc-muted);
}
.ghchart__tt-swatch { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
.ghchart__tt-name { flex: 1 1 auto; }
.ghchart__tt-value { font-weight: 700; color: var(–ghc-text); font-variant-numeric: tabular-nums; }

/* Bar tooltip body: a title line + free-form text lines (no swatches). */
.ghchart__tt-line {
font-size: 13px;
line-height: 1.5;
color: var(–ghc-muted);
}
.ghchart__tt-line:first-child { color: var(–ghc-text); font-weight: 600; }
.ghchart__tt-line + .ghchart__tt-line { margin-top: 2px; }

@media (prefers-reduced-motion: reduce) {
.ghchart__dot,
.ghchart__guide,
.ghchart__bar,
.ghheat__cell,
.ghchart__tooltip { transition: none; }
}

/* —————————————————————— */
/* Co-occurrence matrix */
/* —————————————————————— */
.ghchart__matrixrule { stroke: #aeb6bf; stroke-width: 2.4; }
.ghchart__matrixframe { stroke: var(–ghc-card-border); stroke-width: 1.8; }
.ghchart__matrixswatch { stroke: var(–ghc-card-border); stroke-width: 1.4; }

[data-ghc-push-leak] {
–ghpr-column-gap: 6px;
–ghpr-frame-inset: 16px;
–ghpr-plot-left: calc(42px + var(–ghpr-frame-inset));
–ghpr-plot-right: calc(36px + var(–ghpr-frame-inset));
–ghpr-border: #e4ebe6;
–ghc-text: #000000;
–ghc-muted: #58635b;
–ghc-axis: #58635b;
–ghc-grid: #e4ebe6;
–ghc-divider: #b6bfb8;
font-family: “Mona Sans”, “MonaSansFallback”, -apple-system, BlinkMacSystemFont,
“Segoe UI”, Helvetica, Arial, sans-serif;
padding: 0;
border: 0;
border-radius: 0;
background: var(–ghc-card-bg);
box-shadow: none;
overflow: hidden;
}
[data-ghc-push-leak] .ghpr__header {
padding: 0 0 14px;
border-bottom: 0;
}
[data-ghc-push-leak] .ghchart__title {
display: block;
max-width: 620px;
margin-bottom: 0;
font-size: 20px;
line-height: 26px;
font-weight: 600;
letter-spacing: -0.01em;
text-wrap: balance;
}
[data-ghc-push-leak] .ghpr__body {
padding: 0;
}
[data-ghc-push-leak] .ghpr__legend {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-bottom: 8px;
}
[data-ghc-push-leak] .ghpr__legend-item:last-child { text-align: right; }
[data-ghc-push-leak] .ghpr__legend-item:last-child .ghpr__legend-name { justify-content: flex-end; }
[data-ghc-push-leak] .ghpr__legend-name {
display: flex;
height: 18px;
align-items: center;
gap: 6px;
font-size: 13px;
line-height: 18px;
font-weight: 600;
}
[data-ghc-push-leak] .ghpr__swatch {
display: inline-block;
flex: 0 0 auto;
}
[data-ghc-push-leak] .ghpr__swatch–volume {
width: 10px;
height: 12px;
border-radius: 1px 1px 0 0;
background: var(–ghpr-volume-ink);
}
[data-ghc-push-leak] .ghpr__swatch–prevalence {
position: relative;
width: 18px;
height: 2px;
background: var(–ghpr-share-ink);
}
[data-ghc-push-leak] .ghpr__swatch–prevalence::after {
content: “”;
position: absolute;
left: 6px;
top: -2px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(–ghpr-share-ink);
}
[data-ghc-push-leak] .ghpr__frame {
position: relative;
box-sizing: border-box;
height: 260px;
border: 1px solid var(–ghpr-border);
border-radius: 8px;
}
[data-ghc-push-leak] .ghpr__plot {
position: absolute;
left: var(–ghpr-plot-left);
right: var(–ghpr-plot-right);
top: 24px;
bottom: 56px;
}
[data-ghc-push-leak] .ghpr__grid,
[data-ghc-push-leak] .ghpr__columns {
position: absolute;
inset: 0;
}
[data-ghc-push-leak] .ghpr__grid { pointer-events: none; }
[data-ghc-push-leak] .ghpr__gridline {
position: absolute;
left: 0;
right: 0;
border-top: 1px solid var(–ghc-grid);
opacity: 1;
}
[data-ghc-push-leak] .ghpr__gridline–baseline {
border-top-color: var(–ghc-divider);
opacity: 1;
}
[data-ghc-push-leak] .ghpr__guide {
position: absolute;
left: var(–ghpr-active-position, 0%);
top: 0;
bottom: 0;
transform: translateX(-50%);
border-left: 1px dashed var(–ghc-guide);
pointer-events: none;
opacity: 0;
z-index: 1;
transition: opacity 120ms ease;
}
[data-ghc-push-leak][data-ghpr-active=”true”] .ghpr__guide { opacity: 1; }
[data-ghc-push-leak] .ghpr__columns {
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
}
[data-ghc-push-leak] .ghpr__slot { position: relative; }
[data-ghc-push-leak][data-ghchart-rendered=”true”] .ghpr__slot { cursor: pointer; }
[data-ghc-push-leak] .ghpr__slot:focus-visible { outline: none; }
[data-ghc-push-leak] .ghpr__column {
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: calc((100% – var(–ghpr-column-gap)) * var(–ghpr-bar-width));
height: var(–ghpr-height);
border-radius: 1px 1px 0 0;
background: var(–ghpr-volume-ink);
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
[data-ghc-push-leak] .ghpr__slot:focus-visible .ghpr__column {
outline: 1.25px solid #0377ff;
outline-offset: 2px;
}
[data-ghc-push-leak] .ghpr__prevalence-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
z-index: 1;
pointer-events: none;
}
[data-ghc-push-leak] .ghpr__prevalence-line,
[data-ghc-push-leak] .ghpr__prevalence-halo {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
[data-ghc-push-leak] .ghpr__prevalence-line {
stroke: var(–ghpr-share-ink);
stroke-width: 2.25px;
}
[data-ghc-push-leak] .ghpr__prevalence-halo {
stroke: var(–ghc-card-bg);
stroke-width: 4.25px;
}
[data-ghc-push-leak] .ghpr__point {
position: absolute;
left: 50%;
top: var(–ghpr-prevalence-top);
transform: translate(-50%, -50%);
width: 6px;
height: 6px;
border: 1px solid var(–ghc-card-bg);
border-radius: 50%;
background: var(–ghpr-share-ink);
pointer-events: none;
z-index: 2;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
transition: width 120ms ease, height 120ms ease;
}
[data-ghc-push-leak] .ghpr__slot[data-active=”true”] .ghpr__point,
[data-ghc-push-leak] .ghpr__slot:focus-visible .ghpr__point {
width: 8px;
height: 8px;
}
[data-ghc-push-leak] .ghpr__volume-label {
position: absolute;
left: 50%;
font-size: 12px;
line-height: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
z-index: 3;
}
[data-ghc-push-leak] .ghpr__volume-label {
bottom: calc(var(–ghpr-height) + 8px);
transform: translateX(-50%);
color: var(–ghpr-volume-text);
}
[data-ghc-push-leak] .ghpr__volume-label–last {
left: auto;
right: 0;
transform: none;
}
[data-ghc-push-leak] .ghpr__y-axis {
position: absolute;
top: 24px;
bottom: 56px;
width: 30px;
}
[data-ghc-push-leak] .ghpr__y-axis–volume {
left: var(–ghpr-frame-inset);
width: 36px;
text-align: right;
color: var(–ghpr-volume-text);
}
[data-ghc-push-leak] .ghpr__y-axis–rate {
right: var(–ghpr-frame-inset);
text-align: left;
color: var(–ghpr-share-text);
}
[data-ghc-push-leak] .ghpr__axis-label {
position: absolute;
left: 0;
right: 0;
transform: translateY(-50%);
font-size: 12px;
line-height: 1;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
[data-ghc-push-leak] .ghpr__x-axis {
position: absolute;
left: var(–ghpr-plot-left);
right: var(–ghpr-plot-right);
bottom: 12px;
height: 40px;
}
[data-ghc-push-leak] .ghpr__x-label,
[data-ghc-push-leak] .ghpr__year-label {
position: absolute;
top: 4px;
transform: translateX(-50%);
font-size: 12px;
line-height: 14px;
color: var(–ghc-axis);
white-space: nowrap;
}
[data-ghc-push-leak] .ghpr__year-label { top: 22px; }
[data-ghc-push-leak] .ghpr__x-label[data-active=”true”] {
color: var(–ghc-text);
font-weight: 600;
text-decoration: underline;
text-underline-offset: 3px;
}
[data-ghc-push-leak] .ghpr__readout {
min-height: 18px;
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(–ghc-muted);
font-variant-numeric: tabular-nums;
}
[data-ghc-push-leak] .ghpr__readout[data-active=”true”] { color: var(–ghc-text); }
[data-ghc-push-leak] .ghpr__readout-volume {
color: var(–ghpr-volume-text);
font-weight: 600;
}
[data-ghc-push-leak] .ghpr__readout-share {
color: var(–ghpr-share-text);
font-weight: 600;
}
[data-ghc-push-leak] .ghpr__key-volume { color: var(–ghpr-volume-text); }
[data-ghc-push-leak] .ghpr__key-share { color: var(–ghpr-share-text); }
[data-ghc-push-leak] .ghchart__caption {
margin: 8px 0 0;
padding: 10px 0 0;
border-top: 1px solid var(–ghpr-border);
background: var(–ghc-card-bg);
font-size: 12px;
line-height: 18px;
color: var(–ghc-muted);
}
@media (prefers-reduced-motion: reduce) {
[data-ghc-push-leak] .ghpr__guide,
[data-ghc-push-leak] .ghpr__point {
transition: none;
}
}
@media print {
[data-ghc-push-leak] .ghpr__guide { display: none; }
}
@media (max-width: 520px) {
[data-ghc-push-leak] { –ghpr-column-gap: 3px; –ghpr-frame-inset: 12px; }
[data-ghc-push-leak] .ghpr__header { padding: 0 0 12px; }
[data-ghc-push-leak] .ghpr__body { padding: 0; }
[data-ghc-push-leak] .ghpr__frame { height: 240px; }
[data-ghc-push-leak] .ghchart__caption { padding: 9px 0 0; font-size: 12px; line-height: 18px; }
[data-ghc-push-leak] .ghchart__title { font-size: 19px; line-height: 25px; }
[data-ghc-push-leak] .ghpr__readout { min-height: 36px; margin-top: 8px; }
}

/* GitHub blog chart engine. Dependency-free SVG. */
;(function (global) {
“use strict”;

var SVGNS = “http://www.w3.org/2000/svg”;

/* —- DOM + math —————————————————- */

// Create an SVG element with attributes: el(“rect”, { x: 0, width: 10 }).
function el(tag, attrs) {
var node = document.createElementNS(SVGNS, tag);
if (attrs) {
for (var k in attrs) {
if (Object.prototype.hasOwnProperty.call(attrs, k)) node.setAttribute(k, attrs[k]);
}
}
return node;
}

// Create an HTML element with an optional class: html(“div”, “ghchart__side”).
function html(tag, cls) {
var node = document.createElement(tag);
if (cls) node.className = cls;
return node;
}

function clamp(v, lo, hi) { return v hi ? hi : v; }

/* —- Color ramp —————————————————- *
The whole palette for a series is derived from ONE base hex, so a
single tunable color drives an entire faint->dark scale. This is the
convention behind the contribution-heatmap shading and the accent
colors on badges/stat numbers. */

var WHITE = [255, 255, 255], BLACK = [0, 0, 0];

function hexToRgb(h) {
h = String(h).replace(“#”, “”);
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}

function rgbToHex(c) {
return “#” + c.map(function (x) {
x = Math.max(0, Math.min(255, Math.round(x)));
return (x level 4 (dark), from one base hex.
// Ratios are the project standard — keep them identical across charts.
function rampFrom(baseHex) {
var b = hexToRgb(baseHex);
return [
rgbToHex(mix(b, WHITE, 0.80)),
rgbToHex(mix(b, WHITE, 0.52)),
rgbToHex(mix(b, WHITE, 0.20)),
baseHex,
rgbToHex(mix(b, BLACK, 0.30))
];
}

// The “strong” step (level 4) — use it for badges, stat numbers, accents.
function strongOf(baseHex) { return rampFrom(baseHex)[4]; }

/* —- Number formatting ——————————————– */

// 1234567 -> “1,234,567”
function fmtInt(n) { return Math.round(n).toLocaleString(“en-US”); }

// Compact absolute counts: 6400000 -> “6.4M”, 2000000000 -> “2B”, 39000 -> “39K”.
function fmtCompact(n) {
var v = Math.abs(n), s = n = 1e9) return s + trim((v / 1e9).toFixed(1)) + “B”;
if (v >= 1e6) return s + trim((v / 1e6).toFixed(1)) + “M”;
if (v >= 1e3) return s + Math.round(v / 1e3) + “K”;
return s + String(Math.round(v));
}

/* —- Tooltip ——————————————————- *
One shared tooltip per chart. `root` must be position:relative (the
.ghchart wrapper is). Returns { el, show, hide }.

var tip = createTooltip(root);
marker.addEventListener(“pointermove”, function () {
tip.show(marker, “Mon · May 11, 2026”, [
“10.4M pushes/day”,
“May · 321.9M scanned”
]);
});
marker.addEventListener(“pointerleave”, tip.hide);

Motion contract (matches chart.css):
• fade in/out at 0.12s;
• when moving between marks, ease left/top at 0.1s;
• on the FIRST appearance the position ease is suppressed so the card
fades in place instead of sliding in from the previous mark;
• flip below the anchor when there’s no room above;
• clamp horizontally so it never overflows the chart width;
• prefers-reduced-motion disables the eases (handled in chart.css). */
function createTooltip(root, opts) {
opts = opts || {};
var tip = html(“div”, opts.className || “ghchart__tooltip”);
tip.setAttribute(“role”, “status”);
var titleEl = html(“div”, “ghchart__tt-title”);
var bodyEl = html(“div”, “ghchart__tt-body”);
tip.appendChild(titleEl);
tip.appendChild(bodyEl);
root.appendChild(tip);

var mq = window.matchMedia ? window.matchMedia(“(prefers-reduced-motion: reduce)”) : null;
var visible = false;

function hide() { visible = false; tip.setAttribute(“data-visible”, “false”); }

function show(anchorEl, titleText, lines) {
var firstAppear = !visible;
visible = true;
titleEl.textContent = titleText;
bodyEl.textContent = “”;
(lines || []).forEach(function (ln) {
var row = html(“div”, “ghchart__tt-line”);
row.innerHTML = ln; // lines may contain …
bodyEl.appendChild(row);
});

var a = anchorEl.getBoundingClientRect();
var r = root.getBoundingClientRect();
var left = a.left + a.width / 2 – r.left;
var top = a.top – r.top – 12;

// Suppress the position ease the first time so it fades in place.
var suppress = firstAppear && !(mq && mq.matches);
if (suppress) tip.style.transition = “opacity 0.12s ease”;

tip.setAttribute(“data-visible”, “true”);
var halfW = tip.offsetWidth / 2;
left = clamp(left, halfW + 2, root.clientWidth – halfW – 2);

if (top – tip.offsetHeight flip below
top = a.bottom – r.top + 12;
tip.style.transform = “translate(-50%, 0)”;
} else {
tip.style.transform = “translate(-50%, -100%)”;
}
tip.style.left = left + “px”;
tip.style.top = top + “px”;

if (suppress) { void tip.offsetWidth; tip.style.transition = “”; }
return tip;
}

return { el: tip, show: show, hide: hide };
}

var Kit = {
el: el, html: html, clamp: clamp,
hexToRgb: hexToRgb, rgbToHex: rgbToHex, mix: mix,
rampFrom: rampFrom, strongOf: strongOf,
fmtInt: fmtInt, fmtCompact: fmtCompact,
createTooltip: createTooltip
};

function pointsToPath(points, tension) {
if (!points.length) return “”;
if (points.length === 1) return “M” + points[0].x + ” ” + points[0].y;
var d = “M” + points[0].x + ” ” + points[0].y;
var t = tension || 0;
for (var i = 0; i 0) {
var cp1x = p1.x + (p2.x – p0.x) * t / 6;
var cp1y = p1.y + (p2.y – p0.y) * t / 6;
var cp2x = p2.x – (p3.x – p1.x) * t / 6;
var cp2y = p2.y – (p3.y – p1.y) * t / 6;
d += ” C” + cp1x + ” ” + cp1y + ” ” + cp2x + ” ” + cp2y + ” ” + p2.x + ” ” + p2.y;
} else {
d += ” L” + p2.x + ” ” + p2.y;
}
}
return d;
}

function fmtPlain(v, decimals) {
var n = Number(v);
if (!isFinite(n)) return String(v);
if (decimals == null) return String(v);
return n.toFixed(decimals).replace(/.0+$/, “”).replace(/(.d*?)0+$/, “$1”);
}

function createText(txt, attrs) {
var t = el(“text”, attrs);
t.textContent = txt;
return t;
}

/* —- Axis scales —————————————————- *
Maps a data value onto a pixel coordinate. `p0` is the pixel for `min`
and `p1` the pixel for `max`, so a y-axis passes (y0, y1) — bottom
first — and an x-axis passes (x0, x1). Log axes clamp at `min` rather
than returning -Infinity, because several figures carry legitimate
zeros that must sit on the floor instead of vanishing. */
function makeScale(min, max, p0, p1, isLog) {
if (isLog) {
var lo = Math.log(Math.max(min, 1e-12)) / Math.LN10;
var span = (Math.log(Math.max(max, 1e-12)) / Math.LN10) – lo || 1;
return function (v) {
var n = Number(v);
if (!isFinite(n) || n [10, 100, … 100000].
function logTicks(min, max) {
var out = [];
for (var e = Math.ceil(Math.log(min) / Math.LN10 – 1e-9); Math.pow(10, e) <= max; e++) out.push(Math.pow(10, e));
return out;
}

// Axis tick text: log axes read better compact (1K) than plain (1000).
function tickLabel(v, spec) {
if (spec && typeof spec.format === "function") return spec.format(v);
if (spec && spec.format === "compact") return fmtCompact(v);
if (spec && spec.format === "int") return fmtInt(v);
if (spec && spec.scale === "log") return v < 1 ? String(v) : fmtCompact(v);
return fmtPlain(v, spec && spec.decimals);
}

/* Shared plot-frame setup: viewBox, margins, scales, gridlines, axis
titles. Every renderer below starts here so the 13 figures cannot drift
apart on type size, gutters, or tick placement. */
function frame(config, defaults) {
var W = (config.viewBox && config.viewBox.width) || 1400;
var H = (config.viewBox && config.viewBox.height) || 810;
var m = Object.assign({ top: 56, right: 60, bottom: 76, left: 128 }, defaults || {}, config.margin || {});
var f = { W: W, H: H, m: m, x0: m.left, x1: W – m.right, y0: H – m.bottom, y1: m.top };
f.svg = el("svg", {
"class": "ghchart__plot", viewBox: "0 0 " + W + " " + H,
role: "img", "aria-label": config.ariaLabel || config.title || "Chart"
});
f.svg.style.width = "100%";
f.svg.style.height = "auto";
f.svg.style.overflow = "visible";
return f;
}

// Horizontal gridlines + left tick labels for a value axis.
function gridY(f, spec, yFn) {
var ticks = spec.ticks || (spec.scale === "log" ? logTicks(spec.min, spec.max) : []);
ticks.forEach(function (tv) {
if (tv spec.max) return;
var yy = yFn(tv);
f.svg.appendChild(el(“line”, { “class”: “ghchart__gridline”, x1: f.x0, x2: f.x1, y1: yy, y2: yy }));
f.svg.appendChild(createText(tickLabel(tv, spec), { “class”: “ghchart__axislabel”, x: f.x0 – 18, y: yy + 7, “text-anchor”: “end” }));
});
}

function axisTitles(f, config, xLabelY) {
if (config.y && config.y.title) {
f.svg.appendChild(createText(config.y.title, {
“class”: “ghchart__axislabel”, x: -(f.y1 + f.y0) / 2, y: config.y.titleOffset || 30,
transform: “rotate(-90)”, “text-anchor”: “middle”
}));
}
if (config.x && config.x.title) {
f.svg.appendChild(createText(config.x.title, {
“class”: “ghchart__axislabel”, x: (f.x0 + f.x1) / 2, y: xLabelY == null ? f.y0 + 86 : xLabelY, “text-anchor”: “middle”
}));
}
}

// Always-on comparison labels and the double-headed gap arrow, shared by
// every renderer. `xFn`/`yFn` translate data space; `px`/`py` bypass them.
function decorate(f, config, xFn, yFn) {
function annText(txt, ax, ay, opts) {
var t = createText(txt, { “class”: “ghchart__ann”, x: ax, y: ay, “text-anchor”: opts.anchor || “start” });
t.style.fontSize = (opts.size || 23) + “px”;
t.style.fontWeight = opts.weight || 400;
t.style.fill = opts.color || “#656d76”;
t.setAttribute(“dominant-baseline”, “central”);
return t;
}
(config.annotations || []).forEach(function (a) {
var px = (a.px != null ? a.px : xFn(a.x)) + (a.dx || 0);
var py = (a.py != null ? a.py : yFn(a.y)) + (a.dy || 0);
var anchor = a.align === “right” ? “end” : a.align === “middle” ? “middle” : “start”;
String(a.text).split(“n”).forEach(function (ln, i) {
f.svg.appendChild(annText(ln, px, py + i * (a.lh || 30), { anchor: anchor, size: a.size, weight: a.weight, color: a.color }));
});
});
var gaps = config.gaps || (config.gap ? [config.gap] : []);
gaps.forEach(function (g) {
var gx = (g.px != null ? g.px : xFn(g.x)) + (g.dx || 0), yT = yFn(g.top), yB = yFn(g.bottom);
var col = g.color || “#8250df”;
f.svg.appendChild(el(“line”, { x1: gx, x2: gx, y1: yT + 9, y2: yB – 9, stroke: col, “stroke-width”: g.width || 2.3 }));
[[yT, 1], [yB, -1]].forEach(function (h) {
f.svg.appendChild(el(“polygon”, {
points: gx + “,” + h[0] + ” ” + (gx – 6.6) + “,” + (h[0] + h[1] * 12) + ” ” + (gx + 6.6) + “,” + (h[0] + h[1] * 12),
fill: col
}));
});
var mid = (yT + yB) / 2;
f.svg.appendChild(annText(g.t1, gx + 20, mid – 15, { size: g.size1 || 26, weight: 700, color: col }));
f.svg.appendChild(annText(g.t2, gx + 20, mid + 15, { size: g.size2 || 21, color: col }));
});
return annText;
}

// Shared chrome: resets the mount, writes optional title/subtitle, and
// returns false when the mount is missing or already rendered.
function openRoot(root, config) {
root = typeof root === “string” ? document.getElementById(root) : root;
if (!root || root.getAttribute(“data-ghchart-rendered”) === “true”) return null;
root.setAttribute(“data-ghchart-rendered”, “true”);
root.classList.add(“ghchart”);
root.textContent = “”;
if (config.title) {
var title = html(“span”, “ghchart__title”);
title.textContent = config.title;
root.appendChild(title);
}
if (config.subtitle) {
var sub = html(“p”, “ghchart__subtitle”);
sub.textContent = config.subtitle;
root.appendChild(sub);
}
return root;
}

function closeRoot(root, f, config) {
root.appendChild(f.svg);
if (config.caption) {
var cap = html(“div”, “ghchart__caption”);
cap.innerHTML = config.caption;
root.appendChild(cap);
}
return root;
}

function renderLine(root, config) {
root = openRoot(root, config);
if (!root) return root;

var labels = config.labels || [];
var series = (config.series || []).map(function (s) {
var ramp = rampFrom(s.base || config.base || “#3094ff”);
return {
name: s.name,
data: s.data || [],
xData: s.xData,
raw: s.raw || [],
base: s.base || config.base || “#3094ff”,
ramp: ramp,
color: ramp[s.strokeStep == null ? 3 : s.strokeStep],
width: s.width || 4,
dash: s.dash || “”,
fill: !!s.fill,
fillOpacity: s.fillOpacity == null ? 0.72 : s.fillOpacity,
pointRadius: s.pointRadius == null ? 6 : s.pointRadius,
pointRadiusLast: s.pointRadiusLast == null ? (s.pointRadius == null ? 7 : s.pointRadius) : s.pointRadiusLast,
ringWidth: s.ringWidth == null ? 2.8 : s.ringWidth,
valueSuffix: s.valueSuffix || “”,
rawSuffix: s.rawSuffix || “”,
rawDecimals: s.rawDecimals
};
});
var allVals = series.reduce(function (a, s) { return a.concat(s.data.filter(function (v) { return v != null; })); }, []);
var ySpec = Object.assign({}, config.y);
if (ySpec.min == null) ySpec.min = Math.min.apply(null, allVals);
if (ySpec.max == null) ySpec.max = Math.max.apply(null, allVals);

var f = frame(config, { right: 60 });
var x0 = f.x0, x1 = f.x1, y0 = f.y0, y1 = f.y1, W = f.W, H = f.H, svg = f.svg;
svg.setAttribute(“class”, “ghchart__plot ghchart__plot–line”);
svg.setAttribute(“aria-label”, config.ariaLabel || (config.title || “Line chart”) + “: ” + series.map(function (s) { return s.name; }).join(” and “));

var x = function (i) { return labels.length < 2 ? (x0 + x1) / 2 : x0 + (x1 – x0) * i / (labels.length – 1); };
var y = makeScale(ySpec.min, ySpec.max, y0, y1, ySpec.scale === "log");

/* Two kinds of x-axis. Most figures are categorical (months, halves), so
index position is the x. Figure 4 plots days-to-revocation, where the
gap between day 30 and day 90 has to be three times the gap before it —
give the config an `x` range and series get positioned by value. */
var xs = config.x && config.x.min != null ? Object.assign({ scale: "linear" }, config.x) : null;
var xv = xs ? makeScale(xs.min, xs.max, x0, x1, xs.scale === "log") : null;
var xAt = function (s, i) { return s.xData ? xv(s.xData[i]) : x(i); };

gridY(f, ySpec, y);
(config.referencesY || []).forEach(function (r) {
if (r.value == null || r.value ySpec.max) return;
var ry = y(r.value);
svg.appendChild(el(“line”, {
x1: x0, x2: x1, y1: ry, y2: ry, stroke: r.color || “#afb8c1”,
“stroke-width”: r.width == null ? 2.2 : r.width, “stroke-dasharray”: r.dash == null ? “8 7” : r.dash
}));
if (r.label) {
var rt = createText(r.label, {
“class”: “ghchart__ann”, x: (r.x == null ? x0 : (x0 + (x1 – x0) * r.x)), y: ry + (r.dy == null ? -12 : r.dy),
“text-anchor”: r.align === “right” ? “end” : r.align === “middle” ? “middle” : “start”
});
rt.style.fontSize = (r.size == null ? 21 : r.size) + “px”;
rt.style.fontWeight = r.weight == null ? 400 : r.weight;
rt.style.fill = r.labelColor || “#818b98”;
svg.appendChild(rt);
}
});
axisTitles(f, config, y0 + (config.xTitleOffset || 86));
if (xs && xs.ticks) {
xs.ticks.forEach(function (tv) {
svg.appendChild(createText(tickLabel(tv, xs), { “class”: “ghchart__axislabel ghchart__axislabel–x”, x: xv(tv), y: y0 + (config.xLabelOffset || 42), “text-anchor”: “middle” }));
if (xs.grid) svg.appendChild(el(“line”, { “class”: “ghchart__gridline”, x1: xv(tv), x2: xv(tv), y1: y0, y2: y1 }));
});
}
/* Dense daily axes (Figure 9 carries 49 days) will happily overprint
“May 25” on “May 26”. `xLabelMinGap` drops any label that would land
within N viewBox units of the last one drawn, keeping the first. */
var lastLabelX = -Infinity, minGap = config.xLabelMinGap || 0;
labels.forEach(function (label, i) {
if (label === “”) return;
var lx = x(i);
if (lx – lastLabelX 1) {
var fillD = pointsToPath(pts, config.tension || 0) + ” L” + pts[pts.length – 1].x + ” ” + y0 + ” L” + pts[0].x + ” ” + y0 + ” Z”;
svg.appendChild(el(“path”, { d: fillD, fill: s.ramp[0], opacity: s.fillOpacity }));
}
svg.appendChild(el(“path”, { “class”: “ghchart__line”, d: pointsToPath(pts, config.tension || 0), stroke: s.color, “stroke-width”: s.width, “stroke-dasharray”: s.dash }));
if (s.pointRadius > 0) {
pts.forEach(function (p, i) {
var r = i === pts.length – 1 ? s.pointRadiusLast : s.pointRadius;
var dot = el(“circle”, { “class”: “ghchart__dot”, cx: p.x, cy: p.y, r: r, fill: s.color, stroke: “#ffffff”, “stroke-width”: s.ringWidth });
dot.setAttribute(“data-r”, r);
dot.setAttribute(“data-i”, p.index);
svg.appendChild(dot);
plotted[plotted.length – 1].dots.push(dot);
});
}
});

/* Always-on comparison labels. SKILL §4 bars values on marks but states an
exception for deliberate comparison labels; this report also ships as a
print PDF, where hover does not exist, so the exception is the rule here. */
decorate(f, config, xs ? xv : x, y);

var guide = el(“line”, { “class”: “ghchart__guide”, x1: x0, x2: x0, y1: y1, y2: y0 });
svg.appendChild(guide);
var tip = createTooltip(root);

function tooltipLines(i) {
return series.filter(function (s) { return s.data[i] != null; }).map(function (s) {
var bits = [s.name + “: ” + fmtPlain(s.data[i], config.valueDecimals == null ? 1 : config.valueDecimals) + “” + (s.valueSuffix || “”)];
if (s.raw[i] != null) bits.push(“” + fmtPlain(s.raw[i], s.rawDecimals) + “” + (s.rawSuffix || “”));
return bits.join(” · “);
});
}
/* Hover geometry follows whichever x-axis the figure uses. On a value
axis the hit bands are uneven, which is correct: day 90 owns a wider
band than day 30 because it covers more of the plot. */
var hx = function (i) { return xs && series[0] && series[0].xData ? xv(series[0].xData[i]) : x(i); };
function activate(i, anchor) {
guide.setAttribute(“x1”, hx(i));
guide.setAttribute(“x2”, hx(i));
guide.style.opacity = “1”;
plotted.forEach(function (p) {
p.dots.forEach(function (dot) {
var r = Number(dot.getAttribute(“data-r”));
dot.setAttribute(“r”, Number(dot.getAttribute(“data-i”)) === i ? r + 3 : r);
});
});
tip.show(anchor, labels[i], tooltipLines(i));
}
function deactivate() {
guide.style.opacity = “0”;
plotted.forEach(function (p) {
p.dots.forEach(function (dot) { dot.setAttribute(“r”, dot.getAttribute(“data-r”)); });
});
tip.hide();
}
if (config.hover !== false) {
labels.forEach(function (_label, i) {
var left = i === 0 ? x0 : (hx(i – 1) + hx(i)) / 2;
var right = i === labels.length – 1 ? x1 : (hx(i) + hx(i + 1)) / 2;
var hit = el(“rect”, { “class”: “ghchart__hit”, x: left, y: y1, width: right – left, height: y0 – y1 });
hit.style.cursor = “pointer”;
hit.addEventListener(“pointermove”, function () { activate(i, hit); });
hit.addEventListener(“pointerleave”, deactivate);
svg.appendChild(hit);
});
}

root.appendChild(svg);
if (config.caption) {
var cap = html(“div”, “ghchart__caption”);
cap.innerHTML = config.caption;
root.appendChild(cap);
}
return root;
}

/* —- Scatter ——————————————————– *
Both axes are free (linear or log), which is what the distribution
figures need: repository spread against reuse, CCDF tails, public
against private rates. Optional `curves` draw underneath every marker so
a reference contour never sits on top of the data it explains. */
function renderScatter(root, config) {
root = openRoot(root, config);
if (!root) return root;

var xs = Object.assign({ scale: “linear” }, config.x);
var ys = Object.assign({ scale: “linear” }, config.y);
var f = frame(config, { right: 60, bottom: 104 });
var svg = f.svg;
svg.setAttribute(“class”, “ghchart__plot ghchart__plot–scatter”);

var x = makeScale(xs.min, xs.max, f.x0, f.x1, xs.scale === “log”);
var y = makeScale(ys.min, ys.max, f.y0, f.y1, ys.scale === “log”);

gridY(f, ys, y);
(xs.ticks || (xs.scale === “log” ? logTicks(xs.min, xs.max) : [])).forEach(function (tv) {
if (tv xs.max) return;
var xx = x(tv);
if (xs.grid !== false) svg.appendChild(el(“line”, { “class”: “ghchart__gridline”, x1: xx, x2: xx, y1: f.y0, y2: f.y1 }));
svg.appendChild(createText(tickLabel(tv, xs), { “class”: “ghchart__axislabel ghchart__axislabel–x”, x: xx, y: f.y0 + (config.xLabelOffset || 42), “text-anchor”: “middle” }));
});
axisTitles(f, config, f.y0 + (config.xTitleOffset || 86));

// Reference contours, drawn first so markers always sit above them.
if (config.reference) {
var rf = config.reference, rfx = x(rf.value);
svg.appendChild(el(“line”, {
x1: rfx, x2: rfx, y1: f.y1, y2: f.y0, stroke: rf.color || “#b9c0c8”,
“stroke-width”: rf.width || 2.8, “stroke-dasharray”: rf.dash || “9 7”
}));
if (rf.label) {
var rft = createText(rf.label, { “class”: “ghchart__ann”, x: rfx, y: f.y1 – 14, “text-anchor”: “middle” });
rft.style.fontSize = (rf.size || 23) + “px”;
rft.style.fill = rf.labelColor || “#9aa4ae”;
svg.appendChild(rft);
}
}
var clipId = “ghc-clip-” + Math.random().toString(36).slice(2, 9);
var defs = el(“defs”, {});
var cp = el(“clipPath”, { id: clipId });
cp.appendChild(el(“rect”, { x: f.x0, y: f.y1, width: f.x1 – f.x0, height: f.y0 – f.y1 }));
defs.appendChild(cp);
svg.appendChild(defs);

(config.curves || []).forEach(function (c) {
var d = c.points.map(function (p, i) { return (i ? “L” : “M”) + x(p[0]) + ” ” + y(p[1]); }).join(” “);
svg.appendChild(el(“path”, {
“class”: “ghchart__curve”, d: d, fill: “none”, “clip-path”: “url(#” + clipId + “)”,
stroke: c.color || “#d8dde3”, “stroke-width”: c.width || 2,
“stroke-dasharray”: c.dash || “”
}));
if (c.label) {
var lp = c.labelAt || c.points[c.points.length – 1];
var lt = createText(c.label, { “class”: “ghchart__ann”, x: x(lp[0]) + (c.dx || 0), y: y(lp[1]) + (c.dy || 0), “text-anchor”: c.align === “right” ? “end” : “start” });
lt.style.fontSize = (c.size || 21) + “px”;
lt.style.fill = c.labelColor || “#8c959f”;
lt.setAttribute(“dominant-baseline”, “central”);
svg.appendChild(lt);
}
});

var tip = createTooltip(root);
(config.points || []).forEach(function (p) {
var base = p.color || config.base || “#0969da”;
var cx = x(p.x), cy = y(p.y);
var dot = el(“circle”, {
“class”: “ghchart__dot”, cx: cx, cy: cy, r: p.r || config.pointRadius || 12,
fill: base, stroke: “#ffffff”, “stroke-width”: p.ringWidth || config.ringWidth || 2.8
});
if (p.opacity != null) dot.setAttribute(“opacity”, p.opacity);
dot.setAttribute(“data-r”, p.r || config.pointRadius || 12);
dot.style.cursor = “pointer”;
if (p.label || p.lines) {
dot.addEventListener(“pointermove”, function () {
dot.setAttribute(“r”, Number(dot.getAttribute(“data-r”)) + 3);
tip.show(dot, p.label || “”, p.lines || []);
});
dot.addEventListener(“pointerleave”, function () {
dot.setAttribute(“r”, dot.getAttribute(“data-r”));
tip.hide();
});
}
svg.appendChild(dot);
});

decorate(f, config, x, y);
return closeRoot(root, f, config);
}

/* —- Bar ———————————————————— *
Vertical bars on a categorical x-axis, with optional always-on value
labels above each bar — the same print-first reasoning as the line
annotations, since the PDF has no hover. */
function renderBar(root, config) {
root = openRoot(root, config);
if (!root) return root;

var labels = config.labels || [];
var ys = Object.assign({ scale: “linear” }, config.y);
var vals = (config.values || []).filter(function (v) { return v != null; });
if (ys.min == null) ys.min = ys.scale === “log” ? Math.min.apply(null, vals) : 0;
if (ys.max == null) ys.max = Math.max.apply(null, vals);

var f = frame(config, { right: 60 });
var svg = f.svg;
svg.setAttribute(“class”, “ghchart__plot ghchart__plot–bar”);
var y = makeScale(ys.min, ys.max, f.y0, f.y1, ys.scale === “log”);

gridY(f, ys, y);
axisTitles(f, config, f.y0 + (config.xTitleOffset || 86));

var n = labels.length || 1;
var slot = (f.x1 – f.x0) / n;
var bw = slot * (config.barWidth == null ? 0.62 : config.barWidth);
var tip = createTooltip(root);

labels.forEach(function (label, i) {
var cx = f.x0 + slot * (i + 0.5);
var v = (config.values || [])[i];
svg.appendChild(createText(label, { “class”: “ghchart__axislabel ghchart__axislabel–x”, x: cx, y: f.y0 + (config.xLabelOffset || 42), “text-anchor”: “middle” }));
if (v == null) return;
var top = y(v), base = (config.colors && config.colors[i]) || config.base || “#0969da”;
var rect = el(“rect”, {
“class”: “ghchart__bar”, x: cx – bw / 2, y: Math.min(top, f.y0),
width: bw, height: Math.max(Math.abs(f.y0 – top), 1),
fill: base, rx: config.radius == null ? 4 : config.radius
});
if (config.strokeColors && config.strokeColors[i] != null) rect.setAttribute(“stroke”, config.strokeColors[i]);
if (config.strokeWidths && config.strokeWidths[i] != null) rect.setAttribute(“stroke-width”, config.strokeWidths[i]);
if (config.strokeDasharrays && config.strokeDasharrays[i] != null) rect.setAttribute(“stroke-dasharray”, config.strokeDasharrays[i]);
rect.style.cursor = “pointer”;
var lines = (config.tooltips && config.tooltips[i]) || null;
if (lines) {
rect.addEventListener(“pointermove”, function () { tip.show(rect, label, lines); });
rect.addEventListener(“pointerleave”, tip.hide);
}
svg.appendChild(rect);
if (config.valueLabels) {
var vl = createText(config.valueLabels[i], { “class”: “ghchart__ann”, x: cx, y: top – (config.valueLabelOffset || 22), “text-anchor”: “middle” });
vl.style.fontSize = (config.valueLabelSize || 23) + “px”;
vl.style.fontWeight = 600;
vl.style.fill = config.valueLabelColor || “#424a53”;
vl.setAttribute(“dominant-baseline”, “central”);
svg.appendChild(vl);
}
});

decorate(f, config, function (i) { return f.x0 + slot * (i + 0.5); }, y);
return closeRoot(root, f, config);
}

/* —- Horizontal bar ————————————————– *
Categories on the y-axis, values running left to right. Used where the
category names are phrases rather than tokens (“HashiCorp Vault ·
secrets mgr”), which a vertical axis cannot carry without rotating type.
`reference` draws a vertical comparison line, e.g. a base rate. */
function renderBarH(root, config) {
root = openRoot(root, config);
if (!root) return root;

var labels = config.labels || [];
var values = config.values || [];
var xs = Object.assign({ scale: “linear”, min: 0 }, config.x);
if (xs.max == null) xs.max = Math.max.apply(null, values);

var f = frame(config, { top: 40, right: 230, bottom: 104, left: 420 });
var svg = f.svg;
svg.setAttribute(“class”, “ghchart__plot ghchart__plot–barh”);
var x = makeScale(xs.min, xs.max, f.x0, f.x1, xs.scale === “log”);

(xs.ticks || []).forEach(function (tv) {
var xx = x(tv);
svg.appendChild(el(“line”, { “class”: “ghchart__gridline”, x1: xx, x2: xx, y1: f.y0, y2: f.y1 }));
svg.appendChild(createText(tickLabel(tv, xs), { “class”: “ghchart__axislabel ghchart__axislabel–x”, x: xx, y: f.y0 + (config.xLabelOffset || 42), “text-anchor”: “middle” }));
});
axisTitles(f, config, f.y0 + (config.xTitleOffset || 86));

var n = labels.length || 1;
var slot = (f.y0 – f.y1) / n;
var bh = slot * (config.barWidth == null ? 0.6 : config.barWidth);
var tip = createTooltip(root);

// Reference line first, so bars and their labels always sit above it.
if (config.reference) {
var r = config.reference, rx = x(r.value);
svg.appendChild(el(“line”, {
x1: rx, x2: rx, y1: f.y1, y2: f.y0, stroke: r.color || “#b9c0c8”,
“stroke-width”: r.width || 2.8, “stroke-dasharray”: r.dash || “9 7”
}));
if (r.label) {
// Above the plot, not inside it: at the foot the label sat underneath
// the shortest bar, which is exactly where a base rate cannot hide.
var rt = createText(r.label, {
“class”: “ghchart__ann”, x: rx + (r.dx == null ? 14 : r.dx), y: f.y1 + (r.dy == null ? -14 : r.dy),
“text-anchor”: r.align === “right” ? “end” : r.align === “middle” ? “middle” : “start”
});
rt.style.fontSize = (r.size == null ? 21 : r.size) + “px”;
rt.style.fontWeight = r.weight == null ? 500 : r.weight;
rt.style.fill = r.labelColor || “#9aa4ae”;
svg.appendChild(rt);
}
}

labels.forEach(function (label, i) {
var cy = f.y1 + slot * (i + 0.5);
var v = values[i];
var lt = createText(label, { “class”: “ghchart__axislabel”, x: f.x0 – 20, y: cy, “text-anchor”: “end” });
lt.setAttribute(“dominant-baseline”, “central”);
if (config.categorySize) lt.style.fontSize = config.categorySize + “px”;
if (config.categoryColor) lt.style.fill = config.categoryColor;
svg.appendChild(lt);
if (v == null) return;
var bar = el(“rect”, {
“class”: “ghchart__bar”, x: f.x0, y: cy – bh / 2,
width: Math.max(x(v) – f.x0, 1), height: bh,
fill: (config.colors && config.colors[i]) || config.base || “#0969da”,
rx: config.radius == null ? 4 : config.radius
});
bar.style.cursor = “pointer”;
var lines = (config.tooltips && config.tooltips[i]) || null;
if (lines) {
bar.addEventListener(“pointermove”, function () { tip.show(bar, label, lines); });
bar.addEventListener(“pointerleave”, tip.hide);
}
svg.appendChild(bar);
var valueLabelX = x(v) + (config.valueLabelOffset == null ? 13 : config.valueLabelOffset);
if (config.wilsonIntervals) {
var nSource = config.ns && config.ns[i] != null ? config.ns[i] : (config.valueLabels && String(config.valueLabels[i]).match(/n=([0-9,]+)/));
var sampleN = Array.isArray(nSource) ? Number(String(nSource[1]).replace(/,/g, “”)) : Number(nSource);
if (sampleN > 0) {
var z = config.wilsonZ == null ? 1.959964 : config.wilsonZ;
var p = Number(v) / 100;
var denom = 1 + z * z / sampleN;
var center = (p + z * z / (2 * sampleN)) / denom;
var half = z / denom * Math.sqrt(p * (1 – p) / sampleN + z * z / (4 * sampleN * sampleN));
var lo = Math.max(xs.min, Math.min(xs.max, (center – half) * 100));
var hi = Math.max(xs.min, Math.min(xs.max, (center + half) * 100));
var xLo = x(lo), xHi = x(hi), cap = bh * 0.31;
valueLabelX = xHi + (config.valueLabelOffset == null ? 13 : config.valueLabelOffset);
svg.appendChild(el(“line”, { x1: xLo, x2: xHi, y1: cy, y2: cy, stroke: “#ffffff”, “stroke-width”: config.intervalHaloWidth == null ? 6 : config.intervalHaloWidth, “stroke-linecap”: “round” }));
svg.appendChild(el(“line”, { x1: xLo, x2: xLo, y1: cy – cap, y2: cy + cap, stroke: “#ffffff”, “stroke-width”: config.intervalHaloWidth == null ? 6 : config.intervalHaloWidth, “stroke-linecap”: “round” }));
svg.appendChild(el(“line”, { x1: xHi, x2: xHi, y1: cy – cap, y2: cy + cap, stroke: “#ffffff”, “stroke-width”: config.intervalHaloWidth == null ? 6 : config.intervalHaloWidth, “stroke-linecap”: “round” }));
svg.appendChild(el(“line”, { x1: xLo, x2: xHi, y1: cy, y2: cy, stroke: config.intervalColor || “#24292f”, “stroke-width”: config.intervalWidth == null ? 2.2 : config.intervalWidth, “stroke-linecap”: “round” }));
svg.appendChild(el(“line”, { x1: xLo, x2: xLo, y1: cy – cap, y2: cy + cap, stroke: config.intervalColor || “#24292f”, “stroke-width”: config.intervalWidth == null ? 2.2 : config.intervalWidth, “stroke-linecap”: “round” }));
svg.appendChild(el(“line”, { x1: xHi, x2: xHi, y1: cy – cap, y2: cy + cap, stroke: config.intervalColor || “#24292f”, “stroke-width”: config.intervalWidth == null ? 2.2 : config.intervalWidth, “stroke-linecap”: “round” }));
}
}
if (config.valueLabels) {
var vl = createText(config.valueLabels[i], { “class”: “ghchart__ann”, x: valueLabelX, y: cy });
vl.style.fontSize = (config.valueLabelSize || 21) + “px”;
vl.style.fontWeight = 600;
vl.style.fill = config.valueLabelColor || “#59636e”;
vl.setAttribute(“dominant-baseline”, “central”);
svg.appendChild(vl);
}
});

decorate(f, config, x, function (i) { return f.y1 + slot * (i + 0.5); });
return closeRoot(root, f, config);
}

// Square co-occurrence matrix. Cell colour is a sequential ramp derived from
// one base hex, extended two steps past the five-level convention so seven
// bands fit while the shared levels keep the skill’s exact mix ratios.
function matrixRamp(baseHex) {
var b = hexToRgb(baseHex);
return [
rgbToHex(mix(b, WHITE, 0.92)),
rgbToHex(mix(b, WHITE, 0.80)),
rgbToHex(mix(b, WHITE, 0.52)),
rgbToHex(mix(b, WHITE, 0.20)),
baseHex,
rgbToHex(mix(b, BLACK, 0.30)),
rgbToHex(mix(b, BLACK, 0.55))
];
}

function renderMatrix(root, config) {
root = openRoot(root, config);
if (!root) return root;

var f = frame(config, { top: 190, right: 30, bottom: 118, left: 200 });
var svg = f.svg;
var labels = config.labels || [];
var n = labels.length;
var groups = config.groups || [];
var groupColor = config.groupColor || {};
var m = config.matrix || [];
var breaks = config.breaks || [];
var ramp = matrixRamp(config.base || “#3094ff”);
var noData = config.noDataColor || “#ebedf0”;

var availW = f.x1 – f.x0, availH = f.y0 – f.y1;
var cell = Math.min(availW / n, availH / n);
var gw = cell * n;
var gl = f.x0 + Math.max(0, (availW – gw) / 2);
var gt = f.y1;

function band(v) {
for (var i = 0; i < breaks.length; i++) if (v < breaks[i]) return ramp[i];
return ramp[Math.min(breaks.length, ramp.length – 1)];
}
function labColor(i) { return groupColor[groups[i]] || "#59636e"; }
function labWeight(i) { return groups[i] === (config.emphasisGroup || "") ? 700 : 600; }

var tip = createTooltip(root, config.tooltip);
var labelSize = config.labelSize || 22;

for (var i = 0; i < n; i++) {
for (var j = 0; j < n; j++) {
var v = m[i][j];
var self = v == null || v existing.version) {
existing.version = VERSION;
existing.render = render;
existing.primitives = Kit;
}
global.GHChartKit = global.GHChartKit || Kit;

})(typeof globalThis !== “undefined” ? globalThis : this);

;(function () {
“use strict”;

function enhance(root, config) {
if (root.getAttribute(“data-ghchart-rendered”) === “true”) return;

var K = window.GHChartEngine.primitives;
var quarters = config.quarters;
var slots = Array.prototype.slice.call(root.querySelectorAll(“.ghpr__slot”));
var labels = Array.prototype.slice.call(root.querySelectorAll(“.ghpr__x-label”));
var guide = root.querySelector(“.ghpr__guide”);
var readout = root.querySelector(“.ghpr__readout”);
var readoutQuarter = root.querySelector(“.ghpr__readout-quarter”);
var readoutVolume = root.querySelector(“.ghpr__readout-volume”);
var readoutShare = root.querySelector(“.ghpr__readout-share”);
if (slots.length !== quarters.length || labels.length !== quarters.length ||
!guide || !readout || !readoutQuarter || !readoutVolume || !readoutShare) {
throw new Error(“The bar-and-line chart requires matching chart.html, chart.css, and chart.js.”);
}
function formatCount(value) {
return K.fmtCompact(Math.round(value / 1000000) * 1000000);
}
function updateReadout(q) {
readoutQuarter.textContent = q.label;
readoutVolume.textContent = formatCount(q.pushes);
readoutShare.textContent = (100 * q.finds / q.pushes).toFixed(2) + “%”;
}
root.style.setProperty(“–ghpr-volume-ink”, config.volumeBase);
root.style.setProperty(“–ghpr-volume-text”, K.strongOf(config.volumeBase));
root.style.setProperty(“–ghpr-share-ink”, config.rateBase);
root.style.setProperty(“–ghpr-share-text”, K.strongOf(config.rateBase));
root.style.setProperty(“–ghpr-bar-width”, config.barWidth);
readout.setAttribute(“role”, “status”);
readout.setAttribute(“aria-live”, “polite”);
readout.setAttribute(“aria-atomic”, “true”);

function reset() {
slots.forEach(function (slot) { slot.setAttribute(“data-active”, “false”); });
labels.forEach(function (label) { label.setAttribute(“data-active”, “false”); });
root.setAttribute(“data-ghpr-active”, “false”);
updateReadout(quarters[quarters.length – 1]);
readout.setAttribute(“data-active”, “false”);
}
function activate(i) {
var q = quarters[i];
slots.forEach(function (slot, index) {
slot.setAttribute(“data-active”, index === i ? “true” : “false”);
});
labels.forEach(function (label, index) {
label.setAttribute(“data-active”, index === i ? “true” : “false”);
});
root.style.setProperty(“–ghpr-active-position”, (i + 0.5) / quarters.length * 100 + “%”);
root.setAttribute(“data-ghpr-active”, “true”);
updateReadout(q);
readout.setAttribute(“data-active”, “true”);
}
slots.forEach(function (slot, i) {
var q = quarters[i];
slot.setAttribute(“role”, “button”);
slot.setAttribute(“tabindex”, “0”);
slot.setAttribute(“aria-label”, q.label + “: ” + formatCount(q.pushes) +
” public pushes screened; ” + (100 * q.finds / q.pushes).toFixed(2) +
” percent push prevalence, meaning pushes containing a detected provider secret. Arrow keys move between quarters.”);
slot.addEventListener(“pointerenter”, function () { activate(i); });
slot.addEventListener(“pointerdown”, function () { slot.focus(); activate(i); });
slot.addEventListener(“pointerleave”, function () {
if (document.activeElement !== slot) reset();
});
slot.addEventListener(“focus”, function () { activate(i); });
slot.addEventListener(“blur”, reset);
slot.addEventListener(“keydown”, function (event) {
var next;
if (event.key === “ArrowRight”) next = Math.min(slots.length – 1, i + 1);
else if (event.key === “ArrowLeft”) next = Math.max(0, i – 1);
else if (event.key === “Home”) next = 0;
else if (event.key === “End”) next = slots.length – 1;
else if (event.key === “Escape”) { reset(); return; }
else if (event.key === “Enter” || event.key === ” “) {
event.preventDefault();
activate(i);
return;
} else return;
event.preventDefault();
slots[next].focus();
});
});
root.setAttribute(“data-ghchart-rendered”, “true”);
}

var CONFIG = {
“title”: “More pushes, no clear rise in push prevalence”,
“subtitle”: “2024 Q2u20132026 Q2”,
“ariaLabel”: “Nine quarters, 2024 Q2 through 2026 Q2. Equal-width green bars show screened public pushes on the left axis, in millions. The blue line and quarterly points show push prevalence on the right axis, in percent: the share of screened public pushes containing a detected provider secret. The axes have different units; compare each series over time, not bar/line crossings. Public push volume grew 2.8 times, while the quarterly share varied from 0.47 to 0.71 percent without a clear upward trend. Supported provider patterns include GitHub’s own tokens. Blocked and bypassed attempts are included.”,
“volumeBase”: “#08872b”,
“rateBase”: “#0377ff”,
“quarters”: [
{
“label”: “2024 Q2”,
“pushes”: 202241213,
“finds”: 1046224
},
{
“label”: “2024 Q3”,
“pushes”: 195041780,
“finds”: 1056582
},
{
“label”: “2024 Q4”,
“pushes”: 223196423,
“finds”: 1107237
},
{
“label”: “2025 Q1”,
“pushes”: 247866195,
“finds”: 1292931
},
{
“label”: “2025 Q2”,
“pushes”: 258457360,
“finds”: 1830765
},
{
“label”: “2025 Q3”,
“pushes”: 304013079,
“finds”: 1933975
},
{
“label”: “2025 Q4”,
“pushes”: 347602577,
“finds”: 2228616
},
{
“label”: “2026 Q1”,
“pushes”: 428519511,
“finds”: 2626324
},
{
“label”: “2026 Q2”,
“pushes”: 574206558,
“finds”: 2712225
}
],
“barWidth”: 0.6,
“volumeY”: {
“min”: 0,
“max”: 600000000,
“ticks”: [
0,
300000000,
600000000
]
},
“rateY”: {
“min”: 0,
“max”: 1,
“ticks”: [
0,
0.5,
1
]
},
“scope”: {
“traffic”: “Public push traffic”,
“patterns”: “Supported provider patterns, including GitHub’s own tokens”,
“source”: “Blog author”
},
“analysis”: {
“model”: “Exploratory OLS on quarterly rate percentages versus quarter index”,
“quarters”: 9,
“slopePerYear”: 0.029215870393486706,
“ciLowPerYear”: -0.07200002336113129,
“ciHighPerYear”: 0.1304317641481047,
“pValue”: 0.5168367091504764,
“rSquared”: 0.062399929619299986
},
“captionHtml”: “

Public pushes, Q2 2024u2013Q2 2026. Push prevalence is the share with a detected secret. Covers supported provider patterns, including GitHubu2019s own tokens.

”
};
document.querySelectorAll(“[data-ghc-push-leak]”).forEach(function (root, i) {
if (root.getAttribute(“data-ghchart-rendered”) === “true”) return;
var id = “gh-chart-push-volume-leak-rate-” + i;
while (document.getElementById(id) && document.getElementById(id) !== root) {
i += 1;
id = “gh-chart-push-volume-leak-rate-” + i;
}
root.id = id;
enhance(root, CONFIG);
});
})();

.ghchart {
/* Theme tokens */
–ghc-text: #1f2328;
–ghc-muted: #656d76;
–ghc-grid: #d0d7de;
–ghc-axis: #59636e;
–ghc-card-bg: #ffffff;
–ghc-card-border: #d0d7de;
–ghc-divider: #b6bfb8;
–ghc-guide: #afb8c1;

/* GitHub brand font — matches the github.blog stack (Mona Sans, with
the blog’s metric fallback) so it renders identically in-context. */
font-family: “Mona Sans”, “Mona Sans Fallback”, -apple-system, “system-ui”,
“Segoe UI”, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;

position: relative;
box-sizing: border-box;
width: 100%;
max-width: 699px;
margin: 0 auto;
color: var(–ghc-text);
line-height: 1.4;
}

.ghchart *,
.ghchart *::before,
.ghchart *::after { box-sizing: border-box; }

/* —————————————————————— */
/* Header + legend */
/* —————————————————————— */
.ghchart__header {
display: flex;
justify-content: flex-end;
align-items: center;
min-height: 24px;
margin-bottom: 4px;
}

.ghchart__legend {
display: flex;
align-items: center;
gap: 20px;
}

.ghchart__legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: clamp(14px, 1.9vw, 17px);
font-weight: 600;
color: var(–ghc-muted);
white-space: nowrap;
}

.ghchart__swatch {
width: 14px;
height: 14px;
border-radius: 4px;
flex: 0 0 auto;
}

/* —————————————————————— */
/* Plot (SVG) */
/* —————————————————————— */
.ghchart__plot {
display: block;
width: 100%;
height: auto;
overflow: visible;
touch-action: pan-y;
}

.ghchart__gridline {
stroke: var(–ghc-grid);
stroke-width: 1;
stroke-dasharray: 2 6;
stroke-linecap: round;
opacity: 0.9;
}

.ghchart__axislabel {
fill: var(–ghc-axis);
font-size: 22px;
font-weight: 500;
}
.ghchart__axislabel–x { font-weight: 600; }

.ghchart__line {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}

.ghchart__dot { transition: r 0.12s ease; }

.ghchart__guide {
stroke: var(–ghc-guide);
stroke-width: 1.5;
stroke-dasharray: 4 4;
opacity: 0;
transition: opacity 0.12s ease;
}

.ghchart__hit { fill: transparent; }

/* —————————————————————— */
/* Title + divider */
/* —————————————————————— */
.ghchart__title {
/* Matches the github.blog H2 (.wp-block-heading) exactly */
display: block;
font-size: 32px;
line-height: 40px;
font-weight: 700;
letter-spacing: -0.18px;
color: var(–ghc-text);
}

.ghchart__rule {
height: 1.5px;
border-radius: 1.5px;
background: var(–ghc-divider);
margin: 10px 0 18px;
}

/* —————————————————————— */
/* Bar chart: subtitle, panels, bars, caption */
/* —————————————————————— */
.ghchart__subtitle {
margin: -6px 0 20px;
font-size: clamp(13px, 1.6vw, 15px);
line-height: 1.5;
color: var(–ghc-muted);
}

.ghchart__panel { margin-top: 30px; }
.ghchart__panel:first-of-type { margin-top: 6px; }

.ghchart__panel-title {
margin: 0 0 6px;
font-size: clamp(15px, 1.9vw, 18px);
font-weight: 700;
letter-spacing: -0.01em;
color: var(–ghc-text);
}

.ghchart__plot–bar { touch-action: pan-y; }

.ghchart__axisline {
stroke: var(–ghc-axis);
stroke-width: 1.25;
opacity: 0.35;
}

.ghchart__bar { transition: opacity 0.12s ease; }
.ghchart__barhit { fill: transparent; }

/* Always-on value labels above selected bars. */
.ghchart__barvalue {
fill: var(–ghc-text);
font-size: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
pointer-events: none;
}

/* Anchor annotation: dotted reference line + floating pill. */
.ghchart__anno-line {
stroke: #8b949e;
stroke-width: 1.5;
stroke-dasharray: 4 3;
stroke-linecap: round;
pointer-events: none;
}
.ghchart__anno-chip {
fill: #ffebe9;
stroke: #ffcecb;
stroke-width: 1;
pointer-events: none;
}
.ghchart__anno-chip-text {
fill: #a0111f;
font-weight: 700;
pointer-events: none;
}

.ghchart__caption {
margin-top: 22px;
padding-top: 12px;
border-top: 1px solid var(–ghc-card-border);
font-size: 12.5px;
line-height: 1.55;
color: var(–ghc-muted);
}
.ghchart__caption b { color: var(–ghc-text); font-weight: 700; }
.ghchart__caption code {
font-family: ui-monospace, SFMono-Regular, “SF Mono”, Menlo, Consolas, monospace;
font-size: 0.92em;
padding: 1px 5px;
background: #f6f8fa;
border: 1px solid var(–ghc-card-border);
border-radius: 5px;
}
.ghchart__tag {
display: inline-block;
font-size: 10px;
font-weight: 650;
letter-spacing: 0.03em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid #aceebb;
color: #0a5223;
background: #dafbe1;
white-space: nowrap;
vertical-align: middle;
}

/* —————————————————————— */
/* Calendar heatmap (GitHub contribution-graph style) */
/* —————————————————————— */
.ghheat__eyebrow { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; }
.ghheat__eyedot {
width: 9px; height: 9px; border-radius: 50%;
background: #cf222e; box-shadow: 0 0 0 3px rgba(207, 34, 46, 0.14);
flex: 0 0 auto;
}
.ghheat__eyetext {
font-size: 12px; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: #cf222e;
}
/* Subtitle here is a prominent lede, not the muted variant. */
.ghheat__subtitle {
color: var(–ghc-text);
font-size: clamp(14px, 1.7vw, 16px);
margin: 2px 0 8px;
}
.ghheat__subtitle b { font-weight: 700; }

.ghheat__panel { margin-top: 36px; }
.ghheat__phead {
display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap;
margin-bottom: 16px;
}
.ghheat__badge {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%;
color: #fff; font-size: 12px; font-weight: 700;
flex: 0 0 auto; align-self: center;
}
.ghheat__ptitle {
font-size: clamp(16px, 2vw, 19px); font-weight: 700;
letter-spacing: -0.01em; color: var(–ghc-text);
}
.ghheat__pdesc { font-size: 14px; color: var(–ghc-muted); }

.ghheat__body { display: flex; gap: 30px; align-items: flex-start; flex-wrap: wrap; }
.ghheat__gridwrap { flex: 1 1 340px; min-width: 260px; }
.ghheat__grid { display: block; width: 100%; height: auto; overflow: visible; }
.ghheat__cell {
stroke: rgba(27, 31, 36, 0.06);
stroke-width: 1;
transition: stroke 0.1s ease;
}
.ghheat__cell:hover { stroke: #24292f; stroke-width: 1.4; }
.ghheat__cell–pad { opacity: 0; pointer-events: none; }
.ghheat__wd, .ghheat__month {
fill: var(–ghc-muted); font-size: 11px; font-weight: 600;
}

.ghheat__side { flex: 1 1 250px; min-width: 230px; }
.ghheat__stat { display: flex; align-items: baseline; gap: 7px; }
.ghheat__statbig {
font-size: clamp(26px, 4vw, 34px); font-weight: 800; letter-spacing: -0.02em;
line-height: 1;
}
.ghheat__statsmall { font-size: 15px; font-weight: 600; color: var(–ghc-muted); }
.ghheat__note {
margin: 10px 0 16px; font-size: 14px; line-height: 1.55; color: var(–ghc-muted);
}
.ghheat__note b { color: var(–ghc-text); font-weight: 700; }
.ghheat__legend {
display: flex; align-items: center; gap: 5px;
font-size: 12.5px; color: var(–ghc-muted); flex-wrap: wrap;
}
.ghheat__sw { width: 12px; height: 12px; border-radius: 3px; display: inline-block; flex: 0 0 auto; }
.ghheat__sw–empty { border: 1px solid #d0d7de; }
.ghheat__legunit { margin-left: 2px; }
.ghheat__extra {
display: flex; align-items: flex-start; gap: 7px; margin-top: 14px;
font-size: 12px; line-height: 1.5; color: var(–ghc-muted); max-width: 330px;
}
.ghheat__extra .ghheat__sw { margin-top: 2px; }

/* —————————————————————— */
/* Tooltip */
/* —————————————————————— */
.ghchart__tooltip {
position: absolute;
z-index: 5;
top: 0;
left: 0;
min-width: 128px;
max-width: 260px;
padding: 10px 12px;
background: var(–ghc-card-bg);
border: 1px solid var(–ghc-card-border);
border-radius: 12px;
/* Layered drop shadow from Primer Brand’s ActionMenu (.ActionMenu__menu) */
box-shadow:
0px 100px 80px rgba(0, 0, 0, 0.01),
0px 41px 33px rgba(0, 0, 0, 0.02),
0px 22px 17px rgba(0, 0, 0, 0.02),
0px 12px 10px rgba(0, 0, 0, 0.03),
0px 6px 5px rgba(0, 0, 0, 0.04),
0px 2px 2px rgba(0, 0, 0, 0.07);
pointer-events: none;
opacity: 0;
transform: translate(-50%, -100%);
/* Fade in on show and out on hide; quickly ease left/top when tracking
from one point to the next. The position ease is suppressed on the
first appearance (see chart.js) so the tooltip fades in place rather
than sliding in from its previous spot. */
transition: opacity 0.12s ease,
left 0.1s cubic-bezier(0.4, 0, 0.2, 1),
top 0.1s cubic-bezier(0.4, 0, 0.2, 1);
will-change: opacity, transform, left, top;
}
.ghchart__tooltip[data-visible=”true”] { opacity: 1; }

.ghchart__tt-title {
font-size: 13px;
font-weight: 700;
color: var(–ghc-text);
margin-bottom: 6px;
}

.ghchart__tt-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
line-height: 1.6;
color: var(–ghc-muted);
}
.ghchart__tt-swatch { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
.ghchart__tt-name { flex: 1 1 auto; }
.ghchart__tt-value { font-weight: 700; color: var(–ghc-text); font-variant-numeric: tabular-nums; }

/* Bar tooltip body: a title line + free-form text lines (no swatches). */
.ghchart__tt-line {
font-size: 13px;
line-height: 1.5;
color: var(–ghc-muted);
}
.ghchart__tt-line:first-child { color: var(–ghc-text); font-weight: 600; }
.ghchart__tt-line + .ghchart__tt-line { margin-top: 2px; }

@media (prefers-reduced-motion: reduce) {
.ghchart__dot,
.ghchart__guide,
.ghchart__bar,
.ghheat__cell,
.ghchart__tooltip { transition: none; }
}

/* —————————————————————— */
/* Co-occurrence matrix */
/* —————————————————————— */
.ghchart__matrixrule { stroke: #aeb6bf; stroke-width: 2.4; }
.ghchart__matrixframe { stroke: var(–ghc-card-border); stroke-width: 1.8; }
.ghchart__matrixswatch { stroke: var(–ghc-card-border); stroke-width: 1.4; }

[data-ghc-push-leak] {
–ghpr-column-gap: 6px;
–ghpr-frame-inset: 16px;
–ghpr-plot-left: calc(42px + var(–ghpr-frame-inset));
–ghpr-plot-right: calc(36px + var(–ghpr-frame-inset));
–ghpr-border: #e4ebe6;
–ghc-text: #000000;
–ghc-muted: #58635b;
–ghc-axis: #58635b;
–ghc-grid: #e4ebe6;
–ghc-divider: #b6bfb8;
font-family: “Mona Sans”, “MonaSansFallback”, -apple-system, BlinkMacSystemFont,
“Segoe UI”, Helvetica, Arial, sans-serif;
padding: 0;
border: 0;
border-radius: 0;
background: var(–ghc-card-bg);
box-shadow: none;
overflow: hidden;
}
[data-ghc-push-leak] .ghpr__header {
padding: 0 0 14px;
border-bottom: 0;
}
[data-ghc-push-leak] .ghchart__title {
display: block;
max-width: 620px;
margin-bottom: 0;
font-size: 20px;
line-height: 26px;
font-weight: 600;
letter-spacing: -0.01em;
text-wrap: balance;
}
[data-ghc-push-leak] .ghpr__body {
padding: 0;
}
[data-ghc-push-leak] .ghpr__legend {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-bottom: 8px;
}
[data-ghc-push-leak] .ghpr__legend-item:last-child { text-align: right; }
[data-ghc-push-leak] .ghpr__legend-item:last-child .ghpr__legend-name { justify-content: flex-end; }
[data-ghc-push-leak] .ghpr__legend-name {
display: flex;
height: 18px;
align-items: center;
gap: 6px;
font-size: 13px;
line-height: 18px;
font-weight: 600;
}
[data-ghc-push-leak] .ghpr__swatch {
display: inline-block;
flex: 0 0 auto;
}
[data-ghc-push-leak] .ghpr__swatch–volume {
width: 10px;
height: 12px;
border-radius: 1px 1px 0 0;
background: var(–ghpr-volume-ink);
}
[data-ghc-push-leak] .ghpr__swatch–prevalence {
position: relative;
width: 18px;
height: 2px;
background: var(–ghpr-share-ink);
}
[data-ghc-push-leak] .ghpr__swatch–prevalence::after {
content: “”;
position: absolute;
left: 6px;
top: -2px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(–ghpr-share-ink);
}
[data-ghc-push-leak] .ghpr__frame {
position: relative;
box-sizing: border-box;
height: 260px;
border: 1px solid var(–ghpr-border);
border-radius: 8px;
}
[data-ghc-push-leak] .ghpr__plot {
position: absolute;
left: var(–ghpr-plot-left);
right: var(–ghpr-plot-right);
top: 24px;
bottom: 56px;
}
[data-ghc-push-leak] .ghpr__grid,
[data-ghc-push-leak] .ghpr__columns {
position: absolute;
inset: 0;
}
[data-ghc-push-leak] .ghpr__grid { pointer-events: none; }
[data-ghc-push-leak] .ghpr__gridline {
position: absolute;
left: 0;
right: 0;
border-top: 1px solid var(–ghc-grid);
opacity: 1;
}
[data-ghc-push-leak] .ghpr__gridline–baseline {
border-top-color: var(–ghc-divider);
opacity: 1;
}
[data-ghc-push-leak] .ghpr__guide {
position: absolute;
left: var(–ghpr-active-position, 0%);
top: 0;
bottom: 0;
transform: translateX(-50%);
border-left: 1px dashed var(–ghc-guide);
pointer-events: none;
opacity: 0;
z-index: 1;
transition: opacity 120ms ease;
}
[data-ghc-push-leak][data-ghpr-active=”true”] .ghpr__guide { opacity: 1; }
[data-ghc-push-leak] .ghpr__columns {
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
}
[data-ghc-push-leak] .ghpr__slot { position: relative; }
[data-ghc-push-leak][data-ghchart-rendered=”true”] .ghpr__slot { cursor: pointer; }
[data-ghc-push-leak] .ghpr__slot:focus-visible { outline: none; }
[data-ghc-push-leak] .ghpr__column {
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: calc((100% – var(–ghpr-column-gap)) * var(–ghpr-bar-width));
height: var(–ghpr-height);
border-radius: 1px 1px 0 0;
background: var(–ghpr-volume-ink);
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
[data-ghc-push-leak] .ghpr__slot:focus-visible .ghpr__column {
outline: 1.25px solid #0377ff;
outline-offset: 2px;
}
[data-ghc-push-leak] .ghpr__prevalence-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
z-index: 1;
pointer-events: none;
}
[data-ghc-push-leak] .ghpr__prevalence-line,
[data-ghc-push-leak] .ghpr__prevalence-halo {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
[data-ghc-push-leak] .ghpr__prevalence-line {
stroke: var(–ghpr-share-ink);
stroke-width: 2.25px;
}
[data-ghc-push-leak] .ghpr__prevalence-halo {
stroke: var(–ghc-card-bg);
stroke-width: 4.25px;
}
[data-ghc-push-leak] .ghpr__point {
position: absolute;
left: 50%;
top: var(–ghpr-prevalence-top);
transform: translate(-50%, -50%);
width: 6px;
height: 6px;
border: 1px solid var(–ghc-card-bg);
border-radius: 50%;
background: var(–ghpr-share-ink);
pointer-events: none;
z-index: 2;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
transition: width 120ms ease, height 120ms ease;
}
[data-ghc-push-leak] .ghpr__slot[data-active=”true”] .ghpr__point,
[data-ghc-push-leak] .ghpr__slot:focus-visible .ghpr__point {
width: 8px;
height: 8px;
}
[data-ghc-push-leak] .ghpr__volume-label {
position: absolute;
left: 50%;
font-size: 12px;
line-height: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
z-index: 3;
}
[data-ghc-push-leak] .ghpr__volume-label {
bottom: calc(var(–ghpr-height) + 8px);
transform: translateX(-50%);
color: var(–ghpr-volume-text);
}
[data-ghc-push-leak] .ghpr__volume-label–last {
left: auto;
right: 0;
transform: none;
}
[data-ghc-push-leak] .ghpr__y-axis {
position: absolute;
top: 24px;
bottom: 56px;
width: 30px;
}
[data-ghc-push-leak] .ghpr__y-axis–volume {
left: var(–ghpr-frame-inset);
width: 36px;
text-align: right;
color: var(–ghpr-volume-text);
}
[data-ghc-push-leak] .ghpr__y-axis–rate {
right: var(–ghpr-frame-inset);
text-align: left;
color: var(–ghpr-share-text);
}
[data-ghc-push-leak] .ghpr__axis-label {
position: absolute;
left: 0;
right: 0;
transform: translateY(-50%);
font-size: 12px;
line-height: 1;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
[data-ghc-push-leak] .ghpr__x-axis {
position: absolute;
left: var(–ghpr-plot-left);
right: var(–ghpr-plot-right);
bottom: 12px;
height: 40px;
}
[data-ghc-push-leak] .ghpr__x-label,
[data-ghc-push-leak] .ghpr__year-label {
position: absolute;
top: 4px;
transform: translateX(-50%);
font-size: 12px;
line-height: 14px;
color: var(–ghc-axis);
white-space: nowrap;
}
[data-ghc-push-leak] .ghpr__year-label { top: 22px; }
[data-ghc-push-leak] .ghpr__x-label[data-active=”true”] {
color: var(–ghc-text);
font-weight: 600;
text-decoration: underline;
text-underline-offset: 3px;
}
[data-ghc-push-leak] .ghpr__readout {
min-height: 18px;
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(–ghc-muted);
font-variant-numeric: tabular-nums;
}
[data-ghc-push-leak] .ghpr__readout[data-active=”true”] { color: var(–ghc-text); }
[data-ghc-push-leak] .ghpr__readout-volume {
color: var(–ghpr-volume-text);
font-weight: 600;
}
[data-ghc-push-leak] .ghpr__readout-share {
color: var(–ghpr-share-text);
font-weight: 600;
}
[data-ghc-push-leak] .ghpr__key-volume { color: var(–ghpr-volume-text); }
[data-ghc-push-leak] .ghpr__key-share { color: var(–ghpr-share-text); }
[data-ghc-push-leak] .ghchart__caption {
margin: 8px 0 0;
padding: 10px 0 0;
border-top: 1px solid var(–ghpr-border);
background: var(–ghc-card-bg);
font-size: 12px;
line-height: 18px;
color: var(–ghc-muted);
}
@media (prefers-reduced-motion: reduce) {
[data-ghc-push-leak] .ghpr__guide,
[data-ghc-push-leak] .ghpr__point {
transition: none;
}
}
@media print {
[data-ghc-push-leak] .ghpr__guide { display: none; }
}
@media (max-width: 520px) {
[data-ghc-push-leak] { –ghpr-column-gap: 3px; –ghpr-frame-inset: 12px; }
[data-ghc-push-leak] .ghpr__header { padding: 0 0 12px; }
[data-ghc-push-leak] .ghpr__body { padding: 0; }
[data-ghc-push-leak] .ghpr__frame { height: 240px; }
[data-ghc-push-leak] .ghchart__caption { padding: 9px 0 0; font-size: 12px; line-height: 18px; }
[data-ghc-push-leak] .ghchart__title { font-size: 19px; line-height: 25px; }
[data-ghc-push-leak] .ghpr__readout { min-height: 36px; margin-top: 8px; }
}

More pushes, no clear rise in push prevalence

2026 Q2 · 574M pushes · 0.47% with secrets
Public pushes, Q2 2024–Q2 2026. Push prevalence is the share with a detected secret. Covers supported provider patterns, including GitHub’s own tokens.

/* GitHub blog chart engine. Dependency-free SVG. */
;(function (global) {
“use strict”;

var SVGNS = “http://www.w3.org/2000/svg”;

/* —- DOM + math —————————————————- */

// Create an SVG element with attributes: el(“rect”, { x: 0, width: 10 }).
function el(tag, attrs) {
var node = document.createElementNS(SVGNS, tag);
if (attrs) {
for (var k in attrs) {
if (Object.prototype.hasOwnProperty.call(attrs, k)) node.setAttribute(k, attrs[k]);
}
}
return node;
}

// Create an HTML element with an optional class: html(“div”, “ghchart__side”).
function html(tag, cls) {
var node = document.createElement(tag);
if (cls) node.className = cls;
return node;
}

function clamp(v, lo, hi) { return v hi ? hi : v; }

/* —- Color ramp —————————————————- *
The whole palette for a series is derived from ONE base hex, so a
single tunable color drives an entire faint->dark scale. This is the
convention behind the contribution-heatmap shading and the accent
colors on badges/stat numbers. */

var WHITE = [255, 255, 255], BLACK = [0, 0, 0];

function hexToRgb(h) {
h = String(h).replace(“#”, “”);
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}

function rgbToHex(c) {
return “#” + c.map(function (x) {
x = Math.max(0, Math.min(255, Math.round(x)));
return (x level 4 (dark), from one base hex.
// Ratios are the project standard — keep them identical across charts.
function rampFrom(baseHex) {
var b = hexToRgb(baseHex);
return [
rgbToHex(mix(b, WHITE, 0.80)),
rgbToHex(mix(b, WHITE, 0.52)),
rgbToHex(mix(b, WHITE, 0.20)),
baseHex,
rgbToHex(mix(b, BLACK, 0.30))
];
}

// The “strong” step (level 4) — use it for badges, stat numbers, accents.
function strongOf(baseHex) { return rampFrom(baseHex)[4]; }

/* —- Number formatting ——————————————– */

// 1234567 -> “1,234,567”
function fmtInt(n) { return Math.round(n).toLocaleString(“en-US”); }

// Compact absolute counts: 6400000 -> “6.4M”, 2000000000 -> “2B”, 39000 -> “39K”.
function fmtCompact(n) {
var v = Math.abs(n), s = n = 1e9) return s + trim((v / 1e9).toFixed(1)) + “B”;
if (v >= 1e6) return s + trim((v / 1e6).toFixed(1)) + “M”;
if (v >= 1e3) return s + Math.round(v / 1e3) + “K”;
return s + String(Math.round(v));
}

/* —- Tooltip ——————————————————- *
One shared tooltip per chart. `root` must be position:relative (the
.ghchart wrapper is). Returns { el, show, hide }.

var tip = createTooltip(root);
marker.addEventListener(“pointermove”, function () {
tip.show(marker, “Mon · May 11, 2026”, [
“10.4M pushes/day”,
“May · 321.9M scanned”
]);
});
marker.addEventListener(“pointerleave”, tip.hide);

Motion contract (matches chart.css):
• fade in/out at 0.12s;
• when moving between marks, ease left/top at 0.1s;
• on the FIRST appearance the position ease is suppressed so the card
fades in place instead of sliding in from the previous mark;
• flip below the anchor when there’s no room above;
• clamp horizontally so it never overflows the chart width;
• prefers-reduced-motion disables the eases (handled in chart.css). */
function createTooltip(root, opts) {
opts = opts || {};
var tip = html(“div”, opts.className || “ghchart__tooltip”);
tip.setAttribute(“role”, “status”);
var titleEl = html(“div”, “ghchart__tt-title”);
var bodyEl = html(“div”, “ghchart__tt-body”);
tip.appendChild(titleEl);
tip.appendChild(bodyEl);
root.appendChild(tip);

var mq = window.matchMedia ? window.matchMedia(“(prefers-reduced-motion: reduce)”) : null;
var visible = false;

function hide() { visible = false; tip.setAttribute(“data-visible”, “false”); }

function show(anchorEl, titleText, lines) {
var firstAppear = !visible;
visible = true;
titleEl.textContent = titleText;
bodyEl.textContent = “”;
(lines || []).forEach(function (ln) {
var row = html(“div”, “ghchart__tt-line”);
row.innerHTML = ln; // lines may contain …
bodyEl.appendChild(row);
});

var a = anchorEl.getBoundingClientRect();
var r = root.getBoundingClientRect();
var left = a.left + a.width / 2 – r.left;
var top = a.top – r.top – 12;

// Suppress the position ease the first time so it fades in place.
var suppress = firstAppear && !(mq && mq.matches);
if (suppress) tip.style.transition = “opacity 0.12s ease”;

tip.setAttribute(“data-visible”, “true”);
var halfW = tip.offsetWidth / 2;
left = clamp(left, halfW + 2, root.clientWidth – halfW – 2);

if (top – tip.offsetHeight flip below
top = a.bottom – r.top + 12;
tip.style.transform = “translate(-50%, 0)”;
} else {
tip.style.transform = “translate(-50%, -100%)”;
}
tip.style.left = left + “px”;
tip.style.top = top + “px”;

if (suppress) { void tip.offsetWidth; tip.style.transition = “”; }
return tip;
}

return { el: tip, show: show, hide: hide };
}

var Kit = {
el: el, html: html, clamp: clamp,
hexToRgb: hexToRgb, rgbToHex: rgbToHex, mix: mix,
rampFrom: rampFrom, strongOf: strongOf,
fmtInt: fmtInt, fmtCompact: fmtCompact,
createTooltip: createTooltip
};

function pointsToPath(points, tension) {
if (!points.length) return “”;
if (points.length === 1) return “M” + points[0].x + ” ” + points[0].y;
var d = “M” + points[0].x + ” ” + points[0].y;
var t = tension || 0;
for (var i = 0; i 0) {
var cp1x = p1.x + (p2.x – p0.x) * t / 6;
var cp1y = p1.y + (p2.y – p0.y) * t / 6;
var cp2x = p2.x – (p3.x – p1.x) * t / 6;
var cp2y = p2.y – (p3.y – p1.y) * t / 6;
d += ” C” + cp1x + ” ” + cp1y + ” ” + cp2x + ” ” + cp2y + ” ” + p2.x + ” ” + p2.y;
} else {
d += ” L” + p2.x + ” ” + p2.y;
}
}
return d;
}

function fmtPlain(v, decimals) {
var n = Number(v);
if (!isFinite(n)) return String(v);
if (decimals == null) return String(v);
return n.toFixed(decimals).replace(/.0+$/, “”).replace(/(.d*?)0+$/, “$1”);
}

function createText(txt, attrs) {
var t = el(“text”, attrs);
t.textContent = txt;
return t;
}

/* —- Axis scales —————————————————- *
Maps a data value onto a pixel coordinate. `p0` is the pixel for `min`
and `p1` the pixel for `max`, so a y-axis passes (y0, y1) — bottom
first — and an x-axis passes (x0, x1). Log axes clamp at `min` rather
than returning -Infinity, because several figures carry legitimate
zeros that must sit on the floor instead of vanishing. */
function makeScale(min, max, p0, p1, isLog) {
if (isLog) {
var lo = Math.log(Math.max(min, 1e-12)) / Math.LN10;
var span = (Math.log(Math.max(max, 1e-12)) / Math.LN10) – lo || 1;
return function (v) {
var n = Number(v);
if (!isFinite(n) || n [10, 100, … 100000].
function logTicks(min, max) {
var out = [];
for (var e = Math.ceil(Math.log(min) / Math.LN10 – 1e-9); Math.pow(10, e) <= max; e++) out.push(Math.pow(10, e));
return out;
}

// Axis tick text: log axes read better compact (1K) than plain (1000).
function tickLabel(v, spec) {
if (spec && typeof spec.format === "function") return spec.format(v);
if (spec && spec.format === "compact") return fmtCompact(v);
if (spec && spec.format === "int") return fmtInt(v);
if (spec && spec.scale === "log") return v < 1 ? String(v) : fmtCompact(v);
return fmtPlain(v, spec && spec.decimals);
}

/* Shared plot-frame setup: viewBox, margins, scales, gridlines, axis
titles. Every renderer below starts here so the 13 figures cannot drift
apart on type size, gutters, or tick placement. */
function frame(config, defaults) {
var W = (config.viewBox && config.viewBox.width) || 1400;
var H = (config.viewBox && config.viewBox.height) || 810;
var m = Object.assign({ top: 56, right: 60, bottom: 76, left: 128 }, defaults || {}, config.margin || {});
var f = { W: W, H: H, m: m, x0: m.left, x1: W – m.right, y0: H – m.bottom, y1: m.top };
f.svg = el("svg", {
"class": "ghchart__plot", viewBox: "0 0 " + W + " " + H,
role: "img", "aria-label": config.ariaLabel || config.title || "Chart"
});
f.svg.style.width = "100%";
f.svg.style.height = "auto";
f.svg.style.overflow = "visible";
return f;
}

// Horizontal gridlines + left tick labels for a value axis.
function gridY(f, spec, yFn) {
var ticks = spec.ticks || (spec.scale === "log" ? logTicks(spec.min, spec.max) : []);
ticks.forEach(function (tv) {
if (tv spec.max) return;
var yy = yFn(tv);
f.svg.appendChild(el(“line”, { “class”: “ghchart__gridline”, x1: f.x0, x2: f.x1, y1: yy, y2: yy }));
f.svg.appendChild(createText(tickLabel(tv, spec), { “class”: “ghchart__axislabel”, x: f.x0 – 18, y: yy + 7, “text-anchor”: “end” }));
});
}

function axisTitles(f, config, xLabelY) {
if (config.y && config.y.title) {
f.svg.appendChild(createText(config.y.title, {
“class”: “ghchart__axislabel”, x: -(f.y1 + f.y0) / 2, y: config.y.titleOffset || 30,
transform: “rotate(-90)”, “text-anchor”: “middle”
}));
}
if (config.x && config.x.title) {
f.svg.appendChild(createText(config.x.title, {
“class”: “ghchart__axislabel”, x: (f.x0 + f.x1) / 2, y: xLabelY == null ? f.y0 + 86 : xLabelY, “text-anchor”: “middle”
}));
}
}

// Always-on comparison labels and the double-headed gap arrow, shared by
// every renderer. `xFn`/`yFn` translate data space; `px`/`py` bypass them.
function decorate(f, config, xFn, yFn) {
function annText(txt, ax, ay, opts) {
var t = createText(txt, { “class”: “ghchart__ann”, x: ax, y: ay, “text-anchor”: opts.anchor || “start” });
t.style.fontSize = (opts.size || 23) + “px”;
t.style.fontWeight = opts.weight || 400;
t.style.fill = opts.color || “#656d76”;
t.setAttribute(“dominant-baseline”, “central”);
return t;
}
(config.annotations || []).forEach(function (a) {
var px = (a.px != null ? a.px : xFn(a.x)) + (a.dx || 0);
var py = (a.py != null ? a.py : yFn(a.y)) + (a.dy || 0);
var anchor = a.align === “right” ? “end” : a.align === “middle” ? “middle” : “start”;
String(a.text).split(“n”).forEach(function (ln, i) {
f.svg.appendChild(annText(ln, px, py + i * (a.lh || 30), { anchor: anchor, size: a.size, weight: a.weight, color: a.color }));
});
});
var gaps = config.gaps || (config.gap ? [config.gap] : []);
gaps.forEach(function (g) {
var gx = (g.px != null ? g.px : xFn(g.x)) + (g.dx || 0), yT = yFn(g.top), yB = yFn(g.bottom);
var col = g.color || “#8250df”;
f.svg.appendChild(el(“line”, { x1: gx, x2: gx, y1: yT + 9, y2: yB – 9, stroke: col, “stroke-width”: g.width || 2.3 }));
[[yT, 1], [yB, -1]].forEach(function (h) {
f.svg.appendChild(el(“polygon”, {
points: gx + “,” + h[0] + ” ” + (gx – 6.6) + “,” + (h[0] + h[1] * 12) + ” ” + (gx + 6.6) + “,” + (h[0] + h[1] * 12),
fill: col
}));
});
var mid = (yT + yB) / 2;
f.svg.appendChild(annText(g.t1, gx + 20, mid – 15, { size: g.size1 || 26, weight: 700, color: col }));
f.svg.appendChild(annText(g.t2, gx + 20, mid + 15, { size: g.size2 || 21, color: col }));
});
return annText;
}

// Shared chrome: resets the mount, writes optional title/subtitle, and
// returns false when the mount is missing or already rendered.
function openRoot(root, config) {
root = typeof root === “string” ? document.getElementById(root) : root;
if (!root || root.getAttribute(“data-ghchart-rendered”) === “true”) return null;
root.setAttribute(“data-ghchart-rendered”, “true”);
root.classList.add(“ghchart”);
root.textContent = “”;
if (config.title) {
var title = html(“span”, “ghchart__title”);
title.textContent = config.title;
root.appendChild(title);
}
if (config.subtitle) {
var sub = html(“p”, “ghchart__subtitle”);
sub.textContent = config.subtitle;
root.appendChild(sub);
}
return root;
}

function closeRoot(root, f, config) {
root.appendChild(f.svg);
if (config.caption) {
var cap = html(“div”, “ghchart__caption”);
cap.innerHTML = config.caption;
root.appendChild(cap);
}
return root;
}

function renderLine(root, config) {
root = openRoot(root, config);
if (!root) return root;

var labels = config.labels || [];
var series = (config.series || []).map(function (s) {
var ramp = rampFrom(s.base || config.base || “#3094ff”);
return {
name: s.name,
data: s.data || [],
xData: s.xData,
raw: s.raw || [],
base: s.base || config.base || “#3094ff”,
ramp: ramp,
color: ramp[s.strokeStep == null ? 3 : s.strokeStep],
width: s.width || 4,
dash: s.dash || “”,
fill: !!s.fill,
fillOpacity: s.fillOpacity == null ? 0.72 : s.fillOpacity,
pointRadius: s.pointRadius == null ? 6 : s.pointRadius,
pointRadiusLast: s.pointRadiusLast == null ? (s.pointRadius == null ? 7 : s.pointRadius) : s.pointRadiusLast,
ringWidth: s.ringWidth == null ? 2.8 : s.ringWidth,
valueSuffix: s.valueSuffix || “”,
rawSuffix: s.rawSuffix || “”,
rawDecimals: s.rawDecimals
};
});
var allVals = series.reduce(function (a, s) { return a.concat(s.data.filter(function (v) { return v != null; })); }, []);
var ySpec = Object.assign({}, config.y);
if (ySpec.min == null) ySpec.min = Math.min.apply(null, allVals);
if (ySpec.max == null) ySpec.max = Math.max.apply(null, allVals);

var f = frame(config, { right: 60 });
var x0 = f.x0, x1 = f.x1, y0 = f.y0, y1 = f.y1, W = f.W, H = f.H, svg = f.svg;
svg.setAttribute(“class”, “ghchart__plot ghchart__plot–line”);
svg.setAttribute(“aria-label”, config.ariaLabel || (config.title || “Line chart”) + “: ” + series.map(function (s) { return s.name; }).join(” and “));

var x = function (i) { return labels.length < 2 ? (x0 + x1) / 2 : x0 + (x1 – x0) * i / (labels.length – 1); };
var y = makeScale(ySpec.min, ySpec.max, y0, y1, ySpec.scale === "log");

/* Two kinds of x-axis. Most figures are categorical (months, halves), so
index position is the x. Figure 4 plots days-to-revocation, where the
gap between day 30 and day 90 has to be three times the gap before it —
give the config an `x` range and series get positioned by value. */
var xs = config.x && config.x.min != null ? Object.assign({ scale: "linear" }, config.x) : null;
var xv = xs ? makeScale(xs.min, xs.max, x0, x1, xs.scale === "log") : null;
var xAt = function (s, i) { return s.xData ? xv(s.xData[i]) : x(i); };

gridY(f, ySpec, y);
(config.referencesY || []).forEach(function (r) {
if (r.value == null || r.value ySpec.max) return;
var ry = y(r.value);
svg.appendChild(el(“line”, {
x1: x0, x2: x1, y1: ry, y2: ry, stroke: r.color || “#afb8c1”,
“stroke-width”: r.width == null ? 2.2 : r.width, “stroke-dasharray”: r.dash == null ? “8 7” : r.dash
}));
if (r.label) {
var rt = createText(r.label, {
“class”: “ghchart__ann”, x: (r.x == null ? x0 : (x0 + (x1 – x0) * r.x)), y: ry + (r.dy == null ? -12 : r.dy),
“text-anchor”: r.align === “right” ? “end” : r.align === “middle” ? “middle” : “start”
});
rt.style.fontSize = (r.size == null ? 21 : r.size) + “px”;
rt.style.fontWeight = r.weight == null ? 400 : r.weight;
rt.style.fill = r.labelColor || “#818b98”;
svg.appendChild(rt);
}
});
axisTitles(f, config, y0 + (config.xTitleOffset || 86));
if (xs && xs.ticks) {
xs.ticks.forEach(function (tv) {
svg.appendChild(createText(tickLabel(tv, xs), { “class”: “ghchart__axislabel ghchart__axislabel–x”, x: xv(tv), y: y0 + (config.xLabelOffset || 42), “text-anchor”: “middle” }));
if (xs.grid) svg.appendChild(el(“line”, { “class”: “ghchart__gridline”, x1: xv(tv), x2: xv(tv), y1: y0, y2: y1 }));
});
}
/* Dense daily axes (Figure 9 carries 49 days) will happily overprint
“May 25” on “May 26”. `xLabelMinGap` drops any label that would land
within N viewBox units of the last one drawn, keeping the first. */
var lastLabelX = -Infinity, minGap = config.xLabelMinGap || 0;
labels.forEach(function (label, i) {
if (label === “”) return;
var lx = x(i);
if (lx – lastLabelX 1) {
var fillD = pointsToPath(pts, config.tension || 0) + ” L” + pts[pts.length – 1].x + ” ” + y0 + ” L” + pts[0].x + ” ” + y0 + ” Z”;
svg.appendChild(el(“path”, { d: fillD, fill: s.ramp[0], opacity: s.fillOpacity }));
}
svg.appendChild(el(“path”, { “class”: “ghchart__line”, d: pointsToPath(pts, config.tension || 0), stroke: s.color, “stroke-width”: s.width, “stroke-dasharray”: s.dash }));
if (s.pointRadius > 0) {
pts.forEach(function (p, i) {
var r = i === pts.length – 1 ? s.pointRadiusLast : s.pointRadius;
var dot = el(“circle”, { “class”: “ghchart__dot”, cx: p.x, cy: p.y, r: r, fill: s.color, stroke: “#ffffff”, “stroke-width”: s.ringWidth });
dot.setAttribute(“data-r”, r);
dot.setAttribute(“data-i”, p.index);
svg.appendChild(dot);
plotted[plotted.length – 1].dots.push(dot);
});
}
});

/* Always-on comparison labels. SKILL §4 bars values on marks but states an
exception for deliberate comparison labels; this report also ships as a
print PDF, where hover does not exist, so the exception is the rule here. */
decorate(f, config, xs ? xv : x, y);

var guide = el(“line”, { “class”: “ghchart__guide”, x1: x0, x2: x0, y1: y1, y2: y0 });
svg.appendChild(guide);
var tip = createTooltip(root);

function tooltipLines(i) {
return series.filter(function (s) { return s.data[i] != null; }).map(function (s) {
var bits = [s.name + “: ” + fmtPlain(s.data[i], config.valueDecimals == null ? 1 : config.valueDecimals) + “” + (s.valueSuffix || “”)];
if (s.raw[i] != null) bits.push(“” + fmtPlain(s.raw[i], s.rawDecimals) + “” + (s.rawSuffix || “”));
return bits.join(” · “);
});
}
/* Hover geometry follows whichever x-axis the figure uses. On a value
axis the hit bands are uneven, which is correct: day 90 owns a wider
band than day 30 because it covers more of the plot. */
var hx = function (i) { return xs && series[0] && series[0].xData ? xv(series[0].xData[i]) : x(i); };
function activate(i, anchor) {
guide.setAttribute(“x1”, hx(i));
guide.setAttribute(“x2”, hx(i));
guide.style.opacity = “1”;
plotted.forEach(function (p) {
p.dots.forEach(function (dot) {
var r = Number(dot.getAttribute(“data-r”));
dot.setAttribute(“r”, Number(dot.getAttribute(“data-i”)) === i ? r + 3 : r);
});
});
tip.show(anchor, labels[i], tooltipLines(i));
}
function deactivate() {
guide.style.opacity = “0”;
plotted.forEach(function (p) {
p.dots.forEach(function (dot) { dot.setAttribute(“r”, dot.getAttribute(“data-r”)); });
});
tip.hide();
}
if (config.hover !== false) {
labels.forEach(function (_label, i) {
var left = i === 0 ? x0 : (hx(i – 1) + hx(i)) / 2;
var right = i === labels.length – 1 ? x1 : (hx(i) + hx(i + 1)) / 2;
var hit = el(“rect”, { “class”: “ghchart__hit”, x: left, y: y1, width: right – left, height: y0 – y1 });
hit.style.cursor = “pointer”;
hit.addEventListener(“pointermove”, function () { activate(i, hit); });
hit.addEventListener(“pointerleave”, deactivate);
svg.appendChild(hit);
});
}

root.appendChild(svg);
if (config.caption) {
var cap = html(“div”, “ghchart__caption”);
cap.innerHTML = config.caption;
root.appendChild(cap);
}
return root;
}

/* —- Scatter ——————————————————– *
Both axes are free (linear or log), which is what the distribution
figures need: repository spread against reuse, CCDF tails, public
against private rates. Optional `curves` draw underneath every marker so
a reference contour never sits on top of the data it explains. */
function renderScatter(root, config) {
root = openRoot(root, config);
if (!root) return root;

var xs = Object.assign({ scale: “linear” }, config.x);
var ys = Object.assign({ scale: “linear” }, config.y);
var f = frame(config, { right: 60, bottom: 104 });
var svg = f.svg;
svg.setAttribute(“class”, “ghchart__plot ghchart__plot–scatter”);

var x = makeScale(xs.min, xs.max, f.x0, f.x1, xs.scale === “log”);
var y = makeScale(ys.min, ys.max, f.y0, f.y1, ys.scale === “log”);

gridY(f, ys, y);
(xs.ticks || (xs.scale === “log” ? logTicks(xs.min, xs.max) : [])).forEach(function (tv) {
if (tv xs.max) return;
var xx = x(tv);
if (xs.grid !== false) svg.appendChild(el(“line”, { “class”: “ghchart__gridline”, x1: xx, x2: xx, y1: f.y0, y2: f.y1 }));
svg.appendChild(createText(tickLabel(tv, xs), { “class”: “ghchart__axislabel ghchart__axislabel–x”, x: xx, y: f.y0 + (config.xLabelOffset || 42), “text-anchor”: “middle” }));
});
axisTitles(f, config, f.y0 + (config.xTitleOffset || 86));

// Reference contours, drawn first so markers always sit above them.
if (config.reference) {
var rf = config.reference, rfx = x(rf.value);
svg.appendChild(el(“line”, {
x1: rfx, x2: rfx, y1: f.y1, y2: f.y0, stroke: rf.color || “#b9c0c8”,
“stroke-width”: rf.width || 2.8, “stroke-dasharray”: rf.dash || “9 7”
}));
if (rf.label) {
var rft = createText(rf.label, { “class”: “ghchart__ann”, x: rfx, y: f.y1 – 14, “text-anchor”: “middle” });
rft.style.fontSize = (rf.size || 23) + “px”;
rft.style.fill = rf.labelColor || “#9aa4ae”;
svg.appendChild(rft);
}
}
var clipId = “ghc-clip-” + Math.random().toString(36).slice(2, 9);
var defs = el(“defs”, {});
var cp = el(“clipPath”, { id: clipId });
cp.appendChild(el(“rect”, { x: f.x0, y: f.y1, width: f.x1 – f.x0, height: f.y0 – f.y1 }));
defs.appendChild(cp);
svg.appendChild(defs);

(config.curves || []).forEach(function (c) {
var d = c.points.map(function (p, i) { return (i ? “L” : “M”) + x(p[0]) + ” ” + y(p[1]); }).join(” “);
svg.appendChild(el(“path”, {
“class”: “ghchart__curve”, d: d, fill: “none”, “clip-path”: “url(#” + clipId + “)”,
stroke: c.color || “#d8dde3”, “stroke-width”: c.width || 2,
“stroke-dasharray”: c.dash || “”
}));
if (c.label) {
var lp = c.labelAt || c.points[c.points.length – 1];
var lt = createText(c.label, { “class”: “ghchart__ann”, x: x(lp[0]) + (c.dx || 0), y: y(lp[1]) + (c.dy || 0), “text-anchor”: c.align === “right” ? “end” : “start” });
lt.style.fontSize = (c.size || 21) + “px”;
lt.style.fill = c.labelColor || “#8c959f”;
lt.setAttribute(“dominant-baseline”, “central”);
svg.appendChild(lt);
}
});

var tip = createTooltip(root);
(config.points || []).forEach(function (p) {
var base = p.color || config.base || “#0969da”;
var cx = x(p.x), cy = y(p.y);
var dot = el(“circle”, {
“class”: “ghchart__dot”, cx: cx, cy: cy, r: p.r || config.pointRadius || 12,
fill: base, stroke: “#ffffff”, “stroke-width”: p.ringWidth || config.ringWidth || 2.8
});
if (p.opacity != null) dot.setAttribute(“opacity”, p.opacity);
dot.setAttribute(“data-r”, p.r || config.pointRadius || 12);
dot.style.cursor = “pointer”;
if (p.label || p.lines) {
dot.addEventListener(“pointermove”, function () {
dot.setAttribute(“r”, Number(dot.getAttribute(“data-r”)) + 3);
tip.show(dot, p.label || “”, p.lines || []);
});
dot.addEventListener(“pointerleave”, function () {
dot.setAttribute(“r”, dot.getAttribute(“data-r”));
tip.hide();
});
}
svg.appendChild(dot);
});

decorate(f, config, x, y);
return closeRoot(root, f, config);
}

/* —- Bar ———————————————————— *
Vertical bars on a categorical x-axis, with optional always-on value
labels above each bar — the same print-first reasoning as the line
annotations, since the PDF has no hover. */
function renderBar(root, config) {
root = openRoot(root, config);
if (!root) return root;

var labels = config.labels || [];
var ys = Object.assign({ scale: “linear” }, config.y);
var vals = (config.values || []).filter(function (v) { return v != null; });
if (ys.min == null) ys.min = ys.scale === “log” ? Math.min.apply(null, vals) : 0;
if (ys.max == null) ys.max = Math.max.apply(null, vals);

var f = frame(config, { right: 60 });
var svg = f.svg;
svg.setAttribute(“class”, “ghchart__plot ghchart__plot–bar”);
var y = makeScale(ys.min, ys.max, f.y0, f.y1, ys.scale === “log”);

gridY(f, ys, y);
axisTitles(f, config, f.y0 + (config.xTitleOffset || 86));

var n = labels.length || 1;
var slot = (f.x1 – f.x0) / n;
var bw = slot * (config.barWidth == null ? 0.62 : config.barWidth);
var tip = createTooltip(root);

labels.forEach(function (label, i) {
var cx = f.x0 + slot * (i + 0.5);
var v = (config.values || [])[i];
svg.appendChild(createText(label, { “class”: “ghchart__axislabel ghchart__axislabel–x”, x: cx, y: f.y0 + (config.xLabelOffset || 42), “text-anchor”: “middle” }));
if (v == null) return;
var top = y(v), base = (config.colors && config.colors[i]) || config.base || “#0969da”;
var rect = el(“rect”, {
“class”: “ghchart__bar”, x: cx – bw / 2, y: Math.min(top, f.y0),
width: bw, height: Math.max(Math.abs(f.y0 – top), 1),
fill: base, rx: config.radius == null ? 4 : config.radius
});
if (config.strokeColors && config.strokeColors[i] != null) rect.setAttribute(“stroke”, config.strokeColors[i]);
if (config.strokeWidths && config.strokeWidths[i] != null) rect.setAttribute(“stroke-width”, config.strokeWidths[i]);
if (config.strokeDasharrays && config.strokeDasharrays[i] != null) rect.setAttribute(“stroke-dasharray”, config.strokeDasharrays[i]);
rect.style.cursor = “pointer”;
var lines = (config.tooltips && config.tooltips[i]) || null;
if (lines) {
rect.addEventListener(“pointermove”, function () { tip.show(rect, label, lines); });
rect.addEventListener(“pointerleave”, tip.hide);
}
svg.appendChild(rect);
if (config.valueLabels) {
var vl = createText(config.valueLabels[i], { “class”: “ghchart__ann”, x: cx, y: top – (config.valueLabelOffset || 22), “text-anchor”: “middle” });
vl.style.fontSize = (config.valueLabelSize || 23) + “px”;
vl.style.fontWeight = 600;
vl.style.fill = config.valueLabelColor || “#424a53”;
vl.setAttribute(“dominant-baseline”, “central”);
svg.appendChild(vl);
}
});

decorate(f, config, function (i) { return f.x0 + slot * (i + 0.5); }, y);
return closeRoot(root, f, config);
}

/* —- Horizontal bar ————————————————– *
Categories on the y-axis, values running left to right. Used where the
category names are phrases rather than tokens (“HashiCorp Vault ·
secrets mgr”), which a vertical axis cannot carry without rotating type.
`reference` draws a vertical comparison line, e.g. a base rate. */
function renderBarH(root, config) {
root = openRoot(root, config);
if (!root) return root;

var labels = config.labels || [];
var values = config.values || [];
var xs = Object.assign({ scale: “linear”, min: 0 }, config.x);
if (xs.max == null) xs.max = Math.max.apply(null, values);

var f = frame(config, { top: 40, right: 230, bottom: 104, left: 420 });
var svg = f.svg;
svg.setAttribute(“class”, “ghchart__plot ghchart__plot–barh”);
var x = makeScale(xs.min, xs.max, f.x0, f.x1, xs.scale === “log”);

(xs.ticks || []).forEach(function (tv) {
var xx = x(tv);
svg.appendChild(el(“line”, { “class”: “ghchart__gridline”, x1: xx, x2: xx, y1: f.y0, y2: f.y1 }));
svg.appendChild(createText(tickLabel(tv, xs), { “class”: “ghchart__axislabel ghchart__axislabel–x”, x: xx, y: f.y0 + (config.xLabelOffset || 42), “text-anchor”: “middle” }));
});
axisTitles(f, config, f.y0 + (config.xTitleOffset || 86));

var n = labels.length || 1;
var slot = (f.y0 – f.y1) / n;
var bh = slot * (config.barWidth == null ? 0.6 : config.barWidth);
var tip = createTooltip(root);

// Reference line first, so bars and their labels always sit above it.
if (config.reference) {
var r = config.reference, rx = x(r.value);
svg.appendChild(el(“line”, {
x1: rx, x2: rx, y1: f.y1, y2: f.y0, stroke: r.color || “#b9c0c8”,
“stroke-width”: r.width || 2.8, “stroke-dasharray”: r.dash || “9 7”
}));
if (r.label) {
// Above the plot, not inside it: at the foot the label sat underneath
// the shortest bar, which is exactly where a base rate cannot hide.
var rt = createText(r.label, {
“class”: “ghchart__ann”, x: rx + (r.dx == null ? 14 : r.dx), y: f.y1 + (r.dy == null ? -14 : r.dy),
“text-anchor”: r.align === “right” ? “end” : r.align === “middle” ? “middle” : “start”
});
rt.style.fontSize = (r.size == null ? 21 : r.size) + “px”;
rt.style.fontWeight = r.weight == null ? 500 : r.weight;
rt.style.fill = r.labelColor || “#9aa4ae”;
svg.appendChild(rt);
}
}

labels.forEach(function (label, i) {
var cy = f.y1 + slot * (i + 0.5);
var v = values[i];
var lt = createText(label, { “class”: “ghchart__axislabel”, x: f.x0 – 20, y: cy, “text-anchor”: “end” });
lt.setAttribute(“dominant-baseline”, “central”);
if (config.categorySize) lt.style.fontSize = config.categorySize + “px”;
if (config.categoryColor) lt.style.fill = config.categoryColor;
svg.appendChild(lt);
if (v == null) return;
var bar = el(“rect”, {
“class”: “ghchart__bar”, x: f.x0, y: cy – bh / 2,
width: Math.max(x(v) – f.x0, 1), height: bh,
fill: (config.colors && config.colors[i]) || config.base || “#0969da”,
rx: config.radius == null ? 4 : config.radius
});
bar.style.cursor = “pointer”;
var lines = (config.tooltips && config.tooltips[i]) || null;
if (lines) {
bar.addEventListener(“pointermove”, function () { tip.show(bar, label, lines); });
bar.addEventListener(“pointerleave”, tip.hide);
}
svg.appendChild(bar);
var valueLabelX = x(v) + (config.valueLabelOffset == null ? 13 : config.valueLabelOffset);
if (config.wilsonIntervals) {
var nSource = config.ns && config.ns[i] != null ? config.ns[i] : (config.valueLabels && String(config.valueLabels[i]).match(/n=([0-9,]+)/));
var sampleN = Array.isArray(nSource) ? Number(String(nSource[1]).replace(/,/g, “”)) : Number(nSource);
if (sampleN > 0) {
var z = config.wilsonZ == null ? 1.959964 : config.wilsonZ;
var p = Number(v) / 100;
var denom = 1 + z * z / sampleN;
var center = (p + z * z / (2 * sampleN)) / denom;
var half = z / denom * Math.sqrt(p * (1 – p) / sampleN + z * z / (4 * sampleN * sampleN));
var lo = Math.max(xs.min, Math.min(xs.max, (center – half) * 100));
var hi = Math.max(xs.min, Math.min(xs.max, (center + half) * 100));
var xLo = x(lo), xHi = x(hi), cap = bh * 0.31;
valueLabelX = xHi + (config.valueLabelOffset == null ? 13 : config.valueLabelOffset);
svg.appendChild(el(“line”, { x1: xLo, x2: xHi, y1: cy, y2: cy, stroke: “#ffffff”, “stroke-width”: config.intervalHaloWidth == null ? 6 : config.intervalHaloWidth, “stroke-linecap”: “round” }));
svg.appendChild(el(“line”, { x1: xLo, x2: xLo, y1: cy – cap, y2: cy + cap, stroke: “#ffffff”, “stroke-width”: config.intervalHaloWidth == null ? 6 : config.intervalHaloWidth, “stroke-linecap”: “round” }));
svg.appendChild(el(“line”, { x1: xHi, x2: xHi, y1: cy – cap, y2: cy + cap, stroke: “#ffffff”, “stroke-width”: config.intervalHaloWidth == null ? 6 : config.intervalHaloWidth, “stroke-linecap”: “round” }));
svg.appendChild(el(“line”, { x1: xLo, x2: xHi, y1: cy, y2: cy, stroke: config.intervalColor || “#24292f”, “stroke-width”: config.intervalWidth == null ? 2.2 : config.intervalWidth, “stroke-linecap”: “round” }));
svg.appendChild(el(“line”, { x1: xLo, x2: xLo, y1: cy – cap, y2: cy + cap, stroke: config.intervalColor || “#24292f”, “stroke-width”: config.intervalWidth == null ? 2.2 : config.intervalWidth, “stroke-linecap”: “round” }));
svg.appendChild(el(“line”, { x1: xHi, x2: xHi, y1: cy – cap, y2: cy + cap, stroke: config.intervalColor || “#24292f”, “stroke-width”: config.intervalWidth == null ? 2.2 : config.intervalWidth, “stroke-linecap”: “round” }));
}
}
if (config.valueLabels) {
var vl = createText(config.valueLabels[i], { “class”: “ghchart__ann”, x: valueLabelX, y: cy });
vl.style.fontSize = (config.valueLabelSize || 21) + “px”;
vl.style.fontWeight = 600;
vl.style.fill = config.valueLabelColor || “#59636e”;
vl.setAttribute(“dominant-baseline”, “central”);
svg.appendChild(vl);
}
});

decorate(f, config, x, function (i) { return f.y1 + slot * (i + 0.5); });
return closeRoot(root, f, config);
}

// Square co-occurrence matrix. Cell colour is a sequential ramp derived from
// one base hex, extended two steps past the five-level convention so seven
// bands fit while the shared levels keep the skill’s exact mix ratios.
function matrixRamp(baseHex) {
var b = hexToRgb(baseHex);
return [
rgbToHex(mix(b, WHITE, 0.92)),
rgbToHex(mix(b, WHITE, 0.80)),
rgbToHex(mix(b, WHITE, 0.52)),
rgbToHex(mix(b, WHITE, 0.20)),
baseHex,
rgbToHex(mix(b, BLACK, 0.30)),
rgbToHex(mix(b, BLACK, 0.55))
];
}

function renderMatrix(root, config) {
root = openRoot(root, config);
if (!root) return root;

var f = frame(config, { top: 190, right: 30, bottom: 118, left: 200 });
var svg = f.svg;
var labels = config.labels || [];
var n = labels.length;
var groups = config.groups || [];
var groupColor = config.groupColor || {};
var m = config.matrix || [];
var breaks = config.breaks || [];
var ramp = matrixRamp(config.base || “#3094ff”);
var noData = config.noDataColor || “#ebedf0”;

var availW = f.x1 – f.x0, availH = f.y0 – f.y1;
var cell = Math.min(availW / n, availH / n);
var gw = cell * n;
var gl = f.x0 + Math.max(0, (availW – gw) / 2);
var gt = f.y1;

function band(v) {
for (var i = 0; i < breaks.length; i++) if (v < breaks[i]) return ramp[i];
return ramp[Math.min(breaks.length, ramp.length – 1)];
}
function labColor(i) { return groupColor[groups[i]] || "#59636e"; }
function labWeight(i) { return groups[i] === (config.emphasisGroup || "") ? 700 : 600; }

var tip = createTooltip(root, config.tooltip);
var labelSize = config.labelSize || 22;

for (var i = 0; i < n; i++) {
for (var j = 0; j < n; j++) {
var v = m[i][j];
var self = v == null || v existing.version) {
existing.version = VERSION;
existing.render = render;
existing.primitives = Kit;
}
global.GHChartKit = global.GHChartKit || Kit;

})(typeof globalThis !== “undefined” ? globalThis : this);

;(function () {
“use strict”;

function enhance(root, config) {
if (root.getAttribute(“data-ghchart-rendered”) === “true”) return;

var K = window.GHChartEngine.primitives;
var quarters = config.quarters;
var slots = Array.prototype.slice.call(root.querySelectorAll(“.ghpr__slot”));
var labels = Array.prototype.slice.call(root.querySelectorAll(“.ghpr__x-label”));
var guide = root.querySelector(“.ghpr__guide”);
var readout = root.querySelector(“.ghpr__readout”);
var readoutQuarter = root.querySelector(“.ghpr__readout-quarter”);
var readoutVolume = root.querySelector(“.ghpr__readout-volume”);
var readoutShare = root.querySelector(“.ghpr__readout-share”);
if (slots.length !== quarters.length || labels.length !== quarters.length ||
!guide || !readout || !readoutQuarter || !readoutVolume || !readoutShare) {
throw new Error(“The bar-and-line chart requires matching chart.html, chart.css, and chart.js.”);
}
function formatCount(value) {
return K.fmtCompact(Math.round(value / 1000000) * 1000000);
}
function updateReadout(q) {
readoutQuarter.textContent = q.label;
readoutVolume.textContent = formatCount(q.pushes);
readoutShare.textContent = (100 * q.finds / q.pushes).toFixed(2) + “%”;
}
root.style.setProperty(“–ghpr-volume-ink”, config.volumeBase);
root.style.setProperty(“–ghpr-volume-text”, K.strongOf(config.volumeBase));
root.style.setProperty(“–ghpr-share-ink”, config.rateBase);
root.style.setProperty(“–ghpr-share-text”, K.strongOf(config.rateBase));
root.style.setProperty(“–ghpr-bar-width”, config.barWidth);
readout.setAttribute(“role”, “status”);
readout.setAttribute(“aria-live”, “polite”);
readout.setAttribute(“aria-atomic”, “true”);

function reset() {
slots.forEach(function (slot) { slot.setAttribute(“data-active”, “false”); });
labels.forEach(function (label) { label.setAttribute(“data-active”, “false”); });
root.setAttribute(“data-ghpr-active”, “false”);
updateReadout(quarters[quarters.length – 1]);
readout.setAttribute(“data-active”, “false”);
}
function activate(i) {
var q = quarters[i];
slots.forEach(function (slot, index) {
slot.setAttribute(“data-active”, index === i ? “true” : “false”);
});
labels.forEach(function (label, index) {
label.setAttribute(“data-active”, index === i ? “true” : “false”);
});
root.style.setProperty(“–ghpr-active-position”, (i + 0.5) / quarters.length * 100 + “%”);
root.setAttribute(“data-ghpr-active”, “true”);
updateReadout(q);
readout.setAttribute(“data-active”, “true”);
}
slots.forEach(function (slot, i) {
var q = quarters[i];
slot.setAttribute(“role”, “button”);
slot.setAttribute(“tabindex”, “0”);
slot.setAttribute(“aria-label”, q.label + “: ” + formatCount(q.pushes) +
” public pushes screened; ” + (100 * q.finds / q.pushes).toFixed(2) +
” percent push prevalence, meaning pushes containing a detected provider secret. Arrow keys move between quarters.”);
slot.addEventListener(“pointerenter”, function () { activate(i); });
slot.addEventListener(“pointerdown”, function () { slot.focus(); activate(i); });
slot.addEventListener(“pointerleave”, function () {
if (document.activeElement !== slot) reset();
});
slot.addEventListener(“focus”, function () { activate(i); });
slot.addEventListener(“blur”, reset);
slot.addEventListener(“keydown”, function (event) {
var next;
if (event.key === “ArrowRight”) next = Math.min(slots.length – 1, i + 1);
else if (event.key === “ArrowLeft”) next = Math.max(0, i – 1);
else if (event.key === “Home”) next = 0;
else if (event.key === “End”) next = slots.length – 1;
else if (event.key === “Escape”) { reset(); return; }
else if (event.key === “Enter” || event.key === ” “) {
event.preventDefault();
activate(i);
return;
} else return;
event.preventDefault();
slots[next].focus();
});
});
root.setAttribute(“data-ghchart-rendered”, “true”);
}

var CONFIG = {
“title”: “More pushes, no clear rise in push prevalence”,
“subtitle”: “2024 Q2u20132026 Q2”,
“ariaLabel”: “Nine quarters, 2024 Q2 through 2026 Q2. Equal-width green bars show screened public pushes on the left axis, in millions. The blue line and quarterly points show push prevalence on the right axis, in percent: the share of screened public pushes containing a detected provider secret. The axes have different units; compare each series over time, not bar/line crossings. Public push volume grew 2.8 times, while the quarterly share varied from 0.47 to 0.71 percent without a clear upward trend. Supported provider patterns include GitHub’s own tokens. Blocked and bypassed attempts are included.”,
“volumeBase”: “#08872b”,
“rateBase”: “#0377ff”,
“quarters”: [
{
“label”: “2024 Q2”,
“pushes”: 202241213,
“finds”: 1046224
},
{
“label”: “2024 Q3”,
“pushes”: 195041780,
“finds”: 1056582
},
{
“label”: “2024 Q4”,
“pushes”: 223196423,
“finds”: 1107237
},
{
“label”: “2025 Q1”,
“pushes”: 247866195,
“finds”: 1292931
},
{
“label”: “2025 Q2”,
“pushes”: 258457360,
“finds”: 1830765
},
{
“label”: “2025 Q3”,
“pushes”: 304013079,
“finds”: 1933975
},
{
“label”: “2025 Q4”,
“pushes”: 347602577,
“finds”: 2228616
},
{
“label”: “2026 Q1”,
“pushes”: 428519511,
“finds”: 2626324
},
{
“label”: “2026 Q2”,
“pushes”: 574206558,
“finds”: 2712225
}
],
“barWidth”: 0.6,
“volumeY”: {
“min”: 0,
“max”: 600000000,
“ticks”: [
0,
300000000,
600000000
]
},
“rateY”: {
“min”: 0,
“max”: 1,
“ticks”: [
0,
0.5,
1
]
},
“scope”: {
“traffic”: “Public push traffic”,
“patterns”: “Supported provider patterns, including GitHub’s own tokens”,
“source”: “Blog author”
},
“analysis”: {
“model”: “Exploratory OLS on quarterly rate percentages versus quarter index”,
“quarters”: 9,
“slopePerYear”: 0.029215870393486706,
“ciLowPerYear”: -0.07200002336113129,
“ciHighPerYear”: 0.1304317641481047,
“pValue”: 0.5168367091504764,
“rSquared”: 0.062399929619299986
},
“captionHtml”: “

Public pushes, Q2 2024u2013Q2 2026. Push prevalence is the share with a detected secret. Covers supported provider patterns, including GitHubu2019s own tokens.

”
};
document.querySelectorAll(“[data-ghc-push-leak]”).forEach(function (root, i) {
if (root.getAttribute(“data-ghchart-rendered”) === “true”) return;
var id = “gh-chart-push-volume-leak-rate-” + i;
while (document.getElementById(id) && document.getElementById(id) !== root) {
i += 1;
id = “gh-chart-push-volume-leak-rate-” + i;
}
root.id = id;
enhance(root, CONFIG);
});
})();

At a fixed rate, doubling activity doubles expected exposures. If each exposure requires the same human response, the workload doubles too. The mean time to manually revoke a secret hovers around 40 days; roughly one in five took more than 90 days. We’re accelerating the creation of software while exposed credentials can remain usable for weeks or months, because human remediation can’t scale at the same pace as development.

Telling developers to be more careful cannot, on its own, solve that problem. As the amount of code grows, we must prevent more exposures and reduce human effort required by those that remain, if software development is to remain sustainable.

Prevention scales with compute

I’ve spent the past few years working on secret scanning at GitHub and the past year as product lead for the area. Our greatest impact has come from connecting the dots between detection and systems that can act.

GitHub’s catalog covers more than 150 technical partners through our secret scanning partnership program. Through our partner program, we work with participating secret issuers to build out detectors and report public exposures so they can respond. In Q2 2026, public scanning successfully reported an average of 26 credential matches a second, including repeat observations. Once notified, a large number of these partners immediately revoke the token: OpenAI API keys, Google Cloud account credentials, Slack webhooks, Hugging Face user tokens, SendGrid keys, etc. The owner may still need to replace the token, but revocation can happen without waiting for a developer to find and process a GitHub alert.

Push protection intervenes earlier. It stops recognizable credentials before they enter repository history, giving the developer or agent a chance to correct the change before there’s an exposure to investigate. We work with our technical partners to increase precision rates of their detectors as much as possible, until we’re confident enough to push-protect these secrets for the developer community by default.

Thanks to the efforts of our partners, in the past month, a secret was blocked by push protection at least once every second. When it comes to issuer-bound credentials, GitHub blocks more secrets than those which slip. I’m proud of how ordinary we’ve made that feel for developers.

Remediation scales with people

When including additional secret types, push protection stops about 30% of newly detected secrets before they enter repository history. We find the remaining 70% after the credential is, unfortunately, already lost. And:

  1. Prevention scales with compute, but remediation still scales with people.
  2. Refusing a push costs compute; cleaning up a secret already lost to visible history costs a developer’s time and attention.
  3. As the amount of code grows, we must prevent more exposures and reduce the human effort required by those that remain, or else the volume of vulnerabilities introduced will become untenable.

Telling developers to be more careful cannot solve this imbalance. Recognizing more of these secrets, earlier in development flows, is work the platform must take on.

Solving the four-body problem

Before a secret crosses the push boundary, the cost of stopping it is small, and the decision is binary: block or allow. After it crosses, the same string can authenticate to a real system, and the cost is unbounded.

In many cases, our only detection clue may be the surrounding code and world context. A provider-issued token may have a recognizable prefix. An internal database password may be completely unstructured, with no identifying pattern at all. We were already using context to find these secrets post-push; the problem was balancing that context-aware judgement with other factors.

We refer to this as the “four-body problem” for secret protection: precision, latency, throughput, and cost are coupled constraints. Prevention must be worth a developer’s time. A finding suitable for later review may not justify blocking a push. A false positive interrupts a developer and makes the next block harder to trust. A check that is too slow, expensive, or difficult to scale limits how often it can run.

Protection at the push in under 2 ms

GitHub’s AI-powered generic secret detection model uses surrounding code context to block password-like values in a database URL, Kubernetes Secret manifest, and Dockerfile, while allowing the placeholder changeme.

Our new ModernBERT classifier assesses candidate secrets in context, without generating code or prose. It’s not only more precise than existing LLM-based pipelines, but it’s incredibly fast, evaluating candidate batches in under two milliseconds. It’s also extremely cost efficient, enough to run at scale in the critical path.

The inclusion of our model in push protection makes it possible for us to more than double the number of secrets that we’re able to prevent. The feature is currently in private preview. Later this month, the feature will be available to organizations with GitHub Secret Protection across Enterprise Cloud and GitHub Teams. It will consume AI credits.

We’re also bringing the model to developer surfaces beyond the push.

  • Starting today, any organization with AI secret detection will be automatically updated to the new model. Alerts opened from these post-push scans remain included with an organization’s purchase of secret scanning at no additional cost.
  • The model will also ship with GitHub Enterprise Server 3.23 in public preview, bringing AI-detected alerts to Secret Protection customers even in air-gapped environments.
  • We’re adding the classifier to the /security-review command for the Copilot CLI and Copilot App, so Copilot users can address secrets before a push even without needing an organization’s GitHub Secret Protection plan. AI credit usage will be attributed to GitHub Secret Protection in your AI usage insights.

Looking forward

The future we want is one where developers can entrust more work to agents without supervising every request, and one where the number of people that an organization needs to keep its credentials safe no longer scales with the amount of code it writes. We owe the developer community the same progress in protecting software that we are delivering in producing it.

We want people to build more software. Our capacity to protect it should grow with our capacity to create it.

The post Secret protection must scale with software appeared first on The GitHub Blog.

Source: GitHub Blog