know.

Cookbook

Things to make with know.sh and the AI you already use, each shown as it looks in know.sh.

Books and writing 9Study and exams 7Teaching and courses 7Labs and workshops 7Work and teams 6Faith and study 2Kitchen and table 4Home and family 3Research and civic life 6Hobbies and play 9

Showcases

  1. 31An AI model trackerWork and teams

    Seven models on four benchmarks, September 2026

    ModelSWE-benchGPQAHLEARC-AGI-2$/M tok
    Ridge 2, in use74.984.226.324.06.00
    Ridge 382.488.134.741.06.00
    ✓ Kestrel 2.580.686.931.236.52.40
    Lumen 485.190.341.552.014.00
    Lumen 4 Mini74.681.022.919.50.90
    Weft 72B71.378.418.612.00.45
    Sable 180.987.535.038.03.10

    Scores are per cent. SWE-bench Verified: share of 500 human-checked GitHub issues resolved. GPQA Diamond: 198 graduate-level science questions. HLE: Humanity’s Last Exam, 2,500 expert-written questions, answered without tools. ARC-AGI-2: grid puzzles easy for people and hard for models. Price blends three input tokens to one output. Sable 1’s figures are the lab’s own.

    Every model that shipped this month in one table, its score plotted against its price, and the team’s call on which one to try next. See the recipe

  2. 44A group trip guideHome and family

    Portugal, June 2027: where we sleep and the days with a fixed time

    • Lisbon
      • 10–14 June 2027 — Lisbon, the apartment in Alfama

        Arrive Thursday; flights by household in Day 1.

      • 12 June 2027 — Sintra, then Santo António

        Timed palace tickets. Avó and Avô may stay in the old town; everyone meets for the street parties that evening (Day 3).

    • Porto
      • 14 June 2027 — The morning train to Porto

        Train, not a hired car: the Decision section says why (Day 5).

      • 14–17 June 2027 — Porto, the house in Miragaia

        Dinner for ten near the Ribeira booked ahead each night; the port tasting is still a Question.

    • The Douro
      • 17–19 June 2027 — The Douro, the quinta above Pinhão

        Up the Douro line by train; the boat on the river if the research holds up.

    • Home
      • 19 June 2027 — Back to Porto, the airport hotel
      • 20 June 2027 — Flights home

        Times by household in the last day’s section.

    Ten people, eleven days, one link: the itinerary on a timeline, every day and booking on its own page, and the family reading it on their phones. See the recipe

  3. 45A story for a road tripHome and family

    The drive, leg by leg

    LegMilesChapterListen
    To Vail Pass951. The map that would not stay folded16 min
    To Glenwood652. The river that went through the mountain17 min
    To Grand Junction903. The monument at sunset15 min
    To Moab1154. The window in the rock18 min

    From Denver, by I-70 and US-191; miles are approximate.

    A story written for your route, a chapter a leg, with the pass, the canyon and the arches in it, read aloud in the car by a voice made on your phone. See the recipe

  4. 32Incident runbooks and a postmortem libraryWork and teams

    First checks for LedgerQueueLagHigh, in order

    Check the database pool first: at 100 per cent, escalate without restarting. Otherwise check the consumer is running and restart it if not. If a deploy went out in the last hour, ask its owner to roll back. If not, watch the lag for 20 minutes and escalate if it is still rising.

    • LedgerQueueLagHigh pages
      • Leads to Pool at 100%?
    • Pool at 100%?
      • Leads to Escalate, yes
      • Leads to Consumer up?, no
    • Consumer up?
      • Leads to Restart the consumer, no
      • Leads to Deploy this hour?, yes
    • Restart the consumer
    • Deploy this hour?
      • Leads to Ask for a rollback, yes
      • Leads to Watch for 20 minutes, no
    • Ask for a rollback
    • Watch for 20 minutes
      • Leads to Escalate, still rising
    • Escalate

    Runbooks named exactly as your alerts page, first checks drawn as a flowchart, and every postmortem linked to the runbook that should have caught it. See the recipe

  5. 17A course syllabus and course bookTeaching and courses

    SOC 101, autumn 2026: units, reading responses and exams

    Sixteen weeks from 24 August to 11 December. Foundations runs for weeks 1 to 3, research methods weeks 4 and 5, theoretical perspectives weeks 6 and 7, communities weeks 9 to 11 and inequality and power weeks 12 to 15. Reading responses are due on Fridays 4, 11 and 25 September and 20 November. The midterm is on Thursday 15 October, the community observation paper is due Thursday 5 November, and the final exam is on Thursday 10 December. There is no class on Thursday 26 November, Thanksgiving.

    TaskSectionStartsEndsStatus
    Thanksgiving, no class2026-11-260 daysMarker
    FoundationsUnits2026-08-242026-09-11
    Research methodsUnits2026-09-142026-09-25
    Theoretical perspectivesUnits2026-09-282026-10-09
    CommunitiesUnits2026-10-192026-11-06
    Inequality and powerUnits2026-11-092026-12-04
    Response 1Reading responses2026-09-040 daysMilestone
    Response 2Reading responses2026-09-110 daysMilestone
    Response 3Reading responses2026-09-250 daysMilestone
    Response 4Reading responses2026-11-200 daysMilestone
    MidtermExams and the paper2026-10-150 daysMilestone
    Observation paper dueExams and the paper2026-11-050 daysMilestone
    Final examExams and the paper2026-12-100 daysMilestone

    Weeks start on Monday. End dates are the last day of the task.

    The syllabus and the weekly course book in one document: the term on a Gantt chart, every objective checked against an assessment, one link for students. See the recipe

  6. 24A chemistry lab manualLabs and workshops
    An acid–base titration

    Pick the acid, its concentration and volume, and the sodium hydroxide; then add titrant a drop or a few millilitres at a time. The pH is solved exactly from the charge balance, and the note beside it shows the by-hand route for the region you are in. It assumes 25 °C (Kw = 1.0 × 10⁻¹⁴), ideal dilute solutions, one proton per acid and no carbon dioxide from the air; indicator ranges are the usual data-book figures, and an indicator is suggested when its mid-colour falls within 0.10 mL of equivalence.

    <div class="tit">
      <div class="setup">
        <label class="wide">Acid in the flask
          <select id="acid">
            <option value="hcl">Hydrochloric acid, strong</option>
            <option value="ethanoic" selected>Ethanoic (acetic) acid, weak, pKa 4.76</option>
            <option value="methanoic">Methanoic (formic) acid, weak, pKa 3.75</option>
          </select>
        </label>
        <label>Acid concentration
          <select id="ca"><option value="0.05">0.050 mol/L</option><option value="0.1" selected>0.100 mol/L</option><option value="0.2">0.200 mol/L</option></select>
        </label>
        <label>Acid volume, by pipette
          <select id="va"><option value="10">10.00 mL</option><option value="20">20.00 mL</option><option value="25" selected>25.00 mL</option></select>
        </label>
        <label>NaOH in the burette
          <select id="cb"><option value="0.05">0.050 mol/L</option><option value="0.1" selected>0.100 mol/L</option><option value="0.2">0.200 mol/L</option></select>
        </label>
      </div>
    
      <svg id="plot" role="img" aria-label="Titration curve"></svg>
    
      <div class="burette">
        <label class="slide"><span>NaOH added <b id="vbOut">0.00 mL</b></span>
          <input id="vb" type="range" min="0" max="50" step="0.05" value="0" aria-describedby="say">
        </label>
        <div class="adds" role="group" aria-label="Add sodium hydroxide from the burette">
          <button type="button" data-add="0.05">One drop</button>
          <button type="button" data-add="0.5">+0.5 mL</button>
          <button type="button" data-add="1">+1 mL</button>
          <button type="button" data-add="5">+5 mL</button>
          <label class="check"><input type="checkbox" id="ahead" checked> Show the curve to come</label>
          <button type="button" id="again">Start again</button>
        </div>
      </div>
    
      <dl class="figures">
        <div><dt>pH now</dt><dd id="pOut">–</dd></div>
        <div><dt>Equivalence point</dt><dd id="eqOut">–</dd></div>
        <div><dt>Half-equivalence</dt><dd id="halfOut">–</dd></div>
        <div><dt>Indicator</dt><dd id="indOut">–</dd></div>
      </dl>
      <p class="say" id="say" aria-live="polite"></p>
      <p class="ind" id="ind" aria-live="polite"></p>
    </div>
    .tit { font-family: var(--sans); }
    .setup { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px 16px; }
    .setup .wide { grid-column: 1 / -1; }
    .setup label, .slide { display: grid; gap: 3px; }
    .setup select { width: 100%; min-height: 36px; }
    #plot { display: block; width: 100%; margin-top: 12px; overflow: visible; }
    .axis { stroke: var(--ink); stroke-width: 1; fill: none; }
    .grid { stroke: var(--rule); stroke-width: 1; }
    .tick { font: 400 11px var(--sans); fill: var(--ink-3); font-variant-numeric: tabular-nums; }
    .atitle { font: 400 11.5px var(--sans); fill: var(--ink-2); }
    .band { fill: var(--wash); }
    .bandlab { font: italic 400 11.5px var(--serif); fill: var(--ink-2); }
    .ahead { fill: none; stroke: var(--ink-3); stroke-width: 1.2; stroke-dasharray: 3 3; }
    .done { fill: none; stroke: var(--ink); stroke-width: 2.2; stroke-linejoin: round; }
    .mark { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; }
    .now { fill: var(--ink); }
    .guide { stroke: var(--ink-2); stroke-width: 1; stroke-dasharray: 1 3; }
    .marklab { font: 500 11.5px var(--sans); fill: var(--ink); }
    .halo { stroke: var(--paper); stroke-width: 4px; stroke-linejoin: round; paint-order: stroke; }
    .burette { margin-top: 6px; }
    .slide b { font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; }
    .slide input { width: 100%; }
    .adds { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
    .adds button { font: 500 13.5px/1 var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--ink); padding: 9px 11px; min-height: 38px; }
    .adds button:active { background: var(--ink); color: var(--paper); }
    .adds #again { margin-left: auto; border-color: var(--rule); color: var(--ink-2); }
    .check { display: flex; align-items: center; gap: 6px; margin-left: 6px; }
    .figures { display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); gap: 0 18px; margin: 12px 0 0; border-top: 1px solid var(--ink); }
    .figures div { padding: 6px 0; border-bottom: 1px solid var(--rule); }
    .figures dt { font: 400 12.5px/1.3 var(--sans); color: var(--ink-3); }
    .figures dd { margin: 2px 0 0; font: 400 16px/1.3 var(--serif); color: var(--ink); font-variant-numeric: tabular-nums; }
    .say, .ind { margin: 8px 0 0; font: 400 15px/1.45 var(--serif); color: var(--ink); }
    .ind { color: var(--ink-2); }
    .say i, .ind i { font-style: italic; }
    button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
    @media (max-width: 420px) { .adds #again { margin-left: 0; } }
    (function () {
      var $ = function (id) { return document.getElementById(id); };
      var svg = $('plot');
      var KW = 1e-14, DROP = 0.05, NEAR = 0.1, BURETTE = 50;
      var ACIDS = {
        hcl: { name: 'hydrochloric acid', strong: true, salt: 'sodium chloride' },
        ethanoic: { name: 'ethanoic acid', pKa: 4.76, ion: 'ethanoate', salt: 'sodium ethanoate' },
        methanoic: { name: 'methanoic acid', pKa: 3.75, ion: 'methanoate', salt: 'sodium methanoate' }
      };
      // Transition ranges as school data books give them, with the colour on each side.
      var INDICATORS = [
        { name: 'Methyl orange', lo: 3.1, hi: 4.4, acid: 'red', base: 'yellow' },
        { name: 'Methyl red', lo: 4.4, hi: 6.2, acid: 'red', base: 'yellow' },
        { name: 'Bromothymol blue', lo: 6.0, hi: 7.6, acid: 'yellow', base: 'blue' },
        { name: 'Phenolphthalein', lo: 8.2, hi: 10.0, acid: 'colourless', base: 'pink' },
        { name: 'Thymolphthalein', lo: 9.3, hi: 10.5, acid: 'colourless', base: 'blue' }
      ];
      var s = { acid: 'ethanoic', ca: 0.1, va: 25, cb: 0.1, vb: 0, ahead: true };
      var cache = null;
    
      function acid() { return ACIDS[s.acid]; }
      function ka() { var a = acid(); return a.strong ? Infinity : Math.pow(10, -a.pKa); }
      function veq() { return s.ca * s.va / s.cb; }
      function f2(x) { return (Math.round(x * 100) / 100).toFixed(2); }
      function mmol(x) { return x < 0.1 ? x.toFixed(3) : f2(x); }
    
      // The exact pH, ignoring activities: solve the charge balance
      // [H+] + [Na+] = [OH-] + [A-], with [A-] = C·Ka/(Ka + [H+]) for a weak acid
      // and [A-] = C for a strong one. The left side minus the right rises with
      // [H+], so bisection on pH always finds the one root.
      function pHAt(vb) {
        var vt = s.va + vb, c = s.ca * s.va / vt, na = s.cb * vb / vt, k = ka(), lo = -2, hi = 16;
        for (var i = 0; i < 70; i++) {
          var mid = (lo + hi) / 2, h = Math.pow(10, -mid);
          var a = k === Infinity ? c : c * k / (k + h);
          if (h + na - KW / h - a > 0) lo = mid; else hi = mid;
        }
        return (lo + hi) / 2;
      }
    
      function sci(x) {
        if (x >= 0.001) return x.toPrecision(3);
        var e = Math.floor(Math.log10(x)), m = x / Math.pow(10, e);
        if (m >= 9.995) { m /= 10; e += 1; }
        var sup = { '-': '⁻', '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹' };
        return m.toFixed(2) + ' × 10' + String(e).split('').map(function (ch) { return sup[ch]; }).join('');
      }
    
      // The route a student takes by hand in each region, and its answer.
      function byHand(vb) {
        var a = acid(), nA = s.ca * s.va, nB = s.cb * vb, vt = s.va + vb, rel = (nB - nA) / nA, excess, conc, p;
        var tot = 'total volume ' + f2(vt) + ' mL';
        if (Math.abs(rel) < 1e-9) {
          if (a.strong) return { p: 7, text: '<b>At equivalence.</b> The flask holds only ' + a.salt + ' and water, a neutral salt, so pH = 7.00.' };
          var c = nA / vt, kb = KW / ka(), oh = Math.sqrt(kb * c);
          p = 14 + Math.log10(oh);
          return { p: p, text: '<b>At equivalence.</b> Every acid molecule has become ' + a.ion + ', a weak base that takes H⁺ from water. By hand: K<sub>b</sub> = K<sub>w</sub> ÷ K<sub>a</sub> = ' + sci(kb) + ', c = ' + mmol(nA) + ' mmol ÷ ' + f2(vt) + ' mL = ' + sci(c) + ' mol/L, [OH⁻] = √(K<sub>b</sub>·c) = ' + sci(oh) + ', so pH = 14 − pOH = ' + f2(p) + '.' };
        }
        if (nB > nA) {
          excess = nB - nA; conc = excess / vt; p = 14 + Math.log10(conc);
          return { p: p, text: '<b>After equivalence.</b> Excess sodium hydroxide sets the pH' + (a.strong ? '' : ' and swamps the ' + a.ion) + '. By hand: [OH⁻] = ' + mmol(excess) + ' mmol ÷ ' + f2(vt) + ' mL = ' + sci(conc) + ' mol/L, so pH = 14 − pOH = ' + f2(p) + '.' };
        }
        if (a.strong) {
          excess = nA - nB; conc = excess / vt; p = -Math.log10(conc);
          return { p: p, text: (vb === 0 ? '<b>Acid alone.</b> Hydrochloric acid ionises completely' : '<b>Before equivalence.</b> Hydroxide has neutralised part of the acid; the rest ionises completely') + '. By hand: [H⁺] = ' + mmol(excess) + ' mmol ÷ ' + f2(vt) + ' mL = ' + sci(conc) + ' mol/L, so pH = ' + f2(p) + '.' };
        }
        if (vb === 0) {
          p = 0.5 * (a.pKa - Math.log10(s.ca));
          return { p: p, text: '<b>Acid alone.</b> A weak acid ionises only a little. By hand: [H⁺] ≈ √(K<sub>a</sub>·c), so pH ≈ ½(pK<sub>a</sub> − log c) = ½(' + a.pKa.toFixed(2) + ' − log ' + s.ca.toFixed(3) + ') = ' + f2(p) + '.' };
        }
        p = a.pKa + Math.log10(nB / (nA - nB));
        return { p: p, text: '<b>Buffer region.</b> The flask holds ' + a.name + ' and ' + a.ion + ' together. By hand (Henderson–Hasselbalch): pH = pK<sub>a</sub> + log(' + a.ion + ' ÷ acid) = ' + a.pKa.toFixed(2) + ' + log(' + mmol(nB) + ' mmol ÷ ' + mmol(nA - nB) + ' mmol) = ' + f2(p) + '.' };
      }
    
      function setup() {
        var v = veq(), a = acid(), xmax = Math.max(10, Math.ceil(2 * v / 5) * 5), xs = [], i, d;
        for (i = 0; i <= 320; i++) xs.push(xmax * i / 320);
        [1e-5, 3e-5, 1e-4, 3e-4, 1e-3, 2e-3, 5e-3, 1e-2, 2e-2, 4e-2, 7e-2].forEach(function (d) { xs.push(v * (1 - d)); xs.push(v * (1 + d)); xs.push(v * d); });
        xs.push(v);
        xs = xs.filter(function (x) { return x >= 0 && x <= xmax; }).sort(function (p, q) { return p - q; });
        var pts = xs.map(function (x) { return [x, pHAt(x)]; });
        // An indicator suits the titration when its mid-colour is reached within
        // 0.10 mL (two drops) either side of equivalence.
        var lo = pHAt(Math.max(0, v - NEAR)), hi = pHAt(v + NEAR), pe = pHAt(v);
        var mid = function (n) { return (n.lo + n.hi) / 2; };
        var fits = INDICATORS.filter(function (n) { return mid(n) >= lo && mid(n) <= hi; });
        var closest = function (list) { return list.slice().sort(function (p, q) { return Math.abs((p.lo + p.hi) / 2 - pe) - Math.abs((q.lo + q.hi) / 2 - pe); })[0]; };
        var pick = fits.length ? closest(fits) : closest(INDICATORS);
        cache = { v: v, xmax: xmax, pts: pts, jump: [lo, hi], pe: pe, fits: fits, pick: pick, half: a.strong ? null : pHAt(v / 2) };
        var slider = $('vb'); slider.max = xmax;
        if (s.vb > xmax) s.vb = xmax;
      }
    
      function draw() {
        var c = cache, a = acid();
        var W = Math.max(260, Math.round(svg.getBoundingClientRect().width) || 588), H = W < 440 ? 230 : 252;
        var L = 30, R = 12, T = 12, B = 34, pw = W - L - R, ph = H - T - B;
        var X = function (v) { return L + v / c.xmax * pw; };
        var Y = function (p) { return T + (14 - Math.max(0, Math.min(14, p))) / 14 * ph; };
        var out = [], i;
        svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H); svg.setAttribute('height', H);
    
        var n = c.pick;
        out.push('<rect class="band" x="' + L + '" y="' + Y(n.hi) + '" width="' + pw + '" height="' + (Y(n.lo) - Y(n.hi)) + '"/>');
        for (i = 0; i <= 14; i += 2) {
          out.push('<line class="grid" x1="' + L + '" x2="' + (L + pw) + '" y1="' + Y(i) + '" y2="' + Y(i) + '"/>');
          out.push('<text class="tick" x="' + (L - 6) + '" y="' + (Y(i) + 4) + '" text-anchor="end">' + i + '</text>');
        }
        var steps = [1, 2, 5, 10, 20, 25, 50], step = steps.filter(function (st) { return c.xmax / st <= (W < 440 ? 5 : 8); })[0] || 50;
        for (var x = 0; x <= c.xmax + 1e-9; x += step) {
          out.push('<line class="axis" x1="' + X(x) + '" x2="' + X(x) + '" y1="' + (T + ph) + '" y2="' + (T + ph + 4) + '"/>');
          out.push('<text class="tick" x="' + X(x) + '" y="' + (T + ph + 16) + '" text-anchor="middle">' + x + '</text>');
        }
        out.push('<path class="axis" d="M' + L + ' ' + T + 'V' + (T + ph) + 'H' + (L + pw) + '"/>');
        out.push('<text class="atitle" x="' + (L + pw) + '" y="' + (H - 2) + '" text-anchor="end">NaOH added, mL</text>');
        out.push('<text class="atitle" x="' + (L + 4) + '" y="' + (T + 10) + '">pH</text>');
        out.push('<text class="bandlab halo" x="' + (L + 6) + '" y="' + (Y(n.hi) - 4) + '">' + n.name.toLowerCase() + ' changes colour</text>');
    
        var line = function (pts) { return pts.map(function (p, j) { return (j ? 'L' : 'M') + X(p[0]).toFixed(1) + ' ' + Y(p[1]).toFixed(1); }).join(''); };
        var pNow = pHAt(s.vb);
        var done = c.pts.filter(function (p) { return p[0] < s.vb; }).concat([[s.vb, pNow]]);
        var rest = [[s.vb, pNow]].concat(c.pts.filter(function (p) { return p[0] > s.vb; }));
        if (s.ahead && rest.length > 1) out.push('<path class="ahead" d="' + line(rest) + '"/>');
        if (done.length > 1) out.push('<path class="done" d="' + line(done) + '"/>');
    
        var ex = X(c.v), ey = Y(c.pe), right = ex < L + pw * 0.62;
        out.push('<line class="guide" x1="' + ex + '" x2="' + ex + '" y1="' + T + '" y2="' + (T + ph) + '"/>');
        out.push('<circle class="mark" cx="' + ex + '" cy="' + ey + '" r="4.5"/>');
        out.push('<text class="marklab halo" x="' + (ex + (right ? 7 : -7)) + '" y="' + (T + 12) + '" text-anchor="' + (right ? 'start' : 'end') + '">Equivalence</text>');
        if (c.half !== null) {
          var hx = X(c.v / 2), hy = Y(c.half);
          out.push('<line class="guide" x1="' + L + '" x2="' + hx + '" y1="' + hy + '" y2="' + hy + '"/>');
          out.push('<circle class="mark" cx="' + hx + '" cy="' + hy + '" r="4.5"/>');
          out.push('<text class="marklab halo" x="' + (hx + 7) + '" y="' + (hy + 16) + '">Half-equivalence: pH = pKa</text>');
        }
        out.push('<circle class="now" cx="' + X(s.vb) + '" cy="' + Y(pNow) + '" r="5"/>');
        svg.innerHTML = out.join('');
        svg.setAttribute('aria-label', 'Titration curve of pH against NaOH added, 0 to ' + c.xmax + ' mL, for ' + s.ca.toFixed(3) + ' mol/L ' + a.name + '. It rises steeply at the equivalence point, ' + f2(c.v) + ' mL, pH ' + f2(c.pe) + '. Now at ' + f2(s.vb) + ' mL, pH ' + f2(pNow) + '.');
        return pNow;
      }
    
      function colourIn(n, p) {
        if (p < n.lo) return n.acid;
        if (p > n.hi) return n.base;
        return 'changing, between ' + n.acid + ' and ' + n.base;
      }
    
      function render() {
        var c = cache, a = acid(), p = draw(), hand = byHand(s.vb), n = c.pick;
        $('vb').value = s.vb;
        $('vbOut').textContent = f2(s.vb) + ' mL';
        $('pOut').textContent = f2(p);
        $('eqOut').textContent = f2(c.v) + ' mL, pH ' + f2(c.pe);
        $('halfOut').textContent = c.half === null ? 'None: strong acid' : f2(c.v / 2) + ' mL, pH ' + f2(c.half);
        $('indOut').textContent = n.name + ', ' + n.lo.toFixed(1) + '–' + n.hi.toFixed(1);
        var say = hand.text;
        if (Math.abs(hand.p - p) >= 0.05) say += ' <i>The shortcut is off by ' + Math.abs(hand.p - p).toFixed(2) + ' here: so close to the start or to equivalence it leaves out the acid’s own ionisation or water’s. The exact pH, ' + f2(p) + ', allows for both.</i>';
        if (c.v > BURETTE) say += ' <i>Equivalence needs more than one 50 mL burette: take a smaller aliquot of acid or a stronger NaOH.</i>';
        $('say').innerHTML = say;
        var others = c.fits.filter(function (m) { return m !== n; }).map(function (m) { return m.name.toLowerCase(); });
        var why = c.fits.length
          ? n.name + ' suits it best: its mid-colour, pH ' + ((n.lo + n.hi) / 2).toFixed(1) + ', is nearest the equivalence pH and falls inside the jump, where 0.10 mL (two drops) either side of equivalence takes the pH from ' + f2(c.jump[0]) + ' to ' + f2(c.jump[1]) + '.' + (others.length ? ' Also inside it: ' + others.join(', ') + '.' : '')
          : 'No common indicator reaches its mid-colour within 0.10 mL of equivalence (pH ' + f2(c.jump[0]) + ' to ' + f2(c.jump[1]) + '); ' + n.name.toLowerCase() + ' comes closest. Add the last millilitre drop by drop, or use a pH meter.';
        $('ind').textContent = why + ' In the flask now: ' + colourIn(n, p) + '.';
      }
    
      function set(v) { s.vb = Math.max(0, Math.min(cache.xmax, Math.round(v / DROP) * DROP)); s.vb = Math.round(s.vb * 100) / 100; render(); }
    
      ['acid', 'ca', 'va', 'cb'].forEach(function (id) {
        $(id).addEventListener('change', function () {
          s[id] = id === 'acid' ? this.value : Number(this.value);
          s.vb = 0; setup(); render();
        });
      });
      $('vb').addEventListener('input', function () { set(Number(this.value)); });
      document.querySelectorAll('[data-add]').forEach(function (b) {
        b.addEventListener('click', function () { set(s.vb + Number(b.getAttribute('data-add'))); });
      });
      $('again').addEventListener('click', function () { set(0); });
      $('ahead').addEventListener('change', function () { s.ahead = this.checked; render(); });
      document.addEventListener('know-tokens', render);
      var lastW = 0;
      if (window.ResizeObserver) new ResizeObserver(function (en) { var w = Math.round(en[0].contentRect.width); if (w !== lastW) { lastW = w; requestAnimationFrame(render); } }).observe(svg.parentNode);
      else window.addEventListener('resize', render);
    
      setup(); render();
    })();

    Every practical in the same six parts, every hazard checked against your risk assessment, and a titration model the class watches jump at equivalence. See the recipe

  7. 27A mechanical engineering design notebookLabs and workshops
    A disc brake caliper, in section

    Drag the pedal and watch the pads close on the disc; switch between a floating and a fixed caliper, wear the pads down, and step through what happens from pedal to release. Clearances are drawn ten times larger than life; the figures assume a 4:1 pedal, a 22 mm master cylinder, a 0.4 pad coefficient and a 1,400 kg car.

    <div class="brake">
      <svg viewBox="0 0 660 330" role="img" aria-labelledby="brake-desc">
        <desc id="brake-desc">Cross-section of a disc brake caliper beside a face-on view of the disc. The labels and figures below it describe the same state in words.</desc>
        <defs>
          <pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="6" class="hatch"/></pattern>
          <pattern id="fluid" width="6" height="6" patternUnits="userSpaceOnUse"><circle cx="3" cy="3" r="1" class="dot"/></pattern>
          <pattern id="friction" width="4" height="4" patternUnits="userSpaceOnUse"><rect width="4" height="4" class="grit"/><circle cx="1" cy="1" r=".8" class="dot"/><circle cx="3" cy="3" r=".8" class="dot"/></pattern>
          <marker id="head" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" class="headfill"/></marker>
        </defs>
    
        <g data-part="bracket">
          <rect x="150" y="10" width="300" height="8" class="fixed"/>
          <line x1="170" y1="14" x2="430" y2="14" class="pin"/>
          <text x="155" y="34" class="lab">Bracket and slide pins, bolted to the knuckle</text>
        </g>
    
        <g id="body" data-part="body">
          <path id="bodyShape" class="body" fill="url(#hatch)"/>
        </g>
    
        <g data-part="fluid"><rect id="fluid" y="138" height="64" fill="url(#fluid)" class="fluidbox"/></g>
        <g data-part="fluid"><path id="hose" class="hose"/></g>
    
        <g data-part="piston"><rect id="pistonL" y="140" height="60" class="piston"/><rect id="pistonR" y="140" height="60" class="piston"/></g>
        <g data-part="seal"><path id="sealL" class="seal"/><path id="sealR" class="seal"/></g>
    
        <g data-part="pads">
          <rect id="padInBack" y="112" width="6" height="116" class="backing"/>
          <rect id="padIn" y="118" height="104" fill="url(#friction)" class="padface"/>
          <rect id="padOutBack" y="112" width="6" height="116" class="backing"/>
          <rect id="padOut" y="118" height="104" fill="url(#friction)" class="padface"/>
        </g>
    
        <g data-part="disc">
          <rect x="318" y="60" width="24" height="230" class="disc"/>
          <text x="330" y="310" text-anchor="middle" class="lab">Disc</text>
        </g>
    
        <g id="arrows" class="arrows">
          <line id="clampIn" y1="170" y2="170" marker-end="url(#head)"/>
          <line id="clampOut" y1="170" y2="170" marker-end="url(#head)"/>
        </g>
    
        <text x="200" y="92" class="lab">Caliper body</text>
        <text x="372" y="256" class="lab">Pads</text>
        <text x="36" y="306" class="lab">Inboard (car side)</text>
        <text x="420" y="306" class="lab" text-anchor="end">Outboard</text>
        <text x="40" y="258" class="lab">Piston and square seal</text>
    
        <g transform="translate(560 170)">
          <g id="rotor" data-part="disc">
            <circle r="84" class="discface"/>
            <circle r="30" class="hub"/>
            <g id="vents"></g>
          </g>
          <path d="M-34 -96 A100 100 0 0 1 34 -96 L26 -70 A74 74 0 0 0 -26 -70 Z" class="padarc" fill="url(#friction)" data-part="pads"/>
          <line id="frictionArrow" x1="-40" y1="-110" x2="40" y2="-110" marker-end="url(#head)" class="arrow"/>
          <text y="120" text-anchor="middle" class="lab">Face on</text>
        </g>
      </svg>
    
      <div class="controls">
        <label>Pedal force <b id="pedalOut">0 N</b><input id="pedal" type="range" min="0" max="400" step="5" value="0"></label>
        <label>Pad left <b id="wearOut">10 mm</b><input id="wear" type="range" min="2" max="10" step="0.5" value="10"></label>
        <div class="switches">
          <button type="button" id="kind" aria-pressed="false">Fixed caliper</button>
          <button type="button" id="boost" aria-pressed="true">Booster on</button>
          <button type="button" id="drive">Drive at 100 km/h</button>
        </div>
      </div>
    
      <dl class="figures">
        <div><dt>Line pressure</dt><dd id="pOut">0 bar</dd></div>
        <div><dt>Clamp force</dt><dd id="fOut">0 kN</dd></div>
        <div><dt>Friction at the pads</dt><dd id="frOut">0 kN</dd></div>
        <div><dt>Brake torque, one wheel</dt><dd id="tOut">0 N·m</dd></div>
        <div><dt>Car</dt><dd id="vOut">100 km/h</dd></div>
      </dl>
      <p class="say" id="say" aria-live="polite"></p>
    
      <div class="walk">
        <button type="button" id="prev">Back</button>
        <span id="stepNo">Walk through</span>
        <button type="button" id="next">Next</button>
      </div>
      <p class="step" id="stepText"></p>
    </div>
    .brake svg { display: block; width: 100%; height: auto; }
    .brake text { font: 400 11px var(--sans); fill: var(--ink-2); }
    .lab { font-size: 11px; }
    .hatch { stroke: var(--ink-3); stroke-width: 1; }
    .dot { fill: var(--ink-2); }
    .grit { fill: var(--wash); }
    .headfill { fill: var(--ink); }
    .fixed { fill: var(--wash); stroke: var(--ink); stroke-width: 1; }
    .pin { stroke: var(--ink); stroke-width: 3; }
    .body { stroke: var(--ink); stroke-width: 1.5; }
    .fluidbox { stroke: none; }
    .hose { fill: none; stroke: var(--ink); stroke-width: 6; }
    .piston { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; }
    .seal { fill: var(--ink); }
    .backing { fill: var(--ink); }
    .padface { stroke: var(--ink); stroke-width: 1; }
    .disc { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; }
    .discface { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; }
    .hub { fill: var(--wash); stroke: var(--ink); stroke-width: 1; }
    .vent { stroke: var(--ink-3); stroke-width: 1.5; }
    .padarc { stroke: var(--ink); stroke-width: 1; }
    .arrows line, .arrow { stroke: var(--ink); stroke-width: 2; }
    [data-part] { transition: opacity 120ms; }
    .brake.focus [data-part] { opacity: .22; }
    .brake.focus [data-part].on { opacity: 1; }
    .controls { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px; margin-top: 10px; font: 400 14px/1.3 var(--sans); }
    .controls label { display: grid; gap: 4px; }
    .controls b { font-weight: 600; font-variant-numeric: tabular-nums; }
    .controls input { width: 100%; accent-color: var(--ink); }
    .switches { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; }
    button { font: 500 14px/1 var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--ink); border-radius: 0; padding: 10px 12px; min-height: 40px; cursor: pointer; }
    button[aria-pressed='true'] { background: var(--ink); color: var(--paper); }
    button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
    .figures { display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); gap: 0 18px; margin: 14px 0 0; border-top: 1px solid var(--ink); }
    .figures div { padding: 7px 0; border-bottom: 1px solid var(--rule); }
    .figures dt { font: 400 12.5px/1.3 var(--sans); color: var(--ink-3); }
    .figures dd { margin: 2px 0 0; font: 400 20px/1.2 var(--serif); font-variant-numeric: tabular-nums; }
    .say { min-height: 1.4em; margin: 8px 0 0; font: italic 400 16px/1.45 var(--serif); }
    .walk { display: flex; align-items: center; gap: 12px; margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--rule); font: 400 13px var(--sans); color: var(--ink-3); }
    .walk span { flex: 1; text-align: center; }
    .step { min-height: 3em; margin: 8px 0 0; font: 400 16px/1.5 var(--serif); }
    @media (max-width: 520px) { .controls { grid-template-columns: 1fr; } }
    @media (prefers-reduced-motion: reduce) { [data-part] { transition: none; } }
    (function () {
      var $ = function (id) { return document.getElementById(id); };
      var root = document.querySelector('.brake');
    
      // Millimetres to drawing units; the running clearance is drawn ten times larger than life.
      var MM = 2.2, CLEAR = 0.15 * 10 * MM, DISC_L = 318, DISC_R = 342, BACK = 6;
      var PEDAL_RATIO = 4, BOOST = 3, MC_BORE = 22, MU = 0.4, R_EFF = 0.13, TYRE_R = 0.31, MASS = 1400, REAR_SHARE = 0.6, GRIP = 9.81;
      var CALIPERS = {
        floating: { name: 'floating', pistonBore: 54, pistons: 1 },
        fixed: { name: 'fixed', pistonBore: 40, pistons: 2 }
      };
      var state = { pedal: 0, wear: 10, kind: 'floating', boost: true, v: 100, step: -1 };
      var area = function (d) { return Math.PI * d * d / 4; };
    
      function physics() {
        var cal = CALIPERS[state.kind];
        var force = state.pedal * PEDAL_RATIO * (state.boost ? BOOST : 1);
        var p = force / area(MC_BORE);                                  // N/mm² = MPa
        var pistonArea = area(cal.pistonBore) * cal.pistons;            // per side, mm²
        var clamp = p > 0.05 ? p * pistonArea : 0;                      // N, on each pad
        var friction = 2 * MU * clamp;                                  // two faces
        var torque = friction * R_EFF;                                  // N·m
        var decel = torque * 2 * (1 + REAR_SHARE) / (TYRE_R * MASS);    // m/s², front pair plus a lighter rear pair
        return { p: p, clamp: clamp, friction: friction, torque: torque, decel: decel, locked: decel > GRIP };
      }
    
      function layout(ph) {
        var fixed = state.kind === 'fixed';
        var pad = state.wear * MM, contact = ph.clamp > 0 ? 1 : 0, wearTake = (10 - state.wear) * MM;
        var inFace = DISC_L - CLEAR * (1 - contact);                    // inner pad's friction face
        var outFace = DISC_R + CLEAR * (1 - contact);
        var padInX = inFace - pad, backInX = padInX - BACK;
        var padOutX = outFace, backOutX = padOutX + pad;
        // A floating body is pulled inboard by as much as the outer pad needs; a fixed body never moves.
        var shift = fixed ? 0 : -(wearTake + CLEAR * contact);
        $('padIn').setAttribute('x', padInX); $('padIn').setAttribute('width', pad);
        $('padInBack').setAttribute('x', backInX);
        $('padOut').setAttribute('x', padOutX); $('padOut').setAttribute('width', pad);
        $('padOutBack').setAttribute('x', backOutX);
    
        var leftWall = 175 + shift, rightWall = backOutX + BACK;
        var pistonX = Math.min(leftWall + 40, backInX - 70);
        $('pistonL').setAttribute('x', pistonX); $('pistonL').setAttribute('width', backInX - pistonX);
        var fluidX = leftWall + 12, fluidW = Math.max(4, pistonX - fluidX);
        $('fluid').setAttribute('x', fluidX); $('fluid').setAttribute('width', fluidW);
    
        var body;
        if (fixed) {
          var rWall = backOutX + BACK + 70;
          $('pistonR').style.display = '';
          $('pistonR').setAttribute('x', backOutX + BACK); $('pistonR').setAttribute('width', rWall - 12 - (backOutX + BACK));
          body = 'M' + leftWall + ' 100 H' + (backInX - 4) + ' V60 H' + (backOutX + BACK + 4) + ' V100 H' + rWall + ' V240 H' + (backOutX + BACK + 4) + ' V230 H' + (backOutX + BACK) + ' V110 H' + (backInX) + ' V230 H' + (backInX - 4) + ' V240 H' + leftWall + ' Z';
        } else {
          $('pistonR').style.display = 'none';
          body = 'M' + leftWall + ' 100 H' + (backInX - 4) + ' V60 H' + (rightWall + 26) + ' V240 H' + rightWall + ' V112 H' + (backInX) + ' V230 H' + (backInX - 4) + ' V240 H' + leftWall + ' Z';
        }
        $('bodyShape').setAttribute('d', body);
        var sealX = pistonX + 18, tilt = contact ? 4 : 0;
        $('sealL').setAttribute('d', 'M' + sealX + ' 134 l8 ' + (-tilt) + ' v6 l-8 ' + tilt + ' z M' + sealX + ' 206 l8 ' + tilt + ' v-6 l-8 ' + (-tilt) + ' z');
        $('sealR').setAttribute('d', fixed ? 'M' + (backOutX + BACK + 40) + ' 134 l-8 ' + (-tilt) + ' v6 l8 ' + tilt + ' z' : '');
        $('hose').setAttribute('d', 'M20 170 H' + (fluidX + 2));
    
        var len = Math.min(70, 8 + ph.clamp / 500);
        var show = ph.clamp > 0 ? '' : 'none';
        $('clampIn').style.display = show; $('clampOut').style.display = show;
        $('clampIn').setAttribute('x1', backInX - len - 8); $('clampIn').setAttribute('x2', backInX - 6);
        $('clampOut').setAttribute('x1', backOutX + BACK + len + 30); $('clampOut').setAttribute('x2', backOutX + BACK + 24);
        $('frictionArrow').style.display = ph.clamp > 0 && state.v > 0 ? '' : 'none';
      }
    
      function fmt(n, d) { return n.toLocaleString('en-GB', { maximumFractionDigits: d, minimumFractionDigits: d }); }
    
      function figures(ph) {
        $('pedalOut').textContent = state.pedal + ' N';
        $('wearOut').textContent = fmt(state.wear, 1).replace('.0', '') + ' mm';
        $('pOut').textContent = fmt(ph.p * 10, 0) + ' bar';
        $('fOut').textContent = fmt(ph.clamp / 1000, 1) + ' kN';
        $('frOut').textContent = fmt(ph.friction / 1000, 1) + ' kN';
        $('tOut').textContent = fmt(ph.torque, 0) + ' N·m';
        $('vOut').textContent = fmt(state.v, 0) + ' km/h';
        var msg = '';
        if (state.v <= 0 && state.pedal > 0) msg = 'Stopped.';
        else if (ph.locked && state.v > 0) msg = 'The brakes ask for ' + fmt(ph.decel / 9.81, 1) + ' g; the tyres give about 1 g. Without ABS the wheels lock and slide.';
        else if (ph.clamp > 0) msg = 'Slowing at ' + fmt(ph.decel / 9.81, 2) + ' g.';
        else if (state.v > 0) msg = 'Rolling. Press the pedal.';
        $('say').textContent = msg;
      }
    
      var STEPS = [
        ['bracket', 'The bracket is bolted to the steering knuckle and never moves. The caliper body rides on two greased slide pins fixed to it.'],
        ['fluid', 'The pedal pushes the master cylinder. Brake fluid does not compress, so the pressure it makes arrives unchanged in the caliper bore: pressure equals force over the master cylinder\'s area.'],
        ['piston', 'That pressure acts on the piston\'s much larger face. Clamp force is pressure times piston area, which is where the hydraulic advantage comes from: a 54 mm piston has six times the area of a 22 mm master cylinder bore.'],
        ['body', 'In a floating caliper the piston pushes the inner pad onto the disc; the reaction slides the whole body inboard on its pins, pulling the outer pad against the other face. A fixed caliper has pistons on both sides and its body does not move.'],
        ['pads', 'Both pads rub the disc. Friction is twice (two faces) the pad coefficient times the clamp force, and braking torque is that friction times the effective radius, here 130 mm.'],
        ['seal', 'On release, the square-section seal, twisted as the piston moved, springs back and draws the piston a fraction of a millimetre away from the pad. That is the running clearance; as the pads wear the piston slides further through the seal, and the fluid in the bore makes up the difference.']
      ];
    
      function showStep() {
        var s = state.step;
        root.classList.toggle('focus', s >= 0);
        root.querySelectorAll('[data-part]').forEach(function (el) { el.classList.toggle('on', s >= 0 && el.getAttribute('data-part') === STEPS[s][0]); });
        $('stepNo').textContent = s < 0 ? 'Walk through' : 'Step ' + (s + 1) + ' of ' + STEPS.length;
        $('stepText').textContent = s < 0 ? '' : STEPS[s][1];
        $('prev').disabled = s < 0; $('next').textContent = s === STEPS.length - 1 ? 'Done' : 'Next';
      }
    
      function render() { var ph = physics(); layout(ph); figures(ph); return ph; }
    
      var angle = 0, last = 0;
      (function vents() {
        var g = $('vents'), out = '';
        for (var i = 0; i < 12; i++) { var a = i * Math.PI / 6; out += '<line class="vent" x1="' + (Math.cos(a) * 42) + '" y1="' + (Math.sin(a) * 42) + '" x2="' + (Math.cos(a) * 76) + '" y2="' + (Math.sin(a) * 76) + '"/>'; }
        g.innerHTML = out;
      })();
    
      function tick(t) {
        var dt = Math.min(0.05, (t - last) / 1000 || 0); last = t;
        var ph = physics();
        if (state.v > 0 && ph.clamp > 0) state.v = Math.max(0, state.v - Math.min(ph.decel, GRIP) * dt * 3.6);
        var omega = (state.v / 3.6) / TYRE_R;                            // rad/s
        angle = (angle + omega * dt * 57.3 * 0.25) % 360;                // drawn at a quarter speed, so it can be seen
        $('rotor').setAttribute('transform', 'rotate(' + angle + ')');
        figures(ph);
        requestAnimationFrame(tick);
      }
    
      $('pedal').addEventListener('input', function (e) { state.pedal = Number(e.target.value); render(); });
      $('wear').addEventListener('input', function (e) { state.wear = Number(e.target.value); render(); });
      $('kind').addEventListener('click', function () { state.kind = state.kind === 'fixed' ? 'floating' : 'fixed'; this.setAttribute('aria-pressed', String(state.kind === 'fixed')); render(); });
      $('boost').addEventListener('click', function () { state.boost = !state.boost; this.setAttribute('aria-pressed', String(state.boost)); this.textContent = state.boost ? 'Booster on' : 'Booster off'; render(); });
      $('drive').addEventListener('click', function () { state.v = 100; render(); });
      $('next').addEventListener('click', function () { state.step = state.step >= STEPS.length - 1 ? -1 : state.step + 1; showStep(); });
      $('prev').addEventListener('click', function () { state.step = Math.max(-1, state.step - 1); showStep(); });
    
      render(); showStep(); requestAnimationFrame(tick);
    })();

    Pedal to pad in six worked steps, a brake caliper you can squeeze, and every design choice kept with the figures that made it. See the recipe

  8. 39A family cookbookKitchen and table

    Pound cake with lemon glaze · Baking, 7

    Cook: Nana Rose, from 1961

    Pan: Tube pan, by Aunt Jo; two loaf pans, by Pete

    Oven: 325°F (160°C), started cold

    Time: About 90 minutes

    The card: In Rose’s hand, the glaze in pencil; in the box behind the Baking tab

    Contains: Milk, eggs, wheat

    The cold oven is not on the card; see What the card does not say.

    Photograph the recipe box, let your assistant file every card as a recipe, add the stories only the family knows, and send everyone a link. See the recipe

  9. 30A service-mesh workshopLabs and workshops
    Kubernetes and Istio simulator

    Interactive terminal and traffic topology simulator for Kubernetes and Istio mesh diagnostics.

    <div class="kubectl-sim" tabindex="0" aria-label="Kubernetes and Istio cluster simulator">
      <header class="bar">
        <div class="bar-title">
          <span class="cluster-ctx">Cluster <b>mesh-lab</b></span>
          <span class="ns-ctx">Namespace <b id="activeNs">shop</b></span>
        </div>
        <div class="bar-actions">
          <button type="button" id="btnLoad">Load (100 reqs)</button>
          <button type="button" id="btnReset">Reset</button>
          <button type="button" id="btnHelp">Help</button>
        </div>
      </header>
    
      <div class="sim-body">
        <section class="term-pane" aria-label="Terminal">
          <div class="term-log" id="termLog" role="log" aria-live="polite"></div>
          <div class="term-form" id="termForm">
            <label for="termInput" class="term-prompt">$</label>
            <input
              id="termInput"
              class="term-input"
              type="text"
              spellcheck="false"
              autocapitalize="off"
              autocomplete="off"
              autocorrect="off"
              aria-label="kubectl command input"
            />
          </div>
        </section>
    
        <aside class="side-pane">
          <section class="panel exercises-panel">
            <h3 class="panel-title">Guided exercises</h3>
            <ol class="exercise-list" id="exerciseList">
              <li data-step="1"><span class="check" id="chk1">&#9675;</span> <span class="step-text">Enable injection on namespace shop</span></li>
              <li data-step="2"><span class="check" id="chk2">&#9675;</span> <span class="step-text">Restart reviews to inject sidecars (2/2)</span></li>
              <li data-step="3"><span class="check" id="chk3">&#9675;</span> <span class="step-text">Split reviews traffic 50/50 (v2/v3)</span></li>
              <li data-step="4"><span class="check" id="chk4">&#9675;</span> <span class="step-text">Add 2 s delay to ratings (20% reqs)</span></li>
              <li data-step="5"><span class="check" id="chk5">&#9675;</span> <span class="step-text">Turn on strict mTLS</span></li>
            </ol>
            <div class="hint-bar">
              <button type="button" id="btnHint">Hint</button>
              <span class="hint-text" id="hintOutput">Type hint or click for guidance.</span>
            </div>
          </section>
    
          <section class="panel mesh-panel">
            <div class="panel-header">
              <h3 class="panel-title">Traffic topology</h3>
              <span class="mtls-status" id="mtlsStatus">mTLS permissive</span>
            </div>
            <svg class="mesh-svg" id="meshSvg" viewBox="0 0 380 180" role="img" aria-label="Bookinfo service topology diagram"></svg>
            <div class="traffic-stats" id="trafficStats">
              <span class="stat">Total: <b id="statTotal">0</b></span>
              <span class="stat">v1: <b id="statV1">34%</b></span>
              <span class="stat">v2: <b id="statV2">33%</b></span>
              <span class="stat">v3: <b id="statV3">33%</b></span>
              <span class="stat">Ratings: <b id="statRatings">0</b></span>
              <span class="stat">Latency: <b id="statLatency">12 ms</b></span>
            </div>
          </section>
        </aside>
      </div>
    </div>
    .kubectl-sim {
      position: relative;
      outline: none;
      font-family: var(--sans);
      color: var(--ink);
      background: var(--paper);
    }
    .kubectl-sim:focus-visible {
      outline: 1px solid var(--ink);
      outline-offset: 2px;
    }
    .bar {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      padding: 8px 0;
      border-bottom: 1px solid var(--rule);
      background: var(--paper);
      font: 400 13px/1.3 var(--sans);
      color: var(--ink-2);
    }
    .bar b {
      font-weight: 500;
      color: var(--ink);
    }
    .bar-title {
      display: flex;
      align-items: center;
      gap: 16px;
    }
    .bar-actions {
      display: flex;
      gap: 8px;
    }
    .bar-actions button, .hint-bar button {
      min-height: 28px;
      padding: 4px 10px;
      font: 500 13px/1 var(--sans);
      background: var(--paper);
      color: var(--ink);
      border: 1px solid var(--ink);
      border-radius: 0;
      cursor: pointer;
    }
    .bar-actions button:hover, .hint-bar button:hover,
    .bar-actions button:focus-visible, .hint-bar button:focus-visible {
      background: var(--wash);
    }
    .sim-body {
      display: flex;
      flex-direction: column;
      gap: 16px;
      margin-top: 12px;
    }
    @media (min-width: 860px) {
      .sim-body {
        flex-direction: row;
        align-items: flex-start;
      }
      .term-pane {
        flex: 1 1 56%;
      }
      .side-pane {
        flex: 1 1 44%;
      }
    }
    .term-pane {
      display: flex;
      flex-direction: column;
      background: var(--paper);
      border: 1px solid var(--rule);
    }
    .term-log {
      flex: 1;
      max-height: 380px;
      min-height: 240px;
      overflow-y: auto;
      overflow-x: hidden;
      padding: 10px 12px;
      font-family: var(--mono);
      font-size: 12px;
      line-height: 1.45;
      white-space: pre-wrap;
      overflow-wrap: anywhere;
      word-break: break-word;
    }
    .term-log .cmd-line {
      font-weight: 600;
      color: var(--ink);
      margin-top: 6px;
      white-space: pre-wrap;
      overflow-wrap: anywhere;
      word-break: break-word;
    }
    .term-log .cmd-out {
      color: var(--ink-2);
      margin-bottom: 6px;
      white-space: pre-wrap;
      overflow-wrap: anywhere;
      word-break: break-word;
    }
    .term-log .cmd-err {
      color: var(--ink);
      font-style: italic;
      margin-bottom: 6px;
      white-space: pre-wrap;
      overflow-wrap: anywhere;
      word-break: break-word;
    }
    .term-form {
      display: flex;
      align-items: center;
      gap: 6px;
      padding: 6px 12px;
      border-top: 1px solid var(--rule);
      background: var(--paper);
    }
    .term-prompt {
      font-family: var(--mono);
      font-size: 13px;
      font-weight: 600;
      color: var(--ink);
      user-select: none;
    }
    .term-input {
      flex: 1;
      font-family: var(--mono);
      font-size: 12.5px;
      color: var(--ink);
      background: transparent;
      border: none;
      outline: none;
      min-height: 28px;
    }
    .side-pane {
      display: flex;
      flex-direction: column;
      gap: 16px;
    }
    .panel {
      padding-bottom: 14px;
      border-bottom: 1px solid var(--rule);
    }
    .panel:last-child {
      border-bottom: none;
    }
    .panel-header {
      display: flex;
      justify-content: space-between;
      align-items: baseline;
      margin-bottom: 8px;
    }
    .panel-title {
      margin: 0 0 8px;
      font: 600 13px/1.3 var(--sans);
      color: var(--ink);
    }
    .exercise-list {
      list-style: none;
      padding: 0;
      margin: 0;
    }
    .exercise-list li {
      display: flex;
      align-items: flex-start;
      gap: 8px;
      padding: 3px 0;
      font: 400 13px/1.3 var(--sans);
      color: var(--ink-2);
    }
    .exercise-list .check {
      font-family: var(--mono);
      font-size: 13px;
      font-weight: 600;
      line-height: 1;
      color: var(--ink-2);
    }
    .exercise-list li.done {
      color: var(--ink);
      font-weight: 500;
    }
    .exercise-list li.done .check {
      color: var(--ink);
    }
    .hint-bar {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-top: 8px;
      padding-top: 8px;
      border-top: 1px solid var(--rule);
    }
    .hint-text {
      font: italic 400 12px/1.3 var(--serif);
      color: var(--ink-2);
      flex: 1;
    }
    .mtls-status {
      font: 400 12px/1.3 var(--sans);
      color: var(--ink-2);
    }
    .mtls-status.strict {
      font-weight: 600;
      color: var(--ink);
    }
    .mesh-svg {
      display: block;
      width: 100%;
      height: auto;
      max-height: 190px;
      background: var(--paper);
    }
    .traffic-stats {
      display: flex;
      flex-wrap: wrap;
      gap: 4px 12px;
      margin-top: 8px;
      font: 400 12px/1.3 var(--sans);
      color: var(--ink-2);
    }
    .traffic-stats b {
      font-weight: 600;
      color: var(--ink);
      font-variant-numeric: tabular-nums;
    }
    @media (pointer: coarse) {
      .bar-actions button, .hint-bar button {
        min-height: 44px;
        padding: 8px 14px;
      }
      .term-input {
        min-height: 44px;
      }
    }
    (function () {
    
    var BUILTIN_MANIFESTS = {
      'destination-rule.yaml': 'apiVersion: networking.istio.io/v1alpha3\nkind: DestinationRule\nmetadata:\n  name: reviews\n  namespace: shop\nspec:\n  host: reviews\n  subsets:\n  - name: v1\n    labels:\n      version: v1\n  - name: v2\n    labels:\n      version: v2\n  - name: v3\n    labels:\n      version: v3',
      'reviews-v2-v3-split.yaml': 'apiVersion: networking.istio.io/v1alpha3\nkind: VirtualService\nmetadata:\n  name: reviews\n  namespace: shop\nspec:\n  hosts:\n  - reviews\n  http:\n  - route:\n    - destination:\n        host: reviews\n        subset: v2\n      weight: 50\n    - destination:\n        host: reviews\n        subset: v3\n      weight: 50',
      'fault-delay.yaml': 'apiVersion: networking.istio.io/v1alpha3\nkind: VirtualService\nmetadata:\n  name: ratings\n  namespace: shop\nspec:\n  hosts:\n  - ratings\n  http:\n  - fault:\n      delay:\n        percentage:\n          value: 20\n        fixedDelay: 2s\n    route:\n    - destination:\n        host: ratings\n        subset: v1',
      'mtls-strict.yaml': 'apiVersion: security.istio.io/v1beta1\nkind: PeerAuthentication\nmetadata:\n  name: default\n  namespace: shop\nspec:\n  mtls:\n    mode: STRICT'
    };
    
    var RESOURCE_KINDS = {
      deploy: 'deployment', deployment: 'deployment', deployments: 'deployment',
      'deploy.apps': 'deployment', 'deployment.apps': 'deployment', 'deployments.apps': 'deployment',
      po: 'pod', pod: 'pod', pods: 'pod',
      svc: 'service', service: 'service', services: 'service',
      ns: 'namespace', namespace: 'namespace', namespaces: 'namespace',
      rs: 'replicaset', replicaset: 'replicaset', replicasets: 'replicaset', 'replicasets.apps': 'replicaset',
      no: 'node', node: 'node', nodes: 'node',
      vs: 'virtualservice', virtualservice: 'virtualservice', virtualservices: 'virtualservice',
      'virtualservice.networking.istio.io': 'virtualservice', 'virtualservices.networking.istio.io': 'virtualservice',
      dr: 'destinationrule', destinationrule: 'destinationrule', destinationrules: 'destinationrule',
      'destinationrule.networking.istio.io': 'destinationrule', 'destinationrules.networking.istio.io': 'destinationrule',
      pa: 'peerauthentication', peerauthentication: 'peerauthentication', peerauthentications: 'peerauthentication',
      'peerauthentication.security.istio.io': 'peerauthentication', 'peerauthentications.security.istio.io': 'peerauthentication',
      gw: 'gateway', gateway: 'gateway', gateways: 'gateway',
      'gateway.networking.istio.io': 'gateway', 'gateways.networking.istio.io': 'gateway'
    };
    
    var ERROR_TYPE_NAMES = {
      deployment: 'deployments.apps', pod: 'pods', service: 'services', namespace: 'namespaces',
      replicaset: 'replicasets.apps', node: 'nodes', virtualservice: 'virtualservices.networking.istio.io',
      destinationrule: 'destinationrules.networking.istio.io', peerauthentication: 'peerauthentications.security.istio.io',
      gateway: 'gateways.networking.istio.io'
    };
    
    function formatAge(seconds) {
      if (seconds < 60) return Math.max(0, Math.floor(seconds)) + 's';
      if (seconds < 3600) return Math.floor(seconds / 60) + 'm';
      if (seconds < 86400) return Math.floor(seconds / 3600) + 'h';
      return Math.floor(seconds / 86400) + 'd';
    }
    
    function formatColumns(headers, rows) {
      if (rows.length === 0) return headers.join('   ');
      var colCount = headers.length;
      var colWidths = headers.map(function (h) { return h.length; });
      for (var r = 0; r < rows.length; r++) {
        for (var c = 0; c < colCount; c++) {
          var cell = rows[r][c] || '';
          if (cell.length > colWidths[c]) colWidths[c] = cell.length;
        }
      }
      function renderRow(cells) {
        return cells.map(function (cell, idx) {
          if (idx === colCount - 1) return cell;
          var w = colWidths[idx] || 0;
          var str = cell || '';
          while (str.length < w + 3) str += ' ';
          return str;
        }).join('');
      }
      return [renderRow(headers)].concat(rows.map(renderRow)).join('\n');
    }
    
    function createInitialCluster() {
      var baseAge = 2520;
      function mkDep(name, app, ver, ns, img, cont) {
        ns = ns || "shop";
        return {
          name: name, namespace: ns, replicas: 1, readyReplicas: 1, updatedReplicas: 1, availableReplicas: 1,
          image: img || ("docker.io/istio/examples-bookinfo-" + name + ":1.19.0"),
          containers: cont || [app], labels: { app: app, version: ver }, selector: { app: app, version: ver },
          sidecarInjected: false, ageSeconds: baseAge
        };
      }
      function mkPod(name, depName, ip, node, app, ver, ns, cont) {
        ns = ns || "shop";
        return {
          name: name, namespace: ns, ready: "1/1", status: "Running", restarts: 0, ageSeconds: baseAge,
          ip: ip, node: node, labels: { app: app, version: ver }, sidecar: false, deploymentName: depName,
          containerNames: cont || [app]
        };
      }
      function mkSvc(name, ip, ns, type, ports) {
        ns = ns || "shop";
        return { name: name, namespace: ns, type: type || "ClusterIP", clusterIP: ip, externalIP: "<none>", ports: ports || "9080/TCP", selector: { app: name }, ageSeconds: baseAge };
      }
      function mkRs(name, dep, ns) {
        return { name: name, namespace: ns || "shop", desired: 1, current: 1, ready: 1, deploymentName: dep, ageSeconds: baseAge };
      }
    
      return {
        currentNamespace: "shop",
        namespaces: {
          "default": { name: "default", status: "Active", labels: { "kubernetes.io/metadata.name": "default" }, ageSeconds: baseAge },
          "istio-system": { name: "istio-system", status: "Active", labels: { "kubernetes.io/metadata.name": "istio-system" }, ageSeconds: baseAge },
          "shop": { name: "shop", status: "Active", labels: { "kubernetes.io/metadata.name": "shop" }, ageSeconds: baseAge }
        },
        nodes: [
          { name: "node-1", status: "Ready", roles: "control-plane,worker", ageSeconds: baseAge, version: "v1.28.2" },
          { name: "node-2", status: "Ready", roles: "worker", ageSeconds: baseAge, version: "v1.28.2" }
        ],
        deployments: {
          "details-v1": mkDep("details-v1", "details", "v1"),
          "productpage-v1": mkDep("productpage-v1", "productpage", "v1"),
          "ratings-v1": mkDep("ratings-v1", "ratings", "v1"),
          "reviews-v1": mkDep("reviews-v1", "reviews", "v1"),
          "reviews-v2": mkDep("reviews-v2", "reviews", "v2"),
          "reviews-v3": mkDep("reviews-v3", "reviews", "v3"),
          "istiod": mkDep("istiod", "istiod", "pilot", "istio-system", "docker.io/istio/pilot:1.22.0", ["discovery"]),
          "istio-ingressgateway": mkDep("istio-ingressgateway", "istio-ingressgateway", "ingressgateway", "istio-system", "docker.io/istio/proxyv2:1.22.0", ["istio-proxy"])
        },
        services: {
          details: mkSvc("details", "10.96.10.2"),
          productpage: mkSvc("productpage", "10.96.10.1"),
          ratings: mkSvc("ratings", "10.96.10.3"),
          reviews: mkSvc("reviews", "10.96.10.4"),
          istiod: mkSvc("istiod", "10.96.0.10", "istio-system", "ClusterIP", "15010/TCP,15012/TCP,443/TCP"),
          "istio-ingressgateway": { name: "istio-ingressgateway", namespace: "istio-system", type: "LoadBalancer", clusterIP: "10.96.0.20", externalIP: "192.168.1.100", ports: "80:30080/TCP,443:30443/TCP", selector: { app: "istio-ingressgateway" }, ageSeconds: baseAge }
        },
        replicaSets: {
          "details-v1-66b6955995": mkRs("details-v1-66b6955995", "details-v1"),
          "productpage-v1-564d4686f": mkRs("productpage-v1-564d4686f", "productpage-v1"),
          "ratings-v1-6484c4d8bb": mkRs("ratings-v1-6484c4d8bb", "ratings-v1"),
          "reviews-v1-55b668b89": mkRs("reviews-v1-55b668b89", "reviews-v1"),
          "reviews-v2-68c5b5dd7b": mkRs("reviews-v2-68c5b5dd7b", "reviews-v2"),
          "reviews-v3-74b888b5dc": mkRs("reviews-v3-74b888b5dc", "reviews-v3"),
          "istiod-75f8f5c98d": mkRs("istiod-75f8f5c98d", "istiod", "istio-system"),
          "istio-ingressgateway-6d8fc9f4": mkRs("istio-ingressgateway-6d8fc9f4", "istio-ingressgateway", "istio-system")
        },
        pods: [
          mkPod("details-v1-66b6955995-m8q2x", "details-v1", "10.244.1.12", "node-2", "details", "v1"),
          mkPod("productpage-v1-564d4686f-9zk4b", "productpage-v1", "10.244.0.18", "node-1", "productpage", "v1"),
          mkPod("ratings-v1-6484c4d8bb-s8w5k", "ratings-v1", "10.244.1.14", "node-2", "ratings", "v1"),
          mkPod("reviews-v1-55b668b89-h6dfk", "reviews-v1", "10.244.0.22", "node-1", "reviews", "v1"),
          mkPod("reviews-v2-68c5b5dd7b-p7m2s", "reviews-v2", "10.244.1.25", "node-2", "reviews", "v2"),
          mkPod("reviews-v3-74b888b5dc-d4j8w", "reviews-v3", "10.244.0.31", "node-1", "reviews", "v3"),
          mkPod("istiod-75f8f5c98d-j2k9l", "istiod", "10.244.0.5", "node-1", "istiod", "pilot", "istio-system", ["discovery"]),
          mkPod("istio-ingressgateway-6d8fc9f4-x9q7s", "istio-ingressgateway", "10.244.1.8", "node-2", "istio-ingressgateway", "ingressgateway", "istio-system", ["istio-proxy"])
        ],
        virtualServices: {},
        destinationRules: {},
        peerAuthentications: {},
        gateways: {
          "bookinfo-gateway": { name: "bookinfo-gateway", namespace: "shop", hosts: ["*"], port: 80, ageSeconds: baseAge }
        },
        traffic: {
          total: 0, productpage: 0, details: 0, reviewsV1: 0, reviewsV2: 0, reviewsV3: 0, ratings: 0,
          lastSplitV1: 34, lastSplitV2: 33, lastSplitV3: 33, avgLatencyMs: 12
        },
        elapsedSeconds: 0
      };
    }
    function parseCommand(line) {
      var trimmed = line.trim();
      var execParts = trimmed.split(/\s+--\s+/);
      var mainPart = execParts[0] || '';
      var execExtra = execParts.length > 1 ? execParts.slice(1).join(' -- ').trim().split(/\s+/) : undefined;
    
      var rawTokens = mainPart.split(/\s+/).filter(Boolean);
      if (rawTokens.length === 0) {
        return { raw: trimmed, tool: '', subCmd: '', args: [], flags: {}, execExtra: execExtra };
      }
    
      var tool = rawTokens[0];
      var flags = {};
      var positional = [];
    
      for (var i = 1; i < rawTokens.length; i++) {
        var token = rawTokens[i];
        if (token.indexOf('--') === 0) {
          var eqIdx = token.indexOf('=');
          if (eqIdx !== -1) {
            flags[token.slice(2, eqIdx)] = token.slice(eqIdx + 1);
          } else {
            var k = token.slice(2);
            var next = rawTokens[i + 1];
            if (next && next.indexOf('-') !== 0) {
              flags[k] = next;
              i++;
            } else {
              flags[k] = true;
            }
          }
        } else if (token.indexOf('-') === 0) {
          var eqIdx2 = token.indexOf('=');
          if (eqIdx2 !== -1) {
            flags[token.slice(1, eqIdx2)] = token.slice(eqIdx2 + 1);
          } else {
            var k2 = token.slice(1);
            if (k2 === 'A') {
              flags['A'] = true;
            } else {
              var next2 = rawTokens[i + 1];
              if (next2 && next2.indexOf('-') !== 0) {
                flags[k2] = next2;
                i++;
              } else {
                flags[k2] = true;
              }
            }
          }
        } else {
          positional.push(token);
        }
      }
    
      return { raw: trimmed, tool: tool, subCmd: positional[0] || '', args: positional.slice(1), flags: flags, execExtra: execExtra };
    }
    
    function resolveNamespace(state, flags) {
      if (flags['A'] || flags['all-namespaces']) return null;
      var ns = flags['n'] || flags['namespace'];
      if (ns && typeof ns === 'string') return ns;
      return state.currentNamespace;
    }
    
    function parseResourceTargets(args) {
      if (!args || args.length === 0) return { error: 'you must specify a resource' };
    
      if (args[0].indexOf(',') !== -1 && args[0].indexOf('/') === -1) {
        var types = args[0].split(',');
        var multiTargets = [];
        for (var m = 0; m < types.length; m++) {
          var rawM = types[m].toLowerCase();
          var kM = RESOURCE_KINDS[rawM];
          if (!kM) return { errorType: types[m] };
          multiTargets.push({ kind: kM, name: null, rawType: rawM });
        }
        return { targets: multiTargets };
      }
    
      if (args[0].indexOf('/') !== -1) {
        var targets = [];
        for (var i = 0; i < args.length; i++) {
          var slashIdx = args[i].indexOf('/');
          if (slashIdx === -1) {
            var spelling = args[i].toLowerCase();
            var k = RESOURCE_KINDS[spelling];
            if (!k) return { errorType: args[i] };
            targets.push({ kind: k, name: null, rawType: spelling });
          } else {
            var rawT = args[i].slice(0, slashIdx).toLowerCase();
            var name = args[i].slice(slashIdx + 1);
            var k2 = RESOURCE_KINDS[rawT];
            if (!k2) return { errorType: rawT };
            targets.push({ kind: k2, name: name, rawType: rawT });
          }
        }
        return { targets: targets };
      }
    
      var rawType = args[0].toLowerCase();
      var kind = RESOURCE_KINDS[rawType];
      if (!kind) return { errorType: args[0] };
    
      var names = args.slice(1);
      if (names.length === 0) return { targets: [{ kind: kind, name: null, rawType: rawType }] };
    
      var resTargets = [];
      for (var j = 0; j < names.length; j++) {
        var n = names[j];
        if (n.indexOf('/') !== -1) n = n.slice(n.indexOf('/') + 1);
        resTargets.push({ kind: kind, name: n, rawType: rawType });
      }
      return { targets: resTargets };
    }
    
    function checkExercises(state) {
      var step1 = Boolean(state.namespaces['shop'] && state.namespaces['shop'].labels['istio-injection'] === 'enabled');
      var reviewsPods = state.pods.filter(function (p) { return p.namespace === 'shop' && p.deploymentName.indexOf('reviews-') === 0; });
      var step2 = Boolean(step1 && reviewsPods.length >= 3 && reviewsPods.every(function (p) { return p.sidecar && p.ready === '2/2' && p.status === 'Running'; }));
    
      var hasDr = Boolean(state.destinationRules['reviews'] && state.destinationRules['reviews'].subsets);
      var reviewsVs = state.virtualServices['reviews'];
      var hasSplit = Boolean(reviewsVs && reviewsVs.routes && reviewsVs.routes.some(function (r) { return r.subset === 'v2' && r.weight === 50; }) && reviewsVs.routes.some(function (r) { return r.subset === 'v3' && r.weight === 50; }));
      var step3 = Boolean(hasDr && hasSplit);
    
      var ratingsVs = state.virtualServices['ratings'];
      var step4 = Boolean(ratingsVs && ratingsVs.fault && ratingsVs.fault.delaySeconds === 2 && ratingsVs.fault.delayPercentage === 20);
    
      var defaultPa = state.peerAuthentications['default'];
      var step5 = Boolean(defaultPa && defaultPa.namespace === 'shop' && defaultPa.mode === 'STRICT');
    
      return [step1, step2, step3, step4, step5];
    }
    
    var SERVICE_GRAPH = [
      { from: 'ingress', to: 'productpage' },
      { from: 'productpage', to: 'details' },
      { from: 'productpage', to: 'reviews-v1' },
      { from: 'productpage', to: 'reviews-v2' },
      { from: 'productpage', to: 'reviews-v3' },
      { from: 'reviews-v2', to: 'ratings' },
      { from: 'reviews-v3', to: 'ratings' }
    ];
    
    function getReviewsWeights(state) {
      var rVs = state.virtualServices && state.virtualServices['reviews'];
      if (rVs && rVs.routes && rVs.routes.length > 0) {
        var rMap = {};
        for (var i = 0; i < rVs.routes.length; i++) rMap[rVs.routes[i].subset] = rVs.routes[i].weight;
        return {
          v1: rMap['v1'] !== undefined ? rMap['v1'] : 0,
          v2: rMap['v2'] !== undefined ? rMap['v2'] : 0,
          v3: rMap['v3'] !== undefined ? rMap['v3'] : 0
        };
      }
      return { v1: 34, v2: 33, v3: 33 };
    }
    
    function computeTopology(state) {
      var weights = getReviewsWeights(state);
      var isMtlsStrict = Boolean(state.peerAuthentications && state.peerAuthentications['default'] && state.peerAuthentications['default'].mode === 'STRICT');
      var lockGlyph = isMtlsStrict ? ' 🔒' : '';
      var faultActive = Boolean(state.virtualServices && state.virtualServices['ratings'] && state.virtualServices['ratings'].fault);
    
      var nodes = [
        { id: 'ingress', title: 'Ingress', sub: ':80', x: 10, y: 75, width: 54, height: 26 },
        { id: 'productpage', title: 'productpage', sub: 'v1' + lockGlyph, x: 94, y: 74, width: 80, height: 28 },
        { id: 'details', title: 'details-v1', sub: 'v1' + lockGlyph, x: 226, y: 10, width: 72, height: 26 },
        { id: 'reviews-v1', title: 'reviews-v1', sub: 'v1' + lockGlyph, x: 226, y: 52, width: 72, height: 26 },
        { id: 'reviews-v2', title: 'reviews-v2', sub: 'v2' + lockGlyph, x: 226, y: 94, width: 72, height: 26 },
        { id: 'reviews-v3', title: 'reviews-v3', sub: 'v3' + lockGlyph, x: 226, y: 136, width: 72, height: 26 },
        { id: 'ratings', title: 'ratings-v1', sub: 'v1' + (faultActive ? ' (2s delay)' : '') + lockGlyph, x: 308, y: 114, width: 66, height: 28 }
      ];
    
      var edges = [
        { from: 'ingress', to: 'productpage', d: 'M 64 88 L 94 88' },
        { from: 'productpage', to: 'details', d: 'M 174 80 L 186 23 L 226 23' },
        { from: 'productpage', to: 'reviews-v1', d: 'M 174 84 L 186 65 L 226 65', weight: weights.v1, labelBox: { x: 191, y: 59, width: 24, height: 12, text: weights.v1 + '%' } },
        { from: 'productpage', to: 'reviews-v2', d: 'M 174 88 L 186 107 L 226 107', weight: weights.v2, labelBox: { x: 191, y: 101, width: 24, height: 12, text: weights.v2 + '%' } },
        { from: 'productpage', to: 'reviews-v3', d: 'M 174 92 L 186 149 L 226 149', weight: weights.v3, labelBox: { x: 191, y: 143, width: 24, height: 12, text: weights.v3 + '%' } },
        { from: 'reviews-v2', to: 'ratings', d: 'M 298 107 L 308 122' },
        { from: 'reviews-v3', to: 'ratings', d: 'M 298 149 L 308 134' }
      ];
    
      return {
        viewBox: { width: 380, height: 180 },
        nodes: nodes,
        edges: edges
      };
    }
    
    function simulateTraffic(state, count) {
      if (typeof count !== 'number') count = 100;
      state.traffic.total += count;
    
      var weights = getReviewsWeights(state);
      var splitV1 = weights.v1, splitV2 = weights.v2, splitV3 = weights.v3;
    
      state.traffic.lastSplitV1 = splitV1;
      state.traffic.lastSplitV2 = splitV2;
      state.traffic.lastSplitV3 = splitV3;
    
      state.traffic.productpage += count;
      state.traffic.details += count;
      state.traffic.reviewsV1 += Math.round(count * (splitV1 / 100));
      state.traffic.reviewsV2 += Math.round(count * (splitV2 / 100));
      state.traffic.reviewsV3 += Math.round(count * (splitV3 / 100));
      state.traffic.ratings += Math.round(count * ((splitV2 + splitV3) / 100));
    
      var ratingsVs = state.virtualServices['ratings'];
      if (ratingsVs && ratingsVs.fault) {
        var faultDelay = ratingsVs.fault.delaySeconds || 2;
        var faultPct = ratingsVs.fault.delayPercentage || 20;
        state.traffic.avgLatencyMs = Math.round(12 + (faultDelay * 1000 * (faultPct / 100)));
      } else {
        state.traffic.avgLatencyMs = 12;
      }
    
      var lines = [
        'Simulated ' + count + ' incoming requests to productpage:9080/productpage',
        '  productpage: ' + state.traffic.productpage + ' total',
        '  details:     ' + state.traffic.details + ' total',
        '  reviews v1:  ' + splitV1 + '% (' + state.traffic.reviewsV1 + ')',
        '  reviews v2:  ' + splitV2 + '% (' + state.traffic.reviewsV2 + ')',
        '  reviews v3:  ' + splitV3 + '% (' + state.traffic.reviewsV3 + ')',
        '  ratings:     ' + state.traffic.ratings + ' total (avg latency ' + state.traffic.avgLatencyMs + 'ms)'
      ];
    
      return { output: lines.join('\n'), counts: state.traffic };
    }
    
    function executeRolloutDep(state, dep, action) {
      if (action === 'status') return { line: 'deployment "' + dep.name + '" successfully rolled out' };
      if (action === 'restart') {
        var isNsInjected = Boolean(state.namespaces[dep.namespace] && state.namespaces[dep.namespace].labels['istio-injection'] === 'enabled');
        dep.sidecarInjected = isNsInjected;
        state.pods = state.pods.filter(function (pod) { return pod.deploymentName !== dep.name; });
        for (var i = 0; i < dep.replicas; i++) {
          var randSuffix = Math.random().toString(36).slice(2, 7);
          state.pods.push({
            name: dep.name + '-rollout-' + randSuffix, namespace: dep.namespace,
            ready: isNsInjected ? '2/2' : '1/1', status: 'Running', restarts: 0, ageSeconds: 1,
            ip: '10.244.0.' + (20 + Math.floor(Math.random() * 40)), node: i % 2 === 0 ? 'node-1' : 'node-2',
            labels: Object.assign({}, dep.labels), sidecar: isNsInjected, deploymentName: dep.name,
            containerNames: isNsInjected ? dep.containers.concat(['istio-proxy']) : dep.containers.slice()
          });
        }
        return { line: 'deployment.apps/' + dep.name + ' restarted' };
      }
      if (action === 'undo') {
        if (dep.previousImage) { dep.image = dep.previousImage; dep.previousImage = undefined; }
        for (var p = 0; p < state.pods.length; p++) {
          if (state.pods[p].deploymentName === dep.name) {
            state.pods[p].status = 'Running';
            state.pods[p].ready = state.pods[p].sidecar ? '2/2' : '1/1';
          }
        }
        return { line: 'deployment.apps/' + dep.name + ' rolled back' };
      }
    }
    
    function handleKubectlRollout(state, p) {
      var action = p.args[0];
      if (action !== 'status' && action !== 'restart' && action !== 'undo') {
        return { output: 'error: rollout action "' + action + '" not recognized. Supported: status, restart, undo', state: state, error: true };
      }
    
      var targetArgs = p.args.slice(1);
      if (targetArgs.length === 0) return { output: 'error: you must specify the resource to ' + action, state: state, error: true };
    
      var parsed = parseResourceTargets(targetArgs);
      if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true };
    
      var targets = parsed.targets || [];
      var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop';
      var outputs = [];
    
      for (var i = 0; i < targets.length; i++) {
        var t = targets[i];
        if (t.kind !== 'deployment') {
          return { output: 'error: cannot ' + action + ' ' + t.rawType + ': the server doesn\'t have a resource type "' + t.rawType + '"', state: state, error: true };
        }
    
        if (t.name === null) {
          var nsDeps = Object.values(state.deployments).filter(function (d) { return d.namespace === ns; });
          if (nsDeps.length === 0) outputs.push('No resources found in ' + ns + ' namespace.');
          else {
            for (var d = 0; d < nsDeps.length; d++) outputs.push(executeRolloutDep(state, nsDeps[d], action).line);
          }
        } else {
          var dep = state.deployments[t.name] || Object.values(state.deployments).find(function (d) { return d.namespace === ns && d.name === t.name; });
          if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + t.name + '" not found', state: state, error: true };
          outputs.push(executeRolloutDep(state, dep, action).line);
        }
      }
    
      return { output: outputs.join('\n'), state: state };
    }
    
    function handleKubectlScale(state, p) {
      var rawReplicas = p.flags['replicas'];
      if (rawReplicas === undefined) return { output: 'error: --replicas is required', state: state, error: true };
      var replicas = parseInt(String(rawReplicas), 10);
      if (isNaN(replicas) || replicas < 0) return { output: 'error: invalid replicas count: ' + rawReplicas, state: state, error: true };
      if (p.args.length === 0) return { output: 'error: you must specify a resource to scale', state: state, error: true };
    
      var parsed = parseResourceTargets(p.args);
      if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true };
    
      var targets = parsed.targets || [];
      var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop';
      var outputs = [];
    
      for (var i = 0; i < targets.length; i++) {
        var t = targets[i];
        if (t.kind !== 'deployment') {
          return { output: 'error: cannot scale ' + t.rawType + ': the server doesn\'t have a resource type "' + t.rawType + '"', state: state, error: true };
        }
        if (!t.name) return { output: 'error: you must specify a deployment name to scale', state: state, error: true };
        var dep = state.deployments[t.name] || Object.values(state.deployments).find(function (d) { return d.namespace === ns && d.name === t.name; });
        if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + t.name + '" not found', state: state, error: true };
    
        var oldReplicas = dep.replicas;
        dep.replicas = replicas;
        dep.readyReplicas = replicas;
        dep.availableReplicas = replicas;
        dep.updatedReplicas = replicas;
    
        if (replicas > oldReplicas) {
          var isNsInjected = Boolean(state.namespaces[dep.namespace] && state.namespaces[dep.namespace].labels['istio-injection'] === 'enabled');
          for (var r = oldReplicas; r < replicas; r++) {
            var randSuffix = Math.random().toString(36).slice(2, 7);
            state.pods.push({
              name: dep.name + '-scale-' + randSuffix, namespace: dep.namespace,
              ready: isNsInjected && dep.sidecarInjected ? '2/2' : '1/1', status: 'Running', restarts: 0, ageSeconds: 1,
              ip: '10.244.1.' + (30 + Math.floor(Math.random() * 40)), node: r % 2 === 0 ? 'node-1' : 'node-2',
              labels: Object.assign({}, dep.labels), sidecar: isNsInjected && dep.sidecarInjected, deploymentName: dep.name,
              containerNames: isNsInjected && dep.sidecarInjected ? dep.containers.concat(['istio-proxy']) : dep.containers.slice()
            });
          }
        } else if (replicas < oldReplicas) {
          var toRemove = oldReplicas - replicas;
          var removedCount = 0;
          state.pods = state.pods.filter(function (pod) {
            if (pod.deploymentName === dep.name && removedCount < toRemove) {
              removedCount++;
              return false;
            }
            return true;
          });
        }
    
        outputs.push('deployment.apps/' + dep.name + ' scaled');
      }
    
      return { output: outputs.join('\n'), state: state };
    }
    
    function handleKubectlSet(state, p) {
      var sub = p.args[0];
      if (sub !== 'image') return { output: 'error: set "' + sub + '" not supported. Use "kubectl set image deploy/<name> <container>=<image>"', state: state, error: true };
    
      var rest = p.args.slice(1);
      if (rest.length === 0) return { output: 'error: you must specify a resource and container=image spec', state: state, error: true };
    
      var specIdx = -1;
      for (var i = 0; i < rest.length; i++) {
        if (rest[i].indexOf('=') !== -1) { specIdx = i; break; }
      }
      if (specIdx === -1) return { output: 'error: you must specify a container image in format <container>=<image>', state: state, error: true };
    
      var targetArgs = rest.slice(0, specIdx);
      var spec = rest[specIdx];
      var specParts = spec.split('=');
      var containerName = specParts[0];
      var newImage = specParts[1];
    
      var parsed = parseResourceTargets(targetArgs);
      if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true };
    
      var targets = parsed.targets || [];
      if (targets.length === 0 || !targets[0].name) return { output: 'error: you must specify a deployment name', state: state, error: true };
    
      var target = targets[0];
      if (target.kind !== 'deployment') return { output: 'error: cannot set image on ' + target.rawType + ': the server doesn\'t have a resource type "' + target.rawType + '"', state: state, error: true };
    
      var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop';
      var dep = state.deployments[target.name] || Object.values(state.deployments).find(function (d) { return d.namespace === ns && d.name === target.name; });
      if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + target.name + '" not found', state: state, error: true };
    
      dep.previousImage = dep.image;
      dep.image = newImage;
    
      var isBroken = newImage.indexOf('bad') !== -1 || newImage.indexOf('invalid') !== -1 || newImage.indexOf('fail') !== -1;
      for (var podIdx = 0; podIdx < state.pods.length; podIdx++) {
        var pod = state.pods[podIdx];
        if (pod.deploymentName === dep.name) {
          if (isBroken) {
            pod.status = 'ImagePullBackOff';
            pod.ready = pod.sidecar ? '0/2' : '0/1';
          } else {
            pod.status = 'Running';
            pod.ready = pod.sidecar ? '2/2' : '1/1';
          }
        }
      }
    
      return { output: 'deployment.apps/' + dep.name + ' image updated', state: state };
    }
    
    function handleKubectlDelete(state, p) {
      var file = p.flags['f'] || p.flags['filename'];
      if (file) {
        var manifestName = String(file);
        if (!BUILTIN_MANIFESTS[manifestName]) return { output: 'error: the path "' + manifestName + '" does not exist', state: state, error: true };
        if (manifestName === 'destination-rule.yaml') { delete state.destinationRules['reviews']; return { output: 'destinationrule.networking.istio.io "reviews" deleted', state: state }; }
        if (manifestName === 'reviews-v2-v3-split.yaml') { delete state.virtualServices['reviews']; return { output: 'virtualservice.networking.istio.io "reviews" deleted', state: state }; }
        if (manifestName === 'fault-delay.yaml') { if (state.virtualServices['ratings']) delete state.virtualServices['ratings'].fault; return { output: 'virtualservice.networking.istio.io "ratings" deleted', state: state }; }
        if (manifestName === 'mtls-strict.yaml') { delete state.peerAuthentications['default']; return { output: 'peerauthentication.security.istio.io "default" deleted', state: state }; }
      }
    
      if (p.args.length === 0) return { output: 'error: you must specify a resource to delete', state: state, error: true };
    
      var parsed = parseResourceTargets(p.args);
      if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true };
    
      var targets = parsed.targets || [];
      var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop';
      var outputs = [];
    
      for (var i = 0; i < targets.length; i++) {
        var t = targets[i];
        if (!t.name) return { output: 'error: you must specify a resource name to delete', state: state, error: true };
    
        if (t.kind === 'pod') {
          var podIdx = state.pods.findIndex(function (pod) { return (pod.namespace === ns || ns === null) && pod.name === t.name; });
          if (podIdx === -1) return { output: 'Error from server (NotFound): pods "' + t.name + '" not found', state: state, error: true };
          var oldPod = state.pods[podIdx];
          state.pods.splice(podIdx, 1);
          var depObj = state.deployments[oldPod.deploymentName];
          if (depObj) {
            var isNsInjected = Boolean(state.namespaces[oldPod.namespace] && state.namespaces[oldPod.namespace].labels['istio-injection'] === 'enabled');
            var sidecar = Boolean(isNsInjected && depObj.sidecarInjected);
            var randSuffix = Math.random().toString(36).slice(2, 7);
            state.pods.push({
              name: oldPod.deploymentName + '-repl-' + randSuffix, namespace: oldPod.namespace,
              ready: sidecar ? '2/2' : '1/1', status: 'Running', restarts: 0, ageSeconds: 1,
              ip: oldPod.ip, node: oldPod.node, labels: Object.assign({}, oldPod.labels), sidecar: sidecar,
              deploymentName: oldPod.deploymentName, containerNames: sidecar ? depObj.containers.concat(['istio-proxy']) : depObj.containers.slice()
            });
          }
          outputs.push('pod "' + t.name + '" deleted');
        } else if (t.kind === 'deployment') {
          var dep = state.deployments[t.name];
          if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + t.name + '" not found', state: state, error: true };
          delete state.deployments[t.name];
          state.pods = state.pods.filter(function (p) { return p.deploymentName !== t.name; });
          outputs.push('deployment.apps "' + t.name + '" deleted');
        } else {
          var mapKey = { virtualservice: 'virtualServices', destinationrule: 'destinationRules', peerauthentication: 'peerAuthentications', service: 'services' }[t.kind];
          if (mapKey) {
            if (!state[mapKey][t.name]) return { output: 'Error from server (NotFound): ' + ERROR_TYPE_NAMES[t.kind] + ' "' + t.name + '" not found', state: state, error: true };
            delete state[mapKey][t.name];
            outputs.push(ERROR_TYPE_NAMES[t.kind] + ' "' + t.name + '" deleted');
          } else {
            return { output: 'error: cannot delete ' + t.rawType + ': unsupported resource type', state: state, error: true };
          }
        }
      }
    
      return { output: outputs.join('\n'), state: state };
    }
    
    function handleKubectlLabel(state, p) {
      var specIdx = -1;
      for (var i = 0; i < p.args.length; i++) {
        if (p.args[i].indexOf('=') !== -1) { specIdx = i; break; }
      }
      if (specIdx === -1) return { output: 'error: you must specify a label in key=value format', state: state, error: true };
    
      var targetArgs = p.args.slice(0, specIdx);
      var spec = p.args[specIdx];
      var eqIdx = spec.indexOf('=');
      var key = spec.slice(0, eqIdx);
      var val = spec.slice(eqIdx + 1);
    
      var parsed = parseResourceTargets(targetArgs);
      if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true };
    
      var targets = parsed.targets || [];
      if (targets.length === 0 || !targets[0].name) return { output: 'error: you must specify a resource name to label', state: state, error: true };
    
      var target = targets[0];
      if (target.kind === 'namespace') {
        var nsObj = state.namespaces[target.name];
        if (!nsObj) return { output: 'Error from server (NotFound): namespaces "' + target.name + '" not found', state: state, error: true };
        nsObj.labels[key] = val;
        return { output: 'namespace/' + target.name + ' labeled', state: state };
      }
    
      if (target.kind === 'pod') {
        var podObj = state.pods.find(function (pod) { return pod.name === target.name; });
        if (!podObj) return { output: 'Error from server (NotFound): pods "' + target.name + '" not found', state: state, error: true };
        podObj.labels[key] = val;
        return { output: 'pod/' + target.name + ' labeled', state: state };
      }
    
      return { output: 'error: cannot label ' + target.rawType + ': unsupported resource type', state: state, error: true };
    }
    
    function handleKubectlApply(state, p) {
      var file = p.flags['f'] || p.flags['filename'] || p.args[0];
      if (!file) return { output: 'error: must specify one of -f and -k', state: state, error: true };
      var filename = String(file);
      if (!BUILTIN_MANIFESTS[filename]) return { output: 'error: the path "' + filename + '" does not exist', state: state, error: true };
    
      if (filename === 'destination-rule.yaml') {
        state.destinationRules['reviews'] = {
          name: 'reviews', namespace: 'shop', host: 'reviews',
          subsets: [{ name: 'v1', labels: { version: 'v1' } }, { name: 'v2', labels: { version: 'v2' } }, { name: 'v3', labels: { version: 'v3' } }],
          ageSeconds: 1
        };
        return { output: 'destinationrule.networking.istio.io/reviews created', state: state };
      }
    
      if (filename === 'reviews-v2-v3-split.yaml') {
        state.virtualServices['reviews'] = {
          name: 'reviews', namespace: 'shop', hosts: ['reviews'],
          routes: [{ subset: 'v2', weight: 50 }, { subset: 'v3', weight: 50 }],
          ageSeconds: 1
        };
        return { output: 'virtualservice.networking.istio.io/reviews configured', state: state };
      }
    
      if (filename === 'fault-delay.yaml') {
        if (!state.virtualServices['ratings']) {
          state.virtualServices['ratings'] = { name: 'ratings', namespace: 'shop', hosts: ['ratings'], routes: [{ subset: 'v1', weight: 100 }], ageSeconds: 1 };
        }
        state.virtualServices['ratings'].fault = { delayPercentage: 20, delaySeconds: 2 };
        return { output: 'virtualservice.networking.istio.io/ratings configured', state: state };
      }
    
      if (filename === 'mtls-strict.yaml') {
        state.peerAuthentications['default'] = { name: 'default', namespace: 'shop', mode: 'STRICT', ageSeconds: 1 };
        return { output: 'peerauthentication.security.istio.io/default created', state: state };
      }
    
      return { output: 'applied ' + filename, state: state };
    }
    
    function handleKubectlLogs(state, p) {
      if (p.args.length === 0) return { output: 'error: you must specify a pod to get logs from', state: state, error: true };
      var parsed = parseResourceTargets(p.args);
      if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true };
      var targets = parsed.targets || [];
      var podName = targets[0] && targets[0].name ? targets[0].name : p.args[0];
      if (podName.indexOf('/') !== -1) podName = podName.slice(podName.indexOf('/') + 1);
    
      var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop';
      var pod = state.pods.find(function (item) { return (item.namespace === ns || ns === null) && item.name === podName; });
      if (!pod) return { output: 'Error from server (NotFound): pods "' + podName + '" not found', state: state, error: true };
    
      var cName = p.flags['c'] || p.flags['container'] || pod.containerNames[0];
      if (cName === 'istio-proxy') {
        return {
          output: [
            '2026-09-26T10:00:00.123456Z info  Envoy proxy initialized',
            '2026-09-26T10:00:00.234567Z info  Pilot discovery connected: 10.96.0.10:15012',
            '2026-09-26T10:00:00.345678Z info  CDS: route clusters synchronized',
            '2026-09-26T10:00:00.456789Z info  mTLS: connection established using SPIFFE identity'
          ].join('\n'),
          state: state
        };
      }
    
      return {
        output: [
          '[Server] Listening on port 9080',
          '[Route] GET /reviews/0 HTTP/1.1 200 OK',
          '[Route] GET /ratings/0 HTTP/1.1 200 OK (latency: 14ms)'
        ].join('\n'),
        state: state
      };
    }
    
    function handleKubectlExec(state, p) {
      if (p.args.length === 0) return { output: 'error: you must specify a pod to exec into', state: state, error: true };
      var podTarget = p.args[0];
      if (podTarget.indexOf('/') !== -1) podTarget = podTarget.slice(podTarget.indexOf('/') + 1);
    
      var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop';
      var pod = state.pods.find(function (item) { return (item.namespace === ns || ns === null) && item.name === podTarget; });
      if (!pod) return { output: 'Error from server (NotFound): pods "' + podTarget + '" not found', state: state, error: true };
    
      var rVs = state.virtualServices['reviews'];
      var stars = '(no stars)';
      if (rVs && rVs.routes && rVs.routes.some(function (r) { return r.subset === 'v2'; })) {
        stars = '★★★★☆ (black stars from ratings)';
      } else if (rVs && rVs.routes && rVs.routes.some(function (r) { return r.subset === 'v3'; })) {
        stars = '★★★★★ (red stars from ratings)';
      }
    
      return {
        output: [
          'HTTP/1.1 200 OK', 'content-type: text/html; charset=utf-8', 'content-length: 1824', '',
          '<!DOCTYPE html><html><title>Simple Bookstore App</title><body>',
          '<h1>Product: The Comedy of Errors</h1>', '<p>Reviews: ' + stars + '</p>', '</body></html>'
        ].join('\n'),
        state: state
      };
    }
    
    function handleKubectlDescribe(state, p) {
      if (p.args.length === 0) return { output: 'error: you must specify a resource to describe', state: state, error: true };
      var parsed = parseResourceTargets(p.args);
      if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true };
      var targets = parsed.targets || [];
      var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop';
      var t = targets[0];
      if (!t.name) return { output: 'error: you must specify a resource name', state: state, error: true };
    
      if (t.kind === 'pod') {
        var pod = state.pods.find(function (item) { return (item.namespace === ns || ns === null) && item.name === t.name; });
        if (!pod) return { output: 'Error from server (NotFound): pods "' + t.name + '" not found', state: state, error: true };
        return {
          output: [
            'Name:         ' + pod.name, 'Namespace:    ' + pod.namespace, 'Node:         ' + pod.node,
            'Status:       ' + pod.status, 'IP:           ' + pod.ip, 'Containers:   ' + pod.containerNames.join(', '),
            'Sidecar:      ' + (pod.sidecar ? 'injected' : 'none'), 'Ready:        ' + pod.ready
          ].join('\n'),
          state: state
        };
      }
    
      if (t.kind === 'deployment') {
        var dep = state.deployments[t.name] || Object.values(state.deployments).find(function (d) { return (d.namespace === ns || ns === null) && d.name === t.name; });
        if (!dep) return { output: 'Error from server (NotFound): deployments.apps "' + t.name + '" not found', state: state, error: true };
        return {
          output: [
            'Name:                   ' + dep.name, 'Namespace:              ' + dep.namespace,
            'Replicas:               ' + dep.replicas + ' desired | ' + dep.readyReplicas + ' ready',
            'Image:                  ' + dep.image, 'Sidecar injection:      ' + (dep.sidecarInjected ? 'injected' : 'none')
          ].join('\n'),
          state: state
        };
      }
    
      if (t.kind === 'service') {
        var svc = state.services[t.name];
        if (!svc) return { output: 'Error from server (NotFound): services "' + t.name + '" not found', state: state, error: true };
        return { output: ['Name: ' + svc.name, 'Namespace: ' + svc.namespace, 'Type: ' + svc.type, 'IP: ' + svc.clusterIP].join('\n'), state: state };
      }
    
      if (t.kind === 'namespace') {
        var nsObj = state.namespaces[t.name];
        if (!nsObj) return { output: 'Error from server (NotFound): namespaces "' + t.name + '" not found', state: state, error: true };
        return { output: ['Name: ' + nsObj.name, 'Status: ' + nsObj.status].join('\n'), state: state };
      }
    
      if (t.kind === 'virtualservice') {
        var vs = state.virtualServices[t.name];
        if (!vs) return { output: 'Error from server (NotFound): virtualservices.networking.istio.io "' + t.name + '" not found', state: state, error: true };
        return { output: ['Name: ' + vs.name, 'Hosts: ' + vs.hosts.join(',')].join('\n'), state: state };
      }
    
      if (t.kind === 'destinationrule') {
        var dr = state.destinationRules[t.name];
        if (!dr) return { output: 'Error from server (NotFound): destinationrules.networking.istio.io "' + t.name + '" not found', state: state, error: true };
        return { output: ['Name: ' + dr.name, 'Host: ' + dr.host].join('\n'), state: state };
      }
    
      return { output: 'error: describe not supported for ' + t.rawType, state: state, error: true };
    }
    
    function handleKubectlGet(state, p) {
      if (p.args.length === 0) return { output: 'error: You must specify the type of resource to get.', state: state, error: true };
    
      var parsed = parseResourceTargets(p.args);
      if (parsed.errorType) return { output: 'error: the server doesn\'t have a resource type "' + parsed.errorType + '"', state: state, error: true };
    
      var targets = parsed.targets || [];
      var ns = resolveNamespace(state, p.flags);
      var wide = Boolean(p.flags['o'] === 'wide' || p.flags['output'] === 'wide');
      var nameOnly = Boolean(p.flags['o'] === 'name' || p.flags['output'] === 'name');
      var selector = p.flags['l'] || p.flags['selector'];
    
      function matchesSelector(labels) {
        if (!selector) return true;
        var parts = String(selector).split(',');
        for (var k = 0; k < parts.length; k++) {
          var pair = parts[k].split('=');
          var key = (pair[0] || '').trim();
          var val = (pair[1] || '').trim();
          if (!labels[key] || labels[key] !== val) return false;
        }
        return true;
      }
    
      function renderTable(items, headers, rowFn, notFoundKind, targetName) {
        if (targetName !== null && items.length === 0) {
          return { err: 'Error from server (NotFound): ' + notFoundKind + ' "' + targetName + '" not found' };
        }
        if (items.length === 0) {
          return { out: ns ? 'No resources found in ' + ns + ' namespace.' : 'No resources found.' };
        }
        if (nameOnly) {
          return { out: items.map(function (it) { return it.name; }).join('\n') };
        }
        return { out: formatColumns(headers, items.map(rowFn)) };
      }
    
      var outputs = [];
    
      for (var tIdx = 0; tIdx < targets.length; tIdx++) {
        var target = targets[tIdx];
    
        if (target.kind === 'pod') {
          var podList = state.pods.filter(function (item) {
            if (ns !== null && item.namespace !== ns) return false;
            if (!matchesSelector(item.labels)) return false;
            if (target.name !== null && item.name !== target.name) return false;
            return true;
          });
          var pHeaders = ns === null ? ['NAMESPACE', 'NAME', 'READY', 'STATUS', 'RESTARTS', 'AGE'] : ['NAME', 'READY', 'STATUS', 'RESTARTS', 'AGE'];
          if (wide) pHeaders.push('IP', 'NODE');
          var rP = renderTable(podList, pHeaders, function (pod) {
            var row = ns === null
              ? [pod.namespace, pod.name, pod.ready, pod.status, String(pod.restarts), formatAge(pod.ageSeconds)]
              : [pod.name, pod.ready, pod.status, String(pod.restarts), formatAge(pod.ageSeconds)];
            if (wide) row.push(pod.ip, pod.node);
            return row;
          }, 'pods', target.name);
          if (rP.err) return { output: rP.err, state: state, error: true };
          outputs.push(rP.out);
        } else if (target.kind === 'deployment') {
          var depList = Object.values(state.deployments).filter(function (item) {
            if (ns !== null && item.namespace !== ns) return false;
            if (target.name !== null && item.name !== target.name) return false;
            return true;
          });
          var dHeaders = ns === null ? ['NAMESPACE', 'NAME', 'READY', 'UP-TO-DATE', 'AVAILABLE', 'AGE'] : ['NAME', 'READY', 'UP-TO-DATE', 'AVAILABLE', 'AGE'];
          var rD = renderTable(depList, dHeaders, function (dep) {
            var rStr = dep.readyReplicas + '/' + dep.replicas;
            return ns === null
              ? [dep.namespace, dep.name, rStr, String(dep.updatedReplicas), String(dep.availableReplicas), formatAge(dep.ageSeconds)]
              : [dep.name, rStr, String(dep.updatedReplicas), String(dep.availableReplicas), formatAge(dep.ageSeconds)];
          }, 'deployments.apps', target.name);
          if (rD.err) return { output: rD.err, state: state, error: true };
          outputs.push(rD.out);
        } else if (target.kind === 'service') {
          var svcList = Object.values(state.services).filter(function (item) {
            if (ns !== null && item.namespace !== ns) return false;
            if (target.name !== null && item.name !== target.name) return false;
            return true;
          });
          var sHeaders = ns === null ? ['NAMESPACE', 'NAME', 'TYPE', 'CLUSTER-IP', 'EXTERNAL-IP', 'PORT(S)', 'AGE'] : ['NAME', 'TYPE', 'CLUSTER-IP', 'EXTERNAL-IP', 'PORT(S)', 'AGE'];
          var rS = renderTable(svcList, sHeaders, function (svc) {
            return ns === null
              ? [svc.namespace, svc.name, svc.type, svc.clusterIP, svc.externalIP, svc.ports, formatAge(svc.ageSeconds)]
              : [svc.name, svc.type, svc.clusterIP, svc.externalIP, svc.ports, formatAge(svc.ageSeconds)];
          }, 'services', target.name);
          if (rS.err) return { output: rS.err, state: state, error: true };
          outputs.push(rS.out);
        } else if (target.kind === 'namespace') {
          var nsList = Object.values(state.namespaces).filter(function (item) {
            if (target.name !== null && item.name !== target.name) return false;
            return true;
          });
          var rN = renderTable(nsList, ['NAME', 'STATUS', 'AGE'], function (item) {
            return [item.name, item.status, formatAge(item.ageSeconds)];
          }, 'namespaces', target.name);
          if (rN.err) return { output: rN.err, state: state, error: true };
          outputs.push(rN.out);
        } else {
          var genericMap = {
            virtualservice: { map: 'virtualServices', cols: ['NAME', 'GATEWAYS', 'HOSTS', 'AGE'], fn: function (vs) { return [vs.name, '[mesh]', vs.hosts.join(','), formatAge(vs.ageSeconds)]; } },
            destinationrule: { map: 'destinationRules', cols: ['NAME', 'HOST', 'AGE'], fn: function (dr) { return [dr.name, dr.host, formatAge(dr.ageSeconds)]; } },
            peerauthentication: { map: 'peerAuthentications', cols: ['NAME', 'MODE', 'AGE'], fn: function (pa) { return [pa.name, pa.mode, formatAge(pa.ageSeconds)]; } },
            gateway: { map: 'gateways', cols: ['NAME', 'AGE'], fn: function (gw) { return [gw.name, formatAge(gw.ageSeconds)]; } },
            node: { list: state.nodes, cols: ['NAME', 'STATUS', 'ROLES', 'AGE', 'VERSION'], fn: function (n) { return [n.name, n.status, n.roles, formatAge(n.ageSeconds), n.version]; } },
            replicaset: { map: 'replicaSets', cols: ['NAME', 'DESIRED', 'CURRENT', 'READY', 'AGE'], fn: function (rs) { return [rs.name, String(rs.desired), String(rs.current), String(rs.ready), formatAge(rs.ageSeconds)]; } }
          }[target.kind];
    
          if (genericMap) {
            var rawItems = genericMap.list || Object.values(state[genericMap.map]);
            var filtered = rawItems.filter(function (item) {
              if (genericMap.map && item.namespace && ns !== null && item.namespace !== ns) return false;
              if (target.name !== null && item.name !== target.name) return false;
              return true;
            });
            var resGen = renderTable(filtered, genericMap.cols, genericMap.fn, ERROR_TYPE_NAMES[target.kind], target.name);
            if (resGen.err) return { output: resGen.err, state: state, error: true };
            outputs.push(resGen.out);
          }
        }
      }
    
      return { output: outputs.join('\n'), state: state };
    }
    
    function handleIstioctl(state, p) {
      var sub = p.subCmd;
      var ns = resolveNamespace(state, p.flags) || state.currentNamespace || 'shop';
    
      if (sub === 'version') {
        return {
          output: [
            'client version: 1.22.0',
            'control plane version: 1.22.0',
            'data plane version: 1.22.0 (8 proxies)'
          ].join('\n'),
          state: state
        };
      }
    
      if (sub === 'proxy-status' || sub === 'ps') {
        var rows = [
          ['istio-ingressgateway-6d8fc9f4-x9q7s.istio-system', 'SYNCED', 'SYNCED', 'SYNCED', 'SYNCED', 'istiod-75f8f5c98d-j2k9l', '1.22.0']
        ];
        for (var i = 0; i < state.pods.length; i++) {
          var pod = state.pods[i];
          if (pod.sidecar) {
            rows.push([
              pod.name + '.' + pod.namespace,
              'SYNCED',
              'SYNCED',
              'SYNCED',
              'SYNCED',
              'istiod-75f8f5c98d-j2k9l',
              '1.22.0'
            ]);
          }
        }
        return { output: formatColumns(['NAME', 'CDS', 'LDS', 'EDS', 'RDS', 'ISTIOD', 'VERSION'], rows), state: state };
      }
    
      if (sub === 'analyze') {
        var targetNs = ns || 'shop';
        var isNsInjected = Boolean(state.namespaces[targetNs] && state.namespaces[targetNs].labels['istio-injection'] === 'enabled');
        if (!isNsInjected) {
          return {
            output: [
              'Warning [IST0102] (Namespace ' + targetNs + ') The namespace is not enabled for Istio injection.',
              '  Run \'kubectl label namespace ' + targetNs + ' istio-injection=enabled\' to enable automatic injection.',
              'Info [IST0118] (Service details.shop) Port name details-port (service: details.shop) does not follow Istio naming convention.'
            ].join('\n'),
            state: state
          };
        }
        return { output: '✔ No validation issues found when analyzing namespace: ' + targetNs + '.', state: state };
      }
    
      if (sub === 'x' && p.args[0] === 'describe') {
        var pName = p.args[2] || p.args[1] || '';
        if (pName.indexOf('/') !== -1) pName = pName.slice(pName.indexOf('/') + 1);
        var pPod = state.pods.find(function (item) { return (item.namespace === ns || ns === null) && item.name === pName; });
        if (!pPod) return { output: 'Error: pod "' + pName + '" not found in namespace "' + ns + '"', state: state, error: true };
        if (!pPod.sidecar) return { output: 'Pod ' + pPod.name + ' has no Istio sidecar injected.', state: state };
        return {
          output: [
            'Pod: ' + pPod.name, 'Namespace: ' + pPod.namespace, 'Sidecar: istio-proxy (docker.io/istio/proxyv2:1.22.0)',
            'Inbound Port: 9080/HTTP -> 127.0.0.1:9080', 'mTLS: PERMISSIVE (PeerAuthentication default.shop)',
            'VirtualService: ' + (state.virtualServices['reviews'] ? 'reviews.shop (HTTP routes: v2=50%, v3=50%)' : 'none')
          ].join('\n'),
          state: state
        };
      }
    
      return { output: 'istioctl: unknown subcommand "' + sub + '". Try "istioctl proxy-status" or "istioctl analyze".', state: state, error: true };
    }
    
    function executeCommand(state, line) {
      var p = parseCommand(line);
      if (!p.tool) return { output: '', state: state };
    
      if (p.tool === 'clear') return { output: '__CLEAR__', state: state };
    
      if (p.tool === 'reset') {
        var fresh = createInitialCluster();
        Object.keys(state).forEach(function (k) { delete state[k]; });
        Object.assign(state, fresh);
        return { output: 'Cluster reset to initial state.', state: state };
      }
    
      if (p.tool === 'help') {
        return {
          output: [
            'Supported commands:',
            '  kubectl get pods|deploy|svc|ns|vs|dr|pa|nodes [-n ns|-A] [-o wide|name] [-l key=val]',
            '  kubectl describe pod|deploy|svc|ns|vs|dr <name> [-n ns]',
            '  kubectl logs <pod> [-c container] [-n ns]',
            '  kubectl scale deploy <name> --replicas=N',
            '  kubectl delete pod|deploy|svc <name> [-n ns]',
            '  kubectl rollout status|restart|undo deploy/<name> [-n ns]',
            '  kubectl set image deploy/<name> <container>=<image>',
            '  kubectl label namespace <name> <key>=<val>',
            '  kubectl apply -f <manifest.yaml>',
            '  kubectl exec <pod> -- curl -s productpage:9080/productpage',
            '  istioctl version | proxy-status | analyze [-n ns] | x describe pod <p>',
            '  cat <manifest> | ls | hint | reset | clear'
          ].join('\n'),
          state: state
        };
      }
    
      if (p.tool === 'ls') {
        return { output: 'destination-rule.yaml   reviews-v2-v3-split.yaml   fault-delay.yaml   mtls-strict.yaml', state: state };
      }
    
      if (p.tool === 'cat') {
        var fileName = p.subCmd || p.args[0];
        if (fileName && BUILTIN_MANIFESTS[fileName]) return { output: BUILTIN_MANIFESTS[fileName], state: state };
        return { output: 'cat: ' + (fileName || 'missing') + ': No such file or directory', state: state, error: true };
      }
    
      if (p.tool === 'hint') {
        var chks = checkExercises(state);
        if (!chks[0]) return { output: 'Hint (Step 1): Reviews pods show 1/1 READY (no sidecar) because namespace "shop" is not labeled for automatic injection. Run:\n  kubectl label namespace shop istio-injection=enabled', state: state };
        if (!chks[1]) return { output: 'Hint (Step 2): Namespace injection is enabled, but running pods must be restarted to inject Envoy sidecars (2/2). Restart the deployments:\n  kubectl rollout restart deployment reviews-v1 reviews-v2 reviews-v3', state: state };
        if (!chks[2]) return { output: 'Hint (Step 3): Apply destination-rule.yaml and reviews-v2-v3-split.yaml to define subsets and split traffic 50/50 between v2 and v3:\n  kubectl apply -f destination-rule.yaml\n  kubectl apply -f reviews-v2-v3-split.yaml', state: state };
        if (!chks[3]) return { output: 'Hint (Step 4): Apply fault-delay.yaml to introduce a 2s delay on 20% of ratings calls:\n  kubectl apply -f fault-delay.yaml', state: state };
        if (!chks[4]) return { output: 'Hint (Step 5): Apply mtls-strict.yaml to enforce mutual TLS across the shop namespace:\n  kubectl apply -f mtls-strict.yaml', state: state };
        return { output: 'All 5 guided exercises completed! Try traffic burst or kubectl exec curl.', state: state };
      }
    
      if (p.tool === 'kubectl') {
        var sub = p.subCmd;
        if (sub === 'get') return handleKubectlGet(state, p);
        if (sub === 'describe') return handleKubectlDescribe(state, p);
        if (sub === 'delete') return handleKubectlDelete(state, p);
        if (sub === 'scale') return handleKubectlScale(state, p);
        if (sub === 'rollout') return handleKubectlRollout(state, p);
        if (sub === 'set') return handleKubectlSet(state, p);
        if (sub === 'label') return handleKubectlLabel(state, p);
        if (sub === 'apply') return handleKubectlApply(state, p);
        if (sub === 'logs') return handleKubectlLogs(state, p);
        if (sub === 'exec') return handleKubectlExec(state, p);
        return { output: 'kubectl: unknown subcommand "' + sub + '". Type "help" for usage.', state: state, error: true };
      }
    
      if (p.tool === 'istioctl') return handleIstioctl(state, p);
    
      return { output: p.tool + ': command not found. Type "help" for usage.', state: state, error: true };
    }
    
    
      var root = document.querySelector('.kubectl-sim') || document.body;
      var termLog = root.querySelector('#termLog');
      var termInput = root.querySelector('#termInput');
      var mtlsStatus = root.querySelector('#mtlsStatus');
      var activeNs = root.querySelector('#activeNs');
      var statTotal = root.querySelector('#statTotal');
      var statV1 = root.querySelector('#statV1');
      var statV2 = root.querySelector('#statV2');
      var statV3 = root.querySelector('#statV3');
      var statRatings = root.querySelector('#statRatings');
      var statLatency = root.querySelector('#statLatency');
      var meshSvg = root.querySelector('#meshSvg');
      var hintOutput = root.querySelector('#hintOutput');
    
      var btnLoad = root.querySelector('#btnLoad');
      var btnReset = root.querySelector('#btnReset');
      var btnHelp = root.querySelector('#btnHelp');
      var btnHint = root.querySelector('#btnHint');
    
      var cluster = createInitialCluster();
      var history = [];
      var historyIndex = 0;
    
      function appendLog(line, type) {
        if (!termLog) return;
        var p = document.createElement('div');
        p.className = type === 'cmd' ? 'cmd-line' : type === 'err' ? 'cmd-err' : 'cmd-out';
        p.textContent = line;
        termLog.appendChild(p);
        termLog.scrollTop = termLog.scrollHeight;
      }
    
      function renderExercises() {
        var checks = checkExercises(cluster);
        for (var i = 1; i <= 5; i++) {
          var chk = root.querySelector('#chk' + i);
          var item = chk ? chk.parentElement : null;
          if (chk) {
            if (checks[i - 1]) {
              chk.innerHTML = '&#10003;';
              if (item) item.classList.add('done');
            } else {
              chk.innerHTML = '&#9675;';
              if (item) item.classList.remove('done');
            }
          }
        }
      }
    
      function renderTrafficMesh() {
        var topo = computeTopology(cluster);
        var isMtlsStrict = Boolean(cluster.peerAuthentications['default'] && cluster.peerAuthentications['default'].mode === 'STRICT');
        if (mtlsStatus) {
          mtlsStatus.textContent = isMtlsStrict ? 'mTLS strict' : 'mTLS permissive';
          if (isMtlsStrict) mtlsStatus.classList.add('strict');
          else mtlsStatus.classList.remove('strict');
        }
    
        if (activeNs) activeNs.textContent = cluster.currentNamespace;
        if (statTotal) statTotal.textContent = String(cluster.traffic.total);
        if (statV1) statV1.textContent = cluster.traffic.lastSplitV1 + '%';
        if (statV2) statV2.textContent = cluster.traffic.lastSplitV2 + '%';
        if (statV3) statV3.textContent = cluster.traffic.lastSplitV3 + '%';
        if (statRatings) statRatings.textContent = String(cluster.traffic.ratings);
        if (statLatency) statLatency.textContent = cluster.traffic.avgLatencyMs + ' ms';
    
        if (!meshSvg) return;
    
        var parts = [
          '<defs><marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 1 L 10 5 L 0 9 z" fill="var(--ink-2)"/></marker></defs>'
        ];
    
        for (var i = 0; i < topo.edges.length; i++) {
          var e = topo.edges[i];
          parts.push('<path d="' + e.d + '" fill="none" stroke="var(--ink-2)" stroke-width="1.2" marker-end="url(#arrow)"/>');
          if (e.labelBox) {
            var lb = e.labelBox;
            parts.push('<rect x="' + lb.x + '" y="' + lb.y + '" width="' + lb.width + '" height="' + lb.height + '" fill="var(--paper)"/>');
            parts.push('<text x="' + (lb.x + lb.width / 2) + '" y="' + (lb.y + lb.height / 2 + 3) + '" font-family="var(--sans)" font-size="9" text-anchor="middle" fill="var(--ink-2)" stroke="var(--paper)" stroke-width="2.5" paint-order="stroke fill">' + lb.text + '</text>');
          }
        }
    
        for (var j = 0; j < topo.nodes.length; j++) {
          var n = topo.nodes[j];
          parts.push('<g class="mesh-node" transform="translate(' + n.x + ', ' + n.y + ')">');
          parts.push('<rect width="' + n.width + '" height="' + n.height + '" rx="3" fill="var(--paper)" stroke="var(--ink)" stroke-width="1"/>');
          parts.push('<text x="' + (n.width / 2) + '" y="' + (n.height / 2 - 1) + '" font-family="var(--sans)" font-size="10" font-weight="600" text-anchor="middle" fill="var(--ink)">' + n.title + '</text>');
          parts.push('<text x="' + (n.width / 2) + '" y="' + (n.height / 2 + 10) + '" font-family="var(--mono)" font-size="8" text-anchor="middle" fill="var(--ink-2)">' + n.sub + '</text>');
          parts.push('</g>');
        }
    
        meshSvg.innerHTML = parts.join('');
      }
      function renderUi() {
        renderExercises();
        renderTrafficMesh();
      }
    
      function handleLine(cmd) {
        if (!cmd.trim()) return;
        history.push(cmd);
        historyIndex = history.length;
        appendLog('$ ' + cmd, 'cmd');
    
        var res = executeCommand(cluster, cmd);
        if (res.output === '__CLEAR__') {
          if (termLog) termLog.innerHTML = '';
        } else if (res.output) {
          appendLog(res.output, res.error ? 'err' : 'out');
        }
        renderUi();
      }
    
      if (termInput) {
        termInput.addEventListener('keydown', function (e) {
          if (e.key === 'Enter') {
            e.preventDefault();
            var val = termInput.value;
            termInput.value = '';
            handleLine(val);
          } else if (e.key === 'ArrowUp') {
            e.preventDefault();
            if (history.length > 0) {
              if (historyIndex > 0) historyIndex--;
              else historyIndex = 0;
              termInput.value = history[historyIndex] || '';
            }
          } else if (e.key === 'ArrowDown') {
            e.preventDefault();
            if (historyIndex < history.length - 1) {
              historyIndex++;
              termInput.value = history[historyIndex] || '';
            } else {
              historyIndex = history.length;
              termInput.value = '';
            }
          } else if (e.key === 'l' && e.ctrlKey) {
            e.preventDefault();
            if (termLog) termLog.innerHTML = '';
          } else if (e.key === 'Tab') {
            e.preventDefault();
            var current = termInput.value;
            var tokens = current.split(' ');
            var lastTok = tokens[tokens.length - 1] || '';
            var candidates = [
              'kubectl', 'istioctl', 'help', 'hint', 'clear', 'reset', 'cat', 'ls',
              'get', 'describe', 'logs', 'scale', 'delete', 'rollout', 'set', 'label', 'apply', 'exec',
              'pods', 'deployments', 'services', 'virtualservices', 'destinationrules', 'peerauthentications',
              'destination-rule.yaml', 'reviews-v2-v3-split.yaml', 'fault-delay.yaml', 'mtls-strict.yaml'
            ];
            Object.keys(cluster.deployments).forEach(function (d) {
              candidates.push(d);
              candidates.push('deploy/' + d);
              candidates.push('deployment/' + d);
            });
            cluster.pods.forEach(function (pod) {
              candidates.push(pod.name);
              candidates.push('pod/' + pod.name);
            });
            var matches = candidates.filter(function (c) { return c.indexOf(lastTok) === 0; });
            if (matches.length === 1) {
              tokens[tokens.length - 1] = matches[0];
              termInput.value = tokens.join(' ') + ' ';
            } else if (matches.length > 1) {
              appendLog(matches.join('   '), 'out');
            }
          }
        });
      }
    
      if (btnLoad) {
        btnLoad.addEventListener('click', function () {
          var res = simulateTraffic(cluster, 100);
          appendLog('$ simulate-traffic 100', 'cmd');
          appendLog(res.output, 'out');
          renderUi();
        });
      }
    
      if (btnReset) {
        btnReset.addEventListener('click', function () {
          var res = executeCommand(cluster, 'reset');
          if (termLog) termLog.innerHTML = '';
          appendLog(res.output, 'out');
          renderUi();
        });
      }
    
      if (btnHelp) {
        btnHelp.addEventListener('click', function () {
          var res = executeCommand(cluster, 'help');
          appendLog('$ help', 'cmd');
          appendLog(res.output, 'out');
        });
      }
    
      if (btnHint) {
        btnHint.addEventListener('click', function () {
          var res = executeCommand(cluster, 'hint');
          if (hintOutput) hintOutput.textContent = res.output;
          appendLog('$ hint', 'cmd');
          appendLog(res.output, 'out');
        });
      }
    
      appendLog('Kubernetes v1.28.2 · Istio 1.22.0 · Bookinfo demo in namespace "shop"', 'out');
      appendLog('Type "help" for commands, "hint" for exercises, or click buttons above.', 'out');
      renderUi();
    
    })();

    A half-day Istio workshop as a handout: the afternoon as a table, every command as a step, and a cluster in the page for the laptop that will not start one. See the recipe

  10. 33A standard operating procedureWork and teams

    Document control · Controlled copy

    SOP number: SOP-PA-014

    Revision: 6, superseding revision 5 of 3 March 2025

    Owner: Pre-analytical supervisor, R. Adeyemi

    Approved by: Laboratory manager

    Effective: 1 October 2026

    Next review: 1 October 2028

    Printed copies are uncontrolled. Check a printed revision number against this page before you rely on it.

    Document control on a card, the procedure as numbered steps, the accept-or-reject call as a flowchart, and every revision kept with its reason. See the recipe

  11. 16A Regents study shelfStudy and exams

    Civil rights, from Brown to the Voting Rights Act

    • 17 May 1954 — Brown v. Board of Education

      A unanimous Supreme Court rules segregated public schools unconstitutional, overturning Plessy’s “separate but equal” in education.

    • 5 December 1955 – 20 December 1956 — Montgomery bus boycott

      Begun after Rosa Parks’s arrest; it ends once the Supreme Court upholds a ruling against segregated buses.

    • 25 September 1957 — The Little Rock Nine

      Nine Black students enter Central High School under the protection of federal troops sent by Eisenhower.

    • 1 February 1960 — Greensboro sit-in

      Four students sit at a whites-only lunch counter; sit-ins spread across the South.

    • 28 August 1963 — March on Washington

      About 250,000 people; Martin Luther King Jr. gives the “I Have a Dream” speech.

    • 23 January 1964 — Twenty-fourth Amendment ratified

      Poll taxes are banned in federal elections.

    • 2 July 1964 — Civil Rights Act

      Signed by Lyndon Johnson: bans discrimination in public accommodations and employment.

    • 7 March 1965 — “Bloody Sunday” at Selma

      State troopers attack marchers on the Edmund Pettus Bridge.

    • 6 August 1965 — Voting Rights Act

      Suspends literacy tests and brings federal oversight of registration where discrimination had kept Black voters off the rolls.

    Every topic a document with its timeline, the Supreme Court cases as flashcards, quizzes that come back when due, and a plan that runs to June. See the recipe

  12. 53A fantasy league almanacHobbies and play

    Points by week, the four top-scoring teams, weeks 1 to 5

    WeekFourth and LongHail Mary ClubTwo-Minute DrillThe Taxi Squad
    Week 1118.4 points102.6 points125.9 points91.3 points
    Week 296.2 points124.8 points83.7 points108.5 points
    Week 3131.7 points88.3 points99.2 points112 points
    Week 4104.9 points115.1 points121.6 points79.4 points
    Week 5122.3 points109.6 points94.5 points127.8 points

    Values: Points scored

    Marked at Week 5: The Monday comeback.

    Source: Weekly scores from the league’s platform, half-point per reception.

    The rules email everyone lost, a Tuesday report with the weekend’s injuries and waiver targets, and a scoring calculator that ends the argument. See the recipe

  13. 51A forecasting studyResearch and civic life

    Chance Measure R passes: the market against my forecast, each Sunday

    Week endingMarketMy forecast
    2 Aug52%45%
    9 Aug55%45%
    16 Aug54%45%
    23 Aug61%52%
    30 Aug58%52%
    6 Sep60%52%
    13 Sep66%57%
    20 Sep60%55%
    27 Sep62%55%

    Values: Chance of Yes

    The value axis runs from 30% to 80%.

    Reference line at 50%: Even.

    Marked at 23 Aug: Chamber endorses.

    Marked at 13 Sep: Poll: 54% Yes.

    Marked at 20 Sep: No campaign starts.

    Source: The market’s implied probability, noted each Sunday evening. Port Calder, Measure R and every figure here are invented.

    Pick one question a prediction market is forecasting, pin its rules, find the base rate, weigh every piece of evidence, and log each change to your forecast. See the recipe

  14. 37A small-group Bible studyFaith and study

    The Tuesday group’s six weeks in Philippians

    Week and passageTheme, and a question to open with
    1. Chapter 1Paul in prison, the gospel spreading. What good does Paul see coming from his chains (1:12–14)?
    2. 2:1–4Of one mind. What does Paul ask of the church, and on what grounds (2:1–2)?
    3. 2:5–11The mind of Christ. Where does the passage turn, and what word marks the turn (2:9)?
    4. 2:12–18Working out what God works in. How do verses 12 and 13 belong together?
    5. 2:19–30Timothy and Epaphroditus. What does Paul value in each man, and why tell the church (2:20–22, 2:25–30)?
    6. Chapters 3 and 4Pressing on, and content. What does Paul count as loss, and for what (3:7–8)?

    Weeks 2 to 5 are the four evenings in this study. Read the whole letter at home in week 1, and again before week 6.

    Four evenings on Philippians 2 in one document: the passage, its context and good questions for the group, with your leader notes kept off their link. See the recipe

  15. 34A sprint bookWork and teams

    The board, Thursday morning

    To do: the swap audit log, 3 points. In progress: blocking swaps that break the 11-hour rest rule, 8 points, and the swap screens’ copy, 2 points. Done: swap request, one-tap approval, the approval alert, swap limits, the accessibility pass and the rota-week bug.

    • To do
      • Swap audit log · 3, assigned to Wen, ticket SHF-216, low priority
    • In progress
      • Block swaps that break the 11-hour rest rule · 8, assigned to Priya, ticket SHF-215, very high priority
      • Swap screens copy · 2, assigned to Marta, ticket SHF-219
    • Done
      • Swap request · 5, assigned to Tomás, ticket SHF-212
      • One-tap approval · 5, assigned to Ade, ticket SHF-213
      • Approval alert · 3, assigned to Tomás, ticket SHF-214
      • Swap limits per month · 5, assigned to Ade, ticket SHF-217
      • Accessibility pass · 3, assigned to Marta, ticket SHF-218
      • Rota shows the wrong week · 3, assigned to Wen, ticket SHF-221, high priority

    One document per sprint with the goal on top, the board and the burndown under it, the two weeks as a Gantt, and every retro decision kept. See the recipe

  16. 35An ad campaign fileWork and teams

    Cost per trial by week, paid social and search

    WeekPaid socialSearch
    Wk 1£24.6£19.8
    Wk 2£21.3£19.1
    Wk 3£18.9£18.4
    Wk 4£16.1£18.7
    Wk 5£15.2£17.9
    Wk 6£14.7£18.2

    Values: Cost per trial

    Reference line at £18: Target, £18.

    Marked at Wk 3: Founder video cut.

    Source: Platform exports, Monday to Sunday; a trial is a first bag ordered with a card on file.

    Cost per trial falling week by week, the winning ad marked in the variants table, and every cut creative filed with the numbers that killed it. See the recipe

  17. 36PCI DSS readinessWork and teams

    Controls in place by requirement, per cent, May to September

    RequirementMayJunJulAugSep
    1 Network security controls4055708590
    2 Secure configurations3045607080
    3 Protect stored account data608095100100
    4 Cryptography in transit708090100100
    5 Malicious software5060758085
    6 Secure systems and software2030455565
    7 Access by need to know4555657585
    8 Identify and authenticate2540506070
    9 Physical access80859095100
    10 Log and monitor2030405560
    11 Test security regularly1525354555
    12 Policies and programmes3545607080

    Columns: Month

    Units: %

    Shaded in five steps: 0–20%, 20–40%, 40–60%, 60–80% and 80%+.

    Highest: 3 Protect stored account data, Aug, 100%. Lowest: 11 Test security regularly, May, 15%.

    Source: Share of each requirement’s applicable controls marked In place in its document, at month end.

    All twelve PCI DSS v4.0.1 requirements shaded month by month, evidence moving across a board, and every scope decision on the record before the assessor asks. See the recipe

  18. 60A Hobonichi planning libraryHobbies and play

    Habits kept each day, of five, June to September

    Week ofMoTuWeThFrSaSu
    1 Jun2322223
    8 Jun3433303
    15 Jun3311212
    22 Jun4434424
    29 Jun4533333
    6 Jul4333523
    13 Jul4245233
    20 Jul3442434
    27 Jul5442422
    3 Aug2335213
    10 Aug0005411
    17 Aug4334533
    24 Aug5555525
    31 Aug5524523
    7 Sep5355444
    14 Sep4544533
    21 Sep554343

    385 habits over 118 days; the most on Tuesday 30 June, 5 habits.

    Shades: 0–1, 1–2, 2–3, 3–4 and 4+ habits

    Marked on Monday 22 June: The walk moves to before work.

    Marked on Monday 10 August: Flu, three days.

    Marked on Monday 24 August: The phone charges outside the bedroom.

    Source: Counted from the habit strip on each daily page, 1 June to 26 September.

    A summer of habits shaded day by day, the year’s goals on one chart, every pen and ink you own with how it behaves on thin paper, and a daily page to plan. See the recipe

  19. 50A book club guideResearch and civic life

    Our two Wharton novels: what each has alone, and what they share

    Two overlapping circles. Ethan Frome, 1911, alone: Starkfield, Massachusetts, in winter; a poor hill farm; told by an outsider, an engineer working nearby; the sledding crash. The Age of Innocence, 1920, alone: New York society in the 1870s; the opera and the ballrooms; seen through Newland Archer; the 1921 Pulitzer Prize. Both: a man who loves his wife’s cousin, a town that watches, he never leaves, and an ending set years later.

    • Ethan Frome (1911)
      • Starkfield, in winter
      • A poor hill farm
      • Told by an outsider
      • The sledding crash
    • The Age of Innocence (1920)
      • New York, the 1870s
      • Opera and ballrooms
      • Seen through Archer
      • Pulitzer Prize, 1921
    • Both, where Ethan Frome (1911) and The Age of Innocence (1920) overlap
      • Loves his wife’s cousin
      • A town that watches
      • He never leaves
      • Ends years later

    Your assistant researches the author, the era and the reviews and drafts the questions; you pick the best, write the welcome and send one link per meeting. See the recipe

Every recipe

Books and writingnine recipes

Screenplays, comic books, novels, worlds and memoirs, with the bibles that keep them straight.

Study and examsseven recipes

Study guides, outlines and oral-exam prep you quiz yourself on, term after term.

Teaching and coursesseven recipes

Syllabi, novel units, study guides and open textbooks a class reads from one link.

Labs and workshopsseven recipes

Chemistry, physics, electronics, code and a cluster: lab manuals with working models to press, drag and fly.

Work and teamssix recipes

Runbooks, SOPs, sprints, ad campaigns, compliance and the week’s AI benchmarks: the documents a team leans on.

Faith and studytwo recipes

A chapter studied with a group, and a classic read slowly, with notes that last.

Kitchen and tablefour recipes

Family recipes, a cookbook in progress, a year of bakes and a kitchen’s own manual.

Home and familythree recipes

A family history written from the records, the guide to a trip you take together, and a story to hear on the way.

Research and civic lifesix recipes

Literature reviews, a thesis, a story file, oral histories and a book club’s season.

Hobbies and playnine recipes

Campaign settings, fantasy leagues, chess, puzzle hunts, gardens, brews and builds.

Looking for something?

The A–Z indexThe pantryHow this book was made