[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"playground-tab-leader-election-weblocks-broadcastchannel":44,"search-suggestions":58},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"slug":45,"title":46,"summary":47,"html":48,"css":49,"js":50,"config":51,"doc":53},"tab-leader-election-weblocks-broadcastchannel","Tab leader election — interactive playground",null,"\u003Cdiv class=\"pg\">\n    \u003Ch1 class=\"pg-title\">🗳️ Tab leader election\u003C\u002Fh1>\n    \u003Cp class=\"pg-sub\">\n      Three simulated tabs, one real \u003Ccode>navigator.locks\u003C\u002Fcode> lock. Open a couple of\n      them, watch one become the \u003Cstrong>leader\u003C\u002Fstrong>, then close it and watch the\n      next one in line take over automatically — no polling, no timeout.\n    \u003C\u002Fp>\n\n    \u003Cdiv class=\"panel\">\n      \u003Cdiv class=\"panel-label\">\u003Cspan class=\"n\">1\u003C\u002Fspan> Your tabs\u003C\u002Fdiv>\n      \u003Cdiv class=\"tabrow\" id=\"tabrow\">\u003C!-- tab cards rendered here -->\u003C\u002Fdiv>\n      \u003Cdiv class=\"btn-row\" style=\"margin-top:14px\">\n        \u003Cbutton class=\"btn ghost\" id=\"reset\">Reset everything\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n      \u003Cp class=\"hint\">Open two or three. The first one in wins the lock and becomes leader — the rest wait in a real FIFO queue, same as the browser uses for actual browser tabs.\u003C\u002Fp>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"panel\">\n      \u003Cdiv class=\"panel-label\">\u003Cspan class=\"n\">2\u003C\u002Fspan> What's happening\u003C\u002Fdiv>\n      \u003Cdiv class=\"status\" style=\"margin-bottom:10px\">\n        \u003Cspan class=\"dot\" id=\"dot\">\u003C\u002Fspan>\u003Cspan id=\"statusText\">Open a tab to start.\u003C\u002Fspan>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"log\" id=\"log\" style=\"max-height:220px; overflow:auto\">\u003C\u002Fdiv>\n      \u003Cp class=\"hint\" id=\"hint\">The leader ticks every 1.5s and relays the tick to every tab over \u003Ccode>BroadcastChannel\u003C\u002Fcode> — that's the \"server push\" being fanned out for free.\u003C\u002Fp>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"banner info\" id=\"unsupported\" style=\"display:none\">\n      Your browser doesn't support \u003Ccode>navigator.locks\u003C\u002Fcode> or \u003Ccode>BroadcastChannel\u003C\u002Fcode>.\n      Try this in a recent Chrome, Firefox, or Safari (15.4+).\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>","\u002F*\n * Shared playground design system — daily-post\n * ============================================\n * Link it from any post's playground\u002Findex.html (no per-post boilerplate):\n *\n *   \u003Clink rel=\"stylesheet\" href=\"..\u002F..\u002F..\u002Fplayground\u002Fassets\u002Fbase.css\" \u002F>\n *\n * The relative path resolves the same locally (posts\u002F\u003Cbase>\u002Fplayground\u002F) and on\n * the deployed host (public\u002Fposts\u002F\u003Cbase>\u002Fplayground\u002F), because build-site.js\n * mirrors this file to public\u002Fplayground\u002Fassets\u002Fbase.css. For a CodeSandbox\n * --embed, playground.js inlines this file so the single-file sandbox still styles.\n *\n * Then write ONLY the demo-specific CSS in your inline \u003Cstyle>. Everything below\n * — tokens, layout, panels, buttons, toggles, sliders, code blocks, status\u002Flog,\n * banners, presets — comes for free and looks consistent across every post.\n *\n * Theme-aware: dark by default, light via prefers-color-scheme AND a\n * [data-theme] override on \u003Chtml> (so a toggle can force either). Accessible:\n * real focus rings, adequate contrast, reduced-motion honored.\n *\u002F\n\n\u002F* ---------- design tokens ---------- *\u002F\n:root {\n  --bg: #0d1117;\n  --bg-grid: #ffffff0a;\n  --panel: #161b22;\n  --panel-2: #0b0e14;\n  --card: #0b0e14;\n  --ink: #e8ebf2;\n  --muted: #9aa4b2;\n  --faint: #6b7688;\n  --line: #232a35;\n  --line-soft: #ffffff12;\n  --accent: #7c9cff;\n  --accent-ink: #0b0e14;\n  --good: #3fb950;\n  --warn: #f0883e;\n  --bad: #f85149;\n  --info: #58a6ff;\n  --radius: 14px;\n  --radius-sm: 9px;\n  --shadow: 0 8px 30px #0006;\n  --shadow-sm: 0 4px 14px #0005;\n  --mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n  --sans: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", sans-serif;\n  color-scheme: dark;\n}\n\n\u002F* Light theme — system preference, overridable by [data-theme] on \u003Chtml>. *\u002F\n@media (prefers-color-scheme: light) {\n  :root:not([data-theme=\"dark\"]) {\n    --bg: #f4f6fb;\n    --bg-grid: #00000008;\n    --panel: #ffffff;\n    --panel-2: #f0f3f9;\n    --card: #f7f9fc;\n    --ink: #131722;\n    --muted: #5b6472;\n    --faint: #8a93a3;\n    --line: #e2e7f0;\n    --line-soft: #00000010;\n    --accent: #2f6df0;\n    --accent-ink: #ffffff;\n    --good: #1a7f37;\n    --warn: #bc4c00;\n    --bad: #cf222e;\n    --info: #0969da;\n    --shadow: 0 8px 30px #0000001f;\n    --shadow-sm: 0 4px 14px #00000014;\n    color-scheme: light;\n  }\n}\n:root[data-theme=\"light\"] {\n  --bg: #f4f6fb;\n  --bg-grid: #00000008;\n  --panel: #ffffff;\n  --panel-2: #f0f3f9;\n  --card: #f7f9fc;\n  --ink: #131722;\n  --muted: #5b6472;\n  --faint: #8a93a3;\n  --line: #e2e7f0;\n  --line-soft: #00000010;\n  --accent: #2f6df0;\n  --accent-ink: #ffffff;\n  --good: #1a7f37;\n  --warn: #bc4c00;\n  --bad: #cf222e;\n  --info: #0969da;\n  --shadow: 0 8px 30px #0000001f;\n  --shadow-sm: 0 4px 14px #00000014;\n  color-scheme: light;\n}\n\n\u002F* ---------- reset & base ---------- *\u002F\n*, *::before, *::after { box-sizing: border-box; }\n\nbody {\n  margin: 0;\n  min-height: 100vh;\n  font-family: var(--sans);\n  color: var(--ink);\n  background:\n    radial-gradient(circle at 1px 1px, var(--bg-grid) 1px, transparent 0) 0 0 \u002F 24px 24px,\n    var(--bg);\n  display: flex;\n  justify-content: center;\n  align-items: flex-start;\n  padding: clamp(14px, 3vw, 26px);\n  line-height: 1.55;\n  -webkit-font-smoothing: antialiased;\n  text-rendering: optimizeLegibility;\n}\n\n\u002F* The single content column. *\u002F\n.pg {\n  width: 100%;\n  max-width: 860px;\n}\n\n\u002F* ---------- header ---------- *\u002F\n.pg-title {\n  font-size: clamp(1.15rem, 1rem + 1vw, 1.4rem);\n  font-weight: 800;\n  letter-spacing: -0.02em;\n  margin: 0 0 4px;\n  display: flex;\n  align-items: center;\n  gap: 9px;\n}\n.pg-sub {\n  color: var(--muted);\n  font-size: 0.92rem;\n  line-height: 1.5;\n  margin: 0 0 16px;\n  max-width: 66ch;\n}\n.pg-sub strong, .pg-sub b { color: var(--ink); font-weight: 700; }\n.pg-sub code { font-family: var(--mono); background: var(--line-soft); color: var(--accent);\n  padding: 1px 6px; border-radius: 6px; font-size: 0.85em; }\n\n\u002F* ---------- panels ---------- *\u002F\n.panel {\n  background: var(--panel);\n  border: 1px solid var(--line);\n  border-radius: var(--radius);\n  padding: 16px 18px;\n  margin-bottom: 14px;\n  box-shadow: var(--shadow-sm);\n}\n.panel-label {\n  font-size: 0.7rem;\n  text-transform: uppercase;\n  letter-spacing: 0.08em;\n  color: var(--muted);\n  font-weight: 800;\n  margin: 0 0 13px;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n.panel-label .n {\n  display: inline-grid; place-items: center;\n  width: 18px; height: 18px; border-radius: 50%;\n  background: var(--accent); color: var(--accent-ink);\n  font-size: 0.66rem; font-weight: 800;\n}\n\n\u002F* ---------- layout helpers ---------- *\u002F\n.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }\n.row.between { justify-content: space-between; }\n.col { display: flex; flex-direction: column; gap: 10px; }\n.grow { flex: 1 1 0; min-width: 0; }\n.wrap-controls { display: flex; flex-wrap: wrap; gap: 16px 24px; align-items: flex-end; }\n.field { display: flex; flex-direction: column; gap: 7px; min-width: 0; }\n.field > .label, .ctl-label {\n  font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;\n  color: var(--muted); font-weight: 800;\n}\n\n\u002F* ---------- buttons ---------- *\u002F\n.btn {\n  font: inherit; font-weight: 650; font-size: 0.88rem;\n  cursor: pointer; user-select: none;\n  border: 1px solid var(--line);\n  background: var(--card); color: var(--ink);\n  padding: 9px 15px; border-radius: var(--radius-sm);\n  display: inline-flex; align-items: center; gap: 8px;\n  transition: background .15s ease, border-color .15s ease, transform .05s ease, color .15s ease;\n}\n.btn:hover { background: var(--line-soft); border-color: var(--muted); }\n.btn:active { transform: translateY(1px); }\n.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n.btn[disabled] { opacity: 0.45; cursor: not-allowed; }\n.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }\n.btn.primary:hover { filter: brightness(1.08); background: var(--accent); }\n.btn.good { background: var(--good); border-color: var(--good); color: #04140a; }\n.btn.bad  { background: var(--bad);  border-color: var(--bad);  color: #fff; }\n.btn.ghost { background: transparent; }\n.btn.sm { padding: 6px 11px; font-size: 0.82rem; }\n\n.btn-row { display: flex; flex-wrap: wrap; gap: 9px; }\n\n\u002F* Segmented control — a row of buttons where one is active (aria-pressed). *\u002F\n.seg { display: inline-flex; background: var(--card); border: 1px solid var(--line);\n  border-radius: var(--radius-sm); padding: 3px; gap: 3px; }\n.seg button {\n  font: inherit; font-weight: 650; font-size: 0.84rem; cursor: pointer;\n  border: none; background: transparent; color: var(--muted);\n  padding: 7px 13px; border-radius: 7px;\n  transition: background .15s ease, color .15s ease;\n}\n.seg button:hover { color: var(--ink); }\n.seg button[aria-pressed=\"true\"] { background: var(--accent); color: var(--accent-ink); }\n.seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n\n\u002F* Preset chips — worked-example shortcuts. *\u002F\n.presets { display: flex; flex-wrap: wrap; gap: 8px; }\n.presets .btn { background: var(--panel-2); }\n\n\u002F* ---------- toggle switch ---------- *\u002F\n.toggle { display: inline-flex; align-items: center; gap: 10px; cursor: pointer;\n  font-size: 0.88rem; font-weight: 600; user-select: none; }\n.toggle input { position: absolute; opacity: 0; width: 0; height: 0; }\n.switch { position: relative; width: 44px; height: 25px; border-radius: 999px; flex: none;\n  background: var(--line); border: 1px solid var(--line); transition: background .18s ease; }\n.switch::after { content: \"\"; position: absolute; top: 2px; left: 2px; width: 19px; height: 19px;\n  border-radius: 50%; background: #cfd6e4; transition: transform .18s ease; box-shadow: 0 1px 3px #0006; }\n.toggle input:checked + .switch { background: var(--good); border-color: var(--good); }\n.toggle input:checked + .switch::after { transform: translateX(19px); background: #fff; }\n.toggle input:focus-visible + .switch { outline: 2px solid var(--accent); outline-offset: 2px; }\n\n\u002F* ---------- range slider ---------- *\u002F\ninput[type=\"range\"] {\n  -webkit-appearance: none; appearance: none; width: 100%; height: 6px;\n  background: var(--line); border-radius: 999px; outline: none; cursor: pointer; margin: 8px 0;\n}\ninput[type=\"range\"]::-webkit-slider-thumb {\n  -webkit-appearance: none; appearance: none; width: 19px; height: 19px; border-radius: 50%;\n  background: var(--accent); border: 2px solid var(--panel); box-shadow: var(--shadow-sm); cursor: pointer;\n}\ninput[type=\"range\"]::-moz-range-thumb {\n  width: 19px; height: 19px; border-radius: 50%; background: var(--accent);\n  border: 2px solid var(--panel); box-shadow: var(--shadow-sm); cursor: pointer;\n}\ninput[type=\"range\"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }\n\n\u002F* ---------- text inputs \u002F selects ---------- *\u002F\ninput[type=\"text\"], input[type=\"number\"], input[type=\"search\"], select, textarea {\n  font: inherit; color: var(--ink); background: var(--card);\n  border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 8px 11px; width: 100%;\n}\ntextarea { font-family: var(--mono); font-size: 0.85rem; line-height: 1.6; resize: vertical; }\ninput:focus-visible, select:focus-visible, textarea:focus-visible {\n  outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent);\n}\n\n\u002F* ---------- status \u002F output \u002F log ---------- *\u002F\n.status {\n  display: flex; align-items: center; gap: 9px; flex-wrap: wrap;\n  font-size: 0.9rem; font-weight: 600;\n  padding: 11px 14px; border-radius: var(--radius-sm);\n  background: var(--panel-2); border: 1px solid var(--line); color: var(--ink);\n}\n.status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n.status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n.status.bad  { border-color: color-mix(in srgb, var(--bad) 55%, var(--line)); }\n.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: none; }\n.dot.good { background: var(--good); } .dot.warn { background: var(--warn); }\n.dot.bad { background: var(--bad); } .dot.info { background: var(--info); }\n\n.output { background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--radius-sm);\n  padding: 13px 15px; }\n.log { font-family: var(--mono); font-size: 0.8rem; line-height: 1.7; background: var(--card);\n  border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 11px 14px;\n  max-height: 220px; overflow-y: auto; white-space: pre-wrap; }\n\n\u002F* ---------- code block + syntax tokens ---------- *\u002F\n.code {\n  font-family: var(--mono); font-size: 0.82rem; line-height: 1.7;\n  background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--radius-sm);\n  padding: 13px 15px; overflow-x: auto; white-space: pre; tab-size: 2;\n}\n.code .tok-sel  { color: var(--accent); }\n.code .tok-prop { color: #86e29b; }\n.code .tok-kw   { color: #ff9bcb; }\n.code .tok-str  { color: #f0c674; }\n.code .tok-num  { color: #f0883e; }\n.code .tok-cmt  { color: var(--faint); font-style: italic; }\n.code .hl { background: color-mix(in srgb, var(--good) 22%, transparent); border-radius: 4px; padding: 0 3px; }\n\n\u002F* ---------- hint \u002F caption ---------- *\u002F\n.hint { font-size: 0.83rem; color: var(--muted); line-height: 1.55; margin: 10px 0 0; }\n.hint code { font-family: var(--mono); color: var(--ink); background: var(--line-soft);\n  padding: 1px 5px; border-radius: 5px; font-size: 0.86em; }\n.hint strong { color: var(--ink); }\n\n\u002F* ---------- banner (feature-support \u002F notices) ---------- *\u002F\n.banner {\n  display: none; margin-bottom: 14px; padding: 11px 14px; border-radius: 11px;\n  font-size: 0.85rem; line-height: 1.5;\n  color: var(--ink); background: color-mix(in srgb, var(--warn) 12%, var(--panel));\n  border: 1px solid color-mix(in srgb, var(--warn) 45%, var(--line));\n}\n.banner.show { display: block; }\n.banner.info { background: color-mix(in srgb, var(--info) 12%, var(--panel));\n  border-color: color-mix(in srgb, var(--info) 45%, var(--line)); }\n.banner code { font-family: var(--mono); background: var(--line-soft); padding: 1px 5px; border-radius: 5px; }\n\n\u002F* ---------- misc atoms ---------- *\u002F\n.pill { display: inline-flex; align-items: center; gap: 6px; font-size: 0.75rem; font-weight: 700;\n  padding: 3px 9px; border-radius: 999px; background: var(--line-soft); color: var(--muted); }\n.pill.good { color: var(--good); background: color-mix(in srgb, var(--good) 16%, transparent); }\n.pill.bad  { color: var(--bad);  background: color-mix(in srgb, var(--bad) 16%, transparent); }\n.mono { font-family: var(--mono); }\n.muted { color: var(--muted); }\n.big-num { font-family: var(--mono); font-size: 1.6rem; font-weight: 800; letter-spacing: -0.02em; }\n\n\u002F* Optional theme toggle button (top-right). Wire it in JS if you want it. *\u002F\n.theme-btn { position: fixed; top: 12px; right: 12px; z-index: 20; }\n\n\u002F* ---------- responsive ---------- *\u002F\n@media (max-width: 560px) {\n  .panel { padding: 14px; }\n  .wrap-controls { gap: 14px; }\n  .btn { padding: 9px 13px; }\n}\n\n\u002F* ---------- reduced motion ---------- *\u002F\n@media (prefers-reduced-motion: reduce) {\n  *, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }\n}\n\n\n.tabrow { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-top: 14px; }\n  .tabcard {\n    border: 1px solid var(--line);\n    border-radius: var(--radius-sm);\n    background: var(--panel-2);\n    padding: 12px;\n    display: flex;\n    flex-direction: column;\n    gap: 8px;\n    align-items: stretch;\n  }\n  .tabcard .name { font-weight: 700; font-size: 1rem; }\n  .tabcard .badge {\n    display: inline-block;\n    font-size: 0.78rem;\n    padding: 2px 8px;\n    border-radius: 999px;\n    width: fit-content;\n    border: 1px solid var(--line);\n  }\n  .badge.closed { color: var(--muted); }\n  .badge.waiting { color: #d79a1f; border-color: #d79a1f55; background: #d79a1f1a; }\n  .badge.leader { color: #2fb36a; border-color: #2fb36a55; background: #2fb36a1a; }\n  .tabcard .btn { width: 100%; }\n  .ticks { font-variant-numeric: tabular-nums; }","\"use strict\";\n\nconst supported = ('locks' in navigator) && (typeof BroadcastChannel !== 'undefined');\nif (!supported) {\n  document.getElementById('unsupported').style.display = 'block';\n}\n\nconst SESSION = Math.random().toString(36).slice(2);\nlet LOCK_NAME = 'pg-leader-' + SESSION;\nlet CHANNEL_NAME = 'pg-updates-' + SESSION;\n\u002F\u002F Two channel objects, same name: a BroadcastChannel never delivers a message\n\u002F\u002F back to the instance that sent it (by design), so posting and listening\n\u002F\u002F need separate instances here — exactly like two separate real browser tabs would have.\nlet txChannel = supported ? new BroadcastChannel(CHANNEL_NAME) : null;\nlet rxChannel = supported ? new BroadcastChannel(CHANNEL_NAME) : null;\n\nconst IDS = ['A', 'B', 'C'];\nlet tabs = {};\nlet tickCounters = {};\n\nfunction freshTab() {\n  return { open: false, status: 'closed', abort: null, release: null, interval: null };\n}\nIDS.forEach((id) => { tabs[id] = freshTab(); tickCounters[id] = 0; });\n\nconst $ = (sel) => document.querySelector(sel);\nconst logEl = $('#log');\nconst rowEl = $('#tabrow');\n\nfunction log(text, kind) {\n  const line = document.createElement('div');\n  line.textContent = text;\n  if (kind) line.style.color = kind === 'warn' ? '#d79a1f' : kind === 'good' ? '#2fb36a' : '';\n  logEl.prepend(line);\n  while (logEl.children.length > 40) logEl.removeChild(logEl.lastChild);\n}\n\nfunction setStatus(text, kind = 'info') {\n  $('#statusText').textContent = text;\n  $('#dot').className = 'dot ' + kind;\n}\n\nfunction render() {\n  rowEl.innerHTML = '';\n  let anyLeader = false;\n  IDS.forEach((id) => {\n    const t = tabs[id];\n    if (t.status === 'leader') anyLeader = true;\n    const card = document.createElement('div');\n    card.className = 'tabcard';\n\n    const name = document.createElement('div');\n    name.className = 'name';\n    name.textContent = 'Tab ' + id;\n    card.appendChild(name);\n\n    const badge = document.createElement('span');\n    badge.className = 'badge ' + t.status;\n    badge.textContent = t.status === 'closed' ? 'closed'\n      : t.status === 'waiting' ? 'waiting in queue'\n      : '👑 leader';\n    card.appendChild(badge);\n\n    const toggle = document.createElement('button');\n    toggle.className = 'btn ' + (t.open ? 'bad sm' : 'primary sm');\n    toggle.textContent = t.open ? 'Close tab' : 'Open tab';\n    toggle.disabled = !supported;\n    toggle.addEventListener('click', () => (t.open ? closeTab(id) : openTab(id)));\n    card.appendChild(toggle);\n\n    const steal = document.createElement('button');\n    steal.className = 'btn ghost sm';\n    steal.textContent = '⚡ Steal leadership';\n    steal.disabled = !supported || !t.open || t.status === 'leader';\n    steal.addEventListener('click', () => stealTab(id));\n    card.appendChild(steal);\n\n    rowEl.appendChild(card);\n  });\n\n  const openCount = IDS.filter((id) => tabs[id].open).length;\n  if (!supported) setStatus('Unsupported browser — see note below.', 'bad');\n  else if (openCount === 0) setStatus('Open a tab to start.', 'info');\n  else if (!anyLeader) setStatus('Waiting for the lock to be granted…', 'warn');\n  else setStatus(IDS.find((id) => tabs[id].status === 'leader') + ' is leading. The rest are following.', 'good');\n}\n\nfunction startTicking(id) {\n  tabs[id].interval = setInterval(() => {\n    tickCounters[id]++;\n    txChannel.postMessage({ type: 'tick', from: id, n: tickCounters[id] });\n  }, 1500);\n}\nfunction stopTicking(id) {\n  clearInterval(tabs[id].interval);\n  tabs[id].interval = null;\n}\n\nfunction requestLeadership(id, opts) {\n  opts = opts || {};\n  let releaseFn;\n  const held = new Promise((res) => { releaseFn = res; });\n  tabs[id].release = releaseFn;\n\n  \u002F\u002F steal can't be combined with an abort signal (the spec forbids it), and a\n  \u002F\u002F steal is granted immediately anyway, so there's nothing to abort out of.\n  const requestOpts = { mode: 'exclusive', ...opts };\n  if (!opts.steal) {\n    const ac = new AbortController();\n    tabs[id].abort = ac;\n    requestOpts.signal = ac.signal;\n  } else {\n    tabs[id].abort = null;\n  }\n\n  navigator.locks.request(LOCK_NAME, requestOpts, async () => {\n    if (!tabs[id].open) return; \u002F\u002F closed while still queued — don't take the lock\n    tabs[id].status = 'leader';\n    log((opts && opts.steal ? '⚡ ' : '👑 ') + 'Tab ' + id + ' is now the leader' + (opts && opts.steal ? ' (stole it!)' : ''), 'good');\n    render();\n    startTicking(id);\n    await held;\n    stopTicking(id);\n  }).catch((err) => {\n    if (err.name === 'AbortError') {\n      \u002F\u002F This fires for two different reasons: a voluntary cancel (closeTab on a\n      \u002F\u002F waiting tab), or — if this tab was the LEADER — because another tab just\n      \u002F\u002F stole the lock. navigator.locks does reject this promise with AbortError\n      \u002F\u002F the instant a steal happens; it just doesn't stop the callback above,\n      \u002F\u002F which is why that callback keeps running (and keeps ticking) regardless.\n      if (tabs[id].status === 'leader') {\n        log('📬 Tab ' + id + '\\'s request() promise just rejected with AbortError — that IS navigator.locks telling it the lock was stolen. Its callback (and its ticking) keeps running anyway, because this demo (like a lot of real apps) isn\\'t listening for that rejection.', 'warn');\n      }\n      return;\n    }\n    log('Tab ' + id + ' error: ' + err.message, 'warn');\n  });\n}\n\nfunction openTab(id) {\n  if (tabs[id].open) return;\n  tabs[id].open = true;\n  tabs[id].status = 'waiting';\n  log('Tab ' + id + ' opened — joined the queue for the lock.');\n  render();\n  requestLeadership(id);\n}\n\nfunction closeTab(id) {\n  if (!tabs[id].open) return;\n  const wasLeader = tabs[id].status === 'leader';\n  tabs[id].open = false;\n  tabs[id].status = 'closed';\n  if (tabs[id].abort) tabs[id].abort.abort();\n  if (tabs[id].release) tabs[id].release();\n  log('Tab ' + id + ' closed.' + (wasLeader ? ' The lock is released — next tab in the queue takes over.' : ''), wasLeader ? 'good' : undefined);\n  render();\n}\n\nfunction stealTab(id) {\n  if (!tabs[id].open || tabs[id].status === 'leader') return;\n  if (tabs[id].abort) tabs[id].abort.abort(); \u002F\u002F leave the normal queue\n  log('Tab ' + id + ' is forcing a takeover with { steal: true }…', 'warn');\n  requestLeadership(id, { steal: true });\n  \u002F\u002F Honesty check: navigator.locks DOES notify the old leader — its original\n  \u002F\u002F request() promise rejects with an AbortError the instant it's stolen from\n  \u002F\u002F (see the .catch above). What it does NOT do is stop that leader's running\n  \u002F\u002F callback, so unless the app is listening for the rejection, it keeps acting\n  \u002F\u002F like a leader.\n  const oldLeader = IDS.find((x) => x !== id && tabs[x].status === 'leader');\n  if (oldLeader) {\n    log('⚠ Tab ' + oldLeader + ' still believes it is the leader — its callback keeps running untouched, and this demo isn\\'t listening for the rejection navigator.locks just sent it. Watch for duplicate ticks below until you close it yourself.', 'warn');\n  }\n}\n\nfunction wireRx() {\n  if (!rxChannel) return;\n  rxChannel.onmessage = (e) => {\n    if (e.data && e.data.type === 'tick') {\n      log('📡 relayed: tick #' + e.data.n + ' from Tab ' + e.data.from);\n    }\n  };\n}\nwireRx();\n\n$('#reset').addEventListener('click', () => {\n  IDS.forEach((id) => {\n    if (tabs[id].abort) tabs[id].abort.abort();\n    if (tabs[id].interval) clearInterval(tabs[id].interval);\n    tabs[id] = freshTab();\n    tickCounters[id] = 0;\n  });\n  if (txChannel) txChannel.close();\n  if (rxChannel) rxChannel.close();\n  const fresh = Math.random().toString(36).slice(2);\n  LOCK_NAME = 'pg-leader-' + fresh;\n  CHANNEL_NAME = 'pg-updates-' + fresh;\n  txChannel = supported ? new BroadcastChannel(CHANNEL_NAME) : null;\n  rxChannel = supported ? new BroadcastChannel(CHANNEL_NAME) : null;\n  wireRx();\n  logEl.innerHTML = '';\n  log('Reset. All tabs closed, fresh lock.');\n  render();\n});\n\nrender();",{"height":52,"autorun":17},520,{"slug":45,"title":54,"excerpt":55,"coverUrl":56,"vertical":57},"Your Tabs Each Open Their Own WebSocket. navigator.locks Elects One.","Six open tabs means six live connections to your server, six duplicate pushes, six timers draining the battery. navigator.locks elects a single leader tab to hold the real connection — the rest just listen.","\u002Fmedia\u002Fcovers\u002Ftab-leader-election-weblocks-broadcastchannel.png",{"slug":6},[59,63,67,71,75,79,83,87,91,95,99,103],{"slug":60,"name":61,"articles":62},"webdev","Webdev",137,{"slug":64,"name":65,"articles":66},"javascript","Javascript",112,{"slug":68,"name":69,"articles":70},"frontend","Frontend",83,{"slug":72,"name":73,"articles":74},"tutorial","Tutorial",53,{"slug":76,"name":77,"articles":78},"css","Css",42,{"slug":80,"name":81,"articles":82},"performance","Performance",20,{"slug":84,"name":85,"articles":86},"typescript","Typescript",18,{"slug":88,"name":89,"articles":90},"react","React",17,{"slug":92,"name":93,"articles":94},"browser","Browser",13,{"slug":96,"name":97,"articles":98},"node","Node",12,{"slug":100,"name":101,"articles":102},"programming","Programming",9,{"slug":104,"name":105,"articles":102},"accessibility","Accessibility"]