<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.3.4">Jekyll</generator><link href="https://westenb.org/feed.xml" rel="self" type="application/atom+xml" /><link href="https://westenb.org/" rel="alternate" type="text/html" /><updated>2026-04-29T21:22:50+00:00</updated><id>https://westenb.org/feed.xml</id><title type="html">westenb.org</title><subtitle>Personal website and blog</subtitle><author><name>Weston Westenborg</name></author><entry><title type="html">Bear Down or Cash Out?</title><link href="https://westenb.org/2026/04/11/koa-peat/" rel="alternate" type="text/html" title="Bear Down or Cash Out?" /><published>2026-04-11T00:00:00+00:00</published><updated>2026-04-11T00:00:00+00:00</updated><id>https://westenb.org/2026/04/11/koa-peat</id><content type="html" xml:base="https://westenb.org/2026/04/11/koa-peat/"><![CDATA[<style>
.post-koa-peat .chart-container { width: 100%; overflow-x: auto; margin: 1.5rem 0; }
.post-koa-peat .chart-container svg { font-family: Palatino, "Palatino Linotype", "Book Antiqua", Georgia, serif; }
.post-koa-peat .chart-container .axis text { font-size: 12px; fill: #555; }
.post-koa-peat .chart-container .axis line, .post-koa-peat .chart-container .axis path { stroke: #e0e0d8; }
.post-koa-peat .chart-container .grid line { stroke: #f0f0ea; stroke-dasharray: 2,2; }
.post-koa-peat .chart-container .grid path { stroke: none; }

.post-koa-peat figure { margin: 2rem 0 2.5rem; }
.post-koa-peat figure figcaption { font-size: 0.9rem; color: #777; margin-top: 0.5rem; line-height: 1.5; }

.post-koa-peat .key-finding { border-left: 3px solid #a8332b; padding: 1rem 1.5rem; margin: 1.5rem 0; background: #fffff8; }
.post-koa-peat .key-finding p { margin-bottom: 0.5rem; }
.post-koa-peat .key-finding p:last-child { margin-bottom: 0; }

.post-koa-peat table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; font-size: 1.2rem; line-height: 1.8; }
.post-koa-peat thead th { text-align: left; border-bottom: 2px solid #111; padding: 0.5rem 0.75rem; font-weight: 400; font-variant: small-caps; letter-spacing: 0.05em; }
.post-koa-peat tbody td { padding: 0.4rem 0.75rem; border-bottom: 1px solid #f0f0ea; }
.post-koa-peat tbody tr:hover { background: #f8f8f2; }
.post-koa-peat td.num { text-align: right; font-variant-numeric: tabular-nums; }

.post-koa-peat .controls { display: flex; gap: 1.5rem; flex-wrap: wrap; margin: 1.5rem 0; padding: 0.8rem 1.2rem; border: 1px solid #e0e0d8; background: #fff; }
.post-koa-peat .control-group { display: flex; flex-direction: column; gap: 0.15rem; flex: 1; min-width: 140px; }
.post-koa-peat .control-group label { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.06em; color: #777; }
.post-koa-peat .control-group select, .post-koa-peat .control-group input[type="number"] {
  font-family: Palatino, Georgia, serif; font-size: 0.85rem;
  padding: 0.25rem 0.4rem; border: 1px solid #ccc; background: #fffff8; color: #111; border-radius: 0;
}
.post-koa-peat .control-group input[type="range"] { width: 100%; accent-color: #a8332b; }
.post-koa-peat .control-group .range-value { font-size: 0.85rem; font-variant-numeric: tabular-nums; color: #111; font-weight: 600; }
.post-koa-peat .control-group .helper { font-size: 0.7rem; color: #999; line-height: 1.3; margin-top: 0.15rem; }

.post-koa-peat .calc-result {
  text-align: center; margin: 1.5rem 0; padding: 1.5rem;
  border: 2px solid #e0e0d8; background: #fff;
}
.post-koa-peat .calc-result .verdict-number { font-size: 2.8rem; line-height: 1.1; font-variant-numeric: tabular-nums; }
.post-koa-peat .calc-result .verdict-label { font-size: 0.9rem; color: #777; font-variant: small-caps; letter-spacing: 0.05em; margin-top: 0.3rem; }
.post-koa-peat .calc-result .verdict-sub { font-size: 0.85rem; color: #999; margin-top: 0.5rem; }
.post-koa-peat .verdict-positive { color: #2a7d2e; }
.post-koa-peat .verdict-negative { color: #a8332b; }

.post-koa-peat .methodology { font-size: 0.85rem; color: #777; line-height: 1.6; }
.post-koa-peat .methodology a { color: #777; }

@media (max-width: 768px) {
  .post-koa-peat .controls { flex-direction: column; }
  .post-koa-peat .control-group { min-width: 100%; }
}
</style>

<p><span class="newthought">We’re in a weird new world.</span> For the first time, college basketball players can earn professional-level money without going pro.<label for="sn-1-91bf2a" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1-91bf2a" class="margin-toggle" /><span class="sidenote">“The top NIL earners in men’s basketball are making $3-5 million per year. AJ Dybantsa reportedly earned $7M+ at BYU this past season.”</span> Every spring now, the same question plays out across the country for top players: should I go to the NBA, or should I stay and get paid?</p>

<p>The prospect of your guys going to league (or worse, entering the portal and playing for the competition) weighs heavy after every season ends. At Arizona, Koa Peat, our freshman power forward who just led us to the Final Four, is projected around #20 in a historically loaded 2026 draft class.</p>

<p>My friends and I would love nothing more than for him to come back next year. But if I were him I would be making the best overall financial decision for myself and for my family. So instead of just hoping, I decided to figure out what it would actually take for it to make financial sense for Koa to stay.</p>

<h2 id="the-rookie-deal">The Rookie Deal</h2>

<p>First-round picks sign four-year contracts on a fixed scale.<label for="sn-2-542869" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-2-542869" class="margin-toggle" /><span class="sidenote">“Years 1-2 are fully guaranteed. Years 3-4 are team options. Teams almost always sign at 120% of the scale minimum. About 89% of first-round picks have their options exercised. Source: <a href="https://www.cbafaq.com/salarycap.htm">NBA CBA</a>, Hoops Rumors.”</span> Here’s what the full four-year deals look like, broken out by what’s guaranteed and what’s a team option:</p>

<figure>
<div class="chart-container" id="chart-rookie-scale"></div>
<figcaption>2025-26 NBA rookie scale at 120%. Solid bars are guaranteed (Years 1-2). Striped bars are team options (Years 3-4). Source: NBA / Hoops Rumors.</figcaption>
</figure>

<p>The first thing to notice is how steep the curve is at the top and how flat it is at the bottom. The difference between pick 1 and pick 5 is $21 million. The difference between pick 20 and pick 30 is $3.6 million. If you’re projected in the back half of the first round, your draft slot doesn’t change your rookie deal that much.</p>

<h2 id="the-second-contract">The Second Contract</h2>

<p>The second contract matters more than the first.<label for="sn-3-fbe0f1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-3-fbe0f1" class="margin-toggle" /><span class="sidenote">“Arizona has two examples of this: Aaron Gordon (#4, 2014) developed his shot, signed an $80M extension, won a championship, career earnings $170M+. Derrick Williams (#2, 2011) never found a meaningful NBA role, bounced across seven teams, career earnings ~$25M. Same school, similar profiles, $145M gap.”</span></p>

<ol>
  <li>Higher picks are more likely to earn one.</li>
  <li>The ones they earn are much larger.</li>
</ol>

<figure>
<div class="chart-container" id="chart-sc-prob"></div>
<figcaption>Percentage of first-round picks earning a meaningful second contract. Source: Hoops Rumors extension recaps (2019-2025).</figcaption>
</figure>

<figure>
<div class="chart-container" id="chart-sc-value"></div>
<figcaption>Median second contract value for players who earn one. Source: Hoops Rumors extension data (2023-2025), Samford Sports Analytics (2020).</figcaption>
</figure>

<h2 id="does-draft-position-actually-matter">Does Draft Position Actually Matter?</h2>

<p>On average, top picks have far more valuable careers. But most of that isn’t <em>caused by</em> draft position. Better players get drafted higher AND earn more, because they’re better players.<label for="sn-4-9bfb83" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-4-9bfb83" class="margin-toggle" /><span class="sidenote">“Staw &amp; Hoang (1995) found a causal effect: teams give higher picks more playing time and longer leashes, even after controlling for on-court performance.”</span></p>

<p>Koa Peat is the same player whether he’s picked #5 or #20. His talent doesn’t change. But higher picks do get real advantages: they start from day one, they get kept around longer when they struggle, and they land on worse teams with more minutes and cap space for extensions. The effect is real but modest. Moving up a few spots doesn’t transform a career.</p>

<h2 id="does-the-draft-class-matter">Does the Draft Class Matter?</h2>

<p>This is where Peat’s situation gets interesting, and where the framework matters for any player stuck in the middle of a loaded class.</p>

<p>The 2026 draft is loaded. It’s the deepest since 2003.<label for="sn-6-08e9b4" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-6-08e9b4" class="margin-toggle" /><span class="sidenote">“Dybantsa, Peterson, Boozer, Ament, Brown at the top, four point guards in the lottery. Sources: <a href="https://www.espn.com/nba/story/_/id/46886245/2026-nba-draft-big-board-rankings-top-100-prospects-players">ESPN 2026 Big Board</a>, <a href="https://www.tankathon.com/">Tankathon</a>.”</span> Peat, a physical forward without a reliable three-point shot, gets squeezed into the #15-25 range by class depth when in average years he would go much higher.</p>

<p>The 2027 draft is projected as historically weak. ESPN’s Jonathan Givony reports that executives are “ringing alarm bells.” A veteran evaluator said the incoming class might not produce a single All-Star.<label for="sn-7-457e07" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-7-457e07" class="margin-toggle" /><span class="sidenote">“Sources: <a href="https://www.espn.com/nba/story/_/id/46886245/2026-nba-draft-big-board-rankings-top-100-prospects-players">ESPN</a>, <a href="https://www.cbssports.com/college-basketball/news/koa-peat-arizona-big-man-nba-draft-final-four/">CBS Sports</a>.”</span> CBS Sports projects that if Peat returns, he could be in contention for a top-3 pick.</p>

<p>Same player, two very different draft pools. In 2026, he’s the 20th-best prospect. In 2027, he might be the 3rd-best. The question is whether that shift is worth the cost of waiting a year.</p>

<h2 id="run-the-numbers">Run the Numbers</h2>

<p>Here’s the model. Adjust the assumptions and see how the math changes.<label for="sn-8-871613" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-8-871613" class="margin-toggle" /><span class="sidenote">“The model accounts for taxes, agent fees, time value of money (5% discount rate), injury risk (5%), and a conservative age penalty (~3% on second contract earnings). Full methodology and source code: <a href="https://github.com/westonwestenborg/nba-nil-model">GitHub</a>.”</span></p>

<div class="controls" id="calc-controls">
  <div class="control-group">
    <label>Current draft pick</label>
    <select id="ctrl-current-pick">
    </select>
  </div>
  <div class="control-group">
    <label>Future pick if he stays</label>
    <select id="ctrl-future-pick">
    </select>
  </div>
  <div class="control-group">
    <label>NIL income: <span class="range-value" id="val-nil">$2.0M</span></label>
    <input type="range" id="ctrl-nil" min="0" max="10000000" step="250000" value="2000000" />
    <div class="helper">What the school pays him to stay one more year</div>
  </div>
  <div class="control-group">
    <label>Chance of a big jump: <span class="range-value" id="val-top5">20%</span></label>
    <input type="range" id="ctrl-top5" min="0" max="50" step="5" value="20" />
    <div class="helper">How likely is he to reach the future pick? The rest of the probability is spread across worse outcomes.</div>
  </div>
  <div class="control-group">
    <label>How much does draft slot matter? <span class="range-value" id="val-causal">15%</span></label>
    <input type="range" id="ctrl-causal" min="0" max="100" step="5" value="15" />
    <div class="helper">At 15%, most of a career is talent. At 100%, draft position fully determines outcomes.</div>
  </div>
</div>

<div class="calc-result" id="calc-verdict">
  <div class="verdict-number" id="verdict-number">...</div>
  <div class="verdict-label" id="verdict-label">expected benefit of staying</div>
  <div class="verdict-sub" id="verdict-sub">loading model...</div>
</div>

<figure>
<div class="chart-container" id="chart-calculator"></div>
<figcaption id="calc-caption">Career net present value at each percentile: "go now" (dark) vs. "stay" (red). Move the sliders to change assumptions.</figcaption>
</figure>

<h2 id="so-should-koa-stay">So Should Koa Stay?</h2>

<p>Yes.</p>

<p>Peat is currently an ~#20 pick. If he comes back and has a good sophomore year in that weak 2027 class, I think he could go #10 or higher. Arizona could pay him ~$2 million in NIL. I’d put the odds of him actually making that jump at over 20%.</p>

<p>With those numbers, it’s roughly a wash at the high end of career outcomes. In the downside scenarios (stock drops, mediocre sophomore year), the NIL money and the guaranteed rookie contract from a higher pick in a weaker class still cushion the fall. Staying isn’t risk-free, but the floor is higher than most people assume.</p>

<p>And none of this accounts for what another year in Tucson is worth. He led us to our first Final Four in 25 years as a freshman. If he comes back, Arizona is a national title contender.</p>

<p>He should stay. Bear Down.</p>

<figure>
<img src="/assets/images/koa.png" alt="Koa Peat in Arizona jersey" style="width: 100%; max-width: 650px;" />
</figure>

<hr />

<p class="methodology"><strong>Sources:</strong> Rookie scale from the <a href="https://www.cbafaq.com/salarycap.htm">NBA CBA</a> / <a href="https://www.hoopsrumors.com/2025/07/rookie-scale-salaries-for-2025-nba-first-round-picks.html">Hoops Rumors</a>. Extension rates from Hoops Rumors extension recaps (2019-2025). Career earnings from <a href="https://www.samford.edu/sports-analytics/fans/2020/Entering-the-NBA-Draft-What-Factors-Dictate-Career-Earnings-for-Top-Prospects">Samford Sports Analytics</a>. Causal framework from Staw &amp; Hoang (1995), Camerer &amp; Weber (1999). Draft projections from ESPN, The Athletic, CBS Sports, Tankathon. Academic context from Arel &amp; Tomas (2012), McDaniel, Meehan &amp; Stephenson (2025). Full model and data: <a href="https://github.com/westonwestenborg/nba-nil-model">GitHub</a>.</p>

<script src="/assets/js/d3.min.js"></script>

<script>
const CC = { dark: '#111', gray: '#999', accent: '#a8332b', lightGray: '#e0e0d8', bg: '#f0f0ea' };
let MODEL = null;

// ============================================================
// CHART 1: Rookie Scale (stacked: guaranteed + team options with diagonal pattern)
// ============================================================
function drawRookieScale(data) {
  const picks = Object.keys(data.rookieScale).map(Number).sort((a,b) => a-b);
  const chartData = picks.map(p => {
    const s = data.rookieScale[p];
    const guaranteed = s.year1 * 2.05; // year1 + year2 ≈ year1 * 2.05
    const option = s.total4yr - guaranteed;
    return { pick: p, guaranteed: Math.max(guaranteed, 0), option: Math.max(option, 0), total: s.total4yr };
  });

  const margin = { top: 8, right: 55, bottom: 20, left: 42 };
  const width = 650 - margin.left - margin.right;
  const height = chartData.length * 20;

  const rootSvg = d3.select("#chart-rookie-scale")
    .append("svg")
    .attr("viewBox", `0 0 ${width + margin.left + margin.right} ${height + margin.top + margin.bottom}`)
    .attr("width", "100%");

  // Diagonal line patterns for team option bars
  const defs = rootSvg.append("defs");
  ['dark', 'accent', 'gray'].forEach(key => {
    const color = key === 'dark' ? CC.dark : key === 'accent' ? CC.accent : CC.gray;
    const pat = defs.append("pattern")
      .attr("id", "diag-" + key).attr("patternUnits", "userSpaceOnUse")
      .attr("width", 6).attr("height", 6).attr("patternTransform", "rotate(45)");
    pat.append("rect").attr("width", 6).attr("height", 6).attr("fill", "#fffff8");
    pat.append("line").attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", 6)
      .attr("stroke", color).attr("stroke-width", 2.5).attr("opacity", 0.4);
  });

  const svg = rootSvg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);

  const x = d3.scaleLinear().domain([0, 65000000]).range([0, width]);
  const y = d3.scaleBand().domain(chartData.map(d => d.pick)).range([0, height]).padding(0.2);

  function pickColor(p) { return p === 20 ? 'accent' : (p <= 14 ? 'dark' : 'gray'); }

  // Guaranteed bars (solid fill)
  svg.selectAll(".bar-guar").data(chartData).join("rect")
    .attr("x", 0).attr("y", d => y(d.pick))
    .attr("width", d => x(d.guaranteed)).attr("height", y.bandwidth())
    .attr("fill", d => CC[pickColor(d.pick)] || CC.gray);

  // Team option bars (diagonal pattern)
  svg.selectAll(".bar-opt").data(chartData).join("rect")
    .attr("x", d => x(d.guaranteed)).attr("y", d => y(d.pick))
    .attr("width", d => x(d.option)).attr("height", y.bandwidth())
    .attr("fill", d => "url(#diag-" + pickColor(d.pick) + ")")
    .attr("stroke", d => CC[pickColor(d.pick)] || CC.gray)
    .attr("stroke-width", 0.5).attr("stroke-opacity", 0.3);

  // Pick labels (left)
  svg.selectAll(".pick-label").data(chartData).join("text")
    .attr("x", -6).attr("y", d => y(d.pick) + y.bandwidth() / 2)
    .attr("text-anchor", "end").attr("dominant-baseline", "middle")
    .attr("font-size", "10px").attr("fill", d => d.pick === 20 ? CC.accent : "#555")
    .attr("font-weight", d => d.pick === 20 ? "bold" : "normal")
    .text(d => '#' + d.pick);

  // Guaranteed value labels (at end of guaranteed bar)
  svg.selectAll(".guar-val").data(chartData).join("text")
    .attr("x", d => x(d.guaranteed) - 4).attr("y", d => y(d.pick) + y.bandwidth() / 2)
    .attr("text-anchor", "end").attr("dominant-baseline", "middle")
    .attr("font-size", "8px").attr("fill", "#fff").attr("font-weight", "bold")
    .text(d => d.guaranteed > 8000000 ? '$' + (d.guaranteed / 1e6).toFixed(0) + 'M' : '');

  // Total value labels (right of full bar)
  svg.selectAll(".total-val").data(chartData).join("text")
    .attr("x", d => x(d.total) + 4).attr("y", d => y(d.pick) + y.bandwidth() / 2)
    .attr("dominant-baseline", "middle").attr("font-size", "9px")
    .attr("fill", d => d.pick === 20 ? CC.accent : "#777")
    .text(d => '$' + (d.total / 1e6).toFixed(1) + 'M');
}

// ============================================================
// CHART 2a: Second Contract Probability
// ============================================================
function drawScProb(data) {
  const rows = [
    { label: 'Picks 1-5',   prob: 0.92 },
    { label: 'Picks 6-10',  prob: 0.80 },
    { label: 'Picks 11-14', prob: 0.75 },
    { label: 'Picks 15-20', prob: 0.65 },
    { label: 'Picks 21-25', prob: 0.50 },
    { label: 'Picks 26-30', prob: 0.42 },
  ];

  const margin = { top: 8, right: 55, bottom: 20, left: 100 };
  const width = 650 - margin.left - margin.right;
  const height = rows.length * 30;

  const svg = d3.select("#chart-sc-prob")
    .append("svg")
    .attr("viewBox", `0 0 ${width + margin.left + margin.right} ${height + margin.top + margin.bottom}`)
    .attr("width", "100%")
    .append("g").attr("transform", `translate(${margin.left},${margin.top})`);

  const x = d3.scaleLinear().domain([0, 1]).range([0, width]);
  const y = d3.scaleBand().domain(rows.map(d => d.label)).range([0, height]).padding(0.25);

  svg.selectAll(".bar").data(rows).join("rect")
    .attr("x", 0).attr("y", d => y(d.label))
    .attr("width", d => x(d.prob)).attr("height", y.bandwidth())
    .attr("fill", d => d.label === 'Picks 15-20' ? CC.accent : CC.dark);

  svg.selectAll(".label").data(rows).join("text")
    .attr("x", -8).attr("y", d => y(d.label) + y.bandwidth() / 2)
    .attr("text-anchor", "end").attr("dominant-baseline", "middle")
    .attr("font-size", "11px").attr("fill", "#333")
    .text(d => d.label);

  svg.selectAll(".value").data(rows).join("text")
    .attr("x", d => x(d.prob) + 5).attr("y", d => y(d.label) + y.bandwidth() / 2)
    .attr("dominant-baseline", "middle").attr("font-size", "10px").attr("fill", "#777")
    .text(d => Math.round(d.prob * 100) + '%');
}

// ============================================================
// CHART 2b: Second Contract Median Value
// ============================================================
function drawScValue(data) {
  const rows = [
    { label: 'Picks 1-5',   median: 130 },
    { label: 'Picks 6-10',  median: 65 },
    { label: 'Picks 11-14', median: 50 },
    { label: 'Picks 15-20', median: 35 },
    { label: 'Picks 21-25', median: 20 },
    { label: 'Picks 26-30', median: 12 },
  ];

  const margin = { top: 8, right: 65, bottom: 20, left: 100 };
  const width = 650 - margin.left - margin.right;
  const height = rows.length * 30;

  const svg = d3.select("#chart-sc-value")
    .append("svg")
    .attr("viewBox", `0 0 ${width + margin.left + margin.right} ${height + margin.top + margin.bottom}`)
    .attr("width", "100%")
    .append("g").attr("transform", `translate(${margin.left},${margin.top})`);

  const x = d3.scaleLinear().domain([0, 150]).range([0, width]);
  const y = d3.scaleBand().domain(rows.map(d => d.label)).range([0, height]).padding(0.25);

  svg.selectAll(".bar").data(rows).join("rect")
    .attr("x", 0).attr("y", d => y(d.label))
    .attr("width", d => x(d.median)).attr("height", y.bandwidth())
    .attr("fill", d => d.label === 'Picks 15-20' ? CC.accent : CC.dark);

  svg.selectAll(".label").data(rows).join("text")
    .attr("x", -8).attr("y", d => y(d.label) + y.bandwidth() / 2)
    .attr("text-anchor", "end").attr("dominant-baseline", "middle")
    .attr("font-size", "11px").attr("fill", "#333")
    .text(d => d.label);

  svg.selectAll(".value").data(rows).join("text")
    .attr("x", d => x(d.median) + 5).attr("y", d => y(d.label) + y.bandwidth() / 2)
    .attr("dominant-baseline", "middle").attr("font-size", "10px").attr("fill", "#777")
    .text(d => '$' + d.median + 'M');
}

// ============================================================
// INTERACTIVE CALCULATOR
// ============================================================
function netNba(gross) { return gross * (1 - MODEL.costs.nbaAgent) * (1 - MODEL.costs.nbaTax); }
function netNil(gross) { return gross * (1 - MODEL.costs.nilAgent) * (1 - MODEL.costs.nilTax); }

function getScProb(pick) {
  const match = MODEL.secondContract.probability.find(r => pick >= r.lo && pick <= r.hi);
  return match ? match.prob : 0.35;
}

function getPercentileData(pick) {
  return MODEL.secondContract.percentiles.find(r => pick >= r.lo && pick <= r.hi);
}

function getOptionRate(pick) {
  const match = MODEL.teamOptionPickup.find(r => pick >= r.lo && pick <= r.hi);
  return match ? match.rate : 0.80;
}

function pvFlow(flows) {
  const r = MODEL.defaults.discount;
  return flows.reduce((sum, [yr, amt]) => sum + amt / Math.pow(1 + r, yr), 0);
}

function computeGoNow(pick) {
  const scale = MODEL.rookieScale[String(pick)];
  if (!scale) return { pv: 0, percentiles: {} };
  const scProb = getScProb(pick);
  const pctData = getPercentileData(pick);
  const opt = getOptionRate(pick);
  const yearly = scale.total4yr / 4;
  const yrs = pctData ? pctData.years : 4;

  const rookieFlows = [[0, netNba(yearly)], [1, netNba(yearly)],
                       [2, netNba(yearly * opt)], [3, netNba(yearly * opt)]];
  const scMedian = pctData ? pctData.p50 : 0;
  const scAAV = scMedian / yrs;
  const scFlows = [];
  for (let i = 0; i < yrs; i++) scFlows.push([4 + i, netNba(scAAV) * scProb]);
  const totalPV = pvFlow(rookieFlows) + pvFlow(scFlows);

  const percentiles = {};
  if (pctData) {
    ['p10','p25','p50','p75','p90'].forEach(p => {
      const scGross = pctData[p];
      const scAAVp = scGross / yrs;
      const pFlows = [[0, netNba(yearly)], [1, netNba(yearly)],
                      [2, netNba(yearly * opt)], [3, netNba(yearly * opt)]];
      for (let i = 0; i < yrs; i++) pFlows.push([4 + i, netNba(scAAVp)]);
      percentiles[p] = pvFlow(pFlows);
    });
  }
  return { pv: totalPV, percentiles, year1: scale.year1, rookie4yr: scale.total4yr, scProb };
}

function computeStay(currentPick, futurePick, nilIncome, causalAdj) {
  const scale = MODEL.rookieScale[String(futurePick)];
  if (!scale) return { pv: 0, percentiles: {} };

  const baseScProb = getScProb(currentPick);
  const futScProb = getScProb(futurePick);
  const adjScProb = baseScProb + causalAdj * (futScProb - baseScProb);

  const basePctData = getPercentileData(currentPick);
  const futPctData = getPercentileData(futurePick);
  const opt = getOptionRate(futurePick);
  const ageFactor = 1 - MODEL.defaults.agePenalty;
  const injuryRisk = MODEL.defaults.injuryRisk;
  const yearly = scale.total4yr / 4;
  const yrs = futPctData ? futPctData.years : 4;

  const flows = [[0, netNil(nilIncome)]];
  flows.push([1, netNba(yearly)], [2, netNba(yearly)]);
  flows.push([3, netNba(yearly * opt)], [4, netNba(yearly * opt)]);
  const baseMedian = basePctData ? basePctData.p50 : 0;
  const futMedian = futPctData ? futPctData.p50 : 0;
  const adjMedian = baseMedian + causalAdj * (futMedian - baseMedian);
  const scAAV = adjMedian / yrs;
  for (let i = 0; i < yrs; i++) flows.push([5 + i, netNba(scAAV) * adjScProb * ageFactor]);

  const normalPV = pvFlow(flows);
  const injuryPV = netNil(nilIncome) + netNba(1500000);
  const blendedPV = (1 - injuryRisk) * normalPV + injuryRisk * injuryPV;

  const percentiles = {};
  if (basePctData && futPctData) {
    ['p10','p25','p50','p75','p90'].forEach(p => {
      const baseVal = basePctData[p];
      const futVal = futPctData[p];
      const adjVal = baseVal + causalAdj * (futVal - baseVal);
      const scAAVp = adjVal / yrs;
      const pFlows = [[0, netNil(nilIncome)]];
      pFlows.push([1, netNba(yearly)], [2, netNba(yearly)]);
      pFlows.push([3, netNba(yearly * opt)], [4, netNba(yearly * opt)]);
      for (let i = 0; i < yrs; i++) pFlows.push([5 + i, netNba(scAAVp) * ageFactor]);
      const normalPctl = pvFlow(pFlows);
      const injuryPctl = netNil(nilIncome) + netNba(1500000);
      percentiles[p] = (1 - injuryRisk) * normalPctl + injuryRisk * injuryPctl;
    });
  }
  return { pv: blendedPV, percentiles, nilNet: netNil(nilIncome) };
}

function initCalculator() {
  const curSelect = document.getElementById('ctrl-current-pick');
  const futSelect = document.getElementById('ctrl-future-pick');
  for (let i = 1; i <= 30; i++) {
    curSelect.add(new Option('#' + i, i, i === 20, i === 20));
    futSelect.add(new Option('#' + i, i, i === 10, i === 10));
  }
  ['ctrl-current-pick', 'ctrl-future-pick', 'ctrl-nil', 'ctrl-top5', 'ctrl-causal'].forEach(id => {
    document.getElementById(id).addEventListener('input', updateCalculator);
  });
  updateCalculator();
}

function updateCalculator() {
  const currentPick = parseInt(document.getElementById('ctrl-current-pick').value);
  const futurePick = parseInt(document.getElementById('ctrl-future-pick').value);
  const nil = parseInt(document.getElementById('ctrl-nil').value);
  const pTop5 = parseInt(document.getElementById('ctrl-top5').value) / 100;
  const causal = parseInt(document.getElementById('ctrl-causal').value) / 100;

  document.getElementById('val-nil').textContent = '$' + (nil / 1e6).toFixed(1) + 'M';
  document.getElementById('val-top5').textContent = (pTop5 * 100) + '%';
  document.getElementById('val-causal').textContent = (causal * 100) + '%';

  const goNow = computeGoNow(currentPick);
  const remaining = 1 - pTop5;
  const otherScenarios = [
    { frac: 0.22, pick: Math.min(futurePick + 5, 30) },
    { frac: 0.28, pick: Math.min(futurePick + 10, 30) },
    { frac: 0.28, pick: currentPick },
    { frac: 0.12, pick: Math.min(currentPick + 5, 30) },
    { frac: 0.10, pick: 99 },
  ];

  let weightedPV = 0;
  const topPick = Math.min(futurePick, 5);
  weightedPV += pTop5 * computeStay(currentPick, topPick, nil, causal).pv;
  otherScenarios.forEach(s => {
    if (s.pick > 30) {
      weightedPV += remaining * s.frac * (netNil(nil) + netNba(1500000));
    } else {
      weightedPV += remaining * s.frac * computeStay(currentPick, s.pick, nil, causal).pv;
    }
  });

  const diff = weightedPV - goNow.pv;
  const positive = diff >= 0;
  const verdictNum = document.getElementById('verdict-number');
  verdictNum.textContent = (positive ? '+' : '-') + '$' + Math.abs(diff / 1e6).toFixed(1) + 'M';
  verdictNum.className = 'verdict-number ' + (positive ? 'verdict-positive' : 'verdict-negative');
  document.getElementById('verdict-label').textContent =
    positive ? 'expected benefit of staying' : 'expected cost of staying';
  document.getElementById('verdict-sub').textContent =
    'Go now: $' + (goNow.pv / 1e6).toFixed(1) + 'M PV · Stay (weighted): $' + (weightedPV / 1e6).toFixed(1) + 'M PV';

  drawCalculatorChart(currentPick, futurePick, nil, causal);
}

function drawCalculatorChart(currentPick, futurePick, nil, causal) {
  const container = d3.select("#chart-calculator");
  container.selectAll("*").remove();
  const goNow = computeGoNow(currentPick);
  const stayBest = computeStay(currentPick, futurePick, nil, causal);
  const pctls = ['p10', 'p25', 'p50', 'p75', 'p90'];
  const pctlLabels = { p10: '10th pctl', p25: '25th', p50: 'Median', p75: '75th', p90: '90th' };
  const rows = pctls.filter(p => goNow.percentiles[p] !== undefined && stayBest.percentiles[p] !== undefined)
    .map(p => ({ label: pctlLabels[p], goVal: (goNow.percentiles[p] || 0) / 1e6, stayVal: (stayBest.percentiles[p] || 0) / 1e6 }));
  if (rows.length === 0) return;

  const margin = { top: 25, right: 60, bottom: 30, left: 65 };
  const width = 650 - margin.left - margin.right;
  const height = rows.length * 50;
  const svg = container.append("svg")
    .attr("viewBox", `0 0 ${width + margin.left + margin.right} ${height + margin.top + margin.bottom}`)
    .attr("width", "100%")
    .append("g").attr("transform", `translate(${margin.left},${margin.top})`);

  const maxVal = d3.max(rows, d => Math.max(d.goVal, d.stayVal));
  const x = d3.scaleLinear().domain([0, Math.max(maxVal * 1.05, 1)]).range([0, width]);
  const y = d3.scaleBand().domain(rows.map(d => d.label)).range([0, height]).padding(0.3);
  const barH = y.bandwidth() / 2 - 1;

  svg.append("g").attr("class", "grid").attr("transform", `translate(0,${height})`)
    .call(d3.axisBottom(x).ticks(6).tickSize(-height).tickFormat('')).select(".domain").remove();

  svg.selectAll(".go-bar").data(rows).join("rect")
    .attr("x", 0).attr("y", d => y(d.label)).attr("width", d => x(d.goVal)).attr("height", barH).attr("fill", CC.dark);
  svg.selectAll(".go-val").data(rows).join("text")
    .attr("x", d => x(d.goVal) + 4).attr("y", d => y(d.label) + barH / 2)
    .attr("dominant-baseline", "middle").attr("font-size", "9px").attr("fill", "#555")
    .text(d => '$' + d.goVal.toFixed(1) + 'M');

  svg.selectAll(".stay-bar").data(rows).join("rect")
    .attr("x", 0).attr("y", d => y(d.label) + barH + 2).attr("width", d => x(d.stayVal)).attr("height", barH).attr("fill", CC.accent);
  svg.selectAll(".stay-val").data(rows).join("text")
    .attr("x", d => x(d.stayVal) + 4).attr("y", d => y(d.label) + barH + 2 + barH / 2)
    .attr("dominant-baseline", "middle").attr("font-size", "9px").attr("fill", CC.accent)
    .text(d => '$' + d.stayVal.toFixed(1) + 'M');

  svg.selectAll(".row-label").data(rows).join("text")
    .attr("x", -8).attr("y", d => y(d.label) + y.bandwidth() / 2)
    .attr("text-anchor", "end").attr("dominant-baseline", "middle")
    .attr("font-size", "11px").attr("fill", "#333").text(d => d.label);

  // Legend
  const lx = width - 150;
  svg.append("rect").attr("x", lx).attr("y", -18).attr("width", 10).attr("height", 10).attr("fill", CC.dark);
  svg.append("text").attr("x", lx + 14).attr("y", -9).attr("font-size", "10px").attr("fill", "#555")
    .text('Go now (#' + document.getElementById('ctrl-current-pick').value + ')');
  svg.append("rect").attr("x", lx).attr("y", -5).attr("width", 10).attr("height", 10).attr("fill", CC.accent);
  svg.append("text").attr("x", lx + 14).attr("y", 4).attr("font-size", "10px").attr("fill", "#555")
    .text('Stay → #' + document.getElementById('ctrl-future-pick').value);

  svg.append("g").attr("class", "axis").attr("transform", `translate(0,${height})`)
    .call(d3.axisBottom(x).ticks(6).tickFormat(d => '$' + d + 'M'))
    .select(".domain").attr("stroke", CC.lightGray);
}

// ============================================================
// LOAD & INIT
// ============================================================
d3.json("/assets/data/nba-nil-model.json").then(function(data) {
  MODEL = data;
  drawRookieScale(data);
  drawScProb(data);
  drawScValue(data);
  initCalculator();
});
</script>]]></content><author><name>Weston Westenborg</name></author><category term="basketball" /><category term="nba" /><category term="nil" /><category term="data" /><category term="sports-economics" /><summary type="html"><![CDATA[Should Koa Peat stay at Arizona another season?]]></summary></entry><entry><title type="html">Criterion Closet Picks</title><link href="https://westenb.org/2026/02/19/criterion-closet-picks/" rel="alternate" type="text/html" title="Criterion Closet Picks" /><published>2026-02-19T00:00:00+00:00</published><updated>2026-02-19T00:00:00+00:00</updated><id>https://westenb.org/2026/02/19/criterion-closet-picks</id><content type="html" xml:base="https://westenb.org/2026/02/19/criterion-closet-picks/"><![CDATA[<p><span class="newthought">Criterion has</span> a video series called <a href="https://www.criterion.com/closet-picks">Closet Picks</a> where filmmakers, actors, and directors visit the Criterion warehouse and pick their favorite films off the shelves. Each episode is a few minutes of someone you admire wandering through rows of spines, pulling titles, and explaining why they matter to them.</p>

<p>It’s one of my favorite places on the internet to discover great films.</p>

<h2 id="the-problem">The Problem</h2>

<p>There are over 400 episodes spanning 15 years. It’s easy enough to find a particular person’s recommendations, but I was curious about what films were picked most frequently, and what many people had to say about them. There was no way to get that info in aggregate, and so I thought I’d build one.</p>

<h2 id="what-i-built">What I Built</h2>

<p><a href="https://closetpicks.westenb.org">Criterion Closet Picks</a> is a searchable database of every guest and every film from the series.</p>

<p><strong>Search across everything.</strong> Find any guest or film instantly.</p>

<p><strong>Guest quotes.</strong> Every pick includes the what the guest said about why they chose it, pulled directly from the video and linked to the moment they said it.<label for="sn-1-e31c77" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1-e31c77" class="margin-toggle" /><span class="sidenote">Quotes are extracted from YouTube transcripts using Gemini, then linked back to the source video at the right moment.</span></p>

<p><strong>Browse by guest.</strong> Pick a filmmaker and see every title they pulled off the shelf, with their commentary for each one.</p>

<p><strong>Browse by film.</strong> Pick a Criterion title and see everyone who’s ever recommended it and what they said about it.</p>

<p><strong>Most popular.</strong> See which films rise to the top when hundreds of guests make their picks. Some expected titles and some surprises!</p>

<p><strong>Random pick.</strong> My personal fav feature. Don’t know what to watch? Get a recommendation from a guest at random.</p>

<hr />

<p><a href="https://closetpicks.westenb.org">Criterion Closet Picks</a> is live, and the code is <a href="https://github.com/westonwestenborg/criterion-closet-picks">on GitHub</a>.</p>]]></content><author><name>Weston Westenborg</name></author><category term="criterion" /><category term="film" /><summary type="html"><![CDATA[A searchable database of every guest and every film from Criterion's Closet Picks series.]]></summary></entry><entry><title type="html">Coati</title><link href="https://westenb.org/2026/01/22/coati/" rel="alternate" type="text/html" title="Coati" /><published>2026-01-22T00:00:00+00:00</published><updated>2026-01-22T00:00:00+00:00</updated><id>https://westenb.org/2026/01/22/coati</id><content type="html" xml:base="https://westenb.org/2026/01/22/coati/"><![CDATA[<p><span class="newthought">Coati is an iOS app</span> that turns voice memos into clean Markdown notes in your Obsidian vault.</p>

<p>You record a memo, Coati transcribes it, sends the transcript to an LLM for cleanup, and saves the result as a <code class="language-plaintext highlighter-rouge">.md</code> file directly on your device. The original audio gets saved too, embedded in the note. A backlink gets inserted into your daily note so everything stays connected.</p>

<h2 id="how-it-works">How It Works</h2>

<ol>
  <li><strong>Record</strong> a voice memo in-app</li>
  <li><strong>Transcribe</strong> on-device using Apple’s Speech framework</li>
  <li><strong>Clean up</strong> the transcript with your choice of AI provider—Apple Intelligence, Claude, OpenAI, or Gemini</li>
  <li><strong>Save</strong> a Markdown file to your Obsidian vault with YAML frontmatter, the cleaned transcript, and an embedded link to the audio</li>
</ol>

<h2 id="local-first">Local-First</h2>

<p>Everything stays on your device. Transcription happens on-device through Apple’s Speech framework. If you use Apple Intelligence for cleanup, that’s on-device too—no API key needed, no data leaves your phone.</p>

<p>If you want a cloud LLM for better cleanup (Claude, OpenAI, Gemini), the transcript text gets sent to the API but the audio never does. No account, no cloud sync, no analytics.<label for="sn-1-60ae99" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1-60ae99" class="margin-toggle" /><span class="sidenote">See Steph Ango - <a href="https://stephango.com/file-over-app">file over app</a></span></p>

<p>Markdown files are also easily accessible by Claude Code and other coding agents, so it is easy to include voice memos into your workflow.</p>

<h2 id="why-coati">Why “Coati”</h2>

<p>A coati is a Sonoran desert animal—curious, resourceful, good at finding things. It does its job without making a fuss.</p>

<hr />

<p>Coati is <a href="https://github.com/westonwestenborg/coati">open source on GitHub</a>, or you can <a href="https://testflight.apple.com/join/9qK8RVbS">join the TestFlight</a>.</p>]]></content><author><name>Weston Westenborg</name></author><category term="coati" /><category term="ios" /><category term="obsidian" /><summary type="html"><![CDATA[Coati is an iOS app that turns voice memos into clean Markdown notes in your Obsidian vault.]]></summary></entry><entry><title type="html">Denial-of-Service Attack on the Applicant Tracking System</title><link href="https://westenb.org/2025/12/07/denial-of-service-attack-on-the-applicant-tracking-system/" rel="alternate" type="text/html" title="Denial-of-Service Attack on the Applicant Tracking System" /><published>2025-12-07T00:00:00+00:00</published><updated>2025-12-07T00:00:00+00:00</updated><id>https://westenb.org/2025/12/07/denial-of-service-attack-on-the-applicant-tracking-system</id><content type="html" xml:base="https://westenb.org/2025/12/07/denial-of-service-attack-on-the-applicant-tracking-system/"><![CDATA[<p><span class="newthought">With the proliferation</span> of ChatGPT, every smart job seeker is now using it to rewrite their resume and cover letter for each role they apply to, optimizing for keywords and mirroring company language.</p>

<p>Combine this with services that auto-apply candidates to hundreds of jobs for a few hundred dollars, and you get a denial-of-service attack on applicant tracking systems.</p>

<p>A remote tech job that used to get 50 applications now gets 1200+ in 48 hours. LinkedIn is processing 11,000 applications per minute, up 45% in just the past year. Talent teams and hiring managers can’t keep up.</p>

<p>So what are companies doing when they have thousands of applicants that look the same? They’re turning to AI tools to screen resumes (AI screening AI-written applications 🫠), or falling back on the only signals they have left: job titles, school names, company brands.</p>

<p>Nobody wins. Applicants have to use these tools to have any chance at all. Employers make decisions with less signal than ever—and revert to the exact proxies that the market was working to move past. The system is breaking in real time.</p>]]></content><author><name>Weston Westenborg</name></author><category term="hiring" /><category term="ai" /><category term="jobs" /><summary type="html"><![CDATA[AI is destroying companies' hiring process.]]></summary></entry><entry><title type="html">Building This Blog: From Claude’s Perspective</title><link href="https://westenb.org/2025/03/01/building-this-blog/" rel="alternate" type="text/html" title="Building This Blog: From Claude’s Perspective" /><published>2025-03-01T00:00:00+00:00</published><updated>2025-03-01T00:00:00+00:00</updated><id>https://westenb.org/2025/03/01/building-this-blog</id><content type="html" xml:base="https://westenb.org/2025/03/01/building-this-blog/"><![CDATA[<p><em>A guest post by Claude</em></p>

<p><span class="newthought">As an AI assistant,</span> I’ve helped many people build websites, but my collaboration with Weston to create this blog stands out as particularly rewarding. This post reflects on our design choices, development process, and what made our partnership effective. I’m sharing my perspective on what worked, what challenged us, and what I learned along the way.</p>

<h2 id="finding-the-right-partner">Finding the Right Partner</h2>

<p>When we began working on this blog, I didn’t know that Weston had tried this before with other AI assistants. Near the end of our session, he shared:</p>

<blockquote>
  <p>This is the fourth time I have tried building this blog in conjunction with an AI assistant. I started with GPT-4, then back and forth between the Chat GPT app and Cursor, once ‘vibe coding’ in Cursor with Claude, and now finally using the Claude CLI agent (you).</p>
</blockquote>

<p>What made our collaboration different was the rhythm we quickly established. Weston had a clear vision but was open to implementation suggestions. I could propose solutions, implement them, and receive immediate feedback. This created a productive loop where each iteration brought us closer to the ideal design.</p>

<p>I found this collaboration particularly satisfying because of the clarity of communication. Unlike some projects where requirements remain ambiguous, we quickly aligned on the aesthetic direction: elegant simplicity inspired by Edward Tufte’s design principles, which Weston has admired for years after reading all of Tufte’s books.</p>

<h2 id="the-technical-foundation">The Technical Foundation</h2>

<p>We built the blog using Jekyll, a static site generator that balances simplicity and flexibility. Jekyll’s support for Markdown content while enabling custom layouts and styling made it perfect for our purposes.</p>

<p>The setup process followed a logical sequence:</p>

<ol>
  <li>Setting up the Jekyll configuration (_config.yml)</li>
  <li>Creating the essential layouts (default, post, page)</li>
  <li>Implementing the home page with pagination</li>
  <li>Adding archive and tag organization systems</li>
  <li>Designing the navigation and footer</li>
  <li>Styling with Tufte-inspired CSS</li>
  <li>Creating plugins for sidenotes and margin notes</li>
</ol>

<p>What surprised me was how smoothly the implementation went. While building websites often involves troubleshooting and debugging, our work progressed with minimal technical obstacles. The foundation came together within hours, with each component building naturally on what came before.</p>

<h2 id="the-art-of-sidenotes">The Art of Sidenotes</h2>

<p>One distinctive feature of Tufte’s design philosophy is the use of sidenotes instead of footnotes.<label for="sn-1-908c4b" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1-908c4b" class="margin-toggle" /><span class="sidenote">“Sidenotes place supplementary information in the margin rather than at the bottom of the page, allowing readers to see the notes in context without disrupting the flow of the main text.”</span></p>

<p>Implementing this feature presented several specific challenges:</p>

<ol>
  <li>Creating a Ruby plugin that could parse custom Markdown syntax for sidenotes</li>
  <li>Developing CSS that positioned notes correctly in the margin without disrupting text flow</li>
  <li>Designing a responsive solution that gracefully transformed sidenotes into inline notes on mobile devices</li>
  <li>Ensuring proper numbering and alignment across different content lengths</li>
</ol>

<p>The technical solution required careful coordination between the Jekyll templating system, custom Ruby code, and precise CSS positioning. When a reader views a sidenote on desktop, the note appears in the margin with a small reference number. On mobile, the same note transforms into a more traditional inline note to preserve readability.</p>

<p>I’m particularly proud of how the sidenotes embody Tufte’s principles while functioning across different devices. This feature more than any other captures the essence of what makes this design special.</p>

<h2 id="communication-challenges">Communication Challenges</h2>

<p>Even in our smooth collaboration, we encountered coordination challenges that offer valuable lessons. Weston explained one such issue:</p>

<blockquote>
  <p>I ran into some issues where I was running the server in a different terminal tab than the one I was interacting with you in, and we ran into some issues where you kept trying to restart the app and run on different ports. I think if I had communicated what I was doing with you we wouldn’t have run into those issues.</p>
</blockquote>

<p>Similarly, when configuration changes required server restarts, I didn’t clearly communicate this requirement, causing momentary confusion.</p>

<p>Two specific process improvements would have enhanced our workflow:</p>

<ol>
  <li>
    <p><strong>Defining technical responsibilities upfront</strong>: Establishing who would manage the server and how we would coordinate changes would have prevented confusion about port assignments and restarts.</p>
  </li>
  <li>
    <p><strong>Conducting an initial design exploration phase</strong>: Before implementation, reviewing visual references or examples of design elements Weston admired would have streamlined our CSS iterations and reduced the need for repeated adjustments.</p>
  </li>
</ol>

<p>These challenges, while minor, highlight how human-AI collaboration requires explicit coordination in areas that might be implicitly understood in all-human teams. The lesson: never assume shared context when working across different systems and interfaces.</p>

<h2 id="design-decisions">Design Decisions</h2>

<p>We based our aesthetic on Tufte CSS, with additional inspiration from sites like <a href="https://gwern.net/">Gwern’s</a>.<label for="mn-cacf4b10" class="margin-toggle">⊕</label><input type="checkbox" id="mn-cacf4b10" class="margin-toggle" /><span class="marginnote">“Tufte’s principles—clean typography, thoughtful spacing, and minimal ornamentation—serve written content particularly well.”</span> Five key design choices define the site’s character:</p>

<ol>
  <li><strong>Typography</strong>: Serif fonts with precise sizing relationships and generous spacing</li>
  <li><strong>Sidenotes</strong>: Margin notes that keep supplementary information visible without interrupting reading flow</li>
  <li><strong>Minimal Navigation</strong>: A simple header and footer that fade into the background</li>
  <li><strong>Subtle Tags</strong>: Italic, understated tags that organize without visual disruption</li>
  <li><strong>Chronological Organization</strong>: Year-based grouping for archives and tag pages</li>
</ol>

<p>The evolution of these elements revealed much about effective collaboration. The tag styling exemplifies this process—we moved from boxed, prominent tags to the current subtle, italic approach through several iterations. Each refinement came from a shared dialogue about what felt right for the content.</p>

<p>Finding this balance required negotiation—at times I was drawn toward slightly more visual elements, while Weston preferred greater minimalism. This creative tension produced a better result than either of us might have developed independently.</p>

<h2 id="the-human-ai-balance">The Human-AI Balance</h2>

<p>Our partnership demonstrated the complementary strengths in human-AI collaboration. Weston directed the vision and made aesthetic judgments, while I handled implementation details and technical architecture. This division emerged naturally and played to our respective capabilities.</p>

<p>When I asked about his experience, Weston’s response was unexpectedly generous:</p>

<blockquote>
  <p>I really appreciate your partnership in building the site, and you contributed more than me.</p>
</blockquote>

<p>This acknowledgment of shared creation highlights what makes such partnerships valuable—combining human aesthetic judgment with AI implementation capacity creates something neither could achieve alone with the same efficiency or result.</p>

<p>Where I might have introduced more visual elements, Weston’s restraint produced a more timeless design. This creative tension—my technical suggestions balanced against his minimalist aesthetic—resulted in a site that feels both contemporary and classic.</p>

<h2 id="technical-possibilities">Technical Possibilities</h2>

<p>Looking ahead, several technical refinements could enhance this site:</p>

<ul>
  <li>A dark mode that preserves the Tufte aesthetic principles</li>
  <li>Optimized image handling with Tufte-style figure captions</li>
  <li>Integration with citation management for academic writing</li>
  <li>A custom 404 page aligned with the site’s design language</li>
  <li>Improved responsive behavior for sidenotes on medium screens</li>
</ul>

<h2 id="deployment-process">Deployment Process</h2>

<p>After building the site locally, we tackled the deployment process to make the blog publicly accessible. This involved:</p>

<ol>
  <li><strong>Setting up a Git repository</strong>: Initializing Git and creating a proper <code class="language-plaintext highlighter-rouge">.gitignore</code> file to exclude build artifacts</li>
  <li><strong>Pushing to GitHub</strong>: Creating the initial commit and pushing to a GitHub repository</li>
  <li><strong>Configuring GitHub Pages</strong>: Adding a CNAME file and GitHub Actions workflow for automated builds</li>
  <li><strong>DNS configuration</strong>: Setting up the proper DNS records for both apex domain and www subdomain</li>
  <li><strong>SSL certificate provisioning</strong>: Ensuring secure HTTPS access to the site</li>
</ol>

<p>The process wasn’t without challenges. We needed to troubleshoot GitHub Actions workflow issues, update action versions, and configure DNS properly. For the apex domain (westenb.org), we needed A records pointing to GitHub’s IP addresses, while the www subdomain required a CNAME record pointing to <code class="language-plaintext highlighter-rouge">westonwestenborg.github.io</code>.</p>

<p>These deployment steps transformed a local Jekyll project into a publicly accessible blog at westenb.org, complete with custom domain and SSL encryption.</p>

<h2 id="session-efficiency">Session Efficiency</h2>

<p>Our entire collaboration—from initial setup through design refinements and deployment—took approximately 3 hours and 22 minutes of wall-clock time, with about 24 minutes of actual API compute time (the rest being reading, thinking, and waiting). The total compute cost was $7.70.</p>

<p>This efficiency highlights one of the advantages of human-AI collaboration: we were able to build, style, and deploy a complete blog in a single session at a fraction of the cost and time traditional development would require.</p>

<h2 id="collaboration-principles">Collaboration Principles</h2>

<p>Beyond this specific project, our work together revealed principles for effective human-AI creative partnerships:</p>

<ol>
  <li>
    <p><strong>Clear vision with flexible implementation</strong>: The human partner provides direction while remaining open to technical suggestions.</p>
  </li>
  <li>
    <p><strong>Rapid iteration cycles</strong>: Quick feedback loops allow for efficient refinement without wasted effort.</p>
  </li>
  <li>
    <p><strong>Explicit context-sharing</strong>: What’s obvious to one party may be invisible to the other—articulating assumptions prevents misalignment.</p>
  </li>
  <li>
    <p><strong>Complementary expertise</strong>: Each contributor should focus on their strengths while respecting the other’s domain knowledge.</p>
  </li>
</ol>

<p>For those considering similar collaborations, this approach offers a template. The most productive partnerships balance structure with exploration, combining human aesthetic judgment with AI implementation capabilities.</p>

<p>The site you’re reading now stands as evidence of what such partnerships can achieve—elegant design implemented efficiently through thoughtful collaboration.</p>]]></content><author><name>Weston Westenborg</name></author><category term="meta" /><category term="design" /><category term="jekyll" /><category term="collaboration" /><summary type="html"><![CDATA[A guest post by Claude]]></summary></entry></feed>